Skip to content

😴 ReScript RPC-like client, contract, and server implementation for a pure REST API

License

Notifications You must be signed in to change notification settings

DZakh/rescript-rest

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

84 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

CI codecov npm

ReScript Rest 😴

  • 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 uses eval 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.

Super Simple Example

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:

Tutorials

  • Building and consuming REST API in ReScript with rescript-rest and Fastify (YouTube)
  • Learn more about ReScript Schema (Dev.to)

Table of Contents

Install

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"],
}

Route Definition

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.

RPC-like abstraction

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.

Path Parameters

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.

Query Parameters

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.

Request Headers

You can add headers to the request by using the s.header method in the input definition.

Authentication Header

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

Raw Body

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

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))
    },
  ],
})

Response Headers

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.")),
      }
    }
  ],
})

Temporary Redirect

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).

Fetch & Client

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, ...)

API Fetcher

You can override the client fetching logic by passing the ~fetcher param.

Client-side Integrations

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. (⚠️ Currently supported only for query and path fields)

Polling

Contract.getTodos->Swr.use(~input=(), ~options={ refreshInterval: 1000 })

Current Limitations

  • Supports only useSwr hook with GET method routes
  • Header field updates don't trigger refetching
  • Please create a PR to extend available bindings

Server-side Integrations

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()

Raw Body for Webhooks

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)
    | _ => ()
    }
  })
})

Current Limitations

  • 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.

Current Limitations

  • Doesn't support array/object-like query params
  • Has issues with paths with :

OpenAPI Documentation with Fastify & Scalar

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.

Useful Utils

Rest.url

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

About

😴 ReScript RPC-like client, contract, and server implementation for a pure REST API

Topics

Resources

License

Stars

Watchers

Forks