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.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user