United Kingdom: +44 (0)208 088 8978

Client side GraphQL with F#

Follow along with Akash as he uses snowflaqe to generate client side GraphQL with the SAFE stack!

Whilst developing application front-ends I've often felt my components 'knew too much'. For example, they may call an API and only use two fields from the returned model, ignoring the rest. To get around this I could make an endpoint which only has the fields I want, but that doesn't really follow REST and doesn't scale.

Enter GraphQL, a query language that allows your UI to interrogate an API and retrieve only the fields you're interested in. It solves the problem of APIs oversharing and allows components to be contained, only knowing what they require.

When I made the switch to F# I was looking for a way to use GraphQL, and that's when I found Snowflaqe.

'A dotnet CLI tool for generating type-safe GraphQL clients for F#.'

In today's session I'd like to try out Snowflaqe and see how well GraphQL can be integrated into the SAFE stack.

Setup

  • SAFE app (v3.0.0-beta004)
  • Snowflaqe installed (dotnet tool install snowflaqe -g)
  1. Create a new SAFE app in the directory you want
    dotnet new SAFE

We won't be using the Server project so it's up to you if you'd like to remove it.

  1. Next, at the root level we'll need to create a snowflaqe.json file. This will contain the config that enables the auto generation of F# types and functions. From the docs this config is:
{
    "schema": "<schema>",
    "queries": "<queries>",
    "project": "<project>",
    "output": "<output>"
    ["target"]: "<target>",
    ["errorType"]: <custom error type>,
    ["overrideClientName"]: "<clientName>",
    ["copyLocalLockFileAssemblies"]: <true|false>,
    ["emitMetadata"]: <true|false>
}

We'll only need the first four items, since the target defaults to Fable.

schema

The schema property allows us to set the url of a GraphQL api. Using this does mean that we lose offline verification and type-checking, but we are exploring a new tool and it saves us building a GraphQL api which is a task for another day. For now we can use the Rick and Morty Api!

queries

The queries property holds a relative path to the queries which define the shape we want the data to be returned in. Let's create a folder, also at the root, and call it queries. Inside that create Characters.gql and paste the following:

query GetCharacters($name: String!) {
    characters(filter: { name: $name }) {
        results {
            name
            image
            id
            species
            status
        }
    }
}

Try out the above query in the playground. Provide the following as the input variable:

{ "name": "Morty"}

project

The title of the generated project, I'm going to use RickAndMorty.

output

A relative or absolute path to use as an output folder.

The config should now look like:

{
    "schema": "https://rickandmortyapi.com/graphql",
    "queries": "./queries",
    "project": "RickAndMorty",
    "output": "./src/output"
}

You should also have an empty output folder, and a queries folder that contains Characters.gql

  1. With all the setup in place we just need to run
snowflaqe --generate

You should see four files that have been generated in the output folder:

  • RickAndMorty.fsproj
  • RickAndMorty.Types.fs
  • RickAndMorty.GetCharacters.fs
  • RickAndMorty.GraphqlClient.fs

Understanding what's been generated

RickAndMorty.Types.fs

namespace rec RickAndMorty

[<Fable.Core.StringEnum; RequireQualifiedAccess>]
type CacheControlScope =
    | [<CompiledName "PUBLIC">] Public
    | [<CompiledName "PRIVATE">] Private

type FilterCharacter =
    { name: Option<string>
      status: Option<string>
      species: Option<string>
      ``type``: Option<string>
      gender: Option<string> }

type FilterLocation =
    { name: Option<string>
      ``type``: Option<string>
      dimension: Option<string> }

type FilterEpisode =
    { name: Option<string>
      episode: Option<string> }

/// The error returned by the GraphQL backend
type ErrorType = { message: string }

By comparing the above generated types with the docs from the playground. It looks like these types are the query types that the API expects.

For example, the characters query can take two optional paramters:

  • page: int
  • filter: FilterCharacter

Where FilterCharacter is defined as:

type FilterCharacter {
    name: String
    status: String
    species: String
    type: String
    gender: String
}

In GraphQL the fields default to null if the ! is not present. The following describes a Person whose name cannot be null.

type Person {
    name: String!
}

This explains why the generated types are mostly options. A benefit of using F# with GraphQL is that optional fields can be modelled as such, rather than using null.

RickAndMorty.GetCharacters.fs

[<RequireQualifiedAccess>]
module rec RickAndMorty.GetCharacters

type InputVariables = { name: string }

type Character =
    { /// The name of the character.
      name: Option<string>
      /// Link to the character's image.All images are 300x300px and most are medium shots or portraits since they are intended to be used as avatars.
      image: Option<string>
      /// The id of the character.
      id: Option<string>
      /// The species of the character.
      species: Option<string>
      /// The status of the character ('Alive', 'Dead' or 'unknown').
      status: Option<string> }

/// Get the list of all characters
type Characters =
    { results: Option<list<Option<Character>>> }

type Query =
    { /// Get the list of all characters
      characters: Option<Characters> }

This is where the actual query response seems to be generated. I really like that the comments from the docs are also placed here since they provide small tips like being able to refactor status into a discriminated union.

RickAndMorty.GraphqlClient.fs

namespace RickAndMorty

open Fable.SimpleHttp
open Fable.SimpleJson

type GraphqlInput<'T> = { query: string; variables: Option<'T> }
type GraphqlSuccessResponse<'T> = { data: 'T }
type GraphqlErrorResponse = { errors: ErrorType list }

type RickAndMortyGraphqlClient(url: string, headers: Header list) =
    new(url: string) = RickAndMortyGraphqlClient(url, [ ])

    member _.GetCharacters(input: GetCharacters.InputVariables) =
        async {
            let query = """
                query GetCharacter($name: String!) {
                    characters(filter: { name: $name }) {
                        results {
                            name
                            image
                            id
                            species
                            status
                        }
                    }
                }
            """
            let! response =
                Http.request url
                |> Http.method POST
                |> Http.headers [ Headers.contentType "application/json"; yield! headers ]
                |> Http.content (BodyContent.Text (Json.serialize { query = query; variables = Some input }))
                |> Http.send

            match response.statusCode with
            | 200 ->
                let response = Json.parseNativeAs<GraphqlSuccessResponse<GetCharacter.Query>> response.responseText
                return Ok response.data

            | errorStatus ->
                let response = Json.parseNativeAs<GraphqlErrorResponse> response.responseText
                return Error response.errors
        }

This is definitely where the magic happens. RickAndMortyGraphqlClient is a type that can be given a GraphQL endpoint.
GetCharacters is a member on that type whose signature is

GetCharacters.InputVariables -> Async<Result<GetCharacters.Query, ErrorType list>>

This will work great with Elmish commands, and errors are being returned as a list (rather than only returning the first error).

  1. Add a reference to the RickAndMorty.fsproj from Client.fsproj
    <ItemGroup>
        <ProjectReference Include="..\output\RickAndMorty.fsproj" />
        <ProjectReference Include="..\Shared\Shared.fsproj" />
    </ItemGroup>
  1. Within the output directory that holds all the generated files run
    dotnet restore

With all that setup, let's use the generated code to display a list of Rick and Morty characters.

The next steps will be in the Client project

  1. In Index.fs, open the RickAndMorty module

    open RickAndMorty
  2. Add the following types:

    type AsyncOperationStatus<'t> =
    | Started
    | Finished of 't
    type Deferred<'t> =
    | HasNotStartedYet
    | InProgress
    | Resolved of 't

    I've lifted these from the fantastic Elmish book.

  3. Update the type Model:

    type Model = { Characters: Deferred<GetCharacters.Character list>; SearchValue: string  }

    You can create your own Character type that takes advantage of a Status discriminated union and ensures fields like id and name aren't optional. But just to show how we could use the snowflaqe types I'll be using GetCharacters.Character.

  4. Update the type Msg:

    type Msg =
    | GetRickAndMortyCharacters of AsyncOperationStatus<Result<GetCharacters.Query,ErrorType list>>
    | SetSearchValue of string
    | SearchCharacter
  5. Create an instance of the GraphQl client and update the init function:

let client = RickAndMortyGraphqlClient("https://rickandmortyapi.com/graphql")

let init () : Model * Cmd<Msg> =
    let model = { Characters = List.empty; SearchValue = "" }
    let input: GetCharacters.InputVariables = { name = model.SearchValue }
    let cmd = Cmd.OfAsync.perform client.GetCharacters input (Finished >> GetRickAndMortyCharacters)
    model, cmd
  1. Replace the update function:

    let update (msg: Msg) (model: Model) : Model * Cmd<Msg> =
    match msg with
    | GetRickAndMortyCharacters Started -> model, Cmd.none
    | GetRickAndMortyCharacters (Finished (Error e)) -> model, Cmd.none
    | GetRickAndMortyCharacters (Finished (Ok queryResult)) ->
        let characters =
            queryResult.characters
            |> Option.toList
            |> List.collect (fun characters ->
                characters.results
                |> Option.toList
                |> List.concat
                |> List.choose id)
        { model with Characters = Resolved characters }, Cmd.none
    | SetSearchValue searchVal -> { model with SearchValue = searchVal }, Cmd.none
    | SearchCharacter ->
        let cmd =
            Cmd.OfAsync.perform client.GetCharacters { name = model.SearchValue } (Finished >> GetRickAndMortyCharacters)
        model, cmd
  2. To create a nice view you can add the following:

    let displayOption<'t> (f: 't -> IReactProperty) (opt) = opt |> Option.toList |> (List.map f)
    let displayOptionString = displayOption<string> prop.text
    let displayOptionImage = displayOption prop.src
    let navBar =
    Bulma.navbar [
        prop.style [
            style.backgroundColor "#FF3CAC"
            style.backgroundImage "linear-gradient(225deg, #FF3CAC 0%, #784BA0 50%, #2B86C5 100%)"
        ]
        prop.children [
            Bulma.navbarBrand.div [
                Bulma.navbarItem.a [
                    prop.style [ style.color "white"; style.fontSize 20 ]
                    prop.text "Client side GraphQL"
                ]
            ]
        ]
    ]
    let characterView (character: GetCharacter.Character) =
    Bulma.box [
    prop.style [ style.height 300; style.width 200 ]
    prop.children [
        Html.img (displayOptionImage character.image)
        Html.div (displayOptionString character.name)
        Html.div (displayOptionString character.species)
        Html.div (displayOptionString character.status)
        ]
    ]
    let charactersView (characters: GetCharacters.Character list) (dispatch: Msg -> unit) =
    Bulma.columns [
        columns.isMultiline
        prop.children [
        for character in characters do
        Bulma.column [
            characterView character
        ]
        ]
    ]
    let view (model: Model) (dispatch: Msg -> unit) =
    Html.div [
        prop.children [
            navBar
            Bulma.container [
                prop.style [ 
                    style.marginTop 50
                    style.display.flex
                    style.justifyContent.center
                    style.alignContent.center
                    style.flexDirection.column ]
                prop.children [
                    Bulma.box [
                        Bulma.columns [
                            Bulma.column [
                                column.is10
                                prop.children [
                                    Bulma.input.search [
                                        prop.value model.SearchValue
                                        prop.onChange (SetSearchValue >> dispatch)
                                    ]
                                ]
                            ]
                            Bulma.column [
                                column.is2
                                prop.children [
                                    Bulma.button.button [
                                        prop.style [ style.width (length.percent 100) ]
                                        prop.text "Search"
                                        prop.onClick (fun _ -> SearchCharacter |> dispatch)
                                    ]
                                ]
                            ]
                        ]
                    ]
                    match model.Characters with
                    | HasNotStartedYet -> Html.div "Has not started"
                    | InProgress -> Html.div "Loading"
                    | Resolved characters ->
                        match characters with
                        | [] -> Html.div "No characters with that name"
                        | _ -> charactersView characters dispatch
                    ]
            ]
        ]
    ]

πŸŽ‰πŸŽ‰DONE!!πŸŽ‰πŸŽ‰

Snowflaqe is an awesome tool! The translation from GraphQL types to F# records is seamless making it feel like a natural set of tooling. If you get stuck you can find the code for this demo here. There are a lot more features that Snowflaqe has to offer, from mutations to generating discriminated unions from fragments.

Special thanks to Zaid who helped get this demo running 😁.