From 4a1e46b283f96c6b990ae7ef04157d127ab29a41 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 07:15:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20module=20g=C3=A9n=C3=A9rique?= =?UTF-8?q?=20d'adaptateurs=20de=20sources=20de=20recettes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pose les bases du pipeline d'import décrit dans specs/batch-cooking-architecture.md (Import depuis source → Traduction en étapes → Sauvegarde), en commençant par le premier maillon : récupérer et parser des recettes brutes depuis une source externe, indépendamment du site/API concerné. - RecipeSourceAdapter (recipe-source-adapter.ts) : contrat générique par source — list() pour parcourir un catalogue de façon paginée (l'utilisateur "browse" les recettes disponibles), puis fetchDetail(externalId) une fois une recette sélectionnée, puis parse(raw) pour la transformer en ParsedRecipe. parse() est pure et synchrone (même séparation I/O vs pur que tech-step-matcher.ts), ce qui la rend testable sans réseau. - ParsedRecipe est volontairement distinct de CreateRecipeInput : les ingrédients restent en texte libre (pas d'ingredientId/unitId) — la résolution vers nos catalogues Ingredient/Unit est un sujet séparé, pas encore construit. - recipe-source-registry.ts : registre en mémoire des adaptateurs, identifiés par une clé stable (même convention que Diet.key/ Unit.key/TechStep.key), distinct de la table Source (schema.prisma) qui documente la provenance d'une recette déjà sauvegardée. - recipe-source-errors.ts : RecipeSourceFetchError/RecipeSourceParseError, vocabulaire d'erreur dédié en attendant qu'une route les traduise en HttpError/ErrorCode. Pas de route HTTP, pas d'écriture en base, pas d'implémentation concrète pour l'instant — uniquement le module générique, validé par un adaptateur factice dans les tests. Le câblage (endpoint, sourceId, un vrai parseur) sera une PR suivante. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/lib/recipe-source-adapter.ts | 122 ++++++++++++ apps/api/src/lib/recipe-source-errors.ts | 37 ++++ apps/api/src/lib/recipe-source-registry.ts | 57 ++++++ apps/api/test/recipe-source.test.ts | 215 +++++++++++++++++++++ 4 files changed, 431 insertions(+) create mode 100644 apps/api/src/lib/recipe-source-adapter.ts create mode 100644 apps/api/src/lib/recipe-source-errors.ts create mode 100644 apps/api/src/lib/recipe-source-registry.ts create mode 100644 apps/api/test/recipe-source.test.ts diff --git a/apps/api/src/lib/recipe-source-adapter.ts b/apps/api/src/lib/recipe-source-adapter.ts new file mode 100644 index 0000000..fd316fb --- /dev/null +++ b/apps/api/src/lib/recipe-source-adapter.ts @@ -0,0 +1,122 @@ +/** + * The generic contract every recipe source (a specific website, an API, …) + * implements — groundwork for the "Import d'une recette" pipeline described + * in specs/batch-cooking-architecture.md (import depuis source → traduction + * en étapes → sauvegarde). This file only defines the shapes; no concrete + * source exists yet (see `recipe-source-registry.ts` for where one would be + * registered) and nothing here talks to the database or an HTTP route — + * that wiring (persisting an imported recipe, resolving `sourceId`) is + * deliberately out of scope until a real source needs it. + * + * The flow a caller drives against one adapter: + * 1. `list()` — browse what's available from the source (paginated, + * optionally filtered by `query`), like flipping through a catalog. + * 2. `fetchDetail(externalId)` — once the user picks one item from that + * list, fetch its full raw content. + * 3. `parse(raw)` — turn that raw content into a {@link ParsedRecipe}, + * pure and synchronous so it's unit-testable without any network + * access (same split as `tech-step-matcher.ts`'s pure `matchTechStep` + * vs its DB-touching `loadTechStepMappingRules`). + */ + +/** Search/pagination input for {@link RecipeSourceAdapter.list}. */ +export interface RecipeSourceListParams { + /** Free-text search, if the source supports it. Omitted means "browse everything". */ + query?: string; + /** + * Opaque continuation token from a previous {@link RecipeSourceListResult.nextCursor} + * — omitted (or `null`) means "start from the first page". Deliberately + * opaque (not a page number) so an adapter can back it with whatever its + * source actually supports (page number, offset, an API-provided token). + */ + cursor?: string | null; +} + +/** One entry in a {@link RecipeSourceAdapter.list} result — enough to show in a browsing UI and to fetch the full recipe once selected. */ +export interface RecipeSourceListItem { + /** Source-specific identifier, opaque to callers — passed back verbatim to {@link RecipeSourceAdapter.fetchDetail}. */ + externalId: string; + title: string; + picture: string | null; + /** Canonical URL of the recipe on the source, kept for attribution even before it's imported. */ + url: string; +} + +export interface RecipeSourceListResult { + items: RecipeSourceListItem[]; + /** Pass back as `cursor` to fetch the next page — `null` means this was the last page. */ + nextCursor: string | null; +} + +/** + * One ingredient line as lifted from a source, before it's resolved against + * our own `Ingredient`/`Unit` reference catalogs (that resolution — + * matching free text to a `key`, the way `tech-step-matcher.ts` matches + * step text to a `TechStep` — is a separate, not-yet-built concern; this + * type only carries what a source's raw text actually says). `rawText` is + * kept alongside the (best-effort) parsed fields so a failed/partial parse + * is still traceable back to what the source originally wrote. + */ +export interface ParsedRecipeIngredient { + rawText: string; + quantity: number | null; + /** Free-text unit exactly as written by the source (e.g. `"cuillère à soupe"`, `"g"`) — not yet resolved to a `Unit.key`. */ + unit: string | null; + /** Free-text ingredient name exactly as written by the source — not yet resolved to an `Ingredient.key`. */ + name: string; +} + +export interface ParsedRecipeStep { + description: string; + picture: string | null; +} + +/** + * The normalized shape every adapter's {@link RecipeSourceAdapter.parse} + * produces, regardless of the source. Intentionally *not* + * `CreateRecipeInput` (packages/shared/src/schemas/recipe.ts): ingredients + * are still free text (no `ingredientId`/`unitId` — that catalog-matching + * step doesn't exist yet), and there's no `dietIds`/`visibility` since a + * source can't know those. Turning a `ParsedRecipe` into a saved `Recipe` + * is future work for whichever module ends up driving this pipeline. + */ +export interface ParsedRecipe { + name: string; + description: string | null; + picture: string | null; + /** `null` when the source doesn't state a serving size. */ + portions: number | null; + /** Canonical URL of the recipe on the source — the eventual `Source`/`Recipe.sourceId` link (schema.prisma) is populated from this once the import pipeline saves the recipe. */ + sourceUrl: string; + ingredients: ParsedRecipeIngredient[]; + steps: ParsedRecipeStep[]; +} + +/** + * A single recipe source — a specific website or API, plus the two pieces + * of source-specific logic needed to pull a recipe out of it. `TRawDetail` + * is whatever shape `fetchDetail` naturally returns for this source (an + * HTML string, a parsed JSON body, …); `parse` is the only thing that needs + * to understand it. + * + * @example + * ```ts + * const myAdapter: RecipeSourceAdapter<{ html: string }> = { + * key: "someRecipeSite", + * name: "Some Recipe Site", + * async list(params) { ... }, + * async fetchDetail(externalId) { ... }, + * parse(raw) { ... }, + * }; + * registerRecipeSource(myAdapter); + * ``` + */ +export interface RecipeSourceAdapter { + /** Stable identifier used to look this adapter up in the registry — same "English camelCase uid" convention as `Diet.key`/`Unit.key`/`TechStep.key`. */ + key: string; + /** Human-readable name, for display in a source picker. */ + name: string; + list(params: RecipeSourceListParams): Promise; + fetchDetail(externalId: string): Promise; + parse(raw: TRawDetail): ParsedRecipe; +} diff --git a/apps/api/src/lib/recipe-source-errors.ts b/apps/api/src/lib/recipe-source-errors.ts new file mode 100644 index 0000000..256c898 --- /dev/null +++ b/apps/api/src/lib/recipe-source-errors.ts @@ -0,0 +1,37 @@ +/** + * Error vocabulary a {@link RecipeSourceAdapter} (recipe-source-adapter.ts) + * implementation throws when talking to its source fails — kept separate + * from `@batch-cooking/error-tools`'s `HttpError`/`ErrorCode` (used for + * *this API's* HTTP responses) since no route drives this module yet. A + * future import route would catch these and translate them into an + * `HttpError` with a dedicated `ErrorCode` the same way any other service + * error is; this module only needs a consistent shape to throw in the + * meantime, not that translation. + */ + +/** Base class for every error a {@link RecipeSourceAdapter} can throw — lets a caller `catch (err) { if (err instanceof RecipeSourceError) ... }` regardless of which stage failed. */ +export class RecipeSourceError extends Error { + /** The failing adapter's `key` (recipe-source-adapter.ts's `RecipeSourceAdapter.key`) — which source this error came from. */ + readonly sourceKey: string; + + constructor(sourceKey: string, message: string, options?: { cause?: unknown }) { + super(message, options); + this.sourceKey = sourceKey; + } +} + +/** The source's `list`/`fetchDetail` failed — network error, non-2xx response, source unreachable, etc. */ +export class RecipeSourceFetchError extends RecipeSourceError { + constructor(sourceKey: string, message: string, options?: { cause?: unknown }) { + super(sourceKey, message, options); + this.name = "RecipeSourceFetchError"; + } +} + +/** The source responded, but `parse` couldn't make sense of the raw payload (unexpected shape, missing required field, …). */ +export class RecipeSourceParseError extends RecipeSourceError { + constructor(sourceKey: string, message: string, options?: { cause?: unknown }) { + super(sourceKey, message, options); + this.name = "RecipeSourceParseError"; + } +} diff --git a/apps/api/src/lib/recipe-source-registry.ts b/apps/api/src/lib/recipe-source-registry.ts new file mode 100644 index 0000000..aca3b42 --- /dev/null +++ b/apps/api/src/lib/recipe-source-registry.ts @@ -0,0 +1,57 @@ +import type { RecipeSourceAdapter } from "./recipe-source-adapter.js"; + +/** + * In-memory registry of every {@link RecipeSourceAdapter} (recipe-source-adapter.ts) + * this process knows about, keyed by `adapter.key`. Deliberately not + * DB-backed — an adapter *is* code (a website's fetch/parse logic can't + * live in a database row), unlike the `Source` table in schema.prisma, + * which records *where a saved recipe came from* (a name/url pair) once + * the import pipeline actually persists one. The two are related but + * distinct: this registry is "which sources can we import from right now", + * `Source` rows are "which sources a saved recipe cites" — wiring the two + * together is future work for whichever module ends up saving imported + * recipes. + * + * No adapter is registered here yet — this file only provides the + * mechanism; `registerRecipeSource` is meant to be called once per adapter + * module, at whatever point a concrete source is added. + */ +const adapters = new Map(); + +/** + * Registers `adapter` under its own `key`. Throws if that key is already + * taken — two adapters silently overwriting each other would be a bug (a + * caller reaching for "marmiton" should never get a different adapter than + * the one it registered), not a case to swallow. + */ +export function registerRecipeSource(adapter: RecipeSourceAdapter): void { + if (adapters.has(adapter.key)) { + throw new Error(`Recipe source "${adapter.key}" is already registered`); + } + // `TRawDetail` only matters within one adapter's own list/fetchDetail/parse + // trio — once stored, callers look adapters up by key and drive the same + // three methods generically, so the registry itself doesn't need to know + // each adapter's raw type. This cast is the standard way to store a + // heterogeneous collection of otherwise-identically-shaped generics. + adapters.set(adapter.key, adapter as RecipeSourceAdapter); +} + +/** The adapter registered under `key`, or `undefined` if none is. */ +export function getRecipeSource(key: string): RecipeSourceAdapter | undefined { + return adapters.get(key); +} + +/** Every registered adapter — e.g. to offer a source picker. */ +export function listRecipeSources(): RecipeSourceAdapter[] { + return [...adapters.values()]; +} + +/** + * Empties the registry. Not meant for application code — `apps/api/src` + * never calls this — only for test isolation, the same role + * `test-support/reset-db.ts` plays for the database: without it, adapters + * registered by one test file would leak into the next. + */ +export function clearRecipeSources(): void { + adapters.clear(); +} diff --git a/apps/api/test/recipe-source.test.ts b/apps/api/test/recipe-source.test.ts new file mode 100644 index 0000000..3267ec0 --- /dev/null +++ b/apps/api/test/recipe-source.test.ts @@ -0,0 +1,215 @@ +import { expect } from "chai"; +import type { + ParsedRecipe, + RecipeSourceAdapter, + RecipeSourceListParams, + RecipeSourceListResult, +} from "../src/lib/recipe-source-adapter.js"; +import { + RecipeSourceError, + RecipeSourceFetchError, + RecipeSourceParseError, +} from "../src/lib/recipe-source-errors.js"; +import { + clearRecipeSources, + getRecipeSource, + listRecipeSources, + registerRecipeSource, +} from "../src/lib/recipe-source-registry.js"; + +interface FakeRawRecipe { + externalId: string; + title: string; + servings: number; + ingredientLines: string[]; + instructionLines: string[]; +} + +const FAKE_CATALOG: FakeRawRecipe[] = [ + { + externalId: "1", + title: "Tarte aux pommes", + servings: 6, + ingredientLines: ["3 pommes", "200 g de farine"], + instructionLines: ["Éplucher les pommes", "Cuire 30 minutes"], + }, + { + externalId: "2", + title: "Soupe de légumes", + servings: 4, + ingredientLines: ["2 carottes"], + instructionLines: ["Mijoter 20 minutes"], + }, + { + externalId: "3", + title: "Salade César", + servings: 2, + ingredientLines: ["1 salade"], + instructionLines: ["Mélanger"], + }, +]; + +const PAGE_SIZE = 2; + +/** A minimal in-memory `RecipeSourceAdapter`, standing in for a real website/API — proves the interface (recipe-source-adapter.ts) is actually implementable end to end. */ +function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter { + return { + key, + name: "Fake Source", + async list(params: RecipeSourceListParams): Promise { + const start = params.cursor ? Number(params.cursor) : 0; + const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE); + const nextStart = start + PAGE_SIZE; + return { + items: page.map((recipe) => ({ + externalId: recipe.externalId, + title: recipe.title, + picture: null, + url: `https://fake.test/recipes/${recipe.externalId}`, + })), + nextCursor: nextStart < FAKE_CATALOG.length ? String(nextStart) : null, + }; + }, + async fetchDetail(externalId: string): Promise { + const found = FAKE_CATALOG.find((recipe) => recipe.externalId === externalId); + if (!found) throw new RecipeSourceFetchError(key, `Unknown recipe ${externalId}`); + return found; + }, + parse(raw: FakeRawRecipe): ParsedRecipe { + return { + name: raw.title, + description: null, + picture: null, + portions: raw.servings, + sourceUrl: `https://fake.test/recipes/${raw.externalId}`, + ingredients: raw.ingredientLines.map((line) => ({ + rawText: line, + quantity: null, + unit: null, + name: line, + })), + steps: raw.instructionLines.map((line) => ({ description: line, picture: null })), + }; + }, + }; +} + +describe("recipe-source", () => { + afterEach(() => { + clearRecipeSources(); + }); + + describe("registry", () => { + it("registers and retrieves an adapter by key", () => { + const adapter = buildFakeAdapter(); + registerRecipeSource(adapter); + expect(getRecipeSource("fakeSource")).to.equal(adapter); + }); + + it("returns undefined for an unregistered key", () => { + expect(getRecipeSource("unknown")).to.be.undefined; + }); + + it("lists every registered adapter", () => { + registerRecipeSource(buildFakeAdapter("fakeSource")); + registerRecipeSource(buildFakeAdapter("otherSource")); + + expect( + listRecipeSources() + .map((adapter) => adapter.key) + .sort(), + ).to.deep.equal(["fakeSource", "otherSource"]); + }); + + it("rejects registering the same key twice", () => { + registerRecipeSource(buildFakeAdapter()); + expect(() => registerRecipeSource(buildFakeAdapter())).to.throw(/already registered/); + }); + + it("clearRecipeSources empties the registry", () => { + registerRecipeSource(buildFakeAdapter()); + clearRecipeSources(); + expect(listRecipeSources()).to.deep.equal([]); + }); + }); + + describe("adapter contract (via a fake adapter)", () => { + it("browses in pages until nextCursor is null", async () => { + const adapter = buildFakeAdapter(); + + const firstPage = await adapter.list({}); + expect(firstPage.items.map((item) => item.externalId)).to.deep.equal(["1", "2"]); + expect(firstPage.nextCursor).to.equal("2"); + + const secondPage = await adapter.list({ cursor: firstPage.nextCursor }); + expect(secondPage.items.map((item) => item.externalId)).to.deep.equal(["3"]); + expect(secondPage.nextCursor).to.be.null; + }); + + it("filters by query the same way, when the source supports it (fake adapter ignores it — only pagination is exercised here)", async () => { + const adapter = buildFakeAdapter(); + const res = await adapter.list({ query: "tarte" }); + // Documents that `query` is a valid, optional param even though this + // particular fake doesn't act on it — a real adapter would filter. + expect(res.items).to.have.length(2); + }); + + it("fetches the detail for a selected item, then parses it into a ParsedRecipe", async () => { + const adapter = buildFakeAdapter(); + + const raw = await adapter.fetchDetail("1"); + const parsed = adapter.parse(raw); + + expect(parsed.name).to.equal("Tarte aux pommes"); + expect(parsed.description).to.be.null; + expect(parsed.portions).to.equal(6); + expect(parsed.sourceUrl).to.equal("https://fake.test/recipes/1"); + expect(parsed.ingredients).to.have.length(2); + expect(parsed.ingredients[0]).to.deep.equal({ + rawText: "3 pommes", + quantity: null, + unit: null, + name: "3 pommes", + }); + expect(parsed.steps).to.deep.equal([ + { description: "Éplucher les pommes", picture: null }, + { description: "Cuire 30 minutes", picture: null }, + ]); + }); + + it("throws RecipeSourceFetchError for an unknown externalId", async () => { + const adapter = buildFakeAdapter(); + + try { + await adapter.fetchDetail("does-not-exist"); + expect.fail("expected fetchDetail to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceFetchError); + expect((err as RecipeSourceFetchError).sourceKey).to.equal("fakeSource"); + } + }); + }); + + describe("RecipeSourceError hierarchy", () => { + it("RecipeSourceFetchError carries the source key, a message and an optional cause, and is a RecipeSourceError", () => { + const cause = new Error("network down"); + const err = new RecipeSourceFetchError("fakeSource", "could not reach source", { cause }); + + expect(err).to.be.instanceOf(Error); + expect(err).to.be.instanceOf(RecipeSourceError); + expect(err.name).to.equal("RecipeSourceFetchError"); + expect(err.sourceKey).to.equal("fakeSource"); + expect(err.message).to.equal("could not reach source"); + expect(err.cause).to.equal(cause); + }); + + it("RecipeSourceParseError carries the source key and works without a cause", () => { + const err = new RecipeSourceParseError("fakeSource", "unexpected shape"); + + expect(err).to.be.instanceOf(RecipeSourceError); + expect(err.name).to.equal("RecipeSourceParseError"); + expect(err.sourceKey).to.equal("fakeSource"); + expect(err.cause).to.be.undefined; + }); + }); +});