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
45 changes: 37 additions & 8 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand All @@ -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')
})
}
}
})

Expand All @@ -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({
Expand Down Expand Up @@ -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
Expand Down
136 changes: 53 additions & 83 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, symbol>} */
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
*/
Expand All @@ -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<number, typeof HttpError>}
*/
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[] }}
Expand Down Expand Up @@ -130,7 +99,7 @@ const DEFAULT_CONFIG = {
class Api {
config = simpleClone(DEFAULT_CONFIG)

/** @type {Record<string, ErrorHandler>} */
/** @type {Partial<Record<symbol, ErrorHandler>>} */
handlers = {}

/** @type {FetchClient|null} */
Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -494,7 +458,7 @@ class Api {
* @returns {this} Current instance
*/
accessDenied (fn) {
this.handlers[AccessDeniedError.name] = fn
this.handlers[AccessDenied] = fn
return this
}

Expand All @@ -504,7 +468,7 @@ class Api {
* @returns {this} Current instance
*/
paymentRequired (fn) {
this.handlers[PaymentRequiredError.name] = fn
this.handlers[PaymentRequired] = fn
return this
}

Expand All @@ -514,7 +478,7 @@ class Api {
* @returns {this} Current instance
*/
forbidden (fn) {
this.handlers[ForbiddenError.name] = fn
this.handlers[Forbidden] = fn
return this
}

Expand All @@ -524,7 +488,7 @@ class Api {
* @returns {this} Current instance
*/
notFound (fn) {
this.handlers[NotFoundError.name] = fn
this.handlers[NotFound] = fn
return this
}

Expand All @@ -534,7 +498,7 @@ class Api {
* @returns {this} Current instance
*/
notAcceptable (fn) {
this.handlers[NotAcceptableError.name] = fn
this.handlers[NotAcceptable] = fn
return this
}

Expand All @@ -544,7 +508,7 @@ class Api {
* @returns {this} Current instance
*/
conflict (fn) {
this.handlers[ConflictError.name] = fn
this.handlers[Conflict] = fn
return this
}

Expand All @@ -554,7 +518,7 @@ class Api {
* @returns {this} Current instance
*/
gone (fn) {
this.handlers[GoneError.name] = fn
this.handlers[Gone] = fn
return this
}

Expand All @@ -564,7 +528,7 @@ class Api {
* @returns {this} Current instance
*/
preconditionFailed (fn) {
this.handlers[PreconditionFailedError.name] = fn
this.handlers[PreconditionFailed] = fn
return this
}

Expand All @@ -574,7 +538,7 @@ class Api {
* @returns {this} Current instance
*/
expectationFailed (fn) {
this.handlers[ExpectationFailedError.name] = fn
this.handlers[ExpectationFailed] = fn
return this
}

Expand All @@ -584,7 +548,7 @@ class Api {
* @returns {this} Current instance
*/
badData (fn) {
this.handlers[BadDataError.name] = fn
this.handlers[BadData] = fn
return this
}

Expand All @@ -594,7 +558,7 @@ class Api {
* @returns {this} Current instance
*/
tooManyRequests (fn) {
this.handlers[TooManyRequestsError.name] = fn
this.handlers[TooManyRequests] = fn
return this
}
}
Expand All @@ -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
}

Expand Down
Loading
Loading