From 9da6bd980e5bee6da43db7602fd18da651c63d0d Mon Sep 17 00:00:00 2001 From: Kyle June Date: Thu, 8 May 2025 21:54:59 -0400 Subject: [PATCH 1/2] Breaking: Refactor, simplify, hono compatibility, RFC 9457 --- .github/workflows/ci.yml | 20 +- .gitignore | 1 + README.md | 391 +++++++------ deno.json | 9 +- deno.lock | 79 ++- examples/hono.test.ts | 74 +++ examples/hono.ts | 40 ++ examples/oak.test.ts | 71 +++ examples/oak.ts | 57 ++ mod.test.ts | 1144 +++++++++++++++++++++++++++----------- mod.ts | 652 +++++++++++++--------- 11 files changed, 1797 insertions(+), 741 deletions(-) create mode 100644 .gitignore create mode 100644 examples/hono.test.ts create mode 100644 examples/hono.ts create mode 100644 examples/oak.test.ts create mode 100644 examples/oak.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff7b33b..273667b 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,13 +2,12 @@ name: CI on: [push, pull_request] jobs: build: - name: test deno ${{ matrix.deno }} ${{ matrix.os }} + name: test deno v2.x ${{ matrix.os }} runs-on: ${{ matrix.os }} timeout-minutes: 5 strategy: matrix: os: [ubuntu-latest, windows-latest, macOS-latest] - deno: [v2.x, canary] fail-fast: true steps: - name: Clone repository @@ -16,7 +15,7 @@ jobs: - name: Setup deno uses: denoland/setup-deno@main with: - deno-version: ${{ matrix.deno }} + deno-version: v2.x - name: Check formatting if: matrix.os == 'ubuntu-latest' run: deno fmt --check @@ -24,29 +23,20 @@ jobs: if: matrix.os == 'ubuntu-latest' run: deno lint - name: Run tests - run: deno test --coverage=cov - - name: Run tests unstable - run: deno test --unstable - - name: Generate lcov - if: | - matrix.os == 'ubuntu-latest' && - matrix.deno == 'v2.x' - run: deno coverage --lcov cov > cov.lcov + run: deno test -A --coverage - name: Upload coverage if: | - matrix.os == 'ubuntu-latest' && - matrix.deno == 'v2.x' + matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v4 with: fail_ci_if_error: true - files: cov.lcov + files: coverage/lcov.info env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Release info if: | github.repository == 'udibo/http-error' && matrix.os == 'ubuntu-latest' && - matrix.deno == 'v2.x' && startsWith(github.ref, 'refs/tags/') shell: bash run: | diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ebc8ae --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +coverage diff --git a/README.md b/README.md index f0414a1..35fed03 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This package was inspired by ## Features - Framework agnostic +- RFC 9457 Problem Details compliant error responses ## Usage @@ -21,7 +22,7 @@ Below are some examples of how to use this module. ### HttpError -This class can be used on its own to create any HttpError. It has a few +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. @@ -34,33 +35,45 @@ 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. +You can also include a `cause` in the optional options argument for it like you +can with regular errors. Additional `HttpErrorOptions` include `statusText`, +`type` (a URI for the problem type), `instance` (a URI for this specific error +occurrence), `extensions` (an object for additional details), and `headers` (to +customize response headers). ```ts -import { HttpError } from "@udibo/http-error"; +import { HttpError, type HttpErrorOptions } from "@udibo/http-error"; -throw new HttpError(404, "file not found", { cause: error }); +const cause = new Error("Underlying issue"); +throw new HttpError(400, "Invalid input", { + cause, + type: "/errors/validation-error", + instance: "/requests/123/user-field", + extensions: { field: "username", reason: "must be alphanumeric" }, + headers: { "X-Custom-Error-ID": "err-987" }, +}); ``` -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. +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 statuses (4xx) and `false` for server error statuses (5xx). 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. +For all known HTTP error status codes, a `name` will be generated (e.g., +`NotFoundError` for 404). If the name is not known, 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 +console.log(error.status); // 404 +console.log(error.expose); // true ``` -If you would like to extend the HttpError class, you can pass your own error +If you would like to extend the `HttpError` class, you can pass your own error name in the options. ```ts @@ -76,194 +89,262 @@ class CustomError extends HttpError { } ``` -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. +#### `HttpError.from()` + +This static method intelligently converts various error-like inputs into an +`HttpError` instance. + +- If an `HttpError` is passed, it's returned directly. +- If an `Error` is passed, it's wrapped in a new `HttpError` (usually 500 + status), with the original error as the `cause`. +- If a `Response` object is passed, it attempts to parse the body as RFC 9457 + Problem Details. If successful, it creates an `HttpError` from those details. + If parsing fails or the body isn't Problem Details, it creates a generic + `HttpError` based on the response status. This method is asynchronous and + returns a `Promise`. +- If a `ProblemDetails` object is passed, it creates an `HttpError` from it. +- For other unknown inputs, it creates a generic `HttpError` with a 500 status. ```ts -import { - HttpError, - type HttpErrorOptions, - optionsFromArgs, -} from "@udibo/http-error"; +import { HttpError } 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 }); +// From a standard Error +const plainError = new Error("Something went wrong"); +const httpErrorFromError = HttpError.from(plainError); +console.log(httpErrorFromError.status); // 500 +console.log(httpErrorFromError.message); // "Something went wrong" +console.log(httpErrorFromError.cause === plainError); // true + +// From a Response (example) +async function handleErrorResponse(response: Response) { + if (!response.ok) { + const error = await HttpError.from(response); + // Now 'error' is an HttpError instance + throw error; } + return response.json(); } + +// From a ProblemDetails object +const problemDetails = { + status: 403, + title: "ForbiddenAccess", + detail: "You do not have permission.", + type: "/errors/forbidden", +}; +const httpErrorFromDetails = HttpError.from(problemDetails); +console.log(httpErrorFromDetails.status); // 403 +console.log(httpErrorFromDetails.name); // ForbiddenAccess ``` -### isHttpError +#### `HttpError.toJSON()` -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. +This method returns a plain JavaScript object representing the error in the RFC +9457 Problem Details format. This is useful for serializing the error to a JSON +response body. If `expose` is `false` (default for 5xx errors), the `detail` +(message) property will be omitted. ```ts -import { HttpError, isHttpError } from "@udibo/http-error"; +import { HttpError } 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 -``` +const error = new HttpError(400, "Invalid input", { + type: "/errors/validation", + instance: "/form/user", + extensions: { field: "email" }, +}); -### ErrorResponse +const problemDetails = error.toJSON(); +console.log(problemDetails); +// Outputs: +// { +// field: "email", +// status: 400, +// title: "BadRequestError", +// detail: "Invalid input", +// type: "/errors/validation", +// instance: "/form/user" +// } + +const serverError = new HttpError(500, "Internal details", { expose: false }); +console.log(serverError.toJSON()); +// Outputs (detail omitted): +// { +// status: 500, +// title: "InternalServerError" +// } +``` -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. +#### `HttpError.getResponse()` -Here is an example of how an oak server could have middleware that converts an -error into into a JSON format. +This method returns a `Response` object, ready to be sent to the client. The +response body will be the JSON string of the Problem Details object (from +`toJSON()`), and headers (including `Content-Type: application/problem+json`) +will be set. The response status and status text will match the error's +properties. ```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); - } -}); +import { HttpError } from "@udibo/http-error"; -app.use(() => { - // Will throw a 500 on every request. - throw new HttpError(500); -}); +const error = new HttpError(401, "Authentication required"); +const response = error.getResponse(); -await app.listen({ port: 80 }); +console.log(response.status); // 401 +console.log(response.headers.get("Content-Type")); // application/problem+json +// response.body can be read to get the JSON string ``` -When `JSON.stringify` is used on the ErrorResponse object, the ErrorResponse -becomes a JSON representation of an HttpError. +### `createHttpErrorClass()` -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. +This factory function allows you to create custom error classes that extend +`HttpError`. You can provide default options (like `status`, `name`, `message`, +`extensions`, etc.) for your custom error class. ```ts -import { HttpError } from "@udibo/http-error"; +import { createHttpErrorClass, type HttpErrorOptions } from "@udibo/http-error"; -throw new HttpError(400, "Invalid input"); -``` +interface MyApiErrorExtensions { + errorCode: string; + requestId?: string; +} -Then the response would have a 400 status and it's body would look like this: +const MyApiError = createHttpErrorClass({ + name: "MyApiError", // Default name + status: 452, // Default status + extensions: { + errorCode: "API_GENERAL_FAILURE", // Default extension value + }, +}); -```json -{ - "error": { - "name": "BadRequestError", - "status": 400, - "message": "Invalid input" +try { + throw new MyApiError("Specific operation failed.", { + extensions: { + errorCode: "API_OP_X_FAILED", // Override default extension + requestId: "req-123", // Add instance-specific extension + }, + }); +} catch (e) { + if (e instanceof MyApiError) { + console.log(e.name); // "MyApiError" + console.log(e.status); // 452 + console.log(e.message); // "Specific operation failed." + console.log(e.extensions.errorCode); // "API_OP_X_FAILED" + console.log(e.extensions.requestId); // "req-123" + // console.log(e.toJSON()); } } + +// Instance with overridden status +const anotherError = new MyApiError(453, "Another failure"); +console.log(anotherError.status); // 453 +console.log(anotherError.extensions.errorCode); // "API_GENERAL_FAILURE" (from default) ``` -#### ErrorResponse.toError +### Framework Integration -This function gives a client the ability to convert the error response JSON into -an HttpError. +#### Hono -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. +Here's an example of how to use `HttpError` with Hono, utilizing its +`app.onError` handler to return Problem Details JSON responses. ```ts -import { ErrorResponse, HttpError, isErrorResponse } from "@udibo/http-error"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; // For comparison +import { HttpError } from "@udibo/http-error"; -async function getMovies() { - const response = await fetch("https://example.com/movies.json"); - if (!response.ok) throw await ErrorResponse.toError(response); - return await response.json(); -} -``` +const app = new Hono(); -The next example is similar, but uses the response JSON instead of the response. -The advantage of the first approach in the previous example is that it will -produce an HttpError based on the status code in the case that the response -doesn't have valid JSON. +app.get("/error", () => { + throw new Error("This is an example of a plain Error"); +}); -```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 ErrorResponse.toError(movies); - if (response.status >= 400) { - throw new HttpError(response.status, "Invalid response"); - } - return movies; -} -``` +app.get("/hono-error", () => { + // Hono's native error, for comparison + throw new HTTPException(400, { + message: "This is an example of an error from hono", + }); +}); -If the request returned the following error response, it would be converted into -an HttpError by the `ErrorResponse.toError(movies)` call. +app.get("/http-error", () => { + throw new HttpError(400, "This is an example of an HttpError", { + type: "/errors/http-error", + instance: "/errors/http-error/instance/123", + extensions: { + customField: "customValue", + }, + }); +}); -```json -{ - "error": { - "name": "BadRequestError", - "status": 400, - "message": "Invalid input" - } -} +// Global error handler +app.onError(async (cause, c) => { // c is Hono's context + console.log("Hono onError caught:", cause); + + // Converts non-HttpError instances to HttpError instances + // For Response objects (e.g. from fetch), HttpError.from is async + const error = cause instanceof Response + ? await HttpError.from(cause) + : HttpError.from(cause); + + console.error(error); // Log the full HttpError + + return error.getResponse(); // Return a Response object directly +}); + +console.log("Hono server running on http://localhost:8000"); +Deno.serve(app.fetch); ``` -The error that `getMovies` would throw would be equivalent to throwing the -following HttpError. +#### Oak + +Here is an example of how an Oak server could have middleware that converts any +thrown error into an `HttpError` and returns a Problem Details JSON response. ```ts +import { Application, Router } from "@oak/oak"; import { HttpError } from "@udibo/http-error"; -new HttpError(400, "Invalid input"); -``` - -### isErrrorResponse +const app = new Application(); -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. +// Error handling middleware +app.use(async (context, next) => { + try { + await next(); + } catch (cause) { + // Converts non-HttpError instances to HttpError instances + // For Response objects (e.g. from fetch), HttpError.from is async + const error = cause instanceof Response + ? await HttpError.from(cause) + : HttpError.from(cause); -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. + console.error(error); // Log the full error -```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"); + const { response } = context; + response.status = error.status; + error.headers.forEach((value, key) => { // Set custom headers from HttpError + response.headers.set(key, value); + }); + // Set Content-Type if not already set by error.headers + if (!response.headers.has("Content-Type")) { + response.headers.set("Content-Type", "application/problem+json"); + } + response.body = error.toJSON(); // Send Problem Details as JSON } - return movies; -} +}); + +const router = new Router(); +router.get("/test-error", () => { + // Will be caught and transformed by the middleware + throw new Error("A generic error occurred!"); +}); +router.get("/test-http-error", () => { + throw new HttpError(400, "This is a specific HttpError.", { + type: "/errors/my-custom-error", + extensions: { info: "some detail" }, + }); +}); + +app.use(router.routes()); +app.use(router.allowedMethods()); + +console.log("Oak server running on http://localhost:8000"); +await app.listen({ port: 8000 }); ``` diff --git a/deno.json b/deno.json index 3ec7ad1..b10fb4b 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@udibo/http-error", - "version": "0.9.1", + "version": "0.10.0", "exports": { ".": "./mod.ts" }, @@ -13,9 +13,14 @@ "exclude": ["**/*.test.ts"] }, "imports": { + "@udibo/http-error": "./mod.ts", "@std/assert": "jsr:@std/assert@1", "@std/http": "jsr:@std/http@1", - "@std/testing": "jsr:@std/testing@1" + "@std/testing": "jsr:@std/testing@1", + "@std/streams": "jsr:@std/streams@1", + "@std/path": "jsr:@std/path@1", + "@oak/oak": "jsr:@oak/oak@17", + "hono": "npm:hono@4" }, "tasks": { "check": { diff --git a/deno.lock b/deno.lock index e93d81d..baa607a 100644 --- a/deno.lock +++ b/deno.lock @@ -1,25 +1,84 @@ { - "version": "4", + "version": "5", "specifiers": { + "jsr:@oak/commons@1": "1.0.0", + "jsr:@oak/oak@17": "17.1.4", "jsr:@std/assert@1": "1.0.12", "jsr:@std/assert@^1.0.12": "1.0.12", + "jsr:@std/bytes@1": "1.0.5", + "jsr:@std/bytes@^1.0.5": "1.0.5", + "jsr:@std/crypto@1": "1.0.4", + "jsr:@std/encoding@1": "1.0.8", + "jsr:@std/encoding@^1.0.7": "1.0.8", "jsr:@std/http@1": "1.0.13", "jsr:@std/internal@^1.0.6": "1.0.6", - "jsr:@std/testing@1": "1.0.10" + "jsr:@std/media-types@1": "1.1.0", + "jsr:@std/path@1": "1.0.9", + "jsr:@std/streams@1": "1.0.9", + "jsr:@std/testing@1": "1.0.10", + "npm:hono@4": "4.7.8", + "npm:path-to-regexp@^6.3.0": "6.3.0" }, "jsr": { + "@oak/commons@1.0.0": { + "integrity": "49805b55603c3627a9d6235c0655aa2b6222d3036b3a13ff0380c16368f607ac", + "dependencies": [ + "jsr:@std/assert@1", + "jsr:@std/bytes@1", + "jsr:@std/crypto", + "jsr:@std/encoding@1", + "jsr:@std/http", + "jsr:@std/media-types" + ] + }, + "@oak/oak@17.1.4": { + "integrity": "60530b582bf276ff741e39cc664026781aa08dd5f2bc5134d756cc427bf2c13e", + "dependencies": [ + "jsr:@oak/commons", + "jsr:@std/assert@1", + "jsr:@std/bytes@1", + "jsr:@std/http", + "jsr:@std/media-types", + "jsr:@std/path", + "npm:path-to-regexp" + ] + }, "@std/assert@1.0.12": { "integrity": "08009f0926dda9cbd8bef3a35d3b6a4b964b0ab5c3e140a4e0351fbf34af5b9a", "dependencies": [ "jsr:@std/internal" ] }, + "@std/bytes@1.0.5": { + "integrity": "4465dd739d7963d964c809202ebea6d5c6b8e3829ef25c6a224290fbb8a1021e" + }, + "@std/crypto@1.0.4": { + "integrity": "cee245c453bd5366207f4d8aa25ea3e9c86cecad2be3fefcaa6cb17203d79340" + }, + "@std/encoding@1.0.8": { + "integrity": "a6c8f3f933ab1bed66244f435d1dc0fd23a888e07195532122ddc3d5f8f0e6b4" + }, "@std/http@1.0.13": { - "integrity": "d29618b982f7ae44380111f7e5b43da59b15db64101198bb5f77100d44eb1e1e" + "integrity": "d29618b982f7ae44380111f7e5b43da59b15db64101198bb5f77100d44eb1e1e", + "dependencies": [ + "jsr:@std/encoding@^1.0.7" + ] }, "@std/internal@1.0.6": { "integrity": "9533b128f230f73bd209408bb07a4b12f8d4255ab2a4d22a1fd6d87304aca9a4" }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/path@1.0.9": { + "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e" + }, + "@std/streams@1.0.9": { + "integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035", + "dependencies": [ + "jsr:@std/bytes@^1.0.5" + ] + }, "@std/testing@1.0.10": { "integrity": "8997bd0b0df020b81bf5eae103c66622918adeff7e45e96291c92a29dbf82cc1", "dependencies": [ @@ -28,11 +87,23 @@ ] } }, + "npm": { + "hono@4.7.8": { + "integrity": "sha512-PCibtFdxa7/Ldud9yddl1G81GjYaeMYYTq4ywSaNsYbB1Lug4mwtOMJf2WXykL0pntYwmpRJeOI3NmoDgD+Jxw==" + }, + "path-to-regexp@6.3.0": { + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" + } + }, "workspace": { "dependencies": [ + "jsr:@oak/oak@17", "jsr:@std/assert@1", "jsr:@std/http@1", - "jsr:@std/testing@1" + "jsr:@std/path@1", + "jsr:@std/streams@1", + "jsr:@std/testing@1", + "npm:hono@4" ] } } diff --git a/examples/hono.test.ts b/examples/hono.test.ts new file mode 100644 index 0000000..83dbebe --- /dev/null +++ b/examples/hono.test.ts @@ -0,0 +1,74 @@ +import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; +import { assertEquals } from "@std/assert"; +import { resolve } from "@std/path"; +import { mergeReadableStreams, TextLineStream } from "@std/streams"; + +describe("hono error handling", () => { + let server: Deno.ChildProcess; + + beforeAll(async () => { + const command = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "-A", + resolve(import.meta.dirname!, "./hono.ts"), + ], + stdout: "piped", + stderr: "piped", + }); + server = command.spawn(); + const stdout = mergeReadableStreams(server.stdout, server.stderr) + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextLineStream()); + + for await (const line of stdout.values({ preventCancel: true })) { + if (line.includes("Listening on")) { + assertEquals( + line, + "Listening on http://0.0.0.0:8000/ (http://localhost:8000/)", + ); + break; + } + } + }); + + afterAll(async () => { + server.kill(); + await server.status; + }); + + it("plain error converted to http error response", async () => { + const res = await fetch("http://localhost:8000/error"); + assertEquals(res.status, 500); + assertEquals(res.headers.get("content-type"), "application/problem+json"); + assertEquals(await res.json(), { + status: 500, + title: "InternalServerError", + }); + }); + + it("hono HttpException converted to http error response", async () => { + const res = await fetch("http://localhost:8000/hono-error"); + assertEquals(res.status, 400); + assertEquals(res.headers.get("content-type"), "application/problem+json"); + assertEquals(await res.json(), { + status: 400, + title: "Error", + detail: "This is an example of an error from hono", + }); + }); + + it("http error response", async () => { + const res = await fetch("http://localhost:8000/http-error"); + assertEquals(res.status, 400); + assertEquals(res.headers.get("content-type"), "application/problem+json"); + assertEquals(await res.json(), { + status: 400, + title: "BadRequestError", + detail: "This is an example of an HttpError", + type: "/errors/http-error", + instance: "/errors/http-error/instance/123", + customField: "customValue", + }); + }); +}); diff --git a/examples/hono.ts b/examples/hono.ts new file mode 100644 index 0000000..aa8427b --- /dev/null +++ b/examples/hono.ts @@ -0,0 +1,40 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { HttpError } from "@udibo/http-error"; + +const app = new Hono(); + +app.use(async (context, next) => { + const { req } = context; + console.log(req.method, req.path); + await next(); +}); + +app.get("/error", () => { + throw new Error("This is an example of a plain Error"); +}); + +app.get("/hono-error", () => { + throw new HTTPException(400, { + message: "This is an example of an error from hono", + }); +}); + +app.get("/http-error", () => { + throw new HttpError(400, "This is an example of an HttpError", { + type: "/errors/http-error", + instance: "/errors/http-error/instance/123", + extensions: { + customField: "customValue", + }, + }); +}); + +app.onError((cause) => { + // Converts non HttpError instances to HttpError instances + const error = HttpError.from(cause); + console.error(error); + return error.getResponse(); +}); + +Deno.serve(app.fetch); diff --git a/examples/oak.test.ts b/examples/oak.test.ts new file mode 100644 index 0000000..e25764f --- /dev/null +++ b/examples/oak.test.ts @@ -0,0 +1,71 @@ +import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; +import { assertEquals } from "@std/assert"; +import { resolve } from "@std/path"; +import { mergeReadableStreams, TextLineStream } from "@std/streams"; + +describe("oak error handling", () => { + let server: Deno.ChildProcess; + + beforeAll(async () => { + const command = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "-A", + resolve(import.meta.dirname!, "./oak.ts"), + ], + stdout: "piped", + stderr: "piped", + }); + server = command.spawn(); + const stdout = mergeReadableStreams(server.stdout, server.stderr) + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextLineStream()); + + for await (const line of stdout.values({ preventCancel: true })) { + if (line.includes("Listening on")) { + assertEquals(line, "Listening on http://localhost:8000/"); + break; + } + } + }); + + afterAll(async () => { + server.kill(); + await server.status; + }); + + it("plain error converted to http error response", async () => { + const res = await fetch("http://localhost:8000/error"); + assertEquals(res.status, 500); + assertEquals(res.headers.get("content-type"), "application/problem+json"); + assertEquals(await res.json(), { + status: 500, + title: "InternalServerError", + }); + }); + + it("oak error converted to http error response", async () => { + const res = await fetch("http://localhost:8000/oak-error"); + assertEquals(res.status, 400); + assertEquals(res.headers.get("content-type"), "application/problem+json"); + assertEquals(await res.json(), { + status: 400, + title: "BadRequestError", + detail: "This is an example of an error from oak", + }); + }); + + it("http error response", async () => { + const res = await fetch("http://localhost:8000/http-error"); + assertEquals(res.status, 400); + assertEquals(res.headers.get("content-type"), "application/problem+json"); + assertEquals(await res.json(), { + status: 400, + title: "BadRequestError", + detail: "This is an example of an HttpError", + type: "/errors/http-error", + instance: "/errors/http-error/instance/123", + customField: "customValue", + }); + }); +}); diff --git a/examples/oak.ts b/examples/oak.ts new file mode 100644 index 0000000..e00c944 --- /dev/null +++ b/examples/oak.ts @@ -0,0 +1,57 @@ +import { Application, Router } from "@oak/oak"; +import { HttpError } from "@udibo/http-error"; + +const router = new Router(); + +router.get("/error", () => { + throw new Error("This is an example of a plain Error"); +}); + +router.get("/http-error", () => { + throw new HttpError(400, "This is an example of an HttpError", { + type: "/errors/http-error", + instance: "/errors/http-error/instance/123", + extensions: { + customField: "customValue", + }, + }); +}); + +router.get("/oak-error", (context) => { + context.throw(400, "This is an example of an error from oak"); +}); + +const app = new Application(); +app.use(async (context, next) => { + const { request, response } = context; + try { + console.log(request.method, request.url.pathname); + await next(); + } catch (cause) { + // Converts non HttpError instances to HttpError instances + const error = HttpError.from(cause); + console.error(error); + response.status = error.status; + error.headers.forEach((value, key) => { + response.headers.set(key, value); + }); + response.body = error.toJSON(); + } +}); + +app.use(router.routes()); +app.use(router.allowedMethods()); + +app.addEventListener("listen", ({ port }) => { + console.log(`Listening on http://localhost:${port}/`); +}); + +app.addEventListener("error", (event) => { + console.error("event.error", event.error); +}); + +try { + await app.listen({ port: 8000 }); +} catch (error) { + console.error(error); +} diff --git a/mod.test.ts b/mod.test.ts index f4b08b3..e54c036 100644 --- a/mod.test.ts +++ b/mod.test.ts @@ -1,14 +1,16 @@ import { STATUS_CODE, type StatusCode } from "@std/http/status"; -import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; +import { + assert, + assertEquals, + assertStrictEquals, + assertThrows, +} from "@std/assert"; import { describe, it } from "@std/testing/bdd"; import { - ErrorResponse, + createHttpErrorClass, HttpError, type HttpErrorOptions, - isErrorResponse, - isHttpError, - optionsFromArgs, } from "./mod.ts"; const httpErrorTests = describe("HttpError"); @@ -236,156 +238,443 @@ it(httpErrorTests, "override expose", () => { } }); -it(httpErrorTests, "with all options", () => { +const withAllOptionsTests = describe(httpErrorTests, "with all options"); + +it( + withAllOptionsTests, + "constructor with status, message, options signature", + () => { + const cause = new Error("fail"); + const extensionsData = { key: "value" }; + const customHeaders = new Headers({ "X-Custom-Header": "TestValue" }); + customHeaders.set("content-type", "application/custom+json"); + + function assertAllOptions(error: HttpError) { + assertEquals(error.name, "CustomErrorName"); + assertEquals(error.message, "A very specific error occurred."); + assertEquals(error.status, 418); + assertEquals(error.statusText, "I'm a teapot"); + assertEquals(error.expose, false); + assertEquals(error.cause, cause); + assertEquals(error.type, "/errors/custom-operation-failed"); + assertEquals( + error.instance, + "/errors/custom-operation-failed/instance/123xyz", + ); + assertEquals(error.extensions, extensionsData); + assertEquals(error.headers.get("X-Custom-Header"), "TestValue"); + assertEquals( + error.headers.get("content-type"), + "application/custom+json", + ); + assertEquals( + error.toString(), + "CustomErrorName: A very specific error occurred.", + ); + } + + const allOpts: HttpErrorOptions = { + name: "CustomErrorName", + message: "A very specific error occurred.", + status: 418, + statusText: "I'm a teapot", + expose: false, + cause, + type: "/errors/custom-operation-failed", + instance: "/errors/custom-operation-failed/instance/123xyz", + extensions: extensionsData, + headers: customHeaders, + }; + + assertAllOptions( + new HttpError( + 418, + "A very specific error occurred.", + allOpts, + ), + ); + }, +); + +it(withAllOptionsTests, "constructor with status, options signature", () => { const cause = new Error("fail"); - function assertAllOptions(error: HttpError) { - assertEquals(error.toString(), "CustomError: something went wrong"); - assertEquals(error.name, "CustomError"); - assertEquals(error.message, "something went wrong"); - assertEquals(error.status, 400); + const extensionsData = { key: "value" }; + const customHeaders = new Headers({ "X-Custom-Header": "TestValue" }); + customHeaders.set("content-type", "application/custom+json"); + + function assertAllOptions(error: HttpError) { + assertEquals(error.name, "CustomErrorName"); + assertEquals(error.message, "A very specific error occurred."); + assertEquals(error.status, 418); + assertEquals(error.statusText, "I'm a teapot"); assertEquals(error.expose, false); assertEquals(error.cause, cause); + assertEquals(error.type, "/errors/custom-operation-failed"); + assertEquals( + error.instance, + "/errors/custom-operation-failed/instance/123xyz", + ); + assertEquals(error.extensions, extensionsData); + assertEquals(error.headers.get("X-Custom-Header"), "TestValue"); + assertEquals(error.headers.get("content-type"), "application/custom+json"); + assertEquals( + error.toString(), + "CustomErrorName: A very specific error occurred.", + ); } + + const allOpts: HttpErrorOptions = { + name: "CustomErrorName", + message: "A very specific error occurred.", + status: 418, + statusText: "I'm a teapot", + expose: false, + cause, + type: "/errors/custom-operation-failed", + instance: "/errors/custom-operation-failed/instance/123xyz", + extensions: extensionsData, + headers: customHeaders, + }; + assertAllOptions(new HttpError(418, allOpts)); +}); + +it(withAllOptionsTests, "constructor with message, options signature", () => { + const cause = new Error("fail"); + const extensionsData = { key: "value" }; + const customHeaders = new Headers({ "X-Custom-Header": "TestValue" }); + customHeaders.set("content-type", "application/custom+json"); + + function assertAllOptions(error: HttpError) { + assertEquals(error.name, "CustomErrorName"); + assertEquals(error.message, "A very specific error occurred."); + assertEquals(error.status, 418); + assertEquals(error.statusText, "I'm a teapot"); + assertEquals(error.expose, false); + assertEquals(error.cause, cause); + assertEquals(error.type, "/errors/custom-operation-failed"); + assertEquals( + error.instance, + "/errors/custom-operation-failed/instance/123xyz", + ); + assertEquals(error.extensions, extensionsData); + assertEquals(error.headers.get("X-Custom-Header"), "TestValue"); + assertEquals(error.headers.get("content-type"), "application/custom+json"); + assertEquals( + error.toString(), + "CustomErrorName: A very specific error occurred.", + ); + } + const allOpts: HttpErrorOptions = { + name: "CustomErrorName", + message: "A very specific error occurred.", + status: 418, + statusText: "I'm a teapot", + expose: false, + cause, + type: "/errors/custom-operation-failed", + instance: "/errors/custom-operation-failed/instance/123xyz", + extensions: extensionsData, + headers: customHeaders, + }; + + const optsForMessageFirst = { ...allOpts }; + delete optsForMessageFirst.message; assertAllOptions( - new HttpError(400, "something went wrong", { - name: "CustomError", - expose: false, - cause, - }), - ); - assertAllOptions( - new HttpError(400, { - name: "CustomError", - message: "something went wrong", - expose: false, - cause, + new HttpError("A very specific error occurred.", { + ...optsForMessageFirst, + status: 418, }), ); - assertAllOptions( - new HttpError("something went wrong", { - name: "CustomError", - status: 400, - expose: false, - cause, - }), +}); + +it(withAllOptionsTests, "constructor with options signature", () => { + const cause = new Error("fail"); + const extensionsData = { key: "value" }; + const customHeaders = new Headers({ "X-Custom-Header": "TestValue" }); + customHeaders.set("content-type", "application/custom+json"); + + function assertAllOptions(error: HttpError) { + assertEquals(error.name, "CustomErrorName"); + assertEquals(error.message, "A very specific error occurred."); + assertEquals(error.status, 418); + assertEquals(error.statusText, "I'm a teapot"); + assertEquals(error.expose, false); + assertEquals(error.cause, cause); + assertEquals(error.type, "/errors/custom-operation-failed"); + assertEquals( + error.instance, + "/errors/custom-operation-failed/instance/123xyz", + ); + assertEquals(error.extensions, extensionsData); + assertEquals(error.headers.get("X-Custom-Header"), "TestValue"); + assertEquals(error.headers.get("content-type"), "application/custom+json"); + assertEquals( + error.toString(), + "CustomErrorName: A very specific error occurred.", + ); + } + const allOpts: HttpErrorOptions = { + name: "CustomErrorName", + message: "A very specific error occurred.", + status: 418, + statusText: "I'm a teapot", + expose: false, + cause, + type: "/errors/custom-operation-failed", + instance: "/errors/custom-operation-failed/instance/123xyz", + extensions: extensionsData, + headers: customHeaders, + }; + assertAllOptions(new HttpError(allOpts)); +}); + +it(httpErrorTests, "with headers as object literal", () => { + const error = new HttpError({ + headers: { + "X-Custom-Test": "Value", + "X-Another-Header": "AnotherValue", + }, + }); + assertEquals(error.headers.get("X-Custom-Test"), "Value"); + assertEquals(error.headers.get("X-Another-Header"), "AnotherValue"); + assertEquals( + error.headers.get("content-type"), + "application/problem+json", ); - assertAllOptions( - new HttpError({ - name: "CustomError", - message: "something went wrong", - status: 400, - expose: false, - cause, - }), + + const errorWithContentType = new HttpError({ + headers: { + "X-Custom-Test": "Value", + "content-type": "application/custom+json", + }, + }); + assertEquals(errorWithContentType.headers.get("X-Custom-Test"), "Value"); + assertEquals( + errorWithContentType.headers.get("content-type"), + "application/custom+json", ); }); it(httpErrorTests, "with other data", () => { const cause = new Error("fail"); const data = { x: 2, y: 3 }; - function assertAllOptions(error: HttpError) { + function assertAllOptions(error: HttpError) { assertEquals(error.toString(), "CustomError: something went wrong"); assertEquals(error.name, "CustomError"); assertEquals(error.message, "something went wrong"); assertEquals(error.status, 400); assertEquals(error.expose, false); assertEquals(error.cause, cause); - assertEquals(error.data, data); + assertEquals(error.extensions, data); } assertAllOptions( - new HttpError(400, "something went wrong", { + new HttpError(400, "something went wrong", { name: "CustomError", expose: false, cause, - ...data, + extensions: data, }), ); assertAllOptions( - new HttpError(400, { + new HttpError(400, { name: "CustomError", message: "something went wrong", expose: false, cause, - ...data, + extensions: data, }), ); assertAllOptions( - new HttpError("something went wrong", { + new HttpError("something went wrong", { name: "CustomError", status: 400, expose: false, cause, - ...data, + extensions: data, }), ); assertAllOptions( - new HttpError({ + new HttpError({ name: "CustomError", message: "something went wrong", status: 400, expose: false, cause, - ...data, + extensions: data, }), ); }); -it(httpErrorTests, "json", () => { +const jsonTests = describe(httpErrorTests, "json"); + +it(jsonTests, "HttpError.from(Error).toJSON()", () => { const cause = new Error("fail"); - const data = { x: 2, y: 3 }; + const errorFromCause = HttpError.from(cause); assertEquals( - HttpError.json(cause), + errorFromCause.toJSON(), { - name: "InternalServerError", + title: "InternalServerError", status: 500, }, ); - assertEquals( - HttpError.json( - new HttpError(400, "something went wrong", { - name: "CustomError", - cause, - ...data, - }), - ), +}); + +it(jsonTests, "Exposed client error with extensions toJSON()", () => { + const cause = new Error("fail"); + const data = { x: 2, y: 3 }; + const clientErrorWithOptions = new HttpError( + 400, + "something went wrong", { name: "CustomError", - message: "something went wrong", - status: 400, - ...data, + cause, + extensions: data, }, ); assertEquals( - HttpError.json( - new HttpError(500, "something went wrong", { - name: "CustomError", - cause, - ...data, - }), - ), + clientErrorWithOptions.toJSON(), { - name: "CustomError", - status: 500, ...data, + title: "CustomError", + detail: "something went wrong", + status: 400, }, ); - assertEquals( - HttpError.json( - new HttpError(400, "something went wrong", { - name: "CustomError", - expose: false, - cause, - ...data, - }), - ), +}); + +it(jsonTests, "Non-exposed server error with extensions toJSON()", () => { + const cause = new Error("fail"); + const data = { x: 2, y: 3 }; + const serverErrorWithOptions = new HttpError( + 500, + "something went wrong", { name: "CustomError", - status: 400, + cause, + extensions: data, + }, + ); + assertEquals( + serverErrorWithOptions.toJSON(), + { ...data, + title: "CustomError", + status: 500, }, ); }); +it( + jsonTests, + "Client error, explicitly not exposed, with extensions toJSON()", + () => { + const cause = new Error("fail"); + const data = { x: 2, y: 3 }; + const clientErrorNotExposed = new HttpError( + 400, + "something went wrong", + { + name: "CustomError", + expose: false, + cause, + extensions: data, + }, + ); + assertEquals( + clientErrorNotExposed.toJSON(), + { + ...data, + title: "CustomError", + status: 400, + }, + ); + }, +); + +const getResponseTests = describe(httpErrorTests, "getResponse"); + +it( + getResponseTests, + "Client error, exposed, with extensions and custom type/instance", + async () => { + const error1Extensions = { extra: "data1" }; + const error1 = new HttpError(400, "Client Issue", { + name: "MyClientError", + extensions: error1Extensions, + type: "/err/client", + instance: "/err/client/1", + }); + const response1 = error1.getResponse(); + assertEquals(response1.status, 400); + assertEquals( + response1.headers.get("content-type"), + "application/problem+json", + ); + const body1 = await response1.json(); + assertEquals(body1, { + ...error1Extensions, + status: 400, + title: "MyClientError", + detail: "Client Issue", + type: "/err/client", + instance: "/err/client/1", + }); + }, +); + +it( + getResponseTests, + "Server error, not exposed, with custom statusText and headers", + async () => { + const error2Extensions = { extra: "data2" }; + const customHeaders = new Headers({ "X-RateLimit-Limit": "100" }); + customHeaders.set("content-type", "application/vnd.api+json"); + + const error2 = new HttpError(503, "Server Down", { + name: "CustomServerErr", + statusText: "Service Really Unavailable", + expose: false, + extensions: error2Extensions, + headers: customHeaders, + }); + const response2 = error2.getResponse(); + assertEquals(response2.status, 503); + if (response2.statusText === "Service Really Unavailable") { + assertEquals(response2.statusText, "Service Really Unavailable"); + } + assertEquals( + response2.headers.get("content-type"), + "application/vnd.api+json", + ); + assertEquals(response2.headers.get("X-RateLimit-Limit"), "100"); + const body2 = await response2.json(); + assertEquals(body2, { + ...error2Extensions, + status: 503, + title: "CustomServerErr", + }); + }, +); + +it( + getResponseTests, + "Default headers (content-type) - No extensions", + async () => { + const error3 = new HttpError(404, "Not Found"); + const response3 = error3.getResponse(); + assertEquals( + response3.headers.get("content-type"), + "application/problem+json", + ); + const body3 = await response3.json(); + assertEquals(body3, { + status: 404, + title: "NotFoundError", + detail: "Not Found", + }); + }, +); + const fromTests = describe(httpErrorTests, "from"); it(fromTests, "non HttpError", () => { @@ -417,269 +706,500 @@ it(fromTests, "passthrough HttpError instances", () => { name: "CustomError", message: "something went wrong", cause, - x: 2, - y: 3, + extensions: { x: 2, y: 3 }, }); const error = HttpError.from(originalError); assertStrictEquals(error, originalError); }); -it("isHttpError", () => { - assertEquals(isHttpError(new Error()), false); - assertEquals(isHttpError(new HttpError()), true); - assertEquals(isHttpError(new HttpError(400, "something went wrong")), true); - class CustomError extends HttpError { - constructor(message: string) { - super(420, message, { name: "CustomError" }); - } - } - assertEquals(isHttpError(new CustomError("too high")), true); - class OtherError extends Error { - constructor(message: string) { - super(message); - } - } - assertEquals(isHttpError(new OtherError("failed")), false); - class OtherHttpError extends Error { - status: number; - expose: boolean; - - constructor(status: number, message: string) { - super(message); - this.status = status; - this.expose = status < 500; - } +it(fromTests, "with various unexpected input types", () => { + const testInputs: unknown[] = [ + null, + undefined, + 12345, + true, + Symbol("test-symbol"), + { completely: "unrelated object" }, + () => {}, + ]; + + for (const inputValue of testInputs) { + const error = HttpError.from(inputValue); + assert( + error instanceof HttpError, + `Expected HttpError for input: ${String(inputValue)}`, + ); + assertEquals(error.status, 500, `Status for input: ${String(inputValue)}`); + assertEquals( + error.message, + "unexpected error type", + `Message for input: ${String(inputValue)}`, + ); + assertStrictEquals( + error.cause, + inputValue, + `Cause for input: ${String(inputValue)}`, + ); } - assertEquals(isHttpError(new OtherHttpError(400, "failed")), true); }); -const optionsFromArgsTests = describe("optionsFromArgs"); +it(fromTests, "empty object", () => { + const emptyObjError = HttpError.from({}); + assert(emptyObjError instanceof HttpError); + assertEquals(emptyObjError.status, 500); + assertEquals(emptyObjError.message, "unexpected error type"); + assertEquals(emptyObjError.cause, {}); +}); -it( - optionsFromArgsTests, - "prefer status/message args over status/messagee options", - () => { - const messages = ["something went wrong", "failed"]; - const statuses = [400, 502]; - const options = { message: messages[1], status: statuses[1] }; +const fromResponseTests = describe(fromTests, "Response"); - function assertPreferArgs( - options: HttpErrorOptions, - expectedStatus: number, - expectedMessage: string, - ): void { - assertEquals(options, { - status: expectedStatus, - message: expectedMessage, - }); - } - assertPreferArgs( - optionsFromArgs(statuses[0], messages[0], options), - statuses[0], - messages[0], - ); - assertPreferArgs( - optionsFromArgs(statuses[0], options), - statuses[0], - messages[1], - ); - assertPreferArgs( - optionsFromArgs(messages[0], options), - statuses[1], - messages[0], - ); - assertPreferArgs(optionsFromArgs(options), statuses[1], messages[1]); +it(fromResponseTests, "with ProblemDetails JSON", async () => { + const problemDetails = { + status: 403, + title: "ForbiddenError", + detail: "Access denied", + type: "/errors/forbidden", + instance: "/errors/forbidden/123", + customField: "customValue", + }; + const response = new Response(JSON.stringify(problemDetails), { + status: 403, + headers: { "Content-Type": "application/problem+json" }, + }); + const error = await HttpError.from(response); + assertEquals(error.status, 403); + assertEquals(error.name, "ForbiddenError"); + assertEquals(error.message, "Access denied"); + assertEquals(error.type, "/errors/forbidden"); + assertEquals(error.instance, "/errors/forbidden/123"); + assertEquals(error.extensions, { customField: "customValue" }); + assertEquals(error.expose, true); +}); + +it( + fromResponseTests, + "with status in JSON preferred over response status", + async () => { + const problemDetails = { + status: 403, + title: "ForbiddenError", + detail: "Access denied", + }; + const response = new Response(JSON.stringify(problemDetails), { + status: 400, + headers: { "Content-Type": "application/problem+json" }, + }); + const error = await HttpError.from(response); + assertEquals(error.status, 403); + assertEquals(error.name, "ForbiddenError"); + assertEquals(error.message, "Access denied"); }, ); it( - optionsFromArgsTests, - "supports extended options", - () => { - const status = 400; - const message = "something went wrong"; - const options = { code: "invalid_request", uri: "https://example.com" }; - interface ExtendedErrorOptions extends HttpErrorOptions { - code?: string; - uri?: string; - } - const expectedOptions: ExtendedErrorOptions = { - status, - message, - ...options, + fromResponseTests, + "without status in JSON uses response status", + async () => { + const problemDetails = { + title: "BadRequest", + detail: "Bad input", }; - function assertExtendedInit(options: ExtendedErrorOptions): void { - assertEquals(options, expectedOptions); - } - - assertExtendedInit( - optionsFromArgs(status, message, options), - ); - assertExtendedInit( - optionsFromArgs(status, { message, ...options }), - ); - assertExtendedInit( - optionsFromArgs(message, { status, ...options }), - ); - assertExtendedInit( - optionsFromArgs({ status, message, ...options }), - ); + const response = new Response(JSON.stringify(problemDetails), { + status: 400, + headers: { "Content-Type": "application/problem+json" }, + }); + const error = await HttpError.from(response); + assertEquals(error.status, 400); + assertEquals(error.name, "BadRequest"); + assertEquals(error.message, "Bad input"); }, ); -const ErrorResponseTests = describe("ErrorResponse"); - -it(ErrorResponseTests, "from non Error", () => { - const errorResponse = new ErrorResponse("oops"); - const expected = { - name: "InternalServerError", - status: 500, - }; - assertEquals(errorResponse.error, expected); +it(fromResponseTests, "with invalid JSON", async () => { + const response = new Response("not json {{{{", { status: 500 }); + const error = await HttpError.from(response); + assertEquals(error.status, 500); + assertEquals(error.message, "could not parse problem details response"); + assert(error.cause instanceof SyntaxError); assertEquals( - JSON.stringify(errorResponse), - JSON.stringify({ error: expected }), + error.cause.message, + `Unexpected token 'o', "not json {{{{" is not valid JSON`, ); }); -it(ErrorResponseTests, "from Error", () => { - const errorResponse = new ErrorResponse(new Error("oops")); - const expected = { - name: "InternalServerError", - status: 500, - }; - assertEquals(errorResponse.error, expected); - assertEquals( - JSON.stringify(errorResponse), - JSON.stringify({ error: expected }), - ); -}); +it( + fromResponseTests, + "with valid JSON that is not ProblemDetails", + async () => { + const nonProblemDetailsPayload = { data: "some other structure" }; + const response = new Response(JSON.stringify(nonProblemDetailsPayload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + const error = await HttpError.from(response); + assertEquals(error.status, 500); + assertEquals(error.message, "invalid problem details response"); + assert(error.cause, "Cause should be defined"); + assertEquals(error.cause, nonProblemDetailsPayload); + }, +); -it(ErrorResponseTests, "from internal HttpError", () => { - const errorResponse = new ErrorResponse(new HttpError("oops")); - const expected = { - name: "InternalServerError", - status: 500, +it(fromTests, "from ProblemDetails object", () => { + const problemDetails = { + status: 404, + title: "ResourceNotFound", + detail: "The requested resource was not found.", + type: "/errors/not-found", + instance: "/items/123", + anotherKey: "anotherValue", }; - assertEquals(errorResponse.error, expected); - assertEquals( - JSON.stringify(errorResponse), - JSON.stringify({ error: expected }), - ); + type MyExt = { anotherKey: string }; + const error = HttpError.from(problemDetails); + assertEquals(error.status, 404); + assertEquals(error.name, "ResourceNotFound"); + assertEquals(error.message, "The requested resource was not found."); + assertEquals(error.type, "/errors/not-found"); + assertEquals(error.instance, "/items/123"); + assertEquals(error.extensions, { anotherKey: "anotherValue" }); + assertEquals(error.expose, true); }); -it(ErrorResponseTests, "from external HttpError", () => { - const errorResponse = new ErrorResponse(new HttpError(400, "oops")); - const expected = { - name: "BadRequestError", - status: 400, - message: "oops", - }; - assertEquals(errorResponse.error, expected); - assertEquals( - JSON.stringify(errorResponse), - JSON.stringify({ error: expected }), - ); -}); +const createHttpErrorClassTests = describe("createHttpErrorClass"); -const toErrorTests = describe(ErrorResponseTests, "toError"); +it( + createHttpErrorClassTests, + "creates a basic custom error class without default options", + () => { + const BasicError = createHttpErrorClass(); + const error = new BasicError(400, "test message"); + assert(error instanceof HttpError, "Should be instance of HttpError"); + assert(error instanceof BasicError, "Should be instance of BasicError"); + assertEquals(error.status, 400); + assertEquals(error.message, "test message"); + assertEquals(error.name, "BadRequestError"); -it(toErrorTests, "with text response", async () => { - const response = new Response("oops", { status: 400 }); - const error = await ErrorResponse.toError(response); - assertEquals(error.toString(), "BadRequestError"); - assertEquals(error.name, "BadRequestError"); - assertEquals(error.message, ""); - assertEquals(error.status, 400); - assertEquals(error.expose, true); - assertEquals(error.cause, undefined); -}); + const error2 = new BasicError(); + assertEquals(error2.status, 500); + assertEquals(error2.message, ""); + assertEquals(error2.name, "InternalServerError"); + }, +); -it(toErrorTests, "with error response", async () => { - const response = new Response( - JSON.stringify({ error: { status: 400, message: "oops", custom: "data" } }), - { status: 400 }, - ); - const error = await ErrorResponse.toError(response); - assertEquals(error.toString(), "BadRequestError: oops"); - assertEquals(error.name, "BadRequestError"); - assertEquals(error.message, "oops"); - assertEquals(error.status, 400); - assertEquals(error.expose, true); - assertEquals(error.cause, undefined); - assertEquals(error.data, { custom: "data" }); -}); +it( + createHttpErrorClassTests, + "creates a custom error class with default name and status", + () => { + const DefaultNameStatusError = createHttpErrorClass({ + name: "MyTestError", + status: 499, + }); -it(toErrorTests, "with json response", async () => { - const response = new Response(JSON.stringify({ message: "oops" }), { - status: 400, - }); - const error = await ErrorResponse.toError(response); - assertEquals(error.toString(), "BadRequestError: oops"); - assertEquals(error.name, "BadRequestError"); - assertEquals(error.message, "oops"); - assertEquals(error.status, 400); - assertEquals(error.expose, true); - assertEquals(error.cause, undefined); -}); + const error1 = new DefaultNameStatusError("A message"); + assertEquals(error1.status, 499); + assertEquals(error1.name, "MyTestError"); + assertEquals(error1.message, "A message"); -it(toErrorTests, "with internal ErrorResponse", () => { - const errorResponse = new ErrorResponse(new HttpError("oops")); - const error = ErrorResponse.toError(errorResponse); - assertEquals(error.toString(), "InternalServerError"); - assertEquals(error.name, "InternalServerError"); - assertEquals(error.message, ""); - assertEquals(error.status, 500); - assertEquals(error.expose, false); - assertEquals(error.cause, undefined); -}); + const error2 = new DefaultNameStatusError(498, "Override status"); + assertEquals(error2.status, 498); + assertEquals(error2.name, "MyTestError"); + assertEquals(error2.message, "Override status"); -it(toErrorTests, "with external ErrorResponse", () => { - const errorResponse = new ErrorResponse(new HttpError(400, "oops")); - const error = ErrorResponse.toError(errorResponse); - assertEquals(error.toString(), "BadRequestError: oops"); - assertEquals(error.name, "BadRequestError"); - assertEquals(error.message, "oops"); - assertEquals(error.status, 400); - assertEquals(error.expose, true); - assertEquals(error.cause, undefined); -}); + const error3 = new DefaultNameStatusError({ + message: "Via options", + name: "OverriddenName", + }); + assertEquals(error3.status, 499); + assertEquals(error3.name, "OverriddenName"); + assertEquals(error3.message, "Via options"); -it(toErrorTests, "with ErrorResponse JSON", () => { - const errorResponse = { - error: { - name: "BadRequestError", - message: "oops", - status: 400, - }, - }; - const error = ErrorResponse.toError(errorResponse); - assertEquals(error.toString(), "BadRequestError: oops"); - assertEquals(error.name, "BadRequestError"); - assertEquals(error.message, "oops"); - assertEquals(error.status, 400); - assertEquals(error.expose, true); - assertEquals(error.cause, undefined); -}); + const error4 = new DefaultNameStatusError({ status: 497 }); + assertEquals(error4.status, 497); + assertEquals(error4.name, "MyTestError"); + assertEquals(error4.message, ""); + }, +); -it("isErrorResponse", () => { - assertEquals(isErrorResponse({}), false); - assertEquals(isErrorResponse({ success: true }), false); - assertEquals(isErrorResponse({ error: {} }), true); - assertEquals( - isErrorResponse({ - error: { status: 400, message: "something went wrong" }, - }), - true, - ); - assertEquals( - isErrorResponse({ - error: { - status: 400, - name: "CustomError", - message: "something went wrong", +it( + createHttpErrorClassTests, + "creates a custom error class with default extensions", + () => { + interface MyExt { + defaultKey: string; + anotherKey?: number; + yetAnotherKey?: string; + } + const ExtError = createHttpErrorClass({ + extensions: { defaultKey: "defaultValue", yetAnotherKey: "stillHere" }, + }); + + const error1 = new ExtError("msg"); + assertEquals(error1.extensions.defaultKey, "defaultValue"); + assertEquals(error1.extensions.yetAnotherKey, "stillHere"); + assertEquals(error1.extensions.anotherKey, undefined); + + const error2 = new ExtError("msg", { + extensions: { defaultKey: "override", anotherKey: 123 }, + }); + assertEquals(error2.extensions.defaultKey, "override"); + assertEquals(error2.extensions.anotherKey, 123); + assertEquals((error2.extensions as MyExt).yetAnotherKey, "stillHere"); + + const error3 = new ExtError({ + extensions: { + anotherKey: 456, + defaultKey: "defaultValue", + yetAnotherKey: "stillHere", }, - }), - true, - ); + }); + assertEquals(error3.extensions.defaultKey, "defaultValue"); + assertEquals(error3.extensions.anotherKey, 456); + assertEquals(error3.extensions.yetAnotherKey, "stillHere"); + assertEquals(error3.message, ""); + assertEquals(error3.status, 500); + + const ErrorWithDefaultStatus = createHttpErrorClass({ + status: 420, + extensions: { defaultKey: "val" }, + }); + const error4 = new ErrorWithDefaultStatus(); + assertEquals(error4.status, 420); + assertEquals((error4.extensions as MyExt).defaultKey, "val"); + }, +); + +const allDefaultsAndOverridesTests = describe( + createHttpErrorClassTests, + "custom error class with all possible default options and allows overrides", +); + +interface AllExt { + id: string; + type?: string; + newProp?: string; +} +const defaultHeaders = new Headers({ "X-Default": "true" }); +const defaultCause = new Error("Default cause"); + +const AllDefaultsError = createHttpErrorClass({ + name: "AllDefaultsErrorName", + status: 488, + message: "Default message for error", + expose: true, + statusText: "Default Status Text Here", + type: "/errors/all-defaults-type", + instance: "/errors/all-defaults-instance/0", + extensions: { id: "default-id", type: "default-ext-type" }, + headers: defaultHeaders, + cause: defaultCause, }); + +it( + allDefaultsAndOverridesTests, + "instantiates with no overrides, checking all defaults", + () => { + const error1 = new AllDefaultsError(); + assertEquals(error1.name, "AllDefaultsErrorName"); + assertEquals(error1.status, 488); + assertEquals(error1.message, "Default message for error"); + assertEquals(error1.expose, true); + assertEquals(error1.statusText, "Default Status Text Here"); + assertEquals(error1.type, "/errors/all-defaults-type"); + assertEquals(error1.instance, "/errors/all-defaults-instance/0"); + assertEquals(error1.extensions.id, "default-id"); + assertEquals(error1.extensions.type, "default-ext-type"); + assertEquals(error1.headers.get("X-Default"), "true"); + assertEquals(error1.cause, defaultCause); + }, +); + +it( + allDefaultsAndOverridesTests, + "instantiates with status and message args overriding defaults", + () => { + const error2all = new AllDefaultsError(489, "New message from args"); + assertEquals(error2all.status, 489); + assertEquals(error2all.message, "New message from args"); + assertEquals(error2all.name, "AllDefaultsErrorName"); + assertEquals((error2all.extensions as AllExt).id, "default-id"); + }, +); + +it( + allDefaultsAndOverridesTests, + "instantiates with options object overriding some defaults", + () => { + const instanceHeaders = new Headers({ "X-Instance": "live" }); + const instanceCause = new Error("Instance cause here"); + const error3all = new AllDefaultsError({ + name: "InstanceNameOverride", + message: "Instance message in options", + expose: false, + extensions: { id: "instance-id-override", newProp: "instanceValue" }, + headers: instanceHeaders, + cause: instanceCause, + type: "/errors/instance-type-override", + }); + assertEquals(error3all.name, "InstanceNameOverride"); + assertEquals(error3all.status, 488); + assertEquals(error3all.message, "Instance message in options"); + assertEquals(error3all.expose, false); + assertEquals(error3all.statusText, "Default Status Text Here"); + assertEquals(error3all.type, "/errors/instance-type-override"); + assertEquals(error3all.instance, "/errors/all-defaults-instance/0"); + assertEquals(error3all.extensions.id, "instance-id-override"); + assertEquals((error3all.extensions as AllExt).type, "default-ext-type"); + assertEquals(error3all.extensions.newProp, "instanceValue"); + assertEquals(error3all.headers.get("X-Instance"), "live"); + assertEquals(error3all.headers.has("X-Default"), false); + assertEquals(error3all.cause, instanceCause); + }, +); + +const statusTextOverridesTests = describe( + allDefaultsAndOverridesTests, + "statusText specific overrides", +); + +it( + statusTextOverridesTests, + "prioritizes statusText in constructor options", + () => { + const errorWithStatusTextInOptions = new AllDefaultsError({ + statusText: "Overridden Status Text Via Options", + }); + assertEquals( + errorWithStatusTextInOptions.statusText, + "Overridden Status Text Via Options", + ); + assertEquals(errorWithStatusTextInOptions.status, 488); + assertEquals(errorWithStatusTextInOptions.name, "AllDefaultsErrorName"); + }, +); + +it( + statusTextOverridesTests, + "prioritizes statusText in options when status arg is present", + () => { + const errorWithStatusTextAndStatusArg = new AllDefaultsError(490, { + statusText: "Custom StatusText with Status Arg", + }); + assertEquals(errorWithStatusTextAndStatusArg.status, 490); + assertEquals( + errorWithStatusTextAndStatusArg.statusText, + "Custom StatusText with Status Arg", + ); + }, +); + +it( + statusTextOverridesTests, + "prioritizes statusText in options when message arg is present", + () => { + const errorWithStatusTextAndMessageArg = new AllDefaultsError("A message", { + statusText: "Custom StatusText with Message Arg", + }); + assertEquals(errorWithStatusTextAndMessageArg.message, "A message"); + assertEquals( + errorWithStatusTextAndMessageArg.statusText, + "Custom StatusText with Message Arg", + ); + assertEquals(errorWithStatusTextAndMessageArg.status, 488); + }, +); + +it( + statusTextOverridesTests, + "prioritizes statusText in options when status and message args are present", + () => { + const errorWithStatusTextAndFullArgs = new AllDefaultsError( + 491, + "A full message", + { statusText: "Custom StatusText with Full Args" }, + ); + assertEquals(errorWithStatusTextAndFullArgs.status, 491); + assertEquals(errorWithStatusTextAndFullArgs.message, "A full message"); + assertEquals( + errorWithStatusTextAndFullArgs.statusText, + "Custom StatusText with Full Args", + ); + }, +); + +it( + statusTextOverridesTests, + "uses class default statusText if not in constructor options", + () => { + const errorUsingDefaultStatusText = new AllDefaultsError({ + message: "Message without status text override", + }); + assertEquals( + errorUsingDefaultStatusText.statusText, + "Default Status Text Here", + ); + const error1 = new AllDefaultsError(); + assertEquals(error1.statusText, "Default Status Text Here"); + }, +); + +it( + createHttpErrorClassTests, + "custom error class instances behave like HttpError (toJSON, getResponse)", + async () => { + interface RespExt { + code: number; + field?: string; + defaultInfo?: string; + } + const RespError = createHttpErrorClass({ + name: "RespTestError", + status: 422, + extensions: { code: 100, defaultInfo: "from_default" }, + expose: true, + }); + + const error = new RespError("Validation failed", { + extensions: { code: 101, field: "email" }, + type: "/errors/validation", + instance: "/user/123/email", + }); + + const json = error.toJSON(); + assertEquals(json, { + status: 422, + title: "RespTestError", + detail: "Validation failed", + type: "/errors/validation", + instance: "/user/123/email", + ...error.extensions, + }); + + const response = error.getResponse(); + assertEquals(response.status, 422); + assertEquals( + response.headers.get("content-type"), + "application/problem+json", + ); + const body = await response.json(); + assertEquals(body, json); + + const NonExposedError = createHttpErrorClass({ + name: "SecretError", + status: 500, + expose: false, + }); + const secretError = new NonExposedError("This is a secret detail", { + extensions: { secretCode: "xyz" }, + }); + const secretJson = secretError.toJSON(); + assertEquals(secretJson, { + status: 500, + title: "SecretError", + ...secretError.extensions, + }); + assertEquals(secretJson.detail, undefined); + + const secretResponse = secretError.getResponse(); + const secretBody = await secretResponse.json(); + assertEquals(secretBody, secretJson); + }, +); diff --git a/mod.ts b/mod.ts index c1c1a2d..53bd18d 100644 --- a/mod.ts +++ b/mod.ts @@ -1,25 +1,64 @@ import { STATUS_CODE, STATUS_TEXT, type StatusCode } from "@std/http/status"; /** Options for initializing an HttpError. */ -export interface HttpErrorOptions extends ErrorOptions { - /** The name of the error. Default based on error status. */ +export interface HttpErrorOptions< + Extensions extends object = Record, +> extends ErrorOptions { + /** The name of the error. The default value is based on error status. This will be the default value for the `title` property in the response body. */ name?: string; + /** The message of the error. This will be the default value for the `detail` property in the response body. */ message?: string; /** The HTTP status associated with the error. Defaults to 500. */ status?: number; + /** The HTTP status text associated with the error. */ + statusText?: string; /** - * Determines if the error should be exposed in the response. + * Determines if the error detail should be exposed in the response. * Defaults to true for client error statuses and false for server error statuses. */ expose?: boolean; /** - * The cause of the error. + * The cause of the error. This is never exposed in the response. */ cause?: unknown; /** - * Other data associated with the error. + * The type of the error is a URI reference that identifies the problem type. */ - [key: string]: unknown; + type?: string; + /** + * The instance of the error is a URI reference that identifies the specific occurrence of the problem. + */ + instance?: string; + /** + * Additional data to send in the response. + */ + extensions?: Extensions; + /** + * The headers to send in the response. The content-type will default to application/problem+json unless otherwise specified in the headers. + */ + headers?: Headers | Record; +} + +interface ProblemDetailsBase { + type?: string; + status?: number; + title?: string; + detail?: string; + instance?: string; +} + +export type ProblemDetails< + Extensions extends object = Record, +> = ProblemDetailsBase & Extensions; + +function isProblemDetails< + Extensions extends object = Record, +>(value: unknown): value is ProblemDetails { + return typeof value === "object" && value !== null && ( + "status" in value || + "title" in value || + "type" in value + ); } /** @@ -64,16 +103,16 @@ export interface HttpErrorOptions extends ErrorOptions { * @param options - The options. * @returns The options object. */ -export function optionsFromArgs< - Init extends HttpErrorOptions = HttpErrorOptions, +function optionsFromArgs< + Extensions extends object = Record, >( - statusOrMessageOrOptions?: number | string | Init, - messageOrOptions?: string | Init, - options?: Init, -): Init { + statusOrMessageOrOptions?: number | string | (HttpErrorOptions), + messageOrOptions?: string | (HttpErrorOptions), + options?: HttpErrorOptions, +): HttpErrorOptions { let status: number | undefined = undefined; let message: string | undefined = undefined; - let init: Init | undefined = options; + let init: HttpErrorOptions | undefined = options; if (typeof statusOrMessageOrOptions === "number") { status = statusOrMessageOrOptions; @@ -85,7 +124,7 @@ export function optionsFromArgs< } } else if (typeof statusOrMessageOrOptions === "string") { message = statusOrMessageOrOptions; - init = messageOrOptions as (Init | undefined); + init = messageOrOptions as HttpErrorOptions | undefined; status = init?.status ?? status; } else if (typeof messageOrOptions === "string") { message = messageOrOptions; @@ -95,7 +134,7 @@ export function optionsFromArgs< message = init?.message; } - return { ...init, status, message } as Init; + return { ...init, status, message } as HttpErrorOptions; } function errorNameForStatus(status: number): string { @@ -204,49 +243,107 @@ function errorNameForStatus(status: number): string { * } * ``` * - * @param T - The type of data associated with the error. + * The HttpError class also provides `toJSON()` and `getResponse()` methods to + * convert errors into RFC 9457 Problem Details objects or Response objects, + * respectively. This makes it easy to return standardized error responses from + * your HTTP APIs. + * + * ```ts + * import { HttpError } from "@udibo/http-error"; + * + * const error = new HttpError(403, "Access denied", { + * type: "/errors/forbidden", + * instance: "/docs/123/edit", + * extensions: { accountId: "user-abc" }, + * }); + * + * // Get Problem Details object + * const problemDetails = error.toJSON(); + * console.log(problemDetails); + * // Output: + * // { + * // accountId: "user-abc", + * // status: 403, + * // title: "ForbiddenError", + * // detail: "Access denied", + * // type: "/errors/forbidden", + * // instance: "/docs/123/edit" + * // } + * + * // Get a Response object + * const response = error.getResponse(); + * console.log(response.status); // 403 + * response.json().then(body => console.log(body)); // Same as problemDetails + * ``` + * + * @typeparam Extensions - Other data associated with the error that will be included in the error response. * @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, + Extensions extends object = Record, > extends Error { /** * The HTTP status associated with the error. * Must be a client or server error status. Defaults to 500. */ status: number; + /** The HTTP status text associated with the error. */ + statusText?: string; /** - * Determines if the error should be exposed in the response. + * Determines if the error detail should be exposed in the response. * Defaults to true for client error statuses and false for server error statuses. */ - expose: boolean; + expose?: boolean; + /** + * The type of the error is a URI reference that identifies the problem type. + */ + type?: string; /** - * Other data associated with the error. + * The instance of the error is a URI reference that identifies the specific occurrence of the problem. */ - data: T; + instance?: string; + /** + * Other data associated with the error that will be included in the error response. + */ + extensions: Extensions; + /** + * The headers to send in the response. The content-type will default to application/problem+json unless otherwise specified in the headers. + */ + headers: Headers; constructor( status?: number, message?: string, - options?: HttpErrorOptions & T, + options?: HttpErrorOptions, ); - constructor(status?: number, options?: HttpErrorOptions & T); - constructor(message?: string, options?: HttpErrorOptions & T); - constructor(options?: HttpErrorOptions & T); + constructor(status?: number, options?: HttpErrorOptions); + constructor(message?: string, options?: HttpErrorOptions); + constructor(options?: HttpErrorOptions); constructor( - statusOrMessageOrOptions?: number | string | (HttpErrorOptions & T), - messageOrOptions?: string | (HttpErrorOptions & T), - options?: HttpErrorOptions & T, + statusOrMessageOrOptions?: number | string | HttpErrorOptions, + messageOrOptions?: string | HttpErrorOptions, + options?: HttpErrorOptions, ) { const init = optionsFromArgs( statusOrMessageOrOptions, messageOrOptions, options, ); - const { message, name, expose, status: _status, cause, ...data } = init; + const { + message, + name, + expose, + status: _status, + statusText, + cause, + type, + instance, + extensions, + headers, + } = init; const status = init.status ?? STATUS_CODE.InternalServerError; if (status < 400 || status >= 600) { @@ -264,8 +361,17 @@ export class HttpError< writable: true, }); this.status = status; + if (statusText) this.statusText = statusText; + if (type) this.type = type; + if (instance) this.instance = instance; this.expose = expose ?? (status < 500); - this.data = data as T; + this.extensions = extensions ?? {} as Extensions; + this.headers = headers instanceof Headers + ? headers + : new Headers(headers ?? {}); + if (!this.headers.has("content-type")) { + this.headers.set("content-type", "application/problem+json"); + } } /** @@ -286,285 +392,325 @@ export class HttpError< * @param error - The error to convert. * @returns An HttpError object. */ - static from = Record>( - error: HttpError | Error | unknown, - ): HttpError { + static from< + Extensions extends object = Record, + >( + error: HttpError | ProblemDetails, + ): HttpError; + static from< + Extensions extends object = Record, + >( + error: Response, + ): Promise>; + static from( + error: Error, + ): HttpError; + static from< + Extensions extends object = Record, + >( + error: unknown, + ): HttpError; + static from< + Extensions extends object = Record, + >( + error: + | HttpError + | Error + | ProblemDetails + | Response + | unknown, + ): HttpError | Promise> { if (error instanceof HttpError) { return error; - } else if (isHttpError(error)) { - const { name, message, status, expose, cause, data } = error; + } else if (isHttpErrorLike(error)) { + const { + name, + message, + status, + expose, + cause, + type, + instance, + extensions, + headers, + } = error as HttpError; const options = { - ...data, name, message, status, expose, cause, - } as HttpErrorOptions & T; - return new HttpError(options); + type, + instance, + extensions, + headers, + } as HttpErrorOptions; + return new HttpError(options); } else if (error instanceof Error) { return new HttpError(500, error.message, { cause: error, - }) as unknown as HttpError; + }) as unknown as HttpError; + } else if (error instanceof Response) { + return error.json() + .then((json: unknown) => { + if (!isProblemDetails(json)) { + return new HttpError( + 500, + "invalid problem details response", + { + cause: json, + }, + ); + } + const { status, title, detail, type, instance, ...extensions } = json; + const options: HttpErrorOptions = { + status: status || error.status, + name: title, + message: detail, + type, + instance, + extensions: extensions as Extensions, + }; + return new HttpError(options); + }) + .catch((parseError: unknown) => { + return new HttpError( + 500, + "could not parse problem details response", + { cause: parseError }, + ); + }); + } else if (isProblemDetails(error)) { + const { status, title, detail, type, instance, ...extensions } = error; + const options: HttpErrorOptions = { + status, + name: title, + message: detail, + type, + instance, + extensions: extensions as Extensions, + }; + return new HttpError(options); } else { - return new HttpError(500, { cause: error }) as unknown as HttpError; + return new HttpError(500, "unexpected error type", { + cause: error, + }); } } /** - * Converts an HttpError to an options object that can be used to re-create it. - * 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); - * ``` + * Converts the HttpError to a ProblemDetails object that matches the RFC 9457 + * Problem Details for HTTP APIs specification. This object is suitable for + * direct serialization into a JSON response body. * - * @param error - The error to convert. - * @returns The options object. + * @returns A ProblemDetails object representing the error, compliant with RFC 9457. */ - static json = Record>( - error: HttpError | Error | unknown, - ): HttpErrorOptions & T { - const { name, message, status, expose, data } = isHttpError(error) - ? error - : HttpError.from(error); - const json = { - name, - status, - ...data, - } as (HttpErrorOptions & T); - if (expose && message) { - json.message = message; + toJSON(): ProblemDetails { + const json: ProblemDetails = { + ...this.extensions, + status: this.status, + title: this.name, + }; + if (this.expose && this.message) { + json.detail = this.message; + } + if (this.type) { + json.type = this.type; + } + if (this.instance) { + json.instance = this.instance; } return json; } + + /** + * Converts the HttpError to a Response object that matches the RFC 9457 + * Problem Details for HTTP APIs specification. The body of the response will + * be a JSON string representing the ProblemDetails object. + * + * @returns A Response object suitable for returning from an HTTP handler, compliant with RFC 9457. + */ + getResponse(): Response { + const options: ResponseInit = { + status: this.status, + headers: this.headers, + }; + if (this.statusText) options.statusText = this.statusText; + return new Response(JSON.stringify(this.toJSON()), options); + } } /** * 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. + * will also return true for Error objects that have a `status` property of type number. * * ```ts - * import { HttpError, isHttpError } from "@udibo/http-error"; + * import { HttpError, isHttpErrorLike } from "@udibo/http-error"; * * let error = new Error("file not found"); - * console.log(isHttpError(error)); // false + * console.log(isHttpErrorLike(error)); // false * error = new HttpError(404, "file not found"); - * console.log(isHttpError(error)); // true + * console.log(isHttpErrorLike(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 { - return !!value && typeof value === "object" && +function isHttpErrorLike< + Extensions extends object = Record, +>(value: unknown): value is HttpError | Error { + return typeof value === "object" && value !== null && (value instanceof HttpError || (value instanceof Error && typeof (value as HttpError).status === "number")); } /** - * A format for sharing errors with the browser. - * With a consistent format for error responses, - * the client can convert them back into an HttpErrors. - */ -export interface ErrorResponse< - T extends Record = Record, -> { - error: HttpErrorOptions & T; -} - -/** - * 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. + * Creates a new class that extends HttpError, allowing for predefined default + * options and a specific default type for extensions. * - * Here is an example of how an oak server could have middleware that converts an - * error into into a JSON format. + * This factory is useful for creating custom HttpError types tailored to specific + * kinds of errors in an application, each with its own default status, name, + * or custom extension fields. * + * @example * ```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); - * } - * }); + * // Define a type for default extensions + * interface MyApiErrorExtensions { + * errorCode: string; + * requestId?: string; + * } * - * app.use(() => { - * // Will throw a 500 on every request. - * throw new HttpError(500); + * // Create a custom error class with defaults + * const MyApiError = createHttpErrorClass({ + * name: "MyApiError", + * status: 452, // Custom default status + * extensions: { + * errorCode: "API_GENERAL_FAILURE", // Default extension value + * }, * }); * - * 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" + * // Usage of the custom error class + * try { + * // Simulate an error condition + * throw new MyApiError("A specific API operation failed.", { + * extensions: { + * errorCode: "API_OPERATION_X_FAILED", // Override default extension + * requestId: "req-12345", // Add instance-specific extension + * }, + * }); + * } catch (e) { + * if (e instanceof MyApiError) { + * console.log(e.name); // "MyApiError" + * console.log(e.status); // 452 + * console.log(e.message); // "A specific API operation failed." + * console.log(e.extensions.errorCode); // "API_OPERATION_X_FAILED" + * console.log(e.extensions.requestId); // "req-12345" + * // console.log(e.toJSON()); * } * } + * + * // Example with a different status code at instantiation + * const anotherError = new MyApiError(453, "Another failure"); + * console.log(anotherError.status) // 453 + * console.log(anotherError.extensions.errorCode) // "API_GENERAL_FAILURE" * ``` * - * @param T - The type of data associated with the error. - * @param error - The error to convert. - * @returns An ErrorResponse object. + * @typeparam DefaultExtensions The type for the default `extensions` object. Defaults to `Record`. + * @param defaultOptionsForClass Optional default HttpErrorOptions to apply to the new error class. + * @returns A new class that extends `HttpError`. */ -export class ErrorResponse< - T extends Record = Record, -> implements ErrorResponse { - error: HttpErrorOptions & T; +export function createHttpErrorClass< + DefaultExtensions extends object = Record, +>( + defaultOptionsForClass?: HttpErrorOptions, +): { + new ( + status?: number, + message?: string, + options?: HttpErrorOptions, + ): HttpError; + new ( + status?: number, + options?: HttpErrorOptions, + ): HttpError; + new ( + message?: string, + options?: HttpErrorOptions, + ): HttpError; + new ( + options?: HttpErrorOptions, + ): HttpError; + prototype: HttpError; +} { + return class CustomHttpError< + Extensions extends DefaultExtensions = DefaultExtensions, + > 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, + optionsArgument?: HttpErrorOptions, + ) { + const constructorTimeOptions = optionsFromArgs( + statusOrMessageOrOptions, + messageOrOptions, + optionsArgument, + ); - constructor(error: unknown) { - this.error = HttpError.json(error); - } + const dfo = defaultOptionsForClass; - /** - * This function gives a client the ability to convert the error response into - * an HttpError. - * - * The easiest way to convert an error response into an HttpError is to directly - * pass the response to the `ErrorResponse.toError` function. - * - * 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"); - * if (!response.ok) throw await ErrorResponse.toError(response); - * return await response.json(); - * } - * ``` - * - * This function also supports converting error response JSON into an HttpError. - * However, it is recommended to use the first approach in the previous example as - * it will produce an HttpError based on the status code in the case that the - * response doesn't have valid JSON. - * - * ```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 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< - T extends Record = Record, - >( - response: ErrorResponse, - ): HttpError; - static toError< - T extends Record = Record, - >( - response: Response, - ): Promise>; - static toError< - T extends Record = Record, - >( - response: ErrorResponse | Response, - ): HttpError | Promise> { - if (isErrorResponse(response)) { - return new HttpError(response.error); - } else { - return response.json().then((json) => { - return isErrorResponse(json) - ? new HttpError(response.status, json.error) - : new HttpError(response.status, json.message); - }).catch(() => { - return new HttpError(response.status); - }); - } - } -} + const finalOptions: HttpErrorOptions = {}; -/** - * 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. + finalOptions.status = constructorTimeOptions.status !== undefined + ? constructorTimeOptions.status + : dfo?.status; - * 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. + finalOptions.message = constructorTimeOptions.message !== undefined + ? constructorTimeOptions.message + : dfo?.message; - * ```ts - * import { HttpError, isErrorResponse } from "@udibo/http-error"; + finalOptions.name = constructorTimeOptions.name !== undefined + ? constructorTimeOptions.name + : dfo?.name; - * 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 { - return typeof response === "object" && - typeof (response as ErrorResponse)?.error === "object"; + finalOptions.expose = constructorTimeOptions.expose !== undefined + ? constructorTimeOptions.expose + : dfo?.expose; + + finalOptions.cause = constructorTimeOptions.cause !== undefined + ? constructorTimeOptions.cause + : dfo?.cause; + + finalOptions.type = constructorTimeOptions.type !== undefined + ? constructorTimeOptions.type + : dfo?.type; + + finalOptions.instance = constructorTimeOptions.instance !== undefined + ? constructorTimeOptions.instance + : dfo?.instance; + + finalOptions.headers = constructorTimeOptions.headers !== undefined + ? constructorTimeOptions.headers + : dfo?.headers; + + finalOptions.statusText = constructorTimeOptions.statusText !== undefined + ? constructorTimeOptions.statusText + : dfo?.statusText; + + finalOptions.extensions = { + ...(dfo?.extensions), + ...(constructorTimeOptions.extensions), + } as Extensions; + + super(finalOptions); + } + }; } From 37e93ae9397c022e354142e4f35312505592558a Mon Sep 17 00:00:00 2001 From: Kyle June Date: Thu, 8 May 2025 22:04:04 -0400 Subject: [PATCH 2/2] Fix window test for Deno.serve log --- examples/hono.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/hono.test.ts b/examples/hono.test.ts index 83dbebe..ce82c6f 100644 --- a/examples/hono.test.ts +++ b/examples/hono.test.ts @@ -23,9 +23,10 @@ describe("hono error handling", () => { for await (const line of stdout.values({ preventCancel: true })) { if (line.includes("Listening on")) { + const address = Deno.build.os === "windows" ? "localhost" : "0.0.0.0"; assertEquals( line, - "Listening on http://0.0.0.0:8000/ (http://localhost:8000/)", + `Listening on http://${address}:8000/ (http://localhost:8000/)`, ); break; }