From 2f93b4e9fe92f18d08f6a2700d634e5a7e7c96f4 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Sat, 27 Jul 2024 14:21:00 -0400 Subject: [PATCH 1/6] Prepare for publishing to JSR --- .github/workflows/ci.yml | 6 +- .vscode/settings.json | 10 +- README.md | 133 ++++++--------- deno.jsonc | 26 +++ deno.lock | 43 +++++ deps.ts | 4 - mod_test.ts => mod.test.ts | 102 ++++++------ mod.ts | 321 +++++++++++++++++++++++++++++++++++-- test_deps.ts | 6 - 9 files changed, 486 insertions(+), 165 deletions(-) create mode 100644 deno.jsonc create mode 100644 deno.lock delete mode 100644 deps.ts rename mod_test.ts => mod.test.ts (86%) delete mode 100644 test_deps.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b795e3..30d2aaf 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: files: cov.lcov - name: Release info if: | - github.repository == 'udibo/http_error' && + github.repository == 'udibo/http-error' && matrix.os == 'ubuntu-latest' && matrix.deno == 'v1.x' && startsWith(github.ref, 'refs/tags/') @@ -52,7 +52,7 @@ jobs: if: env.RELEASE_VERSION != '' run: | mkdir -p target/release - deno bundle mod.ts target/release/http_error_${RELEASE_VERSION}.js + deno bundle mod.ts target/release/http-error.${RELEASE_VERSION}.js - name: Release uses: softprops/action-gh-release@v1 if: env.RELEASE_VERSION != '' @@ -61,4 +61,4 @@ jobs: with: draft: true files: | - target/release/http_error_${{ env.RELEASE_VERSION }}.js + target/release/http-error.${{ env.RELEASE_VERSION }}.js diff --git a/.vscode/settings.json b/.vscode/settings.json index cee35e2..6ec7152 100755 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,13 @@ "deno.enable": true, "deno.lint": true, "deno.unstable": false, - "deno.suggest.imports.hosts": { - "https://deno.land": true + "deno.config": "./deno.jsonc", + "files.associations": { + "*.css": "tailwindcss" + }, + "editor.formatOnSave": true, + "editor.defaultFormatter": "denoland.vscode-deno", + "editor.quickSuggestions": { + "strings": true } } diff --git a/README.md b/README.md index 4052fff..ccd4ec8 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,20 @@ # Http Error -[![version](https://img.shields.io/badge/release-0.7.0-success)](https://deno.land/x/http_error@0.7.0) -[![deno doc](https://doc.deno.land/badge.svg)](https://doc.deno.land/https/deno.land/x/http_error@0.7.0/mod.ts) -[![CI](https://github.com/udibo/http_error/workflows/CI/badge.svg)](https://github.com/udibo/http_error/actions?query=workflow%3ACI) -[![codecov](https://codecov.io/gh/udibo/http_error/branch/main/graph/badge.svg?token=8Q7TSUFWUY)](https://codecov.io/gh/udibo/http_error) -[![license](https://img.shields.io/github/license/udibo/http_error)](https://github.com/udibo/http_error/blob/master/LICENSE) +[![JSR](https://jsr.io/badges/@udibo/http-error)](https://jsr.io/@udibo/http-error) +[![JSR Score](https://jsr.io/badges/@udibo/http-error/score)](https://jsr.io/@udibo/http-error) +[![CI](https://github.com/udibo/http-error/workflows/CI/badge.svg)](https://github.com/udibo/http-error/actions?query=workflow%3ACI) +[![codecov](https://codecov.io/gh/udibo/http-error/branch/main/graph/badge.svg?token=8Q7TSUFWUY)](https://codecov.io/gh/udibo/http-error) +[![license](https://img.shields.io/github/license/udibo/http-error)](https://github.com/udibo/http-error/blob/master/LICENSE) -An error class for HTTP requests. +Utilities for creating and working with Http Errors. -This module was inspired by +This package was inspired by [http-errors](https://www.npmjs.com/package/http-errors) for Node.js. ## Features - Framework agnostic -## Installation - -This is an ES Module written in TypeScript and can be used in Deno projects. ES -Modules are the official standard format to package JavaScript code for reuse. A -JavaScript bundle is provided with each release so that it can be used in -Node.js packages or web browsers. - -### Deno - -To include it in a Deno project, you can import directly from the TS files. This -module is available in Deno's third part module registry but can also be -imported directly from GitHub using raw content URLs. - -```ts -// Import from Deno's third party module registry -import { HttpError, isHttpError } from "https://deno.land/x/http_error@0.7.0/mod.ts"; -// Import from GitHub -import { HttpError, isHttpError } "https://raw.githubusercontent.com/udibo/http_error/0.7.0/mod.ts"; -``` - -### Node.js - -Node.js fully supports ES Modules. - -If a Node.js package has the type "module" specified in its package.json file, -the JavaScript bundle can be imported as a `.js` file. - -```js -import { HttpError, isHttpError } from "./http_error_0.7.0.js"; -``` - -The default type for Node.js packages is "commonjs". To import the bundle into a -commonjs package, the file extension of the JavaScript bundle must be changed -from `.js` to `.mjs`. - -```js -import { HttpError, isHttpError } from "./http_error_0.7.0.mjs"; -``` - -See [Node.js Documentation](https://nodejs.org/api/esm.html) for more -information. - -### Browser - -Most modern browsers support ES Modules. - -The JavaScript bundle can be imported into ES modules. Script tags for ES -modules must have the type attribute set to "module". - -```html - -``` - -```js -// main.js -import { HttpError, isHttpError } from "./http_error_0.7.0.js"; -``` - -You can also embed a module script directly into an HTML file by placing the -JavaScript code within the body of the script tag. - -```html - -``` - -See -[MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) -for more information. - ## Usage Below are some examples of how to use this module. @@ -97,6 +26,8 @@ different call signatures you can use. The 4 examples below would throw the same error. ```ts +import { HttpError } from "@udibo/http-error"; + throw new HttpError(404, "file not found"); throw new HttpError(404, { message: "file not found" }); throw new HttpError("file not found", { status: 404 }); @@ -107,6 +38,8 @@ You can also include a cause in the optional options argument for it like you can with regular errors. ```ts +import { HttpError } from "@udibo/http-error"; + throw new HttpError(404, "file not found", { cause: error }); ``` @@ -121,6 +54,8 @@ the name is not known for an HTTP error status code, it will default to UnknownClientError or UnknownServerError. ```ts +import { HttpError } from "@udibo/http-error"; + const error = new HttpError(404, "file not found"); console.log(error.toString()); // NotFoundError: file not found ``` @@ -129,6 +64,8 @@ If you would like to extend the HttpError class, you can pass your own error name in the options. ```ts +import { HttpError, type HttpErrorOptions } from "@udibo/http-error"; + class CustomError extends HttpError { constructor( message?: string, @@ -144,6 +81,12 @@ signature, you can make use of the optionsFromArgs function. It will prioritize the status / message arguments over status / message options. ```ts +import { + HttpError, + type HttpErrorOptions, + optionsFromArgs, +} from "@udibo/http-error"; + class CustomError extends HttpError { constructor( status?: number, @@ -175,6 +118,8 @@ will also return true for Error objects that have status and expose properties with matching types. ```ts +import { HttpError, isHttpError } from "@udibo/http-error"; + let error = new Error("file not found"); console.log(isHttpError(error)); // false error = new HttpError(404, "file not found"); @@ -183,22 +128,35 @@ console.log(isHttpError(error)); // true ### ErrorResponse -This object can be used to transform an HttpError into a JSON format that can be +This class can be used to transform an HttpError into a JSON format that can be converted back into an HttpError. This makes it easy for the server to share HttpError's with the client. This will work with any value that is thrown. -Here is an example of how an oak server could have middleware that convert an +Here is an example of how an oak server could have middleware that converts an error into into a JSON format. ```ts -app.use(async (ctx, next) => { +import { Application } from "@oak/oak/application"; +import { ErrorResponse, HttpError } from "@udibo/http-error"; + +const app = new Application(); + +app.use(async (context, next) => { try { await next(); } catch (error) { + const { response } = context; response.status = isHttpError(error) ? error.status : 500; response.body = new ErrorResponse(error); } }); + +app.use(() => { + // Will throw a 500 on every request. + throw new HttpError(500); +}); + +await app.listen({ port: 80 }); ``` When `JSON.stringify` is used on the ErrorResponse object, the ErrorResponse @@ -209,6 +167,8 @@ that example, the response to the request would have it's status match the error and the body be a JSON representation of the error. ```ts +import { HttpError } from "@udibo/http-error"; + throw new HttpError(400, "Invalid input"); ``` @@ -224,11 +184,6 @@ Then the response would have a 400 status and it's body would look like this: } ``` -If the format of your error responses is different than this, you can look at -the source code in [mod.ts](/mod.ts) to see how you could create your own -ErrorResponse object that can be used to convert your error responses into -HttpErrors. - #### ErrorResponse.toError This function gives a client the ability to convert the error response JSON into @@ -238,6 +193,8 @@ In the following example, if getMovies is called and API endpoint returned an ErrorResponse, it would be converted into an HttpError object and be thrown. ```ts +import { ErrorResponse, HttpError, isErrorResponse } from "@udibo/http-error"; + async function getMovies() { const response = await fetch("https://example.com/movies.json"); const movies = await response.json(); @@ -266,6 +223,8 @@ The error that `getMovies` would throw would be equivalent to throwing the following HttpError. ```ts +import { HttpError } from "@udibo/http-error"; + new HttpError(400, "Invalid input"); ``` @@ -281,6 +240,8 @@ thrown. But if it isn't in that format and doesn't have an error status, the response body will be returned as the assumed movies. ```ts +import { HttpError, isErrorResponse } from "@udibo/http-error"; + async function getMovies() { const response = await fetch("https://example.com/movies.json"); const movies = await response.json(); diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..39180e8 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,26 @@ +{ + "name": "@udibo/http-error", + "version": "0.8.0", + "exports": { + ".": "./mod.ts" + }, + "publish": { + "include": [ + "LICENSE", + "README.md", + "**/*.ts" + ], + "exclude": ["**/*.test.ts"] + }, + "imports": { + "@std/assert": "jsr:@std/assert@1", + "@std/http": "jsr:@std/http@0", + "@std/testing": "jsr:@std/testing@0" + }, + "tasks": { + // Checks the formatting and runs the linter. + "check": "deno lint && deno fmt --check", + // Gets your branch up to date with master after a squash merge. + "git-rebase": "git fetch origin main && git rebase --onto origin/main HEAD" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..1ab060f --- /dev/null +++ b/deno.lock @@ -0,0 +1,43 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "jsr:@std/assert@1": "jsr:@std/assert@1.0.0", + "jsr:@std/http@0": "jsr:@std/http@0.224.5", + "jsr:@std/internal@^1.0.1": "jsr:@std/internal@1.0.1", + "jsr:@std/testing@0": "jsr:@std/testing@0.225.3" + }, + "jsr": { + "@std/assert@1.0.0": { + "integrity": "0e4f6d873f7f35e2a1e6194ceee39686c996b9e5d134948e644d35d4c4df2008", + "dependencies": [ + "jsr:@std/internal@^1.0.1" + ] + }, + "@std/http@0.224.5": { + "integrity": "b03b5d1529f6c423badfb82f6640f9f2557b4034cd7c30655ba5bb447ff750a4" + }, + "@std/internal@1.0.1": { + "integrity": "6f8c7544d06a11dd256c8d6ba54b11ed870aac6c5aeafff499892662c57673e6" + }, + "@std/testing@0.225.3": { + "integrity": "348c24d0479d44ab3dbb4f26170f242e19f24051b45935d4a9e7ca0ab7e37780" + } + } + }, + "remote": { + "https://deno.land/std@0.192.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", + "https://deno.land/std@0.192.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.192.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.192.0/testing/_test_suite.ts": "30f018feeb3835f12ab198d8a518f9089b1bcb2e8c838a8b615ab10d5005465c", + "https://deno.land/std@0.192.0/testing/asserts.ts": "e16d98b4d73ffc4ed498d717307a12500ae4f2cbe668f1a215632d19fcffc22f", + "https://deno.land/std@0.192.0/testing/bdd.ts": "59f7f7503066d66a12e50ace81bfffae5b735b6be1208f5684b630ae6b4de1d0" + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1", + "jsr:@std/http@0", + "jsr:@std/testing@0" + ] + } +} diff --git a/deps.ts b/deps.ts deleted file mode 100644 index 6d2b426..0000000 --- a/deps.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - Status, - STATUS_TEXT, -} from "https://deno.land/std@0.192.0/http/http_status.ts"; diff --git a/mod_test.ts b/mod.test.ts similarity index 86% rename from mod_test.ts rename to mod.test.ts index 176d32f..fb25516 100644 --- a/mod_test.ts +++ b/mod.test.ts @@ -1,19 +1,15 @@ -import { Status } from "./deps.ts"; +import { STATUS_CODE, type StatusCode } from "@std/http/status"; +import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; + import { ErrorResponse, HttpError, - HttpErrorOptions, + type HttpErrorOptions, isErrorResponse, isHttpError, optionsFromArgs, } from "./mod.ts"; -import { - assertEquals, - assertStrictEquals, - assertThrows, - describe, - it, -} from "./test_deps.ts"; const httpErrorTests = describe("HttpError"); @@ -119,52 +115,52 @@ it(httpErrorTests, "invalid status", () => { ); }); -const DEFAULT_ERROR_NAMES = new Map(([ - [Status.BadRequest, "BadRequest"], - [Status.Unauthorized, "Unauthorized"], - [Status.PaymentRequired, "PaymentRequired"], - [Status.Forbidden, "Forbidden"], - [Status.NotFound, "NotFound"], - [Status.MethodNotAllowed, "MethodNotAllowed"], - [Status.NotAcceptable, "NotAcceptable"], - [Status.ProxyAuthRequired, "ProxyAuthRequired"], - [Status.RequestTimeout, "RequestTimeout"], - [Status.Conflict, "Conflict"], - [Status.Gone, "Gone"], - [Status.LengthRequired, "LengthRequired"], - [Status.PreconditionFailed, "PreconditionFailed"], - [Status.RequestEntityTooLarge, "RequestEntityTooLarge"], - [Status.RequestURITooLong, "RequestURITooLong"], - [Status.UnsupportedMediaType, "UnsupportedMediaType"], - [Status.RequestedRangeNotSatisfiable, "RequestedRangeNotSatisfiable"], - [Status.ExpectationFailed, "ExpectationFailed"], - [Status.Teapot, "Teapot"], - [Status.MisdirectedRequest, "MisdirectedRequest"], - [Status.UnprocessableEntity, "UnprocessableEntity"], - [Status.Locked, "Locked"], - [Status.FailedDependency, "FailedDependency"], - [Status.TooEarly, "TooEarly"], - [Status.UpgradeRequired, "UpgradeRequired"], - [Status.PreconditionRequired, "PreconditionRequired"], - [Status.TooManyRequests, "TooManyRequests"], - [Status.RequestHeaderFieldsTooLarge, "RequestHeaderFieldsTooLarge"], - [Status.UnavailableForLegalReasons, "UnavailableForLegalReasons"], - [Status.InternalServerError, "InternalServer"], - [Status.NotImplemented, "NotImplemented"], - [Status.BadGateway, "BadGateway"], - [Status.ServiceUnavailable, "ServiceUnavailable"], - [Status.GatewayTimeout, "GatewayTimeout"], - [Status.HTTPVersionNotSupported, "HTTPVersionNotSupported"], - [Status.VariantAlsoNegotiates, "VariantAlsoNegotiates"], - [Status.InsufficientStorage, "InsufficientStorage"], - [Status.LoopDetected, "LoopDetected"], - [Status.NotExtended, "NotExtended"], - [Status.NetworkAuthenticationRequired, "NetworkAuthenticationRequired"], -] as [Status, string][]).map(([status, name]) => [status, `${name}Error`])); +const DEFAULT_ERROR_NAMES = new Map(([ + [STATUS_CODE.BadRequest, "BadRequest"], + [STATUS_CODE.Unauthorized, "Unauthorized"], + [STATUS_CODE.PaymentRequired, "PaymentRequired"], + [STATUS_CODE.Forbidden, "Forbidden"], + [STATUS_CODE.NotFound, "NotFound"], + [STATUS_CODE.MethodNotAllowed, "MethodNotAllowed"], + [STATUS_CODE.NotAcceptable, "NotAcceptable"], + [STATUS_CODE.ProxyAuthRequired, "ProxyAuthRequired"], + [STATUS_CODE.RequestTimeout, "RequestTimeout"], + [STATUS_CODE.Conflict, "Conflict"], + [STATUS_CODE.Gone, "Gone"], + [STATUS_CODE.LengthRequired, "LengthRequired"], + [STATUS_CODE.PreconditionFailed, "PreconditionFailed"], + [STATUS_CODE.ContentTooLarge, "ContentTooLarge"], + [STATUS_CODE.URITooLong, "URITooLong"], + [STATUS_CODE.UnsupportedMediaType, "UnsupportedMediaType"], + [STATUS_CODE.RangeNotSatisfiable, "RangeNotSatisfiable"], + [STATUS_CODE.ExpectationFailed, "ExpectationFailed"], + [STATUS_CODE.Teapot, "Teapot"], + [STATUS_CODE.MisdirectedRequest, "MisdirectedRequest"], + [STATUS_CODE.UnprocessableEntity, "UnprocessableEntity"], + [STATUS_CODE.Locked, "Locked"], + [STATUS_CODE.FailedDependency, "FailedDependency"], + [STATUS_CODE.TooEarly, "TooEarly"], + [STATUS_CODE.UpgradeRequired, "UpgradeRequired"], + [STATUS_CODE.PreconditionRequired, "PreconditionRequired"], + [STATUS_CODE.TooManyRequests, "TooManyRequests"], + [STATUS_CODE.RequestHeaderFieldsTooLarge, "RequestHeaderFieldsTooLarge"], + [STATUS_CODE.UnavailableForLegalReasons, "UnavailableForLegalReasons"], + [STATUS_CODE.InternalServerError, "InternalServer"], + [STATUS_CODE.NotImplemented, "NotImplemented"], + [STATUS_CODE.BadGateway, "BadGateway"], + [STATUS_CODE.ServiceUnavailable, "ServiceUnavailable"], + [STATUS_CODE.GatewayTimeout, "GatewayTimeout"], + [STATUS_CODE.HTTPVersionNotSupported, "HTTPVersionNotSupported"], + [STATUS_CODE.VariantAlsoNegotiates, "VariantAlsoNegotiates"], + [STATUS_CODE.InsufficientStorage, "InsufficientStorage"], + [STATUS_CODE.LoopDetected, "LoopDetected"], + [STATUS_CODE.NotExtended, "NotExtended"], + [STATUS_CODE.NetworkAuthenticationRequired, "NetworkAuthenticationRequired"], +] as [StatusCode, string][]).map(([status, name]) => [status, `${name}Error`])); function expectedDefaultErrorName(status: number): string { - return DEFAULT_ERROR_NAMES.has(status) - ? DEFAULT_ERROR_NAMES.get(status)! + return DEFAULT_ERROR_NAMES.has(status as StatusCode) + ? DEFAULT_ERROR_NAMES.get(status as StatusCode)! : `Unknown${status < 500 ? "Client" : "Server"}Error`; } diff --git a/mod.ts b/mod.ts index 45a4613..813d189 100644 --- a/mod.ts +++ b/mod.ts @@ -1,4 +1,9 @@ -import { Status, STATUS_TEXT } from "./deps.ts"; +/** + * Utilities for creating and working with Http Errors. + * + * @module + */ +import { STATUS_CODE, STATUS_TEXT, type StatusCode } from "@std/http/status"; /** Options for initializing an HttpError. */ export interface HttpErrorOptions extends ErrorOptions { @@ -21,6 +26,44 @@ export interface HttpErrorOptions extends ErrorOptions { /** * Converts HttpError arguments to an options object. * Prioritizing status and message arguments over status and message options. + * + * This function is useful for creating custom error classes that extend HttpError. + * + * ```ts + * import { + * HttpError, + * type HttpErrorOptions, + * optionsFromArgs, + * } from "@udibo/http-error"; + * + * class CustomError extends HttpError { + * constructor( + * status?: number, + * message?: string, + * options?: HttpErrorOptions, + * ); + * constructor(status?: number, options?: HttpErrorOptions); + * constructor(message?: string, options?: HttpErrorOptions); + * constructor(options?: HttpErrorOptions); + * constructor( + * statusOrMessageOrOptions?: number | string | HttpErrorOptions, + * messageOrOptions?: string | HttpErrorOptions, + * options?: HttpErrorOptions, + * ) { + * const init = optionsFromArgs( + * statusOrMessageOrOptions, + * messageOrOptions, + * options, + * ); + * super({ name: "CustomError", status: 420, ...init }); + * } + * } + * ``` + * + * @param statusOrMessageOrOptions - The status, message, or options. + * @param messageOrOptions - The message or options. + * @param options - The options. + * @returns The options object. */ export function optionsFromArgs< Init extends HttpErrorOptions = HttpErrorOptions, @@ -58,18 +101,116 @@ export function optionsFromArgs< function errorNameForStatus(status: number): string { let name: string; - if (STATUS_TEXT[status as Status]) { - name = status === Status.Teapot + if (STATUS_TEXT[status as StatusCode]) { + name = status === STATUS_CODE.Teapot ? "Teapot" - : STATUS_TEXT[status as Status]!.replace(/\W/g, ""); - if (status !== Status.InternalServerError) name += "Error"; + : STATUS_TEXT[status as StatusCode].replace(/\W/g, ""); + if (status !== STATUS_CODE.InternalServerError) name += "Error"; } else { name = `Unknown${status < 500 ? "Client" : "Server"}Error`; } return name; } -/** An error for an HTTP request. */ +/** + * An error for an HTTP request. + * + * This class can be used on its own to create any HttpError. It has a few + * different call signatures you can use. The 4 examples below would throw the same + * error. + * + * ```ts + * import { HttpError } from "@udibo/http-error"; + * + * throw new HttpError(404, "file not found"); + * throw new HttpError(404, { message: "file not found" }); + * throw new HttpError("file not found", { status: 404 }); + * throw new HttpError({ status: 404, message: "file not found" }); + * ``` + * + * You can also include a cause in the optional options argument for it like you + * can with regular errors. + * + * ```ts + * import { HttpError } from "@udibo/http-error"; + * + * throw new HttpError(404, "file not found", { cause: error }); + * ``` + * + * All HttpError objects have a status associated with them. If a status is not + * provided it will default to 500. The expose property will default to true for + * client error status and false for server error status. You can override the + * default behavior by setting the expose property on the options argument. + * + * For all known HTTP error status codes, a name will be generated for them. For + * example, the name of an HttpError with the 404 status would be NotFoundError. If + * the name is not known for an HTTP error status code, it will default to + * UnknownClientError or UnknownServerError. + * + * ```ts + * import { HttpError } from "@udibo/http-error"; + * + * const error = new HttpError(404, "file not found"); + * console.log(error.toString()); // NotFoundError: file not found + * ``` + * + * If you would like to extend the HttpError class, you can pass your own error + * name in the options. + * + * ```ts + * import { HttpError, type HttpErrorOptions } from "@udibo/http-error"; + * + * class CustomError extends HttpError { + * constructor( + * message?: string, + * options?: HttpErrorOptions, + * ) { + * super(message, { name: "CustomError", status: 420, ...options }); + * } + * } + * ``` + * + * If you'd like the arguments to match the parent HttpError classes call + * signature, you can make use of the optionsFromArgs function. It will prioritize + * the status / message arguments over status / message options. + * + * ```ts + * import { + * HttpError, + * type HttpErrorOptions, + * optionsFromArgs, + * } from "@udibo/http-error"; + * + * class CustomError extends HttpError { + * constructor( + * status?: number, + * message?: string, + * options?: HttpErrorOptions, + * ); + * constructor(status?: number, options?: HttpErrorOptions); + * constructor(message?: string, options?: HttpErrorOptions); + * constructor(options?: HttpErrorOptions); + * constructor( + * statusOrMessageOrOptions?: number | string | HttpErrorOptions, + * messageOrOptions?: string | HttpErrorOptions, + * options?: HttpErrorOptions, + * ) { + * const init = optionsFromArgs( + * statusOrMessageOrOptions, + * messageOrOptions, + * options, + * ); + * super({ name: "CustomError", status: 420, ...init }); + * } + * } + * ``` + * + * @param T - The type of data associated with the error. + * @param status - The HTTP status associated with the error. + * @param message - The message associated with the error. + * @param options - Other data associated with the error. + * @returns An HttpError object. + */ export class HttpError< T extends Record = Record, > extends Error { @@ -107,7 +248,7 @@ export class HttpError< options, ); const { message, name, expose, status: _status, cause, ...data } = init; - const status = init.status ?? Status.InternalServerError; + const status = init.status ?? STATUS_CODE.InternalServerError; if (status < 400 || status >= 600) { throw new RangeError("invalid error status"); @@ -131,6 +272,20 @@ export class HttpError< /** * Converts any HttpError like objects into an HttpError. * If the object is already an instance of HttpError, it will be returned as is. + * + * ```ts + * import { HttpError } from "@udibo/http-error"; + * + * try { + * throw new Error("something went wrong"); + * } catch (cause) { + * // Converts any non HttpError objects into an HttpError before re-throwing. + * throw HttpError.from(cause); + * } + * ``` + * + * @param error - The error to convert. + * @returns An HttpError object. */ static from = Record>( error: HttpError | Error | unknown, @@ -140,7 +295,7 @@ export class HttpError< } else if (isHttpError(error)) { const { name, message, status, expose, cause, data } = error; const options = { - ...(data), + ...data, name, message, status, @@ -162,10 +317,15 @@ export class HttpError< * The message will only be included if the error should be exposed. * * ```ts + * import { HttpError } from "@udibo/http-error"; + * * const error = new HttpError(400, "Invalid id"); * const options = HttpError.json(error); * const copy = new HttpError(options); * ``` + * + * @param error - The error to convert. + * @returns The options object. */ static json = Record>( error: HttpError | Error | unknown, @@ -185,7 +345,23 @@ export class HttpError< } } -/** Checks if the value as an HttpError. */ +/** + * This function can be used to determine if a value is an HttpError object. It + * will also return true for Error objects that have status and expose properties + * with matching types. + * + * ```ts + * import { HttpError, isHttpError } from "@udibo/http-error"; + * + * let error = new Error("file not found"); + * console.log(isHttpError(error)); // false + * error = new HttpError(404, "file not found"); + * console.log(isHttpError(error)); // true + * ``` + * + * @param value - The value to check. + * @returns True if the value is an HttpError. + */ export function isHttpError< T extends Record = Record, >(value: unknown): value is HttpError { @@ -206,7 +382,67 @@ export interface ErrorResponse< error: HttpErrorOptions & T; } -/** Converts errors into error responses that the client can convert back into HttpErrors. */ +/** + * This class can be used to transform an HttpError into a JSON format that can be + * converted back into an HttpError. This makes it easy for the server to share + * HttpError's with the client. This will work with any value that is thrown. + * + * Here is an example of how an oak server could have middleware that converts an + * error into into a JSON format. + * + * ```ts + * import { Application } from "@oak/oak/application"; + * import { ErrorResponse, HttpError } from "@udibo/http-error"; + * + * const app = new Application(); + * + * app.use(async (context, next) => { + * try { + * await next(); + * } catch (error) { + * const { response } = context; + * response.status = isHttpError(error) ? error.status : 500; + * response.body = new ErrorResponse(error); + * } + * }); + * + * app.use(() => { + * // Will throw a 500 on every request. + * throw new HttpError(500); + * }); + * + * await app.listen({ port: 80 }); + * ``` + * + * When `JSON.stringify` is used on the ErrorResponse object, the ErrorResponse + * becomes a JSON representation of an HttpError. + * + * If the server were to have the following error in the next() function call from + * that example, the response to the request would have it's status match the error + * and the body be a JSON representation of the error. + * + * ```ts + * import { HttpError } from "@udibo/http-error"; + * + * throw new HttpError(400, "Invalid input"); + * ``` + * + * Then the response would have a 400 status and it's body would look like this: + * + * ```json + * { + * "error": { + * "name": "BadRequestError", + * "status": 400, + * "message": "Invalid input" + * } + * } + * ``` + * + * @param T - The type of data associated with the error. + * @param error - The error to convert. + * @returns An ErrorResponse object. + */ export class ErrorResponse< T extends Record = Record, > implements ErrorResponse { @@ -216,6 +452,46 @@ export class ErrorResponse< this.error = HttpError.json(error); } + /** + * This function gives a client the ability to convert the error response JSON into + * an HttpError. + * + * In the following example, if getMovies is called and API endpoint returned an + * ErrorResponse, it would be converted into an HttpError object and be thrown. + * + * ```ts + * import { ErrorResponse, HttpError, isErrorResponse } from "@udibo/http-error"; + * + * async function getMovies() { + * const response = await fetch("https://example.com/movies.json"); + * const movies = await response.json(); + * if (isErrorResponse(movies)) throw new ErrorResponse.toError(movies); + * if (response.status >= 400) { + * throw new HttpError(response.status, "Invalid response"); + * } + * return movies; + * } + * ``` + * + * If the request returned the following error response, it would be converted into + * an HttpError by the `ErrorResponse.toError(movies)` call. + * + * ```json + * { + * "error": { + * "name": "BadRequestError", + * "status": 400, + * "message": "Invalid input" + * } + * } + * ``` + * + * The error that `getMovies` would throw would be equivalent to throwing the + * following HttpError. + * + * @param response - The error response to convert. + * @returns An HttpError object. + */ static toError = Record>( response: ErrorResponse, ): HttpError { @@ -223,7 +499,30 @@ export class ErrorResponse< } } -/** Check if the response is an error response that can be converted to an HttpError. */ +/** + * This function gives you the ability to determine if an API's response body is in + * the format of an ErrorResponse. It's useful for knowing when a response should + * be converted into an HttpError. + + * In the following example, you can see that if the request's body is in the + * format of an ErrorResponse, it will be converted into an HttpError and be + * thrown. But if it isn't in that format and doesn't have an error status, the + * response body will be returned as the assumed movies. + + * ```ts + * import { HttpError, isErrorResponse } from "@udibo/http-error"; + + * async function getMovies() { + * const response = await fetch("https://example.com/movies.json"); + * const movies = await response.json(); + * if (isErrorResponse(movies)) throw new ErrorResponse.toError(movies); + * if (response.status >= 400) { + * throw new HttpError(response.status, "Invalid response"); + * } + * return movies; + * } + * ``` + */ export function isErrorResponse< T extends Record = Record, >(response: unknown): response is ErrorResponse { diff --git a/test_deps.ts b/test_deps.ts deleted file mode 100644 index 30dc403..0000000 --- a/test_deps.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - assertEquals, - assertStrictEquals, - assertThrows, -} from "https://deno.land/std@0.192.0/testing/asserts.ts"; -export { describe, it } from "https://deno.land/std@0.192.0/testing/bdd.ts"; From 3dc44bff10a79bf639d288f896e1ee39898e82e6 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Sat, 27 Jul 2024 14:34:06 -0400 Subject: [PATCH 2/6] Update actions/checkout to v4 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30d2aaf..ccb697b 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: fail-fast: true steps: - name: Clone repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup deno uses: denoland/setup-deno@main with: From e4df1d974aa71a6e5199c79b66777d82731997a1 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Sat, 27 Jul 2024 14:41:43 -0400 Subject: [PATCH 3/6] Update codecove/codecov-action to v4 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccb697b..c12394e 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: if: | matrix.os == 'ubuntu-latest' && matrix.deno == 'v1.x' - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 with: files: cov.lcov - name: Release info From 0980aad0c966cde2b4b57f0eee856b0208f907c8 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Sat, 27 Jul 2024 14:44:45 -0400 Subject: [PATCH 4/6] Add CODECOV_TOKEN to action --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c12394e..5946f6a 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,10 @@ jobs: matrix.deno == 'v1.x' uses: codecov/codecov-action@v4 with: + fail_ci_if_error: true files: cov.lcov + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Release info if: | github.repository == 'udibo/http-error' && From cb79e1187f11017a2905ffac108c4b743148e396 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Sat, 27 Jul 2024 14:57:17 -0400 Subject: [PATCH 5/6] Add publish action --- .github/workflows/publish.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9e7cc8a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,16 @@ +name: publish + +on: + push: + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - run: npx jsr publish From cadb87e5805f00f435d154bf5dcdc4185925ecf1 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Sat, 27 Jul 2024 14:58:25 -0400 Subject: [PATCH 6/6] Bump minor version --- deno.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno.jsonc b/deno.jsonc index 39180e8..9407fda 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,6 +1,6 @@ { "name": "@udibo/http-error", - "version": "0.8.0", + "version": "0.8.1", "exports": { ".": "./mod.ts" },