Skip to content

Commit

Permalink
refactor(router)!: change router system (#590)
Browse files Browse the repository at this point in the history
Added `Route#path`, which is an array of each path part
Added `RouteStore#router`, which is a tree router structure
Added `ApiRequest#routerNode`, which is the matched node in the router
Added `ApiRequest#route`, which is the matched route
Added `ApiResponse#methodNotAllowed()`
Added event `routerBranchMethodNotAllowed`
Added listener to send `405 Method Not Allowed` where applicable
Added tests to ensure the router's correct functionality

BREAKING CHANGE: Moved `RouteStore#match` to a listener
BREAKING CHANGE: Replaced `node:events` with `@vladfrangu/async_event_emitter`
BREAKING CHANGE: Removed `route` parameter in the `Middleware#run` method, use `request.route` instead
BREAKING CHANGE: Removed `Route#router` property
BREAKING CHANGE: Removed `RouteStore#methods` property
BREAKING CHANGE: Removed objects in router events, use `request.route` and `request.routerNode` instead
BREAKING CHANGE: Renamed `ServerEvents` enum to `ServerEvent`
BREAKING CHANGE: Renamed event `match` to `routerFound`
BREAKING CHANGE: Renamed event `noMatch` to `routerBranchNotFound`
  • Loading branch information
kyranet authored Jul 6, 2024
1 parent 03b2a90 commit ce675e0
Show file tree
Hide file tree
Showing 28 changed files with 728 additions and 258 deletions.
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
},
"dependencies": {
"@types/ws": "^8.5.10",
"@vladfrangu/async_event_emitter": "2.4.0",
"tldts": "^6.1.30",
"undici": "^6.19.2"
},
Expand Down
4 changes: 3 additions & 1 deletion packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ export * from './lib/structures/MediaParserStore';
export * from './lib/structures/Middleware';
export * from './lib/structures/MiddlewareStore';
export * from './lib/structures/Route';
export * from './lib/structures/router/RouterBranch';
export * from './lib/structures/router/RouterNode';
export * from './lib/structures/router/RouterRoot';
export * from './lib/structures/RouteStore';
export * from './lib/utils/MimeTypes';
export * from './lib/utils/RouteData';

export { loadListeners } from './listeners/_load';
export { loadMediaParsers } from './mediaParsers/_load';
Expand Down
3 changes: 1 addition & 2 deletions packages/api/src/lib/structures/Middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Piece } from '@sapphire/pieces';
import type { Awaitable } from '@sapphire/utilities';
import type { Route } from './Route';
import type { ApiRequest } from './api/ApiRequest';
import type { ApiResponse } from './api/ApiResponse';

Expand Down Expand Up @@ -31,7 +30,7 @@ export abstract class Middleware<Options extends Middleware.Options = Middleware
* @param response The server's response.
* @param route The route that matched this request, will be `null` if none matched.
*/
public abstract run(request: Middleware.Request, response: Middleware.Response, route: Route | null): Awaitable<unknown>;
public abstract run(request: Middleware.Request, response: Middleware.Response): Awaitable<unknown>;
}

/**
Expand Down
5 changes: 2 additions & 3 deletions packages/api/src/lib/structures/MiddlewareStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Store } from '@sapphire/pieces';
import { Middleware } from './Middleware';
import type { Route } from './Route';

/**
* @since 1.0.0
Expand All @@ -15,10 +14,10 @@ export class MiddlewareStore extends Store<Middleware, 'middlewares'> {
super(Middleware, { name: 'middlewares' });
}

public async run(request: Middleware.Request, response: Middleware.Response, route: Route | null): Promise<void> {
public async run(request: Middleware.Request, response: Middleware.Response): Promise<void> {
for (const middleware of this.sortedMiddlewares) {
if (response.writableEnded) return;
if (middleware.enabled) await middleware.run(request, response, route);
if (middleware.enabled) await middleware.run(request, response);
}
}

Expand Down
34 changes: 14 additions & 20 deletions packages/api/src/lib/structures/Route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Piece } from '@sapphire/pieces';
import type { Awaitable } from '@sapphire/utilities';
import { RouteData } from '../utils/RouteData';
import { isNullish, type Awaitable } from '@sapphire/utilities';
import type { ApiRequest } from './api/ApiRequest';
import type { ApiResponse } from './api/ApiResponse';
import type { MethodName } from './http/HttpMethods';
import type { MimeTypeWithoutParameters } from './http/Server';
import { RouterRoot } from './router/RouterRoot';

/**
* @since 1.0.0
Expand Down Expand Up @@ -55,9 +55,9 @@ export abstract class Route<Options extends Route.Options = Route.Options> exten
public readonly acceptedContentMimeTypes: readonly MimeTypeWithoutParameters[] | null;

/**
* The route information.
* The path this route represents.
*/
public readonly router: RouteData;
public readonly path: readonly string[];

/**
* The methods this route accepts.
Expand All @@ -68,27 +68,21 @@ export abstract class Route<Options extends Route.Options = Route.Options> exten
super(context, options);

const api = this.container.server.options;
// Concat a `/` to the prefix if it does not end with it
const prefix = api.prefix ? (api.prefix.endsWith('/') ? api.prefix : `${api.prefix}/`) : '';
// Use the defined route, otherwise:
// - If the location is virtual, use the name.
// - Otherwise, use the directories and the name.
let path = options.route ?? (this.location.virtual ? this.name : this.location.directories.concat(this.name).join('/'));
const path = ([] as string[]).concat(
RouterRoot.normalize(api.prefix),
RouterRoot.normalize(options.route ?? (this.location.virtual ? this.name : this.location.directories.concat(this.name).join('/')))
);

const methods = new Set(options.methods);
// If the path contains a method (e.g. `/users.get`), extract it and add it to the methods set:
const methodIndex = path.lastIndexOf('.');
if (methodIndex !== -1) {
// Extract the method from the path:
const method = path.slice(methodIndex + 1).toUpperCase() as MethodName;
if (!methods.has(method)) methods.add(method);

// Update the path to remove the method:
path = path.slice(0, methodIndex);
const implied = RouterRoot.extractMethod(path);
if (!isNullish(implied)) {
const lastIndex = path.length - 1;
path[lastIndex] = path[lastIndex].slice(0, path[lastIndex].length - implied.length - 1);
methods.add(implied as MethodName);
}

this.path = path;
this.methods = methods;
this.router = new RouteData(`${prefix}${path}`);
this.maximumBodyLength = options.maximumBodyLength ?? api.maximumBodyLength ?? 1024 * 1024 * 50;
this.acceptedContentMimeTypes = options.acceptedContentMimeTypes ?? api.acceptedContentMimeTypes ?? null;
}
Expand Down
13 changes: 2 additions & 11 deletions packages/api/src/lib/structures/RouteLoaderStrategy.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
import { LoaderStrategy } from '@sapphire/pieces';
import { Collection } from 'discord.js';
import type { Route } from './Route';
import type { RouteStore } from './RouteStore';

export class RouteLoaderStrategy extends LoaderStrategy<Route> {
public override onLoad(store: RouteStore, piece: Route): void {
for (const method of piece.methods) {
store.methods.ensure(method, () => new Collection()).set(piece, piece.router);
}
store.router.add(piece);
}

public override onUnload(store: RouteStore, piece: Route): void {
for (const method of piece.methods) {
const methods = store.methods.get(method);
if (typeof methods === 'undefined') continue;

methods.delete(piece);
if (methods.size === 0) store.methods.delete(method);
}
store.router.remove(piece);
}
}
62 changes: 2 additions & 60 deletions packages/api/src/lib/structures/RouteStore.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,15 @@
import { Store } from '@sapphire/pieces';
import { isNullish } from '@sapphire/utilities';
import { Collection } from 'discord.js';
import { URLSearchParams } from 'url';
import type { RouteData } from '../utils/RouteData';
import { Route } from './Route';
import { RouteLoaderStrategy } from './RouteLoaderStrategy';
import type { MethodName } from './http/HttpMethods';

const slash = '/'.charCodeAt(0);
import { RouterRoot } from './router/RouterRoot';

/**
* @since 1.0.0
*/
export class RouteStore extends Store<Route, 'routes'> {
public readonly methods = new Collection<MethodName, Collection<Route, RouteData>>();
public readonly router = new RouterRoot();

public constructor() {
super(Route, { name: 'routes', strategy: new RouteLoaderStrategy() });
}

public match(request: Route.Request): Route | null {
const { method } = request;

// If there is no method, we can't match a route so return null
if (typeof method === 'undefined') {
return null;
}

// We get all the routes that are tied to the provided method to have a smaller list to filter through
const methodTable = this.methods.get(method as MethodName);

// If there are no routes of the provided type then we won't find any route so we return null
if (isNullish(methodTable)) {
return null;
}

const { splits, querystring } = this.parseURL(request.url);

for (const [route, router] of methodTable.entries()) {
const result = router.match(splits);
if (result === null) continue;

request.params = result;
request.query = Object.fromEntries(new URLSearchParams(querystring).entries());

return route;
}

return null;
}

private parseURL(url = '') {
const index = url.indexOf('?');

let pathname: string;
let querystring: string;
if (index === -1) {
pathname = url;
querystring = '';
} else {
pathname = url.substring(0, index);
querystring = url.substring(index + 1);
}

if (pathname.charCodeAt(0) === slash) pathname = pathname.substring(1);
if (pathname.length > 0 && pathname.charCodeAt(pathname.length - 1) === slash) pathname = pathname.substring(0, pathname.length - 1);

const splits = pathname.split('/');

return { splits, querystring };
}
}
30 changes: 30 additions & 0 deletions packages/api/src/lib/structures/api/ApiRequest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { IncomingMessage } from 'node:http';
import type { Route } from '../Route';
import type { AuthData } from '../http/Auth';
import type { RouterNode } from '../router/RouterNode';

export class ApiRequest extends IncomingMessage {
/**
Expand All @@ -25,4 +27,32 @@ export class ApiRequest extends IncomingMessage {
* - `AuthData`: The user is authorized.
*/
public auth?: AuthData | null;

/**
* The router node that matched the request. The field indicates three
* possible values:
*
* - `undefined`: The router handler has not been executed yet.
* - `null`: The router handler has been executed, but no node matched the
* request.
* - `RouterNode`: The router handler has been executed and a node matched
* the request.
*
* @since 7.0.0
*/
public routerNode?: RouterNode | null;

/**
* The route that matched the request. The field indicates three possible
* values:
*
* - `undefined`: The router handler has not been executed yet.
* - `null`: The router handler has been executed, but no route matched the
* request.
* - `Route`: The router handler has been executed and a route matched the
* request.
*
* @since 7.0.0
*/
public route?: Route | null;
}
7 changes: 7 additions & 0 deletions packages/api/src/lib/structures/api/ApiResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ export class ApiResponse<Request extends IncomingMessage = IncomingMessage> exte
return this.error(HttpCodes.NotFound, data);
}

/**
* @since 7.0.0
*/
public methodNotAllowed(data?: unknown): void {
return this.error(HttpCodes.MethodNotAllowed, data);
}

/**
* @since 1.0.0
*/
Expand Down
52 changes: 21 additions & 31 deletions packages/api/src/lib/structures/http/Server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { container } from '@sapphire/pieces';
import { EventEmitter } from 'node:events';
import { AsyncEventEmitter } from '@vladfrangu/async_event_emitter';
import { Server as HttpServer, createServer as httpCreateServer, type ServerOptions as HttpOptions } from 'node:http';
import type { ListenOptions } from 'node:net';
import { MediaParserStore } from '../MediaParserStore';
Expand All @@ -8,23 +8,37 @@ import type { Route } from '../Route';
import { RouteStore } from '../RouteStore';
import { ApiRequest } from '../api/ApiRequest';
import { ApiResponse } from '../api/ApiResponse';
import type { RouterBranch } from '../router/RouterBranch';
import { Auth, type ServerOptionsAuth } from './Auth';

export enum ServerEvents {
export enum ServerEvent {
Error = 'error',
Request = 'request',
Match = 'match',
NoMatch = 'noMatch',
RouterBranchNotFound = 'routerBranchNotFound',
RouterBranchMethodNotAllowed = 'routerBranchMethodNotAllowed',
RouterFound = 'routerFound',
RouteError = 'routeError',
MiddlewareFailure = 'middlewareFailure',
MiddlewareError = 'middlewareError',
MiddlewareSuccess = 'middlewareSuccess'
}

export interface ServerEvents {
[ServerEvent.Error]: [error: Error, request: ApiRequest, response: ApiResponse];
[ServerEvent.Request]: [request: ApiRequest, response: ApiResponse];
[ServerEvent.RouterBranchNotFound]: [request: ApiRequest, response: ApiResponse];
[ServerEvent.RouterBranchMethodNotAllowed]: [request: ApiRequest, response: ApiResponse, node: RouterBranch];
[ServerEvent.RouterFound]: [request: ApiRequest, response: ApiResponse];
[ServerEvent.RouteError]: [error: Error, request: ApiRequest, response: ApiResponse];
[ServerEvent.MiddlewareFailure]: [error: Error, request: ApiRequest, response: ApiResponse];
[ServerEvent.MiddlewareSuccess]: [request: Route.Request, response: Route.Response, route: Route];
[ServerEvent.MiddlewareError]: [error: Error, request: ApiRequest, response: ApiResponse];
}

/**
* @since 1.0.0
*/
export class Server extends EventEmitter {
export class Server extends AsyncEventEmitter<ServerEvents> {
/**
* The routes this server holds.
* @since 1.0.0
Expand Down Expand Up @@ -80,8 +94,8 @@ export class Server extends EventEmitter {
this.middlewares = new MiddlewareStore();
this.mediaParsers = new MediaParserStore();
this.auth = Auth.create(auth);
this.server.on('error', this.emit.bind(this, ServerEvents.Error));
this.server.on('request', this.emit.bind(this, ServerEvents.Request));
this.server.on('error', this.emit.bind(this, ServerEvent.Error));
this.server.on('request', this.emit.bind(this, ServerEvent.Request));
}

public connect() {
Expand Down Expand Up @@ -232,27 +246,3 @@ export interface ServerOptions {
* @since 1.0.0
*/
export type AuthLessServerOptions = Omit<ServerOptions, 'auth'>;

/**
* The context sent in the error events.
* @since 1.2.0
*/
export interface MiddlewareErrorContext {
/**
* The erroneous request.
* @since 1.2.0
*/
request: ApiRequest;

/**
* The server's response.
* @since 1.2.0
*/
response: ApiResponse;

/**
* The route match.
* @since 1.2.0
*/
route: Route;
}
Loading

0 comments on commit ce675e0

Please sign in to comment.