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+ }
0 commit comments