diff --git a/docs/content/docs/api-surface.mdx b/docs/content/docs/api-surface.mdx index 798bc3ec..d2eed2ff 100644 --- a/docs/content/docs/api-surface.mdx +++ b/docs/content/docs/api-surface.mdx @@ -113,6 +113,70 @@ 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. Use the opt-in +`WebAPI.SafeFetch` helpers when request, HTTP status, and body-reading failures should be handled as +`result` values. The wrapper keeps those failure stages separate and preserves the native values +needed to inspect each outcome. + +| 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({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. + +```ReScript +switch await WebAPI.SafeFetch.fetch("https://example.com/api/users") { +| Error(WebAPI.SafeFetch.FetchRejected(cause)) => handleUnavailable(cause) +| Error(WebAPI.SafeFetch.ResponseNotOk({response})) => handleHttpError(response.status) +| Ok(response) => + switch await response->WebAPI.SafeFetch.json { + | Error({response, cause}) => handleInvalidBody(response.status, cause) + | Ok({response, body}) => handleJson(response.headers, body) + } +} +``` + +Use `checkOk` to classify a response obtained from the raw API without reading its body. + +```ReScript +let response = await WebAPI.Fetch.fetch("https://example.com/api/users") + +switch response->WebAPI.SafeFetch.checkOk { +| Ok(response) => useResponse(response) +| Error({response}) => handleHttpError(response.status) +} +``` + +A non-OK response remains readable. This is useful when a server returns structured error details. + +```ReScript +switch await WebAPI.SafeFetch.fetch("https://example.com/api/users/42") { +| Error(WebAPI.SafeFetch.ResponseNotOk({response})) => + switch await response->WebAPI.SafeFetch.text { + | Ok({body}) => Console.error(body) + | Error(_) => Console.error(`Request failed with ${Int.toString(response.status)}`) + } +| Error(WebAPI.SafeFetch.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. This keeps malformed JSON text, which becomes a `readError`, +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..1e25cd67 100644 --- a/rescript.json +++ b/rescript.json @@ -330,7 +330,8 @@ "Headers", "HeadersInit", "Request", - "Response" + "Response", + "SafeFetch" ] }, { diff --git a/src/fetch/SafeFetch.res b/src/fetch/SafeFetch.res new file mode 100644 index 00000000..fabffd05 --- /dev/null +++ b/src/fetch/SafeFetch.res @@ -0,0 +1,57 @@ +type httpError = { + response: Response.t, +} + +type fetchError = + | FetchRejected(exn) + | ResponseNotOk(httpError) + +type readError = { + response: Response.t, + cause: exn, +} + +type response<'body> = { + response: Response.t, + body: 'body, +} + +let checkOk = (response: Response.t): result => + if response.ok { + Ok(response) + } else { + Error({response: response}) + } + +let fetch = async (url: string, ~init: option=?) => { + try { + let response = await SafeFetchRaw.fromUrl(url, ~init?) + response->checkOk->Result.mapError(error => ResponseNotOk(error)) + } catch { + | cause => Error(FetchRejected(cause)) + } +} + +let fetchWithRequest = async (request: Request.t, ~init: option=?) => { + try { + let response = await SafeFetchRaw.fromRequest(request, ~init?) + response->checkOk->Result.mapError(error => ResponseNotOk(error)) + } catch { + | cause => Error(FetchRejected(cause)) + } +} + +let read = async (response: Response.t, readBody: Response.t => promise<'body>) => { + try { + Ok({response, body: await readBody(response)}) + } catch { + | cause => Error({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/fetch/SafeFetch.resi b/src/fetch/SafeFetch.resi new file mode 100644 index 00000000..848d085d --- /dev/null +++ b/src/fetch/SafeFetch.resi @@ -0,0 +1,114 @@ +/** +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. +- `ResponseNotOk({response})` means Fetch fulfilled with a response whose `ok` field is `false`. + The response remains available for inspection and body reading. +*/ +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, +} + +/** +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({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 + +/** +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> + +/** +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> + +/** +Reads the response body as an `ArrayBuffer.t`. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ +let arrayBuffer: Response.t => promise, readError>> + +/** +Reads the response body as a `Blob.t`. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ +let blob: Response.t => promise, readError>> + +/** +Reads the response body as an array of bytes. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ +let bytes: Response.t => promise>, readError>> + +/** +Reads the response body as `FormData.t`. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ +let formData: Response.t => promise, readError>> + +/** +Parses the response body as `JSON.t`. + +Malformed JSON and other native reader rejections become `readError` values. This function does not +validate the parsed JSON against an application domain type. +*/ +let json: Response.t => promise, readError>> + +/** +Reads the response body as a string. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ +let text: Response.t => promise, readError>> diff --git a/src/fetch/SafeFetchRaw.res b/src/fetch/SafeFetchRaw.res new file mode 100644 index 00000000..d0dd3590 --- /dev/null +++ b/src/fetch/SafeFetchRaw.res @@ -0,0 +1,4 @@ +let fromUrl = (url: string, ~init: option=?) => Fetch.fetch(url, ~init?) + +let fromRequest = (request: Request.t, ~init: option=?) => + Fetch.fetchWithRequest(request, ~init?) diff --git a/tests/FetchAPI/SafeFetch__test.res b/tests/FetchAPI/SafeFetch__test.res new file mode 100644 index 00000000..59d5298f --- /dev/null +++ b/tests/FetchAPI/SafeFetch__test.res @@ -0,0 +1,144 @@ +@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(_) => assert(false) +} + +let notOkResponse = Response.fromString("missing", ~init={status: 404}) +switch notOkResponse->SafeFetch.checkOk { +| Error({response}) => assert(isSame(response, notOkResponse)) +| Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response("ok", {status: 200}) +globalThis.fetch = (_input, init) => { + globalThis.__safeFetchInit = init + return Promise.resolve(globalThis.__safeFetchResponse) +} +`) + +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) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response("missing", {status: 404}) +globalThis.fetch = () => 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)) + } +| 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) +} + +let request = Request.fromURL("https://example.com/synchronous") + +%%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) +} + +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({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({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.readError, +> = await Response.fromString("buffer")->SafeFetch.arrayBuffer +assert(arrayBufferResult->Result.isOk) + +let blobResult: result, SafeFetch.readError> = await Response.fromString( + "blob", +)->SafeFetch.blob +assert(blobResult->Result.isOk) + +let bytesResult: result< + SafeFetch.response>, + SafeFetch.readError, +> = 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.readError, +> = await formDataResponse->SafeFetch.formData +assert(formDataResult->Result.isOk) 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", ];