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
)
- 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.
- 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
- 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).
- Add a reference to the
RickAndMorty.fsproj
fromClient.fsproj
<ItemGroup>
<ProjectReference Include="..\output\RickAndMorty.fsproj" />
<ProjectReference Include="..\Shared\Shared.fsproj" />
</ItemGroup>
- Within the
output
directory that holds all the generated files rundotnet 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
-
In Index.fs, open the
RickAndMorty
moduleopen RickAndMorty
-
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.
-
Update the type
Model
:type Model = { Characters: Deferred<GetCharacters.Character list>; SearchValue: string }
You can create your own
Character
type that takes advantage of aStatus
discriminated union and ensures fields likeid
andname
aren't optional. But just to show how we could use the snowflaqe types I'll be usingGetCharacters.Character
. -
Update the type
Msg
:type Msg = | GetRickAndMortyCharacters of AsyncOperationStatus<Result<GetCharacters.Query,ErrorType list>> | SetSearchValue of string | SearchCharacter
-
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
-
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
-
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 π.