I completed the next step, Full application/problem+json error model, and updated the plan in docs/video-clipping-service-implementation-plan.md:701.
The main change is in src/main/java/org/example/videoclips/api/ApiExceptionHandler.java:1. It now consistently returns problem details for: - validation errors - malformed JSON - unsupported request content type - method not allowed - quota/conflict/not-found/media-type domain exceptions - unexpected internal errors I also extended src/test/java/org/example/videoclips/api/VideoAssetControllerTest.java:1 to assert application/problem+json and cover malformed JSON, unsupported request content type, and method-not-allowed responses.
This commit is contained in:
parent
1737d8b220
commit
a660b7c1d8
|
|
@ -704,7 +704,7 @@ Keep the domain independent from Spring framework details. Adapters implement st
|
|||
2. [x] Idempotency keys.
|
||||
3. [x] Retry and DLQ handling.
|
||||
4. [x] Tenant quotas.
|
||||
5. [~] Full `application/problem+json` error model.
|
||||
5. [x] Full `application/problem+json` error model.
|
||||
6. [x] Cleanup lifecycle jobs.
|
||||
7. [~] Security review.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,14 @@ import org.springframework.http.HttpStatusCode;
|
|||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.ServletWebRequest;
|
||||
import org.springframework.web.context.request.WebRequest;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
|
||||
|
||||
import java.net.URI;
|
||||
|
|
@ -75,6 +78,63 @@ public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
|
|||
return problem(HttpStatus.BAD_REQUEST, "validation-error", ex.getMessage(), request.getRequestURI(), null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
|
||||
return problem(HttpStatus.INTERNAL_SERVER_ERROR, "internal-error", "An unexpected error occurred", request.getRequestURI(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpMessageNotReadable(
|
||||
HttpMessageNotReadableException ex,
|
||||
org.springframework.http.HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request
|
||||
) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(problem(HttpStatus.BAD_REQUEST, "malformed-json", "Malformed JSON request", requestPath(request), null));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpMediaTypeNotSupported(
|
||||
HttpMediaTypeNotSupportedException ex,
|
||||
org.springframework.http.HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request
|
||||
) {
|
||||
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
||||
.body(problem(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "unsupported-media-type", "Content type is not supported", requestPath(request), null));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
|
||||
HttpRequestMethodNotSupportedException ex,
|
||||
org.springframework.http.HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request
|
||||
) {
|
||||
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
|
||||
.body(problem(HttpStatus.METHOD_NOT_ALLOWED, "method-not-allowed", "HTTP method is not supported for this endpoint", requestPath(request), null));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleExceptionInternal(
|
||||
Exception ex,
|
||||
Object body,
|
||||
org.springframework.http.HttpHeaders headers,
|
||||
HttpStatusCode statusCode,
|
||||
WebRequest request
|
||||
) {
|
||||
if (body instanceof ProblemDetail) {
|
||||
return super.handleExceptionInternal(ex, body, headers, statusCode, request);
|
||||
}
|
||||
HttpStatus status = HttpStatus.resolve(statusCode.value());
|
||||
HttpStatus resolvedStatus = status == null ? HttpStatus.INTERNAL_SERVER_ERROR : status;
|
||||
return ResponseEntity.status(resolvedStatus)
|
||||
.body(problem(resolvedStatus, "internal-error", resolvedStatus == HttpStatus.INTERNAL_SERVER_ERROR
|
||||
? "An unexpected error occurred"
|
||||
: resolvedStatus.getReasonPhrase(), requestPath(request), null));
|
||||
}
|
||||
|
||||
private Map<String, String> fieldError(FieldError error) {
|
||||
return Map.of(
|
||||
"field", error.getField(),
|
||||
|
|
@ -82,6 +142,12 @@ public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
|
|||
);
|
||||
}
|
||||
|
||||
private String requestPath(WebRequest request) {
|
||||
return request instanceof ServletWebRequest servletWebRequest
|
||||
? servletWebRequest.getRequest().getRequestURI()
|
||||
: "";
|
||||
}
|
||||
|
||||
private ProblemDetail problem(
|
||||
HttpStatus status,
|
||||
String type,
|
||||
|
|
|
|||
|
|
@ -147,11 +147,41 @@ class VideoAssetControllerTest {
|
|||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/validation-error"))
|
||||
.andExpect(jsonPath("$.title").value("Bad Request"))
|
||||
.andExpect(jsonPath("$.errors").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsProblemDetailsForMalformedJson() throws Exception {
|
||||
mockMvc.perform(post("/v1/video-assets")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/malformed-json"))
|
||||
.andExpect(jsonPath("$.title").value("Bad Request"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsProblemDetailsForUnsupportedRequestContentType() throws Exception {
|
||||
mockMvc.perform(post("/v1/video-assets")
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
.content("plain-text"))
|
||||
.andExpect(status().isUnsupportedMediaType())
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/unsupported-media-type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsProblemDetailsForMethodNotAllowed() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/v1/video-assets"))
|
||||
.andExpect(status().isMethodNotAllowed())
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/method-not-allowed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNotFoundProblemForMissingClip() throws Exception {
|
||||
mockMvc.perform(post("/v1/clips/{clipId}/download-url", "clip_missing")
|
||||
|
|
|
|||
Loading…
Reference in New Issue