Skip to content

Commit c005a91

Browse files
authored
feat(fetch): add result-based SafeFetch wrapper [Codex] (#367)
Closes #363 ## Summary Add an opt-in `WebAPI.SafeFetch` module that converts expected Fetch, HTTP-status, and response-body failures into composable `result` values. Raw `WebAPI.Fetch` and `WebAPI.Response` bindings remain unchanged. - Select the separate `WebAPI.SafeFetch` feature; it enables `WebAPI.Fetch` and the shared raw types transitively. - Use tagged polymorphic errors consistently: `#FetchRejected(cause)`, `#ResponseNotOk({response})`, and `#BodyReadRejected({response, cause})`. - Public signatures expose extensible error rows, so `let?` can compose fetch, body reading, and application decoding without `mapError` adapters. - Keep the original response and rejection cause. Successful body reads retain `{response, body}`; the body has been consumed. - Cover all six body readers. `json` returns `JSON.t`; application-specific validation stays with the caller. This intentionally changes the experimental API: consumers that previously selected only `WebAPI.Fetch` must explicitly select `WebAPI.SafeFetch` to use the wrapper. ## Usage The locked ReScript 13 alpha compiler requires `LetUnwrap` for `let?`; ordinary `switch` usage does not require that setting. ```json { "experimental-features": {"LetUnwrap": true}, "dependencies": [ {"name": "@rescript/webapi", "features": ["WebAPI.SafeFetch"]} ] } ``` ```ReScript type user = {name: string} let decodeUser = json => switch json { | JSON.Object(dict{"name": JSON.String(name)}) => Ok({name: name}) | _ => Error(#InvalidUser) } let loadUser = async url => { let? Ok(response) = await WebAPI.SafeFetch.fetch(url) let? Ok({body}) = await response->WebAPI.SafeFetch.json let? Ok(user) = body->decodeUser Ok(user) } ``` The caller handles four distinct error tags in one switch: request rejection, non-OK HTTP status, body-read rejection, and application-owned `#InvalidUser`. Malformed JSON is a body-read error; valid JSON with the wrong shape is an application-decoding error. A non-OK response remains available for reading error details. ## Review follow-up - Converted all error stages to polymorphic variants, including `checkOk` and body readers. - Removed redundant implementation annotations; retained the `checkOk` response annotation required for record-field inference. - Moved the wrapper to `src/safe-fetch` with its own feature and added isolated public-consumer checks. - Added documented and compiled `let?` examples plus runtime propagation/short-circuit tests. Both fetch entry points still construct their final result directly and call native `fetch`. The late public alias prevents native-fetch shadowing, optional initializers are forwarded or omitted, and the only generated runtime import is exception normalization. `exn` preserves arbitrary JavaScript abort reasons via `JsExn.fromException`. ## Validation Passed locally with Node 26.3.0 and locked ReScript 13.0.0-alpha.5: - `npm ci` - `npm run format:check` - `npm run build` - `npm run check:features` — four checker unit tests, all 28 clean public feature builds, a SafeFetch-only consumer with composed application errors, a raw Fetch-only consumer, and a negative check proving SafeFetch is unavailable with only Fetch enabled - `npm test` — all seven runtime suites, including native default/custom abort reasons, both fetch entry points, optional initialization, synchronous/asynchronous rejection, body readers, and composed stage-specific failures/short-circuiting - `npm run build:docs` — 305 pages, including the generated SafeFetch API page
1 parent 4b9be73 commit c005a91

11 files changed

Lines changed: 826 additions & 3 deletions

File tree

‎docs/content/docs/api-surface.mdx‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,30 @@ WebAPI.URL WebAPI.Fetch WebAPI.UIEvents
7070
WebAPI.Observers WebAPI.Media WebAPI.WebAudio
7171
WebAPI.Storage WebAPI.Messaging WebAPI.Workers
7272
WebAPI.Crypto WebAPI.Performance WebAPI.ViewTransitions
73+
WebAPI.SafeFetch
7374
```
7475

7576
The package owns each bundle as one internal source folder. Those internal folder feature
7677
names are an implementation detail; consumers should use only the qualified names above.
78+
`WebAPI.SafeFetch` transitively enables `WebAPI.Fetch`, so consumers that want the result-based
79+
helpers can select only the safe feature:
80+
81+
```json
82+
{
83+
"experimental-features": {
84+
"LetUnwrap": true
85+
},
86+
"dependencies": [
87+
{
88+
"name": "@rescript/webapi",
89+
"features": ["WebAPI.SafeFetch"]
90+
}
91+
]
92+
}
93+
```
94+
95+
The `let?` examples below require the experimental `LetUnwrap` setting with the current ReScript 13
96+
alpha compiler. The SafeFetch API itself can also be used with ordinary `switch` expressions.
7797

7898
## Fetch
7999

@@ -113,6 +133,109 @@ let response = await WebAPI.Fetch.fetch(
113133
let text = await response->WebAPI.Response.text
114134
```
115135

136+
### Result-based workflows
137+
138+
`WebAPI.Fetch` and `WebAPI.Response` are the raw browser bindings and remain unchanged. Use the
139+
separate, opt-in `WebAPI.SafeFetch` module when request, HTTP status, and body-reading failures should
140+
be handled as `result` values. The separate feature is an intentional breaking change for consumers
141+
that restrict enabled features: add `WebAPI.SafeFetch` to use these helpers. Existing raw Fetch
142+
clients are unaffected.
143+
144+
| Stage | Result | Preserved context |
145+
| ----------------------- | --------------------------------------------- | ------------------------------- |
146+
| Fetch throws or rejects | `Error(#FetchRejected(cause))` | Original rejection as `exn` |
147+
| Response is not OK | `Error(#ResponseNotOk({response}))` | Complete response |
148+
| Body reader rejects | `Error(#BodyReadRejected({response, cause}))` | Response and original rejection |
149+
150+
`SafeFetch.fetch` accepts a URL, while `SafeFetch.fetchWithRequest` accepts an existing
151+
`WebAPI.Request.t`. Both promises fulfill with a `result` for these expected failures. A fulfilled
152+
response is classified with the platform's `response.ok` value, so a 404 or 500 is distinct from a
153+
request that did not produce a response. The public `fetchError` type contains `#FetchRejected(exn)`
154+
and `#ResponseNotOk({response})`; `bodyError` contains `#BodyReadRejected(readError)`. `readError` has
155+
`response` and `cause` fields, and `response<'body>` retains the original response alongside the
156+
successful body. The function signatures expose extensible error rows (`[> fetchError]` and
157+
`[> bodyError]`), so application errors can be added directly with `let?`, without `mapError`.
158+
159+
```ReScript
160+
let fetchJson = async url => {
161+
let? Ok(response) = await WebAPI.SafeFetch.fetch(url)
162+
let? Ok({body}) = await response->WebAPI.SafeFetch.json
163+
Ok(body)
164+
}
165+
```
166+
167+
The body reader reports malformed JSON as `#BodyReadRejected`; valid JSON with the wrong shape is an
168+
application decoding failure instead. Here is one way to compose the stages and retain an application
169+
error variant in the result:
170+
171+
```ReScript
172+
type user = {name: string}
173+
174+
let decodeUser = json =>
175+
switch json {
176+
| JSON.Object(dict{"name": JSON.String(name)}) => Ok({name: name})
177+
| _ => Error(#InvalidUser)
178+
}
179+
180+
let loadUser = async url => {
181+
let? Ok(response) = await WebAPI.SafeFetch.fetch(url)
182+
let? Ok({body}) = await response->WebAPI.SafeFetch.json
183+
let? Ok(user) = body->decodeUser
184+
Ok(user)
185+
}
186+
```
187+
188+
The composed result has open error rows, so its caller can handle each stage exhaustively, including
189+
the application-specific `#InvalidUser`:
190+
191+
```ReScript
192+
switch await loadUser("https://example.com/api/users/42") {
193+
| Error(#FetchRejected(cause)) => reportUnavailable(cause)
194+
| Error(#ResponseNotOk({response})) => handleHttpError(response.status)
195+
| Error(#BodyReadRejected({response, cause})) => handleInvalidBody(response.status, cause)
196+
| Error(#InvalidUser) => handleInvalidUser()
197+
| Ok(user) => showUser(user)
198+
}
199+
```
200+
201+
Use `checkOk` to classify a response obtained from the raw API without reading its body. Its
202+
non-success result is tagged as `#ResponseNotOk({response})`.
203+
204+
```ReScript
205+
let response = await WebAPI.Fetch.fetch("https://example.com/api/users")
206+
207+
switch response->WebAPI.SafeFetch.checkOk {
208+
| Ok(response) => useResponse(response)
209+
| Error(#ResponseNotOk({response})) => handleHttpError(response.status)
210+
}
211+
```
212+
213+
A non-OK response remains readable. This is useful when a server returns structured error details;
214+
reading that body can itself fail with `#BodyReadRejected`.
215+
216+
```ReScript
217+
switch await WebAPI.SafeFetch.fetch("https://example.com/api/users/42") {
218+
| Error(#ResponseNotOk({response})) =>
219+
switch await response->WebAPI.SafeFetch.text {
220+
| Ok({body}) => Console.error(body)
221+
| Error(#BodyReadRejected(_)) =>
222+
Console.error(`Request failed with ${Int.toString(response.status)}`)
223+
}
224+
| Error(#FetchRejected(cause)) => reportUnavailable(cause)
225+
| Ok(response) => useResponse(response)
226+
}
227+
```
228+
229+
The safe body readers cover `arrayBuffer`, `blob`, `bytes`, `formData`, `json`, and `text`. A
230+
successful read contains both the body and original response, preserving status, headers, URL, and
231+
other metadata. Reading consumes the native body; clone the response before reading when another
232+
consumer also needs it.
233+
234+
`SafeFetch.json` returns `JSON.t`; validating that JSON against an application's domain types remains
235+
the application's responsibility. A body read preserves its `response` and original rejection in
236+
the `readError` payload. This keeps malformed JSON text separate from valid JSON that does not match
237+
an application schema.
238+
116239
`BodyInit` has constructors for the common JavaScript body shapes.
117240

118241
```ReScript

‎rescript.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
{
22
"name": "@rescript/webapi",
33
"namespace": "WebAPI",
4+
"experimental-features": {
5+
"LetUnwrap": true
6+
},
47
"features": {
58
"WebAPI.DOM": ["DOM"],
69
"WebAPI.Event": ["WebAPI.DOM", "Event"],
@@ -55,6 +58,7 @@
5558
"WebAPI.File": ["WebAPI.Event", "File"],
5659
"WebAPI.URL": ["WebAPI.DOM", "URL"],
5760
"WebAPI.Fetch": ["WebAPI.Event", "WebAPI.File", "WebAPI.URL", "Fetch"],
61+
"WebAPI.SafeFetch": ["WebAPI.Fetch", "SafeFetch"],
5862
"WebAPI.CSSFontLoading": ["WebAPI.Event", "CSSFontLoading"],
5963
"WebAPI.Device": ["WebAPI.Event", "Device"],
6064
"WebAPI.Messaging": ["WebAPI.Event", "WebAPI.File", "Messaging"],
@@ -333,6 +337,12 @@
333337
"Response"
334338
]
335339
},
340+
{
341+
"dir": "src/safe-fetch",
342+
"subdirs": true,
343+
"feature": "SafeFetch",
344+
"public": ["SafeFetch"]
345+
},
336346
{
337347
"dir": "src/file",
338348
"subdirs": true,

‎scripts/check-features.mjs‎

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
import { existsSync, readFileSync } from "node:fs";
1+
import {
2+
cpSync,
3+
existsSync,
4+
mkdtempSync,
5+
mkdirSync,
6+
readFileSync,
7+
rmSync,
8+
statSync,
9+
symlinkSync,
10+
writeFileSync,
11+
} from "node:fs";
12+
import os from "node:os";
213
import path from "node:path";
314
import { spawnSync } from "node:child_process";
415
import { fileURLToPath } from "node:url";
@@ -25,6 +36,7 @@ const expectedFeatureOwners = new Map([
2536
["WebAPI.Canvas", "Canvas"],
2637
["WebAPI.URL", "URL"],
2738
["WebAPI.Fetch", "Fetch"],
39+
["WebAPI.SafeFetch", "SafeFetch"],
2840
["WebAPI.UIEvents", "UIEvents"],
2941
["WebAPI.Observers", "Observers"],
3042
["WebAPI.Media", "Media"],
@@ -129,7 +141,9 @@ const validateSources = (sourceEntries) => {
129141
return [
130142
...(sameMembers(sourceFeatures, expectedInternalFeatures)
131143
? []
132-
: ["Source features do not match the 27 expected internal folder features."]),
144+
: [
145+
`Source features do not match the ${expectedInternalFeatures.length} expected internal folder features.`,
146+
]),
133147
...(duplicateFeatures.length === 0
134148
? []
135149
: [`Duplicate source features: ${duplicateFeatures.join(", ")}`]),
@@ -246,6 +260,64 @@ const runRescript = (args) =>
246260
encoding: "utf8",
247261
});
248262

263+
const prepareConsumer = (directory, features, fixture) => {
264+
const packageDirectory = path.join(directory, "node_modules", "@rescript", "webapi");
265+
mkdirSync(packageDirectory, { recursive: true });
266+
cpSync(path.join(repoRoot, "src"), path.join(packageDirectory, "src"), {
267+
recursive: true,
268+
filter: (source) => statSync(source).isDirectory() || /\.resi?$/.test(source),
269+
});
270+
for (const file of ["package.json", "rescript.json"]) {
271+
cpSync(path.join(repoRoot, file), path.join(packageDirectory, file));
272+
}
273+
for (const dependency of ["rescript", "@rescript/runtime"]) {
274+
symlinkSync(
275+
path.join(repoRoot, "node_modules", dependency),
276+
path.join(directory, "node_modules", dependency),
277+
"junction",
278+
);
279+
}
280+
mkdirSync(path.join(directory, "src"));
281+
cpSync(path.join(path.dirname(scriptPath), fixture), path.join(directory, "src", "Consumer.res"));
282+
writeFileSync(
283+
path.join(directory, "rescript.json"),
284+
JSON.stringify({
285+
name: "safe-fetch-consumer-check",
286+
sources: [{ dir: "src" }],
287+
dependencies: [{ name: "@rescript/webapi", features }],
288+
"experimental-features": { LetUnwrap: true },
289+
warnings: { error: "+8" },
290+
"package-specs": { module: "esmodule", "in-source": true },
291+
}),
292+
);
293+
};
294+
295+
export const isMissingSafeFetchDiagnostic = (output) =>
296+
/The module or file (?:WebAPI\.)?SafeFetch can't be found\./.test(output.replace(/\s+/g, " "));
297+
298+
const runConsumerCheck = ({ name, features, fixture, shouldCompile }) => {
299+
const directory = mkdtempSync(path.join(os.tmpdir(), "webapi-feature-consumer-"));
300+
try {
301+
prepareConsumer(directory, features, fixture);
302+
const result = spawnSync(process.execPath, [rescriptCliPath, "build", "--prod"], {
303+
cwd: directory,
304+
encoding: "utf8",
305+
});
306+
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
307+
const passed = shouldCompile
308+
? result.status === 0
309+
: result.status !== null && result.status !== 0 && isMissingSafeFetchDiagnostic(output);
310+
return passed
311+
? { _tag: "Success" }
312+
: {
313+
_tag: "Failure",
314+
message: `${name} did not ${shouldCompile ? "compile" : "reject the unavailable SafeFetch module"}.\n${result.error?.message ?? ""}\n${output.trim()}`,
315+
};
316+
} finally {
317+
rmSync(directory, { recursive: true, force: true });
318+
}
319+
};
320+
249321
const formatProcessFailure = (featureName, command, result) =>
250322
[`${featureName} failed during ${command}.`, result.stdout?.trim(), result.stderr?.trim()]
251323
.filter(Boolean)
@@ -286,4 +358,32 @@ if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === scriptPat
286358
}
287359
console.log(`[ok] ${featureName}`);
288360
}
361+
362+
for (const consumer of [
363+
{
364+
name: "SafeFetch consumer",
365+
features: ["WebAPI.SafeFetch"],
366+
fixture: "consumer-safe-fetch.res",
367+
shouldCompile: true,
368+
},
369+
{
370+
name: "Raw Fetch consumer",
371+
features: ["WebAPI.Fetch"],
372+
fixture: "consumer-fetch.res",
373+
shouldCompile: true,
374+
},
375+
{
376+
name: "Fetch-only consumer rejection",
377+
features: ["WebAPI.Fetch"],
378+
fixture: "consumer-safe-fetch-disabled.res",
379+
shouldCompile: false,
380+
},
381+
]) {
382+
const result = runConsumerCheck(consumer);
383+
if (result._tag === "Failure") {
384+
console.error(result.message);
385+
process.exit(1);
386+
}
387+
console.log(`[ok] ${consumer.name}`);
388+
}
289389
}

‎scripts/check-features.test.mjs‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from "node:assert/strict";
22
import test from "node:test";
33

4-
import { parsePublicTypeAlias } from "./check-features.mjs";
4+
import { isMissingSafeFetchDiagnostic, parsePublicTypeAlias } from "./check-features.mjs";
55

66
test("parses a public type alias without type parameters", () => {
77
assert.deepEqual(parsePublicTypeAlias("type t = DOMTree.element"), {
@@ -16,3 +16,20 @@ test("parses a public type alias with type parameters", () => {
1616
backingType: "readableStream",
1717
});
1818
});
19+
20+
test("recognizes the unavailable SafeFetch module diagnostic across line breaks", () => {
21+
assert.equal(
22+
isMissingSafeFetchDiagnostic("The module or file WebAPI.SafeFetch\ncan't be found."),
23+
true,
24+
);
25+
});
26+
27+
test("does not accept an unrelated compiler failure mentioning SafeFetch", () => {
28+
assert.equal(
29+
isMissingSafeFetchDiagnostic(
30+
"let fetch = WebAPI.SafeFetch.fetch\nThe module or file WebAPI.Request can't be found.",
31+
),
32+
false,
33+
);
34+
assert.equal(isMissingSafeFetchDiagnostic("SafeFetch consumer has a type error."), false);
35+
});

‎scripts/consumer-fetch.res‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
let request = WebAPI.Request.fromURL("https://example.invalid")
2+
3+
let fetch = () => WebAPI.Fetch.fetchWithRequest(request)
4+
5+
let read = (response: WebAPI.Response.t) => response->WebAPI.Response.text
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
let fetch = WebAPI.SafeFetch.fetch

‎scripts/consumer-safe-fetch.res‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
type user = {name: string}
2+
3+
let decodeUser = json =>
4+
switch json {
5+
| JSON.Object(dict{"name": JSON.String(name)}) => Ok({name: name})
6+
| _ => Error(#InvalidUser)
7+
}
8+
9+
let loadUser = async url => {
10+
let? Ok(response) = await WebAPI.SafeFetch.fetch(url)
11+
let? Ok({body}) = await response->WebAPI.SafeFetch.json
12+
let? Ok(user) = body->decodeUser
13+
Ok(user)
14+
}
15+
16+
let loadUserWithRequest = async request => {
17+
let? Ok(response) = await WebAPI.SafeFetch.fetchWithRequest(request)
18+
let? Ok({body}) = await response->WebAPI.SafeFetch.json
19+
let? Ok(user) = body->decodeUser
20+
Ok(user)
21+
}
22+
23+
type error = [
24+
| WebAPI.SafeFetch.fetchError
25+
| WebAPI.SafeFetch.bodyError
26+
| #InvalidUser
27+
]
28+
29+
let classify = (result: result<user, error>) =>
30+
switch result {
31+
| Ok({name}) => name
32+
| Error(#FetchRejected(_)) => "fetch"
33+
| Error(#ResponseNotOk(_)) => "status"
34+
| Error(#BodyReadRejected(_)) => "body"
35+
| Error(#InvalidUser) => "user"
36+
}
37+
38+
let consume = async () => {
39+
let request = WebAPI.Request.fromURL("https://example.invalid")
40+
let _ = classify(await loadUser("https://example.invalid"))
41+
classify(await loadUserWithRequest(request))
42+
}
43+
44+
let readCheckedText = async (response: WebAPI.Response.t) => {
45+
let? Ok(response) = response->WebAPI.SafeFetch.checkOk
46+
let? Ok({body}) = await response->WebAPI.SafeFetch.text
47+
Ok(body)
48+
}

0 commit comments

Comments
 (0)