@resapi/core 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE 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.1.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,159 @@
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
+ | Network(string)
12
+ | Aborted
13
+ | UnexpectedStatus({status: int, body: string})
14
+ | InvalidResponse(Codec.error)
15
+ | InvalidParams(string)
16
+ | EncodeFailed(string)
17
+
18
+ @val external globalFetch: Fetch.Request.t => promise<Fetch.Response.t> = "fetch"
19
+ @val external encodeURIComponent: string => string = "encodeURIComponent"
20
+
21
+ let make = (~baseUrl, ~fetch=globalFetch, ~headers=dict{}) => {baseUrl, fetch, headers}
22
+
23
+ /** 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
+ let joinUrl = (baseUrl, path) => {
25
+ let base = baseUrl->String.endsWith("/") ? baseUrl->String.slice(~start=0, ~end=-1) : baseUrl
26
+ path->String.startsWith("/") ? base ++ path : `${base}/${path}`
27
+ }
28
+
29
+ let paramValue = (values, name) =>
30
+ 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}"`))
36
+ }
37
+
38
+ /** Fills `:name` segments from the encoded params object, percent-encoding each value. */
39
+ let buildPath = (path, params: JSON.t) => {
40
+ let values = switch params {
41
+ | Object(values) => values
42
+ | _ => dict{}
43
+ }
44
+ path
45
+ ->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
+ )
54
+ )
55
+ ->Result.map(segments => segments->Array.join("/"))
56
+ }
57
+
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"))
62
+ }
63
+
64
+ let parseJson = text =>
65
+ try Ok(JSON.parseOrThrow(text)) catch {
66
+ | JsExn(e) => Error({Codec.path: [], message: e->JsExn.message->Option.getOr("Invalid JSON")})
67
+ }
68
+
69
+ let decodeResponse = (contract: Endpoint.t<_, _, _, _>, text) =>
70
+ parseJson(text)
71
+ ->Result.flatMap(contract.responseCodec.decode)
72
+ ->Result.mapError(e => InvalidResponse(e))
73
+
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}))
86
+ }
87
+ | _ => Error(UnexpectedStatus({status, body: text}))
88
+ }
89
+
90
+ // Only the transport is inside the try: codec and status errors are classified elsewhere.
91
+ let fetchText = async (client, request) =>
92
+ try {
93
+ let response = await client.fetch(request)
94
+ Ok((response, await response->Fetch.Response.readText))
95
+ } catch {
96
+ | JsExn(e) =>
97
+ switch e->JsExn.name {
98
+ | Some("AbortError" | "TimeoutError") => Error(Aborted)
99
+ | _ => Error(Network(e->JsExn.message->Option.getOr("fetch failed")))
100
+ }
101
+ }
102
+
103
+ // Later sources win. Names are lowercased, because Fetch would otherwise combine `Authorization` and
104
+ // `authorization` into one comma-separated value instead of overriding.
105
+ let mergeHeaders = sources => {
106
+ let merged = dict{}
107
+ sources->Array.forEach(source =>
108
+ source->Dict.forEachWithKey((value, name) => merged->Dict.set(name->String.toLowerCase, value))
109
+ )
110
+ merged
111
+ }
112
+
113
+ let requestInit = (client, contract: Endpoint.t<_, _, _, _>, body, ~headers, ~signal): result<
114
+ Fetch.Request.init,
115
+ _,
116
+ > =>
117
+ 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 => {
121
+ method: "POST",
122
+ headers: mergeHeaders([client.headers, dict{"content-type": "application/json"}, headers]),
123
+ body: json->JSON.stringify,
124
+ ?signal,
125
+ })
126
+ }
127
+
128
+ let send = async (
129
+ client,
130
+ contract: Endpoint.t<_, _, _, _>,
131
+ ~params,
132
+ ~body,
133
+ ~headers=dict{},
134
+ ~signal=?,
135
+ ) => {
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 {
143
+ | 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
+ }
153
+ }
154
+ }
155
+
156
+ let call = (client, contract, ~params, ~headers=?, ~signal=?) =>
157
+ send(client, contract, ~params, ~body=(), ~headers?, ~signal?)
158
+
159
+ let callWithBody = send
@@ -0,0 +1,43 @@
1
+ // Calls an endpoint contract over HTTP. Depends only on contracts and Fetch, never on a server adapter.
2
+
3
+ type t
4
+
5
+ type error<'e> =
6
+ | Application({status: int, error: 'e})
7
+ | Network(string)
8
+ | Aborted
9
+ | UnexpectedStatus({status: int, body: string})
10
+ | InvalidResponse(Codec.error)
11
+ | InvalidParams(string)
12
+ | EncodeFailed(string)
13
+
14
+ /** `~fetch` defaults to the global fetch; pass another to call in-process or to fake a server.
15
+ `~baseUrl` keeps its path prefix: `https://x.dev/api/` + `/a` is `https://x.dev/api/a`.
16
+ `~headers` are sent with every request. */
17
+ let make: (
18
+ ~baseUrl: string,
19
+ ~fetch: Fetch.Request.t => promise<Fetch.Response.t>=?,
20
+ ~headers: dict<string>=?,
21
+ ) => t
22
+
23
+ /** For contracts without a request body (`'body = unit`).
24
+
25
+ `~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
+ let call: (
28
+ t,
29
+ Endpoint.t<'p, unit, 'r, 'e>,
30
+ ~params: 'p,
31
+ ~headers: dict<string>=?,
32
+ ~signal: Fetch.AbortSignal.t=?,
33
+ ) => promise<result<'r, error<'e>>>
34
+
35
+ /** For contracts with a request body. `~headers` and `~signal` work as in `call`. */
36
+ let callWithBody: (
37
+ t,
38
+ Endpoint.t<'p, 'b, 'r, 'e>,
39
+ ~params: 'p,
40
+ ~body: 'b,
41
+ ~headers: dict<string>=?,
42
+ ~signal: Fetch.AbortSignal.t=?,
43
+ ) => promise<result<'r, error<'e>>>
package/src/Codec.res ADDED
@@ -0,0 +1,6 @@
1
+ // The only thing an endpoint needs from a codec. Any backend fits: Sury via @resapi/sury, or a hand-written record.
2
+ type error = {path: array<string>, message: string}
3
+ type t<'a> = {decode: JSON.t => result<'a, error>, encode: 'a => JSON.t}
4
+
5
+ /** For endpoints without path params or without a request body. */
6
+ let unit = {decode: _ => Ok(), encode: () => JSON.Object(dict{})}
@@ -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,19 @@
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
+ /** `readBody` is called only for POST, after params decode. `handle` must return one of the contract's
12
+ declared errors. Any exception from a codec, `handle` or reading the body is logged and answered
13
+ with a 500 that reveals nothing. */
14
+ let run: (
15
+ Endpoint.t<'params, 'body, 'response, 'error>,
16
+ ~params: dict<string>,
17
+ ~readBody: unit => rawBody,
18
+ ~handle: input<'params, 'body> => promise<result<'response, 'error>>,
19
+ ) => promise<response>
@@ -0,0 +1,55 @@
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
+ }
@@ -0,0 +1,34 @@
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>
package/src/Fetch.res ADDED
@@ -0,0 +1,46 @@
1
+ // Fetch API bindings shared by the client and server adapters.
2
+
3
+ module AbortSignal = {
4
+ type t
5
+
6
+ /** A signal that aborts after the given number of milliseconds. */
7
+ @scope("AbortSignal") @val external timeout: int => t = "timeout"
8
+ }
9
+
10
+ module AbortController = {
11
+ type t
12
+
13
+ @new external make: unit => t = "AbortController"
14
+ @get external signal: t => AbortSignal.t = "signal"
15
+ @send external abort: t => unit = "abort"
16
+ }
17
+
18
+ module Request = {
19
+ type t
20
+ type init = {method?: string, headers?: dict<string>, body?: string, signal?: AbortSignal.t}
21
+
22
+ @new external make: (string, ~init: init=?) => t = "Request"
23
+ }
24
+
25
+ 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
44
+ }
45
+ let header = (response, name) => response->headers->getHeader(name)
46
+ }
@@ -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