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 org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.Immutable;
023import javax.annotation.concurrent.ThreadSafe;
024import java.util.Collections;
025import java.util.LinkedHashMap;
026import java.util.LinkedHashSet;
027import java.util.List;
028import java.util.Map;
029import java.util.Objects;
030import java.util.Optional;
031import java.util.Set;
032import java.util.regex.Pattern;
033import java.util.stream.Collectors;
034
035import static com.soklet.Utilities.trimAggressivelyToEmpty;
036import static java.lang.String.format;
037import static java.util.Arrays.asList;
038import static java.util.Collections.emptyList;
039import static java.util.Collections.unmodifiableList;
040import static java.util.Objects.requireNonNull;
041import static java.util.stream.Collectors.toList;
042
043/**
044 * A compile-time HTTP URL path declaration associated with an annotated <em>Resource Method</em>, such as {@code /users/{userId}}.
045 * <p>
046 * You may obtain instances via the {@link #fromPath(String)} factory method.
047 * <p>
048 * <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>
049 * <p>
050 * {@link ResourcePathDeclaration} instances must start with the {@code /} character and may contain placeholders denoted by single-mustache syntax.
051 * For example, the {@link ResourcePathDeclaration} {@code /users/{userId}} has a placeholder named {@code userId}.
052 * <p>
053 * A {@link ResourcePathDeclaration} is intended for compile-time <em>Resource Method</em> HTTP URL path declarations.
054 * The corresponding runtime type is {@link ResourcePath} and functionality is provided to check if the two "match" via {@link #matches(ResourcePath)}.
055 * <p>
056 * For example, a {@link ResourcePathDeclaration} {@code /users/{userId}} would match {@link ResourcePath} {@code /users/123}.
057 * <p>
058 * <strong>Please note the following restrictions on {@link ResourcePathDeclaration} structure:</strong>
059 * <p>
060 * 1. It is not legal to use the same placeholder name more than once in a {@link ResourcePathDeclaration}.
061 * <p>
062 * For example:
063 * <ul>
064 *  <li>{@code /users/{userId}} is valid resource path</li>
065 *  <li>{@code /users/{userId}/roles/{roleId}} is valid resource path</li>
066 *  <li>{@code /users/{userId}/other/{userId}} is an <em>invalid</em> resource path</li>
067 * </ul>
068 * 2. Placeholders must span the entire {@code /}-delimited path component in which they reside.
069 * <p>
070 * For example:
071 * <ul>
072 *   <li>{@code /users/{userId}} is a valid resource path</li>
073 *   <li>{@code /users/{userId}/details} is a valid resource path</li>
074 *   <li>{@code /users/prefix{userId}} is an <em>invalid</em> resource path</li>
075 * </ul>
076 * <p>
077 * In addition to simple placeholders, this version supports a special "varargs" placeholder indicated by a trailing {@code *}
078 * in the placeholder name. For example, {@code /static/{filePath*}}. When present, the varargs placeholder must appear only once
079 * and as the last component in the path.
080 *
081 * @author <a href="https://www.revetkn.com">Mark Allen</a>
082 */
083@ThreadSafe
084public final class ResourcePathDeclaration {
085        /**
086         * Pattern which matches a placeholder in a path component.
087         * <p>
088         * Placeholders are bracked-enclosed segments of text, for example {@code &#123;languageId&#125;}
089         * <p>
090         * A path component is either literal text or a placeholder. There is no concept of multiple placeholders in a
091         * component.
092         */
093        @NonNull
094        private static final Pattern COMPONENT_PLACEHOLDER_PATTERN;
095
096        static {
097                COMPONENT_PLACEHOLDER_PATTERN = Pattern.compile("^\\{.+\\}$");
098        }
099
100        @NonNull
101        private final String path;
102        @NonNull
103        private final List<@NonNull Component> components;
104
105        /**
106         * Vends an instance that represents a compile-time path declaration, for example {@code /users/{userId}}.
107         *
108         * @param path a compile-time path declaration that may include placeholders
109         */
110        @NonNull
111public static ResourcePathDeclaration fromPath(@NonNull String path) {
112                requireNonNull(path);
113                return new ResourcePathDeclaration(path);
114        }
115
116        private ResourcePathDeclaration(@NonNull String path) {
117                requireNonNull(path);
118                this.path = normalizePath(path);
119
120                List<Component> components = extractComponents(this.path);
121
122                // Validate varargs: if any component is VARARGS then it must be the last one and only occur once.
123                int varargsCount = 0;
124
125                for (int i = 0; i < components.size(); i++) {
126                        if (components.get(i).getType() == ComponentType.VARARGS) {
127                                varargsCount++;
128
129                                if (i != components.size() - 1)
130                                        throw new IllegalArgumentException(format("Varargs placeholder must be the last component in the path declaration: %s", path));
131                        }
132                }
133
134                Set<String> pathParameterNames = new LinkedHashSet<String>();
135
136                for (var component : components)
137                        if (component.getType() == ComponentType.PLACEHOLDER && !pathParameterNames.add(component.getValue()))
138                                throw new IllegalArgumentException(
139                                                String.format("Duplicate placeholder name '%s' in resource path declaration: %s", component.getValue(), path));
140
141                if (varargsCount > 1)
142                        throw new IllegalArgumentException(format("Only one varargs placeholder is allowed in the path declaration: %s", path));
143
144                this.components = unmodifiableList(components);
145        }
146
147        /**
148         * Gets the {@link ComponentType#VARARGS} component in this declaration, if any.
149         *
150         * @return the {@link ComponentType#VARARGS} component in this declaration, or {@link Optional#empty()} if none exists.
151         */
152        @NonNull
153        public Optional<Component> getVarargsComponent() {
154                if (getComponents().size() == 0)
155                        return Optional.empty();
156
157                Component lastComponent = getComponents().get(getComponents().size() - 1);
158
159                if (lastComponent.getType() == ComponentType.VARARGS)
160                        return Optional.of(lastComponent);
161
162                return Optional.empty();
163        }
164
165        /**
166         * Does this resource path declaration match the given resource path (taking placeholders/varargs into account, if present)?
167         * <p>
168         * For example, resource path declaration {@code /users/{userId}} would match {@code /users/123}.
169         *
170         * @param resourcePath the resource path against which to match
171         * @return {@code true} if the paths match, {@code false} otherwise
172         */
173        @NonNull
174        @SuppressWarnings("ReferenceEquality")
175        public Boolean matches(@NonNull ResourcePath resourcePath) {
176                requireNonNull(resourcePath);
177
178                // OPTIONS * is represented by a singleton sentinel, not a normal path value.
179                if (resourcePath == ResourcePath.OPTIONS_SPLAT_RESOURCE_PATH)
180                        return false;
181
182                List<Component> declarationComponents = getComponents();
183                List<String> pathComponents = resourcePath.getComponents();
184
185                // If the last declaration component is a varargs placeholder, allow extra path components.
186                if (!declarationComponents.isEmpty() && declarationComponents.get(declarationComponents.size() - 1).getType() == ComponentType.VARARGS) {
187                        if (pathComponents.size() < declarationComponents.size() - 1)
188                                return false;
189
190                        // Check the prefix components
191                        for (int i = 0; i < declarationComponents.size() - 1; i++) {
192                                Component comp = declarationComponents.get(i);
193                                String pathComp = pathComponents.get(i);
194                                if (comp.getType() == ComponentType.LITERAL && !comp.getValue().equals(pathComp))
195                                        return false;
196                        }
197
198                        return true;
199                } else {
200                        if (pathComponents.size() != declarationComponents.size())
201                                return false;
202
203                        for (int i = 0; i < declarationComponents.size(); i++) {
204                                Component comp = declarationComponents.get(i);
205                                String pathComp = pathComponents.get(i);
206
207                                if (comp.getType() == ComponentType.LITERAL && !comp.getValue().equals(pathComp))
208                                        return false;
209                        }
210
211                        return true;
212                }
213        }
214
215        /**
216         * What is the mapping between this resource path declaration's placeholder names to the given resource path's placeholder values?
217         * <p>
218         * For example, placeholder extraction for resource path declaration {@code /users/{userId}} and resource path {@code /users/123}
219         * would result in a value equivalent to {@code Map.of("userId", "123")}.
220         * <p>
221         * Resource path declaration placeholder values are automatically URL-decoded.  For example, placeholder extraction for resource path declaration {@code /users/{userId}}
222         * and resource path {@code /users/ab%20c} would result in a value equivalent to {@code Map.of("userId", "ab c")}.
223         * <p>
224         * Varargs placeholders will combine all remaining path components (joined with @{code /}).
225         *
226         * @param resourcePath runtime version of this resource path declaration, used to provide placeholder values
227         * @return a mapping of placeholder names to values, or the empty map if there were no placeholders
228         * @throws IllegalArgumentException if the provided resource path does not match this resource path declaration, i.e. {@link #matches(ResourcePath)} is {@code false}
229         */
230        @NonNull
231        public Map<@NonNull String, @NonNull String> extractPlaceholders(@NonNull ResourcePath resourcePath) {
232                requireNonNull(resourcePath);
233
234                if (!matches(resourcePath))
235                        throw new IllegalArgumentException(format("%s is not a match for %s so we cannot extract placeholders", this, resourcePath));
236
237                Map<String, String> placeholders = new LinkedHashMap<>();
238                List<Component> declarationComponents = getComponents();
239                List<String> pathComponents = resourcePath.getComponents();
240
241                // If varargs is present as the last component, process accordingly.
242                if (!declarationComponents.isEmpty() && declarationComponents.get(declarationComponents.size() - 1).getType() == ComponentType.VARARGS) {
243                        // Process all but the last component normally.
244                        for (int i = 0; i < declarationComponents.size() - 1; i++) {
245                                Component comp = declarationComponents.get(i);
246
247                                if (comp.getType() == ComponentType.PLACEHOLDER)
248                                        placeholders.put(comp.getValue(), pathComponents.get(i));
249                        }
250
251                        // For varargs, join all remaining path components.
252                        String varargsValue = pathComponents.subList(declarationComponents.size() - 1, pathComponents.size())
253                                        .stream().collect(Collectors.joining("/"));
254                        placeholders.put(declarationComponents.get(declarationComponents.size() - 1).getValue(), varargsValue);
255                } else {
256                        // Normal processing: one-to-one mapping.
257                        for (int i = 0; i < declarationComponents.size(); i++) {
258                                Component comp = declarationComponents.get(i);
259
260                                if (comp.getType() == ComponentType.PLACEHOLDER)
261                                        placeholders.put(comp.getValue(), pathComponents.get(i));
262                        }
263                }
264
265                return Collections.unmodifiableMap(placeholders);
266        }
267
268        /**
269         * What is the string representation of this resource path declaration?
270         *
271         * @return the string representation of this resource path declaration, which must start with {@code /}
272         */
273        @NonNull
274        public String getPath() {
275                return this.path;
276        }
277
278        /**
279         * What are the {@code /}-delimited components of this resource path declaration?
280         *
281         * @return the components, or the empty list if this path is equal to {@code /}
282         */
283        @NonNull
284        public List<@NonNull Component> getComponents() {
285                return this.components;
286        }
287
288        /**
289         * Is this resource path declaration comprised of all "literal" components (that is, no placeholders)?
290         *
291         * @return {@code true} if this resource path declaration is entirely literal, {@code false} otherwise
292         */
293        @NonNull
294        public Boolean isLiteral() {
295                for (Component component : components)
296                        if (component.getType() != ComponentType.LITERAL)
297                                return false;
298
299                return true;
300        }
301
302        @NonNull
303        static String normalizePath(@NonNull String path) {
304                requireNonNull(path);
305
306                path = trimAggressivelyToEmpty(path);
307
308                if (path.length() == 0)
309                        return "/";
310
311                // Remove any duplicate slashes, e.g. //test///something -> /test/something
312                path = path.replaceAll("(/)\\1+", "$1");
313
314                if (!path.startsWith("/"))
315                        path = format("/%s", path);
316
317                if ("/".equals(path))
318                        return path;
319
320                if (path.endsWith("/"))
321                        path = path.substring(0, path.length() - 1);
322
323                return path;
324        }
325
326        /**
327         * Assumes {@code path} is already normalized via {@link #normalizePath(String)}.
328         * <p>
329         * If a component is a placeholder, determines whether it is a varargs placeholder (trailing {@code *}).
330         *
331         * @param path path from which components are extracted
332         * @return logical components of the supplied {@code path}
333         */
334        @NonNull
335        List<@NonNull Component> extractComponents(@NonNull String path) {
336                requireNonNull(path);
337
338                if ("/".equals(path))
339                        return emptyList();
340
341                // Strip off leading /
342                path = path.substring(1);
343
344                List<String> parts = asList(path.split("/"));
345
346                return parts.stream().map(part -> {
347                        if (COMPONENT_PLACEHOLDER_PATTERN.matcher(part).matches()) {
348                                // Remove the enclosing '{' and '}'
349                                String inner = part.substring(1, part.length() - 1);
350                                ComponentType type;
351
352                                if (inner.endsWith("*")) {
353                                        type = ComponentType.VARARGS;
354                                        inner = inner.substring(0, inner.length() - 1);
355                                } else {
356                                        type = ComponentType.PLACEHOLDER;
357                                }
358
359                                return Component.with(inner, type);
360                        } else {
361                                return Component.with(part, ComponentType.LITERAL);
362                        }
363                }).collect(toList());
364        }
365
366        @Override
367        public String toString() {
368                return format("%s{path=%s, components=%s}", getClass().getSimpleName(), getPath(), getComponents());
369        }
370
371        @Override
372        public boolean equals(@Nullable Object object) {
373                if (this == object)
374                        return true;
375                if (!(object instanceof ResourcePathDeclaration resourcePathDeclaration))
376                        return false;
377                return Objects.equals(getPath(), resourcePathDeclaration.getPath())
378                                && Objects.equals(getComponents(), resourcePathDeclaration.getComponents());
379        }
380
381        @Override
382        public int hashCode() {
383                return Objects.hash(getPath(), getComponents());
384        }
385
386        /**
387         * How to interpret a {@link Component} of a {@link ResourcePathDeclaration} - is it literal text or a placeholder?
388         * <p>
389         * For example, given the path declaration <code>/languages/&#123;languageId&#125;</code>:
390         * <ul>
391         * <li>{@code ComponentType} at index 0 would be {@code LITERAL}
392         * <li>{@code ComponentType} at index 1 would be {@code PLACEHOLDER}
393         * </ul>
394         * <p>
395         * <strong>Note: this type is not normally used by Soklet applications unless they choose to implement a custom {@link ResourceMethodResolver}.</strong>
396         *
397         * @author <a href="https://www.revetkn.com">Mark Allen</a>
398         * @see ResourcePathDeclaration
399         */
400        public enum ComponentType {
401                /**
402                 * A literal component of a resource path declaration.
403                 * <p>
404                 * For example, given resource path declaration {@code /users/{userId}}, the {@code users} component would be of type {@code LITERAL}.
405                 */
406                LITERAL,
407                /**
408                 * A placeholder component (that is, one whose value is provided at runtime) of a resource path declaration.
409                 * <p>
410                 * For example, given resource path declaration {@code /users/{userId}}, the {@code userId} component would be of type {@code PLACEHOLDER}.
411                 */
412                PLACEHOLDER,
413                /**
414                 * A "varargs" placeholder component that may match multiple path segments.
415                 * <p>
416                 * For example, given resource path declaration {@code /static/{filepath*}}, the {@code filepath*} component would be of type {@code VARARGS}.
417                 */
418                VARARGS
419        }
420
421        /**
422         * Represents a {@code /}-delimited part of a {@link ResourcePathDeclaration}.
423         * <p>
424         * For example, given the path declaration <code>/languages/&#123;languageId&#125;</code>:
425         * <ul>
426         *   <li>{@code Component} 0 would have type {@code LITERAL} and value {@code languages}
427         *   <li>{@code Component} 1 would have type {@code PLACEHOLDER} and value {@code languageId}
428         * </ul>
429         * <p>
430         * You may obtain instances via the {@link #with(String, ComponentType)} factory method.
431         * <p>
432         * <strong>Note: this type is not normally used by Soklet applications unless they choose to implement a custom {@link ResourceMethodResolver}.</strong>
433         *
434         * @author <a href="https://www.revetkn.com">Mark Allen</a>
435         * @see ResourcePathDeclaration
436         */
437        @Immutable
438        public static final class Component {
439                @NonNull
440                private final String value;
441                @NonNull
442                private final ComponentType type;
443
444                /**
445                 * Acquires a {@link Component} instance given a {@code value} and {@code type}.
446                 *
447                 * @param value the value of this component
448                 * @param type  the type of this component (literal or placeholder)
449                 * @return a {@link Component} instance
450                 */
451                @NonNull
452                public static Component with(@NonNull String value,
453                                                                                                                                 @NonNull ComponentType type) {
454                        requireNonNull(value);
455                        requireNonNull(type);
456
457                        return new Component(value, type);
458                }
459
460                private Component(@NonNull String value,
461                                                                                        @NonNull ComponentType type) {
462                        requireNonNull(value);
463                        requireNonNull(type);
464
465                        this.value = value;
466                        this.type = type;
467                }
468
469                @Override
470                public String toString() {
471                        return format("%s{value=%s, type=%s}", getClass().getSimpleName(), getValue(), getType());
472                }
473
474                @Override
475                public boolean equals(@Nullable Object object) {
476                        if (this == object)
477                                return true;
478
479                        if (!(object instanceof Component component))
480                                return false;
481
482                        return Objects.equals(getValue(), component.getValue())
483                                        && Objects.equals(getType(), component.getType());
484                }
485
486                @Override
487                public int hashCode() {
488                        return Objects.hash(getValue(), getType());
489                }
490
491                /**
492                 * What is the value of this resource path declaration component?
493                 * <p>
494                 * Note that the value of a {@link ComponentType#PLACEHOLDER} component does not include enclosing braces.
495                 * For example, given the path declaration <code>/languages/&#123;languageId&#125;</code>,
496                 * the component at index 1 would have value {@code languageId}, not {@code {languageId}}.
497                 *
498                 * @return the value of this component
499                 */
500                @NonNull
501                public String getValue() {
502                        return value;
503                }
504
505                /**
506                 * What type of resource path declaration component is this?
507                 *
508                 * @return the type of component, e.g. {@link ComponentType#LITERAL} or {@link ComponentType#PLACEHOLDER}
509                 */
510                @NonNull
511                public ComponentType getType() {
512                        return type;
513                }
514        }
515}