Skip to content
Open
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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we maybe have the Safe modules as a distinct feature?

]
},
{
Expand Down
78 changes: 78 additions & 0 deletions src/fetch/SafeFetch.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
type httpError = {
response: Response.t,
}

type fetchError =
| FetchRejected(exn)
| ResponseNotOk(httpError)
Comment on lines +5 to +7

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we should not use polymorphic variants here, this way we could more easily compose errors, what do you think @jderochervlk @fhammerschmidt ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep absolutely.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely polyvariants.


type readError = {
response: Response.t,
cause: exn,
}

type response<'body> = {
response: Response.t,
body: 'body,
}

let checkOk = (response: Response.t): result<Response.t, httpError> =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpicking but LLMs tend to annotate functions a lot, which is not very idiomatic in rescript, this kind of code will likely be taken as a reference at some point, maybe we should remove these type annotations?

if response.ok {
Ok(response)
} else {
Error({response: response})
}

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