From 8a3ea37a88d6d46a6809d1f669b87483bf9e9af8 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Fri, 23 May 2025 19:19:46 +0200 Subject: [PATCH 01/16] feat(event-handler): add base router class --- packages/event-handler/src/rest/BaseRouter.ts | 70 +++++++++++++++++++ packages/event-handler/src/rest/Router.ts | 1 + packages/event-handler/src/types/rest.ts | 24 +++++++ 3 files changed, 95 insertions(+) create mode 100644 packages/event-handler/src/rest/BaseRouter.ts create mode 100644 packages/event-handler/src/rest/Router.ts create mode 100644 packages/event-handler/src/types/rest.ts diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts new file mode 100644 index 0000000000..96dce121ac --- /dev/null +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -0,0 +1,70 @@ +import { isRecord } from '@aws-lambda-powertools/commons/typeutils'; +import { + getStringFromEnv, + isDevMode, +} from '@aws-lambda-powertools/commons/utils/env'; +import type { GenericLogger } from '../types/appsync-events.js'; +import type { + RouteHandler, + RouteOptions, + RouterOptions, +} from '../types/rest.js'; + +abstract class BaseRouter { + protected context: Record; // TODO: should this be a map instead? + /** + * 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 route(handler: RouteHandler, options: RouteOptions): void; + + public get(path: string, handler: RouteHandler, options?: RouteOptions): void; + public get(path: string, options?: RouteOptions): MethodDecorator; + public get( + path: string, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + if (handler && typeof handler === 'function') { + this.route(handler, { + ...(options || {}), + method: 'GET', + path, + }); + return; + } + + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + const routeOptions = isRecord(handler) ? handler : options; + this.route(descriptor.value, { + ...(routeOptions || {}), + method: 'GET', + path, + }); + return descriptor; + }; + } +} + +export { BaseRouter }; diff --git a/packages/event-handler/src/rest/Router.ts b/packages/event-handler/src/rest/Router.ts new file mode 100644 index 0000000000..2a504bee37 --- /dev/null +++ b/packages/event-handler/src/rest/Router.ts @@ -0,0 +1 @@ +abstract class BaseRouter {} diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts new file mode 100644 index 0000000000..380e48a89d --- /dev/null +++ b/packages/event-handler/src/types/rest.ts @@ -0,0 +1,24 @@ +import type { BaseRouter } from '../rest/BaseRouter.js'; +import type { GenericLogger } from './appsync-events.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 RouteOptions = { + method?: string; + path?: string; +}; + +export type { RouterOptions, RouteHandler, RouteOptions }; From 7f6d0f6d69e85bfab3ac9e8094d94b30c02a4aaa Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Fri, 23 May 2025 19:23:10 +0200 Subject: [PATCH 02/16] chore: remove file --- packages/event-handler/src/rest/Router.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 packages/event-handler/src/rest/Router.ts diff --git a/packages/event-handler/src/rest/Router.ts b/packages/event-handler/src/rest/Router.ts deleted file mode 100644 index 2a504bee37..0000000000 --- a/packages/event-handler/src/rest/Router.ts +++ /dev/null @@ -1 +0,0 @@ -abstract class BaseRouter {} From 98512983ed8cf466d4bd9149634d50e5b2dfbde7 Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 14 Jul 2025 00:08:35 +0100 Subject: [PATCH 03/16] add methods for all http verbs and unit tests for them --- packages/event-handler/src/rest/BaseRouter.ts | 113 +++++++++++-- packages/event-handler/src/types/rest.ts | 2 +- .../tests/unit/rest/BaseRouter.test.ts | 158 ++++++++++++++++++ 3 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 packages/event-handler/tests/unit/rest/BaseRouter.test.ts diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index 96dce121ac..3f328a342c 100644 --- a/packages/event-handler/src/rest/BaseRouter.ts +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -1,15 +1,27 @@ +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 { GenericLogger } from '../types/appsync-events.js'; +import type { Context } from 'aws-lambda'; +import type { ResolveOptions } from '../types/index.js'; import type { RouteHandler, RouteOptions, RouterOptions, } from '../types/rest.js'; +const HttpVerbs = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', +] as const; + abstract class BaseRouter { protected context: Record; // TODO: should this be a map instead? /** @@ -37,34 +49,107 @@ abstract class BaseRouter { this.isDev = isDevMode(); } + public abstract resolve( + event: unknown, + context: Context, + options?: ResolveOptions + ): Promise; + public abstract route(handler: RouteHandler, options: RouteOptions): void; - public get(path: string, handler: RouteHandler, options?: RouteOptions): void; - public get(path: string, options?: RouteOptions): MethodDecorator; - public get( + #handleHttpMethod( + method: (typeof HttpVerbs)[number], path: string, handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { if (handler && typeof handler === 'function') { - this.route(handler, { - ...(options || {}), - method: 'GET', - path, - }); + this.route(handler, { ...(options || {}), method, path }); return; } return (_target, _propertyKey, descriptor: PropertyDescriptor) => { const routeOptions = isRecord(handler) ? handler : options; - this.route(descriptor.value, { - ...(routeOptions || {}), - method: 'GET', - path, - }); + 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: string, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod('GET', path, handler, options); + } + + public post( + path: string, + handler: RouteHandler, + options?: RouteOptions + ): void; + public post(path: string, options?: RouteOptions): MethodDecorator; + public post( + path: string, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod('POST', path, handler, options); + } + + public put(path: string, handler: RouteHandler, options?: RouteOptions): void; + public put(path: string, options?: RouteOptions): MethodDecorator; + public put( + path: string, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod('PUT', path, handler, options); + } + + public patch( + path: string, + handler: RouteHandler, + options?: RouteOptions + ): void; + public patch(path: string, options?: RouteOptions): MethodDecorator; + public patch( + path: string, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod('PATCH', path, handler, options); + } + + public delete( + path: string, + handler: RouteHandler, + options?: RouteOptions + ): void; + public delete(path: string, options?: RouteOptions): MethodDecorator; + public delete( + path: string, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod('DELETE', path, handler, options); + } + + public head( + path: string, + handler: RouteHandler, + options?: RouteOptions + ): void; + public head(path: string, options?: RouteOptions): MethodDecorator; + public head( + path: string, + handler?: RouteHandler | RouteOptions, + options?: RouteOptions + ): MethodDecorator | undefined { + return this.#handleHttpMethod('HEAD', path, handler, options); + } } export { BaseRouter }; diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 380e48a89d..a48958efbf 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -1,5 +1,5 @@ +import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; import type { BaseRouter } from '../rest/BaseRouter.js'; -import type { GenericLogger } from './appsync-events.js'; /** * Options for the {@link BaseRouter} class 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 0000000000..04d6380f92 --- /dev/null +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -0,0 +1,158 @@ +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 type { ResolveOptions } from '../../../src/types/index.js'; +import type { + RouteHandler, + RouteOptions, + RouterOptions, +} from '../../../src/types/rest.js'; + +describe('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'], + ])('should route %s requests', async (method, verb) => { + const app = new TestResolver(); + ( + app[ + verb as 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' + ] as Function + )('test', () => `${verb}-test`); + const actual = await app.resolve({ path: 'test', method }, context); + expect(actual).toEqual(`${verb}-test`); + }); + + it('should use console.warn and console,error when logger is not provided', () => { + const app = new TestResolver(); + expect(console.debug).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith('test error'); + expect(console.warn).toHaveBeenCalledWith('test warn'); + }); + + it('should use console.debug in DEBUG mode when logger is not provided', () => { + vi.stubEnv('AWS_LAMBDA_LOG_LEVEL', 'DEBUG'); + const app = new TestResolver(); + expect(console.debug).toHaveBeenCalledWith('test debug'); + expect(console.error).toHaveBeenCalledWith('test error'); + expect(console.warn).toHaveBeenCalledWith('test warn'); + }); + + it('should use custom logger when provided', () => { + vi.stubEnv('AWS_LAMBDA_LOG_LEVEL', 'DEBUG'); + + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + const app = new TestResolver({ logger }); + 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'; + } + + 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'], + ])('should route %s requests with decorators', async (method, expected) => { + const lambda = new Lambda(); + const actual = await lambda.handler({ path: 'test', method }, context); + expect(actual).toEqual(expected); + }); + }); +}); From 1711ad1fca00c52910c554f30ecdcba2564284a9 Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 14 Jul 2025 10:36:18 +0100 Subject: [PATCH 04/16] address PR comments --- packages/event-handler/src/rest/BaseRouter.ts | 15 +++------------ packages/event-handler/src/rest/constatnts.ts | 11 +++++++++++ packages/event-handler/src/types/rest.ts | 9 ++++++--- .../tests/unit/rest/BaseRouter.test.ts | 8 ++++---- 4 files changed, 24 insertions(+), 19 deletions(-) create mode 100644 packages/event-handler/src/rest/constatnts.ts diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index 3f328a342c..838e36cf8e 100644 --- a/packages/event-handler/src/rest/BaseRouter.ts +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -7,23 +7,14 @@ import { import type { Context } from 'aws-lambda'; import type { ResolveOptions } from '../types/index.js'; import type { + HttpMethod, RouteHandler, RouteOptions, RouterOptions, } from '../types/rest.js'; -const HttpVerbs = [ - 'GET', - 'POST', - 'PUT', - 'PATCH', - 'DELETE', - 'HEAD', - 'OPTIONS', -] as const; - abstract class BaseRouter { - protected context: Record; // TODO: should this be a map instead? + protected context: Record; /** * A logger instance to be used for logging debug, warning, and error messages. * @@ -58,7 +49,7 @@ abstract class BaseRouter { public abstract route(handler: RouteHandler, options: RouteOptions): void; #handleHttpMethod( - method: (typeof HttpVerbs)[number], + method: HttpMethod, path: string, handler?: RouteHandler | RouteOptions, options?: RouteOptions diff --git a/packages/event-handler/src/rest/constatnts.ts b/packages/event-handler/src/rest/constatnts.ts new file mode 100644 index 0000000000..4a380cb4ed --- /dev/null +++ b/packages/event-handler/src/rest/constatnts.ts @@ -0,0 +1,11 @@ +export const HttpVerbs = [ + 'CONNECT', + 'TRACE', + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', +] as const; diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index a48958efbf..44045e0abc 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -1,5 +1,6 @@ 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 @@ -16,9 +17,11 @@ type RouterOptions = { // 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 = (typeof HttpVerbs)[number]; + type RouteOptions = { - method?: string; - path?: string; + method?: HttpMethod; + path?: `/${string}`; }; -export type { RouterOptions, RouteHandler, RouteOptions }; +export type { HttpMethod, 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 index 04d6380f92..0ce2464abd 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -9,7 +9,7 @@ import type { RouterOptions, } from '../../../src/types/rest.js'; -describe('BaseRouter', () => { +describe('Class: BaseRouter', () => { class TestResolver extends BaseRouter { public readonly handlers: Map = new Map(); @@ -73,7 +73,7 @@ describe('BaseRouter', () => { }); it('should use console.warn and console,error when logger is not provided', () => { - const app = new TestResolver(); + new TestResolver(); expect(console.debug).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith('test error'); expect(console.warn).toHaveBeenCalledWith('test warn'); @@ -81,7 +81,7 @@ describe('BaseRouter', () => { it('should use console.debug in DEBUG mode when logger is not provided', () => { vi.stubEnv('AWS_LAMBDA_LOG_LEVEL', 'DEBUG'); - const app = new TestResolver(); + new TestResolver(); expect(console.debug).toHaveBeenCalledWith('test debug'); expect(console.error).toHaveBeenCalledWith('test error'); expect(console.warn).toHaveBeenCalledWith('test warn'); @@ -97,7 +97,7 @@ describe('BaseRouter', () => { error: vi.fn(), }; - const app = new TestResolver({ logger }); + new TestResolver({ logger }); expect(logger.error).toHaveBeenCalledWith('test error'); expect(logger.warn).toHaveBeenCalledWith('test warn'); expect(logger.debug).toHaveBeenCalledWith('test debug'); From c50d16c90b5ffaeea491867bc33a8692cf2457e8 Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 14 Jul 2025 11:21:35 +0100 Subject: [PATCH 05/16] add Path type to enfoce opening slash --- packages/event-handler/src/rest/BaseRouter.ts | 47 +++++++------------ packages/event-handler/src/types/rest.ts | 6 ++- .../tests/unit/rest/BaseRouter.test.ts | 18 +++---- 3 files changed, 31 insertions(+), 40 deletions(-) diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index 838e36cf8e..732d270355 100644 --- a/packages/event-handler/src/rest/BaseRouter.ts +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -8,6 +8,7 @@ import type { Context } from 'aws-lambda'; import type { ResolveOptions } from '../types/index.js'; import type { HttpMethod, + Path, RouteHandler, RouteOptions, RouterOptions, @@ -50,7 +51,7 @@ abstract class BaseRouter { #handleHttpMethod( method: HttpMethod, - path: string, + path: Path, handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { @@ -69,45 +70,37 @@ abstract class BaseRouter { public get(path: string, handler: RouteHandler, options?: RouteOptions): void; public get(path: string, options?: RouteOptions): MethodDecorator; public get( - path: string, + path: Path, handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { return this.#handleHttpMethod('GET', path, handler, options); } + public post(path: Path, handler: RouteHandler, options?: RouteOptions): void; + public post(path: Path, options?: RouteOptions): MethodDecorator; public post( - path: string, - handler: RouteHandler, - options?: RouteOptions - ): void; - public post(path: string, options?: RouteOptions): MethodDecorator; - public post( - path: string, + path: Path, handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { return this.#handleHttpMethod('POST', path, handler, options); } - public put(path: string, handler: RouteHandler, options?: RouteOptions): void; - public put(path: string, options?: RouteOptions): MethodDecorator; + public put(path: Path, handler: RouteHandler, options?: RouteOptions): void; + public put(path: Path, options?: RouteOptions): MethodDecorator; public put( - path: string, + path: Path, handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { return this.#handleHttpMethod('PUT', path, handler, options); } + public patch(path: Path, handler: RouteHandler, options?: RouteOptions): void; + public patch(path: Path, options?: RouteOptions): MethodDecorator; public patch( - path: string, - handler: RouteHandler, - options?: RouteOptions - ): void; - public patch(path: string, options?: RouteOptions): MethodDecorator; - public patch( - path: string, + path: Path, handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { @@ -115,27 +108,23 @@ abstract class BaseRouter { } public delete( - path: string, + path: Path, handler: RouteHandler, options?: RouteOptions ): void; - public delete(path: string, options?: RouteOptions): MethodDecorator; + public delete(path: Path, options?: RouteOptions): MethodDecorator; public delete( - path: string, + path: Path, handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { return this.#handleHttpMethod('DELETE', path, handler, options); } + public head(path: Path, handler: RouteHandler, options?: RouteOptions): void; + public head(path: Path, options?: RouteOptions): MethodDecorator; public head( - path: string, - handler: RouteHandler, - options?: RouteOptions - ): void; - public head(path: string, options?: RouteOptions): MethodDecorator; - public head( - path: string, + path: Path, handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 44045e0abc..443ebfe301 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -19,9 +19,11 @@ type RouteHandler = (...args: T[]) => R; type HttpMethod = (typeof HttpVerbs)[number]; +type Path = `/${string}`; + type RouteOptions = { method?: HttpMethod; - path?: `/${string}`; + path?: Path; }; -export type { HttpMethod, RouterOptions, RouteHandler, RouteOptions }; +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 index 0ce2464abd..a760abbef1 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -67,8 +67,8 @@ describe('Class: BaseRouter', () => { app[ verb as 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' ] as Function - )('test', () => `${verb}-test`); - const actual = await app.resolve({ path: 'test', method }, context); + )('/test', () => `${verb}-test`); + const actual = await app.resolve({ path: '/test', method }, context); expect(actual).toEqual(`${verb}-test`); }); @@ -107,32 +107,32 @@ describe('Class: BaseRouter', () => { const app = new TestResolver(); class Lambda { - @app.get('test', {}) + @app.get('/test', {}) public async getTest() { return 'get-test'; } - @app.post('test') + @app.post('/test') public async postTest() { return 'post-test'; } - @app.put('test') + @app.put('/test') public async putTest() { return 'put-test'; } - @app.patch('test') + @app.patch('/test') public async patchTest() { return 'patch-test'; } - @app.delete('test') + @app.delete('/test') public async deleteTest() { return 'delete-test'; } - @app.head('test') + @app.head('/test') public async headTest() { return 'head-test'; } @@ -151,7 +151,7 @@ describe('Class: BaseRouter', () => { ['HEAD', 'head-test'], ])('should route %s requests with decorators', async (method, expected) => { const lambda = new Lambda(); - const actual = await lambda.handler({ path: 'test', method }, context); + const actual = await lambda.handler({ path: '/test', method }, context); expect(actual).toEqual(expected); }); }); From d7baca8e54be963110372c8bb2d2b20540e60173 Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 14 Jul 2025 18:31:47 +0100 Subject: [PATCH 06/16] use constants to get HTTP verbs --- packages/event-handler/src/rest/BaseRouter.ts | 13 ++++++----- packages/event-handler/src/rest/constatnts.ts | 22 +++++++++---------- packages/event-handler/src/types/rest.ts | 2 +- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index 732d270355..4fe190189e 100644 --- a/packages/event-handler/src/rest/BaseRouter.ts +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -13,6 +13,7 @@ import type { RouteOptions, RouterOptions, } from '../types/rest.js'; +import { HttpVerbs } from './constatnts.js'; abstract class BaseRouter { protected context: Record; @@ -74,7 +75,7 @@ abstract class BaseRouter { handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { - return this.#handleHttpMethod('GET', path, handler, options); + return this.#handleHttpMethod(HttpVerbs.GET, path, handler, options); } public post(path: Path, handler: RouteHandler, options?: RouteOptions): void; @@ -84,7 +85,7 @@ abstract class BaseRouter { handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { - return this.#handleHttpMethod('POST', path, handler, options); + return this.#handleHttpMethod(HttpVerbs.POST, path, handler, options); } public put(path: Path, handler: RouteHandler, options?: RouteOptions): void; @@ -94,7 +95,7 @@ abstract class BaseRouter { handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { - return this.#handleHttpMethod('PUT', path, handler, options); + return this.#handleHttpMethod(HttpVerbs.PUT, path, handler, options); } public patch(path: Path, handler: RouteHandler, options?: RouteOptions): void; @@ -104,7 +105,7 @@ abstract class BaseRouter { handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { - return this.#handleHttpMethod('PATCH', path, handler, options); + return this.#handleHttpMethod(HttpVerbs.PATCH, path, handler, options); } public delete( @@ -118,7 +119,7 @@ abstract class BaseRouter { handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { - return this.#handleHttpMethod('DELETE', path, handler, options); + return this.#handleHttpMethod(HttpVerbs.DELETE, path, handler, options); } public head(path: Path, handler: RouteHandler, options?: RouteOptions): void; @@ -128,7 +129,7 @@ abstract class BaseRouter { handler?: RouteHandler | RouteOptions, options?: RouteOptions ): MethodDecorator | undefined { - return this.#handleHttpMethod('HEAD', path, handler, options); + return this.#handleHttpMethod(HttpVerbs.HEAD, path, handler, options); } } diff --git a/packages/event-handler/src/rest/constatnts.ts b/packages/event-handler/src/rest/constatnts.ts index 4a380cb4ed..7a62de7cb1 100644 --- a/packages/event-handler/src/rest/constatnts.ts +++ b/packages/event-handler/src/rest/constatnts.ts @@ -1,11 +1,11 @@ -export const HttpVerbs = [ - 'CONNECT', - 'TRACE', - 'GET', - 'POST', - 'PUT', - 'PATCH', - 'DELETE', - 'HEAD', - 'OPTIONS', -] as const; +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 index 443ebfe301..78673dfc12 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -17,7 +17,7 @@ type RouterOptions = { // 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 = (typeof HttpVerbs)[number]; +type HttpMethod = keyof typeof HttpVerbs; type Path = `/${string}`; From 0cce09c41da710c33174e9c9d61d3ca688534bb5 Mon Sep 17 00:00:00 2001 From: Stefano Vozza Date: Mon, 14 Jul 2025 18:35:38 +0100 Subject: [PATCH 07/16] Updtae test name Co-authored-by: Andrea Amorosi --- packages/event-handler/tests/unit/rest/BaseRouter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index a760abbef1..a2a1ce0b19 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -61,7 +61,7 @@ describe('Class: BaseRouter', () => { ['PATCH', 'patch'], ['DELETE', 'delete'], ['HEAD', 'head'], - ])('should route %s requests', async (method, verb) => { + ])('routes %s requests', async (method, verb) => { const app = new TestResolver(); ( app[ From a553aada91267955f97e446b01e14f995f6b3ef9 Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 14 Jul 2025 18:50:18 +0100 Subject: [PATCH 08/16] add prepare/act/assess comments --- .../event-handler/tests/unit/rest/BaseRouter.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index a2a1ce0b19..026e0d8c00 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BaseRouter } from '../../../src/rest/BaseRouter.js'; import type { ResolveOptions } from '../../../src/types/index.js'; import type { + HttpMethod, RouteHandler, RouteOptions, RouterOptions, @@ -62,13 +63,16 @@ describe('Class: BaseRouter', () => { ['DELETE', 'delete'], ['HEAD', 'head'], ])('routes %s requests', async (method, verb) => { + // Prepare const app = new TestResolver(); ( app[ verb as 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' ] as Function )('/test', () => `${verb}-test`); + // Act const actual = await app.resolve({ path: '/test', method }, context); + // Assess expect(actual).toEqual(`${verb}-test`); }); @@ -149,9 +153,12 @@ describe('Class: BaseRouter', () => { ['PATCH', 'patch-test'], ['DELETE', 'delete-test'], ['HEAD', 'head-test'], - ])('should route %s requests with decorators', async (method, expected) => { + ])('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); }); }); From 8a2bfcfdae38bc13aa976b8672c0d08b12cb1108 Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 14 Jul 2025 19:30:43 +0100 Subject: [PATCH 09/16] add options, trace and connect methods --- packages/event-handler/src/rest/BaseRouter.ts | 38 +++++++++++++++++++ .../tests/unit/rest/BaseRouter.test.ts | 30 ++++++++++++--- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index 4fe190189e..aa86d99552 100644 --- a/packages/event-handler/src/rest/BaseRouter.ts +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -131,6 +131,44 @@ abstract class BaseRouter { ): 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/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index 026e0d8c00..ec8271a7ed 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -62,14 +62,16 @@ describe('Class: BaseRouter', () => { ['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 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' - ] as Function - )('/test', () => `${verb}-test`); + (app[verb as Lowercase] as Function)( + '/test', + () => `${verb}-test` + ); // Act const actual = await app.resolve({ path: '/test', method }, context); // Assess @@ -141,6 +143,21 @@ describe('Class: BaseRouter', () => { 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, {}); } @@ -153,6 +170,9 @@ describe('Class: BaseRouter', () => { ['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(); From 8888c3c0e9daeb73f1de8cf7138f8fc4cf06afeb Mon Sep 17 00:00:00 2001 From: Stefano Vozza Date: Mon, 14 Jul 2025 22:31:31 +0100 Subject: [PATCH 10/16] Update no logger global console test name Co-authored-by: Andrea Amorosi --- packages/event-handler/tests/unit/rest/BaseRouter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index ec8271a7ed..cbee368431 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -78,7 +78,7 @@ describe('Class: BaseRouter', () => { expect(actual).toEqual(`${verb}-test`); }); - it('should use console.warn and console,error when logger is not provided', () => { + it('uses the global console when no logger is not provided', () => { new TestResolver(); expect(console.debug).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith('test error'); From 5dd0f6cd08a2a837a907d1fe7aa3232ed471238d Mon Sep 17 00:00:00 2001 From: Stefano Vozza Date: Mon, 14 Jul 2025 22:32:14 +0100 Subject: [PATCH 11/16] Add route to console test to fix SQ issue Co-authored-by: Andrea Amorosi --- packages/event-handler/tests/unit/rest/BaseRouter.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index cbee368431..488e3c96b1 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -79,7 +79,11 @@ describe('Class: BaseRouter', () => { }); it('uses the global console when no logger is not provided', () => { - new TestResolver(); + // Act + const app = new TestResolver(); + app.route(() => true, { path: '/', method: 'get' }); + + // Assess expect(console.debug).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith('test error'); expect(console.warn).toHaveBeenCalledWith('test warn'); From 570ad3d955bca5405a32bade12cfbca16c797bb4 Mon Sep 17 00:00:00 2001 From: Stefano Vozza Date: Mon, 14 Jul 2025 22:32:52 +0100 Subject: [PATCH 12/16] Add route in log tests to fix SQ issue Co-authored-by: Andrea Amorosi --- packages/event-handler/tests/unit/rest/BaseRouter.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index 488e3c96b1..54b8c4a602 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -90,8 +90,14 @@ describe('Class: BaseRouter', () => { }); it('should use console.debug in DEBUG mode when logger is not provided', () => { + // Prepare vi.stubEnv('AWS_LAMBDA_LOG_LEVEL', 'DEBUG'); - new TestResolver(); + + // Act + const app = new TestResolver(); + app.route(() => true, { path: '/', method: 'get' }); + + // Assess expect(console.debug).toHaveBeenCalledWith('test debug'); expect(console.error).toHaveBeenCalledWith('test error'); expect(console.warn).toHaveBeenCalledWith('test warn'); From 4f4708f5fec871951765bbe8d7b0969a51533de7 Mon Sep 17 00:00:00 2001 From: Stefano Vozza Date: Mon, 14 Jul 2025 22:33:12 +0100 Subject: [PATCH 13/16] Update debug log test name Co-authored-by: Andrea Amorosi --- packages/event-handler/tests/unit/rest/BaseRouter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index 54b8c4a602..fc2a876c8a 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -89,7 +89,7 @@ describe('Class: BaseRouter', () => { expect(console.warn).toHaveBeenCalledWith('test warn'); }); - it('should use console.debug in DEBUG mode when logger is not provided', () => { + 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'); From 650a5801c22a11eaf8b5d3a132279563df959b27 Mon Sep 17 00:00:00 2001 From: Stefano Vozza Date: Mon, 14 Jul 2025 22:33:36 +0100 Subject: [PATCH 14/16] update custom logger test name Co-authored-by: Andrea Amorosi --- packages/event-handler/tests/unit/rest/BaseRouter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index fc2a876c8a..596bf02902 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -103,7 +103,7 @@ describe('Class: BaseRouter', () => { expect(console.warn).toHaveBeenCalledWith('test warn'); }); - it('should use custom logger when provided', () => { + it('uses a custom logger when provided', () => { vi.stubEnv('AWS_LAMBDA_LOG_LEVEL', 'DEBUG'); const logger = { From 078108c7da1c00c6ef5f0d0d546199b6f1ab4795 Mon Sep 17 00:00:00 2001 From: Stefano Vozza Date: Mon, 14 Jul 2025 22:34:00 +0100 Subject: [PATCH 15/16] Update packages/event-handler/tests/unit/rest/BaseRouter.test.ts Co-authored-by: Andrea Amorosi --- packages/event-handler/tests/unit/rest/BaseRouter.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index 596bf02902..181633afdb 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -104,8 +104,8 @@ describe('Class: BaseRouter', () => { }); it('uses a custom logger when provided', () => { + // Prepare vi.stubEnv('AWS_LAMBDA_LOG_LEVEL', 'DEBUG'); - const logger = { debug: vi.fn(), info: vi.fn(), @@ -113,7 +113,11 @@ describe('Class: BaseRouter', () => { error: vi.fn(), }; - new TestResolver({ logger }); + // Act + const app = new TestResolver({ logger }); + app.route(() => true, { path: '/', method: 'get' }); + + // Assess expect(logger.error).toHaveBeenCalledWith('test error'); expect(logger.warn).toHaveBeenCalledWith('test warn'); expect(logger.debug).toHaveBeenCalledWith('test debug'); From 730dfc5703231eebcb7653bfb663b51b19ed91a5 Mon Sep 17 00:00:00 2001 From: svozza Date: Tue, 15 Jul 2025 08:47:17 +0100 Subject: [PATCH 16/16] fix type error in logging unit tests --- .../tests/unit/rest/BaseRouter.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index 181633afdb..facd099431 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -2,6 +2,7 @@ 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, @@ -81,8 +82,8 @@ describe('Class: BaseRouter', () => { it('uses the global console when no logger is not provided', () => { // Act const app = new TestResolver(); - app.route(() => true, { path: '/', method: 'get' }); - + app.route(() => true, { path: '/', method: HttpVerbs.GET }); + // Assess expect(console.debug).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith('test error'); @@ -92,11 +93,11 @@ describe('Class: BaseRouter', () => { 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: 'get' }); - + app.route(() => true, { path: '/', method: HttpVerbs.GET }); + // Assess expect(console.debug).toHaveBeenCalledWith('test debug'); expect(console.error).toHaveBeenCalledWith('test error'); @@ -115,8 +116,8 @@ describe('Class: BaseRouter', () => { // Act const app = new TestResolver({ logger }); - app.route(() => true, { path: '/', method: 'get' }); - + app.route(() => true, { path: '/', method: HttpVerbs.GET }); + // Assess expect(logger.error).toHaveBeenCalledWith('test error'); expect(logger.warn).toHaveBeenCalledWith('test warn');