Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 54 additions & 77 deletions packages/event-handler/src/rest/BaseRouter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
import { isRecord } from '@aws-lambda-powertools/commons/typeutils';
import {
getStringFromEnv,
isDevMode,
Expand All @@ -13,10 +12,15 @@ import type {
RouteOptions,
RouterOptions,
} from '../types/rest.js';
import { HttpVerbs } from './constatnts.js';
import { HttpVerbs } from './constants.js';
import { Route } from './Route.js';
import { RouteHandlerRegistry } from './RouteHandlerRegistry.js';

abstract class BaseRouter {
protected context: Record<string, unknown>;

protected routeRegistry: RouteHandlerRegistry;

/**
* A logger instance to be used for logging debug, warning, and error messages.
*
Expand All @@ -39,6 +43,7 @@ abstract class BaseRouter {
error: console.error,
warn: console.warn,
};
this.routeRegistry = new RouteHandlerRegistry({ logger: this.logger });
this.isDev = isDevMode();
}

Expand All @@ -48,126 +53,98 @@ abstract class BaseRouter {
options?: ResolveOptions
): Promise<unknown>;

public abstract route(handler: RouteHandler, options: RouteOptions): void;
public route(handler: RouteHandler, options: RouteOptions): void {
const { method, path } = options;
const methods = Array.isArray(method) ? method : [method];

for (const method of methods) {
this.routeRegistry.register(new Route(method, path, handler));
}
}

#handleHttpMethod(
method: HttpMethod,
path: Path,
handler?: RouteHandler | RouteOptions,
options?: RouteOptions
handler?: RouteHandler
): MethodDecorator | undefined {
if (handler && typeof handler === 'function') {
this.route(handler, { ...(options || {}), method, path });
this.route(handler, { method, path });
return;
}

return (_target, _propertyKey, descriptor: PropertyDescriptor) => {
const routeOptions = isRecord(handler) ? handler : options;
this.route(descriptor.value, { ...(routeOptions || {}), method, path });
this.route(descriptor.value, { 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 get(path: Path, handler: RouteHandler): void;
public get(path: Path): MethodDecorator;
public get(path: Path, handler?: RouteHandler): MethodDecorator | undefined {
return this.#handleHttpMethod(HttpVerbs.GET, path, handler);
}

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 post(path: Path, handler: RouteHandler): void;
public post(path: Path): MethodDecorator;
public post(path: Path, handler?: RouteHandler): MethodDecorator | undefined {
return this.#handleHttpMethod(HttpVerbs.POST, path, handler);
}

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 put(path: Path, handler: RouteHandler): void;
public put(path: Path): MethodDecorator;
public put(path: Path, handler?: RouteHandler): MethodDecorator | undefined {
return this.#handleHttpMethod(HttpVerbs.PUT, path, handler);
}

public patch(path: Path, handler: RouteHandler, options?: RouteOptions): void;
public patch(path: Path, options?: RouteOptions): MethodDecorator;
public patch(path: Path, handler: RouteHandler): void;
public patch(path: Path): MethodDecorator;
public patch(
path: Path,
handler?: RouteHandler | RouteOptions,
options?: RouteOptions
handler?: RouteHandler
): MethodDecorator | undefined {
return this.#handleHttpMethod(HttpVerbs.PATCH, path, handler, options);
return this.#handleHttpMethod(HttpVerbs.PATCH, path, handler);
}

public delete(path: Path, handler: RouteHandler): void;
public delete(path: Path): MethodDecorator;
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
handler?: RouteHandler
): MethodDecorator | undefined {
return this.#handleHttpMethod(HttpVerbs.DELETE, path, handler, options);
return this.#handleHttpMethod(HttpVerbs.DELETE, path, handler);
}

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 head(path: Path, handler: RouteHandler): void;
public head(path: Path): MethodDecorator;
public head(path: Path, handler?: RouteHandler): MethodDecorator | undefined {
return this.#handleHttpMethod(HttpVerbs.HEAD, path, handler);
}

public options(path: Path, handler: RouteHandler): void;
public options(path: Path): MethodDecorator;
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
handler?: RouteHandler
): MethodDecorator | undefined {
return this.#handleHttpMethod(HttpVerbs.OPTIONS, path, handler, options);
return this.#handleHttpMethod(HttpVerbs.OPTIONS, path, handler);
}

public connect(path: Path, handler: RouteHandler): void;
public connect(path: Path): MethodDecorator;
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
handler?: RouteHandler
): MethodDecorator | undefined {
return this.#handleHttpMethod(HttpVerbs.CONNECT, path, handler, options);
return this.#handleHttpMethod(HttpVerbs.CONNECT, path, handler);
}

public trace(path: Path, handler: RouteHandler, options?: RouteOptions): void;
public trace(path: Path, options?: RouteOptions): MethodDecorator;
public trace(path: Path, handler: RouteHandler): void;
public trace(path: Path): MethodDecorator;
public trace(
path: Path,
handler?: RouteHandler | RouteOptions,
options?: RouteOptions
handler?: RouteHandler
): MethodDecorator | undefined {
return this.#handleHttpMethod(HttpVerbs.TRACE, path, handler, options);
return this.#handleHttpMethod(HttpVerbs.TRACE, path, handler);
}
}

Expand Down
19 changes: 19 additions & 0 deletions packages/event-handler/src/rest/Route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Path, RouteHandler } from '../types/rest.js';

class Route {
readonly id: string;
readonly method: string;
readonly path: Path;
readonly handler: RouteHandler;
readonly registeredAt: Date;

constructor(method: string, path: Path, handler: RouteHandler) {
this.id = `${method}:${path}`;
this.method = method.toUpperCase();
this.path = path;
this.handler = handler;
this.registeredAt = new Date();
}
}

export { Route };
53 changes: 53 additions & 0 deletions packages/event-handler/src/rest/RouteHandlerRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
import type { RouteRegistryOptions } from '../types/rest.js';
import type { Route } from './Route.js';
import { validatePathPattern } from './utils.js';

class RouteHandlerRegistry {
readonly #routes: Map<string, Route> = new Map();
readonly #routesByMethod: Map<string, Route[]> = new Map();

readonly #logger: Pick<GenericLogger, 'debug' | 'warn' | 'error'>;

constructor(options: RouteRegistryOptions) {
this.#logger = options.logger;
}

public register(route: Route): void {
const { isValid, issues } = validatePathPattern(route.path);
if (!isValid) {
for (const issue of issues) {
this.#logger.warn(issue);
}
return;
}

if (this.#routes.has(route.id)) {
this.#logger.warn(
`Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.`
);
}

this.#routes.set(route.id, route);

if (!this.#routesByMethod.has(route.method)) {
this.#routesByMethod.set(route.method, []);
}
// biome-ignore lint/style/noNonNullAssertion: Map.set operation above ensures Map.get won't return undefined
this.#routesByMethod.get(route.method)!.push(route);
}

public getRouteCount(): number {
return this.#routes.size;
}

public getRoutesByMethod(method: string): Route[] {
return this.#routesByMethod.get(method.toUpperCase()) || [];
}

public getAllRoutes(): Route[] {
return Array.from(this.#routes.values());
}
}

export { RouteHandlerRegistry };
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ export const HttpVerbs = {
HEAD: 'HEAD',
OPTIONS: 'OPTIONS',
} as const;

export const PARAM_PATTERN = /:([a-zA-Z_]\w*)(?=\/|$)/g;

export const SAFE_CHARS = "-._~()'!*:@,;=+&$";

export const UNSAFE_CHARS = '%<> \\[\\]{}|^';
45 changes: 45 additions & 0 deletions packages/event-handler/src/rest/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { CompiledRoute, Path, ValidationResult } from '../types/rest.js';
import { PARAM_PATTERN, SAFE_CHARS, UNSAFE_CHARS } from './constants.js';

export function compilePath(path: Path): CompiledRoute {
const paramNames: string[] = [];

const regexPattern = path.replace(PARAM_PATTERN, (_match, paramName) => {
paramNames.push(paramName);
return `(?<${paramName}>[${SAFE_CHARS}${UNSAFE_CHARS}\\w]+)`;
});

const finalPattern = `^${regexPattern}$`;

return {
originalPath: path,
regex: new RegExp(finalPattern),
paramNames,
isDynamic: paramNames.length > 0,
};
}

export function validatePathPattern(path: Path): ValidationResult {
const issues: string[] = [];

const matches = [...path.matchAll(PARAM_PATTERN)];
if (path.includes(':')) {
const expectedParams = path.split(':').length;
if (matches.length !== expectedParams - 1) {
issues.push('Malformed parameter syntax. Use :paramName format.');
}

const paramNames = matches.map((match) => match[1]);
const duplicates = paramNames.filter(
(param, index) => paramNames.indexOf(param) !== index
);
if (duplicates.length > 0) {
issues.push(`Duplicate parameter names: ${duplicates.join(', ')}`);
}
}

return {
isValid: issues.length === 0,
issues,
};
}
38 changes: 34 additions & 4 deletions packages/event-handler/src/types/rest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +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';
import type { HttpVerbs } from '../rest/constants.js';

/**
* Options for the {@link BaseRouter} class
Expand All @@ -14,6 +14,13 @@ type RouterOptions = {
logger?: GenericLogger;
};

interface CompiledRoute {
originalPath: string;
regex: RegExp;
paramNames: string[];
isDynamic: boolean;
}

// biome-ignore lint/suspicious/noExplicitAny: we want to keep arguments and return types as any to accept any type of function
type RouteHandler<T = any, R = any> = (...args: T[]) => R;

Expand All @@ -22,8 +29,31 @@ type HttpMethod = keyof typeof HttpVerbs;
type Path = `/${string}`;

type RouteOptions = {
method?: HttpMethod;
path?: Path;
method: HttpMethod | HttpMethod[];
path: Path;
};

export type { HttpMethod, Path, RouterOptions, RouteHandler, RouteOptions };
type RouteRegistryOptions = {
/**
* 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: Pick<GenericLogger, 'debug' | 'warn' | 'error'>;
};

type ValidationResult = {
isValid: boolean;
issues: string[];
};

export type {
CompiledRoute,
HttpMethod,
Path,
RouterOptions,
RouteHandler,
RouteOptions,
RouteRegistryOptions,
ValidationResult,
};
Loading