diff --git a/deno.jsonc b/deno.jsonc index 9ad7b7d..586e1b2 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -9,9 +9,7 @@ "ngrok": "ngrok http 8080", "dnt": "deno run -A tasks/dnt/main.ts" }, - "imports": { - "lc-dailies/": "./" - }, + "imports": { "@fartlabs/rt": "jsr:@fartlabs/rt@^0.0.3", "lc-dailies/": "./" }, "fmt": { "exclude": ["./npm"] }, diff --git a/deno.lock b/deno.lock index 1da6c0f..f60e60d 100644 --- a/deno.lock +++ b/deno.lock @@ -1,5 +1,15 @@ { "version": "3", + "packages": { + "specifiers": { + "jsr:@fartlabs/rt@^0.0.3": "jsr:@fartlabs/rt@0.0.3" + }, + "jsr": { + "@fartlabs/rt@0.0.3": { + "integrity": "a94851f8a185f64b884120bb1d53fb203b62e8f98a27879ffa615560ef6ba806" + } + } + }, "remote": { "https://cdn.skypack.dev/-/tweetnacl@v1.0.3-G4yM3nQ8lnXXlGGQADqJ/dist=es2019,mode=imports/optimized/tweetnacl.js": "d26554516df57e5cb58954e90c633c8871b4e66016b9fe4e07a36db5430bc8c7", "https://cdn.skypack.dev/tweetnacl@1.0.3": "6610aad2ac175c2d575995fc7de8ed552c2e5e05aef80ed8588cf3c6e2db61d7", @@ -114,5 +124,10 @@ "https://deno.land/x/discord_api_types@0.37.71/utils/v10.ts": "056bd036f8c65365ff28eb63ec6897811d51921cca6d068392dd1ca5b397ae62", "https://deno.land/x/discord_api_types@0.37.71/v10.ts": "f3f23492c59e77859aba5b34431edf3668c37f722d7f70c2e1ef7ba4bcda3010", "https://deno.land/x/ulid@v0.3.0/mod.ts": "f7ff065b66abd485051fc68af23becef6ccc7e81f7774d7fcfd894a4b2da1984" + }, + "workspace": { + "dependencies": [ + "jsr:@fartlabs/rt@^0.0.3" + ] } } diff --git a/lib/api/api.ts b/lib/api/api.ts index b793a83..cdb9a57 100644 --- a/lib/api/api.ts +++ b/lib/api/api.ts @@ -1,13 +1,12 @@ +import { createRouter } from "@fartlabs/rt"; import * as discord from "lc-dailies/lib/discord/mod.ts"; import * as leaderboard from "lc-dailies/lib/leaderboard/mod.ts"; -import * as router from "lc-dailies/lib/router/mod.ts"; import * as discord_app from "./discord_app/mod.ts"; -import { - makeSeasonGetHandler, - makeSeasonsGetHandler, - makeSeasonTxtGetHandler, -} from "./seasons.ts"; +import type { Season } from "./types.ts"; +/** + * APIRouterOptions are the options for the API router. + */ export interface APIRouterOptions { discordApplicationID: string; discordPublicKey: string; @@ -20,42 +19,69 @@ export interface APIRouterOptions { * LC-Dailies API. */ export function makeAPIRouter(options: APIRouterOptions) { - return new router.Router() + return createRouter() .post( - new URLPattern({ pathname: "/" }), - discord_app.withErrorResponse( - discord_app.makeDiscordAppHandler( - options.leaderboardClient, - options.discordPublicKey, - options.discordChannelID, - ), - ), + "/", + (ctx) => + discord_app.withErrorResponse( + discord_app.makeDiscordAppHandler( + options.leaderboardClient, + options.discordPublicKey, + options.discordChannelID, + ), + )(ctx.request), ) .get( - new URLPattern({ pathname: "/invite" }), - () => - Promise.resolve( - Response.redirect(makeInviteURL(options.discordApplicationID)), - ), + "/invite", + () => Response.redirect(makeInviteURL(options.discordApplicationID)), ) .get( - new URLPattern({ pathname: "/source" }), - () => - Promise.resolve( - Response.redirect("https://github.com/acmcsufoss/lc-dailies"), - ), + "/source", + () => Response.redirect("https://github.com/acmcsufoss/lc-dailies"), ) .get( - new URLPattern({ pathname: "/seasons" }), - withCORS(makeSeasonsGetHandler(options.leaderboardClient)), + "/seasons", + async () => { + const seasons = await options.leaderboardClient.listSeasons(); + return withCORS(new Response(JSON.stringify(seasons))); + }, ) - .get( - new URLPattern({ pathname: "/seasons/:season_id.txt" }), - withCORS(makeSeasonTxtGetHandler(options.leaderboardClient)), + .get<"season_id">( + "/seasons/:season_id.txt", + async (ctx) => { + const seasonID = ctx.params["season_id"]; + if (!seasonID) { + return new Response("Missing season ID", { status: 400 }); + } + + const season = await getSeasonByIDOrLatest( + options.leaderboardClient, + seasonID, + ); + if (!season) { + return new Response("Season not found", { status: 404 }); + } + + const text = leaderboard.formatScores(season); + return withCORS( + new Response(text, { headers: { "Content-Type": "text/plain" } }), + ); + }, ) - .get( - new URLPattern({ pathname: "/seasons/:season_id" }), - withCORS(makeSeasonGetHandler(options.leaderboardClient)), + .get<"season_id">( + "/seasons", + async (ctx) => { + const seasonID = ctx.params["season_id"]; + if (!seasonID) { + return new Response("Missing season ID", { status: 400 }); + } + + const season = await getSeasonByIDOrLatest( + options.leaderboardClient, + seasonID, + ); + return withCORS(new Response(JSON.stringify(season))); + }, ); } @@ -103,19 +129,38 @@ function makeInviteURL(applicationID: string) { } /** - * withCORS wraps a handler with common CORS headers. + * withCORS wraps a Response with common CORS headers. */ -function withCORS( - handle: router.RouterHandler["handle"], -): router.RouterHandler["handle"] { - return async function (request: router.RouterRequest) { - const response = await handle(request); - response.headers.set("Access-Control-Allow-Origin", "*"); - response.headers.set("Access-Control-Allow-Methods", "GET, POST"); - response.headers.set( - "Access-Control-Allow-Headers", - "Content-Type, Authorization", +function withCORS(response: Response): Response { + response.headers.set("Access-Control-Allow-Origin", "*"); + response.headers.set("Access-Control-Allow-Methods", "GET, POST"); + response.headers.set( + "Access-Control-Allow-Headers", + "Content-Type, Authorization", + ); + + return response; +} + +/** + * getSeasonByIDOrLatest gets a season by ID or the latest season. + */ +async function getSeasonByIDOrLatest( + leaderboardClient: leaderboard.LeaderboardClient, + seasonID: string | undefined, +): Promise { + const season = !seasonID || seasonID === "latest" + ? await leaderboardClient.getLatestSeason() + : await leaderboardClient.getSeason(seasonID); + if (season && !season.scores) { + season.scores = await leaderboard.calculateScores( + leaderboard.makeDefaultCalculateScoresOptions( + season.players, + season.questions, + season.submissions, + ), ); - return response; - }; + } + + return season; } diff --git a/lib/api/discord_app/app.ts b/lib/api/discord_app/app.ts index dfc78c3..5a8ea86 100644 --- a/lib/api/discord_app/app.ts +++ b/lib/api/discord_app/app.ts @@ -11,7 +11,6 @@ import { MessageFlags, Utils, } from "lc-dailies/deps.ts"; -import * as router from "lc-dailies/lib/router/mod.ts"; import * as discord from "lc-dailies/lib/discord/mod.ts"; import * as leaderboard from "lc-dailies/lib/leaderboard/mod.ts"; import { @@ -49,11 +48,11 @@ export function makeDiscordAppHandler( discordChannelID: string, ) { return async function handleDiscordApp( - request: router.RouterRequest, + request: Request, ): Promise { // Verify the request is coming from Discord. const { error, body } = await discord.verify( - request.request, + request, discordPublicKey, ); if (error !== null) { @@ -180,11 +179,9 @@ async function handleSyncSubcommand( * and return a response using the error message. */ export function withErrorResponse( - oldHandle: router.RouterHandler["handle"], -): router.RouterHandler["handle"] { - return async function handle( - request: router.RouterRequest, - ): Promise { + oldHandle: (request: Request) => Promise, +): (request: Request) => Promise { + return async function handle(request: Request): Promise { return await oldHandle(request) .catch((error) => { if (!(error instanceof Error)) { diff --git a/lib/api/seasons.ts b/lib/api/seasons.ts deleted file mode 100644 index bc1947b..0000000 --- a/lib/api/seasons.ts +++ /dev/null @@ -1,90 +0,0 @@ -import * as api from "lc-dailies/lib/api/mod.ts"; -import * as leaderboard from "lc-dailies/lib/leaderboard/mod.ts"; -import * as router from "lc-dailies/lib/router/mod.ts"; - -/** - * makeSeasonsGetHandler makes a handler that returns a list of seasons. - */ -export function makeSeasonsGetHandler( - leaderboardClient: leaderboard.LeaderboardClient, -) { - /** - * handleGetSeasons handles GET requests to the seasons endpoint. - */ - return async function handleGetSeasons(): Promise { - const seasons = await leaderboardClient.listSeasons(); - return new Response(JSON.stringify(seasons)); - }; -} - -/** - * makeSeasonGetHandler makes a handler that returns a season. - */ -export function makeSeasonGetHandler( - leaderboardClient: leaderboard.LeaderboardClient, -) { - /** - * handleGetSeason handles GET requests to the season endpoint. - */ - return async function handleGetSeason( - request: router.RouterRequest, - ): Promise { - const seasonID = request.params["season_id"]; - if (!seasonID) { - return new Response("Missing season ID", { status: 400 }); - } - - const season = await getSeasonByIDOrLatest(leaderboardClient, seasonID); - return new Response(JSON.stringify(season)); - }; -} - -/** - * makeSeasonTxtGetHandler makes a handler that returns a plaintext - * representation of a season. - */ -export function makeSeasonTxtGetHandler( - leaderboardClient: leaderboard.LeaderboardClient, -) { - /** - * handleGetSeasonTxt handles GET requests to the season.txt endpoint. - */ - return async function handleGetSeasonTxt( - request: router.RouterRequest, - ): Promise { - const seasonID = request.params["season_id"]; - if (!seasonID) { - return new Response("Missing season ID", { status: 400 }); - } - - const season = await getSeasonByIDOrLatest(leaderboardClient, seasonID); - if (!season) { - return new Response("Season not found", { status: 404 }); - } - - const text = leaderboard.formatScores(season); - return new Response(text, { - headers: { "Content-Type": "text/plain" }, - }); - }; -} - -async function getSeasonByIDOrLatest( - leaderboardClient: leaderboard.LeaderboardClient, - seasonID: string | undefined, -): Promise { - const season = !seasonID || seasonID === "latest" - ? await leaderboardClient.getLatestSeason() - : await leaderboardClient.getSeason(seasonID); - if (season && !season.scores) { - season.scores = await leaderboard.calculateScores( - leaderboard.makeDefaultCalculateScoresOptions( - season.players, - season.questions, - season.submissions, - ), - ); - } - - return season; -} diff --git a/lib/router/mod.ts b/lib/router/mod.ts deleted file mode 100644 index d18015d..0000000 --- a/lib/router/mod.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./router.ts"; diff --git a/lib/router/router.ts b/lib/router/router.ts deleted file mode 100644 index 760c042..0000000 --- a/lib/router/router.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * RouterHandler is a function which can be registered to a specific route in the - * router. The router will call the handler with the request object and any - * matched parameters. - */ -export interface RouterHandler { - /** - * method is the HTTP method to match on. - */ - method: "get" | "post" | "put" | "delete" | "options" | "head"; - - /** - * handle is the function which will be called when a request matches the - * route. - */ - handle: (r: RouterRequest) => Promise; -} - -/** - * RouterRequest is a structure which contains the request object and any - * matched parameters. - */ -export interface RouterRequest { - /** - * request is the original request object. - */ - request: Request; - - /** - * url is the parsed fully qualified URL of the request. - */ - url: URL; - - /** - * params is a map of matched parameters from the URL pattern. - */ - params: { [key: string]: string }; -} - -/** - * RouterHandlerMap is a map of URL patterns to handlers. The router will use - * this map to find a handler for a given request. - */ -export type RouterHandlerMap = Map; - -/** - * Router is a simple HTTP server which can be configured with handlers for - * specific routes. - */ -export class Router { - constructor( - public handlerMap: RouterHandlerMap = new Map(), - public readonly response404: Response = new Response("Not found", { - status: 404, - }), - ) {} - - /** - * get registers a handler for the "get" HTTP method. - */ - public get(pattern: URLPattern, fn: RouterHandler["handle"]): this { - this.handlerMap.set(pattern, { method: "get", handle: fn }); - return this; - } - - /** - * post registers a handler for the "post" HTTP method. - */ - public post(pattern: URLPattern, fn: RouterHandler["handle"]): this { - this.handlerMap.set(pattern, { method: "post", handle: fn }); - return this; - } - - /** - * put registers a handler for the "put" HTTP method. - */ - public put(pattern: URLPattern, fn: RouterHandler["handle"]): this { - this.handlerMap.set(pattern, { method: "put", handle: fn }); - return this; - } - - /** - * delete registers a handler for the "delete" HTTP method. - */ - public delete(pattern: URLPattern, fn: RouterHandler["handle"]): this { - this.handlerMap.set(pattern, { method: "delete", handle: fn }); - return this; - } - - /** - * options registers a handler for the "options" HTTP method. - */ - public options(pattern: URLPattern, fn: RouterHandler["handle"]): this { - this.handlerMap.set(pattern, { method: "options", handle: fn }); - return this; - } - - /** - * head registers a handler for the "head" HTTP method. - */ - public head(pattern: URLPattern, fn: RouterHandler["handle"]): this { - this.handlerMap.set(pattern, { method: "head", handle: fn }); - return this; - } - - /** - * execute is a method that runs the router with the given request. If a - * handler is found, it will be called and the response will be returned. - */ - public async execute(request: Request): Promise { - for (const [pattern, handler] of this.handlerMap) { - const match = pattern.exec(request.url); - if ( - !match || - handler.method !== request.method.toLowerCase() - ) { - continue; - } - - const url = new URL(request.url); - const params = Object.entries(match.pathname.groups) - .reduce((acc, [key, value]) => { - if (value) acc[key] = value; - return acc; - }, {} as { [key: string]: string }); - const response = await handler.handle({ - request, - url, - params, - }); - - return response; - } - - return this.response404; - } - - /** - * serve starts the server on the given port. If onListen is provided, it will - * be called with the hostname and port that the server is listening on. - */ - public static serve( - serveOptions: Deno.ServeOptions, - router: Router, - ): Deno.HttpServer { - return Deno.serve( - serveOptions, - router.execute.bind(router), - ); - } -} diff --git a/main.ts b/main.ts index 0114a42..9141425 100644 --- a/main.ts +++ b/main.ts @@ -1,5 +1,4 @@ import { DenoKvLeaderboardClient } from "lc-dailies/lib/leaderboard/denokv/mod.ts"; -import { Router } from "lc-dailies/lib/router/mod.ts"; import * as lc from "lc-dailies/lib/lc/mod.ts"; import * as api from "lc-dailies/lib/api/mod.ts"; import { @@ -29,7 +28,7 @@ async function main() { leaderboardClient, }); - Router.serve( + Deno.serve( { port: PORT, onListen: api.makeOnListen( @@ -38,8 +37,6 @@ async function main() { DISCORD_TOKEN, ), }, - router, - ) - .finished - .finally(() => kv.close()); + (request) => router.fetch(request), + ); }