Skip to content

Instantly share code, notes, and snippets.

@kgoggin
Last active April 27, 2020 08:45
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kgoggin/0d712c7ea878200cb42ad77143844399 to your computer and use it in GitHub Desktop.
Save kgoggin/0d712c7ea878200cb42ad77143844399 to your computer and use it in GitHub Desktop.
Custom react-apollo ReasonML bindings
open ApolloTypes;
type js_read_query_options = {
.
"query": queryString,
"variables": Js.undefined(Js.Json.t),
};
type js_write_query_options = {
.
"query": queryString,
"data": Js.Json.t,
};
[@bs.obj]
external makeJSReadQueryOptions:
(~query: queryString, ~variables: Js.Json.t=?, unit) => _ =
"";
type js_cache = {
.
"readQuery": [@bs.meth] (js_read_query_options => Js.Json.t),
"writeQuery": [@bs.meth] (js_write_query_options => unit),
};
type readQuery =
(~query: ApolloTypes.queryString, ~variables: Js.Json.t=?, unit) => Js.Json.t;
type writeQuery =
(~query: ApolloTypes.queryString, ~data: Js.Json.t, unit) => unit;
type cache = {
readQuery,
writeQuery,
};
type updateFunc('response) = (cache, 'response) => unit;
[@bs.deriving jsConverter]
type jsCode = [
| [@bs.as "UNIQUE_CONSTRAINT"] `UniqueConstraint
| [@bs.as "UNAUTHENTICATED"] `Unauthenticated
| [@bs.as "BAD_USER_INPUT"] `BadUserInput
| [@bs.as "NOT_FOUND"] `NotFound
];
[@bs.deriving abstract]
type jsException_ = {
[@bs.optional]
invalidArguments: array((string, string)),
};
[@bs.deriving abstract]
type jsExtensions = {
code: string,
[@bs.as "exception"]
exception_: jsException_,
};
[@bs.deriving abstract]
type jsGraphQLError = {extensions: jsExtensions};
[@bs.deriving abstract]
type jsNetworkError = {message: string};
[@bs.deriving abstract]
type jsError = {
graphQLErrors: array(jsGraphQLError),
networkError: Js.Nullable.t(jsNetworkError),
};
external toApolloError: Js.Promise.error => jsError = "%identity";
type graphQLError =
| Unauthenticated
| UniqueConstraint
| BadUserInput(list((string, string)))
| NotFound
| Unknown;
type t =
| Network(string)
| GraphQL(graphQLError, list(graphQLError));
let mapError: jsError => t =
jsErr =>
switch (jsErr->networkErrorGet->Js.Nullable.toOption) {
| Some(ne) => Network(ne->messageGet)
| None =>
let gqlErrors =
jsErr->graphQLErrorsGet
|> Array.map(gqle =>
switch (gqle->extensionsGet->codeGet->jsCodeFromJs) {
| Some(`BadUserInput) =>
let invalidArgs =
switch (
gqle->extensionsGet->exception_Get->invalidArgumentsGet
) {
| None => []
| Some(args) => args |> Array.to_list
};
BadUserInput(invalidArgs);
| Some(`Unauthenticated) => Unauthenticated
| Some(`UniqueConstraint) => UniqueConstraint
| Some(`NotFound) => NotFound
| None => Unknown
}
)
|> Array.to_list;
GraphQL(gqlErrors->List.hd, gqlErrors);
};
/**
* An abstract type to describe a query string object.
*/
type queryString;
/**
* The signature of the `graphql-tag/gql` function that transforms a GraphQL
* query string to the standard GraphQL AST.
* https://github.com/apollographql/graphql-tag
*/
type gql = (. string) => queryString;
[@bs.module] external gql: gql = "graphql-tag";
/**
* An abstract type to describe an Apollo Link object.
*/
type apolloLink;
/**
* An abstract type to describe an Apollo Cache object.
*/
type apolloCache;
type networkError = {. "statusCode": int};
module NetworkState = {
type t =
| Loading
| SetVariables
| FetchMore
| Refetch
| Poll
| Ready
| Error;
let to_int =
fun
| Loading => 1
| SetVariables => 2
| FetchMore => 3
| Refetch => 4
| Poll => 6
| Ready => 7
| Error => 8;
};
type apolloError;
/* TODO: define missing keys */
type apolloLinkErrorResponse = {. "networkError": option(networkError)};
open ApolloTypes;
open ApolloCache;
open Belt;
[@bs.module "react-apollo"]
external reactApolloMutation: ReasonReact.reactClass = "Mutation";
[@bs.deriving abstract]
type jsMutationResponseData = {data: Js.Json.t};
type js_update = (js_cache, jsMutationResponseData) => unit;
/* The JS options provided to the mutation function */
[@bs.deriving abstract]
type jsMutationOptions = {
[@bs.optional]
variables: Js.Json.t,
[@bs.optional]
update: js_update,
};
[@bs.deriving abstract]
type makeJSMutationProps = {
mutation: queryString,
[@bs.optional]
variables: Js.Json.t,
[@bs.optional]
update: js_update,
};
/* The JS function used to trigger the mutation */
type js_mutate =
jsMutationOptions =>
Repromise.Rejectable.t(jsMutationResponseData, ApolloError.jsError);
/* The JS object provided to the child function */
type js_mutation_result = {
.
"loading": bool,
"data": Js.Nullable.t(Js.Json.t),
"error": Js.Nullable.t(ApolloError.jsError),
"called": bool,
};
module type MutationDef = {
/* The variables used for the mutation */
type variables;
/* The result of the mutation */
type data;
/* A function to decode the JSON result to the result type */
let decodeResult: Js.Json.t => data;
/* A function to encode the variable type as JSON */
let encodeVariables: variables => Js.Json.t;
};
module type Mutation = {
include MutationDef;
type result = Result.t(data, ApolloError.t);
type state =
| Uncalled
| Loading
| Done(result);
type mutate =
(~variables: variables=?, ~update: updateFunc(data)=?, unit) =>
Repromise.t(result);
let make:
(
~mutation: ApolloTypes.queryString,
~variables: variables=?,
~update: updateFunc(data)=?,
(mutate, state) => ReasonReact.reactElement
) =>
ReasonReact.component(
ReasonReact.stateless,
ReasonReact.noRetainedProps,
ReasonReact.actionless,
);
};
module Make = (Definition: MutationDef) => {
include Definition;
type result = Result.t(data, ApolloError.t);
type state =
| Uncalled
| Loading
| Done(result);
/* The function provided as the first argument to the child function,
used to trigger the mutation */
type mutate =
(~variables: Definition.variables=?, ~update: updateFunc(data)=?, unit) =>
Repromise.t(result);
/* convert the JS result to a Reason one */
let mapMutationResult: js_mutation_result => state =
jsResult =>
switch (
jsResult##called,
jsResult##loading,
jsResult##data |> Js.Nullable.toOption,
jsResult##error |> Js.Nullable.toOption,
) {
| (false, false, _, _) => Uncalled
| (_, true, _, _) => Loading
| (true, false, _, Some(err)) =>
Done(Error(err |> ApolloError.mapError))
| (true, false, Some(data), None) => Done(Ok(data |> decodeResult))
/* This case is theoretically not possible, because it'd mean the mutation was called
but returned neither data nor an error. Not sure of a better way to handle this. */
| (true, false, None, None) => Uncalled
};
let updateFunction = (update, jsCache: js_cache, json) => {
let cache: cache = {
readQuery: (~query, ~variables=?, ()) =>
jsCache##readQuery(makeJSReadQueryOptions(~query, ~variables?, ())),
writeQuery: (~query, ~data, ()) =>
jsCache##writeQuery({"query": query, "data": data}),
};
update(cache, json->dataGet |> decodeResult);
};
let renderFunction =
(children, jsMutate: js_mutate, jsResult: js_mutation_result) => {
let mutate: mutate =
(~variables: option(variables)=?, ~update=?, _) =>
jsMutationOptions(
~variables=?variables->(Option.map(encodeVariables)),
~update=?update->(Option.map(updateFunction)),
(),
)
|> jsMutate
|> Repromise.Rejectable.map(res =>
Result.Ok(res->dataGet |> decodeResult)
)
|> Repromise.Rejectable.catch(err =>
Repromise.resolved(Result.Error(err |> ApolloError.mapError))
);
let child: ReasonReact.reactElement =
children(mutate, jsResult |> mapMutationResult);
child;
};
let make = (~mutation, ~variables=?, ~update=?, children) =>
ReasonReact.wrapJsForReason(
~reactClass=reactApolloMutation,
~props=
makeJSMutationProps(
~mutation,
~variables=?variables->(Option.map(encodeVariables)),
~update=?update->(Option.map(updateFunction)),
(),
),
children |> renderFunction,
);
};
open Belt;
[@bs.module "react-apollo"]
external reactApolloQuery: ReasonReact.reactClass = "Query";
let emptyObject = Json.Encode.([] |> object_);
type js_refetch_result = {
.
"data": Js.Null_undefined.t(Js.Json.t),
"error": Js.Nullable.t(ApolloError.jsError),
};
type js_query_result('variables) = {
.
"loading": bool,
"data": Js.Null_undefined.t(Js.Json.t),
"error": Js.Nullable.t(ApolloError.jsError),
"refetch":
[@bs.meth] (
Js.Null_undefined.t('variables) =>
Repromise.Rejectable.t(js_refetch_result, ApolloError.jsError)
),
"networkStatus": int,
"variables": Js.Null_undefined.t(Js.Json.t),
};
[@bs.deriving jsConverter]
type networkStatus =
| [@bs.as 1] Loading
| [@bs.as 2] SetVariables
| [@bs.as 3] FetchMore
| [@bs.as 4] Refetch
| [@bs.as 6] Poll
| [@bs.as 7] Ready
| [@bs.as 8] Error;
module type QueryConfig = {
type variables;
let query: ApolloTypes.queryString;
};
module Make = (Config: QueryConfig) => {
type refetchResponse = {
data: option(Graphql.Query.t),
error: option(ApolloError.t),
};
type response = {
data: option(Graphql.Query.t),
error: option(ApolloError.t),
loading: bool,
networkStatus: int,
refetch:
(~variables: Config.variables=?, unit) =>
Repromise.t(Result.t(refetchResponse, ApolloError.t)),
};
let mapData = d =>
switch (d |> Js.Null_undefined.toOption) {
| None => None
| Some(data) =>
/* result.data is an empty object before the data finishes loading. we want
to also treat that as a `None` condition */
data == emptyObject ? None : Some(data)
};
let mapError = e =>
(e |> Js.Nullable.toOption)->Option.map(ApolloError.mapError);
let resultMapper: js_query_result(Config.variables) => response =
result => {
error: result##error |> mapError,
loading: result##loading,
data: result##data |> mapData,
networkStatus: result##networkStatus,
refetch: (~variables=?, ()) =>
result##refetch(variables |> Js.Null_undefined.fromOption)
|> Repromise.Rejectable.map(res =>
Result.Ok({
data: res##data |> mapData,
error: res##error |> mapError,
})
)
|> Repromise.Rejectable.catch(err =>
Repromise.resolved(Result.Error(err |> ApolloError.mapError))
),
};
let make =
(
~variables: option(Config.variables)=?,
~ssr: bool=true,
~notifyOnNetworkStatusChange: bool=false,
children: response => ReasonReact.reactElement,
) =>
ReasonReact.wrapJsForReason(
~reactClass=reactApolloQuery,
~props={
"query": Config.query,
"variables": variables,
"ssr": ssr,
"notifyOnNetworkStatusChange": notifyOnNetworkStatusChange,
},
result =>
result |> resultMapper |> children
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment