Parcourir la source

Add wildcards

Billy Barrow il y a 4 semaines
Parent
commit
06eac96a1d
1 fichiers modifiés avec 262 ajouts et 25 suppressions
  1. 262 25
      src/Handlers/Router.vala

+ 262 - 25
src/Handlers/Router.vala

@@ -4,13 +4,17 @@ using Invercargill.DataStructures;
 namespace Astralis {
 
     /// <summary>
-    /// HTTP router that maps request paths to handlers with support for named segments.
+    /// HTTP router that maps request paths to handlers with support for named segments and wildcards.
     /// </summary>
     /// <example>
     /// var router = new Router();
     /// router.map("/users", users_handler);
     /// router.map("/users/{id}", user_detail_handler);
     /// router.map("/files/{filename}/details", file_details_handler);
+    /// router.map("/files/*.jpg", jpg_handler);              // Single wildcard with suffix
+    /// router.map("/files/*", file_handler);                  // Single wildcard (one segment)
+    /// router.map("/downloads/**", download_handler);         // Greedy wildcard (any depth)
+    /// router.map("/images/**.png", png_handler);             // Greedy wildcard with suffix
     /// router.not_found_handler = new NotFoundHandler();
     /// router.error_handler = new ErrorHandler();
     /// </example>
@@ -30,8 +34,16 @@ namespace Astralis {
 
         /// <summary>
         /// Registers a route handler for the specified path pattern.
+        /// 
+        /// Pattern syntax:
+        /// - Literal segments: "/users" matches exactly "/users"
+        /// - Named segments: "/users/{id}" captures the segment value as "id"
+        /// - Single wildcard (*): "/files/*" matches exactly one segment (e.g., "/files/test")
+        /// - Single wildcard with suffix (*.ext): "/files/*.jpg" matches one segment ending in .jpg
+        /// - Greedy wildcard (**): "/files/**" matches zero or more segments (e.g., "/files/a/b/c")
+        /// - Greedy wildcard with suffix (**.ext): "/files/**.jpg" matches paths ending in .jpg
         /// </summary>
-        /// <param name="pattern">The path pattern to match. Supports named segments like "/users/{id}"</param>
+        /// <param name="pattern">The path pattern to match</param>
         /// <param name="handler">The handler to invoke when the pattern matches</param>
         /// <param name="methods">Optional HTTP methods to restrict this route to (e.g., "GET", "POST")</param>
         public void map(string pattern, owned RouteHandler handler, string[]? methods = null) {
@@ -117,42 +129,108 @@ namespace Astralis {
         }
     }
 
+    /// <summary>
+    /// Represents the type of a route segment.
+    /// </summary>
+    internal enum SegmentType {
+        /// <summary>Exact literal match required (e.g., "users")</summary>
+        LITERAL,
+        /// <summary>Named segment that captures its value (e.g., "{id}")</summary>
+        NAMED,
+        /// <summary>Single wildcard matching exactly one segment (e.g., "*")</summary>
+        SINGLE_WILDCARD,
+        /// <summary>Single wildcard with suffix requirement (e.g., "*.jpg")</summary>
+        SINGLE_WILDCARD_SUFFIX,
+        /// <summary>Greedy wildcard matching zero or more segments (e.g., "**")</summary>
+        GREEDY_WILDCARD,
+        /// <summary>Greedy wildcard with suffix requirement (e.g., "**.jpg")</summary>
+        GREEDY_WILDCARD_SUFFIX
+    }
+
+    /// <summary>
+    /// Represents a parsed route segment with its type and metadata.
+    /// </summary>
+    internal class ParsedSegment : Object {
+        public SegmentType segment_type;
+        public string value;
+        public string suffix;
+        
+        public ParsedSegment(SegmentType type, string value, string suffix = "") {
+            this.segment_type = type;
+            this.value = value;
+            this.suffix = suffix;
+        }
+    }
+
     /// <summary>
     /// Represents a single route with its pattern, handler, and allowed methods.
+    /// Supports wildcards: "*" for single segment, "**" for greedy multi-segment matching.
     /// </summary>
     internal class Route : Object {
 
         private string _pattern;
-        private string[] _pattern_segments;
-        private string[] _named_segment_names;
+        private ParsedSegment[] _parsed_segments;
         public RouteHandler handler { get; private set; }
         private string[]? _allowed_methods;
+        
+        /// <summary>True if this route contains a greedy wildcard (**)</summary>
+        private bool _has_greedy_wildcard;
+        /// <summary>The suffix for greedy wildcard, if any (e.g., ".jpg" for "**.jpg")</summary>
+        private string _greedy_suffix = "";
 
         public Route(string pattern, owned RouteHandler handler, string[]? methods = null) {
             _pattern = pattern;
             _handler = handler;
             _allowed_methods = methods != null && methods.length > 0 ? methods : null;
+            _has_greedy_wildcard = false;
             
             // Parse pattern into segments
             var segments = pattern.split("/");
-            var pattern_segments = new string[0];
-            var named_segments = new string[0];
+            _parsed_segments = new ParsedSegment[0];
             
             foreach (var segment in segments) {
                 if (segment != "") {
-                    pattern_segments += segment;
+                    var parsed = parse_segment(segment);
+                    _parsed_segments += parsed;
                     
-                    // Check if this is a named segment {name}
-                    if (segment.length >= 3 && segment[0] == '{' && segment[segment.length - 1] == '}') {
-                        named_segments += segment.substring(1, segment.length - 2);
-                    } else {
-                        named_segments += ""; // Empty string indicates literal segment
+                    if (parsed.segment_type == SegmentType.GREEDY_WILDCARD || 
+                        parsed.segment_type == SegmentType.GREEDY_WILDCARD_SUFFIX) {
+                        _has_greedy_wildcard = true;
+                        _greedy_suffix = parsed.suffix;
                     }
                 }
             }
+        }
+        
+        /// <summary>
+        /// Parses a single segment into its type and components.
+        /// </summary>
+        private ParsedSegment parse_segment(string segment) {
+            // Check for named segment {name}
+            if (segment.length >= 3 && segment[0] == '{' && segment[segment.length - 1] == '}') {
+                return new ParsedSegment(SegmentType.NAMED, segment.substring(1, segment.length - 2));
+            }
+            
+            // Check for greedy wildcard ** or **.suffix
+            if (segment.has_prefix("**")) {
+                string suffix = segment.substring(2);
+                if (suffix.length > 0) {
+                    return new ParsedSegment(SegmentType.GREEDY_WILDCARD_SUFFIX, "**", suffix);
+                }
+                return new ParsedSegment(SegmentType.GREEDY_WILDCARD, "**");
+            }
             
-            _pattern_segments = pattern_segments;
-            _named_segment_names = named_segments;
+            // Check for single wildcard * or *.suffix
+            if (segment.has_prefix("*")) {
+                string suffix = segment.substring(1);
+                if (suffix.length > 0) {
+                    return new ParsedSegment(SegmentType.SINGLE_WILDCARD_SUFFIX, "*", suffix);
+                }
+                return new ParsedSegment(SegmentType.SINGLE_WILDCARD, "*");
+            }
+            
+            // Literal segment
+            return new ParsedSegment(SegmentType.LITERAL, segment);
         }
 
         /// <summary>
@@ -183,32 +261,166 @@ namespace Astralis {
                 }
             }
 
-            // Check segment count
-            if (path_segs.length != _pattern_segments.length) {
+            // Use different matching strategy based on whether we have greedy wildcards
+            if (_has_greedy_wildcard) {
+                return match_with_greedy(path_segs);
+            } else {
+                return match_simple(path_segs);
+            }
+        }
+        
+        /// <summary>
+        /// Simple matching for routes without greedy wildcards.
+        /// Requires exact segment count match.
+        /// </summary>
+        private RouteContext? match_simple(string[] path_segs) {
+            // Check segment count - must match exactly
+            if (path_segs.length != _parsed_segments.length) {
                 return null;
             }
 
             // Match each segment
             var named_segments = new Dictionary<string, string>();
+            var wildcard_captures = new Vector<string>();
             
-            for (int i = 0; i < _pattern_segments.length; i++) {
-                var pattern_seg = _pattern_segments[i];
+            for (int i = 0; i < _parsed_segments.length; i++) {
+                var parsed = _parsed_segments[i];
                 var path_seg = path_segs[i];
                 
-                if (_named_segment_names[i] != "") {
-                    // This is a named segment - capture the value
-                    named_segments[_named_segment_names[i]] = path_seg;
-                } else if (pattern_seg != path_seg) {
-                    // Literal segment doesn't match
+                if (!match_segment(parsed, path_seg, named_segments, wildcard_captures)) {
                     return null;
                 }
             }
 
             // Build route context
+            return build_context(path_segs, named_segments, wildcard_captures);
+        }
+        
+        /// <summary>
+        /// Matching for routes with greedy wildcards (**).
+        /// Allows variable segment count.
+        /// </summary>
+        private RouteContext? match_with_greedy(string[] path_segs) {
+            var named_segments = new Dictionary<string, string>();
+            var wildcard_captures = new Vector<string>();
+            
+            int path_index = 0;
+            int pattern_index = 0;
+            
+            while (pattern_index < _parsed_segments.length && path_index <= path_segs.length) {
+                var parsed = _parsed_segments[pattern_index];
+                
+                if (parsed.segment_type == SegmentType.GREEDY_WILDCARD || 
+                    parsed.segment_type == SegmentType.GREEDY_WILDCARD_SUFFIX) {
+                    // Find how many segments this greedy wildcard should consume
+                    int remaining_patterns = _parsed_segments.length - pattern_index - 1;
+                    int min_path_needed = remaining_patterns; // Minimum segments needed for remaining patterns
+                    
+                    // Check if we have enough path segments
+                    if (path_segs.length - path_index < min_path_needed) {
+                        return null;
+                    }
+                    
+                    // Calculate how many segments the greedy wildcard consumes
+                    int greedy_count = path_segs.length - path_index - min_path_needed;
+                    
+                    // For greedy with suffix, the last consumed segment must match the suffix
+                    if (parsed.segment_type == SegmentType.GREEDY_WILDCARD_SUFFIX) {
+                        if (greedy_count == 0) {
+                            // Need at least one segment to match the suffix
+                            return null;
+                        }
+                        int last_greedy_index = path_index + greedy_count - 1;
+                        if (last_greedy_index >= path_segs.length) {
+                            return null;
+                        }
+                        string last_seg = path_segs[last_greedy_index];
+                        if (!last_seg.has_suffix(parsed.suffix)) {
+                            return null;
+                        }
+                    }
+                    
+                    // Capture all segments consumed by greedy wildcard
+                    var greedy_segments = new string[0];
+                    for (int i = 0; i < greedy_count; i++) {
+                        string seg = path_segs[path_index + i];
+                        greedy_segments += seg;
+                        wildcard_captures.add(seg);
+                    }
+                    
+                    // Store the greedy capture as a single string with "/" separator
+                    if (greedy_segments.length > 0) {
+                        named_segments["*"] = string.joinv("/", greedy_segments);
+                    } else {
+                        named_segments["*"] = "";
+                    }
+                    
+                    path_index += greedy_count;
+                    pattern_index++;
+                } else {
+                    // Non-greedy segment - match normally
+                    if (path_index >= path_segs.length) {
+                        return null;
+                    }
+                    
+                    if (!match_segment(parsed, path_segs[path_index], named_segments, wildcard_captures)) {
+                        return null;
+                    }
+                    path_index++;
+                    pattern_index++;
+                }
+            }
+            
+            // Check if we've consumed all path segments and all patterns
+            if (path_index != path_segs.length || pattern_index != _parsed_segments.length) {
+                return null;
+            }
+
+            return build_context(path_segs, named_segments, wildcard_captures);
+        }
+        
+        /// <summary>
+        /// Matches a single path segment against a parsed pattern segment.
+        /// </summary>
+        private bool match_segment(ParsedSegment parsed, string path_seg, 
+                                   Dictionary<string, string> named_segments,
+                                   Vector<string> wildcard_captures) {
+            switch (parsed.segment_type) {
+                case SegmentType.LITERAL:
+                    return path_seg == parsed.value;
+                    
+                case SegmentType.NAMED:
+                    named_segments[parsed.value] = path_seg;
+                    return true;
+                    
+                case SegmentType.SINGLE_WILDCARD:
+                    // Matches any non-empty segment
+                    wildcard_captures.add(path_seg);
+                    return true;
+                    
+                case SegmentType.SINGLE_WILDCARD_SUFFIX:
+                    // Must end with the suffix
+                    if (path_seg.has_suffix(parsed.suffix)) {
+                        wildcard_captures.add(path_seg);
+                        return true;
+                    }
+                    return false;
+                    
+                default:
+                    return false;
+            }
+        }
+        
+        /// <summary>
+        /// Builds a RouteContext from the match results.
+        /// </summary>
+        private RouteContext build_context(string[] path_segs, 
+                                           Dictionary<string, string> named_segments,
+                                           Vector<string> wildcard_captures) {
             var context = new RouteContext();
             context.segments = Wrap.array<string>(path_segs).to_series();
             context.named_segments = named_segments;
-            
+            context.wildcard_captures = wildcard_captures.to_series();
             return context;
         }
     }
@@ -241,7 +453,7 @@ namespace Astralis {
     }
 
     /// <summary>
-    /// Context information for a matched route, containing path segments and named captures.
+    /// Context information for a matched route, containing path segments, named captures, and wildcard captures.
     /// </summary>
     public class RouteContext : Object {
 
@@ -254,9 +466,25 @@ namespace Astralis {
         /// Named segment values captured from the route pattern.
         /// For pattern "/users/{id}/posts/{post_id}" matching "/users/123/posts/456",
         /// this would contain {"id": "123", "post_id": "456"}.
+        /// 
+        /// For greedy wildcards (**), the key "*" contains the matched path segments
+        /// joined with "/" (e.g., for "/files/**" matching "/files/a/b/c", this would
+        /// contain {"*": "a/b/c"}).
         /// </summary>
         public Dictionary<string, string> named_segments { get; set; }
 
+        /// <summary>
+        /// All values captured by wildcard segments (* or **).
+        /// Each wildcard match adds its captured value(s) to this series.
+        /// 
+        /// For single wildcards (*), each matching segment is added individually.
+        /// For greedy wildcards (**), all consumed segments are added individually.
+        /// 
+        /// Example: Pattern "/files/*/*.jpg" matching "/files/images/photo.jpg"
+        /// would have wildcard_captures = ["images", "photo.jpg"]
+        /// </summary>
+        public Series<string> wildcard_captures { get; set; }
+
         /// <summary>
         /// Gets a named segment value by name.
         /// </summary>
@@ -296,6 +524,15 @@ namespace Astralis {
         public bool has_segment(string name) {
             return named_segments.has(name);
         }
+
+        /// <summary>
+        /// Gets the greedy wildcard capture value (from ** patterns).
+        /// This is a convenience method for getting the "*" key from named_segments.
+        /// </summary>
+        /// <returns>The greedy wildcard capture as a path string, or null if not present</returns>
+        public string? get_greedy_capture() {
+            return get_segment("*");
+        }
     }
 
     /// <summary>