Skip to content

Commit 80ae4ac

Browse files
committed
Redesign how client works
1 parent 024065b commit 80ae4ac

9 files changed

Lines changed: 346 additions & 227 deletions

File tree

README.md

Lines changed: 111 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,9 @@
1818

1919
> ⚠️ **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.
2020
21-
## Tutorials
22-
23-
- Building and consuming REST API in ReScript with rescript-rest and Fastify ([YouTube](https://youtu.be/37FY6a-zY20?si=72zT8Gecs5vmDPlD))
24-
2521
## Super Simple Example
2622

27-
Easily define your API contract somewhere shared, for example, `Contract.res`:
23+
Define your API contract somewhere shared, for example, `Contract.res`:
2824

2925
```rescript
3026
let getPosts = Rest.route(() => {
@@ -44,11 +40,17 @@ let getPosts = Rest.route(() => {
4440
})
4541
```
4642

43+
Set an endpoint your fetch calls should use:
44+
45+
```rescript
46+
// Contract.res
47+
Rest.setGlobalClient("http://localhost:3000")
48+
```
49+
4750
Consume the API on the client with a RPC-like interface:
4851

4952
```rescript
5053
let result = await Contract.getPosts->Rest.fetch(
51-
"http://localhost:3000",
5254
{"skip": 0, "take": 10, "page": Some(1)}
5355
// ^-- Fully typed!
5456
) // ℹ️ It'll do a GET request to http://localhost:3000/posts?skip=0&take=10 with the `{"x-pagination-page": "1"}` headers
@@ -85,11 +87,41 @@ let _ = app->Fastify.listen({port: 3000})
8587

8688
- [Cli App Rock-Paper-Scissors](https://github.com/Nicolas1st/net-cli-rock-paper-scissors/blob/main/apps/client/src/Api.res)
8789

90+
## Tutorials
91+
92+
- Building and consuming REST API in ReScript with rescript-rest and Fastify ([YouTube](https://youtu.be/37FY6a-zY20?si=72zT8Gecs5vmDPlD))
93+
94+
## Table of Contents
95+
96+
- [Super Simple Example](#super-simple-example)
97+
- [Tutorials](#tutorials)
98+
- [Table of Contents](#table-of-contents)
99+
- [Install](#install)
100+
- [Route Definition](#route-definition)
101+
- [Path Parameters](#path-parameters)
102+
- [Query Parameters](#query-parameters)
103+
- [Request Headers](#request-headers)
104+
- [Authentication Header](#authentication-header)
105+
- [Raw Body](#raw-body)
106+
- [Responses](#responses)
107+
- [Response Headers](#response-headers)
108+
- [Temporary Redirect](#temporary-redirect)
109+
- [Client-side Integrations](#client-side-integrations)
110+
- [SWR](#swr)
111+
- [Polling](#polling)
112+
- [Server-side Integrations](#server-side-integrations)
113+
- [Next.js](#nextjs)
114+
- [Raw Body for Webhooks](#raw-body-for-webhooks)
115+
- [Fastify](#fastify)
116+
- [OpenAPI Documentation with Fastify & Scalar](#openapi-documentation-with-fastify--scalar)
117+
- [Useful Utils](#useful-utils)
118+
- [`Rest.url`](#resturl)
119+
88120
## Install
89121

90-
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)).
122+
Install peer dependencies `rescript` ([instruction](https://rescript-lang.org/docs/manual/latest/installation)) with `rescript-schema` ([instruction](https://github.com/DZakh/rescript-schema/blob/main/docs/rescript-usage.md#install)).
91123

92-
Then run:
124+
And ReScript Rest itself:
93125

94126
```sh
95127
npm install rescript-rest
@@ -104,7 +136,13 @@ Add `rescript-rest` to `bs-dependencies` in your `rescript.json`:
104136
}
105137
```
106138

107-
## Path Parameters
139+
## Route Definition
140+
141+
Routes are the main building block of the library and a perfect way to describe a contract between your client and server.
142+
143+
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.
144+
145+
### Path Parameters
108146

109147
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.
110148

@@ -121,8 +159,7 @@ let getPost = Rest.route(() => {
121159
],
122160
})
123161
124-
let result = await client.call(
125-
getPost,
162+
let result = await getPost->Rest.fetch(
126163
{
127164
"authorId": "d7fa3ac6-5bfa-4322-bb2b-317ca629f61c",
128165
"id": 1
@@ -132,7 +169,7 @@ let result = await client.call(
132169

133170
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.
134171

135-
## Query Parameters
172+
### Query Parameters
136173

137174
You can add query parameters to the request by using the `s.query` method in the `input` definition.
138175

@@ -149,8 +186,7 @@ let getPosts = Rest.route(() => {
149186
],
150187
})
151188
152-
let result = await client.call(
153-
getPosts,
189+
let result = await getPosts->Rest.fetch(
154190
{
155191
"skip": 0,
156192
"take": 10,
@@ -160,11 +196,11 @@ let result = await client.call(
160196

161197
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.
162198

163-
## Request Headers
199+
### Request Headers
164200

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

167-
### Authentication header
203+
#### Authentication Header
168204

169205
For the Authentication header there's an additional helper `s.auth` which supports `Bearer` and `Basic` authentication schemes.
170206

@@ -181,16 +217,15 @@ let getPosts = Rest.route(() => {
181217
],
182218
})
183219
184-
let result = await client.call(
185-
getPosts,
220+
let result = await getPosts->Rest.fetch(
186221
{
187222
"token": "abc",
188223
"pagination": 10,
189224
}
190225
) // ℹ️ It'll do a GET request to http://localhost:3000/posts with the `{"authorization": "Bearer abc", "x-pagination": "10"}` headers
191226
```
192227

193-
## Raw Body
228+
### Raw Body
194229

195230
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.
196231

@@ -217,10 +252,8 @@ let getLogs = Rest.route(() => {
217252
],
218253
})
219254
220-
let result = await client.call(
221-
getLogs,
222-
"debug"
223-
) // ℹ️ 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"}`
255+
let result = await getLogs->Rest.fetch("debug")
256+
// ℹ️ 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"}`
224257
```
225258

226259
You can also use routes with `rawBody` on the server side with Fastify as any other route:
@@ -233,7 +266,7 @@ app->Fastify.route(getLogs, async input => {
233266

234267
> 🧠 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.
235268
236-
## Responses
269+
### Responses
237270

238271
Responses are described as an array of response definitions. It's possible to assign the definition to a specific status using `s.status` method.
239272

@@ -283,7 +316,7 @@ let createPost = Rest.route(() => {
283316
```
284317
-->
285318

286-
## Response Headers
319+
### Response Headers
287320

288321
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:
289322

@@ -317,7 +350,7 @@ let ping = Rest.route(() => {
317350
})
318351
```
319352

320-
## Temporary Redirect
353+
### Temporary Redirect
321354

322355
You can define a redirect using Route response definition:
323356

@@ -347,6 +380,57 @@ let route = Rest.route(() => {
347380

348381
In a nutshell, the `redirect` function is a wrapper around `s.status(307)` and `s.header("location", schema)`.
349382

383+
## Fetch & Client
384+
385+
To call `Rest.fetch` you either need to explicitely pass a `client` as an argument or have it globally set.
386+
387+
I recommend to set a global client in the contract file:
388+
389+
```rescript
390+
// Contract.res
391+
Rest.setGlobalClient("http://localhost:3000")
392+
```
393+
394+
If you pass the endpoint via environment variables, I recommend using my another library [rescript-envsafe](https://github.com/DZakh/rescript-envsafe):
395+
396+
```rescript
397+
// PublicEnv.res
398+
%%private(let envSafe = EnvSafe.make())
399+
400+
let apiEndpoint = envSafe->EnvSafe.get(
401+
"NEXT_PUBLIC_API_ENDPOINT",
402+
~input=%raw(`process.env.NEXT_PUBLIC_API_ENDPOINT`),
403+
S.url(S.string),
404+
)
405+
406+
envSafe->EnvSafe.close
407+
```
408+
409+
```rescript
410+
// Contract.res
411+
Rest.setGlobalClient(PublicEnv.apiEndpoint)
412+
```
413+
414+
If you can't or don't want to use a global client, you can manually pass it to the `Rest.fetch`:
415+
416+
```rescript
417+
let client = Rest.client(PublicEnv.apiEndpoint)
418+
419+
await route->Rest.fetch(input, ~client)
420+
```
421+
422+
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:
423+
424+
```rescript
425+
let client = Rest.client(PublicEnv.apiEndpoint)
426+
427+
let fetch = Rest.fetch(~client, ...)
428+
```
429+
430+
### API Fetcher
431+
432+
You can override the client fetching logic by passing the `~apiFetcher` param.
433+
350434
## Client-side Integrations
351435

352436
### [SWR](https://swr.vercel.app/)
@@ -420,10 +504,7 @@ let default = Contract.getPosts->RestNextJs.handler(async ({input, req, res}) =>
420504
Then you can call your API handler from the client:
421505

422506
```rescript
423-
let posts = await Contract.getPosts->Rest.fetch(
424-
"/api",
425-
()
426-
)
507+
let posts = await Contract.getPosts->Rest.fetch()
427508
```
428509

429510
#### Raw Body for Webhooks
@@ -585,17 +666,3 @@ let url = Rest.url(
585666
}
586667
) //? /posts?skip=0&take=10
587668
```
588-
589-
## Planned Features
590-
591-
- [x] Support query params
592-
- [x] Support headers
593-
- [x] Support path params
594-
- [x] Implement type-safe response
595-
- [ ] Support custom fetch options
596-
- [ ] Support non-json body
597-
- [x] Generate OpenAPI from Contract
598-
- [ ] Generate Contract from OpenAPI
599-
- [x] Server implementation with Fastify
600-
- [x] NextJs integration
601-
- [ ] Add TS/JS support

__tests__/Rest_path_test.res

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ open Ava
22
open RescriptSchema
33

44
let mockClient = () => {
5-
Rest.client(~baseUrl="http://localhost:3000", ~fetcher=async (_): Rest.ApiFetcher.response => {
5+
Rest.client("http://localhost:3000", ~fetcher=async (_): Rest.ApiFetcher.response => {
66
Js.Exn.raiseError("Not implemented")
77
})
88
}
@@ -24,7 +24,7 @@ asyncTest("Fails with path parameter not defined in input", async t => {
2424

2525
t->Assert.throws(
2626
() => {
27-
client.call(createGame, ())
27+
createGame->Rest.fetch((), ~client)
2828
},
2929
~expectations={
3030
message: `[rescript-rest] Path parameter "gameId" is not defined in input`,
@@ -49,7 +49,7 @@ asyncTest("Fails with path parameter not defined in the path string", async t =>
4949

5050
t->Assert.throws(
5151
() => {
52-
client.call(createGame, "gameId")
52+
createGame->Rest.fetch("gameId", ~client)
5353
},
5454
~expectations={
5555
message: `[rescript-rest] Path parameter "gameId" is not defined in the path`,
@@ -74,7 +74,7 @@ asyncTest("Fails with empty path parameter name", async t => {
7474

7575
t->Assert.throws(
7676
() => {
77-
client.call(createGame, "gameId")
77+
createGame->Rest.fetch("gameId", ~client)
7878
},
7979
~expectations={
8080
message: `[rescript-rest] Path parameter name cannot be empty`,
@@ -99,7 +99,7 @@ asyncTest("Fails with path parameter missing closing curly bracket", async t =>
9999

100100
t->Assert.throws(
101101
() => {
102-
client.call(createGame, "gameId")
102+
createGame->Rest.fetch("gameId", ~client)
103103
},
104104
~expectations={
105105
message: `[rescript-rest] Path contains an unclosed parameter`,
@@ -124,7 +124,7 @@ asyncTest("Fails with path parameter missing opening curly bracket", async t =>
124124

125125
t->Assert.throws(
126126
() => {
127-
client.call(createGame, "gameId")
127+
createGame->Rest.fetch("gameId", ~client)
128128
},
129129
~expectations={
130130
message: `[rescript-rest] Path parameter "gameId" is not defined in the path`,
@@ -149,7 +149,7 @@ asyncTest("Fails with path parameter switched open and close curly bracket", asy
149149

150150
t->Assert.throws(
151151
() => {
152-
client.call(createGame, "gameId")
152+
createGame->Rest.fetch("gameId", ~client)
153153
},
154154
~expectations={
155155
message: `[rescript-rest] Path parameter is not enclosed in curly braces`,

0 commit comments

Comments
 (0)