@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 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
- # Temporary Holding Version
1
+ # @resapi/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "@resapi/core",
3
+ "sources": {"dir": "src"},
4
+ "package-specs": {"module": "esmodule", "in-source": true},
5
+ "suffix": ".res.mjs",
6
+ "namespace": "ResApi"
7
+ }
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
+ }
@@ -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>
@@ -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>
@@ -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