Build an AI Recipe Generation API: End-to-End Tutorial with JSON Schema, Validation, and Caching
Build a production-ready AI recipe generation API with JSON schema, prompting, validation, caching, and code in Node.js and Python.
Image used for representation purposes only.
What you’ll build
You’ll create a production‑ready AI recipe generation API that accepts ingredients, dietary preferences, and constraints, then returns a well‑structured JSON recipe: title, servings, ingredients, steps, timing, nutrition estimates, tags, and warnings. Along the way you’ll learn:
- How to design a robust JSON Schema for recipes
- Prompting techniques for consistent, structured outputs
- Server implementations in Node.js (Express) and Python (FastAPI)
- Validation, caching, evaluation, and safety best practices
By the end, you’ll have a working endpoint you can drop into a mobile app, a web UI, or a meal‑planning service.
Architecture at a glance
A reliable generation pipeline usually looks like this:
- Request intake: query params or JSON body (ingredients, cuisine, diet, allergens, target calories, servings).
- Prompt assembly: system rules + user context + few‑shot exemplars + JSON Schema hints.
- Model call: a structured generation mode (JSON schema / function call) with sensible temperature and token limits.
- Validation: enforce the schema with a server‑side validator; fix minor issues automatically when possible.
- Post‑processing: normalize units, compute nutrition, add substitutions.
- Caching: content‑addressable cache keyed by normalized inputs and a versioned prompt.
- Response: clean JSON with metadata (model, seed, version, latency, cache status).
Define a durable recipe schema
Start with a typed, documented contract your API promises to return. Keep it strict enough to be useful, but flexible for future fields.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/recipe.schema.json",
"title": "Recipe",
"type": "object",
"required": [
"title", "cuisine", "servings", "total_time_minutes",
"ingredients", "steps", "tags", "nutrition"
],
"properties": {
"title": {"type": "string", "minLength": 3},
"cuisine": {"type": "string"},
"description": {"type": "string"},
"servings": {"type": "integer", "minimum": 1},
"total_time_minutes": {"type": "integer", "minimum": 1},
"ingredients": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["item", "quantity", "unit"],
"properties": {
"item": {"type": "string"},
"quantity": {"type": "number", "minimum": 0},
"unit": {"type": "string"},
"notes": {"type": "string"},
"allergens": {"type": "array", "items": {"type": "string"}}
}
}
},
"steps": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["order", "instruction"],
"properties": {
"order": {"type": "integer", "minimum": 1},
"instruction": {"type": "string"},
"duration_minutes": {"type": "integer", "minimum": 0},
"tools": {"type": "array", "items": {"type": "string"}},
"temperature_c": {"type": "integer"}
}
}
},
"tags": {"type": "array", "items": {"type": "string"}},
"nutrition": {
"type": "object",
"required": ["per_serving"],
"properties": {
"per_serving": {
"type": "object",
"required": ["calories"],
"properties": {
"calories": {"type": "number"},
"protein_g": {"type": "number"},
"carbs_g": {"type": "number"},
"fat_g": {"type": "number"},
"sodium_mg": {"type": "number"}
}
}
}
},
"warnings": {"type": "array", "items": {"type": "string"}},
"substitutions": {
"type": "array",
"items": {
"type": "object",
"required": ["for", "use"],
"properties": {
"for": {"type": "string"},
"use": {"type": "string"},
"note": {"type": "string"}
}
}
}
},
"additionalProperties": false
}
Tips:
- Keep units explicit. Favor metric internally; convert for display.
- Include warnings for allergens, high sodium, or undercooked risks.
- Version your schema (e.g., recipe@1). Add a top‑level “schema_version” when you evolve it.
Prompt design that actually holds up
LLMs do better with explicit roles and guardrails. Combine a concise system message, a deterministic output instruction, and optional few‑shot examples.
System message (excerpt):
You are a culinary assistant. Return ONLY valid JSON matching the provided JSON Schema. Do not include comments or prose. Use metric units. Provide accurate step order. Estimate nutrition.
User message template:
ingredients: ["chickpeas", "tomatoes", "spinach"]
diet: "vegan"
allergens_to_avoid: ["peanut", "tree nut"]
servings: 4
target_calories_per_serving: 550
cuisine_hint: "Mediterranean"
Few‑shot strategy:
- Include one or two miniature examples that obey your schema, but keep them short to control token cost.
- If your provider supports JSON‑mode or function‑calling with a schema, use it—then validate anyway.
Model settings:
- temperature: 0.3–0.6 for creativity with stability
- max_tokens: sized for your schema (e.g., 800–1200)
- seed: fixed for reproducible outcomes when appropriate
Node.js implementation (Express + AJV)
This example is provider‑agnostic. Swap the callModel function with your LLM provider’s SDK or a fetch to their REST endpoint.
// server.js
import express from 'express';
import crypto from 'crypto';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import recipeSchema from './recipe.schema.json' assert { type: 'json' };
import fetch from 'node-fetch';
const app = express();
app.use(express.json());
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
const validate = ajv.compile(recipeSchema);
// Simple in-memory cache (swap for Redis/Memcached in prod)
const cache = new Map();
function cacheKey(payload, promptVersion) {
const h = crypto.createHash('sha256');
h.update(JSON.stringify({ payload, promptVersion }));
return h.digest('hex');
}
async function callModel({ system, user, schema }) {
// Replace with your provider. Example generic REST shape.
const res = await fetch(process.env.LLM_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.LLM_API_KEY}`
},
body: JSON.stringify({
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user }
],
response_format: { type: 'json_schema', json_schema: schema },
temperature: 0.4,
max_tokens: 1100,
seed: 7
})
});
if (!res.ok) throw new Error(`Model error: ${res.status}`);
const data = await res.json();
return JSON.parse(data.output); // assumes provider returns JSON string
}
const SYSTEM = `You are a culinary assistant. Return ONLY valid JSON matching the provided JSON Schema. Use metric units. Provide ordered steps and nutrition estimates per serving.`;
const PROMPT_VERSION = 'recipe@1.0.0';
app.post('/v1/recipes:generate', async (req, res) => {
try {
const payload = req.body;
const user = [
`ingredients: ${JSON.stringify(payload.ingredients || [])}`,
`diet: ${payload.diet || 'none'}`,
`allergens_to_avoid: ${JSON.stringify(payload.allergens_to_avoid || [])}`,
`servings: ${payload.servings || 2}`,
`target_calories_per_serving: ${payload.target_calories_per_serving || 'auto'}`,
`cuisine_hint: ${payload.cuisine_hint || 'any'}`
].join('\n');
const key = cacheKey(payload, PROMPT_VERSION);
if (cache.has(key)) {
return res.json({
schema_version: '1',
cached: true,
model: process.env.LLM_MODEL || 'provider-model',
data: cache.get(key)
});
}
const recipe = await callModel({ system: SYSTEM, user, schema: recipeSchema });
if (!validate(recipe)) {
return res.status(422).json({ error: 'Schema validation failed', details: validate.errors });
}
cache.set(key, recipe);
res.json({
schema_version: '1',
cached: false,
model: process.env.LLM_MODEL || 'provider-model',
data: recipe
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Generation failed' });
}
});
app.listen(process.env.PORT || 3000, () => {
console.log('API listening on :3000');
});
Python implementation (FastAPI + Pydantic + jsonschema)
# main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import os, json, hashlib, httpx
from jsonschema import validate as js_validate, Draft202012Validator
with open('recipe.schema.json') as f:
RECIPE_SCHEMA = json.load(f)
validator = Draft202012Validator(RECIPE_SCHEMA)
app = FastAPI()
cache = {}
class GenerateRequest(BaseModel):
ingredients: list[str] = []
diet: str | None = None
allergens_to_avoid: list[str] = []
servings: int | None = None
target_calories_per_serving: int | None = None
cuisine_hint: str | None = None
SYSTEM = (
"You are a culinary assistant. Return ONLY valid JSON matching the provided JSON Schema. "
"Use metric units. Provide ordered steps and nutrition estimates per serving."
)
PROMPT_VERSION = "recipe@1.0.0"
async def call_model(system: str, user: str, schema: dict):
async with httpx.AsyncClient(timeout=60) as client:
r = await client.post(
os.environ['LLM_ENDPOINT'],
headers={
'Authorization': f"Bearer {os.environ['LLM_API_KEY']}",
'Content-Type': 'application/json'
},
json={
'messages': [
{'role': 'system', 'content': system},
{'role': 'user', 'content': user}
],
'response_format': {'type': 'json_schema', 'json_schema': schema},
'temperature': 0.4,
'max_tokens': 1100,
'seed': 7
}
)
r.raise_for_status()
data = r.json()
return json.loads(data['output'])
@app.post('/v1/recipes:generate')
async def generate(req: GenerateRequest):
user = "\n".join([
f"ingredients: {json.dumps(req.ingredients)}",
f"diet: {req.diet or 'none'}",
f"allergens_to_avoid: {json.dumps(req.allergens_to_avoid)}",
f"servings: {req.servings or 2}",
f"target_calories_per_serving: {req.target_calories_per_serving or 'auto'}",
f"cuisine_hint: {req.cuisine_hint or 'any'}",
])
key_src = json.dumps(req.dict(), sort_keys=True) + PROMPT_VERSION
key = hashlib.sha256(key_src.encode()).hexdigest()
if key in cache:
return {"schema_version": "1", "cached": True, "model": os.getenv('LLM_MODEL', 'provider-model'), "data": cache[key]}
try:
recipe = await call_model(SYSTEM, user, RECIPE_SCHEMA)
validator.validate(recipe)
except Exception as e:
raise HTTPException(status_code=422, detail=str(e))
cache[key] = recipe
return {"schema_version": "1", "cached": False, "model": os.getenv('LLM_MODEL', 'provider-model'), "data": recipe}
Input normalization and guardrails
Before calling the model:
- Normalize ingredients (lowercase, singularize if helpful), deduplicate, and sort for stable caching keys.
- Cap list lengths (e.g., max 30 ingredients) and text length (e.g., 300 chars for free‑form fields).
- Validate dietary claims: if diet=vegan but ingredient includes “butter”, flag it for substitution.
After generation:
- Re‑validate JSON. If small issues appear (e.g., string vs number), consider a lightweight fixer or a targeted follow‑up call: “Fix types to match this schema; return only JSON.”
- Ensure step order is contiguous and starts at 1.
- Enforce unit normalization (e.g., ml, g, °C). Convert for display in the client.
Nutrition and substitutions (optional but useful)
- Nutrition: You can approximate based on ingredient lookups and quantities. Map each ingredient to an entry in your nutrition database, multiply by quantity, and divide by servings. Make this an estimate and clearly label it.
- Substitutions: Generate 3–5 sensible swaps for common allergens or unavailable items, each with a short note about effects on taste/texture.
Caching and cost control
- Request deduplication: use a content‑hash of normalized inputs + prompt version + model to avoid regenerating identical recipes.
- TTL vs. forever cache: recipes are deterministic if you fix seed and temperature; consider forever cache, invalidated by prompt or model version bumps.
- Batch generation: when creating weekly plans, send multiple prompts in one request if your provider supports it.
- Streaming: if supported, stream partial JSON tokens through a parser that buffers until valid JSON is closed.
Evaluation: make quality measurable
Create a small test set (“golden prompts”) and assert:
- Schema validity 100% of the time
- Step count, timing sanity (e.g., total_time_minutes ≈ sum of step durations within tolerance)
- Allergen policy: forbidden items never appear when excluded
- Diet checkers: vegan/vegetarian/kosher/halal heuristics
- Readability: instruction length per step within limits
Example Jest test (Node):
import { validateRecipe } from './validator';
import fs from 'fs';
test('golden prompt: vegan pasta', async () => {
const req = JSON.parse(fs.readFileSync('fixtures/vegan_pasta.input.json'));
const res = await fetch('http://localhost:3000/v1/recipes:generate', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(req) });
const json = await res.json();
expect(validateRecipe(json.data)).toBe(true);
expect(json.data.tags).toContain('vegan');
});
Safety and correctness notes
- Allergens: propagate user‑provided exclusions to both ingredients and substitutions; include a warnings array in the response.
- Doneness: for meat or eggs, request explicit internal temperatures in Celsius in step notes when relevant. Add a generic reminder to follow local food safety guidance.
- Sensitive content: block or flag prompts requesting unsafe, illegal, or hazardous outputs (e.g., wild foraging of unknown mushrooms). Add a moderation layer before the model call if needed.
Observability and metadata
Include trace metadata in responses and logs:
- model, model_version, prompt_version
- tokens_in, tokens_out, latency_ms
- cache_status: hit/miss
- request_id: UUID for correlating logs
This makes it far easier to debug regressions when you upgrade models or prompts.
Simple frontend hook (browser fetch)
async function generateRecipe(clientPayload) {
const res = await fetch('/v1/recipes:generate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(clientPayload)
});
const { data } = await res.json();
return data; // Render to a card/list UI
}
Troubleshooting checklist
- Invalid JSON: enable your provider’s JSON‑only mode if available; otherwise, wrap the model in a re‑ask fixer flow.
- Hallucinated units: post‑validate and convert; reject unknown units with a repair pass.
- Diet leakage: add explicit negative examples in few‑shots and assert with a diet checker.
- Long outputs: tighten step count, prefer concise notes, and set a stricter max_tokens.
- Reproducibility drift: set seed; lock model and prompt versions; cache aggressively.
Putting it all together
You now have a clear pattern for building an AI recipe generation API that’s stable under real‑world load:
- Strong schema and validation
- Thoughtful prompts and deterministic settings
- Caching, observability, and tests
- Sensible safety and nutrition handling
From here, extend to meal plans, shopping lists, pantry‑aware suggestions, and multilingual outputs. Keep iterating on your schema and tests as you evolve features and models.
Related Posts
Build an AI Meeting Transcription API: An End-to-End Tutorial
Build a production-ready AI meeting transcription API with streaming, diarization, summaries, and exports. Code samples and architecture included.
Building an AI Email Assistant with APIs: Architecture, Code, and Best Practices
Build a production-ready AI email assistant: architecture, Gmail/Graph integration, LLM prompts, security, reliability, and code examples.
AI Document Understanding API Tutorial: From PDFs to Structured Data in Production
Build a production‑ready pipeline for AI document understanding: upload, OCR, schema‑based extraction, tables, QA, validation, and storage.