9d9dfb46c6
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.
142 lines
14 KiB
TypeScript
142 lines
14 KiB
TypeScript
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 (1–5)", 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;
|
||
}
|