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; + }); + }); +});