sainthkh / reasonql

Type-safe and simple GraphQL library for ReasonML developers.
MIT License
96 stars 5 forks source link

ReasonQL: GraphQL in ReasonML way.

ReasonQL is a type-safe and simple GraphQL library for ReasonML developers.

ReasonQL does 2 things for you:

You might think it's too simple and you're now finding cool features like cache, "fetch more", reload, auth, etc. ==> If so, please check "Why I started this project".

Installation.

You need 2 packages: @reasonql/core and @reasonql/compiler. Install it with npm like below:

npm i @reasonql/core
npm i -D @reasonql/compiler

or with yarn

yarn add @reasonql/core
yarn add @reasonql/compiler --dev

And add @reasonql/core in bs-dependencies under bsconfig.json.

"bs-dependencies": [
  "@reasonql/core",
  "reason-react",
]

How to use ReasonQL.

This document assumes that you're familiar with ReasonML and GraphQL. If you're not sure what GraphQL is, check the offical documentation.

1. Write Query in a Reason file.

let query = ReasonQL.gql({|
  query AppQuery {
    hello {
      message
    }
  }
|})

Don't forget to use ReasonQL.gql function and {||} multiline string. ReasonQL compiler uses them to find if a file has graphql code or not.

WARNING: The query name (AppQuery above) is used for the name of the file generated by the compiler. So, do not use duplicate query names. Compiler doesn't warn this and multiple queries will try to overwrite a single type file.

2. Set up compiler.

To compile GraphQL queries, we need the path to the GraphQL schema file. Create reasonql.config.js at the project root and fill it like below:

module.exports = {
  schema: "path/to/schema.js",
}

3. Compile the query.

Add the below command to package.json:

"scripts": {
  "reasonql": "reasonql-compiler"
}

And run the command with npm run reasonql.

You can find AppQuery.re under src/.reasonql.

Note: As the files under src/.reasonql are generated by the ReasonQL Compiler, it is recommended to ignore the folder in .gitignore.

Note2: It is a really tedious job to type in npm run reasonql each time when queries are changed. So, when in development, use the option, -w, like below:

"scripts": {
  "reasonql": "reasonql-compiler",
  "reasonql:dev": "reasonql-compiler -w"
}

It watches reason files and regenerate files only when the GraphQL queries are changed.

4. Create Request module.

module Request = ReasonQL.MakeRequest(AppQuery, {
  let url = "http://localhost:4000";
});

MakeRequest functor receives 2 arguments: a module generated by the ReasonQL compiler and a module that contains the link to the server.

5. Send request and handle the response.

Request.send(Js.Dict.empty())
->Request.finished(data => {
  Js.log("Data fetched.");
})
Js.log("Loading data...");

All you need to remember are these 3 functions:

As send returns a Js.Promise and finished and finishedWithError have promise as their first argument, we can use the pipe syntax here.

You learned the basics of ReasonQL. Unlike other libraries like Apollo or Relay, you don't need to create React components to use GraphQL.

If you want to know how to make "hello world" with ReasonQL, check the example.

Other Features

If you want to see the working examples, check the snippets folder.

Type Conversions

5 scalar types (ID, Int, Float, String, Boolean) of GraphQL are converted into appropriate ReasonML types(string, int, float, string, bool). (By definition, ID type is serialized into STRING.)

Object types are compiled into ReasonML types.

# Schema
type Greeting {
  hello: String!
}

type Test1 {
  a: Greeting!
}

# Query
query AppQuery {
  a {
    hello
  }
}
type a = {
  hello: string,
};

type queryResult = {
  a: a,
};

Note: As you can see, object type name doesn't follow the actual name(greeting) in the type definition, but uses the variable name(a) to avoid type name conflict. More about name conflict below.

Nullability

In GraphQL, the field types without ! are nullable. So, they're translated into option-ed types in ReasonML.

Example:

# Schema
type Query {
  a: String
  b: String!
}

# Query
query Test1 {
  a
  b
}
type queryResult = {
  a: option(string),
  b: string,
}

Note: When a type is an option, we need to use pattern matching to cover all cases. In ReasonML, it's tedious and sometimes meaningless. So, when you define the schema for your app, always consider when null should be used. If you cannot find the meaningful case, add !. (Unlike many JavaScript examples, you'll find yourself adding many non-null types in ReasonML apps.)

Enum types

Conventionally, GraphQL enum values are written in all capital with underscores like EXTRA_LARGE. And they're strings internally.

However, ReasonML uses camel case with the first letter capital-cased. And they're compiled into numbers.

ReasonQL compiler translates that perfectly.

enum PatchSize {
  SMALL
  MEDIUM
  LARGE
  EXTRA_LARGE
}
type patchSize = 
  | Small
  | Medium
  | Large
  | ExtraLarge
  ;

Unlike other types, encoders and decoders of enum types are defined in EnumTypes.re file. And the functions are imported to each type file.

Name conflict and renaming types

Sometimes, field names of object types can conflict like below:

# Schema
type Query {
  hero: Person!
  villain: Person!
}

type Person {
  name: Name!
  ship: Ship
}

type Name {
  first: String!
  last: String
}

type Ship {
  name: String!
}

# Query
query AppQuery {
  hero {
    name @reasontype(name:"heroName") {
      first
      last
    }
    ship {
      name
    }
  }
  villain {
    name {
      first
    }
    ship @reasontype(name:"villainShip") {
      name
    }
  }
}
type heroName = {
  first: string,
  last: option(string),
};

type hero_ship_Ship = {
  name: string,
};

type hero = {
  name: heroName,
  ship: option(hero_ship_Ship),
};

type villain_name_Name = {
  first: string,
};

type villainShip = {
  name: string,
};

type villain = {
  name: villain_name_Name,
  ship: option(villainShip),
};

type queryResult = {
  hero: hero,
  villain: villain,
};

Both hero and villain have name and ship. In those cases, the type names are generated with the list of the names in the path and schema type name(i.e. hero_ship_Ship, villain_name_Name).

To avoid this, you can use @reasontype directive like @reasontype(name:"villainShip").

Define singular name.

Sometimes, it is logical to name a variable in plural and its type in singular like below:

# Schema
type Query {
  posts: [Post!]!
}

type Post {
  title: String!
  slug: String!
  content: String!
  summary: String
}

# Query
query AppQuery {
  posts @singular(name:"post") {
    title
    slug
    content
    summary
  }
}
type post = {
  title: string,
  slug: string,
  content: string,
  summary: option(string),
};

type queryResult = {
  posts: array(post),
};

Automatically merges fragments into the main query.

Borrowed the idea from Relay. When writing code, it is always a good idea to put related things together in one place. With fragments, you can define the data a component needs in the same file like below:

let query = ReasonQL.gql({|
  fragment PostFragment_post on Post {
    title
    summary
    slug
    ...ButtonFragment_post
  }
|})

let component = ReasonReact.statelessComponent("Post")

let make = (
  ~post: PostFragment.post,
  _children
) => {
  ...component,
  /* Code here */
}

You can read the full code here.

Then, ReasonQL compiler magically merges fragments into the main query.

Mutation and Arguments

Mutations work in the same way like queries. Use MakeRequest functor and send, finished functions. But in mutations, you need to use arguments a lot.

When there are arguments, the type of the argument of send function changes from Js.Dict to a specific variables.

So, we need to write code like below:

let saveTweet = ReasonQL.gql({|
  mutation SaveTweetMutation($tweet: TweetInput!) {
    saveTweet(tweet: $tweet) {
      success
      id
      tempId
      text
    }
  }
|})

module SaveTweet = ReasonQL.MakeRequest(SaveTweetMutation, Client);

SaveTweet.send({
  tweet: {
    text: tweet.text,
    tempId: tweet.id,
  }
})
->SaveTweet.finished(data => {
  Js.log("data recieved");
})

You can read the full code here.

Errors

Apollo Server provides a lot of error types. Among them, you need to provide your own type definition for UserInputError. So, we need decoders for those errors.

To do so, create special GraphQL schema file: errors.graphql.

And write down error types like below:

type LoginFormError {
  code: String!
  email: String
  password: String
}

And add the path to errors.graphql to reasonql.config.js like below:

module.exports = {
  schema: "../server/src/schema.js",
  errors: "../server/src/errors.graphql",
}

Then, the decoder will be generated at .reasonql/QueryErrors.re. Now, you can decode error contents like below:

Login.send({ email, password })
->Login.finishedWithError((result, errors) => {
  switch(errors) {
  | None => {
    login(Belt.Option.getExn(result.login));
    ReasonReact.Router.push("/");
  }
  | Some(errors) => {
    let {email, password}: QueryErrors.loginFormError 
      = QueryErrors.decodeLoginFormError(errors[0].extensions);
    self.send(ShowError(email, password));
  }
  }
})

Compiler options

Commandline options

Config file options

Contribution

Helps are always welcome. If you want to check how to contribute to the project, check this document.