Address recipes by slug on sub-routes, and tags as real objects

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/<id>/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 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 14:42:45 +02:00
parent 71c7a3414e
commit 42d78f4060
3 changed files with 154 additions and 8 deletions
+67 -7
View File
@@ -415,17 +415,68 @@ def import_recipe_image(image_path: str) -> dict[str, Any]:
# --- Write ------------------------------------------------------------------ # --- 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() @mcp.tool()
def patch_recipe(slug_or_id: str, patch: dict[str, Any]) -> dict[str, Any]: 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. """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 Use this for tags and categories too — the bulk-action endpoints return 500 on
this instance. Patch ``recipeCategory`` and ``tags`` directly. The stable this instance. Patch ``recipeCategory`` and ``tags`` directly; a bare name or
recipe id is captured before the PATCH because changing ``name`` also changes ``{"name": ...}`` is accepted and resolved to the existing organizer, or
Mealie's slug, making the old slug invalid for read-back. 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) before = get_recipe(slug_or_id)
stable_id = before.get("id") or 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) _request("PATCH", f"/api/recipes/{slug_or_id}", json=patch)
return get_recipe(stable_id) 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 Local uploads must send the multipart ``extension`` field or the upload fails
validation silently on this instance. 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: if not source_url and not image_path:
raise ValueError("Provide either source_url or 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: if image_path:
path = Path(image_path) path = Path(image_path)
if not path.is_file(): if not path.is_file():
@@ -695,7 +755,7 @@ def set_cover_image(
extension = path.suffix.lstrip(".").lower() or "jpg" extension = path.suffix.lstrip(".").lower() or "jpg"
with _client() as client, path.open("rb") as handle: with _client() as client, path.open("rb") as handle:
response = client.put( response = client.put(
f"/api/recipes/{slug_or_id}/image", f"/api/recipes/{slug}/image",
files={"image": (path.name, handle)}, files={"image": (path.name, handle)},
data={"extension": extension}, data={"extension": extension},
) )
@@ -705,7 +765,7 @@ def set_cover_image(
used_url = source_url used_url = source_url
method = "scrape" method = "scrape"
try: 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: except httpx.HTTPStatusError as exc:
if exc.response.status_code != 400 or "not an image" not in exc.response.text: if exc.response.status_code != 400 or "not an image" not in exc.response.text:
raise 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; " 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" "pass a direct image URL or use image_path"
) from exc ) 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)" 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")} result = {"method": method, "image": recipe.get("image"), "slug": recipe.get("slug")}
if not image_path and used_url != source_url: if not image_path and used_url != source_url:
result["resolved_image_url"] = used_url result["resolved_image_url"] = used_url
+84
View File
@@ -337,7 +337,91 @@ class TestParseIngredients:
assert result["canonical_lines"] == ["2 dl grädde"] 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: 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): def test_local_upload_sends_the_required_extension_field(self, mock_mealie, tmp_path):
# Verified: without the multipart `extension` field the upload fails validation. # Verified: without the multipart `extension` field the upload fails validation.
image = tmp_path / "cover.webp" image = tmp_path / "cover.webp"
+3 -1
View File
@@ -119,7 +119,9 @@ class TestPatchContract:
"id": "stable-id", "id": "stable-id",
"slug": "ny-soppa", "slug": "ny-soppa",
"name": "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): def request(method, path, **kwargs):