|
| 1 | +/* |
| 2 | + * Copyright (C) 2026 Dynamia Soluciones IT S.A.S - NIT 900302344-1 |
| 3 | + * Colombia / South America |
| 4 | + * |
| 5 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 6 | + * you may not use this file except in compliance with the License. |
| 7 | + * You may obtain a copy of the License at |
| 8 | + * |
| 9 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | + * |
| 11 | + * Unless required by applicable law or agreed to in writing, software |
| 12 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 13 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 14 | + * See the License for the specific language governing permissions and |
| 15 | + * limitations under the License. |
| 16 | + */ |
| 17 | +package tools.dynamia.web.navigation; |
| 18 | + |
| 19 | +import jakarta.servlet.http.HttpServletRequest; |
| 20 | +import org.springframework.http.HttpStatus; |
| 21 | +import org.springframework.http.MediaType; |
| 22 | +import org.springframework.http.ResponseEntity; |
| 23 | +import org.springframework.web.bind.annotation.ExceptionHandler; |
| 24 | +import org.springframework.web.bind.annotation.RestControllerAdvice; |
| 25 | +import tools.dynamia.commons.logger.LoggingService; |
| 26 | +import tools.dynamia.commons.logger.SLF4JLoggingService; |
| 27 | +import tools.dynamia.domain.ValidationError; |
| 28 | +import tools.dynamia.navigation.NavigationNotAllowedException; |
| 29 | +import tools.dynamia.navigation.PageNotFoundException; |
| 30 | + |
| 31 | +/** |
| 32 | + * Centralized exception handler for all REST API endpoints under {@code /api/**}. |
| 33 | + * |
| 34 | + * <p>Intercepts exceptions thrown by {@link RestNavigationController} and translates them |
| 35 | + * into consistent, machine-readable JSON error responses using the {@link ErrorResult} structure. |
| 36 | + * This keeps controller methods free of repetitive try/catch blocks and guarantees a uniform |
| 37 | + * error contract for API clients.</p> |
| 38 | + * |
| 39 | + * <h2>Handled exceptions and HTTP status mapping</h2> |
| 40 | + * <table border="1"> |
| 41 | + * <tr><th>Exception</th><th>HTTP Status</th><th>Reason</th></tr> |
| 42 | + * <tr><td>{@link PageNotFoundException}</td><td>404 Not Found</td><td>The requested path does not map to any registered {@link tools.dynamia.crud.CrudPage}</td></tr> |
| 43 | + * <tr><td>{@link NavigationNotAllowedException}</td><td>403 Forbidden</td><td>The current user lacks access to the requested page</td></tr> |
| 44 | + * <tr><td>{@link ValidationError}</td><td>422 Unprocessable Entity</td><td>The submitted entity failed business / bean-validation rules</td></tr> |
| 45 | + * <tr><td>{@link IllegalArgumentException}</td><td>400 Bad Request</td><td>A request parameter or body could not be parsed or is semantically invalid</td></tr> |
| 46 | + * <tr><td>{@link Exception} (catch-all)</td><td>500 Internal Server Error</td><td>Any unexpected server-side failure</td></tr> |
| 47 | + * </table> |
| 48 | + * |
| 49 | + * <p>All responses carry {@code Content-Type: application/json} and a body that conforms to |
| 50 | + * {@link ErrorResult}.</p> |
| 51 | + * |
| 52 | + * @author Mario A. Serrano Leones |
| 53 | + * @see ErrorResult |
| 54 | + * @see RestNavigationController |
| 55 | + */ |
| 56 | +@RestControllerAdvice(basePackages = "tools.dynamia.web.navigation") |
| 57 | +public class RestApiExceptionHandler { |
| 58 | + |
| 59 | + private static final LoggingService logger = new SLF4JLoggingService(RestApiExceptionHandler.class); |
| 60 | + |
| 61 | + // ------------------------------------------------------------------------- |
| 62 | + // 404 — resource / path not found |
| 63 | + // ------------------------------------------------------------------------- |
| 64 | + |
| 65 | + /** |
| 66 | + * Handles {@link PageNotFoundException}, which is thrown when the request URI does not |
| 67 | + * resolve to any registered {@link tools.dynamia.crud.CrudPage} in the navigation structure. |
| 68 | + * |
| 69 | + * @param ex the exception carrying the "not found" message |
| 70 | + * @param request the current HTTP request |
| 71 | + * @return a {@code 404 Not Found} JSON response |
| 72 | + */ |
| 73 | + @ExceptionHandler(PageNotFoundException.class) |
| 74 | + public ResponseEntity<ErrorResult> handlePageNotFound(PageNotFoundException ex, HttpServletRequest request) { |
| 75 | + logger.warn("Page not found: " + request.getRequestURI() + " - " + ex.getMessage()); |
| 76 | + return errorResponse(HttpStatus.NOT_FOUND, "NOT_FOUND", ex.getMessage(), request); |
| 77 | + } |
| 78 | + |
| 79 | + // ------------------------------------------------------------------------- |
| 80 | + // 403 — access denied |
| 81 | + // ------------------------------------------------------------------------- |
| 82 | + |
| 83 | + /** |
| 84 | + * Handles {@link NavigationNotAllowedException}, thrown when |
| 85 | + * {@link tools.dynamia.navigation.NavigationRestrictions#verifyAccess} denies the caller |
| 86 | + * access to the requested page. |
| 87 | + * |
| 88 | + * @param ex the access-denied exception |
| 89 | + * @param request the current HTTP request |
| 90 | + * @return a {@code 403 Forbidden} JSON response |
| 91 | + */ |
| 92 | + @ExceptionHandler(NavigationNotAllowedException.class) |
| 93 | + public ResponseEntity<ErrorResult> handleAccessDenied(NavigationNotAllowedException ex, HttpServletRequest request) { |
| 94 | + logger.warn("Access denied to: " + request.getRequestURI() + " - " + ex.getMessage()); |
| 95 | + return errorResponse(HttpStatus.FORBIDDEN, "ACCESS_DENIED", ex.getMessage(), request); |
| 96 | + } |
| 97 | + |
| 98 | + // ------------------------------------------------------------------------- |
| 99 | + // 422 — validation / business-rule failure |
| 100 | + // ------------------------------------------------------------------------- |
| 101 | + |
| 102 | + /** |
| 103 | + * Handles {@link ValidationError}, thrown by domain validators or bean-validation constraints |
| 104 | + * when the submitted entity does not satisfy business rules. |
| 105 | + * |
| 106 | + * <p>The response body includes the {@code invalidProperty} and {@code invalidValue} fields |
| 107 | + * from the {@link ValidationError} when they are available.</p> |
| 108 | + * |
| 109 | + * @param ex the validation exception |
| 110 | + * @param request the current HTTP request |
| 111 | + * @return a {@code 422 Unprocessable Entity} JSON response |
| 112 | + */ |
| 113 | + @ExceptionHandler(ValidationError.class) |
| 114 | + public ResponseEntity<ErrorResult> handleValidationError(ValidationError ex, HttpServletRequest request) { |
| 115 | + logger.warn("Validation error on: " + request.getRequestURI() + " - " + ex.getMessage()); |
| 116 | + ErrorResult error = new ErrorResult( |
| 117 | + 422, |
| 118 | + "VALIDATION_ERROR", |
| 119 | + ex.getMessage(), |
| 120 | + request.getRequestURI() |
| 121 | + ); |
| 122 | + if (ex.getInvalidProperty() != null) { |
| 123 | + error.addDetail("invalidProperty", ex.getInvalidProperty()); |
| 124 | + } |
| 125 | + if (ex.getInvalidValue() != null) { |
| 126 | + error.addDetail("invalidValue", String.valueOf(ex.getInvalidValue())); |
| 127 | + } |
| 128 | + return ResponseEntity |
| 129 | + .status(HttpStatus.valueOf(422)) |
| 130 | + .contentType(MediaType.APPLICATION_JSON) |
| 131 | + .body(error); |
| 132 | + } |
| 133 | + |
| 134 | + // ------------------------------------------------------------------------- |
| 135 | + // 400 — bad request / illegal argument |
| 136 | + // ------------------------------------------------------------------------- |
| 137 | + |
| 138 | + /** |
| 139 | + * Handles {@link IllegalArgumentException}, raised when a request parameter or body |
| 140 | + * value cannot be parsed or is semantically invalid (e.g., a non-numeric value for a |
| 141 | + * numeric field). |
| 142 | + * |
| 143 | + * @param ex the illegal-argument exception |
| 144 | + * @param request the current HTTP request |
| 145 | + * @return a {@code 400 Bad Request} JSON response |
| 146 | + */ |
| 147 | + @ExceptionHandler(IllegalArgumentException.class) |
| 148 | + public ResponseEntity<ErrorResult> handleBadRequest(IllegalArgumentException ex, HttpServletRequest request) { |
| 149 | + logger.warn("Bad request on: " + request.getRequestURI() + " - " + ex.getMessage()); |
| 150 | + return errorResponse(HttpStatus.BAD_REQUEST, "BAD_REQUEST", ex.getMessage(), request); |
| 151 | + } |
| 152 | + |
| 153 | + // ------------------------------------------------------------------------- |
| 154 | + // 500 — catch-all |
| 155 | + // ------------------------------------------------------------------------- |
| 156 | + |
| 157 | + /** |
| 158 | + * Catch-all handler for any unexpected exception not covered by the more specific handlers above. |
| 159 | + * The full stack trace is logged at ERROR level while only a generic message is returned to |
| 160 | + * the client to avoid leaking internal implementation details. |
| 161 | + * |
| 162 | + * @param ex the unexpected exception |
| 163 | + * @param request the current HTTP request |
| 164 | + * @return a {@code 500 Internal Server Error} JSON response |
| 165 | + */ |
| 166 | + @ExceptionHandler(Exception.class) |
| 167 | + public ResponseEntity<ErrorResult> handleGenericException(Exception ex, HttpServletRequest request) { |
| 168 | + logger.error("Unexpected error on: " + request.getRequestURI(), ex); |
| 169 | + return errorResponse( |
| 170 | + HttpStatus.INTERNAL_SERVER_ERROR, |
| 171 | + "INTERNAL_ERROR", |
| 172 | + "An unexpected error occurred. Please contact the system administrator.", |
| 173 | + request |
| 174 | + ); |
| 175 | + } |
| 176 | + |
| 177 | + // ------------------------------------------------------------------------- |
| 178 | + // Helper |
| 179 | + // ------------------------------------------------------------------------- |
| 180 | + |
| 181 | + /** |
| 182 | + * Builds a {@link ResponseEntity} carrying an {@link ErrorResult} with |
| 183 | + * {@code Content-Type: application/json}. |
| 184 | + * |
| 185 | + * @param status the HTTP status to use |
| 186 | + * @param code the machine-readable error code |
| 187 | + * @param message the human-readable error message |
| 188 | + * @param request the current HTTP request (used to populate the {@code path} field) |
| 189 | + * @return the fully constructed error response |
| 190 | + */ |
| 191 | + private static ResponseEntity<ErrorResult> errorResponse(HttpStatus status, String code, |
| 192 | + String message, HttpServletRequest request) { |
| 193 | + ErrorResult error = new ErrorResult(status.value(), code, message, request.getRequestURI()); |
| 194 | + return ResponseEntity |
| 195 | + .status(status) |
| 196 | + .contentType(MediaType.APPLICATION_JSON) |
| 197 | + .body(error); |
| 198 | + } |
| 199 | +} |
| 200 | + |
| 201 | + |
| 202 | + |
0 commit comments