Skip to content
Draft
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
64 changes: 64 additions & 0 deletions docs/content/docs/api-surface.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion rescript.json
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,8 @@
"Headers",
"HeadersInit",
"Request",
"Response"
"Response",
"SafeFetch"
]
},
{
Expand Down
57 changes: 57 additions & 0 deletions src/fetch/SafeFetch.res
Original file line number Diff line number Diff line change
@@ -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<Response.t, httpError> =>
if response.ok {
Ok(response)
} else {
Error({response: response})
}

let fetch = async (url: string, ~init: option<Request.requestInit>=?) => {
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<Request.requestInit>=?) => {
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)
114 changes: 114 additions & 0 deletions src/fetch/SafeFetch.resi
Original file line number Diff line number Diff line change
@@ -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<Response.t, 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<result<Response.t, 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<result<Response.t, fetchError>>

/**
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<result<response<ArrayBuffer.t>, 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<result<response<Blob.t>, 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<result<response<array<int>>, 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<result<response<FormData.t>, 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<result<response<JSON.t>, 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<result<response<string>, readError>>
4 changes: 4 additions & 0 deletions src/fetch/SafeFetchRaw.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
let fromUrl = (url: string, ~init: option<Request.requestInit>=?) => Fetch.fetch(url, ~init?)

let fromRequest = (request: Request.t, ~init: option<Request.requestInit>=?) =>
Fetch.fetchWithRequest(request, ~init?)
Loading
Loading