Skip to content

Commit

Permalink
refactor(route)!: change method handling
Browse files Browse the repository at this point in the history
Added support for specifying a route's supported methods in the options
Added support for specifying a route's method in the file name
Added `MediaParser.Request` and `MediaParser.Response` type aliases
Added `Middleware.Request` and `Middleware.Response` type aliases
Hardened the `onLoad` and `onUnload` hooks from the `Route` class

BREAKING CHANGE: The headers middleware now uses the supported HTTP methods from the route or the store instead of sending all supported methods
BREAKING CHANGE: Changed the method handling in the `Route` class to not be keyed by the method name, sending all requests to the `run` method. This allows for more flexibility in the route handling
BREAKING CHANGE: Writing the name of a file as `<name>.<method>.ts` will now set `<method>` as a method for the route
  • Loading branch information
kyranet committed Jun 29, 2024
1 parent d27dc1c commit 74da8e4
Show file tree
Hide file tree
Showing 21 changed files with 198 additions and 170 deletions.
10 changes: 6 additions & 4 deletions packages/api/src/lib/structures/MediaParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export abstract class MediaParser<Options extends MediaParser.Options = MediaPar
* Parses the body data from an API request.
* @since 1.3.0
*/
public abstract run(request: ApiRequest): Awaitable<unknown>;
public abstract run(request: MediaParser.Request): Awaitable<unknown>;

/**
* Checks if a route accepts the media type from this parser.
Expand All @@ -34,7 +34,7 @@ export abstract class MediaParser<Options extends MediaParser.Options = MediaPar
* @since 1.3.0
* @param request The request to read the body from.
*/
protected async readString(request: ApiRequest): Promise<string> {
protected async readString(request: MediaParser.Request): Promise<string> {
const stream = this.contentStream(request);
if (stream === null) return '';

Expand All @@ -49,7 +49,7 @@ export abstract class MediaParser<Options extends MediaParser.Options = MediaPar
* @since 1.3.0
* @param request The request to read the body from.
*/
protected async readBuffer(request: ApiRequest): Promise<Buffer> {
protected async readBuffer(request: MediaParser.Request): Promise<Buffer> {
const stream = this.contentStream(request);
if (stream === null) return Buffer.alloc(0);

Expand All @@ -64,7 +64,7 @@ export abstract class MediaParser<Options extends MediaParser.Options = MediaPar
* @since 1.3.0
* @param request The request to read the body from.
*/
protected contentStream(request: ApiRequest): ApiRequest | Gunzip | null {
protected contentStream(request: MediaParser.Request): MediaParser.Request | Gunzip | null {
switch ((request.headers['content-encoding'] ?? 'identity').toLowerCase()) {
// RFC 7230 4.2.2:
//
Expand Down Expand Up @@ -114,4 +114,6 @@ export namespace MediaParser {
export type Options = Piece.Options;
export type JSON = Piece.JSON;
export type LocationJSON = Piece.LocationJSON;

export type Request = ApiRequest;
}
5 changes: 4 additions & 1 deletion packages/api/src/lib/structures/Middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,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: ApiRequest, response: ApiResponse, route: Route | null): Awaitable<unknown>;
public abstract run(request: Middleware.Request, response: Middleware.Response, route: Route | null): Awaitable<unknown>;
}

/**
Expand All @@ -53,4 +53,7 @@ export namespace Middleware {
export type Options = MiddlewareOptions;
export type JSON = Piece.JSON;
export type LocationJSON = Piece.LocationJSON;

export type Request = ApiRequest;
export type Response = ApiResponse;
}
4 changes: 1 addition & 3 deletions packages/api/src/lib/structures/MiddlewareStore.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { Store } from '@sapphire/pieces';
import { Middleware } from './Middleware';
import type { Route } from './Route';
import type { ApiRequest } from './api/ApiRequest';
import type { ApiResponse } from './api/ApiResponse';

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

public async run(request: ApiRequest, response: ApiResponse, route: Route | null): Promise<void> {
public async run(request: Middleware.Request, response: Middleware.Response, route: Route | null): Promise<void> {
for (const middleware of this.sortedMiddlewares) {
if (response.writableEnded) return;
if (middleware.enabled) await middleware.run(request, response, route);
Expand Down
105 changes: 65 additions & 40 deletions packages/api/src/lib/structures/Route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,47 @@
import { Piece } from '@sapphire/pieces';
import type { Awaitable } from '@sapphire/utilities';
import { Collection } from 'discord.js';
import { RouteData } from '../utils/RouteData';
import type { MethodCallback, RouteStore } from './RouteStore';
import type { ApiRequest } from './api/ApiRequest';
import type { ApiResponse } from './api/ApiResponse';
import { methodEntries, type Methods } from './http/HttpMethods';
import type { MethodName } from './http/HttpMethods';
import type { MimeTypeWithoutParameters } from './http/Server';

/**
* @since 1.0.0
*
* @example A simple GET route that returns a JSON response:
* ```typescript
* // hello.get.ts
* import { Route } from '@sapphire/plugin-api';
*
* export class MyRoute extends Route {
* public run(request: Route.Request, response: Route.Response) {
* return response.json({ message: 'Hello, World!' });
* }
* }
* ```
*
* ```bash
* $ curl http://localhost:4000/hello
* {"message":"Hello, World!"}
* ```
*
* @example A simple POST route that reads the body and returns it:
* ```typescript
* // echo.post.ts
* import { Route } from '@sapphire/plugin-api';
*
* export class MyRoute extends Route {
* public run(request: Route.Request, response: Route.Response) {
* return response.json(request.params);
* }
* }
* ```
*
* ```bash
* $ curl -H "Content-Type: application/json" -d '{"hello":"world"}' http://localhost:4000/echo
* {"hello":"world"}
* ```
*/
export abstract class Route<Options extends Route.Options = Route.Options> extends Piece<Options, 'routes'> {
/**
Expand All @@ -30,7 +62,7 @@ export abstract class Route<Options extends Route.Options = Route.Options> exten
/**
* The methods this route accepts.
*/
public readonly methods = new Collection<Methods, MethodCallback>();
public readonly methods: ReadonlySet<MethodName>;

public constructor(context: Route.LoaderContext, options: Options = {} as Options) {
super(context, options);
Expand All @@ -41,52 +73,35 @@ export abstract class Route<Options extends Route.Options = Route.Options> exten
// Use the defined route, otherwise:
// - If the location is virtual, use the name.
// - Otherwise, use the directories and the name.
const path = options.route ?? (this.location.virtual ? this.name : this.location.directories.concat(this.name).join('/'));
this.router = new RouteData(`${prefix}${path}`);
let path = 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) {
path = path.slice(0, methodIndex);

for (const [method, symbol] of methodEntries) {
const value = Reflect.get(this, symbol) as MethodCallback;
if (typeof value === 'function') this.methods.set(method, value);
// Extract the method from the path:
const method = path.slice(methodIndex + 1).toUpperCase() as MethodName;
if (!methods.has(method)) methods.add(method);
}

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;
}

/**
* Per-piece listener that is called when the piece is loaded into the store.
* Useful to set-up asynchronous initialization tasks.
*/
public override onLoad(): Awaitable<unknown> {
const store = this.store as unknown as RouteStore;

for (const [method, cb] of this.methods) {
store.table.get(method)!.set(this, cb.bind(this));
}

return undefined;
}

/**
* Per-piece listener that is called when the piece is unloaded from the store.
* Useful to set-up clean-up tasks.
*/
public override onUnload(): Awaitable<unknown> {
const store = this.store as unknown as RouteStore;

for (const [method] of this.methods) {
store.table.get(method)!.delete(this);
}

return undefined;
}
public abstract run(request: Route.Request, response: Route.Response): Awaitable<unknown>;
}

export interface RouteOptions extends Piece.Options {
/**
* The route the piece should represent.
* @since 1.0.0
* @default ''
*
* @defaultValue The filesystem-based path, or the name if the location is virtual.
*
* @example
* ```typescript
* '/users'
Expand All @@ -103,16 +118,26 @@ export interface RouteOptions extends Piece.Options {
/**
* (RFC 7230 3.3.2) The maximum decimal number of octets.
* @since 1.0.0
* @default this.context.server.options.maximumBodyLength ?? 1024 * 1024 * 50
*
* @defaultValue this.context.server.options.maximumBodyLength ?? 1024 * 1024 * 50
*/
maximumBodyLength?: number;

/**
* The accepted content types for this route. If set to null, the route will accept any data.
* @since 1.3.0
* @default this.context.server.options.acceptedContentMimeTypes ?? null
*
* @defaultValue this.context.server.options.acceptedContentMimeTypes ?? null
*/
acceptedContentMimeTypes?: readonly MimeTypeWithoutParameters[] | null;

/**
* The methods this route accepts.
* @since 7.0.0
*
* @defaultValue The method defined in the piece name, or none if not set.
*/
acceptedContentMimeTypes?: MimeTypeWithoutParameters[] | null;
methods?: readonly MethodName[];
}

export namespace Route {
Expand Down
22 changes: 22 additions & 0 deletions packages/api/src/lib/structures/RouteLoaderStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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);
}
}

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);
}
}
}
40 changes: 14 additions & 26 deletions packages/api/src/lib/structures/RouteStore.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,50 @@
import { Store } from '@sapphire/pieces';
import { isNullish } from '@sapphire/utilities';
import { Collection } from 'discord.js';
import { URLSearchParams } from 'url';
import type { ApiRequest } from './api/ApiRequest';
import type { ApiResponse } from './api/ApiResponse';
import { methodEntries, type Methods } from './http/HttpMethods';
import type { RouteData } from '../utils/RouteData';
import { Route } from './Route';
import { RouteLoaderStrategy } from './RouteLoaderStrategy';
import type { MethodName } from './http/HttpMethods';

const slash = '/'.charCodeAt(0);

export interface MethodCallback {
(request: ApiRequest, response: ApiResponse): unknown;
}

export interface RouteMatch {
route: Route;
cb: MethodCallback;
}

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

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

for (const [method] of methodEntries) this.table.set(method, new Collection());
super(Route, { name: 'routes', strategy: new RouteLoaderStrategy() });
}

public match(request: ApiRequest): RouteMatch | null {
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 methods that are tied to the provided method to have a smaller list to filter through
const methodTable = this.table.get(method as Methods);
// 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 methods of the provided type then we won't find any route so we return null
if (typeof methodTable === 'undefined') {
// 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, cb] of methodTable.entries()) {
const result = route.router.match(splits);
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, cb };
return route;
}

return null;
Expand All @@ -63,10 +53,8 @@ export class RouteStore extends Store<Route, 'routes'> {
private parseURL(url = '') {
const index = url.indexOf('?');

/* eslint-disable @typescript-eslint/init-declarations */
let pathname: string;
let querystring: string;
/* eslint-enable @typescript-eslint/init-declarations */
if (index === -1) {
pathname = url;
querystring = '';
Expand Down
6 changes: 3 additions & 3 deletions packages/api/src/lib/structures/api/CookieStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export class CookieStore extends Map<string, string> {
const index = pair.indexOf('=');
if (index === -1) continue;

const key = decodeURIComponent(pair.substr(0, index).trim());
const value = decodeURIComponent(pair.substr(index + 1).trim());
const key = decodeURIComponent(pair.slice(0, index).trim());
const value = decodeURIComponent(pair.slice(index + 1).trim());
this.set(key, value);
}

Expand Down Expand Up @@ -59,7 +59,7 @@ export class CookieStore extends Map<string, string> {
set = [set.toString()];
}

set = set.filter((i) => i.substr(0, i.indexOf('=')) !== name);
set = set.filter((i) => i.slice(0, i.indexOf('=')) !== name);
set.push(entry);

this.response.setHeader('Set-Cookie', set);
Expand Down
Loading

0 comments on commit 74da8e4

Please sign in to comment.