001/*
002 * Copyright 2022-2025 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet.core;
018
019import com.soklet.core.ResourcePathDeclaration.Component;
020import com.soklet.core.ResourcePathDeclaration.ComponentType;
021
022import javax.annotation.Nonnull;
023import javax.annotation.Nullable;
024import javax.annotation.concurrent.ThreadSafe;
025import java.util.Arrays;
026import java.util.Collections;
027import java.util.LinkedHashMap;
028import java.util.List;
029import java.util.Map;
030import java.util.Objects;
031
032import static java.lang.String.format;
033import static java.util.Collections.emptyList;
034import static java.util.Collections.unmodifiableList;
035import static java.util.Objects.requireNonNull;
036
037/**
038 * An HTTP URL path used to resolve a <em>Resource Method</em> at runtime, such as {@code /users/123}.
039 * <p>
040 * You may obtain instances via the {@link #of(String)} factory method.
041 * <p>
042 * <strong>Note: this type is not normally used by Soklet applications unless they support <a href="https://www.soklet.com/docs/server-sent-events">Server-Sent Events</a> or choose to implement a custom {@link ResourceMethodResolver}.</strong>
043 * <p>
044 * The corresponding compile-time type for {@link ResourcePath} is {@link ResourcePathDeclaration} and functionality is provided to check if the two "match" via {@link #matches(ResourcePathDeclaration)}.
045 * <p>
046 * For example, a {@link ResourcePath} {@code /users/123} would match {@link ResourcePathDeclaration} {@code /users/{userId}}.
047 *
048 * @author <a href="https://www.revetkn.com">Mark Allen</a>
049 */
050@ThreadSafe
051public class ResourcePath {
052        @Nonnull
053        private final String path;
054        @Nonnull
055        private final List<String> components;
056
057        /**
058         * Vends an instance that represents a runtime representation of a resource path, for example {@code /users/123}.
059         * <p>
060         * This is in contrast to {@link ResourcePathDeclaration}, which represents compile-time path declarations
061         * that may include placeholders, e.g. {@code /users/{userId}}.
062         *
063         * @param path a runtime path which may not include placeholders
064         */
065        @Nonnull
066        public static ResourcePath of(@Nonnull String path) {
067                requireNonNull(path);
068                return new ResourcePath(path);
069        }
070
071        protected ResourcePath(@Nonnull String path) {
072                requireNonNull(path);
073                this.path = ResourcePathDeclaration.normalizePath(path);
074                this.components = unmodifiableList(extractComponents(this.path));
075        }
076
077        /**
078         * Does this resource path match the given resource path (taking placeholders into account, if present)?
079         * <p>
080         * For example, resource path {@code /users/123} would match the resource path declaration {@code /users/{userId}}.
081         *
082         * @param resourcePathDeclaration the resource path against which to match
083         * @return {@code true} if the paths match, {@code false} otherwise
084         */
085        @Nonnull
086        public Boolean matches(@Nonnull ResourcePathDeclaration resourcePathDeclaration) {
087                requireNonNull(resourcePathDeclaration);
088
089                if (resourcePathDeclaration.getComponents().size() != getComponents().size())
090                        return false;
091
092                for (int i = 0; i < resourcePathDeclaration.getComponents().size(); ++i) {
093                        Component resourcePathDeclarationComponent = resourcePathDeclaration.getComponents().get(i);
094                        String resourcePathComponent = getComponents().get(i);
095
096                        if (resourcePathDeclarationComponent.getType() == ComponentType.PLACEHOLDER)
097                                continue;
098
099                        if (!resourcePathDeclarationComponent.getValue().equals(resourcePathComponent))
100                                return false;
101                }
102
103                return true;
104        }
105
106        /**
107         * What is the mapping between this resource path's placeholder values to the given resource path declaration's placeholder names?
108         * <p>
109         * For example, placeholder extraction for resource path {@code /users/123} and resource path declaration {@code /users/{userId}}
110         * would result in a value equivalent to {@code Map.of("userId", "123")}.
111         * <p>
112         * Resource path placeholder values are automatically URL-decoded.  For example, placeholder extraction for resource path declaration {@code /users/{userId}}
113         * and resource path {@code /users/ab%20c} would result in a value equivalent to {@code Map.of("userId", "ab c")}.
114         *
115         * @param resourcePathDeclaration compile-time resource path, used to provide placeholder names
116         * @return a mapping of placeholder names to values, or the empty map if there were no placeholders
117         * @throws IllegalArgumentException if the provided resource path declaration does not match this resource path, i.e. {@link #matches(ResourcePathDeclaration)} is {@code false}
118         */
119        @Nonnull
120        public Map<String, String> extractPlaceholders(@Nonnull ResourcePathDeclaration resourcePathDeclaration) {
121                requireNonNull(resourcePathDeclaration);
122
123                if (!matches(resourcePathDeclaration))
124                        throw new IllegalArgumentException(format("%s is not a match for %s so we cannot extract placeholders", this,
125                                        resourcePathDeclaration));
126
127                Map<String, String> placeholders = new LinkedHashMap<>(resourcePathDeclaration.getComponents().size());
128
129                for (int i = 0; i < resourcePathDeclaration.getComponents().size(); ++i) {
130                        Component resourcePathDeclarationComponent = resourcePathDeclaration.getComponents().get(i);
131                        String resourcePathComponent = getComponents().get(i);
132
133                        if (resourcePathDeclarationComponent.getType() == ComponentType.PLACEHOLDER)
134                                placeholders.put(resourcePathDeclarationComponent.getValue(), resourcePathComponent);
135                }
136
137                return Collections.unmodifiableMap(placeholders);
138        }
139
140        /**
141         * What is the string representation of this resource path?
142         *
143         * @return the string representation of this resource path, which must start with {@code /}
144         */
145        @Nonnull
146        public String getPath() {
147                return this.path;
148        }
149
150        /**
151         * What are the {@code /}-delimited components of this resource path?
152         *
153         * @return the components, or the empty list if this path is equal to {@code /}
154         */
155        @Nonnull
156        public List<String> getComponents() {
157                return this.components;
158        }
159
160        /**
161         * Assumes {@code path} is already normalized via {@link ResourcePathDeclaration#normalizePath(String)}.
162         *
163         * @param path (nonnull) Path from which components are extracted
164         * @return Logical components of the supplied {@code path}
165         */
166        @Nonnull
167        protected List<String> extractComponents(@Nonnull String path) {
168                requireNonNull(path);
169
170                if ("/".equals(path))
171                        return emptyList();
172
173                // Strip off leading /
174                path = path.substring(1);
175
176                return Arrays.asList(path.split("/"));
177        }
178
179        @Override
180        public String toString() {
181                return format("%s{path=%s, components=%s}", getClass().getSimpleName(), getPath(), getComponents());
182        }
183
184        @Override
185        public boolean equals(@Nullable Object object) {
186                if (this == object)
187                        return true;
188
189                if (!(object instanceof ResourcePath resourcePath))
190                        return false;
191
192                return Objects.equals(getPath(), resourcePath.getPath());
193        }
194
195        @Override
196        public int hashCode() {
197                return Objects.hash(getPath());
198        }
199}