diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0e92d..ce6506c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,26 +5,48 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). -## [1.0.0] - 2024-10-29 -### Added -- Initial release of the package. -- Implemented centralized error handling using higher-order functions. -- Added custom error classes: `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `InternalServerError`, and `MethodNotAllowedError`. -- Enabled JSON serialization for frontend-compatible error responses. -- Added support for logging services like Sentry. +## [1.0.17] - 2024-11-01 -## [1.0.1] - 2024-10-29 ### Added -- Option to customize error responses using `formatError`. -- Improved error handling for unsupported HTTP methods. -- Expanded documentation in the README. -## [1.0.9] - 2024-10-30 +- **Enhanced Test Suite:** + - Expanded the test suite with additional test cases covering more scenarios for both **API Routes** and **App Router** contexts. + - Introduced new custom error classes (e.g., `NotFoundError`) to handle a broader range of HTTP errors. + +- **Documentation Improvements:** + - Enhanced code comments and documentation across the codebase for better clarity and maintainability. + ### Fixed -- Enhanced the `errorHandler` function to support both API Routes and App Router in Next.js, allowing for flexible error handling across different route types. -- Adjusted error response handling to ensure appropriate responses are returned based on the presence of `res` in the request. + +- **Application Stability:** + - Resolved a critical issue where the application would crash if the `logger` function was undefined. Implemented proper validation and fallback mechanisms in the `errorHandler` to ensure graceful error handling even when optional configurations are missing or incorrect. + +### Changed + +- **Error Handling Logic:** + - Improved the `errorHandler` implementation to better differentiate between **API Routes** and **App Router** contexts, ensuring appropriate response structures and status codes. + - Updated the `errorHandler` to safely invoke custom logging and formatting functions, preventing potential crashes from malformed user-provided functions. ## [1.0.10] - 2024-10-30 ### Fixed - Corrected log message for errors in API Route handling to reflect 'API Route Error:' instead of 'Route Error:' in the logger. -- Ensured consistent error handling for both API Routes and App Router by refining the errorHandler implementation. \ No newline at end of file +- Ensured consistent error handling for both API Routes and App Router by refining the errorHandler implementation. + +## [1.0.9] - 2024-10-30 +### Fixed +- Enhanced the `errorHandler` function to support both API Routes and App Router in Next.js, allowing for flexible error handling across different route types. +- Adjusted error response handling to ensure appropriate responses are returned based on the presence of `res` in the request. + +## [1.0.1] - 2024-10-29 +### Added +- Option to customize error responses using `formatError`. +- Improved error handling for unsupported HTTP methods. +- Expanded documentation in the README. + +## [1.0.0] - 2024-10-29 +### Added +- Initial release of the package. +- Implemented centralized error handling using higher-order functions. +- Added custom error classes: `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `InternalServerError`, and `MethodNotAllowedError`. +- Enabled JSON serialization for frontend-compatible error responses. +- Added support for logging services like Sentry. \ No newline at end of file diff --git a/README.md b/README.md index 5fb10c3..299e84e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Inspired by my experiences with the Yii2 framework—where built-in error classes allow developers to manage error handling efficiently without hardcoding status codes or messages—I saw a need for similar functionality in the Node.js ecosystem. This led to the development of custom error classes in this package, enhancing consistency and usability. -**Note:** This package is currently in beta. As this package is newly released, your feedback is crucial for identifying any potential issues and improving its stability. I encourage you to try out the `nextjs-centralized-error-handler` package and share your experiences, whether through bug reports or suggestions for improvement. Together, we can enhance this package for the Next.js community. +> **_Important Note:_** This package is currently in beta. As this package is newly released, your feedback is crucial for identifying any potential issues and improving its stability. I encourage you to try out the `nextjs-centralized-error-handler` package and share your experiences, whether through bug reports or suggestions for improvement. Together, we can enhance this package for the Next.js community. ## Table of Contents @@ -33,6 +33,7 @@ Inspired by my experiences with the Yii2 framework—where built-in error classe - [Example Usage with Default Messages](#example-usage-with-default-messages) - [Creating Custom Errors](#creating-custom-errors) - [Using `nextjs-centralized-error-handler` with App Router](#using-nextjs-centralized-error-handler-with-app-router) +- [Testing](#testing) - [Customizing Error Handling Behavior](#customizing-error-handling-behavior) - [Error Handler Options](#error-handler-options) - [Customizing Error Responses](#customizing-error-responses) @@ -528,6 +529,48 @@ Using the App Router allows for a clean and structured way to manage errors whil --- +## Testing + +Ensuring the reliability and security of the `errorHandler` is paramount. A comprehensive test suite has been implemented to cover various scenarios, guaranteeing that the error handler functions as intended across different contexts and use cases. + +### Testing Strategy + +- **Isolation**: Each test case is designed to be independent, ensuring that the outcome of one test does not affect others. +- **Coverage**: Tests cover both **API Routes** and **App Router** contexts, handling both custom-defined errors and unexpected runtime errors. +- **Mocking**: Utilizes Jest's mocking capabilities to simulate different environments and behaviors, such as custom loggers and formatters. +- **Edge Cases**: Includes tests for scenarios like throwing non-error objects and invalid `formatError` functions to ensure robustness. +- **Security Assurance**: Verifies that sensitive information is not leaked through error responses. + +### Test Cases + +| **Test Case** | **Description** | **Context** | +|---------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|---------------------------| +| **Handle Custom Errors Correctly (API Routes)** | Verifies that custom errors (e.g., `BadRequestError`) are handled with the correct status code and error response structure. | API Routes | +| **Handle Unexpected Errors with Default Status (API Routes)** | Ensures that unexpected errors default to a `500` status code and a generic error message, preventing information leakage. | API Routes | +| **Use Custom `formatError` Function (API Routes)** | Checks that the `formatError` function customizes the error response as expected in API Routes. | API Routes | +| **Use Custom Logger Function (API Routes)** | Confirms that a custom logger function is invoked when an error occurs in API Routes. | API Routes | +| **Handle Custom Errors Correctly (App Router)** | Verifies that custom errors are handled with the correct status code and error response structure in the App Router. | App Router | +| **Handle Unexpected Errors with Default Status (App Router)** | Ensures that unexpected errors default to a `500` status code and a generic error message in the App Router. | App Router | +| **Use Custom `formatError` Function (App Router)** | Checks that the `formatError` function customizes the error response as expected in the App Router. | App Router | +| **Return 204 No Content if Handler Returns Undefined (App Router)** | Ensures that if the handler does not return a response, a `204 No Content` response is sent by default in the App Router. | App Router | +| **Handle Different Custom Errors Correctly** | Tests handling of various custom errors (e.g., `NotFoundError`) with their respective status codes and messages. | API Routes | +| **Use Custom Logger Function (App Router)** | Confirms that a custom logger function is invoked when an error occurs in the App Router. | App Router | +| **Set Content-Type to application/json (App Router)** | Verifies that the `Content-Type` header is correctly set to `application/json` for JSON responses in the App Router. | App Router | +| **Handle Non-Error Objects Gracefully** | Ensures that throwing non-Error objects does not crash the application and defaults to a `500` status with a generic message.| API Routes | +| **Ignore Additional Properties in Errors** | Confirms that additional properties in error objects do not affect the error response structure. | API Routes | +| **Fallback to Default Error Response if `formatError` is Invalid** | Ensures that if `formatError` is not a function or throws an error, the handler defaults to the standard error response. | API Routes & App Router | + +### Running Tests + +To execute the test suite, ensure you have all dependencies installed and run the following command: + +```bash +npm test +``` +This command will run all tests using Jest, providing a summary of passed and failed test cases along with coverage information. + +--- + ## Customizing Error Handling Behavior Beyond custom errors, this package allows developers to fully control the behavior of error handling by: @@ -804,9 +847,9 @@ This setup captures errors for monitoring, while safeguarding against exposing s --- ## Community Feedback and Stability -As this package is newly released, I am aware of the importance of stability in production environments. While I have conducted testing, the real-world usage and feedback from the community are crucial for identifying any potential issues and improving the package further. +As this package is newly released, I recognize the importance of stability in production environments. While I have conducted testing, real-world usage and feedback from the community are crucial for identifying any potential issues and further improving the package. -I encourage developers to integrate `nextjs-centralized-error-handler` into their projects and share their experiences. Whether it’s bug reports, suggestions for improvement, or simply sharing how it has helped streamline error management in your applications, your feedback is invaluable. Together, we can enhance this package and make it even more effective for the Next.js community. +I encourage developers to try out the nextjs-centralized-error-handler package and share their experiences. Whether it’s bug reports, improvement suggestions, or simply sharing how it has helped streamline error management in your applications, your feedback is invaluable. Together, we can enhance this package and make it even more effective for the Next.js community. --- diff --git a/package.json b/package.json index dbddaf5..88cb47f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nextjs-centralized-error-handler", - "version": "1.0.16-beta.1", + "version": "1.0.17", "main": "src/index.js", "scripts": { "test": "jest", diff --git a/src/customErrors.js b/src/customErrors.js index b26afe0..ad04055 100644 --- a/src/customErrors.js +++ b/src/customErrors.js @@ -1,6 +1,19 @@ // src/customErrors.js +/** + * Base class for all custom errors. + * Extends the built-in Error class to include additional properties such as statusCode. + * + * @extends Error + */ class CustomError extends Error { + /** + * Creates an instance of CustomError. + * + * @param {string} [message='An error occurred.'] - The error message. + * @param {number} [statusCode=500] - The HTTP status code associated with the error. + * @param {string} [name='CustomError'] - The name of the error. + */ constructor( message = 'An error occurred.', statusCode = 500, @@ -13,9 +26,19 @@ class CustomError extends Error { } } -// Define predefined error classes - +/** + * Represents a Bad Request error (HTTP 400). + * Indicates that the server cannot or will not process the request due to a client error. + * + * @extends CustomError + */ class BadRequestError extends CustomError { + /** + * Creates an instance of BadRequestError. + * + * @param {string} [message='It seems there was an error with your request. Please check the data you entered and try again.'] + * - The error message. + */ constructor( message = 'It seems there was an error with your request. Please check the data you entered and try again.', ) { @@ -23,31 +46,86 @@ class BadRequestError extends CustomError { } } +/** + * Represents an Unauthorized error (HTTP 401). + * Indicates that the request requires user authentication. + * + * @extends CustomError + */ class UnauthorizedError extends CustomError { + /** + * Creates an instance of UnauthorizedError. + * + * @param {string} [message='Unauthorized access. Please log in again.'] - The error message. + */ constructor(message = 'Unauthorized access. Please log in again.') { super(message, 401, 'UnauthorizedError'); } } +/** + * Represents a Payment Required error (HTTP 402). + * Reserved for future use. Currently indicates that payment is required to access the resource. + * + * @extends CustomError + */ class PaymentRequiredError extends CustomError { + /** + * Creates an instance of PaymentRequiredError. + * + * @param {string} [message='Payment is required to access this resource.'] - The error message. + */ constructor(message = 'Payment is required to access this resource.') { super(message, 402, 'PaymentRequiredError'); } } +/** + * Represents a Forbidden error (HTTP 403). + * Indicates that the server understood the request but refuses to authorize it. + * + * @extends CustomError + */ class ForbiddenError extends CustomError { + /** + * Creates an instance of ForbiddenError. + * + * @param {string} [message='Access denied.'] - The error message. + */ constructor(message = 'Access denied.') { super(message, 403, 'ForbiddenError'); } } +/** + * Represents a Not Found error (HTTP 404). + * Indicates that the server can't find the requested resource. + * + * @extends CustomError + */ class NotFoundError extends CustomError { + /** + * Creates an instance of NotFoundError. + * + * @param {string} [message='The requested resource was not found.'] - The error message. + */ constructor(message = 'The requested resource was not found.') { super(message, 404, 'NotFoundError'); } } +/** + * Represents a Method Not Allowed error (HTTP 405). + * Indicates that the HTTP method used is not allowed for the requested resource. + * + * @extends CustomError + */ class MethodNotAllowedError extends CustomError { + /** + * Creates an instance of MethodNotAllowedError. + * + * @param {string} [message='The HTTP method used is not allowed for this resource.'] - The error message. + */ constructor( message = 'The HTTP method used is not allowed for this resource.', ) { @@ -55,7 +133,20 @@ class MethodNotAllowedError extends CustomError { } } +/** + * Represents a Not Acceptable error (HTTP 406). + * Indicates that the requested resource is not available in a format acceptable to the client. + * + * @extends CustomError + */ class NotAcceptableError extends CustomError { + /** + * Creates an instance of NotAcceptableError. + * + * @param {string} [ + * message='The requested resource is not available in a format acceptable to your browser.' + * ] - The error message. + */ constructor( message = 'The requested resource is not available in a format acceptable to your browser.', ) { @@ -63,13 +154,35 @@ class NotAcceptableError extends CustomError { } } +/** + * Represents a Request Timeout error (HTTP 408). + * Indicates that the server timed out waiting for the request. + * + * @extends CustomError + */ class RequestTimeoutError extends CustomError { + /** + * Creates an instance of RequestTimeoutError. + * + * @param {string} [message='The server timed out waiting for your request.'] - The error message. + */ constructor(message = 'The server timed out waiting for your request.') { super(message, 408, 'RequestTimeoutError'); } } +/** + * Represents a Conflict error (HTTP 409). + * Indicates that a conflict occurred with the current state of the target resource. + * + * @extends CustomError + */ class ConflictError extends CustomError { + /** + * Creates an instance of ConflictError. + * + * @param {string} [message='A conflict occurred with the current state of the resource.'] - The error message. + */ constructor( message = 'A conflict occurred with the current state of the resource.', ) { @@ -77,13 +190,37 @@ class ConflictError extends CustomError { } } +/** + * Represents a Payload Too Large error (HTTP 413). + * Indicates that the request entity is larger than limits defined by server. + * + * @extends CustomError + */ class PayloadTooLargeError extends CustomError { + /** + * Creates an instance of PayloadTooLargeError. + * + * @param {string} [message='The request payload is too large.'] - The error message. + */ constructor(message = 'The request payload is too large.') { super(message, 413, 'PayloadTooLargeError'); } } +/** + * Represents a Too Many Requests error (HTTP 429). + * Indicates that the user has sent too many requests in a given amount of time. + * + * @extends CustomError + */ class TooManyRequestsError extends CustomError { + /** + * Creates an instance of TooManyRequestsError. + * + * @param {string} [ + * message='You have made too many requests in a short period of time.' + * ] - The error message. + */ constructor( message = 'You have made too many requests in a short period of time.', ) { @@ -91,7 +228,20 @@ class TooManyRequestsError extends CustomError { } } +/** + * Represents an Internal Server Error (HTTP 500). + * Indicates that the server encountered an unexpected condition. + * + * @extends CustomError + */ class InternalServerError extends CustomError { + /** + * Creates an instance of InternalServerError. + * + * @param {string} [ + * message='An internal server error occurred. Please try again later.' + * ] - The error message. + */ constructor( message = 'An internal server error occurred. Please try again later.', ) { @@ -99,13 +249,37 @@ class InternalServerError extends CustomError { } } +/** + * Represents a Not Implemented error (HTTP 501). + * Indicates that the server does not support the functionality required to fulfill the request. + * + * @extends CustomError + */ class NotImplementedError extends CustomError { + /** + * Creates an instance of NotImplementedError. + * + * @param {string} [message='This functionality has not been implemented.'] - The error message. + */ constructor(message = 'This functionality has not been implemented.') { super(message, 501, 'NotImplementedError'); } } +/** + * Represents a Bad Gateway error (HTTP 502). + * Indicates that the server received an invalid response from the upstream server. + * + * @extends CustomError + */ class BadGatewayError extends CustomError { + /** + * Creates an instance of BadGatewayError. + * + * @param {string} [ + * message='Received an invalid response from the upstream server.' + * ] - The error message. + */ constructor( message = 'Received an invalid response from the upstream server.', ) { @@ -113,13 +287,37 @@ class BadGatewayError extends CustomError { } } +/** + * Represents a Service Unavailable error (HTTP 503). + * Indicates that the server is currently unable to handle the request due to temporary overloading or maintenance. + * + * @extends CustomError + */ class ServiceUnavailableError extends CustomError { + /** + * Creates an instance of ServiceUnavailableError. + * + * @param {string} [message='The service is currently unavailable.'] - The error message. + */ constructor(message = 'The service is currently unavailable.') { super(message, 503, 'ServiceUnavailableError'); } } +/** + * Represents a Gateway Timeout error (HTTP 504). + * Indicates that the server, while acting as a gateway or proxy, did not receive a timely response from the upstream server. + * + * @extends CustomError + */ class GatewayTimeoutError extends CustomError { + /** + * Creates an instance of GatewayTimeoutError. + * + * @param {string} [ + * message='The upstream server failed to send a request in time.' + * ] - The error message. + */ constructor( message = 'The upstream server failed to send a request in time.', ) { @@ -127,7 +325,20 @@ class GatewayTimeoutError extends CustomError { } } +/** + * Represents an HTTP Version Not Supported error (HTTP 505). + * Indicates that the server does not support the HTTP protocol version used in the request. + * + * @extends CustomError + */ class HTTPVersionNotSupportedError extends CustomError { + /** + * Creates an instance of HTTPVersionNotSupportedError. + * + * @param {string} [ + * message='The server does not support the HTTP protocol version used in the request.' + * ] - The error message. + */ constructor( message = 'The server does not support the HTTP protocol version used in the request.', ) { @@ -135,13 +346,37 @@ class HTTPVersionNotSupportedError extends CustomError { } } +/** + * Represents a Variant Also Negotiates error (HTTP 506). + * Indicates that the server has an internal configuration error: the chosen variant resource is configured to engage in content negotiation itself, and is therefore not a proper end point in the negotiation process. + * + * @extends CustomError + */ class VariantAlsoNegotiatesError extends CustomError { + /** + * Creates an instance of VariantAlsoNegotiatesError. + * + * @param {string} [message='Variant Also Negotiates.'] - The error message. + */ constructor(message = 'Variant Also Negotiates.') { super(message, 506, 'VariantAlsoNegotiatesError'); } } +/** + * Represents an Insufficient Storage error (HTTP 507). + * Indicates that the server is unable to store the representation needed to complete the request. + * + * @extends CustomError + */ class InsufficientStorageError extends CustomError { + /** + * Creates an instance of InsufficientStorageError. + * + * @param {string} [ + * message='The server is unable to store the representation needed to complete the request.' + * ] - The error message. + */ constructor( message = 'The server is unable to store the representation needed to complete the request.', ) { @@ -149,13 +384,37 @@ class InsufficientStorageError extends CustomError { } } +/** + * Represents a Bandwidth Limit Exceeded error (HTTP 509). + * Indicates that the bandwidth limit has been exceeded. + * + * @extends CustomError + */ class BandwidthLimitExceededError extends CustomError { + /** + * Creates an instance of BandwidthLimitExceededError. + * + * @param {string} [message='Bandwidth limit exceeded.'] - The error message. + */ constructor(message = 'Bandwidth limit exceeded.') { super(message, 509, 'BandwidthLimitExceededError'); } } +/** + * Represents a Network Authentication Required error (HTTP 511). + * Indicates that network authentication is required to access the resource. + * + * @extends CustomError + */ class NetworkAuthenticationRequiredError extends CustomError { + /** + * Creates an instance of NetworkAuthenticationRequiredError. + * + * @param {string} [ + * message='Network authentication is required to access this resource.' + * ] - The error message. + */ constructor( message = 'Network authentication is required to access this resource.', ) { @@ -163,7 +422,7 @@ class NetworkAuthenticationRequiredError extends CustomError { } } -// Export all error classes +// Export all error classes for external use module.exports = { CustomError, BadRequestError, diff --git a/src/errorHandler.js b/src/errorHandler.js index e555609..5b0dcea 100644 --- a/src/errorHandler.js +++ b/src/errorHandler.js @@ -2,14 +2,72 @@ const { CustomError } = require('./customErrors'); +/** + * A higher-order function that wraps Next.js API route handlers or App Router handlers + * to provide centralized error handling. It catches all exceptions thrown within the + * handler, logs them, and sends a standardized error response to the client. + * + * @param {Function} handler - The original route handler function to be wrapped. + * For API Routes, it should accept (req, res). + * For App Router, it should accept (req) and return a Response. + * @param {Object} [options={}] - Optional configuration object to customize error handling. + * @param {Function} [options.logger=console.error] - A logging function to log errors. + * Defaults to console.error. + * @param {number} [options.defaultStatusCode=500] - The default HTTP status code for unhandled errors. + * @param {string} [options.defaultMessage='An internal server error occurred. Please try again later.'] + * - The default error message for unhandled errors. + * @param {Function} [options.formatError=null] - A function to customize the error response structure. + * It receives (error, req) and should return an object. + * + * @returns {Function} A wrapped handler function compatible with Next.js API Routes or App Router. + * + * @example + * + * // For API Routes + * const { errorHandler, BadRequestError } = require('nextjs-centralized-error-handler'); + * + * const handler = async (req, res) => { + * if (!req.body.name) { + * throw new BadRequestError('Name is required.'); + * } + * // Other logic + * res.status(200).json({ success: true }); + * }; + * + * export default errorHandler(handler); + * + * @example + * + * // For App Router + * const { errorHandler, InternalServerError } = require('nextjs-centralized-error-handler'); + * + * const handler = async (req) => { + * const data = await fetchData(); // May throw an error + * return new Response(JSON.stringify(data), { + * status: 200, + * headers: { 'Content-Type': 'application/json' }, + * }); + * }; + * + * export default errorHandler(handler); + */ function errorHandler(handler, options = {}) { const { - logger = console.error, - defaultStatusCode = 500, - defaultMessage = 'An internal server error occurred. Please try again later.', - formatError = null, + logger = console.error, // Default logger + defaultStatusCode = 500, // Default status code for unhandled errors + defaultMessage = 'An internal server error occurred. Please try again later.', // Default message for unhandled errors + formatError = null, // Function to customize error response } = options; + /** + * The wrapped handler function that includes error handling logic. + * + * @param {Object} req - The incoming request object. + * @param {Object} [res] - The response object (present for API Routes). + * + * @returns {Promise} - For API Routes, it sends a JSON response. + * For App Router, it returns a Response object. + */ return async (req, res) => { try { // Determine if it's an API Route or App Router based on the presence of 'res' @@ -24,20 +82,41 @@ function errorHandler(handler, options = {}) { return response || new Response(null, { status: 204 }); // Default to a 204 No Content if nothing is returned } } catch (error) { - // Log the error for monitoring and debugging - const logMessage = res && typeof res.status === 'function' ? 'API Route Error:' : 'Route Error:'; - logger(logMessage, error); + /** + * Log the error with a context-specific message. + * For API Routes, prepend 'API Route Error:' + * For App Router, prepend 'Route Error:' + */ + const logMessage = + res && typeof res.status === 'function' + ? 'API Route Error:' + : 'Route Error:'; + + // Safely invoke the logger + if (typeof logger === 'function') { + try { + logger(logMessage, error); + } catch (loggerError) { + console.error('Logging failed:', loggerError); + } + } let statusCode = defaultStatusCode; let message = defaultMessage; - // Check if the error is an instance of CustomError to get the specific status code and message + /** + * If the error is an instance of CustomError, use its statusCode and message. + * This ensures that custom-defined errors have their specific responses. + */ if (error instanceof CustomError) { statusCode = error.statusCode; message = error.message || defaultMessage; } - // Prepare the error response structure + /** + * Prepare the error response structure. + * This can be customized further by the user through the formatError function. + */ let errorResponse = { error: { message, @@ -45,12 +124,27 @@ function errorHandler(handler, options = {}) { }, }; - // Format the error response if a formatError function is provided + /** + * If a formatError function is provided, use it to customize the error response. + * This allows users to add additional fields or modify the structure as needed. + */ if (formatError && typeof formatError === 'function') { - errorResponse = formatError(error, req); + try { + errorResponse = formatError(error, req); + } catch (formatErrorException) { + console.error('formatError failed:', formatErrorException); + errorResponse = { + message, + type: error.name || 'Error', + }; + } } - // Send the error response based on the context (API Route or App Router) + /** + * Send the error response based on the context (API Route or App Router). + * - For API Routes: Send a JSON response with the appropriate status code. + * - For App Router: Return a Response object with JSON content. + */ if (res && typeof res.status === 'function') { // For API Route, send JSON response res.status(statusCode).json(errorResponse); diff --git a/src/index.js b/src/index.js index 364d290..fe66f89 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,26 @@ // src/index.js +/** + * # Next.js Centralized Error Handler + * + * Centralized error handling for Next.js applications, compatible with both API Routes and the App Router. + * + * ## Features + * - Dual compatibility with API Routes and App Router + * - Comprehensive error handling to prevent information leakage + * - Customizable logging and error response formatting + * + * ## Quick Start + * + * See the [README](./README.md) for detailed usage examples. + */ + const errorHandler = require('./errorHandler'); const customErrors = require('./customErrors'); +/** + * Exported functions and classes. + */ module.exports = { errorHandler, ...customErrors, diff --git a/tests/errorHandler.test.js b/tests/errorHandler.test.js index e3b0443..4c127cd 100644 --- a/tests/errorHandler.test.js +++ b/tests/errorHandler.test.js @@ -1,7 +1,11 @@ // tests/errorHandler.test.js const errorHandler = require('../src/errorHandler'); -const { BadRequestError, CustomError } = require('../src/customErrors'); +const { + BadRequestError, + NotFoundError, + CustomError, +} = require('../src/customErrors'); beforeAll(() => { jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -11,7 +15,7 @@ afterAll(() => { console.error.mockRestore(); }); -describe('errorHandler', () => { +describe('errorHandler - API Routes', () => { test('should handle custom errors correctly', async () => { const req = {}; const res = { @@ -82,10 +86,79 @@ describe('errorHandler', () => { requestId: '12345', }); }); + + test('should handle different custom errors correctly', async () => { + const req = {}; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + const handler = async () => { + throw new NotFoundError('Resource not found.'); + }; + + await errorHandler(handler)(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: { + message: 'Resource not found.', + type: 'NotFoundError', + }, + }); + }); + + test('should handle non-error objects gracefully', async () => { + const req = {}; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + const handler = async () => { + throw { message: 'Non-error object', code: 123 }; + }; + + await errorHandler(handler)(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: { + message: 'An internal server error occurred. Please try again later.', + type: 'Error', + }, + }); + }); + + test('should ignore additional properties in errors', async () => { + const req = {}; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + const handler = async () => { + const error = new Error('Test error with extra properties.'); + error.statusCode = 400; + error.extra = 'extraProperty'; + throw error; + }; + + await errorHandler(handler)(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: { + message: 'An internal server error occurred. Please try again later.', + type: 'Error', + }, + }); + }); }); -describe('errorHandler with custom logger', () => { - test('should use the custom logger function', async () => { +describe('errorHandler - Custom Logger', () => { + test('should use the custom logger function (API Routes)', async () => { const req = {}; const res = { status: jest.fn().mockReturnThis(), @@ -100,45 +173,152 @@ describe('errorHandler with custom logger', () => { await errorHandler(handler, { logger: mockLogger })(req, res); - expect(mockLogger).toHaveBeenCalledWith('API Route Error:', expect.any(Error)); + expect(mockLogger).toHaveBeenCalledWith( + 'API Route Error:', + expect.any(Error), + ); expect(res.status).toHaveBeenCalledWith(500); }); + + test('should use the custom logger function in App Router', async () => { + const req = { url: '/api/test' }; + + const handler = async () => { + throw new Error('App Router test error.'); + }; + + const mockLogger = jest.fn(); + + const res = null; // No res object for App Router + + const mockResponse = await errorHandler(handler, { logger: mockLogger })( + req, + res, + ); + + expect(mockLogger).toHaveBeenCalledWith('Route Error:', expect.any(Error)); + expect(mockResponse.status).toBe(500); + const body = await mockResponse.json(); + expect(body).toEqual({ + error: { + message: 'An internal server error occurred. Please try again later.', + type: 'Error', + }, + }); + }); }); -// tests/errorHandler.test.js +describe('errorHandler - App Router', () => { + test('should handle custom errors correctly', async () => { + const req = { url: '/api/test' }; -describe('errorHandler with custom formatError', () => { - test('should format the error response using formatError function', async () => { - const req = { url: '/api/test', headers: {} }; - const res = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), + const handler = async (req) => { + throw new CustomError('Test custom error.', 400, 'BadRequestError'); }; + const res = null; // No res object for App Router + + // Mock Response object + const mockResponse = await errorHandler(handler)(req, res); + + expect(mockResponse.status).toBe(400); + expect(mockResponse.headers.get('Content-Type')).toBe('application/json'); + const body = await mockResponse.json(); + expect(body).toEqual({ + error: { + message: 'Test custom error.', + type: 'BadRequestError', + }, + }); + }); + + test('should handle unexpected errors with default status and message', async () => { + const req = { url: '/api/test' }; + + const handler = async (req) => { + throw new Error('Unexpected error.'); + }; + + const res = null; // No res object for App Router + + // Mock Response object + const mockResponse = await errorHandler(handler)(req, res); + + expect(mockResponse.status).toBe(500); + expect(mockResponse.headers.get('Content-Type')).toBe('application/json'); + const body = await mockResponse.json(); + expect(body).toEqual({ + error: { + message: 'An internal server error occurred. Please try again later.', + type: 'Error', + }, + }); + }); + + test('should use custom formatError function in App Router', async () => { + const req = { url: '/api/test', headers: { 'x-request-id': 'abc123' } }; + const handler = async () => { - throw new BadRequestError('Test bad request.'); + throw new BadRequestError('Invalid input.'); }; const formatError = (error, req) => ({ message: error.message, type: error.name, - path: req.url, - customField: 'customValue', + requestId: req.headers['x-request-id'], }); - await errorHandler(handler, { formatError })(req, res); + const res = null; // No res object for App Router - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - message: 'Test bad request.', + const mockResponse = await errorHandler(handler, { formatError })(req, res); + + expect(mockResponse.status).toBe(400); + expect(mockResponse.headers.get('Content-Type')).toBe('application/json'); + const body = await mockResponse.json(); + expect(body).toEqual({ + message: 'Invalid input.', type: 'BadRequestError', - path: '/api/test', - customField: 'customValue', + requestId: 'abc123', }); }); + + test('should return 204 No Content if handler does not return a response (App Router)', async () => { + const req = { url: '/api/test' }; + + const handler = async () => { + // No return statement + }; + + const res = null; // No res object for App Router + + const mockResponse = await errorHandler(handler)(req, res); + + expect(mockResponse.status).toBe(204); + expect(mockResponse.headers.get('Content-Type')).toBeNull(); + const body = await mockResponse.text(); + expect(body).toBe(''); + }); + + test('should set Content-Type to application/json for JSON responses (App Router)', async () => { + const req = { url: '/api/test' }; + + const handler = async () => { + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const res = null; // No res object for App Router + + const mockResponse = await errorHandler(handler)(req, res); + + expect(mockResponse.status).toBe(200); + expect(mockResponse.headers.get('Content-Type')).toBe('application/json'); + }); }); -describe('errorHandler security', () => { +describe('errorHandler - Security', () => { test('should use default status code and message for non-custom errors', async () => { const req = {}; const res = { @@ -162,4 +342,30 @@ describe('errorHandler security', () => { }, }); }); -}); \ No newline at end of file +}); + +describe('errorHandler - Invalid `formatError` Function', () => { + test('should fallback to default error response if formatError is not a function', async () => { + const req = {}; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + const handler = async () => { + throw new BadRequestError('Test bad request.'); + }; + + const invalidFormatError = 'not a function'; + + await errorHandler(handler, { formatError: invalidFormatError })(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: { + message: 'Test bad request.', + type: 'BadRequestError', + }, + }); + }); +});