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 {languageId}} 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/{languageId}</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/{languageId}</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/{languageId}</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}