- Edited
Hi, and welcome back to my OCaml web development project! In the last 2 posts, we introduced the Bonsai web framework, and did a deep dive into understanding it. In this post, we'll connect our frontend to the API we built in the third post, via GraphQL.
Building a Safe GraphQL Client
GraphQL APIs are usually served over HTTP. Clients send a GET
or POST
request with a query and any applicable variables. The server returns a JSON response with either the requested data or an error. What does this mean for us?
- We'll need to send HTTP POST requests with an OCaml HTTP client library that supports JS of OCaml. For my project, I chose cohttp-lwt-jsoo.
- We would like to statically verify that our GraphQL queries/variables are valid. Luckily, this is made possible by graphql-ppx
The "Safe" Part
Let's start with the latter. One of the benefits of GraphQL is that its APIs must follow a strongly-typed schema, which can be viewed by users of the API. As a result, we can use the get-graphql-schema JS script to download the entire schema of our project's API. It's not super useful for humans, but it can be used by other programs to validate queries. That's where graphql-ppx
comes into play.
Remember how we made a "current user" query back in part 3? Well, now we can write the following:
[%graphql
{|
query CurrentUserQuery {
current_user {
email
}
}
|}]
;;
and if it's a valid query (i.e. all fields are present), graphql-ppx
will generate a module of the following type:
module CurrentUserQuery: Query
module type Query = sig
type t
type t_variables
module Raw : sig
type t
type t_variables
end
val query : string
val parse : Raw.t -> t
val serialize : t -> Raw.t
val serializeVariables : t_variables -> Raw.t_variables
val unsafe_fromJson : Yojson.Basic.t -> Raw.t
val toJson : Raw.t -> Yojson.Basic.t
val variablesToJson : Raw.t_variables -> Yojson.Basic.t
end
In other words, if we give graphql-ppx
a GraphQL query string, it will check whether it satisfies our API's schema, and if so, automatically give us a type-safe representation of the results, as well as functions for converting the data and variables to/from JSON. The only configuration we need to do is include it in our dune file and add a graphql_schema.json to our project's root.
Now we can be sure that our GraphQL query strings and variables are valid, and access query results in a type-safe way!
One minor note: I needed to put all generation of GraphQL queries in a separate dune folder from the rest of my frontend code, because it conflicted with some of the Jane Street PPXs used in the main frontend
folder.
The "Client" Part
The implementation of our client is pretty simple:
- Format the query string and variables into a JSON object
- Send that object to the server via cohttp-lwt-jsoo
- If the request failed, return an error type. Otherwise, parse the data using helpers generated by
graphql-ppx
, and return a success type.
Here's the code:
module ForQuery (Q : Queries.Query) = struct
module SerializableQ = Queries.SerializableQuery(Q)
type response =
| Success of SerializableQ.t
| Unauthorized
| Forbidden
| NotFound
| TooManyRequests
| OtherError of string
[@@deriving sexp]
type query_body = { query : string; variables : SerializableQ.t_variables }
[@@deriving yojson_of]
let create_body q vars =
let yojson = yojson_of_query_body { query = q; variables = vars } in
let json = Yojson.Safe.to_string yojson in
Cohttp_lwt.Body.of_string json
let query ?(url = url) vars =
(* Send the query and variables via HTTP POST. *)
Cohttp_lwt_jsoo.Client.post
~headers:(Cohttp.Header.init_with "Content-Type" "application/json")
~body:(create_body Q.query vars) url
>>= fun (resp, raw_body) ->
let resp_lwt =
let body_str_lwt = Cohttp_lwt.Body.to_string raw_body in
body_str_lwt >|= fun body_str ->
match resp.status with
| #Cohttp.Code.success_status ->
(* If the request succeeded, parse it into a Q.t *)
let full_body_json = Yojson.Basic.from_string body_str in
let body_json = Yojson.Basic.Util.member "data" full_body_json in
let body_unsafe = Q.unsafe_fromJson body_json in
let body = Q.parse body_unsafe in
Success body
(* Handle "known" errors *)
| `Unauthorized -> Unauthorized
| `Forbidden -> Forbidden
| `Not_found -> NotFound
| `Too_many_requests -> TooManyRequests
| #Cohttp.Code.server_error_status -> OtherError body_str
| #Cohttp.Code.redirection_status -> OtherError body_str
| #Cohttp.Code.informational_status -> OtherError body_str
| #Cohttp.Code.client_error_status -> OtherError body_str
| `Code _ -> OtherError body_str
in
resp_lwt
end
Pretty straightforward. I needed to define some additional functions and modules for parsing the data to/from s expressions, but that's a minor technicality.
Query Loader
Now, I have a client that we can use to fetch any query/mutation from our server. I could use it directly in every component that uses GraphQL, but then I'd need to duplicate logic for:
- triggering queries on component activation
- loading indicators
- handling errors
- re-running queries if the URL changes
So instead, I decided to build a generic QueryLoader
component. It can be used as follows:
- You instantiate the query loader with a query type (
module Q
), and a component that takes the query as input (Q.t Value.t -> Vdom.Note.t Computation.t
), as well as any variables used in the query (Q.t_variables Value.t
). - The query loader fetches query results from the server, showing a "loading" message in the meantime.
- If any errors were encountered fetching the query, an error message is shown. Otherwise, the component is rendered with the query as input.
- If an optional
trigger
argument is passed (as a'a Value.t
), the query will be reloaded whenever the trigger's value changes.
This is the single most complicated component in my project, but also one of the most interesting for showcasing Bonsai's flexibility. It's 5x shorter than the equivalent component in React/Relay, with many less imports and a clearer flow of logic. It's a higher-order component, meaning that it takes another component (not instance, but component definition) as input. This pattern is possible in other frameworks, but OCaml's type system can model it much better than TypeScript or Flow.
You can skip the implementation if you want and go straight to the "applications" section, but I recommend trying to understand it: this component is where Bonsai really made sense for me. I've annotated the source code extensively to make it easier to follow. You can also check out this GitHub issue for a detailed explanation of a simpler version from the Bonsai team.
open! Core
open! Bonsai_web
open Bonsai.Let_syntax
module G = Nittany_market_frontend_graphql
(* This is an abstract implementation of a query loader
* which is used to implement the concrete, GraphQL-based loader
* towards the end of the file.*)
let private_component :
(* We start by naming our parametric types. *)
type data view trigger.
(* What to display while the query is loading: *)
view_while_loading:view Value.t ->
(* The component to display once the query has loaded: *)
computation:(data Value.t -> view Computation.t) ->
(* The type of the query: *)
(module Bonsai.Model with type t = data) ->
(* An effect that, when run, fetches the query: *)
data Effect.t Value.t ->
(* The type of a "reload trigger": *)
(module Bonsai.Model with type t = trigger) ->
(* The "reload trigger" itself: *)
trigger Value.t ->
view Computation.t =
(* Everything up to here has been an explicit type declaration. *)
fun ~view_while_loading ~computation (module Model) effect
(module ReloadTrigger) reload_trigger ->
(* The API response state is optional, since it won't be set until loaded. *)
let%sub response, set_response = Bonsai.state_opt [%here] (module Model) in
(* On activate, we'll dispatch our `a Effect.t`, and set the result as state. *)
let%sub on_activate =
let%arr effect = effect and set_response = set_response in
let%bind.Effect response_from_server = effect in
set_response (Some response_from_server)
in
let%sub () = Bonsai.Edge.lifecycle ~on_activate () in
(* When the trigger changes, we'll clear state, and re-run the
* `on_activate` effect to refresh the query.
*)
let on_change =
Value.map2 on_activate set_response ~f:(fun refetch set_resp _ ->
Effect.ignore_m (Effect.all [ set_resp None; refetch ]))
in
let%sub () =
Bonsai.Edge.on_change [%here]
(module ReloadTrigger)
reload_trigger ~callback:on_change
in
(* If the response has been loaded, we display the component.
* Otherwise, we show a loading indicator.
*)
match%sub response with
| None -> return view_while_loading
| Some response -> computation response
(* This is the concrete implementation of our query loader. *)
module ForQuery (Q : G.Queries.Query) = struct
module QSexpable = G.Queries.SerializableQuery (Q)
module Client = G.Client.ForQuery (Q)
module ClientResponse = struct
type t = Client.response [@@deriving sexp]
let equal a b = Sexplib0.Sexp.equal (sexp_of_t a) (sexp_of_t b)
end
let view_while_loading = Value.return @@ Vdom.Node.text "Loading..."
let error_handler component response =
Client.(
match%sub response with
| Success body -> component body
| Unauthorized -> Bonsai.const @@ Vdom.Node.text "Error: Unauthorized"
| Forbidden -> Bonsai.const @@ Vdom.Node.text "Error: Forbidden"
| NotFound -> Bonsai.const @@ Vdom.Node.text "Error: Not Found"
| TooManyRequests ->
Bonsai.const
@@ Vdom.Node.text "Error: Too Many Requests, try again later."
| OtherError err ->
let%arr err = err in
Vdom.Node.text err)
let component ?(trigger = Value.return "") inner qvars =
(* Wraps the component in an error handler, since the
* type returned by the API client will be `ClientResponse`, not `Q.t` directly.
*)
let inner_handled = error_handler inner in
(* Map our query variables into an effect that fetches the query.
* `Effect_lwt.of_deffered_fun` turns an async function into an effect function.
*)
let fetch_query =
Value.map
~f:(fun qvars -> Effect_lwt.of_deferred_fun Client.query qvars)
qvars
in
private_component ~view_while_loading ~computation:inner_handled
(module ClientResponse)
fetch_query
(module String)
trigger
end
Using GraphQL in a Component
Let's put it all together with an example of usage:
[%graphql
{|
query CurrentUserQuery {
current_user {
email
}
}
|}]
(* See note below regarding module placement. *)
let greeting (query: CurrentUserQuery.t) =
let%arr query = query in
let message = match current.user with
| Some u -> Printf.sprintf "Welcome, %s!" u.email
| None -> "You are not logged in."
in
Vdom.Node.div [
Vdom.Node.p [ Vdom.Node.text message ]
]
let root_component =
let query_vars = Value.return (CurrentUserQuery.makeVariables ())
let module CurrentUserLoader = Loader.ForQuery(CurrentUserQuery) in
CurrentUserLoader.component greeting query_vars
As noted previously, the [%graphql]
invocation would need to be in a separate library. Other than that, we've concisely and safely implemented a component that depends on dynamically-obtained data from the API.
I'll note that in hindsight, perhaps it would have been simpler to make the loader component take a query/mutation module as an argument rather than functorizing it:
let root_component =
let query_vars = Value.return (CurrentUserQuery.makeVariables ())
GraphqlLoader.component (module CurrentUserQuery) greeting query_vars
But that would be easy to change in the future.
Conclusion
In this post, we built a concise, type-safe, idiomatic mechanism for frontend components to interact with the backend API, demonstrating the flexibility and power of Bonsai and the OCaml type system. In the next post, I'll explain how to build a simple router in Bonsai, and conclude the project.