From e01f15a2a63794bc3b867c32d74befc746c2f1c1 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:42:46 +0200 Subject: [PATCH] feat(api): POST /api/recipes/extract-from-photo --- .../api/recipes/extract-from-photo/+server.ts | 155 ++++++++++++++++++ tests/integration/extract-from-photo.test.ts | 140 ++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 src/routes/api/recipes/extract-from-photo/+server.ts create mode 100644 tests/integration/extract-from-photo.test.ts diff --git a/src/routes/api/recipes/extract-from-photo/+server.ts b/src/routes/api/recipes/extract-from-photo/+server.ts new file mode 100644 index 0000000..cc8da10 --- /dev/null +++ b/src/routes/api/recipes/extract-from-photo/+server.ts @@ -0,0 +1,155 @@ +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { extractRecipeFromImage, GeminiError } from '$lib/server/ai/gemini-client'; +import { preprocessImage } from '$lib/server/ai/image-preprocess'; +import { pickRandomPhrase } from '$lib/server/ai/description-phrases'; +import { createRateLimiter } from '$lib/server/ai/rate-limit'; +import type { Ingredient, Step } from '$lib/types'; + +const MAX_BYTES = 8 * 1024 * 1024; +const ALLOWED_MIME = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/heic', + 'image/heif' +]); + +// Singleton-Limiter: 10 Requests/Minute pro IP. Verhindert Kosten-Runaways +// bei versehentlichem Dauer-Tappen. +const limiter = createRateLimiter({ windowMs: 60_000, max: 10 }); + +function errJson(status: number, code: string, message: string) { + return json({ code, message }, { status }); +} + +function buildRawText(q: number | null, u: string | null, name: string): string { + const parts: string[] = []; + if (q !== null) parts.push(String(q).replace('.', ',')); + if (u) parts.push(u); + parts.push(name); + return parts.join(' '); +} + +export const POST: RequestHandler = async ({ request, getClientAddress }) => { + const ip = getClientAddress(); + if (!limiter.check(ip)) { + return errJson( + 429, + 'RATE_LIMITED', + 'Zu viele Anfragen — bitte einen Moment warten.' + ); + } + + let form: FormData; + try { + form = await request.formData(); + } catch { + return errJson(400, 'BAD_REQUEST', 'Multipart body erwartet.'); + } + const photo = form.get('photo'); + if (!(photo instanceof Blob)) { + return errJson(400, 'BAD_REQUEST', 'Feld "photo" fehlt.'); + } + if (photo.size > MAX_BYTES) { + return errJson( + 413, + 'PAYLOAD_TOO_LARGE', + `Foto zu groß (max ${MAX_BYTES / 1024 / 1024} MB).` + ); + } + if (!ALLOWED_MIME.has(photo.type)) { + return errJson( + 415, + 'UNSUPPORTED_MEDIA_TYPE', + `MIME "${photo.type}" nicht unterstützt.` + ); + } + + const rawBuffer = Buffer.from(await photo.arrayBuffer()); + let preprocessed: { buffer: Buffer; mimeType: 'image/jpeg' }; + try { + preprocessed = await preprocessImage(rawBuffer); + } catch (e) { + return errJson( + 415, + 'UNSUPPORTED_MEDIA_TYPE', + `Bild konnte nicht gelesen werden: ${(e as Error).message}` + ); + } + + const startedAt = Date.now(); + let extracted; + try { + extracted = await extractRecipeFromImage( + preprocessed.buffer, + preprocessed.mimeType + ); + } catch (e) { + if (e instanceof GeminiError) { + const status = + e.code === 'AI_RATE_LIMITED' + ? 429 + : e.code === 'AI_TIMEOUT' + ? 503 + : e.code === 'AI_NOT_CONFIGURED' + ? 503 + : 503; + // Nur Code + Meta loggen, niemals Prompt/Response-Inhalt. + console.warn( + `[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes` + ); + return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.'); + } + console.warn(`[extract-from-photo] UNEXPECTED ${(e as Error).message}`); + return errJson(503, 'AI_FAILED', 'Die Bild-Analyse ist fehlgeschlagen.'); + } + + // Minimum-Gültigkeit: Titel + (mind. 1 Zutat ODER mind. 1 Schritt). + if ( + !extracted.title.trim() || + (extracted.ingredients.length === 0 && extracted.steps.length === 0) + ) { + return errJson( + 422, + 'NO_RECIPE_IN_IMAGE', + 'Ich konnte kein Rezept im Bild erkennen.' + ); + } + + const ingredients: Ingredient[] = extracted.ingredients.map((i, idx) => ({ + position: idx + 1, + quantity: i.quantity, + unit: i.unit, + name: i.name, + note: i.note, + raw_text: buildRawText(i.quantity, i.unit, i.name), + section_heading: null + })); + + const steps: Step[] = extracted.steps.map((s, idx) => ({ + position: idx + 1, + text: s.text + })); + + return json({ + recipe: { + id: null, + title: extracted.title, + description: pickRandomPhrase(), + source_url: null, + source_domain: null, + image_path: null, + servings_default: extracted.servings_default, + servings_unit: extracted.servings_unit, + prep_time_min: extracted.prep_time_min, + cook_time_min: extracted.cook_time_min, + total_time_min: extracted.total_time_min, + cuisine: null, + category: null, + ingredients, + steps, + tags: [] + } + }); +}; diff --git a/tests/integration/extract-from-photo.test.ts b/tests/integration/extract-from-photo.test.ts new file mode 100644 index 0000000..ffeeec4 --- /dev/null +++ b/tests/integration/extract-from-photo.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import sharp from 'sharp'; + +const { mockExtract } = vi.hoisted(() => ({ mockExtract: vi.fn() })); + +vi.mock('$lib/server/ai/gemini-client', () => ({ + extractRecipeFromImage: mockExtract, + GeminiError: class GeminiError extends Error { + constructor( + public readonly code: string, + message: string + ) { + super(message); + this.name = 'GeminiError'; + } + } +})); + +import { POST } from '../../src/routes/api/recipes/extract-from-photo/+server'; +import { GeminiError } from '$lib/server/ai/gemini-client'; + +async function makeJpeg(): Promise { + return sharp({ + create: { width: 100, height: 100, channels: 3, background: '#888' } + }) + .jpeg() + .toBuffer(); +} + +function mkEvent(body: FormData, ip = '1.2.3.4') { + return { + request: new Request('http://test/api/recipes/extract-from-photo', { + method: 'POST', + body + }), + getClientAddress: () => ip + }; +} + +const validAiResponse = { + title: 'Testrezept', + servings_default: 4, + servings_unit: 'Portionen', + prep_time_min: 10, + cook_time_min: 20, + total_time_min: null, + ingredients: [{ quantity: 1, unit: null, name: 'Apfel', note: null }], + steps: [{ text: 'Apfel schälen.' }] +}; + +beforeEach(() => { + mockExtract.mockReset(); + process.env.GEMINI_API_KEY = 'test-key'; +}); + +describe('POST /api/recipes/extract-from-photo', () => { + it('happy path: 200 with recipe shape', async () => { + mockExtract.mockResolvedValueOnce(validAiResponse); + const fd = new FormData(); + fd.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' }), 'x.jpg'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = await POST(mkEvent(fd) as any); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.recipe.title).toBe('Testrezept'); + expect(typeof body.recipe.description).toBe('string'); + expect(body.recipe.description.length).toBeGreaterThan(0); + expect(body.recipe.image_path).toBeNull(); + expect(body.recipe.ingredients[0].raw_text).toContain('Apfel'); + expect(body.recipe.id).toBeNull(); + }); + + it('413 when file exceeds 8 MB', async () => { + const big = Buffer.alloc(9 * 1024 * 1024); + const fd = new FormData(); + fd.append('photo', new Blob([big], { type: 'image/jpeg' })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = await POST(mkEvent(fd, '1.1.1.1') as any); + expect(res.status).toBe(413); + expect((await res.json()).code).toBe('PAYLOAD_TOO_LARGE'); + }); + + it('415 when content-type not in whitelist', async () => { + const fd = new FormData(); + fd.append('photo', new Blob([Buffer.from('hi')], { type: 'text/plain' })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = await POST(mkEvent(fd, '2.2.2.2') as any); + expect(res.status).toBe(415); + expect((await res.json()).code).toBe('UNSUPPORTED_MEDIA_TYPE'); + }); + + it('400 when no photo field', async () => { + const fd = new FormData(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = await POST(mkEvent(fd, '3.3.3.3') as any); + expect(res.status).toBe(400); + }); + + it('422 NO_RECIPE_IN_IMAGE when 0 ingredients AND 0 steps', async () => { + mockExtract.mockResolvedValueOnce({ + ...validAiResponse, + ingredients: [], + steps: [] + }); + const fd = new FormData(); + fd.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = await POST(mkEvent(fd, '4.4.4.4') as any); + expect(res.status).toBe(422); + expect((await res.json()).code).toBe('NO_RECIPE_IN_IMAGE'); + }); + + it('503 AI_NOT_CONFIGURED when GeminiError thrown', async () => { + mockExtract.mockRejectedValueOnce( + new GeminiError('AI_NOT_CONFIGURED', 'no key') + ); + const fd = new FormData(); + fd.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = await POST(mkEvent(fd, '5.5.5.5') as any); + expect(res.status).toBe(503); + expect((await res.json()).code).toBe('AI_NOT_CONFIGURED'); + }); + + it('429 when rate limit exceeded for same IP', async () => { + mockExtract.mockResolvedValue(validAiResponse); + const ip = '9.9.9.9'; + for (let i = 0; i < 10; i++) { + const fd = new FormData(); + fd.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await POST(mkEvent(fd, ip) as any); + } + const last = new FormData(); + last.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = await POST(mkEvent(last, ip) as any); + expect(res.status).toBe(429); + }); +});