diff --git a/README.MD b/README.MD index 6e7b5f6..57d1df9 100644 --- a/README.MD +++ b/README.MD @@ -198,7 +198,7 @@ ctx can be whatever you want really - it is whatever you pass in as `context(... However, if the `context` object you pass in has a `fetch` function, this is used as the `fetch` for XHR requests. -If it has a `logger` with an `error(details, message)` method, such as a pino logger, every failure that reaches a `default` handler (request-level or global) is logged through it before the handler runs, with `err`, `status`, `method`, `url` and `body`. Without one the console is used. Failures caught by a status-specific handler such as `.notFound()` are not logged. +If it has a `logger` with an `error(details, message)` method, such as a pino logger, every failure that reaches a `default` handler (on the request or in `Api.configure()`) is logged through it before the handler runs, with `err`, `status`, `method`, `url` and `body`. Without one the console is used. Failures caught by a status-specific handler such as `.notFound()` are not logged. ```js export async function preload () { @@ -220,22 +220,22 @@ export async function preload () { } ``` -### At a global level +### In `Api.configure()` -Request local error handlers override global error handlers, but if a local error handler are not specified, these will be called instead, if it exists. +A handler on the request (such as `.notFound()`) wins over one in `Api.configure()`. If the request doesn't have one, the handler in `Api.configure()` for that status runs, if there is one. -Names are the same as the local handlers: +Handlers in `Api.configure()` are keyed by error type, imported from the library: ```js - import Api from '@beyonk/http' + import Api, { PaymentRequired } from '@beyonk/http' import { redirect } from '@sveltejs/kit' Api.configure({ baseUrl: 'https://example.com/your/api/base', handlers: { - paymentRequired(e => { + [PaymentRequired] (e) { throw redirect(307, '/checkout') - }) + } } }) @@ -244,7 +244,21 @@ Names are the same as the local handlers: .get() ``` -A global `default` handler catches anything that no other handler does. A request-level `.default()` takes precedence over it, but global status handlers (such as `notFound`) do not check it. +| Status | On the request | In `Api.configure()` | +| --- | --- | --- | +| 401 | `.accessDenied()` | `[AccessDenied]` | +| 402 | `.paymentRequired()` | `[PaymentRequired]` | +| 403 | `.forbidden()` | `[Forbidden]` | +| 404 | `.notFound()` | `[NotFound]` | +| 406 | `.notAcceptable()` | `[NotAcceptable]` | +| 409 | `.conflict()` | `[Conflict]` | +| 410 | `.gone()` | `[Gone]` | +| 412 | `.preconditionFailed()` | `[PreconditionFailed]` | +| 417 | `.expectationFailed()` | `[ExpectationFailed]` | +| 422 | `.badData()` | `[BadData]` | +| 429 | `.tooManyRequests()` | `[TooManyRequests]` | + +A `default` in `Api.configure()` catches anything no other handler does. A `.default()` on the request wins over it, but a status handler in `Api.configure()` (such as `[NotFound]`) still wins over a `.default()` on the request. ```js Api.configure({ @@ -320,6 +334,21 @@ Api.configure({ }) ``` +## Upgrading to 14 + +Handlers in `Api.configure()` are keyed by the exported error types instead of by name. `Api.configure()` throws if any handler other than `default` is keyed by name, so update every call: + +```js +// 13 +Api.configure({ handlers: { accessDenied: fn } }) + +// 14 +import Api, { AccessDenied } from '@beyonk/http' +Api.configure({ handlers: { [AccessDenied]: fn } }) +``` + +Handlers on the request (such as `.notFound()`) and `default` are unchanged. + ## Running Tests ```sh diff --git a/lib/index.js b/lib/index.js index 7ba8cd9..1a905eb 100644 --- a/lib/index.js +++ b/lib/index.js @@ -11,6 +11,33 @@ * } from './types.js' */ +export const AccessDenied = Symbol.for('@beyonk/http/accessDenied') +export const PaymentRequired = Symbol.for('@beyonk/http/paymentRequired') +export const Forbidden = Symbol.for('@beyonk/http/forbidden') +export const NotFound = Symbol.for('@beyonk/http/notFound') +export const NotAcceptable = Symbol.for('@beyonk/http/notAcceptable') +export const Conflict = Symbol.for('@beyonk/http/conflict') +export const Gone = Symbol.for('@beyonk/http/gone') +export const PreconditionFailed = Symbol.for('@beyonk/http/preconditionFailed') +export const ExpectationFailed = Symbol.for('@beyonk/http/expectationFailed') +export const BadData = Symbol.for('@beyonk/http/badData') +export const TooManyRequests = Symbol.for('@beyonk/http/tooManyRequests') + +/** @type {Record} */ +const errorTypes = { + 401: AccessDenied, + 402: PaymentRequired, + 403: Forbidden, + 404: NotFound, + 406: NotAcceptable, + 409: Conflict, + 410: Gone, + 412: PreconditionFailed, + 417: ExpectationFailed, + 422: BadData, + 429: TooManyRequests +} + /** * Base HTTP error class */ @@ -26,76 +53,18 @@ export class HttpError extends Error { this.body = body /** @type {number | undefined} HTTP status code, absent when the request never got a response */ this.status = status + /** @type {symbol | undefined} Error type, used as the handler key */ + this.type = status ? errorTypes[status] : undefined /** @type {{ method: string, url: string } | undefined} The request that failed */ this.request = undefined } } -/** 401 Unauthorized error */ -class AccessDeniedError extends HttpError {} - -/** 402 Payment Required error */ -class PaymentRequiredError extends HttpError {} - -/** 403 Forbidden error */ -class ForbiddenError extends HttpError {} - -/** 404 Not Found error */ -class NotFoundError extends HttpError {} - -/** 406 Not Acceptable error */ -class NotAcceptableError extends HttpError {} - -/** 409 Conflict error */ -class ConflictError extends HttpError {} - -/** 410 Gone error */ -class GoneError extends HttpError {} - -/** 412 Precondition Failed error */ -class PreconditionFailedError extends HttpError {} - -/** 417 Expectation Failed error */ -class ExpectationFailedError extends HttpError {} - -/** 422 Unprocessable Entity error */ -class BadDataError extends HttpError {} - -/** 429 Too Many Requests error */ -class TooManyRequestsError extends HttpError {} - -/** - * Map of HTTP status codes to error classes - * @type {Record} - */ -const errorMapping = { - 401: AccessDeniedError, - 402: PaymentRequiredError, - 403: ForbiddenError, - 404: NotFoundError, - 406: NotAcceptableError, - 409: ConflictError, - 410: GoneError, - 412: PreconditionFailedError, - 417: ExpectationFailedError, - 422: BadDataError, - 429: TooManyRequestsError -} - /** @param {unknown} obj */ function simpleClone (obj) { return JSON.parse(JSON.stringify(obj)) } -/** - * Get the error class for a status code - * @param {number} code - HTTP status code - * @returns {typeof HttpError} Error class - */ -function getErrorByCode (code) { - return errorMapping[code] || HttpError -} - /** * @param {ApiOptions['retry']} retry * @returns {{ attempts: number, errors: string[], methods: string[] }} @@ -130,7 +99,7 @@ const DEFAULT_CONFIG = { class Api { config = simpleClone(DEFAULT_CONFIG) - /** @type {Record} */ + /** @type {Partial>} */ handlers = {} /** @type {FetchClient|null} */ @@ -195,24 +164,20 @@ class Api { * @returns {any} Result of error handler */ handle (e, ctx) { - const constructorName = Object.getPrototypeOf(e).constructor.name - const globalHandlerName = `${constructorName[0].toLowerCase()}${constructorName.slice(1, -5)}` + const type = e instanceof HttpError ? e.type : undefined - const specificHandler = this.handlers[constructorName] || - (this.options.handlers && this.options.handlers[globalHandlerName]) + const specificHandler = type && (this.handlers[type] || + (this.options.handlers && this.options.handlers[type])) const handler = specificHandler || this.defaultHandler || - (this.options.handlers && this.options.handlers.default) || - ((e) => { - console.error(constructorName, e.message, e) - }) + (this.options.handlers && this.options.handlers.default) if (!specificHandler) { this.log(e, ctx) } - return handler(e, ctx) + return handler ? handler(e, ctx) : undefined } /** @@ -328,8 +293,7 @@ class Api { console.log('Failed to parse error body when asked.') } - const ClientError = getErrorByCode(r.status) - throw new ClientError(r.statusText, content, r.status) + throw new HttpError(r.statusText, content, r.status) } catch (e) { const retry = retrySettings(this.options.retry) // @ts-ignore @@ -494,7 +458,7 @@ class Api { * @returns {this} Current instance */ accessDenied (fn) { - this.handlers[AccessDeniedError.name] = fn + this.handlers[AccessDenied] = fn return this } @@ -504,7 +468,7 @@ class Api { * @returns {this} Current instance */ paymentRequired (fn) { - this.handlers[PaymentRequiredError.name] = fn + this.handlers[PaymentRequired] = fn return this } @@ -514,7 +478,7 @@ class Api { * @returns {this} Current instance */ forbidden (fn) { - this.handlers[ForbiddenError.name] = fn + this.handlers[Forbidden] = fn return this } @@ -524,7 +488,7 @@ class Api { * @returns {this} Current instance */ notFound (fn) { - this.handlers[NotFoundError.name] = fn + this.handlers[NotFound] = fn return this } @@ -534,7 +498,7 @@ class Api { * @returns {this} Current instance */ notAcceptable (fn) { - this.handlers[NotAcceptableError.name] = fn + this.handlers[NotAcceptable] = fn return this } @@ -544,7 +508,7 @@ class Api { * @returns {this} Current instance */ conflict (fn) { - this.handlers[ConflictError.name] = fn + this.handlers[Conflict] = fn return this } @@ -554,7 +518,7 @@ class Api { * @returns {this} Current instance */ gone (fn) { - this.handlers[GoneError.name] = fn + this.handlers[Gone] = fn return this } @@ -564,7 +528,7 @@ class Api { * @returns {this} Current instance */ preconditionFailed (fn) { - this.handlers[PreconditionFailedError.name] = fn + this.handlers[PreconditionFailed] = fn return this } @@ -574,7 +538,7 @@ class Api { * @returns {this} Current instance */ expectationFailed (fn) { - this.handlers[ExpectationFailedError.name] = fn + this.handlers[ExpectationFailed] = fn return this } @@ -584,7 +548,7 @@ class Api { * @returns {this} Current instance */ badData (fn) { - this.handlers[BadDataError.name] = fn + this.handlers[BadData] = fn return this } @@ -594,7 +558,7 @@ class Api { * @returns {this} Current instance */ tooManyRequests (fn) { - this.handlers[TooManyRequestsError.name] = fn + this.handlers[TooManyRequests] = fn return this } } @@ -610,6 +574,12 @@ let config * @param {ApiOptions} options - API configuration options */ function configure (options) { + const unknownKey = Object.keys((options && options.handlers) || {}).find(key => key !== 'default') + + if (unknownKey) { + throw new Error(`Unknown handler "${unknownKey}" in configure(). Key handlers by the error types exported from @beyonk/http, such as [AccessDenied].`) + } + config = options } diff --git a/lib/index.spec.js b/lib/index.spec.js index 8abe621..1581b91 100644 --- a/lib/index.spec.js +++ b/lib/index.spec.js @@ -1,6 +1,6 @@ import sinon from 'sinon' import { expect } from '@hapi/code' -import { Api } from './index.js' +import Http, { Api, AccessDenied } from './index.js' const { stub } = sinon @@ -54,145 +54,6 @@ describe('util/api', () => { expect(error).to.equal(expectedError) }) - it('has specific error handler', async () => { - let error - const expectedError = 'hit local 401 handler' - - const api = createApi() - - clientStub.resolves(response({ status: 401, statusText: 'No', body: { error: 'no' } })) - - await api - .context({ fetch: clientStub }) - .endpoint('some/url') - .accessDenied(() => { - error = expectedError - }) - .get() - - expect(error).to.equal(expectedError) - }) - - it('has global error handler', async () => { - let error - const expectedError = 'hit global 401 handler' - - const api = createApi({ - handlers: { - accessDenied: () => { - error = expectedError - } - } - }) - - clientStub.resolves(response({ status: 401, statusText: 'No', body: { error: 'no' } })) - - await api - .context({ fetch: clientStub }) - .endpoint('some/url') - .get() - - expect(error).to.equal(expectedError) - }) - - it('prefers local error handler to global error handler', async () => { - let globalCalled = false - let localCalled = false - - const api = createApi({ - handlers: { - accessDenied: () => { - globalCalled = true - } - } - }) - - clientStub.resolves(response({ status: 401, statusText: 'No', body: { error: 'no' } })) - - await api - .context({ fetch: clientStub }) - .endpoint('some/url') - .accessDenied(() => { - localCalled = true - }) - .get() - - expect(globalCalled).to.be.false() - expect(localCalled).to.be.true() - }) - - it('prefers global error handler to default error handler', async () => { - let globalCalled = false - let defaultCalled = false - - const api = createApi({ - handlers: { - accessDenied: () => { - globalCalled = true - } - } - }) - - clientStub.resolves(response({ status: 401, statusText: 'No', body: { error: 'no' } })) - - await api - .context({ fetch: clientStub }) - .endpoint('some/url') - .default(() => { - defaultCalled = true - }) - .get() - - expect(globalCalled).to.be.true() - expect(defaultCalled).to.be.false() - }) - - it('falls back to global default handler', async () => { - let received - - const api = createApi({ - handlers: { - default: (e) => { - received = e - return 'fallback' - } - } - }) - - clientStub.resolves(response({ status: 404, statusText: 'No', body: { error: 'no' } })) - - const result = await api - .context({ fetch: clientStub }) - .endpoint('some/url') - .get() - - expect(result).to.equal('fallback') - expect(received.body).to.equal({ error: 'no' }) - }) - - it('prefers request default handler to global default handler', async () => { - let globalCalled = false - - const api = createApi({ - handlers: { - default: () => { - globalCalled = true - } - } - }) - - clientStub.resolves(response({ status: 404, statusText: 'No', body: { error: 'no' } })) - - const result = await api - .context({ fetch: clientStub }) - .endpoint('some/url') - .default(() => 'local') - .get() - - expect(result).to.equal('local') - expect(globalCalled).to.be.false() - }) - it('attaches the status and request to http errors', async () => { let received @@ -294,6 +155,92 @@ describe('util/api', () => { }) }) + context('choosing which handler handles a failed request', function () { + beforeEach(function () { + this.clientStub = stub().resolves(response({ status: 401, statusText: 'No' })) + }) + + context('when the request and configure() both have a handler for the status', function () { + beforeEach(async function () { + this.result = await createApi({ handlers: { [AccessDenied]: () => '[AccessDenied] in configure()' } }) + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .accessDenied(() => '.accessDenied() on the request') + .get() + }) + + it('uses the handler on the request', function () { + expect(this.result).to.equal('.accessDenied() on the request') + }) + }) + + context('when configure() has a handler for the status and the request only has a default', function () { + beforeEach(async function () { + this.result = await createApi({ handlers: { [AccessDenied]: () => '[AccessDenied] in configure()' } }) + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .default(() => '.default() on the request') + .get() + }) + + it('uses the handler for the status in configure()', function () { + expect(this.result).to.equal('[AccessDenied] in configure()') + }) + }) + + context('when the request and configure() both have a default', function () { + beforeEach(async function () { + this.result = await createApi({ handlers: { default: () => 'default in configure()' } }) + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .default(() => '.default() on the request') + .get() + }) + + it('uses the default on the request', function () { + expect(this.result).to.equal('.default() on the request') + }) + }) + + context('when only configure() has a default', function () { + beforeEach(async function () { + this.result = await createApi({ handlers: { default: () => 'default in configure()' } }) + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .get() + }) + + it('uses the default in configure()', function () { + expect(this.result).to.equal('default in configure()') + }) + }) + + context('when configure() has a handler keyed by its name instead of its error type', function () { + it('throws, naming the key', function () { + expect(() => Http.configure({ handlers: { accessDenied: () => {} } })).to.throw('Unknown handler "accessDenied" in configure(). Key handlers by the error types exported from @beyonk/http, such as [AccessDenied].') + }) + }) + + context('when nothing handles the failure', function () { + beforeEach(async function () { + this.consoleError = stub(console, 'error') + + await createApi() + .context({ fetch: this.clientStub }) + .endpoint('some/url') + .get() + }) + + afterEach(function () { + this.consoleError.restore() + }) + + it('logs it to the console once', function () { + expect(this.consoleError.callCount).to.equal(1) + }) + }) + }) + context('responds ok', () => { let api let clientStub diff --git a/lib/types.ts b/lib/types.ts index 47b24eb..28fa8c2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,4 +1,5 @@ import { HttpError } from "."; +import type { AccessDenied, PaymentRequired, Forbidden, NotFound, NotAcceptable, Conflict, Gone, PreconditionFailed, ExpectationFailed, BadData, TooManyRequests } from "."; /** * API Configuration options @@ -14,7 +15,7 @@ export type ApiOptions = { retry?: RetryOptions | false; /** Whether to parse error responses as JSON */ parseErrors?: boolean; - /** Global error handlers */ + /** Error handlers for every request, keyed by error type */ handlers?: ErrorHandlers; }; @@ -69,20 +70,19 @@ export type ErrorHandler = (error: HttpError, context?: ApiContext) => any; * Map of error handlers */ export type ErrorHandlers = { - accessDenied?: ErrorHandler; - paymentRequired?: ErrorHandler; - forbidden?: ErrorHandler; - notFound?: ErrorHandler; - notAcceptable?: ErrorHandler; - conflict?: ErrorHandler; - gone?: ErrorHandler; - preconditionFailed?: ErrorHandler; - expectationFailed?: ErrorHandler; - badData?: ErrorHandler; - tooManyRequests?: ErrorHandler; - /** Called when no request-level or status-specific handler matches */ + [AccessDenied]?: ErrorHandler; + [PaymentRequired]?: ErrorHandler; + [Forbidden]?: ErrorHandler; + [NotFound]?: ErrorHandler; + [NotAcceptable]?: ErrorHandler; + [Conflict]?: ErrorHandler; + [Gone]?: ErrorHandler; + [PreconditionFailed]?: ErrorHandler; + [ExpectationFailed]?: ErrorHandler; + [BadData]?: ErrorHandler; + [TooManyRequests]?: ErrorHandler; + /** Called when no other handler matches, on the request or here */ default?: ErrorHandler; - [key: string]: ErrorHandler | undefined; }; /**