Skip to content

Commit c8e4e73

Browse files
Implement REST operations for CRUD functionality in navigation API
1 parent ef76554 commit c8e4e73

File tree

9 files changed

+1513
-628
lines changed

9 files changed

+1513
-628
lines changed
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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 com.fasterxml.jackson.annotation.JsonInclude;
20+
21+
import java.time.Instant;
22+
import java.util.LinkedHashMap;
23+
import java.util.Map;
24+
25+
/**
26+
* Standardized JSON error response body returned by the REST API for all error conditions.
27+
*
28+
* <p>Every field in this record is included in the serialized JSON response so that API clients
29+
* receive a predictable, self-describing error envelope regardless of the failure type.
30+
* The {@code details} map is omitted when empty to keep simple error responses concise.</p>
31+
*
32+
* <h2>JSON example — validation error</h2>
33+
* <pre>{@code
34+
* {
35+
* "timestamp": "2026-03-08T14:32:01.123Z",
36+
* "status": 422,
37+
* "error": "VALIDATION_ERROR",
38+
* "message": "Name is required",
39+
* "path": "/api/users",
40+
* "details": {
41+
* "invalidProperty": "name",
42+
* "invalidValue": "null"
43+
* }
44+
* }
45+
* }</pre>
46+
*
47+
* <h2>JSON example — not found</h2>
48+
* <pre>{@code
49+
* {
50+
* "timestamp": "2026-03-08T14:32:05.456Z",
51+
* "status": 404,
52+
* "error": "NOT_FOUND",
53+
* "message": "Invalid Path users/unknown",
54+
* "path": "/api/users/unknown"
55+
* }
56+
* }</pre>
57+
*
58+
* <h2>Error codes</h2>
59+
* <table border="1">
60+
* <tr><th>Code</th><th>HTTP Status</th><th>Meaning</th></tr>
61+
* <tr><td>{@code NOT_FOUND}</td><td>404</td><td>Navigation path not registered</td></tr>
62+
* <tr><td>{@code ACCESS_DENIED}</td><td>403</td><td>User lacks access to the requested page</td></tr>
63+
* <tr><td>{@code VALIDATION_ERROR}</td><td>422</td><td>Entity failed business / bean-validation rules</td></tr>
64+
* <tr><td>{@code BAD_REQUEST}</td><td>400</td><td>Invalid request parameter or body</td></tr>
65+
* <tr><td>{@code INTERNAL_ERROR}</td><td>500</td><td>Unexpected server-side failure</td></tr>
66+
* </table>
67+
*
68+
* @author Mario A. Serrano Leones
69+
* @see RestApiExceptionHandler
70+
*/
71+
public class ErrorResult {
72+
73+
/** ISO-8601 timestamp of when the error occurred, set automatically on construction. */
74+
private final String timestamp;
75+
76+
/** HTTP status code (e.g. {@code 404}, {@code 422}). */
77+
private final int status;
78+
79+
/** Machine-readable error code (e.g. {@code "NOT_FOUND"}, {@code "VALIDATION_ERROR"}). */
80+
private final String error;
81+
82+
/** Human-readable description of the error, safe to display to the end user. */
83+
private final String message;
84+
85+
/** The request URI that triggered the error. */
86+
private final String path;
87+
88+
/**
89+
* Optional map of additional error details (e.g. the invalid field name and value for
90+
* validation errors). Omitted from JSON serialization when empty.
91+
*/
92+
@JsonInclude(JsonInclude.Include.NON_EMPTY)
93+
private final Map<String, String> details = new LinkedHashMap<>();
94+
95+
/**
96+
* Constructs a new {@code ErrorResult}.
97+
*
98+
* @param status the HTTP status code
99+
* @param error the machine-readable error code
100+
* @param message the human-readable error message
101+
* @param path the request URI that triggered the error
102+
*/
103+
public ErrorResult(int status, String error, String message, String path) {
104+
this.timestamp = Instant.now().toString();
105+
this.status = status;
106+
this.error = error;
107+
this.message = message;
108+
this.path = path;
109+
}
110+
111+
/**
112+
* Adds an entry to the {@code details} map.
113+
*
114+
* @param key the detail key (e.g. {@code "invalidProperty"})
115+
* @param value the detail value
116+
* @return this instance for method chaining
117+
*/
118+
public ErrorResult addDetail(String key, String value) {
119+
details.put(key, value);
120+
return this;
121+
}
122+
123+
/**
124+
* Returns the ISO-8601 timestamp of when the error was created.
125+
*
126+
* @return the timestamp string
127+
*/
128+
public String getTimestamp() {
129+
return timestamp;
130+
}
131+
132+
/**
133+
* Returns the HTTP status code.
134+
*
135+
* @return the status code
136+
*/
137+
public int getStatus() {
138+
return status;
139+
}
140+
141+
/**
142+
* Returns the machine-readable error code.
143+
*
144+
* @return the error code
145+
*/
146+
public String getError() {
147+
return error;
148+
}
149+
150+
/**
151+
* Returns the human-readable error message.
152+
*
153+
* @return the message
154+
*/
155+
public String getMessage() {
156+
return message;
157+
}
158+
159+
/**
160+
* Returns the request URI that triggered the error.
161+
*
162+
* @return the request path
163+
*/
164+
public String getPath() {
165+
return path;
166+
}
167+
168+
/**
169+
* Returns the optional details map. May be empty but is never {@code null}.
170+
*
171+
* @return the details map
172+
*/
173+
public Map<String, String> getDetails() {
174+
return details;
175+
}
176+
}
177+
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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

Comments
 (0)