UNPKG

rfc9457

Version:

RFC 9457 Problem Details for HTTP APIs - A standardized error handling package for Node.js

489 lines (367 loc) 15 kB
# RFC9457 TypeScript-first error handling package implementing [RFC 9457 Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457.html). > RFC 9457 obsoletes [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807.html) - this package implements the latest specification. ## Features - **RFC 9457 Compliant** - Strictly follows the Problem Details specification - **TypeScript First** - Full type safety with excellent IDE support - **Auto-normalization** - Accepts `unknown` errors and normalizes them automatically - **Categorized API** - Clean, readable error handling with `errors.client.*` and `errors.server.*` - **39 Standard HTTP Errors** - Complete coverage of all standard HTTP error codes - **Convenient Aliases** - Common shortcuts like `errors.server.db()` for frequent use cases - **Built-in Middleware** - Ready-to-use error handlers for popular frameworks (Hono, and more coming) - **Framework Agnostic** - Works with Express, Hono, Fastify, and any Node.js framework - **Zero Dependencies** - Lightweight with no external dependencies (middleware have optional peer dependencies) - **ESM Only** - Modern ES Modules for Node.js 22+ ## Installation ```bash npm install rfc9457 ``` ## Quick Start ```typescript import { errors } from "rfc9457"; // Client errors throw errors.client.authentication("Invalid token"); throw errors.client.notFound("User", "123"); throw errors.client.validation("Email is required"); // Server errors throw errors.server.internal("Database connection failed"); throw errors.server.db("Connection pool exhausted"); ``` ## Available Errors ### Client Errors (4xx) 28 client error types covering all standard 4xx HTTP status codes: | Method | Status | Description | Example | |--------|--------|-------------|---------| | `badRequest` | 400 | Malformed request | `errors.client.badRequest("Invalid JSON")` | | `authentication` | 401 | Missing/invalid credentials | `errors.client.authentication("Token expired")` | | `paymentRequired` | 402 | Payment required | `errors.client.paymentRequired("Subscription required")` | | `authorization` | 403 | Insufficient permissions | `errors.client.authorization("Admin access required")` | | `notFound` | 404 | Resource not found | `errors.client.notFound("User", "123")` | | `methodNotAllowed` | 405 | HTTP method not allowed | `errors.client.methodNotAllowed("POST not allowed")` | | `notAcceptable` | 406 | Not acceptable | `errors.client.notAcceptable("Only JSON supported")` | | `proxyAuthenticationRequired` | 407 | Proxy auth required | `errors.client.proxyAuthenticationRequired("Proxy auth needed")` | | `requestTimeout` | 408 | Request timeout | `errors.client.requestTimeout("Request took too long")` | | `conflict` | 409 | Resource conflict | `errors.client.conflict("Email already exists")` | | `gone` | 410 | Resource permanently deleted | `errors.client.gone("Resource permanently deleted")` | | `lengthRequired` | 411 | Length header required | `errors.client.lengthRequired("Content-Length required")` | | `preconditionFailed` | 412 | Precondition failed | `errors.client.preconditionFailed("ETag mismatch")` | | `payloadTooLarge` | 413 | Payload too large | `errors.client.payloadTooLarge("File too large", 5000000)` | | `uriTooLong` | 414 | URI too long | `errors.client.uriTooLong("URL exceeds maximum length")` | | `unsupportedMediaType` | 415 | Unsupported media type | `errors.client.unsupportedMediaType("Only image/* allowed")` | | `rangeNotSatisfiable` | 416 | Range not satisfiable | `errors.client.rangeNotSatisfiable("Invalid byte range")` | | `expectationFailed` | 417 | Expectation failed | `errors.client.expectationFailed("Expect header failed")` | | `misdirectedRequest` | 421 | Misdirected request | `errors.client.misdirectedRequest("Wrong server")` | | `validation` | 422 | Invalid input data | `errors.client.validation("Invalid email", { email: ["Invalid format"] })` | | `locked` | 423 | Resource locked | `errors.client.locked("Resource is locked")` | | `failedDependency` | 424 | Failed dependency | `errors.client.failedDependency("Dependency failed")` | | `tooEarly` | 425 | Too early | `errors.client.tooEarly("Request too early")` | | `upgradeRequired` | 426 | Upgrade required | `errors.client.upgradeRequired("Upgrade to TLS required")` | | `preconditionRequired` | 428 | Precondition required | `errors.client.preconditionRequired("If-Match header required")` | | `rateLimit` | 429 | Too many requests | `errors.client.rateLimit("Rate limit exceeded", 60)` | | `requestHeaderFieldsTooLarge` | 431 | Headers too large | `errors.client.requestHeaderFieldsTooLarge("Request headers too large")` | | `unavailableForLegalReasons` | 451 | Legal restriction | `errors.client.unavailableForLegalReasons("Blocked by legal order")` | ### Server Errors (5xx) 11 server error types plus convenient aliases: | Method | Status | Description | Example | |--------|--------|-------------|---------| | `internal` | 500 | Internal server error | `errors.server.internal(caughtError)` | | `notImplemented` | 501 | Feature not implemented | `errors.server.notImplemented("Feature not available")` | | `badGateway` | 502 | External service error | `errors.server.badGateway(stripeError, "Stripe")` | | `serviceUnavailable` | 503 | Service temporarily unavailable | `errors.server.serviceUnavailable("Maintenance mode", 60)` | | `gatewayTimeout` | 504 | External service timeout | `errors.server.gatewayTimeout("Payment timeout", "Stripe")` | | `httpVersionNotSupported` | 505 | HTTP version not supported | `errors.server.httpVersionNotSupported("HTTP/2 required")` | | `variantAlsoNegotiates` | 506 | Variant also negotiates | `errors.server.variantAlsoNegotiates("Configuration error")` | | `insufficientStorage` | 507 | Insufficient storage | `errors.server.insufficientStorage("Out of storage space")` | | `loopDetected` | 508 | Loop detected | `errors.server.loopDetected("Circular dependency detected")` | | `notExtended` | 510 | Not extended | `errors.server.notExtended("Extension not supported")` | | `networkAuthenticationRequired` | 511 | Network authentication required | `errors.server.networkAuthenticationRequired("Proxy auth required")` | **Convenient Aliases:** Common shortcuts for frequent use cases: | Alias | Maps To | Status | Example | |-------|---------|--------|---------| | **Client Aliases** | | | | | `validate` | `validation` | 422 | `errors.client.validate("Invalid email format")` | | `permission` | `authorization` | 403 | `errors.client.permission("Access denied")` | | `access` | `authorization` | 403 | `errors.client.access("Insufficient permissions")` | | `idNotFound` | `notFound` | 404 | `errors.client.idNotFound("User", "123")` | | `duplicate` | `conflict` | 409 | `errors.client.duplicate("Email already exists")` | | `thirdParty` | `failedDependency` | 424 | `errors.client.thirdParty("External service failed")` | | **Server Aliases** | | | | | `db` | `serviceUnavailable` | 503 | `errors.server.db("Connection pool exhausted")` | | `fetch` | `badGateway` | 502 | `errors.server.fetch("GitHub API unreachable")` | | `envNotSet` | `notImplemented` | 501 | `errors.server.envNotSet("DATABASE_URL not configured")` | | `envInvalid` | `notImplemented` | 501 | `errors.server.envInvalid("DATABASE_URL must be a valid URL")` | | `maintenance` | `serviceUnavailable` | 503 | `errors.server.maintenance("System under maintenance")` | | `migration` | `insufficientStorage` | 507 | `errors.server.migration("Migration storage limit exceeded")` | | `unhandledRejection` | `internal` | 500 | `errors.server.unhandledRejection("Unhandled promise rejection")` | | `uncaughtException` | `internal` | 500 | `errors.server.uncaughtException("Uncaught exception")` | ## Usage Examples ### Basic Error Throwing ```typescript import { errors } from "rfc9457"; if (!user) { throw errors.client.notFound("User", userId); } if (!hasPermission) { throw errors.client.authorization("Admin access required"); } ``` ### Auto-Normalization The package automatically normalizes any value to a string: ```typescript import { errors } from "rfc9457"; try { await externalAPI.call(); } catch (err) { throw errors.server.badGateway(err, "External API"); } ``` ### Validation Errors ```typescript import { errors } from "rfc9457"; const validationErrors = { email: ["Invalid email format", "Email already exists"], password: ["Password too weak"], }; throw errors.client.validation("Validation failed", validationErrors); ``` ### NotFound with Auto-formatting ```typescript import { errors } from "rfc9457"; // Auto-formatted message: "User 123 not found" throw errors.client.notFound("User", "123"); // Custom message throw errors.client.notFound("Custom message: User not found in database"); ``` ### Using Convenient Aliases ```typescript import { errors } from "rfc9457"; // Validation errors if (!email.includes("@")) { throw errors.client.validate("Invalid email format"); } // Permission checks if (!user.isAdmin) { throw errors.client.permission("Admin access required"); } // ID lookups const user = await db.findUser(userId); if (!user) { throw errors.client.idNotFound("User", userId); } // Duplicate entries if (await db.emailExists(email)) { throw errors.client.duplicate("User with this email already exists"); } // Database errors try { await db.query("SELECT * FROM users"); } catch (err) { throw errors.server.db(err); } // External API failures try { await fetch("https://api.github.com/users/octocat"); } catch (err) { throw errors.server.fetch(err); } // Third-party integrations try { await stripe.customers.create({ email }); } catch (err) { throw errors.client.thirdParty(err); } // Environment configuration - missing if (!process.env.DATABASE_URL) { throw errors.server.envNotSet("DATABASE_URL environment variable not set"); } // Environment configuration - invalid value if (process.env.NODE_ENV && !["development", "production", "test"].includes(process.env.NODE_ENV)) { throw errors.server.envInvalid("NODE_ENV must be development, production, or test"); } // Maintenance mode if (isMaintenanceMode) { throw errors.server.maintenance("System is under scheduled maintenance", 3600); } // Migration failures try { await runMigration(); } catch (err) { throw errors.server.migration(err); } // Node.js process error handlers process.on('unhandledRejection', (reason) => { console.error(errors.server.unhandledRejection(reason)); process.exit(1); }); process.on('uncaughtException', (error) => { console.error(errors.server.uncaughtException(error)); process.exit(1); }); ``` ### Additional Utilities ```typescript import { errors, isValidRFC9457Json } from "rfc9457"; // Create error by status code throw errors.byStatus(404, "Not found"); // Get JSON without throwing const json = errors.client.badRequest("Invalid input").toJSON(); // Validate RFC 9457 response if (isValidRFC9457Json(data)) { // Valid RFC 9457 error } ``` ## Framework Integration ### Express ```typescript import express from "express"; import { errors, isHttpError } from "rfc9457"; const app = express(); app.get("/users/:id", async (req, res) => { const user = await db.users.findById(req.params.id); if (!user) { throw errors.client.notFound("User", req.params.id); } res.json(user); }); app.use((err, req, res, next) => { if (isHttpError(err)) { return res.status(err.status).json(err.toJSON()); } const internalError = errors.server.internal(err); res.status(500).json(internalError.toJSON()); }); ``` ### Hono **Option 1: Using the built-in middleware (recommended)** ```typescript import { Hono } from "hono"; import { errors, honoErrorMiddleware } from "rfc9457"; const app = new Hono(); app.get("/users/:id", async (c) => { const user = await db.users.findById(c.req.param("id")); if (!user) { throw errors.client.notFound("User", c.req.param("id")); } return c.json(user); }); app.onError(honoErrorMiddleware); ``` **Option 2: Manual error handling** ```typescript import { Hono } from "hono"; import { errors, isHttpError } from "rfc9457"; const app = new Hono(); app.get("/users/:id", async (c) => { const user = await db.users.findById(c.req.param("id")); if (!user) { throw errors.client.notFound("User", c.req.param("id")); } return c.json(user); }); app.onError((err, c) => { if (isHttpError(err)) { return c.json(err.toJSON(), err.status); } const internalError = errors.server.internal(err); return c.json(internalError.toJSON(), 500); }); ``` **Process Error Handlers** ```typescript import { errors } from "rfc9457"; process.on('unhandledRejection', (reason) => { console.error(errors.server.unhandledRejection(reason)); process.exit(1); }); process.on('uncaughtException', (error) => { console.error(errors.server.uncaughtException(error)); process.exit(1); }); ``` ### Fastify ```typescript import Fastify from "fastify"; import { errors, isHttpError } from "rfc9457"; const fastify = Fastify(); fastify.get("/users/:id", async (request, reply) => { const user = await db.users.findById(request.params.id); if (!user) { throw errors.client.notFound("User", request.params.id); } return user; }); fastify.setErrorHandler((error, request, reply) => { if (isHttpError(error)) { return reply.status(error.status).send(error.toJSON()); } const internalError = errors.server.internal(error); reply.status(500).send(internalError.toJSON()); }); ``` ## Configuration Set the base URL for error type URIs using the environment variable: ```bash export RFC9457_BASE_URL=https://api.example.com/errors ``` ## Error Response Format All errors follow RFC 9457 structure: ```json { "type": "about:blank#not-found", "title": "Not Found", "status": 404, "detail": "User 123 not found" } ``` With custom base URL: ```json { "type": "https://api.example.com/errors/validation", "title": "Validation Error", "status": 422, "detail": "Validation failed", "validationErrors": { "email": ["Invalid email format"] } } ``` ## TypeScript Support Full type safety and IDE autocomplete: ```typescript import { errors, isHttpError } from "rfc9457"; const err = errors.client.validation("Invalid data"); if (isHttpError(err)) { console.log(err.status); // 422 console.log(err.toJSON()); // { type: "...", title: "...", ... } } ``` ## API Reference ### Categorized Errors (Recommended) ```typescript import { errors } from "rfc9457"; throw errors.client.badRequest("Invalid input"); throw errors.server.internal("System error"); ``` ### Flat API (Alternative) ```typescript import { error } from "rfc9457"; throw error.badRequest("Invalid input"); throw error.internal("System error"); ``` ### Helpers ```typescript import { isHttpError } from "rfc9457"; if (isHttpError(err)) { console.log(err.status); console.log(err.toJSON()); } ``` ## Development ```bash npm install npm run build npm run lint ``` ## License MIT