summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/consts.ts4
-rw-r--r--src/env.ts2
-rw-r--r--src/globals.d.ts2
-rw-r--r--src/index.ts103
-rw-r--r--src/schemas.ts26
-rw-r--r--src/teawie.ts98
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}`;
diff --git a/src/env.ts b/src/env.ts
index 01b9713..2ee3db7 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -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;
+};