diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/consts.ts | 4 | ||||
| -rw-r--r-- | src/env.ts | 2 | ||||
| -rw-r--r-- | src/globals.d.ts | 2 | ||||
| -rw-r--r-- | src/index.ts | 103 | ||||
| -rw-r--r-- | src/schemas.ts | 26 | ||||
| -rw-r--r-- | src/teawie.ts | 98 |
6 files changed, 202 insertions, 33 deletions
diff --git a/src/consts.ts b/src/consts.ts new file mode 100644 index 0000000..0e31b83 --- /dev/null +++ b/src/consts.ts @@ -0,0 +1,4 @@ +import { version } from "../package.json"; + +export const VERSION = version; +export const USER_AGENT = `teawieAPI/${version}`; @@ -1,6 +1,6 @@ export type Bindings = { - ASSETS: Fetcher; REDIRECT_ROOT?: string; + TEAWIE_API: KVNamespace; }; export type Variables = Record<string, never>; diff --git a/src/globals.d.ts b/src/globals.d.ts deleted file mode 100644 index 5e8373c..0000000 --- a/src/globals.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const WIES: string[]; -declare const WIE_DIR: string; diff --git a/src/index.ts b/src/index.ts index 3126b15..9f2703b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,45 +1,92 @@ -import { Hono } from "hono"; import { logger } from "hono/logger"; import { prettyJSON } from "hono/pretty-json"; -import { zValidator } from "@hono/zod-validator"; -import { list } from "./schemas"; +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { VERSION } from "./consts"; import { Bindings, Variables } from "./env"; +import { + ListTeawiesParams, + ListTeawiesResponse, + RandomTeawiesResponse, +} from "./schemas"; +import { imageUrls } from "./teawie"; -const app = new Hono<{ Bindings: Bindings; Variables: Variables }>(); +const app = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>(); app.use("*", logger()); app.use("*", prettyJSON()); -app.get("/", (c) => { - return c.redirect( - c.env.REDIRECT_ROOT ?? "https://github.com/getchoo/teawieAPI", - ); -}); +app.get("/", (c) => + c.redirect(c.env.REDIRECT_ROOT ?? "https://github.com/getchoo/teawieAPI"), +); -app.get("/static/*", async (c) => { - return await c.env.ASSETS.fetch(c.req.raw); +app.doc("/doc", { + openapi: "3.0.0", + info: { + version: VERSION, + title: "teawieAPI", + }, }); -app.get("/list_teawies", zValidator("query", list), async (c) => { - const { limit } = c.req.query(); - - return c.json( - WIES.slice(0, parseInt(limit ?? "5")).map((wie) => { - return { - url: new URL(`/${WIE_DIR}/${wie}`, c.req.url).toString(), - }; - }), - ); -}); +app.openapi( + createRoute({ + method: "get", + path: "/list_teawies", + request: { + params: ListTeawiesParams, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ListTeawiesResponse, + }, + }, + description: "List known Teawie URLS", + }, + }, + }), + async (c) => { + const { limit } = c.req.query(); + const urls = await imageUrls(c.env.TEAWIE_API); -app.get("/random_teawie", (c) => { - const wie = WIES[Math.floor(Math.random() * WIES.length)]; + return c.json( + { + urls: urls.splice(0, parseInt(limit ?? "5")), + }, + 200, + ); + }, +); - return c.json({ - url: new URL(`/${WIE_DIR}/${wie}`, c.req.url).toString(), - }); -}); +app.openapi( + createRoute({ + method: "get", + path: "/random_teawie", + responses: { + 200: { + content: { + "application/json": { + schema: RandomTeawiesResponse, + }, + }, + description: "A random URL to a picture of Teawie", + }, + }, + }), + async (c) => + imageUrls(c.env.TEAWIE_API).then((urls) => + c.json({ + url: urls[Math.floor(Math.random() * urls.length)], + }), + ), +); app.get("/get_random_teawie", (c) => c.redirect("/random_teawie")); +app.onError((error, c) => { + console.error(error); + + return c.json({ error: error.message }, 500); +}); + export default app; diff --git a/src/schemas.ts b/src/schemas.ts index 1b858ff..669674a 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,6 +1,18 @@ -import { z } from "zod"; +import { z } from "@hono/zod-openapi"; -export const list = z.object({ +const ErrorResponse = z.string().optional().openapi({ + description: "Error message reported by server", +}); + +const TeawieURLResponse = z.object({ + url: z.string().url().optional().openapi({ + description: "URL to Teawie", + }), + + error: ErrorResponse, +}); + +export const ListTeawiesParams = z.object({ limit: z .string() .optional() @@ -8,5 +20,15 @@ export const list = z.object({ .refine((data) => { const parsed = parseInt(data); return !isNaN(parsed); + }) + .openapi({ + description: "Maximum number of Teawie URLs to be returned", }), }); + +export const ListTeawiesResponse = z.object({ + urls: z.array(z.string().url()).optional(), + error: ErrorResponse, +}); + +export const RandomTeawiesResponse = TeawieURLResponse; diff --git a/src/teawie.ts b/src/teawie.ts new file mode 100644 index 0000000..65d6617 --- /dev/null +++ b/src/teawie.ts @@ -0,0 +1,98 @@ +import { USER_AGENT } from "./consts"; +import { Endpoints } from "@octokit/types"; + +type repositoryPathContentsResponse = + Endpoints["GET /repos/{owner}/{repo}/contents/{path}"]["response"]; + +const GITHUB_API = "https://api.github.com"; + +// Teawie repository owner and name +const REPO_OWNER = "SympathyTea"; +const REPO_NAME = "Teawie-Archive"; + +// Subdirectories of the above repository containing files we want +const SUBDIRS = [ + "teawie-media/Original Teawies", + "teawie-media/Teawie Variants", + "teawie-media/Teawie in Places", + "teawie-media/Unfinished Teawies", +]; + +// File extensions we consider to be images +const IMAGE_EXTENSIONS = ["gif", "jpg", "jpeg", "png", "svg", "webp"]; + +const contentsOf = ( + path: string, +): Promise<repositoryPathContentsResponse["data"]> => + fetch(`${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/contents/${path}`, { + headers: { + accept: "application/vnd.github+json", + "user-agent": USER_AGENT, + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error( + `HTTP Error ${response.status}: ${response.statusText}`, + ); + } + + return response.json(); + }) + .then((json) => { + return json as repositoryPathContentsResponse["data"]; + }); + +const imageUrlsIn = ( + files: repositoryPathContentsResponse["data"], +): string[] => { + // NOTE: This is done because the response may only contain data + // for a single file's path + const filesArray = Array.isArray(files) ? files : [files]; + + return ( + filesArray + // Find *files* that are (probably) images and have a download URL + .filter( + (file) => + !Array.isArray(file) && + file.download_url && + file.type == "file" && + IMAGE_EXTENSIONS.includes(file.name.split(".").at(-1) ?? ""), + ) + .map((file) => { + // Should this happen? No + // Could it? I don't know + // But let's be safe :steamhappy: + if (!file.download_url) { + throw new Error( + `Could not find download URL for file "${file.name}"`, + ); + } + + return file.download_url; + }) + ); +}; + +export const imageUrls = async (kv: KVNamespace): Promise<string[]> => { + const cached = await kv.get("urls"); + if (cached) { + console.trace("Found Teawie URLs in cache!"); + return JSON.parse(cached); + } + + console.warn("Couldn't find Teawie URLs in cache! Fetching fresh ones"); + const fresh = await Promise.all(SUBDIRS.map(contentsOf)).then((responses) => { + // See the note above + const flatResponses = responses.flatMap((response) => + Array.isArray(response) ? response : [response], + ); + + return imageUrlsIn(flatResponses); + }); + + await kv.put("urls", JSON.stringify(fresh), { expirationTtl: 60 * 60 }); + + return fresh; +}; |
