This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new a304a4893 UNOMI-928: Improve REST API error handling with dedicated
exception mappers (#771)
a304a4893 is described below
commit a304a48939cd92ce403c07f0fb7784c7d300b89c
Author: Serge Huber <[email protected]>
AuthorDate: Thu Jun 11 17:27:26 2026 +0200
UNOMI-928: Improve REST API error handling with dedicated exception mappers
(#771)
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---
rest/pom.xml | 6 +
.../exception/AbstractRestExceptionMapper.java | 156 +++++++++++++++++++++
.../InternalServerErrorExceptionMapper.java | 81 +++++++++++
...Mapper.java => JsonMappingExceptionMapper.java} | 38 ++---
.../apache/unomi/rest/exception/LogSanitizer.java | 148 +++++++++++++++++++
.../rest/exception/RuntimeExceptionMapper.java | 39 +++---
.../unomi/rest/exception/LogSanitizerTest.java | 131 +++++++++++++++++
.../rest/exception/RestExceptionMapperTest.java | 89 ++++++++++++
8 files changed, 654 insertions(+), 34 deletions(-)
diff --git a/rest/pom.xml b/rest/pom.xml
index 315b9bbac..9e67c1f29 100644
--- a/rest/pom.xml
+++ b/rest/pom.xml
@@ -187,6 +187,12 @@
<artifactId>slf4j-api</artifactId>
<scope>provided</scope>
</dependency>
+
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
diff --git
a/rest/src/main/java/org/apache/unomi/rest/exception/AbstractRestExceptionMapper.java
b/rest/src/main/java/org/apache/unomi/rest/exception/AbstractRestExceptionMapper.java
new file mode 100644
index 000000000..e5fe695b7
--- /dev/null
+++
b/rest/src/main/java/org/apache/unomi/rest/exception/AbstractRestExceptionMapper.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.exception;
+
+import com.fasterxml.jackson.core.JsonParseException;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import org.apache.cxf.jaxrs.utils.JAXRSUtils;
+import org.apache.cxf.message.Message;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Base class for the REST {@code ExceptionMapper}s, factoring out the
behaviour they share:
+ * building a sanitized request context for logging, walking to a root cause,
detecting JSON
+ * deserialization failures, and producing the standard JSON error responses.
+ */
+public abstract class AbstractRestExceptionMapper {
+
+ private static final Logger LOGGER =
LoggerFactory.getLogger(AbstractRestExceptionMapper.class.getName());
+
+ private static final String ERROR_MESSAGE_KEY = "errorMessage";
+
+ /**
+ * @return a {@code 400 Bad Request} JSON response: {@code
{"errorMessage":"badRequest"}}
+ */
+ protected Response badRequestResponse() {
+ return jsonErrorResponse(Response.Status.BAD_REQUEST, "badRequest");
+ }
+
+ /**
+ * @return a {@code 500 Internal Server Error} JSON response: {@code
{"errorMessage":"internalServerError"}}
+ */
+ protected Response internalServerErrorResponse() {
+ return jsonErrorResponse(Response.Status.INTERNAL_SERVER_ERROR,
"internalServerError");
+ }
+
+ private Response jsonErrorResponse(Response.Status status, String
errorMessage) {
+ Map<String, Object> body = new HashMap<>();
+ body.put(ERROR_MESSAGE_KEY, errorMessage);
+ return Response.status(status).header("Content-Type",
MediaType.APPLICATION_JSON).entity(body).build();
+ }
+
+ /**
+ * @return {@code true} when the given root cause is a Jackson
deserialization failure, i.e. a
+ * client error (malformed/mistyped request body) rather than a genuine
server fault.
+ */
+ protected boolean isJsonDeserializationError(Throwable rootCause) {
+ return rootCause instanceof JsonMappingException || rootCause
instanceof JsonParseException;
+ }
+
+ protected Throwable getRootCause(Throwable throwable) {
+ if (throwable == null) {
+ return null;
+ }
+ Throwable current = throwable;
+ java.util.Set<Throwable> visited =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
+ while (current.getCause() != null && current.getCause() != current &&
visited.add(current)) {
+ current = current.getCause();
+ }
+ return current;
+ }
+
+ /**
+ * @return the throwable's message, or its simple class name when no
message is available.
+ */
+ protected String messageOrType(Throwable throwable) {
+ if (throwable == null) {
+ return "";
+ }
+ String message = throwable.getMessage();
+ return (message != null && !message.isEmpty()) ? message :
throwable.getClass().getSimpleName();
+ }
+
+ /**
+ * Builds a sanitized "METHOD /path?query" description of the current
request for logging.
+ * Never throws: returns a placeholder when the request context cannot be
resolved.
+ */
+ protected String buildRequestContext() {
+ StringBuilder context = new StringBuilder();
+ try {
+ Message message = JAXRSUtils.getCurrentMessage();
+ if (message == null) {
+ return "REQUEST CONTEXT UNAVAILABLE";
+ }
+ HttpServletRequest request = (HttpServletRequest)
message.get("HTTP.REQUEST");
+ if (request != null) {
+ appendFromServletRequest(context, request);
+ } else {
+ appendFromCxfMessage(context, message);
+ }
+ } catch (Exception e) {
+ LOGGER.debug("Error building request context", e);
+ return "REQUEST CONTEXT UNAVAILABLE";
+ }
+ return context.toString();
+ }
+
+ private void appendFromServletRequest(StringBuilder context,
HttpServletRequest request) {
+ context.append(LogSanitizer.httpMethod(request.getMethod()))
+ .append(" ")
+ .append(LogSanitizer.url(request.getRequestURI()));
+ String queryString = request.getQueryString();
+ if (queryString != null && !queryString.isEmpty()) {
+ context.append("?").append(LogSanitizer.queryString(queryString));
+ }
+ }
+
+ private void appendFromCxfMessage(StringBuilder context, Message message) {
+ String httpMethod = (String) message.get(Message.HTTP_REQUEST_METHOD);
+ String basePath = (String) message.get(Message.BASE_PATH);
+ String pathInfo = (String) message.get(Message.PATH_INFO);
+ String requestURI = (String) message.get(Message.REQUEST_URI);
+
+ if (requestURI != null) {
+ context.append(sanitizedMethodOrUnknown(httpMethod)).append("
").append(LogSanitizer.url(requestURI));
+ } else if (basePath != null || pathInfo != null) {
+ String path = (basePath != null ? basePath : "") + (pathInfo !=
null ? pathInfo : "");
+ context.append(sanitizedMethodOrUnknown(httpMethod)).append("
").append(LogSanitizer.url(path));
+ } else {
+ UriInfo uriInfo = message.get(UriInfo.class);
+ if (uriInfo != null) {
+ context.append("HTTP
").append(LogSanitizer.url(uriInfo.getPath()));
+ if (uriInfo.getQueryParameters() != null &&
!uriInfo.getQueryParameters().isEmpty()) {
+
context.append("?").append(LogSanitizer.queryParameters(uriInfo.getQueryParameters()));
+ }
+ } else {
+ context.append("UNKNOWN REQUEST");
+ }
+ }
+ }
+
+ private String sanitizedMethodOrUnknown(String httpMethod) {
+ return httpMethod != null ? LogSanitizer.httpMethod(httpMethod) :
"UNKNOWN";
+ }
+}
diff --git
a/rest/src/main/java/org/apache/unomi/rest/exception/InternalServerErrorExceptionMapper.java
b/rest/src/main/java/org/apache/unomi/rest/exception/InternalServerErrorExceptionMapper.java
new file mode 100644
index 000000000..42c4d437e
--- /dev/null
+++
b/rest/src/main/java/org/apache/unomi/rest/exception/InternalServerErrorExceptionMapper.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.exception;
+
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.ws.rs.InternalServerErrorException;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.ext.ExceptionMapper;
+import javax.ws.rs.ext.Provider;
+
+/**
+ * Maps {@link InternalServerErrorException}. When the underlying cause is a
Jackson deserialization
+ * failure (a wrapped client error) the response is downgraded to a {@code 400
Bad Request};
+ * otherwise it remains a {@code 500 Internal Server Error} with detailed,
sanitized logging.
+ */
+@Provider
+@Component(service = ExceptionMapper.class)
+public class InternalServerErrorExceptionMapper extends
AbstractRestExceptionMapper
+ implements ExceptionMapper<InternalServerErrorException> {
+
+ private static final Logger LOGGER =
LoggerFactory.getLogger(InternalServerErrorExceptionMapper.class.getName());
+
+ @Override
+ public Response toResponse(InternalServerErrorException exception) {
+ String requestContext = buildRequestContext();
+ Throwable rootCause = getRootCause(exception);
+
+ // A wrapped JSON deserialization failure is really a client error ->
400 Bad Request.
+ if (isJsonDeserializationError(rootCause)) {
+ String errorMessage =
LogSanitizer.forLogging(messageOrType(rootCause));
+ LOGGER.warn("Bad request on {} - JSON deserialization error: {}
(Set InternalServerErrorExceptionMapper to debug to get the full stacktrace)",
+ requestContext, errorMessage);
+ LOGGER.debug("Full JSON mapping exception details for request:
{}", requestContext, exception);
+ return badRequestResponse();
+ }
+
+ // Genuine server error -> 500 with detailed context.
+ LOGGER.error("{} (Set InternalServerErrorExceptionMapper to debug to
get the full stacktrace)",
+ buildServerErrorDetails(requestContext, exception, rootCause));
+ LOGGER.debug("Full exception details for request: {}", requestContext,
exception);
+
+ return internalServerErrorResponse();
+ }
+
+ private String buildServerErrorDetails(String requestContext,
InternalServerErrorException exception, Throwable rootCause) {
+ StringBuilder errorDetails = new StringBuilder();
+ errorDetails.append("Request failed: ").append(requestContext);
+
+ if (rootCause != null && rootCause != exception) {
+ errorDetails.append(" - Root cause:
").append(LogSanitizer.className(rootCause.getClass().getSimpleName()));
+ String rootCauseMessage = rootCause.getMessage();
+ if (rootCauseMessage != null && !rootCauseMessage.isEmpty()) {
+ errorDetails.append("
(").append(LogSanitizer.forLogging(rootCauseMessage)).append(")");
+ }
+ }
+
+ String exceptionMessage = exception.getMessage();
+ if (exceptionMessage != null && !exceptionMessage.isEmpty()
+ && (rootCause == null ||
!exceptionMessage.equals(rootCause.getMessage()))) {
+ errorDetails.append(" - Error:
").append(LogSanitizer.forLogging(exceptionMessage));
+ }
+ return errorDetails.toString();
+ }
+}
diff --git
a/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java
b/rest/src/main/java/org/apache/unomi/rest/exception/JsonMappingExceptionMapper.java
similarity index 50%
copy from
rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java
copy to
rest/src/main/java/org/apache/unomi/rest/exception/JsonMappingExceptionMapper.java
index c505d57d4..198fd47d7 100644
---
a/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java
+++
b/rest/src/main/java/org/apache/unomi/rest/exception/JsonMappingExceptionMapper.java
@@ -16,34 +16,36 @@
*/
package org.apache.unomi.rest.exception;
-import org.apache.commons.lang3.ArrayUtils;
+import com.fasterxml.jackson.databind.JsonMappingException;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
-import java.util.HashMap;
-
+/**
+ * Maps Jackson {@link JsonMappingException} (raised when a syntactically
valid JSON body cannot be
+ * deserialized into the target type) to a {@code 400 Bad Request}, so a
client mistake is not
+ * reported as a server error.
+ */
@Provider
-@Component(service=ExceptionMapper.class)
-public class RuntimeExceptionMapper implements
ExceptionMapper<RuntimeException> {
- private static final Logger LOGGER =
LoggerFactory.getLogger(RuntimeExceptionMapper.class.getName());
+@Component(service = ExceptionMapper.class)
+public class JsonMappingExceptionMapper extends AbstractRestExceptionMapper
implements ExceptionMapper<JsonMappingException> {
+
+ private static final Logger LOGGER =
LoggerFactory.getLogger(JsonMappingExceptionMapper.class.getName());
@Override
- public Response toResponse(RuntimeException exception) {
- HashMap<String, Object> body = new HashMap<>();
- body.put("errorMessage", "internalServerError");
- LOGGER.error(
- "Internal server error {}: {} in {} (Set
RuntimeExceptionMapper in debug to get the full stacktrace)",
- exception.getMessage(),
- exception,
- ArrayUtils.isEmpty(exception.getStackTrace()) ? "Stack not
available" : exception.getStackTrace()[0]
- );
- LOGGER.debug("{}", exception.getMessage(), exception);
- return
Response.status(Response.Status.INTERNAL_SERVER_ERROR).header("Content-Type",
MediaType.APPLICATION_JSON).entity(body).build();
+ public Response toResponse(JsonMappingException exception) {
+ String requestContext = buildRequestContext();
+ String errorMessage =
LogSanitizer.forLogging(messageOrType(exception));
+
+ // Client error: log at WARN level, full stack trace only at debug
level.
+ LOGGER.warn("Bad request on {} - JSON deserialization error: {} (Set
JsonMappingExceptionMapper to debug to get the full stacktrace)",
+ requestContext, errorMessage);
+ LOGGER.debug("Full JSON mapping exception details for request: {}",
requestContext, exception);
+
+ return badRequestResponse();
}
}
diff --git
a/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java
b/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java
new file mode 100644
index 000000000..4b40d7516
--- /dev/null
+++ b/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.exception;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Utility for sanitizing untrusted, request-derived values before they are
written to logs.
+ * <p>
+ * Centralizes the defenses against log injection (control characters,
log-format markers) and
+ * unbounded log growth (length limits) that are shared by the REST exception
mappers.
+ */
+final class LogSanitizer {
+
+ private static final int MAX_URL_LENGTH = 500;
+ private static final int MAX_QUERY_STRING_LENGTH = 200;
+ private static final int MAX_CLASS_NAME_LENGTH = 100;
+ private static final int MAX_METHOD_LENGTH = 10;
+ private static final int MAX_QUERY_PARAMS = 10;
+ private static final int MAX_QUERY_PARAM_VALUE_LENGTH = 50;
+
+ private static final Set<String> VALID_HTTP_METHODS = new
HashSet<>(Arrays.asList(
+ "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS",
"TRACE", "CONNECT"));
+
+ private LogSanitizer() {
+ }
+
+ /**
+ * Replaces every character that is not printable ASCII (or is a
log-format marker such as
+ * {@code \ { } % $}) with an underscore. This removes newlines, tabs and
other control
+ * characters that could be used for log injection.
+ */
+ static String forLogging(String input) {
+ if (input == null) {
+ return "";
+ }
+ StringBuilder sanitized = new StringBuilder(input.length());
+ for (int i = 0; i < input.length(); i++) {
+ char c = input.charAt(i);
+ if (c >= 0x20 && c <= 0x7E && c != '\\' && c != '{' && c != '}' &&
c != '%' && c != '$') {
+ sanitized.append(c);
+ } else {
+ sanitized.append('_');
+ }
+ }
+ return sanitized.toString();
+ }
+
+ static String url(String url) {
+ if (url == null) {
+ return "null";
+ }
+ if (url.length() > MAX_URL_LENGTH) {
+ url = url.substring(0, MAX_URL_LENGTH) + "...[truncated]";
+ }
+ return forLogging(url);
+ }
+
+ static String queryString(String queryString) {
+ if (queryString == null) {
+ return "";
+ }
+ if (queryString.length() > MAX_QUERY_STRING_LENGTH) {
+ queryString = queryString.substring(0, MAX_QUERY_STRING_LENGTH) +
"...[truncated]";
+ }
+ return forLogging(queryString);
+ }
+
+ static String httpMethod(String method) {
+ if (method == null || method.isEmpty()) {
+ return "UNKNOWN";
+ }
+ String sanitized = forLogging(method.toUpperCase());
+ if (VALID_HTTP_METHODS.contains(sanitized)) {
+ return sanitized;
+ }
+ if (sanitized.length() > MAX_METHOD_LENGTH) {
+ return sanitized.substring(0, MAX_METHOD_LENGTH) + "...";
+ }
+ return sanitized;
+ }
+
+ static String className(String className) {
+ if (className == null || className.isEmpty()) {
+ return "Unknown";
+ }
+ StringBuilder sanitized = new StringBuilder(className.length());
+ for (int i = 0; i < className.length(); i++) {
+ char c = className.charAt(i);
+ if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
+ || (c >= '0' && c <= '9') || c == '$' || c == '_' || c ==
'.') {
+ sanitized.append(c);
+ } else {
+ sanitized.append('_');
+ }
+ }
+ String result = sanitized.toString();
+ if (result.length() > MAX_CLASS_NAME_LENGTH) {
+ return result.substring(0, MAX_CLASS_NAME_LENGTH) + "...";
+ }
+ return result;
+ }
+
+ static String queryParameters(Map<String, List<String>> queryParams) {
+ if (queryParams == null || queryParams.isEmpty()) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ int paramCount = 0;
+ for (Map.Entry<String, List<String>> entry : queryParams.entrySet()) {
+ if (paramCount >= MAX_QUERY_PARAMS) {
+ sb.append("...[more params]");
+ break;
+ }
+ if (paramCount > 0) {
+ sb.append("&");
+ }
+ sb.append(url(entry.getKey())).append("=");
+ if (entry.getValue() != null && !entry.getValue().isEmpty()) {
+ String value = url(entry.getValue().get(0));
+ if (value.length() > MAX_QUERY_PARAM_VALUE_LENGTH) {
+ value = value.substring(0, MAX_QUERY_PARAM_VALUE_LENGTH) +
"...";
+ }
+ sb.append(value);
+ }
+ paramCount++;
+ }
+ return sb.toString();
+ }
+}
diff --git
a/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java
b/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java
index c505d57d4..1b50558a3 100644
---
a/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java
+++
b/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java
@@ -16,34 +16,41 @@
*/
package org.apache.unomi.rest.exception;
-import org.apache.commons.lang3.ArrayUtils;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
-import java.util.HashMap;
-
@Provider
-@Component(service=ExceptionMapper.class)
-public class RuntimeExceptionMapper implements
ExceptionMapper<RuntimeException> {
+@Component(service = ExceptionMapper.class)
+public class RuntimeExceptionMapper extends AbstractRestExceptionMapper
implements ExceptionMapper<RuntimeException> {
+
private static final Logger LOGGER =
LoggerFactory.getLogger(RuntimeExceptionMapper.class.getName());
@Override
public Response toResponse(RuntimeException exception) {
- HashMap<String, Object> body = new HashMap<>();
- body.put("errorMessage", "internalServerError");
- LOGGER.error(
- "Internal server error {}: {} in {} (Set
RuntimeExceptionMapper in debug to get the full stacktrace)",
- exception.getMessage(),
- exception,
- ArrayUtils.isEmpty(exception.getStackTrace()) ? "Stack not
available" : exception.getStackTrace()[0]
- );
- LOGGER.debug("{}", exception.getMessage(), exception);
- return
Response.status(Response.Status.INTERNAL_SERVER_ERROR).header("Content-Type",
MediaType.APPLICATION_JSON).entity(body).build();
+ String requestContext = buildRequestContext();
+ Throwable rootCause = getRootCause(exception);
+ String rootCauseClassName = LogSanitizer.className(rootCause != null ?
rootCause.getClass().getSimpleName() : "Unknown");
+ String rootCauseMessage = LogSanitizer.forLogging(rootCause != null &&
rootCause.getMessage() != null
+ ? rootCause.getMessage()
+ : (exception.getMessage() != null ? exception.getMessage() :
""));
+
+ // For client errors (like deserialization), log at WARN level. For
true server errors, log at ERROR level.
+ if (isJsonDeserializationError(rootCause)) {
+ LOGGER.warn(
+ "Bad request on {} - Root cause: {} - {} (Set
RuntimeExceptionMapper to debug to get the full stacktrace)",
+ requestContext, rootCauseClassName, rootCauseMessage);
+ } else {
+ LOGGER.error(
+ "Internal server error on {} - Root cause: {} - {} (Set
RuntimeExceptionMapper to debug to get the full stacktrace)",
+ requestContext, rootCauseClassName, rootCauseMessage);
+ }
+ LOGGER.debug("Full exception details for request: {}", requestContext,
exception);
+
+ return internalServerErrorResponse();
}
}
diff --git
a/rest/src/test/java/org/apache/unomi/rest/exception/LogSanitizerTest.java
b/rest/src/test/java/org/apache/unomi/rest/exception/LogSanitizerTest.java
new file mode 100644
index 000000000..dce71e7bf
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/exception/LogSanitizerTest.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.exception;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for {@link LogSanitizer}, guarding the log-injection defenses
shared by the exception mappers.
+ */
+class LogSanitizerTest {
+
+ @Test
+ void forLogging_replacesControlCharacters() {
+ assertEquals("a_b", LogSanitizer.forLogging("a\nb"));
+ assertEquals("a_b", LogSanitizer.forLogging("a\rb"));
+ assertEquals("a_b", LogSanitizer.forLogging("a\tb"));
+ }
+
+ @Test
+ void forLogging_replacesLogFormatMarkers() {
+ assertEquals("a_b_c_d_e_", LogSanitizer.forLogging("a{b}c%d$e\\"));
+ }
+
+ @Test
+ void forLogging_keepsPlainText() {
+ assertEquals("GET /context.json", LogSanitizer.forLogging("GET
/context.json"));
+ }
+
+ @Test
+ void forLogging_handlesNull() {
+ assertEquals("", LogSanitizer.forLogging(null));
+ }
+
+ @Test
+ void url_handlesNullAndTruncatesLongValues() {
+ assertEquals("null", LogSanitizer.url(null));
+ String longUrl = repeat("a", 600);
+ String sanitized = LogSanitizer.url(longUrl);
+ assertTrue(sanitized.endsWith("...[truncated]"), "Long URL should be
truncated: " + sanitized);
+ assertTrue(sanitized.length() < longUrl.length());
+ }
+
+ @Test
+ void httpMethod_normalizesAndWhitelists() {
+ assertEquals("GET", LogSanitizer.httpMethod("get"));
+ assertEquals("POST", LogSanitizer.httpMethod("post"));
+ assertEquals("UNKNOWN", LogSanitizer.httpMethod(null));
+ assertEquals("UNKNOWN", LogSanitizer.httpMethod(""));
+ // Non-standard methods are still sanitized but not blindly trusted.
+ assertEquals("WEIRD", LogSanitizer.httpMethod("weird"));
+ }
+
+ @Test
+ void className_keepsValidIdentifiersAndStripsTheRest() {
+ assertEquals("com.example.Foo$Bar",
LogSanitizer.className("com.example.Foo$Bar"));
+ assertEquals("bad_name_", LogSanitizer.className("bad name!"));
+ assertEquals("Unknown", LogSanitizer.className(null));
+ }
+
+ @Test
+ void queryString_handlesNullAndTruncatesLongValues() {
+ assertEquals("", LogSanitizer.queryString(null));
+ assertEquals("a=1", LogSanitizer.queryString("a=1"));
+ String longQs = repeat("a=b&", 60); // > MAX_QUERY_STRING_LENGTH (200)
+ String sanitized = LogSanitizer.queryString(longQs);
+ assertTrue(sanitized.endsWith("...[truncated]"), "Long query string
should be truncated: " + sanitized);
+ assertTrue(sanitized.length() < longQs.length());
+ }
+
+ @Test
+ void queryParameters_rendersKeyValuePairsAndTruncates() {
+ Map<String, List<String>> params = new LinkedHashMap<>();
+ params.put("q", Collections.singletonList("hello"));
+ params.put("page", Collections.singletonList("2"));
+ assertEquals("q=hello&page=2", LogSanitizer.queryParameters(params));
+
+ // Exactly at the limit: 10 params — no truncation marker expected
+ Map<String, List<String>> atLimit = new LinkedHashMap<>();
+ for (int i = 0; i < 10; i++) atLimit.put("k" + i,
Collections.singletonList("v" + i));
+ String atLimitResult = LogSanitizer.queryParameters(atLimit);
+ assertFalse(atLimitResult.contains("...[more params]"), "Should not
truncate at exactly 10 params");
+ assertEquals(9, countOccurrences(atLimitResult, '&'), "9 separators
expected for 10 params");
+
+ // 11 params — truncation marker must follow a separator cleanly
+ Map<String, List<String>> overLimit = new LinkedHashMap<>(atLimit);
+ overLimit.put("k10", Collections.singletonList("v10"));
+ String overLimitResult = LogSanitizer.queryParameters(overLimit);
+ assertTrue(overLimitResult.endsWith("...[more params]"), "Should end
with truncation marker: " + overLimitResult);
+ assertFalse(overLimitResult.contains("&...[more params]"),
+ "Separator must not immediately precede truncation marker: " +
overLimitResult);
+ }
+
+ private static int countOccurrences(String s, char c) {
+ int count = 0;
+ for (int i = 0; i < s.length(); i++) {
+ if (s.charAt(i) == c) count++;
+ }
+ return count;
+ }
+
+ private static String repeat(String s, int times) {
+ StringBuilder sb = new StringBuilder(s.length() * times);
+ for (int i = 0; i < times; i++) {
+ sb.append(s);
+ }
+ return sb.toString();
+ }
+}
diff --git
a/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java
b/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java
new file mode 100644
index 000000000..8594a89c7
--- /dev/null
+++
b/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.exception;
+
+import com.fasterxml.jackson.core.JsonParseException;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import org.junit.jupiter.api.Test;
+
+import javax.ws.rs.InternalServerErrorException;
+import javax.ws.rs.core.Response;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for the REST exception mappers (UNOMI-928). These verify the
status code and JSON body
+ * contract directly, without a running container, so the mapper logic is
validated deterministically
+ * regardless of how a given endpoint deserializes its request body.
+ */
+class RestExceptionMapperTest {
+
+ @Test
+ void jsonMappingException_mapsToBadRequest() {
+ Response response = new JsonMappingExceptionMapper()
+
.toResponse(JsonMappingException.from((com.fasterxml.jackson.core.JsonParser)
null, "cannot deserialize"));
+ assertErrorResponse(response, 400, "badRequest");
+ }
+
+ @Test
+ void runtimeException_mapsToInternalServerError() {
+ Response response = new RuntimeExceptionMapper().toResponse(new
RuntimeException("boom"));
+ assertErrorResponse(response, 500, "internalServerError");
+ }
+
+ @Test
+ void runtimeException_withJsonCause_stillMapsToInternalServerError() {
+ // RuntimeExceptionMapper only downgrades the log level for JSON
causes; the response stays 500.
+ RuntimeException exception = new RuntimeException(
+
JsonMappingException.from((com.fasterxml.jackson.core.JsonParser) null, "cannot
deserialize"));
+ Response response = new RuntimeExceptionMapper().toResponse(exception);
+ assertErrorResponse(response, 500, "internalServerError");
+ }
+
+ @Test
+ void internalServerError_withJsonMappingCause_mapsToBadRequest() {
+ InternalServerErrorException exception = new
InternalServerErrorException("wrapped",
+
JsonMappingException.from((com.fasterxml.jackson.core.JsonParser) null, "cannot
deserialize"));
+ Response response = new
InternalServerErrorExceptionMapper().toResponse(exception);
+ assertErrorResponse(response, 400, "badRequest");
+ }
+
+ @Test
+ void internalServerError_withJsonParseCause_mapsToBadRequest() {
+ InternalServerErrorException exception = new
InternalServerErrorException("wrapped",
+ new JsonParseException(null, "malformed"));
+ Response response = new
InternalServerErrorExceptionMapper().toResponse(exception);
+ assertErrorResponse(response, 400, "badRequest");
+ }
+
+ @Test
+ void internalServerError_withNonJsonCause_mapsToInternalServerError() {
+ InternalServerErrorException exception = new
InternalServerErrorException("kaboom",
+ new IllegalStateException("server fault"));
+ Response response = new
InternalServerErrorExceptionMapper().toResponse(exception);
+ assertErrorResponse(response, 500, "internalServerError");
+ }
+
+ private static void assertErrorResponse(Response response, int
expectedStatus, String expectedErrorMessage) {
+ assertEquals(expectedStatus, response.getStatus());
+ Object entity = response.getEntity();
+ assertTrue(entity instanceof Map, "Expected a Map entity but was: " +
entity);
+ assertEquals(expectedErrorMessage, ((Map<?, ?>)
entity).get("errorMessage"));
+ }
+}