Skip to content

Commit 404c01e

Browse files
authored
Adding support for parameterized routes (#334)
1 parent 49d920b commit 404c01e

File tree

3 files changed

+302
-16
lines changed

3 files changed

+302
-16
lines changed

nanoFramework.WebServer/WebServer.cs

Lines changed: 157 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,101 @@ public static UrlParameter[] DecodeParam(string parameter)
159159
return retParams;
160160
}
161161

162+
/// <summary>
163+
/// Extracts route parameters from a URL that matches a parameterized route.
164+
/// </summary>
165+
/// <param name="route">The route template with parameters (e.g., "/api/devices/{id}").</param>
166+
/// <param name="rawUrl">The actual URL being requested.</param>
167+
/// <param name="caseSensitive">Whether the comparison should be case sensitive.</param>
168+
/// <returns>An array of UrlParameter objects containing the parameter names and values, or null if the route doesn't match.</returns>
169+
public static UrlParameter[] ExtractRouteParameters(string route, string rawUrl, bool caseSensitive = false)
170+
{
171+
if (string.IsNullOrEmpty(route) || string.IsNullOrEmpty(rawUrl))
172+
{
173+
return null;
174+
}
175+
176+
// Remove query parameters from the URL for matching
177+
var urlParam = rawUrl.IndexOf(ParamStart);
178+
var urlPath = urlParam > 0 ? rawUrl.Substring(0, urlParam) : rawUrl;
179+
180+
// Normalize the URL path and route for comparison
181+
var urlToCompare = caseSensitive ? urlPath : urlPath.ToLower();
182+
var routeToCompare = caseSensitive ? route : route.ToLower();
183+
184+
// Ensure both paths start with '/' for consistent segment splitting
185+
if (!urlToCompare.StartsWith("/"))
186+
{
187+
urlToCompare = "/" + urlToCompare;
188+
}
189+
if (!routeToCompare.StartsWith("/"))
190+
{
191+
routeToCompare = "/" + routeToCompare;
192+
}
193+
194+
// Split into segments
195+
var urlSegments = urlToCompare.Split('/');
196+
var routeSegments = routeToCompare.Split('/');
197+
198+
// Number of segments must match
199+
if (urlSegments.Length != routeSegments.Length)
200+
{
201+
return null;
202+
}
203+
204+
ArrayList parameters = new ArrayList();
205+
206+
// Compare each segment and extract parameters
207+
for (int i = 0; i < routeSegments.Length; i++)
208+
{
209+
var routeSegment = routeSegments[i];
210+
var urlSegment = urlSegments[i];
211+
212+
// Skip empty segments (from leading slash)
213+
if (string.IsNullOrEmpty(routeSegment) && string.IsNullOrEmpty(urlSegment))
214+
{
215+
continue;
216+
}
217+
218+
// Check if this is a parameter segment (starts and ends with curly braces)
219+
if (routeSegment.Length > 2 &&
220+
routeSegment.StartsWith("{") &&
221+
routeSegment.EndsWith("}"))
222+
{
223+
// Parameter segment matches any non-empty segment that doesn't contain '/'
224+
if (string.IsNullOrEmpty(urlSegment) || urlSegment.IndexOf('/') >= 0)
225+
{
226+
return null;
227+
}
228+
229+
// Extract parameter name (remove curly braces)
230+
var paramName = routeSegment.Substring(1, routeSegment.Length - 2);
231+
parameters.Add(new UrlParameter { Name = paramName, Value = urlSegments[i] }); // Use original case for value
232+
continue;
233+
}
234+
235+
// Exact match required for non-parameter segments
236+
if (routeSegment != urlSegment)
237+
{
238+
return null;
239+
}
240+
}
241+
242+
// Convert ArrayList to array
243+
if (parameters.Count == 0)
244+
{
245+
return null;
246+
}
247+
248+
var result = new UrlParameter[parameters.Count];
249+
for (int i = 0; i < parameters.Count; i++)
250+
{
251+
result[i] = (UrlParameter)parameters[i];
252+
}
253+
254+
return result;
255+
}
256+
162257
#endregion
163258

164259
#region Constructors
@@ -695,29 +790,68 @@ public static bool IsRouteMatch(CallbackRoutes route, string method, string rawU
695790
return false;
696791
}
697792

793+
// Remove query parameters from the URL for matching
698794
var urlParam = rawUrl.IndexOf(ParamStart);
699-
var incForSlash = route.Route.IndexOf('/') == 0 ? 0 : 1;
700-
var rawUrlToCompare = route.CaseSensitive ? rawUrl : rawUrl.ToLower();
795+
var urlPath = urlParam > 0 ? rawUrl.Substring(0, urlParam) : rawUrl;
796+
797+
// Normalize the URL path and route for comparison
798+
var urlToCompare = route.CaseSensitive ? urlPath : urlPath.ToLower();
701799
var routeToCompare = route.CaseSensitive ? route.Route : route.Route.ToLower();
702-
bool isFound;
703-
704-
if (urlParam > 0)
800+
801+
// Ensure both paths start with '/' for consistent segment splitting
802+
if (!urlToCompare.StartsWith("/"))
705803
{
706-
isFound = urlParam == routeToCompare.Length + incForSlash;
804+
urlToCompare = "/" + urlToCompare;
707805
}
708-
else
806+
807+
if (!routeToCompare.StartsWith("/"))
709808
{
710-
isFound = rawUrlToCompare.Length == routeToCompare.Length + incForSlash;
809+
routeToCompare = "/" + routeToCompare;
711810
}
712-
713-
// Matching the route name
714-
// Matching the method type
715-
if (!isFound ||
716-
(!string.IsNullOrEmpty(routeToCompare) && rawUrlToCompare.IndexOf(routeToCompare) != incForSlash))
811+
812+
// Split into segments
813+
var urlSegments = urlToCompare.Split('/');
814+
var routeSegments = routeToCompare.Split('/');
815+
816+
// Number of segments must match
817+
if (urlSegments.Length != routeSegments.Length)
717818
{
718819
return false;
719820
}
720-
821+
822+
// Compare each segment
823+
for (int i = 0; i < routeSegments.Length; i++)
824+
{
825+
var routeSegment = routeSegments[i];
826+
var urlSegment = urlSegments[i];
827+
828+
// Skip empty segments (from leading slash)
829+
if (string.IsNullOrEmpty(routeSegment) && string.IsNullOrEmpty(urlSegment))
830+
{
831+
continue;
832+
}
833+
834+
// Check if this is a parameter segment (starts and ends with curly braces)
835+
if (routeSegment.Length > 2 &&
836+
routeSegment.StartsWith("{") &&
837+
routeSegment.EndsWith("}"))
838+
{
839+
// Parameter segment matches any non-empty segment that doesn't contain '/'
840+
if (string.IsNullOrEmpty(urlSegment) || urlSegment.IndexOf('/') >= 0)
841+
{
842+
return false;
843+
}
844+
// Parameter matches, continue to next segment
845+
continue;
846+
}
847+
848+
// Exact match required for non-parameter segments
849+
if (routeSegment != urlSegment)
850+
{
851+
return false;
852+
}
853+
}
854+
721855
return true;
722856
}
723857

@@ -728,7 +862,15 @@ public static bool IsRouteMatch(CallbackRoutes route, string method, string rawU
728862
/// <param name="context">Context of current request.</param>
729863
protected virtual void InvokeRoute(CallbackRoutes route, HttpListenerContext context)
730864
{
731-
route.Callback.Invoke(null, new object[] { new WebServerEventArgs(context) });
865+
// Extract route parameters if the route contains parameter placeholders
866+
var routeParameters = ExtractRouteParameters(route.Route, context.Request.RawUrl, route.CaseSensitive);
867+
868+
// Create WebServerEventArgs with or without route parameters
869+
var eventArgs = routeParameters != null
870+
? new WebServerEventArgs(context, routeParameters)
871+
: new WebServerEventArgs(context);
872+
873+
route.Callback.Invoke(null, new object[] { eventArgs });
732874
}
733875

734876
private static void HandleContextResponse(HttpListenerContext context)

nanoFramework.WebServer/WebServerEventArgs.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,49 @@ public class WebServerEventArgs
1818
public WebServerEventArgs(HttpListenerContext context)
1919
{
2020
Context = context;
21+
RouteParameters = null;
22+
}
23+
24+
/// <summary>
25+
/// Constructor for the event arguments with route parameters
26+
/// </summary>
27+
public WebServerEventArgs(HttpListenerContext context, UrlParameter[] routeParameters)
28+
{
29+
Context = context;
30+
RouteParameters = routeParameters;
2131
}
2232

2333
/// <summary>
2434
/// The response class
2535
/// </summary>
2636
public HttpListenerContext Context { get; protected set; }
37+
38+
/// <summary>
39+
/// Route parameters extracted from the URL (if any)
40+
/// </summary>
41+
public UrlParameter[] RouteParameters { get; protected set; }
42+
43+
/// <summary>
44+
/// Gets the value of a route parameter by name
45+
/// </summary>
46+
/// <param name="parameterName">The name of the parameter to retrieve</param>
47+
/// <returns>The parameter value if found, otherwise null</returns>
48+
public string GetRouteParameter(string parameterName)
49+
{
50+
if (RouteParameters == null || string.IsNullOrEmpty(parameterName))
51+
{
52+
return null;
53+
}
54+
55+
foreach (UrlParameter param in RouteParameters)
56+
{
57+
if (param.Name.ToLower() == parameterName.ToLower())
58+
{
59+
return param.Value;
60+
}
61+
}
62+
63+
return null;
64+
}
2765
}
2866
}

tests/nanoFramework.WebServer.Tests/WebServerTests.cs

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//
1+
//
22
// Copyright (c) 2020 Laurent Ellerbach and the project contributors
33
// See LICENSE file in the project root for full license information.
44
//
@@ -95,5 +95,111 @@ public void IsRouteMatch_Should_ReturnTrueForMatchingMethodAndRouteCaseSensitive
9595
Assert.IsTrue(resultMatch);
9696
Assert.IsFalse(resultNotMatch);
9797
}
98+
99+
[TestMethod]
100+
[DataRow("/api/devices/{id}", "/api/devices/123", true)]
101+
[DataRow("/api/devices/{id}", "/api/devices/device123", true)]
102+
[DataRow("/api/devices/{id}", "/api/devices/123abc", true)]
103+
[DataRow("/api/devices/{id}/actions", "/api/devices/123/actions", true)]
104+
[DataRow("/api/devices/{deviceId}/sensors/{sensorId}", "/api/devices/123/sensors/456", true)]
105+
[DataRow("/api/devices/{id}", "/api/devices/", false)]
106+
[DataRow("/api/devices/{id}", "/api/devices/123/456", false)]
107+
[DataRow("/api/devices/{id}", "/api/devices", false)]
108+
[DataRow("/api/devices/{id}", "/api/different/123", false)]
109+
[DataRow("/api/devices/{id}/actions", "/api/devices/123", false)]
110+
[DataRow("/api/devices/{deviceId}/sensors/{sensorId}", "/api/devices/123/sensors", false)]
111+
public void IsRouteMatch_Should_HandleParameterizedRoutes(string routeTemplate, string requestUrl, bool shouldMatch)
112+
{
113+
// Arrange
114+
var route = new CallbackRoutes()
115+
{
116+
Method = "GET",
117+
Route = routeTemplate,
118+
CaseSensitive = false
119+
};
120+
121+
// Act
122+
var result = WebServer.IsRouteMatch(route, "GET", requestUrl);
123+
124+
// Assert
125+
if (shouldMatch)
126+
{
127+
Assert.IsTrue(result, $"Route '{routeTemplate}' should match URL '{requestUrl}'");
128+
}
129+
else
130+
{
131+
Assert.IsFalse(result, $"Route '{routeTemplate}' should not match URL '{requestUrl}'");
132+
}
133+
}
134+
135+
[TestMethod]
136+
[DataRow("/api/devices/{id}", "/api/devices/123", "id", "123")]
137+
[DataRow("/api/devices/{deviceId}/sensors/{sensorId}", "/api/devices/mydevice/sensors/mysensor", "deviceId", "mydevice")]
138+
[DataRow("/api/devices/{deviceId}/sensors/{sensorId}", "/api/devices/mydevice/sensors/mysensor", "sensorId", "mysensor")]
139+
[DataRow("/users/{userId}/posts/{postId}/comments", "/users/john/posts/100/comments", "userId", "john")]
140+
[DataRow("/users/{userId}/posts/{postId}/comments", "/users/john/posts/100/comments", "postId", "100")]
141+
public void ExtractRouteParameters_Should_ExtractParameterValues(string routeTemplate, string requestUrl, string paramName, string expectedValue)
142+
{
143+
// Act
144+
var parameters = WebServer.ExtractRouteParameters(routeTemplate, requestUrl, false);
145+
146+
// Assert
147+
Assert.IsNotNull(parameters, "Route parameters should not be null");
148+
149+
string actualValue = null;
150+
foreach (UrlParameter param in parameters)
151+
{
152+
if (param.Name.ToLower() == paramName.ToLower())
153+
{
154+
actualValue = param.Value;
155+
break;
156+
}
157+
}
158+
159+
Assert.AreEqual(expectedValue, actualValue, $"Parameter '{paramName}' should have value '{expectedValue}'");
160+
}
161+
162+
[TestMethod]
163+
public void ExtractRouteParameters_Should_ReturnNullForNonMatchingRoute()
164+
{
165+
// Act
166+
var parameters = WebServer.ExtractRouteParameters("/api/devices/{id}", "/api/users/123", false);
167+
168+
// Assert
169+
Assert.IsNull(parameters, "Should return null for non-matching routes");
170+
}
171+
172+
[TestMethod]
173+
public void ExtractRouteParameters_Should_ReturnNullForEmptyInputs()
174+
{
175+
// Act
176+
var parameters1 = WebServer.ExtractRouteParameters("", "/api/test", false);
177+
var parameters2 = WebServer.ExtractRouteParameters("/api/test", "", false);
178+
var parameters3 = WebServer.ExtractRouteParameters(null, "/api/test", false);
179+
var parameters4 = WebServer.ExtractRouteParameters("/api/test", null, false);
180+
181+
// Assert
182+
Assert.IsNull(parameters1, "Should return null for empty route template");
183+
Assert.IsNull(parameters2, "Should return null for empty URL");
184+
Assert.IsNull(parameters3, "Should return null for null route template");
185+
Assert.IsNull(parameters4, "Should return null for null URL");
186+
}
187+
188+
[TestMethod]
189+
public void ExtractRouteParameters_Should_HandleQueryParameters()
190+
{
191+
// Arrange
192+
var routeTemplate = "/api/devices/{id}";
193+
var requestUrl = "/api/devices/123?filter=active&sort=name";
194+
195+
// Act
196+
var parameters = WebServer.ExtractRouteParameters(routeTemplate, requestUrl, false);
197+
198+
// Assert
199+
Assert.IsNotNull(parameters, "Route parameters should not be null");
200+
Assert.AreEqual(1, parameters.Length, "Should have exactly one route parameter");
201+
Assert.AreEqual("id", parameters[0].Name, "Parameter name should be 'id'");
202+
Assert.AreEqual("123", parameters[0].Value, "Parameter value should be '123'");
203+
}
98204
}
99205
}

0 commit comments

Comments
 (0)