diff --git a/nanoFramework.WebServer/WebServer.cs b/nanoFramework.WebServer/WebServer.cs index b781f4f..f552e22 100644 --- a/nanoFramework.WebServer/WebServer.cs +++ b/nanoFramework.WebServer/WebServer.cs @@ -159,6 +159,101 @@ public static UrlParameter[] DecodeParam(string parameter) return retParams; } + /// + /// Extracts route parameters from a URL that matches a parameterized route. + /// + /// The route template with parameters (e.g., "/api/devices/{id}"). + /// The actual URL being requested. + /// Whether the comparison should be case sensitive. + /// An array of UrlParameter objects containing the parameter names and values, or null if the route doesn't match. + public static UrlParameter[] ExtractRouteParameters(string route, string rawUrl, bool caseSensitive = false) + { + if (string.IsNullOrEmpty(route) || string.IsNullOrEmpty(rawUrl)) + { + return null; + } + + // Remove query parameters from the URL for matching + var urlParam = rawUrl.IndexOf(ParamStart); + var urlPath = urlParam > 0 ? rawUrl.Substring(0, urlParam) : rawUrl; + + // Normalize the URL path and route for comparison + var urlToCompare = caseSensitive ? urlPath : urlPath.ToLower(); + var routeToCompare = caseSensitive ? route : route.ToLower(); + + // Ensure both paths start with '/' for consistent segment splitting + if (!urlToCompare.StartsWith("/")) + { + urlToCompare = "/" + urlToCompare; + } + if (!routeToCompare.StartsWith("/")) + { + routeToCompare = "/" + routeToCompare; + } + + // Split into segments + var urlSegments = urlToCompare.Split('/'); + var routeSegments = routeToCompare.Split('/'); + + // Number of segments must match + if (urlSegments.Length != routeSegments.Length) + { + return null; + } + + ArrayList parameters = new ArrayList(); + + // Compare each segment and extract parameters + for (int i = 0; i < routeSegments.Length; i++) + { + var routeSegment = routeSegments[i]; + var urlSegment = urlSegments[i]; + + // Skip empty segments (from leading slash) + if (string.IsNullOrEmpty(routeSegment) && string.IsNullOrEmpty(urlSegment)) + { + continue; + } + + // Check if this is a parameter segment (starts and ends with curly braces) + if (routeSegment.Length > 2 && + routeSegment.StartsWith("{") && + routeSegment.EndsWith("}")) + { + // Parameter segment matches any non-empty segment that doesn't contain '/' + if (string.IsNullOrEmpty(urlSegment) || urlSegment.IndexOf('/') >= 0) + { + return null; + } + + // Extract parameter name (remove curly braces) + var paramName = routeSegment.Substring(1, routeSegment.Length - 2); + parameters.Add(new UrlParameter { Name = paramName, Value = urlSegments[i] }); // Use original case for value + continue; + } + + // Exact match required for non-parameter segments + if (routeSegment != urlSegment) + { + return null; + } + } + + // Convert ArrayList to array + if (parameters.Count == 0) + { + return null; + } + + var result = new UrlParameter[parameters.Count]; + for (int i = 0; i < parameters.Count; i++) + { + result[i] = (UrlParameter)parameters[i]; + } + + return result; + } + #endregion #region Constructors @@ -695,29 +790,68 @@ public static bool IsRouteMatch(CallbackRoutes route, string method, string rawU return false; } + // Remove query parameters from the URL for matching var urlParam = rawUrl.IndexOf(ParamStart); - var incForSlash = route.Route.IndexOf('/') == 0 ? 0 : 1; - var rawUrlToCompare = route.CaseSensitive ? rawUrl : rawUrl.ToLower(); + var urlPath = urlParam > 0 ? rawUrl.Substring(0, urlParam) : rawUrl; + + // Normalize the URL path and route for comparison + var urlToCompare = route.CaseSensitive ? urlPath : urlPath.ToLower(); var routeToCompare = route.CaseSensitive ? route.Route : route.Route.ToLower(); - bool isFound; - - if (urlParam > 0) + + // Ensure both paths start with '/' for consistent segment splitting + if (!urlToCompare.StartsWith("/")) { - isFound = urlParam == routeToCompare.Length + incForSlash; + urlToCompare = "/" + urlToCompare; } - else + + if (!routeToCompare.StartsWith("/")) { - isFound = rawUrlToCompare.Length == routeToCompare.Length + incForSlash; + routeToCompare = "/" + routeToCompare; } - - // Matching the route name - // Matching the method type - if (!isFound || - (!string.IsNullOrEmpty(routeToCompare) && rawUrlToCompare.IndexOf(routeToCompare) != incForSlash)) + + // Split into segments + var urlSegments = urlToCompare.Split('/'); + var routeSegments = routeToCompare.Split('/'); + + // Number of segments must match + if (urlSegments.Length != routeSegments.Length) { return false; } - + + // Compare each segment + for (int i = 0; i < routeSegments.Length; i++) + { + var routeSegment = routeSegments[i]; + var urlSegment = urlSegments[i]; + + // Skip empty segments (from leading slash) + if (string.IsNullOrEmpty(routeSegment) && string.IsNullOrEmpty(urlSegment)) + { + continue; + } + + // Check if this is a parameter segment (starts and ends with curly braces) + if (routeSegment.Length > 2 && + routeSegment.StartsWith("{") && + routeSegment.EndsWith("}")) + { + // Parameter segment matches any non-empty segment that doesn't contain '/' + if (string.IsNullOrEmpty(urlSegment) || urlSegment.IndexOf('/') >= 0) + { + return false; + } + // Parameter matches, continue to next segment + continue; + } + + // Exact match required for non-parameter segments + if (routeSegment != urlSegment) + { + return false; + } + } + return true; } @@ -728,7 +862,15 @@ public static bool IsRouteMatch(CallbackRoutes route, string method, string rawU /// Context of current request. protected virtual void InvokeRoute(CallbackRoutes route, HttpListenerContext context) { - route.Callback.Invoke(null, new object[] { new WebServerEventArgs(context) }); + // Extract route parameters if the route contains parameter placeholders + var routeParameters = ExtractRouteParameters(route.Route, context.Request.RawUrl, route.CaseSensitive); + + // Create WebServerEventArgs with or without route parameters + var eventArgs = routeParameters != null + ? new WebServerEventArgs(context, routeParameters) + : new WebServerEventArgs(context); + + route.Callback.Invoke(null, new object[] { eventArgs }); } private static void HandleContextResponse(HttpListenerContext context) diff --git a/nanoFramework.WebServer/WebServerEventArgs.cs b/nanoFramework.WebServer/WebServerEventArgs.cs index 6d5c10e..1bae96f 100644 --- a/nanoFramework.WebServer/WebServerEventArgs.cs +++ b/nanoFramework.WebServer/WebServerEventArgs.cs @@ -18,11 +18,49 @@ public class WebServerEventArgs public WebServerEventArgs(HttpListenerContext context) { Context = context; + RouteParameters = null; + } + + /// + /// Constructor for the event arguments with route parameters + /// + public WebServerEventArgs(HttpListenerContext context, UrlParameter[] routeParameters) + { + Context = context; + RouteParameters = routeParameters; } /// /// The response class /// public HttpListenerContext Context { get; protected set; } + + /// + /// Route parameters extracted from the URL (if any) + /// + public UrlParameter[] RouteParameters { get; protected set; } + + /// + /// Gets the value of a route parameter by name + /// + /// The name of the parameter to retrieve + /// The parameter value if found, otherwise null + public string GetRouteParameter(string parameterName) + { + if (RouteParameters == null || string.IsNullOrEmpty(parameterName)) + { + return null; + } + + foreach (UrlParameter param in RouteParameters) + { + if (param.Name.ToLower() == parameterName.ToLower()) + { + return param.Value; + } + } + + return null; + } } } diff --git a/tests/nanoFramework.WebServer.Tests/WebServerTests.cs b/tests/nanoFramework.WebServer.Tests/WebServerTests.cs index 016e1ad..21fea69 100644 --- a/tests/nanoFramework.WebServer.Tests/WebServerTests.cs +++ b/tests/nanoFramework.WebServer.Tests/WebServerTests.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2020 Laurent Ellerbach and the project contributors // See LICENSE file in the project root for full license information. // @@ -95,5 +95,111 @@ public void IsRouteMatch_Should_ReturnTrueForMatchingMethodAndRouteCaseSensitive Assert.IsTrue(resultMatch); Assert.IsFalse(resultNotMatch); } + + [TestMethod] + [DataRow("/api/devices/{id}", "/api/devices/123", true)] + [DataRow("/api/devices/{id}", "/api/devices/device123", true)] + [DataRow("/api/devices/{id}", "/api/devices/123abc", true)] + [DataRow("/api/devices/{id}/actions", "/api/devices/123/actions", true)] + [DataRow("/api/devices/{deviceId}/sensors/{sensorId}", "/api/devices/123/sensors/456", true)] + [DataRow("/api/devices/{id}", "/api/devices/", false)] + [DataRow("/api/devices/{id}", "/api/devices/123/456", false)] + [DataRow("/api/devices/{id}", "/api/devices", false)] + [DataRow("/api/devices/{id}", "/api/different/123", false)] + [DataRow("/api/devices/{id}/actions", "/api/devices/123", false)] + [DataRow("/api/devices/{deviceId}/sensors/{sensorId}", "/api/devices/123/sensors", false)] + public void IsRouteMatch_Should_HandleParameterizedRoutes(string routeTemplate, string requestUrl, bool shouldMatch) + { + // Arrange + var route = new CallbackRoutes() + { + Method = "GET", + Route = routeTemplate, + CaseSensitive = false + }; + + // Act + var result = WebServer.IsRouteMatch(route, "GET", requestUrl); + + // Assert + if (shouldMatch) + { + Assert.IsTrue(result, $"Route '{routeTemplate}' should match URL '{requestUrl}'"); + } + else + { + Assert.IsFalse(result, $"Route '{routeTemplate}' should not match URL '{requestUrl}'"); + } + } + + [TestMethod] + [DataRow("/api/devices/{id}", "/api/devices/123", "id", "123")] + [DataRow("/api/devices/{deviceId}/sensors/{sensorId}", "/api/devices/mydevice/sensors/mysensor", "deviceId", "mydevice")] + [DataRow("/api/devices/{deviceId}/sensors/{sensorId}", "/api/devices/mydevice/sensors/mysensor", "sensorId", "mysensor")] + [DataRow("/users/{userId}/posts/{postId}/comments", "/users/john/posts/100/comments", "userId", "john")] + [DataRow("/users/{userId}/posts/{postId}/comments", "/users/john/posts/100/comments", "postId", "100")] + public void ExtractRouteParameters_Should_ExtractParameterValues(string routeTemplate, string requestUrl, string paramName, string expectedValue) + { + // Act + var parameters = WebServer.ExtractRouteParameters(routeTemplate, requestUrl, false); + + // Assert + Assert.IsNotNull(parameters, "Route parameters should not be null"); + + string actualValue = null; + foreach (UrlParameter param in parameters) + { + if (param.Name.ToLower() == paramName.ToLower()) + { + actualValue = param.Value; + break; + } + } + + Assert.AreEqual(expectedValue, actualValue, $"Parameter '{paramName}' should have value '{expectedValue}'"); + } + + [TestMethod] + public void ExtractRouteParameters_Should_ReturnNullForNonMatchingRoute() + { + // Act + var parameters = WebServer.ExtractRouteParameters("/api/devices/{id}", "/api/users/123", false); + + // Assert + Assert.IsNull(parameters, "Should return null for non-matching routes"); + } + + [TestMethod] + public void ExtractRouteParameters_Should_ReturnNullForEmptyInputs() + { + // Act + var parameters1 = WebServer.ExtractRouteParameters("", "/api/test", false); + var parameters2 = WebServer.ExtractRouteParameters("/api/test", "", false); + var parameters3 = WebServer.ExtractRouteParameters(null, "/api/test", false); + var parameters4 = WebServer.ExtractRouteParameters("/api/test", null, false); + + // Assert + Assert.IsNull(parameters1, "Should return null for empty route template"); + Assert.IsNull(parameters2, "Should return null for empty URL"); + Assert.IsNull(parameters3, "Should return null for null route template"); + Assert.IsNull(parameters4, "Should return null for null URL"); + } + + [TestMethod] + public void ExtractRouteParameters_Should_HandleQueryParameters() + { + // Arrange + var routeTemplate = "/api/devices/{id}"; + var requestUrl = "/api/devices/123?filter=active&sort=name"; + + // Act + var parameters = WebServer.ExtractRouteParameters(routeTemplate, requestUrl, false); + + // Assert + Assert.IsNotNull(parameters, "Route parameters should not be null"); + Assert.AreEqual(1, parameters.Length, "Should have exactly one route parameter"); + Assert.AreEqual("id", parameters[0].Name, "Parameter name should be 'id'"); + Assert.AreEqual("123", parameters[0].Value, "Parameter value should be '123'"); + } } }