Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/dzakh/rescript-rest
đ´ ReScript RPC-like client, contract, and server implementation for a pure REST API
https://github.com/dzakh/rescript-rest
contract rescript rest-api
Last synced: about 1 month ago
JSON representation
đ´ ReScript RPC-like client, contract, and server implementation for a pure REST API
- Host: GitHub
- URL: https://github.com/dzakh/rescript-rest
- Owner: DZakh
- License: mit
- Created: 2024-04-30T18:02:37.000Z (9 months ago)
- Default Branch: main
- Last Pushed: 2024-12-11T09:54:10.000Z (about 1 month ago)
- Last Synced: 2024-12-11T10:36:31.788Z (about 1 month ago)
- Topics: contract, rescript, rest-api
- Language: ReScript
- Homepage:
- Size: 355 KB
- Stars: 32
- Watchers: 3
- Forks: 0
- Open Issues: 2
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
[![CI](https://github.com/DZakh/rescript-rest/actions/workflows/ci.yml/badge.svg)](https://github.com/DZakh/rescript-rest/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/DZakh/rescript-rest/branch/main/graph/badge.svg?token=40G6YKKD6J)](https://codecov.io/gh/DZakh/rescript-rest)# 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**
Less unnecessary builds in monorepos, instant compile-time errors, and instantly view endpoint implementations through your IDEs "go to definition"- **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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src) header.
## Install
Install peer dependencies `rescript` ([instruction](https://rescript-lang.org/docs/manual/latest/installation)) and `rescript-schema` ([instruction](https://github.com/DZakh/rescript-schema/blob/main/docs/rescript-usage.md#install)).
Then run:
```sh
npm install rescript-rest
```Add `rescript-rest` to `bs-dependencies` in your `rescript.json`:
```diff
{
...
+ "bs-dependencies": ["rescript-rest"],
}
```## Super Simple Example
Easily define your API contract somewhere shared, for example, `Contract.res`:
```rescript
let getPosts = Rest.route(() => {
path: "/posts",
method: Get,
variables: 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))
},
],
})
```Consume the API on the client with a RPC-like interface:
```rescript
let client = Rest.client(~baseUrl="http://localhost:3000")let result = await client.call(
Contract.getPosts,
{
"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
```Fulfil the contract on your sever, with a type-safe Fasitfy integration:
```rescript
let app = Fastify.make()app->Fastify.route(Contract.getPosts, variables => {
queryPosts(~skip=variables["skip"], ~take=variables["take"], ~page=variables["page"])
})
// ^-- Both variables and return value are fully typed!let _ = app->Fastify.listen({port: 3000})
```**Examples from public repositories:**
- [Cli App Rock-Paper-Scissors](https://github.com/Nicolas1st/net-cli-rock-paper-scissors/blob/main/apps/client/src/Api.res)
## 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 `variables` with the `s.param` method.
```rescript
let getPost = Rest.route(() => {
path: "/api/author/{authorId}/posts/{id}",
method: Get,
variables: s => {
"authorId": s.param("authorId", S.string->S.uuid),
"id": s.param("id", S.int),
},
responses: [
s => s.data(postSchema),
],
})let result = await client.call(
getPost,
{
"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`](https://github.com/DZakh/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 `variables` definition.
```rescript
let getPosts = Rest.route(() => {
path: "/posts",
method: Get,
variables: s => {
"skip": s.query("skip", S.int),
"take": s.query("take", S.int),
},
responses: [
s => s.data(S.array(postSchema)),
],
})let result = await client.call(
getPosts,
{
"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 `variables` definition.
### Authentication header
For the Authentication header there's an additional helper `s.auth` which supports `Bearer` and `Basic` authentication schemes.
```rescript
let getPosts = Rest.route(() => {
path: "/posts",
method: Get,
variables: s => {
"token": s.auth(Bearer),
"pagination": s.header("x-pagination", S.option(S.int)),
},
responses: [
s => s.data(S.array(postSchema)),
],
})let result = await client.call(
getPosts,
{
"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.
```rescript
let getLogs = Rest.route(() => {
path: "/logs",
method: POST,
variables: 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 client.call(
getLogs,
"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:
```rescript
app->Fastify.route(getLogs, async variables => {
// Do something with variables 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`.
```rescript
let createPost = Rest.route(() => {
path: "/posts",
method: Post,
variables: _ => (),
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:
```rescript
let ping = Rest.route(() => {
path: "/ping",
method: Get,
summary: "Checks if the server is alive",
variables: _ => (),
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.")),
}
}
],
})
```## Server Implementation
### [Fastify](https://fastify.dev/)
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`:
```sh
npm install rescript-rest fastify
```Then define your API contract:
```rescript
let getPosts = Rest.route(() => {...})
```And implement it on the server side:
```rescript
let app = Fastify.make()app->Fastify.route(Contract.getPosts, async variables => {
// 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.
#### Known 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](https://github.com/fastify/fastify-swagger) to generate OpenAPI documentation for your API. Additionally, it also supports [Scalar](https://github.com/scalar/scalar/blob/main/packages/fastify-api-reference/README.md) 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:
```sh
npm install @fastify/swagger @scalar/fastify-api-reference
```Then let's connect the plugins to our Fastify app:
```rescript
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 variables => {
// 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.
## Planned Features
- [x] Support query params
- [x] Support headers
- [x] Support path params
- [x] Implement type-safe response
- [ ] Support custom fetch options
- [ ] Support non-json body
- [x] Generate OpenAPI from Contract
- [ ] Generate Contract from OpenAPI
- [x] Server implementation with Fastify
- [ ] NextJs integration
- [ ] Add TS/JS support