Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding support for custom http routes #743

Closed
wants to merge 3 commits into from
Closed

Adding support for custom http routes #743

wants to merge 3 commits into from

Conversation

mathewc
Copy link
Member

@mathewc mathewc commented Oct 5, 2016

Adds the following features:

  • ability to specify custom routes for functions, with arbitrarily complex route constraints (e.g. "route": "products/{Category:alpha}/{Id:int}"). Documentation on supported constraint formats can be found here. Basically this is just regular Asp.Net WebApi routing.
  • integrates captured route parameters with our binding data pipeline
  • allow for the default "api" route prefix to be customized or completely removed via host.json
  • changes/simplifications to the way we were populating binding data + POCOs from request data. The way it works now:
    • if the function binds to a poco, we initialize it from the WebHook or from the request body
    • we create an aggregated bag of binding data from that poco (if it exists) and from route parameters, query string parameters, and request body
    • finally we apply that binding data to the poco, which enables scenarios where the poco binds to data from the body as well as other sources

The initial PR for this work was here: #490. It has been completely reworked to use Asp.Net core types for route templating, to avoid pulling in a bunch of unecessary Asp.Net Core packages. I've also reworked this on top of our new HttpTrigger binding, which didn't exist when Connor did his initial work. Thanks @ConnorMcMahon for driving these improvements :)

I'm still working on unit tests, but I have e2e test coverage here already, and all existing tests pass :)

@@ -4,7 +4,11 @@
"sendGrid": {
"from": "Azure Functions <samples@functions.com>"
},
"http": {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If customers want to remove the "api" prefix completely, they can set this to empty here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome!


HttpFunctions.TryGetValue(route.ToLowerInvariant(), out function);
HttpFunctions.TryGetValue(routeData.Route, out function);
request.Properties.Add(ScriptConstants.AzureFunctionsHttpRouteDataKey, routeData.Values);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass the parsed route data through via request params so our http binding has access to it for binding


public HttpRouteFactory(string prefix)
{
var constraintResolver = new DefaultInlineConstraintResolver();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're using Asp.Net core types for all template/route handling :)

}

// add any route parameters to the contract
if (!string.IsNullOrEmpty(attribute.RouteTemplate))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding route parameters to this static binding contract is required to support allowing users to include function parameters mapping to route parameters

var msg = source[e.Index].ToString();

traceWriter.Info(msg);
var data = source[e.Index];
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In some PowerShell testing, I was getting null refs here, e.g. if the powershell function writes a null output line


namespace Microsoft.Azure.WebJobs.Script
{
// Original code here: https://github.com/aspnet/Common/blob/dev/src/Microsoft.Extensions.PropertyHelper.Sources/PropertyHelper.cs
Copy link
Member Author

@mathewc mathewc Oct 5, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a common helper class floating around in a lot of MSFT projects. We have one in WebJobs SDK as well, but it is an incomplete early version of this (it didn't have setter support).

I'm pulling this in to allow us to do performant property sets on user pocos, when aggregating binding data onto the instance

@@ -46,7 +46,7 @@ public PowerShellEndToEndTests(TestFixture fixture)

HttpRequestMessage request = new HttpRequestMessage
{
RequestUri = new Uri("http://localhost/api/httptrigger-powershell?code=1388a6b0d05eca2237f10e4a4641260b0a08f3a5&name=testuser"),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just cleaned up the PS sample functions - they didn't need to have Powershell in their names, since they're in a Powershell folder. Same pattern as all other tests.

@@ -1,6 +1,6 @@
Function Get-DateToday
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are all just renames

@@ -124,36 +125,6 @@ public void SecretFilesArePurgedOnStartup()
}

[Fact]
public void GetHttpFunctionOrNull_DecodesUriProperly()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We used to allow spaces in function names, which required URL decode on function names. I cleaned that code up and removed it, so this test is no longer needed.

string route = HttpUtility.UrlDecode(uri.AbsolutePath);
int idx = route.ToLowerInvariant().IndexOf("api", StringComparison.OrdinalIgnoreCase);
if (idx > 0)
var routeData = HttpRoutes.GetRouteData(request);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We let HttpRouteCollection to do all routing and parsing for us. If we have a match, this returns non-null, and we have all the route data

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this handle duplicate route templates automatically? I think Web API will throw some 'ambiguous route' exception if it can't figure out which route to pick -- we should make sure we at least handle that similarly.


In reply to: 82024856 [](ancestors = 82024856)

Copy link
Member Author

@mathewc mathewc Oct 5, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The behavior is (I just wrote a test for it) is that the first match is used. No error is thrown. I'm going to open a follow up issue to investigate what validations we can perform and improve this.

}

private static IEnumerable<string> ParseRouteParameters(string routeTemplate)
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the inline constraints like {id:int} are already stripped out before they get here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

}

public static ProductInfo Run(ProductInfo info, string category, string id, TraceWriter log)
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't gotten this far yet -- but are you setting Category and Id on the info parameter?

Copy link
Member Author

@mathewc mathewc Oct 5, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep :) This is just core SDK functionality. Any data that is part of the static binding contract for a trigger can be used as parameters. E.g. queue trigger dequeue count, etc.

"name": "req",
"direction": "in",
"methods": [ "get" ],
"route": "node/products/{category:alpha}/{id:int?}"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Demonstrates an optional binding - the function returns a singleton or array based on this

foreach (string parameterName in routeParameters)
{
aggregateDataContract[parameterName] = typeof(string);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will override any existing property type in the contract. Does that matter?

Copy link
Member Author

@mathewc mathewc Oct 5, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. I've put a guard here so we only add if not already present. That way the original Type is retained.

@@ -0,0 +1,16 @@
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add/modify a sample that shows using a docdb/table/etc input binding using {Id} to perform the lookup directly for you?

if (configSection.TryGetValue("routePrefix", out value))
{
routePrefix = (string)value;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to allow this to start with 'admin', do we? Although, I think we overwrite it later anyway. Or maybe that validation happens in WebScriptHostManager somewhere already.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plan was to prevent any routes from using the "admin" prefix, as among other things, this could have potentially unintended consequences.
Validation and early failure here would be a good idea. Perhaps some tests to make sure those routes are disallowed as well?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a good idea to add a route that catches all requests to unregistered routes under admin and responds appropriately (404?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opened #748 to track this and the duplicate route issue. I'll address that in a follow up.

private IDictionary<string, FunctionDescriptor> HttpFunctions { get; set; }
private IDictionary<IHttpRoute, FunctionDescriptor> HttpFunctions { get; set; }

private HttpRouteCollection HttpRoutes { get; set; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason these are properties instead of fields?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably for testing purposes at one time. Changing them to fields, since they're not used from outside.


public static ProductInfo Run(ProductInfo info, string category, string id, TraceWriter log)
{
log.Info($"category: {category} id: {id}");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to have logging showing that ProductInfo was populated as well

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

"name": "info",
"direction": "in",
"methods": [ "get" ],
"route": "csharp/products/{Category:alpha}/{Id:int}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are not case sensitive, correct? Should we have this lower cased?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. I've made it lower.


HttpFunctions.Add(route.ToLowerInvariant(), function);
var httpRoute = _httpRouteFactory.AddRoute(functionName, route, HttpRoutes);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming the ambiguous route validation @brettsam mentioned above would be done here. It would be good to make sure that is indeed the case and we have clear exceptions showing that.

public IEnumerable<string> GetRouteParameters(string routeTemplate)
{
var routeBuilder = CreateRouteBuilder(routeTemplate);
return ParseRouteParameters(routeBuilder.Template);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the routeTemplate different than what the routeBuilder.Template property returns here? (does it deal with the constraints in this case?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is different - routeBuilder.Template has all the constraints removed after they are parsed out. I'll add a code comment.

if (segment.StartsWith("{") && segment.EndsWith("}"))
{
string parameter = segment.Substring(1, segment.Length - 2);
routeParameters.Add(parameter);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we could get this information from the ASP.NET Routing types, guessing that wasn't the case... :(

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I looked.

return new TriggerData(valueProvider, bindingData);
// create a modifiable collection of binding data and
// copy in any initial binding data from the poco
Dictionary<string, object> aggregateBindingData = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: var

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

aggregateBindingData.AddRange(userTypeBindingData);

// Apply additional binding data coming from request route, query params, etc.
var requestBindingData = await GetRequestBindingDataAsync(request);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome to see this being combined now! :) 👍

// pull binding data from the query string
var queryParameters = request.GetQueryNameValuePairs();
foreach (var pair in queryParameters)
if (string.Compare("code", pair.Key, StringComparison.OrdinalIgnoreCase) == 0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be filtering the recently introduced clientid here as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps. However, clientId won't have any secrets, so I don't see it being that important? Maybe there's scenarios for binding to it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. Probably a good idea to let it flow.


Assert.Equal("Ted", poco.Name);
Assert.Equal("Seattle", poco.Location);
Assert.Equal(25, poco.Age); // verifies string was converted
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

if (configSection.TryGetValue("routePrefix", out value))
{
routePrefix = (string)value;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plan was to prevent any routes from using the "admin" prefix, as among other things, this could have potentially unintended consequences.
Validation and early failure here would be a good idea. Perhaps some tests to make sure those routes are disallowed as well?

@mathewc
Copy link
Member Author

mathewc commented Oct 5, 2016

Follow up items are tracked via #748 and will be addressed soon.

@brettsam
Copy link
Member

brettsam commented Oct 5, 2016

:shipit:

@mathewc mathewc closed this Oct 5, 2016
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants