001/*
002 * Copyright 2022-2026 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;
018
019import com.soklet.ResourcePathDeclaration.Component;
020import com.soklet.ResourcePathDeclaration.ComponentType;
021import org.jspecify.annotations.NonNull;
022import org.jspecify.annotations.Nullable;
023
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 #fromPath(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 final class ResourcePath {
052        @NonNull
053        final static ResourcePath OPTIONS_SPLAT_RESOURCE_PATH;
054
055        static {
056                OPTIONS_SPLAT_RESOURCE_PATH = new ResourcePath();
057        }
058
059        @NonNull
060        private final String path;
061        @NonNull
062        private final List<@NonNull String> components;
063
064        /**
065         * Vends an instance that represents a runtime representation of a resource path, for example {@code /users/123}.
066         * <p>
067         * This is in contrast to {@link ResourcePathDeclaration}, which represents compile-time path declarations
068         * that may include placeholders, e.g. {@code /users/{userId}}.
069         *
070         * @param path a runtime path (no placeholders) e.g. {@code /users/123}
071         */
072        @NonNull
073public static ResourcePath fromPath(@NonNull String path) {
074                requireNonNull(path);
075                return new ResourcePath(path);
076        }
077
078        // Special "options splat" path
079        private ResourcePath() {
080                this.path = "*";
081                this.components = List.of();
082        }
083
084        private ResourcePath(@NonNull String path) {
085                requireNonNull(path);
086                this.path = ResourcePathDeclaration.normalizePath(path);
087                this.components = unmodifiableList(extractComponents(this.path));
088        }
089
090        /**
091         * Does this resource path match the given resource path (taking placeholders/varargs into account, if present)?
092         * <p>
093         * For example, resource path {@code /users/123} would match the resource path declaration {@code /users/{userId}}.
094         *
095         * @param resourcePathDeclaration the compile-time declaration to match against
096         * @return {@code true} if this resource path matches, {@code false} otherwise
097         */
098        @NonNull
099        @SuppressWarnings("ReferenceEquality")
100        public Boolean matches(@NonNull ResourcePathDeclaration resourcePathDeclaration) {
101                requireNonNull(resourcePathDeclaration);
102
103                // OPTIONS * is represented by a singleton sentinel, not a normal path value.
104                if (this == OPTIONS_SPLAT_RESOURCE_PATH)
105                        return false;
106
107                List<Component> declarationComponents = resourcePathDeclaration.getComponents();
108
109                if (!declarationComponents.isEmpty() && declarationComponents.get(declarationComponents.size() - 1).getType() == ComponentType.VARARGS) {
110                        if (getComponents().size() < declarationComponents.size() - 1)
111                                return false;
112
113                        // Check prefix
114                        for (int i = 0; i < declarationComponents.size() - 1; i++) {
115                                Component comp = declarationComponents.get(i);
116                                String pathComp = getComponents().get(i);
117
118                                if (comp.getType() == ComponentType.LITERAL && !comp.getValue().equals(pathComp))
119                                        return false;
120                        }
121
122                        return true;
123                } else {
124                        if (getComponents().size() != declarationComponents.size())
125                                return false;
126
127                        for (int i = 0; i < declarationComponents.size(); i++) {
128                                Component comp = declarationComponents.get(i);
129                                String pathComp = getComponents().get(i);
130
131                                if (comp.getType() == ComponentType.LITERAL && !comp.getValue().equals(pathComp))
132                                        return false;
133                        }
134
135                        return true;
136                }
137        }
138
139        /**
140         * What is the mapping between this resource path's placeholder values to the given resource path declaration's placeholder names?
141         * <p>
142         * For example, placeholder extraction for resource path {@code /users/123} and resource path declaration {@code /users/{userId}}
143         * would result in a value equivalent to {@code Map.of("userId", "123")}.
144         * <p>
145         * Resource path placeholder values are automatically URL-decoded.  For example, placeholder extraction for resource path declaration {@code /users/{userId}}
146         * and resource path {@code /users/ab%20c} would result in a value equivalent to {@code Map.of("userId", "ab c")}.
147         * <p>
148         * For varargs placeholders, the extra path components are joined with '/'.
149         *
150         * @param resourcePathDeclaration compile-time resource path, used to provide placeholder names
151         * @return a mapping of placeholder names to values, or the empty map if there were no placeholders
152         * @throws IllegalArgumentException if the provided resource path declaration does not match this resource path, i.e. {@link #matches(ResourcePathDeclaration)} is {@code false}
153         */
154        @NonNull
155        public Map<@NonNull String, @NonNull String> extractPlaceholders(@NonNull ResourcePathDeclaration resourcePathDeclaration) {
156                requireNonNull(resourcePathDeclaration);
157
158                if (!matches(resourcePathDeclaration))
159                        throw new IllegalArgumentException(format("%s is not a match for %s so we cannot extract placeholders", this, resourcePathDeclaration));
160
161                Map<String, String> placeholders = new LinkedHashMap<>();
162                List<Component> declarationComponents = resourcePathDeclaration.getComponents();
163
164                if (!declarationComponents.isEmpty() && declarationComponents.get(declarationComponents.size() - 1).getType() == ComponentType.VARARGS) {
165                        for (int i = 0; i < declarationComponents.size() - 1; i++) {
166                                Component comp = declarationComponents.get(i);
167
168                                if (comp.getType() == ComponentType.PLACEHOLDER)
169                                        placeholders.put(comp.getValue(), getComponents().get(i));
170                        }
171
172                        // Join remaining components for varargs placeholder.
173                        String varargsValue = String.join("/", getComponents().subList(declarationComponents.size() - 1, getComponents().size()));
174                        placeholders.put(declarationComponents.get(declarationComponents.size() - 1).getValue(), varargsValue);
175                } else {
176                        for (int i = 0; i < declarationComponents.size(); i++) {
177                                Component comp = declarationComponents.get(i);
178
179                                if (comp.getType() == ComponentType.PLACEHOLDER)
180                                        placeholders.put(comp.getValue(), getComponents().get(i));
181                        }
182                }
183
184                return Collections.unmodifiableMap(placeholders);
185        }
186
187        /**
188         * What is the string representation of this resource path?
189         *
190         * @return the string representation of this resource path, which must start with {@code /}
191         */
192        @NonNull
193        public String getPath() {
194                return this.path;
195        }
196
197        /**
198         * What are the {@code /}-delimited components of this resource path?
199         *
200         * @return the components, or the empty list if this path is equal to {@code /}
201         */
202        @NonNull
203        public List<@NonNull String> getComponents() {
204                return this.components;
205        }
206
207        /**
208         * Assumes {@code path} is already normalized via {@link ResourcePathDeclaration#normalizePath(String)}.
209         *
210         * @param path (nonnull) Path from which components are extracted
211         * @return Logical components of the supplied {@code path}
212         */
213        @NonNull
214        List<@NonNull String> extractComponents(@NonNull String path) {
215                requireNonNull(path);
216
217                if ("/".equals(path))
218                        return emptyList();
219
220                // Strip off leading /
221                path = path.substring(1);
222                return Arrays.asList(path.split("/"));
223        }
224
225        @Override
226        public String toString() {
227                return format("%s{path=%s, components=%s}", getClass().getSimpleName(), getPath(), getComponents());
228        }
229
230        @Override
231        public boolean equals(@Nullable Object object) {
232                if (this == object)
233                        return true;
234
235                if (!(object instanceof ResourcePath resourcePath))
236                        return false;
237
238                return Objects.equals(getPath(), resourcePath.getPath());
239        }
240
241        @Override
242        public int hashCode() {
243                return Objects.hash(getPath());
244        }
245}