A RetroSearch Logo

Home - News ( United States | United Kingdom | Italy | Germany ) - Football scores

Search Query:

Showing content from https://github.com/DZakh/rescript-rest below:

DZakh/rescript-rest: 😴 ReScript RPC-like client, contract, and server implementation for a pure REST API

⚠️ 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.

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:

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

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

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

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.

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.

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

RetroSearch is an open source project built by @garambo | Open a GitHub Issue

Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo

HTML: 3.2 | Encoding: UTF-8 | Version: 0.7.4