From cab1ea25179d3b9270c3138d132b34e1ee021a73 Mon Sep 17 00:00:00 2001 From: Pasindu Owa Gamage Date: Sat, 7 Mar 2026 01:39:03 +0530 Subject: [PATCH 1/3] feat(exception): update ApiExceptionTranslator Javadoc - Rewrote interface-level Javadoc with full usage example, error response format, and type parameter explanation - Updated method-level Javadoc for getTargetException(), getStatus(), and getMessage() with detailed descriptions - Added @version, @since, and cross-reference @see tags - Bumped documented version to 1.4.0 Work in progress - not final release --- .../og4dev/advice/GlobalResponseWrapper.java | 129 +++---- .../io/github/og4dev/advice/package-info.java | 33 +- .../og4dev/annotation/AutoResponse.java | 98 ++++-- .../io/github/og4dev/annotation/AutoTrim.java | 115 +++--- .../io/github/og4dev/annotation/XssCheck.java | 221 ++++-------- .../og4dev/annotation/package-info.java | 163 +++------ .../config/AdvancedStringDeserializer.java | 188 ++++++++++ .../config/ApiResponseAutoConfiguration.java | 326 ++++++------------ .../io/github/og4dev/config/package-info.java | 50 ++- .../io/github/og4dev/dto/ApiResponse.java | 210 ++++++----- .../io/github/og4dev/dto/package-info.java | 45 ++- .../github/og4dev/exception/ApiException.java | 62 ++-- .../exception/ApiExceptionTranslator.java | 135 ++++++++ .../exception/GlobalExceptionHandler.java | 185 +++++++--- .../github/og4dev/exception/package-info.java | 85 ++++- .../github/og4dev/filter/TraceIdFilter.java | 82 ++--- .../io/github/og4dev/filter/package-info.java | 49 ++- .../java/io/github/og4dev/package-info.java | 40 ++- 18 files changed, 1315 insertions(+), 901 deletions(-) create mode 100644 src/main/java/io/github/og4dev/config/AdvancedStringDeserializer.java create mode 100644 src/main/java/io/github/og4dev/exception/ApiExceptionTranslator.java diff --git a/src/main/java/io/github/og4dev/advice/GlobalResponseWrapper.java b/src/main/java/io/github/og4dev/advice/GlobalResponseWrapper.java index 8d4a2f9..f057e20 100644 --- a/src/main/java/io/github/og4dev/advice/GlobalResponseWrapper.java +++ b/src/main/java/io/github/og4dev/advice/GlobalResponseWrapper.java @@ -19,79 +19,75 @@ import tools.jackson.databind.ObjectMapper; /** - * Global response interceptor that automatically wraps REST controller outputs into the - * standardized {@link ApiResponse} format. + * Spring {@link ResponseBodyAdvice} implementation that automatically encapsulates REST + * controller return values inside a standardized {@link ApiResponse} envelope. *

- * This wrapper is conditionally activated only for controllers or specific methods - * annotated with the {@link AutoResponse @AutoResponse} annotation. It provides a seamless - * developer experience by eliminating the need to manually return {@code ResponseEntity>} - * from every controller method. + * This wrapper is activated only for controllers or individual methods annotated + * with {@link AutoResponse @AutoResponse}. It intercepts the outgoing response body + * before Jackson serializes it, wraps the payload, and sends the resulting + * {@link ApiResponse} structure to the client — eliminating manual + * {@code ResponseEntity>} boilerplate. *

- *

Core Functionalities:

+ * + *

Core Behaviours

* * * @author Pasindu OG * @version 1.4.0 + * @since 1.4.0 * @see AutoResponse * @see ApiResponse - * @see ResponseBodyAdvice - * @since 1.4.0 + * @see org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice */ @RestControllerAdvice @SuppressWarnings("unused") -public @NullMarked class GlobalResponseWrapper implements ResponseBodyAdvice { +@NullMarked +public class GlobalResponseWrapper implements ResponseBodyAdvice { private final ObjectMapper objectMapper; /** - * Constructs a new {@code GlobalResponseWrapper} with the provided {@link ObjectMapper}. + * Constructs a {@code GlobalResponseWrapper} with the Jackson {@code ObjectMapper} + * used for explicit {@code String} payload serialization. * - * @param objectMapper The Jackson object mapper used for explicit string serialization. + * @param objectMapper the configured Jackson mapper injected by Spring; must not be {@code null} */ public GlobalResponseWrapper(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } /** - * Determines whether the current response should be intercepted and wrapped. + * Determines whether this advice should process the current response body. *

- * This method evaluates two main conditions before allowing the response to be wrapped: + * Returns {@code true} only when both of the following conditions are met: *

*
    - *
  1. Annotation Presence: The target controller class or the specific handler method - * must be annotated with {@link AutoResponse}.
  2. - *
  3. Type Exclusion: The return type must not be one of the explicitly excluded types.
  4. + *
  5. The controller class or the specific handler method is annotated with + * {@link AutoResponse}.
  6. + *
  7. The method's return type is not one of the explicitly excluded types: + * {@link ApiResponse}, {@link ResponseEntity}, or {@link ProblemDetail}.
  8. *
- *

- * To guarantee application stability and adherence to standard HTTP protocols, this method - * specifically excludes the following return types from being wrapped: - *

- *
    - *
  • {@link ApiResponse} - Prevents recursive double-wrapping (e.g., {@code ApiResponse>}).
  • - *
  • {@link ResponseEntity} - Skips manual responses to respect developer's explicit configurations.
  • - *
  • {@link ProblemDetail} - Excludes RFC 9457 error responses generated by exception handlers.
  • - *
- *

- * Note: Unlike standard wrappers, raw {@link String} payloads are supported and handled - * appropriately during the write phase. - *

* - * @param returnType The return type of the controller method. - * @param converterType The selected HTTP message converter. - * @return {@code true} if annotated with {@code @AutoResponse} and not an excluded type; {@code false} otherwise. + * @param returnType the return type descriptor of the handler method + * @param converterType the message converter selected by Spring MVC + * @return {@code true} if the response body should be wrapped; {@code false} otherwise */ @Override - public @NullMarked boolean supports(MethodParameter returnType, Class> converterType) { + public boolean supports(MethodParameter returnType, Class> converterType) { Class type = returnType.getParameterType(); boolean isExcludedType = ApiResponse.class.isAssignableFrom(type) || ResponseEntity.class.isAssignableFrom(type) || @@ -103,28 +99,30 @@ public GlobalResponseWrapper(ObjectMapper objectMapper) { } /** - * Intercepts the response body before it is written to the output stream and encapsulates it - * within an {@link ApiResponse}. + * Intercepts the outgoing response body and wraps it inside an {@link ApiResponse}. *

- * This method extracts the actual HTTP status code set on the current response (defaulting to 200 OK). - * Based on whether the status code represents a success (2xx) or another state, it dynamically - * assigns an appropriate message ("Success" or "Processed") to the API response. + * The HTTP status code already set on the servlet response is read and encoded in the + * {@code ApiResponse.status} field. For 2xx status codes the + * {@link AutoResponse#message()} value (method-level first, then class-level) is used + * as the message; for all other codes the message is {@code "Processed"}. *

*

- * Special String Handling: If the intercepted payload is a raw {@code String}, it is - * explicitly serialized to a JSON string using the configured {@link ObjectMapper}, and the - * response {@code Content-Type} is strictly set to {@code application/json}. This prevents - * standard message converter conflicts. + * Raw {@code String} handling: if the original body is a {@code String}, the + * resulting {@link ApiResponse} is serialized to a JSON string immediately using the + * injected {@code ObjectMapper} and the response {@code Content-Type} header is set to + * {@code application/json}. This prevents Spring from routing the value through the + * {@code StringHttpMessageConverter}, which would cause a {@code ClassCastException}. + * If serialization fails, the original string is returned unchanged as a fallback. *

* - * @param body The raw object returned by the controller method. - * @param returnType The return type of the controller method. - * @param selectedContentType The selected content type for the response. - * @param selectedConverterType The selected HTTP message converter. - * @param request The current server HTTP request. - * @param response The current server HTTP response. - * @return The newly wrapped {@code ApiResponse} object ready to be serialized, or a pre-serialized - * JSON {@code String} if the original payload was a raw string. + * @param body the value returned by the handler method; may be {@code null} + * @param returnType the return type descriptor of the handler method + * @param selectedContentType the content type selected by content negotiation + * @param selectedConverterType the message converter selected by Spring MVC + * @param request the current server-side HTTP request + * @param response the current server-side HTTP response + * @return the wrapped {@link ApiResponse} object, or a pre-serialized JSON + * {@code String} when the original body was a raw string */ @Override public @Nullable Object beforeBodyWrite(@Nullable Object body, MethodParameter returnType, MediaType selectedContentType, Class> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { @@ -134,7 +132,16 @@ public GlobalResponseWrapper(ObjectMapper objectMapper) { statusCode = serverHttpResponse.getServletResponse().getStatus(); } HttpStatus httpStatus = HttpStatus.valueOf(statusCode); - ApiResponse apiResponse = ApiResponse.status(httpStatus.is2xxSuccessful() ? "Success" : "Processed", body, httpStatus).getBody(); + + String responseMessage = "Success"; + AutoResponse methodAnnotation = returnType.getMethodAnnotation(AutoResponse.class); + if (methodAnnotation != null) { + responseMessage = methodAnnotation.message(); + } else { + AutoResponse classAnnotation = returnType.getDeclaringClass().getAnnotation(AutoResponse.class); + if (classAnnotation != null) responseMessage = classAnnotation.message(); + } + ApiResponse apiResponse = ApiResponse.status(httpStatus.is2xxSuccessful() ? responseMessage : "Processed", body, httpStatus).getBody(); if (body instanceof String) { try { diff --git a/src/main/java/io/github/og4dev/advice/package-info.java b/src/main/java/io/github/og4dev/advice/package-info.java index 793e9b3..431bd74 100644 --- a/src/main/java/io/github/og4dev/advice/package-info.java +++ b/src/main/java/io/github/og4dev/advice/package-info.java @@ -1,30 +1,31 @@ /** - * Provides global advisory components for the OG4Dev Spring API Response Library. + * Global response-body advisory component for automatic API response wrapping. *

- * This package contains Spring {@link org.springframework.web.bind.annotation.RestControllerAdvice} - * implementations that intercept and modify HTTP responses and requests globally across the application. + * This package contains the {@link io.github.og4dev.advice.GlobalResponseWrapper}, a Spring + * {@link org.springframework.web.bind.annotation.RestControllerAdvice} implementation that + * intercepts outgoing HTTP response bodies and encapsulates them within the standardized + * {@link io.github.og4dev.dto.ApiResponse} structure before they are serialized and sent + * to the client. *

*

- * The primary component within this package is the {@link io.github.og4dev.advice.GlobalResponseWrapper}. - * It facilitates the seamless, opt-in encapsulation of standard controller return values into the - * standardized {@link io.github.og4dev.dto.ApiResponse} format, significantly reducing boilerplate code. - *

- *

Integration & Usage

- *

- * Components in this package are automatically registered via Spring Boot's auto-configuration - * mechanism ({@code ApiResponseAutoConfiguration}). Developers do not need to manually scan, import, - * or configure this package. + * The wrapper is activation-based: it only processes controllers or methods annotated + * with {@link io.github.og4dev.annotation.AutoResponse @AutoResponse}, leaving all other + * endpoints completely unaffected. *

+ * + *

Registration

*

- * To activate the response wrapping capabilities provided by this package, simply annotate - * target REST controllers or specific methods with the {@link io.github.og4dev.annotation.AutoResponse @AutoResponse} - * annotation. + * {@link io.github.og4dev.advice.GlobalResponseWrapper} is registered automatically as a + * Spring bean by + * {@link io.github.og4dev.config.ApiResponseAutoConfiguration#globalResponseWrapper(tools.jackson.databind.ObjectMapper)}. + * No manual configuration is needed. *

* * @author Pasindu OG * @version 1.4.0 + * @since 1.4.0 * @see io.github.og4dev.advice.GlobalResponseWrapper * @see io.github.og4dev.annotation.AutoResponse - * @since 1.4.0 + * @see io.github.og4dev.dto.ApiResponse */ package io.github.og4dev.advice; \ No newline at end of file diff --git a/src/main/java/io/github/og4dev/annotation/AutoResponse.java b/src/main/java/io/github/og4dev/annotation/AutoResponse.java index 3360904..6f1b1eb 100644 --- a/src/main/java/io/github/og4dev/annotation/AutoResponse.java +++ b/src/main/java/io/github/og4dev/annotation/AutoResponse.java @@ -5,53 +5,99 @@ /** * Opt-in annotation to enable automatic API response wrapping for Spring REST controllers. *

- * When this annotation is applied to a {@link org.springframework.web.bind.annotation.RestController} - * class or a specific request mapping method, the {@link io.github.og4dev.advice.GlobalResponseWrapper} - * intercepts the returned object and automatically encapsulates it within the standardized - * {@link io.github.og4dev.dto.ApiResponse} format. + * When applied to a {@link org.springframework.web.bind.annotation.RestController} class or + * to a specific request-mapping method, {@link io.github.og4dev.advice.GlobalResponseWrapper} + * intercepts the returned object and encapsulates it within the standardized + * {@link io.github.og4dev.dto.ApiResponse} format before it is written to the HTTP response body. *

- *

Usage:

+ *

+ * This eliminates the need to manually wrap every return value in + * {@code ResponseEntity>}, reducing boilerplate while keeping the response + * contract consistent across the entire API surface. + *

+ * + *

Target Scopes

*
    - *
  • Class Level ({@link ElementType#TYPE}): Applies the wrapping behavior to all endpoint methods within the controller.
  • - *
  • Method Level ({@link ElementType#METHOD}): Applies the wrapping behavior only to the specific annotated method.
  • + *
  • Class Level ({@link ElementType#TYPE}): Applies automatic wrapping to + * all request-mapping methods within the annotated controller.
  • + *
  • Method Level ({@link ElementType#METHOD}): Applies automatic wrapping + * only to the specific annotated method, leaving others unaffected.
  • *
- *

Example:

+ * + *

Custom Response Message

+ *

+ * The {@link #message()} element controls the value of the {@code message} field in the + * produced {@link io.github.og4dev.dto.ApiResponse}. It defaults to {@code "Success"} and + * can be overridden at class or method level: + *

+ *
{@code
+ * @GetMapping("/{id}")
+ * @AutoResponse(message = "User retrieved successfully")
+ * public UserDto getUser(@PathVariable Long id) {
+ *     return userService.findById(id);
+ * }
+ * }
+ * + *

Usage Example

*
{@code
  * @RestController
  * @RequestMapping("/api/users")
- * @AutoResponse // All methods in this controller will be automatically wrapped
+ * @AutoResponse // All methods in this controller are automatically wrapped
  * public class UserController {
- * * @GetMapping("/{id}")
- * public UserDto getUser(@PathVariable Long id) {
- * // Returns: { "status": "Success", "content": { "id": 1, ... }, "timestamp": "..." }
- * return userService.findById(id);
- * }
- * * @PostMapping
- * @ResponseStatus(HttpStatus.CREATED)
- * // @AutoResponse can also be placed here for method-level granularity instead of class-level
- * public UserDto createUser(@RequestBody UserDto dto) {
- * return userService.create(dto);
- * }
+ *
+ *     @GetMapping("/{id}")
+ *     public UserDto getUser(@PathVariable Long id) {
+ *         // Response: { "status": 200, "message": "Success", "content": { ... }, "timestamp": "..." }
+ *         return userService.findById(id);
+ *     }
+ *
+ *     @PostMapping
+ *     @ResponseStatus(HttpStatus.CREATED)
+ *     public UserDto createUser(@RequestBody UserDto dto) {
+ *         // Response: { "status": 201, "message": "Success", "content": { ... }, "timestamp": "..." }
+ *         return userService.create(dto);
+ *     }
  * }
  * }
- *

Exclusions:

+ * + *

Excluded Return Types

*

- * To prevent errors and double-wrapping, the interceptor will safely ignore methods that return: + * The interceptor automatically skips wrapping for the following return types to prevent + * conflicts and double-wrapping: *

*
    - *
  • {@code ApiResponse} or {@code ResponseEntity} (Assumes the developer has explicitly formatted the response)
  • - *
  • {@code ProblemDetail} (RFC 9457 error responses managed by the global exception handler)
  • - *
  • {@code String} (Bypassed to avoid {@code ClassCastException} with Spring's internal string message converters)
  • + *
  • {@code ApiResponse} — already wrapped by the developer; wrapping again would produce + * {@code ApiResponse>}.
  • + *
  • {@code ResponseEntity} — the developer has explicitly configured the response; + * the wrapper respects that decision.
  • + *
  • {@code ProblemDetail} — RFC 9457 error responses produced by the global exception + * handler; must not be re-wrapped.
  • + *
  • {@code String} — handled with explicit JSON serialization via the injected + * {@code ObjectMapper} to avoid {@code ClassCastException} with Spring's + * {@code StringHttpMessageConverter}.
  • *
* * @author Pasindu OG * @version 1.4.0 + * @since 1.4.0 * @see io.github.og4dev.advice.GlobalResponseWrapper * @see io.github.og4dev.dto.ApiResponse - * @since 1.4.0 */ @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface AutoResponse { + + /** + * The message to include in the {@code message} field of the produced + * {@link io.github.og4dev.dto.ApiResponse}. + *

+ * When set at class level, all methods in the controller use this message unless + * overridden at the method level. When set at method level, it takes precedence + * over any class-level value. + *

+ * + * @return the response message; defaults to {@code "Success"} + */ + String message() default "Success"; } \ No newline at end of file diff --git a/src/main/java/io/github/og4dev/annotation/AutoTrim.java b/src/main/java/io/github/og4dev/annotation/AutoTrim.java index 768079e..e73bd6f 100644 --- a/src/main/java/io/github/og4dev/annotation/AutoTrim.java +++ b/src/main/java/io/github/og4dev/annotation/AutoTrim.java @@ -6,116 +6,91 @@ import java.lang.annotation.Target; /** - * Annotation to explicitly enable automatic string trimming during JSON deserialization. + * Opt-in annotation to enable automatic whitespace trimming for string fields during + * JSON deserialization. *

- * By default, the OG4Dev Spring API Response library does NOT automatically trim strings. - * This annotation allows you to opt-in to automatic trimming for specific fields or entire - * classes where removing leading and trailing whitespace is desired for data quality and consistency. - *

- *

- * Important: When {@code @AutoTrim} is applied, XSS validation (HTML tag detection) - * is still performed on the trimmed value to maintain security. + * By default the OG4Dev Spring API Response library does not modify string values. + * Placing {@code @AutoTrim} on a field or class opts in to automatic removal of leading + * and trailing whitespace at the deserialization layer, before the value reaches application + * code, ensuring consistent data quality without manual {@code .trim()} calls. *

* *

Target Scopes

*
    - *
  • Field Level ({@link ElementType#FIELD}): Applies trimming only to the specific annotated String field.
  • - *
  • Class Level ({@link ElementType#TYPE}): Applies trimming to all String fields within the annotated class globally.
  • + *
  • Field Level ({@link ElementType#FIELD}): Trims only the annotated + * {@code String} field; all other fields in the class are unaffected.
  • + *
  • Class Level ({@link ElementType#TYPE}): Trims all {@code String} + * fields within the annotated class without requiring per-field annotations.
  • *
* - *

Example Usage: Field Level

+ *

Example — Field Level

*
{@code
  * public class UserRegistrationDTO {
- * @AutoTrim
- * private String username;       // Trimmed: "  john_doe  " → "john_doe"
  *
- * @AutoTrim
- * private String email;          // Trimmed: " user@example.com " → "user@example.com"
+ *     @AutoTrim
+ *     private String username;   // "  john_doe  " → "john_doe"
  *
- * private String password;       // NOT trimmed (no annotation)
- * private String bio;            // NOT trimmed (no annotation)
- * }
- * }
+ * @AutoTrim + * private String email; // " user@example.com " → "user@example.com" * - *

Example Usage: Class Level

- *
{@code
- * @AutoTrim // Automatically applies to ALL String fields in this class!
- * public class GlobalTrimDTO {
- * private String firstName;      // Trimmed: "  John  " → "John"
- * private String lastName;       // Trimmed: " Doe  " → "Doe"
- * private String address;        // Trimmed: " 123 Main St " → "123 Main St"
+ *     private String password;   // untouched — "  secret  " → "  secret  "
  * }
  * }
* - *

Input/Output Examples (Class Level)

+ *

Example — Class Level

*
{@code
- * // Request JSON for GlobalTrimDTO
- * {
- * "firstName": "\t\nJohn\t\n",
- * "lastName": "  Doe  ",
- * "address": " 123 Main St "
+ * @AutoTrim
+ * public class AddressDTO {
+ *     private String street;   // "  123 Main St  " → "123 Main St"
+ *     private String city;     // "  London  " → "London"
+ *     private String postCode; // "  SW1A 1AA  " → "SW1A 1AA"
  * }
- *
- * // After Deserialization
- * firstName = "John"                  // ✓ Trimmed (due to class-level @AutoTrim)
- * lastName  = "Doe"                   // ✓ Trimmed (due to class-level @AutoTrim)
- * address   = "123 Main St"           // ✓ Trimmed (due to class-level @AutoTrim)
  * }
* - *

XSS Validation Still Active

+ *

Combining with {@code @XssCheck}

*

- * Even with {@code @AutoTrim}, all string values are still validated for XSS attacks. - * The following will still be rejected: + * Both annotations may be applied together. When combined, trimming is applied first + * and XSS validation is then performed on the trimmed value, ensuring that whitespace + * padding cannot be used to bypass HTML tag detection: *

*
{@code
- * {"username": "    "}  // Rejected: Contains HTML tags
- * {"email": "user@example.comtest"}          // Rejected: Contains HTML tags
+ * @AutoTrim
+ * @XssCheck
+ * private String comment; // First trimmed, then validated for HTML tags
  * }
* - *

Combining with @XssCheck

+ *

Null Value Handling

*

- * You can combine {@code @AutoTrim} with {@link XssCheck @XssCheck} for both behaviors: + * {@code null} values pass through unchanged and are never converted to empty strings: *

*
{@code
- * @AutoTrim // Trims all fields
- * public class SecureDTO {
- * @XssCheck
- * private String cleanInput;  // Both trimmed (from class scope) and XSS-validated
- * }
+ * {"name": null}   → name = null  (not "")
+ * {"name": ""}     → name = ""
+ * {"name": "  "}   → name = ""    (trimmed to empty)
  * }
* *

How It Works

*

- * This annotation is processed by the {@code AdvancedStringDeserializer} in + * This annotation is detected by the {@code AdvancedStringDeserializer} registered via * {@link io.github.og4dev.config.ApiResponseAutoConfiguration#strictJsonCustomizer()}. - * The deserializer uses {@link tools.jackson.databind.ValueDeserializer#createContextual} - * to detect the annotation on either the field itself or its declaring class, creating a - * specialized instance that enables trimming. + * The deserializer inspects each field's annotations — and the annotations on its + * declaring class — at mapper initialization time (once per field, not per request) and + * returns a contextual instance with trimming enabled when {@code @AutoTrim} is found. *

* - *

Null Value Handling

+ *

Performance

*

- * Null values are preserved and never converted to empty strings: - *

- *
{@code
- * {"name": null}      → name = null (not "")
- * {"name": ""}        → name = ""
- * {"name": "  "}      → name = ""   (trimmed to empty)
- * }
- * - *

Performance Considerations

- *

- * The trimming operation is highly optimized and adds negligible overhead (typically {@code <0.1ms} - * per field). The deserializer is created once per field during mapper initialization, - * not on every request, ensuring optimal runtime performance. + * Trimming adds negligible overhead (typically under {@code 0.1 ms} per field) because + * the contextual deserializer is created once during {@code ObjectMapper} initialization, + * not on every request. *

* * @author Pasindu OG * @version 1.4.0 - * @see io.github.og4dev.config.ApiResponseAutoConfiguration#strictJsonCustomizer() - * @see io.github.og4dev.annotation.XssCheck - * @see tools.jackson.databind.ValueDeserializer#createContextual * @since 1.3.0 + * @see XssCheck + * @see io.github.og4dev.config.ApiResponseAutoConfiguration#strictJsonCustomizer() + * @see io.github.og4dev.config.AdvancedStringDeserializer */ @Target({ElementType.TYPE, ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) diff --git a/src/main/java/io/github/og4dev/annotation/XssCheck.java b/src/main/java/io/github/og4dev/annotation/XssCheck.java index aadffea..6802b70 100644 --- a/src/main/java/io/github/og4dev/annotation/XssCheck.java +++ b/src/main/java/io/github/og4dev/annotation/XssCheck.java @@ -6,206 +6,135 @@ import java.lang.annotation.Target; /** - * Annotation to explicitly enable XSS (Cross-Site Scripting) validation for string fields during JSON deserialization. + * Opt-in annotation to enable XSS (Cross-Site Scripting) protection for string fields + * during JSON deserialization. *

- * By default, the OG4Dev Spring API Response library does NOT perform XSS validation on strings. - * This annotation allows you to opt-in to automatic HTML/XML tag detection and rejection for specific fields - * or entire classes where preventing malicious content injection is critical for security. + * By default the OG4Dev Spring API Response library does not validate string values. + * Placing {@code @XssCheck} on a field or class opts in to automatic HTML and XML tag + * detection at the deserialization layer. Any string containing a tag pattern is rejected + * immediately with an HTTP 400 Bad Request response — the malicious payload never reaches + * application logic or the database. *

*

- * Security Approach: This annotation implements a fail-fast rejection strategy - requests - * containing HTML tags are rejected entirely with a 400 Bad Request error. This is more secure than - * HTML escaping, as it prevents stored XSS, DOM-based XSS, and second-order injection vulnerabilities. + * This implements a fail-fast rejection strategy which is more secure than HTML + * escaping because it prevents stored XSS, DOM-based XSS, and second-order injection + * vulnerabilities at the earliest possible point. *

* *

Target Scopes

*
    - *
  • Field Level ({@link ElementType#FIELD}): Applies XSS validation only to the specific annotated String field.
  • - *
  • Class Level ({@link ElementType#TYPE}): Applies XSS validation to all String fields within the annotated class globally.
  • + *
  • Field Level ({@link ElementType#FIELD}): Validates only the annotated + * {@code String} field; all other fields in the class are unaffected.
  • + *
  • Class Level ({@link ElementType#TYPE}): Validates all {@code String} + * fields within the annotated class without requiring per-field annotations.
  • *
* - *

Example Usage: Field Level

+ *

Example — Field Level

*
{@code
  * public class CommentDTO {
- * @XssCheck
- * private String content;        // XSS validated - rejects HTML tags
  *
- * @XssCheck
- * private String authorName;     // XSS validated - rejects HTML tags
+ *     @XssCheck
+ *     private String content;    // Rejects HTML tags
  *
- * private String commentId;      // NOT validated (no annotation)
- * private Instant timestamp;     // NOT validated (not a string)
+ *     @XssCheck
+ *     private String authorName; // Rejects HTML tags
+ *
+ *     private String commentId;  // NOT validated (no annotation)
  * }
  * }
* - *

Example Usage: Class Level

+ *

Example — Class Level

*
{@code
- * @XssCheck // Automatically protects ALL String fields in this class!
+ * @XssCheck
  * public class SecureUserProfileDTO {
- * private String bio;            // XSS validated automatically
- * private String displayName;    // XSS validated automatically
- * private String websiteUrl;     // XSS validated automatically
+ *     private String bio;         // Validated automatically
+ *     private String displayName; // Validated automatically
+ *     private String websiteUrl;  // Validated automatically
  * }
  * }
* *

Valid and Invalid Inputs

*
{@code
- * // ✅ Valid inputs (accepted)
- * {"content": "Hello World"}                     // Plain text
- * {"content": "Price: $100 < $200"}              // Comparison operators (no tag)
- * {"content": "2 + 2 = 4"}                       // Math expressions
- * {"content": "Use angle brackets: 3 < 5"}       // Text with < but no HTML tag
- *
- * // ❌ Invalid inputs (rejected with 400 Bad Request)
- * {"content": ""}   // Script injection
- * {"content": ""}    // Image XSS attack
- * {"content": "Hello
World"} // HTML break tag - * {"content": ""} // HTML comment - * {"content": ""} // DOCTYPE declaration - * {"content": ""} // Closing tag - * {"content": "Bold text"} // HTML formatting + * // Accepted + * {"content": "Hello World"} // Plain text + * {"content": "Price: $100 < $200"} // Comparison operator, not an HTML tag + * {"content": "3 < 5 and 6 > 4"} // Arithmetic, not HTML + * + * // Rejected with 400 Bad Request + * {"content": ""} // Script injection + * {"content": ""} // Attribute-based XSS + * {"content": "Hello
World"} // HTML tag + * {"content": ""} // DOCTYPE declaration + * {"content": ""} // Closing tag * }
* - *

Error Response Format

+ *

Error Response

*

- * When HTML tags are detected, the request is rejected with a 400 Bad Request error: + * When a tag is detected the request is rejected with an RFC 9457 ProblemDetail response: *

*
{@code
  * {
- * "type": "about:blank",
- * "title": "Bad Request",
- * "status": 400,
- * "detail": "Security Error: HTML tags or XSS payloads are not allowed in the request.",
- * "traceId": "550e8400-e29b-41d4-a716-446655440000",
- * "timestamp": "2026-02-21T10:30:45.123Z"
+ *     "type": "about:blank",
+ *     "title": "Bad Request",
+ *     "status": 400,
+ *     "detail": "Security Error: HTML tags or XSS payloads are not allowed in the request.",
+ *     "traceId": "550e8400-e29b-41d4-a716-446655440000",
+ *     "timestamp": "2026-03-03T10:30:45.123Z"
  * }
  * }
* - *

XSS Detection Mechanism

- *

- * The validation uses a robust regex pattern: {@code (?s).*<\s*[a-zA-Z/!].*} - *

+ *

Detection Pattern

*

- * This pattern detects: + * Tags are detected with the regular expression {@code (?s).*<\s*[a-zA-Z/!].*} (DOTALL mode). + * It matches opening tags, closing tags, self-closing tags, HTML comments, DOCTYPE declarations, + * and tags spanning multiple lines. Bare {@code <} characters in mathematical comparisons + * (e.g., {@code 5 < 10}) are not matched because they are not followed by a letter, + * slash, or exclamation mark. *

- *
    - *
  • Opening tags: {@code }, {@code }
  • - *
  • Self-closing tags: {@code
    }, {@code }
  • - *
  • Special tags: {@code }, {@code }, {@code }
  • - *
  • Tags with attributes: {@code
    }, {@code }
  • - *
  • Multiline tags: Tags spanning multiple lines (DOTALL mode enabled)
  • - *
- *

- * What is NOT detected (safe to use): - *

- *
    - *
  • Mathematical comparisons: {@code 5 < 10}, {@code x > y}
  • - *
  • Arrows and symbols: {@code -> <-}, {@code <=>}
  • - *
  • Quoted examples: {@code "less than symbol: <"} (if properly escaped in JSON)
  • - *
* - *

Why Rejection Instead of Escaping?

+ *

Combining with {@code @AutoTrim}

*

- * This library uses a fail-fast rejection approach rather than HTML escaping (converting {@code <} to {@code <}). - * This is more secure because: + * Both annotations may be applied together. Trimming is always applied first so that + * whitespace-padded payloads (e.g., {@code " " + * private String comment; // "" - ``` ### 3. Opt-in String Trimming with @AutoTrim ✂️ @@ -307,10 +300,9 @@ Automatic whitespace removal for specific fields. ```java @AutoTrim private String username; // " john_doe " -> "john_doe" - ``` -### 4. Class-Level Protection (New in v1.4.0) 🛡️ +### 4. Class-Level Protection (New in v1.5.0) 🛡️ Apply annotations to the class level to automatically protect **ALL** string fields within that class! @@ -323,14 +315,13 @@ public class SecureRegistrationDTO { private String email; private String bio; } - ``` *(See full Security details in the Javadocs and examples above).* ## 🛡️ Built-in Exception Handling -The library includes a **production-ready `GlobalExceptionHandler**` that automatically handles 10 common exceptions using Spring Boot's **ProblemDetail (RFC 9457)** standard. +The library includes a **production-ready `GlobalExceptionHandler`** that automatically handles 10 common exceptions using Spring Boot's **ProblemDetail (RFC 9457)** standard. * **Automatic Logging:** SLF4J integration for all errors. * **Trace ID Consistency:** Logs and responses always have matching trace IDs. @@ -342,9 +333,30 @@ public class ResourceNotFoundException extends ApiException { super(String.format("%s not found with ID: %d", resource, id), HttpStatus.NOT_FOUND); } } +``` +### 🔄 Dynamic Exception Registry (New in v1.5.0) + +Seamlessly map 3rd-party or framework-specific exceptions (like `SQLException`, `MongoException`, or `AuthenticationException`) to standard `ProblemDetail` responses without writing custom `@ExceptionHandler` methods! + +Simply define a configuration bean to register your external exceptions: + +```java +@Configuration +public class ApiExceptionConfig { + @Bean + public ApiExceptionRegistry apiExceptionRegistry() { + return new ApiExceptionRegistry() + // Map Database Exceptions + .register(SQLException.class, HttpStatus.INTERNAL_SERVER_ERROR, "A database error occurred.") + // Map Security Exceptions + .register(AuthenticationException.class, HttpStatus.UNAUTHORIZED, "Invalid authentication token."); + } +} ``` +All registered exceptions will automatically be caught by the `GlobalExceptionHandler` and formatted into RFC 9457 compliant JSON responses with trace IDs. + ## 🌍 Real-World Examples ### Example 1: Clean CRUD Controller (Using Class-Level @AutoResponse) @@ -381,7 +393,6 @@ public class ProductController { // Returns empty content with 200 OK automatically } } - ``` ## 📚 API Reference @@ -390,26 +401,27 @@ public class ProductController { ## 📈 Version History -### 1.4.0 (February 2026) - Current Release +### 1.5.0 (April 2026) - **Current Release** ✨ **New Features & Improvements:** -* **@AutoResponse Annotation & GlobalResponseWrapper** -* Opt-in automatic response wrapping to eliminate boilerplate code. -* **Improved Granularity:** Fully supports both Class-level (`ElementType.TYPE`) and Method-level (`ElementType.METHOD`) placement for precision control over which endpoints are wrapped. -* Returns raw DTOs from controllers and automatically wraps them in `ApiResponse`. -* Preserves HTTP status codes from `@ResponseStatus`. -* Intelligently skips `ResponseEntity`, `ApiResponse`, and `ProblemDetail` to prevent double-wrapping. -* **Intelligent String Handling:** Uses Spring's `ObjectMapper` to safely serialize raw `String` returns to JSON, avoiding `ClassCastException` with native converters. +* **Dynamic Exception Registry (`ApiExceptionRegistry`)** + * Centralized mapping for 3rd-party exceptions (e.g., SQL, Mongo, Spring Security) without writing custom handlers. + * Thread-safe registry preserving insertion order for hierarchy-based exception catching. +* **@AutoResponse Annotation & GlobalResponseWrapper** + * Opt-in automatic response wrapping to eliminate boilerplate code. + * **Improved Granularity:** Fully supports both Class-level (`ElementType.TYPE`) and Method-level (`ElementType.METHOD`) placement for precision control over which endpoints are wrapped. + * Returns raw DTOs from controllers and automatically wraps them in `ApiResponse`. + * Preserves HTTP status codes from `@ResponseStatus`. + * Intelligently skips `ResponseEntity`, `ApiResponse`, and `ProblemDetail` to prevent double-wrapping. + * **Intelligent String Handling:** Uses Spring's `ObjectMapper` to safely serialize raw `String` returns to JSON, avoiding `ClassCastException` with native converters. * **Class-Level Security Annotations** -* `@AutoTrim` and `@XssCheck` can now be applied at the Class level (`ElementType.TYPE`) to automatically protect all String fields within the DTO at once. - + * `@AutoTrim` and `@XssCheck` can now be applied at the Class level (`ElementType.TYPE`) to automatically protect all String fields within the DTO at once. * **Documentation** -* `package-info.java` documentation added for the new `advice` package. - + * `package-info.java` documentation added for the new `advice` package. ### 1.3.0 (February 2026) diff --git a/pom.xml b/pom.xml index 8d66f6c..109a436 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ io.github.og4dev og4dev-spring-response - 1.4.0 + 1.5.0 OG4Dev Spring API Response A lightweight, zero-configuration REST API Response wrapper and Global Exception Handler (RFC 9457) for Spring Boot applications, maintained by OG4Dev. @@ -36,7 +36,7 @@ 17 17 - 4.0.3 + 4.0.5 UTF-8 diff --git a/src/main/java/io/github/og4dev/advice/GlobalResponseWrapper.java b/src/main/java/io/github/og4dev/advice/GlobalResponseWrapper.java index f057e20..f5245ee 100644 --- a/src/main/java/io/github/og4dev/advice/GlobalResponseWrapper.java +++ b/src/main/java/io/github/og4dev/advice/GlobalResponseWrapper.java @@ -47,7 +47,7 @@ * * * @author Pasindu OG - * @version 1.4.0 + * @version 1.5.0 * @since 1.4.0 * @see AutoResponse * @see ApiResponse @@ -141,7 +141,7 @@ public boolean supports(MethodParameter returnType, Class apiResponse = ApiResponse.status(httpStatus.is2xxSuccessful() ? responseMessage : "Processed", body, httpStatus).getBody(); + var apiResponse = ApiResponse.status(httpStatus.is2xxSuccessful() ? responseMessage : "Processed", body, httpStatus).getBody(); if (body instanceof String) { try { diff --git a/src/main/java/io/github/og4dev/advice/package-info.java b/src/main/java/io/github/og4dev/advice/package-info.java index 431bd74..6f1bd74 100644 --- a/src/main/java/io/github/og4dev/advice/package-info.java +++ b/src/main/java/io/github/og4dev/advice/package-info.java @@ -22,7 +22,7 @@ *

* * @author Pasindu OG - * @version 1.4.0 + * @version 1.5.0 * @since 1.4.0 * @see io.github.og4dev.advice.GlobalResponseWrapper * @see io.github.og4dev.annotation.AutoResponse diff --git a/src/main/java/io/github/og4dev/annotation/AutoResponse.java b/src/main/java/io/github/og4dev/annotation/AutoResponse.java index 6f1b1eb..01659c6 100644 --- a/src/main/java/io/github/og4dev/annotation/AutoResponse.java +++ b/src/main/java/io/github/og4dev/annotation/AutoResponse.java @@ -78,7 +78,7 @@ * * * @author Pasindu OG - * @version 1.4.0 + * @version 1.5.0 * @since 1.4.0 * @see io.github.og4dev.advice.GlobalResponseWrapper * @see io.github.og4dev.dto.ApiResponse diff --git a/src/main/java/io/github/og4dev/annotation/AutoTrim.java b/src/main/java/io/github/og4dev/annotation/AutoTrim.java index e73bd6f..21cb47b 100644 --- a/src/main/java/io/github/og4dev/annotation/AutoTrim.java +++ b/src/main/java/io/github/og4dev/annotation/AutoTrim.java @@ -6,91 +6,116 @@ import java.lang.annotation.Target; /** - * Opt-in annotation to enable automatic whitespace trimming for string fields during - * JSON deserialization. + * Annotation to explicitly enable automatic string trimming during JSON deserialization. *

- * By default the OG4Dev Spring API Response library does not modify string values. - * Placing {@code @AutoTrim} on a field or class opts in to automatic removal of leading - * and trailing whitespace at the deserialization layer, before the value reaches application - * code, ensuring consistent data quality without manual {@code .trim()} calls. + * By default, the OG4Dev Spring API Response library does NOT automatically trim strings. + * This annotation allows you to opt-in to automatic trimming for specific fields or entire + * classes where removing leading and trailing whitespace is desired for data quality and consistency. + *

+ *

+ * Important: When {@code @AutoTrim} is applied, XSS validation (HTML tag detection) + * is still performed on the trimmed value to maintain security. *

* *

Target Scopes

*
    - *
  • Field Level ({@link ElementType#FIELD}): Trims only the annotated - * {@code String} field; all other fields in the class are unaffected.
  • - *
  • Class Level ({@link ElementType#TYPE}): Trims all {@code String} - * fields within the annotated class without requiring per-field annotations.
  • + *
  • Field Level ({@link ElementType#FIELD}): Applies trimming only to the specific annotated String field.
  • + *
  • Class Level ({@link ElementType#TYPE}): Applies trimming to all String fields within the annotated class globally.
  • *
* - *

Example — Field Level

+ *

Example Usage: Field Level

*
{@code
  * public class UserRegistrationDTO {
+ * @AutoTrim
+ * private String username;       // Trimmed: "  john_doe  " → "john_doe"
  *
- *     @AutoTrim
- *     private String username;   // "  john_doe  " → "john_doe"
+ * @AutoTrim
+ * private String email;          // Trimmed: " user@example.com " → "user@example.com"
  *
- *     @AutoTrim
- *     private String email;      // " user@example.com " → "user@example.com"
+ * private String password;       // NOT trimmed (no annotation)
+ * private String bio;            // NOT trimmed (no annotation)
+ * }
+ * }
* - * private String password; // untouched — " secret " → " secret " + *

Example Usage: Class Level

+ *
{@code
+ * @AutoTrim // Automatically applies to ALL String fields in this class!
+ * public class GlobalTrimDTO {
+ * private String firstName;      // Trimmed: "  John  " → "John"
+ * private String lastName;       // Trimmed: " Doe  " → "Doe"
+ * private String address;        // Trimmed: " 123 Main St " → "123 Main St"
  * }
  * }
* - *

Example — Class Level

+ *

Input/Output Examples (Class Level)

*
{@code
- * @AutoTrim
- * public class AddressDTO {
- *     private String street;   // "  123 Main St  " → "123 Main St"
- *     private String city;     // "  London  " → "London"
- *     private String postCode; // "  SW1A 1AA  " → "SW1A 1AA"
+ * // Request JSON for GlobalTrimDTO
+ * {
+ * "firstName": "\t\nJohn\t\n",
+ * "lastName": "  Doe  ",
+ * "address": " 123 Main St "
  * }
+ *
+ * // After Deserialization
+ * firstName = "John"                  // ✓ Trimmed (due to class-level @AutoTrim)
+ * lastName  = "Doe"                   // ✓ Trimmed (due to class-level @AutoTrim)
+ * address   = "123 Main St"           // ✓ Trimmed (due to class-level @AutoTrim)
  * }
* - *

Combining with {@code @XssCheck}

+ *

XSS Validation Still Active

*

- * Both annotations may be applied together. When combined, trimming is applied first - * and XSS validation is then performed on the trimmed value, ensuring that whitespace - * padding cannot be used to bypass HTML tag detection: + * Even with {@code @AutoTrim}, all string values are still validated for XSS attacks. + * The following will still be rejected: *

*
{@code
- * @AutoTrim
- * @XssCheck
- * private String comment; // First trimmed, then validated for HTML tags
+ * {"username": "    "}  // Rejected: Contains HTML tags
+ * {"email": "user@example.comtest"}          // Rejected: Contains HTML tags
  * }
* - *

Null Value Handling

+ *

Combining with @XssCheck

*

- * {@code null} values pass through unchanged and are never converted to empty strings: + * You can combine {@code @AutoTrim} with {@link XssCheck @XssCheck} for both behaviors: *

*
{@code
- * {"name": null}   → name = null  (not "")
- * {"name": ""}     → name = ""
- * {"name": "  "}   → name = ""    (trimmed to empty)
+ * @AutoTrim // Trims all fields
+ * public class SecureDTO {
+ * @XssCheck
+ * private String cleanInput;  // Both trimmed (from class scope) and XSS-validated
+ * }
  * }
* *

How It Works

*

- * This annotation is detected by the {@code AdvancedStringDeserializer} registered via + * This annotation is processed by the {@code AdvancedStringDeserializer} in * {@link io.github.og4dev.config.ApiResponseAutoConfiguration#strictJsonCustomizer()}. - * The deserializer inspects each field's annotations — and the annotations on its - * declaring class — at mapper initialization time (once per field, not per request) and - * returns a contextual instance with trimming enabled when {@code @AutoTrim} is found. + * The deserializer uses {@link tools.jackson.databind.ValueDeserializer#createContextual} + * to detect the annotation on either the field itself or its declaring class, creating a + * specialized instance that enables trimming. *

* - *

Performance

+ *

Null Value Handling

*

- * Trimming adds negligible overhead (typically under {@code 0.1 ms} per field) because - * the contextual deserializer is created once during {@code ObjectMapper} initialization, - * not on every request. + * Null values are preserved and never converted to empty strings: + *

+ *
{@code
+ * {"name": null}      → name = null (not "")
+ * {"name": ""}        → name = ""
+ * {"name": "  "}      → name = ""   (trimmed to empty)
+ * }
+ * + *

Performance Considerations

+ *

+ * The trimming operation is highly optimized and adds negligible overhead (typically {@code <0.1ms} + * per field). The deserializer is created once per field during mapper initialization, + * not on every request, ensuring optimal runtime performance. *

* * @author Pasindu OG - * @version 1.4.0 - * @since 1.3.0 - * @see XssCheck + * @version 1.5.0 * @see io.github.og4dev.config.ApiResponseAutoConfiguration#strictJsonCustomizer() - * @see io.github.og4dev.config.AdvancedStringDeserializer + * @see io.github.og4dev.annotation.XssCheck + * @see tools.jackson.databind.ValueDeserializer#createContextual + * @since 1.3.0 */ @Target({ElementType.TYPE, ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) diff --git a/src/main/java/io/github/og4dev/annotation/XssCheck.java b/src/main/java/io/github/og4dev/annotation/XssCheck.java index 6802b70..7eb8dac 100644 --- a/src/main/java/io/github/og4dev/annotation/XssCheck.java +++ b/src/main/java/io/github/og4dev/annotation/XssCheck.java @@ -6,135 +6,206 @@ import java.lang.annotation.Target; /** - * Opt-in annotation to enable XSS (Cross-Site Scripting) protection for string fields - * during JSON deserialization. + * Annotation to explicitly enable XSS (Cross-Site Scripting) validation for string fields during JSON deserialization. *

- * By default the OG4Dev Spring API Response library does not validate string values. - * Placing {@code @XssCheck} on a field or class opts in to automatic HTML and XML tag - * detection at the deserialization layer. Any string containing a tag pattern is rejected - * immediately with an HTTP 400 Bad Request response — the malicious payload never reaches - * application logic or the database. + * By default, the OG4Dev Spring API Response library does NOT perform XSS validation on strings. + * This annotation allows you to opt-in to automatic HTML/XML tag detection and rejection for specific fields + * or entire classes where preventing malicious content injection is critical for security. *

*

- * This implements a fail-fast rejection strategy which is more secure than HTML - * escaping because it prevents stored XSS, DOM-based XSS, and second-order injection - * vulnerabilities at the earliest possible point. + * Security Approach: This annotation implements a fail-fast rejection strategy - requests + * containing HTML tags are rejected entirely with a 400 Bad Request error. This is more secure than + * HTML escaping, as it prevents stored XSS, DOM-based XSS, and second-order injection vulnerabilities. *

* *

Target Scopes

*
    - *
  • Field Level ({@link ElementType#FIELD}): Validates only the annotated - * {@code String} field; all other fields in the class are unaffected.
  • - *
  • Class Level ({@link ElementType#TYPE}): Validates all {@code String} - * fields within the annotated class without requiring per-field annotations.
  • + *
  • Field Level ({@link ElementType#FIELD}): Applies XSS validation only to the specific annotated String field.
  • + *
  • Class Level ({@link ElementType#TYPE}): Applies XSS validation to all String fields within the annotated class globally.
  • *
* - *

Example — Field Level

+ *

Example Usage: Field Level

*
{@code
  * public class CommentDTO {
+ * @XssCheck
+ * private String content;        // XSS validated - rejects HTML tags
  *
- *     @XssCheck
- *     private String content;    // Rejects HTML tags
- *
- *     @XssCheck
- *     private String authorName; // Rejects HTML tags
+ * @XssCheck
+ * private String authorName;     // XSS validated - rejects HTML tags
  *
- *     private String commentId;  // NOT validated (no annotation)
+ * private String commentId;      // NOT validated (no annotation)
+ * private Instant timestamp;     // NOT validated (not a string)
  * }
  * }
* - *

Example — Class Level

+ *

Example Usage: Class Level

*
{@code
- * @XssCheck
+ * @XssCheck // Automatically protects ALL String fields in this class!
  * public class SecureUserProfileDTO {
- *     private String bio;         // Validated automatically
- *     private String displayName; // Validated automatically
- *     private String websiteUrl;  // Validated automatically
+ * private String bio;            // XSS validated automatically
+ * private String displayName;    // XSS validated automatically
+ * private String websiteUrl;     // XSS validated automatically
  * }
  * }
* *

Valid and Invalid Inputs

*
{@code
- * // Accepted
- * {"content": "Hello World"}               // Plain text
- * {"content": "Price: $100 < $200"}        // Comparison operator, not an HTML tag
- * {"content": "3 < 5 and 6 > 4"}          // Arithmetic, not HTML
- *
- * // Rejected with 400 Bad Request
- * {"content": ""} // Script injection
- * {"content": ""}   // Attribute-based XSS
- * {"content": "Hello
World"} // HTML tag - * {"content": ""} // DOCTYPE declaration - * {"content": ""} // Closing tag + * // ✅ Valid inputs (accepted) + * {"content": "Hello World"} // Plain text + * {"content": "Price: $100 < $200"} // Comparison operators (no tag) + * {"content": "2 + 2 = 4"} // Math expressions + * {"content": "Use angle brackets: 3 < 5"} // Text with < but no HTML tag + * + * // ❌ Invalid inputs (rejected with 400 Bad Request) + * {"content": ""} // Script injection + * {"content": ""} // Image XSS attack + * {"content": "Hello
World"} // HTML break tag + * {"content": ""} // HTML comment + * {"content": ""} // DOCTYPE declaration + * {"content": ""} // Closing tag + * {"content": "Bold text"} // HTML formatting * }
* - *

Error Response

+ *

Error Response Format

*

- * When a tag is detected the request is rejected with an RFC 9457 ProblemDetail response: + * When HTML tags are detected, the request is rejected with a 400 Bad Request error: *

*
{@code
  * {
- *     "type": "about:blank",
- *     "title": "Bad Request",
- *     "status": 400,
- *     "detail": "Security Error: HTML tags or XSS payloads are not allowed in the request.",
- *     "traceId": "550e8400-e29b-41d4-a716-446655440000",
- *     "timestamp": "2026-03-03T10:30:45.123Z"
+ * "type": "about:blank",
+ * "title": "Bad Request",
+ * "status": 400,
+ * "detail": "Security Error: HTML tags or XSS payloads are not allowed in the request.",
+ * "traceId": "550e8400-e29b-41d4-a716-446655440000",
+ * "timestamp": "2026-02-21T10:30:45.123Z"
  * }
  * }
* - *

Detection Pattern

+ *

XSS Detection Mechanism

+ *

+ * The validation uses a robust regex pattern: {@code (?s).*<\s*[a-zA-Z/!].*} + *

*

- * Tags are detected with the regular expression {@code (?s).*<\s*[a-zA-Z/!].*} (DOTALL mode). - * It matches opening tags, closing tags, self-closing tags, HTML comments, DOCTYPE declarations, - * and tags spanning multiple lines. Bare {@code <} characters in mathematical comparisons - * (e.g., {@code 5 < 10}) are not matched because they are not followed by a letter, - * slash, or exclamation mark. + * This pattern detects: *

+ *
    + *
  • Opening tags: {@code }, {@code }
  • + *
  • Self-closing tags: {@code
    }, {@code }
  • + *
  • Special tags: {@code }, {@code }, {@code }
  • + *
  • Tags with attributes: {@code
    }, {@code }
  • + *
  • Multiline tags: Tags spanning multiple lines (DOTALL mode enabled)
  • + *
+ *

+ * What is NOT detected (safe to use): + *

+ *
    + *
  • Mathematical comparisons: {@code 5 < 10}, {@code x > y}
  • + *
  • Arrows and symbols: {@code -> <-}, {@code <=>}
  • + *
  • Quoted examples: {@code "less than symbol: <"} (if properly escaped in JSON)
  • + *
* - *

Combining with {@code @AutoTrim}

+ *

Why Rejection Instead of Escaping?

*

- * Both annotations may be applied together. Trimming is always applied first so that - * whitespace-padded payloads (e.g., {@code " " * - * @AutoTrim * @XssCheck - * private String bio; // Trimmed first, then XSS-validated + * private String authorName; // Rejects: "John" * - * private String role; // Untouched + * private String commentId; // Allows: "" (no validation) * } * } * - *

Class-Level Usage

+ *

3. Combining Both Annotations

+ *

+ * Use both annotations together for fields that need trimming AND XSS validation: + *

*
{@code
- * @AutoTrim
- * @XssCheck
+ * import io.github.og4dev.annotation.AutoTrim;
+ * import io.github.og4dev.annotation.XssCheck;
+ *
  * public class SecureInputDTO {
- *     private String firstName; // Trimmed and XSS-validated automatically
- *     private String lastName;  // Trimmed and XSS-validated automatically
+ *     @AutoTrim
+ *     @XssCheck
+ *     private String username;  // First trimmed, then XSS-validated
+ *
+ *     @XssCheck
+ *     private String comment;   // Only XSS-validated (not trimmed)
+ *
+ *     @AutoTrim
+ *     private String email;     // Only trimmed (not XSS-validated)
+ *
+ *     private String bio;       // Neither (preserved as-is)
  * }
  * }
* - *

Processing Order

+ *

Processing Order:

*

- * When both {@code @AutoTrim} and {@code @XssCheck} are active on the same field, - * processing always occurs in this order: + * When both annotations are present on a field, processing happens in this order: *

*
    - *
  1. The string value is trimmed.
  2. - *
  3. The trimmed value is checked for HTML or XML tags.
  4. - *
  5. If a tag is found, an {@link java.lang.IllegalArgumentException} is thrown, - * which produces an HTTP 400 Bad Request response.
  6. + *
  7. String is trimmed (if {@code @AutoTrim} is present)
  8. + *
  9. Trimmed string is checked for HTML tags (if {@code @XssCheck} is present)
  10. + *
  11. If HTML tags found, an exception is thrown
  12. *
* - *

Integration

+ *

Migration from v1.2.0:

+ *

+ * Version 1.2.0 automatically trimmed and validated all string fields. Version 1.3.0 requires + * explicit annotations. To maintain the same behavior: + *

+ *
{@code
+ * // v1.2.0 (automatic)
+ * public class UserDTO {
+ *     private String username;  // Was automatically trimmed
+ * }
+ *
+ * // v1.3.0 (opt-in)
+ * import io.github.og4dev.annotation.AutoTrim;
+ * import io.github.og4dev.annotation.XssCheck;
+ *
+ * public class UserDTO {
+ *     @AutoTrim
+ *     @XssCheck
+ *     private String username;  // Now explicitly enabled
+ * }
+ * }
+ * + *

Integration with Jackson:

*

- * {@code @AutoTrim} and {@code @XssCheck} are processed by - * {@link io.github.og4dev.config.AdvancedStringDeserializer}, which is registered with - * Jackson via + * These annotations are processed by the {@code AdvancedStringDeserializer} registered in * {@link io.github.og4dev.config.ApiResponseAutoConfiguration#strictJsonCustomizer()}. - * Annotation detection happens once per field during {@code ObjectMapper} initialization, - * not on every request, keeping runtime overhead negligible. + * The deserializer uses Jackson's contextual deserialization mechanism + * ({@link tools.jackson.databind.ValueDeserializer#createContextual}) to detect + * annotations and create specialized deserializer instances with the appropriate behavior. + *

+ * + *

Performance:

+ *

+ * The annotation detection and deserializer creation happens once per field during Jackson + * ObjectMapper initialization, not on every request. This ensures optimal runtime performance + * with negligible overhead (typically <1ms per request). *

* * @author Pasindu OG - * @version 1.4.0 - * @since 1.3.0 - * @see io.github.og4dev.annotation.AutoResponse + * @version 1.5.0 * @see io.github.og4dev.annotation.AutoTrim * @see io.github.og4dev.annotation.XssCheck * @see io.github.og4dev.config.ApiResponseAutoConfiguration - * @see io.github.og4dev.config.AdvancedStringDeserializer + * @since 1.3.0 */ -package io.github.og4dev.annotation; - +package io.github.og4dev.annotation; \ No newline at end of file diff --git a/src/main/java/io/github/og4dev/config/AdvancedStringDeserializer.java b/src/main/java/io/github/og4dev/config/AdvancedStringDeserializer.java deleted file mode 100644 index 1c9454f..0000000 --- a/src/main/java/io/github/og4dev/config/AdvancedStringDeserializer.java +++ /dev/null @@ -1,188 +0,0 @@ -package io.github.og4dev.config; - -import io.github.og4dev.annotation.AutoTrim; -import io.github.og4dev.annotation.XssCheck; -import tools.jackson.core.JacksonException; -import tools.jackson.core.JsonParser; -import tools.jackson.databind.BeanProperty; -import tools.jackson.databind.DeserializationContext; -import tools.jackson.databind.ValueDeserializer; -import tools.jackson.databind.deser.std.StdScalarDeserializer; - -import java.lang.annotation.Annotation; - -/** - * Custom Jackson {@code String} deserializer that provides opt-in XSS validation and - * automatic whitespace trimming at the deserialization layer. - *

- * This deserializer is registered by - * {@link ApiResponseAutoConfiguration#strictJsonCustomizer()} and is applied to every - * {@code String} field during JSON deserialization. Its active behavior is determined - * contextually per field via {@link #createContextual(DeserializationContext, BeanProperty)}: - * by default, neither trimming nor XSS validation is performed. Features are activated - * only when the corresponding annotation is present on the field or its declaring class. - *

- * - *

Activation Rules

- *
    - *
  • Trimming — enabled when {@link AutoTrim @AutoTrim} is present on the field - * or on the enclosing class.
  • - *
  • XSS Validation — enabled when {@link XssCheck @XssCheck} is present on the - * field or on the enclosing class.
  • - *
- * - *

Processing Order

- *

- * When both features are active, trimming is applied first and XSS validation is performed - * on the trimmed value, ensuring that whitespace-padded payloads do not bypass detection. - *

- * - *

XSS Detection Pattern

- *

- * HTML and XML tags are detected using the regular expression {@code (?s).*<\s*[a-zA-Z/!].*}. - * When a match is found the deserializer throws an {@link IllegalArgumentException} which - * propagates as an HTTP 400 Bad Request response via the global exception handler. - *

- * - *

Null Value Handling

- *

- * {@code null} values are returned unchanged — they are never converted to empty strings - * and are not subject to trimming or XSS validation. - *

- * - * @author Pasindu OG - * @version 1.4.0 - * @since 1.3.0 - * @see AutoTrim - * @see XssCheck - * @see ApiResponseAutoConfiguration#strictJsonCustomizer() - */ -public class AdvancedStringDeserializer extends StdScalarDeserializer { - - /** - * Whether leading and trailing whitespace should be stripped from deserialized strings. - */ - private final boolean shouldTrim; - - /** - * Whether strings should be validated against an HTML/XSS tag detection pattern. - */ - private final boolean shouldXssCheck; - - /** - * Constructs a default {@code AdvancedStringDeserializer} with both trimming and - * XSS validation disabled. - *

- * This no-arg constructor is used when registering the deserializer with the Jackson - * module. Contextual instances with the appropriate flags are created per field by - * {@link #createContextual(DeserializationContext, BeanProperty)}. - *

- */ - public AdvancedStringDeserializer() { - super(String.class); - this.shouldTrim = false; - this.shouldXssCheck = false; - } - - /** - * Constructs a fully configured {@code AdvancedStringDeserializer} with explicit - * control over trimming and XSS validation behavior. - *

- * Instances with specific flags are created by - * {@link #createContextual(DeserializationContext, BeanProperty)} after inspecting - * the annotations on the target field and its declaring class. - *

- * - * @param shouldTrim {@code true} to strip leading and trailing whitespace - * @param shouldXssCheck {@code true} to reject strings containing HTML or XML tags - */ - public AdvancedStringDeserializer(boolean shouldTrim, boolean shouldXssCheck) { - super(String.class); - this.shouldTrim = shouldTrim; - this.shouldXssCheck = shouldXssCheck; - } - - /** - * Deserializes a JSON string value, applying trimming and/or XSS validation - * according to the flags set on this instance. - * - * @param p the JSON parser positioned at the current string token - * @param ctxt the deserialization context - * @return the processed string value, or {@code null} if the token value is {@code null} - * @throws JacksonException if a JSON parsing error occurs - * @throws IllegalArgumentException if XSS validation is enabled and the value contains - * HTML or XML tags - */ - @Override - public String deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { - String value = p.getValueAsString(); - if (value == null) return null; - String processedValue = shouldTrim ? value.trim() : value; - validateXss(processedValue); - return processedValue; - } - - /** - * Creates a contextual deserializer instance configured for the specific field being - * deserialized. - *

- * This method is called once per field during Jackson's mapper initialization. It inspects - * the annotations on the {@link BeanProperty} and its declaring class to determine whether - * trimming ({@link AutoTrim @AutoTrim}) and/or XSS validation ({@link XssCheck @XssCheck}) - * should be enabled, and returns an appropriately configured instance. - *

- * - * @param ct the deserialization context - * @param property the bean property being deserialized; {@code null} when no property - * context is available, in which case this instance is returned unchanged - * @return a contextual {@code AdvancedStringDeserializer} configured for the target field, - * or {@code this} if {@code property} is {@code null} - * @throws JacksonException if a Jackson processing error occurs during context creation - */ - @Override - public ValueDeserializer createContextual(DeserializationContext ct, BeanProperty property) throws JacksonException { - if (property == null) { - return this; - } - - boolean trim = hasAnnotation(property, AutoTrim.class); - boolean xss = hasAnnotation(property, XssCheck.class); - return new AdvancedStringDeserializer(trim, xss); - } - - /** - * Validates the given string value against the XSS detection pattern when XSS - * validation is enabled on this instance. - *

- * Uses the regular expression {@code (?s).*<\s*[a-zA-Z/!].*} in DOTALL mode to detect - * HTML and XML tags, including opening tags, closing tags, self-closing tags, comments, - * and DOCTYPE declarations spanning single or multiple lines. - *

- * - * @param value the string to validate; must not be {@code null} - * @throws IllegalArgumentException if {@code shouldXssCheck} is {@code true} and the - * value contains HTML or XML tags - */ - private void validateXss(String value) { - if (shouldXssCheck && value.matches("(?s).*<\\s*[a-zA-Z/!].*")) - throw new IllegalArgumentException("Security Error: HTML tags or XSS payloads are not allowed in the request."); - } - - /** - * Checks whether a given annotation is present on the bean property itself or on its - * declaring class, supporting both field-level and class-level annotation scopes. - * - * @param property the bean property to inspect; must not be {@code null} - * @param annotationClass the annotation type to look for; must not be {@code null} - * @return {@code true} if the annotation is found on the field or its declaring class; - * {@code false} otherwise - */ - private boolean hasAnnotation(BeanProperty property, Class annotationClass) { - if (property.getAnnotation(annotationClass) != null) return true; - if (property.getMember() != null) { - Class declaringClass = property.getMember().getDeclaringClass(); - return declaringClass != null && declaringClass.getAnnotation(annotationClass) != null; - } - return false; - } -} diff --git a/src/main/java/io/github/og4dev/config/ApiResponseAutoConfiguration.java b/src/main/java/io/github/og4dev/config/ApiResponseAutoConfiguration.java index 71de493..ba67ede 100644 --- a/src/main/java/io/github/og4dev/config/ApiResponseAutoConfiguration.java +++ b/src/main/java/io/github/og4dev/config/ApiResponseAutoConfiguration.java @@ -4,142 +4,153 @@ import io.github.og4dev.annotation.AutoResponse; import io.github.og4dev.annotation.AutoTrim; import io.github.og4dev.annotation.XssCheck; -import io.github.og4dev.exception.ApiExceptionTranslator; +import io.github.og4dev.exception.ApiExceptionRegistry; import io.github.og4dev.exception.GlobalExceptionHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import tools.jackson.databind.DeserializationFeature; -import tools.jackson.databind.MapperFeature; -import tools.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.*; +import tools.jackson.databind.deser.std.StdScalarDeserializer; import tools.jackson.databind.module.SimpleModule; -import java.util.List; - /** - * Spring Boot autoconfiguration class for the OG4Dev Spring API Response Library. + * Autoconfiguration class for the OG4Dev Spring API Response Library. *

- * This class is loaded automatically by Spring Boot's autoconfiguration mechanism - * when the library is on the classpath — no manual {@code @ComponentScan} or - * {@code @Import} annotation is required. It registers all core library beans and - * applies a Jackson customizer that enforces strict, secure JSON deserialization. + * This configuration is automatically loaded by Spring Boot's autoconfiguration mechanism + * when the library is present on the classpath. It registers essential beans required for + * the library to function properly, including the comprehensive global exception handler + * and the automatic response wrapper. *

- * - *

Beans Registered

+ *

+ * Zero Configuration Required: Simply adding the library dependency enables all features + * automatically. No manual {@code @ComponentScan} or {@code @Import} annotations are needed. + *

+ *

What Gets Auto-Configured:

+ *
    + *
  • {@link GlobalExceptionHandler} - Comprehensive exception handling with RFC 9457 ProblemDetail format *
      - *
    • {@link GlobalExceptionHandler} — Central RFC 9457 exception handler with - * 10 built-in handlers and support for {@link ApiExceptionTranslator} beans.
    • - *
    • {@link GlobalResponseWrapper} — Opt-in response envelope (activated by - * {@link AutoResponse @AutoResponse}); conditionally skipped when the developer - * provides their own {@code GlobalResponseWrapper} bean.
    • - *
    • {@link JsonMapperBuilderCustomizer} — Applies strict property validation, - * case-insensitive enum handling, and the {@link AdvancedStringDeserializer} - * for opt-in {@link AutoTrim @AutoTrim} / {@link XssCheck @XssCheck} support.
    • + *
    • 10 built-in exception handlers covering all common error scenarios
    • + *
    • Automatic trace ID generation and logging
    • + *
    • Validation error aggregation and formatting
    • + *
    • Production-ready error messages
    • *
    - * - *

    How Autoconfiguration Works

    + *
  • + *
  • {@link GlobalResponseWrapper} - Automatic wrapping of controller responses (Opt-in via {@code @AutoResponse})
  • + *
+ *

How It Works:

*

- * Spring Boot 3.x reads - * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports} - * and loads this class during application context startup. + * Spring Boot 3.x+ automatically reads {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports} + * and loads this configuration class during application startup. *

- * - *

Disabling Autoconfiguration

+ *

Disabling Auto-Configuration:

*

- * Exclude this class when you need full manual control: + * If you need to customize or disable this autoconfiguration, you can exclude it in your main application class: *

*
{@code
  * @SpringBootApplication(exclude = ApiResponseAutoConfiguration.class)
  * public class Application {
- *     public static void main(String[] args) {
- *         SpringApplication.run(Application.class, args);
- *     }
+ * public static void main(String[] args) {
+ * SpringApplication.run(Application.class, args);
+ * }
  * }
  * }
*

- * Or via {@code application.properties}: + * Or in {@code application.properties}: *

*
  * spring.autoconfigure.exclude=io.github.og4dev.config.ApiResponseAutoConfiguration
  * 
* * @author Pasindu OG - * @version 1.4.0 - * @since 1.0.0 + * @version 1.5.0 * @see GlobalExceptionHandler * @see GlobalResponseWrapper - * @see AdvancedStringDeserializer * @see org.springframework.boot.autoconfigure.AutoConfiguration + * @since 1.0.0 */ @Configuration @SuppressWarnings("unused") public class ApiResponseAutoConfiguration { /** - * Default no-arg constructor required by Spring's configuration processing. + * Default constructor for ApiResponseAutoConfiguration. */ public ApiResponseAutoConfiguration() { - // Required by Spring's @Configuration processing + // Default constructor for Spring autoconfiguration } /** - * Registers {@link GlobalExceptionHandler} as a Spring bean. + * Registers the {@link GlobalExceptionHandler} as a Spring bean for automatic exception handling. *

- * All {@link ApiExceptionTranslator} beans found in the application context are - * injected and stored in the handler. They are consulted before the generic HTTP 500 - * fallback, allowing third-party exceptions to be mapped to meaningful RFC 9457 - * ProblemDetail responses without any additional {@code @ExceptionHandler} methods. + * The handler provides comprehensive centralized exception management using Spring's + * {@link org.springframework.web.bind.annotation.RestControllerAdvice} mechanism, + * automatically converting various exceptions to RFC 9457 ProblemDetail responses. *

* - * @param translators optional list of {@link ApiExceptionTranslator} beans discovered - * by Spring; {@code null} when none are registered, in which case - * the handler uses an empty list - * @return a fully configured {@link GlobalExceptionHandler} instance - * @see ApiExceptionTranslator + * @param registry optional registry used to map external exception types + * @return A new instance of {@link GlobalExceptionHandler} registered as a Spring bean. */ @Bean - public GlobalExceptionHandler apiResponseAdvisor(@Autowired(required = false) List> translators) { - return new GlobalExceptionHandler(translators); + public GlobalExceptionHandler apiResponseAdvisor(@Autowired(required = false) ApiExceptionRegistry registry) { + return new GlobalExceptionHandler(registry); } /** - * Registers {@link GlobalResponseWrapper} as a Spring bean for opt-in response - * envelope wrapping. - *

- * When a REST controller class or method is annotated with - * {@link AutoResponse @AutoResponse}, this wrapper intercepts the return value and - * encapsulates it inside an {@link io.github.og4dev.dto.ApiResponse} before it is - * serialized to JSON. - *

+ * Registers the {@link GlobalResponseWrapper} as a Spring bean for automatic API response wrapping. *

- * This bean is guarded by {@code @ConditionalOnMissingBean}: if the application - * defines its own {@code GlobalResponseWrapper} bean, this default registration is - * skipped entirely, giving developers full control over wrapping behaviour. + * This bean enables the opt-in {@link AutoResponse @AutoResponse} feature. When a REST controller + * or method is annotated with {@code @AutoResponse}, this wrapper automatically intercepts the + * outgoing payload and encapsulates it within the standardized {@code ApiResponse} structure + * before it is written to the HTTP response body. *

- * - *

Key Behaviours

+ *

Key Capabilities:

*
    - *
  • Zero Boilerplate — No manual {@code ResponseEntity>} - * wrapping required in controllers.
  • - *
  • Status Code Preservation — Reads the current HTTP status set via - * {@code @ResponseStatus} and reflects it in the {@code ApiResponse.status} - * field.
  • - *
  • Double-Wrap Prevention — Skips wrapping when the return type is - * already {@code ApiResponse}, {@code ResponseEntity}, or {@code ProblemDetail}. - *
  • - *
  • String Safety — Raw {@code String} returns are serialized explicitly - * via the injected {@code ObjectMapper} to prevent - * {@code ClassCastException}.
  • + *
  • Zero Boilerplate: Eliminates the need to manually return {@code ResponseEntity>} + * from every controller method.
  • + *
  • Status Code Preservation: Intelligently reads and preserves custom HTTP status codes + * set via {@code @ResponseStatus} (e.g., 201 Created).
  • + *
  • Double-Wrap Prevention: Safely skips wrapping if the controller already returns + * an {@code ApiResponse} or {@code ResponseEntity}.
  • + *
  • String Payload Support: Safely intercepts and serializes raw {@code String} returns + * using the injected {@link ObjectMapper} to prevent {@code ClassCastException} with Spring's + * native message converters.
  • + *
  • Error Compatibility: Bypasses {@code ProblemDetail} and exception responses to maintain + * RFC 9457 compliance managed by {@link GlobalExceptionHandler}.
  • *
+ *

Example Usage:

+ *
{@code
+     * @RestController
+     * @RequestMapping("/api/users")
+     * @AutoResponse // Enables automatic wrapping for all methods in this controller
+     * public class UserController {
+     * @GetMapping("/{id}")
+     * public UserDto getUser(@PathVariable Long id) {
+     * // Simply return the DTO. It will be sent to the client as:
+     * // { "status": "Success", "content": { "id": 1, "name": "..." }, "timestamp": "..." }
+     * return userService.findById(id);
+     * }
+     * @PostMapping
+     * @ResponseStatus(HttpStatus.CREATED)
+     * public UserDto createUser(@RequestBody UserDto dto) {
+     * // The 201 Created status will be preserved in the final ApiResponse
+     * return userService.create(dto);
+     * }
+     * }
+     * }
+ *

+ * Note: This bean is conditionally loaded using {@code @ConditionalOnMissingBean}, allowing developers + * to easily override the default wrapping behavior by defining their own {@code GlobalResponseWrapper} bean. + *

* - * @param objectMapper the Jackson {@code ObjectMapper} injected by Spring, used for - * explicit {@code String} payload serialization - * @return a fully configured {@link GlobalResponseWrapper} instance - * @since 1.4.0 + * @param objectMapper The Jackson object mapper injected by Spring, used by the wrapper for explicit string serialization. + * @return A new instance of {@link GlobalResponseWrapper} registered as a Spring bean. * @see AutoResponse * @see io.github.og4dev.dto.ApiResponse + * @since 1.4.0 */ @Bean @ConditionalOnMissingBean @@ -148,42 +159,20 @@ public GlobalResponseWrapper globalResponseWrapper(ObjectMapper objectMapper) { } /** - * Applies a {@link JsonMapperBuilderCustomizer} that configures strict and secure - * Jackson JSON deserialization globally. - * - *

Always-On Features

- *
    - *
  • Unknown property rejection ({@code FAIL_ON_UNKNOWN_PROPERTIES}) — - * prevents mass-assignment attacks by rejecting payloads with unexpected - * fields.
  • - *
  • Case-insensitive enums ({@code ACCEPT_CASE_INSENSITIVE_ENUMS}) — - * accepts enum values in any letter case for improved API usability.
  • - *
- * - *

Opt-in Features (Annotation-Driven)

- *
    - *
  • {@link XssCheck @XssCheck} — Rejects {@code String} values containing HTML - * or XML tags with a 400 Bad Request error.
  • - *
  • {@link AutoTrim @AutoTrim} — Strips leading and trailing whitespace from - * {@code String} values at deserialization time.
  • - *
- *

- * When both annotations are active on the same field, trimming is applied first and - * XSS validation is performed on the trimmed value. - *

- * - *

Null Value Handling

+ * Configures strict JSON deserialization with opt-in security features via field-level and class-level annotations. *

- * {@code null} values pass through unchanged regardless of which annotations are - * present on the field. + * This bean customizer enhances Jackson's JSON processing with production-ready security and data quality + * features that can be selectively applied to specific fields or entire classes using the + * {@link AutoTrim @AutoTrim} and {@link XssCheck @XssCheck} annotations. By default, fields are NOT + * trimmed or XSS-validated unless explicitly annotated. *

* - * @return a {@link JsonMapperBuilderCustomizer} that registers the strict deserialization - * settings and the {@link AdvancedStringDeserializer} + * @return A {@link JsonMapperBuilderCustomizer} that configures strict JSON processing. + * @see DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES + * @see MapperFeature#ACCEPT_CASE_INSENSITIVE_ENUMS + * @see io.github.og4dev.annotation.AutoTrim + * @see io.github.og4dev.annotation.XssCheck * @since 1.1.0 - * @see AutoTrim - * @see XssCheck - * @see AdvancedStringDeserializer */ @Bean public JsonMapperBuilderCustomizer strictJsonCustomizer() { @@ -191,7 +180,67 @@ public JsonMapperBuilderCustomizer strictJsonCustomizer() { builder.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); builder.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS); - builder.addModules(new SimpleModule().addDeserializer(String.class, new AdvancedStringDeserializer())); + SimpleModule stringTrimModule = new SimpleModule(); + stringTrimModule.addDeserializer(String.class, new AdvancedStringDeserializer()); + builder.addModules(stringTrimModule); }; } + + /** + * A specialized deserializer that applies opt-in string trimming and XSS validation. + * Extracted as a private static class to reduce cognitive complexity. + */ + private static class AdvancedStringDeserializer extends StdScalarDeserializer { + private final boolean shouldTrim; + private final boolean shouldXssCheck; + + public AdvancedStringDeserializer() { + super(String.class); + this.shouldTrim = false; + this.shouldXssCheck = false; + } + + public AdvancedStringDeserializer(boolean shouldTrim, boolean shouldXssCheck) { + super(String.class); + this.shouldTrim = shouldTrim; + this.shouldXssCheck = shouldXssCheck; + } + + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { + String value = p.getValueAsString(); + if (value == null) { + return null; + } + + String processedValue = shouldTrim ? value.trim() : value; + + if (shouldXssCheck && processedValue.matches("(?s).*<\\s*[a-zA-Z/!].*")) { + throw new IllegalArgumentException("Security Error: HTML tags or XSS payloads are not allowed in the request."); + } + + return processedValue; + } + + @Override + public ValueDeserializer createContextual(DeserializationContext ct, BeanProperty property) throws JacksonException { + if (property == null) { + return this; + } + + boolean trim = property.getAnnotation(AutoTrim.class) != null; + boolean xss = property.getAnnotation(XssCheck.class) != null; + if (trim && xss) { + return new AdvancedStringDeserializer(true, true); + } + if (property.getMember() != null) { + Class declaringClass = property.getMember().getDeclaringClass(); + if (declaringClass != null) { + trim = trim || declaringClass.getAnnotation(AutoTrim.class) != null; + xss = xss || declaringClass.getAnnotation(XssCheck.class) != null; + } + } + return new AdvancedStringDeserializer(trim, xss); + } + } } \ No newline at end of file diff --git a/src/main/java/io/github/og4dev/config/package-info.java b/src/main/java/io/github/og4dev/config/package-info.java index 0123c32..950e854 100644 --- a/src/main/java/io/github/og4dev/config/package-info.java +++ b/src/main/java/io/github/og4dev/config/package-info.java @@ -1,52 +1,18 @@ /** - * Spring Boot autoconfiguration and Jackson customization for the OG4Dev Spring API - * Response Library. + * Configuration classes for the OG4Dev Spring API Response Library. *

- * This package contains the classes that wire the library into a Spring Boot application - * context automatically, with zero manual configuration required from the developer. + * This package contains Spring Boot autoconfiguration classes that automatically + * register required beans when the library is added to the classpath. Zero manual + * configuration is required - simply add the dependency and all features are enabled. *

- * - *

Components

- *
    - *
  • {@link io.github.og4dev.config.ApiResponseAutoConfiguration} — The - * {@link org.springframework.context.annotation.Configuration} class loaded by Spring - * Boot's autoconfiguration mechanism via - * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}. - * It registers {@link io.github.og4dev.exception.GlobalExceptionHandler}, - * {@link io.github.og4dev.advice.GlobalResponseWrapper}, and the Jackson customizer - * as Spring beans.
  • - *
  • {@link io.github.og4dev.config.AdvancedStringDeserializer} — A contextual Jackson - * {@code StdScalarDeserializer} that enforces opt-in string trimming - * ({@link io.github.og4dev.annotation.AutoTrim @AutoTrim}) and XSS validation - * ({@link io.github.og4dev.annotation.XssCheck @XssCheck}) at the deserialization - * layer.
  • - *
- * - *

Disabling Autoconfiguration

- *

- * Exclude the configuration class from your main application if you need full manual - * control: - *

- *
{@code
- * @SpringBootApplication(exclude = ApiResponseAutoConfiguration.class)
- * public class Application {
- *     public static void main(String[] args) {
- *         SpringApplication.run(Application.class, args);
- *     }
- * }
- * }
*

- * Or via {@code application.properties}: + * The {@link io.github.og4dev.config.ApiResponseAutoConfiguration} class is + * automatically loaded by Spring Boot's autoconfiguration mechanism via + * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}. *

- *
- * spring.autoconfigure.exclude=io.github.og4dev.config.ApiResponseAutoConfiguration
- * 
* * @author Pasindu OG - * @version 1.4.0 + * @version 1.5.0 * @since 1.0.0 - * @see io.github.og4dev.config.ApiResponseAutoConfiguration - * @see io.github.og4dev.config.AdvancedStringDeserializer */ -package io.github.og4dev.config; - +package io.github.og4dev.config; \ No newline at end of file diff --git a/src/main/java/io/github/og4dev/dto/ApiResponse.java b/src/main/java/io/github/og4dev/dto/ApiResponse.java index 869606f..0fdcba6 100644 --- a/src/main/java/io/github/og4dev/dto/ApiResponse.java +++ b/src/main/java/io/github/og4dev/dto/ApiResponse.java @@ -7,79 +7,52 @@ import java.time.Instant; /** - * Immutable, type-safe wrapper for standardized HTTP API responses. + * Standard API Response wrapper for Spring Boot applications. *

- * Every response produced by this class follows the same four-field contract, ensuring a - * uniform API surface across the entire application: + * This class provides a consistent, type-safe structure for API responses across your application, + * including HTTP status codes, descriptive messages, content payload, and automatic timestamps. + * It supports both successful and error responses with optional content, ensuring a uniform API contract. + *

+ *

+ * The response structure follows a standardized format: *

*
    - *
  • status — The HTTP status code (e.g., 200, 201, 404).
  • - *
  • message — A human-readable description of the outcome.
  • - *
  • content — The response payload of generic type {@code T}; - * excluded from JSON serialization when {@code null}.
  • - *
  • timestamp — An RFC 3339 UTC {@link Instant} auto-generated at - * construction time.
  • + *
  • status - HTTP status code (200, 201, 404, etc.)
  • + *
  • message - Human-readable description of the response
  • + *
  • content - The response payload (generic type T, optional)
  • + *
  • timestamp - RFC 3339 UTC timestamp (auto-generated)
  • *
- * - *

Factory Methods

*

- * Use the static factory methods instead of the internal builder for common scenarios: + * Thread Safety: This class is immutable and thread-safe. All fields are final and set during + * construction. The response object can be safely shared across threads without synchronization. *

- *
{@code
- * // HTTP 200 — with payload
- * return ApiResponse.success("User retrieved", user);
- *
- * // HTTP 200 — without payload
- * return ApiResponse.success("User deleted");
- *
- * // HTTP 201 — with payload
- * return ApiResponse.created("User created", newUser);
- *
- * // HTTP 201 — without payload
- * return ApiResponse.created("Resource created");
- *
- * // Custom status — with payload
- * return ApiResponse.status("Request accepted", data, HttpStatus.ACCEPTED);
- *
- * // Error — without payload
- * return ApiResponse.error("Insufficient funds", HttpStatus.PAYMENT_REQUIRED);
- * }
- * - *

JSON Output

*

- * Fields annotated with {@code @JsonInclude(NON_NULL)} are omitted from serialization - * when {@code null}, keeping error or no-content responses concise: + * Usage Examples: *

*
{@code
- * // ApiResponse.success("User deleted") →
- * {
- *     "status": 200,
- *     "message": "User deleted",
- *     "timestamp": "2026-03-03T10:30:45.123Z"
- * }
+ * // Success response with data
+ * return ApiResponse.success("User retrieved successfully", user);
  *
- * // ApiResponse.success("User retrieved", user) →
- * {
- *     "status": 200,
- *     "message": "User retrieved",
- *     "content": { "id": 1, "name": "Alice" },
- *     "timestamp": "2026-03-03T10:30:45.123Z"
- * }
- * }
+ * // Created response (HTTP 201) + * return ApiResponse.created("User created successfully", newUser); * - *

Thread Safety

+ * // Success response without data + * return ApiResponse.success("User deleted successfully"); + * + * // Custom status response + * return ApiResponse.status("Request accepted", HttpStatus.ACCEPTED); + * } *

- * All fields are {@code final} and set once at construction. Instances are immutable - * and safe to share across threads without synchronization. + * JSON Serialization: The class uses Jackson's {@code @JsonInclude(NON_NULL)} to exclude + * null fields from the JSON output, reducing response payload size. *

* - * @param the type of the response content; use {@link Void} for responses without a payload + * @param the type of the response content (can be any Java type or Void for no content) * @author Pasindu OG - * @version 1.4.0 + * @version 1.5.0 * @since 1.0.0 * @see org.springframework.http.ResponseEntity * @see org.springframework.http.HttpStatus - * @see io.github.og4dev.annotation.AutoResponse */ @SuppressWarnings({"unused"}) public class ApiResponse { @@ -91,28 +64,27 @@ public class ApiResponse { private final Integer status; /** - * A human-readable description of the response outcome. + * A descriptive message about the response. */ @JsonInclude(JsonInclude.Include.NON_NULL) private final String message; /** - * The response payload; {@code null} for responses that carry no body content. + * The response content/payload. */ @JsonInclude(JsonInclude.Include.NON_NULL) private final T content; /** - * The UTC timestamp at which this response object was created. + * The timestamp when the response was created. */ @JsonInclude(JsonInclude.Include.NON_NULL) private final Instant timestamp; /** - * Constructs an {@code ApiResponse} from the given builder, auto-generating the - * current UTC timestamp. + * Private constructor that builds an ApiResponse from a builder. * - * @param builder the populated builder; must not be {@code null} + * @param builder the ApiResponseBuilder containing the response data */ private ApiResponse(ApiResponseBuilder builder) { this.status = builder.status; @@ -122,47 +94,47 @@ private ApiResponse(ApiResponseBuilder builder) { } /** - * Returns the HTTP status code. + * Gets the HTTP status code. * - * @return the status code, or {@code null} if not set + * @return the status code */ public Integer getStatus() { return status; } /** - * Returns the human-readable response message. + * Gets the response message. * - * @return the message, or {@code null} if not set + * @return the message */ public String getMessage() { return message; } /** - * Returns the response content payload. + * Gets the response content. * - * @return the content, or {@code null} for no-content responses + * @return the content */ public T getContent() { return content; } /** - * Returns the UTC timestamp at which this response was created. + * Gets the response timestamp. * - * @return the timestamp; never {@code null} + * @return the timestamp */ public Instant getTimestamp() { return timestamp; } /** - * Creates an HTTP 201 Created response with a message and no content. + * Creates a CREATED (201) response with a message. * * @param the type of the response content - * @param message the human-readable response message; must not be {@code null} - * @return a {@link ResponseEntity} with HTTP 201 status wrapping an {@code ApiResponse} + * @param message the response message + * @return a ResponseEntity with CREATED status */ public static ResponseEntity> created(String message) { return ResponseEntity.status(HttpStatus.CREATED) @@ -173,12 +145,12 @@ public static ResponseEntity> created(String message) { } /** - * Creates an HTTP 201 Created response with a message and a content payload. + * Creates a CREATED (201) response with a message and content. * * @param the type of the response content - * @param message the human-readable response message; must not be {@code null} - * @param content the response payload; may be {@code null} - * @return a {@link ResponseEntity} with HTTP 201 status wrapping an {@code ApiResponse} + * @param message the response message + * @param content the response content + * @return a ResponseEntity with CREATED status */ public static ResponseEntity> created(String message, T content) { return ResponseEntity.status(HttpStatus.CREATED) @@ -190,10 +162,10 @@ public static ResponseEntity> created(String message, T conte } /** - * Creates an HTTP 200 OK response with a message and no content. + * Creates a SUCCESS (200) response with only a message. * - * @param message the human-readable response message; must not be {@code null} - * @return a {@link ResponseEntity} with HTTP 200 status wrapping an {@code ApiResponse} + * @param message the response message + * @return a ResponseEntity with OK status */ public static ResponseEntity> success(String message) { return ResponseEntity.status(HttpStatus.OK) @@ -204,12 +176,12 @@ public static ResponseEntity> success(String message) { } /** - * Creates an HTTP 200 OK response with a message and a content payload. + * Creates a SUCCESS (200) response with a message and content. * * @param the type of the response content - * @param message the human-readable response message; must not be {@code null} - * @param content the response payload; may be {@code null} - * @return a {@link ResponseEntity} with HTTP 200 status wrapping an {@code ApiResponse} + * @param message the response message + * @param content the response content + * @return a ResponseEntity with OK status */ public static ResponseEntity> success(String message, T content) { return ResponseEntity.status(HttpStatus.OK) @@ -221,11 +193,11 @@ public static ResponseEntity> success(String message, T conte } /** - * Creates a response with a custom HTTP status and a message, carrying no content. + * Creates a response with a custom HTTP status and message only. * - * @param message the human-readable response message; must not be {@code null} - * @param status the HTTP status to use; must not be {@code null} - * @return a {@link ResponseEntity} with the specified status wrapping an {@code ApiResponse} + * @param message the response message + * @param status the HTTP status + * @return a ResponseEntity with the specified status */ public static ResponseEntity> status(String message, HttpStatus status) { return ResponseEntity.status(status) @@ -236,13 +208,13 @@ public static ResponseEntity> status(String message, HttpStatu } /** - * Creates a response with a custom HTTP status, a message, and a content payload. + * Creates a response with a custom HTTP status, message, and content. * * @param the type of the response content - * @param message the human-readable response message; must not be {@code null} - * @param content the response payload; may be {@code null} - * @param status the HTTP status to use; must not be {@code null} - * @return a {@link ResponseEntity} with the specified status wrapping an {@code ApiResponse} + * @param message the response message + * @param content the response content + * @param status the HTTP status + * @return a ResponseEntity with the specified status */ public static ResponseEntity> status(String message, T content, HttpStatus status) { return ResponseEntity.status(status) @@ -254,11 +226,11 @@ public static ResponseEntity> status(String message, T conten } /** - * Creates an error response with a custom HTTP status and a message, carrying no content. + * Creates a response with a custom HTTP status and error message only. * - * @param message the human-readable error message; must not be {@code null} - * @param status the HTTP error status to use; must not be {@code null} - * @return a {@link ResponseEntity} with the specified status wrapping an {@code ApiResponse} + * @param message the error response message + * @param status the HTTP status + * @return a ResponseEntity with the specified status */ public static ResponseEntity> error(String message, HttpStatus status) { return ResponseEntity.status(status) @@ -269,17 +241,13 @@ public static ResponseEntity> error(String message, HttpStatus } /** - * Creates an error response with a custom HTTP status, a message, and a content payload. - *

- * Use this overload when you need to include structured error details (e.g., field-level - * validation errors) alongside the error message. - *

+ * Creates a response with a custom HTTP status, error message, and content. * - * @param the type of the error detail content - * @param message the human-readable error message; must not be {@code null} - * @param content the structured error detail payload; may be {@code null} - * @param status the HTTP error status to use; must not be {@code null} - * @return a {@link ResponseEntity} with the specified status wrapping an {@code ApiResponse} + * @param the type of the response content + * @param message the error response message + * @param content the response content + * @param status the HTTP status + * @return a ResponseEntity with the specified status */ public static ResponseEntity> error(String message, T content, HttpStatus status) { return ResponseEntity.status(status) @@ -291,11 +259,7 @@ public static ResponseEntity> error(String message, T content } /** - * Builder for constructing {@link ApiResponse} instances with a fluent API. - *

- * Prefer the static factory methods ({@link #success}, {@link #created}, {@link #status}, - * {@link #error}) over this builder for common use cases. - *

+ * Builder class for constructing {@link ApiResponse} instances. * * @param the type of the response content */ @@ -306,7 +270,7 @@ public static class ApiResponseBuilder { private T content; /** - * Creates an empty builder. All fields are {@code null} until explicitly set. + * Default constructor for creating an empty builder. */ public ApiResponseBuilder() { // Default constructor @@ -315,8 +279,8 @@ public ApiResponseBuilder() { /** * Sets the HTTP status code. * - * @param status the HTTP status code value (e.g., 200, 201, 404) - * @return this builder instance for chaining + * @param status the status code + * @return this builder instance */ public ApiResponseBuilder status(Integer status) { this.status = status; @@ -324,10 +288,10 @@ public ApiResponseBuilder status(Integer status) { } /** - * Sets the human-readable response message. + * Sets the response message. * - * @param message the response message - * @return this builder instance for chaining + * @param message the message + * @return this builder instance */ public ApiResponseBuilder message(String message) { this.message = message; @@ -335,10 +299,10 @@ public ApiResponseBuilder message(String message) { } /** - * Sets the response content payload. + * Sets the response content. * - * @param content the content payload; may be {@code null} - * @return this builder instance for chaining + * @param content the content + * @return this builder instance */ public ApiResponseBuilder content(T content) { this.content = content; @@ -346,13 +310,9 @@ public ApiResponseBuilder content(T content) { } /** - * Builds and returns the {@link ApiResponse} instance. - *

- * The {@code timestamp} field is auto-generated to the current UTC instant at - * the moment this method is called. - *

+ * Builds the {@link ApiResponse} instance. * - * @return a new, fully populated {@link ApiResponse}; never {@code null} + * @return a new ApiResponse instance */ public ApiResponse build() { return new ApiResponse<>(this); diff --git a/src/main/java/io/github/og4dev/dto/package-info.java b/src/main/java/io/github/og4dev/dto/package-info.java index dd9809b..e56adf5 100644 --- a/src/main/java/io/github/og4dev/dto/package-info.java +++ b/src/main/java/io/github/og4dev/dto/package-info.java @@ -1,46 +1,23 @@ /** - * Standardized API response DTO for consistent HTTP response formatting. + * Provides Data Transfer Object (DTO) classes for standardized API responses. *

- * This package contains {@link io.github.og4dev.dto.ApiResponse}, the generic, immutable - * response wrapper used throughout the OG4Dev Spring API Response Library. Every successful - * API response produced by the library follows the same four-field structure: + * This package contains classes for structuring API responses in a consistent, type-safe format. + * The main class {@link io.github.og4dev.dto.ApiResponse} provides a generic wrapper that includes: *

*
    - *
  • status — The HTTP status code (e.g., 200, 201, 404).
  • - *
  • message — A human-readable description of the result.
  • - *
  • content — The response payload of generic type {@code T}; omitted from - * JSON when {@code null} (via {@code @JsonInclude(NON_NULL)}).
  • - *
  • timestamp — An RFC 3339 UTC timestamp auto-generated at response creation.
  • + *
  • HTTP status code
  • + *
  • Human-readable message
  • + *
  • Response content (generic type {@code T})
  • + *
  • Automatic UTC timestamp generation
  • *
- * - *

Factory Methods

- *

- * {@link io.github.og4dev.dto.ApiResponse} exposes static factory methods for the most - * common scenarios, eliminating the need to use the internal builder directly: - *

- *
    - *
  • {@code ApiResponse.success(message)} — HTTP 200, no content.
  • - *
  • {@code ApiResponse.success(message, content)} — HTTP 200 with a payload.
  • - *
  • {@code ApiResponse.created(message)} — HTTP 201, no content.
  • - *
  • {@code ApiResponse.created(message, content)} — HTTP 201 with a payload.
  • - *
  • {@code ApiResponse.status(message, status)} — Custom status, no content.
  • - *
  • {@code ApiResponse.status(message, content, status)} — Custom status with a payload.
  • - *
  • {@code ApiResponse.error(message, status)} — Error response, no content.
  • - *
  • {@code ApiResponse.error(message, content, status)} — Error response with details.
  • - *
- * - *

Thread Safety

*

- * All fields are {@code final} and set at construction time, making - * {@link io.github.og4dev.dto.ApiResponse} instances immutable and safe to share - * across threads without synchronization. + * All response objects are immutable and thread-safe, using a builder pattern for flexible construction. + * Factory methods like {@code success()}, {@code created()}, {@code status()}, and {@code error()} + * provide convenient ways to create responses without manual building. *

* * @author Pasindu OG - * @version 1.4.0 + * @version 1.5.0 * @since 1.0.0 - * @see io.github.og4dev.dto.ApiResponse - * @see io.github.og4dev.advice.GlobalResponseWrapper */ -package io.github.og4dev.dto; - +package io.github.og4dev.dto; \ No newline at end of file diff --git a/src/main/java/io/github/og4dev/exception/ApiException.java b/src/main/java/io/github/og4dev/exception/ApiException.java index 958c771..10e38f1 100644 --- a/src/main/java/io/github/og4dev/exception/ApiException.java +++ b/src/main/java/io/github/og4dev/exception/ApiException.java @@ -3,23 +3,20 @@ import org.springframework.http.HttpStatus; /** - * Abstract base class for domain-specific API exceptions with an associated HTTP status code. + * Base abstract exception class for custom API-related business logic exceptions. *

- * Extend this class to create typed business logic exceptions that are automatically - * intercepted by {@link GlobalExceptionHandler} and converted to RFC 9457 ProblemDetail - * responses without any additional {@code @ExceptionHandler} method. + * This class provides a foundation for creating domain-specific exceptions with associated + * HTTP status codes. Extend this class to create business logic exceptions that are automatically + * handled by {@link io.github.og4dev.exception.GlobalExceptionHandler} and converted to + * RFC 9457 ProblemDetail responses. *

*

- * Prefer {@code ApiException} subclasses over implementing {@link ApiExceptionTranslator} - * when the exception is defined within your own codebase. Use {@link ApiExceptionTranslator} - * for third-party exceptions that you cannot modify. + * Usage Example: *

- * - *

Usage Example

*
{@code
- * public class UserNotFoundException extends ApiException {
- *     public UserNotFoundException(Long id) {
- *         super("User not found with ID: " + id, HttpStatus.NOT_FOUND);
+ * public class ResourceNotFoundException extends ApiException {
+ *     public ResourceNotFoundException(String resource, Long id) {
+ *         super(String.format("%s not found with ID: %d", resource, id), HttpStatus.NOT_FOUND);
  *     }
  * }
  *
@@ -29,50 +26,35 @@
  *     }
  * }
  * }
- * - *

Resulting Error Response

*

- * Throwing any {@code ApiException} subclass produces an RFC 9457 ProblemDetail response: + * Benefits: *

- *
{@code
- * {
- *     "type": "about:blank",
- *     "title": "Not Found",
- *     "status": 404,
- *     "detail": "User not found with ID: 42",
- *     "traceId": "550e8400-e29b-41d4-a716-446655440000",
- *     "timestamp": "2026-03-03T10:30:45.123Z"
- * }
- * }
- * - *

Benefits

*
    - *
  • No {@code @ExceptionHandler} boilerplate required.
  • - *
  • RFC 9457 ProblemDetail formatting out of the box.
  • - *
  • Consistent error responses across all business exceptions.
  • - *
  • Automatic {@code WARN}-level logging with trace ID correlation.
  • + *
  • Automatic exception handling - No need to create {@code @ExceptionHandler} methods
  • + *
  • RFC 9457 ProblemDetail formatting - Industry-standard error responses
  • + *
  • Type-safe with compile-time checking
  • + *
  • Clean, readable code - Express business rules clearly
  • + *
  • Consistent error responses - All custom exceptions follow the same format
  • *
* * @author Pasindu OG - * @version 1.4.0 + * @version 1.5.0 * @since 1.0.0 - * @see GlobalExceptionHandler - * @see ApiExceptionTranslator + * @see io.github.og4dev.exception.GlobalExceptionHandler * @see org.springframework.http.HttpStatus */ public abstract class ApiException extends RuntimeException { /** - * The HTTP status code to include in the ProblemDetail error response. + * The HTTP status code associated with this exception. */ private final HttpStatus status; /** - * Constructs a new {@code ApiException} with a detail message and an HTTP status code. + * Constructs a new ApiException with the specified message and status. * - * @param message the detail message surfaced in the {@code detail} field of the - * ProblemDetail response; must not be {@code null} - * @param status the HTTP status code for the error response; must not be {@code null} + * @param message the detail message + * @param status the HTTP status code */ protected ApiException(String message, HttpStatus status) { super(message); @@ -80,9 +62,9 @@ protected ApiException(String message, HttpStatus status) { } /** - * Returns the HTTP status code associated with this exception. + * Gets the HTTP status code associated with this exception. * - * @return the HTTP status; never {@code null} + * @return the HTTP status */ public HttpStatus getStatus() { return status; diff --git a/src/main/java/io/github/og4dev/exception/ApiExceptionRegistry.java b/src/main/java/io/github/og4dev/exception/ApiExceptionRegistry.java new file mode 100644 index 0000000..2ff4a28 --- /dev/null +++ b/src/main/java/io/github/og4dev/exception/ApiExceptionRegistry.java @@ -0,0 +1,105 @@ +package io.github.og4dev.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.util.Assert; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A thread-safe registry for dynamically mapping third-party or framework-specific exceptions + * to standard HTTP statuses and user-friendly messages. + *

+ * This registry allows developers to handle external exceptions (for example, SQL, Mongo, or + * authentication exceptions) centrally without writing dedicated {@code @ExceptionHandler} + * methods for each type. + *

+ * + * @author Pasindu OG + * @version 1.5.0 + * @since 1.5.0 + */ +public class ApiExceptionRegistry { + + private final Map, ExceptionRule> registry = Collections.synchronizedMap(new LinkedHashMap<>()); + + /** + * Registers a custom or third-party exception type with a specific HTTP status and message. + *

+ * Registration order matters when both parent and child exception types are registered. + * The first assignable entry wins during lookup. + *

+ * + * @param exceptionClass the exception class to map + * @param status the HTTP status to return + * @param defaultMessage the fallback client-facing message + * @param the exception type + * @return this registry for fluent chaining + * @throws IllegalArgumentException if any argument is null or message is blank + */ + @SuppressWarnings("unused") + public ApiExceptionRegistry register(Class exceptionClass, HttpStatus status, String defaultMessage) { + Assert.notNull(exceptionClass, "Exception class must not be null"); + Assert.notNull(status, "HttpStatus must not be null"); + Assert.hasText(defaultMessage, "Default message must not be empty or null"); + + registry.put(exceptionClass, new ExceptionRule(status, defaultMessage)); + return this; + } + + /** + * Returns the first matching rule for the given thrown exception type. + * + * @param exceptionClass the thrown exception class + * @return matching rule or {@code null} when no mapping exists + */ + public ExceptionRule getRule(Class exceptionClass) { + if (exceptionClass == null) { + return null; + } + + synchronized (registry) { + for (Map.Entry, ExceptionRule> entry : registry.entrySet()) { + if (entry.getKey().isAssignableFrom(exceptionClass)) { + return entry.getValue(); + } + } + } + return null; + } + + /** + * Value object containing status and message for a registered exception mapping. + */ + @SuppressWarnings("ClassCanBeRecord") + public static class ExceptionRule { + private final HttpStatus status; + private final String message; + + /** + * Creates a new exception mapping rule. + * + * @param status mapped HTTP status + * @param message mapped client-facing message + */ + public ExceptionRule(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + /** + * @return mapped HTTP status + */ + public HttpStatus getStatus() { + return status; + } + + /** + * @return mapped client-facing message + */ + public String getMessage() { + return message; + } + } +} \ No newline at end of file diff --git a/src/main/java/io/github/og4dev/exception/ApiExceptionTranslator.java b/src/main/java/io/github/og4dev/exception/ApiExceptionTranslator.java deleted file mode 100644 index c227ed1..0000000 --- a/src/main/java/io/github/og4dev/exception/ApiExceptionTranslator.java +++ /dev/null @@ -1,135 +0,0 @@ -package io.github.og4dev.exception; - -import org.springframework.http.HttpStatus; - -/** - * Strategy interface for translating arbitrary exceptions into structured API error responses. - *

- * Implement this interface to register a custom exception translator that maps any - * {@link Throwable} subtype to an HTTP status code and a human-readable error message - * derived from the exception instance itself. - * Registered translators are picked up by {@link GlobalExceptionHandler} and invoked - * automatically when the matching exception type is thrown during request processing, - * producing an RFC 9457 ProblemDetail response without requiring additional - * {@code @ExceptionHandler} methods. - *

- *

- * This is the recommended extension point when you need to handle third-party or - * framework-level exceptions that cannot extend {@link ApiException}. - *

- * - *

Usage Example

- *
{@code
- * @Component
- * public class EntityNotFoundTranslator implements ApiExceptionTranslator {
- *
- *     @Override
- *     public Class getTargetException() {
- *         return EntityNotFoundException.class;
- *     }
- *
- *     @Override
- *     public HttpStatus getStatus() {
- *         return HttpStatus.NOT_FOUND;
- *     }
- *
- *     @Override
- *     public String getMessage(EntityNotFoundException ex) {
- *         // Access the exception instance to build a contextual message
- *         return "Entity not found: " + ex.getEntityName();
- *     }
- * }
- * }
- * - *

Error Response Format

- *

- * When a matching exception is caught, the handler produces an RFC 9457 ProblemDetail - * response using the values supplied by this interface: - *

- *
{@code
- * {
- *     "type": "about:blank",
- *     "title": "Not Found",
- *     "status": 404,
- *     "detail": "Entity not found: User",
- *     "traceId": "550e8400-e29b-41d4-a716-446655440000",
- *     "timestamp": "2026-03-03T10:30:45.123Z"
- * }
- * }
- * - *

Type Parameter

- *

- * The generic type parameter {@code T} constrains the translator to a specific exception - * type, providing compile-time safety and giving {@link #getMessage(Throwable)} access to - * the strongly-typed exception instance so contextual details (e.g., entity names, field - * values) can be included in the error message. - *

- * - * @param the specific {@link Throwable} subtype this translator handles - * @author Pasindu OG - * @version 1.4.0 - * @since 1.4.0 - * @see ApiException - * @see GlobalExceptionHandler - * @see org.springframework.http.ProblemDetail - * @see org.springframework.http.HttpStatus - */ -public interface ApiExceptionTranslator { - - /** - * Returns the exact exception class this translator is responsible for handling. - *

- * The {@link GlobalExceptionHandler} uses this value to match incoming exceptions - * against registered translators via - * {@link Class#isAssignableFrom(Class)}, so a translator registered for a supertype - * will also be invoked for its subtypes. Register a more specific translator to - * override the behaviour for a particular subtype. - *

- * - * @return the {@link Class} object representing the target exception type {@code T}; - * never {@code null} - */ - Class getTargetException(); - - /** - * Returns the HTTP status code to be used in the error response when the target - * exception is caught. - *

- * Choose a status that accurately reflects the nature of the error. Common mappings: - *

- *
    - *
  • {@link HttpStatus#BAD_REQUEST} (400) — invalid client input
  • - *
  • {@link HttpStatus#NOT_FOUND} (404) — resource does not exist
  • - *
  • {@link HttpStatus#CONFLICT} (409) — state conflict (e.g., duplicate entry)
  • - *
  • {@link HttpStatus#UNPROCESSABLE_CONTENT} (422) — semantically invalid request
  • - *
  • {@link HttpStatus#INTERNAL_SERVER_ERROR} (500) — unexpected server-side failure
  • - *
- * - * @return the {@link HttpStatus} to set on the ProblemDetail response; never {@code null} - */ - HttpStatus getStatus(); - - /** - * Produces the human-readable detail message to include in the error response body, - * using the caught exception instance to provide contextual information. - *

- * The returned value is mapped to the {@code detail} field of the RFC 9457 ProblemDetail - * response. Access the exception's fields or message to build a specific, actionable - * description. The message must be safe to expose to API consumers — it must - * not leak internal stack traces, class names, or sensitive data. - *

- *

- * Example implementation that extracts context from the exception: - *

- *
{@code
-     * @Override
-     * public String getMessage(EntityNotFoundException ex) {
-     *     return "Entity not found: " + ex.getEntityName();
-     * }
-     * }
- * - * @param ex the caught exception instance of type {@code T}; never {@code null} - * @return a non-{@code null}, client-safe error detail message derived from {@code ex} - */ - String getMessage(T ex); -} diff --git a/src/main/java/io/github/og4dev/exception/GlobalExceptionHandler.java b/src/main/java/io/github/og4dev/exception/GlobalExceptionHandler.java index 1557863..cc6b143 100644 --- a/src/main/java/io/github/og4dev/exception/GlobalExceptionHandler.java +++ b/src/main/java/io/github/og4dev/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; @@ -18,7 +19,9 @@ import org.springframework.web.servlet.resource.NoResourceFoundException; import java.time.Instant; -import java.util.*; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; /** * Global exception handler for Spring Boot REST APIs with comprehensive error coverage. @@ -29,39 +32,31 @@ * trace IDs for debugging and request correlation. *

*

- * Built-in Supported Exception Types (10 handlers): + * Supported Exception Types (10 handlers): *

*
    - *
  1. General Exceptions - Catches all unhandled exceptions (HTTP 500)
  2. - *
  3. Validation Errors - {@code @Valid} annotation failures (HTTP 400)
  4. - *
  5. Type Mismatches - Method argument type conversion errors (HTTP 400)
  6. - *
  7. Malformed JSON - Invalid request body format (HTTP 400)
  8. - *
  9. Missing Parameters - Required {@code @RequestParam} missing (HTTP 400)
  10. - *
  11. 404 Not Found - Missing endpoints or resources (HTTP 404)
  12. - *
  13. Method Not Allowed - Unsupported HTTP methods (HTTP 405)
  14. - *
  15. Unsupported Media Type - Invalid Content-Type headers (HTTP 415)
  16. - *
  17. Null Pointer Exceptions - NullPointerException handling (HTTP 500)
  18. - *
  19. Custom API Exceptions - Domain-specific business logic errors (custom status)
  20. + *
  21. General Exceptions - Catches all unhandled exceptions (HTTP 500)
  22. + *
  23. Validation Errors - {@code @Valid} annotation failures (HTTP 400)
  24. + *
  25. Type Mismatches - Method argument type conversion errors (HTTP 400)
  26. + *
  27. Malformed JSON - Invalid request body format (HTTP 400)
  28. + *
  29. Missing Parameters - Required {@code @RequestParam} missing (HTTP 400)
  30. + *
  31. 404 Not Found - Missing endpoints or resources (HTTP 404)
  32. + *
  33. Method Not Allowed - Unsupported HTTP methods (HTTP 405)
  34. + *
  35. Unsupported Media Type - Invalid Content-Type headers (HTTP 415)
  36. + *
  37. Null Pointer Exceptions - NullPointerException handling (HTTP 500)
  38. + *
  39. Custom API Exceptions - Domain-specific business logic errors (custom status)
  40. *
*

- * Extensible Exception Handling via {@link ApiExceptionTranslator}: - * Register one or more {@link ApiExceptionTranslator} beans in the application context to handle - * third-party or framework exceptions that cannot extend {@link ApiException}. Each translator - * is automatically discovered and invoked when its target exception type is thrown, producing - * a consistent RFC 9457 ProblemDetail response without any additional {@code @ExceptionHandler} - * methods. - *

- *

* Error Response Format (RFC 9457 ProblemDetail): *

*
    - *
  • type - URI reference identifying the problem type (defaults to "about:blank")
  • - *
  • title - Short, human-readable summary of the problem
  • - *
  • status - HTTP status code
  • - *
  • detail - Human-readable explanation specific to this occurrence
  • - *
  • traceId - Unique UUID for request correlation and debugging
  • - *
  • timestamp - RFC 3339 UTC timestamp
  • - *
  • errors - Validation field errors (for validation failures only)
  • + *
  • type - URI reference identifying the problem type (defaults to "about:blank")
  • + *
  • title - Short, human-readable summary of the problem
  • + *
  • status - HTTP status code
  • + *
  • detail - Human-readable explanation specific to this occurrence
  • + *
  • traceId - Unique UUID for request correlation and debugging
  • + *
  • timestamp - RFC 3339 UTC timestamp
  • + *
  • errors - Validation field errors (for validation failures only)
  • *
*

* Trace ID Management: All exception handlers ensure consistent trace IDs between logs @@ -76,17 +71,16 @@ * Logging: All exceptions are automatically logged with appropriate severity levels: *

*
    - *
  • ERROR - General exceptions, null pointer exceptions
  • - *
  • WARN - Validation errors, type mismatches, business logic exceptions, 400/404/405/415 errors
  • + *
  • ERROR - General exceptions, null pointer exceptions
  • + *
  • WARN - Validation errors, type mismatches, business logic exceptions, 400/404/405/415 errors
  • *
* * @author Pasindu OG - * @version 1.4.0 + * @version 1.5.0 * @since 1.0.0 * @see org.springframework.web.bind.annotation.RestControllerAdvice * @see org.springframework.http.ProblemDetail - * @see ApiException - * @see ApiExceptionTranslator + * @see io.github.og4dev.exception.ApiException */ @ConditionalOnProperty( prefix = "api-response", @@ -99,35 +93,21 @@ public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); - private final List> translators; + private final ApiExceptionRegistry registry; + /** - * Constructs a new {@code GlobalExceptionHandler} with an optional list of custom - * {@link ApiExceptionTranslator} beans. - *

- * The provided translators are stored and consulted by the general - * {@link #handleAllExceptions(Exception)} handler before falling back to the default - * HTTP 500 response, allowing third-party exceptions to be mapped to meaningful - * RFC 9457 ProblemDetail responses without additional {@code @ExceptionHandler} methods. - *

+ * Constructor for Spring bean instantiation, accepting an optional ApiExceptionRegistry. * - * @param translators an optional list of {@link ApiExceptionTranslator} beans injected - * by Spring; may be {@code null} if no translators are registered, - * in which case an empty list is used + * @param registry the optional API exception registry for custom exception mapping */ - public GlobalExceptionHandler(List> translators) { - this.translators = translators != null ? translators : Collections.emptyList(); + public GlobalExceptionHandler(@Autowired(required = false) ApiExceptionRegistry registry) { + this.registry = registry; } /** - * Returns the current trace ID from SLF4J MDC, generating and storing a new UUID - * if none is present. - *

- * This ensures every error response carries a trace ID regardless of whether a - * {@link io.github.og4dev.filter.TraceIdFilter} is registered, so logs and responses - * are always correlatable. - *

+ * Retrieves the trace ID from MDC or generates a new one if not present. * - * @return the existing or newly generated trace ID string; never {@code null} + * @return the trace ID */ private String getOrGenerateTraceId() { String traceId = MDC.get("traceId"); @@ -139,27 +119,16 @@ private String getOrGenerateTraceId() { } /** - * Handles all unhandled exceptions and dispatches to registered {@link ApiExceptionTranslator} - * beans before falling back to a generic HTTP 500 response. - *

- * Processing order: - *

- *
    - *
  1. Logs the exception at {@code ERROR} level with the trace ID, source class, and line number.
  2. - *
  3. Iterates registered {@link ApiExceptionTranslator} beans and checks whether any translator's - * {@link ApiExceptionTranslator#getTargetException()} is assignable from the thrown exception type.
  4. - *
  5. On a match, logs the translation at {@code WARN} level (exception type, translator class name, - * and translated message), then returns a ProblemDetail built from the translator's status and message.
  6. - *
  7. If no translator matches, returns a generic HTTP 500 ProblemDetail response.
  8. - *
+ * Handles all unhandled exceptions, logs them with stack trace details, + * and dynamically maps them via ApiExceptionRegistry if configured. * - * @param ex the unhandled exception - * @return a {@link ProblemDetail} response — translated by a matching {@link ApiExceptionTranslator} - * if one is registered, otherwise HTTP 500 + * @param ex the exception + * @return ProblemDetail response with appropriate status */ @ExceptionHandler(Exception.class) public ProblemDetail handleAllExceptions(Exception ex) { String traceId = getOrGenerateTraceId(); + StackTraceElement rootCause = ex.getStackTrace().length > 0 ? ex.getStackTrace()[0] : null; String className = (rootCause != null) ? rootCause.getClassName() : "Unknown Class"; int lineNumber = (rootCause != null) ? rootCause.getLineNumber() : -1; @@ -167,49 +136,31 @@ public ProblemDetail handleAllExceptions(Exception ex) { log.error("[TraceID: {}] Error in {}:{} - Message: {}", traceId, className, lineNumber, ex.getMessage()); - if (translators != null) { - for (ApiExceptionTranslator translator : translators) { - if (translator.getTargetException().isAssignableFrom(ex.getClass())) { - - @SuppressWarnings("unchecked") - ApiExceptionTranslator typedTranslator = (ApiExceptionTranslator) translator; - - log.warn("[TraceID: {}] Translated exception [{}] via {}: {}", - traceId, - ex.getClass().getSimpleName(), - typedTranslator.getClass().getSimpleName(), - typedTranslator.getMessage(ex)); - - ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( - typedTranslator.getStatus(), - typedTranslator.getMessage(ex) - ); - problemDetail.setProperty("traceId", traceId); - problemDetail.setProperty("timestamp", Instant.now()); - return problemDetail; - } + // Check if the exception is registered in the ApiExceptionRegistry + if (registry != null) { + ApiExceptionRegistry.ExceptionRule rule = registry.getRule(ex.getClass()); + if (rule != null) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(rule.getStatus(), rule.getMessage()); + problemDetail.setProperty("traceId", traceId); + problemDetail.setProperty("timestamp", Instant.now()); + return problemDetail; } } + // Default Fallback ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( HttpStatus.INTERNAL_SERVER_ERROR, - "Internal Server Error. Please contact technical support"); + "Internal Server Error. Please contact technical support."); problemDetail.setProperty("traceId", traceId); problemDetail.setProperty("timestamp", Instant.now()); return problemDetail; } /** - * Handles validation failures raised by {@code @Valid} and {@code @Validated} annotations. - *

- * Collects all field-level constraint violations from the binding result. When multiple - * violations exist for the same field, their messages are merged with a {@code "; "} - * separator. The aggregated map is included in the {@code errors} extension field of the - * ProblemDetail response and logged at {@code WARN} level. - *

+ * Handles validation exceptions from @Valid annotations. * - * @param ex the validation exception containing one or more field errors - * @return a {@link ProblemDetail} with HTTP 400 status and an {@code errors} map + * @param ex the validation exception + * @return ProblemDetail response with 400 status and field errors */ @ExceptionHandler(MethodArgumentNotValidException.class) public ProblemDetail handleValidationExceptions(MethodArgumentNotValidException ex) { @@ -230,21 +181,22 @@ public ProblemDetail handleValidationExceptions(MethodArgumentNotValidException } /** - * Handles type conversion failures for method arguments (e.g., passing a non-numeric - * string where an {@code Integer} path variable is expected). - *

- * Logs the mismatch details at {@code WARN} level and returns a descriptive message - * that includes the rejected value, the parameter name, and the expected type. - *

+ * Handles method argument type mismatch exceptions. * - * @param ex the exception describing the type mismatch - * @return a {@link ProblemDetail} with HTTP 400 status + * @param ex the type mismatch exception + * @return ProblemDetail response with 400 status */ @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ProblemDetail handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException ex) { String traceId = getOrGenerateTraceId(); + + // SonarQube Fix: Assigning getRequiredType() to a variable before checking for null + Class requiredType = ex.getRequiredType(); + String expectedType = (requiredType != null) ? requiredType.getSimpleName() : "Unknown"; + String errorMessage = String.format("Invalid value '%s' for parameter '%s'. Expected type: %s.", - ex.getValue(), ex.getName(), ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "Unknown"); + ex.getValue(), ex.getName(), expectedType); + log.warn("[TraceID: {}] Type mismatch error: {}", traceId, errorMessage); ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, errorMessage); problemDetail.setProperty("traceId", traceId); @@ -253,15 +205,10 @@ public ProblemDetail handleMethodArgumentTypeMismatchException(MethodArgumentTyp } /** - * Handles malformed or unreadable JSON request bodies. - *

- * Triggered when Jackson cannot parse the incoming request body (e.g., missing quotes, - * invalid structure, wrong data types). Logs at {@code WARN} level and returns a - * generic message that guides the client to check the request body format. - *

+ * Handles malformed JSON request exceptions. * - * @param ex the exception raised when the HTTP message body cannot be read - * @return a {@link ProblemDetail} with HTTP 400 status + * @param ex the HTTP message not readable exception + * @return ProblemDetail response with 400 status */ @ExceptionHandler(HttpMessageNotReadableException.class) public ProblemDetail handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { @@ -274,14 +221,10 @@ public ProblemDetail handleHttpMessageNotReadableException(HttpMessageNotReadabl } /** - * Handles missing required {@code @RequestParam} query parameters. - *

- * Returns a descriptive message that includes the expected parameter name and its - * declared type so the client can correct the request. - *

+ * Handles missing required request parameter exceptions. * - * @param ex the exception carrying the missing parameter name and type - * @return a {@link ProblemDetail} with HTTP 400 status + * @param ex the missing parameter exception + * @return ProblemDetail response with 400 status */ @ExceptionHandler(MissingServletRequestParameterException.class) public ProblemDetail handleMissingServletRequestParameterException(MissingServletRequestParameterException ex) { @@ -297,14 +240,10 @@ public ProblemDetail handleMissingServletRequestParameterException(MissingServle } /** - * Handles requests for endpoints or static resources that do not exist. - *

- * Includes the requested resource path in the response message to help clients - * identify the incorrect URL. Logs at {@code WARN} level. - *

+ * Handles 404 Not Found exceptions. * - * @param ex the exception carrying the unresolved resource path - * @return a {@link ProblemDetail} with HTTP 404 status + * @param ex the no resource found exception + * @return ProblemDetail response with 404 status */ @ExceptionHandler(NoResourceFoundException.class) public ProblemDetail handleNoResourceFoundException(NoResourceFoundException ex) { @@ -319,14 +258,10 @@ public ProblemDetail handleNoResourceFoundException(NoResourceFoundException ex) } /** - * Handles requests that use an HTTP method not supported by the target endpoint. - *

- * Includes the unsupported method and the list of allowed methods in the response - * message so the client can retry with a valid method. Logs at {@code WARN} level. - *

+ * Handles HTTP method not supported exceptions. * - * @param ex the exception carrying the unsupported method and the supported method set - * @return a {@link ProblemDetail} with HTTP 405 status + * @param ex the method not supported exception + * @return ProblemDetail response with 405 status */ @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ProblemDetail handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) { @@ -342,15 +277,10 @@ public ProblemDetail handleHttpRequestMethodNotSupportedException(HttpRequestMet } /** - * Handles requests whose {@code Content-Type} header specifies a media type not - * accepted by the target endpoint. - *

- * Includes the received content type and the list of supported types in the response - * message. Logs at {@code WARN} level. - *

+ * Handles unsupported media type exceptions. * - * @param ex the exception carrying the unsupported content type and the supported set - * @return a {@link ProblemDetail} with HTTP 415 status + * @param ex the media type not supported exception + * @return ProblemDetail response with 415 status */ @ExceptionHandler(HttpMediaTypeNotSupportedException.class) public ProblemDetail handleHttpMediaTypeNotSupportedException(HttpMediaTypeNotSupportedException ex) { @@ -366,15 +296,10 @@ public ProblemDetail handleHttpMediaTypeNotSupportedException(HttpMediaTypeNotSu } /** - * Handles {@link NullPointerException} thrown anywhere during request processing. - *

- * Logs the full stack trace at {@code ERROR} level for server-side investigation while - * returning a generic, non-leaking message to the client. Stack trace details are - * intentionally withheld from the response to prevent information disclosure. - *

+ * Handles null pointer exceptions. * * @param ex the null pointer exception - * @return a {@link ProblemDetail} with HTTP 500 status + * @return ProblemDetail response with 500 status */ @ExceptionHandler(NullPointerException.class) public ProblemDetail handleNullPointerExceptions(NullPointerException ex) { @@ -387,17 +312,10 @@ public ProblemDetail handleNullPointerExceptions(NullPointerException ex) { } /** - * Handles all {@link ApiException} subclasses representing domain-specific business - * logic errors. - *

- * The HTTP status and detail message are taken directly from the exception instance, - * giving each subclass full control over the error response. Logs at {@code WARN} level - * with the trace ID, message, and status code. - *

+ * Handles custom API exceptions. * - * @param ex the domain exception carrying the status and detail message - * @return a {@link ProblemDetail} whose status and {@code detail} field are sourced - * from {@link ApiException#getStatus()} and {@link ApiException#getMessage()} + * @param ex the API exception + * @return ProblemDetail response with the exception's status code */ @ExceptionHandler(ApiException.class) public ProblemDetail handleApiException(ApiException ex) { diff --git a/src/main/java/io/github/og4dev/exception/package-info.java b/src/main/java/io/github/og4dev/exception/package-info.java index dcefc55..e41ea33 100644 --- a/src/main/java/io/github/og4dev/exception/package-info.java +++ b/src/main/java/io/github/og4dev/exception/package-info.java @@ -1,80 +1,32 @@ /** - * Exception handling infrastructure for comprehensive, RFC 9457-compliant API error management. + * Exception handling classes for comprehensive API error management. *

- * This package contains all classes responsible for converting Java exceptions into structured - * HTTP error responses. Every error response produced by this package conforms to - * RFC 9457 ProblemDetail format and includes a unique trace ID for request correlation. + * This package provides production-ready centralized exception handling using Spring's + * {@link org.springframework.web.bind.annotation.RestControllerAdvice} mechanism, automatically + * converting exceptions into RFC 9457 ProblemDetail responses with trace IDs for debugging. + *

+ *

+ * Key components include: *

- * - *

Components

*
    - *
  • {@link io.github.og4dev.exception.GlobalExceptionHandler} — Central - * {@link org.springframework.web.bind.annotation.RestControllerAdvice} with 10 built-in - * {@link org.springframework.web.bind.annotation.ExceptionHandler} methods covering - * the most common Spring MVC error scenarios. Also discovers and invokes registered - * {@link io.github.og4dev.exception.ApiExceptionTranslator} beans.
  • - *
  • {@link io.github.og4dev.exception.ApiException} — Abstract base class for - * domain-specific business logic exceptions. Subclasses are automatically caught and - * converted to ProblemDetail responses by - * {@link io.github.og4dev.exception.GlobalExceptionHandler}.
  • - *
  • {@link io.github.og4dev.exception.ApiExceptionTranslator} — Strategy interface - * for translating third-party or framework exceptions (that cannot extend - * {@link io.github.og4dev.exception.ApiException}) into structured error responses - * without additional {@code @ExceptionHandler} methods.
  • + *
  • {@link io.github.og4dev.exception.GlobalExceptionHandler} - Handles 10 common exception types + * including validation errors, malformed JSON, 404 errors, method not allowed, and more
  • + *
  • {@link io.github.og4dev.exception.ApiException} - Abstract base class for creating + * custom business logic exceptions with automatic handling
  • *
- * - *

Error Response Format

*

- * All error responses conform to RFC 9457 ProblemDetail: + * All error responses include: *

- *
{@code
- * {
- *     "type": "about:blank",
- *     "title": "Not Found",
- *     "status": 404,
- *     "detail": "The requested resource '/api/users/99' was not found.",
- *     "traceId": "550e8400-e29b-41d4-a716-446655440000",
- *     "timestamp": "2026-03-03T10:30:45.123Z"
- * }
- * }
- * - *

Custom Business Logic Exceptions

- *
{@code
- * public class UserNotFoundException extends ApiException {
- *     public UserNotFoundException(Long id) {
- *         super("User not found with ID: " + id, HttpStatus.NOT_FOUND);
- *     }
- * }
- * }
- * - *

Third-Party Exception Translation

- *
{@code
- * @Component
- * public class EntityNotFoundTranslator
- *         implements ApiExceptionTranslator {
- *
- *     @Override
- *     public Class getTargetException() {
- *         return EntityNotFoundException.class;
- *     }
- *
- *     @Override
- *     public HttpStatus getStatus() { return HttpStatus.NOT_FOUND; }
- *
- *     @Override
- *     public String getMessage(EntityNotFoundException ex) {
- *         return "Entity not found: " + ex.getEntityName();
- *     }
- * }
- * }
+ *
    + *
  • Standard RFC 9457 ProblemDetail format
  • + *
  • Unique trace ID for request correlation
  • + *
  • RFC 3339 UTC timestamp
  • + *
  • Detailed, actionable error messages
  • + *
  • Automatic SLF4J logging with trace IDs
  • + *
* * @author Pasindu OG - * @version 1.4.0 + * @version 1.5.0 * @since 1.0.0 - * @see io.github.og4dev.exception.GlobalExceptionHandler - * @see io.github.og4dev.exception.ApiException - * @see io.github.og4dev.exception.ApiExceptionTranslator - * @see org.springframework.http.ProblemDetail */ package io.github.og4dev.exception; - diff --git a/src/main/java/io/github/og4dev/filter/TraceIdFilter.java b/src/main/java/io/github/og4dev/filter/TraceIdFilter.java index ecbd4bb..3a89691 100644 --- a/src/main/java/io/github/og4dev/filter/TraceIdFilter.java +++ b/src/main/java/io/github/og4dev/filter/TraceIdFilter.java @@ -12,79 +12,77 @@ import java.util.UUID; /** - * {@link OncePerRequestFilter} that generates a unique UUID trace ID for every incoming - * HTTP request and propagates it through SLF4J MDC and request attributes. + * Servlet filter that generates and manages trace IDs for distributed tracing. *

- * The trace ID is stored under the key {@code "traceId"} in two places: - *

- *
    - *
  • SLF4J MDC — automatically appended to every log statement produced during - * the request when the Logback/Log4j pattern includes {@code %X{traceId}}.
  • - *
  • Servlet request attributes — accessible programmatically within filters, - * interceptors, and controllers via {@code request.getAttribute("traceId")}.
  • - *
- *

- * The MDC entry is always removed in a {@code finally} block after the filter chain - * completes, preventing trace ID leakage across requests in shared thread-pool environments. + * This filter generates a unique UUID for each incoming HTTP request and stores it in both + * the request attributes and SLF4J's MDC (Mapped Diagnostic Context). This enables automatic + * trace ID inclusion in all log statements throughout the request lifecycle, facilitating + * request correlation and debugging across distributed systems. *

*

- * The same trace ID is also embedded in every RFC 9457 ProblemDetail error response - * produced by {@link io.github.og4dev.exception.GlobalExceptionHandler}, allowing - * client-reported trace IDs to be matched directly against server logs. + * Key Features: *

- * - *

Registration

+ *
    + *
  • Automatic UUID generation for each request
  • + *
  • MDC integration for automatic log inclusion
  • + *
  • Request attribute storage for programmatic access
  • + *
  • Thread-safe with automatic MDC cleanup
  • + *
  • Compatible with microservices architectures
  • + *
*

- * This filter is not registered automatically. Register it with highest precedence - * so the trace ID is available to all downstream filters and handlers: + * Usage: Register this filter as a Spring bean with highest precedence: *

*
{@code
- * @Bean
- * public FilterRegistrationBean traceIdFilter() {
- *     FilterRegistrationBean registration = new FilterRegistrationBean<>();
- *     registration.setFilter(new TraceIdFilter());
- *     registration.addUrlPatterns("/*");
- *     registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
- *     return registration;
+ * @Configuration
+ * public class FilterConfig {
+ *     @Bean
+ *     public FilterRegistrationBean traceIdFilter() {
+ *         FilterRegistrationBean registration = new FilterRegistrationBean<>();
+ *         registration.setFilter(new TraceIdFilter());
+ *         registration.addUrlPatterns("/*");
+ *         registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
+ *         return registration;
+ *     }
  * }
  * }
- * - *

Logback Pattern

+ *

+ * Logback Configuration: Configure your logger to include the trace ID: + *

*
{@code
  * %d{yyyy-MM-dd HH:mm:ss.SSS} [%X{traceId}] %-5level %logger{36} - %msg%n
  * }
+ *

+ * MDC Cleanup: The filter automatically clears MDC in the finally block to prevent + * memory leaks and cross-request contamination. + *

* * @author Pasindu OG - * @version 1.4.0 + * @version 1.5.0 * @since 1.0.0 * @see org.springframework.web.filter.OncePerRequestFilter * @see org.slf4j.MDC - * @see io.github.og4dev.exception.GlobalExceptionHandler */ @SuppressWarnings("unused") public class TraceIdFilter extends OncePerRequestFilter { /** - * Default no-arg constructor required for Spring's - * {@link org.springframework.web.filter.GenericFilterBean} registration mechanism. + * Default constructor for Spring filter registration. */ public TraceIdFilter() { - // Required by Spring's filter registration mechanism + // Default constructor for Spring filter registration } /** - * Generates a UUID trace ID, stores it in MDC and request attributes, then delegates - * to the rest of the filter chain. + * Generates a trace ID and stores it in request attributes and MDC. *

- * MDC is unconditionally cleared in the {@code finally} block to prevent the trace ID - * from leaking into subsequent requests handled by the same thread. + * The trace ID is cleared from MDC after the request completes. *

* - * @param request the current HTTP servlet request - * @param response the current HTTP servlet response - * @param filterChain the remaining filter chain to delegate to - * @throws ServletException if a servlet-layer error occurs during processing - * @throws IOException if an I/O error occurs during processing + * @param request the HTTP request + * @param response the HTTP response + * @param filterChain the filter chain + * @throws ServletException if a servlet error occurs + * @throws IOException if an I/O error occurs */ @Override @NullMarked @@ -98,4 +96,4 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse MDC.clear(); } } -} +} \ No newline at end of file diff --git a/src/main/java/io/github/og4dev/filter/package-info.java b/src/main/java/io/github/og4dev/filter/package-info.java index bad210a..4bbbf7c 100644 --- a/src/main/java/io/github/og4dev/filter/package-info.java +++ b/src/main/java/io/github/og4dev/filter/package-info.java @@ -1,49 +1,27 @@ /** - * Servlet filter for distributed tracing and request correlation. + * Servlet filter classes for request processing and distributed tracing. *

- * This package contains {@link io.github.og4dev.filter.TraceIdFilter}, a - * {@link org.springframework.web.filter.OncePerRequestFilter} that generates a unique - * UUID trace ID for every incoming HTTP request and makes it available in two places: + * This package contains servlet filters for cross-cutting concerns in Spring Boot applications, + * particularly focused on distributed tracing and request correlation. *

- *
    - *
  • SLF4J MDC ({@code traceId} key) — automatically included in every log - * statement written during the request lifecycle when the logging pattern contains - * {@code %X{traceId}}.
  • - *
  • Request attributes ({@code traceId} key) — accessible programmatically - * via {@code request.getAttribute("traceId")}.
  • - *
*

- * The MDC entry is cleared in a {@code finally} block after the filter chain completes, - * preventing trace ID leakage between requests in thread-pool environments. + * The {@link io.github.og4dev.filter.TraceIdFilter} automatically generates unique trace IDs + * for each incoming HTTP request and stores them in both request attributes and SLF4J's MDC + * (Mapped Diagnostic Context). This enables: *

- * - *

Registration

+ *
    + *
  • Automatic trace ID inclusion in all log statements
  • + *
  • Request correlation across microservices
  • + *
  • Simplified debugging and troubleshooting
  • + *
  • Thread-safe MDC management with automatic cleanup
  • + *
*

- * {@link io.github.og4dev.filter.TraceIdFilter} is not registered automatically. - * Register it as a Spring bean with highest precedence to ensure the trace ID is available - * before any other filter or servlet processes the request: + * Note: The TraceIdFilter is not automatically registered. To use it, manually register + * it as a bean in your Spring configuration. *

- *
{@code
- * @Bean
- * public FilterRegistrationBean traceIdFilter() {
- *     FilterRegistrationBean registration = new FilterRegistrationBean<>();
- *     registration.setFilter(new TraceIdFilter());
- *     registration.addUrlPatterns("/*");
- *     registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
- *     return registration;
- * }
- * }
- * - *

Logback Configuration

- *
{@code
- * %d{yyyy-MM-dd HH:mm:ss} [%X{traceId}] %-5level %logger - %msg%n
- * }
* * @author Pasindu OG - * @version 1.4.0 + * @version 1.5.0 * @since 1.0.0 - * @see io.github.og4dev.filter.TraceIdFilter - * @see org.slf4j.MDC */ package io.github.og4dev.filter; - diff --git a/src/main/java/io/github/og4dev/package-info.java b/src/main/java/io/github/og4dev/package-info.java index a2e7610..efb2abf 100644 --- a/src/main/java/io/github/og4dev/package-info.java +++ b/src/main/java/io/github/og4dev/package-info.java @@ -1,49 +1,16 @@ /** * Root package for the OG4Dev Spring API Response Library. *

- * This library provides zero-configuration, production-ready REST API response handling - * for Spring Boot applications. Simply adding the dependency enables all features - * automatically through Spring Boot's autoconfiguration mechanism. + * This library provides standardized API response handling for Spring Boot applications, + * including exception handling, response formatting, and tracing capabilities with zero configuration. *

- * - *

Key Features

- *
    - *
  • Standardized Responses — Uniform {@link io.github.og4dev.dto.ApiResponse} - * structure with HTTP status, message, content, and timestamp.
  • - *
  • Automatic Wrapping — Opt-in {@link io.github.og4dev.annotation.AutoResponse} - * annotation eliminates manual {@code ResponseEntity>} boilerplate.
  • - *
  • Global Exception Handling — 10 built-in RFC 9457 ProblemDetail handlers - * covering validation, 404, 405, 415, malformed JSON, and more.
  • - *
  • Extensible Translators — {@link io.github.og4dev.exception.ApiExceptionTranslator} - * allows mapping of third-party exceptions without additional {@code @ExceptionHandler} - * methods.
  • - *
  • Distributed Tracing — {@link io.github.og4dev.filter.TraceIdFilter} injects - * a UUID trace ID into every request for log correlation.
  • - *
  • XSS Protection — Opt-in {@link io.github.og4dev.annotation.XssCheck} rejects - * HTML payloads at the deserialization layer with fail-fast 400 responses.
  • - *
  • String Trimming — Opt-in {@link io.github.og4dev.annotation.AutoTrim} strips - * whitespace at the deserialization layer before values reach business logic.
  • - *
- * - *

Quick Start

*

- * Add the Maven dependency — no other configuration is required: + * Simply add the dependency to your project, and all features are automatically enabled through + * Spring Boot autoconfiguration. *

- *
{@code
- * 
- *     io.github.og4dev
- *     og4dev-spring-response
- *     1.4.0
- * 
- * }
* * @author Pasindu OG - * @version 1.4.0 + * @version 1.5.0 * @since 1.0.0 - * @see io.github.og4dev.dto.ApiResponse - * @see io.github.og4dev.advice.GlobalResponseWrapper - * @see io.github.og4dev.exception.GlobalExceptionHandler - * @see io.github.og4dev.config.ApiResponseAutoConfiguration */ package io.github.og4dev; - From 4f976158c43d2d32779574515793833b9669d9a2 Mon Sep 17 00:00:00 2001 From: Pasindu Owa Gamage Date: Sat, 18 Apr 2026 21:59:43 +0530 Subject: [PATCH 3/3] fix: correct typos in Javadoc comments and update license URL to HTTPS --- pom.xml | 2 +- .../java/io/github/og4dev/advice/GlobalResponseWrapper.java | 2 +- src/main/java/io/github/og4dev/annotation/AutoTrim.java | 2 +- src/main/java/io/github/og4dev/annotation/XssCheck.java | 4 ++-- .../io/github/og4dev/config/ApiResponseAutoConfiguration.java | 2 +- src/main/java/io/github/og4dev/filter/TraceIdFilter.java | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pom.xml b/pom.xml index 109a436..546b348 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt + https://www.apache.org/licenses/LICENSE-2.0.txt diff --git a/src/main/java/io/github/og4dev/advice/GlobalResponseWrapper.java b/src/main/java/io/github/og4dev/advice/GlobalResponseWrapper.java index f5245ee..2de90c3 100644 --- a/src/main/java/io/github/og4dev/advice/GlobalResponseWrapper.java +++ b/src/main/java/io/github/og4dev/advice/GlobalResponseWrapper.java @@ -29,7 +29,7 @@ * {@code ResponseEntity>} boilerplate. *

* - *

Core Behaviours

+ *

Core Behaviors

*
    *
  • Automatic Encapsulation — Raw DTOs, collections, and primitive values are * placed in the {@code content} field of an {@link ApiResponse}.
  • diff --git a/src/main/java/io/github/og4dev/annotation/AutoTrim.java b/src/main/java/io/github/og4dev/annotation/AutoTrim.java index 21cb47b..2187e28 100644 --- a/src/main/java/io/github/og4dev/annotation/AutoTrim.java +++ b/src/main/java/io/github/og4dev/annotation/AutoTrim.java @@ -9,7 +9,7 @@ * Annotation to explicitly enable automatic string trimming during JSON deserialization. *

    * By default, the OG4Dev Spring API Response library does NOT automatically trim strings. - * This annotation allows you to opt-in to automatic trimming for specific fields or entire + * This annotation allows you to opt in to automatic trimming for specific fields or entire * classes where removing leading and trailing whitespace is desired for data quality and consistency. *

    *

    diff --git a/src/main/java/io/github/og4dev/annotation/XssCheck.java b/src/main/java/io/github/og4dev/annotation/XssCheck.java index 7eb8dac..a7af91b 100644 --- a/src/main/java/io/github/og4dev/annotation/XssCheck.java +++ b/src/main/java/io/github/og4dev/annotation/XssCheck.java @@ -9,7 +9,7 @@ * Annotation to explicitly enable XSS (Cross-Site Scripting) validation for string fields during JSON deserialization. *

    * By default, the OG4Dev Spring API Response library does NOT perform XSS validation on strings. - * This annotation allows you to opt-in to automatic HTML/XML tag detection and rejection for specific fields + * This annotation allows you to opt in to automatic HTML/XML tag detection and rejection for specific fields * or entire classes where preventing malicious content injection is critical for security. *

    *

    @@ -94,7 +94,7 @@ *

  • Self-closing tags: {@code
    }, {@code }
  • *
  • Special tags: {@code }, {@code }, {@code }
  • *
  • Tags with attributes: {@code
    }, {@code }
  • - *
  • Multiline tags: Tags spanning multiple lines (DOTALL mode enabled)
  • + *
  • Multiline tags: Tags spanning multiple lines (DOT ALL mode enabled)
  • *
*

* What is NOT detected (safe to use): diff --git a/src/main/java/io/github/og4dev/config/ApiResponseAutoConfiguration.java b/src/main/java/io/github/og4dev/config/ApiResponseAutoConfiguration.java index ba67ede..3d10b4a 100644 --- a/src/main/java/io/github/og4dev/config/ApiResponseAutoConfiguration.java +++ b/src/main/java/io/github/og4dev/config/ApiResponseAutoConfiguration.java @@ -207,7 +207,7 @@ public AdvancedStringDeserializer(boolean shouldTrim, boolean shouldXssCheck) { } @Override - public String deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { + public String deserialize(JsonParser p, DeserializationContext text) throws JacksonException { String value = p.getValueAsString(); if (value == null) { return null; diff --git a/src/main/java/io/github/og4dev/filter/TraceIdFilter.java b/src/main/java/io/github/og4dev/filter/TraceIdFilter.java index 3a89691..aa67152 100644 --- a/src/main/java/io/github/og4dev/filter/TraceIdFilter.java +++ b/src/main/java/io/github/og4dev/filter/TraceIdFilter.java @@ -30,7 +30,7 @@ *

  • Compatible with microservices architectures
  • * *

    - * Usage: Register this filter as a Spring bean with highest precedence: + * Usage: Register this filter as a Spring bean with the highest precedence: *

    *
    {@code
      * @Configuration