SLING-9662 Introduce Resource Mapping SPI
diff --git a/pom.xml b/pom.xml
index 8e02cb7..10f3109 100644
--- a/pom.xml
+++ b/pom.xml
@@ -105,7 +105,7 @@
             <groupId>org.apache.commons</groupId>
             <artifactId>commons-lang3</artifactId>
             <version>3.2</version>
-            <scope>test</scope>
+            
         </dependency>
         <dependency>
             <groupId>org.osgi</groupId>
diff --git a/src/main/java/org/apache/sling/api/resource/ResourceResolver.java b/src/main/java/org/apache/sling/api/resource/ResourceResolver.java
index 8a2b27d..adba8ac 100644
--- a/src/main/java/org/apache/sling/api/resource/ResourceResolver.java
+++ b/src/main/java/org/apache/sling/api/resource/ResourceResolver.java
@@ -22,12 +22,14 @@
 import java.util.Iterator;
 import java.util.Map;
 
-import org.jetbrains.annotations.Nullable;
-import org.jetbrains.annotations.NotNull;
 import javax.servlet.http.HttpServletRequest;
 
 import org.apache.sling.api.adapter.Adaptable;
 import org.apache.sling.api.resource.mapping.ResourceMapper;
+import org.apache.sling.api.resource.uri.ResourceUri;
+import org.apache.sling.api.resource.uri.ResourceUriBuilder;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
 import org.osgi.annotation.versioning.ProviderType;
 
 /**
@@ -331,6 +333,13 @@
      */
     @Nullable String map(@NotNull HttpServletRequest request, @NotNull String resourcePath);
 
+    /** Same as map(request, resourcePath) but returns a {@link ResourceUri} */
+    @Nullable
+    default ResourceUri mapToUri(@NotNull HttpServletRequest request, @NotNull String resourcePath) {
+        String resourceUri = map(request, resourcePath);
+        return ResourceUriBuilder.parse(resourceUri).build();
+    }
+
     /**
      * Returns a {@link Resource} object for data located at the given path.
      * <p>
diff --git a/src/main/java/org/apache/sling/api/resource/mapping/spi/MappingChainContext.java b/src/main/java/org/apache/sling/api/resource/mapping/spi/MappingChainContext.java
new file mode 100644
index 0000000..e5dc89b
--- /dev/null
+++ b/src/main/java/org/apache/sling/api/resource/mapping/spi/MappingChainContext.java
@@ -0,0 +1,47 @@
+/*
+ * 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.api.resource.mapping.spi;
+
+import java.util.Map;
+
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.uri.ResourceUri;
+import org.osgi.annotation.versioning.ProviderType;
+
+/** Provides ResourceToUriMapper instances with additional context. */
+@ProviderType
+public interface MappingChainContext {
+
+    /** The resource resolver that was used to call map() or resolve(). */
+    ResourceResolver getResourceResolver();
+
+    /** May be called by any ResourceToUriMapper in the chain to indicate that the rest of the chain should be skipped. */
+    void skipRemainingChain();
+
+    /** Allows to share state between ResourceToUriMapper instances in the chain.
+     * 
+     * @return a mutable map to share state (never null). */
+    Map<String, Object> getAttributes();
+
+    /** Provides access to intermediate mappings.
+     * 
+     * @return the resource mappings */
+    Map<String, ResourceUri> getIntermediateMappings();
+
+}
diff --git a/src/main/java/org/apache/sling/api/resource/mapping/spi/ResourceToUriMapper.java b/src/main/java/org/apache/sling/api/resource/mapping/spi/ResourceToUriMapper.java
new file mode 100644
index 0000000..a91b694
--- /dev/null
+++ b/src/main/java/org/apache/sling/api/resource/mapping/spi/ResourceToUriMapper.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.api.resource.mapping.spi;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.sling.api.resource.uri.ResourceUri;
+import org.jetbrains.annotations.NotNull;
+import org.osgi.annotation.versioning.ConsumerType;
+
+/** SPI interface that contributes to resource mapping and resolving of the resource resolver's map() and resolve() methods.
+ * 
+ * All registered services build a conceptual chain sorted by service ranking. The resource link is passed through the chain while any
+ * ResourceToUriMapper chain member may or may not make adjustments to the resource link.
+ * 
+ * rr.resolve() passes through the chain starting at the ResourceToUriMapper with the <strong>highest</strong> service ranking and rr.map()
+ * passes through the chain starting at the ResourceToUriMapper with the <strong>lowest</strong> service ranking */
+@ConsumerType
+public interface ResourceToUriMapper {
+    
+    /** Contributes to the resolve process, may or may not make adjustments to the resource link
+     * 
+     * @param resourceURI the URI to be resolved
+     * @param request the request
+     * @param pipelineContext can be used to skip further processing of the chain or for sharing state between instances of ResourceMapping
+     * @return the adjusted ResourceUri */
+    ResourceUri resolve(@NotNull ResourceUri resourceUri, HttpServletRequest request, MappingChainContext context);
+
+    /** Contributes to the map process, may or may not make adjustments to the resource link.
+     * 
+     * @param resourceURI the URI to be mapped
+     * @param request the request to be taken as example
+     * @param pipelineContext can be used to skip further processing of the chain or for sharing state between instances of ResourceMapping
+     * @return the adjusted ResourceUri */
+    ResourceUri map(@NotNull ResourceUri resourceUri, HttpServletRequest request, MappingChainContext context);
+
+}
diff --git a/src/main/java/org/apache/sling/api/resource/mapping/spi/package-info.java b/src/main/java/org/apache/sling/api/resource/mapping/spi/package-info.java
new file mode 100644
index 0000000..a58f5b5
--- /dev/null
+++ b/src/main/java/org/apache/sling/api/resource/mapping/spi/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+@Version("1.0.0")
+package org.apache.sling.api.resource.mapping.spi;
+
+import org.osgi.annotation.versioning.Version;
+
diff --git a/src/main/java/org/apache/sling/api/resource/package-info.java b/src/main/java/org/apache/sling/api/resource/package-info.java
index 7bd85e6..ac05b61 100644
--- a/src/main/java/org/apache/sling/api/resource/package-info.java
+++ b/src/main/java/org/apache/sling/api/resource/package-info.java
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-@Version("2.12.2")
+@Version("2.13.0")
 package org.apache.sling.api.resource;
 
 import org.osgi.annotation.versioning.Version;
diff --git a/src/main/java/org/apache/sling/api/resource/uri/ResourceUri.java b/src/main/java/org/apache/sling/api/resource/uri/ResourceUri.java
new file mode 100644
index 0000000..60e57fc
--- /dev/null
+++ b/src/main/java/org/apache/sling/api/resource/uri/ResourceUri.java
@@ -0,0 +1,118 @@
+/*
+ * 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.api.resource.uri;
+
+import static org.apache.commons.lang3.StringUtils.isBlank;
+import static org.apache.commons.lang3.StringUtils.isNotBlank;
+
+import java.net.URI;
+import java.util.function.Consumer;
+
+import org.apache.sling.api.request.RequestPathInfo;
+import org.apache.sling.api.resource.Resource;
+
+/** Represents an immutable URI that points to a resource or alternatively, can contain special URIs like {@code mailto:} or
+ * {@code javascript:}. */
+public interface ResourceUri extends RequestPathInfo {
+
+    /** @return returns the URI. */
+    public URI toUri();
+
+    /** @return returns the URI as String. */
+    public String toString();
+
+    /** @return returns the scheme of the link */
+    public String getScheme();
+
+    /** @return returns the user info of the link */
+    public String getUserInfo();
+
+    /** @return returns the host of the link */
+    public String getHost();
+
+    /** @return returns the port of the link */
+    public int getPort();
+
+    /** @return returns the resource path or null if link does not contain path. */
+    @Override
+    public String getResourcePath();
+
+    /** @return returns the selector string */
+    @Override
+    public String getSelectorString();
+
+    /** @return returns the selector array */
+    @Override
+    public String[] getSelectors();
+
+    /** @return returns the extension of the link */
+    @Override
+    public String getExtension();
+
+    /** @return returns the suffix of the link */
+    @Override
+    public String getSuffix();
+
+    /** @return returns the query part of the link */
+    public String getQuery();
+
+    /** @return returns the url fragment of the link */
+    public String getFragment();
+
+    /** @return scheme specific part of the URL */
+    public String getSchemeSpecificPart();
+    
+    /** @return returns the corresponding */
+    @Override
+    public Resource getSuffixResource();
+
+    /** @return returns true if the link is either a relative or absolute path (this is the case if scheme and host is empty and the URI
+     *         path is set) */
+    default boolean isPath() {
+        return isBlank(getScheme())
+                && isBlank(getHost())
+                && isNotBlank(getResourcePath());
+    }
+
+    /** @return true if the link is a absolute path starting with a slash ('/'). This is the default case for all links to pages and assets
+     *         in AEM. */
+    default boolean isAbsolutePath() {
+        return isPath() && getResourcePath().startsWith(ResourceUriBuilder.CHAR_SLASH);
+    }
+
+    /** @return true if link is relative (not an URL and not starting with '/') */
+    default boolean isRelativePath() {
+        return isPath() && !getResourcePath().startsWith(ResourceUriBuilder.CHAR_SLASH);
+    }
+
+    /** @return true if the link is an absolute URI containing a scheme. */
+    default boolean isFullUri() {
+        return isNotBlank(getScheme())
+                && isNotBlank(getHost());
+    }
+
+    /** @param builderConsumer
+     * @return the adjusted ResourceUri (new instance) */
+    default ResourceUri adjust(Consumer<ResourceUriBuilder> builderConsumer) {
+        ResourceUriBuilder builder = ResourceUriBuilder.createFrom(this);
+        builderConsumer.accept(builder);
+        return builder.build();
+    }
+
+}
diff --git a/src/main/java/org/apache/sling/api/resource/uri/ResourceUriBuilder.java b/src/main/java/org/apache/sling/api/resource/uri/ResourceUriBuilder.java
new file mode 100644
index 0000000..17d82dc
--- /dev/null
+++ b/src/main/java/org/apache/sling/api/resource/uri/ResourceUriBuilder.java
@@ -0,0 +1,497 @@
+/*
+ * 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.api.resource.uri;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Arrays;
+import java.util.LinkedList;
+import java.util.List;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.sling.api.SlingHttpServletRequest;
+import org.apache.sling.api.request.RequestPathInfo;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+
+public class ResourceUriBuilder {
+
+    static final String CHAR_HASH = "#";
+    static final String CHAR_QM = "?";
+    static final String CHAR_DOT = ".";
+    static final String CHAR_SLASH = "/";
+    static final String CHAR_AT = "@";
+    static final String DOT_LITERAL_REGEX = "\\.(?!\\.?/)";
+    static final String CHAR_COLON = ":";
+
+    public static ResourceUriBuilder create() {
+        return new ResourceUriBuilder();
+    }
+
+    /** Creates a builder from another ResourceUri.
+     * 
+     * @param resourceUri
+     * @return a ResourceUriBuilder */
+    public static ResourceUriBuilder createFrom(ResourceUri resourceUri) {
+        return create()
+                .setScheme(resourceUri.getScheme())
+                .setUserInfo(resourceUri.getUserInfo())
+                .setHost(resourceUri.getHost())
+                .setPort(resourceUri.getPort())
+                .setResourcePath(resourceUri.getResourcePath())
+                .setSelectors(resourceUri.getSelectors())
+                .setExtension(resourceUri.getExtension())
+                .setSuffix(resourceUri.getSuffix())
+                .setQuery(resourceUri.getQuery())
+                .setFragment(resourceUri.getFragment())
+                .setSchemeSpecificPart(resourceUri.getSchemeSpecificPart())
+                .setResourceResolver(resourceUri instanceof ImmutableResourceUri
+                        ? ((ImmutableResourceUri) resourceUri).getBuilder().resourceResolver
+                        : null);
+    }
+
+    /** Creates a builder from a Resource (only taking the resource path into account).
+     * 
+     * @param resource
+     * @return a ResourceUriBuilder */
+    public static ResourceUriBuilder createFrom(Resource resource) {
+        return create()
+                .setResourcePath(resource.getPath())
+                .setResourceResolver(resource.getResourceResolver());
+    }
+
+    /** Creates a builder from a RequestPathInfo instance .
+     * 
+     * @param requestPathInfo
+     * @return a ResourceUriBuilder */
+    public static ResourceUriBuilder createFrom(RequestPathInfo requestPathInfo) {
+        return create()
+                .setResourcePath(requestPathInfo.getResourcePath())
+                .setSelectors(requestPathInfo.getSelectors())
+                .setExtension(requestPathInfo.getExtension())
+                .setSuffix(requestPathInfo.getSuffix());
+    }
+
+    /** Creates a builder from a request.
+     * 
+     * @param request
+     * @return a ResourceUriBuilder */
+    public static ResourceUriBuilder createFrom(SlingHttpServletRequest request) {
+        return createFrom(request.getRequestPathInfo())
+                .setScheme(request.getScheme())
+                .setHost(request.getServerName())
+                .setPort(request.getServerPort())
+                .setResourceResolver(request.getResourceResolver());
+    }
+
+    /** Creates a builder from a URI.
+     * 
+     * @param uri
+     * @return a ResourceUriBuilder */
+    public static ResourceUriBuilder createFrom(URI uri) {
+        String path = uri.getPath();
+        boolean pathExists = !StringUtils.isBlank(path);
+        boolean schemeSpecificRelevant = !pathExists && uri.getQuery() == null;
+        return create()
+                .setScheme(uri.getScheme())
+                .setUserInfo(uri.getUserInfo())
+                .setHost(uri.getHost())
+                .setPort(uri.getPort())
+                .setPath(pathExists ? path : null)
+                .setQuery(uri.getQuery())
+                .setFragment(uri.getFragment())
+                .setSchemeSpecificPart(schemeSpecificRelevant ? uri.getSchemeSpecificPart() : null);
+    }
+
+    /** Creates a builder from an arbitrary URI string.
+     * 
+     * @param resourceUriStr
+     * @return a ResourceUriBuilder */
+    public static ResourceUriBuilder parse(String resourceUriStr) {
+        URI uri;
+        try {
+            uri = new URI(resourceUriStr);
+            return createFrom(uri);
+        } catch (URISyntaxException e) {
+            throw new IllegalArgumentException("Invalid URI " + resourceUriStr + ": " + e.getMessage(), e);
+        }
+    }
+
+    /** Creates a builder from a resource path.
+     * 
+     * @param resourcePathStr
+     * @return a ResourceUriBuilder */
+    public static ResourceUriBuilder forPath(String resourcePathStr) {
+        return new ResourceUriBuilder().setPath(resourcePathStr);
+    }
+
+    private String scheme = null;
+
+    private String userInfo = null;
+    private String host = null;
+    private int port = -1;
+
+    private String resourcePath = null;
+    private final List<String> selectors = new LinkedList<String>();
+    private String extension = null;
+    private String suffix = null;
+    private String schemeSpecificPart = null;
+    private String query = null;
+    private String fragment = null;
+
+    // only needed for getSuffixResource() from interface RequestPathInfo
+    private ResourceResolver resourceResolver = null;
+
+    private ResourceUriBuilder() {
+    }
+
+    /** @param userInfo
+     * @return the builder for method chaining */
+    public ResourceUriBuilder setUserInfo(String userInfo) {
+        if (schemeSpecificPart != null) {
+            return this;
+        }
+        this.userInfo = userInfo;
+        return this;
+    }
+
+    /** @param host
+     * @return the builder for method chaining */
+    public ResourceUriBuilder setHost(String host) {
+        if (schemeSpecificPart != null) {
+            return this;
+        }
+        this.host = host;
+        return this;
+    }
+
+    /** @param port
+     * @return the builder for method chaining */
+    public ResourceUriBuilder setPort(int port) {
+        if (schemeSpecificPart != null) {
+            return this;
+        }
+        this.port = port;
+        return this;
+    }
+
+    /** @param path
+     * @return the builder for method chaining */
+    public ResourceUriBuilder setPath(String path) {
+        if (schemeSpecificPart != null) {
+            return this;
+        }
+        if (path != null && path.contains(CHAR_DOT)) {
+            String[] pathBits = path.split(DOT_LITERAL_REGEX);
+            setResourcePath(pathBits[0]);
+            if (pathBits.length > 2) {
+                setSelectors(Arrays.copyOfRange(pathBits, 1, pathBits.length - 1));
+            }
+            String extensionAndSuffix = pathBits[pathBits.length - 1];
+            String[] extensionAndSuffixBits = extensionAndSuffix.split(CHAR_SLASH, 2);
+            setExtension(extensionAndSuffixBits[0]);
+            if (extensionAndSuffixBits.length == 2) {
+                setSuffix(CHAR_SLASH + extensionAndSuffixBits[1]);
+            }
+        } else {
+            setResourcePath(path);
+        }
+        return this;
+    }
+
+    /** @param resourcePath
+     * @return the builder for method chaining */
+    public ResourceUriBuilder setResourcePath(String resourcePath) {
+        if (schemeSpecificPart != null) {
+            return this;
+        }
+        this.resourcePath = resourcePath;
+        return this;
+    }
+
+    /** @param selectors
+     * @return the builder for method chaining */
+    public ResourceUriBuilder setSelectors(String[] selectors) {
+        if (schemeSpecificPart != null || resourcePath == null) {
+            return this;
+        }
+        this.selectors.clear();
+        Arrays.stream(selectors).forEach(this.selectors::add);
+        return this;
+    }
+
+    /** @param selector
+     * @return the builder for method chaining */
+    public ResourceUriBuilder addSelector(String selector) {
+        if (schemeSpecificPart != null || resourcePath == null) {
+            return this;
+        }
+        this.selectors.add(selector);
+        return this;
+    }
+
+    /** @param extension
+     * @return the builder for method chaining */
+    public ResourceUriBuilder setExtension(String extension) {
+        if (schemeSpecificPart != null || resourcePath == null) {
+            return this;
+        }
+        this.extension = extension;
+        return this;
+    }
+
+    /** @param suffix
+     * @return the builder for method chaining */
+    public ResourceUriBuilder setSuffix(String suffix) {
+        if (schemeSpecificPart != null || resourcePath == null) {
+            return this;
+        }
+        if (suffix != null && !StringUtils.startsWith(suffix, "/")) {
+            throw new IllegalArgumentException("Suffix needs to start with slash");
+        }
+        this.suffix = suffix;
+        return this;
+    }
+
+    /** @param query
+     * @return the builder for method chaining */
+    public ResourceUriBuilder setQuery(String query) {
+        if (schemeSpecificPart != null) {
+            return this;
+        }
+        this.query = query;
+        return this;
+    }
+
+    /** @param urlFragment
+     * @return the builder for method chaining */
+    public ResourceUriBuilder setFragment(String urlFragment) {
+        if (schemeSpecificPart != null) {
+            return this;
+        }
+        this.fragment = urlFragment;
+        return this;
+    }
+
+    /** @param scheme
+     * @return the builder for method chaining */
+    public ResourceUriBuilder setScheme(String scheme) {
+        this.scheme = scheme;
+        return this;
+    }
+
+    /** @param schemeSpecificPart
+     * @return the builder for method chaining */
+    public ResourceUriBuilder setSchemeSpecificPart(String schemeSpecificPart) {
+        if (schemeSpecificPart != null && schemeSpecificPart.isEmpty()) {
+            return this;
+        }
+        this.schemeSpecificPart = schemeSpecificPart;
+        return this;
+    }
+
+    /** Will remove scheme and authority (that is user info, host and port).
+     * 
+     * @return the builder for method chaining */
+    public ResourceUriBuilder removeSchemeAndAuthority() {
+        setScheme(null);
+        setUserInfo(null);
+        setHost(null);
+        setPort(-1);
+        return this;
+    }
+
+    /** Will take over scheme and authority (user info, host and port) from provided resourceUri.
+     * 
+     * @param resourceUri
+     * @return the builder for method chaining */
+    public ResourceUriBuilder useSchemeAndAuthority(ResourceUri resourceUri) {
+        setScheme(resourceUri.getScheme());
+        setUserInfo(resourceUri.getUserInfo());
+        setHost(resourceUri.getHost());
+        setPort(resourceUri.getPort());
+        return this;
+    }
+
+    // only to support getSuffixResource() from interface RequestPathInfo
+    private ResourceUriBuilder setResourceResolver(ResourceResolver resourceResolver) {
+        this.resourceResolver = resourceResolver;
+        return this;
+    }
+
+    /** Will take over scheme and authority (user info, host and port) from provided uri.
+     * 
+     * @param uri
+     * @return the builder for method chaining */
+    public ResourceUriBuilder useSchemeAndAuthority(URI uri) {
+        useSchemeAndAuthority(createFrom(uri).build());
+        return this;
+    }
+
+    /** Builds the immutable ResourceUri from this builder.
+     * 
+     * @return the builder for method chaining */
+    public ResourceUri build() {
+        return new ImmutableResourceUri();
+    }
+
+    /** @return string representation of builder */
+    public String toString() {
+        return build().toString();
+    }
+
+    // read-only view on the builder data (to avoid another copy of the data into a new object)
+    private class ImmutableResourceUri implements ResourceUri {
+
+        private static final String HTTPS_SCHEME = "https";
+        private static final int HTTPS_DEFAULT_PORT = 443;
+        private static final String HTTP_SCHEME = "http";
+        private static final int HTTP_DEFAULT_PORT = 80;
+
+        @Override
+        public String getResourcePath() {
+            return resourcePath;
+        }
+
+        // returns null in line with
+        // https://sling.apache.org/apidocs/sling11/org/apache/sling/api/request/RequestPathInfo.html#getSelectorString--
+        @Override
+        public String getSelectorString() {
+            return !selectors.isEmpty() ? String.join(CHAR_DOT, selectors) : null;
+        }
+
+        @Override
+        public String[] getSelectors() {
+            return selectors.toArray(new String[selectors.size()]);
+        }
+
+        @Override
+        public String getExtension() {
+            return extension;
+        }
+
+        @Override
+        public String getSuffix() {
+            return suffix;
+        }
+
+        @Override
+        public String getSchemeSpecificPart() {
+            return schemeSpecificPart;
+        }
+
+        @Override
+        public String getQuery() {
+            return query;
+        }
+
+        @Override
+        public String getFragment() {
+            return fragment;
+        }
+
+        @Override
+        public String getScheme() {
+            return scheme;
+        }
+
+        @Override
+        public String getHost() {
+            return host;
+        }
+
+        @Override
+        public int getPort() {
+            return port;
+        }
+
+        @Override
+        public Resource getSuffixResource() {
+            if (StringUtils.isNotBlank(suffix) && resourceResolver != null) {
+                return resourceResolver.resolve(suffix);
+            } else {
+                return null;
+            }
+        }
+
+        @Override
+        public String getUserInfo() {
+            return userInfo;
+        }
+
+        @Override
+        public String toString() {
+            StringBuilder requestUri = new StringBuilder();
+
+            if (StringUtils.isNotBlank(scheme)) {
+                requestUri.append(scheme + CHAR_COLON);
+            }
+            if (isFullUri()) {
+                requestUri.append(CHAR_SLASH + CHAR_SLASH);
+                if (StringUtils.isNotBlank(userInfo)) {
+                    requestUri.append(userInfo + CHAR_AT);
+                }
+                requestUri.append(host);
+                if (port > 0
+                        && !(scheme.equals(HTTP_SCHEME) && port == HTTP_DEFAULT_PORT)
+                        && !(scheme.equals(HTTPS_SCHEME) && port == HTTPS_DEFAULT_PORT)) {
+                    requestUri.append(CHAR_COLON + port);
+                }
+            }
+            if (resourcePath != null) {
+                requestUri.append(resourcePath);
+            }
+            if (!selectors.isEmpty()) {
+                requestUri.append(CHAR_DOT + String.join(CHAR_DOT, selectors));
+            }
+            if (!StringUtils.isBlank(extension)) {
+                requestUri.append(CHAR_DOT + extension);
+            }
+            if (!StringUtils.isBlank(suffix)) {
+                requestUri.append(suffix);
+            }
+            if (schemeSpecificPart != null) {
+                requestUri.append(schemeSpecificPart);
+            }
+            if (query != null) {
+                requestUri.append(CHAR_QM + query);
+            }
+            if (fragment != null) {
+                requestUri.append(CHAR_HASH + fragment);
+            }
+            return requestUri.toString();
+        }
+
+        @Override
+        public URI toUri() {
+            String uriString = toString();
+            try {
+                return new URI(uriString);
+            } catch (URISyntaxException e) {
+                throw new IllegalArgumentException("Invalid Sling URI: " + uriString, e);
+            }
+        }
+
+        private ResourceUriBuilder getBuilder() {
+            return ResourceUriBuilder.this;
+        }
+
+    }
+
+}
diff --git a/src/main/java/org/apache/sling/api/resource/uri/package-info.java b/src/main/java/org/apache/sling/api/resource/uri/package-info.java
new file mode 100644
index 0000000..dd21e49
--- /dev/null
+++ b/src/main/java/org/apache/sling/api/resource/uri/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+@Version("1.0.0")
+package org.apache.sling.api.resource.uri;
+
+import org.osgi.annotation.versioning.Version;
+
diff --git a/src/main/java/org/apache/sling/api/wrappers/ResourceResolverWrapper.java b/src/main/java/org/apache/sling/api/wrappers/ResourceResolverWrapper.java
index 67c2fbf..faad12d 100644
--- a/src/main/java/org/apache/sling/api/wrappers/ResourceResolverWrapper.java
+++ b/src/main/java/org/apache/sling/api/wrappers/ResourceResolverWrapper.java
@@ -18,7 +18,7 @@
 
 import java.util.Iterator;
 import java.util.Map;
-import org.jetbrains.annotations.NotNull;
+
 import javax.servlet.http.HttpServletRequest;
 
 import org.apache.sling.api.resource.LoginException;
@@ -26,6 +26,8 @@
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
 import org.apache.sling.api.resource.ResourceWrapper;
+import org.apache.sling.api.resource.uri.ResourceUri;
+import org.jetbrains.annotations.NotNull;
 import org.osgi.annotation.versioning.ConsumerType;
 
 /**
@@ -107,6 +109,11 @@
         return wrapped.map(request, resourcePath);
     }
 
+    /** Same as map(request, resourcePath) but returns a {@link ResourceUri} */
+    public ResourceUri mapToURI(@NotNull HttpServletRequest request, @NotNull String resourcePath) {
+        throw new UnsupportedOperationException("");
+    }
+
     /**
      * Wraps and returns the {@code Resource} obtained by calling {@code getResource} on the wrapped resource resolver.
      *
diff --git a/src/test/java/org/apache/sling/api/resource/uri/ResourceUriTest.java b/src/test/java/org/apache/sling/api/resource/uri/ResourceUriTest.java
new file mode 100644
index 0000000..672836d
--- /dev/null
+++ b/src/test/java/org/apache/sling/api/resource/uri/ResourceUriTest.java
@@ -0,0 +1,377 @@
+/*
+ * 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.api.resource.uri;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+
+import java.util.function.Consumer;
+
+import org.junit.Test;
+
+public class ResourceUriTest {
+
+    @Test
+    public void testFullResourceUri() {
+
+        String testUriStr = "http://host.com/test/to/path.html";
+        testUri(testUriStr, false, false, false, true, resourceUri -> {
+            assertEquals("http", resourceUri.getScheme());
+            assertEquals(null, resourceUri.getUserInfo());
+            assertEquals("host.com", resourceUri.getHost());
+            assertEquals(-1, resourceUri.getPort());
+            assertEquals("/test/to/path", resourceUri.getResourcePath());
+            assertEquals(null, resourceUri.getSelectorString());
+            assertEquals("html", resourceUri.getExtension());
+            assertEquals(null, resourceUri.getSuffix());
+            assertEquals(null, resourceUri.getSchemeSpecificPart());
+            assertEquals(null, resourceUri.getQuery());
+            assertEquals(null, resourceUri.getFragment());
+        });
+
+    }
+
+    @Test
+    public void testFullResourceUriComplex() {
+
+        String testUriStr = "https://test:pw@host.com:888/test/to/path.sel1.json/suffix/path?p1=2&p2=3#frag3939";
+        testUri(testUriStr, false, false, false, true, resourceUri -> {
+            assertEquals("https", resourceUri.getScheme());
+            assertEquals("test:pw", resourceUri.getUserInfo());
+            assertEquals("host.com", resourceUri.getHost());
+            assertEquals(888, resourceUri.getPort());
+            assertEquals("/test/to/path", resourceUri.getResourcePath());
+            assertEquals("sel1", resourceUri.getSelectorString());
+            assertEquals("json", resourceUri.getExtension());
+            assertEquals("/suffix/path", resourceUri.getSuffix());
+            assertEquals(null, resourceUri.getSchemeSpecificPart());
+            assertEquals("p1=2&p2=3", resourceUri.getQuery());
+            assertEquals("frag3939", resourceUri.getFragment());
+        });
+
+    }
+
+    @Test
+    public void testAbsolutePathResourceUri() {
+        String testUriStr = "/test/to/path.sel1.json/suffix/path?p1=2&p2=3#frag3939";
+
+        testUri(testUriStr, true, true, false, false, resourceUri -> {
+            assertEquals(null, resourceUri.getScheme());
+            assertEquals(null, resourceUri.getUserInfo());
+            assertEquals(null, resourceUri.getHost());
+            assertEquals(-1, resourceUri.getPort());
+            assertEquals("/test/to/path", resourceUri.getResourcePath());
+            assertEquals("sel1", resourceUri.getSelectorString());
+            assertEquals("json", resourceUri.getExtension());
+            assertEquals("/suffix/path", resourceUri.getSuffix());
+            assertEquals(null, resourceUri.getSchemeSpecificPart());
+            assertEquals("p1=2&p2=3", resourceUri.getQuery());
+            assertEquals("frag3939", resourceUri.getFragment());
+        });
+    }
+
+    @Test
+    public void testRelativePathResourceUri() {
+        String testUriStr = "../path.html#frag1";
+
+        testUri(testUriStr, true, false, true, false, resourceUri -> {
+            assertEquals(null, resourceUri.getScheme());
+            assertEquals(null, resourceUri.getUserInfo());
+            assertEquals(null, resourceUri.getHost());
+            assertEquals(-1, resourceUri.getPort());
+            assertEquals("../path", resourceUri.getResourcePath());
+            assertEquals(null, resourceUri.getSelectorString());
+            assertEquals("html", resourceUri.getExtension());
+            assertEquals(null, resourceUri.getSuffix());
+            assertEquals(null, resourceUri.getSchemeSpecificPart());
+            assertEquals(null, resourceUri.getQuery());
+            assertEquals("frag1", resourceUri.getFragment());
+        });
+    }
+
+    @Test
+    public void testJavascriptUri() {
+        String testUriStr = "javascript:void(0)";
+
+        testUri(testUriStr, false, false, false, false, resourceUri -> {
+            assertEquals("javascript", resourceUri.getScheme());
+            assertEquals(null, resourceUri.getUserInfo());
+            assertEquals(null, resourceUri.getHost());
+            assertEquals(-1, resourceUri.getPort());
+            assertEquals(null, resourceUri.getSelectorString());
+            assertEquals(null, resourceUri.getExtension());
+            assertEquals(null, resourceUri.getSuffix());
+            assertEquals("void(0)", resourceUri.getSchemeSpecificPart());
+            assertEquals(null, resourceUri.getQuery());
+            assertEquals(null, resourceUri.getFragment());
+        });
+    }
+
+    @Test
+    public void testMailtotUri() {
+        String testUriStr = "mailto:jon.doe@example.com";
+
+        testUri(testUriStr, false, false, false, false, resourceUri -> {
+            assertEquals("mailto", resourceUri.getScheme());
+            assertEquals(null, resourceUri.getUserInfo());
+            assertEquals(null, resourceUri.getHost());
+            assertEquals(-1, resourceUri.getPort());
+            assertEquals(null, resourceUri.getSelectorString());
+            assertEquals(null, resourceUri.getExtension());
+            assertEquals(null, resourceUri.getSuffix());
+            assertEquals("jon.doe@example.com", resourceUri.getSchemeSpecificPart());
+            assertEquals(null, resourceUri.getQuery());
+            assertEquals(null, resourceUri.getFragment());
+        });
+    }
+
+    @Test
+    public void testHashOnlyUri() {
+
+        testUri("#", false, false, false, false, resourceUri -> {
+            assertEquals(null, resourceUri.getScheme());
+            assertEquals(null, resourceUri.getUserInfo());
+            assertEquals(null, resourceUri.getHost());
+            assertEquals(-1, resourceUri.getPort());
+            assertEquals(null, resourceUri.getResourcePath());
+            assertEquals(null, resourceUri.getSelectorString());
+            assertEquals(null, resourceUri.getExtension());
+            assertEquals(null, resourceUri.getSuffix());
+            assertEquals(null, resourceUri.getSchemeSpecificPart());
+            assertEquals(null, resourceUri.getQuery());
+            assertEquals("", resourceUri.getFragment());
+        });
+
+        testUri("#fragment", false, false, false, false, resourceUri -> {
+            assertEquals(null, resourceUri.getScheme());
+            assertEquals(null, resourceUri.getUserInfo());
+            assertEquals(null, resourceUri.getHost());
+            assertEquals(-1, resourceUri.getPort());
+            assertEquals(null, resourceUri.getResourcePath());
+            assertEquals(null, resourceUri.getSelectorString());
+            assertEquals(null, resourceUri.getExtension());
+            assertEquals(null, resourceUri.getSuffix());
+            assertEquals(null, resourceUri.getSchemeSpecificPart());
+            assertEquals(null, resourceUri.getQuery());
+            assertEquals("fragment", resourceUri.getFragment());
+        });
+    }
+
+    @Test
+    public void testQueryOnlyUri() {
+
+        testUri("?", false, false, false, false, resourceUri -> {
+            assertEquals(null, resourceUri.getScheme());
+            assertEquals(null, resourceUri.getUserInfo());
+            assertEquals(null, resourceUri.getHost());
+            assertEquals(-1, resourceUri.getPort());
+            assertEquals(null, resourceUri.getResourcePath());
+            assertEquals(null, resourceUri.getSelectorString());
+            assertEquals(null, resourceUri.getExtension());
+            assertEquals(null, resourceUri.getSuffix());
+            assertEquals(null, resourceUri.getSchemeSpecificPart());
+            assertEquals("", resourceUri.getQuery());
+            assertEquals(null, resourceUri.getFragment());
+        });
+
+        testUri("?test=test", false, false, false, false, resourceUri -> {
+            assertEquals(null, resourceUri.getScheme());
+            assertEquals(null, resourceUri.getUserInfo());
+            assertEquals(null, resourceUri.getHost());
+            assertEquals(-1, resourceUri.getPort());
+            assertEquals(null, resourceUri.getResourcePath());
+            assertEquals(null, resourceUri.getSelectorString());
+            assertEquals(null, resourceUri.getExtension());
+            assertEquals(null, resourceUri.getSuffix());
+            assertEquals(null, resourceUri.getSchemeSpecificPart());
+            assertEquals("test=test", resourceUri.getQuery());
+            assertEquals(null, resourceUri.getFragment());
+        });
+    }
+
+    @Test
+    public void testUnusualQueryFragmentCombinations() {
+        testUri("?#", false, false, false, false, resourceUri -> {
+            assertEquals("", resourceUri.getQuery());
+            assertEquals("", resourceUri.getFragment());
+        });
+        testUri("?t=2#", false, false, false, false, resourceUri -> {
+            assertEquals("t=2", resourceUri.getQuery());
+            assertEquals("", resourceUri.getFragment());
+        });
+        testUri("?#t=3", false, false, false, false, resourceUri -> {
+            assertEquals("", resourceUri.getQuery());
+            assertEquals("t=3", resourceUri.getFragment());
+        });
+        testUri("", false, false, false, false, resourceUri -> {
+            assertEquals(null, resourceUri.getQuery());
+            assertEquals(null, resourceUri.getFragment());
+        });
+    }
+
+    // -- adjustment test cases
+
+    @Test
+    public void testAdjustAddSelectorFullUrl() {
+
+        testAdjustUri(
+                "http://host.com/test/to/path.html",
+                resourceUriBuilder -> {
+                    resourceUriBuilder.addSelector("test");
+                },
+                "http://host.com/test/to/path.test.html",
+                resourceUri -> {
+                    assertEquals("test", resourceUri.getSelectorString());
+                });
+    }
+
+    @Test
+    public void testAdjustAddSelectorAndSuffixPath() {
+
+        testAdjustUri(
+                "/test/to/path.html",
+                resourceUriBuilder -> {
+                    resourceUriBuilder.addSelector("test");
+                    resourceUriBuilder.setSuffix("/suffix/path/to/file");
+                },
+                "/test/to/path.test.html/suffix/path/to/file",
+                resourceUri -> {
+                    assertArrayEquals(new String[] { "test" }, resourceUri.getSelectors());
+                    assertEquals("/suffix/path/to/file", resourceUri.getSuffix());
+                });
+    }
+
+    @Test
+    public void testExtendSimplePathToFullUrl() {
+
+        testAdjustUri(
+                "/test/to/path.html",
+                resourceUriBuilder -> {
+                    resourceUriBuilder.setScheme("https");
+                    resourceUriBuilder.setHost("example.com");
+                    resourceUriBuilder.setSuffix("/suffix/path/to/file");
+                },
+                "https://example.com/test/to/path.html/suffix/path/to/file",
+                resourceUri -> {
+                    assertEquals("https", resourceUri.getScheme());
+                    assertEquals("example.com", resourceUri.getHost());
+                    assertEquals("/suffix/path/to/file", resourceUri.getSuffix());
+                });
+    }
+
+    @Test
+    public void testFullUrltoSimplePath() {
+
+        testAdjustUri(
+                "https://user:pw@example.com/test/to/path.html/suffix/path/to/file",
+                resourceUriBuilder -> {
+                    resourceUriBuilder.removeSchemeAndAuthority();
+                },
+                "/test/to/path.html/suffix/path/to/file",
+                resourceUri -> {
+                    assertEquals(null, resourceUri.getScheme());
+                    assertEquals(null, resourceUri.getUserInfo());
+                    assertEquals(null, resourceUri.getHost());
+                });
+    }
+
+    @Test
+    public void testAdjustPathInSpecialUriWithoutEffect() {
+
+        testAdjustUri(
+                "mailto:jon.doe@example.com",
+                resourceUriBuilder -> {
+                    resourceUriBuilder.setPath("/path/to/resource");
+                    resourceUriBuilder.setResourcePath("/path/to/resource");
+                    resourceUriBuilder.addSelector("test");
+                    resourceUriBuilder.setExtension("html");
+                    resourceUriBuilder.setSuffix("/suffix");
+                },
+                "mailto:jon.doe@example.com",
+                resourceUri -> {
+                    assertEquals(null, resourceUri.getResourcePath());
+                    assertEquals(null, resourceUri.getSelectorString());
+                    assertEquals(null, resourceUri.getExtension());
+                    assertEquals(null, resourceUri.getSuffix());
+                });
+    }
+
+    @Test
+    public void testAdjustSelectorsInFragmentOnlyUrlWithoutEffect() {
+
+        testAdjustUri(
+                "#fragment",
+                resourceUriBuilder -> {
+                    resourceUriBuilder.addSelector("test");
+                    resourceUriBuilder.setSuffix("/suffix");
+                },
+                "#fragment",
+                resourceUri -> {
+                    assertEquals(null, resourceUri.getSelectorString());
+                    assertEquals(null, resourceUri.getSuffix());
+                });
+    }
+
+    @Test
+    public void testAjustFtpUrl() {
+
+        testAdjustUri(
+                "sftp://user:pw@example.com:9090/some/path",
+                resourceUriBuilder -> {
+                    resourceUriBuilder.setPath("/some/other/path");
+                    resourceUriBuilder.setPort(9091);
+                },
+                "sftp://user:pw@example.com:9091/some/other/path",
+                resourceUri -> {
+                    assertEquals("/some/other/path", resourceUri.getResourcePath());
+                    assertEquals(null, resourceUri.getSelectorString());
+                    assertEquals(9091, resourceUri.getPort());
+                });
+    }
+
+    // -- helper methods
+
+    public void testUri(String testUri, boolean isPath, boolean isAbsolutePath, boolean isRelativePath, boolean isFullUri,
+            Consumer<ResourceUri> additionalAssertions) {
+        ResourceUri resourceUri = ResourceUriBuilder.parse(testUri).build();
+
+        assertEquals(testUri, resourceUri.toString());
+        assertEquals(testUri, resourceUri.toUri().toString());
+
+        assertEquals("isPath()", isPath, resourceUri.isPath());
+        assertEquals("isAbsolutePath()", isAbsolutePath, resourceUri.isAbsolutePath());
+        assertEquals("isRelativePath()", isRelativePath, resourceUri.isRelativePath());
+        assertEquals("isFullUri()", isFullUri, resourceUri.isFullUri());
+
+        additionalAssertions.accept(resourceUri);
+    }
+
+    public void testAdjustUri(String testUri, Consumer<ResourceUriBuilder> adjuster, String testUriAfterEdit,
+            Consumer<ResourceUri> additionalAssertions) {
+        ResourceUri resourceUri = ResourceUriBuilder.parse(testUri).build();
+
+        ResourceUri adjustedResourceUri = resourceUri.adjust(adjuster);
+
+        assertEquals(testUriAfterEdit, adjustedResourceUri.toString());
+        assertEquals(testUriAfterEdit, adjustedResourceUri.toUri().toString());
+
+        additionalAssertions.accept(adjustedResourceUri);
+    }
+
+}