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}