Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/java/spotbugs-exclude.xml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@
<Method name="&lt;init&gt;" />
</Match>

<!-- PagedLampsResult returns a defensive copy; SpotBugs still flags EI_EXPOSE_REP on accessor -->
<Match>
<Bug pattern="EI_EXPOSE_REP" />
<Class name="org.openapitools.service.LampService$PagedLampsResult" />
<Method name="data" />
</Match>

<!-- Suppress clone-without-super for our custom DateFormat which intentionally reinitializes internal state -->
<Match>
<Bug pattern="CN_IDIOM_NO_SUPER_CALL" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package org.openapitools.controller;

import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
Expand Down Expand Up @@ -64,15 +63,32 @@ public CompletableFuture<ResponseEntity<ListLamps200Response>> listLamps(
final Optional<String> cursor, final Optional<Integer> pageSize) {
return CompletableFuture.supplyAsync(
() -> {
final List<Lamp> lamps = lampService.findAllActive();
final int offset = parseCursor(cursor);
final int limit = pageSize.orElse(25);
final LampService.PagedLampsResult pagedResult =
lampService.findAllActivePage(offset, limit);
final ListLamps200Response response = new ListLamps200Response();
response.setData(lamps);
response.setHasMore(false);
response.setData(pagedResult.data());
response.setHasMore(pagedResult.hasMore());
pagedResult.nextCursor().ifPresent(response::nextCursor);
return ResponseEntity.ok().body(response);
},
Runnable::run);
}

private int parseCursor(final Optional<String> cursor) {
if (cursor.isEmpty() || cursor.get().isBlank()) {
return 0;
}

try {
final int parsed = Integer.parseInt(cursor.get());
return Math.max(parsed, 0);
} catch (final NumberFormatException ignored) {
return 0;
}
}

@Override
public CompletableFuture<ResponseEntity<Lamp>> updateLamp(
final String lampId, final LampUpdate lampUpdate) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ public List<LampEntity> findAll() {

@Override
public Page<LampEntity> findAll(final Pageable pageable) {
final List<LampEntity> allLamps = new ArrayList<>(lamps.values());
final List<LampEntity> allLamps =
lamps.values().stream()
.filter(lamp -> lamp.getDeletedAt() == null)
.sorted(Comparator.comparing(LampEntity::getCreatedAt).thenComparing(LampEntity::getId))
.toList();
final int start = (int) pageable.getOffset();
final int end = Math.min(start + pageable.getPageSize(), allLamps.size());

Expand Down
114 changes: 114 additions & 0 deletions src/java/src/main/java/org/openapitools/service/LampService.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.openapitools.service;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
Expand Down Expand Up @@ -41,6 +43,34 @@ public class LampService {
private final LampRepository repository;
private final LampMapper mapper;

public static final class PagedLampsResult {
private final List<Lamp> pagedData;
private final boolean hasMoreFlag;
private final Optional<String> nextCursorValue;

public PagedLampsResult(
final List<Lamp> data, final boolean hasMore, final Optional<String> nextCursor) {
this.pagedData = List.copyOf(data);
this.hasMoreFlag = hasMore;
this.nextCursorValue = nextCursor;
}

@SuppressFBWarnings(
value = "EI_EXPOSE_REP",
justification = "Returns a defensive copy of immutable snapshot data")
public List<Lamp> data() {
return new ArrayList<>(pagedData);
}

public boolean hasMore() {
return hasMoreFlag;
}

public Optional<String> nextCursor() {
return nextCursorValue;
}
}

/**
* Create a new lamp.
*
Expand Down Expand Up @@ -88,6 +118,30 @@ public List<Lamp> findAllActive() {
return repository.findAllActive().stream().map(mapper::toModel).toList();
}

/**
* Find a page of active lamps using offset-based cursor pagination.
*
* @param offset starting position in the active lamp list (0-based)
* @param pageSize maximum number of lamps to return
* @return paged lamps and pagination metadata
*/
public PagedLampsResult findAllActivePage(final int offset, final int pageSize) {
final int safeOffset = Math.max(offset, 0);
final int safePageSize = pageSize > 0 ? pageSize : 25;
final Pageable pageable =
new OffsetBasedPageRequest(
safeOffset, safePageSize, Sort.by(Sort.Order.asc("createdAt"), Sort.Order.asc("id")));

final Page<LampEntity> page = repository.findAll(pageable);
final List<Lamp> data = page.getContent().stream().map(mapper::toModel).toList();
final long totalActive = repository.countActive();
final boolean hasMore = safeOffset + data.size() < totalActive;
final Optional<String> nextCursor =
hasMore ? Optional.of(Integer.toString(safeOffset + data.size())) : Optional.empty();

return new PagedLampsResult(data, hasMore, nextCursor);
}

/**
* Find all lamps with the specified status.
*
Expand Down Expand Up @@ -144,4 +198,64 @@ public void delete(final UUID id) {
entity.setDeletedAt(OffsetDateTime.now());
repository.save(entity);
}

private static final class OffsetBasedPageRequest implements Pageable {
private final int offset;
private final int pageSize;
private final Sort sort;

private OffsetBasedPageRequest(final int offset, final int pageSize, final Sort sort) {
this.offset = offset;
this.pageSize = pageSize;
this.sort = sort;
}

@Override
public int getPageNumber() {
return offset / pageSize;
}

@Override
public int getPageSize() {
return pageSize;
}

@Override
public long getOffset() {
return offset;
}

@Override
public Sort getSort() {
return sort;
}

@Override
public Pageable next() {
return new OffsetBasedPageRequest(offset + pageSize, pageSize, sort);
}

@Override
public Pageable previousOrFirst() {
if (offset < pageSize) {
return first();
}
return new OffsetBasedPageRequest(offset - pageSize, pageSize, sort);
}

@Override
public Pageable first() {
return new OffsetBasedPageRequest(0, pageSize, sort);
}

@Override
public Pageable withPage(final int pageNumber) {
return new OffsetBasedPageRequest(pageNumber * pageSize, pageSize, sort);
}

@Override
public boolean hasPrevious() {
return offset > 0;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
package org.openapitools.controller;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
Expand Down Expand Up @@ -44,15 +45,17 @@ void setUp() {
}

@Test
void listLamps_ShouldReturnAllLamps() throws Exception {
void listLamps_ShouldReturnBoundedPageWithNextCursor() throws Exception {
// Given
List<Lamp> lamps = Arrays.asList(testLamp);
when(lampService.findAllActive()).thenReturn(lamps);
final Lamp secondLamp = new Lamp(UUID.randomUUID(), false);
final List<Lamp> lamps = List.of(testLamp, secondLamp);
when(lampService.findAllActivePage(anyInt(), anyInt()))
.thenReturn(new LampService.PagedLampsResult(lamps, true, Optional.of("2")));

// When & Then
MvcResult result =
mockMvc
.perform(get("/v1/lamps").accept(MediaType.APPLICATION_JSON))
.perform(get("/v1/lamps").param("pageSize", "2").accept(MediaType.APPLICATION_JSON))
.andExpect(request().asyncStarted())
.andReturn();

Expand All @@ -62,7 +65,50 @@ void listLamps_ShouldReturnAllLamps() throws Exception {
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data[0].id").value(testLampId.toString()))
.andExpect(jsonPath("$.data[0].status").value(true));
.andExpect(jsonPath("$.data[0].status").value(true))
.andExpect(jsonPath("$.hasMore").value(true))
.andExpect(jsonPath("$.nextCursor").value("2"));
verify(lampService).findAllActivePage(0, 2);
}

@Test
void listLamps_TerminalPage_ShouldOmitNextCursor() throws Exception {
// Given
when(lampService.findAllActivePage(anyInt(), anyInt()))
.thenReturn(new LampService.PagedLampsResult(List.of(testLamp), false, Optional.empty()));

// When & Then
MvcResult result =
mockMvc
.perform(get("/v1/lamps").param("cursor", "4").param("pageSize", "2"))
.andExpect(request().asyncStarted())
.andReturn();

mockMvc
.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.data[0].id").value(testLampId.toString()))
.andExpect(jsonPath("$.hasMore").value(false))
.andExpect(jsonPath("$.nextCursor").doesNotExist());
verify(lampService).findAllActivePage(4, 2);
}

@Test
void listLamps_InvalidCursor_ShouldFallbackToFirstPage() throws Exception {
// Given
when(lampService.findAllActivePage(anyInt(), anyInt()))
.thenReturn(new LampService.PagedLampsResult(List.of(testLamp), false, Optional.empty()));

// When & Then
MvcResult result =
mockMvc
.perform(get("/v1/lamps").param("cursor", "abc").param("pageSize", "2"))
.andExpect(request().asyncStarted())
.andReturn();

mockMvc.perform(asyncDispatch(result)).andExpect(status().isOk());
verify(lampService).findAllActivePage(0, 2);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,13 +341,17 @@ void findAllPaged_ShouldReturnCorrectPage() {
for (int i = 0; i < 5; i++) {
lampRepository.save(new LampEntity(i % 2 == 0));
}
LampEntity deletedLamp = lampRepository.save(new LampEntity(true));
deletedLamp.setDeletedAt(OffsetDateTime.now());
lampRepository.save(deletedLamp);

// When
Page<LampEntity> page = lampRepository.findAll(PageRequest.of(0, 3));

// Then
assertThat(page.getContent()).hasSize(3);
assertThat(page.getTotalElements()).isEqualTo(5);
assertThat(page.getContent()).allMatch(lamp -> lamp.getDeletedAt() == null);
}

@Test
Expand Down Expand Up @@ -377,4 +381,32 @@ void findAllPaged_SecondPage_ShouldReturnRemainder() {
assertThat(page.getContent()).hasSize(2);
assertThat(page.getTotalElements()).isEqualTo(5);
}

@Test
void findAllPaged_ShouldReturnDeterministicOrderByCreatedAtThenId() {
// Given
final OffsetDateTime baseTime = OffsetDateTime.now();
final LampEntity firstByIdAtSameTime =
new LampEntity(UUID.fromString("00000000-0000-0000-0000-000000000001"), true);
firstByIdAtSameTime.setCreatedAt(baseTime.minusMinutes(2));
lampRepository.save(firstByIdAtSameTime);

final LampEntity secondByIdAtSameTime =
new LampEntity(UUID.fromString("00000000-0000-0000-0000-000000000002"), true);
secondByIdAtSameTime.setCreatedAt(baseTime.minusMinutes(2));
lampRepository.save(secondByIdAtSameTime);

final LampEntity latest =
new LampEntity(UUID.fromString("00000000-0000-0000-0000-000000000003"), true);
latest.setCreatedAt(baseTime.minusMinutes(1));
lampRepository.save(latest);

// When
final Page<LampEntity> page = lampRepository.findAll(PageRequest.of(0, 3));

// Then
assertThat(page.getContent())
.extracting(LampEntity::getId)
.containsExactly(firstByIdAtSameTime.getId(), secondByIdAtSameTime.getId(), latest.getId());
}
}
Loading