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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user