@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@resapi/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Type-safe HTTP contracts for ReScript: endpoint contracts, codecs and a typed client.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://resapi.dev",
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=globalFetch, ~headers=dict{}) => {baseUrl, fetch, headers}
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)) => Ok(s)
32
- | Some(Number(n)) => Ok(n->Float.toString)
33
- | Some(Boolean(b)) => Ok(b ? "true" : "false")
34
- | Some(_) => Error(InvalidParams(`Path parameter "${name}" must be a string, number or boolean`))
35
- | None => Error(InvalidParams(`Missing path parameter "${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}"`)
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.reduce(Ok([]), (acc, segment) =>
47
- acc->Result.flatMap(segments =>
48
- segment->String.startsWith(":")
49
- ? paramValue(values, segment->String.slice(~start=1))->Result.map(
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
- ->Result.map(segments => segments->Array.join("/"))
56
+ ->Array.join("/")
56
57
  }
57
58
 
58
- let encodeWith = (encode, value) =>
59
- try Ok(encode(value)) catch {
60
- | JsExn(e) => Error(EncodeFailed(e->JsExn.message->Option.getOr("Encoding failed")))
61
- | _ => Error(EncodeFailed("Encoding failed"))
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
- // A non-2xx body of `{"applicationError": ...}` is a declared error and must decode as one; anything
75
- // else (framework errors, proxies, plain text) is just an unexpected status.
76
- let decodeFailure = (contract: Endpoint.t<_, _, _, _>, status, text) =>
77
- switch parseJson(text) {
78
- | Ok(Object(body)) =>
79
- switch body->Dict.get("applicationError") {
80
- | Some(json) =>
81
- switch contract.errorCodec.decode(json) {
82
- | Ok(error) => Error(Application({status, error}))
83
- | Error(e) => Error(InvalidResponse({...e, path: ["applicationError", ...e.path]}))
84
- }
85
- | None => Error(UnexpectedStatus({status, body: text}))
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
- | _ => Error(UnexpectedStatus({status, body: text}))
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.readText))
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 = (client, contract: Endpoint.t<_, _, _, _>, body, ~headers, ~signal): result<
114
- Fetch.Request.init,
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 => Ok({method: "GET", headers: mergeHeaders([client.headers, headers]), ?signal})
119
- | Post =>
120
- encodeWith(contract.bodyCodec.encode, body)->Result.map((json): Fetch.Request.init => {
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: mergeHeaders([client.headers, dict{"content-type": "application/json"}, headers]),
123
- body: json->JSON.stringify,
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 prepared =
137
- encodeWith(contract.paramsCodec.encode, params)
138
- ->Result.flatMap(json => buildPath(contract.path, json))
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((path, init)) =>
145
- // An invalid baseUrl makes Request throw: a configuration bug, not a request failure.
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, or a hand-written record.
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
- // Fetch API bindings shared by the client and server adapters.
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
- /** A signal that aborts after the given number of milliseconds. */
7
- @scope("AbortSignal") @val external timeout: int => t = "timeout"
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
- @new external make: unit => t = "AbortController"
14
- @get external signal: t => AbortSignal.t = "signal"
15
- @send external abort: t => unit = "abort"
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 Request = {
47
+ module BodyInit = {
19
48
  type t
20
- type init = {method?: string, headers?: dict<string>, body?: string, signal?: AbortSignal.t}
49
+ external fromString: string => t = "%identity"
50
+ }
21
51
 
22
- @new external make: (string, ~init: init=?) => t = "Request"
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
- type init = {status?: int, headers?: dict<string>}
28
-
29
- @scope("Response") @val external json: (JSON.t, ~init: init=?) => t = "json"
30
- @new external text: (string, ~init: init=?) => t = "Response"
31
- @get external status: t => int = "status"
32
- @get external ok: t => bool = "ok"
33
- @send external readText: t => promise<string> = "text"
34
-
35
- type headers
36
- @get external headers: t => headers = "headers"
37
- @send @return(nullable) external getHeader: (headers, string) => option<string> = "get"
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
- let header = (response, name) => response->headers->getHeader(name)
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