Add REST controller with RFC 9457 error handling
EventController for POST /events, GlobalExceptionHandler mapping validation and business exceptions to problem+json responses. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
|
import de.fete.adapter.in.web.api.EventsApi;
|
||||||
|
import de.fete.adapter.in.web.model.CreateEventRequest;
|
||||||
|
import de.fete.adapter.in.web.model.CreateEventResponse;
|
||||||
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.port.in.CreateEventUseCase;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** REST controller for event operations. */
|
||||||
|
@RestController
|
||||||
|
public class EventController implements EventsApi {
|
||||||
|
|
||||||
|
private final CreateEventUseCase createEventUseCase;
|
||||||
|
|
||||||
|
/** Creates a new controller with the given use case. */
|
||||||
|
public EventController(CreateEventUseCase createEventUseCase) {
|
||||||
|
this.createEventUseCase = createEventUseCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<CreateEventResponse> createEvent(
|
||||||
|
CreateEventRequest request) {
|
||||||
|
var command = new CreateEventCommand(
|
||||||
|
request.getTitle(),
|
||||||
|
request.getDescription(),
|
||||||
|
request.getDateTime(),
|
||||||
|
request.getLocation(),
|
||||||
|
request.getExpiryDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
Event event = createEventUseCase.createEvent(command);
|
||||||
|
|
||||||
|
var response = new CreateEventResponse();
|
||||||
|
response.setEventToken(event.getEventToken());
|
||||||
|
response.setOrganizerToken(event.getOrganizerToken());
|
||||||
|
response.setTitle(event.getTitle());
|
||||||
|
response.setDateTime(event.getDateTime());
|
||||||
|
response.setExpiryDate(event.getExpiryDate());
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
|
import de.fete.application.service.ExpiryDateInPastException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.HttpStatusCode;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ProblemDetail;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
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.WebRequest;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
|
||||||
|
|
||||||
|
/** Global exception handler producing RFC 9457 Problem Details responses. */
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
||||||
|
MethodArgumentNotValidException ex,
|
||||||
|
HttpHeaders headers,
|
||||||
|
HttpStatusCode status,
|
||||||
|
WebRequest request) {
|
||||||
|
|
||||||
|
ProblemDetail problemDetail = ex.getBody();
|
||||||
|
problemDetail.setTitle("Validation Failed");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:validation-error"));
|
||||||
|
|
||||||
|
List<Map<String, String>> fieldErrors = ex.getBindingResult()
|
||||||
|
.getFieldErrors()
|
||||||
|
.stream()
|
||||||
|
.map(fe -> Map.of(
|
||||||
|
"field", fe.getField(),
|
||||||
|
"message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "invalid"
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
problemDetail.setProperty("fieldErrors", fieldErrors);
|
||||||
|
return handleExceptionInternal(ex, problemDetail, headers, status, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles expiry date validation failures. */
|
||||||
|
@ExceptionHandler(ExpiryDateInPastException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleExpiryDateInPast(
|
||||||
|
ExpiryDateInPastException ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Invalid Expiry Date");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:expiry-date-in-past"));
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catches all unhandled exceptions. */
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleAll(Exception ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"An unexpected error occurred.");
|
||||||
|
problemDetail.setTitle("Internal Server Error");
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import de.fete.TestcontainersConfig;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Import(TestcontainersConfig.class)
|
||||||
|
class EventControllerIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventWithValidBody() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "Birthday Party",
|
||||||
|
"description": "Come celebrate!",
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||||
|
"location": "Berlin",
|
||||||
|
"expiryDate": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.eventToken").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.title").value("Birthday Party"))
|
||||||
|
.andExpect(jsonPath("$.dateTime").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.expiryDate").isNotEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventWithOptionalFieldsNull() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "Minimal Event",
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||||
|
"expiryDate": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.eventToken").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.title").value("Minimal Event"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventMissingTitleReturns400() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||||
|
"expiryDate": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.title").value("Validation Failed"))
|
||||||
|
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventMissingDateTimeReturns400() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "No Date",
|
||||||
|
"expiryDate": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventMissingExpiryDateReturns400() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "No Expiry",
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventExpiryDateInPastReturns400() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "Past Expiry",
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||||
|
"expiryDate": "2025-01-01"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventExpiryDateTodayReturns400() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "Today Expiry",
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||||
|
"expiryDate": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(LocalDate.now());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void errorResponseContentTypeIsProblemJson() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||||
|
"expiryDate": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user