1
votes

I have spent a day on this and I really thought this would be something trivial.

There are about a dozen options surrounding the HTTP binding "Route" parameter, the related "Run" method signature, and the underlying web API routing mechanism documentation. I've tried each of the, and each one seems to throw a different error. By the way, this trial and error was particularly frustrating and time consuming.

Azure Function Bindings

https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-http-webhook

ASP.NET Route Constraints Docs

https://docs.microsoft.com/en-us/aspnet/web-api/overview/web-api-routing-and-actions/attribute-routing-in-web-api-2#constraints

Functions bindings allow you to define the "Route" string in each function config file which defines the entry point for that function. It effectively works the same way a the "Route" Annotations worked from ASP.NET (which is new to me). Thus, the Azure Function docs link to the Constraints docs for advanced usage.

Our use case is not advanced, we want our function work something like the following:

Handle something like the following route:

"route" : "/books/{bookName}"

Which maps to a function with a Run() signature that looks something like:

Run(HttpRequestMessage req, string bookName, TraceWriter log)

If there's no bookName, our function will return the array of all books. If there's a bookname, it will return the details of that book. Standard API stuff.

According to the "constraints" doc, we must make an adjustment for the bookName to make it an optional URI parameter, and we have a few options. Here are the two examples/options provided:

public class BooksController : ApiController
{
    [Route("api/books/locale/{lcid:int?}")]
    public IEnumerable<Book> GetBooksByLocale(int lcid = 1033) { ... }
}

public class BooksController : ApiController
{
    [Route("api/books/locale/{lcid:int=1033}")]
    public IEnumerable<Book> GetBooksByLocale(int lcid) { ... }
}

Currently, neither of the two options above work in functions. The bindings don't allow the equal sign in the function.json, and any optional parameters in the Run() method would need to be the "last" parameters, and we don't have control over the order of the Run() parameters. Even if we could change them, we wouldn't want to, as having TraceWriter log be the last parameter is a sensible convention we'd like to follow.

Now, the Azure Functions docs provides an optional parameter example that seems to be very straightforward, but somehow manages to dodge the specific use case above.

Here is the route from function.json:

"route": "products/{category:alpha}/{id:int?}"

Here is the method signature:

Run(HttpRequestMessage request, string category, int? id, TraceWriter log)

So, the difference here is that the parameter they marked as optional is specifically an int, which is a nullable type. The azure function runtime clearly passes through a null int if the URL doesn't contain it, thereby bypassing the requirement of making "id" an optional parameter to the method.

If you try the same trick with a string parameter, you get this:

error CS0453: The type 'string' must be a non-nullable value type in order to use it as parameter 'T' in the generic type or method 'Nullable'

Is there a reasonable solution to achieve an optional string parameter in Azure Functions?

If so, please provide a tip on the tutorial. If not, please clearly indicate this limitation on the tutorial.

2
Would be nice if you could make your question shorter. AFAIK, ASP.NET Route Constraints Docs has no relation to Azure Functions, same applies to your ApiController samples. Shorter question would be easier for future readers. - Mikhail Shilkov
This is a quote from the azure function docs which contains an embedded link to the route constraints doc provided, indicating a direct relation... "You can use any Web API Route Constraint with your parameters." - solvingJ
I'm seeing the same thing -- this seems like it should be supported. Let me do a little more investigating and if it's an issue, I'll file it on github and link it here. - brettsam
I think the answer is going to be that we SHOULD be doing a separate function for each possible combination of unique route + http method. Since /products and /products/{productId} are actually separate paths, you should have separate functions. After all, if you process them both in the same function, you need to do manual switching on the path and method, and so why not just do a separate function. IMO, separate functions for each possible API target seems like a very "sprawling" way to manage an API surface. I'm trying to find alternatives now. - solvingJ

2 Answers

2
votes

This works for structs like int? but not for classes like string. I've filed an issue here: https://github.com/Azure/azure-webjobs-sdk-extensions/issues/224.

Until this is fixed, your best bet may be to use a route template like: "route": "books/{bookName?}", but not use bookName as a parameter in your function: Run(HttpRequestMessage req, TraceWriter log)

In my brief testing, that will get you the proper route matching but you'll have to do some URI parsing of the req parameter yourself to check the book name.

Update: This has been fixed and should now work as expected.

1
votes

This sounds like it would be two different end points as they do two different things in terms of the Single Responsibility Principle

1) Gets a list of resources at that location, "books/"

Run(HttpRequestMessage req, TraceWriter log)

2) Get a specific resource, "books/{bookName}"

Run(HttpRequestMessage req, string bookName, TraceWriter log)

With 1. I would use query string parameters to pass in a page size and page number so that I didn't return all of the resources at that uri for performance reasons.

string pageSizeQuery = request.GetQueryNameValuePairs()
        .FirstOrDefault(q => String.Compare(q.Key, "pagesize", StringComparison.OrdinalIgnoreCase) == 0)
        .Value;

if (!int.TryParse(pageSizeQuery, out var pageSize))
{
    pageSize = 25;
}

string pageQuery = request.GetQueryNameValuePairs()
        .FirstOrDefault(q => String.Compare(q.Key, "page", StringComparison.OrdinalIgnoreCase) == 0)
        .Value;

if (!int.TryParse(pageQuery, out var page))
{
    page = 1;
}