Let's try .net LinkGenerator, will it make working with links easier?

Jan 21, 2024 min read

This is a continuation to "HATEOAS in F#" post. Let's try to simplify link generatrion in our Hogwarts accommodation API.

Intro

This is 2nd post in this 3-post series:

  1. HATEOAS in F# + source code
  2. Let’s try LinkGenerator to see if it can simplify HATEOAS implementation (this post) + source code
  3. Consuming RESTful API and leveraging HATEOAS (coming soon)

Some libraries have built-in mechanics which can help us in creating HATEOAS. In spring we have WebMvcLinkBuilder, in .net MVC we have IUrlHelper, now in .net core minimal APIs we have LinkGenerator. Let’s check if this can actually help. Here you can find the updated code.

What is LinkGenerator

From MSDN [1] docs:

LinkGenerator efines a contract to generate absolute and related URIs based on endpoint routing.

I’ve found an example in C# on a blog [2]:

Some endpoints:

 1app.MapGet("/messages", Ok<List<TextMessageDto>> () =>
 2{
 3    List<TextMessageDto> textMessages = new()
 4    {
 5        new TextMessageDto { Id = 1, Message = "Hello, World!"},
 6        new TextMessageDto { Id = 1, Message = "Yet another Hello, World!"}
 7    };
 8    return TypedResults.Ok(textMessages);
 9}).WithName("readmessages");
10
11app.MapGet("/messages/{id}", Ok<TextMessageDto> (int id) =>
12{
13    //TODO : Implement a lookup for messages
14    return TypedResults.Ok(new TextMessageDto { Id = id, Message = $"Hello, World! The id is {id}" });
15}).WithName("readmessagebyid");
16
17app.MapPut("/messages/{id}", (...) =>
18{
19    //update message
20}).WithName("updatemessage");
21
22app.MapDelete("/messages/{id}", (...) =>
23{
24    //delete message
25}).WithName("deletemessage");

And an endpoint that uses LinkGenerator:

 1//use the LinkGenerator class to build the url for each endpoint by using the endpointname associated with each endpoint 
 2app.MapGet("/messages/{id}", Ok<TextMessageDto> (int id, HttpContext httpContext, LinkGenerator linkGenerator) =>
 3{
 4    TextMessageDto textMessage = new() { Id = id, Message = $"Hello, World from {id}" };
 5    List<LinkDto> links = new()
 6    {
 7        new LinkDto(linkGenerator.GetUriByName(httpContext, "readmessagebyid",values: new{id})!, "self", "GET"),
 8        new LinkDto(linkGenerator.GetUriByName(httpContext, "updatemessage",values: new{id})!, "update_message", "PUT"),
 9        new LinkDto(linkGenerator.GetUriByName(httpContext, "deletemessage",values: new{id})!, "delete_message", "DELETE")
10    };
11    textMessage.Links = links;
12    return TypedResults.Ok(textMessage);
13})
14.WithName("readmessagebyid");

I see some tradeoffs:

  1. It looks that it can save us from some troubles when we want to do some changes in our API that involve changing URI paths as we can refer to an endpoint by name instead of the full API path, but we have to bother now with naming the endpoints.
  2. We don’t have to hardcode paths versus we have to hardcode endpoint names.
  3. While in C# we can get some extra typesafety (in C# string interpolation actually doesn’t check the types), we can have the same benefit in F# by using typed interpolated strings, so let’s pretend that this point doesn’t exist (as this is blog post about F# not C#).

So… I am somewhat sceptical but let’s go. We have to try it, before I can make an opinion.

LinkGenerator can be retrieved from services by quering .net HttpContext like so;

1    fun (next: HttpFunc) (ctx: HttpContext) ->
2        let linker = ctx.GetService<LinkGenerator>()

The most important 2 methods are GetPathByName and GetUriByName. Let’s consider http://localhost/accommodation/houses/Gryffindor. The first will return the path - so /accommodation/houses/Gryffindor from the URL, the second will include the protocol and domain name. Both are good - depends if You want the client to handle the protocol and domain or if You want to provide full URL.

Adding it to our API

The endpoint definition `will change slightly (names are just added):

 1let endpoints =
 2    [
 3      OPTIONS [
 4          route "/" readOptions
 5      ]
 6      GET [
 7            routef "/houses/%s/students" readStudentsBy |> addMetadata(EndpointNameMetadata "get_house_students") 
 8            routef "/houses/%s" readHouseBy |> addMetadata(EndpointNameMetadata "get_houses_by") 
 9            route "/houses" readHouses |> addMetadata(EndpointNameMetadata "get_houses")
10          ]
11      DELETE [
12          routef "/houses/%s/students/%s" deleteStudentBy
13      ]
14    ]

and let’s see how the route “/houses” readHouses looks like:

 1let readHouses: HttpHandler =
 2    fun (next: HttpFunc) (ctx: HttpContext) ->
 3        let linker = ctx.GetService<LinkGenerator>()
 4        let data =
 5            houses
 6            |> List.map (fun house ->
 7                { Name = house.Name.ToString()
 8                  Links =
 9                    [ { Rel = "self"
10                        Href = linker.GetPathByName("get_houses_by", {|s0 = house.Name.ToString()|}) }
11                      { Rel = "all_students"
12                        Href = linker.GetPathByName("get_house_students", {|s0 = house.Name.ToString()|})}
13                    ]})
14        json data next ctx

You can see other endpoints in the Full source code. But wait… `s0``… what’s that? …

Giraffe uses format strings…

Yup. So in C# You have this:

1app.MapGet("/messages/{id}", Ok<TextMessageDto> (int id) =>

in F# You have this:

1 GET [
2       routef "/messages/%s" func test 
3     ]

and messages/%s at the end of the day is translated to route template and looks like this:

This may look strange, especially when You look on my anonymous records {|s0 = house.Name.ToString()|} but it doesn’t hurt readibility that much if I can understand what endpoint I am refering to - I am passing it’s name. So instead get_house_students I can name it get_students_by_house_name and then I know that s0 is house name. For multiple route paramaters I can work on the naming as well, for example get_students_by_house_name_and_year and I can pass {|s0 = "Slytherin"; i0 = 1989|}.

Actually I have opened a GitHub issue maybe I don’t know something, maybe this can be improved in the near future.

Other frameworks - Let’s try in Falco

I hoped that in falco it will be possible to name an endpoint and pass record with peroper names instead {s0}, {s1} and so on as it uses route templates instead of format strings. First impression was good:

but I was not able add endpoint name. In C# when we use map, mapGet etc we operate on IEndpointConventionBuilder whereas in Falco we operate on HttpEndpoint (Falco built-in type) and in Giraffe on Endpoint built-in type. Giraffe provides addMetadata function to extend the configuration while in Falco we don’t have such option (at least for now).

Conclusions

I don’t have to think about parent path in my routing when I am dealing with subRoute, so from our Program.fs file:

1let endpoints useMocks =
2    let auth = if useMocks then applyBefore fakeAuth else id
3    [
4        GET [ route "/health" (text "Up") ]
5        auth (subRoute "/accommodation" HouseAllocation.Router.endpoints)
6    ]

I can forget about the /accomodation fragment. This in my eyes is a big benefit as the subroute links will continue to work when I will change the /accomodation to for example /hausing. Without it I will break all links and I will have to update each of them separetely. The s0, s1, s2… i0 etc may look bad, but at the end of the day I love to have format strings as part of my routing which automatically can validate my handler function paramater types. I am going to use LinkGenerator.

The final conclusion is that is it worth to test a thing by your own before you give a rigid opinion.


References:
[1] MSDN LinkGenerator
[2] Minimal APIs and HATEOAS, Poornima Nayar
[3] https://github.com/giraffe-fsharp/Giraffe/issues/569
[4] Full source code