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:
+67
-7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user