feat: REST-API unter /api/v1 mit OpenAPI-Beschreibung

Dieselben Endpunkte bedienen Skripte und das eigene Frontend: erst wird ein
Bearer-Token geprüft, sonst das Sitzungscookie.

Sichtungsaktionen geben immer watchIds zurück — damit lässt sich genau diese
Aktion über DELETE zurücknehmen, ohne raten zu müssen, was dazugehörte.

Die OpenAPI-Beschreibung ist handgeschrieben statt aus Zod erzeugt: die
Schnittstelle ist überschaubar, und ein Generator brächte eine weitere
Abhängigkeit für einen Nutzen, den ein Dutzend Zeilen JSON auch hergeben.
This commit is contained in:
Florian Wilde
2026-09-01 20:24:48 +02:00
parent b25f0473fd
commit bb63827bf7
12 changed files with 847 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
import "server-only";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getApiUser, type SessionUser } from "./auth";
import { LibraryError } from "./library";
import { UniverseError } from "./universes";
/**
* Gemeinsames Gerüst der REST-API.
*
* Zwei Dinge macht jeder Endpunkt gleich: die Anmeldung prüfen (Bearer-Token
* oder Sitzungscookie — dieselben Endpunkte bedienen Skripte und das eigene
* Frontend) und Fehler in eine einheitliche Form bringen.
*/
export type ApiHandler<T = unknown> = (
request: Request,
user: SessionUser,
context: { params: Promise<Record<string, string>> },
) => Promise<T>;
export function apiError(message: string, status = 400, details?: unknown) {
return NextResponse.json({ error: message, ...(details ? { details } : {}) }, { status });
}
/**
* Umhüllt einen Endpunkt: Anmeldung, Fehlerbehandlung, JSON-Antwort.
* Gibt der Handler bereits eine Response zurück, wird sie durchgereicht.
*/
export function withApi<T>(handler: ApiHandler<T>) {
return async (request: Request, context: { params: Promise<Record<string, string>> }) => {
const user = await getApiUser(request);
if (!user) {
return NextResponse.json(
{
error:
"Nicht angemeldet. Schicke einen API-Schlüssel im Kopf: Authorization: Bearer <schlüssel>",
},
{ status: 401, headers: { "www-authenticate": "Bearer" } },
);
}
try {
const result = await handler(request, user, context);
if (result instanceof NextResponse) return result;
return NextResponse.json(result ?? { ok: true });
} catch (error) {
if (error instanceof z.ZodError) {
return apiError("Die Anfrage ist unvollständig oder falsch aufgebaut.", 422, error.issues);
}
if (error instanceof LibraryError || error instanceof UniverseError) {
return apiError(error.message, 400);
}
console.error("API-Fehler:", error);
return apiError("Interner Fehler.", 500);
}
};
}
/** Liest und prüft den JSON-Rumpf einer Anfrage. */
export async function readJson<S extends z.ZodTypeAny>(
request: Request,
schema: S,
): Promise<z.infer<S>> {
let raw: unknown;
try {
raw = await request.json();
} catch {
throw new z.ZodError([
{
code: "custom",
path: [],
message: "Der Rumpf der Anfrage ist kein gültiges JSON.",
},
]);
}
return schema.parse(raw);
}
/** Seitenweise Abfrage: `?limit=50&offset=0` */
export function readPaging(request: Request, defaultLimit = 50, maxLimit = 200) {
const url = new URL(request.url);
const limit = Math.min(
maxLimit,
Math.max(1, Number.parseInt(url.searchParams.get("limit") ?? "", 10) || defaultLimit),
);
const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") ?? "", 10) || 0);
return { limit, offset };
}