-
RPC-like client with no codegen
Fully typed RPC-like client, with no need for code generation! -
API design agnostic
REST? HTTP-RPC? Your own custom hybrid? rescript-rest doesn't care! -
First class DX
Use your application data structures and types without worrying about how they're transformed and transferred. -
Small package size and tree-shakable routes
Routes comple to simple functions which allows tree-shaking only possible with ReScript.
β οΈ rescript-rest relies on rescript-schema which useseval
for parsing. It's usually fine but might not work in some environments like Cloudflare Workers or third-party scripts used on pages with the script-src header.
Define your API contract somewhere shared, for example, Contract.res
:
let getPosts = Rest.route(() => {
path: "/posts",
method: Get,
input: s => {
"skip": s.query("skip", S.int),
"take": s.query("take", S.int),
"page": s.header("x-pagination-page", S.option(S.int)),
},
responses: [
s => {
s.status(200)
s.field("posts", S.array(postSchema))
},
],
})
In the same file set an endpoint your fetch calls should use:
// Contract.res
Rest.setGlobalClient("http://localhost:3000")
Consume the API on the client with a RPC-like interface:
let result = await Contract.getPosts->Rest.fetch(
{"skip": 0, "take": 10, "page": Some(1)}
// ^-- Fully typed!
) // βΉοΈ It'll do a GET request to http://localhost:3000/posts?skip=0&take=10 with the `{"x-pagination-page": "1"}` headers
Or use the SWR client-side integration and consume your data in React components:
@react.component
let make = () => {
let posts = Contract.getPosts->Swr.use(~input={"skip": 0, "take": 10, "page": Some(1)})
switch posts {
| {error: Some(_)} => "Something went wrong!"->React.string
| {data: None} => "Loading..."->React.string
| {data: Some(posts)} => <Posts posts />
}
}
Fulfil the contract on your sever, with a type-safe Fastify or Next.js integrations:
let app = Fastify.make()
app->Fastify.route(Contract.getPosts, ({input}) => {
queryPosts(~skip=input["skip"], ~take=input["take"], ~page=input["page"])
})
// ^-- Both input and return value are fully typed!
let _ = app->Fastify.listen({port: 3000})
Examples from public repositories:
- Building and consuming REST API in ReScript with rescript-rest and Fastify (YouTube)
- Learn more about ReScript Schema (Dev.to)
- Super Simple Example
- Tutorials
- Table of Contents
- Install
- Route Definition
- Fetch & Client
- Client-side Integrations
- Server-side Integrations
- Useful Utils
Install peer dependencies rescript
(instruction) with rescript-schema
(instruction).
And ReScript Rest itself:
npm install rescript-rest
Add rescript-rest
to bs-dependencies
in your rescript.json
:
{
...
+ "bs-dependencies": ["rescript-rest"],
}
Routes are the main building block of the library and a perfect way to describe a contract between your client and server.
For every route you can describe how the HTTP transport will look like, the 'input
and 'output
types, as well as add additional metadata to use for OpenAPI.
Alternatively if you use ReScript Rest both on client and server and you don't care about how the data is transfered, there's a helper built on top of Rest.route
. Just define input and output schemas and done:
let getPosts = Rest.rpc(() => {
input: S.schema(s => {
"skip": s.matches(S.int),
"take": s.matches(S.int),
"page": s.matches(S.option(S.int)),
}),
output: S.array(postSchema),
})
let result = await Contract.getPosts->Rest.fetch(
{"skip": 0, "take": 10, "page": Some(1)}
)
// βΉοΈ It'll do a POST request to http://localhost:3000/getPosts with the `{"skip": 0, "take": 10, "page": 1}` body and application/json Content Type
This is a code snipped from the super simple example above. Note how I only changed the route definition, but the fetching call stayed untouched. The same goes for the server implementation - if the input and output types of the route don't change there's no need to rewrite any logic.
π§ The path for the route is either taken from
operationId
or the name of the route variable.
You can define path parameters by adding them to the path
strin with a curly brace {}
including the parameter name. Then each parameter must be defined in input
with the s.param
method.
let getPost = Rest.route(() => {
path: "/api/author/{authorId}/posts/{id}",
method: Get,
input: s => {
"authorId": s.param("authorId", S.string->S.uuid),
"id": s.param("id", S.int),
},
responses: [
s => s.data(postSchema),
],
})
let result = await getPost->Rest.fetch(
{
"authorId": "d7fa3ac6-5bfa-4322-bb2b-317ca629f61c",
"id": 1
}
) // βΉοΈ It'll do a GET request to http://localhost:3000/api/author/d7fa3ac6-5bfa-4322-bb2b-317ca629f61c/posts/1
If you would like to run validations or transformations on the path parameters, you can use rescript-schema
features for this. Note that the parameter names in the s.param
must match the parameter names in the path
string.
You can add query parameters to the request by using the s.query
method in the input
definition.
let getPosts = Rest.route(() => {
path: "/posts",
method: Get,
input: s => {
"skip": s.query("skip", S.int),
"take": s.query("take", S.int),
},
responses: [
s => s.data(S.array(postSchema)),
],
})
let result = await getPosts->Rest.fetch(
{
"skip": 0,
"take": 10,
}
) // βΉοΈ It'll do a GET request to http://localhost:3000/posts?skip=0&take=10
You can also configure rescript-rest to encode/decode query parameters as JSON by using the jsonQuery
option. This allows you to skip having to do type coercions, and allow you to use complex and typed JSON objects.
You can add headers to the request by using the s.header
method in the input
definition.
For the Authentication header there's an additional helper s.auth
which supports Bearer
and Basic
authentication schemes.
let getPosts = Rest.route(() => {
path: "/posts",
method: Get,
input: s => {
"token": s.auth(Bearer),
"pagination": s.header("x-pagination", S.option(S.int)),
},
responses: [
s => s.data(S.array(postSchema)),
],
})
let result = await getPosts->Rest.fetch(
{
"token": "abc",
"pagination": 10,
}
) // βΉοΈ It'll do a GET request to http://localhost:3000/posts with the `{"authorization": "Bearer abc", "x-pagination": "10"}` headers
For some low-level APIs, you may need to send raw body without any additional processing. You can use s.rawBody
method to define a raw body schema. The schema should be string-based, but you can apply transformations to it using s.variant
or s.transform
methods.
let getLogs = Rest.route(() => {
path: "/logs",
method: POST,
input: s => s.rawBody(S.string->S.transform(s => {
// If you use the route on server side, you should also provide the parse function here,
// But for client side, you can omit it
serialize: logLevel => {
`{
"size": 20,
"query": {
"bool": {
"must": [{"terms": {"log.level": ${logLevels}}}]
}
}
}`
}
})),
responses: [
s => s.data(S.array(S.string)),
],
})
let result = await getLogs->Rest.fetch("debug")
// βΉοΈ It'll do a POST request to http://localhost:3000/logs with the body `{"size": 20, "query": {"bool": {"must": [{"terms": {"log.level": ["debug"]}}]}}}` and the headers `{"content-type": "application/json"}`
You can also use routes with rawBody
on the server side with Fastify as any other route:
app->Fastify.route(getLogs, async input => {
// Do something with input and return response
})
π§ Currently Raw Body is sent with the application/json Content Type. If you need support for other Content Types, please open an issue or PR.
Responses are described as an array of response definitions. It's possible to assign the definition to a specific status using s.status
method.
If s.status
is not used in a response definition, it'll be treated as a default
case, accepting a response with any status code. And for the server-side code, it'll send a response with the status code 200
.
let createPost = Rest.route(() => {
path: "/posts",
method: Post,
input: _ => (),
responses: [
s => {
s.status(201)
Ok(s.data(postSchema))
},
s => {
s.status(404)
Error(s.field("message", S.string))
},
],
})
Responses from an API can include custom headers to provide additional information on the result of an API call. For example, a rate-limited API may provide the rate limit status via response headers as follows:
HTTP 1/1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 2016-10-12T11:00:00Z
{ ... }
You can define custom headers in a response as follows:
let ping = Rest.route(() => {
path: "/ping",
method: Get,
summary: "Checks if the server is alive",
input: _ => (),
responses: [
s => {
s.status(200)
s.description("OK")
{
"limit": s.header("X-RateLimit-Limit", S.int->S.description("Request limit per hour.")),
"remaining": s.header("X-RateLimit-Remaining", S.int->S.description("The number of requests left for the time window.")),
"reset": s.header("X-RateLimit-Reset", S.string->S.datetime->S.description("The UTC date/time at which the current rate limit window resets.")),
}
}
],
})
You can define a redirect using Route response definition:
let route = Rest.route(() => {
path: "/redirect",
method: Get,
summary: "Redirect to another URL",
description: `This endpoint redirects the client to "/new-destination" using an HTTP "307 Temporary Redirect".
The request method (e.g., "GET", "POST") is preserved.`,
input: _ => (),
responses: [
s => {
s.description("Temporary redirect to another URL.")
// Use literal to hardcode the value
let _ = s.redirect(S.literal("/new-destination"))
// Or string schema to dynamically set it
s.redirect(S.string)
}
s => {
s.description("Bad request.")
s.status(400)
}
],
})
In a nutshell, the redirect
function is a wrapper around s.status(307)
and s.header("location", schema)
.
To call Rest.fetch
you either need to explicitely pass a client
as an argument or have it globally set.
I recommend to set a global client in the contract file:
// Contract.res
Rest.setGlobalClient("http://localhost:3000")
If you pass the endpoint via environment variables, I recommend using my another library rescript-envsafe:
// PublicEnv.res
%%private(let envSafe = EnvSafe.make())
let apiEndpoint = envSafe->EnvSafe.get(
"NEXT_PUBLIC_API_ENDPOINT",
~input=%raw(`process.env.NEXT_PUBLIC_API_ENDPOINT`),
S.url(S.string),
)
envSafe->EnvSafe.close
// Contract.res
Rest.setGlobalClient(PublicEnv.apiEndpoint)
If you can't or don't want to use a global client, you can manually pass it to the Rest.fetch
:
let client = Rest.client(PublicEnv.apiEndpoint)
await route->Rest.fetch(input, ~client)
This might be useful when you interact with multiple backends in a single application. For this case I recommend to have a separate contract file for every backend and include wrappers for fetch with already configured client:
let client = Rest.client(PublicEnv.apiEndpoint)
let fetch = Rest.fetch(~client, ...)
You can override the client fetching logic by passing the ~fetcher
param.
React Hooks for Data Fetching - With SWR, components will get a stream of data updates constantly and automatically. And the UI will be always fast and reactive.
npm install rescript-rest swr
@react.component
let make = () => {
let posts = Contract.getPosts->Swr.use(~input={"skip": 0, "take": 10, "page": Some(1)})
switch posts {
| {error: Some(_)} => "Something went wrong!"->React.string
| {data: None} => "Loading..."->React.string
| {data: Some(posts)} => <Posts posts />
}
}
It'll automatically refetch the data when the input parameters change. (query
and path
fields)
Contract.getTodos->Swr.use(~input=(), ~options={ refreshInterval: 1000 })
- Supports only
useSwr
hook with GET method routes - Header field updates don't trigger refetching
- Please create a PR to extend available bindings
Next.js is a React framework for server-side rendering and static site generation.
Currently rescript-rest
supports only page API handlers.
Start with defining your API contract:
let getPosts = Rest.route(() => {
path: "/getPosts",
method: Get,
input: _ => (),
responses: [
s => {
s.status(200)
s.data(S.array(postSchema))
}
]
})
Create a pages/api
directory and add a file getPosts.res
with the following content:
let default = Contract.getPosts->RestNextJs.handler(async ({input, req, res}) => {
// Here's your logic
[]
})
Then you can call your API handler from the client:
let posts = await Contract.getPosts->Rest.fetch()
To make Raw Body work with Next.js handler, you need to disable the automatic body parsing. One use case for this is to allow you to verify the raw body of a webhook request, for example from Stripe.
π§ This example uses another great library ReScript Stripe
let stripe = Stripe.make("sk_test_...")
let route = Rest.route(() => {
path: "/api/stripe/webhook",
method: Post,
input: s => {
"body": s.rawBody(S.string),
"sig": s.header("stripe-signature", S.string),
},
responses: [
s => {
s.status(200)
let _ = s.data(S.literal({"received": true}))
Ok()
},
s => {
s.status(400)
Error(s.data(S.string))
},
],
})
// Disable bodyParsing to make Raw Body work
let config: RestNextJs.config = {api: {bodyParser: false}}
let default = RestNextJs.handler(route, async ({input}) => {
stripe
->Stripe.Webhook.constructEvent(
~body=input["body"],
~sig=input["sig"],
// You can find your endpoint's secret in your webhook settings
~secret="whsec_...",
)
->Result.map(event => {
switch event {
| CustomerSubscriptionCreated({data: {object: subscription}}) =>
await processSubscription(subscription)
| _ => ()
}
})
})
- Doesn't support path parameters
Fastify is a fast and low overhead web framework, for Node.js. You can use it to implement your API server with rescript-rest
.
To start, install rescript-rest
and fastify
:
npm install rescript-rest fastify
Then define your API contract:
let getPosts = Rest.route(() => {...})
And implement it on the server side:
let app = Fastify.make()
app->Fastify.route(Contract.getPosts, async ({input}) => {
// Implementation where return type is promise<'response>
})
let _ = app->Fastify.listen({port: 3000})
π§
rescript-rest
ships with minimal bindings for Fastify to improve the integration experience. If you need more advanced configuration, please open an issue or PR.
- Doesn't support array/object-like query params
- Has issues with paths with
:
ReScript Rest ships with a plugin for Fastify to generate OpenAPI documentation for your API. Additionally, it also supports Scalar which is a free, open-source, self-hosted API documentation tool.
To start, you need to additionally install @fastify/swagger
which is used for OpenAPI generation. And if you want to host your documentation on a server, install @scalar/fastify-api-reference
which is a nice and free OpenAPI UI:
npm install @fastify/swagger @scalar/fastify-api-reference
Then let's connect the plugins to our Fastify app:
let app = Fastify.make()
// Set up @fastify/swagger
app->Fastify.register(
Fastify.Swagger.plugin,
{
openapi: {
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0",
},
},
},
)
app->Fastify.route(Contract.getPosts, async ({input}) => {
// Implementation where return type is promise<'response>
})
// Render your OpenAPI reference with Scalar
app->Fastify.register(Fastify.Scalar.plugin, {routePrefix: "/reference"})
let _ = await app->Fastify.listen({port: 3000})
Console.log("OpenAPI reference: http://localhost:3000/reference")
Also, you can use the Fastify.Swagger.generate
function to get the OpenAPI JSON.
Rest.url
is a helper function which builds a complete URL for a given route and input.
let getPosts = Rest.route(() => {
path: "/posts",
method: Get,
input: s => {
"skip": s.query("skip", S.int),
"take": s.query("take", S.int),
},
responses: [
s => s.data(S.array(postSchema)),
],
})
let url = Rest.url(
getPosts,
{
"skip": 0,
"take": 10,
}
) //? /posts?skip=0&take=10