diff --git a/README.md b/README.md index 200cd27..2ef687c 100644 --- a/README.md +++ b/README.md @@ -49,16 +49,16 @@ export const handler = async (event: any, context: any) => { ### API Response Example ```typescript -import { success, badRequest } from '@leanstacks/lambda-utils'; +import { ok, badRequest } from '@leanstacks/lambda-utils'; -export const handler = async (event: any) => { +export const handler = async (event: APIGatewayProxyEvent) => { if (!event.body) { - return badRequest({ message: 'Body is required' }); + return badRequest('Body is required'); } // Process request - return success({ message: 'Request processed successfully' }); + return ok({ message: 'Request processed successfully' }); }; ``` @@ -108,10 +108,10 @@ logger.error({ message: 'Operation failed', error: err.message }); Generate properly formatted responses for API Gateway: ```typescript -import { success, error, created, badRequest } from '@leanstacks/lambda-utils'; +import { ok, created, badRequest } from '@leanstacks/lambda-utils'; -export const handler = async (event: any) => { - return success({ +export const handler = async (event: APIGatewayProxyEvent) => { + return ok({ data: { id: '123', name: 'Example' }, }); }; diff --git a/docs/API_GATEWAY_RESPONSES.md b/docs/API_GATEWAY_RESPONSES.md new file mode 100644 index 0000000..3241de3 --- /dev/null +++ b/docs/API_GATEWAY_RESPONSES.md @@ -0,0 +1,451 @@ +# API Gateway Responses Guide + +The Lambda Utilities library provides a set of helper functions for creating properly formatted API Gateway responses. These utilities abstract away the boilerplate of response construction and ensure consistent response formatting across your Lambda functions. + +## Overview + +API Gateway responses require a specific structure with a status code, headers, and a JSON-stringified body. The response helpers provided by Lambda Utilities simplify this by: + +- Providing typed functions for common HTTP status codes +- Managing automatic JSON serialization +- Supporting custom headers +- Ensuring consistency with AWS Lambda proxy integration specifications + +## Installation + +```bash +npm install @leanstacks/lambda-utils +``` + +## Basic Usage + +### Creating Responses + +Import the response helpers from Lambda Utilities: + +```typescript +import { ok, created, badRequest, notFound, internalServerError } from '@leanstacks/lambda-utils'; +``` + +### Response Functions + +#### `ok(body, headers?)` + +Creates a **200 OK** response. + +```typescript +export const handler = async (event: any) => { + const data = { id: '123', name: 'Example' }; + return ok(data); +}; + +// Response: +// { +// statusCode: 200, +// body: '{"id":"123","name":"Example"}', +// headers: {} +// } +``` + +#### `created(body, headers?)` + +Creates a **201 Created** response, typically used when a resource is successfully created. + +```typescript +export const handler = async (event: any) => { + const newResource = { id: '456', name: 'New Resource' }; + return created(newResource); +}; + +// Response: +// { +// statusCode: 201, +// body: '{"id":"456","name":"New Resource"}', +// headers: {} +// } +``` + +#### `noContent(headers?)` + +Creates a **204 No Content** response, used when the request is successful but there's no content to return. + +```typescript +export const handler = async (event: any) => { + // Delete operation + return noContent(); +}; + +// Response: +// { +// statusCode: 204, +// body: '{}', +// headers: {} +// } +``` + +#### `badRequest(message?, headers?)` + +Creates a **400 Bad Request** error response. + +```typescript +export const handler = async (event: any) => { + if (!event.body) { + return badRequest('Request body is required'); + } +}; + +// Response: +// { +// statusCode: 400, +// body: '{"message":"Request body is required"}', +// headers: {} +// } +``` + +#### `notFound(message?, headers?)` + +Creates a **404 Not Found** error response. + +```typescript +export const handler = async (event: any) => { + const resource = await getResource(event.pathParameters.id); + + if (!resource) { + return notFound(`Resource with id ${event.pathParameters.id} not found`); + } + + return ok(resource); +}; + +// Response: +// { +// statusCode: 404, +// body: '{"message":"Resource with id 123 not found"}', +// headers: {} +// } +``` + +#### `internalServerError(message?, headers?)` + +Creates a **500 Internal Server Error** response. + +```typescript +export const handler = async (event: any) => { + try { + // Process request + } catch (error) { + return internalServerError('An unexpected error occurred'); + } +}; + +// Response: +// { +// statusCode: 500, +// body: '{"message":"An unexpected error occurred"}', +// headers: {} +// } +``` + +#### `createResponse(statusCode, body, headers?)` + +Creates a custom response with any status code. Use this for status codes not covered by the helper functions. + +```typescript +import { createResponse } from '@leanstacks/lambda-utils'; + +export const handler = async (event: any) => { + return createResponse(202, { status: 'Accepted' }); +}; + +// Response: +// { +// statusCode: 202, +// body: '{"status":"Accepted"}', +// headers: {} +// } +``` + +## Headers + +### HTTP Headers Helpers + +Lambda Utilities provides a `httpHeaders` object with common header builders: + +#### `httpHeaders.json` + +Sets the `Content-Type` header to `application/json`. + +```typescript +import { ok, httpHeaders } from '@leanstacks/lambda-utils'; + +export const handler = async (event: any) => { + return ok({ message: 'Success' }, httpHeaders.json); +}; + +// Response: +// { +// statusCode: 200, +// body: '{"message":"Success"}', +// headers: { 'Content-Type': 'application/json' } +// } +``` + +#### `httpHeaders.contentType(type)` + +Sets the `Content-Type` header to a custom MIME type. + +```typescript +import { ok, httpHeaders } from '@leanstacks/lambda-utils'; + +export const handler = async (event: any) => { + return ok(csvData, httpHeaders.contentType('text/csv')); +}; + +// Response: +// { +// statusCode: 200, +// body: '...', +// headers: { 'Content-Type': 'text/csv' } +// } +``` + +#### `httpHeaders.cors(origin?)` + +Sets the `Access-Control-Allow-Origin` header for CORS support. Default is `*`. + +```typescript +import { ok, httpHeaders } from '@leanstacks/lambda-utils'; + +export const handler = async (event: any) => { + return ok({ data: '...' }, httpHeaders.cors('https://example.com')); +}; + +// Response: +// { +// statusCode: 200, +// body: '{"data":"..."}', +// headers: { 'Access-Control-Allow-Origin': 'https://example.com' } +// } +``` + +### Custom Headers + +Combine multiple headers or add custom ones by passing a headers object: + +```typescript +import { ok, httpHeaders } from '@leanstacks/lambda-utils'; + +export const handler = async (event: any) => { + const headers = { + ...httpHeaders.json, + ...httpHeaders.cors(), + 'X-Custom-Header': 'value', + }; + + return ok({ message: 'Success' }, headers); +}; + +// Response: +// { +// statusCode: 200, +// body: '{"message":"Success"}', +// headers: { +// 'Content-Type': 'application/json', +// 'Access-Control-Allow-Origin': '*', +// 'X-Custom-Header': 'value' +// } +// } +``` + +## Complete Examples + +### Validation and Error Handling + +```typescript +import { ok, badRequest, internalServerError, httpHeaders } from '@leanstacks/lambda-utils'; + +interface RequestBody { + email: string; + name: string; +} + +export const handler = async (event: any) => { + try { + // Validate request + if (!event.body) { + return badRequest('Request body is required', httpHeaders.json); + } + + const body: RequestBody = JSON.parse(event.body); + + if (!body.email || !body.name) { + return badRequest('Missing required fields: email, name', httpHeaders.json); + } + + // Process request + const result = { id: '123', ...body }; + + return ok(result, httpHeaders.json); + } catch (error) { + console.error('Handler error:', error); + return internalServerError('Failed to process request', httpHeaders.json); + } +}; +``` + +### CRUD Operations + +```typescript +import { + ok, + created, + noContent, + badRequest, + notFound, + internalServerError, + httpHeaders, +} from '@leanstacks/lambda-utils'; + +const headers = httpHeaders.json; + +export const handlers = { + // GET /items/{id} + getItem: async (event: any) => { + try { + const item = await findItem(event.pathParameters.id); + return item ? ok(item, headers) : notFound('Item not found', headers); + } catch (error) { + return internalServerError('Failed to retrieve item', headers); + } + }, + + // POST /items + createItem: async (event: any) => { + try { + if (!event.body) { + return badRequest('Request body is required', headers); + } + + const newItem = await saveItem(JSON.parse(event.body)); + return created(newItem, headers); + } catch (error) { + return internalServerError('Failed to create item', headers); + } + }, + + // DELETE /items/{id} + deleteItem: async (event: any) => { + try { + await removeItem(event.pathParameters.id); + return noContent(headers); + } catch (error) { + return internalServerError('Failed to delete item', headers); + } + }, +}; +``` + +### CORS-Enabled Handler + +```typescript +import { ok, badRequest, httpHeaders } from '@leanstacks/lambda-utils'; + +const corsHeaders = { + ...httpHeaders.json, + ...httpHeaders.cors('https://app.example.com'), + 'X-API-Version': '1.0', +}; + +export const handler = async (event: any) => { + // Handle preflight requests + if (event.requestContext.http.method === 'OPTIONS') { + return ok({}, corsHeaders); + } + + if (!event.body) { + return badRequest('Body is required', corsHeaders); + } + + return ok({ processed: true }, corsHeaders); +}; +``` + +## Best Practices + +1. **Use Consistent Headers** – Define headers once and reuse them across handlers to maintain consistency. + + ```typescript + const defaultHeaders = httpHeaders.json; + ``` + +2. **Provide Meaningful Error Messages** – Include specific error details to help clients understand what went wrong. + + ```typescript + return badRequest(`Missing required field: ${fieldName}`, headers); + ``` + +3. **Handle Errors Gracefully** – Use try-catch blocks and return appropriate error responses. + + ```typescript + try { + // Process + } catch (error) { + return internalServerError('Operation failed', headers); + } + ``` + +4. **Use Appropriate Status Codes** – Choose the correct HTTP status code for each scenario: + - `200 OK` – Request successful + - `201 Created` – Resource created + - `204 No Content` – Request successful, no content + - `400 Bad Request` – Invalid input + - `404 Not Found` – Resource not found + - `500 Internal Server Error` – Unexpected error + +5. **Log Errors** – Log error details for debugging while returning user-friendly messages. + + ```typescript + catch (error) { + logger.error({ message: 'Processing failed', error: error.message }); + return internalServerError('Failed to process request', headers); + } + ``` + +6. **Combine with Logging** – Use response helpers with structured logging for complete observability. + + ```typescript + import { Logger } from '@leanstacks/lambda-utils'; + const logger = new Logger().instance; + + export const handler = async (event: any) => { + logger.info('Request received', { path: event.path }); + return ok({ message: 'Success' }, httpHeaders.json); + }; + ``` + +## Type Safety + +All response functions are fully typed with TypeScript. The `body` parameter accepts `unknown`, allowing you to pass any serializable value: + +```typescript +interface User { + id: string; + name: string; + email: string; +} + +const user: User = { id: '1', name: 'John', email: 'john@example.com' }; +return ok(user); // ✓ Type-safe +``` + +Error functions accept string or number messages: + +```typescript +return badRequest('Invalid input'); // ✓ String message +return notFound(404); // ✓ Number message +``` + +## Further reading + +- **[Logging Guide](./LOGGING.md)** – Structured logging for Lambda functions +- **[Back to the project documentation](README.md)** diff --git a/docs/LOGGING.md b/docs/LOGGING.md index baa7494..422cd9c 100644 --- a/docs/LOGGING.md +++ b/docs/LOGGING.md @@ -327,7 +327,8 @@ describe('MyHandler', () => { ## Further reading -- [Pino Documentation](https://getpino.io/) -- [AWS Lambda Environment and Context](https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html) -- [CloudWatch Logs Insights](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_QuerySyntax.html) -- [Back to the project documentation](README.md) +- **[Pino Documentation](https://getpino.io/)** +- **[Pino Lambda Documentation](https://github.com/FormidableLabs/pino-lambda#readme)** +- **[AWS Lambda Environment and Context](https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html)** +- **[CloudWatch Logs Insights](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_QuerySyntax.html)** +- **[Back to the project documentation](README.md)** diff --git a/package-lock.json b/package-lock.json index 366e228..18ab806 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@leanstacks/lambda-utils", - "version": "0.1.0", + "version": "0.2.0-alpha.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@leanstacks/lambda-utils", - "version": "0.1.0", + "version": "0.2.0-alpha.4", "license": "MIT", "dependencies": { "pino": "10.1.0", diff --git a/package.json b/package.json index a09b568..90d3c10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@leanstacks/lambda-utils", - "version": "0.1.0", + "version": "0.2.0-alpha.4", "description": "A collection of utilities and helper functions designed to streamline the development of AWS Lambda functions using TypeScript.", "main": "dist/index.js", "module": "dist/index.esm.js", @@ -35,6 +35,7 @@ "lint": "eslint src", "lint:fix": "eslint src --fix", "prepare": "husky", + "prepublish": "npm run clean && npm run build", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" diff --git a/src/index.ts b/src/index.ts index 96d5cdf..af6b8f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,12 @@ export { Logger, LoggerConfig, withRequestTracking } from './logging/logger'; +export { + createResponse, + ok, + created, + noContent, + badRequest, + notFound, + internalServerError, + httpHeaders, + Headers, +} from './utils/apigateway-response'; diff --git a/src/utils/apigateway-response.test.ts b/src/utils/apigateway-response.test.ts new file mode 100644 index 0000000..80babd4 --- /dev/null +++ b/src/utils/apigateway-response.test.ts @@ -0,0 +1,624 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { + Headers, + httpHeaders, + createResponse, + ok, + created, + noContent, + badRequest, + notFound, + internalServerError, +} from './apigateway-response'; + +describe('API Gateway Response Utilities', () => { + describe('jsonHeaders constant', () => { + it('should have Content-Type set to application/json', () => { + // Arrange & Act + const headers = httpHeaders.json; + + // Assert + expect(headers['Content-Type']).toBe('application/json'); + }); + + it('should be of type Headers', () => { + // Arrange & Act + const headers = httpHeaders.json; + + // Assert + expect(typeof headers).toBe('object'); + expect(headers).not.toBeNull(); + }); + }); + + describe('contentType function', () => { + it('should return headers with provided content type', () => { + // Arrange + const type = 'application/json'; + + // Act + const headers = httpHeaders.contentType(type); + + // Assert + expect(headers['Content-Type']).toBe('application/json'); + }); + + it('should accept text/plain as content type', () => { + // Arrange + const type = 'text/plain'; + + // Act + const headers = httpHeaders.contentType(type); + + // Assert + expect(headers['Content-Type']).toBe('text/plain'); + }); + + it('should accept text/html as content type', () => { + // Arrange + const type = 'text/html'; + + // Act + const headers = httpHeaders.contentType(type); + + // Assert + expect(headers['Content-Type']).toBe('text/html'); + }); + + it('should accept application/xml as content type', () => { + // Arrange + const type = 'application/xml'; + + // Act + const headers = httpHeaders.contentType(type); + + // Assert + expect(headers['Content-Type']).toBe('application/xml'); + }); + + it('should handle custom content types with charset', () => { + // Arrange + const type = 'application/json; charset=utf-8'; + + // Act + const headers = httpHeaders.contentType(type); + + // Assert + expect(headers['Content-Type']).toBe('application/json; charset=utf-8'); + }); + + it('should accept empty string as content type', () => { + // Arrange + const type = ''; + + // Act + const headers = httpHeaders.contentType(type); + + // Assert + expect(headers['Content-Type']).toBe(''); + }); + + it('should be of type Headers', () => { + // Arrange + const type = 'application/json'; + + // Act + const headers = httpHeaders.contentType(type); + + // Assert + expect(typeof headers).toBe('object'); + expect(headers).not.toBeNull(); + }); + + it('should handle multiple different content types', () => { + // Arrange + const types = ['application/json', 'text/plain', 'text/html', 'application/xml', 'image/png']; + + // Act & Assert + types.forEach((type) => { + const headers = httpHeaders.contentType(type); + expect(headers['Content-Type']).toBe(type); + }); + }); + }); + + describe('corsHeaders function', () => { + it('should return headers with default origin when no argument is provided', () => { + // Arrange & Act + const headers = httpHeaders.cors(); + + // Assert + expect(headers['Access-Control-Allow-Origin']).toBe('*'); + }); + + it('should return headers with custom origin when origin is provided', () => { + // Arrange + const origin = 'https://example.com'; + + // Act + const headers = httpHeaders.cors(origin); + + // Assert + expect(headers['Access-Control-Allow-Origin']).toBe('https://example.com'); + }); + + it('should accept empty string as origin', () => { + // Arrange + const origin = ''; + + // Act + const headers = httpHeaders.cors(origin); + + // Assert + expect(headers['Access-Control-Allow-Origin']).toBe(''); + }); + + it('should accept multiple domain patterns as origin', () => { + // Arrange + const origins = ['https://localhost:3000', 'https://api.example.com', 'https://example.com']; + + // Act & Assert + origins.forEach((origin) => { + const headers = httpHeaders.cors(origin); + expect(headers['Access-Control-Allow-Origin']).toBe(origin); + }); + }); + }); + + describe('createResponse function', () => { + it('should create a response with status code and stringified body', () => { + // Arrange + const statusCode = 200; + const body = { message: 'Success' }; + + // Act + const response = createResponse(statusCode, body); + + // Assert + expect(response.statusCode).toBe(200); + expect(response.body).toBe(JSON.stringify(body)); + expect(typeof response.body).toBe('string'); + }); + + it('should include empty headers object when headers are not provided', () => { + // Arrange + const statusCode = 200; + const body = { message: 'Success' }; + + // Act + const response = createResponse(statusCode, body); + + // Assert + expect(response.headers).toEqual({}); + }); + + it('should merge provided headers with response', () => { + // Arrange + const statusCode = 200; + const body = { message: 'Success' }; + const headers: Headers = { 'Content-Type': 'application/json', 'X-Custom-Header': 'value' }; + + // Act + const response = createResponse(statusCode, body, headers); + + // Assert + expect(response.headers).toEqual(headers); + expect(response.headers?.['Content-Type']).toBe('application/json'); + expect(response.headers?.['X-Custom-Header']).toBe('value'); + }); + + it('should handle various status codes', () => { + // Arrange + const statusCodes = [200, 201, 204, 400, 404, 500]; + const body = { message: 'Test' }; + + // Act & Assert + statusCodes.forEach((statusCode) => { + const response = createResponse(statusCode, body); + expect(response.statusCode).toBe(statusCode); + }); + }); + + it('should stringify complex body objects', () => { + // Arrange + const statusCode = 200; + const body = { + id: 123, + name: 'Test Item', + nested: { + value: 'nested value', + array: [1, 2, 3], + }, + }; + + // Act + const response = createResponse(statusCode, body); + + // Assert + expect(response.body).toBe(JSON.stringify(body)); + expect(JSON.parse(response.body)).toEqual(body); + }); + + it('should handle null body', () => { + // Arrange + const statusCode = 204; + const body = null; + + // Act + const response = createResponse(statusCode, body); + + // Assert + expect(response.body).toBe(JSON.stringify(null)); + expect(response.body).toBe('null'); + }); + + it('should handle empty object body', () => { + // Arrange + const statusCode = 200; + const body = {}; + + // Act + const response = createResponse(statusCode, body); + + // Assert + expect(response.body).toBe('{}'); + }); + + it('should return a valid APIGatewayProxyResult', () => { + // Arrange + const statusCode = 200; + const body = { message: 'Success' }; + const headers: Headers = { 'Content-Type': 'application/json' }; + + // Act + const response = createResponse(statusCode, body, headers); + + // Assert + expect(response).toHaveProperty('statusCode'); + expect(response).toHaveProperty('body'); + expect(response).toHaveProperty('headers'); + expect(typeof response.statusCode).toBe('number'); + expect(typeof response.body).toBe('string'); + expect(typeof response.headers).toBe('object'); + }); + }); + + describe('ok function (200)', () => { + it('should create a 200 response with provided body', () => { + // Arrange + const body = { message: 'Success' }; + + // Act + const response = ok(body); + + // Assert + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual(body); + }); + + it('should accept headers parameter', () => { + // Arrange + const body = { message: 'Success' }; + const headers: Headers = { 'Content-Type': 'application/json' }; + + // Act + const response = ok(body, headers); + + // Assert + expect(response.statusCode).toBe(200); + expect(response.headers).toEqual(headers); + }); + + it('should create response without headers when not provided', () => { + // Arrange + const body = { message: 'Success' }; + + // Act + const response = ok(body); + + // Assert + expect(response.headers).toEqual({}); + }); + + it('should stringify complex body objects', () => { + // Arrange + const body = { id: 1, items: [{ name: 'item1' }, { name: 'item2' }] }; + + // Act + const response = ok(body); + + // Assert + expect(JSON.parse(response.body)).toEqual(body); + }); + }); + + describe('created function (201)', () => { + it('should create a 201 response with provided body', () => { + // Arrange + const body = { id: 123, message: 'Resource created' }; + + // Act + const response = created(body); + + // Assert + expect(response.statusCode).toBe(201); + expect(JSON.parse(response.body)).toEqual(body); + }); + + it('should accept headers parameter', () => { + // Arrange + const body = { id: 123 }; + const headers: Headers = { 'Content-Type': 'application/json', Location: '/resource/123' }; + + // Act + const response = created(body, headers); + + // Assert + expect(response.statusCode).toBe(201); + expect(response.headers).toEqual(headers); + }); + + it('should create response without headers when not provided', () => { + // Arrange + const body = { id: 123 }; + + // Act + const response = created(body); + + // Assert + expect(response.headers).toEqual({}); + }); + }); + + describe('noContent function (204)', () => { + it('should create a 204 response with empty body', () => { + // Arrange & Act + const response = noContent(); + + // Assert + expect(response.statusCode).toBe(204); + expect(response.body).toBe('{}'); + }); + + it('should accept headers parameter', () => { + // Arrange + const headers: Headers = { 'Cache-Control': 'no-cache' }; + + // Act + const response = noContent(headers); + + // Assert + expect(response.statusCode).toBe(204); + expect(response.headers).toEqual(headers); + }); + + it('should create response without headers when not provided', () => { + // Arrange & Act + const response = noContent(); + + // Assert + expect(response.headers).toEqual({}); + }); + }); + + describe('badRequest function (400)', () => { + it('should create a 400 response with default message', () => { + // Arrange & Act + const response = badRequest(); + + // Assert + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body)).toEqual({ message: 'Bad Request' }); + }); + + it('should create a 400 response with custom message', () => { + // Arrange + const message = 'Invalid input provided'; + + // Act + const response = badRequest(message); + + // Assert + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body)).toEqual({ message }); + }); + + it('should accept headers parameter', () => { + // Arrange + const message = 'Invalid input'; + const headers: Headers = { 'Content-Type': 'application/json' }; + + // Act + const response = badRequest(message, headers); + + // Assert + expect(response.statusCode).toBe(400); + expect(response.headers).toEqual(headers); + }); + + it('should create response with default message when headers are provided', () => { + // Arrange + const headers: Headers = { 'Content-Type': 'application/json' }; + + // Act + const response = badRequest(undefined, headers); + + // Assert + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body)).toEqual({ message: 'Bad Request' }); + expect(response.headers).toEqual(headers); + }); + + it('should handle empty string message', () => { + // Arrange + const message = ''; + + // Act + const response = badRequest(message); + + // Assert + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body)).toEqual({ message: '' }); + }); + }); + + describe('notFound function (404)', () => { + it('should create a 404 response with default message', () => { + // Arrange & Act + const response = notFound(); + + // Assert + expect(response.statusCode).toBe(404); + expect(JSON.parse(response.body)).toEqual({ message: 'Not Found' }); + }); + + it('should create a 404 response with custom message', () => { + // Arrange + const message = 'Resource with ID 123 not found'; + + // Act + const response = notFound(message); + + // Assert + expect(response.statusCode).toBe(404); + expect(JSON.parse(response.body)).toEqual({ message }); + }); + + it('should accept headers parameter', () => { + // Arrange + const message = 'Resource not found'; + const headers: Headers = { 'Content-Type': 'application/json' }; + + // Act + const response = notFound(message, headers); + + // Assert + expect(response.statusCode).toBe(404); + expect(response.headers).toEqual(headers); + }); + + it('should create response with default message when headers are provided', () => { + // Arrange + const headers: Headers = { 'Content-Type': 'application/json' }; + + // Act + const response = notFound(undefined, headers); + + // Assert + expect(response.statusCode).toBe(404); + expect(JSON.parse(response.body)).toEqual({ message: 'Not Found' }); + expect(response.headers).toEqual(headers); + }); + }); + + describe('internalServerError function (500)', () => { + it('should create a 500 response with default message', () => { + // Arrange & Act + const response = internalServerError(); + + // Assert + expect(response.statusCode).toBe(500); + expect(JSON.parse(response.body)).toEqual({ message: 'Internal Server Error' }); + }); + + it('should create a 500 response with custom message', () => { + // Arrange + const message = 'Database connection failed'; + + // Act + const response = internalServerError(message); + + // Assert + expect(response.statusCode).toBe(500); + expect(JSON.parse(response.body)).toEqual({ message }); + }); + + it('should accept headers parameter', () => { + // Arrange + const message = 'Something went wrong'; + const headers: Headers = { 'Content-Type': 'application/json' }; + + // Act + const response = internalServerError(message, headers); + + // Assert + expect(response.statusCode).toBe(500); + expect(response.headers).toEqual(headers); + }); + + it('should create response with default message when headers are provided', () => { + // Arrange + const headers: Headers = { 'Content-Type': 'application/json' }; + + // Act + const response = internalServerError(undefined, headers); + + // Assert + expect(response.statusCode).toBe(500); + expect(JSON.parse(response.body)).toEqual({ message: 'Internal Server Error' }); + expect(response.headers).toEqual(headers); + }); + }); + + describe('Integration scenarios', () => { + it('should combine jsonHeaders and corsHeaders with ok response', () => { + // Arrange + const body = { data: 'test' }; + const headers: Headers = { + ...httpHeaders.json, + ...httpHeaders.cors('https://example.com'), + }; + + // Act + const response = ok(body, headers); + + // Assert + expect(response.statusCode).toBe(200); + expect(response.headers?.['Content-Type']).toBe('application/json'); + expect(response.headers?.['Access-Control-Allow-Origin']).toBe('https://example.com'); + expect(JSON.parse(response.body)).toEqual(body); + }); + + it('should handle complex error response scenario', () => { + // Arrange + const errorMessage = 'Validation failed for field: email'; + const headers: Headers = { + ...httpHeaders.json, + ...httpHeaders.cors(), + 'X-Error-Code': 'VALIDATION_ERROR', + }; + + // Act + const response = badRequest(errorMessage, headers); + + // Assert + expect(response.statusCode).toBe(400); + expect(response.headers?.['Content-Type']).toBe('application/json'); + expect(response.headers?.['Access-Control-Allow-Origin']).toBe('*'); + expect(response.headers?.['X-Error-Code']).toBe('VALIDATION_ERROR'); + expect(JSON.parse(response.body)).toEqual({ message: errorMessage }); + }); + + it('should handle multiple status codes with consistent structure', () => { + // Arrange + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const testCases: Array<{ fn: (...args: any[]) => APIGatewayProxyResult; statusCode: number; body?: any }> = [ + { fn: ok, statusCode: 200, body: { data: 'test' } }, + { fn: created, statusCode: 201, body: { id: 1, data: 'created' } }, + { fn: noContent, statusCode: 204, body: undefined }, + { fn: badRequest, statusCode: 400, body: undefined }, + { fn: notFound, statusCode: 404, body: undefined }, + { fn: internalServerError, statusCode: 500, body: undefined }, + ]; + + // Act & Assert + testCases.forEach(({ fn, statusCode, body }) => { + const response = body !== undefined ? fn(body) : fn(); + expect(response.statusCode).toBe(statusCode); + expect(response.body).toBeDefined(); + expect(typeof response.body).toBe('string'); + expect(response.headers).toBeDefined(); + }); + }); + }); +}); diff --git a/src/utils/apigateway-response.ts b/src/utils/apigateway-response.ts new file mode 100644 index 0000000..8f2984a --- /dev/null +++ b/src/utils/apigateway-response.ts @@ -0,0 +1,116 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; + +/** + * Represents the headers for an API Gateway response + */ +export type Headers = Record; + +/** + * Commonly used headers for API Gateway responses + */ +export const httpHeaders = { + /** Content-Type: */ + contentType: (type: string) => ({ 'Content-Type': type }), + /** Content-Type: application/json */ + json: { 'Content-Type': 'application/json' }, + /** Access-Control-Allow-Origin: */ + cors: (origin: string = '*') => ({ 'Access-Control-Allow-Origin': origin }), +}; + +/** + * Creates a standardized API Gateway response. + * @param statusCode The HTTP status code of the response + * @param body The body of the response + * @param headers Optional headers to include in the response + * @returns An API Gateway proxy result + * + * @example + * ```ts + * const response = createResponse(200, { message: 'Success' }, httpHeaders.json); + * ``` + */ +export const createResponse = (statusCode: number, body: unknown, headers: Headers = {}): APIGatewayProxyResult => { + return { + statusCode, + headers: { + ...headers, + }, + body: JSON.stringify(body), + }; +}; + +// Common response patterns +/** + * Creates a 200 OK API Gateway response + * @param body The body of the response + * @param headers Optional headers to include in the response + * @returns An API Gateway proxy result + * @example + * ```ts + * const response = ok({ message: 'Success' }, httpHeaders.json); + * ``` + */ +export const ok = (body: unknown, headers: Headers = {}): APIGatewayProxyResult => createResponse(200, body, headers); + +/** + * Creates a 201 Created API Gateway response + * @param body The body of the response + * @param headers Optional headers to include in the response + * @returns An API Gateway proxy result + * @example + * ```ts + * const response = created({ message: 'Resource created' }, httpHeaders.json); + * ``` + */ +export const created = (body: unknown, headers: Headers = {}): APIGatewayProxyResult => + createResponse(201, body, headers); + +/** + * Creates a 204 No Content API Gateway response + * @param headers Optional headers to include in the response + * @returns An API Gateway proxy result + * @example + * ```ts + * const response = noContent(httpHeaders.cors()); + * ``` + */ +export const noContent = (headers: Headers = {}): APIGatewayProxyResult => createResponse(204, {}, headers); + +/** + * Creates a 400 Bad Request API Gateway response + * @param message The error message to include in the response + * @param headers Optional headers to include in the response + * @returns An API Gateway proxy result + * @example + * ```ts + * const response = badRequest('Invalid input', httpHeaders.json); + * ``` + */ +export const badRequest = (message = 'Bad Request', headers: Headers = {}): APIGatewayProxyResult => + createResponse(400, { message }, headers); + +/** + * Creates a 404 Not Found API Gateway response + * @param message The error message to include in the response + * @param headers Optional headers to include in the response + * @returns An API Gateway proxy result + * @example + * ```ts + * const response = notFound('Resource not found', httpHeaders.json); + * ``` + */ +export const notFound = (message = 'Not Found', headers: Headers = {}): APIGatewayProxyResult => + createResponse(404, { message }, headers); + +/** + * Creates a 500 Internal Server Error API Gateway response + * @param message The error message to include in the response + * @param headers Optional headers to include in the response + * @returns An API Gateway proxy result + * @example + * ```ts + * const response = internalServerError('Something went wrong', httpHeaders.json); + * ``` + */ +export const internalServerError = (message = 'Internal Server Error', headers: Headers = {}): APIGatewayProxyResult => + createResponse(500, { message }, headers);