From 74da8e48b315de1fc855f7a3803122bcaa216902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aura=20Rom=C3=A1n?= Date: Sat, 29 Jun 2024 08:45:26 +0200 Subject: [PATCH] refactor(route)!: change method handling 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 `..ts` will now set `` as a method for the route --- .../api/src/lib/structures/MediaParser.ts | 10 +- packages/api/src/lib/structures/Middleware.ts | 5 +- .../api/src/lib/structures/MiddlewareStore.ts | 4 +- packages/api/src/lib/structures/Route.ts | 105 +++++++++++------- .../src/lib/structures/RouteLoaderStrategy.ts | 22 ++++ packages/api/src/lib/structures/RouteStore.ts | 40 +++---- .../api/src/lib/structures/api/CookieStore.ts | 6 +- .../src/lib/structures/http/HttpMethods.ts | 77 ++++++------- .../api/src/lib/structures/http/Server.ts | 5 +- .../api/src/listeners/PluginServerMatch.ts | 8 +- .../PluginServerMiddlewareSuccess.ts | 8 +- .../api/src/listeners/PluginServerRequest.ts | 10 +- .../api/src/mediaParsers/applicationJson.ts | 3 +- packages/api/src/mediaParsers/textPlain.ts | 3 +- packages/api/src/middlewares/auth.ts | 4 +- packages/api/src/middlewares/body.ts | 4 +- packages/api/src/middlewares/cookies.ts | 4 +- packages/api/src/middlewares/headers.ts | 26 +++-- packages/api/src/routes/_load.ts | 4 +- .../oauth/{callback.ts => callback.post.ts} | 7 +- .../oauth/{logout.ts => logout.post.ts} | 13 +-- 21 files changed, 198 insertions(+), 170 deletions(-) create mode 100644 packages/api/src/lib/structures/RouteLoaderStrategy.ts rename packages/api/src/routes/oauth/{callback.ts => callback.post.ts} (88%) rename packages/api/src/routes/oauth/{logout.ts => logout.post.ts} (89%) diff --git a/packages/api/src/lib/structures/MediaParser.ts b/packages/api/src/lib/structures/MediaParser.ts index 88104090..4c5f0b5f 100644 --- a/packages/api/src/lib/structures/MediaParser.ts +++ b/packages/api/src/lib/structures/MediaParser.ts @@ -18,7 +18,7 @@ export abstract class MediaParser; + public abstract run(request: MediaParser.Request): Awaitable; /** * Checks if a route accepts the media type from this parser. @@ -34,7 +34,7 @@ export abstract class MediaParser { + protected async readString(request: MediaParser.Request): Promise { const stream = this.contentStream(request); if (stream === null) return ''; @@ -49,7 +49,7 @@ export abstract class MediaParser { + protected async readBuffer(request: MediaParser.Request): Promise { const stream = this.contentStream(request); if (stream === null) return Buffer.alloc(0); @@ -64,7 +64,7 @@ export abstract class MediaParser; + public abstract run(request: Middleware.Request, response: Middleware.Response, route: Route | null): Awaitable; } /** @@ -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; } diff --git a/packages/api/src/lib/structures/MiddlewareStore.ts b/packages/api/src/lib/structures/MiddlewareStore.ts index 61c65b87..451a18e4 100644 --- a/packages/api/src/lib/structures/MiddlewareStore.ts +++ b/packages/api/src/lib/structures/MiddlewareStore.ts @@ -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 @@ -17,7 +15,7 @@ export class MiddlewareStore extends Store { super(Middleware, { name: 'middlewares' }); } - public async run(request: ApiRequest, response: ApiResponse, route: Route | null): Promise { + public async run(request: Middleware.Request, response: Middleware.Response, route: Route | null): Promise { for (const middleware of this.sortedMiddlewares) { if (response.writableEnded) return; if (middleware.enabled) await middleware.run(request, response, route); diff --git a/packages/api/src/lib/structures/Route.ts b/packages/api/src/lib/structures/Route.ts index c3c4b939..d8e4f28e 100644 --- a/packages/api/src/lib/structures/Route.ts +++ b/packages/api/src/lib/structures/Route.ts @@ -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 extends Piece { /** @@ -30,7 +62,7 @@ export abstract class Route exten /** * The methods this route accepts. */ - public readonly methods = new Collection(); + public readonly methods: ReadonlySet; public constructor(context: Route.LoaderContext, options: Options = {} as Options) { super(context, options); @@ -41,52 +73,35 @@ export abstract class Route 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 { - 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 { - 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; } 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' @@ -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 { diff --git a/packages/api/src/lib/structures/RouteLoaderStrategy.ts b/packages/api/src/lib/structures/RouteLoaderStrategy.ts new file mode 100644 index 00000000..f0eefa01 --- /dev/null +++ b/packages/api/src/lib/structures/RouteLoaderStrategy.ts @@ -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 { + 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); + } + } +} diff --git a/packages/api/src/lib/structures/RouteStore.ts b/packages/api/src/lib/structures/RouteStore.ts index eed36dc6..836a49af 100644 --- a/packages/api/src/lib/structures/RouteStore.ts +++ b/packages/api/src/lib/structures/RouteStore.ts @@ -1,35 +1,25 @@ 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 { - public readonly table = new Collection>(); + public readonly methods = new Collection>(); 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 @@ -37,24 +27,24 @@ export class RouteStore extends Store { 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; @@ -63,10 +53,8 @@ export class RouteStore extends Store { 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 = ''; diff --git a/packages/api/src/lib/structures/api/CookieStore.ts b/packages/api/src/lib/structures/api/CookieStore.ts index 70cd479c..c782c1da 100644 --- a/packages/api/src/lib/structures/api/CookieStore.ts +++ b/packages/api/src/lib/structures/api/CookieStore.ts @@ -25,8 +25,8 @@ export class CookieStore extends Map { 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); } @@ -59,7 +59,7 @@ export class CookieStore extends Map { 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); diff --git a/packages/api/src/lib/structures/http/HttpMethods.ts b/packages/api/src/lib/structures/http/HttpMethods.ts index 865c5f67..be7f9fcf 100644 --- a/packages/api/src/lib/structures/http/HttpMethods.ts +++ b/packages/api/src/lib/structures/http/HttpMethods.ts @@ -1,41 +1,38 @@ -import { METHODS } from 'node:http'; +export type MethodName = (typeof MethodNames)[number]; -export type Methods = - | 'ACL' - | 'BIND' - | 'CHECKOUT' - | 'CONNECT' - | 'COPY' - | 'DELETE' - | 'GET' - | 'HEAD' - | 'LINK' - | 'LOCK' - | 'M-SEARCH' - | 'MERGE' - | 'MKACTIVITY' - | 'MKCALENDAR' - | 'MKCOL' - | 'MOVE' - | 'NOTIFY' - | 'OPTIONS' - | 'PATCH' - | 'POST' - | 'PRI' - | 'PROPFIND' - | 'PROPPATCH' - | 'PURGE' - | 'PUT' - | 'REBIND' - | 'REPORT' - | 'SEARCH' - | 'SOURCE' - | 'SUBSCRIBE' - | 'TRACE' - | 'UNBIND' - | 'UNLINK' - | 'UNLOCK' - | 'UNSUBSCRIBE'; - -export const methods = Object.fromEntries(METHODS.map((method) => [method as Methods, Symbol(`HTTP-${method}`)])) as Record; -export const methodEntries = Object.entries(methods) as readonly [Methods, symbol][]; +export const MethodNames = [ + 'ACL', + 'BIND', + 'CHECKOUT', + 'CONNECT', + 'COPY', + 'DELETE', + 'GET', + 'HEAD', + 'LINK', + 'LOCK', + 'M-SEARCH', + 'MERGE', + 'MKACTIVITY', + 'MKCALENDAR', + 'MKCOL', + 'MOVE', + 'NOTIFY', + 'OPTIONS', + 'PATCH', + 'POST', + 'PROPFIND', + 'PROPPATCH', + 'PURGE', + 'PUT', + 'REBIND', + 'REPORT', + 'SEARCH', + 'SOURCE', + 'SUBSCRIBE', + 'TRACE', + 'UNBIND', + 'UNLINK', + 'UNLOCK', + 'UNSUBSCRIBE' +] as const; diff --git a/packages/api/src/lib/structures/http/Server.ts b/packages/api/src/lib/structures/http/Server.ts index 9c24d1f4..c765b036 100644 --- a/packages/api/src/lib/structures/http/Server.ts +++ b/packages/api/src/lib/structures/http/Server.ts @@ -4,7 +4,8 @@ import { Server as HttpServer, createServer as httpCreateServer, type ServerOpti import type { ListenOptions } from 'node:net'; import { MediaParserStore } from '../MediaParserStore'; import { MiddlewareStore } from '../MiddlewareStore'; -import { RouteStore, type RouteMatch } from '../RouteStore'; +import type { Route } from '../Route'; +import { RouteStore } from '../RouteStore'; import { ApiRequest } from '../api/ApiRequest'; import { ApiResponse } from '../api/ApiResponse'; import { Auth, type ServerOptionsAuth } from './Auth'; @@ -253,5 +254,5 @@ export interface MiddlewareErrorContext { * The route match. * @since 1.2.0 */ - match: RouteMatch; + match: Route; } diff --git a/packages/api/src/listeners/PluginServerMatch.ts b/packages/api/src/listeners/PluginServerMatch.ts index 151c3250..213a4d45 100644 --- a/packages/api/src/listeners/PluginServerMatch.ts +++ b/packages/api/src/listeners/PluginServerMatch.ts @@ -1,7 +1,5 @@ import { Listener } from '@sapphire/framework'; -import type { RouteMatch } from '../lib/structures/RouteStore'; -import type { ApiRequest } from '../lib/structures/api/ApiRequest'; -import type { ApiResponse } from '../lib/structures/api/ApiResponse'; +import type { Route } from '../lib/structures/Route'; import { ServerEvents } from '../lib/structures/http/Server'; export class PluginListener extends Listener { @@ -9,12 +7,12 @@ export class PluginListener extends Listener { super(context, { emitter: 'server', event: ServerEvents.Match }); } - public override run(request: ApiRequest, response: ApiResponse, match: RouteMatch) { + public override run(request: Route.Request, response: Route.Response, route: Route) { this.container.server.emit( response.writableEnded ? ServerEvents.MiddlewareFailure : ServerEvents.MiddlewareSuccess, request, response, - match + route ); } } diff --git a/packages/api/src/listeners/PluginServerMiddlewareSuccess.ts b/packages/api/src/listeners/PluginServerMiddlewareSuccess.ts index e5feb98c..ad5a510c 100644 --- a/packages/api/src/listeners/PluginServerMiddlewareSuccess.ts +++ b/packages/api/src/listeners/PluginServerMiddlewareSuccess.ts @@ -1,7 +1,5 @@ import { Listener } from '@sapphire/framework'; -import type { RouteMatch } from '../lib/structures/RouteStore'; -import type { ApiRequest } from '../lib/structures/api/ApiRequest'; -import type { ApiResponse } from '../lib/structures/api/ApiResponse'; +import type { Route } from '../lib/structures/Route'; import { ServerEvents } from '../lib/structures/http/Server'; export class PluginListener extends Listener { @@ -9,9 +7,9 @@ export class PluginListener extends Listener { super(context, { emitter: 'server', event: ServerEvents.MiddlewareSuccess }); } - public override async run(request: ApiRequest, response: ApiResponse, match: RouteMatch) { + public override async run(request: Route.Request, response: Route.Response, match: Route) { try { - await match.cb(request, response); + await match.run(request, response); } catch (error) { this.container.server.emit(ServerEvents.RouteError, error, { request, response, match }); } diff --git a/packages/api/src/listeners/PluginServerRequest.ts b/packages/api/src/listeners/PluginServerRequest.ts index 7254b3c2..bdd89b6c 100644 --- a/packages/api/src/listeners/PluginServerRequest.ts +++ b/packages/api/src/listeners/PluginServerRequest.ts @@ -9,22 +9,22 @@ export class PluginListener extends Listener { } public override async run(request: ApiRequest, response: ApiResponse) { - const match = this.container.server.routes.match(request); + const route = this.container.server.routes.match(request); try { // Middlewares need to be run regardless of the match, specially since browsers do an OPTIONS request first. - await this.container.server.middlewares.run(request, response, match?.route ?? null); + await this.container.server.middlewares.run(request, response, route); } catch (error) { - this.container.server.emit(ServerEvents.MiddlewareError, error, { request, response, match }); + this.container.server.emit(ServerEvents.MiddlewareError, error, { request, response, match: route }); // If a middleware errored, it might cause undefined behavior in the routes, so we will return early. return; } - if (match === null) { + if (route === null) { this.container.server.emit(ServerEvents.NoMatch, request, response); } else { - this.container.server.emit(ServerEvents.Match, request, response, match); + this.container.server.emit(ServerEvents.Match, request, response, route); } } } diff --git a/packages/api/src/mediaParsers/applicationJson.ts b/packages/api/src/mediaParsers/applicationJson.ts index f9dc23c4..e2b49e08 100644 --- a/packages/api/src/mediaParsers/applicationJson.ts +++ b/packages/api/src/mediaParsers/applicationJson.ts @@ -1,5 +1,4 @@ import { MediaParser } from '../lib/structures/MediaParser'; -import type { ApiRequest } from '../lib/structures/api/ApiRequest'; import { MimeTypes } from '../lib/utils/MimeTypes'; export class PluginMediaParser extends MediaParser { @@ -7,7 +6,7 @@ export class PluginMediaParser extends MediaParser { super(context, { name: MimeTypes.ApplicationJson }); } - public override async run(request: ApiRequest): Promise { + public override async run(request: MediaParser.Request): Promise { const body = await this.readString(request); return body.length === 0 ? null : JSON.parse(body); } diff --git a/packages/api/src/mediaParsers/textPlain.ts b/packages/api/src/mediaParsers/textPlain.ts index 0e2dc4ca..099d10bc 100644 --- a/packages/api/src/mediaParsers/textPlain.ts +++ b/packages/api/src/mediaParsers/textPlain.ts @@ -1,4 +1,3 @@ -import type { ApiRequest } from '../lib/structures/api/ApiRequest'; import { MediaParser } from '../lib/structures/MediaParser'; import { MimeTypes } from '../lib/utils/MimeTypes'; @@ -7,7 +6,7 @@ export class PluginMediaParser extends MediaParser { super(context, { name: MimeTypes.TextPlain }); } - public override async run(request: ApiRequest): Promise { + public override async run(request: MediaParser.Request): Promise { const body = await this.readString(request); return body.length === 0 ? null : body; } diff --git a/packages/api/src/middlewares/auth.ts b/packages/api/src/middlewares/auth.ts index 5fa65577..9d830727 100644 --- a/packages/api/src/middlewares/auth.ts +++ b/packages/api/src/middlewares/auth.ts @@ -1,5 +1,3 @@ -import type { ApiRequest } from '../lib/structures/api/ApiRequest'; -import type { ApiResponse } from '../lib/structures/api/ApiResponse'; import { Middleware } from '../lib/structures/Middleware'; export class PluginMiddleware extends Middleware { @@ -13,7 +11,7 @@ export class PluginMiddleware extends Middleware { this.enabled = server.auth !== null; } - public override run(request: ApiRequest, response: ApiResponse) { + public override run(request: Middleware.Request, response: Middleware.Response) { // If there are no cookies, set auth as null: const authorization = response.cookies.get(this.cookieName); if (!authorization) { diff --git a/packages/api/src/middlewares/body.ts b/packages/api/src/middlewares/body.ts index 5deb4d62..de8877bb 100644 --- a/packages/api/src/middlewares/body.ts +++ b/packages/api/src/middlewares/body.ts @@ -1,5 +1,3 @@ -import type { ApiRequest } from '../lib/structures/api/ApiRequest'; -import type { ApiResponse } from '../lib/structures/api/ApiResponse'; import { HttpCodes } from '../lib/structures/http/HttpCodes'; import type { MediaParserStore } from '../lib/structures/MediaParserStore'; import { Middleware } from '../lib/structures/Middleware'; @@ -13,7 +11,7 @@ export class PluginMiddleware extends Middleware { this.mediaParsers = this.container.server.mediaParsers; } - public override async run(request: ApiRequest, response: ApiResponse, route: Route) { + public override async run(request: Middleware.Request, response: Middleware.Response, route: Route) { // RFC 1341 4. const contentType = request.headers['content-type']; if (typeof contentType !== 'string') return; diff --git a/packages/api/src/middlewares/cookies.ts b/packages/api/src/middlewares/cookies.ts index 7f8d7943..357a2e25 100644 --- a/packages/api/src/middlewares/cookies.ts +++ b/packages/api/src/middlewares/cookies.ts @@ -1,6 +1,4 @@ import { Middleware } from '../lib/structures/Middleware'; -import type { ApiRequest } from '../lib/structures/api/ApiRequest'; -import type { ApiResponse } from '../lib/structures/api/ApiResponse'; import { CookieStore } from '../lib/structures/api/CookieStore'; export class PluginMiddleware extends Middleware { @@ -14,7 +12,7 @@ export class PluginMiddleware extends Middleware { this.domainOverwrite = server.auth?.domainOverwrite ?? null; } - public override run(request: ApiRequest, response: ApiResponse) { + public override run(request: Middleware.Request, response: Middleware.Response) { response.cookies = new CookieStore(request, response, this.production, this.domainOverwrite); } } diff --git a/packages/api/src/middlewares/headers.ts b/packages/api/src/middlewares/headers.ts index 749c2c81..0b86ba99 100644 --- a/packages/api/src/middlewares/headers.ts +++ b/packages/api/src/middlewares/headers.ts @@ -1,29 +1,41 @@ -import { METHODS } from 'node:http'; import { Middleware } from '../lib/structures/Middleware'; import type { Route } from '../lib/structures/Route'; -import type { ApiRequest } from '../lib/structures/api/ApiRequest'; -import type { ApiResponse } from '../lib/structures/api/ApiResponse'; +import type { RouteStore } from '../lib/structures/RouteStore'; import { HttpCodes } from '../lib/structures/http/HttpCodes'; export class PluginMiddleware extends Middleware { private readonly origin: string; - private readonly methods: string = METHODS.join(', '); + private readonly routes: RouteStore; public constructor(context: Middleware.LoaderContext) { super(context, { position: 10 }); this.origin = this.container.server.options.origin ?? '*'; + this.routes = this.container.stores.get('routes'); } - public override run(request: ApiRequest, response: ApiResponse, route: Route | null) { + public override run(request: Middleware.Request, response: Middleware.Response, route: Route | null) { response.setHeader('Date', new Date().toUTCString()); response.setHeader('Access-Control-Allow-Credentials', 'true'); response.setHeader('Access-Control-Allow-Origin', this.origin); response.setHeader('Access-Control-Allow-Headers', 'Authorization, User-Agent, Content-Type'); - response.setHeader('Access-Control-Allow-Methods', this.methods); + response.setHeader('Access-Control-Allow-Methods', this.getMethods(route)); this.ensurePotentialEarlyExit(request, response, route); } + private getMethods(route: Route | null) { + if (route === null) { + const { methods } = this.routes; + if (methods.size === 0) return ''; + if (methods.size === 1) return methods.firstKey()!; + return [...methods.keys()].join(', '); + } + + if (route.methods.size === 0) return ''; + if (route.methods.size === 1) return route.methods.keys().next().value; + return [...route.methods].join(', '); + } + /** * **RFC 7231 4.3.7.** * > This method allows a client to determine the options and/or requirements associated with a @@ -39,7 +51,7 @@ export class PluginMiddleware extends Middleware { * @param response The API response that will go out * @param route The route being requested by the request */ - private ensurePotentialEarlyExit(request: ApiRequest, response: ApiResponse, route: Route | null) { + private ensurePotentialEarlyExit(request: Middleware.Request, response: Middleware.Response, route: Route | null) { if (request.method === 'OPTIONS') { if (!route || !route.methods.has('OPTIONS')) { response.end(); diff --git a/packages/api/src/routes/_load.ts b/packages/api/src/routes/_load.ts index e3be3d92..204888d0 100644 --- a/packages/api/src/routes/_load.ts +++ b/packages/api/src/routes/_load.ts @@ -1,6 +1,6 @@ import { container } from '@sapphire/pieces'; -import { PluginRoute as PluginOAuthCallback } from './oauth/callback'; -import { PluginRoute as PluginOAuthLogout } from './oauth/logout'; +import { PluginRoute as PluginOAuthCallback } from './oauth/callback.post'; +import { PluginRoute as PluginOAuthLogout } from './oauth/logout.post'; export function loadRoutes() { const store = 'routes' as const; diff --git a/packages/api/src/routes/oauth/callback.ts b/packages/api/src/routes/oauth/callback.post.ts similarity index 88% rename from packages/api/src/routes/oauth/callback.ts rename to packages/api/src/routes/oauth/callback.post.ts index bb822605..ab1edd46 100644 --- a/packages/api/src/routes/oauth/callback.ts +++ b/packages/api/src/routes/oauth/callback.post.ts @@ -2,23 +2,20 @@ import { OAuth2Routes, type RESTPostOAuth2AccessTokenResult, type RESTPostOAuth2 import { stringify } from 'querystring'; import { fetch } from 'undici'; import { Route } from '../../lib/structures/Route'; -import type { ApiRequest } from '../../lib/structures/api/ApiRequest'; -import type { ApiResponse } from '../../lib/structures/api/ApiResponse'; import { HttpCodes } from '../../lib/structures/http/HttpCodes'; -import { methods } from '../../lib/structures/http/HttpMethods'; export class PluginRoute extends Route { private readonly redirectUri: string | undefined; public constructor(context: Route.LoaderContext) { - super(context, { route: 'oauth/callback' }); + super(context, { route: 'oauth/callback', methods: ['POST'] }); const { server } = this.container; this.enabled = server.auth !== null; this.redirectUri = server.auth?.redirect; } - public override async [methods.POST](request: ApiRequest, response: ApiResponse) { + public override async run(request: Route.Request, response: Route.Response) { const body = request.body as OAuth2BodyData; if (typeof body?.code !== 'string') { return response.badRequest(); diff --git a/packages/api/src/routes/oauth/logout.ts b/packages/api/src/routes/oauth/logout.post.ts similarity index 89% rename from packages/api/src/routes/oauth/logout.ts rename to packages/api/src/routes/oauth/logout.post.ts index 39bf15f3..82e14976 100644 --- a/packages/api/src/routes/oauth/logout.ts +++ b/packages/api/src/routes/oauth/logout.post.ts @@ -1,22 +1,17 @@ +import { sleep } from '@sapphire/utilities'; import { OAuth2Routes } from 'discord.js'; import { stringify } from 'querystring'; import { fetch } from 'undici'; -import { promisify } from 'util'; import { Route } from '../../lib/structures/Route'; -import type { ApiRequest } from '../../lib/structures/api/ApiRequest'; -import type { ApiResponse } from '../../lib/structures/api/ApiResponse'; import { HttpCodes } from '../../lib/structures/http/HttpCodes'; -import { methods } from '../../lib/structures/http/HttpMethods'; - -const sleep = promisify(setTimeout); export class PluginRoute extends Route { public constructor(context: Route.LoaderContext) { - super(context, { route: 'oauth/logout' }); + super(context, { route: 'oauth/logout', methods: ['POST'] }); this.enabled = this.container.server.auth !== null; } - public override async [methods.POST](request: ApiRequest, response: ApiResponse) { + public override async run(request: Route.Request, response: Route.Response) { if (!request.auth) return response.status(HttpCodes.Unauthorized).json({ error: 'Unauthorized.' }); const result = await this.revoke(request.auth.token); @@ -50,7 +45,7 @@ export class PluginRoute extends Route { return response.status(HttpCodes.InternalServerError).json({ error: 'Unexpected error from server.' }); } - private success(response: ApiResponse) { + private success(response: Route.Response) { // Sending an empty cookie with "expires" set to 1970-01-01 makes the browser instantly remove the cookie. response.cookies.remove(this.container.server.auth!.cookie); return response.json({ success: true });