From 42d78f4060019ad8e63dcee1ae1bd0d451025869 Mon Sep 17 00:00:00 2001 From: fredamn76 Date: Mon, 17 Aug 2026 14:42:45 +0200 Subject: [PATCH] Address recipes by slug on sub-routes, and tags as real objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures from today's TikTok import, both misread as faults on the Mealie instance. set_cover_image passed slug_or_id straight into /api/recipes/{slug}/image. That path only resolves slugs: /api/recipes/{slug} itself accepts an id, the routes beneath it do not. With the recipe's id the scrape POST answered 404 and the file upload PUT answered 500 — neither of which says "wrong key", so the agent concluded the image endpoint was broken and gave up on the cover. Verified on this instance (v3.22.0): GET /api/recipes//comments answers 500 while the same call with the slug answers 200. The recipe is now read first and its stored slug used for the sub-route. patch_recipe sent tags through untouched, so {"name": "Källa: TikTok"} went out as-is and came back 422. RecipeTag and RecipeCategory both require name *and* slug per the instance's OpenAPI schema. Tags and categories are now resolved before the PATCH: an existing organizer is reused (case-insensitive on name) so tagging cannot fork a vocabulary that already holds "Källa: Instagram" and "Källa: YouTube", and a genuinely new name is created through its own endpoint, which supplies the slug Mealie itself would pick. Neither path had a test that could have caught this: the cover tests always passed a slug, and the patch contract test asserted the 422-producing shape as if it were correct. Co-Authored-By: Claude Opus 5 --- mealie_mcp/server.py | 74 +++++++++++++++++++++++++++--- tests/test_server.py | 84 ++++++++++++++++++++++++++++++++++ tests/test_server_contracts.py | 4 +- 3 files changed, 154 insertions(+), 8 deletions(-) diff --git a/mealie_mcp/server.py b/mealie_mcp/server.py index 46629b4..57e4760 100644 --- a/mealie_mcp/server.py +++ b/mealie_mcp/server.py @@ -415,17 +415,68 @@ def import_recipe_image(image_path: str) -> dict[str, Any]: # --- Write ------------------------------------------------------------------ +#: Recipe fields holding organizer objects, and the endpoint that owns each one. +_ORGANIZER_FIELDS = { + "tags": "/api/organizers/tags", + "recipeCategory": "/api/organizers/categories", +} + + +def _organizers_by_name(path: str) -> dict[str, dict[str, Any]]: + data = _request("GET", path, params={"perPage": 200}) + items = data.get("items", data if isinstance(data, list) else []) + return {(item.get("name") or "").casefold(): item for item in items} + + +def _resolve_organizers(field: str, entries: Any) -> Any: + """Turn tag/category shorthand into the objects Mealie's schema demands. + + ``RecipeTag`` and ``RecipeCategory`` require both ``name`` and ``slug``, so + patching ``[{"name": "Källa: TikTok"}]`` answers 422 (verified against the + instance's own OpenAPI schema, v3.22.0). An existing organizer is reused so + tagging cannot fork the vocabulary, and a genuinely new name is created + through its own endpoint — which is what supplies the slug Mealie itself + would have picked. + """ + if not isinstance(entries, list): + return entries + path = _ORGANIZER_FIELDS[field] + existing: dict[str, dict[str, Any]] | None = None + resolved: list[Any] = [] + for entry in entries: + if isinstance(entry, dict) and entry.get("slug"): + resolved.append(entry) + continue + name = entry if isinstance(entry, str) else (entry or {}).get("name") + if not name: + raise ValueError(f"{field} entries need a name; got {entry!r}") + if existing is None: + existing = _organizers_by_name(path) + match = existing.get(name.casefold()) + if match is None: + match = _request("POST", path, json={"name": name}) + existing[name.casefold()] = match + resolved.append(match) + return resolved + + @mcp.tool() def patch_recipe(slug_or_id: str, patch: dict[str, Any]) -> dict[str, Any]: """Apply an explicit Mealie recipe patch, then read the recipe back. Use this for tags and categories too — the bulk-action endpoints return 500 on - this instance. Patch ``recipeCategory`` and ``tags`` directly. The stable - recipe id is captured before the PATCH because changing ``name`` also changes - Mealie's slug, making the old slug invalid for read-back. + this instance. Patch ``recipeCategory`` and ``tags`` directly; a bare name or + ``{"name": ...}`` is accepted and resolved to the existing organizer, or + created if the library has no such name. The stable recipe id is captured + before the PATCH because changing ``name`` also changes Mealie's slug, making + the old slug invalid for read-back. """ before = get_recipe(slug_or_id) stable_id = before.get("id") or slug_or_id + patch = { + field: _resolve_organizers(field, value) if field in _ORGANIZER_FIELDS else value + for field, value in patch.items() + } _request("PATCH", f"/api/recipes/{slug_or_id}", json=patch) return get_recipe(stable_id) @@ -684,10 +735,19 @@ def set_cover_image( Local uploads must send the multipart ``extension`` field or the upload fails validation silently on this instance. + + The recipe is resolved to its stored slug first: ``/api/recipes/{slug}`` takes + a slug *or* an id, but the sub-routes under it are slug-only. Verified on + v3.22.0 — an id answers 404 on ``POST .../image``, 500 on ``PUT .../image`` + and 500 on ``GET .../comments``, none of which say what is actually wrong. """ if not source_url and not image_path: raise ValueError("Provide either source_url or image_path") + slug = (get_recipe(slug_or_id) or {}).get("slug") + if not slug: + raise RuntimeError(f"No recipe found for {slug_or_id!r}") + if image_path: path = Path(image_path) if not path.is_file(): @@ -695,7 +755,7 @@ def set_cover_image( extension = path.suffix.lstrip(".").lower() or "jpg" with _client() as client, path.open("rb") as handle: response = client.put( - f"/api/recipes/{slug_or_id}/image", + f"/api/recipes/{slug}/image", files={"image": (path.name, handle)}, data={"extension": extension}, ) @@ -705,7 +765,7 @@ def set_cover_image( used_url = source_url method = "scrape" try: - _request("POST", f"/api/recipes/{slug_or_id}/image", json={"url": used_url}) + _request("POST", f"/api/recipes/{slug}/image", json={"url": used_url}) except httpx.HTTPStatusError as exc: if exc.response.status_code != 400 or "not an image" not in exc.response.text: raise @@ -715,10 +775,10 @@ def set_cover_image( f"{source_url} is not an image and no og:image was found on the page; " "pass a direct image URL or use image_path" ) from exc - _request("POST", f"/api/recipes/{slug_or_id}/image", json={"url": used_url}) + _request("POST", f"/api/recipes/{slug}/image", json={"url": used_url}) method = "scrape (resolved og:image)" - recipe = get_recipe(slug_or_id) + recipe = get_recipe(slug) result = {"method": method, "image": recipe.get("image"), "slug": recipe.get("slug")} if not image_path and used_url != source_url: result["resolved_image_url"] = used_url diff --git a/tests/test_server.py b/tests/test_server.py index d135f1b..8fbfbdd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -337,7 +337,91 @@ class TestParseIngredients: assert result["canonical_lines"] == ["2 dl grädde"] +class TestOrganizerPatch: + """Tags and categories must reach Mealie as full RecipeTag/RecipeCategory objects. + + Verified against the instance's OpenAPI schema (v3.22.0): both require ``name`` + *and* ``slug``, so a bare ``{"name": ...}`` is answered with 422. + """ + + def _mealie(self, mock_mealie, existing, recipe=None): + recipe = recipe or {"id": "stable-id", "slug": "soppa"} + bodies: list[dict] = [] + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.startswith("/api/organizers/"): + if request.method == "GET": + return httpx.Response(200, json={"items": existing}) + created = json.loads(request.content) + made = {"id": "new-id", "name": created["name"], "slug": "kalla-tiktok"} + bodies.append({"created": made}) + return httpx.Response(201, json=made) + if request.method == "PATCH": + bodies.append({"patch": json.loads(request.content)}) + return httpx.Response(200, json=recipe) + + return bodies, mock_mealie(handler) + + def test_a_bare_name_is_created_and_patched_with_its_slug(self, mock_mealie): + bodies, _ = self._mealie(mock_mealie, existing=[]) + + server.patch_recipe("soppa", {"tags": [{"name": "Källa: TikTok"}]}) + + created = [b["created"] for b in bodies if "created" in b] + patch = next(b["patch"] for b in bodies if "patch" in b) + assert created == [{"id": "new-id", "name": "Källa: TikTok", "slug": "kalla-tiktok"}] + assert patch["tags"] == created + + def test_an_existing_organizer_is_reused_instead_of_forking_the_vocabulary(self, mock_mealie): + existing = [{"id": "tag-1", "name": "Källa: TikTok", "slug": "kalla-tiktok"}] + bodies, recorded = self._mealie(mock_mealie, existing=existing) + + server.patch_recipe("soppa", {"tags": ["källa: tiktok"]}) + + patch = next(b["patch"] for b in bodies if "patch" in b) + assert patch["tags"] == existing + assert not any(r.method == "POST" for r in recorded) + + def test_a_complete_object_is_passed_through_untouched(self, mock_mealie): + tag = {"id": "tag-1", "name": "📅 Vardag", "slug": "vardag"} + bodies, recorded = self._mealie(mock_mealie, existing=[]) + + server.patch_recipe("soppa", {"tags": [tag], "description": "Av: Matprinsessan"}) + + patch = next(b["patch"] for b in bodies if "patch" in b) + assert patch == {"tags": [tag], "description": "Av: Matprinsessan"} + assert not any(r.url.path.startswith("/api/organizers/") for r in recorded) + + def test_categories_go_through_their_own_endpoint(self, mock_mealie): + _, recorded = self._mealie(mock_mealie, existing=[]) + + server.patch_recipe("soppa", {"recipeCategory": ["Såser"]}) + + lookups = [r.url.path for r in recorded if r.url.path.startswith("/api/organizers/")] + assert lookups == ["/api/organizers/categories", "/api/organizers/categories"] + + class TestCoverImage: + def test_an_id_is_resolved_to_the_slug_before_hitting_the_image_route(self, mock_mealie): + # Verified on v3.22.0: /api/recipes/{slug} takes an id, but the sub-routes + # under it are slug-only — an id answered 404 on POST .../image and 500 on + # PUT .../image, which is what silently killed the TikTok cover. + recipe_id = "1488dd5c-abbe-4660-b61b-eeca3f6cfb91" + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/image"): + assert request.url.path == "/api/recipes/skolans-ost-broccolisas/image" + return httpx.Response(200, json=None) + return httpx.Response(200, json={"slug": "skolans-ost-broccolisas", "image": "img"}) + + recorded = mock_mealie(handler) + + result = server.set_cover_image(recipe_id, source_url="https://cdn.test/cover.jpg") + + assert result["slug"] == "skolans-ost-broccolisas" + assert not any(recipe_id in str(r.url) and r.url.path.endswith("/image") for r in recorded) + def test_local_upload_sends_the_required_extension_field(self, mock_mealie, tmp_path): # Verified: without the multipart `extension` field the upload fails validation. image = tmp_path / "cover.webp" diff --git a/tests/test_server_contracts.py b/tests/test_server_contracts.py index b8fdec2..414ee1d 100644 --- a/tests/test_server_contracts.py +++ b/tests/test_server_contracts.py @@ -119,7 +119,9 @@ class TestPatchContract: "id": "stable-id", "slug": "ny-soppa", "name": "Ny soppa", - "tags": [{"name": "📅 Vardag"}], + # A complete organizer object, the shape Mealie's schema requires: + # name alone is 422, and resolving it is covered in TestOrganizerPatch. + "tags": [{"id": "tag-1", "name": "📅 Vardag", "slug": "vardag"}], } def request(method, path, **kwargs):