Chaque fois que je commence à implémenter une nouvelle API REST à l'aide de Spring, je trouve difficile de décider comment valider les demandes et gérer les exceptions commerciales. Contrairement à d'autres problèmes d'API courants, Spring et sa communauté ne semblent pas s'entendre sur les meilleures pratiques pour résoudre ces problèmes, et il est difficile de trouver des articles utiles sur le sujet.
Dans cet article, je résume mon expérience et donne quelques conseils sur la validation d'interface.
Architecture et terminologie
Je crée mes propres applications qui fournissent des API Web, en suivant le modèle de l'architecture onion ( Onion Architecture ) . Cet article ne porte pas sur l'architecture Onion, mais je voudrais mentionner certains de ses points clés qui sont importants pour comprendre mes pensées:
Les contrôleurs REST et tous les composants et configurations Web font partie de la couche «infrastructure» externe .
Le niveau «service» intermédiaire contient des services qui intègrent des fonctions commerciales et traitent des problèmes courants tels que la sécurité ou les transactions.
La couche "domaine" interne contient une logique métier sans aucune tâche liée à l'infrastructure, telle que l'accès à la base de données, les points de terminaison Web, etc.
, . REST :
, :
. , API . , Jackson, , @NotNull. .
, . .
, . .
, . Spring Boot Jackson . , BGG:
@GetMapping("/newest")
Flux<ThreadsPerBoardGame> getThreads(@RequestParam String user, @RequestParam(defaultValue = "PT1H") Duration since) {
return threadService.findNewestThreads(user, since);
}
:
curl -i localhost:8080/threads/newest
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 189
{"timestamp":"2020-04-15T03:40:00.460+0000","path":"/threads/newest","status":400,"error":"Bad Request","message":"Required String parameter 'user' is not present","requestId":"98427b15-7"}
curl -i "localhost:8080/threads/newest?user=chrigu&since=a"
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 156
{"timestamp":"2020-04-15T03:40:06.952+0000","path":"/threads/newest","status":400,"error":"Bad Request","message":"Type mismatch.","requestId":"7600c788-8"}
Spring Boot . ,
server:
error:
include-stacktrace: never
application.yml . BasicErrorController Web MVC DefaultErrorWebExceptionHandler WebFlux, ErrorAttributes.
@RequestParam . @ModelAttribute , @RequestBody ,
@GetMapping("/newest/obj")
Flux<ThreadsPerBoardGame> getThreads(@Valid ThreadRequest params) {
return threadService.findNewestThreads(params.user, params.since);
}
static class ThreadRequest {
@NotNull
private final String user;
@NotNull
private final Duration since;
public ThreadRequest(String user, Duration since) {
this.user = user;
this.since = since == null ? Duration.ofHours(1) : since;
}
}
@RequestParam , , bean-, @NotNull Java / Kotlin. bean-, @Valid.
bean- , BindException WebExchangeBindException . BindingResult, . ,
curl "localhost:8080/java/threads/newest/obj" -i
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 1138
{"timestamp":"2020-04-17T13:52:39.500+0000","path":"/java/threads/newest/obj","status":400,"error":"Bad Request","message":"Validation failed for argument at index 0 in method: reactor.core.publisher.Flux<ch.chrigu.bgg.service.ThreadsPerBoardGame> ch.chrigu.bgg.infrastructure.web.JavaThreadController.getThreads(ch.chrigu.bgg.infrastructure.web.JavaThreadController$ThreadRequest), with 1 error(s): [Field error in object 'threadRequest' on field 'user': rejected value [null]; codes [NotNull.threadRequest.user,NotNull.user,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [threadRequest.user,user]; arguments []; default message [user]]; default message [darf nicht null sein]] ","requestId":"c87c7cbb-17","errors":[{"codes":["NotNull.threadRequest.user","NotNull.user","NotNull.java.lang.String","NotNull"],"arguments":[{"codes":["threadRequest.user","user"],"arguments":null,"defaultMessage":"user","code":"user"}],"defaultMessage":"darf nicht null sein","objectName":"threadRequest","field":"user","rejectedValue":null,"bindingFailure":false,"code":"NotNull"}]}
, , API. Spring Boot:
curl "localhost:8080/java/threads/newest/obj?user=chrigu&since=a" -i
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
Content-Length: 513
{"timestamp":"2020-04-17T13:56:42.922+0000","path":"/java/threads/newest/obj","status":500,"error":"Internal Server Error","message":"Failed to convert value of type 'java.lang.String' to required type 'java.time.Duration'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [java.time.Duration] for value 'a'; nested exception is java.lang.IllegalArgumentException: Parse attempt failed for value [a]","requestId":"4c0dc6bd-21"}
, , since. , MVC . . , bean- ErrorAttributes , . status.
DefaultErrorAttributes, @ResponseStatus, ResponseStatusException . . , , , , . - @ExceptionHandler . , , . , , (rethrow):
@ControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(TypeMismatchException::class)
fun handleTypeMismatchException(e: TypeMismatchException): HttpStatus {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid value '${e.value}'", e)
}
@ExceptionHandler(WebExchangeBindException::class)
fun handleWebExchangeBindException(e: WebExchangeBindException): HttpStatus {
throw object : WebExchangeBindException(e.methodParameter!!, e.bindingResult) {
override val message = "${fieldError?.field} has invalid value '${fieldError?.rejectedValue}'"
}
}
}
Spring Boot , , , Spring. , , , :
try/catch (MVC) onErrorResume() (Webflux). , , , , .
@ExceptionHandler . @ExceptionHandler (Throwable.class) .
, @ResponseStatus ResponseStatusException, .
Spring Boot , . , , .
, . , , , , Java Kotlin, , , . .