Skip to content

Commit 3d2ef8a

Browse files
authored
Merge pull request #31 from OG4Dev/dev
v1.4.0 Released Officially to Maven Central!
2 parents f258726 + 76475eb commit 3d2ef8a

18 files changed

Lines changed: 827 additions & 3875 deletions

README.md

Lines changed: 256 additions & 3451 deletions
Large diffs are not rendered by default.

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
<groupId>io.github.og4dev</groupId>
77
<artifactId>og4dev-spring-response</artifactId>
8-
<version>1.3.0</version>
8+
<version>1.4.0</version>
99

1010
<name>OG4Dev Spring API Response</name>
1111
<description>A lightweight, zero-configuration REST API Response wrapper and Global Exception Handler (RFC 9457) for Spring Boot applications, maintained by OG4Dev.</description>
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package io.github.og4dev.advice;
2+
3+
import io.github.og4dev.annotation.AutoResponse;
4+
import io.github.og4dev.dto.ApiResponse;
5+
import org.jspecify.annotations.NullMarked;
6+
import org.jspecify.annotations.Nullable;
7+
import org.springframework.core.MethodParameter;
8+
import org.springframework.http.HttpStatus;
9+
import org.springframework.http.MediaType;
10+
import org.springframework.http.ProblemDetail;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.http.converter.HttpMessageConverter;
13+
import org.springframework.http.server.ServerHttpRequest;
14+
import org.springframework.http.server.ServerHttpResponse;
15+
import org.springframework.http.server.ServletServerHttpResponse;
16+
import org.springframework.web.bind.annotation.RestControllerAdvice;
17+
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
18+
import tools.jackson.core.JacksonException;
19+
import tools.jackson.databind.ObjectMapper;
20+
21+
/**
22+
* Global response interceptor that automatically wraps REST controller outputs into the
23+
* standardized {@link ApiResponse} format.
24+
* <p>
25+
* This wrapper is conditionally activated <b>only</b> for controllers or specific methods
26+
* annotated with the {@link AutoResponse @AutoResponse} annotation. It provides a seamless
27+
* developer experience by eliminating the need to manually return {@code ResponseEntity<ApiResponse<T>>}
28+
* from every controller method.
29+
* </p>
30+
* <h2>Core Functionalities:</h2>
31+
* <ul>
32+
* <li><b>Automatic Encapsulation:</b> Intercepts raw DTOs, Lists, or primitive responses and packages
33+
* them into the {@code content} field of an {@code ApiResponse}.</li>
34+
* <li><b>Status Code Preservation:</b> Dynamically reads the current HTTP status of the response
35+
* (e.g., set via {@code @ResponseStatus(HttpStatus.CREATED)}) and ensures it is accurately
36+
* reflected in the final {@code ApiResponse}.</li>
37+
* <li><b>String Payload Compatibility:</b> Safely intercepts raw {@code String} returns and manually
38+
* serializes them to prevent {@code ClassCastException} when Spring utilizes the {@code StringHttpMessageConverter}.</li>
39+
* <li><b>Safety Mechanisms:</b> Intelligently skips wrapping if the response is already formatted
40+
* to prevent double-wrapping errors or interference with standard error handling protocols.</li>
41+
* </ul>
42+
*
43+
* @author Pasindu OG
44+
* @version 1.4.0
45+
* @see AutoResponse
46+
* @see ApiResponse
47+
* @see ResponseBodyAdvice
48+
* @since 1.4.0
49+
*/
50+
@RestControllerAdvice
51+
@SuppressWarnings("unused")
52+
public @NullMarked class GlobalResponseWrapper implements ResponseBodyAdvice<Object> {
53+
54+
private final ObjectMapper objectMapper;
55+
56+
/**
57+
* Constructs a new {@code GlobalResponseWrapper} with the provided {@link ObjectMapper}.
58+
*
59+
* @param objectMapper The Jackson object mapper used for explicit string serialization.
60+
*/
61+
public GlobalResponseWrapper(ObjectMapper objectMapper) {
62+
this.objectMapper = objectMapper;
63+
}
64+
65+
/**
66+
* Determines whether the current response should be intercepted and wrapped.
67+
* <p>
68+
* This method evaluates two main conditions before allowing the response to be wrapped:
69+
* </p>
70+
* <ol>
71+
* <li><b>Annotation Presence:</b> The target controller class or the specific handler method
72+
* must be annotated with {@link AutoResponse}.</li>
73+
* <li><b>Type Exclusion:</b> The return type must <b>not</b> be one of the explicitly excluded types.</li>
74+
* </ol>
75+
* <p>
76+
* To guarantee application stability and adherence to standard HTTP protocols, this method
77+
* specifically <b>excludes</b> the following return types from being wrapped:
78+
* </p>
79+
* <ul>
80+
* <li>{@link ApiResponse} - Prevents recursive double-wrapping (e.g., {@code ApiResponse<ApiResponse<T>>}).</li>
81+
* <li>{@link ResponseEntity} - Skips manual responses to respect developer's explicit configurations.</li>
82+
* <li>{@link ProblemDetail} - Excludes RFC 9457 error responses generated by exception handlers.</li>
83+
* </ul>
84+
* <p>
85+
* Note: Unlike standard wrappers, raw {@link String} payloads are <b>supported</b> and handled
86+
* appropriately during the write phase.
87+
* </p>
88+
*
89+
* @param returnType The return type of the controller method.
90+
* @param converterType The selected HTTP message converter.
91+
* @return {@code true} if annotated with {@code @AutoResponse} and not an excluded type; {@code false} otherwise.
92+
*/
93+
@Override
94+
public @NullMarked boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
95+
Class<?> type = returnType.getParameterType();
96+
boolean isExcludedType = ApiResponse.class.isAssignableFrom(type) ||
97+
ResponseEntity.class.isAssignableFrom(type) ||
98+
ProblemDetail.class.isAssignableFrom(type);
99+
if (isExcludedType) return false;
100+
boolean hasClassAnnotation = returnType.getDeclaringClass().isAnnotationPresent(AutoResponse.class);
101+
boolean hasMethodAnnotation = returnType.hasMethodAnnotation(AutoResponse.class);
102+
return hasClassAnnotation || hasMethodAnnotation;
103+
}
104+
105+
/**
106+
* Intercepts the response body before it is written to the output stream and encapsulates it
107+
* within an {@link ApiResponse}.
108+
* <p>
109+
* This method extracts the actual HTTP status code set on the current response (defaulting to 200 OK).
110+
* Based on whether the status code represents a success (2xx) or another state, it dynamically
111+
* assigns an appropriate message ("Success" or "Processed") to the API response.
112+
* </p>
113+
* <p>
114+
* <b>Special String Handling:</b> If the intercepted payload is a raw {@code String}, it is
115+
* explicitly serialized to a JSON string using the configured {@link ObjectMapper}, and the
116+
* response {@code Content-Type} is strictly set to {@code application/json}. This prevents
117+
* standard message converter conflicts.
118+
* </p>
119+
*
120+
* @param body The raw object returned by the controller method.
121+
* @param returnType The return type of the controller method.
122+
* @param selectedContentType The selected content type for the response.
123+
* @param selectedConverterType The selected HTTP message converter.
124+
* @param request The current server HTTP request.
125+
* @param response The current server HTTP response.
126+
* @return The newly wrapped {@code ApiResponse} object ready to be serialized, or a pre-serialized
127+
* JSON {@code String} if the original payload was a raw string.
128+
*/
129+
@Override
130+
public @Nullable Object beforeBodyWrite(@Nullable Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
131+
int statusCode = HttpStatus.OK.value();
132+
133+
if (response instanceof ServletServerHttpResponse serverHttpResponse) {
134+
statusCode = serverHttpResponse.getServletResponse().getStatus();
135+
}
136+
HttpStatus httpStatus = HttpStatus.valueOf(statusCode);
137+
ApiResponse<Object> apiResponse = ApiResponse.status(httpStatus.is2xxSuccessful() ? "Success" : "Processed", body, httpStatus).getBody();
138+
139+
if (body instanceof String) {
140+
try {
141+
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
142+
assert apiResponse != null;
143+
return objectMapper.writeValueAsString(apiResponse);
144+
} catch (JacksonException e) {
145+
return body;
146+
}
147+
}
148+
return apiResponse;
149+
}
150+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Provides global advisory components for the OG4Dev Spring API Response Library.
3+
* <p>
4+
* This package contains Spring {@link org.springframework.web.bind.annotation.RestControllerAdvice}
5+
* implementations that intercept and modify HTTP responses and requests globally across the application.
6+
* </p>
7+
* <p>
8+
* The primary component within this package is the {@link io.github.og4dev.advice.GlobalResponseWrapper}.
9+
* It facilitates the seamless, opt-in encapsulation of standard controller return values into the
10+
* standardized {@link io.github.og4dev.dto.ApiResponse} format, significantly reducing boilerplate code.
11+
* </p>
12+
* <h2>Integration &amp; Usage</h2>
13+
* <p>
14+
* Components in this package are automatically registered via Spring Boot's auto-configuration
15+
* mechanism ({@code ApiResponseAutoConfiguration}). Developers do not need to manually scan, import,
16+
* or configure this package.
17+
* </p>
18+
* <p>
19+
* To activate the response wrapping capabilities provided by this package, simply annotate
20+
* target REST controllers or specific methods with the {@link io.github.og4dev.annotation.AutoResponse @AutoResponse}
21+
* annotation.
22+
* </p>
23+
*
24+
* @author Pasindu OG
25+
* @version 1.4.0
26+
* @see io.github.og4dev.advice.GlobalResponseWrapper
27+
* @see io.github.og4dev.annotation.AutoResponse
28+
* @since 1.4.0
29+
*/
30+
package io.github.og4dev.advice;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package io.github.og4dev.annotation;
2+
3+
import java.lang.annotation.*;
4+
5+
/**
6+
* Opt-in annotation to enable automatic API response wrapping for Spring REST controllers.
7+
* <p>
8+
* When this annotation is applied to a {@link org.springframework.web.bind.annotation.RestController}
9+
* class or a specific request mapping method, the {@link io.github.og4dev.advice.GlobalResponseWrapper}
10+
* intercepts the returned object and automatically encapsulates it within the standardized
11+
* {@link io.github.og4dev.dto.ApiResponse} format.
12+
* </p>
13+
* <h2>Usage:</h2>
14+
* <ul>
15+
* <li><b>Class Level ({@link ElementType#TYPE}):</b> Applies the wrapping behavior to <i>all</i> endpoint methods within the controller.</li>
16+
* <li><b>Method Level ({@link ElementType#METHOD}):</b> Applies the wrapping behavior <i>only</i> to the specific annotated method.</li>
17+
* </ul>
18+
* <h2>Example:</h2>
19+
* <pre>{@code
20+
* @RestController
21+
* @RequestMapping("/api/users")
22+
* @AutoResponse // All methods in this controller will be automatically wrapped
23+
* public class UserController {
24+
* * @GetMapping("/{id}")
25+
* public UserDto getUser(@PathVariable Long id) {
26+
* // Returns: { "status": "Success", "content": { "id": 1, ... }, "timestamp": "..." }
27+
* return userService.findById(id);
28+
* }
29+
* * @PostMapping
30+
* @ResponseStatus(HttpStatus.CREATED)
31+
* // @AutoResponse can also be placed here for method-level granularity instead of class-level
32+
* public UserDto createUser(@RequestBody UserDto dto) {
33+
* return userService.create(dto);
34+
* }
35+
* }
36+
* }</pre>
37+
* <h2>Exclusions:</h2>
38+
* <p>
39+
* To prevent errors and double-wrapping, the interceptor will safely ignore methods that return:
40+
* </p>
41+
* <ul>
42+
* <li>{@code ApiResponse} or {@code ResponseEntity} (Assumes the developer has explicitly formatted the response)</li>
43+
* <li>{@code ProblemDetail} (RFC 9457 error responses managed by the global exception handler)</li>
44+
* <li>{@code String} (Bypassed to avoid {@code ClassCastException} with Spring's internal string message converters)</li>
45+
* </ul>
46+
*
47+
* @author Pasindu OG
48+
* @version 1.4.0
49+
* @see io.github.og4dev.advice.GlobalResponseWrapper
50+
* @see io.github.og4dev.dto.ApiResponse
51+
* @since 1.4.0
52+
*/
53+
@Target({ElementType.TYPE, ElementType.METHOD})
54+
@Retention(RetentionPolicy.RUNTIME)
55+
@Documented
56+
public @interface AutoResponse {
57+
}

src/main/java/io/github/og4dev/annotation/AutoTrim.java

Lines changed: 37 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,57 +9,57 @@
99
* Annotation to explicitly enable automatic string trimming during JSON deserialization.
1010
* <p>
1111
* By default, the OG4Dev Spring API Response library does NOT automatically trim strings.
12-
* This annotation allows you to opt-in to automatic trimming for specific fields where
13-
* removing leading and trailing whitespace is desired for data quality and consistency.
12+
* This annotation allows you to opt-in to automatic trimming for specific fields or entire
13+
* classes where removing leading and trailing whitespace is desired for data quality and consistency.
1414
* </p>
1515
* <p>
1616
* <b>Important:</b> When {@code @AutoTrim} is applied, XSS validation (HTML tag detection)
1717
* is still performed on the trimmed value to maintain security.
1818
* </p>
1919
*
20-
* <h2>Use Cases</h2>
20+
* <h2>Target Scopes</h2>
2121
* <ul>
22-
* <li><b>User input fields:</b> Names, emails, addresses where whitespace is typically unwanted</li>
23-
* <li><b>Search queries:</b> Remove accidental spaces from user search inputs</li>
24-
* <li><b>Usernames:</b> Ensure consistent username formatting without leading/trailing spaces</li>
25-
* <li><b>Reference numbers:</b> IDs, codes, or identifiers that should not have extra whitespace</li>
26-
* <li><b>Categories/Tags:</b> Taxonomy values that need consistent formatting</li>
22+
* <li><b>Field Level ({@link ElementType#FIELD}):</b> Applies trimming <i>only</i> to the specific annotated String field.</li>
23+
* <li><b>Class Level ({@link ElementType#TYPE}):</b> Applies trimming to <i>all</i> String fields within the annotated class globally.</li>
2724
* </ul>
2825
*
29-
* <h2>Example Usage</h2>
26+
* <h2>Example Usage: Field Level</h2>
3027
* <pre>{@code
3128
* public class UserRegistrationDTO {
32-
* @AutoTrim
33-
* private String username; // Trimmed: " john_doe " → "john_doe"
29+
* @AutoTrim
30+
* private String username; // Trimmed: " john_doe " → "john_doe"
3431
*
35-
* @AutoTrim
36-
* private String email; // Trimmed: " user@example.com " → "user@example.com"
32+
* @AutoTrim
33+
* private String email; // Trimmed: " user@example.com " → "user@example.com"
3734
*
38-
* @AutoTrim
39-
* private String firstName; // Trimmed: " John " → "John"
35+
* private String password; // NOT trimmed (no annotation)
36+
* private String bio; // NOT trimmed (no annotation)
37+
* }
38+
* }</pre>
4039
*
41-
* private String password; // NOT trimmed (no annotation)
42-
* private String bio; // NOT trimmed (no annotation)
40+
* <h2>Example Usage: Class Level</h2>
41+
* <pre>{@code
42+
* @AutoTrim // Automatically applies to ALL String fields in this class!
43+
* public class GlobalTrimDTO {
44+
* private String firstName; // Trimmed: " John " → "John"
45+
* private String lastName; // Trimmed: " Doe " → "Doe"
46+
* private String address; // Trimmed: " 123 Main St " → "123 Main St"
4347
* }
4448
* }</pre>
4549
*
46-
* <h2>Input/Output Examples</h2>
50+
* <h2>Input/Output Examples (Class Level)</h2>
4751
* <pre>{@code
48-
* // Request JSON
52+
* // Request JSON for GlobalTrimDTO
4953
* {
50-
* "username": " john_doe ",
51-
* "email": " john@example.com ",
52-
* "firstName": "\t\nJohn\t\n",
53-
* "password": " myPass123 ",
54-
* "bio": " Software Developer "
54+
* "firstName": "\t\nJohn\t\n",
55+
* "lastName": " Doe ",
56+
* "address": " 123 Main St "
5557
* }
5658
*
5759
* // After Deserialization
58-
* username = "john_doe" // ✓ Trimmed (has @AutoTrim)
59-
* email = "john@example.com" // ✓ Trimmed (has @AutoTrim)
60-
* firstName = "John" // ✓ Trimmed (has @AutoTrim)
61-
* password = " myPass123 " // ✗ NOT trimmed (no annotation)
62-
* bio = " Software Developer " // ✗ NOT trimmed (no annotation)
60+
* firstName = "John" // ✓ Trimmed (due to class-level @AutoTrim)
61+
* lastName = "Doe" // ✓ Trimmed (due to class-level @AutoTrim)
62+
* address = "123 Main St" // ✓ Trimmed (due to class-level @AutoTrim)
6363
* }</pre>
6464
*
6565
* <h2>XSS Validation Still Active</h2>
@@ -77,10 +77,10 @@
7777
* You can combine {@code @AutoTrim} with {@link XssCheck @XssCheck} for both behaviors:
7878
* </p>
7979
* <pre>{@code
80+
* @AutoTrim // Trims all fields
8081
* public class SecureDTO {
81-
* @AutoTrim
82-
* @XssCheck
83-
* private String cleanInput; // Both trimmed and XSS-validated
82+
* @XssCheck
83+
* private String cleanInput; // Both trimmed (from class scope) and XSS-validated
8484
* }
8585
* }</pre>
8686
*
@@ -89,7 +89,8 @@
8989
* This annotation is processed by the {@code AdvancedStringDeserializer} in
9090
* {@link io.github.og4dev.config.ApiResponseAutoConfiguration#strictJsonCustomizer()}.
9191
* The deserializer uses {@link tools.jackson.databind.ValueDeserializer#createContextual}
92-
* to detect the annotation and create a specialized instance that enables trimming.
92+
* to detect the annotation on either the field itself or its declaring class, creating a
93+
* specialized instance that enables trimming.
9394
* </p>
9495
*
9596
* <h2>Null Value Handling</h2>
@@ -110,14 +111,14 @@
110111
* </p>
111112
*
112113
* @author Pasindu OG
113-
* @version 1.3.0
114+
* @version 1.4.0
114115
* @see io.github.og4dev.config.ApiResponseAutoConfiguration#strictJsonCustomizer()
115116
* @see io.github.og4dev.annotation.XssCheck
116117
* @see tools.jackson.databind.ValueDeserializer#createContextual
117118
* @since 1.3.0
118119
*/
119-
@Target(ElementType.FIELD)
120+
@Target({ElementType.TYPE, ElementType.FIELD})
120121
@Retention(RetentionPolicy.RUNTIME)
121122
@SuppressWarnings("unused")
122123
public @interface AutoTrim {
123-
}
124+
}

0 commit comments

Comments
 (0)