diff options
| author | seth <[email protected]> | 2024-10-12 14:28:54 -0400 |
|---|---|---|
| committer | seth <[email protected]> | 2024-10-12 14:59:04 -0400 |
| commit | 6a2d9e752fab27b59da4f194b0ef6daf7e8b6d81 (patch) | |
| tree | ed8f9f07861a0a4463dcd910baa349b6cc6656aa /lib | |
| parent | 08912b439bd61088dd849b9342a81341fa9e4a23 (diff) | |
port to deno
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/consts.ts | 2 | ||||
| -rw-r--r-- | lib/mod.ts | 104 | ||||
| -rw-r--r-- | lib/schemas.ts | 34 | ||||
| -rw-r--r-- | lib/teawie.ts | 108 |
4 files changed, 248 insertions, 0 deletions
diff --git a/lib/consts.ts b/lib/consts.ts new file mode 100644 index 0000000..b14ee1d --- /dev/null +++ b/lib/consts.ts @@ -0,0 +1,2 @@ +export const VERSION = "0.2.0"; +export const USER_AGENT = `teawieAPI/${VERSION}`; diff --git a/lib/mod.ts b/lib/mod.ts new file mode 100644 index 0000000..67f10e6 --- /dev/null +++ b/lib/mod.ts @@ -0,0 +1,104 @@ +import { env } from "@hono/hono/adapter"; +import { logger } from "@hono/hono/logger"; +import { prettyJSON } from "@hono/hono/pretty-json"; +import { swaggerUI } from "@hono/swagger-ui"; +import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; +import { VERSION } from "./consts.ts"; +import { + ListTeawiesParams, + ListTeawiesResponse, + RandomTeawiesResponse, +} from "./schemas.ts"; +import { imageUrls } from "./teawie.ts"; + +const app = new OpenAPIHono(); + +app.use("*", logger()); +app.use("*", prettyJSON()); + +app.get( + "/", + (c) => { + const { REDIRECT_ROOT } = env<{ REDIRECT_ROOT: string | undefined }>(c); + + return c.redirect( + REDIRECT_ROOT ?? "https://github.com/getchoo/teawieAPI", + ); + }, +); + +app.get("/swagger", swaggerUI({ url: "/doc" })); + +app.doc("/doc", { + openapi: "3.0.0", + info: { + version: VERSION, + title: "teawieAPI", + }, +}); + +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 kv = await Deno.openKv(); + const urls = await imageUrls(kv); + + return c.json( + { + urls: urls.splice(0, parseInt(limit ?? "5")), + }, + 200, + ); + }, +); + +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) => { + const kv = await Deno.openKv(); + + return imageUrls(kv).then((urls) => + c.json({ + url: urls[Math.floor(Math.random() * urls.length)], + }) + ); + }, +); + +app.onError((error, c) => { + console.error(error); + + return c.json({ error: error.message }, 500); +}); + +export default app; diff --git a/lib/schemas.ts b/lib/schemas.ts new file mode 100644 index 0000000..669674a --- /dev/null +++ b/lib/schemas.ts @@ -0,0 +1,34 @@ +import { z } from "@hono/zod-openapi"; + +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() + .default("5") + .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/lib/teawie.ts b/lib/teawie.ts new file mode 100644 index 0000000..b5c68c7 --- /dev/null +++ b/lib/teawie.ts @@ -0,0 +1,108 @@ +import { USER_AGENT } from "./consts.ts"; +import { Endpoints } from "@octokit/types"; + +const URL_CACHE_KEY = ["urls"]; +const URL_CACHE_TTL_MS = 1000 * 60 * 60; // 1 hour + +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: Deno.Kv): Promise<string[]> => { + const cached = await kv.get(URL_CACHE_KEY); + const urls = cached.value; + if (typeof urls == "string") { + console.trace("Found Teawie URLs in cache!"); + return JSON.parse(urls); + } + + 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.set(URL_CACHE_KEY, JSON.stringify(fresh), { + expireIn: URL_CACHE_TTL_MS, + }); + + return fresh; +}; |
