mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(api): add versioned REST API with OpenAPI 3.1 spec
Expose /api/v1/* for feed and email management (feeds CRUD, email list/get/delete, stats) so the service can be automated without scraping the admin UI. Built on @hono/zod-openapi; the OpenAPI 3.1 spec is served at /api/openapi.json with a Scalar reference at /api/docs. Auth is token-based (Authorization: Bearer <ADMIN_PASSWORD>) plus the existing reverse-proxy headers — no cookie, no CSRF. Extracted the auth primitives into src/lib/auth.ts and the feed create/update/delete orchestration into src/lib/feed-service.ts so the admin UI and the REST API share a single source of truth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
|
||||
import { Scalar } from "@scalar/hono-api-reference";
|
||||
import { Env, FeedConfig } from "../../types";
|
||||
import { apiAuthMiddleware } from "../../lib/auth";
|
||||
import {
|
||||
createFeedRecord,
|
||||
updateFeedRecord,
|
||||
deleteFeedRecord,
|
||||
} from "../../lib/feed-service";
|
||||
import { listAllFeeds, deleteAttachmentsForEmails } from "../admin/helpers";
|
||||
import {
|
||||
getFeedConfig,
|
||||
getFeedMetadata,
|
||||
getEmailData,
|
||||
} from "../../utils/storage";
|
||||
import { getStats } from "../../utils/stats";
|
||||
import { feedEmailAddress, feedRssUrl, feedAtomUrl } from "../../utils/urls";
|
||||
import {
|
||||
ErrorSchema,
|
||||
FeedIdParam,
|
||||
EntryIdParam,
|
||||
FeedCreateSchema,
|
||||
FeedUpdateSchema,
|
||||
FeedSchema,
|
||||
FeedListSchema,
|
||||
EmailListSchema,
|
||||
EmailSchema,
|
||||
StatsSchema,
|
||||
} from "./schemas";
|
||||
|
||||
type AppEnv = { Bindings: Env };
|
||||
|
||||
const OkSchema = z.object({ ok: z.boolean() }).openapi("Ok");
|
||||
|
||||
const jsonContent = <T>(schema: T, description: string) => ({
|
||||
content: { "application/json": { schema } },
|
||||
description,
|
||||
});
|
||||
|
||||
const bearer = [{ bearerAuth: [] }];
|
||||
|
||||
function normalizeSenders(senders?: string[]): string[] | undefined {
|
||||
return senders?.map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
function toFeed(
|
||||
id: string,
|
||||
config: FeedConfig,
|
||||
emailCount: number,
|
||||
env: Env,
|
||||
): z.infer<typeof FeedSchema> {
|
||||
return {
|
||||
id,
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
language: config.language,
|
||||
allowedSenders: config.allowed_senders ?? [],
|
||||
blockedSenders: config.blocked_senders ?? [],
|
||||
createdAt: config.created_at,
|
||||
updatedAt: config.updated_at,
|
||||
expiresAt: config.expires_at,
|
||||
emailCount,
|
||||
emailAddress: feedEmailAddress(id, env),
|
||||
rssUrl: feedRssUrl(id, env),
|
||||
atomUrl: feedAtomUrl(id, env),
|
||||
};
|
||||
}
|
||||
|
||||
export const apiApp = new OpenAPIHono<AppEnv>({
|
||||
defaultHook: (result, c) => {
|
||||
if (!result.success) {
|
||||
const message = result.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join("; ");
|
||||
return c.json({ error: `Validation failed: ${message}` }, 400);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Token auth on every /v1 route. The spec + docs stay public.
|
||||
apiApp.use("/v1/*", apiAuthMiddleware);
|
||||
|
||||
apiApp.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
description: "Use the admin password as the bearer token.",
|
||||
});
|
||||
|
||||
// ── Feeds ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/v1/feeds",
|
||||
tags: ["Feeds"],
|
||||
summary: "List all feeds",
|
||||
security: bearer,
|
||||
responses: {
|
||||
200: jsonContent(FeedListSchema, "The list of feeds"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const feeds = await listAllFeeds(env.EMAIL_STORAGE);
|
||||
return c.json(
|
||||
{
|
||||
feeds: feeds.map((f) => ({
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
description: f.description,
|
||||
expiresAt: f.expires_at,
|
||||
emailAddress: feedEmailAddress(f.id, env),
|
||||
rssUrl: feedRssUrl(f.id, env),
|
||||
atomUrl: feedAtomUrl(f.id, env),
|
||||
})),
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/v1/feeds",
|
||||
tags: ["Feeds"],
|
||||
summary: "Create a feed",
|
||||
security: bearer,
|
||||
request: {
|
||||
body: { content: { "application/json": { schema: FeedCreateSchema } } },
|
||||
},
|
||||
responses: {
|
||||
201: jsonContent(FeedSchema, "The created feed"),
|
||||
400: jsonContent(ErrorSchema, "Invalid request body"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const body = c.req.valid("json");
|
||||
const { feedId, config } = await createFeedRecord(env, {
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
language: body.language,
|
||||
allowedSenders: normalizeSenders(body.allowedSenders) ?? [],
|
||||
blockedSenders: normalizeSenders(body.blockedSenders) ?? [],
|
||||
lifetimeHours: body.lifetimeHours,
|
||||
});
|
||||
return c.json(toFeed(feedId, config, 0, env), 201);
|
||||
},
|
||||
);
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/v1/feeds/{feedId}",
|
||||
tags: ["Feeds"],
|
||||
summary: "Get a feed",
|
||||
security: bearer,
|
||||
request: { params: FeedIdParam },
|
||||
responses: {
|
||||
200: jsonContent(FeedSchema, "The feed"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
404: jsonContent(ErrorSchema, "Feed not found"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const { feedId } = c.req.valid("param");
|
||||
const config = await getFeedConfig(env.EMAIL_STORAGE, feedId);
|
||||
if (!config) return c.json({ error: "Feed not found" }, 404);
|
||||
const metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId);
|
||||
return c.json(
|
||||
toFeed(feedId, config, metadata?.emails.length ?? 0, env),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "patch",
|
||||
path: "/v1/feeds/{feedId}",
|
||||
tags: ["Feeds"],
|
||||
summary: "Update a feed",
|
||||
security: bearer,
|
||||
request: {
|
||||
params: FeedIdParam,
|
||||
body: { content: { "application/json": { schema: FeedUpdateSchema } } },
|
||||
},
|
||||
responses: {
|
||||
200: jsonContent(FeedSchema, "The updated feed"),
|
||||
400: jsonContent(ErrorSchema, "Invalid request body"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
404: jsonContent(ErrorSchema, "Feed not found"),
|
||||
409: jsonContent(ErrorSchema, "Feed has expired"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const { feedId } = c.req.valid("param");
|
||||
const body = c.req.valid("json");
|
||||
const result = await updateFeedRecord(env, feedId, {
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
language: body.language,
|
||||
allowedSenders: normalizeSenders(body.allowedSenders),
|
||||
blockedSenders: normalizeSenders(body.blockedSenders),
|
||||
lifetimeHours: body.lifetimeHours,
|
||||
});
|
||||
if (result.status === "not_found")
|
||||
return c.json({ error: "Feed not found" }, 404);
|
||||
if (result.status === "expired")
|
||||
return c.json({ error: "Feed has expired and cannot be modified" }, 409);
|
||||
const metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId);
|
||||
return c.json(
|
||||
toFeed(feedId, result.config, metadata?.emails.length ?? 0, env),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/v1/feeds/{feedId}",
|
||||
tags: ["Feeds"],
|
||||
summary: "Delete a feed",
|
||||
security: bearer,
|
||||
request: { params: FeedIdParam },
|
||||
responses: {
|
||||
200: jsonContent(OkSchema, "The feed was deleted"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
404: jsonContent(ErrorSchema, "Feed not found"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const { feedId } = c.req.valid("param");
|
||||
const removed = await deleteFeedRecord(c, env, feedId);
|
||||
if (!removed) return c.json({ error: "Feed not found" }, 404);
|
||||
return c.json({ ok: true }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Emails ──────────────────────────────────────────────────────────────────
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/v1/feeds/{feedId}/emails",
|
||||
tags: ["Emails"],
|
||||
summary: "List a feed's emails",
|
||||
security: bearer,
|
||||
request: { params: FeedIdParam },
|
||||
responses: {
|
||||
200: jsonContent(EmailListSchema, "The feed's emails"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
404: jsonContent(ErrorSchema, "Feed not found"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const { feedId } = c.req.valid("param");
|
||||
const metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId);
|
||||
if (!metadata) return c.json({ error: "Feed not found" }, 404);
|
||||
return c.json(
|
||||
{
|
||||
emails: metadata.emails.map((e) => ({
|
||||
entryId: e.receivedAt,
|
||||
subject: e.subject,
|
||||
receivedAt: e.receivedAt,
|
||||
size: e.size,
|
||||
attachmentIds: e.attachmentIds,
|
||||
})),
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/v1/feeds/{feedId}/emails/{entryId}",
|
||||
tags: ["Emails"],
|
||||
summary: "Get a single email",
|
||||
security: bearer,
|
||||
request: { params: EntryIdParam },
|
||||
responses: {
|
||||
200: jsonContent(EmailSchema, "The email"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
404: jsonContent(ErrorSchema, "Email not found"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const { feedId, entryId } = c.req.valid("param");
|
||||
const receivedAt = parseInt(entryId, 10);
|
||||
const metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId);
|
||||
const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt);
|
||||
if (!metaEntry) return c.json({ error: "Email not found" }, 404);
|
||||
const data = await getEmailData(env.EMAIL_STORAGE, metaEntry.key);
|
||||
if (!data) return c.json({ error: "Email not found" }, 404);
|
||||
return c.json(
|
||||
{
|
||||
entryId: receivedAt,
|
||||
subject: data.subject,
|
||||
from: data.from,
|
||||
receivedAt: data.receivedAt,
|
||||
content: data.content,
|
||||
attachments: (data.attachments ?? []).map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
contentType: a.contentType,
|
||||
size: a.size,
|
||||
url: `/files/${a.id}/${encodeURIComponent(a.filename)}`,
|
||||
})),
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/v1/feeds/{feedId}/emails/{entryId}",
|
||||
tags: ["Emails"],
|
||||
summary: "Delete a single email",
|
||||
security: bearer,
|
||||
request: { params: EntryIdParam },
|
||||
responses: {
|
||||
200: jsonContent(OkSchema, "The email was deleted"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
404: jsonContent(ErrorSchema, "Email not found"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const { feedId, entryId } = c.req.valid("param");
|
||||
const receivedAt = parseInt(entryId, 10);
|
||||
const metadata = await getFeedMetadata(emailStorage, feedId);
|
||||
const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt);
|
||||
if (!metadata || !metaEntry)
|
||||
return c.json({ error: "Email not found" }, 404);
|
||||
|
||||
await emailStorage.delete(metaEntry.key);
|
||||
await deleteAttachmentsForEmails(env, metadata.emails, [metaEntry.key]);
|
||||
metadata.emails = metadata.emails.filter((e) => e.key !== metaEntry.key);
|
||||
await emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(metadata));
|
||||
|
||||
return c.json({ ok: true }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
apiApp.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/v1/stats",
|
||||
tags: ["Stats"],
|
||||
summary: "Read monitoring counters",
|
||||
security: bearer,
|
||||
responses: {
|
||||
200: jsonContent(StatsSchema, "Monitoring counters"),
|
||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await getStats(c.env), 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ── OpenAPI document + docs (public) ───────────────────────────────────────────
|
||||
|
||||
apiApp.doc31("/openapi.json", {
|
||||
openapi: "3.1.0",
|
||||
info: {
|
||||
title: "kill-the-news API",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"REST API for managing email-to-RSS feeds, their emails, and monitoring counters.",
|
||||
},
|
||||
servers: [{ url: "/api" }],
|
||||
});
|
||||
|
||||
apiApp.get(
|
||||
"/docs",
|
||||
Scalar({
|
||||
url: "/api/openapi.json",
|
||||
pageTitle: "kill-the-news API",
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user