From 5e886b11a11a179363dc69e5fde7d6875bd32263 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:16:31 -0500 Subject: [PATCH] feat: Add feedback for bad JSON in HTML-JSON page (#7956) --- frontend/app/lang/messages/en-US.json | 1 + frontend/app/lib/api/user/recipes/recipe.ts | 32 +++++++--- .../app/pages/g/[groupSlug]/r/create/html.vue | 58 +++++++++++++++++++ mealie/routes/recipe/recipe_crud_routes.py | 20 ++++++- .../user_recipe_tests/test_recipe_crud.py | 41 +++++++++++++ 5 files changed, 144 insertions(+), 8 deletions(-) diff --git a/frontend/app/lang/messages/en-US.json b/frontend/app/lang/messages/en-US.json index 1deb85b2d..1201b9ebc 100644 --- a/frontend/app/lang/messages/en-US.json +++ b/frontend/app/lang/messages/en-US.json @@ -480,6 +480,7 @@ "from-url": "Import a Recipe", "github-issues": "GitHub Issues", "google-ld-json-info": "Google ld+json Info", + "html-or-json-error-details": "Mealie could not find a recipe in the data you provided. Only ld+json or microdata containing a schema.org Recipe can be imported. If you're pasting JSON, make sure it declares both \"@context\": \"https://schema.org/\" and \"@type\": \"Recipe\".", "must-be-a-valid-url": "Must be a Valid URL", "paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Paste in your recipe data. Each line will be treated as an item in a list", "recipe-markup-specification": "Recipe Markup Specification", diff --git a/frontend/app/lib/api/user/recipes/recipe.ts b/frontend/app/lib/api/user/recipes/recipe.ts index 70ae3aae7..9844a73ad 100644 --- a/frontend/app/lib/api/user/recipes/recipe.ts +++ b/frontend/app/lib/api/user/recipes/recipe.ts @@ -1,5 +1,5 @@ import { SSE } from "sse.js"; -import type { SSEvent } from "sse.js"; +import type { ReadyStateEvent, SSEvent } from "sse.js"; import { BaseCRUDAPI } from "../../base/base-clients"; import { route } from "../../base"; import { CommentsApi } from "./recipe-comments"; @@ -164,6 +164,16 @@ export class RecipeAPI extends BaseCRUDAPI { autoReconnect: false, }); + let settled = false; + const settle = (result: RequestResponse) => { + if (settled) { + return; + } + settled = true; + sse.close(); + resolve(result); + }; + if (onProgress) { sse.addEventListener(SSEDataEventStatus.Progress, (e: SSEvent) => { const { message } = JSON.parse(e.data) as SSEDataEventMessage; @@ -173,18 +183,26 @@ export class RecipeAPI extends BaseCRUDAPI { sse.addEventListener(SSEDataEventStatus.Done, (e: SSEvent) => { const { slug } = JSON.parse(e.data) as SSEDataEventDone; - sse.close(); - resolve({ response: { status: 201, data: slug } as any, data: slug, error: null }); + settle({ response: { status: 201, data: slug } as any, data: slug, error: null }); }); sse.addEventListener(SSEDataEventStatus.Error, (e: SSEvent) => { + let message: string | undefined; try { - const { message } = JSON.parse(e.data) as SSEDataEventMessage; - sse.close(); - resolve({ response: null, data: null, error: new Error(message) }); + ({ message } = JSON.parse(e.data) as SSEDataEventMessage); } catch { - // Not a backend error payload (e.g. XHR connection-close event); ignore + // Not a backend error payload (e.g. an XHR failure carrying the raw response body) + } + settle({ response: null, data: null, error: new Error(message || "Recipe creation failed") }); + }); + + // The stream can also close without ever emitting a done/error event, e.g. when the + // request fails before the route handler runs. Settle here so callers always get a + // response instead of waiting on a promise that never resolves. + sse.addEventListener("readystatechange", (e: ReadyStateEvent) => { + if (e.readyState === SSE.CLOSED) { + settle({ response: null, data: null, error: new Error("Recipe creation failed") }); } }); diff --git a/frontend/app/pages/g/[groupSlug]/r/create/html.vue b/frontend/app/pages/g/[groupSlug]/r/create/html.vue index d3d5bc429..ca0f692d3 100644 --- a/frontend/app/pages/g/[groupSlug]/r/create/html.vue +++ b/frontend/app/pages/g/[groupSlug]/r/create/html.vue @@ -100,6 +100,57 @@ + + + + + {{ $globals.icons.robot }} + + {{ $t("new-recipe.error-title") }} + + + +
+

+ {{ $t("new-recipe.html-or-json-error-details") }} +

+
+ +
+
@@ -191,6 +242,7 @@ async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, impo dataString = JSON.stringify(htmlOrJsonData); } + state.error = false; state.loading = true; const { response } = await api.recipes.createOneByHtmlOrJson( dataString, @@ -203,3 +255,9 @@ async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, impo handleResponse(response, importKeywordsAsTags); } + + diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 7dbefc3ae..52a3c0921 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -193,6 +193,24 @@ class RecipeController(BaseRecipeController): async for event in self._create_recipe_from_web(req): yield event + @staticmethod + def _error_message(ex: Exception) -> str: + """ + Extract a meaningful message from an exception raised during recipe creation. + + Scraper failures surface as an HTTPException carrying a `ParserErrors` value + (e.g. BAD_RECIPE_DATA), which is far more useful to the client than the class name. + """ + + if isinstance(ex, HTTPException): + detail = ex.detail + if isinstance(detail, dict) and (details := detail.get("details")): + return str(details) + if isinstance(detail, str) and detail: + return detail + + return ex.__class__.__name__ + async def _create_recipe_from_web(self, req: ScrapeRecipe | ScrapeRecipeData) -> AsyncIterable[ServerSentEvent]: """ Create a recipe from the web, returning progress via SSE. @@ -236,7 +254,7 @@ class RecipeController(BaseRecipeController): self.logger.exception("Error in streaming recipe creation") await queue.put( ServerSentEvent( - data=SSEDataEventMessage(message=e.__class__.__name__), + data=SSEDataEventMessage(message=self._error_message(e)), event=SSEDataEventStatus.ERROR, ) ) diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py index 9975c8a3c..9859ab78d 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py @@ -29,6 +29,7 @@ from mealie.schema.recipe.recipe_notes import RecipeNote from mealie.schema.recipe.recipe_tool import RecipeToolSave from mealie.services.recipe.recipe_data_service import RecipeDataService from mealie.services.scraper.recipe_scraper import DEFAULT_SCRAPER_STRATEGIES +from mealie.services.scraper.scraper import ParserErrors from tests import utils from tests.utils import api_routes from tests.utils.factories import random_int, random_string @@ -335,6 +336,46 @@ def test_create_by_html_or_json_stream_error( assert "error" in event_types +@pytest.mark.parametrize( + "data", + [ + # valid JSON, but not tagged as a schema.org Recipe + json.dumps({"name": "Test", "recipeIngredient": ["1 cup flour"], "recipeInstructions": [{"text": "Mix"}]}), + # not valid JSON at all + '{"name": "Test",,,}', + # no recipe data whatsoever + "not a recipe", + ], + ids=["missing-schema-declaration", "malformed-json", "no-recipe-data"], +) +def test_create_by_html_or_json_stream_invalid_data(api_client: TestClient, unique_user: TestUser, data: str): + """Unparseable data must report an error to the client, rather than silently ending the stream""" + + response = api_client.post( + api_routes.recipes_create_html_or_json_stream, + json={"data": data}, + headers=unique_user.token, + ) + + assert response.status_code == 200 + events = parse_sse_events(response.text) + + error_events = [e for e in events if e["event"] == "error"] + assert error_events + assert error_events[0]["data"]["message"] == ParserErrors.BAD_RECIPE_DATA.value + + +def test_create_by_html_or_json_invalid_data(api_client: TestClient, unique_user: TestUser): + response = api_client.post( + api_routes.recipes_create_html_or_json, + json={"data": json.dumps({"name": "Test"})}, + headers=unique_user.token, + ) + + assert response.status_code == 400 + assert response.json()["detail"]["message"] == ParserErrors.BAD_RECIPE_DATA.value + + def test_create_recipe_from_zip(api_client: TestClient, unique_user: TestUser, tempdir: str): database = unique_user.repos recipe_name = random_string()