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:
JSLMPR 2026-07-09 01:14:38 +02:00
parent 1737d8b220
commit a660b7c1d8
3 changed files with 97 additions and 1 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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")