Attention une mise à jour du service Gitlab va être effectuée le mardi 30 novembre entre 17h30 et 18h00. Cette mise à jour va générer une interruption du service dont nous ne maîtrisons pas complètement la durée mais qui ne devrait pas excéder quelques minutes. Cette mise à jour intermédiaire en version 14.0.12 nous permettra de rapidement pouvoir mettre à votre disposition une version plus récente.

Commit 799978c2 authored by Jujube Orange's avatar Jujube Orange
Browse files

refactor(ws-rest): use openapi to generate controller interface

parent 8f8486ed
......@@ -69,6 +69,11 @@
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.openapitools</groupId>
<artifactId>jackson-databind-nullable</artifactId>
<version>0.2.1</version>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
......@@ -126,6 +131,36 @@
<build>
<plugins>
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>5.1.1</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>
${project.basedir}/src/main/resources/api-report-v1.yml
</inputSpec>
<generatorName>spring</generatorName>
<apiPackage>fr.gouv.clea.ws.api</apiPackage>
<modelPackage>fr.gouv.clea.ws.api.model</modelPackage>
<configOptions>
<interfaceOnly>true</interfaceOnly>
<useBeanValidation>false</useBeanValidation>
<useTags>true</useTags>
<additionalModelTypeAnnotations>
@lombok.Builder
@lombok.AllArgsConstructor
@lombok.NoArgsConstructor
</additionalModelTypeAnnotations>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
......
package fr.gouv.clea.ws.api;
import fr.gouv.clea.ws.dto.ReportResponse;
import fr.gouv.clea.ws.vo.ReportRequest;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import io.swagger.annotations.Example;
import io.swagger.annotations.ExampleProperty;
import org.springframework.http.MediaType;
@Api(tags = "clea", description = "Clea API", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public interface CleaWsRestAPI {
@ApiOperation(value = "Upload locations history", notes = "" +
"Upload a list of {qrCode, timestamp} tuples where :\n" +
"* **qrCode**: QR code content encoded in Base64\n" +
"* **qrCodeScanTime**: NTP timestamp when a user terminal scans a given QR code\n" +
"", httpMethod = "POST", response = ReportResponse.class, protocols = "https")
@ApiResponses(value = {
@ApiResponse(code = 200, message = "Successful Operation", response = ReportResponse.class, examples = @Example(@ExampleProperty(value = "{\n"
+
" \"success\": \"true\",\n" +
" \"message\": \"2 qr processed, 0 rejected\"\n" +
"}", mediaType = MediaType.APPLICATION_JSON_VALUE))),
@ApiResponse(code = 400, message = "Bad Request"),
@ApiResponse(code = 401, message = "Unauthorized"),
@ApiResponse(code = 403, message = "Forbidden"),
@ApiResponse(code = 500, message = "Internal Error")
})
ReportResponse report(ReportRequest reportRequestVo);
}
package fr.gouv.clea.ws.controller;
import fr.gouv.clea.ws.api.CleaWsRestAPI;
import fr.gouv.clea.ws.dto.ReportResponse;
import fr.gouv.clea.ws.api.CleaApi;
import fr.gouv.clea.ws.api.model.ReportResponse;
import fr.gouv.clea.ws.exception.CleaBadRequestException;
import fr.gouv.clea.ws.model.DecodedVisit;
import fr.gouv.clea.ws.service.IReportService;
......@@ -10,9 +10,7 @@ import fr.gouv.clea.ws.vo.ReportRequest;
import fr.gouv.clea.ws.vo.Visit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
......@@ -23,13 +21,14 @@ import javax.validation.Validator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static java.util.stream.Collectors.toList;
@RestController
@RequestMapping(path = "/api/clea")
@RequestMapping(path = "/api/clea/v1")
@RequiredArgsConstructor
@Slf4j
public class CleaController implements CleaWsRestAPI {
public class CleaController implements CleaApi {
public static final String MALFORMED_VISIT_LOG_MESSAGE = "Filtered out %d malformed visits of %d while Exposure Status Request";
......@@ -42,8 +41,13 @@ public class CleaController implements CleaWsRestAPI {
private final Validator validator;
@Override
@PostMapping(path = "/v1/wreport", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ReportResponse report(@RequestBody ReportRequest reportRequestVo) {
public ResponseEntity<ReportResponse> reportUsingPOST(fr.gouv.clea.ws.api.model.ReportRequest reportRequest) {
final var reportRequestVo = new ReportRequest(
reportRequest.getVisits().stream()
.map(visit -> new Visit(visit.getQrCode(), visit.getQrCodeScanTime()))
.collect(toList()),
reportRequest.getPivotDate()
);
ReportRequest filtered = this.filterReports(reportRequestVo, webRequest);
List<DecodedVisit> reported = List.of();
if (!filtered.getVisits().isEmpty()) {
......@@ -54,7 +58,7 @@ public class CleaController implements CleaWsRestAPI {
reportRequestVo.getVisits().size() - reported.size()
);
log.info(message);
return new ReportResponse(true, message);
return ResponseEntity.ok(new ReportResponse(message, true));
}
private ReportRequest filterReports(ReportRequest report, WebRequest webRequest) {
......@@ -75,7 +79,7 @@ public class CleaController implements CleaWsRestAPI {
return true;
}
}
).collect(Collectors.toList());
).collect(toList());
if (validVisits.isEmpty()) {
throw new CleaBadRequestException(Set.of(), visitViolations);
}
......
package fr.gouv.clea.ws.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.Set;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiError {
private int httpStatus;
private Instant timestamp;
private String message;
private Set<ValidationError> validationErrors;
}
package fr.gouv.clea.ws.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ReportResponse {
private boolean success;
private String message;
}
package fr.gouv.clea.ws.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = false)
public class ValidationError {
private String object;
private String field;
private Object rejectedValue;
private String message;
}
package fr.gouv.clea.ws.exception;
import fr.gouv.clea.ws.dto.ApiError;
import fr.gouv.clea.ws.dto.ValidationError;
import fr.gouv.clea.ws.api.model.ErrorResponse;
import fr.gouv.clea.ws.api.model.ValidationError;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static java.time.ZoneOffset.UTC;
import static java.util.stream.Collectors.toList;
@ControllerAdvice
@Slf4j
public class CleaRestExceptionHandler extends ResponseEntityExceptionHandler {
......@@ -26,7 +31,8 @@ public class CleaRestExceptionHandler extends ResponseEntityExceptionHandler {
public static final String ERROR_MESSAGE_TEMPLATE = "%s, requested uri: %s";
@ExceptionHandler(CleaBadRequestException.class)
public ResponseEntity<ApiError> handleCleaBadRequestException(CleaBadRequestException ex, WebRequest webRequest) {
public ResponseEntity<ErrorResponse> handleCleaBadRequestException(CleaBadRequestException ex,
WebRequest webRequest) {
final HttpStatus status = getHttpStatus(ex);
log.error(String.format(ERROR_MESSAGE_TEMPLATE, ex.getLocalizedMessage(), webRequest.getDescription(false)));
return this.jsonResponseEntity(this.cleaBadRequestExceptionToApiError(ex, status));
......@@ -36,17 +42,49 @@ public class CleaRestExceptionHandler extends ResponseEntityExceptionHandler {
protected ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException ex,
HttpHeaders headers, HttpStatus status, WebRequest request) {
log.error(String.format(ERROR_MESSAGE_TEMPLATE, ex.getLocalizedMessage(), request.getDescription(false)));
ApiError error = new ApiError(
ErrorResponse error = new ErrorResponse(
status.value(),
Instant.now(),
OffsetDateTime.now(),
ex.getLocalizedMessage().split(":")[0],
Set.of()
List.of()
);
return new ResponseEntity<>(error, headers, status);
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatus status, WebRequest request) {
log.error(String.format(ERROR_MESSAGE_TEMPLATE, ex.getLocalizedMessage(), request.getDescription(false)));
return ResponseEntity.badRequest()
.body(
new ErrorResponse(
status.value(),
OffsetDateTime.now(),
"Invalid request",
Stream.concat(
ex.getFieldErrors().stream()
.map(
fieldError -> ValidationError.builder()
._object(fieldError.getObjectName())
.field(fieldError.getField())
.rejectedValue(fieldError.getRejectedValue())
.message(fieldError.getDefaultMessage())
.build()
),
ex.getGlobalErrors().stream()
.map(
globalError -> ValidationError.builder()
._object(globalError.getObjectName())
.message(globalError.getDefaultMessage())
.build()
)
).collect(toList())
)
);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleOtherException(Exception ex, WebRequest webRequest) {
public ResponseEntity<ErrorResponse> handleOtherException(Exception ex, WebRequest webRequest) {
final HttpStatus status = getHttpStatus(ex);
log.error(String.format(ERROR_MESSAGE_TEMPLATE, ex.getLocalizedMessage(), webRequest.getDescription(false)));
return this.jsonResponseEntity(this.exceptionToApiError(ex, status));
......@@ -57,22 +95,23 @@ public class CleaRestExceptionHandler extends ResponseEntityExceptionHandler {
return responseStatus == null ? HttpStatus.INTERNAL_SERVER_ERROR : responseStatus.value();
}
private ResponseEntity<ApiError> jsonResponseEntity(ApiError apiError) {
private ResponseEntity<ErrorResponse> jsonResponseEntity(ErrorResponse apiError) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return new ResponseEntity<>(apiError, headers, apiError.getHttpStatus());
}
private ApiError exceptionToApiError(Exception ex, HttpStatus status) {
return new ApiError(
private ErrorResponse exceptionToApiError(Exception ex, HttpStatus status) {
return new ErrorResponse(
status.value(),
ex instanceof AbstractCleaException ? ((AbstractCleaException) ex).getTimestamp() : Instant.now(),
ex instanceof AbstractCleaException ? ((AbstractCleaException) ex).getTimestamp().atOffset(UTC)
: OffsetDateTime.now(),
ex.getLocalizedMessage(),
Set.of()
List.of()
);
}
private ApiError cleaBadRequestExceptionToApiError(CleaBadRequestException ex, HttpStatus status) {
private ErrorResponse cleaBadRequestExceptionToApiError(CleaBadRequestException ex, HttpStatus status) {
final String splitRegex = "\\.";
Set<ValidationError> superErrors = ex.getReportRequestViolations().stream().map(
it -> {
......@@ -98,14 +137,14 @@ public class CleaRestExceptionHandler extends ResponseEntityExceptionHandler {
);
}
).collect(Collectors.toSet());
return new ApiError(
return new ErrorResponse(
status.value(),
ex.getTimestamp(),
ex.getTimestamp().atOffset(UTC),
ex.getLocalizedMessage(),
Stream.concat(
superErrors.stream(),
subErrors.stream()
).collect(Collectors.toSet())
).collect(toList())
);
}
}
......@@ -15,7 +15,7 @@ tags:
- name: clea
description: Clea API
paths:
"/api/clea/v1/wreport":
"/wreport":
post:
tags:
- clea
......@@ -80,6 +80,7 @@ components:
type: array
items:
"$ref": "#/components/schemas/Visit"
minItems: 1
ReportResponse:
title: ReportResponse
type: object
......@@ -93,9 +94,10 @@ components:
type: object
properties:
httpStatus:
type: string
type: integer
timestamp:
type: string
format: date-time
message:
type: string
validationErrors:
......
......@@ -155,16 +155,11 @@ class CleaControllerTest {
.body("httpStatus", equalTo(BAD_REQUEST.value()))
.body("timestamp", isStringDateBetweenNowAndTenSecondsAgo())
.body("message", equalTo("Invalid request"))
.body("validationErrors[0].object", equalTo("ReportRequest"))
.body("validationErrors[0].object", equalTo("reportRequest"))
.body("validationErrors[0].field", equalTo("visits"))
.body("validationErrors[0].rejectedValue", equalTo(null))
.body("validationErrors[0].message", equalTo("must not be null"))
// FIXME why to we report 2 validation errors on visit field? 🤔
.body("validationErrors[1].object", equalTo("ReportRequest"))
.body("validationErrors[1].field", equalTo("visits"))
.body("validationErrors[1].rejectedValue", equalTo(null))
.body("validationErrors[1].message", containsString("must not be empty"))
.body("validationErrors", hasSize(2));
.body("validationErrors", hasSize(1));
}
@Test
......@@ -181,10 +176,10 @@ class CleaControllerTest {
.body("httpStatus", equalTo(BAD_REQUEST.value()))
.body("timestamp", isStringDateBetweenNowAndTenSecondsAgo())
.body("message", equalTo("Invalid request"))
.body("validationErrors[0].object", equalTo("ReportRequest"))
.body("validationErrors[0].object", equalTo("reportRequest"))
.body("validationErrors[0].field", equalTo("visits"))
.body("validationErrors[0].rejectedValue", hasSize(0))
.body("validationErrors[0].message", containsString("must not be empty"))
.body("validationErrors[0].message", startsWith("size must be between 1 and"))
.body("validationErrors", hasSize(1));
}
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment