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.
|
2. [x] Idempotency keys.
|
||||||
3. [x] Retry and DLQ handling.
|
3. [x] Retry and DLQ handling.
|
||||||
4. [x] Tenant quotas.
|
4. [x] Tenant quotas.
|
||||||
5. [~] Full `application/problem+json` error model.
|
5. [x] Full `application/problem+json` error model.
|
||||||
6. [x] Cleanup lifecycle jobs.
|
6. [x] Cleanup lifecycle jobs.
|
||||||
7. [~] Security review.
|
7. [~] Security review.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,14 @@ import org.springframework.http.HttpStatusCode;
|
||||||
import org.springframework.http.ProblemDetail;
|
import org.springframework.http.ProblemDetail;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.validation.FieldError;
|
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.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
import org.springframework.web.context.request.ServletWebRequest;
|
import org.springframework.web.context.request.ServletWebRequest;
|
||||||
import org.springframework.web.context.request.WebRequest;
|
import org.springframework.web.context.request.WebRequest;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
|
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
|
||||||
|
|
||||||
import java.net.URI;
|
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);
|
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) {
|
private Map<String, String> fieldError(FieldError error) {
|
||||||
return Map.of(
|
return Map.of(
|
||||||
"field", error.getField(),
|
"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(
|
private ProblemDetail problem(
|
||||||
HttpStatus status,
|
HttpStatus status,
|
||||||
String type,
|
String type,
|
||||||
|
|
|
||||||
|
|
@ -147,11 +147,41 @@ class VideoAssetControllerTest {
|
||||||
}
|
}
|
||||||
"""))
|
"""))
|
||||||
.andExpect(status().isBadRequest())
|
.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("$.type").value("https://api.example.com/problems/validation-error"))
|
||||||
.andExpect(jsonPath("$.title").value("Bad Request"))
|
.andExpect(jsonPath("$.title").value("Bad Request"))
|
||||||
.andExpect(jsonPath("$.errors").isArray());
|
.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
|
@Test
|
||||||
void returnsNotFoundProblemForMissingClip() throws Exception {
|
void returnsNotFoundProblemForMissingClip() throws Exception {
|
||||||
mockMvc.perform(post("/v1/clips/{clipId}/download-url", "clip_missing")
|
mockMvc.perform(post("/v1/clips/{clipId}/download-url", "clip_missing")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue