Files
Epicure/apps/web/lib/openapi.ts
T
Arnaud 9d9dfb46c6 feat: misc — cooking mode, OpenAPI docs, email, storage, upload, homepage
Cooking mode step-by-step view. OpenAPI 3.1 spec auto-generated from Zod schemas.
Email lib (resend). Redis client. S3 storage helper. Photo upload endpoint.
Landing page. Short recipe URL redirect /r/[id]. API docs page.
2026-07-01 08:11:31 +02:00

142 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
OpenApiGeneratorV31,
OpenAPIRegistry,
extendZodWithOpenApi,
} from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
// Nothing at module level — Next.js evaluates route modules at build time to
// determine static vs dynamic, which would run registry.register() before
// extendZodWithOpenApi patches the Zod prototype. Wrap everything lazily.
let cachedSpec: object | null = null;
export function generateOpenApiSpec(): object {
if (cachedSpec) return cachedSpec;
extendZodWithOpenApi(z);
const registry = new OpenAPIRegistry();
const security: Array<Record<string, string[]>> = [{ SessionCookie: [] }, { BearerApiKey: [] }];
registry.registerComponent("securitySchemes", "SessionCookie", { type: "apiKey", in: "cookie", name: "better-auth.session_token" });
registry.registerComponent("securitySchemes", "BearerApiKey", { type: "http", scheme: "bearer", bearerFormat: "ek_..." });
const DietaryTagsRef = registry.register("DietaryTags", z.object({
vegan: z.boolean().optional(), vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(), dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(), halal: z.boolean().optional(), kosher: z.boolean().optional(),
}));
const RecipeIngredientRef = registry.register("RecipeIngredient", z.object({
id: z.string(), rawName: z.string(), quantity: z.number().nullable(),
unit: z.string().nullable(), note: z.string().nullable(), order: z.number().int(),
}));
const RecipeStepRef = registry.register("RecipeStep", z.object({
id: z.string(), order: z.number().int(), instruction: z.string(), timerSeconds: z.number().int().nullable(),
}));
const RecipeRef = registry.register("Recipe", z.object({
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
baseServings: z.number().int(), visibility: z.enum(["private", "unlisted", "public"]),
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
ingredients: z.array(RecipeIngredientRef), steps: z.array(RecipeStepRef),
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
}));
const CreateRecipeRef = registry.register("CreateRecipe", z.object({
title: z.string().min(1).max(200), description: z.string().optional(),
baseServings: z.number().int().positive(), visibility: z.enum(["private", "unlisted", "public"]),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
prepMins: z.number().int().positive().optional(), cookMins: z.number().int().positive().optional(),
dietaryTags: DietaryTagsRef.optional(),
ingredients: z.array(z.object({ rawName: z.string(), quantity: z.number().nullable(), unit: z.string().nullable(), note: z.string().nullable(), order: z.number().int() })),
steps: z.array(z.object({ order: z.number().int(), instruction: z.string(), timerSeconds: z.number().int().nullable() })),
}));
const ApiErrorRef = registry.register("ApiError", z.object({ error: z.string() }));
const Pagination = z.object({ page: z.coerce.number().default(1), limit: z.coerce.number().default(20) });
const CommentRef = registry.register("Comment", z.object({
id: z.string(), recipeId: z.string(), userId: z.string(),
body: z.string(), parentId: z.string().nullable(), createdAt: z.string().datetime(),
}));
const CollectionRef = registry.register("Collection", z.object({
id: z.string(), userId: z.string(), name: z.string(),
description: z.string().nullable(), isPublic: z.boolean(), createdAt: z.string().datetime(),
}));
const MealPlanRef = registry.register("MealPlan", z.object({
weekStart: z.string(),
entries: z.array(z.object({ id: z.string(), dayOfWeek: z.number(), mealType: z.string(), recipeId: z.string(), servings: z.number() })),
}));
const PantryItemRef = registry.register("PantryItem", z.object({
id: z.string(), rawName: z.string(), quantity: z.string().nullable(),
unit: z.string().nullable(), expiresAt: z.string().datetime().nullable(),
}));
const ShoppingListRef = registry.register("ShoppingList", z.object({
id: z.string(), name: z.string(), generatedAt: z.string().datetime().nullable(), createdAt: z.string().datetime(),
items: z.array(z.object({ id: z.string(), rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(), aisle: z.string().nullable(), checked: z.boolean() })),
}));
const ApiKeyRef = registry.register("ApiKey", z.object({
id: z.string(), name: z.string(), lastUsedAt: z.string().datetime().nullable(), createdAt: z.string().datetime(),
}));
const CreateApiKeyResponseRef = registry.register("CreateApiKeyResponse", z.object({
id: z.string(), name: z.string(), key: z.string().describe("Full key — shown once"), createdAt: z.string().datetime(),
}));
const AiGeneratedRef = registry.register("AiGeneratedRecipe", z.object({
title: z.string(), description: z.string(), baseServings: z.number(),
difficulty: z.enum(["easy", "medium", "hard"]),
prepMins: z.number().nullable(), cookMins: z.number().nullable(),
dietaryTags: DietaryTagsRef,
ingredients: z.array(z.object({ rawName: z.string(), quantity: z.number().nullable(), unit: z.string().nullable(), note: z.string().nullable() })),
steps: z.array(z.object({ instruction: z.string(), timerSeconds: z.number().nullable() })),
}));
const PaginatedRecipes = z.object({
data: z.array(RecipeRef),
pagination: z.object({ page: z.number(), limit: z.number(), total: z.number(), pages: z.number() }),
});
const idParam = z.object({ id: z.string() });
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List recipes", security, request: { query: z.object({ page: z.coerce.number().default(1), limit: z.coerce.number().default(20), visibility: z.enum(["private", "unlisted", "public"]).optional(), q: z.string().optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes", summary: "Create recipe", security, request: { body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}", summary: "Get recipe", security, request: { params: idParam }, responses: { 200: { description: "Recipe", content: { "application/json": { schema: RecipeRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}", summary: "Update recipe", security, request: { params: idParam, body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: RecipeRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/recipes/{id}", summary: "Delete recipe", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/favorite", summary: "Toggle favorite", security, request: { params: idParam }, responses: { 200: { description: "Favorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/rate", summary: "Rate recipe (15)", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ score: z.number().int().min(1).max(5) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ score: z.number() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments", summary: "List comments", security, request: { params: idParam, query: Pagination }, responses: { 200: { description: "Comments", content: { "application/json": { schema: z.array(CommentRef) } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments", summary: "Post comment", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ body: z.string().min(1), parentId: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: CommentRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(1) }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based)", security, request: { query: Pagination }, responses: { 200: { description: "Feed", content: { "application/json": { schema: z.array(RecipeRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: Pagination }, responses: { 200: { description: "Collections", content: { "application/json": { schema: z.array(CollectionRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}", summary: "Get meal plan for week", security, request: { params: z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") }) }, responses: { 200: { description: "Meal plan", content: { "application/json": { schema: MealPlanRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: Pagination }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.array(PantryItemRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists", summary: "List shopping lists", security, request: { query: Pagination }, responses: { 200: { description: "Lists", content: { "application/json": { schema: z.array(ShoppingListRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists", summary: "Create shopping list", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1), fromMealPlanWeek: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: ShoppingListRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/api-keys", summary: "List API keys", security, responses: { 200: { description: "Keys (hash never returned)", content: { "application/json": { schema: z.array(ApiKeyRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/api-keys", summary: "Create API key", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100) }) } }, required: true } }, responses: { 201: { description: "Key created — shown once", content: { "application/json": { schema: CreateApiKeyResponseRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/api-keys/{id}", summary: "Revoke API key", security, request: { params: idParam }, responses: { 204: { description: "Revoked" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
const generator = new OpenApiGeneratorV31(registry.definitions);
cachedSpec = generator.generateDocument({
openapi: "3.1.0",
info: { title: "Epicure API", version: "1.0.0", description: "Auth: session cookie or Bearer API key (`ek_...`)." },
servers: [{ url: process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000" }],
});
return cachedSpec;
}