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 ------------------------------------------------------------------
|
# --- 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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user