Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 157 additions & 15 deletions nanoFramework.WebServer/WebServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,101 @@ public static UrlParameter[] DecodeParam(string parameter)
return retParams;
}

/// <summary>
/// Extracts route parameters from a URL that matches a parameterized route.
/// </summary>
/// <param name="route">The route template with parameters (e.g., "/api/devices/{id}").</param>
/// <param name="rawUrl">The actual URL being requested.</param>
/// <param name="caseSensitive">Whether the comparison should be case sensitive.</param>
/// <returns>An array of UrlParameter objects containing the parameter names and values, or null if the route doesn't match.</returns>
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
Expand Down Expand Up @@ -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;
}

Expand All @@ -728,7 +862,15 @@ public static bool IsRouteMatch(CallbackRoutes route, string method, string rawU
/// <param name="context">Context of current request.</param>
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)
Expand Down
38 changes: 38 additions & 0 deletions nanoFramework.WebServer/WebServerEventArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,49 @@ public class WebServerEventArgs
public WebServerEventArgs(HttpListenerContext context)
{
Context = context;
RouteParameters = null;
}

/// <summary>
/// Constructor for the event arguments with route parameters
/// </summary>
public WebServerEventArgs(HttpListenerContext context, UrlParameter[] routeParameters)
{
Context = context;
RouteParameters = routeParameters;
}

/// <summary>
/// The response class
/// </summary>
public HttpListenerContext Context { get; protected set; }

/// <summary>
/// Route parameters extracted from the URL (if any)
/// </summary>
public UrlParameter[] RouteParameters { get; protected set; }

/// <summary>
/// Gets the value of a route parameter by name
/// </summary>
/// <param name="parameterName">The name of the parameter to retrieve</param>
/// <returns>The parameter value if found, otherwise null</returns>
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;
}
}
}
108 changes: 107 additions & 1 deletion tests/nanoFramework.WebServer.Tests/WebServerTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//
//
// Copyright (c) 2020 Laurent Ellerbach and the project contributors
// See LICENSE file in the project root for full license information.
//
Expand Down Expand Up @@ -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'");
}
}
}