diff --git a/.github/workflows/basic_code_checks.yml b/.github/workflows/basic_code_checks.yml index 945ff648..acbe82d8 100644 --- a/.github/workflows/basic_code_checks.yml +++ b/.github/workflows/basic_code_checks.yml @@ -213,7 +213,7 @@ jobs: SPRING_JPA_DATABASE_PLATFORM: 'org.hibernate.dialect.H2Dialect' SPRING_FLYWAY_ENABLED: 'false' SPRING_JPA_HIBERNATE_DDL_AUTO: 'none' - SPRING_DATASOURCE_URL: 'jdbc:h2:mem' + SPRING_DATASOURCE_URL: 'jdbc:h2:mem:idp_core;DB_CLOSE_DELAY=-1' SPRING_DATASOURCE_DRIVER_CLASS_NAME: 'org.h2.Driver' run: | mkdir -p specs diff --git a/docs/src/contributing/code/exception-handling.md b/docs/src/contributing/code/exception-handling.md index 72a9aae2..779c9d6d 100644 --- a/docs/src/contributing/code/exception-handling.md +++ b/docs/src/contributing/code/exception-handling.md @@ -6,28 +6,52 @@ description: Global exception handling strategy and error response formats for I ## Strategy 1. **Domain**: Throw specific business exceptions, for example `EntityTemplateNotFoundException`. -2. **Infrastructure**: Catch exceptions in `ApiExceptionHandler` with `@ControllerAdvice` annotation. -3. **API**: Return consistent JSON error responses. +2. **Infrastructure**: Catch exceptions in `ApiExceptionHandler` with `@RestControllerAdvice`. +3. **API**: Return RFC 7807 `ProblemDetail` responses with legacy extension fields. ## Exception Mapping -| Exception Type | HTTP Status | Description | -| -------------------------------------- | ------------------ | -------------------- | -| `EntityTemplateNotFoundException` | 404 Not Found | Entity not found | -| `EntityTemplateAlreadyExistsException` | 409 Conflict | Duplicate identifier | -| `ConstraintViolationException` | 400 Bad Request | Validation failed | -| `MethodArgumentNotValidException` | 400 Bad Request | Invalid request body | -| `HttpMessageNotReadableException` | 400 Bad Request | JSON parsing error | -| `Exception` | 500 Internal Error | Unexpected error | +| Exception Type | HTTP Status | Description | +| -------------------------------------- | ------------------ | ---------------------------- | +| `EntityTemplateNotFoundException` | 404 Not Found | Entity not found | +| `EntityTemplateAlreadyExistsException` | 409 Conflict | Duplicate identifier | +| `ConstraintViolationException` | 400 Bad Request | Validation failed | +| `MethodArgumentNotValidException` | 400 Bad Request | Invalid request body | +| `HttpMessageNotReadableException` | 400 Bad Request | Framework JSON parsing error | +| `Exception` | 500 Internal Error | Unexpected error | ## Error Response Format -At API level, always follow the same error response structure: +The API now returns `application/problem+json` responses. Standard RFC 7807 fields are +present alongside legacy compatibility fields: ```json { + "type": "about:blank", + "title": "Not Found", + "status": 404, + "detail": "Template with ID 'invalid-id' not found", + "instance": "/api/v1/entity-templates/invalid-id", "error": "NOT_FOUND", "error_description": "Template with ID 'invalid-id' not found", "timestamp": "2025-11-28T10:30:00Z" } ``` + +The `error` and `error_description` fields preserve the legacy contract for existing +clients. `timestamp` is included as an `Instant` for traceability. + +> [!WARNING] +> Strict clients may reject the new media type or the extra extension fields. They should +> be updated to parse `application/problem+json` and tolerate RFC 7807 extensions. + +### Intentional differences + +- Spring MVC validation and JSON parsing errors now use the framework's native problem detail handling. +- The manual JSON parsing logic was removed, so some malformed payload messages may differ from the previous custom wording. + +### Migration note + +Communicate the response shape change to API consumers before they depend on the new contract. +Keep compatibility tests in place while clients migrate, and verify both the standard +ProblemDetail fields and the legacy extension properties. diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 5d564f02..a2d70bd1 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -14,6 +14,8 @@ tags: description: Entity relationship graph operations - name: Inbound Webhook Management description: Operations for managing inbound webhook connector configurations +- name: Principals + description: Principal identity and profile management - name: Entities Management description: Operations related to entity management - name: Audit @@ -51,9 +53,9 @@ paths: '404': description: Webhook connector not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" put: tags: - Inbound Webhook Management @@ -83,21 +85,21 @@ paths: '400': description: Invalid request payload content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '404': description: Webhook connector not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '409': description: Webhook connector name already exists content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" delete: tags: - Inbound Webhook Management @@ -116,9 +118,9 @@ paths: '404': description: Webhook connector not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" "/api/v1/entity_dynamic_mappings/{identifier}": get: tags: @@ -142,9 +144,9 @@ paths: '404': description: Entity dynamic mapping not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" put: tags: - Entity dynamic mapping @@ -175,21 +177,21 @@ paths: description: Entity template identifier is mandatory or target entity template not found content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '404': description: Entity dynamic mapping not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '409': description: Target entity template not found or identifier already exists content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" delete: tags: - Entity dynamic mapping @@ -208,9 +210,9 @@ paths: '404': description: Entity dynamic mapping not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" "/api/v1/entity-templates/{identifier}": get: tags: @@ -234,9 +236,9 @@ paths: '404': description: Template not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" put: tags: - Entities Templates Management @@ -266,9 +268,9 @@ paths: '404': description: Template not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" delete: tags: - Entities Templates Management @@ -287,15 +289,15 @@ paths: '400': description: 'Cannot delete template: it is a target for other relations' content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '404': description: Template not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" "/api/v1/entities/{templateIdentifier}/{entityIdentifier}": get: tags: @@ -346,9 +348,9 @@ paths: '404': description: Entity not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" put: tags: - Entities Management @@ -384,9 +386,9 @@ paths: '400': description: Invalid entity data provided content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '401': description: Unauthorized - Missing or invalid token '403': @@ -394,15 +396,15 @@ paths: '404': description: Entity not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '500': description: Unexpected server-side failure content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" patch: tags: - Entities Management @@ -438,9 +440,9 @@ paths: '400': description: Invalid entity data provided content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '401': description: Unauthorized - Missing or invalid token '403': @@ -448,15 +450,15 @@ paths: '404': description: Entity not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '500': description: Unexpected server-side failure content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" delete: tags: - Entities Management @@ -484,9 +486,9 @@ paths: '400': description: Invalid entity data provided content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '401': description: Unauthorized - Missing or invalid token '403': @@ -494,21 +496,21 @@ paths: '404': description: Entity not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '409': description: Target entity has required relations content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '500': description: Unexpected server-side failure content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" "/webhooks/{webhookIdentifier}": post: tags: @@ -560,9 +562,9 @@ paths: '400': description: Invalid pagination parameters content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" post: tags: - Inbound Webhook Management @@ -639,9 +641,9 @@ paths: '400': description: Invalid pagination parameters content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" post: tags: - Entity dynamic mapping @@ -698,22 +700,22 @@ paths: '400': description: Invalid entity dynamic mapping data provided content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '404': description: Entity template not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '422': description: Mapping validation, template validation or expression evaluation error content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" "/api/v1/entity-templates": get: tags: @@ -757,9 +759,9 @@ paths: '400': description: Invalid pagination parameters content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" post: tags: - Entities Templates Management @@ -784,7 +786,7 @@ paths: content: "*/*": schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" "/api/v1/entities/{templateIdentifier}": get: tags: @@ -847,9 +849,9 @@ paths: '400': description: Invalid filter query syntax content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" post: tags: - Entities Management @@ -879,31 +881,31 @@ paths: '400': description: Invalid entity data provided content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '401': description: Unauthorized - Missing or invalid token '403': description: Insufficient rights - '404': - description: Template not found with the provided identifier - content: - "*/*": - schema: - "$ref": "#/components/schemas/ErrorResponse" '409': description: Entity already exists in this template content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" + '404': + description: Template not found with the provided identifier + content: + application/problem+json: + schema: + "$ref": "#/components/schemas/ProblemDetail" '500': description: Unexpected server-side failure content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" "/api/v1/entities/search": post: tags: @@ -929,9 +931,9 @@ paths: '400': description: Invalid search filter content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" "/api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph": get: tags: @@ -1012,9 +1014,61 @@ paths: '404': description: Entity not found with the provided identifier content: - "*/*": + application/problem+json: + schema: + "$ref": "#/components/schemas/ProblemDetail" + "/api/v1/entities/principals/me": + get: + tags: + - Principals + summary: Get current authenticated principal + description: | + Returns the Principal entity of the currently authenticated actor (human or service account). + The principal is automatically provisioned via JIT if this is the first authentication. + + **Response includes:** + - Principal identifier (unique ID) + - Display name + - Principal kind (HUMAN or SERVICE_ACCOUNT) + - Attributes (email, client_id, etc.) + - Group memberships (via relations) + + **Use cases:** + - Profile inspection: "Who am I?" + - Authorization context retrieval + - Service account validation + operationId: getCurrentPrincipal + responses: + '200': + description: Principal entity retrieved successfully + content: + application/json: + schema: + "$ref": "#/components/schemas/EntityDtoOut" + '401': + description: Unauthorized - Missing or invalid token + content: + application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + '403': + description: Insufficient rights + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: Principal not found in catalog (JIT provisioning may have failed) + content: + application/problem+json: + schema: + "$ref": "#/components/schemas/ProblemDetail" + '500': + description: Unexpected server-side failure + content: + application/problem+json: + schema: + "$ref": "#/components/schemas/ProblemDetail" "/api/v1/audit/entities/{templateIdentifier}/{entityIdentifier}": get: tags: @@ -1048,9 +1102,9 @@ paths: '400': description: Invalid template or entity identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '401': description: Unauthorized - Missing or invalid token '403': @@ -1059,15 +1113,15 @@ paths: description: Template not found with the provided identifier or Entity not found with the provided identifier content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" '500': description: Unexpected server-side failure content: - "*/*": + application/problem+json: schema: - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ProblemDetail" "/actuator": get: tags: @@ -1224,6 +1278,25 @@ components: type: array items: type: string + ProblemDetail: + type: object + properties: + type: + type: string + format: uri + title: + type: string + status: + type: integer + format: int32 + detail: + type: string + instance: + type: string + format: uri + properties: + type: object + additionalProperties: {} ErrorResponse: type: object properties: @@ -1569,7 +1642,7 @@ components: - name EntityPatchDtoIn: type: object - description: Input DTO for partially updating an entity. Omitted fields remain unchanged. + description: Input DTO for partially updating an entity properties: name: type: string @@ -1579,10 +1652,12 @@ components: type: object additionalProperties: type: string - description: Properties to merge with the existing entity properties + description: Map of property name to value for this entity + example: + port: '8080' relations: type: array - description: Relations to merge with the existing entity relations + description: List of relations for this entity items: "$ref": "#/components/schemas/RelationDtoIn" RelationDtoIn: @@ -1973,11 +2048,11 @@ components: type: boolean sort: "$ref": "#/components/schemas/SortObject" - first: - type: boolean numberOfElements: type: integer format: int32 + first: + type: boolean size: type: integer format: int32 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java index 94d9e28a..41e71644 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java @@ -5,6 +5,7 @@ import java.util.List; +import org.springdoc.core.customizers.OpenApiCustomizer; import org.springdoc.core.models.GroupedOpenApi; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -25,6 +26,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.DateTimeSchema; +import io.swagger.v3.oas.models.media.StringSchema; import io.swagger.v3.oas.models.security.OAuthFlow; import io.swagger.v3.oas.models.security.OAuthFlows; import io.swagger.v3.oas.models.security.SecurityRequirement; @@ -98,6 +101,34 @@ public OpenAPI openAPI() { return openApi; } + /** + * Documents the ProblemDetail extension members at the schema root, matching + * the wire format (Jackson unwraps the properties map), instead of a nested + * "properties" object. + */ + @Bean + public OpenApiCustomizer problemDetailSchemaCustomizer() { + return openApi -> { + if (openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) { + return; + } + io.swagger.v3.oas.models.media.Schema problemDetail = openApi.getComponents().getSchemas() + .get("ProblemDetail"); + if (problemDetail == null) { + return; + } + if (problemDetail.getProperties() != null) { + problemDetail.getProperties().remove("properties"); + } + problemDetail.addProperty("error", + new StringSchema().description("HTTP status name, e.g. NOT_FOUND")); + problemDetail.addProperty("error_description", + new StringSchema().description("Human-readable description of the error")); + problemDetail.addProperty("timestamp", + new DateTimeSchema().description("Instant at which the error occurred")); + }; + } + @Bean public GroupedOpenApi allApis() { return GroupedOpenApi.builder().group("internal").pathsToMatch("/**").build(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditController.java index fbbdabd1..f9926ae8 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditController.java @@ -20,6 +20,7 @@ import jakarta.validation.constraints.NotBlank; +import org.springframework.http.ProblemDetail; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -31,7 +32,6 @@ import com.decathlon.idp_core.domain.service.entity.EntityAuditService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.audit.EntityAuditDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityAuditDtoOutMapper; -import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -77,14 +77,14 @@ public class AuditController { @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_AUDIT_SUCCESS, content = { @Content(array = @ArraySchema(schema = @Schema(implementation = EntityAuditDtoOut.class)))}) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "Invalid template or entity identifier", content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER + " or " + RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @GetMapping("entities/{templateIdentifier}/{entityIdentifier}") @ResponseStatus(OK) public List getEntityAuditHistory( diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 15b8a8f4..f02923fb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -62,6 +62,8 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -99,7 +101,6 @@ import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoOutFromEntityNodeMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoOutMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.SearchFilterMapper; -import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -160,9 +161,9 @@ public class EntityController { @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_QUERY, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) @@ -212,7 +213,7 @@ public Page getEntities(@RequestParam(defaultValue = "0") int page @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { @Content(schema = @Schema(implementation = EntityDtoOut.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @Parameter(name = "relations_depth", description = RELATIONS_DEPTH_DESCRIPTION, in = ParameterIn.QUERY, schema = @Schema(type = "integer", defaultValue = "1", minimum = "1", maximum = "6")) @Parameter(name = "relations_to_display", description = RELATIONS_TO_DISPLAY_DESCRIPTION, in = ParameterIn.QUERY, schema = @Schema(type = "array")) @GetMapping("/{templateIdentifier}/{entityIdentifier}") @@ -246,15 +247,15 @@ public EntityDtoOut getEntity(@PathVariable String templateIdentifier, @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { @Content(schema = @Schema(implementation = EntityDtoOut.class))}) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @PostMapping("/{templateIdentifier}") @ResponseStatus(CREATED) public EntityDtoOut createEntity(@NotBlank @PathVariable String templateIdentifier, @@ -283,13 +284,13 @@ public EntityDtoOut createEntity(@NotBlank @PathVariable String templateIdentifi @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_UPDATED, content = { @Content(schema = @Schema(implementation = EntityDtoOut.class))}) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @PutMapping("/{templateIdentifier}/{entityIdentifier}") @ResponseStatus(OK) public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifier, @@ -310,13 +311,13 @@ public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifi @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_UPDATED, content = { @Content(schema = @Schema(implementation = EntityDtoOut.class))}) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @PatchMapping("/{templateIdentifier}/{entityIdentifier}") @ResponseStatus(OK) public EntityDtoOut patchEntity(@NotBlank @PathVariable String templateIdentifier, @@ -341,15 +342,15 @@ public EntityDtoOut patchEntity(@NotBlank @PathVariable String templateIdentifie @Operation(summary = ENDPOINT_DELETE_ENTITY_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DESCRIPTION) @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DELETED) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_RELATION_CONFLICT, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @DeleteMapping("/{templateIdentifier}/{entityIdentifier}") @ResponseStatus(NO_CONTENT) public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, @@ -370,7 +371,7 @@ public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, @Operation(summary = ENDPOINT_POST_SEARCH_SUMMARY, description = ENDPOINT_POST_SEARCH_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_SEARCH_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_SEARCH_QUERY, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @PostMapping("/search") @ResponseStatus(OK) public Page searchEntities(@RequestBody EntitySearchRequestDtoIn searchRequest) { diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java index e6eae749..09882971 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java @@ -8,6 +8,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.http.ProblemDetail; import org.springframework.web.bind.annotation.*; import com.decathlon.idp_core.domain.model.entity_mapping.DryRunResult; @@ -23,7 +24,6 @@ import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.EntityDynamicMappingDryRunDtoInMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.EntityDynamicMappingDryRunDtoOutMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.EntityDynamicMappingMapper; -import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -62,7 +62,7 @@ public EntityDynamicMappingDtoOut createDynamicMapping( @Operation(summary = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUMMARY, description = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = SwaggerConfiguration.EntityDynamicMappingPageResponse.class))) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) @@ -77,7 +77,7 @@ public Page getEntityDynamicMappingPaginated( @Operation(summary = ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_DESCRIPTION) @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DELETED) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ResponseStatus(NO_CONTENT) @DeleteMapping("/{identifier}") public void deleteEntityDynamicMapping(@PathVariable String identifier) { @@ -88,7 +88,7 @@ public void deleteEntityDynamicMapping(@PathVariable String identifier) { @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_FOUND, content = { @Content(schema = @Schema(implementation = EntityDynamicMappingDtoOut.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @GetMapping("/{identifier}") @ResponseStatus(OK) public EntityDynamicMappingDtoOut getEntityDynamicMappingByIdentifier( @@ -102,11 +102,11 @@ public EntityDynamicMappingDtoOut getEntityDynamicMappingByIdentifier( @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_UPDATED, content = { @Content(schema = @Schema(implementation = EntityDynamicMappingDtoOut.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_UPDATE_INVALID_TEMPLATE, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_UPDATE_CONFLICT, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @PutMapping("/{identifier}") @ResponseStatus(OK) public EntityDynamicMappingDtoOut updateEntityDynamicMapping(@PathVariable String identifier, @@ -120,11 +120,11 @@ public EntityDynamicMappingDtoOut updateEntityDynamicMapping(@PathVariable Strin @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DRY_RUN_SUCCESS, content = { @Content(schema = @Schema(implementation = EntityDynamicMappingDryRunDtoOut.class))}) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DRY_RUN_TEMPLATE_NOT_FOUND, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = UNPROCESSABLE_CONTENT_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DRY_RUN_VALIDATION_ERROR, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @PostMapping("/dry-run") @ResponseStatus(OK) public EntityDynamicMappingDryRunDtoOut executeDryRun( diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 17e896fe..7ecb6e7b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -18,6 +18,7 @@ import jakarta.validation.constraints.NotBlank; +import org.springframework.http.ProblemDetail; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -32,7 +33,6 @@ import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_graph.EntityGraphFlatDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_graph.EntityGraphFlatDtoOutMapper; -import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -77,7 +77,7 @@ public class EntityGraphController { @ResponseStatus(OK) @Operation(summary = ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY, description = ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION, responses = { @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS, content = @Content(schema = @Schema(implementation = EntityGraphFlatDtoOut.class))), - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class)))}) public EntityGraphFlatDtoOut getEntityGraph(@PathVariable @NotBlank String templateIdentifier, @PathVariable @NotBlank String entityIdentifier, @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java index bd32c898..d5fda59f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java @@ -34,6 +34,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.http.ProblemDetail; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -52,7 +53,6 @@ import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.EntityTemplateDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_template.EntityTemplateMapper; -import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -98,7 +98,7 @@ public class EntityTemplateController { @Operation(summary = ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY, description = ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = TemplatePageResponse.class))) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) @@ -119,7 +119,7 @@ public Page getTemplatesPaginated( @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATE_FOUND, content = { @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @GetMapping("/{identifier}") @ResponseStatus(OK) public EntityTemplateDtoOut getTemplateByIdentifier(@PathVariable String identifier) { @@ -137,7 +137,7 @@ public EntityTemplateDtoOut getTemplateByIdentifier(@PathVariable String identif @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_TEMPLATE_CREATED, content = { @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class))}) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_TEMPLATE_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(schema = @Schema(implementation = ProblemDetail.class))}) @PostMapping @ResponseStatus(CREATED) public EntityTemplateDtoOut createTemplate( @@ -157,7 +157,7 @@ public EntityTemplateDtoOut createTemplate( @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATE_UPDATED, content = { @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class))}) @ApiResponse(responseCode = "404", description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @PutMapping("/{identifier}") public EntityTemplateDtoOut updateTemplate(@PathVariable(name = "identifier") String identifier, @RequestBody @Valid EntityTemplateUpdateDtoIn entityTemplateUpdateDtoIn) { @@ -176,9 +176,9 @@ public EntityTemplateDtoOut updateTemplate(@PathVariable(name = "identifier") St @Operation(summary = ENDPOINT_DELETE_TEMPLATE_SUMMARY, description = ENDPOINT_DELETE_TEMPLATE_DESCRIPTION) @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_TEMPLATE_DELETED) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "Cannot delete template: it is a target for other relations", content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ResponseStatus(NO_CONTENT) @DeleteMapping("/{identifier}") public void deleteTemplate(@PathVariable String identifier) { diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java index 1405f0a8..8d722a27 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java @@ -8,6 +8,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.http.ProblemDetail; import org.springframework.web.bind.annotation.*; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; @@ -17,7 +18,6 @@ import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookUpdateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.webhook.InboundWebhookMapper; -import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -59,7 +59,7 @@ public InboundWebhookDtoOut createInboundWebhook( @Operation(summary = ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_SUMMARY, description = ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = SwaggerConfiguration.WebhookConnectorPageResponse.class))) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) @@ -74,7 +74,7 @@ public Page getWebhooksPaginated( @Operation(summary = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_DESCRIPTION) @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_DELETED) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ResponseStatus(NO_CONTENT) @DeleteMapping("/{identifier}") public void deleteWebhookConnector(@PathVariable String identifier) { @@ -85,7 +85,7 @@ public void deleteWebhookConnector(@PathVariable String identifier) { @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_FOUND, content = { @Content(schema = @Schema(implementation = InboundWebhookDtoOut.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @GetMapping("/{identifier}") @ResponseStatus(OK) public InboundWebhookDtoOut getWebhookConnectorByIdentifier(@PathVariable String identifier) { @@ -97,11 +97,11 @@ public InboundWebhookDtoOut getWebhookConnectorByIdentifier(@PathVariable String @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_UPDATED, content = { @Content(schema = @Schema(implementation = InboundWebhookDtoOut.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "Invalid request payload", content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @ApiResponse(responseCode = CONFLICT_CODE, description = "Webhook connector name already exists", content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))}) @PutMapping("/{identifier}") @ResponseStatus(OK) public InboundWebhookDtoOut putWebhookConnector(@PathVariable String identifier, diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/PrincipalController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/PrincipalController.java index 8faf06d5..b69fbfd7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/PrincipalController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/PrincipalController.java @@ -10,6 +10,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.UNAUTHORIZED_CODE; import static org.springframework.http.HttpStatus.OK; +import org.springframework.http.ProblemDetail; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -73,8 +74,8 @@ Returns the Principal entity of the currently authenticated actor (human or serv @ApiResponse(responseCode = OK_CODE, description = "Principal entity retrieved successfully", content = @Content(mediaType = "application/json", schema = @Schema(implementation = EntityDtoOut.class))) @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = "Principal not found in catalog (JIT provisioning may have failed)", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = "Principal not found in catalog (JIT provisioning may have failed)", content = @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = @Content(mediaType = org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE, schema = @Schema(implementation = ProblemDetail.class))) public EntityDtoOut getCurrentPrincipal(Authentication authentication) { PrincipalInfo principalInfo = principalExtractor.extractPrincipalInfo(authentication); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 7627cd64..934a51a9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -2,413 +2,332 @@ import static org.springframework.http.HttpStatus.NOT_FOUND; -import java.util.List; +import java.time.Instant; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingPathVariableException; -import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityDeletionBlockedException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; -import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.*; -import com.decathlon.idp_core.domain.exception.entity_template.*; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyInUseException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingHasNoPropertiesException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingHasNoRelationsException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingJsltErrorException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.ExpressionEvaluationFailedException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIsRelationTargetException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateUsedByDynamicMappingException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; +import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; import com.decathlon.idp_core.domain.exception.principal.PrincipalNotFoundException; import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; -import com.decathlon.idp_core.domain.exception.webhook.*; -import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; +import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorAlreadyExistException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorTitleAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; import lombok.extern.slf4j.Slf4j; -import tools.jackson.core.JacksonException; -import tools.jackson.databind.exc.InvalidFormatException; -import tools.jackson.databind.exc.MismatchedInputException; - -/// Global exception handler providing centralized error handling for all API -/// endpoints. -/// -/// **Infrastructure error handling strategy:** Intercepts domain and validation -/// exceptions and converts them to appropriate HTTP responses with consistent -/// error formatting. Ensures API consumers receive standardized error messages -/// regardless of internal failures. -/// -/// **Exception mapping approach:** -/// - Domain exceptions → HTTP 404/409 with business-meaningful messages -/// - Validation exceptions → HTTP 400 with field-specific error details -/// - JSON parsing errors → HTTP 400 with user-friendly parsing messages -/// - Generic exceptions → HTTP 500 with safe internal error responses -/// -/// **Error response standardization:** All errors follow consistent -/// [ErrorResponse] format with appropriate HTTP status codes and logged for -/// monitoring/debugging purposes. + @Slf4j -@ControllerAdvice -public class ApiExceptionHandler { +@RestControllerAdvice +public class ApiExceptionHandler extends ResponseEntityExceptionHandler { + + private static final String PROPERTY_ERROR = "error"; + private static final String PROPERTY_ERROR_DESCRIPTION = "error_description"; + private static final String PROPERTY_TIMESTAMP = "timestamp"; + /** + * Prevents direct instantiation because Spring manages this exception handler. + */ private ApiExceptionHandler() { } - /// Handles domain exception when entity templates are not found. - /// - /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 - /// status with business-meaningful error message for API consumers. + /** + * Converts request-body validation errors into a structured bad-request + * response. + */ + @Override + protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex, + HttpHeaders headers, org.springframework.http.HttpStatusCode statusCode, WebRequest request) { + log.warn("Method argument validation error: {}", ex.getMessage()); + String errorMessage = ex.getBindingResult().getFieldErrors().stream() + .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + return ResponseEntity.status(statusCode).headers(headers) + .body(buildProblemDetail(HttpStatus.valueOf(statusCode.value()), errorMessage, request)); + } + + /** + * Reports a required path variable that could not be resolved from the request. + */ + @Override + protected ResponseEntity handleMissingPathVariable(MissingPathVariableException ex, + HttpHeaders headers, org.springframework.http.HttpStatusCode statusCode, WebRequest request) { + log.warn("Missing path variable: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).headers(headers) + .body(buildProblemDetail(HttpStatus.BAD_REQUEST, + "Missing required path variable: " + ex.getVariableName(), request)); + } + + /** Reports requests for which no matching HTTP handler exists. */ + @Override + protected ResponseEntity handleNoHandlerFoundException(NoHandlerFoundException ex, + HttpHeaders headers, org.springframework.http.HttpStatusCode statusCode, WebRequest request) { + log.warn("No handler found or missing path variable: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).headers(headers).body(buildProblemDetail( + HttpStatus.NOT_FOUND, "Malformed request URL or missing path variable.", request)); + } + + /** Maps a missing entity template to an HTTP 404 problem detail. */ @ExceptionHandler(EntityTemplateNotFoundException.class) - public ResponseEntity handleTemplateNotFoundException( - EntityTemplateNotFoundException ex) { + public ProblemDetail handleTemplateNotFoundException(EntityTemplateNotFoundException ex) { log.warn("Template not found: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); + return buildProblemDetail(NOT_FOUND, ex.getMessage(), null); } - /// Handles domain exception for malformed filter query strings (`q=` DSL). - /// - /// **HTTP mapping:** Maps domain [InvalidFilterDslException] to HTTP 400 Bad - /// Request - /// so API consumers receive clear feedback about invalid `q` parameter syntax. + /** Maps an invalid filter expression to an HTTP 400 problem detail. */ @ExceptionHandler(InvalidFilterDslException.class) - public ResponseEntity handleInvalidFilterDslException( - InvalidFilterDslException ex) { + public ProblemDetail handleInvalidFilterDslException(InvalidFilterDslException ex) { log.warn("Invalid filter query: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles domain exception for malformed search filter trees or free-text - /// query strings. - /// - /// **HTTP mapping:** Maps domain [InvalidSearchQueryException] to HTTP 400 Bad - /// Request - /// so API consumers receive clear feedback about invalid search request syntax. + /** Maps an invalid search query to an HTTP 400 problem detail. */ @ExceptionHandler(InvalidSearchQueryException.class) - public ResponseEntity handleInvalidSearchQueryException( - InvalidSearchQueryException ex) { + public ProblemDetail handleInvalidSearchQueryException(InvalidSearchQueryException ex) { log.warn("Invalid search query: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles domain exception when entity templates already exist. - /// - /// **HTTP mapping:** Maps domain EntityTemplateAlreadyExistsException to HTTP - /// 409 status indicating business rule conflict for duplicate identifiers. + /** + * Maps a duplicate entity template identifier to an HTTP 409 problem detail. + */ @ExceptionHandler(EntityTemplateAlreadyExistsException.class) - public ResponseEntity handleEntityTemplateAlreadyExistsException( + public ProblemDetail handleEntityTemplateAlreadyExistsException( EntityTemplateAlreadyExistsException ex) { log.warn("Entity entityTemplateIdentifier already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + return buildProblemDetail(HttpStatus.CONFLICT, ex.getMessage(), null); } - /// Handles domain exception when entity entityTemplateIdentifier names already - /// exist. - /// - /// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to - /// HTTP 409 status indicating business rule conflict for duplicate - /// entityTemplateIdentifier names. + /** Maps a duplicate entity template name to an HTTP 409 problem detail. */ @ExceptionHandler(EntityTemplateNameAlreadyExistsException.class) - public ResponseEntity handleEntityTemplateNameAlreadyExistsException( + public ProblemDetail handleEntityTemplateNameAlreadyExistsException( EntityTemplateNameAlreadyExistsException ex) { log.warn("Entity entityTemplateIdentifier name already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + return buildProblemDetail(HttpStatus.CONFLICT, ex.getMessage(), null); } - /// Handles domain exception when attempting to change an entity - /// entityTemplateIdentifier - /// identifier. - /// - /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException - /// to HTTP 400 status indicating validation error for immutable - /// identifier field. + /** Reports an attempt to change an immutable entity template identifier. */ @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) - public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( + public ProblemDetail handleEntityTemplateIdentifierCannotChangeException( EntityTemplateIdentifierCannotChangeException ex) { log.warn("Entity entityTemplateIdentifier identifier cannot be changed: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles domain exception for wrong entity entityTemplateIdentifier property - /// rules. - /// - /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to - /// HTTP 400 status indicating validation error for wrong property rules. + /** + * Reports conflicting validation rules in an entity template property + * definition. + */ @ExceptionHandler(PropertyDefinitionRulesConflictException.class) - public ResponseEntity handleWrongPropertyRulesException( + public ProblemDetail handleWrongPropertyRulesException( PropertyDefinitionRulesConflictException ex) { log.warn("Wrong Entity entityTemplateIdentifier property rules: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles domain exception when property names are duplicated within a - /// entityTemplateIdentifier. - /// - /// **HTTP mapping:** Maps domain PropertyNameAlreadyExistsException to HTTP 400 - /// status indicating validation error for duplicate property names. + /** Reports a duplicate property name in an entity template. */ @ExceptionHandler(PropertyNameAlreadyExistsException.class) - public ResponseEntity handlePropertyNameAlreadyExistsException( + public ProblemDetail handlePropertyNameAlreadyExistsException( PropertyNameAlreadyExistsException ex) { log.warn("Duplicate property name: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles domain exception when relation names are duplicated within a - /// entityTemplateIdentifier. - /// - /// **HTTP mapping:** Maps domain RelationNameAlreadyExistsException to HTTP 400 - /// status indicating validation error for duplicate relation names. + /** Reports a duplicate relation name in an entity template. */ @ExceptionHandler(RelationNameAlreadyExistsException.class) - public ResponseEntity handleRelationNameAlreadyExistsException( + public ProblemDetail handleRelationNameAlreadyExistsException( RelationNameAlreadyExistsException ex) { log.warn("Duplicate relation name: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles domain exception when a relation references a non-existent target - /// entityTemplateIdentifier. - /// - /// **HTTP mapping:** Maps domain TargetTemplateNotFoundException to HTTP 400 - /// status indicating validation error for missing target - /// entityTemplateIdentifier. + /** Reports a relation whose target entity template does not exist. */ @ExceptionHandler(TargetTemplateNotFoundException.class) - public ResponseEntity handleTargetTemplateNotFoundException( - TargetTemplateNotFoundException ex) { + public ProblemDetail handleTargetTemplateNotFoundException(TargetTemplateNotFoundException ex) { log.warn("Target entityTemplateIdentifier not found: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles domain exception when attempting to delete a template that is still - /// referenced as a relation target in another template. - /// - /// **HTTP mapping:** Maps domain [EntityTemplateIsRelationTargetException] to - /// HTTP 400 Bad Request to signal a business rule violation that must be - /// resolved by the caller before retrying the deletion. + /** + * Reports that an entity template cannot be deleted while targeted by a + * relation. + */ @ExceptionHandler(EntityTemplateIsRelationTargetException.class) - public ResponseEntity handleEntityTemplateIsRelationTargetException( + public ProblemDetail handleEntityTemplateIsRelationTargetException( EntityTemplateIsRelationTargetException ex) { log.warn("Template deletion blocked – still a relation target: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles domain exception when type changes are attempted. - /// - /// **HTTP mapping:** Maps domain PropertyTypeChangeException to HTTP 400 status - /// indicating validation error for type changes. + /** Reports an unsupported change to an entity template property type. */ @ExceptionHandler(PropertyTypeChangeException.class) - public ResponseEntity handleTypeChangeException(PropertyTypeChangeException ex) { + public ProblemDetail handleTypeChangeException(PropertyTypeChangeException ex) { log.warn("Type change error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles domain exception when relation target entityTemplateIdentifier - /// changes are - /// attempted. - /// - /// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP - /// 400 status indicating validation error for immutable target - /// entityTemplateIdentifier field. + /** Reports an unsupported change to the target of an existing relation. */ @ExceptionHandler(RelationTargetTemplateChangeException.class) - public ResponseEntity handleRelationTargetTemplateChangeException( + public ProblemDetail handleRelationTargetTemplateChangeException( RelationTargetTemplateChangeException ex) { log.warn("Relation target entityTemplateIdentifier change error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles domain exception when a relation's target entityTemplateIdentifier - /// identifier is the - /// entityTemplateIdentifier itself. - /// - /// **HTTP mapping:** Maps domain RelationCannotTargetItselfException to HTTP - /// 400 - /// status indicating validation error for self-referential relations. + /** Reports a relation that illegally targets its own entity template. */ @ExceptionHandler(RelationCannotTargetItselfException.class) - public ResponseEntity handleRelationCannotTargetItselfException( + public ProblemDetail handleRelationCannotTargetItselfException( RelationCannotTargetItselfException ex) { log.warn("Relation self-reference error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles validation exceptions from Spring MVC handler method parameters. - /// - /// **Error aggregation:** Combines multiple validation error messages into a - /// single user-friendly response with HTTP 400 status for client correction. - @ExceptionHandler(HandlerMethodValidationException.class) - public ResponseEntity handleHandlerMethodValidationException( - HandlerMethodValidationException ex) { + /** + * Converts method-parameter validation failures into a structured error + * response. + */ + @Override + protected ResponseEntity handleHandlerMethodValidationException( + HandlerMethodValidationException ex, HttpHeaders headers, HttpStatusCode statusCode, + WebRequest request) { log.warn("Handler method validation error: {}", ex.getMessage()); String errorMessage = ex.getAllErrors().stream() .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) .collect(Collectors.joining(", ")); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).headers(headers) + .body(buildProblemDetail(HttpStatus.BAD_REQUEST, errorMessage, request)); } - /// Handles domain exception when entities already exist. - /// - /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 - /// status - /// indicating business rule conflict for duplicate entities. + /** Maps a duplicate entity to an HTTP 409 problem detail. */ @ExceptionHandler(EntityAlreadyExistsException.class) - public ResponseEntity handleEntityAlreadyExistsException( - EntityAlreadyExistsException ex) { + public ProblemDetail handleEntityAlreadyExistsException(EntityAlreadyExistsException ex) { log.warn("Entity already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + return buildProblemDetail(HttpStatus.CONFLICT, ex.getMessage(), null); } - /// Handles domain exception when entity validation fails. - /// - /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status - /// with aggregated validation error messages for client correction. + /** Maps semantic entity validation failures to an HTTP 400 problem detail. */ @ExceptionHandler(EntityValidationException.class) - public ResponseEntity handleEntityValidationException( - EntityValidationException ex) { + public ProblemDetail handleEntityValidationException(EntityValidationException ex) { log.warn("Entity validation failed: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles Spring MVC request body validation failures. - /// - /// **Field-level errors:** Extracts and aggregates field validation errors from - /// request body binding into comprehensive HTTP 400 error response. - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleMethodArgumentNotValidException( - MethodArgumentNotValidException ex) { - log.warn("Method argument validation error: {}", ex.getMessage()); - - String errorMessage = ex.getBindingResult().getFieldErrors().stream() - .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) - .collect(Collectors.joining(", ")); - - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); - } - - /// Handles JSON parsing and deserialization errors from request bodies. - /// - /// **User-friendly parsing:** Converts technical JSON parsing errors into - /// readable messages, especially for enum validation and format issues. - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity handleHttpMessageNotReadableException( - HttpMessageNotReadableException ex) { - log.warn("HTTP message not readable: {}", ex.getMessage()); - - if (ex.getCause()instanceof MismatchedInputException mismatch - && !(ex.getCause() instanceof InvalidFormatException)) { - String fieldPath = extractLastFieldNameFromPath(mismatch.getPath()); - String targetType = extractTargetType(mismatch.getOriginalMessage()); - - if (!targetType.isEmpty() && !fieldPath.isEmpty()) { - return createErrorResponse(HttpStatus.BAD_REQUEST, - "Invalid type for '" + fieldPath + "': expected " + targetType); - } - } - - String errorMessage = parseHttpMessageNotReadableError(ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); - } - - /// Extracts the deepest (last) field name from a Jackson path reference list. - /// - /// Jackson populates `MismatchedInputException.getPath()` with references from - /// the root to the failing field. The last named reference is the most specific - /// field name to show in the error message. - private String extractLastFieldNameFromPath(List path) { - if (path == null || path.isEmpty()) { - return ""; - } - return path.reversed().stream().map(JacksonException.Reference::getPropertyName) - .filter(name -> name != null && !name.isBlank()).findFirst().orElse(""); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles invalid dynamic mapping targetIdentifiersExpressions (JSLT) provided - /// in webhook - /// configuration. - /// - /// **HTTP mapping:** Maps domain mapping configuration failures to HTTP 400, - /// because clients can fix these targetIdentifiersExpressions and retry. + /** Reports an invalid dynamic-mapping configuration. */ @ExceptionHandler(EntityDynamicMappingConfigurationException.class) - public ResponseEntity handleEntityDynamicMappingConfigurationException( + public ProblemDetail handleEntityDynamicMappingConfigurationException( EntityDynamicMappingConfigurationException ex) { log.warn("Invalid entity dynamic mapping configuration: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles JSLT expression evaluation failures (both primary and fallback - /// attempts failed). - /// - /// **HTTP mapping:** Maps to HTTP 422 Unprocessable Entity because the request - /// body is syntactically valid, but the mapping expression cannot be evaluated. + /** Reports a dynamic-mapping expression that could not be evaluated. */ @ExceptionHandler(ExpressionEvaluationFailedException.class) - public ResponseEntity handleExpressionEvaluationFailedException( + public ProblemDetail handleExpressionEvaluationFailedException( ExpressionEvaluationFailedException ex) { log.warn("Expression evaluation failed for '{}': {}", ex.getExpression(), ex.getReason()); - return createErrorResponse(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage()); + return buildProblemDetail(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage(), null); } + /** Reports a JSLT transformation error in a dynamic mapping. */ @ExceptionHandler(EntityDynamicMappingJsltErrorException.class) - public ResponseEntity handleEntityDynamicMappingJsltErrorException( + public ProblemDetail handleEntityDynamicMappingJsltErrorException( EntityDynamicMappingJsltErrorException ex) { log.warn("JSLT error in entity dynamic mapping: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage()); + return buildProblemDetail(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage(), null); } + /** Reports a webhook mapping that references an unknown template property. */ @ExceptionHandler(PropertyNameNotFoundEntityTemplatePropertiesException.class) - public ResponseEntity handlePropertyNameNotFoundEntityTemplatePropertiesException( + public ProblemDetail handlePropertyNameNotFoundEntityTemplatePropertiesException( PropertyNameNotFoundEntityTemplatePropertiesException ex) { log.warn("Webhook mapping references unknown property: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage()); + return buildProblemDetail(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage(), null); } + /** Reports a webhook mapping that references an unknown template relation. */ @ExceptionHandler(RelationNameNotFoundEntityTemplateRelationsException.class) - public ResponseEntity handleRelationNameNotFoundEntityTemplateRelationsException( + public ProblemDetail handleRelationNameNotFoundEntityTemplateRelationsException( RelationNameNotFoundEntityTemplateRelationsException ex) { log.warn("Webhook mapping references unknown relation: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage()); + return buildProblemDetail(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage(), null); } + /** Reports a dynamic mapping that does not define any property mapping. */ @ExceptionHandler(EntityDynamicMappingHasNoPropertiesException.class) - public ResponseEntity handleEntityDynamicMappingHasNoPropertiesException( + public ProblemDetail handleEntityDynamicMappingHasNoPropertiesException( EntityDynamicMappingHasNoPropertiesException ex) { log.warn("Dynamic mapping is missing required properties: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage()); + return buildProblemDetail(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage(), null); } + /** Reports a dynamic mapping that does not define any relation mapping. */ @ExceptionHandler(EntityDynamicMappingHasNoRelationsException.class) - public ResponseEntity handleEntityDynamicMappingHasNoRelationsException( + public ProblemDetail handleEntityDynamicMappingHasNoRelationsException( EntityDynamicMappingHasNoRelationsException ex) { log.warn("Dynamic mapping is missing required relations: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage()); + return buildProblemDetail(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage(), null); } + /** Reports invalid security settings for a webhook connector. */ @ExceptionHandler(WebhookSecurityConfigurationException.class) - public ResponseEntity handleWebhookSecurityConfigurationException( + public ProblemDetail handleWebhookSecurityConfigurationException( WebhookSecurityConfigurationException ex) { log.warn("Invalid webhook security configuration: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + return buildProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), null); } - /// Handles domain exception when entities are not found. - /// - /// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status - /// with - /// specific entity context for API consumers. + /** Maps a missing catalog entity to an HTTP 404 problem detail. */ @ExceptionHandler(EntityNotFoundException.class) - public ResponseEntity handleEntityNotFoundException(EntityNotFoundException ex) { - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); + public ProblemDetail handleEntityNotFoundException(EntityNotFoundException ex) { + return buildProblemDetail(NOT_FOUND, ex.getMessage(), null); } /// Handles principal not found exception when JIT provisioning fails. @@ -416,11 +335,9 @@ public ResponseEntity handleEntityNotFoundException(EntityNotFoun /// **HTTP mapping:** Maps PrincipalNotFoundException to HTTP 404 status /// indicating the authenticated principal does not have a catalog entry. @ExceptionHandler(PrincipalNotFoundException.class) - public ResponseEntity handlePrincipalNotFoundException( - PrincipalNotFoundException ex) { + public ProblemDetail handlePrincipalNotFoundException(PrincipalNotFoundException ex) { log.warn("Principal not found: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); + return buildProblemDetail(NOT_FOUND, ex.getMessage(), null); } /// Handles Bean Validation constraint violations from domain model validation. @@ -428,233 +345,170 @@ public ResponseEntity handlePrincipalNotFoundException( /// **Error aggregation:** Combines multiple constraint violation messages into /// single user-friendly response with HTTP 400 status for client correction. @ExceptionHandler(ConstraintViolationException.class) - public ResponseEntity handleConstraintViolationException( - ConstraintViolationException ex) { + public ProblemDetail handleConstraintViolationException(ConstraintViolationException ex) { log.warn("Validation constraint violation: {}", ex.getMessage()); - String errorMessage = ex.getConstraintViolations().stream().map(ConstraintViolation::getMessage) .collect(Collectors.joining(", ")); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + return buildProblemDetail(HttpStatus.BAD_REQUEST, errorMessage, null); } - /// Handles domain exception when entity deletion is blocked by required - /// relations. - /// - /// **HTTP mapping:** Maps domain EntityDeletionBlockedException to HTTP 409 - /// status indicating business rule conflict where required relations prevent - /// deletion. + + /** Maps a blocked entity deletion to an HTTP 409 problem detail. */ @ExceptionHandler(EntityDeletionBlockedException.class) - public ResponseEntity handleEntityDeletionBlockedException( - EntityDeletionBlockedException ex) { + public ProblemDetail handleEntityDeletionBlockedException(EntityDeletionBlockedException ex) { log.warn("Entity deletion blocked: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.CONFLICT, ex.getMessage()); - } - - /// Handles missing path variables in the request URL. - /// - /// **HTTP mapping:** Maps MissingPathVariableException to HTTP 400 - /// status indicating a malformed request URL from the client. - @ExceptionHandler(MissingPathVariableException.class) - public ResponseEntity handleMissingPathVariableException( - MissingPathVariableException ex) { - log.warn("Missing path variable: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, - "Missing required path variable: " + ex.getVariableName()); + return buildProblemDetail(HttpStatus.CONFLICT, ex.getMessage(), null); } - /// Handles cases where a truncated URL matches no route, often caused by - /// missing trailing path variables. - /// - /// **HTTP mapping:** Maps NoHandlerFoundException to HTTP 400 - /// to align with missing identifier logic and pass integration tests. - @ExceptionHandler(NoHandlerFoundException.class) - public ResponseEntity handleNoHandlerFoundException(NoHandlerFoundException ex) { - log.warn("No handler found or missing path variable: {}", ex.getMessage()); - return createErrorResponse(NOT_FOUND, "Malformed request URL or missing path variable."); - } - - private String parseHttpMessageNotReadableError(String originalMessage) { - if (originalMessage == null) { - return "Invalid request body format"; - } - - if (originalMessage.contains("Cannot deserialize value")) { - return parseDeserializationError(originalMessage); - } else if (originalMessage.contains("Required request body is missing")) { - return "Request body is required"; - } else if (originalMessage.contains("JSON parse error")) { - return "Invalid JSON format in request body"; - } - - return "Invalid request body format"; - } - - private String parseDeserializationError(String originalMessage) { - if (originalMessage.contains("not one of the values accepted for Enum class")) { - return parseEnumDeserializationError(originalMessage); - } - return parseTypeDeserializationError(originalMessage); - } - - private String parseTypeDeserializationError(String originalMessage) { - String targetType = extractTargetType(originalMessage); - String invalidValue = extractInvalidValueFromString(originalMessage); - - if (!targetType.isEmpty() && !invalidValue.isEmpty()) { - return "Invalid value '" + invalidValue + "' for property, expected " + targetType; - } else if (!targetType.isEmpty()) { - return "Invalid type: expected " + targetType; - } - return "Cannot deserialize request body property"; - } - - private String extractTargetType(String message) { - Pattern typePattern = Pattern.compile("Cannot deserialize value of type `([^`]+)`"); - Matcher matcher = typePattern.matcher(message); - if (matcher.find()) { - String fullType = matcher.group(1); - String rawType = fullType.contains("<") - ? fullType.substring(0, fullType.indexOf('<')) - : fullType; - return rawType.substring(rawType.lastIndexOf('.') + 1); - } - return ""; - } - - private String extractInvalidValueFromString(String message) { - Pattern valuePattern = Pattern.compile("from String \"([^\"]+)\""); - Matcher matcher = valuePattern.matcher(message); - if (matcher.find()) { - return matcher.group(1); - } - return ""; - } - - private String parseEnumDeserializationError(String originalMessage) { - String enumTypeName = getPropertyNameFromEnumType(originalMessage); - String invalidValue = extractInvalidValueFromString(originalMessage); - - if (!enumTypeName.isEmpty() && !invalidValue.isEmpty()) { - return "Invalid value '" + invalidValue + "' for property '" + enumTypeName + "'"; - } else if (!enumTypeName.isEmpty()) { - return "Invalid value for property '" + enumTypeName + "'"; - } - return "Invalid enum value in request body"; - } - - private static final Map ENUM_TYPE_TO_PROPERTY = Map.of("PropertyType", "type", - "PropertyFormat", "format"); - - private static final Pattern ENUM_CLASS_PATTERN = Pattern - .compile("Cannot deserialize value of type `(?:[\\w.]+\\.)?(\\w+)`"); - - private String getPropertyNameFromEnumType(String message) { - Matcher matcher = ENUM_CLASS_PATTERN.matcher(message); - if (matcher.find()) { - String enumType = matcher.group(1); - return ENUM_TYPE_TO_PROPERTY.getOrDefault(enumType, ""); - } - return ""; - } - - /// Handles all unexpected exceptions as safety fallback. - /// - /// **Security consideration:** Returns generic error message to prevent - /// information leakage while logging full exception details for - /// internal debugging. - @ExceptionHandler(Exception.class) - public ResponseEntity handleGenericException(Exception ex) { - log.error("Unexpected error occurred: {}", ex.getMessage(), ex); - - String errorMessage = "An unexpected error occurred. Please try again later."; - return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage); - } - - /// Handles webhook signature and credential validation failures. - /// - /// HTTP mapping: Maps WebhookAuthenticationException to HTTP 401 Unauthorized. + /** Maps failed webhook authentication to an HTTP 401 problem detail. */ @ExceptionHandler(WebhookAuthenticationException.class) - public ResponseEntity handleWebhookAuthenticationException( - WebhookAuthenticationException ex) { + public ProblemDetail handleWebhookAuthenticationException(WebhookAuthenticationException ex) { log.warn("Webhook authentication failed: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.UNAUTHORIZED.name(), - ex.getMessage()); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse); + return buildProblemDetail(HttpStatus.UNAUTHORIZED, ex.getMessage(), null); } - /// Handles missing webhook connector configuration. - /// - /// HTTP mapping: Maps WebhookConnectorNotFoundException to HTTP 404 Not Found. + /** Maps a missing webhook connector to an HTTP 404 problem detail. */ @ExceptionHandler(WebhookConnectorNotFoundException.class) - public ResponseEntity handleWebhookConnectorNotFoundException( + public ProblemDetail handleWebhookConnectorNotFoundException( WebhookConnectorNotFoundException ex) { log.warn("Webhook connector not found: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); + return buildProblemDetail(NOT_FOUND, ex.getMessage(), null); } - /// Handles a webhook connector referencing a non-existent entity dynamic - /// mapping. - /// - /// HTTP mapping: Maps EntityDynamicMappingNotFoundException to HTTP 404 Not - /// Found, because the referenced mapping must be created beforehand. + /** Maps a missing entity dynamic mapping to an HTTP 404 problem detail. */ @ExceptionHandler(EntityDynamicMappingNotFoundException.class) - public ResponseEntity handleEntityDynamicMappingNotFoundException( + public ProblemDetail handleEntityDynamicMappingNotFoundException( EntityDynamicMappingNotFoundException ex) { log.warn("Referenced entity dynamic mapping not found: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); + return buildProblemDetail(NOT_FOUND, ex.getMessage(), null); } - /// Handles creation of a dynamic mapping whose identifier already exists. - /// - /// HTTP mapping: Maps EntityDynamicMappingAlreadyExistsException to HTTP 409 - /// Conflict, surfacing the uniqueness violation with business meaning. + /** Maps a duplicate entity dynamic mapping to an HTTP 409 problem detail. */ @ExceptionHandler(EntityDynamicMappingAlreadyExistsException.class) - public ResponseEntity handleEntityDynamicMappingAlreadyExistsException( + public ProblemDetail handleEntityDynamicMappingAlreadyExistsException( EntityDynamicMappingAlreadyExistsException ex) { log.warn("Entity dynamic mapping identifier conflict: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + return buildProblemDetail(HttpStatus.CONFLICT, ex.getMessage(), null); } + /** Reports an entity dynamic mapping that cannot be changed while in use. */ @ExceptionHandler(EntityDynamicMappingAlreadyInUseException.class) - public ResponseEntity handleEntityDynamicMappingAlreadyInUseException( + public ProblemDetail handleEntityDynamicMappingAlreadyInUseException( EntityDynamicMappingAlreadyInUseException ex) { log.warn("Entity dynamic mapping already in use: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + return buildProblemDetail(HttpStatus.CONFLICT, ex.getMessage(), null); } - /// Handles webhook connector identifier duplication conflicts. + /** + * Maps a duplicate webhook connector identifier to an HTTP 409 problem detail. + */ @ExceptionHandler(WebhookConnectorAlreadyExistException.class) - public ResponseEntity handleWebhookConnectorAlreadyExistException( + public ProblemDetail handleWebhookConnectorAlreadyExistException( WebhookConnectorAlreadyExistException ex) { log.warn("Webhook connector identifier conflict: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + return buildProblemDetail(HttpStatus.CONFLICT, ex.getMessage(), null); } + /** + * Maps an entity template used by a dynamic mapping to an HTTP 409 problem + * detail. + */ @ExceptionHandler(EntityTemplateUsedByDynamicMappingException.class) - public ResponseEntity handleTemplateAlreadyMappedInWebhookConfiguration( + public ProblemDetail handleTemplateAlreadyMappedInWebhookConfiguration( EntityTemplateUsedByDynamicMappingException ex) { log.warn("Entity entityTemplateIdentifier in use by webhook mapping conflict: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + return buildProblemDetail(HttpStatus.CONFLICT, ex.getMessage(), null); } - /// Handles webhook connector name duplication conflicts. + /** Maps a duplicate webhook connector title to an HTTP 409 problem detail. */ @ExceptionHandler(WebhookConnectorTitleAlreadyExistsException.class) - public ResponseEntity handleWebhookConnectorTitleAlreadyExistsException( + public ProblemDetail handleWebhookConnectorTitleAlreadyExistsException( WebhookConnectorTitleAlreadyExistsException ex) { log.warn("Webhook connector name conflict: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + return buildProblemDetail(HttpStatus.CONFLICT, ex.getMessage(), null); } - private static ResponseEntity createErrorResponse(HttpStatus httpStatus, - String errorMessage) { - return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus); + /** Converts database integrity conflicts into a safe HTTP 409 response. */ + @ExceptionHandler(org.springframework.dao.DataIntegrityViolationException.class) + public ProblemDetail handleDataIntegrityViolationException(DataIntegrityViolationException ex) { + log.warn("Data integrity violation: {}", ex.getMostSpecificCause().getMessage()); + + return buildProblemDetail(HttpStatus.CONFLICT, + "The request conflicts with the current state of the resource", null); } + /** + * Converts unexpected failures into a safe response without exposing internal + * details. + */ + @ExceptionHandler(Exception.class) + public ProblemDetail handleGenericException(Exception ex) { + log.error("Unexpected error occurred: {}", ex.getMessage(), ex); + + return buildProblemDetail(HttpStatus.INTERNAL_SERVER_ERROR, + "An unexpected error occurred. Please try again later.", null); + } + + /** + * Adds the API error contract fields to framework-generated problem details. + */ + @Override + protected ResponseEntity createResponseEntity(Object body, HttpHeaders headers, + HttpStatusCode statusCode, WebRequest request) { + if (body instanceof ProblemDetail problemDetail) { + enrichProblemDetail(problemDetail, statusCode, request); + } + return super.createResponseEntity(body, headers, statusCode, request); + } + + private void enrichProblemDetail(ProblemDetail problemDetail, HttpStatusCode statusCode, + WebRequest request) { + Map properties = problemDetail.getProperties(); + if (properties == null || !properties.containsKey(PROPERTY_ERROR)) { + HttpStatus status = HttpStatus.resolve(statusCode.value()); + problemDetail.setProperty(PROPERTY_ERROR, + status != null ? status.name() : Integer.toString(statusCode.value())); + } + if (properties == null || !properties.containsKey(PROPERTY_ERROR_DESCRIPTION)) { + problemDetail.setProperty(PROPERTY_ERROR_DESCRIPTION, problemDetail.getDetail()); + } + if (properties == null || !properties.containsKey(PROPERTY_TIMESTAMP)) { + problemDetail.setProperty(PROPERTY_TIMESTAMP, Instant.now()); + } + if (problemDetail.getInstance() == null + && request instanceof ServletWebRequest servletRequest) { + problemDetail.setInstance(java.net.URI.create(servletRequest.getRequest().getRequestURI())); + } + } + + /** + * Builds the common RFC 9457 problem-detail representation returned by this + * handler. + */ + private ProblemDetail buildProblemDetail(HttpStatus status, String detail, WebRequest request) { + ProblemDetail problemDetail = ProblemDetail.forStatus(status); + problemDetail.setTitle(status.getReasonPhrase()); + problemDetail.setDetail(detail); + String path = resolveRequestPath(request); + if (path != null) { + problemDetail.setInstance(java.net.URI.create(path)); + } + problemDetail.setProperty(PROPERTY_ERROR, status.name()); + problemDetail.setProperty(PROPERTY_ERROR_DESCRIPTION, detail); + problemDetail.setProperty(PROPERTY_TIMESTAMP, Instant.now()); + return problemDetail; + } + + /** + * Resolves the request path from the given request or, for handlers that do not + * receive one, from the current servlet request context. + */ + private static String resolveRequestPath(WebRequest request) { + WebRequest effective = request; + if (effective == null + && RequestContextHolder.getRequestAttributes()instanceof ServletRequestAttributes attrs) { + effective = new ServletWebRequest(attrs.getRequest()); + } + return effective == null ? null : effective.getDescription(false).replace("uri=", ""); + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfigurationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfigurationTest.java new file mode 100644 index 00000000..523b67ec --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfigurationTest.java @@ -0,0 +1,157 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.configuration; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.LinkedHashMap; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.security.SecurityScheme; + +class SwaggerConfigurationTest { + + private final SwaggerConfiguration configuration = new SwaggerConfiguration( + "https://identity.example/token", "https://idp.example", true); + + @Test + void shouldDocumentProblemDetailExtensionsAtRootAndPreserveStandardFields() { + Schema schema = new ObjectSchema().addProperty("detail", new StringSchema()) + .addProperty("instance", new StringSchema().format("uri")) + .addProperty("properties", new ObjectSchema()); + OpenAPI openApi = new OpenAPI() + .components(new Components().addSchemas("ProblemDetail", schema)); + + configuration.problemDetailSchemaCustomizer().customise(openApi); + + assertFalse(schema.getProperties().containsKey("properties")); + assertEquals("string", schema.getProperties().get("detail").getType()); + assertEquals("uri", schema.getProperties().get("instance").getFormat()); + assertEquals("string", schema.getProperties().get("error").getType()); + assertEquals("string", schema.getProperties().get("error_description").getType()); + assertEquals("string", schema.getProperties().get("timestamp").getType()); + assertEquals("date-time", schema.getProperties().get("timestamp").getFormat()); + assertEquals(5, schema.getProperties().size()); + } + + @Test + void shouldAddExtensionsWhenProblemDetailHasNoProperties() { + Schema schema = new ObjectSchema(); + OpenAPI openApi = new OpenAPI() + .components(new Components().addSchemas("ProblemDetail", schema)); + + configuration.problemDetailSchemaCustomizer().customise(openApi); + + assertEquals(3, schema.getProperties().size()); + assertTrue(schema.getProperties().keySet() + .containsAll(List.of("error", "error_description", "timestamp"))); + } + + @Test + void shouldLeaveDocumentWithoutComponentsUnchanged() { + OpenAPI openApi = new OpenAPI(); + + configuration.problemDetailSchemaCustomizer().customise(openApi); + + assertNull(openApi.getComponents()); + } + + @Test + void shouldLeaveComponentsWithoutSchemasUnchanged() { + OpenAPI openApi = new OpenAPI().components(new Components()); + + configuration.problemDetailSchemaCustomizer().customise(openApi); + + assertNull(openApi.getComponents().getSchemas()); + } + + @Test + void shouldLeaveOtherSchemasUnchangedWhenProblemDetailIsAbsent() { + Schema schema = new ObjectSchema().addProperty("name", new StringSchema()); + OpenAPI openApi = new OpenAPI().components(new Components().addSchemas("Entity", schema)); + + configuration.problemDetailSchemaCustomizer().customise(openApi); + + assertEquals(1, openApi.getComponents().getSchemas().size()); + assertSame(schema, openApi.getComponents().getSchemas().get("Entity")); + assertEquals(1, schema.getProperties().size()); + } + + @Test + void shouldHandleEmptySchemas() { + OpenAPI openApi = new OpenAPI().components(new Components().schemas(new LinkedHashMap<>())); + + configuration.problemDetailSchemaCustomizer().customise(openApi); + + assertTrue(openApi.getComponents().getSchemas().isEmpty()); + } + + @Test + void shouldKeepCustomizationIdempotent() { + Schema schema = new ObjectSchema(); + OpenAPI openApi = new OpenAPI() + .components(new Components().addSchemas("ProblemDetail", schema)); + + configuration.problemDetailSchemaCustomizer().customise(openApi); + configuration.problemDetailSchemaCustomizer().customise(openApi); + + assertEquals(3, schema.getProperties().size()); + assertEquals("date-time", schema.getProperties().get("timestamp").getFormat()); + } + + @Test + void shouldConfigureApiMetadataAndBothAuthenticationSchemes() { + OpenAPI openApi = configuration.openAPI(); + + assertEquals("Idp core API", openApi.getInfo().getTitle()); + assertEquals("v1", openApi.getInfo().getVersion()); + assertEquals("https://idp.example", openApi.getServers().getFirst().getUrl()); + var schemes = openApi.getComponents().getSecuritySchemes(); + assertEquals(SecurityScheme.Type.OAUTH2, schemes.get("clientId").getType()); + assertEquals("https://identity.example/token", + schemes.get("clientId").getFlows().getClientCredentials().getTokenUrl()); + assertEquals(SecurityScheme.Type.HTTP, schemes.get("bearer").getType()); + assertEquals("bearer", schemes.get("bearer").getScheme()); + assertEquals("JWT", schemes.get("bearer").getBearerFormat()); + assertEquals(2, openApi.getSecurity().size()); + assertTrue(openApi.getSecurity().get(0).containsKey("clientId")); + assertTrue(openApi.getSecurity().get(1).containsKey("bearer")); + } + + @Test + void shouldOmitSecurityWhenAuthenticationIsDisabled() { + OpenAPI openApi = new SwaggerConfiguration(" ", "http://localhost:8080", false).openAPI(); + + assertNull(openApi.getComponents()); + assertNull(openApi.getSecurity()); + } + + @Test + void shouldConfigureInternalApiGroup() { + var group = configuration.allApis(); + + assertEquals("internal", group.getGroup()); + assertEquals(List.of("/**"), group.getPathsToMatch()); + } + + @Test + void shouldConstructPaginatedResponseModels() { + var pageable = PageRequest.of(0, 20); + var pages = List.of(new SwaggerConfiguration.TemplatePageResponse(List.of(), pageable, 0), + new SwaggerConfiguration.EntityPageResponse(List.of(), pageable, 0), + new SwaggerConfiguration.WebhookConnectorPageResponse(List.of(), pageable, 0), + new SwaggerConfiguration.EntityDynamicMappingPageResponse(List.of(), pageable, 0)); + + for (var page : pages) { + assertTrue(page.getContent().isEmpty()); + assertEquals(pageable, page.getPageable()); + assertEquals(0, page.getTotalElements()); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 0ec4a066..f1579210 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -4,6 +4,7 @@ import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.hasSize; import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -74,8 +75,16 @@ void getEntities_paginated_200() throws Exception { @DisplayName("Should return 404 when template does not exist") @WithMockUser void getEntities_paginated_404_when_non_existent_template() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") - .accept(APPLICATION_JSON)).andExpect(status().isNotFound()); + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") + .accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(content().contentType(APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.title").value("Not Found")) + .andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()) + .andExpect(jsonPath("$.timestamp").exists()); } @Test @@ -199,8 +208,14 @@ void getEntities_400_malformedQuery() throws Exception { mockMvc .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) .param("q", "noOperator").accept(APPLICATION_JSON)) - .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description") - .value("Invalid query format, expected field:operator:value")); + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.title").value("Bad Request")) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description") + .value("Invalid query format, expected field:operator:value")) + .andExpect(jsonPath("$.timestamp").exists()); } @Test diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java index c2c18cf7..f45eea81 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java @@ -3,6 +3,7 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.*; import static org.hamcrest.Matchers.containsString; import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -317,7 +318,7 @@ void postMapping_400_missing_required_properties() throws Exception { @Test @WithMockUser - @DisplayName("Should return 400 with explicit message when target_entity_identifiers is a plain string instead of an array") + @DisplayName("Should return a native 400 problem detail when target_entity_identifiers is a string instead of an array") void postMapping_400_target_entity_identifiers_is_string() throws Exception { var payload = """ { @@ -344,10 +345,15 @@ void postMapping_400_target_entity_identifiers_is_string() throws Exception { mockMvc .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) .accept(APPLICATION_JSON).with(csrf()).content(payload)) - .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect( - jsonPath("$.error_description").value(containsString("target_entity_identifiers"))) - .andExpect(jsonPath("$.error_description").value(containsString("expected"))); + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.title").value("Bad Request")) + .andExpect(jsonPath("$.detail").value("Failed to read request")) + .andExpect(jsonPath("$.instance").value(MAPPING_PATH)) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value("Failed to read request")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()); } @Test @@ -1390,7 +1396,7 @@ void dryRunMapping_422_with_expression_evaluation_failed_exception() throws Exce .perform(MockMvcRequestBuilders.post(MAPPING_PATH + "/dry-run") .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) .andExpect(status().isUnprocessableContent()) - .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(content().contentType(APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.error").value("UNPROCESSABLE_CONTENT")) .andExpect(jsonPath("$.error_description") .value(containsString("Expression evaluation failed"))); diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index d9833076..33ced4b0 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -5,6 +5,7 @@ import static org.hamcrest.Matchers.containsString; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -382,7 +383,15 @@ void postTemplate_409_identifier_already_exists() throws Exception { .accept(APPLICATION_JSON).with(csrf()) .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_409_identifier_already_exists.json"))) - .andExpect(status().isConflict()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(status().isConflict()) + .andExpect(content().contentType(APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.status").value(409)) + .andExpect(jsonPath("$.title").value("Conflict")) + .andExpect(jsonPath("$.detail").value(TEMPLATE_ALREADY_EXISTS + ":web-service")) + .andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect( + jsonPath("$.error_description").value(TEMPLATE_ALREADY_EXISTS + ":web-service")) + .andExpect(jsonPath("$.timestamp").exists()) .andExpect(jsonPath("$.error_description").exists()).andExpect( jsonPath("$.error_description").value(TEMPLATE_ALREADY_EXISTS + ":web-service")); } @@ -397,7 +406,7 @@ void postTemplate_409_identifier_already_exists() throws Exception { void postTemplate_400_property_type_invalid_enum() throws Exception { MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_bad_property_type.json", - "Invalid value 'NOT IN ENUM' for property 'type'"); + "Failed to read request"); assertNotNull(res, "Test executed successfully"); } @@ -411,7 +420,7 @@ void postTemplate_400_property_type_invalid_enum() throws Exception { void postTemplate_400_property_format_invalid_enum() throws Exception { MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_bad_property_format.json", - "Invalid value 'NOT A VALID FORMAT' for property 'format'"); + "Failed to read request"); assertNotNull(res, "Test executed successfully"); } @@ -758,8 +767,9 @@ void putTemplate_404_withUnknownIdentifier() throws Exception { .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) .content(getJsonTestFileContent( "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) - .andExpect(status().isNotFound()).andExpect(content().string( - "{\"error\":\"NOT_FOUND\",\"error_description\":\"Template not found with identifier: unknown-identifier\"}")); + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description") + .value("Template not found with identifier: unknown-identifier")); } @Test @@ -770,8 +780,9 @@ void putTemplate_400_propertyNameIsMissing() throws Exception { .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) .content(getJsonTestFileContent( "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsMissing.json"))) - .andExpect(status().isBadRequest()).andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description") + .value("Property name is mandatory and cannot be blank")); } @Test @@ -782,8 +793,9 @@ void putTemplate_400_propertyNameIsBlank() throws Exception { .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) .content(getJsonTestFileContent( "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsBlank.json"))) - .andExpect(status().isBadRequest()).andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description") + .value("Property name is mandatory and cannot be blank")); } @Test @@ -794,8 +806,9 @@ void putTemplate_400_propertyDescriptionIsBlank() throws Exception { .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) .content(getJsonTestFileContent( "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsBlank.json"))) - .andExpect(status().isBadRequest()).andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description") + .value("Property description is mandatory and cannot be blank")); } @Test @@ -806,8 +819,9 @@ void putTemplate_400_propertyDescriptionIsMissing() throws Exception { .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) .content(getJsonTestFileContent( "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsMissing.json"))) - .andExpect(status().isBadRequest()).andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description") + .value("Property description is mandatory and cannot be blank")); } @Test @@ -818,8 +832,8 @@ void putTemplate_400_propertyTypeIsMissing() throws Exception { .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) .content(getJsonTestFileContent( "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyTypeIsMissing.json"))) - .andExpect(status().isBadRequest()).andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property type is mandatory\"}")); + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value("Property type is mandatory")); } @Test @@ -833,8 +847,9 @@ void putTemplate_409_whenIdentifierAlreadyExists() throws Exception { .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) .content(getJsonTestFileContent( "integration_test/json/entity-template/v1/putEntityTemplate_409_withIdentifierAlreadyExists.json"))) - .andExpect(status().isConflict()).andExpect(content().string( - "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description") + .value("The entity template name Microservice already exists")); } /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when the name @@ -891,8 +906,9 @@ void putTemplate_409_name_already_exists() throws Exception { .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) .content(getJsonTestFileContent( "integration_test/json/entity-template/v1/putEntityTemplate_409_name_already_exists.json"))) - .andExpect(status().isConflict()).andExpect(content().string( - "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description") + .value("The entity template name Microservice already exists")); } /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field @@ -1037,7 +1053,8 @@ void deleteTemplate_404_not_found() throws Exception { mockMvc .perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + nonExistentId) .accept(APPLICATION_JSON).with(csrf())) - .andExpect(status().isNotFound()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(content().contentType(APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.error").value("NOT_FOUND")) .andExpect(jsonPath("$.error_description").exists()); @@ -1060,7 +1077,8 @@ void deleteTemplate_400_is_relation_target() throws Exception { mockMvc .perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) .accept(APPLICATION_JSON).with(csrf())) - .andExpect(status().isBadRequest()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.error").value("BAD_REQUEST")) .andExpect(jsonPath("$.error_description").exists()); diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index f29c7213..4e38623d 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -1,651 +1,619 @@ package com.decathlon.idp_core.infrastructure.adapters.api.handler; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.lang.reflect.Constructor; +import java.net.URI; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.Set; -import java.util.stream.Stream; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.context.MessageSourceResolvable; import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.annotation.ExceptionHandlerMethodResolver; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityDeletionBlockedException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyInUseException; import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException; import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingHasNoPropertiesException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingHasNoRelationsException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingJsltErrorException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.ExpressionEvaluationFailedException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIsRelationTargetException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateUsedByDynamicMappingException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; +import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; +import com.decathlon.idp_core.domain.exception.principal.PrincipalNotFoundException; +import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorAlreadyExistException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorTitleAlreadyExistsException; import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; -import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; -/// Comprehensive unit tests for [ApiExceptionHandler]. -/// -/// Tests all exception handler methods and utility functions to ensure proper -/// error handling and response formatting across the API layer. -@DisplayName("ApiExceptionHandler Tests") class ApiExceptionHandlerTest { private ApiExceptionHandler exceptionHandler; @BeforeEach void setUp() throws Exception { - // Use reflection to create instance since constructor is private Constructor constructor = ApiExceptionHandler.class .getDeclaredConstructor(); constructor.setAccessible(true); exceptionHandler = constructor.newInstance(); } - @Nested - @DisplayName("Domain Exception Handling") - class DomainExceptionTests { - - /// Tests the handling of [EntityTemplateNotFoundException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateNotFoundException is properly caught and handled - /// - HTTP 404 Not Found status is returned - /// - Error response contains the correct error status and description - /// - Original exception message is preserved in the response - @Test - @DisplayName("Should handle EntityTemplateNotFoundException with 404 status") - void shouldHandleEntityTemplateNotFoundException() { - // Given - String errorMessage = "Template with ID 'test-id' not found"; - EntityTemplateNotFoundException exception = new EntityTemplateNotFoundException(errorMessage); - - // When - ResponseEntity response = exceptionHandler - .handleTemplateNotFoundException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); - assertEquals(errorMessage, body.getErrorDescription()); - } + /** + * Creates a mocked {@link WebRequest} with a stubbed description, matching the + * framework's non-null contract for {@code getDescription(boolean)}. + */ + private static WebRequest mockWebRequest() { + WebRequest request = mock(WebRequest.class); + when(request.getDescription(false)).thenReturn("uri=/api/v1/test"); + return request; + } - /// Tests the handling of [EntityTemplateAlreadyExistsException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the correct error status and formatted description - /// - Exception message is properly formatted with validation constants - @Test - @DisplayName("Should handle EntityTemplateAlreadyExistsException with 409 status") - void shouldHandleEntityTemplateAlreadyExistsException() { - // Given - String identifier = "duplicate-id"; - EntityTemplateAlreadyExistsException exception = new EntityTemplateAlreadyExistsException( - identifier); - String expectedMessage = "An Entity Template already exists with the same identifier:duplicate-id"; - - // When - ResponseEntity response = exceptionHandler - .handleEntityTemplateAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(expectedMessage, body.getErrorDescription()); - } + @Test + void shouldHandleEntityTemplateNotFoundException() { + ProblemDetail body = exceptionHandler + .handleTemplateNotFoundException(new EntityTemplateNotFoundException("missing")); + assertProblemDetail(body, HttpStatus.NOT_FOUND, "missing", "NOT_FOUND"); + } - /// Tests the handling of [EntityAlreadyExistsException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the original domain exception message - @Test - @DisplayName("Should handle EntityAlreadyExistsException with 409 status") - void shouldHandleEntityAlreadyExistsException() { - // Given - EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", - "api-gateway"); - - // When - ResponseEntity response = exceptionHandler - .handleEntityAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } + @Test + void shouldHandleEntityTemplateAlreadyExistsException() { + ProblemDetail body = exceptionHandler.handleEntityTemplateAlreadyExistsException( + new EntityTemplateAlreadyExistsException("duplicate-id")); + assertProblemDetail(body, HttpStatus.CONFLICT, + "An Entity Template already exists with the same identifier:duplicate-id", "CONFLICT"); + } - @Test - @DisplayName("Should handle EntityValidationException with 400 status") - void shouldHandleEntityValidationException() { - EntityValidationException exception = new EntityValidationException( - java.util.List.of("Invalid property")); - - ResponseEntity response = exceptionHandler - .handleEntityValidationException(exception); - - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } + @Test + void shouldHandleEntityAlreadyExistsException() { + ProblemDetail body = exceptionHandler + .handleEntityAlreadyExistsException(new EntityAlreadyExistsException("a", "b")); + assertProblemDetail(body, HttpStatus.CONFLICT, + "Entity with name 'b' already exists for template 'a'", "CONFLICT"); + } - @Nested - @DisplayName("Validation Exception Handling") - class ValidationExceptionTests { - - @Test - @DisplayName("Should handle EntityDynamicMappingConfigurationException with 400 status") - void shouldHandleEntityDynamicMappingConfigurationException() { - String details = "Syntax Error in 'properties.deployment_id': Parse error"; - EntityDynamicMappingConfigurationException exception = new EntityDynamicMappingConfigurationException( - details); - - ResponseEntity response = exceptionHandler - .handleEntityDynamicMappingConfigurationException(exception); - - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals(details, body.getErrorDescription()); - } - - @Test - @DisplayName("Should handle PropertyNameNotFoundEntityTemplatePropertiesException with 400 status") - void shouldHandlePropertyNameNotFoundEntityTemplatePropertiesException() { - String details = "Property name additionalProp3 not found in entity entityTemplateIdentifier properties"; - PropertyNameNotFoundEntityTemplatePropertiesException exception = new PropertyNameNotFoundEntityTemplatePropertiesException( - details); - - ResponseEntity response = exceptionHandler - .handlePropertyNameNotFoundEntityTemplatePropertiesException(exception); - - assertNotNull(response); - assertEquals(HttpStatus.UNPROCESSABLE_CONTENT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.UNPROCESSABLE_CONTENT.name(), body.getError()); - assertEquals(details, body.getErrorDescription()); - } - - @Test - @DisplayName("Should handle RelationNameNotFoundEntityTemplateRelationsException with 400 status") - void shouldHandleRelationNameNotFoundEntityTemplateRelationsException() { - // Given - String details = "Relation name github_repository not found in entity entityTemplateIdentifier relations"; - RelationNameNotFoundEntityTemplateRelationsException exception = new RelationNameNotFoundEntityTemplateRelationsException( - details); - - // When - ResponseEntity response = exceptionHandler - .handleRelationNameNotFoundEntityTemplateRelationsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.UNPROCESSABLE_CONTENT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.UNPROCESSABLE_CONTENT.name(), body.getError()); - assertEquals(details, body.getErrorDescription()); - } - - @Test - @DisplayName("Should handle EntityDynamicMappingHasNoPropertiesException with 400 status") - void shouldHandleEntityDynamicMappingHasNoPropertiesException() { - String details = "The mapping defines properties but the target entityTemplateIdentifier has no property definitions"; - EntityDynamicMappingHasNoPropertiesException exception = new EntityDynamicMappingHasNoPropertiesException( - details); - - ResponseEntity response = exceptionHandler - .handleEntityDynamicMappingHasNoPropertiesException(exception); - - assertNotNull(response); - assertEquals(HttpStatus.UNPROCESSABLE_CONTENT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.UNPROCESSABLE_CONTENT.name(), body.getError()); - assertEquals(details, body.getErrorDescription()); - } - - @Test - @DisplayName("Should handle WebhookSecurityConfigurationException with 400 status") - void shouldHandleWebhookSecurityConfigurationException() { - String details = "Webhook security type is mandatory"; - WebhookSecurityConfigurationException exception = new WebhookSecurityConfigurationException( - details); - - ResponseEntity response = exceptionHandler - .handleWebhookSecurityConfigurationException(exception); - - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals(details, body.getErrorDescription()); - } - - /// Tests the handling of [ConstraintViolationException] with a single - /// validation violation. - /// - /// **This test verifies that:** - /// - ConstraintViolationException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Single violation message is correctly extracted and returned - /// - Error response format matches expected structure - @Test - @DisplayName("Should handle ConstraintViolationException with single violation") - void shouldHandleConstraintViolationExceptionSingleViolation() { - // Given - ConstraintViolation violation = createMockConstraintViolation( - "Field must not be null"); - Set> violations = Set.of(violation); - ConstraintViolationException exception = new ConstraintViolationException( - "Validation failed", violations); - - // When - ResponseEntity response = exceptionHandler - .handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals("Field must not be null", body.getErrorDescription()); - } - - /// Tests the handling of [ConstraintViolationException] with multiple - /// validation violations. - /// - /// **This test verifies that:** - /// - ConstraintViolationException with multiple violations is properly handled - /// - HTTP 400 Bad Request status is returned - /// - All violation messages are concatenated with comma separation - /// - Error response contains all validation error messages - @Test - @DisplayName("Should handle ConstraintViolationException with multiple violations") - void shouldHandleConstraintViolationExceptionMultipleViolations() { - // Given - ConstraintViolation violation1 = createMockConstraintViolation( - "Field1 must not be null"); - ConstraintViolation violation2 = createMockConstraintViolation( - "Field2 must not be blank"); - Set> violations = Set.of(violation1, violation2); - ConstraintViolationException exception = new ConstraintViolationException( - "Validation failed", violations); - - // When - ResponseEntity response = exceptionHandler - .handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 must not be null")); - assertTrue(errorDescription.contains("Field2 must not be blank")); - assertTrue(errorDescription.contains(", ")); - } - - /// Tests the handling of [MethodArgumentNotValidException] with field - /// validation errors. - /// - /// **This test verifies that:** - /// - MethodArgumentNotValidException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Field error messages from binding result are extracted and concatenated - /// - All field validation errors are included in the response with comma - /// separation - /// - /// @throws Exception if reflection fails during test setup - @Test - @DisplayName("Should handle MethodArgumentNotValidException with field errors") - void shouldHandleMethodArgumentNotValidException() throws Exception { - // Given - Object target = new Object(); - BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, - "testObject"); - bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); - bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); - - // Create a proper MethodParameter mock with required methods - MethodParameter methodParameter = mock(MethodParameter.class); - when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); - - MethodArgumentNotValidException exception = new MethodArgumentNotValidException( - methodParameter, bindingResult); - - // When - ResponseEntity response = exceptionHandler - .handleMethodArgumentNotValidException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 is required")); - assertTrue(errorDescription.contains("Field2 must be valid")); - assertTrue(errorDescription.contains(", ")); - } - - // Helper method for mocking - public void testMethod() { - // Empty method for testing purposes - } - - @SuppressWarnings("unchecked") - private ConstraintViolation createMockConstraintViolation(String message) { - ConstraintViolation violation = mock(ConstraintViolation.class); - when(violation.getMessage()).thenReturn(message); - return violation; - } - } + @Test + void shouldHandleEntityValidationException() { + ProblemDetail body = exceptionHandler.handleEntityValidationException( + new EntityValidationException(java.util.List.of("Invalid property"))); + assertProblemDetail(body, HttpStatus.BAD_REQUEST, "Entity validation failed: Invalid property", + "BAD_REQUEST"); + } - /// Tests the handling of [ConstraintViolationException] with multiple - /// validation violations. - /// - /// **This test verifies that:** - /// - ConstraintViolationException with multiple violations is properly handled - /// - HTTP 400 Bad Request status is returned - /// - All violation messages are concatenated with comma separation - /// - Error response contains all validation error messages - @Test - @DisplayName("Should handle ConstraintViolationException with multiple violations") - void shouldHandleConstraintViolationExceptionMultipleViolations() { - // Given - ConstraintViolation violation1 = createMockConstraintViolation( - "Field1 must not be null"); - ConstraintViolation violation2 = createMockConstraintViolation( - "Field2 must not be blank"); - Set> violations = Set.of(violation1, violation2); - ConstraintViolationException exception = new ConstraintViolationException("Validation failed", - violations); - - // When - ResponseEntity response = exceptionHandler - .handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 must not be null")); - assertTrue(errorDescription.contains("Field2 must not be blank")); - assertTrue(errorDescription.contains(", ")); - } + @Test + void shouldHandleConstraintViolationException() { + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getMessage()).thenReturn("Field must not be null"); + ProblemDetail body = exceptionHandler.handleConstraintViolationException( + new ConstraintViolationException("failed", Set.of(violation))); + assertProblemDetail(body, HttpStatus.BAD_REQUEST, "Field must not be null", "BAD_REQUEST"); + } - /// Tests the handling of [MethodArgumentNotValidException] with field - /// validation errors. - /// - /// **This test verifies that:** - /// - MethodArgumentNotValidException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Field error messages from binding result are extracted and concatenated - /// - All field validation errors are included in the response with comma - /// separation - /// - /// @throws Exception if reflection fails during test setup - @Test - @DisplayName("Should handle MethodArgumentNotValidException with field errors") - void shouldHandleMethodArgumentNotValidException() throws Exception { - // Given - Object target = new Object(); - BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "testObject"); - bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); - bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); - - // Create a proper MethodParameter mock with required methods - MethodParameter methodParameter = mock(MethodParameter.class); - when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); - - MethodArgumentNotValidException exception = new MethodArgumentNotValidException( - methodParameter, bindingResult); - - // When - ResponseEntity response = exceptionHandler - .handleMethodArgumentNotValidException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 is required")); - assertTrue(errorDescription.contains("Field2 must be valid")); - assertTrue(errorDescription.contains(", ")); - } + @Test + void shouldHandleMethodArgumentNotValidViaFrameworkOverride() throws Exception { + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "obj"); + bindingResult.addError(new FieldError("obj", "field", "Field is required")); + MethodParameter methodParameter = new MethodParameter( + ApiExceptionHandlerTest.class.getDeclaredMethod("setUp"), -1); + MethodArgumentNotValidException ex = new MethodArgumentNotValidException(methodParameter, + bindingResult); + ProblemDetail body = (ProblemDetail) exceptionHandler.handleMethodArgumentNotValid(ex, + new HttpHeaders(), HttpStatus.BAD_REQUEST, mockWebRequest()).getBody(); + assertProblemDetail(body, HttpStatus.BAD_REQUEST, "Field is required", "BAD_REQUEST"); + } - // Helper method for mocking - public void testMethod() { - // Empty method for testing purposes - } + @Test + void shouldHandleHandlerMethodValidationViaFrameworkOverride() { + HandlerMethodValidationException ex = mock(HandlerMethodValidationException.class); + MessageSourceResolvable error = mock(MessageSourceResolvable.class); + when(error.getDefaultMessage()).thenReturn("Parameter is invalid"); + doReturn(List.of(error)).when(ex).getAllErrors(); - @SuppressWarnings("unchecked") - private ConstraintViolation createMockConstraintViolation(String message) { - ConstraintViolation violation = mock(ConstraintViolation.class); - when(violation.getMessage()).thenReturn(message); - return violation; - } + ResponseEntity response = exceptionHandler.handleHandlerMethodValidationException(ex, + new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, mockWebRequest()); + ProblemDetail body = (ProblemDetail) response.getBody(); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertProblemDetail(body, HttpStatus.BAD_REQUEST, "Parameter is invalid", "BAD_REQUEST"); } - @Nested - @DisplayName("HTTP Message Exception Handling") - class HttpMessageExceptionTests { - - /// Provides test data for [HttpMessageNotReadableException] scenarios. Each - /// argument contains: input message and expected error description. - static Stream httpMessageNotReadableExceptionTestData() { - return Stream.of( - Arguments.of("Required request body is missing: public ResponseEntity", - "Request body is required"), - Arguments.of("JSON parse error: Unexpected character", - "Invalid JSON format in request body"), - Arguments.of( - "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_TYPE' for property 'type'"), - Arguments.of( - "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_FORMAT' for property 'format'"), - Arguments.of( - "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", - "Invalid enum value in request body"), - Arguments.of("Cannot deserialize value of type `com.example.SomeType`: some other error", - "Invalid type: expected SomeType"), - Arguments.of("Something completely unexpected happened", "Invalid request body format"), - Arguments.of( - "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", - "Invalid value for property 'type'")); - } + @Test + void shouldDeclareUnambiguousExceptionHandlerMappings() { + assertDoesNotThrow(() -> new ExceptionHandlerMethodResolver(ApiExceptionHandler.class)); + } - /// Tests the handling of [HttpMessageNotReadableException] when exception - /// message is null. - /// - /// **This test verifies that:** - /// - HttpMessageNotReadableException with null message is properly handled - /// - HTTP 400 Bad Request status is returned - /// - Default error message is provided when original message is null - /// - Graceful handling of edge case scenarios - @Test - @DisplayName("Should handle HttpMessageNotReadableException with null message") - void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { - // Given - HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); - when(exception.getMessage()).thenReturn(null); - - // When - ResponseEntity response = exceptionHandler - .handleHttpMessageNotReadableException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals("Invalid request body format", body.getErrorDescription()); - } + @Test + void shouldUseNativeProblemDetailForUnreadableMessage() throws Exception { + HttpMessageNotReadableException ex = new HttpMessageNotReadableException( + "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\"", + new HttpInputMessage() { + @Override + public HttpHeaders getHeaders() { + return new HttpHeaders(); + } + + @Override + public java.io.InputStream getBody() { + return java.io.InputStream.nullInputStream(); + } + }); + ProblemDetail body = (ProblemDetail) exceptionHandler.handleException(ex, mockWebRequest()) + .getBody(); + + assertProblemDetail(body, HttpStatus.BAD_REQUEST, "Failed to read request", "BAD_REQUEST"); + } - /// Parameterized test for handling [HttpMessageNotReadableException] with - /// various error scenarios. - /// - /// **This test verifies that different types of HttpMessageNotReadableException - /// are properly parsed and converted to user-friendly error messages:** - /// - Missing request body errors → "Request body is required" - /// - JSON parse errors → "Invalid JSON format in request body" - /// - PropertyType enum deserialization errors → Specific property and value - /// information - /// - Unknown enum deserialization errors → Generic enum error message - /// - /// **Each test case validates that:** - /// - HTTP 400 Bad Request status is returned - /// - Original complex error message is parsed and simplified - /// - User-friendly error description is provided - /// - Error response structure is consistent - /// - /// @param originalMessage the original exception message to be - /// processed - /// @param expectedErrorDescription the expected user-friendly error description - @ParameterizedTest - @MethodSource("httpMessageNotReadableExceptionTestData") - @DisplayName("Should handle HttpMessageNotReadableException with various error types") - void shouldHandleHttpMessageNotReadableExceptionWithVariousErrorTypes(String originalMessage, - String expectedErrorDescription) { - // Given - HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); - when(exception.getMessage()).thenReturn(originalMessage); - - // When - ResponseEntity response = exceptionHandler - .handleHttpMessageNotReadableException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals(expectedErrorDescription, body.getErrorDescription()); + @Test + void shouldSetInstanceOnDomainErrorsFromCurrentRequest() { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(); + servletRequest.setRequestURI("/api/v1/entities/x"); + org.springframework.web.context.request.RequestContextHolder.setRequestAttributes( + new org.springframework.web.context.request.ServletRequestAttributes(servletRequest)); + try { + ProblemDetail body = exceptionHandler + .handleEntityAlreadyExistsException(new EntityAlreadyExistsException("a", "b")); + assertEquals("/api/v1/entities/x", String.valueOf(body.getInstance())); + } finally { + org.springframework.web.context.request.RequestContextHolder.resetRequestAttributes(); } } - @Nested - @DisplayName("Generic Exception Handling") - class GenericExceptionTests { - - /// Tests the handling of generic Exception as a fallback mechanism. - /// - /// **This test verifies that:** - /// - Unexpected exceptions are caught by the generic handler - /// - HTTP 500 Internal Server Error status is returned - /// - Generic error message is provided to avoid exposing internal details - /// - Exception is properly logged for debugging purposes - @Test - @DisplayName("Should handle generic Exception with 500 status") - void shouldHandleGenericException() { - // Given - Exception exception = new RuntimeException("Unexpected error"); - - // When - ResponseEntity response = exceptionHandler.handleGenericException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.name(), body.getError()); - assertEquals("An unexpected error occurred. Please try again later.", - body.getErrorDescription()); - } + @Test + void shouldHandleGenericExceptionWithoutExposingInternalDetails() { + ProblemDetail body = exceptionHandler + .handleGenericException(new RuntimeException("Sensitive internal details")); + + assertProblemDetail(body, HttpStatus.INTERNAL_SERVER_ERROR, + "An unexpected error occurred. Please try again later.", "INTERNAL_SERVER_ERROR"); } - @Nested - @DisplayName("ErrorResponse Class Tests") - class ErrorResponseTests { - - /// Tests the creation of [ErrorResponse] using the all-arguments constructor. - /// - /// **This test verifies that:** - /// - ErrorResponse can be instantiated with HttpStatus and description - /// - All fields are properly initialized with provided values - /// - Getter methods return the expected values - /// - Object is successfully created and accessible - @Test - @DisplayName("Should create ErrorResponse with all args constructor") - void shouldCreateErrorResponseWithAllArgsConstructor() { - // Given - HttpStatus status = HttpStatus.BAD_REQUEST; - String description = "Test error message"; - - // When - ErrorResponse errorResponse = new ErrorResponse(status.name(), description); - - // Then - assertNotNull(errorResponse); - assertEquals(status.name(), errorResponse.getError()); - assertEquals(description, errorResponse.getErrorDescription()); - } + @Test + void shouldNormalizeInheritedFrameworkProblemDetails() throws Exception { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(); + servletRequest.setRequestURI("/api/v1/test"); + ServletWebRequest webRequest = new ServletWebRequest(servletRequest); + + ResponseEntity response = exceptionHandler.handleException( + new MissingServletRequestParameterException("page", "integer"), webRequest); + ProblemDetail body = (ProblemDetail) response.getBody(); + + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("BAD_REQUEST", body.getProperties().get("error")); + assertEquals(body.getDetail(), body.getProperties().get("error_description")); + assertNotNull(body.getProperties().get("timestamp")); + assertEquals("/api/v1/test", body.getInstance().toString()); + } - /// Tests the creation of [ErrorResponse] using the no-arguments constructor. - /// - /// **This test verifies that:** - /// - ErrorResponse can be instantiated without parameters - /// - Object is successfully created with default/null field values - /// - Constructor works with `@NoArgsConstructor(force = true)` annotation - /// - Provides flexibility for frameworks requiring default constructors - @Test - @DisplayName("Should create ErrorResponse with no args constructor") - void shouldCreateErrorResponseWithNoArgsConstructor() { - ErrorResponse errorResponse = new ErrorResponse(); - assertNotNull(errorResponse); - } + @Test + void shouldPreserveExistingProblemDetailExtensionsAndInstance() { + ProblemDetail body = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Invalid input"); + Instant timestamp = Instant.parse("2026-10-14T08:00:00Z"); + body.setProperty("error", "CUSTOM_ERROR"); + body.setProperty("error_description", "Custom description"); + body.setProperty("timestamp", timestamp); + body.setInstance(URI.create("/original")); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/replacement"); + + ResponseEntity response = exceptionHandler.createResponseEntity(body, new HttpHeaders(), + HttpStatus.BAD_REQUEST, new ServletWebRequest(request)); + + assertSame(body, response.getBody()); + assertEquals("CUSTOM_ERROR", body.getProperties().get("error")); + assertEquals("Custom description", body.getProperties().get("error_description")); + assertEquals(timestamp, body.getProperties().get("timestamp")); + assertEquals(URI.create("/original"), body.getInstance()); + } + + @Test + void shouldAddMissingExtensionsWithoutReplacingExistingProperties() { + ProblemDetail body = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Invalid input"); + body.setProperty("traceId", "trace-123"); + body.setProperty("error", "CUSTOM_ERROR"); + + exceptionHandler.createResponseEntity(body, new HttpHeaders(), HttpStatus.BAD_REQUEST, + mockWebRequest()); + + assertEquals("trace-123", body.getProperties().get("traceId")); + assertEquals("CUSTOM_ERROR", body.getProperties().get("error")); + assertEquals("Invalid input", body.getProperties().get("error_description")); + assertInstanceOf(Instant.class, body.getProperties().get("timestamp")); + assertNull(body.getInstance()); + } + + @Test + void shouldEnrichProblemDetailWithEmptyProperties() { + ProblemDetail body = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Invalid input"); + body.setProperties(new LinkedHashMap<>()); + + exceptionHandler.createResponseEntity(body, new HttpHeaders(), HttpStatus.BAD_REQUEST, + mockWebRequest()); + + assertProblemDetail(body, HttpStatus.BAD_REQUEST, "Invalid input", "BAD_REQUEST"); + } + + @Test + void shouldUseNumericErrorForNonStandardHttpStatus() { + HttpStatusCode status = HttpStatusCode.valueOf(599); + ProblemDetail body = ProblemDetail.forStatusAndDetail(status, "Non-standard error"); + + ResponseEntity response = exceptionHandler.createResponseEntity(body, new HttpHeaders(), + status, mockWebRequest()); + + assertEquals(status, response.getStatusCode()); + assertEquals("599", body.getProperties().get("error")); + assertEquals("Non-standard error", body.getProperties().get("error_description")); + assertInstanceOf(Instant.class, body.getProperties().get("timestamp")); + } + + @Test + void shouldLeaveNonProblemDetailResponseUnchanged() { + Map body = Map.of("message", "original"); + HttpHeaders headers = new HttpHeaders(); + headers.add("X-Test", "unchanged"); + + ResponseEntity response = exceptionHandler.createResponseEntity(body, headers, + HttpStatus.BAD_REQUEST, mockWebRequest()); + + assertSame(body, response.getBody()); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("unchanged", response.getHeaders().getFirst("X-Test")); + } + + @Test + void shouldAllowResponseWithoutBody() { + ResponseEntity response = exceptionHandler.createResponseEntity(null, new HttpHeaders(), + HttpStatus.NO_CONTENT, mockWebRequest()); + + assertNull(response.getBody()); + assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode()); + } + + @Test + void shouldHandlePrincipalNotFoundException() { + PrincipalNotFoundException ex = new PrincipalNotFoundException("missing-principal"); + + ProblemDetail body = exceptionHandler.handlePrincipalNotFoundException(ex); + + assertProblemDetail(body, HttpStatus.NOT_FOUND, ex.getMessage(), "NOT_FOUND"); + } + + @Test + void shouldHandleMissingPathVariableViaFrameworkOverride() { + ProblemDetail body = (ProblemDetail) exceptionHandler.handleMissingPathVariable( + mock(org.springframework.web.bind.MissingPathVariableException.class), new HttpHeaders(), + HttpStatus.BAD_REQUEST, mockWebRequest()).getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.value(), body.getStatus()); + assertEquals("BAD_REQUEST", body.getProperties().get("error")); + } + + @Test + void shouldSetInstanceWhenWebRequestIsPresent() { + ServletWebRequest request = mock(ServletWebRequest.class); + when(request.getDescription(false)).thenReturn("uri=/api/v1/test"); + ProblemDetail body = (ProblemDetail) exceptionHandler.handleMissingPathVariable( + mock(org.springframework.web.bind.MissingPathVariableException.class), new HttpHeaders(), + HttpStatus.BAD_REQUEST, request).getBody(); + + assertNotNull(body); + assertNotNull(body.getInstance()); + assertEquals("/api/v1/test", body.getInstance().toString()); + } + + @Test + void shouldHandleInvalidFilterDslException() { + ProblemDetail body = exceptionHandler + .handleInvalidFilterDslException(new InvalidFilterDslException("bad filter")); + assertProblemDetail(body, HttpStatus.BAD_REQUEST, "bad filter", "BAD_REQUEST"); + } + + @Test + void shouldHandleInvalidSearchQueryException() { + ProblemDetail body = exceptionHandler + .handleInvalidSearchQueryException(new InvalidSearchQueryException("bad query")); + assertProblemDetail(body, HttpStatus.BAD_REQUEST, "bad query", "BAD_REQUEST"); + } + + @Test + void shouldHandleEntityTemplateNameAlreadyExistsException() { + ProblemDetail body = exceptionHandler.handleEntityTemplateNameAlreadyExistsException( + new EntityTemplateNameAlreadyExistsException("duplicate-name")); + assertEquals(HttpStatus.CONFLICT.value(), body.getStatus()); + assertEquals("CONFLICT", body.getProperties().get("error")); + } + + @Test + void shouldHandleEntityTemplateIdentifierCannotChangeException() { + ProblemDetail body = exceptionHandler.handleEntityTemplateIdentifierCannotChangeException( + new EntityTemplateIdentifierCannotChangeException("template-id")); + assertEquals(HttpStatus.BAD_REQUEST.value(), body.getStatus()); + assertEquals("BAD_REQUEST", body.getProperties().get("error")); + } + + @Test + void shouldHandleWrongPropertyRulesException() { + ProblemDetail body = exceptionHandler.handleWrongPropertyRulesException( + new PropertyDefinitionRulesConflictException("property-test", "STRING", "not allowed")); + assertEquals(HttpStatus.BAD_REQUEST.value(), body.getStatus()); + assertEquals("BAD_REQUEST", body.getProperties().get("error")); + } + + @Test + void shouldHandlePropertyNameAlreadyExistsException() { + ProblemDetail body = exceptionHandler.handlePropertyNameAlreadyExistsException( + new PropertyNameAlreadyExistsException("property-test")); + assertEquals(HttpStatus.BAD_REQUEST.value(), body.getStatus()); + assertEquals("BAD_REQUEST", body.getProperties().get("error")); + } + + @Test + void shouldHandleRelationNameAlreadyExistsException() { + ProblemDetail body = exceptionHandler.handleRelationNameAlreadyExistsException( + new RelationNameAlreadyExistsException("belongsto")); + assertEquals(HttpStatus.BAD_REQUEST.value(), body.getStatus()); + assertEquals("BAD_REQUEST", body.getProperties().get("error")); + } + + @Test + void shouldHandleTargetTemplateNotFoundException() { + ProblemDetail body = exceptionHandler + .handleTargetTemplateNotFoundException(new TargetTemplateNotFoundException("non-existent")); + assertEquals(HttpStatus.BAD_REQUEST.value(), body.getStatus()); + assertEquals("BAD_REQUEST", body.getProperties().get("error")); + } + + @Test + void shouldHandleEntityTemplateIsRelationTargetException() { + ProblemDetail body = exceptionHandler.handleEntityTemplateIsRelationTargetException( + new EntityTemplateIsRelationTargetException("microservice")); + assertEquals(HttpStatus.BAD_REQUEST.value(), body.getStatus()); + assertEquals("BAD_REQUEST", body.getProperties().get("error")); + } + + @Test + void shouldHandleTypeChangeException() { + ProblemDetail body = exceptionHandler + .handleTypeChangeException(new PropertyTypeChangeException("name", "STRING", "NUMBER")); + assertEquals(HttpStatus.BAD_REQUEST.value(), body.getStatus()); + assertEquals("BAD_REQUEST", body.getProperties().get("error")); + } + + @Test + void shouldHandleRelationTargetTemplateChangeException() { + ProblemDetail body = exceptionHandler.handleRelationTargetTemplateChangeException( + new RelationTargetTemplateChangeException("dependencies", "service", "service-modified")); + assertEquals(HttpStatus.BAD_REQUEST.value(), body.getStatus()); + assertEquals("BAD_REQUEST", body.getProperties().get("error")); + } + + @Test + void shouldHandleRelationCannotTargetItselfException() { + ProblemDetail body = exceptionHandler.handleRelationCannotTargetItselfException( + new RelationCannotTargetItselfException("circular", "self-ref-template")); + assertEquals(HttpStatus.BAD_REQUEST.value(), body.getStatus()); + assertEquals("BAD_REQUEST", body.getProperties().get("error")); + } + + @Test + void shouldHandleEntityDynamicMappingConfigurationException() { + ProblemDetail body = exceptionHandler.handleEntityDynamicMappingConfigurationException( + new EntityDynamicMappingConfigurationException("invalid configuration")); + assertProblemDetail(body, HttpStatus.BAD_REQUEST, "invalid configuration", "BAD_REQUEST"); + } + + @Test + void shouldHandleExpressionEvaluationFailedException() { + ProblemDetail body = exceptionHandler.handleExpressionEvaluationFailedException( + new ExpressionEvaluationFailedException(".foo", "syntax error", null)); + assertEquals(HttpStatus.UNPROCESSABLE_CONTENT.value(), body.getStatus()); + assertEquals("UNPROCESSABLE_CONTENT", body.getProperties().get("error")); + } + + @Test + void shouldHandleEntityDynamicMappingJsltErrorException() { + ProblemDetail body = exceptionHandler.handleEntityDynamicMappingJsltErrorException( + new EntityDynamicMappingJsltErrorException("jslt failed")); + assertProblemDetail(body, HttpStatus.UNPROCESSABLE_CONTENT, "jslt failed", + "UNPROCESSABLE_CONTENT"); + } + + @Test + void shouldHandlePropertyNameNotFoundEntityTemplatePropertiesException() { + ProblemDetail body = exceptionHandler + .handlePropertyNameNotFoundEntityTemplatePropertiesException( + new PropertyNameNotFoundEntityTemplatePropertiesException("unknown property")); + assertProblemDetail(body, HttpStatus.UNPROCESSABLE_CONTENT, "unknown property", + "UNPROCESSABLE_CONTENT"); + } + + @Test + void shouldHandleRelationNameNotFoundEntityTemplateRelationsException() { + ProblemDetail body = exceptionHandler + .handleRelationNameNotFoundEntityTemplateRelationsException( + new RelationNameNotFoundEntityTemplateRelationsException("unknown relation")); + assertProblemDetail(body, HttpStatus.UNPROCESSABLE_CONTENT, "unknown relation", + "UNPROCESSABLE_CONTENT"); + } + + @Test + void shouldHandleEntityDynamicMappingHasNoPropertiesException() { + ProblemDetail body = exceptionHandler.handleEntityDynamicMappingHasNoPropertiesException( + new EntityDynamicMappingHasNoPropertiesException("missing properties")); + assertProblemDetail(body, HttpStatus.UNPROCESSABLE_CONTENT, "missing properties", + "UNPROCESSABLE_CONTENT"); + } + + @Test + void shouldHandleEntityDynamicMappingHasNoRelationsException() { + ProblemDetail body = exceptionHandler.handleEntityDynamicMappingHasNoRelationsException( + new EntityDynamicMappingHasNoRelationsException("missing relations")); + assertProblemDetail(body, HttpStatus.UNPROCESSABLE_CONTENT, "missing relations", + "UNPROCESSABLE_CONTENT"); + } + + @Test + void shouldHandleWebhookSecurityConfigurationException() { + ProblemDetail body = exceptionHandler.handleWebhookSecurityConfigurationException( + new WebhookSecurityConfigurationException("invalid security")); + assertProblemDetail(body, HttpStatus.BAD_REQUEST, "invalid security", "BAD_REQUEST"); + } + + @Test + void shouldHandleEntityNotFoundException() { + ProblemDetail body = exceptionHandler + .handleEntityNotFoundException(new EntityNotFoundException("template", "entity-id")); + assertEquals(HttpStatus.NOT_FOUND.value(), body.getStatus()); + assertEquals("NOT_FOUND", body.getProperties().get("error")); + } + + @Test + void shouldHandleEntityDeletionBlockedException() { + ProblemDetail body = exceptionHandler.handleEntityDeletionBlockedException( + new EntityDeletionBlockedException("template", "entity-id", List.of("child-id"))); + assertEquals(HttpStatus.CONFLICT.value(), body.getStatus()); + assertEquals("CONFLICT", body.getProperties().get("error")); + } + + @Test + void shouldHandleWebhookAuthenticationException() { + ProblemDetail body = exceptionHandler.handleWebhookAuthenticationException( + new WebhookAuthenticationException("invalid signature")); + assertProblemDetail(body, HttpStatus.UNAUTHORIZED, "invalid signature", "UNAUTHORIZED"); + } + + @Test + void shouldHandleWebhookConnectorNotFoundException() { + ProblemDetail body = exceptionHandler.handleWebhookConnectorNotFoundException( + new WebhookConnectorNotFoundException("connector-id")); + assertEquals(HttpStatus.NOT_FOUND.value(), body.getStatus()); + assertEquals("NOT_FOUND", body.getProperties().get("error")); + } + + @Test + void shouldHandleEntityDynamicMappingNotFoundException() { + ProblemDetail body = exceptionHandler.handleEntityDynamicMappingNotFoundException( + new EntityDynamicMappingNotFoundException("mapping-id")); + assertEquals(HttpStatus.NOT_FOUND.value(), body.getStatus()); + assertEquals("NOT_FOUND", body.getProperties().get("error")); + } + + @Test + void shouldHandleEntityDynamicMappingAlreadyExistsException() { + ProblemDetail body = exceptionHandler.handleEntityDynamicMappingAlreadyExistsException( + new EntityDynamicMappingAlreadyExistsException("mapping-id")); + assertEquals(HttpStatus.CONFLICT.value(), body.getStatus()); + assertEquals("CONFLICT", body.getProperties().get("error")); + } + + @Test + void shouldHandleEntityDynamicMappingAlreadyInUseException() { + ProblemDetail body = exceptionHandler.handleEntityDynamicMappingAlreadyInUseException( + new EntityDynamicMappingAlreadyInUseException(List.of("mapping-id"))); + assertEquals(HttpStatus.CONFLICT.value(), body.getStatus()); + assertEquals("CONFLICT", body.getProperties().get("error")); + } + + @Test + void shouldHandleWebhookConnectorAlreadyExistException() { + ProblemDetail body = exceptionHandler.handleWebhookConnectorAlreadyExistException( + new WebhookConnectorAlreadyExistException("connector-id")); + assertEquals(HttpStatus.CONFLICT.value(), body.getStatus()); + assertEquals("CONFLICT", body.getProperties().get("error")); + } + + @Test + void shouldHandleTemplateAlreadyMappedInWebhookConfiguration() { + ProblemDetail body = exceptionHandler.handleTemplateAlreadyMappedInWebhookConfiguration( + new EntityTemplateUsedByDynamicMappingException("template in use")); + assertProblemDetail(body, HttpStatus.CONFLICT, "template in use", "CONFLICT"); + } + + @Test + void shouldHandleWebhookConnectorTitleAlreadyExistsException() { + ProblemDetail body = exceptionHandler.handleWebhookConnectorTitleAlreadyExistsException( + new WebhookConnectorTitleAlreadyExistsException("connector-name")); + assertEquals(HttpStatus.CONFLICT.value(), body.getStatus()); + assertEquals("CONFLICT", body.getProperties().get("error")); + } + + @Test + void shouldHandleDataIntegrityViolationException() { + Throwable rootCause = new RuntimeException("constraint violation"); + org.springframework.dao.DataIntegrityViolationException ex = mock( + org.springframework.dao.DataIntegrityViolationException.class); + when(ex.getMostSpecificCause()).thenReturn(rootCause); + + ProblemDetail body = exceptionHandler.handleDataIntegrityViolationException(ex); + + assertProblemDetail(body, HttpStatus.CONFLICT, + "The request conflicts with the current state of the resource", "CONFLICT"); + } + + @Test + void shouldHandleNoHandlerFoundExceptionViaFrameworkOverride() { + ProblemDetail body = (ProblemDetail) exceptionHandler.handleNoHandlerFoundException( + mock(org.springframework.web.servlet.NoHandlerFoundException.class), new HttpHeaders(), + HttpStatus.NOT_FOUND, mockWebRequest()).getBody(); + assertProblemDetail(body, HttpStatus.NOT_FOUND, + "Malformed request URL or missing path variable.", "NOT_FOUND"); + } + + private static void assertProblemDetail(ProblemDetail body, HttpStatus status, String detail, + String error) { + assertNotNull(body); + assertEquals(status.value(), body.getStatus()); + assertEquals(status.getReasonPhrase(), body.getTitle()); + assertEquals(detail, body.getDetail()); + assertEquals(error, body.getProperties().get("error")); + assertEquals(detail, body.getProperties().get("error_description")); + assertNotNull(body.getProperties().get("timestamp")); } }