diff --git a/CHANGELOG.md b/CHANGELOG.md index cca91e1..d095bac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,19 @@ # Changelog +All notable changes to this project will be documented in this file. -## 1.1.0 +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -- `HttpError.from(response)` now aggregates `errors[]` entries from the response body into `err.message`, joined by `; `. For each entry, `message` is used if present, otherwise `detail` — covering both the [GraphQL specification](https://spec.graphql.org/October2021/#sec-Errors) (`message`) and [JSON:API](https://jsonapi.org/format/#errors) (`detail`) envelope shapes. The default `"${status} ${statusText}"` message is still used when the body has no `errors[]`, or when no entry has either field. +## [1.2.0] - 2026-05-26 +### Added +- `HttpError.from()` accepts an optional second argument — an already-parsed JSON body. `from(response)` reads the body via `response.clone()` as before; `from(response, json)` uses the supplied body instead of re-reading the response, for a 200 response whose JSON you've already read (e.g. an `errors[]` envelope), since a response body can only be read once. The error keeps the response status and `cause`. Backward compatible. -## 1.0.0 +## [1.1.0] - 2026-05-11 +### Added +- `HttpError.from(response)` now aggregates `errors[]` entries from the response body into `err.message`, joined by `; `. For each entry, `message` is used if present, otherwise `detail` — covering both the [GraphQL specification](https://spec.graphql.org/October2021/#sec-Errors) (`message`) and [JSON:API](https://jsonapi.org/format/#errors) (`detail`) envelope shapes. The default `"${status} ${statusText}"` message is still used when the body has no `errors[]`, or when no entry has either field. +## [1.0.0] - 2026-02-11 +### Added - Initial release. - `new HttpError(response)` — error with message `"${status} ${statusText}"` and `cause` set to the response. - `HttpError.from(response)` — async factory that also captures `err.text` and `err.json`. diff --git a/README.md b/README.md index 751ece7..3ac16d6 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,15 @@ if (!response.ok) { const json = await response.json(); if (json?.errors?.length) { - throw await HttpError.from(response); + throw await HttpError.from(response, json); } return json; ``` -The default `"${status} ${statusText}"` message is used when `from()` can't read the body (already consumed) or when the body has no `errors[]`. +Pass just the `Response` while its body is still unread (the non-ok case). Once you've read it with `response.json()`, pass that parsed body as the second argument — `from(response, json)` — so it isn't re-read (a response body can only be read once). Either way the error keeps the response status and `cause`. + +The default `"${status} ${statusText}"` message is used when the body has no `errors[]`. This covers two widely used envelope shapes: @@ -75,14 +77,17 @@ This covers two widely used envelope shapes: Creates an error with message `"${status} ${statusText}"` and sets `cause` to the response. -### `HttpError.from(response)` +### `HttpError.from(response, json)` + +Async factory that creates an `HttpError` from a `Response` and captures the body: -Async factory that creates an `HttpError` and captures the response body: +- With **just a `Response`**, the body is read via `response.clone()` (the original is not consumed) and captured as `err.text` and `err.json`. +- With an **already-parsed `json`** as the second argument, that body is used directly (the response is not read), with `err.text` set to its JSON string — for a 200 response whose JSON you've already read. -- `err.text` — the response body as a string -- `err.json` — the parsed JSON (if the body is valid JSON) -- `err.cause` — the original `Response` object +In both cases: -The original response is not consumed (uses `response.clone()`). +- `err.text` — the body as a string +- `err.json` — the parsed JSON (if the body is/was valid JSON) +- `err.cause` — the original `Response` -If the parsed body carries an `errors[]` array, `err.message` is set to each entry's `message` or `detail` (whichever is present, in that order) joined by `; ` instead of the default `"${status} ${statusText}"`. See [APIs that return errors in the body](#apis-that-return-errors-in-the-body) above. +If the body carries an `errors[]` array, `err.message` is set to each entry's `message` or `detail` (whichever is present, in that order) joined by `; ` instead of the default `"${status} ${statusText}"`. See [APIs that return errors in the body](#apis-that-return-errors-in-the-body) above. diff --git a/index.js b/index.js index 7f6d6a5..9853d87 100644 --- a/index.js +++ b/index.js @@ -12,24 +12,42 @@ class HttpError extends Error { } /** - * Create an HttpError from a fetch Response, capturing the response body as text and JSON. + * Create an HttpError from a fetch Response, optionally with an already-parsed body. + * + * Pass just the `Response` — e.g. for a non-ok status — and the body is captured via + * `response.clone()`, leaving the original intact. When you've already read the body — e.g. + * a 200 response with an `errors[]` envelope, read with `await response.json()` — pass it as + * the second argument so it isn't re-read (a `Response` body can only be read once). Either + * way the error keeps the response status and `cause`. + * * @param {Response} response - The fetch Response object. + * @param {object} [json] - An already-parsed JSON body, used instead of reading the response. * @returns {Promise} Error with text and json properties. */ - static async from(response) { + static async from(response, json) { const err = new HttpError(response); - try { - err.text = await response.clone().text(); - } catch { - // Body already consumed or otherwise unreadable - } + if (json !== undefined) { + err.json = json; - if (err.text) { try { - err.json = JSON.parse(err.text); + err.text = JSON.stringify(json); + } catch { + // Body is not serializable + } + } else { + try { + err.text = await response.clone().text(); } catch { - // Response body is not JSON + // Body already consumed or otherwise unreadable + } + + if (err.text) { + try { + err.json = JSON.parse(err.text); + } catch { + // Response body is not JSON + } } } diff --git a/package.json b/package.json index aa798f3..8552096 100644 --- a/package.json +++ b/package.json @@ -23,5 +23,5 @@ "test": "node --test --test-reporter=spec", "test:only": "node --test --test-only --test-reporter=spec" }, - "version": "1.1.0" + "version": "1.2.0" } diff --git a/test/index.js b/test/index.js index cbc2293..276c08b 100644 --- a/test/index.js +++ b/test/index.js @@ -112,4 +112,39 @@ test('HttpError', { concurrency: true }, async (t) => { assert.strictEqual(err.text, undefined); assert.strictEqual(err.json, undefined); }); + + t.test('should use a body passed alongside the response without re-reading it', async () => { + const response = new Response('{"errors":[{"message":"from the response body"}]}', { status: 200, statusText: 'OK' }); + const json = { + errors: [ + { code: 'SHIPMENT.CANCEL.FAILURE', message: 'Shipment already tendered' }, + { code: 'SERVICE.UNAVAILABLE', message: 'Service is currently unavailable' } + ] + }; + const err = await HttpError.from(response, json); + + assert.strictEqual(err.name, 'HttpError'); + assert.strictEqual(err.message, 'Shipment already tendered; Service is currently unavailable'); + assert.deepStrictEqual(err.json, json); + assert.strictEqual(err.text, JSON.stringify(json)); + assert.strictEqual(err.cause, response); + assert(err instanceof Error); + }); + + t.test('should aggregate JSON:API detail from a body passed alongside the response', async () => { + const response = new Response('', { status: 422, statusText: 'Unprocessable Entity' }); + const json = { errors: [{ code: '422', detail: 'first name is required' }] }; + const err = await HttpError.from(response, json); + + assert.strictEqual(err.message, 'first name is required'); + assert.deepStrictEqual(err.json, json); + }); + + t.test('should fall back to the status message when a passed body has no errors[]', async () => { + const response = new Response('', { status: 200, statusText: 'OK' }); + const err = await HttpError.from(response, { ok: true }); + + assert.strictEqual(err.message, '200 OK'); + assert.deepStrictEqual(err.json, { ok: true }); + }); });