@resapi/express 0.0.0-stage → 0.1.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/Req.res +12 -0
- package/src/Res.res +7 -0
- package/src/Router.res +75 -0
- package/src/Router.resi +32 -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.1.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.1.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/Req.res
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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"
|
package/src/Res.res
ADDED
package/src/Router.res
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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 expressPostWith: (t, string, middleware, handler) => unit = "post"
|
|
20
|
+
@send external expressUse: (t, middleware) => unit = "use"
|
|
21
|
+
@send external expressUseAt: (t, string, middleware) => unit = "use"
|
|
22
|
+
|
|
23
|
+
@send external listen: (t, int, unit => unit) => server = "listen"
|
|
24
|
+
@send external close: server => unit = "close"
|
|
25
|
+
|
|
26
|
+
// Express's error handler logs `err.stack`, which a raw ReScript exception lacks: it logs as
|
|
27
|
+
// "[object Object]". Rethrowing it as an Error keeps the log readable. JS errors pass untouched.
|
|
28
|
+
let rethrowAsError = exn =>
|
|
29
|
+
switch exn {
|
|
30
|
+
| JsExn(e) => JsExn.throw(e)
|
|
31
|
+
| exn =>
|
|
32
|
+
JsError.throwWithMessage(
|
|
33
|
+
`Uncaught ReScript exception: ${exn->JSON.stringifyAny->Option.getOr("unknown")}`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let guard = (handler: handler): handler =>
|
|
38
|
+
async (req, res) =>
|
|
39
|
+
try await handler(req, res) catch {
|
|
40
|
+
| exn => rethrowAsError(exn)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let get = (router, path, handler) => {
|
|
44
|
+
router->expressGet(path, guard(handler))
|
|
45
|
+
router
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let post = (router, path, handler) => {
|
|
49
|
+
router->expressPost(path, guard(handler))
|
|
50
|
+
router
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let use = (router, ~path=?, middleware: middleware) => {
|
|
54
|
+
let guarded = (req, res, next) =>
|
|
55
|
+
try middleware(req, res, next) catch {
|
|
56
|
+
| exn => rethrowAsError(exn)
|
|
57
|
+
}
|
|
58
|
+
switch path {
|
|
59
|
+
| None => router->expressUse(guarded)
|
|
60
|
+
| Some(path) => router->expressUseAt(path, guarded)
|
|
61
|
+
}
|
|
62
|
+
router
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Reads any request body as raw text, so Dispatch reports malformed JSON itself. It skips a body an
|
|
66
|
+
// upstream parser already read.
|
|
67
|
+
let rawText = text({"type": _ => true})
|
|
68
|
+
|
|
69
|
+
let register = (router, route: EndpointHandler.route) => {
|
|
70
|
+
switch route.method {
|
|
71
|
+
| Get => router->expressGet(route.path, route.run)
|
|
72
|
+
| Post => router->expressPostWith(route.path, rawText, route.run)
|
|
73
|
+
}
|
|
74
|
+
router
|
|
75
|
+
}
|
package/src/Router.resi
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
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
|