HATEOAS in F#

Dec 23, 2023 min read

Hypermedia as the engine of application state (HATEOAS) is 24 years old now! I am coding for more than 12 years and yet I didn't see it on production in projects I worked with. Why? Is it so bad? Complex?

This post is part of the F# Advent Calendar 2023. Special thanks to Sergey Tihon for organizing this! Check out all the other great posts there!

What is HATEOAS?

You will have to read the whole post to get sense of it. The extra short definition of mine would be: Hypermedia as the engine of application state (HATEOAS) is the most mature form of a RESTful API:

It’s about including links to resources to make it clear what is possible and what’s not.

Sounds boring? Maybe, but it can save you from writing a lot of code and remove some coupling if you are ready for some additional complexity.

I will divide the topic into 3 parts:

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

The problem

Let’s say that you are an software engineer and you are supposed to create a house allocation app for Hogwarts.

  1. List houses
  2. List house students
  3. Delete student (only admin can delete student)
  4. Onboard student (only admin can onboard student)
  5. The API should be accessible after “/accommodation” url part.
  6. The API should be RESTful

Let’s create quickly some models

 1type HouseName =
 2    | Gryffindor
 3    | Hufflepuff
 4    | Ravenclaw
 5    | Slytherin
 6
 7type House = { Name: HouseName; Capacity: int }
 8
 9type Student =
10    { Id: string
11      Name: string
12      House: HouseName }
13
14let Gryffindor = { Name = Gryffindor; Capacity = 190 }
15let Slytherin = { Name = Slytherin; Capacity = 200 }
16let Ravenclaw = { Name = Ravenclaw; Capacity = 200 }
17let Hufflepuff = { Name = Hufflepuff; Capacity = 250 }

and some DTOs

 1module HouseAllocation.Dtos
 2
 3open HouseAllocation.Domain
 4
 5type HouseDto =
 6    { Name: string
 7      Capacity: int }
 8
 9    static member map(house: House) =
10        { Name = house.Name.ToString()
11          Capacity = house.Capacity }
12
13type StudentDto =
14    { Id: string
15      Name: string
16      House: string }
17
18    static member map (student: Student) =
19        { Id = student.Id
20          Name = student.Name
21          House = student.House.ToString() }

and some simple in-memory data access:

 1module HouseAllocation.Dao
 2
 3open Domain
 4open System
 5
 6let f = Bogus.Faker()
 7
 8let houses: House list = [ Gryffindor; Slytherin; Ravenclaw; Hufflepuff ]
 9
10let private generateStudents house =
11    [ for _ in 1..100 ->
12          { Id = Guid.NewGuid().ToString()
13            Name = f.Name.FullName()
14            House = house } ]
15
16let private allStudents =
17    [ for house in
18          [ HouseName.Gryffindor
19            HouseName.Slytherin
20            HouseName.Ravenclaw
21            HouseName.Hufflepuff ] do
22          yield! generateStudents house ]
23    
24let mutable housedStudents =
25    allStudents |> List.groupBy _.House
26                |> Map.ofList
27    
28let deleteStudentBy (id: string) =
29    housedStudents <- housedStudents.Values
30                            |> List.concat
31                            |> List.filter(fun student -> student.Id <> id)
32                            |> List.groupBy _.House
33                            |> Map.ofList
34                      

Simple stuff. Let’s add endpoints! (I am using endpoints routing from Giraffe)

 1module HouseAllocation.Router
 2
 3open Giraffe
 4open Giraffe.EndpointRouting
 5open Microsoft.AspNetCore.Http
 6open Microsoft.FSharp.Reflection
 7open Domain
 8open Dao
 9open Dtos
10
11let fromString<'a> (s: string) =
12    match FSharpType.GetUnionCases typeof<'a> |> Array.filter (fun case -> case.Name = s) with
13    | [| case |] -> Some(FSharpValue.MakeUnion(case, [||]) :?> 'a)
14    | _ -> None
15
16let readHouses: HttpHandler =
17    fun (next: HttpFunc) (ctx: HttpContext) ->
18        let data =
19            houses
20            |> List.map (fun house -> house.Name.ToString())
21        json data next ctx
22
23let readHouseBy (name: string) : HttpHandler =
24    fun (next: HttpFunc) (ctx: HttpContext) ->
25        let house =
26            houses
27            |> List.tryFind (fun house -> house.Name.ToString() = name)
28            |> Option.bind (fun house -> HouseDto.map house |> Some)
29        match house with
30        | Some house -> json house next ctx
31        | None ->
32            ctx.SetStatusCode(StatusCodes.Status404NotFound)
33            text "Page not found" next ctx
34
35let readStudentsBy (houseName: string) : HttpHandler =
36    fun (next: HttpFunc) (ctx: HttpContext) ->
37        let house = fromString<HouseName> houseName
38        match house with
39        | Some house -> housedStudents[house] |> fun list ->
40            let response: ResponseDto<StudentDto list> = { Members = list |> List.map(StudentDto.map) }
41            json response next ctx
42        | None ->
43            ctx.SetStatusCode(StatusCodes.Status404NotFound)
44            text "Page not found" next ctx
45
46let deleteStudentBy (house: string, id: string) : HttpHandler =
47    fun (next: HttpFunc) (ctx: HttpContext) ->
48        deleteStudentBy id
49        text "ok" next ctx
50
51let endpoints =
52    [
53      GET [
54            routef "/houses/%s/students" readStudentsBy
55            routef "/houses/%s" readHouseBy
56            route "/houses" readHouses
57          ]
58      DELETE [
59          routef "/houses/%s/students/%s" deleteStudentBy
60      ]
61    ]

But wait… I’ve done all the code in the problem part… Why? Because we can still do better. Let me ask you:

  1. How someone may now that the /houses endpoint does exist? How can we make sure that our API is discoverable?
  2. How can the consumer of the API know what is possible?

This two questions are related, I just wanted to emphasize the importance of it. If the API consumer doesn’t know the answers to this questions the only thing to do is to learn about it from the docs (like swagger), implement some logic on the frontend and pray that no one will do a breaking change.

The solution

Swagger? Yes, that’s it. The blog post is over… :D Swagger provides a documentation of all endpoints, we can generate clients from OpenApi spec, but does it make our API discoverable? Let me bring my point of view here on discoverable vs documented:

Documented means written down, cataloged and explained. First we need to read the documentation and then make a decision. Discoverability is about taking the first step and seeing what we can do next. So we can make the next decision as we go. In short:

  • Documented: Formalized information
  • Discoverable: Exploratory learning

The two serve different purposes and are not mutually exclusive. you can have one without the other, and you can have both.

Without HATEOAS

So let’s talk about

only admin can delete student

When such requirements take place we can easily implement it on the backend right? Let’s say we have a popular JWT auth mechanism in place. We check if users has a particular role and if not we say 401.

On the frontend part we then end up with something like this (this is actual code from my current job)

1const { checkPermissions } = usePermissionHandler();
2...
3<Stack>
4    {checkPermissions("general", "write") && (
5            <form onSubmit={handleSubmit(saveNote)}>
6             ...
7    }

Now… this is the problem HATEOAS addresses. We duplicate the code because we don’t know what and when is possible with the API. So we fetch roles/permissions and we check them on FE so we can display more/less buttons and then we do validate the actions on BE. HATEOAS is about making the workflow explicit by leveraging hypermedia, so the API consumers don’t have to reproduce the logic on their side.

With HATEOAS

We will focus now on discoverability. A common practice to inform API clients what’s possible is to implement OPTIONS to return the list of supported actions [1]. Let’s add them!

1let endpoints =
2    [
3      OPTIONS [
4          route "/" readOptions
5      ]
6//... remaining endpoints.

and handler:

1let readOptions: HttpHandler =
2    fun (next: HttpFunc) (ctx: HttpContext) ->
3        let links : Link list = [{
4                Rel = "all_houses"
5                Href = "/accommodation/houses"
6        }]
7        json links next ctx

the Link Dto:

1type Link = {
2    Rel: string
3    Href: string
4}

What’s Link … and Rel and what’s Href? The fundamental idea of hypermedia is to enrich the representation of a resource with hypermedia elements. The most common form hypermedia is a “link”.

  1. Href attribute specifies the URL of the resource the link goes to.
  2. Rel indicates the relationship of the target resource to the current one. There are some predefined in the wild [2], but you are not limited to them. You can come up with your own. Just be sure that they are meaningful and consistent. Seeing this in API may seem something new… but you know Links, don’t you?
1<head>  
2   <link rel="stylesheet" href="mystyle.css">
3</head>

What kind of response we will get?

1[{ 
2    Rel = "all_houses"
3    Href = "/accommodation/houses" 
4}]

So there is only one option. So far so good. Now we can discover the endpoint, list the houses… and what? How is this related to

only admin can delete student That was an intermediate step. Now let’s add the hypermedia elements to list of houses.

Let’s add hypermedia to the /accommodation/houses endpoint. What do you think the response should include? Everything what’s possible. So list of houses and related links;

 1[
 2   {
 3      "name":"Gryffindor",
 4      "links":[
 5         {
 6            "rel":"self",
 7            "href":"/accommodation/houses/Gryffindor"
 8         },
 9         {
10            "rel":"all_students",
11            "href":"/accommodation/houses/Gryffindor/students"
12         }
13      ]
14   },
15   {
16      "name":"Slytherin",
17      "links":[
18         {
19            "rel":"self",
20            "href":"/accommodation/houses/Slytherin"
21         },
22         {
23            "rel":"all_students",
24            "href":"/accommodation/houses/Slytherin/students"
25         }
26      ]
27   },
28   {
29      "name":"Ravenclaw",
30     ...
31   },
32   {
33     "name": "Hufflepuff",
34     ...
35   }
36]

Discoverable? I hope so. How to make this response happen?

 1let readHouses: HttpHandler =
 2    fun (next: HttpFunc) (ctx: HttpContext) ->
 3        let data =
 4            houses
 5            |> List.map (fun house ->
 6                { Name = house.Name.ToString()
 7                  Links =
 8                    [ { Rel = "self"
 9                        Href = $"/accommodation/houses/{house.Name.ToString()}" }
10                      { Rel = "all_students"
11                        Href = $"/accommodation/houses/{house.Name.ToString()}/students" }
12                    ] })
13
14        json data next ctx

This requires some extra work, but it gives you a lot of freedom. You can change endpoints, without breaking consumer as long as they use links instead hardcoded URLs. Is it Discoverable? I hope so. Let’s hit the href "/accommodation/houses/Slytherin/students" now. What would you expect? Now here is the power of HATEOAS. You can expect a student without hypermedia or with it - depending on who ask. Admin? Yes, there is a “edit” link. Not and admin? So no link for that. Not an admin:

 1{
 2   "members":[
 3      {
 4         "id":"b22344a2-51cd-40b6-b1a2-377cf83d3fa1",
 5         "name":"Elmer Goodwin",
 6         "house":"Gryffindor",
 7         "links":[  
 8         ]
 9      }
10      ....

Now imagine that admin requested the resource:

 1{
 2   "members":[
 3      {
 4         "id":"bb2409fe-633d-42f8-b3fa-60a94e1744fb",
 5         "name":"Oda Kub",
 6         "house":"Gryffindor",
 7         "links":[
 8            {
 9               "rel":"edit",
10               "href":"/accommodation/houses/Gryffindor/students/bb2409fe-633d-42f8-b3fa-60a94e1744fb"
11            }
12         ]
13      },
14      ...

Can you feel the power now? Not yet? Why? FE can now rely only on the presence/absence of links. Your api can now drive the workflows, not duplicated logic on FE side in the form of providers, if-statements or whatever. FE now can be free of roles, permissions, business logic rules duplication.

Note that I’ve used IANA “edit” role. There is nothing wrong in adding extra “delete” or “archive” rel type. From my experience I can tell that if someone can edit, it can most probably also delete. If not feel free to add the rel type I’ve mentioned. Just be consistent. How to make this responses happen?

 1let readStudentsBy (houseName: string) : HttpHandler =
 2    fun (next: HttpFunc) (ctx: HttpContext) ->
 3        let house = fromString<HouseName> houseName
 4        let isAdmin = ctx.User.IsInRole "Admin"
 5        match house with
 6        | Some house -> housedStudents[house] |> fun list ->
 7            let response: ResponseDto<StudentDto list> = { Members = list |> List.map(StudentDto.map isAdmin)
 8                                                           Links = [{Rel = "parent"; Href = $"/accommodation/houses/{houseName}" }] 
 9            }
10            json response next ctx
11        | None ->
12            ctx.SetStatusCode(StatusCodes.Status404NotFound)
13            text "Page not found" next ctx

Note that this code is for JWT auth. If implemented correctly the jwt middleware from .netcore will add User claims to http context. For the sake of completion let me paste the StudentDto.fs code here:

 1type StudentDto =
 2    { Id: string
 3      Name: string
 4      House: string
 5      Links: Link list }
 6
 7    static member map (canEdit: bool) (student: Student) =
 8        let houseName = student.House.ToString()
 9
10        { Id = student.Id
11          Name = student.Name
12          House = houseName
13          Links =
14            match canEdit with
15            | false -> []
16            | true ->
17                [ { Rel = "edit"
18                    Href = $"/accommodation/houses/{houseName}/students/{student.Id}" } ] }

Testing HATEOAS

Here is a test which can test HATEOAS:

 1[<Fact>]
 2let ``HATEOAS: Admin Can list students, delete one of them and get refreshed list`` () =
 3    task {
 4        let api = run().CreateClient()
 5        let! options = api.Options<Link list>"/accommodation"
 6        let allHousesLink =  options |> List.find(fun link -> link.Rel = "all_houses") |> _.Href
 7        let! housesReponse = api.Get<HouseListItemDto list> allHousesLink None
 8        let fstHouseLink = housesReponse.Head
 9                           |> _.Links
10                           |> List.find(fun link -> link.Rel = "all_students")
11                           |> _.Href
12        let! studentsReponse = api.Get<ResponseDto<StudentDto list>> fstHouseLink (Some (AuthenticationHeaderValue("Test", "Admin")))
13        let fstStudentLink = studentsReponse.Members.Head
14                             |> _.Links
15                             |> List.find(fun link -> link.Rel = "edit")
16                             |> _.Href
17        let! _ = api.Delete<ConfirmationDto> fstStudentLink
18        let! studentsReponseAfterDelete = api.Get<ResponseDto<StudentDto list>> fstHouseLink None
19        let studentThatShouldBeRemoved =
20            studentsReponseAfterDelete.Members
21            |> List.tryFind(fun student -> student.Name = studentsReponse.Members.Head.Name)
22        studentThatShouldBeRemoved |> should equal None
23    }

I am not using real JWT auth nor a database underneath, but even with real things the test would look like the same. From this test you can see how you can process links to derive current state, without coding any logic on the API consumer side.

Homework

Onboard student (only admin can onboard student)

What about cloning this repo and trying to implement this? I keep my fingers crossed.

Summary

I hope that by going through an imaginary example you are able to take some conclusions by your own. Is your client app relying on logic duplication? Do you want to introduce some additional complexity to remove it? Long story short:

API with HATEOAS:

Pros:

  • Discoverability: HATEOAS enables better discoverability of resources and actions by providing links within API responses.
  • Flexibility: Clients can dynamically navigate through the API by following links, reducing the need for hardcoded URLs.
  • Clients can rely on links and state transitions, simplifying client logic and making it more adaptable to changes.

Cons:

  • Complexity: Implementing HATEOAS can add complexity to both server and client implementations.
  • Learning Curve: Developers may need time to understand and adapt to the dynamic nature of HATEOAS-driven APIs.

API without HATEOAS:

Pros:

  • Simplicity: APIs without HATEOAS are often simpler to implement and understand.

Cons:

  • Hardcoded Logic: Clients rely on hardcoded URLs, making them more brittle to changes in the API structure (so coupling).
  • Limited Discoverability: Without HATEOAS, discovering available actions and resources may require external documentation, leading to a potential lack of self-discovery.

These are the key differences I can see.

Where to go from here?

If what I presented got your attention then you should definitely check “Crafting domain driven web APIs” By Julien Topçu [3] excellent talk. He uses Kotlin and spring on the slides, but this shouldn’t be a problem. Also spring documentation [4] is an excellent resource where you can find a lot of good stuff about HATEOAS even if you are not using spring.


References:
[1] RESTful Web Services Cookbook, Subbu Allamaraju, O’Reilly 2010. Chapter 14, Enabling Discovery.
[2] https://www.iana.org/assignments/link-relations/link-relations.xhtml
[3] Crafting domain driven web APIs - By Julien Topçu
[4] Spring Hateoas documentation
[5] Full source code