@resapi/core 0.1.0 → 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/package.json +1 -1
- package/src/Client.res +93 -62
- package/src/Client.resi +11 -3
- package/src/Codec.res +130 -2
- package/src/Codec.resi +44 -0
- package/src/Dispatch.resi +9 -0
- package/src/Endpoint.res +10 -0
- package/src/Endpoint.resi +9 -0
- package/src/Fetch.res +80 -29
- package/src/Key.res +13 -0
- package/src/Key.resi +17 -0
package/package.json
CHANGED
package/src/Client.res
CHANGED
|
@@ -8,17 +8,19 @@ type t = {
|
|
|
8
8
|
|
|
9
9
|
type error<'e> =
|
|
10
10
|
| Application({status: int, error: 'e})
|
|
11
|
+
| Unauthorized({status: int, reason: option<string>})
|
|
11
12
|
| Network(string)
|
|
12
13
|
| Aborted
|
|
13
14
|
| UnexpectedStatus({status: int, body: string})
|
|
14
15
|
| InvalidResponse(Codec.error)
|
|
15
|
-
| InvalidParams(string)
|
|
16
|
-
| EncodeFailed(string)
|
|
17
16
|
|
|
18
|
-
@val external globalFetch: Fetch.Request.t => promise<Fetch.Response.t> = "fetch"
|
|
19
17
|
@val external encodeURIComponent: string => string = "encodeURIComponent"
|
|
20
18
|
|
|
21
|
-
let make = (~baseUrl, ~fetch=
|
|
19
|
+
let make = (~baseUrl, ~fetch=request => Fetch.fetchWithRequest(request), ~headers=dict{}) => {
|
|
20
|
+
baseUrl,
|
|
21
|
+
fetch,
|
|
22
|
+
headers,
|
|
23
|
+
}
|
|
22
24
|
|
|
23
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`. */
|
|
24
26
|
let joinUrl = (baseUrl, path) => {
|
|
@@ -26,13 +28,16 @@ let joinUrl = (baseUrl, path) => {
|
|
|
26
28
|
path->String.startsWith("/") ? base ++ path : `${base}/${path}`
|
|
27
29
|
}
|
|
28
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).
|
|
29
33
|
let paramValue = (values, name) =>
|
|
30
34
|
switch values->Dict.get(name) {
|
|
31
|
-
| Some(JSON.String(s)) =>
|
|
32
|
-
| Some(Number(n)) =>
|
|
33
|
-
| Some(Boolean(b)) =>
|
|
34
|
-
| Some(_) =>
|
|
35
|
-
|
|
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}"`)
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/** Fills `:name` segments from the encoded params object, percent-encoding each value. */
|
|
@@ -43,22 +48,20 @@ let buildPath = (path, params: JSON.t) => {
|
|
|
43
48
|
}
|
|
44
49
|
path
|
|
45
50
|
->String.split("/")
|
|
46
|
-
->Array.
|
|
47
|
-
|
|
48
|
-
segment->String.
|
|
49
|
-
|
|
50
|
-
value => [...segments, encodeURIComponent(value)],
|
|
51
|
-
)
|
|
52
|
-
: Ok([...segments, segment])
|
|
53
|
-
)
|
|
51
|
+
->Array.map(segment =>
|
|
52
|
+
segment->String.startsWith(":")
|
|
53
|
+
? encodeURIComponent(paramValue(values, segment->String.slice(~start=1)))
|
|
54
|
+
: segment
|
|
54
55
|
)
|
|
55
|
-
->
|
|
56
|
+
->Array.join("/")
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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")
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
let parseJson = text =>
|
|
@@ -71,27 +74,44 @@ let decodeResponse = (contract: Endpoint.t<_, _, _, _>, text) =>
|
|
|
71
74
|
->Result.flatMap(contract.responseCodec.decode)
|
|
72
75
|
->Result.mapError(e => InvalidResponse(e))
|
|
73
76
|
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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]}))
|
|
86
103
|
}
|
|
87
|
-
|
|
|
104
|
+
| None if status == 401 || status == 403 =>
|
|
105
|
+
Error(Unauthorized({status, reason: rejectionReason(json)}))
|
|
106
|
+
| None => Error(UnexpectedStatus({status, body: text}))
|
|
88
107
|
}
|
|
108
|
+
}
|
|
89
109
|
|
|
90
110
|
// Only the transport is inside the try: codec and status errors are classified elsewhere.
|
|
91
111
|
let fetchText = async (client, request) =>
|
|
92
112
|
try {
|
|
93
113
|
let response = await client.fetch(request)
|
|
94
|
-
Ok((response, await response->Fetch.Response.
|
|
114
|
+
Ok((response, await response->Fetch.Response.text))
|
|
95
115
|
} catch {
|
|
96
116
|
| JsExn(e) =>
|
|
97
117
|
switch e->JsExn.name {
|
|
@@ -110,20 +130,32 @@ let mergeHeaders = sources => {
|
|
|
110
130
|
merged
|
|
111
131
|
}
|
|
112
132
|
|
|
113
|
-
let requestInit = (
|
|
114
|
-
|
|
115
|
-
_,
|
|
116
|
-
|
|
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)
|
|
117
141
|
switch contract.method {
|
|
118
|
-
| Get =>
|
|
119
|
-
|
|
120
|
-
|
|
142
|
+
| Get => {
|
|
143
|
+
method: "GET",
|
|
144
|
+
headers: Fetch.HeadersInit.fromDict(mergeHeaders([client.headers, headers])),
|
|
145
|
+
?signal,
|
|
146
|
+
}
|
|
147
|
+
| Post => {
|
|
121
148
|
method: "POST",
|
|
122
|
-
headers:
|
|
123
|
-
|
|
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
|
+
),
|
|
124
155
|
?signal,
|
|
125
|
-
}
|
|
156
|
+
}
|
|
126
157
|
}
|
|
158
|
+
}
|
|
127
159
|
|
|
128
160
|
let send = async (
|
|
129
161
|
client,
|
|
@@ -133,23 +165,12 @@ let send = async (
|
|
|
133
165
|
~headers=dict{},
|
|
134
166
|
~signal=?,
|
|
135
167
|
) => {
|
|
136
|
-
let
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
->Result.flatMap(path =>
|
|
140
|
-
requestInit(client, contract, body, ~headers, ~signal)->Result.map(init => (path, init))
|
|
141
|
-
)
|
|
142
|
-
switch prepared {
|
|
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)) {
|
|
143
171
|
| Error(e) => Error(e)
|
|
144
|
-
| Ok((
|
|
145
|
-
|
|
146
|
-
switch await fetchText(client, Fetch.Request.make(joinUrl(client.baseUrl, path), ~init)) {
|
|
147
|
-
| Error(e) => Error(e)
|
|
148
|
-
| Ok((response, text)) =>
|
|
149
|
-
response->Fetch.Response.ok
|
|
150
|
-
? decodeResponse(contract, text)
|
|
151
|
-
: decodeFailure(contract, response->Fetch.Response.status, text)
|
|
152
|
-
}
|
|
172
|
+
| Ok((response, text)) =>
|
|
173
|
+
response.ok ? decodeResponse(contract, text) : decodeFailure(contract, response.status, text)
|
|
153
174
|
}
|
|
154
175
|
}
|
|
155
176
|
|
|
@@ -157,3 +178,13 @@ let call = (client, contract, ~params, ~headers=?, ~signal=?) =>
|
|
|
157
178
|
send(client, contract, ~params, ~body=(), ~headers?, ~signal?)
|
|
158
179
|
|
|
159
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
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
type t
|
|
4
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`). */
|
|
5
7
|
type error<'e> =
|
|
6
8
|
| Application({status: int, error: 'e})
|
|
9
|
+
| Unauthorized({status: int, reason: option<string>})
|
|
7
10
|
| Network(string)
|
|
8
11
|
| Aborted
|
|
9
12
|
| UnexpectedStatus({status: int, body: string})
|
|
10
13
|
| InvalidResponse(Codec.error)
|
|
11
|
-
| InvalidParams(string)
|
|
12
|
-
| EncodeFailed(string)
|
|
13
14
|
|
|
14
15
|
/** `~fetch` defaults to the global fetch; pass another to call in-process or to fake a server.
|
|
15
16
|
`~baseUrl` keeps its path prefix: `https://x.dev/api/` + `/a` is `https://x.dev/api/a`.
|
|
@@ -23,7 +24,10 @@ let make: (
|
|
|
23
24
|
/** For contracts without a request body (`'body = unit`).
|
|
24
25
|
|
|
25
26
|
`~headers` override the client's headers and resAPI's `content-type`; names are case-insensitive.
|
|
26
|
-
Aborting `~signal` (or its timeout firing) ends the call with `Error(Aborted)`.
|
|
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. */
|
|
27
31
|
let call: (
|
|
28
32
|
t,
|
|
29
33
|
Endpoint.t<'p, unit, 'r, 'e>,
|
|
@@ -41,3 +45,7 @@ let callWithBody: (
|
|
|
41
45
|
~headers: dict<string>=?,
|
|
42
46
|
~signal: Fetch.AbortSignal.t=?,
|
|
43
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
CHANGED
|
@@ -1,6 +1,134 @@
|
|
|
1
|
-
// The only thing an endpoint needs from a codec. Any backend fits: Sury via @resapi/sury,
|
|
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.
|
|
2
3
|
type error = {path: array<string>, message: string}
|
|
3
4
|
type t<'a> = {decode: JSON.t => result<'a, error>, encode: 'a => JSON.t}
|
|
4
5
|
|
|
5
|
-
/** For endpoints without path params or without a request body. */
|
|
6
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.resi
CHANGED
|
@@ -8,6 +8,15 @@ type response = {status: int, body: JSON.t}
|
|
|
8
8
|
/** The request body, as the adapter has it. `Parsed` is JSON already parsed by framework middleware. */
|
|
9
9
|
type rawBody = Text(promise<string>) | Parsed(JSON.t)
|
|
10
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
|
+
|
|
11
20
|
/** `readBody` is called only for POST, after params decode. `handle` must return one of the contract's
|
|
12
21
|
declared errors. Any exception from a codec, `handle` or reading the body is logged and answered
|
|
13
22
|
with a 500 that reveals nothing. */
|
package/src/Endpoint.res
CHANGED
|
@@ -53,3 +53,13 @@ let withErrors = (contract: t<'p, 'b, 'r, noError>, errorCodec: Codec.t<'e>, ~st
|
|
|
53
53
|
errorCodec,
|
|
54
54
|
errorStatus: status,
|
|
55
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
|
+
}
|
package/src/Endpoint.resi
CHANGED
|
@@ -32,3 +32,12 @@ let post: (
|
|
|
32
32
|
|
|
33
33
|
/** Declares the endpoint's application errors and the HTTP status (4xx or 5xx) each one is sent with. */
|
|
34
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
CHANGED
|
@@ -1,46 +1,97 @@
|
|
|
1
|
-
//
|
|
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`.
|
|
2
13
|
|
|
3
14
|
module AbortSignal = {
|
|
4
15
|
type t
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@scope("AbortSignal")
|
|
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"
|
|
8
19
|
}
|
|
9
20
|
|
|
10
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 = {
|
|
11
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
|
+
}
|
|
12
37
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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"
|
|
16
45
|
}
|
|
17
46
|
|
|
18
|
-
module
|
|
47
|
+
module BodyInit = {
|
|
19
48
|
type t
|
|
20
|
-
|
|
49
|
+
external fromString: string => t = "%identity"
|
|
50
|
+
}
|
|
21
51
|
|
|
22
|
-
|
|
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"
|
|
23
70
|
}
|
|
24
71
|
|
|
25
72
|
module Response = {
|
|
26
|
-
type t
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
type
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
/** Mutates in place. Throws on immutable responses, such as one returned by `fetch()`. */
|
|
40
|
-
@send external setHeader: (headers, string, string) => unit = "set"
|
|
41
|
-
let setHeader = (response, name, value) => {
|
|
42
|
-
response->headers->setHeader(name, value)
|
|
43
|
-
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,
|
|
44
86
|
}
|
|
45
|
-
|
|
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"
|
|
46
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
|