Conversation
|
This seems like a great change. I've just tried on a project I have and noticed a few things. Given: both This seems to be a limitation of the type Something like: seems to over fine but it is a bit more cumbersome. |
I see, I'll dig into it in the next few days. |
|
@copilot continue this PR and make sure CI fully passes and that this feature works |
|
@naorpeled I've opened a new pull request, #304, to work on those changes. Once the pull request is ready, I'll request review from you. |
Sorry for the huge delay with this, hopefully I'll be able to finalize this soon |
Implement isApiGatewayEvent, isApiGatewayV2Event, isAlbEvent event type guards and isApiGatewayRequest, isApiGatewayV2Request, isAlbRequest request type guards. Re-add ErrorCallback type declaration referenced by getLink, download, and sendFile. Export all 9 type guards from index.js and add comprehensive unit tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds generic TypeScript support to lambda-api, addressing issues #276 (ALB TypeScript support) and #244 (generic request/response types). It introduces type-safe generics across route handlers, middleware, and error handlers, along with runtime type guards to discriminate between API Gateway v1, API Gateway v2, and ALB request contexts.
Changes:
- New
lib/typeguards.jsfile exporting runtime type guard functions for API Gateway v1/v2 and ALB contexts, events, and requests index.d.tsextensively updated to add generics (TResponse,TContext,TQuery,TParams,TBody) toRequest,Response,Middleware,HandlerFunction,ErrorHandlingMiddleware, and allAPImethods; exports new context types (ALBContext,APIGatewayContext,APIGatewayV2Context),SourceAgnostic*helper types, and type guard declarationsREADME.mdandindex.test-d.tsupdated with comprehensive TypeScript documentation and new type-level tests
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
lib/typeguards.js |
New module with runtime type guards for discriminating event/context/request sources |
index.js |
Imports and re-exports the new type guard functions |
index.d.ts |
Full overhaul of TypeScript declarations to support generics and new context types |
index.test-d.ts |
Rewrites tsd type tests to validate new generic and type guard features |
__tests__/typeguards.unit.js |
New unit tests for all type guard functions |
README.md |
Adds TypeScript documentation, type guard usage examples, and patterns for multi-source handlers |
| 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<string, string | undefined> { | ||
| fields?: string; | ||
| } | ||
|
|
||
| interface UserParams extends Record<string, string | undefined> { | ||
| 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>(apiGatewayContext); | ||
| } | ||
|
|
||
| if (isApiGatewayV2Context(apiGatewayV2Context)) { | ||
| expectType<APIGatewayV2Context>(apiGatewayV2Context); | ||
| } | ||
|
|
||
| if (isAlbContext(albContext)) { | ||
| expectType<ALBContext>(albContext); | ||
| } | ||
| }; | ||
| expectType<Options>(options); | ||
|
|
||
| const req = {} as Request; | ||
| expectType<string>(req.method); | ||
| expectType<string>(req.path); | ||
| expectType<{ [key: string]: string | undefined }>(req.params); | ||
| expectType<{ [key: string]: string | undefined }>(req.query); | ||
| expectType<{ [key: string]: string | undefined }>(req.headers); | ||
| expectType<any>(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<APIGatewayProxyEvent>(apiGatewayEvent); | ||
| } | ||
|
|
||
| if (isApiGatewayV2Event(apiGatewayV2Event)) { | ||
| expectType<APIGatewayProxyEventV2>(apiGatewayV2Event); | ||
| } | ||
|
|
||
| if (isAlbEvent(albEvent)) { | ||
| expectType<ALBEvent>(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<string>(req.requestContext.requestId); | ||
| const sourceIp = req.requestContext.identity.sourceIp; | ||
| if (sourceIp) { | ||
| expectType<string>(sourceIp); | ||
| } | ||
| } else if (isApiGatewayV2Context(req.requestContext)) { | ||
| expectType<string>(req.requestContext.requestId); | ||
| const sourceIp = req.requestContext.http.sourceIp; | ||
| if (sourceIp) { | ||
| expectType<string>(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<string>(req.requestContext.accountId); | ||
| next(); | ||
| }; | ||
|
|
||
| const api = new API(); | ||
| expectType<Promise<any>>(api.run(apiGwV1Event, context)); | ||
| expectType<Promise<any>>(api.run(apiGwV2Event, context)); | ||
| // @ts-expect-error ALB events are not supported | ||
| expectType<void & Promise<any>>(api.run(albEvent, context)); | ||
|
|
||
| const res = {} as Response; | ||
| expectType<Response>(res.status(200)); | ||
| expectType<Response>(res.header('Content-Type', 'application/json')); | ||
| expectType<Response>( | ||
| 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<string>(req.requestContext.accountId); | ||
| res.json({ | ||
| id: '1', | ||
| name: req.body.name, | ||
| email: req.body.email, | ||
| }); | ||
| }; | ||
|
|
||
| const testRequestTypeGuards = () => { | ||
| const req = {} as Request<RequestContext>; | ||
|
|
||
| if (isApiGatewayRequest(req)) { | ||
| expectType<Request<APIGatewayContext>>(req); | ||
| } | ||
|
|
||
| if (isApiGatewayV2Request(req)) { | ||
| expectType<Request<APIGatewayV2Context>>(req); | ||
| } | ||
|
|
||
| if (isAlbRequest(req)) { | ||
| expectType<Request<ALBContext>>(req); | ||
| } | ||
| }; | ||
|
|
||
| const sourceAgnosticHandler: SourceAgnosticHandler<UserResponse> = ( | ||
| req, | ||
| res | ||
| ) => { | ||
| expectType<string>(req.method); | ||
| expectType<string>(req.path); | ||
| expectType<Record<string, string | undefined>>(req.query); | ||
| expectType<Record<string, string | undefined>>(req.headers); | ||
| expectType<string>(req.ip); | ||
|
|
||
| if (isApiGatewayContext(req.requestContext)) { | ||
| expectType<string>(req.requestContext.requestId); | ||
| expectType<string>(req.requestContext.identity.sourceIp); | ||
| res.json({ | ||
| id: req.requestContext.requestId, | ||
| name: 'API Gateway User', | ||
| email: 'user@apigateway.com', | ||
| }); | ||
| } else if (isApiGatewayV2Context(req.requestContext)) { | ||
| expectType<string>(req.requestContext.requestId); | ||
| expectType<string>(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<UserResponse>('/source-agnostic', sourceAgnosticHandler); | ||
| api.post<UserResponse>( | ||
| '/source-agnostic', | ||
| sourceAgnosticMiddleware, | ||
| sourceAgnosticHandler | ||
| ); | ||
|
|
||
| api.post<UserResponse, RequestContext>( | ||
| '/users', | ||
| sourceAgnosticMiddleware, | ||
| (req: Request<RequestContext>, res: Response<UserResponse>) => { | ||
| res.json({ | ||
| id: '1', | ||
| name: 'John', | ||
| email: 'john@example.com', | ||
| }); | ||
| } | ||
| ); | ||
|
|
||
| expectType<void>(res.send({ message: 'test' })); | ||
| expectType<void>(res.json({ message: 'test' })); | ||
| expectType<void>(res.html('<div>test</div>')); | ||
| api.post<UserResponse, ALBContext, UserQuery, UserParams, UserBody>( | ||
| '/alb-users', | ||
| albMiddleware, | ||
| albHandler | ||
| ); | ||
|
|
||
| expectType<void>(res.error('Test error')); | ||
| expectType<void>( | ||
| res.error(500, 'Server error', { details: 'Additional info' }) | ||
| api.post<UserResponse, APIGatewayV2Context, UserQuery, UserParams, UserBody>( | ||
| '/v2-users', | ||
| apiGwV2Middleware, | ||
| apiGwV2Handler | ||
| ); | ||
|
|
||
| expectType<void>(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<UserResponse>({} as APIGatewayProxyEvent, {} as Context); | ||
| expectType<Promise<UserResponse>>(result); | ||
|
|
||
| api.run<UserResponse>({} as APIGatewayProxyEvent, {} as Context, (err, res) => { | ||
| expectType<Error>(err); | ||
| expectType<UserResponse>(res); | ||
| }); | ||
|
|
||
| const testHttpMethods = () => { | ||
| api.get<UserResponse, APIGatewayContext, UserQuery, UserParams>( | ||
| '/users/:id', | ||
| ( | ||
| req: Request<APIGatewayContext, UserQuery, UserParams>, | ||
| res: Response<UserResponse> | ||
| ) => { | ||
| expectType<string | undefined>(req.params.id); | ||
| res.json({ id: '1', name: 'John', email: 'test@example.com' }); | ||
| } | ||
| ); | ||
|
|
||
| api.put<UserResponse, APIGatewayContext, UserQuery, UserParams, UserBody>( | ||
| '/users/:id', | ||
| ( | ||
| req: Request<APIGatewayContext, UserQuery, UserParams, UserBody>, | ||
| res: Response<UserResponse> | ||
| ) => { | ||
| expectType<UserBody>(req.body); | ||
| res | ||
| .status(200) | ||
| .json({ id: '1', name: req.body.name, email: req.body.email }); | ||
| } | ||
| ); | ||
|
|
||
| api.patch< | ||
| UserResponse, | ||
| APIGatewayContext, | ||
| UserQuery, | ||
| UserParams, | ||
| Partial<UserBody> | ||
| >( | ||
| '/users/:id', | ||
| ( | ||
| req: Request<APIGatewayContext, UserQuery, UserParams, Partial<UserBody>>, | ||
| res: Response<UserResponse> | ||
| ) => { | ||
| expectType<Partial<UserBody>>(req.body); | ||
| res.json({ id: '1', name: 'John', email: 'test@example.com' }); | ||
| } | ||
| ); | ||
|
|
||
| api.delete<void, APIGatewayContext>( | ||
| '/users/:id', | ||
| (req: Request<APIGatewayContext>, res: Response<void>) => { | ||
| res.status(204).send(); | ||
| } | ||
| ); | ||
|
|
||
| api.head<void, APIGatewayContext>( | ||
| '/users', | ||
| (req: Request<APIGatewayContext>, res: Response<void>) => { | ||
| res.status(200).send(); | ||
| } | ||
| ); | ||
|
|
||
| const middleware: Middleware = (req, res, next) => { | ||
| api.options<void, APIGatewayContext>( | ||
| '/users', | ||
| (req: Request<APIGatewayContext>, res: Response<void>) => { | ||
| res.header('Allow', 'GET, POST, PUT, DELETE').status(204).send(); | ||
| } | ||
| ); | ||
|
|
||
| api.any<{ method: string }, APIGatewayContext>( | ||
| '/wildcard', | ||
| (req: Request<APIGatewayContext>, res: Response<{ method: string }>) => { | ||
| expectType<string>(req.method); | ||
| res.send({ method: req.method }); | ||
| } | ||
| ); | ||
| }; | ||
|
|
||
| const pathSpecificMiddleware: Middleware<any, RequestContext> = ( | ||
| req, | ||
| res, | ||
| next | ||
| ) => { | ||
| req.log.info('Path-specific middleware'); | ||
| next(); | ||
| }; | ||
| expectType<Middleware>(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<RequestContext> { | ||
| custom1: string; | ||
| } | ||
|
|
||
| interface RequestWithCustom2 extends Request<RequestContext> { | ||
| custom2: string; | ||
| } | ||
|
|
||
| const middleware1: Middleware<any, RequestContext> = (req, res, next) => { | ||
| (req as RequestWithCustom1).custom1 = 'value1'; | ||
| next(); | ||
| }; | ||
| expectType<ErrorHandlingMiddleware>(errorMiddleware); | ||
|
|
||
| const handler: HandlerFunction = (req, res) => { | ||
| res.json({ success: true }); | ||
| const middleware2: Middleware<any, RequestContext> = (req, res, next) => { | ||
| (req as RequestWithCustom2).custom2 = 'value2'; | ||
| next(); | ||
| }; | ||
| expectType<HandlerFunction>(handler); | ||
|
|
||
| const cookieOptions: CookieOptions = { | ||
| domain: 'example.com', | ||
| httpOnly: true, | ||
| secure: true, | ||
| sameSite: 'Strict', | ||
| api.use(middleware1, middleware2); | ||
|
|
||
| const handlerWithCustomProps: HandlerFunction<any, RequestContext> = ( | ||
| req, | ||
| res | ||
| ) => { | ||
| if ('custom1' in req) { | ||
| expectType<string>((req as RequestWithCustom1).custom1); | ||
| } | ||
| if ('custom2' in req) { | ||
| expectType<string>((req as RequestWithCustom2).custom2); | ||
| } | ||
| res.send({ status: 'ok' }); | ||
| }; | ||
| expectType<CookieOptions>(cookieOptions); | ||
|
|
||
| const corsOptions: CorsOptions = { | ||
| origin: '*', | ||
| methods: 'GET,POST', | ||
| headers: 'Content-Type,Authorization', | ||
| credentials: true, | ||
| const testRequestProperties: HandlerFunction<any, RequestContext> = ( | ||
| req, | ||
| res | ||
| ) => { | ||
| expectType<string>(req.id); | ||
| expectType<string>(req.method); | ||
| expectType<string>(req.path); | ||
| expectType<Record<string, string | undefined>>(req.query); | ||
| expectType<Record<string, string | undefined>>(req.headers); | ||
| expectType<string>(req.ip); | ||
| expectType<string>(req.userAgent); | ||
| expectType<'desktop' | 'mobile' | 'tv' | 'tablet' | 'unknown'>( | ||
| req.clientType | ||
| ); | ||
| expectType<string>(req.clientCountry); | ||
| expectType<boolean>(req.coldStart); | ||
| expectType<number>(req.requestCount); | ||
| expectType<'apigateway' | 'alb'>(req.interface); | ||
| expectType<string | undefined>(req.payloadVersion); | ||
| expectType<App>(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>(corsOptions); | ||
|
|
||
| const fileOptions: FileOptions = { | ||
| maxAge: 3600, | ||
| root: '/public', | ||
| lastModified: true, | ||
| headers: { 'Cache-Control': 'public' }, | ||
| const testResponseMethods: HandlerFunction<any, RequestContext> = ( | ||
| 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<string>(res.getHeader('X-Custom')); | ||
| expectType<{ [key: string]: string }>(res.getHeaders()); | ||
| expectType<boolean>(res.hasHeader('X-Custom')); | ||
| res.removeHeader('X-Custom'); | ||
|
|
||
| res.send({ data: 'raw' }); | ||
| res.json({ data: 'json' }); | ||
| res.jsonp({ data: 'jsonp' }); | ||
| res.html('<div>html</div>'); | ||
| 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>(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>(loggerOptions); | ||
|
|
||
| const methods: METHODS[] = [ | ||
| 'GET', | ||
| 'POST', | ||
| 'PUT', | ||
| 'DELETE', | ||
| 'OPTIONS', | ||
| 'HEAD', | ||
| 'ANY', | ||
| ]; | ||
| expectType<METHODS[]>(methods); | ||
|
|
||
| const routeError = new RouteError('Route not found', '/api/test'); | ||
| expectType<RouteError>(routeError); | ||
|
|
||
| const methodError = new MethodError( | ||
| 'Method not allowed', | ||
| 'POST' as METHODS, | ||
| '/api/test' | ||
| ); | ||
| expectType<MethodError>(methodError); | ||
|
|
||
| const configError = new ConfigurationError('Invalid configuration'); | ||
| expectType<ConfigurationError>(configError); | ||
| const testDefaultTypes = () => { | ||
| api.get('/simple', (req: Request<APIGatewayContext>, res: Response) => { | ||
| expectType<Request<APIGatewayContext>>(req); | ||
| expectType<Response>(res); | ||
| expectType<APIGatewayContext>(req.requestContext); | ||
| expectType<Record<string, string | undefined>>(req.query); | ||
| expectType<Record<string, string | undefined>>(req.params); | ||
| expectType<any>(req.body); | ||
| res.json({ message: 'ok' }); | ||
| }); | ||
|
|
||
| const responseError = new ResponseError('Response error', 500); | ||
| expectType<ResponseError>(responseError); | ||
| const simpleMiddleware: Middleware = ( | ||
| req: Request<APIGatewayContext>, | ||
| res: Response, | ||
| next: NextFunction | ||
| ) => { | ||
| expectType<Request<APIGatewayContext>>(req); | ||
| expectType<Response>(res); | ||
| expectType<APIGatewayContext>(req.requestContext); | ||
| next(); | ||
| }; | ||
|
|
||
| const fileError = new FileError('File not found', { | ||
| code: 'ENOENT', | ||
| syscall: 'open', | ||
| }); | ||
| expectType<FileError>(fileError); | ||
| const simpleErrorHandler: ErrorHandlingMiddleware = ( | ||
| error: Error, | ||
| req: Request<APIGatewayContext>, | ||
| res: Response, | ||
| next: NextFunction | ||
| ) => { | ||
| expectType<Request<APIGatewayContext>>(req); | ||
| expectType<Response>(res); | ||
| expectType<APIGatewayContext>(req.requestContext); | ||
| res.status(500).json({ error: error.message }); | ||
| }; | ||
|
|
||
| api.post( | ||
| '/simple-chain', | ||
| simpleMiddleware, | ||
| (req: Request<APIGatewayContext>, res: Response) => { | ||
| expectType<Request<APIGatewayContext>>(req); | ||
| expectType<Response>(res); | ||
| res.json({ status: 'ok' }); | ||
| } | ||
| ); | ||
|
|
||
| api.use( | ||
| (req: Request<APIGatewayContext>, res: Response, next: NextFunction) => { | ||
| expectType<Request<APIGatewayContext>>(req); | ||
| expectType<Response>(res); | ||
| next(); | ||
| } | ||
| ); | ||
|
|
||
| api.use( | ||
| '/path', | ||
| (req: Request<APIGatewayContext>, res: Response, next: NextFunction) => { | ||
| expectType<Request<APIGatewayContext>>(req); | ||
| expectType<Response>(res); | ||
| next(); | ||
| } | ||
| ); | ||
|
|
||
| api.finally((req: Request<APIGatewayContext>, res: Response) => { | ||
| expectType<Request<APIGatewayContext>>(req); | ||
| expectType<Response>(res); | ||
| }); | ||
|
|
||
| const runResult = api.run({} as APIGatewayProxyEvent, {} as Context); | ||
| expectType<Promise<any>>(runResult); | ||
|
|
||
| api.run({} as APIGatewayProxyEvent, {} as Context, (err: Error, res: any) => { | ||
| expectType<Error>(err); | ||
| expectType<any>(res); | ||
| }); | ||
|
|
||
| const albApi = new API(); | ||
| albApi.get('/alb-default', (req: Request<ALBContext>, res: Response) => { | ||
| if (isAlbContext(req.requestContext)) { | ||
| expectType<ALBContext>(req.requestContext); | ||
| expectType<{ targetGroupArn: string }>(req.requestContext.elb); | ||
| expectType<Record<string, string | undefined>>(req.query); | ||
| expectType<Record<string, string | undefined>>(req.params); | ||
| expectType<any>(req.body); | ||
| res.json({ message: 'ALB response' }); | ||
| } | ||
| }); | ||
|
|
||
| const albResult = albApi.run({} as ALBEvent, {} as Context); | ||
| expectType<Promise<any>>(albResult); | ||
|
|
||
| const apiGwV2Api = new API(); | ||
| apiGwV2Api.get( | ||
| '/apigw-v2-default', | ||
| (req: Request<APIGatewayV2Context>, res: Response) => { | ||
| if (isApiGatewayV2Context(req.requestContext)) { | ||
| expectType<APIGatewayV2Context>(req.requestContext); | ||
| expectType<string>(req.requestContext.accountId); | ||
| expectType<Record<string, string | undefined>>(req.query); | ||
| expectType<Record<string, string | undefined>>(req.params); | ||
| expectType<any>(req.body); | ||
| res.json({ message: 'API Gateway V2 response' }); | ||
| } | ||
| } | ||
| ); | ||
|
|
||
| const apiGwV2Result = apiGwV2Api.run( | ||
| {} as APIGatewayProxyEventV2, | ||
| {} as Context | ||
| ); | ||
| expectType<Promise<any>>(apiGwV2Result); | ||
| }; | ||
|
|
||
| const testSourceAgnosticTypes = () => { | ||
| // Test source-agnostic handler with minimal type parameters | ||
| const simpleSourceAgnosticHandler: SourceAgnosticHandler = (req, res) => { | ||
| expectType<RequestContext>(req.requestContext); | ||
| res.json({ message: 'ok' }); | ||
| }; | ||
|
|
||
| // Test source-agnostic handler with response type | ||
| const typedSourceAgnosticHandler: SourceAgnosticHandler<UserResponse> = ( | ||
| 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<RequestContext>(req.requestContext); | ||
| next(); | ||
| }; | ||
|
|
||
| // Test source-agnostic error handler | ||
| const sourceAgnosticErrorHandler: SourceAgnosticErrorHandler = ( | ||
| error, | ||
| req, | ||
| res, | ||
| next | ||
| ) => { | ||
| expectType<RequestContext>(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); | ||
| }; |
There was a problem hiding this comment.
The updated index.test-d.ts removes type-level test coverage for several previously-tested exports: CookieOptions, CorsOptions, FileOptions, Options, METHODS, RouteError, MethodError, ConfigurationError, ResponseError, and FileError. These types still exist in index.d.ts but are no longer validated by any type test. Since this project uses tsd for type testing, these exports should remain covered to prevent future regressions.
| Request, | ||
| Response, | ||
| SourceAgnosticHandler, | ||
| SourceAgnosticMiddleware, |
There was a problem hiding this comment.
The TypeScript Support code example in the README uses APIGatewayContext at line 103, but this type is not included in the import statement shown at lines 42-50. Users copying this example would get a TypeScript error. The import block should include APIGatewayContext (and potentially ALBContext for completeness).
| SourceAgnosticMiddleware, | |
| SourceAgnosticMiddleware, | |
| APIGatewayContext, | |
| ALBContext, |
| ### 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 |
There was a problem hiding this comment.
The "Type Safety Examples" code snippet uses UserType at line 1748, but this type is never defined in the code snippet or the surrounding context. This would cause a TypeScript compile error for anyone copying this example. It should be replaced with an actual type definition (e.g., defining interface UserType { id: string; roles: string[]; email: string; } above or using an existing type like UserResponse).
| ```typescript | |
| ```typescript | |
| interface UserType { | |
| id: string; | |
| roles: string[]; | |
| email: string; | |
| } |
| api.get<Response, APIGatewayRequestContext>('/api-gateway', (req, res) => { | ||
| console.log(req.requestContext.identity); | ||
| }); | ||
|
|
||
| api.get<Response, ALBRequestContext>('/alb', (req, res) => { |
There was a problem hiding this comment.
The README's second "Handling Multiple Request Sources" section (near the end of the file) uses the non-existent type names APIGatewayRequestContext and ALBRequestContext as type parameters. The correct exported types are APIGatewayContext and ALBContext respectively. Additionally, Response is used as the first type parameter (the response body type), but Response is the response class, not a response body shape. The earlier section of the same README (around line 173) correctly uses APIGatewayContext and ALBContext, making this a clear inconsistency in the documentation.
| api.get<Response, APIGatewayRequestContext>('/api-gateway', (req, res) => { | |
| console.log(req.requestContext.identity); | |
| }); | |
| api.get<Response, ALBRequestContext>('/alb', (req, res) => { | |
| api.get<{ message: string }, APIGatewayContext>('/api-gateway', (req, res) => { | |
| console.log(req.requestContext.identity); | |
| }); | |
| api.get<{ message: string }, ALBContext>('/alb', (req, res) => { |
| ## Handling Multiple Request Sources | ||
|
|
||
| ```typescript | ||
| import { | ||
| isApiGatewayContext, | ||
| isApiGatewayV2Context, | ||
| isAlbContext, | ||
| } from 'lambda-api'; | ||
|
|
||
| api.get<Response, APIGatewayRequestContext>('/api-gateway', (req, res) => { | ||
| console.log(req.requestContext.identity); | ||
| }); | ||
|
|
||
| api.get<Response, ALBRequestContext>('/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); | ||
| } | ||
| }); | ||
| ``` |
There was a problem hiding this comment.
The section heading "## Handling Multiple Request Sources" appears twice in the README — at line 160 and line 1791. This creates a duplicate heading with conflicting content, where the TOC entry at line 295 can only link to one of them. The second occurrence (line 1791) only contains a short code snippet and duplicates content already present in the first. The duplicate section should be merged or removed to avoid confusion.
| interface UserQuery { | ||
| fields: string; | ||
| } | ||
| interface UserParams { | ||
| id: string; | ||
| } | ||
| interface UserBody { | ||
| name: string; | ||
| email: string; | ||
| } |
There was a problem hiding this comment.
The UserQuery and UserParams interfaces defined in the README TypeScript example (lines 92-101) are not compatible with the generic type constraint TQuery extends Record<string, string | undefined>. The interfaces declare non-optional string properties (fields: string, id: string), but the constraint requires values to be string | undefined. Using these definitions at line 103 would cause a TypeScript compile error. The properties should be declared as optional (e.g., fields?: string) or the interfaces should extend Record<string, string | undefined>, consistent with how they are defined in index.test-d.ts.
Basic Type-Safe Setup
Type-Safe Request Handlers
ALB Handler Example
API Gateway v1 Handler Example
API Gateway v2 Handler Example
Type-Safe Middleware
Source-Agnostic Middleware
Source-Specific Middleware
Type-Safe Error Handling
Advanced Type-Safe Examples
Custom Request Types
Response Type Extensions
Using Built-in Auth Property
Running the API
Type Guards Usage
Best Practices
Issues
implements #276
and closes #244