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