diff --git a/README.md b/README.md index a08c981..331ee1d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,171 @@ exports.handler = async (event, context) => { For a full tutorial see [How To: Build a Serverless API with Serverless, AWS Lambda and Lambda API](https://www.jeremydaly.com/build-serverless-api-serverless-aws-lambda-lambda-api/). +## TypeScript Support + +Lambda API includes comprehensive TypeScript definitions out of the box. You can leverage type safety across your entire API: + +```typescript +import { + API, + Request, + Response, + SourceAgnosticHandler, + SourceAgnosticMiddleware, + isApiGatewayContext, + isAlbContext, +} from 'lambda-api'; + +// Define your response type +interface UserResponse { + id: string; + name: string; + email: string; +} + +// Create a typed API instance +const api = new API(); + +// Source-agnostic handler that works with any Lambda trigger +const handler: SourceAgnosticHandler = (req, res) => { + // Common properties are always available + console.log(req.method, req.path); + + // Type-safe access to source-specific features + if (isApiGatewayContext(req.requestContext)) { + console.log(req.requestContext.identity); + } else if (isAlbContext(req.requestContext)) { + console.log(req.requestContext.elb); + } + + res.json({ + id: '1', + name: 'John', + email: 'john@example.com', + }); +}; + +// Source-agnostic middleware +const middleware: SourceAgnosticMiddleware = (req, res, next) => { + // Works with any Lambda trigger + console.log(`${req.method} ${req.path}`); + next(); +}; + +// Use with API methods +api.get('/users', middleware, handler); + +// For source-specific handlers, you can specify the context type +interface UserQuery { + fields: string; +} +interface UserParams { + id: string; +} +interface UserBody { + name: string; + email: string; +} + +api.post( + '/users', + (req, res) => { + // Full type safety for: + req.query.fields; // UserQuery + req.params.id; // UserParams + req.body.name; // UserBody + req.requestContext; // APIGatewayContext + + res.json({ + id: '1', + name: req.body.name, + email: req.body.email, + }); + } +); +``` + +Key TypeScript Features: + +- Source-agnostic types that work with any Lambda trigger +- Type guards for safe context type checking +- Full type inference for request and response objects +- Generic type parameters for response types +- Support for API Gateway and ALB contexts +- Type-safe query parameters, path parameters, and request body +- Middleware and error handler type definitions +- Automatic type inference for all HTTP methods +- Type safety for cookies, headers, and other API features + +## Type Guards + +Lambda API provides type guards to safely work with different request sources: + +```typescript +import { + isApiGatewayContext, + isApiGatewayV2Context, + isAlbContext, + isApiGatewayRequest, + isApiGatewayV2Request, + isAlbRequest, +} from 'lambda-api'; + +// Check request context type +if (isApiGatewayContext(req.requestContext)) { + // TypeScript knows this is APIGatewayContext + console.log(req.requestContext.identity); +} + +// Check entire request type +if (isApiGatewayRequest(req)) { + // TypeScript knows this is Request + console.log(req.requestContext.identity); +} +``` + +## Handling Multiple Request Sources + +Lambda API provides type-safe support for different AWS Lambda triggers. You can write source-specific handlers or use source-agnostic handlers that work with any trigger: + +```typescript +import { + isApiGatewayContext, + isApiGatewayV2Context, + isAlbContext, + SourceAgnosticHandler, +} from 'lambda-api'; + +// Source-specific handler +api.get('/api-gateway', (req, res) => { + console.log(req.requestContext.identity); +}); + +api.get('/alb', (req, res) => { + console.log(req.requestContext.elb); +}); + +// Source-agnostic handler (works with any trigger) +const handler: SourceAgnosticHandler = (req, res) => { + if (isApiGatewayContext(req.requestContext)) { + console.log(req.requestContext.identity); + } else if (isAlbContext(req.requestContext)) { + console.log(req.requestContext.elb); + } + + res.json({ status: 'ok' }); +}; + +api.get('/any', handler); +``` + +Key features for handling multiple sources: + +- Type guards for safe context type checking +- Source-agnostic types that work with any trigger +- Full type safety for source-specific properties +- Automatic payload format detection + ## Why Another Web Framework? Express.js, Fastify, Koa, Restify, and Hapi are just a few of the many amazing web frameworks out there for Node.js. So why build yet another one when there are so many great options already? One word: **DEPENDENCIES**. @@ -127,6 +292,7 @@ Whatever you decide is best for your use case, **Lambda API** is there to suppor - [TypeScript Support](#typescript-support) - [Contributions](#contributions) - [Are you using Lambda API?](#are-you-using-lambda-api) +- [Handling Multiple Request Sources](#handling-multiple-request-sources) ## Installation @@ -1463,7 +1629,7 @@ Lambda API automatically parses this information to create a normalized `REQUEST ## ALB Integration -AWS recently added support for Lambda functions as targets for Application Load Balancers. While the events from ALBs are similar to API Gateway, there are a number of differences that would require code changes based on implementation. Lambda API detects the event `interface` and automatically normalizes the `REQUEST` object. It also correctly formats the `RESPONSE` (supporting both multi-header and non-multi-header mode) for you. This allows you to call your Lambda function from API Gateway, ALB, or both, without requiring any code changes. +AWS supports Lambda functions as targets for Application Load Balancers. While the events from ALBs are similar to API Gateway, there are a number of differences that would require code changes based on implementation. Lambda API detects the event `interface` and automatically normalizes the `REQUEST` object. It also correctly formats the `RESPONSE` (supporting both multi-header and non-multi-header mode) for you. This allows you to call your Lambda function from API Gateway, ALB, or both, without requiring any code changes. Please note that ALB events do not contain all of the same headers as API Gateway (such as `clientType`), but Lambda API provides defaults for seamless integration between the interfaces. ALB also automatically enables binary support, giving you the ability to serve images and other binary file types. Lambda API reads the `path` parameter supplied by the ALB event and uses that to route your requests. If you specify a wildcard in your listener rule, then all matching paths will be forwarded to your Lambda function. Lambda API's routing system can be used to process these routes just like with API Gateway. This includes static paths, parameterized paths, wildcards, middleware, etc. @@ -1479,37 +1645,171 @@ Simply create a `{proxy+}` route that uses the `ANY` method and all requests wil If you are using persistent connections in your function routes (such as AWS RDS or Elasticache), be sure to set `context.callbackWaitsForEmptyEventLoop = false;` in your main handler. This will allow the freezing of connections and will prevent Lambda from hanging on open connections. See [here](https://www.jeremydaly.com/reuse-database-connections-aws-lambda/) for more information. -## TypeScript Support +## Contributions + +Contributions, ideas and bug reports are welcome and greatly appreciated. Please add [issues](https://github.com/jeremydaly/lambda-api/issues) for suggestions and bug reports or create a pull request. + +## Are you using Lambda API? + +If you're using Lambda API and finding it useful, hit me up on [Twitter](https://twitter.com/jeremy_daly) or email me at contact[at]jeremydaly.com. I'd love to hear your stories, ideas, and even your complaints! -An `index.d.ts` declaration file has been included for use with your TypeScript projects (thanks @hassankhan). Please feel free to make suggestions and contributions to keep this up-to-date with future releases. +## Type-Safe Middleware and Extensions -**TypeScript Example** +Lambda API provides full TypeScript support with type-safe middleware and request/response extensions. Here are the recommended patterns: + +### Extending Request and Response Types ```typescript -// import AWS Lambda types -import { APIGatewayEvent, Context } from 'aws-lambda'; -// import Lambda API default function -import createAPI from 'lambda-api'; +declare module 'lambda-api' { + interface Request { + user?: { + id: string; + roles: string[]; + email: string; + }; + } +} + +function hasUser( + req: Request +): req is Request & { user: { id: string; roles: string[]; email: string } } { + return 'user' in req && req.user !== undefined; +} + +const authMiddleware: Middleware = (req, res, next) => { + req.user = { + id: '123', + roles: ['admin'], + email: 'user@example.com', + }; + next(); +}; + +api.get('/protected', (req, res) => { + if (hasUser(req)) { + const { id, roles, email } = req.user; + res.json({ message: `Hello ${email}` }); + } +}); +``` -// instantiate framework -const api = createAPI(); +### Response Extensions -// Define a route -api.get('/status', async (req, res) => { - return { status: 'ok' }; +```typescript +declare module 'lambda-api' { + interface Response { + sendWithTimestamp?: (data: any) => void; + } +} + +const responseEnhancer: Middleware = (req, res, next) => { + res.sendWithTimestamp = (data: any) => { + res.json({ + ...data, + timestamp: Date.now(), + }); + }; + next(); +}; + +api.get('/users', (req, res) => { + res.sendWithTimestamp({ name: 'John' }); }); +``` -// Declare your Lambda handler -exports.run = async (event: APIGatewayEvent, context: Context) => { - // Run the request - return await api.run(event, context); +### Using Built-in Auth Property + +```typescript +interface AuthInfo { + userId: string; + roles: string[]; + type: 'Bearer' | 'Basic' | 'OAuth' | 'Digest' | 'none'; + value: string | null; +} + +function hasAuth(req: Request): req is Request & { auth: AuthInfo } { + return 'auth' in req && req.auth?.type !== undefined; +} + +const authMiddleware: Middleware = (req, res, next) => { + req.auth = { + userId: '123', + roles: ['user'], + type: 'Bearer', + value: 'token123', + }; + next(); }; ``` -## Contributions +### Type Safety Examples -Contributions, ideas and bug reports are welcome and greatly appreciated. Please add [issues](https://github.com/jeremydaly/lambda-api/issues) for suggestions and bug reports or create a pull request. +```typescript +function hasUser(req: Request): req is Request & { user: UserType } { + return 'user' in req && req.user !== undefined; +} + +interface QueryParams { + limit?: string; + offset?: string; +} + +api.get('/users', (req, res) => { + const { limit, offset } = req.query; + res.json({ + /* ... */ + }); +}); -## Are you using Lambda API? +interface CreateUserBody { + name: string; + email: string; +} + +api.post( + '/users', + (req, res) => { + const { name, email } = req.body; + res.json({ + /* ... */ + }); + } +); -If you're using Lambda API and finding it useful, hit me up on [Twitter](https://twitter.com/jeremy_daly) or email me at contact[at]jeremydaly.com. I'd love to hear your stories, ideas, and even your complaints! +const withUser = (handler: HandlerFunction): HandlerFunction => { + return (req, res) => { + if (!hasUser(req)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + return handler(req, res); + }; +}; + +api.get('/protected', withUser(handler)); +``` + +## Handling Multiple Request Sources + +```typescript +import { + isApiGatewayContext, + isApiGatewayV2Context, + isAlbContext, +} from 'lambda-api'; + +api.get('/api-gateway', (req, res) => { + console.log(req.requestContext.identity); +}); + +api.get('/alb', (req, res) => { + console.log(req.requestContext.elb); +}); + +api.get('/any', (req, res) => { + if (isApiGatewayContext(req.requestContext)) { + console.log(req.requestContext.identity); + } else if (isAlbContext(req.requestContext)) { + console.log(req.requestContext.elb); + } +}); +``` diff --git a/index.d.ts b/index.d.ts index c23ae10..7e31fe7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2,6 +2,7 @@ import { APIGatewayEventRequestContext, APIGatewayProxyEvent, APIGatewayProxyEventV2, + ALBEvent, Context, } from 'aws-lambda'; import { S3ClientConfig } from '@aws-sdk/client-s3'; @@ -44,50 +45,104 @@ export declare interface App { [namespace: string]: Package; } -export declare type Middleware = ( - req: Request, - res: Response, +export type ALBContext = ALBEvent['requestContext']; +export type APIGatewayV2Context = APIGatewayProxyEventV2['requestContext']; +export type APIGatewayContext = APIGatewayEventRequestContext; + +export type RequestContext = + | APIGatewayContext + | APIGatewayV2Context + | ALBContext; + +export declare type Event = + | APIGatewayProxyEvent + | APIGatewayProxyEventV2 + | ALBEvent; + +export declare type Middleware< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any +> = ( + req: Request, + res: Response, next: NextFunction ) => void; -export declare type ErrorHandlingMiddleware = ( + +export declare type NextFunction = () => void; + +export declare type ErrorHandlingMiddleware< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any +> = ( + /** + * The error that was thrown or passed to res.error() + */ error: Error, - req: Request, - res: Response, + /** + * The request object + */ + req: Request, + /** + * The response object. Call res.send() or return a value to send a response. + * The error will continue through the error middleware chain until a response is sent. + */ + res: Response, + /** + * Call next() to continue to the next error middleware. + * Note: next(error) is not supported - the error parameter will be ignored. + */ next: NextFunction -) => void; -export declare type ErrorCallback = (error?: Error) => void; -export declare type HandlerFunction = ( - req: Request, - res: Response, - next?: NextFunction -) => void | any | Promise; - -export declare type LoggerFunction = ( - message?: any, - additionalInfo?: LoggerFunctionAdditionalInfo -) => void; -export declare type LoggerFunctionAdditionalInfo = - | string - | number - | boolean - | null - | LoggerFunctionAdditionalInfo[] - | { [key: string]: LoggerFunctionAdditionalInfo }; +) => void | Promise | TResponse; -export declare type NextFunction = () => void; -export declare type TimestampFunction = () => string; -export declare type SerializerFunction = (body: object) => string; -export declare type FinallyFunction = (req: Request, res: Response) => void; -export declare type METHODS = +export type METHODS = | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' - | 'OPTIONS' | 'HEAD' + | 'OPTIONS' | 'ANY'; +export type LoggerFunction = (message: string, ...args: any[]) => void; + +export type HandlerFunction< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any +> = ( + req: Request, + res: Response +) => void; + +export type TimestampFunction = () => string; + export declare interface SamplingOptions { route?: string; target?: number; @@ -101,26 +156,35 @@ export declare interface LoggerOptions { customKey?: string; errorLogging?: boolean; detail?: boolean; - level?: string; + level?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'none'; levels?: { - [key: string]: string; + [key: string]: number; }; messageKey?: string; nested?: boolean; timestamp?: boolean | TimestampFunction; - sampling?: { - target?: number; - rate?: number; - period?: number; - rules?: SamplingOptions[]; - }; + sampling?: + | boolean + | { + target?: number; + rate?: number; + period?: number; + rules?: SamplingOptions[]; + }; serializers?: { - [name: string]: (prop: any) => any; + main?: (req: Request) => object; + req?: (req: Request) => object; + res?: (res: Response) => object; + context?: (context: Context) => object; + custom?: (custom: any) => object; }; stack?: boolean; + timer?: boolean; + multiValue?: boolean; + log?: (message: string) => void; } -export declare interface Options { +export interface Options { base?: string; callbackName?: string; logger?: boolean | LoggerOptions; @@ -131,25 +195,36 @@ export declare interface Options { version?: string; errorHeaderWhitelist?: string[]; isBase64?: boolean; - compression?: boolean; - headers?: object; + compression?: boolean | string[]; + headers?: { + [key: string]: string; + }; s3Config?: S3ClientConfig; } -export declare class Request { +export declare type SerializerFunction = (body: object) => string; + +export declare class Request< + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any +> { app: API; version: string; id: string; - params: { - [key: string]: string | undefined; - }; + params: TParams; method: string; path: string; - query: { - [key: string]: string | undefined; - }; + query: TQuery; multiValueQuery: { - [key: string]: string[] | undefined; + [K in keyof TQuery]: string[] | undefined; }; headers: { [key: string]: string | undefined; @@ -160,10 +235,10 @@ export declare class Request { rawHeaders?: { [key: string]: string | undefined; }; - body: any; + body: TBody; rawBody: string; - route: ''; - requestContext: APIGatewayEventRequestContext; + route: string; + requestContext: TContext; isBase64Encoded: boolean; pathParameters: { [name: string]: string } | null; stageVariables: { [name: string]: string } | null; @@ -183,6 +258,12 @@ export declare class Request { clientType: 'desktop' | 'mobile' | 'tv' | 'tablet' | 'unknown'; clientCountry: string; namespace: App; + /** + * Alias for namespace + */ + ns: App; + interface: 'apigateway' | 'alb'; + payloadVersion?: string; log: { trace: LoggerFunction; @@ -196,68 +277,43 @@ export declare class Request { [key: string]: any; } -export declare class Response { +export declare class Response { status(code: number): this; - sendStatus(code: number): void; - header(key: string, value?: string | Array, append?: boolean): this; - getHeader(key: string): string; - getHeaders(): { [key: string]: string }; - setHeader(...args: Parameters): this; - hasHeader(key: string): boolean; - removeHeader(key: string): this; - getLink( s3Path: string, expires?: number, callback?: ErrorCallback ): Promise; - - send(body: any): void; - - json(body: any): void; - - jsonp(body: any): void; - - html(body: any): void; - + send(body: TResponse): void; + json(body: TResponse): void; + jsonp(body: TResponse): void; + html(body: string): void; type(type: string): this; - location(path: string): this; - redirect(status: number, path: string): void; redirect(path: string): void; - cors(options: CorsOptions): this; - error(message: string, detail?: any): void; error(code: number, message: string, detail?: any): void; - cookie(name: string, value: string, options?: CookieOptions): this; - clearCookie(name: string, options?: CookieOptions): this; - etag(enable?: boolean): this; - cache(age?: boolean | number | string, private?: boolean): this; - modified(date: boolean | string | Date): this; - attachment(fileName?: string): this; - download( file: string | Buffer, fileName?: string, options?: FileOptions, callback?: ErrorCallback ): void; - sendFile( file: string | Buffer, options?: FileOptions, @@ -269,62 +325,185 @@ export declare class API { app(namespace: string, package: Package): App; app(packages: App): App; - get( + get< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any + >( path: string, - ...middlewaresAndHandler: (Middleware | HandlerFunction)[] + ...middlewaresAndHandler: ( + | Middleware + | HandlerFunction + )[] ): void; - get(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; - post( + post< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any + >( path: string, - ...middlewaresAndHandler: (Middleware | HandlerFunction)[] + ...middlewaresAndHandler: ( + | Middleware + | HandlerFunction + )[] ): void; - post(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; - put( + put< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any + >( path: string, - ...middlewaresAndHandler: (Middleware | HandlerFunction)[] + ...middlewaresAndHandler: ( + | Middleware + | HandlerFunction + )[] ): void; - put(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; - patch( + patch< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any + >( path: string, - ...middlewaresAndHandler: (Middleware | HandlerFunction)[] + ...middlewaresAndHandler: ( + | Middleware + | HandlerFunction + )[] ): void; - patch(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; - delete( + delete< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any + >( path: string, - ...middlewaresAndHandler: (Middleware | HandlerFunction)[] + ...middlewaresAndHandler: ( + | Middleware + | HandlerFunction + )[] ): void; - delete(...middlewaresAndHandler: HandlerFunction[]): void; - options( + head< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any + >( path: string, - ...middlewaresAndHandler: (Middleware | HandlerFunction)[] + ...middlewaresAndHandler: ( + | Middleware + | HandlerFunction + )[] ): void; - options(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; - head( + options< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any + >( path: string, - ...middlewaresAndHandler: (Middleware | HandlerFunction)[] + ...middlewaresAndHandler: ( + | Middleware + | HandlerFunction + )[] ): void; - head(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; - any( + any< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any + >( path: string, - ...middlewaresAndHandler: (Middleware | HandlerFunction)[] + ...middlewaresAndHandler: ( + | Middleware + | HandlerFunction + )[] ): void; - any(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; - METHOD( + METHOD< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any + >( method: METHODS | METHODS[], path: string, - ...middlewaresAndHandler: (Middleware | HandlerFunction)[] - ): void; - METHOD( - method: METHODS | METHODS[], - ...middlewaresAndHandler: (Middleware | HandlerFunction)[] + ...middlewaresAndHandler: ( + | Middleware + | HandlerFunction + )[] ): void; register( @@ -336,21 +515,55 @@ export declare class API { routes(format: false): string[][]; routes(): string[][]; - use(path: string, ...middleware: Middleware[]): void; - use(paths: string[], ...middleware: Middleware[]): void; - use(...middleware: (Middleware | ErrorHandlingMiddleware)[]): void; + use< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any + >( + path: string | string[], + ...middleware: ( + | Middleware + | ErrorHandlingMiddleware + )[] + ): void; + + use< + TResponse = any, + TContext extends RequestContext = APIGatewayContext, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any + >( + ...middleware: ( + | Middleware + | ErrorHandlingMiddleware + )[] + ): void; - finally(callback: FinallyFunction): void; + finally( + callback: (req: Request, res: Response) => void + ): void; - run( - event: APIGatewayProxyEvent | APIGatewayProxyEventV2, + run( + event: Event, context: Context, - cb: (err: Error, result: any) => void + cb: (err: Error, result: TResponse) => void ): void; - run( - event: APIGatewayProxyEvent | APIGatewayProxyEventV2, - context: Context - ): Promise; + run(event: Event, context: Context): Promise; } export declare class RouteError extends Error { @@ -376,3 +589,130 @@ export declare class FileError extends Error { declare function createAPI(options?: Options): API; export default createAPI; + +export declare function isApiGatewayContext( + context: RequestContext +): context is APIGatewayContext; + +export declare function isApiGatewayV2Context( + context: RequestContext +): context is APIGatewayV2Context; + +export declare function isAlbContext( + context: RequestContext +): context is ALBContext; + +export declare function isApiGatewayEvent( + event: Event +): event is APIGatewayProxyEvent; + +export declare function isApiGatewayV2Event( + event: Event +): event is APIGatewayProxyEventV2; + +export declare function isAlbEvent(event: Event): event is ALBEvent; + +export declare function isApiGatewayRequest< + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any +>( + req: Request +): req is Request; + +export declare function isApiGatewayV2Request< + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any +>( + req: Request +): req is Request; + +export declare function isAlbRequest< + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any +>( + req: Request +): req is Request; + +/** + * Source-agnostic request type that works with any AWS Lambda trigger + */ +export type SourceAgnosticRequest< + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any +> = Request; + +/** + * Source-agnostic middleware type that works with any AWS Lambda trigger + */ +export type SourceAgnosticMiddleware< + TResponse = any, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any +> = Middleware; + +/** + * Source-agnostic handler function type that works with any AWS Lambda trigger + */ +export type SourceAgnosticHandler< + TResponse = any, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any +> = HandlerFunction; + +/** + * Source-agnostic error handling middleware type that works with any AWS Lambda trigger + */ +export type SourceAgnosticErrorHandler< + TResponse = any, + TQuery extends Record = Record< + string, + string | undefined + >, + TParams extends Record = Record< + string, + string | undefined + >, + TBody = any +> = ErrorHandlingMiddleware; diff --git a/index.js b/index.js index 86e3439..c8a217b 100644 --- a/index.js +++ b/index.js @@ -12,6 +12,11 @@ const LOGGER = require('./lib/logger'); const S3 = () => require('./lib/s3-service'); const prettyPrint = require('./lib/prettyPrint'); const { ConfigurationError } = require('./lib/errors'); +const { + isApiGatewayContext, + isApiGatewayV2Context, + isAlbContext, +} = require('./lib/typeguards'); class API { constructor(props) { @@ -552,6 +557,10 @@ class API { } // end API class // Export the API class as a new instance -module.exports = (opts) => new API(opts); +module.exports = (options) => new API(options); // Add createAPI as default export (to match index.d.ts) module.exports.default = module.exports; + +module.exports.isApiGatewayContext = isApiGatewayContext; +module.exports.isApiGatewayV2Context = isApiGatewayV2Context; +module.exports.isAlbContext = isAlbContext; diff --git a/index.test-d.ts b/index.test-d.ts index 782501b..1515bbe 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -1,257 +1,665 @@ -import { expectType, expectError } from 'tsd'; +import { expectType } from 'tsd'; + import { API, Request, Response, - CookieOptions, - CorsOptions, - FileOptions, - LoggerOptions, - Options, - Middleware, + ALBContext, + APIGatewayV2Context, + APIGatewayContext, ErrorHandlingMiddleware, HandlerFunction, - METHODS, - RouteError, - MethodError, - ConfigurationError, - ResponseError, - FileError, + Middleware, + NextFunction, + RequestContext, + isApiGatewayContext, + isApiGatewayV2Context, + isAlbContext, + isApiGatewayRequest, + isApiGatewayV2Request, + isAlbRequest, + isApiGatewayEvent, + isApiGatewayV2Event, + isAlbEvent, + App, + SourceAgnosticMiddleware, + SourceAgnosticHandler, + SourceAgnosticErrorHandler, } from './index'; import { APIGatewayProxyEvent, APIGatewayProxyEventV2, - Context, ALBEvent, + Context, } from 'aws-lambda'; -const options: Options = { - base: '/api', - version: 'v1', - logger: { - level: 'info', - access: true, - timestamp: true, - }, - compression: true, +interface UserResponse { + id: string; + name: string; + email: string; +} + +interface UserQuery extends Record { + fields?: string; +} + +interface UserParams extends Record { + id?: string; +} + +interface UserBody { + name: string; + email: string; +} + +interface AuthInfo { + userId: string; + roles: string[]; + type: 'Bearer' | 'Basic' | 'OAuth' | 'Digest' | 'none'; + value: string | null; +} + +function hasAuth(req: Request): req is Request & { auth: AuthInfo } { + return 'auth' in req && req.auth?.type !== undefined; +} + +const api = new API(); + +const testContextTypeGuards = () => { + const apiGatewayContext: APIGatewayContext = {} as APIGatewayContext; + const apiGatewayV2Context: APIGatewayV2Context = {} as APIGatewayV2Context; + const albContext: ALBContext = {} as ALBContext; + + if (isApiGatewayContext(apiGatewayContext)) { + expectType(apiGatewayContext); + } + + if (isApiGatewayV2Context(apiGatewayV2Context)) { + expectType(apiGatewayV2Context); + } + + if (isAlbContext(albContext)) { + expectType(albContext); + } }; -expectType(options); - -const req = {} as Request; -expectType(req.method); -expectType(req.path); -expectType<{ [key: string]: string | undefined }>(req.params); -expectType<{ [key: string]: string | undefined }>(req.query); -expectType<{ [key: string]: string | undefined }>(req.headers); -expectType(req.body); -expectType<{ [key: string]: string }>(req.cookies); - -const apiGwV1Event: APIGatewayProxyEvent = { - body: '{"test":"body"}', - headers: { 'content-type': 'application/json' }, - multiValueHeaders: { 'content-type': ['application/json'] }, - httpMethod: 'POST', - isBase64Encoded: false, - path: '/test', - pathParameters: { id: '123' }, - queryStringParameters: { query: 'test' }, - multiValueQueryStringParameters: { query: ['test'] }, - stageVariables: { stage: 'dev' }, - requestContext: { - accountId: '', - apiId: '', - authorizer: {}, - protocol: '', - httpMethod: 'POST', - identity: { - accessKey: null, - accountId: null, - apiKey: null, - apiKeyId: null, - caller: null, - cognitoAuthenticationProvider: null, - cognitoAuthenticationType: null, - cognitoIdentityId: null, - cognitoIdentityPoolId: null, - principalOrgId: null, - sourceIp: '', - user: null, - userAgent: null, - userArn: null, - }, - path: '/test', - stage: 'dev', - requestId: '', - requestTimeEpoch: 0, - resourceId: '', - resourcePath: '', - }, - resource: '', + +const testEventTypeGuards = () => { + const apiGatewayEvent: APIGatewayProxyEvent = {} as APIGatewayProxyEvent; + const apiGatewayV2Event: APIGatewayProxyEventV2 = + {} as APIGatewayProxyEventV2; + const albEvent: ALBEvent = {} as ALBEvent; + + if (isApiGatewayEvent(apiGatewayEvent)) { + expectType(apiGatewayEvent); + } + + if (isApiGatewayV2Event(apiGatewayV2Event)) { + expectType(apiGatewayV2Event); + } + + if (isAlbEvent(albEvent)) { + expectType(albEvent); + } }; -const apiGwV2Event: APIGatewayProxyEventV2 = { - version: '2.0', - routeKey: 'POST /test', - rawPath: '/test', - rawQueryString: 'query=test', - headers: { 'content-type': 'application/json' }, - requestContext: { - accountId: '', - apiId: '', - domainName: '', - domainPrefix: '', - http: { - method: 'POST', - path: '/test', - protocol: 'HTTP/1.1', - sourceIp: '', - userAgent: '', - }, - requestId: '', - routeKey: 'POST /test', - stage: 'dev', - time: '', - timeEpoch: 0, - }, - body: '{"test":"body"}', - isBase64Encoded: false, +const sourceAgnosticMiddleware: SourceAgnosticMiddleware = (req, res, next) => { + if (isApiGatewayContext(req.requestContext)) { + expectType(req.requestContext.requestId); + const sourceIp = req.requestContext.identity.sourceIp; + if (sourceIp) { + expectType(sourceIp); + } + } else if (isApiGatewayV2Context(req.requestContext)) { + expectType(req.requestContext.requestId); + const sourceIp = req.requestContext.http.sourceIp; + if (sourceIp) { + expectType(sourceIp); + } + } else if (isAlbContext(req.requestContext)) { + expectType<{ targetGroupArn: string }>(req.requestContext.elb); + } + next(); }; -const albEvent: ALBEvent = { - requestContext: { - elb: { - targetGroupArn: '', - }, - }, - httpMethod: 'GET', - path: '/test', - queryStringParameters: {}, - headers: {}, - body: '', - isBase64Encoded: false, +const albMiddleware: Middleware< + UserResponse, + ALBContext, + UserQuery, + UserParams, + UserBody +> = (req, res, next) => { + expectType<{ targetGroupArn: string }>(req.requestContext.elb); + next(); }; -const context: Context = { - callbackWaitsForEmptyEventLoop: true, - functionName: '', - functionVersion: '', - invokedFunctionArn: '', - memoryLimitInMB: '', - awsRequestId: '', - logGroupName: '', - logStreamName: '', - getRemainingTimeInMillis: () => 0, - done: () => {}, - fail: () => {}, - succeed: () => {}, +const apiGwV2Middleware: Middleware< + UserResponse, + APIGatewayV2Context, + UserQuery, + UserParams, + UserBody +> = (req, res, next) => { + expectType(req.requestContext.accountId); + next(); }; -const api = new API(); -expectType>(api.run(apiGwV1Event, context)); -expectType>(api.run(apiGwV2Event, context)); -// @ts-expect-error ALB events are not supported -expectType>(api.run(albEvent, context)); - -const res = {} as Response; -expectType(res.status(200)); -expectType(res.header('Content-Type', 'application/json')); -expectType( - res.cookie('session', 'value', { - httpOnly: true, - secure: true, - }) +const albHandler: HandlerFunction< + UserResponse, + ALBContext, + UserQuery, + UserParams, + UserBody +> = (req, res) => { + expectType<{ targetGroupArn: string }>(req.requestContext.elb); + res.json({ + id: '1', + name: req.body.name, + email: req.body.email, + }); +}; + +const apiGwV2Handler: HandlerFunction< + UserResponse, + APIGatewayV2Context, + UserQuery, + UserParams, + UserBody +> = (req, res) => { + expectType(req.requestContext.accountId); + res.json({ + id: '1', + name: req.body.name, + email: req.body.email, + }); +}; + +const testRequestTypeGuards = () => { + const req = {} as Request; + + if (isApiGatewayRequest(req)) { + expectType>(req); + } + + if (isApiGatewayV2Request(req)) { + expectType>(req); + } + + if (isAlbRequest(req)) { + expectType>(req); + } +}; + +const sourceAgnosticHandler: SourceAgnosticHandler = ( + req, + res +) => { + expectType(req.method); + expectType(req.path); + expectType>(req.query); + expectType>(req.headers); + expectType(req.ip); + + if (isApiGatewayContext(req.requestContext)) { + expectType(req.requestContext.requestId); + expectType(req.requestContext.identity.sourceIp); + res.json({ + id: req.requestContext.requestId, + name: 'API Gateway User', + email: 'user@apigateway.com', + }); + } else if (isApiGatewayV2Context(req.requestContext)) { + expectType(req.requestContext.requestId); + expectType(req.requestContext.http.sourceIp); + res.json({ + id: req.requestContext.requestId, + name: 'API Gateway V2 User', + email: 'user@apigatewayv2.com', + }); + } else if (isAlbContext(req.requestContext)) { + expectType<{ targetGroupArn: string }>(req.requestContext.elb); + res.json({ + id: req.requestContext.elb.targetGroupArn, + name: 'ALB User', + email: 'user@alb.com', + }); + } +}; + +api.get('/source-agnostic', sourceAgnosticHandler); +api.post( + '/source-agnostic', + sourceAgnosticMiddleware, + sourceAgnosticHandler +); + +api.post( + '/users', + sourceAgnosticMiddleware, + (req: Request, res: Response) => { + res.json({ + id: '1', + name: 'John', + email: 'john@example.com', + }); + } ); -expectType(res.send({ message: 'test' })); -expectType(res.json({ message: 'test' })); -expectType(res.html('
test
')); +api.post( + '/alb-users', + albMiddleware, + albHandler +); -expectType(res.error('Test error')); -expectType( - res.error(500, 'Server error', { details: 'Additional info' }) +api.post( + '/v2-users', + apiGwV2Middleware, + apiGwV2Handler ); -expectType(res.redirect('/new-path')); +const errorHandler: ErrorHandlingMiddleware = (error, req, res, next) => { + if (isAlbContext(req.requestContext)) { + res.status(500).json({ + id: 'alb-error', + name: error.name, + email: error.message, + }); + } else { + res.status(500).json({ + id: 'error', + name: error.name, + email: error.message, + }); + } +}; + +api.use(errorHandler); + +api.finally((req, res) => { + if (isApiGatewayContext(req.requestContext)) { + console.log('API Gateway request completed'); + } else if (isApiGatewayV2Context(req.requestContext)) { + console.log('API Gateway v2 request completed'); + } else if (isAlbContext(req.requestContext)) { + console.log('ALB request completed'); + } +}); + +const result = api.run({} as APIGatewayProxyEvent, {} as Context); +expectType>(result); + +api.run({} as APIGatewayProxyEvent, {} as Context, (err, res) => { + expectType(err); + expectType(res); +}); + +const testHttpMethods = () => { + api.get( + '/users/:id', + ( + req: Request, + res: Response + ) => { + expectType(req.params.id); + res.json({ id: '1', name: 'John', email: 'test@example.com' }); + } + ); + + api.put( + '/users/:id', + ( + req: Request, + res: Response + ) => { + expectType(req.body); + res + .status(200) + .json({ id: '1', name: req.body.name, email: req.body.email }); + } + ); + + api.patch< + UserResponse, + APIGatewayContext, + UserQuery, + UserParams, + Partial + >( + '/users/:id', + ( + req: Request>, + res: Response + ) => { + expectType>(req.body); + res.json({ id: '1', name: 'John', email: 'test@example.com' }); + } + ); + + api.delete( + '/users/:id', + (req: Request, res: Response) => { + res.status(204).send(); + } + ); + + api.head( + '/users', + (req: Request, res: Response) => { + res.status(200).send(); + } + ); -const middleware: Middleware = (req, res, next) => { + api.options( + '/users', + (req: Request, res: Response) => { + res.header('Allow', 'GET, POST, PUT, DELETE').status(204).send(); + } + ); + + api.any<{ method: string }, APIGatewayContext>( + '/wildcard', + (req: Request, res: Response<{ method: string }>) => { + expectType(req.method); + res.send({ method: req.method }); + } + ); +}; + +const pathSpecificMiddleware: Middleware = ( + req, + res, + next +) => { + req.log.info('Path-specific middleware'); next(); }; -expectType(middleware); -const errorMiddleware: ErrorHandlingMiddleware = (error, req, res, next) => { - res.status(500).json({ error: error.message }); +api.use('/specific-path', pathSpecificMiddleware); +api.use(['/path1', '/path2'], pathSpecificMiddleware); + +interface RequestWithCustom1 extends Request { + custom1: string; +} + +interface RequestWithCustom2 extends Request { + custom2: string; +} + +const middleware1: Middleware = (req, res, next) => { + (req as RequestWithCustom1).custom1 = 'value1'; + next(); }; -expectType(errorMiddleware); -const handler: HandlerFunction = (req, res) => { - res.json({ success: true }); +const middleware2: Middleware = (req, res, next) => { + (req as RequestWithCustom2).custom2 = 'value2'; + next(); }; -expectType(handler); -const cookieOptions: CookieOptions = { - domain: 'example.com', - httpOnly: true, - secure: true, - sameSite: 'Strict', +api.use(middleware1, middleware2); + +const handlerWithCustomProps: HandlerFunction = ( + req, + res +) => { + if ('custom1' in req) { + expectType((req as RequestWithCustom1).custom1); + } + if ('custom2' in req) { + expectType((req as RequestWithCustom2).custom2); + } + res.send({ status: 'ok' }); }; -expectType(cookieOptions); -const corsOptions: CorsOptions = { - origin: '*', - methods: 'GET,POST', - headers: 'Content-Type,Authorization', - credentials: true, +const testRequestProperties: HandlerFunction = ( + req, + res +) => { + expectType(req.id); + expectType(req.method); + expectType(req.path); + expectType>(req.query); + expectType>(req.headers); + expectType(req.ip); + expectType(req.userAgent); + expectType<'desktop' | 'mobile' | 'tv' | 'tablet' | 'unknown'>( + req.clientType + ); + expectType(req.clientCountry); + expectType(req.coldStart); + expectType(req.requestCount); + expectType<'apigateway' | 'alb'>(req.interface); + expectType(req.payloadVersion); + expectType(req.ns); + req.log.trace('trace message'); + req.log.debug('debug message'); + req.log.info('info message'); + req.log.warn('warn message'); + req.log.error('error message'); + req.log.fatal('fatal message'); }; -expectType(corsOptions); -const fileOptions: FileOptions = { - maxAge: 3600, - root: '/public', - lastModified: true, - headers: { 'Cache-Control': 'public' }, +const testResponseMethods: HandlerFunction = ( + req, + res +) => { + res + .status(201) + .header('X-Custom', 'value') + .type('json') + .cors({ + origin: '*', + methods: 'GET, POST', + headers: 'Content-Type', + }) + .cookie('session', 'value', { httpOnly: true }) + .cache(3600) + .etag(true) + .modified(new Date()); + + expectType(res.getHeader('X-Custom')); + expectType<{ [key: string]: string }>(res.getHeaders()); + expectType(res.hasHeader('X-Custom')); + res.removeHeader('X-Custom'); + + res.send({ data: 'raw' }); + res.json({ data: 'json' }); + res.jsonp({ data: 'jsonp' }); + res.html('
html
'); + res.sendStatus(204); + + res.redirect('/new-location'); + res.redirect(301, '/permanent-location'); + + res.clearCookie('session'); + + res.error(400, 'Bad Request'); + res.error('Error message'); }; -expectType(fileOptions); - -const loggerOptions: LoggerOptions = { - level: 'info', - access: true, - timestamp: true, - sampling: { - target: 10, - rate: 0.1, - }, + +const testErrorHandlingMiddleware: ErrorHandlingMiddleware = async ( + error, + req, + res, + next +) => { + if (error.message === 'sync') { + return { message: 'handled synchronously' }; + } + + if (error.message === 'async') { + return Promise.resolve({ message: 'handled asynchronously' }); + } + + if (error.message === 'void') { + res.json({ message: 'handled with void' }); + return; + } + + if (error.message === 'promise-void') { + await Promise.resolve(); + res.json({ message: 'handled with promise void' }); + return; + } + + next(); }; -expectType(loggerOptions); - -const methods: METHODS[] = [ - 'GET', - 'POST', - 'PUT', - 'DELETE', - 'OPTIONS', - 'HEAD', - 'ANY', -]; -expectType(methods); - -const routeError = new RouteError('Route not found', '/api/test'); -expectType(routeError); - -const methodError = new MethodError( - 'Method not allowed', - 'POST' as METHODS, - '/api/test' -); -expectType(methodError); -const configError = new ConfigurationError('Invalid configuration'); -expectType(configError); +const testDefaultTypes = () => { + api.get('/simple', (req: Request, res: Response) => { + expectType>(req); + expectType(res); + expectType(req.requestContext); + expectType>(req.query); + expectType>(req.params); + expectType(req.body); + res.json({ message: 'ok' }); + }); -const responseError = new ResponseError('Response error', 500); -expectType(responseError); + const simpleMiddleware: Middleware = ( + req: Request, + res: Response, + next: NextFunction + ) => { + expectType>(req); + expectType(res); + expectType(req.requestContext); + next(); + }; -const fileError = new FileError('File not found', { - code: 'ENOENT', - syscall: 'open', -}); -expectType(fileError); + const simpleErrorHandler: ErrorHandlingMiddleware = ( + error: Error, + req: Request, + res: Response, + next: NextFunction + ) => { + expectType>(req); + expectType(res); + expectType(req.requestContext); + res.status(500).json({ error: error.message }); + }; + + api.post( + '/simple-chain', + simpleMiddleware, + (req: Request, res: Response) => { + expectType>(req); + expectType(res); + res.json({ status: 'ok' }); + } + ); + + api.use( + (req: Request, res: Response, next: NextFunction) => { + expectType>(req); + expectType(res); + next(); + } + ); + + api.use( + '/path', + (req: Request, res: Response, next: NextFunction) => { + expectType>(req); + expectType(res); + next(); + } + ); + + api.finally((req: Request, res: Response) => { + expectType>(req); + expectType(res); + }); + + const runResult = api.run({} as APIGatewayProxyEvent, {} as Context); + expectType>(runResult); + + api.run({} as APIGatewayProxyEvent, {} as Context, (err: Error, res: any) => { + expectType(err); + expectType(res); + }); + + const albApi = new API(); + albApi.get('/alb-default', (req: Request, res: Response) => { + if (isAlbContext(req.requestContext)) { + expectType(req.requestContext); + expectType<{ targetGroupArn: string }>(req.requestContext.elb); + expectType>(req.query); + expectType>(req.params); + expectType(req.body); + res.json({ message: 'ALB response' }); + } + }); + + const albResult = albApi.run({} as ALBEvent, {} as Context); + expectType>(albResult); + + const apiGwV2Api = new API(); + apiGwV2Api.get( + '/apigw-v2-default', + (req: Request, res: Response) => { + if (isApiGatewayV2Context(req.requestContext)) { + expectType(req.requestContext); + expectType(req.requestContext.accountId); + expectType>(req.query); + expectType>(req.params); + expectType(req.body); + res.json({ message: 'API Gateway V2 response' }); + } + } + ); + + const apiGwV2Result = apiGwV2Api.run( + {} as APIGatewayProxyEventV2, + {} as Context + ); + expectType>(apiGwV2Result); +}; + +const testSourceAgnosticTypes = () => { + // Test source-agnostic handler with minimal type parameters + const simpleSourceAgnosticHandler: SourceAgnosticHandler = (req, res) => { + expectType(req.requestContext); + res.json({ message: 'ok' }); + }; + + // Test source-agnostic handler with response type + const typedSourceAgnosticHandler: SourceAgnosticHandler = ( + req, + res + ) => { + res.json({ + id: '1', + name: 'John', + email: 'john@example.com', + }); + }; + + // Test source-agnostic middleware with minimal type parameters + const simpleSourceAgnosticMiddleware: SourceAgnosticMiddleware = ( + req, + res, + next + ) => { + expectType(req.requestContext); + next(); + }; + + // Test source-agnostic error handler + const sourceAgnosticErrorHandler: SourceAgnosticErrorHandler = ( + error, + req, + res, + next + ) => { + expectType(req.requestContext); + res.status(500).json({ error: error.message }); + }; + + // Test using source-agnostic types with API + api.get('/source-agnostic', simpleSourceAgnosticHandler); + api.post( + '/source-agnostic', + simpleSourceAgnosticMiddleware, + typedSourceAgnosticHandler + ); + api.use(sourceAgnosticErrorHandler); +}; diff --git a/lib/typeguards.js b/lib/typeguards.js new file mode 100644 index 0000000..7188004 --- /dev/null +++ b/lib/typeguards.js @@ -0,0 +1,19 @@ +'use strict'; + +const isApiGatewayContext = (context) => { + return 'identity' in context; +}; + +const isApiGatewayV2Context = (context) => { + return 'http' in context; +}; + +const isAlbContext = (context) => { + return 'elb' in context; +}; + +module.exports = { + isApiGatewayContext, + isApiGatewayV2Context, + isAlbContext, +};