This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch UNOMI-928-rest-error-handling
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit b676a10f278e73d8c587026268c04a368e4b94cf
Author: Serge Huber <[email protected]>
AuthorDate: Wed Jun 10 20:05:22 2026 +0200

    UNOMI-928: Improve REST API error handling with dedicated exception mappers
    Add JsonMappingExceptionMapper and InternalServerErrorExceptionMapper so 
Jackson
    deserialization failures return 400 badRequest instead of 500. Enrich
    RuntimeExceptionMapper with sanitized request-context logging. Extract 
shared
    logic into AbstractRestExceptionMapper and LogSanitizer. Add unit tests.
    Backported from unomi-3-dev.
---
 rest/pom.xml                                       |   7 +
 .../exception/AbstractRestExceptionMapper.java     | 155 +++++++++++++++++++++
 .../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     |  84 +++++++++++
 .../rest/exception/RestExceptionMapperTest.java    |  89 ++++++++++++
 8 files changed, 607 insertions(+), 34 deletions(-)

diff --git a/rest/pom.xml b/rest/pom.xml
index 315b9bbac..2584c8421 100644
--- a/rest/pom.xml
+++ b/rest/pom.xml
@@ -187,6 +187,13 @@
             <artifactId>slf4j-api</artifactId>
             <scope>provided</scope>
         </dependency>
+
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <version>${junit-jupiter.version}</version>
+            <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..7492529b9
--- /dev/null
+++ 
b/rest/src/main/java/org/apache/unomi/rest/exception/AbstractRestExceptionMapper.java
@@ -0,0 +1,155 @@
+/*
+ * 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 cause = throwable.getCause();
+        if (cause == null || cause == throwable) {
+            return throwable;
+        }
+        return getRootCause(cause);
+    }
+
+    /**
+     * @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..5d2f3eac5
--- /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), 
exception);
+        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..1a67e3166
--- /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 > 0) {
+                sb.append("&");
+            }
+            if (paramCount >= MAX_QUERY_PARAMS) {
+                sb.append("...[more params]");
+                break;
+            }
+            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..7c95718c8 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 in debug to get the full stacktrace)",
+                    requestContext, rootCauseClassName, rootCauseMessage, 
exception);
+        }
+        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..bfceaaea4
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/exception/LogSanitizerTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+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));
+    }
+
+    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"));
+    }
+}

Reply via email to