Skip to content

Commit

Permalink
[kbss-cvut/record-manager-ui#71] Return HATEOAS response headers when…
Browse files Browse the repository at this point in the history
… using paging.
  • Loading branch information
ledsoft committed Jan 31, 2024
1 parent bac3a31 commit 5d62bac
Show file tree
Hide file tree
Showing 7 changed files with 427 additions and 15 deletions.
28 changes: 21 additions & 7 deletions src/main/java/cz/cvut/kbss/study/rest/PatientRecordController.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
import cz.cvut.kbss.study.exception.NotFoundException;
import cz.cvut.kbss.study.model.PatientRecord;
import cz.cvut.kbss.study.model.RecordPhase;
import cz.cvut.kbss.study.rest.event.PaginatedResultRetrievedEvent;
import cz.cvut.kbss.study.rest.exception.BadRequestException;
import cz.cvut.kbss.study.rest.util.RecordFilterMapper;
import cz.cvut.kbss.study.rest.util.RestUtils;
import cz.cvut.kbss.study.security.SecurityConstants;
import cz.cvut.kbss.study.service.PatientRecordService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
Expand All @@ -26,6 +30,7 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.List;

Expand All @@ -36,27 +41,36 @@ public class PatientRecordController extends BaseController {

private final PatientRecordService recordService;

public PatientRecordController(PatientRecordService recordService) {
private final ApplicationEventPublisher eventPublisher;

public PatientRecordController(PatientRecordService recordService, ApplicationEventPublisher eventPublisher) {
this.recordService = recordService;
this.eventPublisher = eventPublisher;
}

@PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "') or @securityUtils.isMemberOfInstitution(#institutionKey)")
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public List<PatientRecordDto> getRecords(
@RequestParam(value = "institution", required = false) String institutionKey,
@RequestParam MultiValueMap<String, String> params) {
return recordService.findAll(RecordFilterMapper.constructRecordFilter(params), RestUtils.resolvePaging(params))
.getContent();
@RequestParam MultiValueMap<String, String> params,
UriComponentsBuilder uriBuilder, HttpServletResponse response) {
final Page<PatientRecordDto> result = recordService.findAll(RecordFilterMapper.constructRecordFilter(params),
RestUtils.resolvePaging(params));
eventPublisher.publishEvent(new PaginatedResultRetrievedEvent(this, uriBuilder, response, result));
return result.getContent();
}

@PreAuthorize(
"hasRole('" + SecurityConstants.ROLE_ADMIN + "') or @securityUtils.isMemberOfInstitution(#institutionKey)")
@GetMapping(value = "/export", produces = MediaType.APPLICATION_JSON_VALUE)
public List<PatientRecord> exportRecords(
@RequestParam(name = "institution", required = false) String institutionKey,
@RequestParam MultiValueMap<String, String> params) {
return recordService.findAllFull(RecordFilterMapper.constructRecordFilter(params),
RestUtils.resolvePaging(params)).getContent();
@RequestParam MultiValueMap<String, String> params,
UriComponentsBuilder uriBuilder, HttpServletResponse response) {
final Page<PatientRecord> result = recordService.findAllFull(RecordFilterMapper.constructRecordFilter(params),
RestUtils.resolvePaging(params));
eventPublisher.publishEvent(new PaginatedResultRetrievedEvent(this, uriBuilder, response, result));
return result.getContent();
}

@PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "') or @securityUtils.isRecordInUsersInstitution(#key)")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cz.cvut.kbss.study.rest.event;

import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.ApplicationEvent;
import org.springframework.data.domain.Page;
import org.springframework.web.util.UriComponentsBuilder;

/**
* Fired when a paginated result is retrieved by a REST controller, so that HATEOAS headers can be added to the
* response.
*/
public class PaginatedResultRetrievedEvent extends ApplicationEvent {

private final UriComponentsBuilder uriBuilder;
private final HttpServletResponse response;
private final Page<?> page;

public PaginatedResultRetrievedEvent(Object source, UriComponentsBuilder uriBuilder, HttpServletResponse response,
Page<?> page) {
super(source);
this.uriBuilder = uriBuilder;
this.response = response;
this.page = page;
}

public UriComponentsBuilder getUriBuilder() {
return uriBuilder;
}

public HttpServletResponse getResponse() {
return response;
}

public Page<?> getPage() {
return page;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cz.cvut.kbss.study.rest.handler;

import cz.cvut.kbss.study.rest.event.PaginatedResultRetrievedEvent;
import cz.cvut.kbss.study.rest.util.HttpPaginationLink;
import cz.cvut.kbss.study.util.Constants;
import org.springframework.context.ApplicationListener;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;

/**
* Generates HATEOAS paging headers based on the paginated result retrieved by a REST controller.
*/
@Component
public class HateoasPagingListener implements ApplicationListener<PaginatedResultRetrievedEvent> {

@Override
public void onApplicationEvent(PaginatedResultRetrievedEvent event) {
final Page<?> page = event.getPage();
final LinkHeader header = new LinkHeader();
if (page.hasNext()) {
header.addLink(generateNextPageLink(page, event.getUriBuilder()), HttpPaginationLink.NEXT);
header.addLink(generateLastPageLink(page, event.getUriBuilder()), HttpPaginationLink.LAST);
}
if (page.hasPrevious()) {
header.addLink(generatePreviousPageLink(page, event.getUriBuilder()), HttpPaginationLink.PREVIOUS);
header.addLink(generateFirstPageLink(page, event.getUriBuilder()), HttpPaginationLink.FIRST);
}
if (header.hasLinks()) {
event.getResponse().addHeader(HttpHeaders.LINK, header.toString());
}
}

private String generateNextPageLink(Page<?> page, UriComponentsBuilder uriBuilder) {
return uriBuilder.replaceQueryParam(Constants.PAGE_PARAM, page.getNumber() + 1)
.replaceQueryParam(Constants.PAGE_SIZE_PARAM, page.getSize())
.build().encode().toUriString();
}

private String generatePreviousPageLink(Page<?> page, UriComponentsBuilder uriBuilder) {
return uriBuilder.replaceQueryParam(Constants.PAGE_PARAM, page.getNumber() - 1)
.replaceQueryParam(Constants.PAGE_SIZE_PARAM, page.getSize())
.build().encode().toUriString();
}

private String generateFirstPageLink(Page<?> page, UriComponentsBuilder uriBuilder) {
return uriBuilder.replaceQueryParam(Constants.PAGE_PARAM, 0)
.replaceQueryParam(Constants.PAGE_SIZE_PARAM, page.getSize())
.build().encode().toUriString();
}

private String generateLastPageLink(Page<?> page, UriComponentsBuilder uriBuilder) {
return uriBuilder.replaceQueryParam(Constants.PAGE_PARAM, page.getTotalPages() - 1)
.replaceQueryParam(Constants.PAGE_SIZE_PARAM, page.getSize())
.build().encode().toUriString();
}

private static class LinkHeader {

private final StringBuilder linkBuilder = new StringBuilder();

private void addLink(String url, HttpPaginationLink type) {
if (!linkBuilder.isEmpty()) {
linkBuilder.append(", ");
}
linkBuilder.append('<').append(url).append('>').append("; ").append("rel=\"").append(type.getName())
.append('"');
}

private boolean hasLinks() {
return !linkBuilder.isEmpty();
}

@Override
public String toString() {
return linkBuilder.toString();
}
}
}
18 changes: 18 additions & 0 deletions src/main/java/cz/cvut/kbss/study/rest/util/HttpPaginationLink.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package cz.cvut.kbss.study.rest.util;

/**
* Types of HTTP pagination links.
*/
public enum HttpPaginationLink {
NEXT("next"), PREVIOUS("prev"), FIRST("first"), LAST("last");

private final String name;

HttpPaginationLink(String name) {
this.name = name;
}

public String getName() {
return name;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import cz.cvut.kbss.study.model.User;
import cz.cvut.kbss.study.persistence.dao.util.RecordFilterParams;
import cz.cvut.kbss.study.persistence.dao.util.RecordSort;
import cz.cvut.kbss.study.rest.event.PaginatedResultRetrievedEvent;
import cz.cvut.kbss.study.rest.util.RestUtils;
import cz.cvut.kbss.study.service.PatientRecordService;
import cz.cvut.kbss.study.util.Constants;
Expand All @@ -23,6 +24,7 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
Expand Down Expand Up @@ -61,6 +63,9 @@ public class PatientRecordControllerTest extends BaseControllerTestRunner {
@Mock
private PatientRecordService patientRecordServiceMock;

@Mock
private ApplicationEventPublisher eventPublisherMock;

@InjectMocks
private PatientRecordController controller;

Expand Down Expand Up @@ -120,18 +125,14 @@ public void getRecordsReturnsAllRecords() throws Exception {
User user1 = Generator.generateUser(institution);
User user2 = Generator.generateUser(institution);

PatientRecordDto record1 = Generator.generatePatientRecordDto(user1);
PatientRecordDto record2 = Generator.generatePatientRecordDto(user1);
PatientRecordDto record3 = Generator.generatePatientRecordDto(user2);
List<PatientRecordDto> records = new ArrayList<>();
records.add(record1);
records.add(record2);
records.add(record3);
List<PatientRecordDto> records =
List.of(Generator.generatePatientRecordDto(user1), Generator.generatePatientRecordDto(user1),
Generator.generatePatientRecordDto(user2));

when(patientRecordServiceMock.findAll(any(RecordFilterParams.class), any(Pageable.class))).thenReturn(
new PageImpl<>(records));

final MvcResult result = mockMvc.perform(get("/records/")).andReturn();
final MvcResult result = mockMvc.perform(get("/records")).andReturn();

assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus()));
final List<PatientRecordDto> body = objectMapper.readValue(result.getResponse().getContentAsString(),
Expand Down Expand Up @@ -371,4 +372,56 @@ void getRecordsResolvesPagingConfigurationFromRequestParameters() throws Excepti
new RecordFilterParams(null, minDate, maxDate, Collections.emptySet()),
PageRequest.of(page, pageSize, Sort.Direction.DESC, RecordSort.SORT_DATE_PROPERTY));
}

@Test
void getRecordsPublishesPagingEvent() throws Exception {
List<PatientRecordDto> records =
List.of(Generator.generatePatientRecordDto(user), Generator.generatePatientRecordDto(user),
Generator.generatePatientRecordDto(user));

final Page<PatientRecordDto> page = new PageImpl<>(records, PageRequest.of(0, 5), 3);
when(patientRecordServiceMock.findAll(any(RecordFilterParams.class), any(Pageable.class))).thenReturn(page);
final MvcResult result = mockMvc.perform(get("/records").queryParam(Constants.PAGE_PARAM, "0")
.queryParam(Constants.PAGE_SIZE_PARAM, "5"))
.andReturn();

assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus()));
final List<PatientRecordDto> body = objectMapper.readValue(result.getResponse().getContentAsString(),
new TypeReference<>() {
});
assertEquals(3, body.size());
verify(patientRecordServiceMock).findAll(new RecordFilterParams(), PageRequest.of(0, 5));
final ArgumentCaptor<PaginatedResultRetrievedEvent> captor = ArgumentCaptor.forClass(
PaginatedResultRetrievedEvent.class);
verify(eventPublisherMock).publishEvent(captor.capture());
final PaginatedResultRetrievedEvent event = captor.getValue();
assertEquals(page, event.getPage());
}

@Test
void exportRecordsPublishesPagingEvent() throws Exception {
final LocalDate minDate = LocalDate.now().minusDays(35);
final LocalDate maxDate = LocalDate.now().minusDays(5);
final List<PatientRecord> records =
List.of(Generator.generatePatientRecord(user), Generator.generatePatientRecord(user));
final Page<PatientRecord> page = new PageImpl<>(records, PageRequest.of(0, 50), 100);
when(patientRecordServiceMock.findAllFull(any(RecordFilterParams.class), any(Pageable.class))).thenReturn(page);

final MvcResult mvcResult = mockMvc.perform(get("/records/export")
.param("minDate", minDate.toString())
.param("maxDate", maxDate.toString())
.param(Constants.PAGE_PARAM, "0")
.param(Constants.PAGE_SIZE_PARAM, "50"))
.andReturn();
final List<PatientRecord> result = readValue(mvcResult, new TypeReference<>() {
});
assertThat(result, containsSameEntities(records));
verify(patientRecordServiceMock).findAllFull(
new RecordFilterParams(null, minDate, maxDate, Collections.emptySet()), PageRequest.of(0, 50));
final ArgumentCaptor<PaginatedResultRetrievedEvent> captor =
ArgumentCaptor.forClass(PaginatedResultRetrievedEvent.class);
verify(eventPublisherMock).publishEvent(captor.capture());
final PaginatedResultRetrievedEvent event = captor.getValue();
assertEquals(page, event.getPage());
}
}
Loading

0 comments on commit 5d62bac

Please sign in to comment.