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 }