Commit 0da7a191 authored by Bergamote Orange's avatar Bergamote Orange
Browse files

refacto(clea-ws-rest): v2 implementation of interface contract

parent ae39135d
Pipeline #284921 failed with stages
in 8 minutes and 31 seconds
......@@ -135,27 +135,61 @@
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>5.1.1</version>
<configuration>
<generatorName>spring</generatorName>
<configOptions>
<interfaceOnly>true</interfaceOnly>
<useTags>true</useTags>
<additionalModelTypeAnnotations>
@lombok.Builder
@lombok.AllArgsConstructor
@lombok.NoArgsConstructor
</additionalModelTypeAnnotations>
</configOptions>
</configuration>
<executions>
<execution>
<id>v1</id>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>
${project.basedir}/src/main/resources/api-clea-server-specs/v1/api-clea-server-v1.yml
</inputSpec>
<apiPackage>fr.gouv.clea.ws.api.v1</apiPackage>
<modelPackage
>fr.gouv.clea.ws.api.v1.model</modelPackage>
</configuration>
</execution>
<execution>
<id>v2-with-validation</id>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>
${project.basedir}/src/main/resources/api-clea-server-specs/v2/with-validation.yml
</inputSpec>
<apiPackage>fr.gouv.clea.ws.api.v2</apiPackage>
<modelPackage
>fr.gouv.clea.ws.api.v2.model</modelPackage>
</configuration>
</execution>
<execution>
<id>v2-without-validation</id>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>
${project.basedir}/src/main/resources/api-report-v1.yml
${project.basedir}/src/main/resources/api-clea-server-specs/v2/without-validation.yml
</inputSpec>
<generatorName>spring</generatorName>
<apiPackage>fr.gouv.clea.ws.api</apiPackage>
<modelPackage>fr.gouv.clea.ws.api.model</modelPackage>
<apiPackage>fr.gouv.clea.ws.api.v2</apiPackage>
<modelPackage
>fr.gouv.clea.ws.api.v2.model</modelPackage>
<configOptions>
<interfaceOnly>true</interfaceOnly>
<useBeanValidation>false</useBeanValidation>
<useTags>true</useTags>
<additionalModelTypeAnnotations>
@lombok.Builder
@lombok.AllArgsConstructor
@lombok.NoArgsConstructor
</additionalModelTypeAnnotations>
</configOptions>
</configuration>
</execution>
......
package fr.gouv.clea.ws.controller;
package fr.gouv.clea.ws.controller.v1;
import fr.gouv.clea.ws.api.CleaApi;
import fr.gouv.clea.ws.api.model.ReportRequest;
import fr.gouv.clea.ws.api.model.ReportResponse;
import fr.gouv.clea.ws.api.model.ValidationError;
import fr.gouv.clea.ws.exception.CleaBadRequestException;
import fr.gouv.clea.ws.api.v1.CleaApi;
import fr.gouv.clea.ws.api.v1.model.ReportRequest;
import fr.gouv.clea.ws.api.v1.model.ReportResponse;
import fr.gouv.clea.ws.api.v1.model.ValidationError;
import fr.gouv.clea.ws.controller.v1.exception.CleaBadRequestException;
import fr.gouv.clea.ws.service.ReportService;
import fr.gouv.clea.ws.service.model.Visit;
import fr.inria.clea.lsp.utils.TimeUtils;
......@@ -48,7 +48,7 @@ public class CleaController implements CleaApi {
);
}
private Visit toVisitNullSafe(fr.gouv.clea.ws.api.model.Visit visit) {
private Visit toVisitNullSafe(fr.gouv.clea.ws.api.v1.model.Visit visit) {
return visit == null ? null : new Visit(visit.getQrCode(), visit.getQrCodeScanTime());
}
......
package fr.gouv.clea.ws.exception;
package fr.gouv.clea.ws.controller.v1.exception;
import fr.gouv.clea.ws.api.model.ValidationError;
import fr.gouv.clea.ws.api.v1.model.ValidationError;
import lombok.Getter;
import java.util.Collections;
......
package fr.gouv.clea.ws.exception;
package fr.gouv.clea.ws.controller.v1.exception;
import fr.gouv.clea.ws.api.model.ErrorResponse;
import fr.gouv.clea.ws.api.model.ValidationError;
import fr.gouv.clea.ws.api.v1.model.ErrorResponse;
import fr.gouv.clea.ws.api.v1.model.ValidationError;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
......
package fr.gouv.clea.ws.controller.v2;
import fr.gouv.clea.ws.api.v2.DefaultApi;
import fr.gouv.clea.ws.api.v2.model.ManualReportRequest;
import fr.gouv.clea.ws.api.v2.model.ReportResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
public class ManualReportController implements DefaultApi {
@Override
public ResponseEntity<ReportResponse> manualReport(final ManualReportRequest manualReportRequest) {
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
}
}
package fr.gouv.clea.ws.controller.v2;
import fr.gouv.clea.ws.api.v2.WithoutValidationApi;
import fr.gouv.clea.ws.api.v2.model.ReportRequest;
import fr.gouv.clea.ws.api.v2.model.ReportResponse;
import fr.gouv.clea.ws.api.v2.model.ValidationError;
import fr.gouv.clea.ws.controller.v2.exception.CleaBadRequestException;
import fr.gouv.clea.ws.service.ReportService;
import fr.gouv.clea.ws.service.model.Visit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static java.util.stream.Collectors.toList;
import static org.springframework.util.CollectionUtils.isEmpty;
@RestController
@RequestMapping(path = "/api/clea/v2")
@RequiredArgsConstructor
@Slf4j
public class WReportController implements WithoutValidationApi {
private final ReportService reportService;
@Override
public ResponseEntity<ReportResponse> wreport(ReportRequest reportRequest) {
nonNullPivotDateOrThrowBadRequest(reportRequest);
nonEmptyVisitsOrThrowBadRequest(reportRequest);
final var pivotDate = reportRequest.getPivotDate().toInstant();
final var visits = reportRequest.getVisits()
.stream()
.map(this::toVisitNullSafe)
.collect(toList());
final var acceptedVisits = reportService.report(pivotDate, visits);
return ResponseEntity.ok(
ReportResponse.builder()
.accepted(Integer.toUnsignedLong(acceptedVisits))
.rejected(Integer.toUnsignedLong(reportRequest.getVisits().size() - acceptedVisits))
.build()
);
}
private Visit toVisitNullSafe(fr.gouv.clea.ws.api.v2.model.Visit visit) {
return visit == null ? null
: new Visit(visit.getEncryptedLocationSpecificPart(), visit.getScanTime().toInstant());
}
private void nonNullPivotDateOrThrowBadRequest(ReportRequest reportRequest) {
if (reportRequest.getPivotDate() == null) {
throw new CleaBadRequestException(
ValidationError.builder()
.rejectedValue(null)
._object("ReportRequest")
.message("must not be null")
.field("pivotDate")
.build()
);
}
}
private void nonEmptyVisitsOrThrowBadRequest(ReportRequest reportRequest) {
if (isEmpty(reportRequest.getVisits())) {
throw new CleaBadRequestException(
ValidationError.builder()
.rejectedValue(reportRequest.getVisits())
._object("ReportRequest")
.message("must not be empty")
.field("visits")
.build()
);
}
}
}
package fr.gouv.clea.ws.controller.v2.exception;
import fr.gouv.clea.ws.api.v2.model.ValidationError;
import lombok.Getter;
import java.util.List;
public class CleaBadRequestException extends RuntimeException {
@Getter
private final List<ValidationError> validationErrors;
public CleaBadRequestException(ValidationError error) {
super("Invalid request");
validationErrors = List.of(error);
}
}
package fr.gouv.clea.ws.controller.v2.exception;
import fr.gouv.clea.ws.api.v2.model.ErrorResponse;
import fr.gouv.clea.ws.api.v2.model.ValidationError;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
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.util.List;
import java.util.stream.Stream;
import static java.util.stream.Collectors.toList;
@ControllerAdvice
@Slf4j
public class CleaRestExceptionHandler extends ResponseEntityExceptionHandler {
public static final String ERROR_MESSAGE_TEMPLATE = "%s, requested uri: %s";
@ExceptionHandler(CleaBadRequestException.class)
public ResponseEntity<ErrorResponse> handleCleaBadRequestException(CleaBadRequestException ex,
WebRequest webRequest) {
log.error(String.format(ERROR_MESSAGE_TEMPLATE, ex.getLocalizedMessage(), webRequest.getDescription(false)));
return ResponseEntity.badRequest()
.body(
new ErrorResponse(
ex.getLocalizedMessage(),
ex.getValidationErrors()
)
);
}
@Override
protected ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException ex,
HttpHeaders headers, HttpStatus status, WebRequest request) {
log.error(String.format(ERROR_MESSAGE_TEMPLATE, ex.getLocalizedMessage(), request.getDescription(false)));
ErrorResponse error = new ErrorResponse(
ex.getLocalizedMessage().split(":")[0],
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(
"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<ErrorResponse> handleOtherException(Exception ex, WebRequest webRequest) {
final HttpStatus status = getHttpStatus(ex);
log.error(
String.format(ERROR_MESSAGE_TEMPLATE, ex.getLocalizedMessage(), webRequest.getDescription(false)), ex
);
return ResponseEntity
.status(status)
.body(this.exceptionToApiError(ex, status));
}
private HttpStatus getHttpStatus(Exception ex) {
final ResponseStatus responseStatus = ex.getClass().getAnnotation(ResponseStatus.class);
return responseStatus == null ? HttpStatus.INTERNAL_SERVER_ERROR : responseStatus.value();
}
private ErrorResponse exceptionToApiError(Exception ex, HttpStatus status) {
return new ErrorResponse(
ex.getLocalizedMessage(),
List.of()
);
}
}
package fr.gouv.clea.ws.service.model;
import fr.inria.clea.lsp.utils.TimeUtils;
import lombok.AllArgsConstructor;
import lombok.Value;
import java.time.Instant;
@Value
@AllArgsConstructor
public class Visit {
/**
......
openapi: 3.0.3
info:
title: Tous AntiCovid Cluster Exposure Verification (Cléa)
description: "#TOUSANTICOVID, Cléa API"
contact:
email: stopcovid@inria.fr
license:
name: Apache 2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
version: 2.0.0
tags:
- name: clea
description: Clea API
paths:
"/api/v2/wreport":
post:
tags:
- noValidation
summary: Upload locations history
operationId: wreport
requestBody:
content:
application/json:
schema:
"$ref": "#/components/schemas/ReportRequest"
responses:
"200":
description: Successful operation
content:
application/json:
schema:
"$ref": "#/components/schemas/ReportResponse"
"400":
description: Bad request
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"401":
description: Invalid authentication
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"500":
description: Internal error
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v2/manual-report":
post:
summary: Manually upload locations history
description: Pivot date for the whole list is automatically set to 1 hour before oldest visit of the list
operationId: manualReport
requestBody:
content:
application/json:
schema:
"$ref": "#/components/schemas/ManualReportRequest"
responses:
"200":
description: Successful operation
content:
application/json:
schema:
"$ref": "#/components/schemas/ReportResponse"
"400":
description: Bad Request
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"401":
description: Invalid authentication
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"500":
description: Internal error
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
components:
schemas:
ReportRequest:
......@@ -92,8 +7,8 @@ components:
type: object
properties:
pivotDate:
type: integer
format: int64
type: string
format: date-time
description: An estimate date of contamination
visits:
type: array
......@@ -140,9 +55,9 @@ components:
type: string
description: Encrypted location specific part encoded in Base64
scanTime:
type: integer
format: int64
description: ISO 8601 timestamp when a user terminal scans a given QR code
type: string
format: date-time
description: ISO 8601 date when a user terminal scans a given QR code
required:
- encryptedLocationSpecificPart
- scanTime
......
openapi: 3.0.3
info:
title: Tous AntiCovid Cluster Exposure Verification (Cléa)
description: "#TOUSANTICOVID, Cléa API"
contact:
email: stopcovid@inria.fr
license:
name: Apache 2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
version: 2.0.0
tags:
- name: clea
description: Clea API
paths:
"/api/v2/manual-report":
post:
summary: Manually upload locations history
description: Pivot date for the whole list is automatically set to 1 hour before oldest visit of the list
operationId: manualReport
requestBody:
content:
application/json:
schema:
"$ref": "components.yml#/components/schemas/ManualReportRequest"
responses:
"200":
description: Successful operation
content:
application/json:
schema:
"$ref": "components.yml#/components/schemas/ReportResponse"
"400":
description: Bad Request
content:
application/json:
schema:
"$ref": "components.yml#/components/schemas/ErrorResponse"
"401":
description: Invalid authentication
content:
application/json:
schema:
"$ref": "components.yml#/components/schemas/ErrorResponse"
"500":
description: Internal error
content:
application/json:
schema:
"$ref": "components.yml#/components/schemas/ErrorResponse"
openapi: 3.0.3
info:
title: Tous AntiCovid Cluster Exposure Verification (Cléa)
description: "#TOUSANTICOVID, Cléa API"
contact:
email: stopcovid@inria.fr
license:
name: Apache 2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
version: 2.0.0
tags:
- name: clea
description: Clea API
paths:
"/api/v2/wreport":
post:
tags:
- withoutValidation
summary: Upload locations history
operationId: wreport
requestBody:
content:
application/json:
schema:
"$ref": "components.yml#/components/schemas/ReportRequest"
responses:
"200":
description: Successful operation
content:
application/json:
schema:
"$ref": "components.yml#/components/schemas/ReportResponse"
"400":
description: Bad request
content:
application/json:
schema:
"$ref": "components.yml#/components/schemas/ErrorResponse"
"401":
description: Invalid authentication
content:
application/json:
schema:
"$ref": "components.yml#/components/schemas/ErrorResponse"
"500":
description: Internal error
content:
application/json:
schema:
"$ref": "components.yml#/components/schemas/ErrorResponse"
package fr.gouv.clea.ws.controller;
package fr.gouv.clea.ws.controller.v1;
import fr.gouv.clea.ws.api.model.ReportRequest;
import fr.gouv.clea.ws.api.model.Visit;
import fr.gouv.clea.ws.api.v1.model.ReportRequest;