diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cca91e1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 1.1.0 + +- `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 + +- 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 edc58af..751ece7 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,40 @@ try { throw await HttpError.from(response); } } catch (err) { - console.error(err.message); // "404 Not Found" + console.error(err.message); // "404 Not Found" — or aggregated body errors[].message values console.error(err.text); // Raw response body console.error(err.json); // Parsed JSON (if applicable) console.error(err.cause); // Original Response object } ``` +### APIs that return errors in the body + +Some APIs carry application-level failures in the response body rather than (or in addition to) HTTP status codes. `from()` reads the body and aggregates an `errors[]` envelope into the message automatically — for each entry, the first present of `message` or `detail` is used, joined by `; `. Codes and any other per-error fields stay on `err.json.errors[]`. + +```javascript +const response = await fetch('https://api.example.com/graphql', { /* ... */ }); + +if (!response.ok) { + throw await HttpError.from(response); +} + +const json = await response.json(); + +if (json?.errors?.length) { + throw await HttpError.from(response); +} + +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[]`. + +This covers two widely used envelope shapes: + +- [**GraphQL**](https://spec.graphql.org/October2021/#sec-Errors) — every response error entry includes a `message` string. Servers return 200 OK with a top-level `errors[]` for both partial and total failures. The same envelope is used by many REST APIs that signal application-level failures in the body rather than (or in addition to) HTTP status codes. +- [**JSON:API**](https://jsonapi.org/format/#errors) — error objects use `detail` for the per-occurrence explanation. + ## API ### `new HttpError(response)` @@ -57,3 +84,5 @@ Async factory that creates an `HttpError` and captures the response body: - `err.cause` — the original `Response` object The original response is not consumed (uses `response.clone()`). + +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. diff --git a/index.js b/index.js index 7dfd1ea..7f6d6a5 100644 --- a/index.js +++ b/index.js @@ -18,7 +18,12 @@ class HttpError extends Error { */ static async from(response) { const err = new HttpError(response); - err.text = await response.clone().text().catch(() => {}); + + try { + err.text = await response.clone().text(); + } catch { + // Body already consumed or otherwise unreadable + } if (err.text) { try { @@ -28,6 +33,14 @@ class HttpError extends Error { } } + if (err.json?.errors?.length) { + const messages = err.json.errors.map(e => e.message ?? e.detail).filter(Boolean); + + if (messages.length) { + err.message = messages.join('; '); + } + } + return err; } } diff --git a/package.json b/package.json index 7ba262d..aa798f3 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.0.0" + "version": "1.1.0" } diff --git a/test/index.js b/test/index.js index 387da49..cbc2293 100644 --- a/test/index.js +++ b/test/index.js @@ -49,4 +49,67 @@ test('HttpError', { concurrency: true }, async (t) => { assert.strictEqual(text, 'body'); }); + + t.test('should aggregate body errors[] messages into err.message', async () => { + const body = { + errors: [ + { code: 'RATING.INVALID', message: 'Invalid account number' }, + { code: 'SERVICE.UNAVAILABLE', message: 'Service is currently unavailable' } + ] + }; + const response = new Response(JSON.stringify(body), { status: 200, statusText: 'OK' }); + const err = await HttpError.from(response); + + assert.strictEqual(err.message, 'Invalid account number; Service is currently unavailable'); + assert.deepStrictEqual(err.json, body); + }); + + t.test('should leave message as status when body has no errors[]', async () => { + const response = new Response('{"foo":"bar"}', { status: 200, statusText: 'OK' }); + const err = await HttpError.from(response); + + assert.strictEqual(err.message, '200 OK'); + }); + + t.test('should aggregate errors[] even on non-2xx responses', async () => { + const body = { errors: [{ message: 'Account locked' }] }; + const response = new Response(JSON.stringify(body), { status: 403, statusText: 'Forbidden' }); + const err = await HttpError.from(response); + + assert.strictEqual(err.message, 'Account locked'); + assert.deepStrictEqual(err.json, body); + }); + + t.test('should aggregate JSON:API-style errors[] using detail', async () => { + const body = { + errors: [ + { code: '422', detail: 'first name is required', title: 'Invalid Attribute' }, + { code: '422', detail: 'email is malformed', title: 'Invalid Attribute' } + ] + }; + const response = new Response(JSON.stringify(body), { status: 422, statusText: 'Unprocessable Entity' }); + const err = await HttpError.from(response); + + assert.strictEqual(err.message, 'first name is required; email is malformed'); + }); + + t.test('should keep default status message when errors[] entries have neither message nor detail', async () => { + const body = { errors: [{ code: 'UNKNOWN', title: 'Service Unavailable' }] }; + const response = new Response(JSON.stringify(body), { status: 500, statusText: 'Internal Server Error' }); + const err = await HttpError.from(response); + + assert.strictEqual(err.message, '500 Internal Server Error'); + }); + + t.test('should not throw when the response body has already been consumed', async () => { + const response = new Response('{"errors":[{"message":"x"}]}', { status: 200, statusText: 'OK' }); + await response.json(); + + const err = await HttpError.from(response); + + assert.strictEqual(err.name, 'HttpError'); + assert.strictEqual(err.message, '200 OK'); + assert.strictEqual(err.text, undefined); + assert.strictEqual(err.json, undefined); + }); });