@resapi/core 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 +29 -4
- package/rescript.json +7 -0
- package/src/Client.res +190 -0
- package/src/Client.resi +51 -0
- package/src/Codec.res +134 -0
- package/src/Codec.resi +44 -0
- package/src/Dispatch.res +87 -0
- package/src/Dispatch.resi +28 -0
- package/src/Endpoint.res +65 -0
- package/src/Endpoint.resi +43 -0
- package/src/Fetch.res +97 -0
- package/src/Key.res +13 -0
- package/src/Key.resi +17 -0
- package/src/RouteCheck.res +83 -0
- package/src/RouteCheck.resi +10 -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/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Type-safe HTTP contracts for ReScript: endpoint contracts, codecs and a typed client. Part of [resAPI](https://github.com/Seaside-Logic/resapi).
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @resapi/core
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Add `"@resapi/core"` to `dependencies` in `rescript.json`. Modules are under the `ResApi` namespace.
|
|
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,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@resapi/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Type-safe HTTP contracts for ReScript: endpoint contracts, codecs and a typed client.",
|
|
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/core"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"rescript",
|
|
17
|
+
"http",
|
|
18
|
+
"api",
|
|
19
|
+
"contract",
|
|
20
|
+
"typed-client"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"files": [
|
|
24
|
+
"src/*.res",
|
|
25
|
+
"src/*.resi",
|
|
26
|
+
"rescript.json"
|
|
27
|
+
],
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"rescript": "^12.3.1"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/rescript.json
ADDED
package/src/Client.res
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Calls an endpoint contract over HTTP. Depends only on contracts and Fetch, never on a server adapter.
|
|
2
|
+
|
|
3
|
+
type t = {
|
|
4
|
+
baseUrl: string,
|
|
5
|
+
fetch: Fetch.Request.t => promise<Fetch.Response.t>,
|
|
6
|
+
headers: dict<string>,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type error<'e> =
|
|
10
|
+
| Application({status: int, error: 'e})
|
|
11
|
+
| Unauthorized({status: int, reason: option<string>})
|
|
12
|
+
| Network(string)
|
|
13
|
+
| Aborted
|
|
14
|
+
| UnexpectedStatus({status: int, body: string})
|
|
15
|
+
| InvalidResponse(Codec.error)
|
|
16
|
+
|
|
17
|
+
@val external encodeURIComponent: string => string = "encodeURIComponent"
|
|
18
|
+
|
|
19
|
+
let make = (~baseUrl, ~fetch=request => Fetch.fetchWithRequest(request), ~headers=dict{}) => {
|
|
20
|
+
baseUrl,
|
|
21
|
+
fetch,
|
|
22
|
+
headers,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Appends the path to the base URL, keeping any path prefix in the base: `https://x.dev/api/` + `/a` is `https://x.dev/api/a`. */
|
|
26
|
+
let joinUrl = (baseUrl, path) => {
|
|
27
|
+
let base = baseUrl->String.endsWith("/") ? baseUrl->String.slice(~start=0, ~end=-1) : baseUrl
|
|
28
|
+
path->String.startsWith("/") ? base ++ path : `${base}/${path}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Programmer errors throw: a param that can't fill the path, or a codec that throws. They're bugs in the
|
|
32
|
+
// calling code, and nothing has been sent (like an invalid baseUrl, which makes Request throw).
|
|
33
|
+
let paramValue = (values, name) =>
|
|
34
|
+
switch values->Dict.get(name) {
|
|
35
|
+
| Some(JSON.String(s)) => s
|
|
36
|
+
| Some(Number(n)) => n->Float.toString
|
|
37
|
+
| Some(Boolean(b)) => b ? "true" : "false"
|
|
38
|
+
| Some(_) =>
|
|
39
|
+
JsError.throwWithMessage(`Path parameter "${name}" must be a string, number or boolean`)
|
|
40
|
+
| None => JsError.throwWithMessage(`Missing path parameter "${name}"`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Fills `:name` segments from the encoded params object, percent-encoding each value. */
|
|
44
|
+
let buildPath = (path, params: JSON.t) => {
|
|
45
|
+
let values = switch params {
|
|
46
|
+
| Object(values) => values
|
|
47
|
+
| _ => dict{}
|
|
48
|
+
}
|
|
49
|
+
path
|
|
50
|
+
->String.split("/")
|
|
51
|
+
->Array.map(segment =>
|
|
52
|
+
segment->String.startsWith(":")
|
|
53
|
+
? encodeURIComponent(paramValue(values, segment->String.slice(~start=1)))
|
|
54
|
+
: segment
|
|
55
|
+
)
|
|
56
|
+
->Array.join("/")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A ReScript exception, or a JS throw of something other than an Error, becomes a JS Error, so the
|
|
60
|
+
// rejected call always carries a message and a stack.
|
|
61
|
+
let encodeOrThrow = (encode, value) =>
|
|
62
|
+
try encode(value) catch {
|
|
63
|
+
| JsExn(e) if e->JsExn.message->Option.isSome => JsExn.throw(e)
|
|
64
|
+
| _ => JsError.throwWithMessage("Encoding failed")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let parseJson = text =>
|
|
68
|
+
try Ok(JSON.parseOrThrow(text)) catch {
|
|
69
|
+
| JsExn(e) => Error({Codec.path: [], message: e->JsExn.message->Option.getOr("Invalid JSON")})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let decodeResponse = (contract: Endpoint.t<_, _, _, _>, text) =>
|
|
73
|
+
parseJson(text)
|
|
74
|
+
->Result.flatMap(contract.responseCodec.decode)
|
|
75
|
+
->Result.mapError(e => InvalidResponse(e))
|
|
76
|
+
|
|
77
|
+
// The reason a resAPI rejection (the adapters' `Reject`) carries, when this body is one.
|
|
78
|
+
let rejectionReason = json =>
|
|
79
|
+
switch json {
|
|
80
|
+
| Ok(JSON.Object(dict{
|
|
81
|
+
"error": JSON.Object(dict{
|
|
82
|
+
"kind": JSON.String("unauthenticated" | "forbidden"),
|
|
83
|
+
"message": JSON.String(message),
|
|
84
|
+
}),
|
|
85
|
+
})) if message != "" =>
|
|
86
|
+
Some(message)
|
|
87
|
+
| _ => None
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A declared error (`{"applicationError": ...}`) wins at any status and must decode as one. Otherwise
|
|
91
|
+
// 401 and 403 are Unauthorized, from resAPI's Reject or any other middleware; the rest is unexpected.
|
|
92
|
+
let decodeFailure = (contract: Endpoint.t<_, _, _, _>, status, text) => {
|
|
93
|
+
let json = parseJson(text)
|
|
94
|
+
let declared = switch json {
|
|
95
|
+
| Ok(Object(body)) => body->Dict.get("applicationError")
|
|
96
|
+
| _ => None
|
|
97
|
+
}
|
|
98
|
+
switch declared {
|
|
99
|
+
| Some(error) =>
|
|
100
|
+
switch contract.errorCodec.decode(error) {
|
|
101
|
+
| Ok(error) => Error(Application({status, error}))
|
|
102
|
+
| Error(e) => Error(InvalidResponse({...e, path: ["applicationError", ...e.path]}))
|
|
103
|
+
}
|
|
104
|
+
| None if status == 401 || status == 403 =>
|
|
105
|
+
Error(Unauthorized({status, reason: rejectionReason(json)}))
|
|
106
|
+
| None => Error(UnexpectedStatus({status, body: text}))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Only the transport is inside the try: codec and status errors are classified elsewhere.
|
|
111
|
+
let fetchText = async (client, request) =>
|
|
112
|
+
try {
|
|
113
|
+
let response = await client.fetch(request)
|
|
114
|
+
Ok((response, await response->Fetch.Response.text))
|
|
115
|
+
} catch {
|
|
116
|
+
| JsExn(e) =>
|
|
117
|
+
switch e->JsExn.name {
|
|
118
|
+
| Some("AbortError" | "TimeoutError") => Error(Aborted)
|
|
119
|
+
| _ => Error(Network(e->JsExn.message->Option.getOr("fetch failed")))
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Later sources win. Names are lowercased, because Fetch would otherwise combine `Authorization` and
|
|
124
|
+
// `authorization` into one comma-separated value instead of overriding.
|
|
125
|
+
let mergeHeaders = sources => {
|
|
126
|
+
let merged = dict{}
|
|
127
|
+
sources->Array.forEach(source =>
|
|
128
|
+
source->Dict.forEachWithKey((value, name) => merged->Dict.set(name->String.toLowerCase, value))
|
|
129
|
+
)
|
|
130
|
+
merged
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let requestInit = (
|
|
134
|
+
client,
|
|
135
|
+
contract: Endpoint.t<_, _, _, _>,
|
|
136
|
+
body,
|
|
137
|
+
~headers,
|
|
138
|
+
~signal,
|
|
139
|
+
): Fetch.Request.requestInit => {
|
|
140
|
+
let signal = signal->Option.map(Nullable.make)
|
|
141
|
+
switch contract.method {
|
|
142
|
+
| Get => {
|
|
143
|
+
method: "GET",
|
|
144
|
+
headers: Fetch.HeadersInit.fromDict(mergeHeaders([client.headers, headers])),
|
|
145
|
+
?signal,
|
|
146
|
+
}
|
|
147
|
+
| Post => {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: Fetch.HeadersInit.fromDict(
|
|
150
|
+
mergeHeaders([client.headers, dict{"content-type": "application/json"}, headers]),
|
|
151
|
+
),
|
|
152
|
+
body: Nullable.make(
|
|
153
|
+
Fetch.BodyInit.fromString(encodeOrThrow(contract.bodyCodec.encode, body)->JSON.stringify),
|
|
154
|
+
),
|
|
155
|
+
?signal,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let send = async (
|
|
161
|
+
client,
|
|
162
|
+
contract: Endpoint.t<_, _, _, _>,
|
|
163
|
+
~params,
|
|
164
|
+
~body,
|
|
165
|
+
~headers=dict{},
|
|
166
|
+
~signal=?,
|
|
167
|
+
) => {
|
|
168
|
+
let path = buildPath(contract.path, encodeOrThrow(contract.paramsCodec.encode, params))
|
|
169
|
+
let init = requestInit(client, contract, body, ~headers, ~signal)
|
|
170
|
+
switch await fetchText(client, Fetch.Request.fromURL(joinUrl(client.baseUrl, path), ~init)) {
|
|
171
|
+
| Error(e) => Error(e)
|
|
172
|
+
| Ok((response, text)) =>
|
|
173
|
+
response.ok ? decodeResponse(contract, text) : decodeFailure(contract, response.status, text)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let call = (client, contract, ~params, ~headers=?, ~signal=?) =>
|
|
178
|
+
send(client, contract, ~params, ~body=(), ~headers?, ~signal?)
|
|
179
|
+
|
|
180
|
+
let callWithBody = send
|
|
181
|
+
|
|
182
|
+
let message = (error, ~declared) =>
|
|
183
|
+
switch error {
|
|
184
|
+
| Application({error, _}) => declared(error)
|
|
185
|
+
| Unauthorized({status: 401, _}) => "Please sign in."
|
|
186
|
+
| Unauthorized(_) => "You don't have permission to do that."
|
|
187
|
+
| Network(_) => "Can't reach the server. Check your connection and try again."
|
|
188
|
+
| Aborted => "The request was cancelled."
|
|
189
|
+
| UnexpectedStatus(_) | InvalidResponse(_) => "Something went wrong. Please try again."
|
|
190
|
+
}
|
package/src/Client.resi
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Calls an endpoint contract over HTTP. Depends only on contracts and Fetch, never on a server adapter.
|
|
2
|
+
|
|
3
|
+
type t
|
|
4
|
+
|
|
5
|
+
/** A declared error (`Application`) wins at any status. Any other 401 or 403 is `Unauthorized`: from
|
|
6
|
+
resAPI's `Reject` (with its reason) or any other middleware (`reason: None`). */
|
|
7
|
+
type error<'e> =
|
|
8
|
+
| Application({status: int, error: 'e})
|
|
9
|
+
| Unauthorized({status: int, reason: option<string>})
|
|
10
|
+
| Network(string)
|
|
11
|
+
| Aborted
|
|
12
|
+
| UnexpectedStatus({status: int, body: string})
|
|
13
|
+
| InvalidResponse(Codec.error)
|
|
14
|
+
|
|
15
|
+
/** `~fetch` defaults to the global fetch; pass another to call in-process or to fake a server.
|
|
16
|
+
`~baseUrl` keeps its path prefix: `https://x.dev/api/` + `/a` is `https://x.dev/api/a`.
|
|
17
|
+
`~headers` are sent with every request. */
|
|
18
|
+
let make: (
|
|
19
|
+
~baseUrl: string,
|
|
20
|
+
~fetch: Fetch.Request.t => promise<Fetch.Response.t>=?,
|
|
21
|
+
~headers: dict<string>=?,
|
|
22
|
+
) => t
|
|
23
|
+
|
|
24
|
+
/** For contracts without a request body (`'body = unit`).
|
|
25
|
+
|
|
26
|
+
`~headers` override the client's headers and resAPI's `content-type`; names are case-insensitive.
|
|
27
|
+
Aborting `~signal` (or its timeout firing) ends the call with `Error(Aborted)`.
|
|
28
|
+
|
|
29
|
+
Programmer errors reject the promise with a JS Error: a missing or non-scalar path param, a codec that
|
|
30
|
+
throws, or an invalid baseUrl. Nothing is sent. */
|
|
31
|
+
let call: (
|
|
32
|
+
t,
|
|
33
|
+
Endpoint.t<'p, unit, 'r, 'e>,
|
|
34
|
+
~params: 'p,
|
|
35
|
+
~headers: dict<string>=?,
|
|
36
|
+
~signal: Fetch.AbortSignal.t=?,
|
|
37
|
+
) => promise<result<'r, error<'e>>>
|
|
38
|
+
|
|
39
|
+
/** For contracts with a request body. `~headers` and `~signal` work as in `call`. */
|
|
40
|
+
let callWithBody: (
|
|
41
|
+
t,
|
|
42
|
+
Endpoint.t<'p, 'b, 'r, 'e>,
|
|
43
|
+
~params: 'p,
|
|
44
|
+
~body: 'b,
|
|
45
|
+
~headers: dict<string>=?,
|
|
46
|
+
~signal: Fetch.AbortSignal.t=?,
|
|
47
|
+
) => promise<result<'r, error<'e>>>
|
|
48
|
+
|
|
49
|
+
/** A message for showing the user: `declared` for the contract's declared errors, and an English default
|
|
50
|
+
for the rest. Match the cases you word yourself first, then fall back to this. */
|
|
51
|
+
let message: (error<'e>, ~declared: 'e => string) => string
|
package/src/Codec.res
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The only thing an endpoint needs from a codec. Any backend fits: Sury via @resapi/sury, a hand-written
|
|
2
|
+
// record, or the combinators below.
|
|
3
|
+
type error = {path: array<string>, message: string}
|
|
4
|
+
type t<'a> = {decode: JSON.t => result<'a, error>, encode: 'a => JSON.t}
|
|
5
|
+
|
|
6
|
+
let unit = {decode: _ => Ok(), encode: () => JSON.Object(dict{})}
|
|
7
|
+
|
|
8
|
+
let fail = (message): result<_, error> => Error({path: [], message})
|
|
9
|
+
|
|
10
|
+
// The failed value's position, in front of the inner path: ["2", "role"].
|
|
11
|
+
let under = (segment, error: error) => {...error, path: [segment, ...error.path]}
|
|
12
|
+
|
|
13
|
+
let string = {
|
|
14
|
+
decode: json =>
|
|
15
|
+
switch json {
|
|
16
|
+
| JSON.String(s) => Ok(s)
|
|
17
|
+
| _ => fail("Expected string")
|
|
18
|
+
},
|
|
19
|
+
encode: s => JSON.String(s),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let float = {
|
|
23
|
+
decode: json =>
|
|
24
|
+
switch json {
|
|
25
|
+
| JSON.Number(n) => Ok(n)
|
|
26
|
+
| _ => fail("Expected number")
|
|
27
|
+
},
|
|
28
|
+
encode: n => JSON.Number(n),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@scope("Number") @val external isSafeInteger: float => bool = "isSafeInteger"
|
|
32
|
+
|
|
33
|
+
// int is 32-bit: a larger JSON integer is rejected rather than wrapped.
|
|
34
|
+
let int = {
|
|
35
|
+
decode: json =>
|
|
36
|
+
switch json {
|
|
37
|
+
| JSON.Number(n) if isSafeInteger(n) && n >= -2147483648. && n <= 2147483647. =>
|
|
38
|
+
Ok(n->Float.toInt)
|
|
39
|
+
| _ => fail("Expected integer")
|
|
40
|
+
},
|
|
41
|
+
encode: n => JSON.Number(n->Int.toFloat),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let bool = {
|
|
45
|
+
decode: json =>
|
|
46
|
+
switch json {
|
|
47
|
+
| JSON.Boolean(b) => Ok(b)
|
|
48
|
+
| _ => fail("Expected boolean")
|
|
49
|
+
},
|
|
50
|
+
encode: b => JSON.Boolean(b),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A linear search: value lists are short, and a lookup object would match inherited keys such as
|
|
54
|
+
// "constructor".
|
|
55
|
+
let enum = (~values, ~toString, ~name="one of the allowed values") => {
|
|
56
|
+
decode: json =>
|
|
57
|
+
switch json {
|
|
58
|
+
| JSON.String(s) =>
|
|
59
|
+
switch values->Array.find(value => toString(value) == s) {
|
|
60
|
+
| Some(value) => Ok(value)
|
|
61
|
+
| None => fail(`Expected ${name}`)
|
|
62
|
+
}
|
|
63
|
+
| _ => fail(`Expected ${name}`)
|
|
64
|
+
},
|
|
65
|
+
encode: value => JSON.String(toString(value)),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let array = codec => {
|
|
69
|
+
decode: json =>
|
|
70
|
+
switch json {
|
|
71
|
+
| JSON.Array(items) =>
|
|
72
|
+
items->Array.reduceWithIndex(Ok([]), (acc, item, index) =>
|
|
73
|
+
acc->Result.flatMap(values =>
|
|
74
|
+
switch codec.decode(item) {
|
|
75
|
+
| Ok(value) => Ok([...values, value])
|
|
76
|
+
| Error(error) => Error(under(index->Int.toString, error))
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
| _ => fail("Expected array")
|
|
81
|
+
},
|
|
82
|
+
encode: values => JSON.Array(values->Array.map(codec.encode)),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let option = codec => {
|
|
86
|
+
decode: json =>
|
|
87
|
+
switch json {
|
|
88
|
+
| JSON.Null => Ok(None)
|
|
89
|
+
| json => codec.decode(json)->Result.map(value => Some(value))
|
|
90
|
+
},
|
|
91
|
+
encode: value =>
|
|
92
|
+
switch value {
|
|
93
|
+
| None => JSON.Null
|
|
94
|
+
| Some(value) => codec.encode(value)
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let refine = (codec, check, back) => {
|
|
99
|
+
decode: json =>
|
|
100
|
+
codec.decode(json)->Result.flatMap(value =>
|
|
101
|
+
check(value)->Result.mapError(message => {path: [], message})
|
|
102
|
+
),
|
|
103
|
+
encode: value => codec.encode(back(value)),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type fields = dict<JSON.t>
|
|
107
|
+
|
|
108
|
+
// Raised by `field` and caught by `object`, so a record decoder reads as a record literal. Never
|
|
109
|
+
// escapes `object`.
|
|
110
|
+
exception FieldError(error)
|
|
111
|
+
|
|
112
|
+
@scope("Object") @val external hasOwn: (dict<JSON.t>, string) => bool = "hasOwn"
|
|
113
|
+
|
|
114
|
+
// A missing field decodes as null: `option` fields become None, required ones fail as missing.
|
|
115
|
+
let field = (fields, key, codec) => {
|
|
116
|
+
let json = hasOwn(fields, key) ? fields->Dict.get(key) : None
|
|
117
|
+
switch (json, codec.decode(json->Option.getOr(JSON.Null))) {
|
|
118
|
+
| (_, Ok(value)) => value
|
|
119
|
+
| (Some(_), Error(error)) => throw(FieldError(under(key, error)))
|
|
120
|
+
| (None, Error(_)) => throw(FieldError({path: [key], message: "Missing field"}))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let object = (~decode, ~encode) => {
|
|
125
|
+
decode: json =>
|
|
126
|
+
switch json {
|
|
127
|
+
| JSON.Object(fields) =>
|
|
128
|
+
try Ok(decode(fields)) catch {
|
|
129
|
+
| FieldError(error) => Error(error)
|
|
130
|
+
}
|
|
131
|
+
| _ => fail("Expected object")
|
|
132
|
+
},
|
|
133
|
+
encode: value => JSON.Object(encode(value)->Dict.fromArray),
|
|
134
|
+
}
|
package/src/Codec.resi
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The only thing an endpoint needs from a codec. Any backend fits: Sury via @resapi/sury, a hand-written
|
|
2
|
+
// record, or the combinators below.
|
|
3
|
+
|
|
4
|
+
/** Where decoding failed (field names and array indexes, outermost first) and why. */
|
|
5
|
+
type error = {path: array<string>, message: string}
|
|
6
|
+
|
|
7
|
+
type t<'a> = {decode: JSON.t => result<'a, error>, encode: 'a => JSON.t}
|
|
8
|
+
|
|
9
|
+
/** For endpoints without path params or without a request body. Decodes anything, encodes `{}`. */
|
|
10
|
+
let unit: t<unit>
|
|
11
|
+
|
|
12
|
+
let string: t<string>
|
|
13
|
+
let float: t<float>
|
|
14
|
+
|
|
15
|
+
/** Rejects non-integers and values outside the 32-bit range of ReScript's `int`. */
|
|
16
|
+
let int: t<int>
|
|
17
|
+
|
|
18
|
+
let bool: t<bool>
|
|
19
|
+
|
|
20
|
+
/** A value from a fixed set, sent as a string. With `@as` variants, `~toString=v => (v :> string)` keeps
|
|
21
|
+
the wire names in the type and is compiler-checked. List every value in `~values`: a missing one fails
|
|
22
|
+
to decode, so cover it with a round-trip test. */
|
|
23
|
+
let enum: (~values: array<'a>, ~toString: 'a => string, ~name: string=?) => t<'a>
|
|
24
|
+
|
|
25
|
+
/** Errors carry the index: `["2", "role"]`. */
|
|
26
|
+
let array: t<'a> => t<array<'a>>
|
|
27
|
+
|
|
28
|
+
/** `null`, or a missing field inside `object`, is `None`. `None` encodes as `null`. */
|
|
29
|
+
let option: t<'a> => t<option<'a>>
|
|
30
|
+
|
|
31
|
+
/** Validates or converts after decoding: `Error(message)` fails at this value's path. `back` converts
|
|
32
|
+
for encoding. */
|
|
33
|
+
let refine: (t<'a>, 'a => result<'b, string>, 'b => 'a) => t<'b>
|
|
34
|
+
|
|
35
|
+
/** An object's fields, readable only inside `object`'s `~decode`. */
|
|
36
|
+
type fields
|
|
37
|
+
|
|
38
|
+
/** A field's value. Failures carry the field name in their path; a missing field that isn't an `option`
|
|
39
|
+
fails as "Missing field". Only call it inside `object`'s `~decode`. */
|
|
40
|
+
let field: (fields, string, t<'a>) => 'a
|
|
41
|
+
|
|
42
|
+
/** A record codec. Decode by building the record literal from `field`s, so a field left out is a
|
|
43
|
+
compile error. Encode by listing every field as `(name, json)`. */
|
|
44
|
+
let object: (~decode: fields => 'a, ~encode: 'a => array<(string, JSON.t)>) => t<'a>
|
package/src/Dispatch.res
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The adapter-neutral server pipeline: decodes a request, runs a typed handler and encodes its result
|
|
2
|
+
// in resAPI's wire format. Server adapters supply the raw params and body and send the result.
|
|
3
|
+
// No server or runtime code here.
|
|
4
|
+
|
|
5
|
+
type input<'params, 'body> = {params: 'params, body: 'body}
|
|
6
|
+
|
|
7
|
+
type response = {status: int, body: JSON.t}
|
|
8
|
+
|
|
9
|
+
type rawBody = Text(promise<string>) | Parsed(JSON.t)
|
|
10
|
+
|
|
11
|
+
let errorResponse = (~status, ~kind, ~message, ~path=[]) => {
|
|
12
|
+
status,
|
|
13
|
+
body: JSON.Encode.object(
|
|
14
|
+
dict{
|
|
15
|
+
"error": JSON.Encode.object(
|
|
16
|
+
dict{
|
|
17
|
+
"kind": JSON.Encode.string(kind),
|
|
18
|
+
"message": JSON.Encode.string(message),
|
|
19
|
+
"path": JSON.Encode.array(path->Array.map(JSON.Encode.string)),
|
|
20
|
+
},
|
|
21
|
+
),
|
|
22
|
+
},
|
|
23
|
+
),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Declared errors travel as `{"applicationError": <encoded>}`, apart from the framework's `{"error": ...}`.
|
|
27
|
+
let applicationErrorResponse = (contract: Endpoint.t<_, _, _, _>, error) => {
|
|
28
|
+
let status = contract.errorStatus(error)
|
|
29
|
+
if status < 400 || status > 599 {
|
|
30
|
+
JsError.throwWithMessage(
|
|
31
|
+
`Application error status must be 4xx or 5xx, got ${status->Int.toString}`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
{status, body: JSON.Encode.object(dict{"applicationError": contract.errorCodec.encode(error)})}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let readJsonBody = async (contract: Endpoint.t<_, _, _, _>, readBody) =>
|
|
38
|
+
switch contract.method {
|
|
39
|
+
| Get => Ok(JSON.Null)
|
|
40
|
+
| Post =>
|
|
41
|
+
switch readBody() {
|
|
42
|
+
| Parsed(json) => Ok(json)
|
|
43
|
+
| Text(text) =>
|
|
44
|
+
// Awaited outside the try: a failed read is unexpected (500), not malformed JSON.
|
|
45
|
+
let text = await text
|
|
46
|
+
try Ok(JSON.parseOrThrow(text)) catch {
|
|
47
|
+
| JsExn(e) => Error(e->JsExn.message->Option.getOr("Invalid JSON"))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Params are decoded before the body is read, so a bad URL never costs a body parse.
|
|
53
|
+
let respond = async (contract: Endpoint.t<_, _, _, _>, ~params, ~readBody, ~handle) => {
|
|
54
|
+
let rawParams = JSON.Object(params->Dict.mapValues(s => JSON.String(s)))
|
|
55
|
+
switch contract.paramsCodec.decode(rawParams) {
|
|
56
|
+
| Error({path, message}) => errorResponse(~status=400, ~kind="invalid_params", ~message, ~path)
|
|
57
|
+
| Ok(params) =>
|
|
58
|
+
switch await readJsonBody(contract, readBody) {
|
|
59
|
+
| Error(message) => errorResponse(~status=400, ~kind="malformed_json", ~message)
|
|
60
|
+
| Ok(json) =>
|
|
61
|
+
switch contract.bodyCodec.decode(json) {
|
|
62
|
+
| Error({path, message}) => errorResponse(~status=400, ~kind="invalid_body", ~message, ~path)
|
|
63
|
+
| Ok(body) =>
|
|
64
|
+
switch await handle({params, body}) {
|
|
65
|
+
| Ok(response) => {status: 200, body: contract.responseCodec.encode(response)}
|
|
66
|
+
| Error(error) => applicationErrorResponse(contract, error)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let run = async (
|
|
74
|
+
contract: Endpoint.t<'params, 'body, 'response, 'error>,
|
|
75
|
+
~params: dict<string>,
|
|
76
|
+
~readBody: unit => rawBody,
|
|
77
|
+
~handle: input<'params, 'body> => promise<result<'response, 'error>>,
|
|
78
|
+
) =>
|
|
79
|
+
try await respond(contract, ~params, ~readBody, ~handle) catch {
|
|
80
|
+
| exn =>
|
|
81
|
+
// Log the JS Error itself, not ReScript's wrapper, so the stack trace shows.
|
|
82
|
+
switch exn {
|
|
83
|
+
| JsExn(e) => Console.error(e)
|
|
84
|
+
| exn => Console.error(exn)
|
|
85
|
+
}
|
|
86
|
+
errorResponse(~status=500, ~kind="internal", ~message="Internal Server Error")
|
|
87
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The adapter-neutral server pipeline shared by the first-party adapters (@resapi/hono, @resapi/express).
|
|
2
|
+
// Unstable: this is not an extension API for third-party adapters yet, and it may change in any release.
|
|
3
|
+
|
|
4
|
+
type input<'params, 'body> = {params: 'params, body: 'body}
|
|
5
|
+
|
|
6
|
+
type response = {status: int, body: JSON.t}
|
|
7
|
+
|
|
8
|
+
/** The request body, as the adapter has it. `Parsed` is JSON already parsed by framework middleware. */
|
|
9
|
+
type rawBody = Text(promise<string>) | Parsed(JSON.t)
|
|
10
|
+
|
|
11
|
+
/** The framework error body, `{"error": {"kind", "message", "path"}}`, with its status. The adapters'
|
|
12
|
+
`Reject` uses it for auth rejections. */
|
|
13
|
+
let errorResponse: (
|
|
14
|
+
~status: int,
|
|
15
|
+
~kind: string,
|
|
16
|
+
~message: string,
|
|
17
|
+
~path: array<string>=?,
|
|
18
|
+
) => response
|
|
19
|
+
|
|
20
|
+
/** `readBody` is called only for POST, after params decode. `handle` must return one of the contract's
|
|
21
|
+
declared errors. Any exception from a codec, `handle` or reading the body is logged and answered
|
|
22
|
+
with a 500 that reveals nothing. */
|
|
23
|
+
let run: (
|
|
24
|
+
Endpoint.t<'params, 'body, 'response, 'error>,
|
|
25
|
+
~params: dict<string>,
|
|
26
|
+
~readBody: unit => rawBody,
|
|
27
|
+
~handle: input<'params, 'body> => promise<result<'response, 'error>>,
|
|
28
|
+
) => promise<response>
|
package/src/Endpoint.res
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// A contract describes an endpoint's wire format and is safe to share with clients:
|
|
2
|
+
// it references no server or runtime code. Server adapters turn contracts into routes.
|
|
3
|
+
// Constructors check the route's params against the params codec and throw on a mismatch (RouteCheck).
|
|
4
|
+
|
|
5
|
+
type method = Get | Post
|
|
6
|
+
|
|
7
|
+
type noError
|
|
8
|
+
|
|
9
|
+
type t<'params, 'body, 'response, 'error> = {
|
|
10
|
+
method: method,
|
|
11
|
+
path: string,
|
|
12
|
+
paramsCodec: Codec.t<'params>,
|
|
13
|
+
bodyCodec: Codec.t<'body>,
|
|
14
|
+
responseCodec: Codec.t<'response>,
|
|
15
|
+
errorCodec: Codec.t<'error>,
|
|
16
|
+
errorStatus: 'error => int,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Never encodes (no value exists) and never decodes: an error body for such an endpoint is a protocol error.
|
|
20
|
+
let noErrorCodec: Codec.t<noError> = {
|
|
21
|
+
decode: _ => Error({path: [], message: "This endpoint declares no application errors"}),
|
|
22
|
+
encode: _ => JSON.Null,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let get = (path, ~params, ~response) => {
|
|
26
|
+
RouteCheck.assertValid(path, params)
|
|
27
|
+
{
|
|
28
|
+
method: Get,
|
|
29
|
+
path,
|
|
30
|
+
paramsCodec: params,
|
|
31
|
+
bodyCodec: Codec.unit,
|
|
32
|
+
responseCodec: response,
|
|
33
|
+
errorCodec: noErrorCodec,
|
|
34
|
+
errorStatus: _ => 500,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let post = (path, ~params, ~body, ~response) => {
|
|
39
|
+
RouteCheck.assertValid(path, params)
|
|
40
|
+
{
|
|
41
|
+
method: Post,
|
|
42
|
+
path,
|
|
43
|
+
paramsCodec: params,
|
|
44
|
+
bodyCodec: body,
|
|
45
|
+
responseCodec: response,
|
|
46
|
+
errorCodec: noErrorCodec,
|
|
47
|
+
errorStatus: _ => 500,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let withErrors = (contract: t<'p, 'b, 'r, noError>, errorCodec: Codec.t<'e>, ~status) => {
|
|
52
|
+
...contract,
|
|
53
|
+
errorCodec,
|
|
54
|
+
errorStatus: status,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type any = {method: method, path: string}
|
|
58
|
+
|
|
59
|
+
let any = (contract: t<_, _, _, _>) => {method: contract.method, path: contract.path}
|
|
60
|
+
|
|
61
|
+
let methodName = method =>
|
|
62
|
+
switch method {
|
|
63
|
+
| Get => "GET"
|
|
64
|
+
| Post => "POST"
|
|
65
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// A contract describes an endpoint's wire format and is safe to share with clients: it references no
|
|
2
|
+
// server or runtime code. Server adapters turn contracts into routes.
|
|
3
|
+
|
|
4
|
+
type method = Get | Post
|
|
5
|
+
|
|
6
|
+
/** The error type of an endpoint that declares no application errors. It has no values, so a handler
|
|
7
|
+
for such an endpoint cannot return `Error(_)`. */
|
|
8
|
+
type noError
|
|
9
|
+
|
|
10
|
+
/** Read-only outside this module: contracts are built with `get`/`post`, which check the route. */
|
|
11
|
+
type t<'params, 'body, 'response, 'error> = private {
|
|
12
|
+
method: method,
|
|
13
|
+
path: string,
|
|
14
|
+
paramsCodec: Codec.t<'params>,
|
|
15
|
+
bodyCodec: Codec.t<'body>,
|
|
16
|
+
responseCodec: Codec.t<'response>,
|
|
17
|
+
errorCodec: Codec.t<'error>,
|
|
18
|
+
errorStatus: 'error => int,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Throws if the route's `:params` don't match the params codec (see `RouteCheck`). Define contracts
|
|
22
|
+
at module top level, so a mismatch fails at load. */
|
|
23
|
+
let get: (string, ~params: Codec.t<'p>, ~response: Codec.t<'r>) => t<'p, unit, 'r, noError>
|
|
24
|
+
|
|
25
|
+
/** Throws if the route's `:params` don't match the params codec (see `RouteCheck`). */
|
|
26
|
+
let post: (
|
|
27
|
+
string,
|
|
28
|
+
~params: Codec.t<'p>,
|
|
29
|
+
~body: Codec.t<'b>,
|
|
30
|
+
~response: Codec.t<'r>,
|
|
31
|
+
) => t<'p, 'b, 'r, noError>
|
|
32
|
+
|
|
33
|
+
/** Declares the endpoint's application errors and the HTTP status (4xx or 5xx) each one is sent with. */
|
|
34
|
+
let withErrors: (t<'p, 'b, 'r, noError>, Codec.t<'e>, ~status: 'e => int) => t<'p, 'b, 'r, 'e>
|
|
35
|
+
|
|
36
|
+
/** A contract with its types erased: just the route. Lets contracts of different types share a list,
|
|
37
|
+
e.g. for an adapter's `assertRegistered`. */
|
|
38
|
+
type any = private {method: method, path: string}
|
|
39
|
+
|
|
40
|
+
let any: t<'p, 'b, 'r, 'e> => any
|
|
41
|
+
|
|
42
|
+
/** `"GET"` or `"POST"`. */
|
|
43
|
+
let methodName: method => string
|
package/src/Fetch.res
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// A source-compatible subset of @rescript/webapi's Fetch bindings, so code written against ResApi.Fetch
|
|
2
|
+
// keeps compiling when this module becomes re-exports of webapi (planned for ReScript 13).
|
|
3
|
+
//
|
|
4
|
+
// Rule: only names that exist in webapi, with its module paths, labels, argument order, return types and
|
|
5
|
+
// record fields. Leave things out; never change them. resAPI's own conveniences live elsewhere.
|
|
6
|
+
//
|
|
7
|
+
// Mirrored from rescript-lang/experimental-rescript-webapi main as of 2026-10-11 (alpha build b1d58f9):
|
|
8
|
+
// src/fetch/{Request,Response,Headers,HeadersInit,BodyInit,Fetch}.res, src/event/{AbortSignal,
|
|
9
|
+
// AbortController}.res. Re-checked for 0.2.0 against main b46bc2c on 2026-10-11: those files are unchanged.
|
|
10
|
+
// Re-check it at every resAPI release (docs/release-process.md).
|
|
11
|
+
// Abstract types (Headers.t, AbortSignal.t) are subsets of webapi's records: the re-export adds
|
|
12
|
+
// `type t = WebAPI.FetchTypes.headers` and `type t = WebAPI.DOM.abortSignal`.
|
|
13
|
+
|
|
14
|
+
module AbortSignal = {
|
|
15
|
+
type t
|
|
16
|
+
@scope("AbortSignal") external abort: (~reason: JSON.t=?) => t = "abort"
|
|
17
|
+
@scope("AbortSignal") external timeout: int => t = "timeout"
|
|
18
|
+
@scope("AbortSignal") external any: array<t> => t = "any"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module AbortController = {
|
|
22
|
+
type t = private {signal: AbortSignal.t}
|
|
23
|
+
@new external make: unit => t = "AbortController"
|
|
24
|
+
@send external abort: (t, ~reason: JSON.t=?) => unit = "abort"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module Headers = {
|
|
28
|
+
type t
|
|
29
|
+
@new external make: unit => t = "Headers"
|
|
30
|
+
@new external fromDict: dict<string> => t = "Headers"
|
|
31
|
+
@send external append: (t, ~name: string, ~value: string) => unit = "append"
|
|
32
|
+
@send external delete: (t, string) => unit = "delete"
|
|
33
|
+
@send external get: (t, string) => Null.t<string> = "get"
|
|
34
|
+
@send external has: (t, string) => bool = "has"
|
|
35
|
+
@send external set: (t, ~name: string, ~value: string) => unit = "set"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Mirrored from webapi: its union-typed init values are bound as abstract types with %identity
|
|
39
|
+
// constructors. The only casts allowed in resAPI (see AGENTS.md).
|
|
40
|
+
module HeadersInit = {
|
|
41
|
+
type t
|
|
42
|
+
external fromDict: dict<string> => t = "%identity"
|
|
43
|
+
external fromHeaders: Headers.t => t = "%identity"
|
|
44
|
+
external fromKeyValueArray: array<(string, string)> => t = "%identity"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module BodyInit = {
|
|
48
|
+
type t
|
|
49
|
+
external fromString: string => t = "%identity"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module Request = {
|
|
53
|
+
type t = private {
|
|
54
|
+
method: string,
|
|
55
|
+
url: string,
|
|
56
|
+
headers: Headers.t,
|
|
57
|
+
signal: AbortSignal.t,
|
|
58
|
+
bodyUsed: bool,
|
|
59
|
+
}
|
|
60
|
+
type requestInit = {
|
|
61
|
+
mutable method?: string,
|
|
62
|
+
mutable headers?: HeadersInit.t,
|
|
63
|
+
mutable body?: Nullable.t<BodyInit.t>,
|
|
64
|
+
mutable signal?: Nullable.t<AbortSignal.t>,
|
|
65
|
+
}
|
|
66
|
+
@new external fromURL: (string, ~init: requestInit=?) => t = "Request"
|
|
67
|
+
@send external json: t => promise<JSON.t> = "json"
|
|
68
|
+
@send external text: t => promise<string> = "text"
|
|
69
|
+
@send external clone: t => t = "clone"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module Response = {
|
|
73
|
+
type t = private {
|
|
74
|
+
status: int,
|
|
75
|
+
ok: bool,
|
|
76
|
+
statusText: string,
|
|
77
|
+
url: string,
|
|
78
|
+
redirected: bool,
|
|
79
|
+
headers: Headers.t,
|
|
80
|
+
bodyUsed: bool,
|
|
81
|
+
}
|
|
82
|
+
type responseInit = {
|
|
83
|
+
mutable status?: int,
|
|
84
|
+
mutable statusText?: string,
|
|
85
|
+
mutable headers?: HeadersInit.t,
|
|
86
|
+
}
|
|
87
|
+
@new external fromNull: (@as(json`null`) _, ~init: responseInit=?) => t = "Response"
|
|
88
|
+
@new external fromString: (string, ~init: responseInit=?) => t = "Response"
|
|
89
|
+
@scope("Response") external jsonR: (~data: JSON.t, ~init: responseInit=?) => t = "json"
|
|
90
|
+
@send external json: t => promise<JSON.t> = "json"
|
|
91
|
+
@send external text: t => promise<string> = "text"
|
|
92
|
+
@send external clone: t => t = "clone"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
external fetch: (string, ~init: Request.requestInit=?) => promise<Response.t> = "fetch"
|
|
96
|
+
external fetchWithRequest: (Request.t, ~init: Request.requestInit=?) => promise<Response.t> =
|
|
97
|
+
"fetch"
|
package/src/Key.res
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// A typed name for a request value: see Key.resi.
|
|
2
|
+
type t<'a> = string
|
|
3
|
+
|
|
4
|
+
let make = name => name
|
|
5
|
+
|
|
6
|
+
let name = key => key
|
|
7
|
+
|
|
8
|
+
let orThrow = (value, key) =>
|
|
9
|
+
switch value {
|
|
10
|
+
| Some(value) => value
|
|
11
|
+
| None =>
|
|
12
|
+
JsError.throwWithMessage(`No value for key "${key}": is its middleware mounted for this path?`)
|
|
13
|
+
}
|
package/src/Key.resi
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// A typed name for a value that middleware stores on a request and handlers read back, through the
|
|
2
|
+
// adapters' `Context.set/get` (Hono) and `Req.set/get` (Express). The type is a declaration, not a proof:
|
|
3
|
+
// resAPI trusts whoever stored the value to have used this type, as with any external binding.
|
|
4
|
+
|
|
5
|
+
type t<'a>
|
|
6
|
+
|
|
7
|
+
/** Annotate the type when you make one: `let userKey: Key.t<User.t> = Key.make("user")`. The name is
|
|
8
|
+
the framework's own: Hono's `c.set`/`c.get` name, or the property on Express's `req`. So
|
|
9
|
+
`Key.make("jwtPayload")` reads what hono/jwt sets, and `Key.make("user")` reads Passport's `req.user`. On Express the name is a property on `req`, so don't reuse one
|
|
10
|
+
of Express's own (`query`, `path`, `params`, `body`...). */
|
|
11
|
+
let make: string => t<'a>
|
|
12
|
+
|
|
13
|
+
let name: t<'a> => string
|
|
14
|
+
|
|
15
|
+
/** The value an adapter's `get` found, or a JS Error naming the key when there is none. It backs the
|
|
16
|
+
adapters' `getOrThrow`, so the message is the same on every adapter. */
|
|
17
|
+
let orThrow: (option<'a>, t<'a>) => 'a
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Checks at contract construction that a route's `:params` and its params codec agree. Works with any
|
|
2
|
+
// Codec.t, without metadata: it probes the codec. Decode an object with every route param set to "x",
|
|
3
|
+
// encode the result back, and compare its keys with the route's params.
|
|
4
|
+
//
|
|
5
|
+
// A codec that refines strings (uuid, int parsing) rejects "x" at a field the route does have. That is
|
|
6
|
+
// inconclusive, not a mismatch: CannotValidate. The decode probe still catches a codec field missing
|
|
7
|
+
// from the route when it is the first error reported, but route params the codec ignores go unnoticed.
|
|
8
|
+
|
|
9
|
+
type outcome = Valid | Invalid(string) | CannotValidate(string)
|
|
10
|
+
|
|
11
|
+
let paramName = /^:([A-Za-z_][A-Za-z0-9_]*)$/
|
|
12
|
+
let unsupportedChars = /[:*{}?]/
|
|
13
|
+
|
|
14
|
+
/** The route's param names, or the first segment this checker doesn't understand
|
|
15
|
+
(Hono wildcards, regex params, optional params). */
|
|
16
|
+
let parse = (path): result<array<string>, string> =>
|
|
17
|
+
path
|
|
18
|
+
->String.split("/")
|
|
19
|
+
->Array.reduce(Ok([]), (acc, segment) =>
|
|
20
|
+
acc->Result.flatMap(names =>
|
|
21
|
+
switch paramName->RegExp.exec(segment) {
|
|
22
|
+
| Some(match) =>
|
|
23
|
+
switch match[1] {
|
|
24
|
+
| Some(Some(name)) => Ok([...names, name])
|
|
25
|
+
| _ => Error(segment)
|
|
26
|
+
}
|
|
27
|
+
| None => unsupportedChars->RegExp.test(segment) ? Error(segment) : Ok(names)
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
let keysOf = (json: JSON.t) =>
|
|
33
|
+
switch json {
|
|
34
|
+
| Object(d) => d->Dict.keysToArray
|
|
35
|
+
| _ => []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let probe = (names, codec: Codec.t<_>) => {
|
|
39
|
+
let input = JSON.Object(names->Array.map(n => (n, JSON.String("x")))->Dict.fromArray)
|
|
40
|
+
switch codec.decode(input) {
|
|
41
|
+
| Error({path, message})
|
|
42
|
+
if path->Array.get(0)->Option.mapOr(false, field => names->Array.includes(field)) =>
|
|
43
|
+
CannotValidate(
|
|
44
|
+
`params codec rejected the probe value at [${path->Array.join(".")}]: ${message}`,
|
|
45
|
+
)
|
|
46
|
+
| Error({path, message}) =>
|
|
47
|
+
Invalid(
|
|
48
|
+
`params codec rejected {${names->Array.join(", ")}} at [${path->Array.join(
|
|
49
|
+
".",
|
|
50
|
+
)}]: ${message}`,
|
|
51
|
+
)
|
|
52
|
+
| Ok(decoded) =>
|
|
53
|
+
let keys = keysOf(codec.encode(decoded))
|
|
54
|
+
let missing = names->Array.filter(n => !(keys->Array.includes(n)))
|
|
55
|
+
let unexpected = keys->Array.filter(k => !(names->Array.includes(k)))
|
|
56
|
+
missing == [] && unexpected == []
|
|
57
|
+
? Valid
|
|
58
|
+
: Invalid(
|
|
59
|
+
`codec lacks [${missing->Array.join(", ")}], route lacks [${unexpected->Array.join(
|
|
60
|
+
", ",
|
|
61
|
+
)}]`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let check = (path, codec) =>
|
|
67
|
+
switch parse(path) {
|
|
68
|
+
| Error(segment) =>
|
|
69
|
+
Invalid(
|
|
70
|
+
`unsupported route syntax in segment "${segment}" (only literal segments and :name params)`,
|
|
71
|
+
)
|
|
72
|
+
| Ok(names) =>
|
|
73
|
+
switch names->Array.find(n => names->Array.indexOf(n) != names->Array.lastIndexOf(n)) {
|
|
74
|
+
| Some(name) => Invalid(`duplicate route param :${name}`)
|
|
75
|
+
| None => probe(names, codec)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let assertValid = (path, codec) =>
|
|
80
|
+
switch check(path, codec) {
|
|
81
|
+
| Invalid(details) => JsError.throwWithMessage(`Invalid route "${path}": ${details}`)
|
|
82
|
+
| Valid | CannotValidate(_) => ()
|
|
83
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Checks at contract construction that a route's `:params` and its params codec agree.
|
|
2
|
+
|
|
3
|
+
/** `CannotValidate`: the codec rejected the probe value at a real route param, e.g. because it refines
|
|
4
|
+
strings. Inconclusive, not a mismatch. */
|
|
5
|
+
type outcome = Valid | Invalid(string) | CannotValidate(string)
|
|
6
|
+
|
|
7
|
+
let check: (string, Codec.t<'p>) => outcome
|
|
8
|
+
|
|
9
|
+
/** Throws on Invalid, so a bad top-level contract stops the module (and the Worker) from loading. */
|
|
10
|
+
let assertValid: (string, Codec.t<'p>) => unit
|