diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts new file mode 100644 index 000000000..aa86d9955 --- /dev/null +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -0,0 +1,174 @@ +import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; +import { isRecord } from '@aws-lambda-powertools/commons/typeutils'; +import { + getStringFromEnv, + isDevMode, +} from '@aws-lambda-powertools/commons/utils/env'; +import type { Context } from 'aws-lambda'; +import type { ResolveOptions } from '../types/index.js'; +import type { + HttpMethod, + Path, + RouteHandler, + RouteOptions, + RouterOptions, +} from '../types/rest.js'; +import { HttpVerbs } from './constatnts.js'; + +abstract class BaseRouter { + protected context: Record; + /** + * A logger instance to be used for logging debug, warning, and error messages. + * + * When no logger is provided, we'll only log warnings and errors using the global `console` object. + */ + protected readonly logger: Pick; + /** + * Whether the router is running in development mode. + */ + protected readonly isDev: boolean = false; + + public constructor(options?: RouterOptions) { + this.context = {}; + const alcLogLevel = getStringFromEnv({ + key: 'AWS_LAMBDA_LOG_LEVEL', + defaultValue: '', + }); + this.logger = options?.logger ?? { + debug: alcLogLevel === 'DEBUG' ? console.debug : () => undefined, + error: console.error, + warn: console.warn, + }; + this.isDev = isDevMode(); + } + + public abstract resolve( + event: unknown, + context: Context, + options?: ResolveOptions + ): Promise; + + public abstract route(handler: RouteHandler, options: RouteOptions): void; + + #handleHttpMethod( + method: HttpMethod, + path: Path, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + if (handler && typeof handler === 'function') { + this.route(handler, { ...(options || {}), method, path }); + return; + } + + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + const routeOptions = isRecord(handler) ? handler : options; + this.route(descriptor.value, { ...(routeOptions || {}), method, path }); + return descriptor; + }; + } + + public get(path: string, handler: RouteHandler, options?: RouteOptions): void; + public get(path: string, options?: RouteOptions): MethodDecorator; + public get( + path: Path, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod(HttpVerbs.GET, path, handler, options); + } + + public post(path: Path, handler: RouteHandler, options?: RouteOptions): void; + public post(path: Path, options?: RouteOptions): MethodDecorator; + public post( + path: Path, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod(HttpVerbs.POST, path, handler, options); + } + + public put(path: Path, handler: RouteHandler, options?: RouteOptions): void; + public put(path: Path, options?: RouteOptions): MethodDecorator; + public put( + path: Path, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod(HttpVerbs.PUT, path, handler, options); + } + + public patch(path: Path, handler: RouteHandler, options?: RouteOptions): void; + public patch(path: Path, options?: RouteOptions): MethodDecorator; + public patch( + path: Path, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod(HttpVerbs.PATCH, path, handler, options); + } + + public delete( + path: Path, + handler: RouteHandler, + options?: RouteOptions + ): void; + public delete(path: Path, options?: RouteOptions): MethodDecorator; + public delete( + path: Path, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod(HttpVerbs.DELETE, path, handler, options); + } + + public head(path: Path, handler: RouteHandler, options?: RouteOptions): void; + public head(path: Path, options?: RouteOptions): MethodDecorator; + public head( + path: Path, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod(HttpVerbs.HEAD, path, handler, options); + } + + public options( + path: Path, + handler: RouteHandler, + options?: RouteOptions + ): void; + public options(path: Path, options?: RouteOptions): MethodDecorator; + public options( + path: Path, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod(HttpVerbs.OPTIONS, path, handler, options); + } + + public connect( + path: Path, + handler: RouteHandler, + options?: RouteOptions + ): void; + public connect(path: Path, options?: RouteOptions): MethodDecorator; + public connect( + path: Path, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod(HttpVerbs.CONNECT, path, handler, options); + } + + public trace(path: Path, handler: RouteHandler, options?: RouteOptions): void; + public trace(path: Path, options?: RouteOptions): MethodDecorator; + public trace( + path: Path, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod(HttpVerbs.TRACE, path, handler, options); + } +} + +export { BaseRouter }; diff --git a/packages/event-handler/src/rest/constatnts.ts b/packages/event-handler/src/rest/constatnts.ts new file mode 100644 index 000000000..7a62de7cb --- /dev/null +++ b/packages/event-handler/src/rest/constatnts.ts @@ -0,0 +1,11 @@ +export const HttpVerbs = { + CONNECT: 'CONNECT', + TRACE: 'TRACE', + GET: 'GET', + POST: 'POST', + PUT: 'PUT', + PATCH: 'PATCH', + DELETE: 'DELETE', + HEAD: 'HEAD', + OPTIONS: 'OPTIONS', +} as const; diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts new file mode 100644 index 000000000..78673dfc1 --- /dev/null +++ b/packages/event-handler/src/types/rest.ts @@ -0,0 +1,29 @@ +import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; +import type { BaseRouter } from '../rest/BaseRouter.js'; +import type { HttpVerbs } from '../rest/constatnts.js'; + +/** + * Options for the {@link BaseRouter} class + */ +type RouterOptions = { + /** + * A logger instance to be used for logging debug, warning, and error messages. + * + * When no logger is provided, we'll only log warnings and errors using the global `console` object. + */ + logger?: GenericLogger; +}; + +// biome-ignore lint/suspicious/noExplicitAny: we want to keep arguments and return types as any to accept any type of function +type RouteHandler = (...args: T[]) => R; + +type HttpMethod = keyof typeof HttpVerbs; + +type Path = `/${string}`; + +type RouteOptions = { + method?: HttpMethod; + path?: Path; +}; + +export type { HttpMethod, Path, RouterOptions, RouteHandler, RouteOptions }; diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts new file mode 100644 index 000000000..facd09943 --- /dev/null +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -0,0 +1,200 @@ +import context from '@aws-lambda-powertools/testing-utils/context'; +import type { Context } from 'aws-lambda'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BaseRouter } from '../../../src/rest/BaseRouter.js'; +import { HttpVerbs } from '../../../src/rest/constatnts.js'; +import type { ResolveOptions } from '../../../src/types/index.js'; +import type { + HttpMethod, + RouteHandler, + RouteOptions, + RouterOptions, +} from '../../../src/types/rest.js'; + +describe('Class: BaseRouter', () => { + class TestResolver extends BaseRouter { + public readonly handlers: Map = new Map(); + + constructor(options?: RouterOptions) { + super(options); + this.logger.debug('test debug'); + this.logger.warn('test warn'); + this.logger.error('test error'); + } + + #isEvent(obj: unknown): asserts obj is { path: string; method: string } { + if ( + typeof obj !== 'object' || + obj === null || + !('path' in obj) || + !('method' in obj) + ) { + throw new Error('Invalid event object'); + } + } + + public route(handler: RouteHandler, options: RouteOptions) { + if (options.path == null || options.method == null) + throw new Error('path or method cannot be null'); + this.handlers.set(options.path + options.method, handler); + } + + public resolve( + event: unknown, + context: Context, + options?: ResolveOptions + ): Promise { + this.#isEvent(event); + const { method, path } = event; + const handler = this.handlers.get(path + method); + if (handler == null) throw new Error('404'); + return handler(event, context); + } + } + + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + ['GET', 'get'], + ['POST', 'post'], + ['PUT', 'put'], + ['PATCH', 'patch'], + ['DELETE', 'delete'], + ['HEAD', 'head'], + ['OPTIONS', 'options'], + ['TRACE', 'trace'], + ['CONNECT', 'connect'], + ])('routes %s requests', async (method, verb) => { + // Prepare + const app = new TestResolver(); + (app[verb as Lowercase] as Function)( + '/test', + () => `${verb}-test` + ); + // Act + const actual = await app.resolve({ path: '/test', method }, context); + // Assess + expect(actual).toEqual(`${verb}-test`); + }); + + it('uses the global console when no logger is not provided', () => { + // Act + const app = new TestResolver(); + app.route(() => true, { path: '/', method: HttpVerbs.GET }); + + // Assess + expect(console.debug).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith('test error'); + expect(console.warn).toHaveBeenCalledWith('test warn'); + }); + + it('emits debug logs using global console when the log level is set to `DEBUG` and a logger is not provided', () => { + // Prepare + vi.stubEnv('AWS_LAMBDA_LOG_LEVEL', 'DEBUG'); + + // Act + const app = new TestResolver(); + app.route(() => true, { path: '/', method: HttpVerbs.GET }); + + // Assess + expect(console.debug).toHaveBeenCalledWith('test debug'); + expect(console.error).toHaveBeenCalledWith('test error'); + expect(console.warn).toHaveBeenCalledWith('test warn'); + }); + + it('uses a custom logger when provided', () => { + // Prepare + vi.stubEnv('AWS_LAMBDA_LOG_LEVEL', 'DEBUG'); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + // Act + const app = new TestResolver({ logger }); + app.route(() => true, { path: '/', method: HttpVerbs.GET }); + + // Assess + expect(logger.error).toHaveBeenCalledWith('test error'); + expect(logger.warn).toHaveBeenCalledWith('test warn'); + expect(logger.debug).toHaveBeenCalledWith('test debug'); + }); + + describe('decorators', () => { + const app = new TestResolver(); + + class Lambda { + @app.get('/test', {}) + public async getTest() { + return 'get-test'; + } + + @app.post('/test') + public async postTest() { + return 'post-test'; + } + + @app.put('/test') + public async putTest() { + return 'put-test'; + } + + @app.patch('/test') + public async patchTest() { + return 'patch-test'; + } + + @app.delete('/test') + public async deleteTest() { + return 'delete-test'; + } + + @app.head('/test') + public async headTest() { + return 'head-test'; + } + + @app.options('/test') + public async optionsTest() { + return 'options-test'; + } + + @app.trace('/test') + public async traceTest() { + return 'trace-test'; + } + + @app.connect('/test') + public async connectTest() { + return 'connect-test'; + } + + public async handler(event: unknown, context: Context) { + return app.resolve(event, context, {}); + } + } + + it.each([ + ['GET', 'get-test'], + ['POST', 'post-test'], + ['PUT', 'put-test'], + ['PATCH', 'patch-test'], + ['DELETE', 'delete-test'], + ['HEAD', 'head-test'], + ['OPTIONS', 'options-test'], + ['TRACE', 'trace-test'], + ['CONNECT', 'connect-test'], + ])('routes %s requests with decorators', async (method, expected) => { + // Prepare + const lambda = new Lambda(); + // Act + const actual = await lambda.handler({ path: '/test', method }, context); + // Assess + expect(actual).toEqual(expected); + }); + }); +});