diff --git a/docs/content/docs/api-surface.mdx b/docs/content/docs/api-surface.mdx index 798bc3ec..7297f6aa 100644 --- a/docs/content/docs/api-surface.mdx +++ b/docs/content/docs/api-surface.mdx @@ -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 @@ -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 diff --git a/rescript.json b/rescript.json index ce2f933b..8ac1cd9c 100644 --- a/rescript.json +++ b/rescript.json @@ -1,6 +1,9 @@ { "name": "@rescript/webapi", "namespace": "WebAPI", + "experimental-features": { + "LetUnwrap": true + }, "features": { "WebAPI.DOM": ["DOM"], "WebAPI.Event": ["WebAPI.DOM", "Event"], @@ -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"], @@ -333,6 +337,12 @@ "Response" ] }, + { + "dir": "src/safe-fetch", + "subdirs": true, + "feature": "SafeFetch", + "public": ["SafeFetch"] + }, { "dir": "src/file", "subdirs": true, diff --git a/scripts/check-features.mjs b/scripts/check-features.mjs index df400647..03ddf84b 100644 --- a/scripts/check-features.mjs +++ b/scripts/check-features.mjs @@ -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"; @@ -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"], @@ -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(", ")}`]), @@ -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) @@ -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}`); + } } diff --git a/scripts/check-features.test.mjs b/scripts/check-features.test.mjs index 71dfd088..590b42c8 100644 --- a/scripts/check-features.test.mjs +++ b/scripts/check-features.test.mjs @@ -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"), { @@ -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); +}); diff --git a/scripts/consumer-fetch.res b/scripts/consumer-fetch.res new file mode 100644 index 00000000..e941af88 --- /dev/null +++ b/scripts/consumer-fetch.res @@ -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 diff --git a/scripts/consumer-safe-fetch-disabled.res b/scripts/consumer-safe-fetch-disabled.res new file mode 100644 index 00000000..d90da7cd --- /dev/null +++ b/scripts/consumer-safe-fetch-disabled.res @@ -0,0 +1 @@ +let fetch = WebAPI.SafeFetch.fetch diff --git a/scripts/consumer-safe-fetch.res b/scripts/consumer-safe-fetch.res new file mode 100644 index 00000000..002da71a --- /dev/null +++ b/scripts/consumer-safe-fetch.res @@ -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) => + 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) +} diff --git a/src/safe-fetch/SafeFetch.res b/src/safe-fetch/SafeFetch.res new file mode 100644 index 00000000..0d02588c --- /dev/null +++ b/src/safe-fetch/SafeFetch.res @@ -0,0 +1,78 @@ +type httpError = { + response: Response.t, +} + +type fetchError = [#FetchRejected(exn) | #ResponseNotOk(httpError)] + +type readError = { + response: Response.t, + cause: exn, +} + +type bodyError = [#BodyReadRejected(readError)] + +type response<'body> = { + response: Response.t, + body: 'body, +} + +let checkOk = (response: Response.t) => + if response.ok { + Ok(response) + } else { + Error(#ResponseNotOk({response: response})) + } + +let fetchUrl = async (url, ~init=?) => { + try { + let response = await ( + switch init { + | Some(init) => Fetch.fetch(url, ~init) + | None => Fetch.fetch(url) + } + ) + if response.ok { + Ok(response) + } else { + Error(#ResponseNotOk({response: response})) + } + } catch { + | cause => Error(#FetchRejected(cause)) + } +} + +let fetchWithRequest = async (request, ~init=?) => { + try { + let response = await ( + switch init { + | Some(init) => Fetch.fetchWithRequest(request, ~init) + | None => Fetch.fetchWithRequest(request) + } + ) + if response.ok { + Ok(response) + } else { + Error(#ResponseNotOk({response: response})) + } + } catch { + | cause => Error(#FetchRejected(cause)) + } +} + +// Bind the public name after both native calls so it cannot shadow JavaScript fetch. +let fetch = fetchUrl + +let read = async (response, readBody) => { + try { + Ok({response, body: await readBody(response)}) + } catch { + | cause => Error(#BodyReadRejected({response, cause})) + } +} + +let arrayBuffer = response => read(response, Response.arrayBuffer) +let blob = response => read(response, Response.blob) +let bytes = response => read(response, Response.bytes) +let formData = response => read(response, Response.formData) +let json = response => read(response, Response.json) +let text = response => read(response, Response.text) diff --git a/src/safe-fetch/SafeFetch.resi b/src/safe-fetch/SafeFetch.resi new file mode 100644 index 00000000..b3c76c52 --- /dev/null +++ b/src/safe-fetch/SafeFetch.resi @@ -0,0 +1,124 @@ +/** +Describes a response whose `ok` field is `false`. + +The original response is retained so callers can inspect its status, headers, and body. +*/ +type httpError = { + response: Response.t, +} + +/** +Describes an expected failure while producing or classifying a response. + +- `#FetchRejected(cause)` means the native Fetch operation threw or its promise rejected. Fetch does + not expose a reliable browser-independent distinction between network, abort, permission, and + other request failures. The `exn` preserves the original JavaScript rejection value; use + `JsExn.fromException` to retrieve it. A custom abort reason can be any JavaScript value, so the + cause is not restricted to `JsError.t`. +- `#ResponseNotOk({response})` means Fetch fulfilled with a response whose `ok` field is `false`. + The response remains available for inspection and body reading. + +Function results expose `[> fetchError]` so these errors can compose with errors from body readers +and application decoders. +*/ +type fetchError = [#FetchRejected(exn) | #ResponseNotOk(httpError)] + +/** +Describes a rejected response body reader. + +The record retains both the response whose body was being consumed and the original rejection +cause. +*/ +type readError = { + response: Response.t, + cause: exn, +} + +/** +Describes a rejected native response body reader. + +Function results expose `[> bodyError]` so body errors can compose with fetch and application errors. +*/ +type bodyError = [#BodyReadRejected(readError)] + +/** +A successfully read body paired with its original response. + +The response body has been consumed. Clone the response before reading when another consumer also +needs the body. +*/ +type response<'body> = { + response: Response.t, + body: 'body, +} + +/** +Classifies an existing response using its native `ok` field. + +Returns `Ok(response)` for successful responses and `Error(#ResponseNotOk({response}))` otherwise. +This function does not read the body and is useful with responses obtained from the raw `Fetch` API. +*/ +let checkOk: Response.t => result #ResponseNotOk(httpError)]> + +/** +Fetches a URL and returns expected request and HTTP status failures as values. + +The returned promise fulfills with `Error(#FetchRejected(cause))` when the native operation throws or +rejects, `Error(#ResponseNotOk({response}))` when the response is not OK, or `Ok(response)` for an OK +response. +*/ +let fetch: (string, ~init: Request.requestInit=?) => promise fetchError]>> + +/** +Fetches an existing `Request.t` and returns expected request and HTTP status failures as values. + +This has the same result contract as `fetch` and accepts an optional request initializer that is +passed to the native Fetch operation. +*/ +let fetchWithRequest: ( + Request.t, + ~init: Request.requestInit=?, +) => promise fetchError]>> + +/** +Reads the response body as an `ArrayBuffer.t`. + +Returns the body with the original response, or a `bodyError` when the native reader rejects. +*/ +let arrayBuffer: Response.t => promise, [> bodyError]>> + +/** +Reads the response body as a `Blob.t`. + +Returns the body with the original response, or a `bodyError` when the native reader rejects. +*/ +let blob: Response.t => promise, [> bodyError]>> + +/** +Reads the response body as an array of bytes. + +Returns the body with the original response, or a `bodyError` when the native reader rejects. +*/ +let bytes: Response.t => promise>, [> bodyError]>> + +/** +Reads the response body as `FormData.t`. + +Returns the body with the original response, or a `bodyError` when the native reader rejects. +*/ +let formData: Response.t => promise, [> bodyError]>> + +/** +Parses the response body as `JSON.t`. + +Malformed JSON and other native reader rejections become `bodyError` values. This function does not +validate the parsed JSON against an application domain type. +*/ +let json: Response.t => promise, [> bodyError]>> + +/** +Reads the response body as a string. + +Returns the body with the original response, or a `bodyError` when the native reader rejects. +*/ +let text: Response.t => promise, [> bodyError]>> diff --git a/tests/FetchAPI/SafeFetch__test.res b/tests/FetchAPI/SafeFetch__test.res new file mode 100644 index 00000000..bfc020fb --- /dev/null +++ b/tests/FetchAPI/SafeFetch__test.res @@ -0,0 +1,316 @@ +@scope("Object") +external isSame: ('value, 'value) => bool = "is" + +let okResponse = Response.fromString("ok", ~init={status: 200}) +switch okResponse->SafeFetch.checkOk { +| Ok(response) => assert(isSame(response, okResponse)) +| Error(#ResponseNotOk(_)) => assert(false) +} + +let notOkResponse = Response.fromString("missing", ~init={status: 404}) +switch notOkResponse->SafeFetch.checkOk { +| Error(#ResponseNotOk({response})) => assert(isSame(response, notOkResponse)) +| Ok(_) => assert(false) +} + +// Exercise native Fetch with already-aborted signals before installing the mocks. +// Fetch must not make a network request for either signal. +switch await SafeFetch.fetch( + "https://example.com/aborted", + ~init={signal: Nullable.make(AbortSignal.abort())}, +) { +| Error(#FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(JsExn.name(rawCause) == Some("AbortError")) + | None => assert(false) + } +| Error(#ResponseNotOk(_)) | Ok(_) => assert(false) +} + +let customAbortSignal = AbortSignal.abort(~reason=JSON.Encode.string("cancelled")) +switch await SafeFetch.fetch( + "https://example.com/aborted", + ~init={signal: Nullable.make(customAbortSignal)}, +) { +| Error(#FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`"cancelled"`))) + | None => assert(false) + } +| Error(#ResponseNotOk(_)) | Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response("ok", {status: 200}) +globalThis.fetch = (_input, init) => { + globalThis.__safeFetchInit = init + return Promise.resolve(globalThis.__safeFetchResponse) +} +`) + +let request = Request.fromURL("https://example.com/request") + +switch await SafeFetch.fetch("https://example.com/ok", ~init={method: "POST"}) { +| Ok(response) => { + let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) + let method: string = %raw(`globalThis.__safeFetchInit.method`) + assert(isSame(response, expected)) + assert(method == "POST") + } +| Error(_) => assert(false) +} + +switch await SafeFetch.fetchWithRequest(request, ~init={method: "POST"}) { +| Ok(response) => { + let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) + let method: string = %raw(`globalThis.__safeFetchInit.method`) + assert(isSame(response, expected)) + assert(method == "POST") + } +| Error(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response("missing", {status: 404}) +globalThis.fetch = (_input, init) => { + globalThis.__safeFetchInit = init + return Promise.resolve(globalThis.__safeFetchResponse) +} +`) + +switch await SafeFetch.fetch("https://example.com/missing") { +| Error(#ResponseNotOk({response})) => { + let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) + assert(isSame(response, expected)) + let initOmitted: bool = %raw(`globalThis.__safeFetchInit === undefined`) + assert(initOmitted) + } +| Error(#FetchRejected(_)) | Ok(_) => assert(false) +} + +switch await SafeFetch.fetchWithRequest(request) { +| Error(#ResponseNotOk({response})) => { + let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) + assert(isSame(response, expected)) + let initOmitted: bool = %raw(`globalThis.__safeFetchInit === undefined`) + assert(initOmitted) + } +| Error(#FetchRejected(_)) | Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchCause = new TypeError("request rejected") +globalThis.fetch = () => Promise.reject(globalThis.__safeFetchCause) +`) + +switch await SafeFetch.fetch("https://example.com/rejected") { +| Error(#FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) + | None => assert(false) + } +| Error(#ResponseNotOk(_)) | Ok(_) => assert(false) +} + +switch await SafeFetch.fetchWithRequest(request) { +| Error(#FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) + | None => assert(false) + } +| Error(#ResponseNotOk(_)) | Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchCause = new TypeError("request threw") +globalThis.fetch = () => { throw globalThis.__safeFetchCause } +`) + +switch await SafeFetch.fetchWithRequest(request) { +| Error(#FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) + | None => assert(false) + } +| Error(#ResponseNotOk(_)) | Ok(_) => assert(false) +} + +switch await SafeFetch.fetch("https://example.com/synchronous") { +| Error(#FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) + | None => assert(false) + } +| Error(#ResponseNotOk(_)) | Ok(_) => assert(false) +} + +let textResponse = Response.fromString("hello") +switch await textResponse->SafeFetch.text { +| Ok({response, body}) => { + assert(isSame(response, textResponse)) + assert(body == "hello") + } +| Error(_) => assert(false) +} + +let jsonResponse = Response.fromString(`{"name":"Ada"}`) +switch await jsonResponse->SafeFetch.json { +| Ok({response, body}) => { + let _json: JSON.t = body + assert(isSame(response, jsonResponse)) + } +| Error(_) => assert(false) +} + +let malformedResponse = Response.fromString("not json") +switch await malformedResponse->SafeFetch.json { +| Error(#BodyReadRejected({response, cause: _})) => assert(isSame(response, malformedResponse)) +| Ok(_) => assert(false) +} + +let consumedResponse = Response.fromString("once") +let _ = await consumedResponse->Response.text +switch await consumedResponse->SafeFetch.text { +| Error(#BodyReadRejected({response, cause: _})) => assert(isSame(response, consumedResponse)) +| Ok(_) => assert(false) +} + +let errorBodyResponse = Response.fromString("details", ~init={status: 500}) +switch await errorBodyResponse->SafeFetch.text { +| Ok({response, body}) => { + assert(isSame(response, errorBodyResponse)) + assert(body == "details") + } +| Error(_) => assert(false) +} + +let arrayBufferResult: result< + SafeFetch.response, + [> SafeFetch.bodyError], +> = await Response.fromString("buffer")->SafeFetch.arrayBuffer +assert(arrayBufferResult->Result.isOk) + +let blobResult: result< + SafeFetch.response, + [> SafeFetch.bodyError], +> = await Response.fromString("blob")->SafeFetch.blob +assert(blobResult->Result.isOk) + +let bytesResult: result< + SafeFetch.response>, + [> SafeFetch.bodyError], +> = await Response.fromString("bytes")->SafeFetch.bytes +assert(bytesResult->Result.isOk) + +let formDataResponse = Response.fromString( + "fruit=peach", + ~init={ + headers: HeadersInit.fromDict(dict{"Content-Type": "application/x-www-form-urlencoded"}), + }, +) +let formDataResult: result< + SafeFetch.response, + [> SafeFetch.bodyError], +> = await formDataResponse->SafeFetch.formData +assert(formDataResult->Result.isOk) + +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, decode) => { + let? Ok(response) = await SafeFetch.fetch(url) + let? Ok({body}) = await response->SafeFetch.json + let? Ok(user) = body->decode + Ok(user) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response('{"name":"Ada"}') +globalThis.fetch = () => Promise.resolve(globalThis.__safeFetchResponse) +`) + +switch await loadUser("https://example.com/user", decodeUser) { +| Ok({name}) => assert(name == "Ada") +| Error(#FetchRejected(_) | #ResponseNotOk(_) | #BodyReadRejected(_) | #InvalidUser) => + assert(false) +} + +let mustNotDecode = _ => assert(false) + +%%raw(` +globalThis.__safeFetchCause = new TypeError("composed request rejected") +globalThis.fetch = () => Promise.reject(globalThis.__safeFetchCause) +`) + +switch await loadUser("https://example.com/user", mustNotDecode) { +| Error(#FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) + | None => assert(false) + } +| Error(#ResponseNotOk(_) | #BodyReadRejected(_)) | Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response('{"name":"Ada"}', {status: 404}) +globalThis.fetch = () => Promise.resolve(globalThis.__safeFetchResponse) +`) + +switch await loadUser("https://example.com/user", mustNotDecode) { +| Error(#ResponseNotOk({response})) => { + assert(isSame(response, %raw(`globalThis.__safeFetchResponse`))) + assert(!response.bodyUsed) + } +| Error(#FetchRejected(_) | #BodyReadRejected(_)) | Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response("not json") +globalThis.fetch = () => Promise.resolve(globalThis.__safeFetchResponse) +`) + +switch await loadUser("https://example.com/user", mustNotDecode) { +| Error(#BodyReadRejected({response, cause})) => { + assert(isSame(response, %raw(`globalThis.__safeFetchResponse`))) + switch cause->JsExn.fromException { + | Some(rawCause) => assert(JsExn.name(rawCause) == Some("SyntaxError")) + | None => assert(false) + } + } +| Error(#FetchRejected(_) | #ResponseNotOk(_)) | Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response('{"name":42}') +globalThis.fetch = () => Promise.resolve(globalThis.__safeFetchResponse) +`) + +switch await loadUser("https://example.com/user", decodeUser) { +| Error(#InvalidUser) => () +| Error(#FetchRejected(_) | #ResponseNotOk(_) | #BodyReadRejected(_)) | Ok(_) => assert(false) +} + +let readCheckedText = async response => { + let? Ok(response) = response->SafeFetch.checkOk + let? Ok({body}) = await response->SafeFetch.text + Ok(body) +} + +switch await Response.fromString("checked")->readCheckedText { +| Ok(body) => assert(body == "checked") +| Error(#ResponseNotOk(_) | #BodyReadRejected(_)) => assert(false) +} + +let uncheckedResponse = Response.fromString("not read", ~init={status: 500}) +switch await uncheckedResponse->readCheckedText { +| Error(#ResponseNotOk({response})) => { + assert(isSame(response, uncheckedResponse)) + assert(!response.bodyUsed) + } +| Error(#BodyReadRejected(_)) | Ok(_) => assert(false) +} diff --git a/tests/index.js b/tests/index.js index c989c737..1547bb4f 100644 --- a/tests/index.js +++ b/tests/index.js @@ -16,6 +16,7 @@ const runtimeTests = [ "FetchAPI/Headers__test.res", "FetchAPI/Request__test.res", "FetchAPI/Response__test.res", + "FetchAPI/SafeFetch__test.res", "FetchAPI/URLSearchParams__test.res", "URLAPI/URL__test.res", ];