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

cziegeler pushed a commit to branch master
in repository 
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-engine.git


The following commit(s) were added to refs/heads/master by this push:
     new 4651005  SLING-13355 Support a dedicated error handler for non-HTML 
requests (#106)
4651005 is described below

commit 4651005f524ac62f84ea0fbe56f5f2dc4bb923ff
Author: Carsten Ziegeler <[email protected]>
AuthorDate: Fri Nov 13 14:29:05 2026 +0100

    SLING-13355 Support a dedicated error handler for non-HTML requests (#106)
    
    * SLING-13355 Support a dedicated error handler for non-HTML requests
    
    Error handlers registered with the service property
    sling.error.handler=nonhtml are used for requests whose Accept header
    neither contains text/html nor */*. The highest ranked one is used.
    A default implementation returning just the status with an empty body
    is provided.
    
    Co-authored-by: Copilot <[email protected]>
    
    * SLING-13355 Add configuration to disable the Accept header check for 
error handling
    
    Co-authored-by: Copilot <[email protected]>
    
    * Use new constants from API PR
    
    * Fix references to new API constants
    
    * Fix test cases and accept all handling
    
    * SLING-13355 Clarify HTML error response selection
    
    Co-authored-by: Copilot <[email protected]>
    
    * SLING-13355 Retrigger PR checks
    
    Co-authored-by: Copilot <[email protected]>
    
    ---------
    
    Co-authored-by: Copilot <[email protected]>
---
 pom.xml                                            |   2 +-
 .../java/org/apache/sling/engine/impl/Config.java  |   6 +
 .../sling/engine/impl/DefaultErrorHandler.java     |  97 ++++-
 .../engine/impl/DefaultNonHtmlErrorHandler.java    |  53 +++
 .../engine/impl/SlingRequestProcessorImpl.java     |  24 ++
 .../sling/engine/impl/DefaultErrorHandlerTest.java | 395 +++++++++++++++++++++
 .../impl/DefaultNonHtmlErrorHandlerTest.java       |  70 ++++
 .../engine/impl/SlingRequestProcessorImplTest.java | 159 +++++++++
 8 files changed, 803 insertions(+), 3 deletions(-)

diff --git a/pom.xml b/pom.xml
index c0a0e04..1a3e01b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -120,7 +120,7 @@
         <dependency>
             <groupId>org.apache.sling</groupId>
             <artifactId>org.apache.sling.api</artifactId>
-            <version>3.0.0</version>
+            <version>3.0.3-SNAPSHOT</version>
             <scope>provided</scope>
         </dependency>
         <dependency>
diff --git a/src/main/java/org/apache/sling/engine/impl/Config.java 
b/src/main/java/org/apache/sling/engine/impl/Config.java
index 91153d0..3963418 100644
--- a/src/main/java/org/apache/sling/engine/impl/Config.java
+++ b/src/main/java/org/apache/sling/engine/impl/Config.java
@@ -126,4 +126,10 @@ public @interface Config {
                     "When enabled, the getUserPrincipal method of the request 
object might even return a principal "
                             + "in the non authenticated case. This is not spec 
compliant but might be required for some legacy code.")
     boolean disable_spec_compliant_getuserprincipal() default true;
+
+    @AttributeDefinition(
+            name = "Enable Accept Header Check for Error Handling",
+            description =
+                    "When enabled, the engine will check the Accept header to 
determine which error handler to use.")
+    boolean enable_accept_header_check() default true;
 }
diff --git 
a/src/main/java/org/apache/sling/engine/impl/DefaultErrorHandler.java 
b/src/main/java/org/apache/sling/engine/impl/DefaultErrorHandler.java
index 3cf96c4..1e9192f 100644
--- a/src/main/java/org/apache/sling/engine/impl/DefaultErrorHandler.java
+++ b/src/main/java/org/apache/sling/engine/impl/DefaultErrorHandler.java
@@ -20,6 +20,10 @@ package org.apache.sling.engine.impl;
 
 import java.io.IOException;
 import java.io.PrintWriter;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.Optional;
+import java.util.TreeMap;
 
 import jakarta.servlet.RequestDispatcher;
 import jakarta.servlet.http.HttpServletRequest;
@@ -29,11 +33,13 @@ import org.apache.sling.api.SlingHttpServletResponse;
 import org.apache.sling.api.SlingJakartaHttpServletRequest;
 import org.apache.sling.api.SlingJakartaHttpServletResponse;
 import org.apache.sling.api.request.ResponseUtil;
+import org.apache.sling.api.request.header.JakartaMediaRangeList;
 import org.apache.sling.api.servlets.ErrorHandler;
 import org.apache.sling.api.servlets.JakartaErrorHandler;
 import org.apache.sling.api.wrappers.JakartaToJavaxRequestWrapper;
 import org.apache.sling.api.wrappers.JakartaToJavaxResponseWrapper;
 import org.osgi.framework.ServiceReference;
+import org.osgi.util.converter.Converters;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -48,14 +54,25 @@ public class DefaultErrorHandler implements 
JakartaErrorHandler {
     /** default log */
     private final Logger log = LoggerFactory.getLogger(getClass());
 
+    private static final String ALL_CONTENT_TYPES = "*/*";
+
     /** Use this if not null, and if that fails output a report about that 
failure */
     private volatile JakartaErrorHandler delegate;
 
+    /** Use this if not null for non html responses */
+    private volatile JakartaErrorHandler nonHtmlDelegate;
+
     private volatile JakartaErrorHandler errorHandler;
     private volatile JakartaErrorHandler jakartaErrorHandler;
     private volatile ServiceReference<?> errorHandlerRef;
     private volatile ServiceReference<?> jakartaErrorHandlerRef;
 
+    private final Map<ServiceReference<JakartaErrorHandler>, 
JakartaErrorHandler> jakartaErrorHandlers =
+            new TreeMap<>(Comparator.reverseOrder());
+
+    /** Flag for html accept header check for error handling */
+    private volatile boolean enableAcceptHeaderCheck = true;
+
     @SuppressWarnings("deprecation")
     public synchronized void setDelegate(final ServiceReference<?> ref, final 
ErrorHandler eh) {
         if (eh != null) {
@@ -133,6 +150,30 @@ public class DefaultErrorHandler implements 
JakartaErrorHandler {
         sendError(originalStatus, originalMessage, null, request, response);
     }
 
+    protected JakartaErrorHandler getErrorHandler(final 
SlingJakartaHttpServletRequest request) {
+        final JakartaErrorHandler nonHtml = this.nonHtmlDelegate;
+        if (this.enableAcceptHeaderCheck && nonHtml != null && 
!serveHtmlErrorResponse(request)) {
+            return nonHtml;
+        }
+        return this.delegate;
+    }
+
+    /**
+     * Determines whether the request is classified as HTML for error-handler 
selection,
+     * rather than merely whether it accepts HTML.
+     * The Accept header must match {@code text/html}, including through 
{@code text/*},
+     * and must not contain {@code *}{@code /*}. HTML need not be the 
highest-quality match.
+     * A missing or blank Accept header, or any header containing {@code 
*}{@code /*},
+     * is treated as non-HTML, even if it also explicitly accepts {@code 
text/html}.
+     *
+     * @param request the request to classify
+     * @return {@code true} if the request is classified as HTML, {@code 
false} otherwise
+     */
+    private static boolean serveHtmlErrorResponse(final 
SlingJakartaHttpServletRequest request) {
+        final JakartaMediaRangeList acceptList = 
JakartaMediaRangeList.fromRequest(request);
+        return !acceptList.matchesAll() && 
acceptList.prefer(JakartaMediaRangeList.CONTENT_TYPE_HTML) != null;
+    }
+
     // ---------- ErrorHandler interface (default implementation) 
--------------
 
     /**
@@ -153,7 +194,7 @@ public class DefaultErrorHandler implements 
JakartaErrorHandler {
             final SlingJakartaHttpServletResponse response)
             throws IOException {
         // If we have a delegate let it handle the error
-        final JakartaErrorHandler local = this.delegate;
+        final JakartaErrorHandler local = this.getErrorHandler(request);
         if (local != null) {
             try {
                 local.handleError(status, message, request, response);
@@ -198,7 +239,7 @@ public class DefaultErrorHandler implements 
JakartaErrorHandler {
             throws IOException {
         final int status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
         // If we have a delegate let it handle the error
-        final JakartaErrorHandler local = this.delegate;
+        final JakartaErrorHandler local = this.getErrorHandler(request);
         if (local != null) {
             try {
                 local.handleError(throwable, request, response);
@@ -263,4 +304,56 @@ public class DefaultErrorHandler implements 
JakartaErrorHandler {
         // close the response (SLING-2724)
         pw.close();
     }
+
+    private boolean matchesName(final ServiceReference<JakartaErrorHandler> 
ref, final String name) {
+        final String[] names = Converters.standardConverter()
+                .convert(ref.getProperty(JakartaErrorHandler.PROPERTY_NAME))
+                .to(String[].class);
+
+        for (final String n : names) {
+            if (name.equals(n)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Set the non html error handler
+     */
+    private void setNonHtmlErrorHandler() {
+        final Optional<Map.Entry<ServiceReference<JakartaErrorHandler>, 
JakartaErrorHandler>> first =
+                jakartaErrorHandlers.entrySet().stream()
+                        .filter(entry -> matchesName(entry.getKey(), 
JakartaErrorHandler.NAME_NONHTML))
+                        .findFirst();
+        if (first.isPresent()) {
+            this.nonHtmlDelegate = first.get().getValue();
+        } else {
+            this.nonHtmlDelegate = null;
+        }
+    }
+
+    /**
+     * Add a named delegate for handling non-HTML errors.
+     * @param ref the service reference for the delegate
+     * @param handler the error handler instance for the delegate
+     */
+    public synchronized void addNamedDelegate(
+            final ServiceReference<JakartaErrorHandler> ref, final 
JakartaErrorHandler handler) {
+        this.jakartaErrorHandlers.put(ref, handler);
+        this.setNonHtmlErrorHandler();
+    }
+
+    /**
+     * Remove a named delegate for handling non-HTML errors.
+     * @param ref the service reference for the delegate
+     */
+    public synchronized void removeNamedDelegate(final 
ServiceReference<JakartaErrorHandler> ref) {
+        this.jakartaErrorHandlers.remove(ref);
+        this.setNonHtmlErrorHandler();
+    }
+
+    public void setEnableAcceptHeaderCheck(final boolean enable) {
+        this.enableAcceptHeaderCheck = enable;
+    }
 }
diff --git 
a/src/main/java/org/apache/sling/engine/impl/DefaultNonHtmlErrorHandler.java 
b/src/main/java/org/apache/sling/engine/impl/DefaultNonHtmlErrorHandler.java
new file mode 100644
index 0000000..c0633ca
--- /dev/null
+++ b/src/main/java/org/apache/sling/engine/impl/DefaultNonHtmlErrorHandler.java
@@ -0,0 +1,53 @@
+/*
+ * 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.sling.engine.impl;
+
+import java.io.IOException;
+
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.sling.api.SlingJakartaHttpServletRequest;
+import org.apache.sling.api.SlingJakartaHttpServletResponse;
+import org.apache.sling.api.servlets.JakartaErrorHandler;
+import org.osgi.service.component.annotations.Component;
+
+@Component(
+        service = JakartaErrorHandler.class,
+        property = {JakartaErrorHandler.PROPERTY_NAME + "=" + 
JakartaErrorHandler.NAME_NONHTML})
+public class DefaultNonHtmlErrorHandler implements JakartaErrorHandler {
+
+    @Override
+    public void handleError(
+            int status,
+            String message,
+            SlingJakartaHttpServletRequest request,
+            SlingJakartaHttpServletResponse response)
+            throws IOException {
+        response.setStatus(status);
+        response.setContentLength(0);
+        // commit the response
+        response.flushBuffer();
+    }
+
+    @Override
+    public void handleError(
+            Throwable throwable, SlingJakartaHttpServletRequest request, 
SlingJakartaHttpServletResponse response)
+            throws IOException {
+        this.handleError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, null, 
request, response);
+    }
+}
diff --git 
a/src/main/java/org/apache/sling/engine/impl/SlingRequestProcessorImpl.java 
b/src/main/java/org/apache/sling/engine/impl/SlingRequestProcessorImpl.java
index 1cd2ac5..0c83dba 100644
--- a/src/main/java/org/apache/sling/engine/impl/SlingRequestProcessorImpl.java
+++ b/src/main/java/org/apache/sling/engine/impl/SlingRequestProcessorImpl.java
@@ -152,10 +152,12 @@ public class SlingRequestProcessorImpl implements 
SlingRequestProcessor {
         this.protectHeadersOnInclude = config.sling_includes_protectheaders();
         this.checkContentTypeOnInclude = 
config.sling_includes_checkcontenttype();
         this.disableCheckCompliantGetUserPrincipal = 
config.disable_spec_compliant_getuserprincipal();
+        
this.errorHandler.setEnableAcceptHeaderCheck(config.enable_accept_header_check());
     }
 
     @Reference(
             name = "JakartaErrorHandler",
+            target = "(!(" + JakartaErrorHandler.PROPERTY_NAME + "=*))",
             cardinality = ReferenceCardinality.OPTIONAL,
             policy = ReferencePolicy.DYNAMIC,
             policyOption = ReferencePolicyOption.GREEDY)
@@ -167,6 +169,28 @@ public class SlingRequestProcessorImpl implements 
SlingRequestProcessor {
         this.errorHandler.setDelegate(ref, (JakartaErrorHandler) null);
     }
 
+    /**
+     * Add named error handlers
+     * @param handler The JakartaErrorHandler instance to add as a named 
delegate.
+     * @param ref The ServiceReference associated with the JakartaErrorHandler 
instance.
+     */
+    @Reference(
+            target = "(" + JakartaErrorHandler.PROPERTY_NAME + "=*)",
+            cardinality = ReferenceCardinality.MULTIPLE,
+            policy = ReferencePolicy.DYNAMIC)
+    void setNamedJakartaErrorHandler(
+            final JakartaErrorHandler handler, final 
ServiceReference<JakartaErrorHandler> ref) {
+        this.errorHandler.addNamedDelegate(ref, handler);
+    }
+
+    /**
+     * Remove named error handlers
+     * @param ref The ServiceReference associated with the JakartaErrorHandler 
instance to remove.
+     */
+    void unsetNamedJakartaErrorHandler(final 
ServiceReference<JakartaErrorHandler> ref) {
+        this.errorHandler.removeNamedDelegate(ref);
+    }
+
     @SuppressWarnings("deprecation")
     @Reference(
             name = "ErrorHandler",
diff --git 
a/src/test/java/org/apache/sling/engine/impl/DefaultErrorHandlerTest.java 
b/src/test/java/org/apache/sling/engine/impl/DefaultErrorHandlerTest.java
index 7ba3b55..3d36fc3 100644
--- a/src/test/java/org/apache/sling/engine/impl/DefaultErrorHandlerTest.java
+++ b/src/test/java/org/apache/sling/engine/impl/DefaultErrorHandlerTest.java
@@ -18,20 +18,31 @@
  */
 package org.apache.sling.engine.impl;
 
+import java.io.IOException;
 import java.io.PrintWriter;
 import java.io.StringWriter;
+import java.util.Hashtable;
 
 import org.apache.sling.api.SlingJakartaHttpServletRequest;
 import org.apache.sling.api.SlingJakartaHttpServletResponse;
 import org.apache.sling.api.request.RequestProgressTracker;
+import org.apache.sling.api.servlets.JakartaErrorHandler;
+import org.apache.sling.testing.mock.osgi.junit.OsgiContext;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
+import org.osgi.framework.Constants;
+import org.osgi.framework.ServiceReference;
 
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 /**
@@ -42,6 +53,9 @@ import static org.mockito.Mockito.when;
  */
 public class DefaultErrorHandlerTest {
 
+    @Rule
+    public final OsgiContext context = new OsgiContext();
+
     private DefaultErrorHandler handler;
     private SlingJakartaHttpServletRequest request;
     private SlingJakartaHttpServletResponse response;
@@ -115,4 +129,385 @@ public class DefaultErrorHandlerTest {
         responseBody.flush();
         assertTrue(responseBody.toString().isEmpty());
     }
+
+    // ---------- named / non-html delegate selection ----------
+
+    @SuppressWarnings("unchecked")
+    private ServiceReference<JakartaErrorHandler> registerNamed(
+            final JakartaErrorHandler eh, final int ranking, final Object 
names) {
+        final Hashtable<String, Object> props = new Hashtable<>();
+        props.put(JakartaErrorHandler.PROPERTY_NAME, names);
+        props.put(Constants.SERVICE_RANKING, ranking);
+        return (ServiceReference<JakartaErrorHandler>) context.bundleContext()
+                .registerService(JakartaErrorHandler.class, eh, props)
+                .getReference();
+    }
+
+    private void accept(final String accept) {
+        when(request.getHeader("Accept")).thenReturn(accept);
+    }
+
+    @Test
+    public void testNonHtmlRequestUsesNonHtmlDelegateForStatus() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        accept("application/json");
+
+        handler.handleError(404, "not found", request, response);
+
+        verify(nonHtml).handleError(404, "not found", request, response);
+        verifyNoInteractions(main);
+    }
+
+    @Test
+    public void testNonHtmlRequestUsesNonHtmlDelegateForThrowable() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        accept("application/json");
+
+        final Exception cause = new IllegalStateException("boom");
+        handler.handleError(cause, request, response);
+
+        verify(nonHtml).handleError(cause, request, response);
+        verifyNoInteractions(main);
+    }
+
+    @Test
+    public void testHtmlRequestWithWildcardUsesNonHtmlDelegate() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        
accept("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
+
+        handler.handleError(500, "error", request, response);
+
+        verify(nonHtml).handleError(500, "error", request, response);
+        verifyNoInteractions(main);
+    }
+
+    @Test
+    public void testMissingAcceptHeaderIsTreatedAsNonHtml() throws Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+
+        handler.handleError(404, null, request, response);
+
+        verify(nonHtml).handleError(404, null, request, response);
+        verifyNoInteractions(main);
+    }
+
+    @Test
+    public void testBlankAcceptHeaderIsTreatedAsNonHtml() throws Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        accept("  ");
+
+        handler.handleError(404, null, request, response);
+
+        verify(nonHtml).handleError(404, null, request, response);
+        verifyNoInteractions(main);
+    }
+
+    @Test
+    public void 
testMissingAcceptHeaderWithoutNonHtmlDelegateUsesMainDelegate() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+
+        handler.handleError(404, null, request, response);
+
+        verify(main).handleError(404, null, request, response);
+    }
+
+    @Test
+    public void testWildcardAcceptIsTreatedAsNonHtml() throws Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        accept("*/*");
+
+        handler.handleError(404, null, request, response);
+
+        verify(nonHtml).handleError(404, null, request, response);
+        verifyNoInteractions(main);
+    }
+
+    @Test
+    public void testWildcardWithLowerQualityIsTreatedAsNonHtml() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        accept("application/json, */*;q=0.1");
+
+        handler.handleError(404, null, request, response);
+
+        verify(nonHtml).handleError(404, null, request, response);
+        verifyNoInteractions(main);
+    }
+
+    @Test
+    public void testTextWildcardAcceptIsTreatedAsHtml() throws Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        accept("text/*");
+
+        handler.handleError(404, null, request, response);
+
+        verify(main).handleError(404, null, request, response);
+        verifyNoInteractions(nonHtml);
+    }
+
+    @Test
+    public void testHtmlWithLowerQualityIsStillTreatedAsHtml() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        accept("application/json, text/html;q=0.1");
+
+        handler.handleError(404, null, request, response);
+
+        verify(main).handleError(404, null, request, response);
+        verifyNoInteractions(nonHtml);
+    }
+
+    @Test
+    public void testRequestParametersAreNotEvaluated() throws Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        accept("application/json");
+        // parameter parsing might be the cause of the error being handled
+        when(request.getParameter(any())).thenThrow(new 
IllegalStateException("parameter parsing failed"));
+
+        handler.handleError(400, null, request, response);
+
+        verify(nonHtml).handleError(400, null, request, response);
+        verifyNoInteractions(main);
+        verify(request, never()).getParameter(any());
+    }
+
+    @Test
+    public void 
testNonHtmlRequestWithoutNonHtmlDelegateFallsBackToMainDelegate() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        accept("application/json");
+
+        handler.handleError(404, null, request, response);
+
+        verify(main).handleError(404, null, request, response);
+    }
+
+    @Test
+    public void testNonHtmlRequestWithoutAnyDelegateUsesBuiltInResponse() 
throws Exception {
+        accept("application/json");
+
+        handler.handleError(404, "not found", request, response);
+
+        verify(response).setStatus(404);
+        responseBody.flush();
+        assertTrue(responseBody.toString().contains("not found"));
+    }
+
+    @Test
+    public void testNonHtmlDelegateWithoutMainDelegate() throws Exception {
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+
+        accept("application/json");
+        handler.handleError(404, null, request, response);
+        verify(nonHtml).handleError(404, null, request, response);
+
+        // html requests must not be routed to the non-html delegate
+        accept("text/html");
+        handler.handleError(403, null, request, response);
+        verify(nonHtml, never()).handleError(403, null, request, response);
+        verify(response).setStatus(403);
+    }
+
+    @Test
+    public void testNamedDelegateWithOtherNameIsIgnored() throws Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler other = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(other, 100, "something-else"), 
other);
+        accept("application/json");
+
+        handler.handleError(404, null, request, response);
+
+        verify(main).handleError(404, null, request, response);
+        verifyNoInteractions(other);
+    }
+
+    @Test
+    public void testNamedDelegateWithMultipleNamesIncludingNonHtml() throws 
Exception {
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.addNamedDelegate(
+                registerNamed(nonHtml, 0, new String[] {"something-else", 
JakartaErrorHandler.NAME_NONHTML}), nonHtml);
+        accept("application/json");
+
+        handler.handleError(404, null, request, response);
+
+        verify(nonHtml).handleError(404, null, request, response);
+    }
+
+    @Test
+    public void testHighestRankedNonHtmlDelegateWinsRegardlessOfBindOrder() 
throws Exception {
+        final JakartaErrorHandler low = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler high = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler mid = mock(JakartaErrorHandler.class);
+        handler.addNamedDelegate(registerNamed(low, -10, 
JakartaErrorHandler.NAME_NONHTML), low);
+        handler.addNamedDelegate(registerNamed(high, 100, 
JakartaErrorHandler.NAME_NONHTML), high);
+        handler.addNamedDelegate(registerNamed(mid, 10, 
JakartaErrorHandler.NAME_NONHTML), mid);
+        accept("application/json");
+
+        handler.handleError(404, null, request, response);
+
+        verify(high).handleError(404, null, request, response);
+        verifyNoInteractions(low, mid);
+    }
+
+    @Test
+    public void testOlderNonHtmlDelegateWinsOnEqualRanking() throws Exception {
+        final JakartaErrorHandler first = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler second = mock(JakartaErrorHandler.class);
+        final ServiceReference<JakartaErrorHandler> firstRef =
+                registerNamed(first, 0, JakartaErrorHandler.NAME_NONHTML);
+        final ServiceReference<JakartaErrorHandler> secondRef =
+                registerNamed(second, 0, JakartaErrorHandler.NAME_NONHTML);
+        // bind in reverse registration order
+        handler.addNamedDelegate(secondRef, second);
+        handler.addNamedDelegate(firstRef, first);
+        accept("application/json");
+
+        handler.handleError(404, null, request, response);
+
+        verify(first).handleError(404, null, request, response);
+        verifyNoInteractions(second);
+    }
+
+    @Test
+    public void 
testRemovingNonHtmlDelegateFallsBackToNextRankedAndThenToMain() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler low = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler high = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        final ServiceReference<JakartaErrorHandler> lowRef = 
registerNamed(low, 0, JakartaErrorHandler.NAME_NONHTML);
+        final ServiceReference<JakartaErrorHandler> highRef = 
registerNamed(high, 10, JakartaErrorHandler.NAME_NONHTML);
+        handler.addNamedDelegate(lowRef, low);
+        handler.addNamedDelegate(highRef, high);
+        accept("application/json");
+
+        handler.removeNamedDelegate(highRef);
+        handler.handleError(404, null, request, response);
+        verify(low).handleError(404, null, request, response);
+        verifyNoInteractions(high);
+
+        handler.removeNamedDelegate(lowRef);
+        handler.handleError(410, null, request, response);
+        verify(main).handleError(410, null, request, response);
+        verify(low, never()).handleError(410, null, request, response);
+    }
+
+    @Test
+    public void testRemovingUnknownNamedDelegateIsHarmless() throws Exception {
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler unknown = mock(JakartaErrorHandler.class);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        accept("application/json");
+
+        handler.removeNamedDelegate(registerNamed(unknown, 0, 
JakartaErrorHandler.NAME_NONHTML));
+        handler.handleError(404, null, request, response);
+
+        verify(nonHtml).handleError(404, null, request, response);
+    }
+
+    @Test
+    public void testFailingNonHtmlDelegateFallsBackToBuiltInResponse() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        doThrow(new IOException("failed"))
+                .when(nonHtml)
+                .handleError(
+                        anyInt(),
+                        any(),
+                        any(SlingJakartaHttpServletRequest.class),
+                        any(SlingJakartaHttpServletResponse.class));
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        accept("application/json");
+
+        handler.handleError(404, "not found", request, response);
+
+        verify(response).reset();
+        verify(response).setStatus(404);
+        verifyNoInteractions(main);
+        responseBody.flush();
+        assertTrue(responseBody.toString().contains("not found"));
+    }
+
+    @Test
+    public void testDisabledAcceptHeaderCheckAlwaysUsesMainDelegate() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        handler.setEnableAcceptHeaderCheck(false);
+        accept("application/json");
+
+        handler.handleError(404, null, request, response);
+        final Exception cause = new IllegalStateException("boom");
+        handler.handleError(cause, request, response);
+
+        verify(main).handleError(404, null, request, response);
+        verify(main).handleError(cause, request, response);
+        verifyNoInteractions(nonHtml);
+        verify(request, never()).getHeader(any());
+    }
+
+    @Test
+    public void 
testDisabledAcceptHeaderCheckWithoutMainDelegateUsesBuiltInResponse() throws 
Exception {
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        handler.setEnableAcceptHeaderCheck(false);
+        accept("application/json");
+
+        handler.handleError(404, "not found", request, response);
+
+        verifyNoInteractions(nonHtml);
+        verify(response).setStatus(404);
+        responseBody.flush();
+        assertTrue(responseBody.toString().contains("not found"));
+    }
+
+    @Test
+    public void testReEnablingAcceptHeaderCheckUsesNonHtmlDelegateAgain() 
throws Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        handler.setDelegate(null, main);
+        handler.addNamedDelegate(registerNamed(nonHtml, 0, 
JakartaErrorHandler.NAME_NONHTML), nonHtml);
+        accept("application/json");
+
+        handler.setEnableAcceptHeaderCheck(false);
+        handler.handleError(404, null, request, response);
+        verify(main).handleError(404, null, request, response);
+
+        handler.setEnableAcceptHeaderCheck(true);
+        handler.handleError(410, null, request, response);
+        verify(nonHtml).handleError(410, null, request, response);
+        verify(main, never()).handleError(410, null, request, response);
+    }
 }
diff --git 
a/src/test/java/org/apache/sling/engine/impl/DefaultNonHtmlErrorHandlerTest.java
 
b/src/test/java/org/apache/sling/engine/impl/DefaultNonHtmlErrorHandlerTest.java
new file mode 100644
index 0000000..d659255
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/engine/impl/DefaultNonHtmlErrorHandlerTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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.sling.engine.impl;
+
+import org.apache.sling.api.SlingJakartaHttpServletRequest;
+import org.apache.sling.api.SlingJakartaHttpServletResponse;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.InOrder;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+public class DefaultNonHtmlErrorHandlerTest {
+
+    private DefaultNonHtmlErrorHandler handler;
+    private SlingJakartaHttpServletRequest request;
+    private SlingJakartaHttpServletResponse response;
+
+    @Before
+    public void setup() {
+        handler = new DefaultNonHtmlErrorHandler();
+        request = mock(SlingJakartaHttpServletRequest.class);
+        response = mock(SlingJakartaHttpServletResponse.class);
+    }
+
+    @Test
+    public void testStatusIsSentWithEmptyBody() throws Exception {
+        handler.handleError(404, "secret detail", request, response);
+
+        final InOrder order = inOrder(response);
+        order.verify(response).setStatus(404);
+        order.verify(response).setContentLength(0);
+        order.verify(response).flushBuffer();
+        verify(response, never()).getWriter();
+        verify(response, never()).getOutputStream();
+        verify(response, never()).setContentType(anyString());
+    }
+
+    @Test
+    public void testThrowableIsSentAsInternalServerErrorWithEmptyBody() throws 
Exception {
+        handler.handleError(new IllegalStateException("secret detail"), 
request, response);
+
+        final InOrder order = inOrder(response);
+        order.verify(response).setStatus(500);
+        order.verify(response).setContentLength(0);
+        order.verify(response).flushBuffer();
+        verify(response, never()).getWriter();
+        verify(response, never()).getOutputStream();
+    }
+}
diff --git 
a/src/test/java/org/apache/sling/engine/impl/SlingRequestProcessorImplTest.java 
b/src/test/java/org/apache/sling/engine/impl/SlingRequestProcessorImplTest.java
index 8f38a40..68be674 100644
--- 
a/src/test/java/org/apache/sling/engine/impl/SlingRequestProcessorImplTest.java
+++ 
b/src/test/java/org/apache/sling/engine/impl/SlingRequestProcessorImplTest.java
@@ -21,6 +21,7 @@ package org.apache.sling.engine.impl;
 import java.io.PrintWriter;
 import java.io.StringWriter;
 import java.lang.reflect.Field;
+import java.util.Collections;
 
 import jakarta.servlet.Servlet;
 import jakarta.servlet.ServletRequest;
@@ -33,22 +34,31 @@ import org.apache.sling.api.request.RequestProgressTracker;
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceMetadata;
 import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.servlets.JakartaErrorHandler;
 import org.apache.sling.api.servlets.ServletResolver;
 import org.apache.sling.engine.impl.filter.FilterHandle;
 import org.apache.sling.engine.impl.filter.ServletFilterManager;
 import 
org.apache.sling.engine.impl.filter.ServletFilterManager.FilterChainType;
 import org.apache.sling.engine.impl.parameters.SlingParameterParseException;
+import org.apache.sling.testing.mock.osgi.MockOsgi;
+import org.apache.sling.testing.mock.osgi.junit.OsgiContext;
 import org.jetbrains.annotations.NotNull;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
+import org.osgi.framework.Constants;
+import org.osgi.framework.ServiceRegistration;
 
 import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 /**
@@ -59,6 +69,9 @@ import static org.mockito.Mockito.when;
  */
 public class SlingRequestProcessorImplTest {
 
+    @Rule
+    public final OsgiContext context = new OsgiContext();
+
     private SlingRequestProcessorImpl processor;
     private ServletFilterManager filterManager;
     private SlingJakartaHttpServletRequest request;
@@ -173,4 +186,150 @@ public class SlingRequestProcessorImplTest {
         when(resourceMetadata.getResolutionPathInfo()).thenReturn(path);
         return resource;
     }
+
+    // ---------- error handler service wiring (DS reference targets) 
----------
+
+    private static SlingJakartaHttpServletRequest anyRequest() {
+        return any(SlingJakartaHttpServletRequest.class);
+    }
+
+    private static SlingJakartaHttpServletResponse anyResponse() {
+        return any(SlingJakartaHttpServletResponse.class);
+    }
+
+    private SlingRequestProcessorImpl activateProcessorWithOsgi(final 
Object... config) {
+        context.registerService(ServletResolver.class, 
mock(ServletResolver.class));
+        context.registerService(ServletFilterManager.class, filterManager);
+        context.registerService(RequestProcessorMBeanImpl.class, 
mock(RequestProcessorMBeanImpl.class));
+        return 
context.registerInjectActivateService(SlingRequestProcessorImpl.class, config);
+    }
+
+    @Test
+    public void testNamedErrorHandlerIsNotBoundAsDefaultErrorHandler() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        context.registerService(JakartaErrorHandler.class, main, 
Constants.SERVICE_RANKING, 0);
+        // higher ranking, but must not replace the default handler as it is a 
named one
+        context.registerService(
+                JakartaErrorHandler.class,
+                nonHtml,
+                Constants.SERVICE_RANKING,
+                1000,
+                JakartaErrorHandler.PROPERTY_NAME,
+                JakartaErrorHandler.NAME_NONHTML);
+        final SlingRequestProcessorImpl osgiProcessor = 
activateProcessorWithOsgi();
+
+        when(request.getHeader("Accept")).thenReturn("text/html");
+        osgiProcessor.handleError(404, "not found", request, response);
+
+        verify(main).handleError(eq(404), eq("not found"), anyRequest(), 
anyResponse());
+        verifyNoInteractions(nonHtml);
+    }
+
+    @Test
+    public void testNamedNonHtmlErrorHandlerIsUsedForNonHtmlRequests() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        context.registerService(JakartaErrorHandler.class, main);
+        final SlingRequestProcessorImpl osgiProcessor = 
activateProcessorWithOsgi();
+
+        // registered after activation: dynamic binding
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        context.registerService(
+                JakartaErrorHandler.class,
+                nonHtml,
+                JakartaErrorHandler.PROPERTY_NAME,
+                JakartaErrorHandler.NAME_NONHTML);
+
+        when(request.getHeader("Accept")).thenReturn("application/json");
+        osgiProcessor.handleError(404, "not found", request, response);
+
+        verify(nonHtml).handleError(eq(404), eq("not found"), anyRequest(), 
anyResponse());
+        verifyNoInteractions(main);
+    }
+
+    @Test
+    public void 
testUnregisteringNonHtmlErrorHandlerFallsBackToDefaultErrorHandler() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        context.registerService(JakartaErrorHandler.class, main);
+        final ServiceRegistration<JakartaErrorHandler> reg = 
context.bundleContext()
+                .registerService(
+                        JakartaErrorHandler.class,
+                        nonHtml,
+                        new java.util.Hashtable<>(Collections.singletonMap(
+                                JakartaErrorHandler.PROPERTY_NAME, 
JakartaErrorHandler.NAME_NONHTML)));
+        final SlingRequestProcessorImpl osgiProcessor = 
activateProcessorWithOsgi();
+
+        reg.unregister();
+        when(request.getHeader("Accept")).thenReturn("application/json");
+        osgiProcessor.handleError(404, "not found", request, response);
+
+        verify(main).handleError(eq(404), eq("not found"), anyRequest(), 
anyResponse());
+        verifyNoInteractions(nonHtml);
+    }
+
+    @Test
+    public void 
testDefaultNonHtmlErrorHandlerComponentIsUsedForNonHtmlRequests() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        context.registerService(JakartaErrorHandler.class, main);
+        // registered with the component properties from its DS metadata
+        
context.registerInjectActivateService(DefaultNonHtmlErrorHandler.class);
+        final SlingRequestProcessorImpl osgiProcessor = 
activateProcessorWithOsgi();
+
+        when(request.getHeader("Accept")).thenReturn("application/json");
+        osgiProcessor.handleError(404, "not found", request, response);
+
+        verify(response).setStatus(404);
+        verify(response).setContentLength(0);
+        verifyNoInteractions(main);
+        responseBody.flush();
+        assertTrue(responseBody.toString().isEmpty());
+    }
+
+    @Test
+    public void 
testDisabledAcceptHeaderCheckUsesDefaultErrorHandlerForNonHtmlRequests() throws 
Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        context.registerService(JakartaErrorHandler.class, main);
+        context.registerService(
+                JakartaErrorHandler.class,
+                nonHtml,
+                JakartaErrorHandler.PROPERTY_NAME,
+                JakartaErrorHandler.NAME_NONHTML);
+        final SlingRequestProcessorImpl osgiProcessor = 
activateProcessorWithOsgi("enable.accept.header.check", false);
+
+        when(request.getHeader("Accept")).thenReturn("application/json");
+        osgiProcessor.handleError(404, "not found", request, response);
+
+        verify(main).handleError(eq(404), eq("not found"), anyRequest(), 
anyResponse());
+        verifyNoInteractions(nonHtml);
+    }
+
+    @Test
+    public void testAcceptHeaderCheckCanBeToggledByModifyingConfiguration() 
throws Exception {
+        final JakartaErrorHandler main = mock(JakartaErrorHandler.class);
+        final JakartaErrorHandler nonHtml = mock(JakartaErrorHandler.class);
+        context.registerService(JakartaErrorHandler.class, main);
+        context.registerService(
+                JakartaErrorHandler.class,
+                nonHtml,
+                JakartaErrorHandler.PROPERTY_NAME,
+                JakartaErrorHandler.NAME_NONHTML);
+        final SlingRequestProcessorImpl osgiProcessor = 
activateProcessorWithOsgi();
+        when(request.getHeader("Accept")).thenReturn("application/json");
+
+        // enabled by default
+        osgiProcessor.handleError(404, "not found", request, response);
+        verify(nonHtml).handleError(eq(404), eq("not found"), anyRequest(), 
anyResponse());
+
+        MockOsgi.modified(osgiProcessor, context.bundleContext(), 
"enable.accept.header.check", false);
+        osgiProcessor.handleError(410, "gone", request, response);
+        verify(main).handleError(eq(410), eq("gone"), anyRequest(), 
anyResponse());
+        verify(nonHtml, never()).handleError(eq(410), eq("gone"), 
anyRequest(), anyResponse());
+
+        MockOsgi.modified(osgiProcessor, context.bundleContext(), 
"enable.accept.header.check", true);
+        osgiProcessor.handleError(403, "forbidden", request, response);
+        verify(nonHtml).handleError(eq(403), eq("forbidden"), anyRequest(), 
anyResponse());
+        verify(main, never()).handleError(eq(403), eq("forbidden"), 
anyRequest(), anyResponse());
+    }
 }

Reply via email to