@resapi/express 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/package.json +38 -4
- package/rescript.json +8 -0
- package/src/EndpointHandler.res +39 -0
- package/src/EndpointHandler.resi +24 -0
- package/src/Reject.res +12 -0
- package/src/Reject.resi +8 -0
- package/src/Req.res +19 -0
- package/src/Req.resi +24 -0
- package/src/Res.res +7 -0
- package/src/Router.res +99 -0
- package/src/Router.resi +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Seaside Logic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @resapi/express
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[Express](https://expressjs.com) 5 server adapter for [resAPI](https://github.com/Seaside-Logic/resapi) contracts.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @resapi/core @resapi/express express
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Add `"@resapi/core"` and `"@resapi/express"` to `dependencies` in `rescript.json`. Modules are under the `ResApiExpress` namespace. Requires Express 5 (tested with 5.2).
|
|
10
|
+
|
|
11
|
+
See the [README](https://github.com/Seaside-Logic/resapi#readme) and [API reference](https://github.com/Seaside-Logic/resapi/blob/main/docs/api.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@resapi/express",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Express server adapter for resAPI contracts.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://resapi.dev",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/Seaside-Logic/resapi/issues"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/Seaside-Logic/resapi.git",
|
|
13
|
+
"directory": "packages/express"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"rescript",
|
|
17
|
+
"http",
|
|
18
|
+
"api",
|
|
19
|
+
"contract",
|
|
20
|
+
"typed-client",
|
|
21
|
+
"express",
|
|
22
|
+
"node"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"files": [
|
|
26
|
+
"src/*.res",
|
|
27
|
+
"src/*.resi",
|
|
28
|
+
"rescript.json"
|
|
29
|
+
],
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"express": "^5.2.1",
|
|
32
|
+
"rescript": "^12.3.1"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@resapi/core": "^0.2.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"express": "5.2.1"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/rescript.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Turns a contract into a route: the request runs through core's Dispatch pipeline, and the result is
|
|
2
|
+
// sent with `res.status().json()`. The handler's types are closed over, so routes of any contract types share one type.
|
|
3
|
+
open ResApi
|
|
4
|
+
|
|
5
|
+
type input<'params, 'body> = Dispatch.input<'params, 'body> = {params: 'params, body: 'body}
|
|
6
|
+
|
|
7
|
+
type handler<'params, 'body, 'response, 'error> = (
|
|
8
|
+
Req.t,
|
|
9
|
+
input<'params, 'body>,
|
|
10
|
+
) => promise<result<'response, 'error>>
|
|
11
|
+
|
|
12
|
+
type route = {
|
|
13
|
+
method: Endpoint.method,
|
|
14
|
+
path: string,
|
|
15
|
+
run: (Req.t, Res.t) => promise<unit>,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Router.register mounts POST routes behind `express.text`, so the body is the raw string. A body
|
|
19
|
+
// already parsed by an upstream `express.json()` is used as it is. No body at all parses as "".
|
|
20
|
+
let rawBody = req =>
|
|
21
|
+
switch req->Req.body->Nullable.toOption {
|
|
22
|
+
| None => Dispatch.Text(Promise.resolve(""))
|
|
23
|
+
| Some(JSON.String(text)) => Text(Promise.resolve(text))
|
|
24
|
+
| Some(json) => Parsed(json)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let make = (contract: Endpoint.t<_, _, _, _>, handler: handler<_, _, _, _>) => {
|
|
28
|
+
method: contract.method,
|
|
29
|
+
path: contract.path,
|
|
30
|
+
run: async (req, res) => {
|
|
31
|
+
let {status, body} = await Dispatch.run(
|
|
32
|
+
contract,
|
|
33
|
+
~params=req->Req.params,
|
|
34
|
+
~readBody=() => rawBody(req),
|
|
35
|
+
~handle=input => handler(req, input),
|
|
36
|
+
)
|
|
37
|
+
res->Res.status(status)->Res.json(body)
|
|
38
|
+
},
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Turns a contract into a route: the request runs through core's Dispatch pipeline, and the result is
|
|
2
|
+
// sent with `res.status().json()`.
|
|
3
|
+
|
|
4
|
+
type input<'params, 'body> = ResApi.Dispatch.input<'params, 'body> = {params: 'params, body: 'body}
|
|
5
|
+
|
|
6
|
+
/** `Error(e)` must be one of the contract's declared application errors (see `Endpoint.withErrors`). */
|
|
7
|
+
type handler<'params, 'body, 'response, 'error> = (
|
|
8
|
+
Req.t,
|
|
9
|
+
input<'params, 'body>,
|
|
10
|
+
) => promise<result<'response, 'error>>
|
|
11
|
+
|
|
12
|
+
/** Mount with `Router.register`. */
|
|
13
|
+
type route = private {
|
|
14
|
+
method: ResApi.Endpoint.method,
|
|
15
|
+
path: string,
|
|
16
|
+
run: (Req.t, Res.t) => promise<unit>,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Invalid input is a 400, and any exception from a codec or the handler is logged and answered with a
|
|
20
|
+
500 that reveals nothing. */
|
|
21
|
+
let make: (
|
|
22
|
+
ResApi.Endpoint.t<'params, 'body, 'response, 'error>,
|
|
23
|
+
handler<'params, 'body, 'response, 'error>,
|
|
24
|
+
) => route
|
package/src/Reject.res
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Auth rejections in resAPI's error shape, for middleware. See Reject.resi.
|
|
2
|
+
let send = (res, ~status, ~kind, ~reason) => {
|
|
3
|
+
let {status, body} = ResApi.Dispatch.errorResponse(
|
|
4
|
+
~status,
|
|
5
|
+
~kind,
|
|
6
|
+
~message=reason->Option.getOr(""),
|
|
7
|
+
)
|
|
8
|
+
res->Res.status(status)->Res.json(body)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let unauthenticated = (res, ~reason=?) => res->send(~status=401, ~kind="unauthenticated", ~reason)
|
|
12
|
+
let forbidden = (res, ~reason=?) => res->send(~status=403, ~kind="forbidden", ~reason)
|
package/src/Reject.resi
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Auth rejections for middleware, in resAPI's error shape, so the typed client sees them as
|
|
2
|
+
// `Unauthorized({status, reason})`. Call one instead of `next()`: `Reject.unauthenticated(res)`.
|
|
3
|
+
|
|
4
|
+
/** Sends 401 `{"error": {"kind": "unauthenticated", "message": <reason or "">, "path": []}}`. */
|
|
5
|
+
let unauthenticated: (Res.t, ~reason: string=?) => unit
|
|
6
|
+
|
|
7
|
+
/** Sends 403 `{"error": {"kind": "forbidden", "message": <reason or "">, "path": []}}`. */
|
|
8
|
+
let forbidden: (Res.t, ~reason: string=?) => unit
|
package/src/Req.res
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Express's request object.
|
|
2
|
+
type t
|
|
3
|
+
|
|
4
|
+
/** Path parameters, already percent-decoded by Express. */
|
|
5
|
+
@get external params: t => dict<string> = "params"
|
|
6
|
+
|
|
7
|
+
/** Set by body-parsing middleware: a string from `express.text`, parsed JSON from `express.json`. */
|
|
8
|
+
@get external body: t => Nullable.t<JSON.t> = "body"
|
|
9
|
+
|
|
10
|
+
@send @return(nullable) external header: (t, string) => option<string> = "get"
|
|
11
|
+
@get external method: t => string = "method"
|
|
12
|
+
@get external path: t => string = "path"
|
|
13
|
+
|
|
14
|
+
@get_index external field: (t, string) => Nullable.t<'a> = ""
|
|
15
|
+
@set_index external setField: (t, string, 'a) => unit = ""
|
|
16
|
+
|
|
17
|
+
let set = (req, key, value) => req->setField(ResApi.Key.name(key), value)
|
|
18
|
+
let get = (req, key) => req->field(ResApi.Key.name(key))->Nullable.toOption
|
|
19
|
+
let getOrThrow = (req, key) => req->get(key)->ResApi.Key.orThrow(key)
|
package/src/Req.resi
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Express's request object.
|
|
2
|
+
type t
|
|
3
|
+
|
|
4
|
+
/** Path parameters, already percent-decoded by Express. */
|
|
5
|
+
@get external params: t => dict<string> = "params"
|
|
6
|
+
|
|
7
|
+
/** Set by body-parsing middleware: a string from `express.text`, parsed JSON from `express.json`. */
|
|
8
|
+
@get external body: t => Nullable.t<JSON.t> = "body"
|
|
9
|
+
|
|
10
|
+
@send @return(nullable) external header: (t, string) => option<string> = "get"
|
|
11
|
+
@get external method: t => string = "method"
|
|
12
|
+
@get external path: t => string = "path"
|
|
13
|
+
|
|
14
|
+
/** Stores a value on the request under `key` (as `req[name]`), usually in middleware. The name shares
|
|
15
|
+
`req` with Express's own fields: a getter such as `query` or `path` throws, and a field Express writes,
|
|
16
|
+
such as `params` or `body`, gets overwritten. Pick a name Express doesn't use. */
|
|
17
|
+
let set: (t, ResApi.Key.t<'a>, 'a) => unit
|
|
18
|
+
|
|
19
|
+
/** The value under `key` (`req[name]`), or `None` when it's unset, `undefined` or `null`. */
|
|
20
|
+
let get: (t, ResApi.Key.t<'a>) => option<'a>
|
|
21
|
+
|
|
22
|
+
/** For routes behind the middleware that sets `key`. Throws when the value is missing: a logged 500
|
|
23
|
+
that reveals nothing, inside a typed endpoint. */
|
|
24
|
+
let getOrThrow: (t, ResApi.Key.t<'a>) => 'a
|
package/src/Res.res
ADDED
package/src/Router.res
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// A router is the Express app itself, so any Express API missing here can be bound against `t` with one
|
|
2
|
+
// more external. Express 5 sends a rejected handler promise to its error handler (by default: log, then
|
|
3
|
+
// a 500; outside production the body shows the stack, as Express always does).
|
|
4
|
+
|
|
5
|
+
type t
|
|
6
|
+
|
|
7
|
+
type handler = (Req.t, Res.t) => promise<unit>
|
|
8
|
+
|
|
9
|
+
type next = unit => unit
|
|
10
|
+
|
|
11
|
+
type middleware = (Req.t, Res.t, next) => unit
|
|
12
|
+
|
|
13
|
+
type server
|
|
14
|
+
|
|
15
|
+
@module("express") external make: unit => t = "default"
|
|
16
|
+
@module("express") external text: {"type": string => bool} => middleware = "text"
|
|
17
|
+
@send external expressGet: (t, string, handler) => unit = "get"
|
|
18
|
+
@send external expressPost: (t, string, handler) => unit = "post"
|
|
19
|
+
@send external expressAll: (t, string, handler) => unit = "all"
|
|
20
|
+
@send external expressPostWith: (t, string, middleware, handler) => unit = "post"
|
|
21
|
+
@send external expressUse: (t, middleware) => unit = "use"
|
|
22
|
+
@send external expressUseAt: (t, string, middleware) => unit = "use"
|
|
23
|
+
|
|
24
|
+
@send external listen: (t, int, unit => unit) => server = "listen"
|
|
25
|
+
@send external close: server => unit = "close"
|
|
26
|
+
|
|
27
|
+
// Express's error handler logs `err.stack`, which a raw ReScript exception lacks: it logs as
|
|
28
|
+
// "[object Object]". Rethrowing it as an Error keeps the log readable. JS errors pass untouched.
|
|
29
|
+
let rethrowAsError = exn =>
|
|
30
|
+
switch exn {
|
|
31
|
+
| JsExn(e) => JsExn.throw(e)
|
|
32
|
+
| exn =>
|
|
33
|
+
JsError.throwWithMessage(
|
|
34
|
+
`Uncaught ReScript exception: ${exn->JSON.stringifyAny->Option.getOr("unknown")}`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let catchExceptions = (handler: handler): handler =>
|
|
39
|
+
async (req, res) =>
|
|
40
|
+
try await handler(req, res) catch {
|
|
41
|
+
| exn => rethrowAsError(exn)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let get = (router, path, handler) => {
|
|
45
|
+
router->expressGet(path, catchExceptions(handler))
|
|
46
|
+
router
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let post = (router, path, handler) => {
|
|
50
|
+
router->expressPost(path, catchExceptions(handler))
|
|
51
|
+
router
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let all = (router, path, handler) => {
|
|
55
|
+
router->expressAll(path, catchExceptions(handler))
|
|
56
|
+
router
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let use = (router, ~path=?, middleware: middleware) => {
|
|
60
|
+
let wrapped = (req, res, next) =>
|
|
61
|
+
try middleware(req, res, next) catch {
|
|
62
|
+
| exn => rethrowAsError(exn)
|
|
63
|
+
}
|
|
64
|
+
switch path {
|
|
65
|
+
| None => router->expressUse(wrapped)
|
|
66
|
+
| Some(path) => router->expressUseAt(path, wrapped)
|
|
67
|
+
}
|
|
68
|
+
router
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Reads any request body as raw text, so Dispatch reports malformed JSON itself. It skips a body an
|
|
72
|
+
// upstream parser already read.
|
|
73
|
+
let rawText = text({"type": _ => true})
|
|
74
|
+
|
|
75
|
+
// Express has no public route list, so `register` records what it mounted, for assertRegistered.
|
|
76
|
+
let registered: WeakMap.t<t, array<string>> = WeakMap.make()
|
|
77
|
+
|
|
78
|
+
let routeName = (method, path) => `${ResApi.Endpoint.methodName(method)} ${path}`
|
|
79
|
+
|
|
80
|
+
let register = (router, route: EndpointHandler.route) => {
|
|
81
|
+
let names = registered->WeakMap.get(router)->Option.getOr([])
|
|
82
|
+
registered->WeakMap.set(router, [...names, routeName(route.method, route.path)])->ignore
|
|
83
|
+
switch route.method {
|
|
84
|
+
| Get => router->expressGet(route.path, route.run)
|
|
85
|
+
| Post => router->expressPostWith(route.path, rawText, route.run)
|
|
86
|
+
}
|
|
87
|
+
router
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let assertRegistered = (router, endpoints: array<ResApi.Endpoint.any>) => {
|
|
91
|
+
let names = registered->WeakMap.get(router)->Option.getOr([])
|
|
92
|
+
let missing =
|
|
93
|
+
endpoints
|
|
94
|
+
->Array.map(endpoint => routeName(endpoint.method, endpoint.path))
|
|
95
|
+
->Array.filter(name => !(names->Array.includes(name)))
|
|
96
|
+
if missing != [] {
|
|
97
|
+
JsError.throwWithMessage(`Endpoints without a route: ${missing->Array.join(", ")}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/Router.resi
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A router is the Express app itself, so any Express API missing here can be bound against `t` with one
|
|
2
|
+
// more external.
|
|
3
|
+
|
|
4
|
+
type t
|
|
5
|
+
|
|
6
|
+
/** Handlers are always async: `async (_req, res) => res->Res.send("hi")`. */
|
|
7
|
+
type handler = (Req.t, Res.t) => promise<unit>
|
|
8
|
+
|
|
9
|
+
type next = unit => unit
|
|
10
|
+
|
|
11
|
+
/** Native Express middleware: call `next()` to continue, or answer through `res`. Express's own
|
|
12
|
+
middleware (`express.json()`, `express.static(...)`) binds to this type directly. */
|
|
13
|
+
type middleware = (Req.t, Res.t, next) => unit
|
|
14
|
+
|
|
15
|
+
type server
|
|
16
|
+
|
|
17
|
+
@module("express") external make: unit => t = "default"
|
|
18
|
+
|
|
19
|
+
/** Starts a Node HTTP server. Port 0 picks a free port. */
|
|
20
|
+
@send external listen: (t, int, unit => unit) => server = "listen"
|
|
21
|
+
@send external close: server => unit = "close"
|
|
22
|
+
|
|
23
|
+
/** A rejected handler goes to Express's error handler. */
|
|
24
|
+
let get: (t, string, handler) => t
|
|
25
|
+
let post: (t, string, handler) => t
|
|
26
|
+
|
|
27
|
+
/** Runs for every request whose path starts with `~path` (default all), including unmatched routes.
|
|
28
|
+
The first `use` is the outermost layer. */
|
|
29
|
+
let use: (t, ~path: string=?, middleware) => t
|
|
30
|
+
|
|
31
|
+
/** POST endpoints get a raw-text body parser (100kb limit). A body already parsed upstream is used as is. */
|
|
32
|
+
let register: (t, EndpointHandler.route) => t
|
|
33
|
+
|
|
34
|
+
/** A plain route for every HTTP method. For unmatched requests, add a last `use` (Express has no
|
|
35
|
+
separate not-found handler). */
|
|
36
|
+
let all: (t, string, handler) => t
|
|
37
|
+
|
|
38
|
+
/** For tests: throws, naming each endpoint in the list that wasn't mounted with `register`. */
|
|
39
|
+
let assertRegistered: (t, array<ResApi.Endpoint.any>) => unit
|