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