From bf3943a56a292feb6ca6a4d2ba80a2b10f4fae47 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Tue, 30 Dec 2025 22:46:45 -0500 Subject: [PATCH 1/2] Add exposedMessage and make error names more human readable --- .github/workflows/ci.yml | 0 .vscode/launch.json | 0 .vscode/settings.json | 0 README.md | 92 ++++++++++++++++++++--- deno.json | 16 ++-- deno.lock | 157 +++++++++++++++++++++++++-------------- examples/hono.test.ts | 5 +- examples/oak.test.ts | 7 +- mod.test.ts | 129 +++++++++++++++++--------------- mod.ts | 156 ++++++++++++++++++++++++++++++++++---- 10 files changed, 411 insertions(+), 151 deletions(-) mode change 100755 => 100644 .github/workflows/ci.yml mode change 100755 => 100644 .vscode/launch.json mode change 100755 => 100644 .vscode/settings.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml old mode 100755 new mode 100644 diff --git a/.vscode/launch.json b/.vscode/launch.json old mode 100755 new mode 100644 diff --git a/.vscode/settings.json b/.vscode/settings.json old mode 100755 new mode 100644 diff --git a/README.md b/README.md index 35fed03..584a230 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,14 @@ 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 (e.g., -`NotFoundError` for 404). If the name is not known, it will default to -`UnknownClientError` or `UnknownServerError`. +`Not Found` for 404). If the name is not known, it will default to +`Unknown Client Error` or `Unknown Server Error`. ```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.toString()); // Not Found: file not found console.log(error.status); // 404 console.log(error.expose); // true ``` @@ -141,8 +141,8 @@ console.log(httpErrorFromDetails.name); // ForbiddenAccess 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. +response body. The `detail` property is set to `exposedMessage`, which provides +a safe message for clients (see `exposedMessage` section below). ```ts import { HttpError } from "@udibo/http-error"; @@ -159,18 +159,20 @@ console.log(problemDetails); // { // field: "email", // status: 400, -// title: "BadRequestError", +// title: "Bad Request", // detail: "Invalid input", // type: "/errors/validation", // instance: "/form/user" // } -const serverError = new HttpError(500, "Internal details", { expose: false }); +// For server errors (expose=false), detail uses a safe default message +const serverError = new HttpError(500, "SQL syntax error near 'users'"); console.log(serverError.toJSON()); -// Outputs (detail omitted): +// Outputs: // { // status: 500, -// title: "InternalServerError" +// title: "Internal Server Error", +// detail: "The server encountered an unexpected condition." // } ``` @@ -193,6 +195,78 @@ console.log(response.headers.get("Content-Type")); // application/problem+json // response.body can be read to get the JSON string ``` +#### `exposedMessage` + +The `exposedMessage` property provides a safe, user-friendly message that can be +exposed to clients. This prevents internal error details (like SQL errors, file +paths, or stack traces) from leaking to users. + +**How it works:** + +- If `exposedMessage` is explicitly provided in options, that value is used +- If `expose` is `true` and `message` exists, `exposedMessage` defaults to + `message` +- Otherwise, `exposedMessage` defaults to a generic message for the status code + (e.g., "The server encountered an unexpected condition." for 500) + +```ts +import { HttpError } from "@udibo/http-error"; + +// Client error (expose=true by default) - message is used as exposedMessage +const clientError = new HttpError(400, "Invalid email format"); +console.log(clientError.exposedMessage); // "Invalid email format" + +// Server error (expose=false by default) - safe default is used +const serverError = new HttpError(500, "Database connection refused"); +console.log(serverError.exposedMessage); // "The server encountered an unexpected condition." +console.log(serverError.message); // "Database connection refused" (internal use only) + +// Custom exposedMessage - always takes priority +const customError = new HttpError(500, "SQL syntax error", { + exposedMessage: "An error occurred while processing your request.", +}); +console.log(customError.exposedMessage); // "An error occurred while processing your request." +``` + +### Server-Side Rendering Best Practices + +When rendering error messages in server-side templates or responses, always use +`error.exposedMessage` instead of `error.message` to prevent leaking internal +implementation details to users. + +**Why this matters:** + +- `error.message` may contain sensitive information (SQL errors, file paths, + stack traces) +- `error.exposedMessage` provides a safe, user-friendly message by default +- For server errors (5xx), the default `exposedMessage` is generic and safe +- For client errors (4xx), `exposedMessage` defaults to `message` since those + are typically user-facing + +**Example in a template:** + +```ts +// BAD - may expose internal details +

Error: ${error.message}

// "SQLSTATE[42S02]: Table 'users' doesn't exist" + +// GOOD - safe for users +

Error: ${error.exposedMessage}

// "The server encountered an unexpected condition." +``` + +**Example in error handling middleware:** + +```ts +app.onError((cause, c) => { + const error = HttpError.from(cause); + + // Log the full internal message for debugging + console.error(`[${error.status}] ${error.message}`, error.cause); + + // Return safe message to client (via toJSON which uses exposedMessage) + return error.getResponse(); +}); +``` + ### `createHttpErrorClass()` This factory function allows you to create custom error classes that extend diff --git a/deno.json b/deno.json index b10fb4b..2d38943 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@udibo/http-error", - "version": "0.10.0", + "version": "0.11.0", "exports": { ".": "./mod.ts" }, @@ -14,13 +14,13 @@ }, "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/streams": "jsr:@std/streams@1", - "@std/path": "jsr:@std/path@1", - "@oak/oak": "jsr:@oak/oak@17", - "hono": "npm:hono@4" + "@std/assert": "jsr:@std/assert@^1.0.16", + "@std/http": "jsr:@std/http@^1.0.22", + "@std/testing": "jsr:@std/testing@^1.0.16", + "@std/streams": "jsr:@std/streams@^1.0.14", + "@std/path": "jsr:@std/path@^1.1.3", + "@oak/oak": "jsr:@oak/oak@^17.2.0", + "hono": "npm:hono@^4.10.7" }, "tasks": { "check": { diff --git a/deno.lock b/deno.lock index baa607a..50dd24f 100644 --- a/deno.lock +++ b/deno.lock @@ -1,95 +1,142 @@ { "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:@oak/commons@1": "1.0.1", + "jsr:@oak/oak@^17.2.0": "17.2.0", + "jsr:@std/assert@1": "1.0.16", + "jsr:@std/assert@^1.0.15": "1.0.16", + "jsr:@std/assert@^1.0.16": "1.0.16", + "jsr:@std/bytes@1": "1.0.6", + "jsr:@std/bytes@^1.0.6": "1.0.6", + "jsr:@std/cli@^1.0.24": "1.0.24", + "jsr:@std/crypto@1": "1.0.5", + "jsr:@std/data-structures@^1.0.9": "1.0.9", + "jsr:@std/encoding@1": "1.0.10", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@^1.0.8": "1.0.8", + "jsr:@std/fs@^1.0.19": "1.0.20", + "jsr:@std/fs@^1.0.20": "1.0.20", + "jsr:@std/html@^1.0.5": "1.0.5", + "jsr:@std/http@1": "1.0.22", + "jsr:@std/http@^1.0.22": "1.0.22", + "jsr:@std/internal@^1.0.12": "1.0.12", "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", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/net@^1.0.6": "1.0.6", + "jsr:@std/path@1": "1.1.3", + "jsr:@std/path@^1.1.2": "1.1.3", + "jsr:@std/path@^1.1.3": "1.1.3", + "jsr:@std/streams@^1.0.14": "1.0.14", + "jsr:@std/testing@^1.0.16": "1.0.16", + "npm:hono@^4.10.7": "4.10.7", "npm:path-to-regexp@^6.3.0": "6.3.0" }, "jsr": { - "@oak/commons@1.0.0": { - "integrity": "49805b55603c3627a9d6235c0655aa2b6222d3036b3a13ff0380c16368f607ac", + "@oak/commons@1.0.1": { + "integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c", "dependencies": [ "jsr:@std/assert@1", "jsr:@std/bytes@1", "jsr:@std/crypto", "jsr:@std/encoding@1", - "jsr:@std/http", - "jsr:@std/media-types" + "jsr:@std/http@1", + "jsr:@std/media-types@1" ] }, - "@oak/oak@17.1.4": { - "integrity": "60530b582bf276ff741e39cc664026781aa08dd5f2bc5134d756cc427bf2c13e", + "@oak/oak@17.2.0": { + "integrity": "938537a92fc7922a46a9984696c65fb189c9baad164416ac3e336768a9ff0cd1", "dependencies": [ "jsr:@oak/commons", "jsr:@std/assert@1", "jsr:@std/bytes@1", - "jsr:@std/http", - "jsr:@std/media-types", - "jsr:@std/path", + "jsr:@std/http@1", + "jsr:@std/media-types@1", + "jsr:@std/path@1", "npm:path-to-regexp" ] }, - "@std/assert@1.0.12": { - "integrity": "08009f0926dda9cbd8bef3a35d3b6a4b964b0ab5c3e140a4e0351fbf34af5b9a", + "@std/assert@1.0.16": { + "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", "dependencies": [ "jsr:@std/internal" ] }, - "@std/bytes@1.0.5": { - "integrity": "4465dd739d7963d964c809202ebea6d5c6b8e3829ef25c6a224290fbb8a1021e" + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, - "@std/crypto@1.0.4": { - "integrity": "cee245c453bd5366207f4d8aa25ea3e9c86cecad2be3fefcaa6cb17203d79340" + "@std/cli@1.0.24": { + "integrity": "b655a5beb26aa94f98add6bc8889f5fb9bc3ee2cc3fc954e151201f4c4200a5e" }, - "@std/encoding@1.0.8": { - "integrity": "a6c8f3f933ab1bed66244f435d1dc0fd23a888e07195532122ddc3d5f8f0e6b4" + "@std/crypto@1.0.5": { + "integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40" }, - "@std/http@1.0.13": { - "integrity": "d29618b982f7ae44380111f7e5b43da59b15db64101198bb5f77100d44eb1e1e", + "@std/data-structures@1.0.9": { + "integrity": "033d6e17e64bf1f84a614e647c1b015fa2576ae3312305821e1a4cb20674bb4d" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fmt@1.0.8": { + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" + }, + "@std/fs@1.0.20": { + "integrity": "e953206aae48d46ee65e8783ded459f23bec7dd1f3879512911c35e5484ea187", + "dependencies": [ + "jsr:@std/path@^1.1.3" + ] + }, + "@std/html@1.0.5": { + "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" + }, + "@std/http@1.0.22": { + "integrity": "53f0bb70e23a2eec3e17c4240a85bb23d185b2e20635adb37ce0f03cc4ca012a", "dependencies": [ - "jsr:@std/encoding@^1.0.7" + "jsr:@std/cli", + "jsr:@std/encoding@^1.0.10", + "jsr:@std/fmt", + "jsr:@std/fs@^1.0.20", + "jsr:@std/html", + "jsr:@std/media-types@^1.1.0", + "jsr:@std/net", + "jsr:@std/path@^1.1.3", + "jsr:@std/streams" ] }, - "@std/internal@1.0.6": { - "integrity": "9533b128f230f73bd209408bb07a4b12f8d4255ab2a4d22a1fd6d87304aca9a4" + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, "@std/media-types@1.1.0": { "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" }, - "@std/path@1.0.9": { - "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e" + "@std/net@1.0.6": { + "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" }, - "@std/streams@1.0.9": { - "integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035", + "@std/path@1.1.3": { + "integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3", "dependencies": [ - "jsr:@std/bytes@^1.0.5" + "jsr:@std/internal" ] }, - "@std/testing@1.0.10": { - "integrity": "8997bd0b0df020b81bf5eae103c66622918adeff7e45e96291c92a29dbf82cc1", + "@std/streams@1.0.14": { + "integrity": "c0df6cdd73bd4bbcbe4baa89e323b88418c90ceb2d926f95aa99bdcdbfca2411", "dependencies": [ - "jsr:@std/assert@^1.0.12", - "jsr:@std/internal" + "jsr:@std/bytes@^1.0.6" + ] + }, + "@std/testing@1.0.16": { + "integrity": "a917ffdeb5924c9be436dc78bc32e511760e14d3a96e49c607fc5ecca86d0092", + "dependencies": [ + "jsr:@std/assert@^1.0.15", + "jsr:@std/data-structures", + "jsr:@std/fs@^1.0.19", + "jsr:@std/internal", + "jsr:@std/path@^1.1.2" ] } }, "npm": { - "hono@4.7.8": { - "integrity": "sha512-PCibtFdxa7/Ldud9yddl1G81GjYaeMYYTq4ywSaNsYbB1Lug4mwtOMJf2WXykL0pntYwmpRJeOI3NmoDgD+Jxw==" + "hono@4.10.7": { + "integrity": "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw==" }, "path-to-regexp@6.3.0": { "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" @@ -97,13 +144,13 @@ }, "workspace": { "dependencies": [ - "jsr:@oak/oak@17", - "jsr:@std/assert@1", - "jsr:@std/http@1", - "jsr:@std/path@1", - "jsr:@std/streams@1", - "jsr:@std/testing@1", - "npm:hono@4" + "jsr:@oak/oak@^17.2.0", + "jsr:@std/assert@^1.0.16", + "jsr:@std/http@^1.0.22", + "jsr:@std/path@^1.1.3", + "jsr:@std/streams@^1.0.14", + "jsr:@std/testing@^1.0.16", + "npm:hono@^4.10.7" ] } } diff --git a/examples/hono.test.ts b/examples/hono.test.ts index ce82c6f..8c77d60 100644 --- a/examples/hono.test.ts +++ b/examples/hono.test.ts @@ -44,7 +44,8 @@ describe("hono error handling", () => { assertEquals(res.headers.get("content-type"), "application/problem+json"); assertEquals(await res.json(), { status: 500, - title: "InternalServerError", + title: "Internal Server Error", + detail: "The server encountered an unexpected condition.", }); }); @@ -65,7 +66,7 @@ describe("hono error handling", () => { assertEquals(res.headers.get("content-type"), "application/problem+json"); assertEquals(await res.json(), { status: 400, - title: "BadRequestError", + title: "Bad Request", detail: "This is an example of an HttpError", type: "/errors/http-error", instance: "/errors/http-error/instance/123", diff --git a/examples/oak.test.ts b/examples/oak.test.ts index e25764f..70b490a 100644 --- a/examples/oak.test.ts +++ b/examples/oak.test.ts @@ -40,7 +40,8 @@ describe("oak error handling", () => { assertEquals(res.headers.get("content-type"), "application/problem+json"); assertEquals(await res.json(), { status: 500, - title: "InternalServerError", + title: "Internal Server Error", + detail: "The server encountered an unexpected condition.", }); }); @@ -50,7 +51,7 @@ describe("oak error handling", () => { assertEquals(res.headers.get("content-type"), "application/problem+json"); assertEquals(await res.json(), { status: 400, - title: "BadRequestError", + title: "Bad Request", detail: "This is an example of an error from oak", }); }); @@ -61,7 +62,7 @@ describe("oak error handling", () => { assertEquals(res.headers.get("content-type"), "application/problem+json"); assertEquals(await res.json(), { status: 400, - title: "BadRequestError", + title: "Bad Request", detail: "This is an example of an HttpError", type: "/errors/http-error", instance: "/errors/http-error/instance/123", diff --git a/mod.test.ts b/mod.test.ts index e54c036..dca7a26 100644 --- a/mod.test.ts +++ b/mod.test.ts @@ -1,4 +1,4 @@ -import { STATUS_CODE, type StatusCode } from "@std/http/status"; +import { STATUS_TEXT, type StatusCode } from "@std/http/status"; import { assert, assertEquals, @@ -17,12 +17,16 @@ const httpErrorTests = describe("HttpError"); it(httpErrorTests, "without args", () => { const error = new HttpError(); - assertEquals(error.toString(), "InternalServerError"); - assertEquals(error.name, "InternalServerError"); + assertEquals(error.toString(), "Internal Server Error"); + assertEquals(error.name, "Internal Server Error"); assertEquals(error.message, ""); assertEquals(error.status, 500); assertEquals(error.expose, false); assertEquals(error.cause, undefined); + assertEquals( + error.exposedMessage, + "The server encountered an unexpected condition.", + ); }); it(httpErrorTests, "with status", () => { @@ -37,8 +41,16 @@ it(httpErrorTests, "with status", () => { it(httpErrorTests, "with message", () => { function assertWithMessage(error: HttpError): void { - assertEquals(error.toString(), "InternalServerError: something went wrong"); + assertEquals( + error.toString(), + "Internal Server Error: something went wrong", + ); assertEquals(error.message, "something went wrong"); + // expose is false for 500 errors, so exposedMessage uses default + assertEquals( + error.exposedMessage, + "The server encountered an unexpected condition.", + ); } assertWithMessage(new HttpError("something went wrong")); assertWithMessage(new HttpError({ message: "something went wrong" })); @@ -52,7 +64,7 @@ it( httpErrorTests, "prefer status/message args over status/messagee options", () => { - const names = ["BadRequestError", "BadGatewayError"]; + const names = ["Bad Request", "Bad Gateway"]; const messages = ["something went wrong", "failed"]; const statuses = [400, 502]; const options = { message: messages[1], status: statuses[1] }; @@ -117,53 +129,11 @@ it(httpErrorTests, "invalid status", () => { ); }); -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 as StatusCode) - ? DEFAULT_ERROR_NAMES.get(status as StatusCode)! - : `Unknown${status < 500 ? "Client" : "Server"}Error`; + if (STATUS_TEXT[status as StatusCode]) { + return STATUS_TEXT[status as StatusCode]; + } + return status < 500 ? "Unknown Client Error" : "Unknown Server Error"; } function assertName( @@ -513,8 +483,9 @@ it(jsonTests, "HttpError.from(Error).toJSON()", () => { assertEquals( errorFromCause.toJSON(), { - title: "InternalServerError", + title: "Internal Server Error", status: 500, + detail: "The server encountered an unexpected condition.", }, ); }); @@ -560,6 +531,7 @@ it(jsonTests, "Non-exposed server error with extensions toJSON()", () => { ...data, title: "CustomError", status: 500, + detail: "The server encountered an unexpected condition.", }, ); }); @@ -586,11 +558,47 @@ it( ...data, title: "CustomError", status: 400, + detail: "The server cannot process the request due to a client error.", }, ); }, ); +it(jsonTests, "exposedMessage with expose=true uses message", () => { + const error = new HttpError(400, "Invalid email format"); + assertEquals(error.message, "Invalid email format"); + assertEquals(error.exposedMessage, "Invalid email format"); + assertEquals(error.toJSON().detail, "Invalid email format"); +}); + +it(jsonTests, "exposedMessage with expose=false uses default", () => { + const error = new HttpError(500, "SQL syntax error near 'DROP TABLE'"); + assertEquals( + error.exposedMessage, + "The server encountered an unexpected condition.", + ); + assertEquals( + error.toJSON().detail, + "The server encountered an unexpected condition.", + ); +}); + +it(jsonTests, "explicit exposedMessage takes priority", () => { + // Even with expose=true, explicit exposedMessage is used + const error1 = new HttpError(400, "Internal validation details", { + exposedMessage: "Please check your input.", + }); + assertEquals(error1.exposedMessage, "Please check your input."); + assertEquals(error1.toJSON().detail, "Please check your input."); + + // With expose=false, explicit exposedMessage is also used + const error2 = new HttpError(500, "Database connection refused", { + exposedMessage: "Service temporarily unavailable.", + }); + assertEquals(error2.exposedMessage, "Service temporarily unavailable."); + assertEquals(error2.toJSON().detail, "Service temporarily unavailable."); +}); + const getResponseTests = describe(httpErrorTests, "getResponse"); it( @@ -652,6 +660,7 @@ it( ...error2Extensions, status: 503, title: "CustomServerErr", + detail: "The server is currently unavailable.", }); }, ); @@ -669,7 +678,7 @@ it( const body3 = await response3.json(); assertEquals(body3, { status: 404, - title: "NotFoundError", + title: "Not Found", detail: "Not Found", }); }, @@ -680,12 +689,16 @@ const fromTests = describe(httpErrorTests, "from"); it(fromTests, "non HttpError", () => { const cause = new Error("fail"); const error = HttpError.from(cause); - assertEquals(error.toString(), "InternalServerError: fail"); - assertEquals(error.name, "InternalServerError"); + assertEquals(error.toString(), "Internal Server Error: fail"); + assertEquals(error.name, "Internal Server Error"); assertEquals(error.message, "fail"); assertEquals(error.status, 500); assertEquals(error.expose, false); assertEquals(error.cause, cause); + assertEquals( + error.exposedMessage, + "The server encountered an unexpected condition.", + ); }); it(fromTests, "Error with status", () => { @@ -876,12 +889,12 @@ it( assert(error instanceof BasicError, "Should be instance of BasicError"); assertEquals(error.status, 400); assertEquals(error.message, "test message"); - assertEquals(error.name, "BadRequestError"); + assertEquals(error.name, "Bad Request"); const error2 = new BasicError(); assertEquals(error2.status, 500); assertEquals(error2.message, ""); - assertEquals(error2.name, "InternalServerError"); + assertEquals(error2.name, "Internal Server Error"); }, ); @@ -1194,9 +1207,9 @@ it( assertEquals(secretJson, { status: 500, title: "SecretError", + detail: "The server encountered an unexpected condition.", ...secretError.extensions, }); - assertEquals(secretJson.detail, undefined); const secretResponse = secretError.getResponse(); const secretBody = await secretResponse.json(); diff --git a/mod.ts b/mod.ts index 53bd18d..25a9050 100644 --- a/mod.ts +++ b/mod.ts @@ -1,5 +1,63 @@ import { STATUS_CODE, STATUS_TEXT, type StatusCode } from "@std/http/status"; +/** + * Default user-friendly messages for HTTP error status codes. + * These are only used when expose is false and no exposedMessage was explicitly set. + */ +const DEFAULT_EXPOSED_MESSAGES: Partial> = { + // 4xx Client Errors + 400: "The server cannot process the request due to a client error.", + 401: "Authentication is required to access this resource.", + 402: "Payment is required to access this resource.", + 403: "You do not have permission to access this resource.", + 404: "The requested resource could not be found.", + 405: "The request method is not supported for this resource.", + 406: "The server cannot produce a response matching the acceptable values.", + 407: "Proxy authentication is required.", + 408: "The server timed out waiting for the request.", + 409: "The request conflicts with the current state of the resource.", + 410: "The requested resource is no longer available.", + 411: "The request requires a Content-Length header.", + 412: "A precondition in the request headers was not met.", + 413: "The request body is larger than the server is willing to process.", + 414: "The request URI is longer than the server is willing to process.", + 415: "The request uses a media type that is not supported.", + 416: "The requested range cannot be satisfied.", + 417: "The expectation in the Expect header cannot be met.", + 418: "The server refuses to brew coffee because it is a teapot.", + 421: "The request was directed at a server unable to produce a response.", + 422: "The request was well-formed but contained semantic errors.", + 423: "The requested resource is locked.", + 424: "The request failed due to a previous request failure.", + 425: "The server is unwilling to process a request that might be replayed.", + 426: "The client must upgrade to a different protocol.", + 428: "The request must be conditional.", + 429: "Too many requests have been sent in a given amount of time.", + 431: "The request headers are too large.", + 451: "The resource is unavailable for legal reasons.", + // 5xx Server Errors + 500: "The server encountered an unexpected condition.", + 501: "The server does not support the functionality required.", + 502: "The server received an invalid response from an upstream server.", + 503: "The server is currently unavailable.", + 504: "The server did not receive a timely response from an upstream server.", + 505: "The HTTP version used in the request is not supported.", + 506: "The server has an internal configuration error.", + 507: "The server has insufficient storage to complete the request.", + 508: "The server detected an infinite loop while processing the request.", + 510: "Further extensions to the request are required.", + 511: "Network authentication is required to access this resource.", +}; + +/** + * Gets the default exposed message for a given HTTP error status code. + * Falls back to generic client/server error messages for unknown status codes. + */ +function defaultExposedMessageForStatus(status: number): string { + return DEFAULT_EXPOSED_MESSAGES[status] ?? + (status < 500 ? "A client error occurred." : "A server error occurred."); +} + /** Options for initializing an HttpError. */ export interface HttpErrorOptions< Extensions extends object = Record, @@ -37,6 +95,14 @@ export interface HttpErrorOptions< * 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; + /** + * The message to expose in the response. This is used in toJSON() as the `detail` field. + * If not provided: + * - When expose is true and message exists, exposedMessage defaults to message + * - Otherwise, exposedMessage defaults to a generic message for the status code + * This allows you to have detailed internal error messages while showing safe, user-friendly messages to clients. + */ + exposedMessage?: string; } interface ProblemDetailsBase { @@ -137,17 +203,32 @@ function optionsFromArgs< return { ...init, status, message } as HttpErrorOptions; } +/** + * Gets the human-readable name for a given HTTP error status code. + * Returns names with spaces (e.g., "Bad Request", "Not Found"). + */ function errorNameForStatus(status: number): string { - let name: string; if (STATUS_TEXT[status as StatusCode]) { - name = status === STATUS_CODE.Teapot - ? "Teapot" - : STATUS_TEXT[status as StatusCode].replace(/\W/g, ""); - if (status !== STATUS_CODE.InternalServerError) name += "Error"; - } else { - name = `Unknown${status < 500 ? "Client" : "Server"}Error`; + return STATUS_TEXT[status as StatusCode]; } - return name; + return status < 500 ? "Unknown Client Error" : "Unknown Server Error"; +} + +/** + * Checks if a name matches the old error name format for a given status. + * The old format was "{StatusText}Error" (e.g., "BadRequestError" for 400). + * This is used to convert old-format names to the new human-readable format + * when processing errors from other frameworks. + */ +function matchesOldNameFormat( + name: string | undefined, + status: number, +): boolean { + if (!name) return false; + const statusText = STATUS_TEXT[status as StatusCode]; + if (!statusText) return false; + const oldFormat = statusText.replace(/\W/g, "") + "Error"; + return name === oldFormat; } /** @@ -313,6 +394,15 @@ export class HttpError< * The headers to send in the response. The content-type will default to application/problem+json unless otherwise specified in the headers. */ headers: Headers; + /** + * The message to expose in the response. Used as the `detail` field in toJSON(). + * - If explicitly set in options, that value is used + * - If expose is true and message exists, defaults to message + * - Otherwise, defaults to a generic message for the status code + * + * Use this instead of `message` when rendering errors to prevent leaking internal details. + */ + exposedMessage: string; constructor( status?: number, @@ -343,6 +433,7 @@ export class HttpError< instance, extensions, headers, + exposedMessage, } = init; const status = init.status ?? STATUS_CODE.InternalServerError; @@ -372,6 +463,14 @@ export class HttpError< if (!this.headers.has("content-type")) { this.headers.set("content-type", "application/problem+json"); } + // Compute exposedMessage with precedence: + // 1. Explicitly provided exposedMessage option + // 2. If expose=true and message exists, use message + // 3. Default message for status code + this.exposedMessage = exposedMessage ?? + (this.expose && this.message + ? this.message + : defaultExposedMessageForStatus(status)); } /** @@ -424,7 +523,7 @@ export class HttpError< return error; } else if (isHttpErrorLike(error)) { const { - name, + name: originalName, message, status, expose, @@ -433,7 +532,12 @@ export class HttpError< instance, extensions, headers, + exposedMessage, } = error as HttpError; + // Use new format if name matches old format, otherwise preserve custom name + const name = matchesOldNameFormat(originalName, status) + ? undefined + : originalName; const options = { name, message, @@ -443,6 +547,7 @@ export class HttpError< type, instance, extensions, + exposedMessage, headers, } as HttpErrorOptions; return new HttpError(options); @@ -510,10 +615,8 @@ export class HttpError< ...this.extensions, status: this.status, title: this.name, + detail: this.exposedMessage, }; - if (this.expose && this.message) { - json.detail = this.message; - } if (this.type) { json.type = this.type; } @@ -541,22 +644,38 @@ export class 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 a `status` property of type number. + * Checks if a value is HttpError-like (an Error with a numeric status property). + * + * This function returns true for any Error object that has a numeric `status` property, + * not just HttpError instances. This allows it to recognize HTTP errors from other + * libraries or custom error classes that follow the same pattern. + * + * If you need to verify that an error is specifically an HttpError instance + * (not just similar to one), use `error instanceof HttpError` instead. * * ```ts * import { HttpError, isHttpErrorLike } from "@udibo/http-error"; * * let error = new Error("file not found"); * console.log(isHttpErrorLike(error)); // false + * * error = new HttpError(404, "file not found"); * console.log(isHttpErrorLike(error)); // true + * console.log(error instanceof HttpError); // true + * + * // Custom error class with status property + * class CustomHttpError extends Error { + * status = 400; + * } + * const customError = new CustomHttpError("bad request"); + * console.log(isHttpErrorLike(customError)); // true + * console.log(customError instanceof HttpError); // false * ``` * * @param value - The value to check. - * @returns True if the value is an HttpError. + * @returns True if the value is an HttpError or an Error with a numeric status property. */ -function isHttpErrorLike< +export function isHttpErrorLike< Extensions extends object = Record, >(value: unknown): value is HttpError | Error { return typeof value === "object" && value !== null && @@ -705,6 +824,11 @@ export function createHttpErrorClass< ? constructorTimeOptions.statusText : dfo?.statusText; + finalOptions.exposedMessage = + constructorTimeOptions.exposedMessage !== undefined + ? constructorTimeOptions.exposedMessage + : dfo?.exposedMessage; + finalOptions.extensions = { ...(dfo?.extensions), ...(constructorTimeOptions.extensions), From c19bfa0b697f13f3f535806301e9aedbba40cabc Mon Sep 17 00:00:00 2001 From: Kyle June Date: Tue, 30 Dec 2025 22:50:48 -0500 Subject: [PATCH 2/2] Fix windows CI tests --- examples/hono.test.ts | 7 +------ examples/oak.test.ts | 3 +-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/examples/hono.test.ts b/examples/hono.test.ts index 8c77d60..589dcf7 100644 --- a/examples/hono.test.ts +++ b/examples/hono.test.ts @@ -22,12 +22,7 @@ describe("hono error handling", () => { .pipeThrough(new TextLineStream()); 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://${address}:8000/ (http://localhost:8000/)`, - ); + if (line.includes("Listening on") && line.includes(":8000")) { break; } } diff --git a/examples/oak.test.ts b/examples/oak.test.ts index 70b490a..c1f5160 100644 --- a/examples/oak.test.ts +++ b/examples/oak.test.ts @@ -22,8 +22,7 @@ describe("oak error handling", () => { .pipeThrough(new TextLineStream()); for await (const line of stdout.values({ preventCancel: true })) { - if (line.includes("Listening on")) { - assertEquals(line, "Listening on http://localhost:8000/"); + if (line.includes("Listening on") && line.includes(":8000")) { break; } }