Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions docs/content/docs/api-surface.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,30 @@ WebAPI.URL WebAPI.Fetch WebAPI.UIEvents
WebAPI.Observers WebAPI.Media WebAPI.WebAudio
WebAPI.Storage WebAPI.Messaging WebAPI.Workers
WebAPI.Crypto WebAPI.Performance WebAPI.ViewTransitions
WebAPI.SafeFetch
```

The package owns each bundle as one internal source folder. Those internal folder feature
names are an implementation detail; consumers should use only the qualified names above.
`WebAPI.SafeFetch` transitively enables `WebAPI.Fetch`, so consumers that want the result-based
helpers can select only the safe feature:

```json
{
"experimental-features": {
"LetUnwrap": true
},
"dependencies": [
{
"name": "@rescript/webapi",
"features": ["WebAPI.SafeFetch"]
}
]
}
```

The `let?` examples below require the experimental `LetUnwrap` setting with the current ReScript 13
alpha compiler. The SafeFetch API itself can also be used with ordinary `switch` expressions.

## Fetch

Expand Down Expand Up @@ -113,6 +133,109 @@ let response = await WebAPI.Fetch.fetch(
let text = await response->WebAPI.Response.text
```

### Result-based workflows

`WebAPI.Fetch` and `WebAPI.Response` are the raw browser bindings and remain unchanged. Use the
separate, opt-in `WebAPI.SafeFetch` module when request, HTTP status, and body-reading failures should
be handled as `result` values. The separate feature is an intentional breaking change for consumers
that restrict enabled features: add `WebAPI.SafeFetch` to use these helpers. Existing raw Fetch
clients are unaffected.

| Stage | Result | Preserved context |
| ----------------------- | --------------------------------------------- | ------------------------------- |
| Fetch throws or rejects | `Error(#FetchRejected(cause))` | Original rejection as `exn` |
| Response is not OK | `Error(#ResponseNotOk({response}))` | Complete response |
| Body reader rejects | `Error(#BodyReadRejected({response, cause}))` | Response and original rejection |

`SafeFetch.fetch` accepts a URL, while `SafeFetch.fetchWithRequest` accepts an existing
`WebAPI.Request.t`. Both promises fulfill with a `result` for these expected failures. A fulfilled
response is classified with the platform's `response.ok` value, so a 404 or 500 is distinct from a
request that did not produce a response. The public `fetchError` type contains `#FetchRejected(exn)`
and `#ResponseNotOk({response})`; `bodyError` contains `#BodyReadRejected(readError)`. `readError` has
`response` and `cause` fields, and `response<'body>` retains the original response alongside the
successful body. The function signatures expose extensible error rows (`[> fetchError]` and
`[> bodyError]`), so application errors can be added directly with `let?`, without `mapError`.

```ReScript
let fetchJson = async url => {
let? Ok(response) = await WebAPI.SafeFetch.fetch(url)
let? Ok({body}) = await response->WebAPI.SafeFetch.json
Ok(body)
}
```

The body reader reports malformed JSON as `#BodyReadRejected`; valid JSON with the wrong shape is an
application decoding failure instead. Here is one way to compose the stages and retain an application
error variant in the result:

```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 composed result has open error rows, so its caller can handle each stage exhaustively, including
the application-specific `#InvalidUser`:

```ReScript
switch await loadUser("https://example.com/api/users/42") {
| Error(#FetchRejected(cause)) => reportUnavailable(cause)
| Error(#ResponseNotOk({response})) => handleHttpError(response.status)
| Error(#BodyReadRejected({response, cause})) => handleInvalidBody(response.status, cause)
| Error(#InvalidUser) => handleInvalidUser()
| Ok(user) => showUser(user)
}
```

Use `checkOk` to classify a response obtained from the raw API without reading its body. Its
non-success result is tagged as `#ResponseNotOk({response})`.

```ReScript
let response = await WebAPI.Fetch.fetch("https://example.com/api/users")

switch response->WebAPI.SafeFetch.checkOk {
| Ok(response) => useResponse(response)
| Error(#ResponseNotOk({response})) => handleHttpError(response.status)
}
```

A non-OK response remains readable. This is useful when a server returns structured error details;
reading that body can itself fail with `#BodyReadRejected`.

```ReScript
switch await WebAPI.SafeFetch.fetch("https://example.com/api/users/42") {
| Error(#ResponseNotOk({response})) =>
switch await response->WebAPI.SafeFetch.text {
| Ok({body}) => Console.error(body)
| Error(#BodyReadRejected(_)) =>
Console.error(`Request failed with ${Int.toString(response.status)}`)
}
| Error(#FetchRejected(cause)) => reportUnavailable(cause)
| Ok(response) => useResponse(response)
}
```

The safe body readers cover `arrayBuffer`, `blob`, `bytes`, `formData`, `json`, and `text`. A
successful read contains both the body and original response, preserving status, headers, URL, and
other metadata. Reading consumes the native body; clone the response before reading when another
consumer also needs it.

`SafeFetch.json` returns `JSON.t`; validating that JSON against an application's domain types remains
the application's responsibility. A body read preserves its `response` and original rejection in
the `readError` payload. This keeps malformed JSON text separate from valid JSON that does not match
an application schema.

`BodyInit` has constructors for the common JavaScript body shapes.

```ReScript
Expand Down
10 changes: 10 additions & 0 deletions rescript.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"name": "@rescript/webapi",
"namespace": "WebAPI",
"experimental-features": {
"LetUnwrap": true
},
"features": {
"WebAPI.DOM": ["DOM"],
"WebAPI.Event": ["WebAPI.DOM", "Event"],
Expand Down Expand Up @@ -55,6 +58,7 @@
"WebAPI.File": ["WebAPI.Event", "File"],
"WebAPI.URL": ["WebAPI.DOM", "URL"],
"WebAPI.Fetch": ["WebAPI.Event", "WebAPI.File", "WebAPI.URL", "Fetch"],
"WebAPI.SafeFetch": ["WebAPI.Fetch", "SafeFetch"],
"WebAPI.CSSFontLoading": ["WebAPI.Event", "CSSFontLoading"],
"WebAPI.Device": ["WebAPI.Event", "Device"],
"WebAPI.Messaging": ["WebAPI.Event", "WebAPI.File", "Messaging"],
Expand Down Expand Up @@ -333,6 +337,12 @@
"Response"
]
},
{
"dir": "src/safe-fetch",
"subdirs": true,
"feature": "SafeFetch",
"public": ["SafeFetch"]
},
{
"dir": "src/file",
"subdirs": true,
Expand Down
104 changes: 102 additions & 2 deletions scripts/check-features.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { existsSync, readFileSync } from "node:fs";
import {
cpSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
Expand All @@ -25,6 +36,7 @@ const expectedFeatureOwners = new Map([
["WebAPI.Canvas", "Canvas"],
["WebAPI.URL", "URL"],
["WebAPI.Fetch", "Fetch"],
["WebAPI.SafeFetch", "SafeFetch"],
["WebAPI.UIEvents", "UIEvents"],
["WebAPI.Observers", "Observers"],
["WebAPI.Media", "Media"],
Expand Down Expand Up @@ -129,7 +141,9 @@ const validateSources = (sourceEntries) => {
return [
...(sameMembers(sourceFeatures, expectedInternalFeatures)
? []
: ["Source features do not match the 27 expected internal folder features."]),
: [
`Source features do not match the ${expectedInternalFeatures.length} expected internal folder features.`,
]),
...(duplicateFeatures.length === 0
? []
: [`Duplicate source features: ${duplicateFeatures.join(", ")}`]),
Expand Down Expand Up @@ -246,6 +260,64 @@ const runRescript = (args) =>
encoding: "utf8",
});

const prepareConsumer = (directory, features, fixture) => {
const packageDirectory = path.join(directory, "node_modules", "@rescript", "webapi");
mkdirSync(packageDirectory, { recursive: true });
cpSync(path.join(repoRoot, "src"), path.join(packageDirectory, "src"), {
recursive: true,
filter: (source) => statSync(source).isDirectory() || /\.resi?$/.test(source),
});
for (const file of ["package.json", "rescript.json"]) {
cpSync(path.join(repoRoot, file), path.join(packageDirectory, file));
}
for (const dependency of ["rescript", "@rescript/runtime"]) {
symlinkSync(
path.join(repoRoot, "node_modules", dependency),
path.join(directory, "node_modules", dependency),
"junction",
);
}
mkdirSync(path.join(directory, "src"));
cpSync(path.join(path.dirname(scriptPath), fixture), path.join(directory, "src", "Consumer.res"));
writeFileSync(
path.join(directory, "rescript.json"),
JSON.stringify({
name: "safe-fetch-consumer-check",
sources: [{ dir: "src" }],
dependencies: [{ name: "@rescript/webapi", features }],
"experimental-features": { LetUnwrap: true },
warnings: { error: "+8" },
"package-specs": { module: "esmodule", "in-source": true },
}),
);
};

export const isMissingSafeFetchDiagnostic = (output) =>
/The module or file (?:WebAPI\.)?SafeFetch can't be found\./.test(output.replace(/\s+/g, " "));

const runConsumerCheck = ({ name, features, fixture, shouldCompile }) => {
const directory = mkdtempSync(path.join(os.tmpdir(), "webapi-feature-consumer-"));
try {
prepareConsumer(directory, features, fixture);
const result = spawnSync(process.execPath, [rescriptCliPath, "build", "--prod"], {
cwd: directory,
encoding: "utf8",
});
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
const passed = shouldCompile
? result.status === 0
: result.status !== null && result.status !== 0 && isMissingSafeFetchDiagnostic(output);
return passed
? { _tag: "Success" }
: {
_tag: "Failure",
message: `${name} did not ${shouldCompile ? "compile" : "reject the unavailable SafeFetch module"}.\n${result.error?.message ?? ""}\n${output.trim()}`,
};
} finally {
rmSync(directory, { recursive: true, force: true });
}
};

const formatProcessFailure = (featureName, command, result) =>
[`${featureName} failed during ${command}.`, result.stdout?.trim(), result.stderr?.trim()]
.filter(Boolean)
Expand Down Expand Up @@ -286,4 +358,32 @@ if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === scriptPat
}
console.log(`[ok] ${featureName}`);
}

for (const consumer of [
{
name: "SafeFetch consumer",
features: ["WebAPI.SafeFetch"],
fixture: "consumer-safe-fetch.res",
shouldCompile: true,
},
{
name: "Raw Fetch consumer",
features: ["WebAPI.Fetch"],
fixture: "consumer-fetch.res",
shouldCompile: true,
},
{
name: "Fetch-only consumer rejection",
features: ["WebAPI.Fetch"],
fixture: "consumer-safe-fetch-disabled.res",
shouldCompile: false,
},
]) {
const result = runConsumerCheck(consumer);
if (result._tag === "Failure") {
console.error(result.message);
process.exit(1);
}
console.log(`[ok] ${consumer.name}`);
}
}
19 changes: 18 additions & 1 deletion scripts/check-features.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";

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

test("parses a public type alias without type parameters", () => {
assert.deepEqual(parsePublicTypeAlias("type t = DOMTree.element"), {
Expand All @@ -16,3 +16,20 @@ test("parses a public type alias with type parameters", () => {
backingType: "readableStream",
});
});

test("recognizes the unavailable SafeFetch module diagnostic across line breaks", () => {
assert.equal(
isMissingSafeFetchDiagnostic("The module or file WebAPI.SafeFetch\ncan't be found."),
true,
);
});

test("does not accept an unrelated compiler failure mentioning SafeFetch", () => {
assert.equal(
isMissingSafeFetchDiagnostic(
"let fetch = WebAPI.SafeFetch.fetch\nThe module or file WebAPI.Request can't be found.",
),
false,
);
assert.equal(isMissingSafeFetchDiagnostic("SafeFetch consumer has a type error."), false);
});
5 changes: 5 additions & 0 deletions scripts/consumer-fetch.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
let request = WebAPI.Request.fromURL("https://example.invalid")

let fetch = () => WebAPI.Fetch.fetchWithRequest(request)

let read = (response: WebAPI.Response.t) => response->WebAPI.Response.text
1 change: 1 addition & 0 deletions scripts/consumer-safe-fetch-disabled.res
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
let fetch = WebAPI.SafeFetch.fetch
48 changes: 48 additions & 0 deletions scripts/consumer-safe-fetch.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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)
}

let loadUserWithRequest = async request => {
let? Ok(response) = await WebAPI.SafeFetch.fetchWithRequest(request)
let? Ok({body}) = await response->WebAPI.SafeFetch.json
let? Ok(user) = body->decodeUser
Ok(user)
}

type error = [
| WebAPI.SafeFetch.fetchError
| WebAPI.SafeFetch.bodyError
| #InvalidUser
]

let classify = (result: result<user, error>) =>
switch result {
| Ok({name}) => name
| Error(#FetchRejected(_)) => "fetch"
| Error(#ResponseNotOk(_)) => "status"
| Error(#BodyReadRejected(_)) => "body"
| Error(#InvalidUser) => "user"
}

let consume = async () => {
let request = WebAPI.Request.fromURL("https://example.invalid")
let _ = classify(await loadUser("https://example.invalid"))
classify(await loadUserWithRequest(request))
}

let readCheckedText = async (response: WebAPI.Response.t) => {
let? Ok(response) = response->WebAPI.SafeFetch.checkOk
let? Ok({body}) = await response->WebAPI.SafeFetch.text
Ok(body)
}
Loading
Loading