You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@jena.apache.org by an...@apache.org on 2022/02/16 08:47:07 UTC

[jena] branch main updated: JENA-2281: Routing all request forms

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

andy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/jena.git


The following commit(s) were added to refs/heads/main by this push:
     new 039d26c  JENA-2281: Routing all request forms
     new 1b2b9fa  Merge pull request #1198 from afs/dispatch
039d26c is described below

commit 039d26ce6cc198fd4b5c9b5dd9cf115cfeebd3d8
Author: Andy Seaborne <an...@apache.org>
AuthorDate: Tue Feb 15 15:59:09 2022 +0000

    JENA-2281: Routing all request forms
---
 .../fuseki/server/DataAccessPointRegistry.java     |   6 +-
 .../org/apache/jena/fuseki/server/Dispatcher.java  |  70 +++++++--
 .../apache/jena/fuseki/servlets/ActionExecLib.java | 174 +++++++++++----------
 .../org/apache/jena/fuseki/servlets/ActionLib.java |  49 ++----
 .../org/apache/jena/fuseki/servlets/UploadRDF.java |  10 ++
 .../java/org/apache/jena/fuseki/TS_FusekiCore.java |   4 +-
 .../jena/fuseki/server/TestDispatchOnURI.java      | 137 ++++++++++++++++
 7 files changed, 320 insertions(+), 130 deletions(-)

diff --git a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/DataAccessPointRegistry.java b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/DataAccessPointRegistry.java
index 60df646..b50b3fc 100644
--- a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/DataAccessPointRegistry.java
+++ b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/DataAccessPointRegistry.java
@@ -36,7 +36,11 @@ import org.apache.jena.fuseki.metrics.FusekiRequestsMetrics;
  */
 public class DataAccessPointRegistry extends Registry<String, DataAccessPoint>
 {
-    private MeterRegistry meterRegistry;
+    private final MeterRegistry meterRegistry;
+
+    public DataAccessPointRegistry() {
+        this.meterRegistry = null;
+    }
 
     public DataAccessPointRegistry(MeterRegistry meterRegistry) {
         this.meterRegistry = meterRegistry;
diff --git a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/Dispatcher.java b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/Dispatcher.java
index d338f84..98a44c0 100644
--- a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/Dispatcher.java
+++ b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/Dispatcher.java
@@ -73,27 +73,73 @@ public class Dispatcher {
      * This function does not throw exceptions.
      */
     public static boolean dispatch(HttpServletRequest request, HttpServletResponse response) {
+        DataAccessPointRegistry registry = DataAccessPointRegistry.get(request.getServletContext());
+        DataAccessPoint dap = locateDataAccessPoint(request, registry);
+        if ( dap == null ) {
+            if ( LogDispatch )
+                LOG.debug("No dispatch for '"+request.getRequestURI()+"'");
+            return false;
+        }
+        return process(dap, request, response);
+    }
+
+    /**
+     * The request may be /path/dataset/sparql or /path/dataset, or even /.
+     * <p>
+     * If the servlet context path is the "/path", then that was removed in ActionLib.actionURI.
+     * But the dataset name may have a path within the servlet context.
+     * <p>
+     * The second form looks like dataset="path" and service="dataset"
+     * We don't know the service until we find the DataAccessPoint.
+     * For /dataset/sparql or /dataset, there is not problem. The latter is too short to be a named service.
+     * <p>
+     * This function chooses the DataAccessPoint.
+     * There may not be an endpoint and operation to handle the request.
+     */
+    private static DataAccessPoint locateDataAccessPoint(HttpServletRequest request, DataAccessPointRegistry registry) {
         // Path component of the URI, without context path
         String uri = ActionLib.actionURI(request);
-        String datasetUri = ActionLib.mapRequestToDataset(uri);
-
         if ( LogDispatch ) {
             LOG.info("Filter: Request URI = " + request.getRequestURI());
             LOG.info("Filter: Action URI  = " + uri);
-            LOG.info("Filter: Dataset URI = " + datasetUri);
         }
+        DataAccessPoint dap = locateDataAccessPoint(uri, registry);
+        // At this point, we are going to dispatch to the DataAccessPoint.
+        // It still may not have a handler for the service on this dataset.
+        // See #chooseProcessor(HttpAction) for locating the endpoint.
+        return dap;
+    }
+
+    /*package:testing*/ static DataAccessPoint locateDataAccessPoint(String uri, DataAccessPointRegistry registry) {
+        // Direct match.
+        if ( registry.isRegistered(uri) )
+            // Cases: /, /dataset and /path/dataset where /path is not the servlet context path.
+            return registry.get(uri);
 
+        // Remove possible service endpoint name.
+        String datasetUri = removeFinalComponent(uri);
+
+        // Requests should at least have "/".
         if ( datasetUri == null )
-            return false;
+            return null;
 
-        DataAccessPointRegistry registry = DataAccessPointRegistry.get(request.getServletContext());
-        if ( !registry.isRegistered(datasetUri) ) {
-            if ( LogDispatch )
-                LOG.debug("No dispatch for '"+datasetUri+"'");
-            return false;
+        if ( registry.isRegistered(datasetUri) )
+            // Cases: /dataset/sparql and /path/dataset/sparql
+            return registry.get(datasetUri);
+
+        return null;
+    }
+
+    /** Remove the final component of a path - return a valid URI path. */
+    private static String removeFinalComponent(String uri) {
+        int i = uri.lastIndexOf('/');
+        if ( i == -1 )
+            return null;
+        if ( i == 0 ) {
+            // /pathComponent - return a valid URI path.
+            return "/";
         }
-        DataAccessPoint dap = registry.get(datasetUri);
-        return process(dap, request, response);
+        return uri.substring(0, i);
     }
 
     /**
@@ -121,7 +167,7 @@ public class Dispatcher {
     /**
      * Find the ActionProcessor or return null if there can't determine one.
      *
-     * This function sends the appropriate HTTP error response.
+     * This function sends the appropriate HTTP error response on failure to choose an endpoint.
      *
      * Returning null indicates an HTTP error response, and the HTTP response has been done.
      *
diff --git a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/ActionExecLib.java b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/ActionExecLib.java
index 0f86022..d590317 100644
--- a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/ActionExecLib.java
+++ b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/ActionExecLib.java
@@ -73,97 +73,30 @@ public class ActionExecLib {
      * <li>completion/error statistics,</li>
      * <li>{@link #finishRequest(HttpAction)}
      * </ul>
-     * Common process for handling HTTP requests with logging and Java error handling.
+     * Common process for handling HTTP requests with logging and Java error
+     * handling. This is the case where the ActionProcessor is defined by or is the
+     * servlet directly outside the Fuseki dispatch process ({@link ServletAction}
+     * for special case like {@link SPARQL_QueryGeneral} which directly holds the {@link ActionProcessor}
+     * and {@link ServletProcessor} for administration actions.
+     * <p>
+     * Return false if the ActionProcessor is null.
+     *
      * @param action
      * @param processor
      */
-    public static boolean execAction(HttpAction action, ActionProcessor processor) {
+    public static void execAction(HttpAction action, ActionProcessor processor) {
         boolean b = execAction(action, ()->processor);
         if ( !b )
             ServletOps.errorNotFound("Not found: "+action.getActionURI());
-        return true;
     }
 
-    /** execAction, allowing for a choice of {@link ActionProcessor} within the logging and error handling. */
+    /**
+     * execAction, allowing for a choice of {@link ActionProcessor} within the logging and error handling.
+     * Return false if there was no ActionProcessor to handle the action.
+     */
     public static boolean execAction(HttpAction action, Supplier<ActionProcessor> processor) {
         try {
-            logRequest(action);
-            action.setStartTime();
-            initResponse(action);
-            HttpServletResponse response = action.getResponse();
-
-            startRequest(action);
-
-            try {
-                // Get the processor inside the startRequest - error handling - finishRequest sequence.
-                ActionProcessor proc = processor.get();
-                if ( proc == null ) {
-                    // Only for the logging.
-                    finishRequest(action);
-                    logNoResponse(action);
-                    archiveHttpAction(action);
-                    // Can't find the URL (the /dataset/service case) - not handled here.
-                    return false;
-                }
-                proc.process(action);
-            } catch (QueryCancelledException ex) {
-                // To put in the action timeout, need (1) global, (2) dataset and (3) protocol settings.
-                // See
-                //    global -- cxt.get(ARQ.queryTimeout)
-                //    dataset -- dataset.getContect(ARQ.queryTimeout)
-                //    protocol -- SPARQL_Query.setAnyTimeouts
-                String message = "Query timed out";
-                ServletOps.responseSendError(response, HttpSC.SERVICE_UNAVAILABLE_503, message);
-            } catch (OperationDeniedException ex) {
-                if ( ex.getMessage() == null )
-                    FmtLog.info(action.log, "[%d] OperationDeniedException", action.id);
-                else
-                    FmtLog.info(action.log, "[%d] OperationDeniedException: %s", action.id, ex.getMessage());
-                ServletOps.responseSendError(response, HttpSC.FORBIDDEN_403);
-            } catch (ActionErrorException ex) {
-                if ( ex.getCause() != null )
-                    FmtLog.warn(action.log, ex, "[%d] ActionErrorException with cause", action.id);
-                // Log message done by printResponse in a moment.
-                if ( ex.getMessage() != null )
-                    ServletOps.responseSendError(response, ex.getRC(), ex.getMessage());
-                else
-                    ServletOps.responseSendError(response, ex.getRC());
-            } catch (HttpException ex) {
-                int sc = ex.getStatusCode();
-                if ( sc <= 0 )
-                    // -1: Connection problem.
-                    sc = 400;
-                // Some code is passing up its own HttpException.
-                if ( ex.getMessage() == null )
-                    ServletOps.responseSendError(response, sc);
-                else
-                    ServletOps.responseSendError(response, sc, ex.getMessage());
-            } catch (QueryExceptionHTTP ex) {
-                // SERVICE failure.
-                int sc = ex.getStatusCode();
-                if ( sc <= 0 )
-                    // -1: Connection problem. "Bad Gateway"
-                    sc = 502;
-                if ( ex.getMessage() == null )
-                    ServletOps.responseSendError(response, sc);
-                else
-                    ServletOps.responseSendError(response, sc, ex.getMessage());
-            } catch (RuntimeIOException ex) {
-                FmtLog.warn(action.log, /*ex,*/ "[%d] Runtime IO Exception (client left?) RC = %d : %s", action.id, HttpSC.INTERNAL_SERVER_ERROR_500, ex.getMessage());
-                ServletOps.responseSendError(response, HttpSC.INTERNAL_SERVER_ERROR_500, ex.getMessage());
-            } catch (Throwable ex) {
-                // This should not happen.
-                //ex.printStackTrace(System.err);
-                FmtLog.warn(action.log, ex, "[%d] RC = %d : %s", action.id, HttpSC.INTERNAL_SERVER_ERROR_500, ex.getMessage());
-                ServletOps.responseSendError(response, HttpSC.INTERNAL_SERVER_ERROR_500, ex.getMessage());
-            } finally {
-                action.setFinishTime();
-                finishRequest(action);
-            }
-            // Handled - including sending back errors.
-            logResponse(action);
-            archiveHttpAction(action);
-            return true;
+            return execActionSub(action, processor);
         } catch (Throwable th) {
             // This really should not catch anything.
             FmtLog.error(action.log, th, "Internal error");
@@ -171,6 +104,85 @@ public class ActionExecLib {
         }
     }
 
+    private static boolean execActionSub(HttpAction action, Supplier<ActionProcessor> processor) {
+        logRequest(action);
+        action.setStartTime();
+        initResponse(action);
+        HttpServletResponse response = action.getResponse();
+
+        startRequest(action);
+        try {
+            // Get the processor inside the startRequest - error handling - finishRequest sequence.
+            ActionProcessor proc = processor.get();
+            if ( proc == null ) {
+                // Only for the logging.
+                finishRequest(action);
+                logNoResponse(action);
+                archiveHttpAction(action);
+                // Can't find the URL (the /dataset/service case) - not handled here.
+                return false;
+            }
+            proc.process(action);
+        } catch (QueryCancelledException ex) {
+            // To put in the action timeout, need (1) global, (2) dataset and (3) protocol settings.
+            // See
+            //    global -- cxt.get(ARQ.queryTimeout)
+            //    dataset -- dataset.getContect(ARQ.queryTimeout)
+            //    protocol -- SPARQL_Query.setAnyTimeouts
+            String message = "Query timed out";
+            ServletOps.responseSendError(response, HttpSC.SERVICE_UNAVAILABLE_503, message);
+        } catch (OperationDeniedException ex) {
+            if ( ex.getMessage() == null )
+                FmtLog.info(action.log, "[%d] OperationDeniedException", action.id);
+            else
+                FmtLog.info(action.log, "[%d] OperationDeniedException: %s", action.id, ex.getMessage());
+            ServletOps.responseSendError(response, HttpSC.FORBIDDEN_403);
+        } catch (ActionErrorException ex) {
+            if ( ex.getCause() != null )
+                FmtLog.warn(action.log, ex, "[%d] ActionErrorException with cause", action.id);
+            // Log message done by printResponse in a moment.
+            if ( ex.getMessage() != null )
+                ServletOps.responseSendError(response, ex.getRC(), ex.getMessage());
+            else
+                ServletOps.responseSendError(response, ex.getRC());
+        } catch (HttpException ex) {
+            int sc = ex.getStatusCode();
+            if ( sc <= 0 )
+                // -1: Connection problem.
+                sc = 400;
+            // Some code is passing up its own HttpException.
+            if ( ex.getMessage() == null )
+                ServletOps.responseSendError(response, sc);
+            else
+                ServletOps.responseSendError(response, sc, ex.getMessage());
+        } catch (QueryExceptionHTTP ex) {
+            // SERVICE failure.
+            int sc = ex.getStatusCode();
+            if ( sc <= 0 )
+                // -1: Connection problem. "Bad Gateway"
+                sc = 502;
+            if ( ex.getMessage() == null )
+                ServletOps.responseSendError(response, sc);
+            else
+                ServletOps.responseSendError(response, sc, ex.getMessage());
+        } catch (RuntimeIOException ex) {
+            FmtLog.warn(action.log, /*ex,*/ "[%d] Runtime IO Exception (client left?) RC = %d : %s", action.id, HttpSC.INTERNAL_SERVER_ERROR_500, ex.getMessage());
+            ServletOps.responseSendError(response, HttpSC.INTERNAL_SERVER_ERROR_500, ex.getMessage());
+        } catch (Throwable ex) {
+            // This should not happen.
+            //ex.printStackTrace(System.err);
+            FmtLog.warn(action.log, ex, "[%d] RC = %d : %s", action.id, HttpSC.INTERNAL_SERVER_ERROR_500, ex.getMessage());
+            ServletOps.responseSendError(response, HttpSC.INTERNAL_SERVER_ERROR_500, ex.getMessage());
+        } finally {
+            action.setFinishTime();
+            finishRequest(action);
+        }
+        // Handled - including sending back errors.
+        logResponse(action);
+        archiveHttpAction(action);
+        return true;
+    }
+
     /**
      * Helper method which gets a unique request ID and appends it as a header to the
      * response
diff --git a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/ActionLib.java b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/ActionLib.java
index 084e4ee..b5421b0 100644
--- a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/ActionLib.java
+++ b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/ActionLib.java
@@ -59,45 +59,23 @@ import org.apache.jena.web.HttpSC;
 /** Operations related to servlets */
 
 public class ActionLib {
-    /**
-     * Get the datasets from an {@link HttpAction}
-     * that assumes the form /dataset/service.
-     * @param action the request
-     * @return the dataset
-     */
-    public static String mapRequestToDataset(HttpAction action) {
-         String uri = action.getActionURI();
-         return mapRequestToDataset(uri);
-     }
-
-    /** Map request to uri in the registry.
-     *  A possible implementation for mapRequestToDataset(String)
-     *  that assumes the form /dataset/service
-     *  Returning null means no mapping found.
-     *  The URI must be the action URI (no contact path)
-     */
 
-    public static String mapRequestToDataset(String uri) {
-        // Chop off trailing part - the service selector
-        // e.g. /dataset/sparql => /dataset
-        int i = uri.lastIndexOf('/');
-        if ( i == -1 )
-            return null;
-        if ( i == 0 ) {
-            // started with '/' - leave.
-            return uri;
-        }
-        return uri.substring(0, i);
+    /** Calculate the operation, given action and data access point */
+    public static String mapRequestToEndpointName(HttpAction action, DataAccessPoint dataAccessPoint) {
+        String uri = action.getActionURI();
+        return mapRequestToEndpointName(uri, dataAccessPoint);
     }
 
-    /** Calculate the operation, given action and data access point */
-    public static String mapRequestToEndpointName(HttpAction action, DataAccessPoint dsRef) {
-        if ( dsRef == null )
+    /** Calculate the operation, given request URI and data access point */
+    public static String mapRequestToEndpointName(String uri, DataAccessPoint dataAccessPoint) {
+        if ( dataAccessPoint == null )
             return "";
-        String uri = action.getActionURI();
-        String name = dsRef.getName();
+        String name = dataAccessPoint.getName();
         if ( name.length() >= uri.length() )
             return "";
+        if ( name.equals("/") )
+            // Case "/" and uri "/service"
+            return uri.substring(1);
         return uri.substring(name.length()+1);   // Skip the separating "/"
     }
 
@@ -105,7 +83,7 @@ public class ActionLib {
      * Implementation of mapRequestToDataset(String) that looks for the longest match
      * in the registry. This includes use in direct naming GSP.
      */
-    public static String mapRequestToDatasetLongest$(String uri, DataAccessPointRegistry registry) {
+    public static String unused_mapRequestToDatasetLongest(String uri, DataAccessPointRegistry registry) {
         if ( uri == null )
             return null;
 
@@ -167,16 +145,17 @@ public class ActionLib {
 //      ServletContext cxt = this.getServletContext();
 //      Log.info(this, "ServletContext path     = '"+cxt.getContextPath()+"'");
 
+        String uri = request.getRequestURI();
         ServletContext servletCxt = request.getServletContext();
         if ( servletCxt == null )
             return request.getRequestURI();
 
         String contextPath = servletCxt.getContextPath();
-        String uri = request.getRequestURI();
         if ( contextPath == null )
             return uri;
         if ( contextPath.isEmpty())
             return uri;
+
         String x = uri;
         if ( uri.startsWith(contextPath) )
             x = uri.substring(contextPath.length());
diff --git a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/UploadRDF.java b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/UploadRDF.java
index c251a9f..6f922e9 100644
--- a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/UploadRDF.java
+++ b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/UploadRDF.java
@@ -18,6 +18,8 @@
 
 package org.apache.jena.fuseki.servlets;
 
+import static java.lang.String.format;
+
 import java.util.function.Function;
 
 import org.apache.jena.atlas.web.ContentType;
@@ -120,12 +122,20 @@ public class UploadRDF extends ActionREST {
         } catch (RiotException ex) {
             // Parse error
             action.abortSilent();
+            if ( ex.getMessage() != null )
+                action.log.info(format("[%d] Data error: %s", action.id, ex.getMessage()));
+            else
+                action.log.info(format("[%d] Data error", action.id), ex);
             ServletOps.errorBadRequest(ex.getMessage());
         } catch (OperationDeniedException ex) {
             action.abortSilent();
             throw ex;
         } catch (ActionErrorException ex) {
             action.abortSilent();
+            if ( ex.getMessage() != null )
+                action.log.info(format("[%d] Upload error: %s", action.id, ex.getMessage()));
+            else
+                action.log.info(format("[%d] Upload error", action.id), ex);
             throw ex;
         } catch (Exception ex) {
             // Something else went wrong. Backout.
diff --git a/jena-fuseki2/jena-fuseki-core/src/test/java/org/apache/jena/fuseki/TS_FusekiCore.java b/jena-fuseki2/jena-fuseki-core/src/test/java/org/apache/jena/fuseki/TS_FusekiCore.java
index bab7afc..a3b1ce9 100644
--- a/jena-fuseki2/jena-fuseki-core/src/test/java/org/apache/jena/fuseki/TS_FusekiCore.java
+++ b/jena-fuseki2/jena-fuseki-core/src/test/java/org/apache/jena/fuseki/TS_FusekiCore.java
@@ -18,6 +18,7 @@
 
 package org.apache.jena.fuseki;
 
+import org.apache.jena.fuseki.server.TestDispatchOnURI;
 import org.junit.runner.RunWith;
 import org.junit.runners.Suite;
 import org.junit.runners.Suite.SuiteClasses;
@@ -25,7 +26,8 @@ import org.junit.runners.Suite.SuiteClasses;
 // Most testing needs a server.
 @RunWith(Suite.class)
 @SuiteClasses({
-    TestValidators.class
+    TestValidators.class,
+    TestDispatchOnURI.class
 })
 public class TS_FusekiCore {}
 
diff --git a/jena-fuseki2/jena-fuseki-core/src/test/java/org/apache/jena/fuseki/server/TestDispatchOnURI.java b/jena-fuseki2/jena-fuseki-core/src/test/java/org/apache/jena/fuseki/server/TestDispatchOnURI.java
new file mode 100644
index 0000000..abad6c0
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-core/src/test/java/org/apache/jena/fuseki/server/TestDispatchOnURI.java
@@ -0,0 +1,137 @@
+/*
+ * 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.jena.fuseki.server;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
+
+import org.apache.jena.fuseki.servlets.ActionLib;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/*
+ * Test the request URI part of dispatch.
+ * This covers finding the DataAccessPoint.
+ * A request may still fail due to no endpoint or suitable processor;
+ * this isn't covered in these unit tests.
+ */
+public class TestDispatchOnURI {
+
+    private static DataAccessPointRegistry registryNoRoot;
+    private static DataAccessPointRegistry registryWithRoot;
+
+    @BeforeClass public static void beforeClass() {
+        registryNoRoot = new DataAccessPointRegistry();
+        DataService dSrv1 = DataService.newBuilder()
+                .addEndpoint(Operation.Query)
+                .addEndpoint(Operation.Query, "spook")
+                .build();
+        registryNoRoot.register(new DataAccessPoint("ds", dSrv1));
+        DataService dSrv2 = DataService.newBuilder()
+                .addEndpoint(Operation.Query)
+                .build();
+        registryNoRoot.register(new DataAccessPoint("/path/dataset", dSrv2));
+        registryNoRoot.register(new DataAccessPoint("/path1/path2/dataset", dSrv2));
+
+        registryWithRoot = new DataAccessPointRegistry(registryNoRoot);
+        registryWithRoot.register(new DataAccessPoint("/", dSrv1));
+    }
+
+    @Test public void dispatch_1() {
+        testDispatch("/ds", registryWithRoot, "/ds", "");
+    }
+
+    @Test public void dispatch_2() {
+        // Request URI dispatch does not consider existence of a suitable endpoint.
+        testDispatch("/ds/does-not-exist", registryWithRoot, "/ds", "does-not-exist");
+    }
+
+    @Test public void dispatch_3() {
+        testDispatch("/ds/spook", registryWithRoot, "/ds", "spook");
+    }
+
+    @Test public void dispatch_root_1() {
+        testDispatch("/", registryWithRoot, "/", "");
+    }
+
+    @Test public void dispatch_root_2() {
+        testDispatch("/sparql", registryWithRoot, "/", "sparql");
+    }
+
+    // endpoint names can only be path components.
+    @Test public void no_dispatch_1() {
+        testNoDispatch("/ds/abc/def", registryWithRoot);
+    }
+
+    @Test public void no_dispatch_2() {
+        testNoDispatch("/x404", registryNoRoot);
+    }
+
+    @Test public void no_dispatch_3() {
+        testNoDispatch("/", registryNoRoot);
+    }
+
+    @Test public void no_dispatch_4() {
+        testNoDispatch("/x404/sparql", registryWithRoot);
+    }
+
+    @Test public void no_dispatch_5() {
+        testNoDispatch("/anotherPath/dataset", registryWithRoot);
+    }
+
+    @Test public void dispatch_path_1() {
+        testDispatch("/path/dataset", registryWithRoot, "/path/dataset", "");
+    }
+
+    @Test public void dispatch_path_2() {
+        testDispatch("/path/dataset/sparql", registryWithRoot, "/path/dataset", "sparql");
+    }
+
+    @Test public void dispatch_path_3() {
+        testDispatch("/path/dataset/does-not-exist", registryWithRoot, "/path/dataset", "does-not-exist");
+    }
+
+    @Test public void dispatch_path_4() {
+        testDispatch("/path1/path2/dataset", registryWithRoot, "/path1/path2/dataset", "");
+    }
+
+    @Test public void dispatch_path_5() {
+        testDispatch("/path1/path2/dataset/sparql", registryWithRoot, "/path1/path2/dataset", "sparql");
+    }
+
+    private void testNoDispatch(String requestURI, DataAccessPointRegistry registry) {
+        DataAccessPoint dap = Dispatcher.locateDataAccessPoint(requestURI, registry);
+        assertNull("Expect no dispatch for "+requestURI, dap);
+    }
+
+    private void testDispatch(String requestURI, DataAccessPointRegistry registry, String expectedDataset, String expectedEndpoint) {
+        DataAccessPoint dap = Dispatcher.locateDataAccessPoint(requestURI, registry);
+        if ( dap == null ) {
+            if ( expectedDataset != null )
+                fail("No DataAccessPoint: expected to find a match: "+requestURI+" -> ("+expectedDataset+", "+expectedEndpoint+")");
+            return;
+        }
+        // The request URI part of dispatch choice in Dispatcher.chooseProcessor(HttpAction action)
+        String ep = ActionLib.mapRequestToEndpointName(requestURI, dap);
+        assertNotNull(ep);
+        assertEquals("Endpoint", expectedEndpoint, ep);
+    }
+}