1 /** 2 * The module containing the main matching logic and associated symbols. 3 */ 4 module path_matcher.match; 5 6 import path_matcher.url_util; 7 8 @safe: 9 10 /// The maximum number of path segments that this library supports. 11 immutable size_t MAX_PATH_SEGMENTS = 64; 12 13 /** 14 * The list of possible integral types that a path parameter can be annotated as. 15 * Note that there may be other complex types supported in addition to these; 16 * integral types are just listed here so we can generate validation with CTFE. 17 */ 18 immutable string[] PATH_PARAMETER_INTEGRAL_TYPES = [ 19 "byte", "ubyte", 20 "short", "ushort", 21 "int", "uint", 22 "long", "ulong", 23 "float", "double", 24 "bool" 25 ]; 26 27 /** 28 * Represents a path parameter that was parsed from a URL when matching it 29 * against a pattern. 30 */ 31 immutable struct PathParam { 32 /// The name of the path parameter. 33 string name; 34 /// The raw value of the path parameter. 35 string value; 36 37 /** 38 * Gets the value of this path parameter, converted to the specified 39 * template type. Note that this may throw a `std.conv.ConvException` if 40 * you have not specified a type for validation when parsing. 41 * Returns: The value, converted to the specified template type. 42 */ 43 T getAs(T)() const { 44 import std.conv : to; 45 return to!(T)(this.value); 46 } 47 } 48 49 /** 50 * Contains the result of attempting to match a URL to a path pattern. This 51 * includes whether there is a match at all, and if so, a set of path 52 * parameters that were parsed from the URL, according to the path pattern. 53 */ 54 immutable struct PathMatchResult { 55 /// Whether the URL matches the path pattern. 56 bool matches; 57 /// The list of all path parameters that were parsed from the URL. 58 PathParam[] pathParams; 59 60 /** 61 * Converts the result to a boolean value. This is synonymous to `matches`. 62 * Returns: True, if the pattern matches the pattern. 63 */ 64 T opCast(T : bool)() const { 65 return matches; 66 } 67 68 /// 69 unittest { 70 if (auto match = matchPath("/path", "/:name")) { 71 assert(match.getPathParam("name") == "path"); 72 } 73 else { 74 assert(false); 75 } 76 77 if (auto match = matchPath("/path", "/no-match")) { 78 assert(false); 79 } 80 } 81 82 /** 83 * Gets the path parameters as a string-to-string mapping. 84 * Returns: An associative array containing the path parameters. 85 */ 86 immutable(string[string]) pathParamsAsMap() const @trusted { 87 string[string] map; 88 foreach (param; this.pathParams) { 89 map[param.name] = param.value; 90 } 91 return cast(immutable(string[string])) map; 92 } 93 94 /** 95 * Gets the string value of a specified path parameter. 96 * Params: 97 * name = The name of the path parameter. 98 * Returns: The value of the path parameter, or null if no such parameter exists. 99 */ 100 string getPathParam(string name) const { 101 foreach (param; this.pathParams) { 102 if (param.name == name) return param.value; 103 } 104 return null; 105 } 106 107 /** 108 * Gets a specified path parameter's value converted to the specified type. 109 * Params: 110 * name = The name of the path parameter to get. 111 * defaultValue = The default value to use if no such path parameter exists. 112 * Returns: The value for the path parameter. 113 */ 114 T getPathParamAs(T)(string name, T defaultValue = T.init) const { 115 foreach (param; this.pathParams) { 116 if (param.name == name) { 117 return param.getAs!(T); 118 } 119 } 120 return defaultValue; 121 } 122 } 123 124 /** 125 * An exception that may be thrown when parsing a path pattern string. This 126 * exception will only be thrown if the programmer has made an error in defining 127 * their path pattern; NOT if a user-provided URL is incorrect. 128 */ 129 class PathPatternParseException : Exception { 130 this(string msg) { 131 super(msg); 132 } 133 } 134 135 /** 136 * Attempts to match a given URL with a given pattern string, and parse any 137 * path parameters defined by the pattern. 138 * Params: 139 * url = The URL to match. 140 * pattern = The pattern to match against. 141 * Returns: A result that tells whether there was a match, and contains any 142 * parsed path parameters if a match exists. 143 */ 144 PathMatchResult matchPath(string url, string pattern) { 145 import std.array; 146 147 // First initialize buffers for the URL and pattern segments on the stack. 148 string[MAX_PATH_SEGMENTS] urlSegmentsBuffer; 149 int urlSegmentsCount = toSegments(url, urlSegmentsBuffer); 150 if (urlSegmentsCount == -1) throw new PathPatternParseException("Too many URL segments."); 151 scope string[] urlSegments = urlSegmentsBuffer[0 .. urlSegmentsCount]; 152 uint urlSegmentIdx = 0; 153 154 string[MAX_PATH_SEGMENTS] patternSegmentsBuffer; 155 int patternSegmentsCount = toSegments(pattern, patternSegmentsBuffer); 156 if (patternSegmentsCount == -1) throw new PathPatternParseException("Too many pattern segments."); 157 scope string[] patternSegments = patternSegmentsBuffer[0 .. patternSegmentsCount]; 158 uint patternSegmentIdx = 0; 159 160 Appender!(PathParam[]) pathParamAppender = appender!(PathParam[])(); 161 162 // Now pop segments from each stack until we've consumed the whole URL and pattern. 163 string urlSegment = popSegment(urlSegments, urlSegmentIdx); 164 string patternSegment = popSegment(patternSegments, patternSegmentIdx); 165 166 // Do some initial checks for special conditions: 167 // If the first segment in the pattern is a multi-match wildcard, anything is a match. 168 if (patternSegment !is null && patternSegment == "**") return PathMatchResult(true, []); 169 170 bool doingMultiMatch = false; 171 while (urlSegment !is null && patternSegment !is null) { 172 if (patternSegment == "*") { 173 // This matches any single URL segment. Skip to the next one. 174 urlSegment = popSegment(urlSegments, urlSegmentIdx); 175 patternSegment = popSegment(patternSegments, patternSegmentIdx); 176 doingMultiMatch = false; 177 } else if (patternSegment[0] == ':' && pathParamMatches(patternSegment, urlSegment)) { 178 // This matches a path parameter. 179 string name = extractPathParamName(patternSegment); 180 string value = urlSegment; 181 pathParamAppender ~= PathParam(name, value); 182 urlSegment = popSegment(urlSegments, urlSegmentIdx); 183 patternSegment = popSegment(patternSegments, patternSegmentIdx); 184 doingMultiMatch = false; 185 } else if (patternSegment == "**") { 186 // This matches zero or more URL segments. 187 // If this is the last pattern segment, it's a match. 188 if (patternSegmentIdx == patternSegments.length) { 189 return PathMatchResult(true, pathParamAppender[]); 190 } 191 // Otherwise, keep absorbing URL segments until we find one matching the next pattern segment. 192 doingMultiMatch = true; 193 patternSegment = popSegment(patternSegments, patternSegmentIdx); 194 } else if (patternSegment == urlSegment) { 195 // Literal segment match. Consume both and continue; 196 urlSegment = popSegment(urlSegments, urlSegmentIdx); 197 patternSegment = popSegment(patternSegments, patternSegmentIdx); 198 doingMultiMatch = false; 199 } else if (doingMultiMatch) { 200 urlSegment = popSegment(urlSegments, urlSegmentIdx); 201 } else { 202 return PathMatchResult(false, PathParam[].init); 203 } 204 } 205 // If not all segments were consumed, there's some extra logic to check. 206 if ((patternSegment !is null && patternSegment != "**") || urlSegment !is null) { 207 return PathMatchResult(false, PathParam[].init); 208 } 209 210 return PathMatchResult(true, pathParamAppender[]); 211 } 212 213 unittest { 214 void assertMatch( 215 string pattern, 216 string url, 217 bool matches, 218 immutable string[string] pathParams = string[string].init 219 ) { 220 import std.format : format; 221 import std.stdio; 222 PathMatchResult result = matchPath(url, pattern); 223 writefln!"Asserting that matching URL %s against pattern %s results in %s and path params %s."( 224 url, pattern, matches, pathParams 225 ); 226 assert( 227 result.matches == matches, 228 format!"PathMatchResult.matches is not correct for\nURL:\t\t%s\nPattern:\t%s\nExpected %s instead of %s."( 229 url, 230 pattern, 231 matches, 232 result.matches 233 ) 234 ); 235 if (result.matches) { 236 assert( 237 result.pathParamsAsMap == pathParams, 238 format!( 239 "PathMatchResult.pathParams is not correct for\nURL:\t\t%s\nPattern:\t%s\n" ~ 240 "Expected %s instead of %s." 241 )( 242 url, 243 pattern, 244 pathParams, 245 result.pathParamsAsMap 246 ) 247 ); 248 } 249 writeln("\tCheck!"); 250 } 251 252 assertMatch("/**", "", true); 253 assertMatch("/**", "/", true); 254 assertMatch("/**", "/a/b/c/d", true); 255 assertMatch("/users", "/users", true); 256 assertMatch("/users/*", "/users/andrew", true); 257 assertMatch("/users/**", "/users", true); 258 assertMatch("/users/data/**", "/users/andrew", false); 259 assertMatch("/users/data/**", "/users/data", true); 260 assertMatch("/users/data/**", "/users/data/andrew", true); 261 assertMatch("/users/:username", "/users/andrew", true, ["username": "andrew"]); 262 assertMatch("/users", "/user", false); 263 assertMatch("/users", "/data", false); 264 assertMatch("/users/:username/data", "/users/andrew/data", true, ["username": "andrew"]); 265 assertMatch("/users/:username/data", "/users/andrew", false); 266 assertMatch( 267 "/users/:username/data/:dname", "/users/andrew/data/date-of-birth", 268 true, ["username": "andrew", "dname": "date-of-birth"] 269 ); 270 assertMatch("/users/all", "/users/andrew/data", false); 271 assertMatch("/users/**/settings", "/users/andrew/data/a/settings", true); 272 assertMatch("/users/**/settings", "/users/settings", true); 273 assertMatch("/users/**/:username", "/users/andrew", true, ["username": "andrew"]); 274 assertMatch("/users/:id:ulong", "/users/123", true, ["id": "123"]); 275 assertMatch("/users", "/users/123", false); 276 } 277 278 /** 279 * Checks if a path parameter pattern segment (something like ":value" or 280 * ":id:int") matches a given URL segment. If a type is provided like in the 281 * second example, then it'll ensure that URL segment contains a value that 282 * can be converted to the specified type. 283 * Params: 284 * patternSegment = The pattern segment containing the path parameter pattern. 285 * urlSegment = The URL segment containing the value for the path parameter. 286 * Returns: True if the URL segment contains a valid value for the path 287 * parameter, or false otherwise. 288 */ 289 private bool pathParamMatches(in string patternSegment, in string urlSegment) { 290 if ( 291 patternSegment is null || patternSegment.length < 2 || patternSegment[0] != ':' || 292 urlSegment is null || urlSegment.length < 1 293 ) { 294 return false; 295 } 296 int typeSeparatorIdx = -1; 297 for (int i = 1; i < patternSegment.length; i++) { 298 if (patternSegment[i] == ':') { 299 typeSeparatorIdx = i; 300 break; 301 } 302 } 303 if (typeSeparatorIdx != -1) { 304 import std.conv : to, ConvException; 305 import std.uuid : parseUUID, UUIDParsingException; 306 import std.uni : toLower; 307 if (patternSegment.length < typeSeparatorIdx + 2) return false; // The type name is too short. 308 string typeName = toLower(patternSegment[typeSeparatorIdx + 1 .. $]); 309 try { 310 static foreach (string integralType; PATH_PARAMETER_INTEGRAL_TYPES) { 311 if (typeName == integralType) { 312 mixin("to!" ~ integralType ~ "(urlSegment);"); 313 return true; 314 } 315 } 316 // Any other supported types. 317 if (typeName == "uuid") { 318 parseUUID(urlSegment); 319 return true; 320 } 321 // None of the allowed types were matched. 322 return false; 323 } catch (ConvException e) { 324 return false; 325 } catch (UUIDParsingException e) { 326 return false; 327 } 328 } else { 329 return true; 330 } 331 } 332 333 unittest { 334 assert(pathParamMatches(":name", "andrew")); 335 assert(pathParamMatches(":age:int", "25")); 336 assert(pathParamMatches(":age:int", "0")); 337 assert(!pathParamMatches(":age:int", "two")); 338 assert(pathParamMatches(":value:float", "3.14")); 339 assert(pathParamMatches(":flag:bool", "true")); 340 assert(pathParamMatches(":flag:bool", "false")); 341 assert(!pathParamMatches(":name", null)); 342 } 343 344 /** 345 * Extracts the parameter name from a path parameter pattern segment string 346 * like ":name" or ":id:int". 347 * Params: 348 * patternSegment = The pattern segment to parse. 349 * Returns: The parameter's name, or null if the pattern segment is invalid. 350 */ 351 private string extractPathParamName(string patternSegment) { 352 if (patternSegment is null || patternSegment.length < 2) { 353 throw new PathPatternParseException("Cannot extract path parameter name from \"" ~ patternSegment ~ "\"."); 354 } 355 int typeSeparatorIdx = -1; 356 for (int i = 2; i < patternSegment.length; i++) { 357 if (patternSegment[i] == ':') { 358 typeSeparatorIdx = i; 359 break; 360 } 361 } 362 if (typeSeparatorIdx == -1) return patternSegment[1 .. $]; 363 return patternSegment[1 .. typeSeparatorIdx]; 364 } 365 366 unittest { 367 import std.exception : assertThrown; 368 assert(extractPathParamName(":test") == "test"); 369 assert(extractPathParamName(":a") == "a"); 370 assertThrown!PathPatternParseException(extractPathParamName(":")); 371 assert(extractPathParamName(":id:int") == "id"); 372 }