feat(api): POST /api/recipes/extract-from-photo
This commit is contained in:
155
src/routes/api/recipes/extract-from-photo/+server.ts
Normal file
155
src/routes/api/recipes/extract-from-photo/+server.ts
Normal file
@@ -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: []
|
||||
}
|
||||
});
|
||||
};
|
||||
140
tests/integration/extract-from-photo.test.ts
Normal file
140
tests/integration/extract-from-photo.test.ts
Normal file
@@ -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<Buffer> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user