From 51ab5083f8c3b189bf745dac613ca3e6c4f63a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 10:19:31 +0000 Subject: [PATCH 1/2] feat(integrations): Grocy + Spoolman lookup clients with defensive id-guards - GrocyClient: GROCY-API-KEY header auth, maps both 400 and 404 to GrocyNotFoundError (Grocy quirk), raises ValueError on missing 'id' - SpoolmanClient: no-auth trusted-network client, '#'-prefixed primary_id, remaining_weight formatted with round-half-up, raises ValueError on missing 'id' - Both clients: keyword-only __init__, URL-encoded ids, base_url trailing- slash strip, TODO(phase6) httpx.AsyncClient pooling comment - 17 new tests (8 Grocy + 9 Spoolman), 101 total pass Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/grocy_client.py | 69 +++++++++ backend/app/services/spoolman_client.py | 74 ++++++++++ .../tests/unit/services/test_grocy_client.py | 109 ++++++++++++++ .../unit/services/test_spoolman_client.py | 133 ++++++++++++++++++ 4 files changed, 385 insertions(+) create mode 100644 backend/app/services/grocy_client.py create mode 100644 backend/app/services/spoolman_client.py create mode 100644 backend/tests/unit/services/test_grocy_client.py create mode 100644 backend/tests/unit/services/test_spoolman_client.py diff --git a/backend/app/services/grocy_client.py b/backend/app/services/grocy_client.py new file mode 100644 index 0000000..586c585 --- /dev/null +++ b/backend/app/services/grocy_client.py @@ -0,0 +1,69 @@ +"""Grocy REST API client — product lookup by id. + +Grocy uses a custom `GROCY-API-KEY` header (not Bearer) and returns 400 +with `{"error_message": "..."}` for missing products instead of 404 — +both quirks are explicit in the client's mapping logic. +""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import quote + +import httpx + +from app.schemas.label_data import LabelData + + +class GrocyNotFoundError(Exception): + """Raised when no Grocy product matches the given id.""" + + +class GrocyClient: + """Async client for Grocy's REST API.""" + + def __init__( + self, + *, + base_url: str, + api_key: str, + timeout: float = 5.0, + ) -> None: + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._timeout = timeout + + async def lookup(self, product_id: str) -> LabelData: + """Return LabelData for `product_id`, or raise GrocyNotFoundError.""" + # TODO(phase6): inject a shared httpx.AsyncClient for connection pooling + # when consumed by the FastAPI request handler. + encoded_id = quote(product_id, safe="") + url = f"{self._base_url}/api/objects/products/{encoded_id}" + headers = { + "GROCY-API-KEY": self._api_key, + "Accept": "application/json", + } + async with httpx.AsyncClient(timeout=self._timeout) as client: + response = await client.get(url, headers=headers) + + # Grocy returns 400 with error_message for missing products, not 404. + if response.status_code in (400, 404): + raise GrocyNotFoundError(f"Product {product_id!r} not found") + # 401/403/5xx surface as httpx.HTTPStatusError — callers (AppLookupService) + # decide whether to treat them as configuration errors vs transient failures. + response.raise_for_status() + + payload: dict[str, Any] = response.json() + return self._payload_to_label(payload, product_id) + + def _payload_to_label(self, payload: dict[str, Any], product_id: str) -> LabelData: + grocy_id = payload.get("id") + if grocy_id is None: + raise ValueError(f"Grocy response for {product_id!r} is missing required field 'id'") + return LabelData( + title=str(payload.get("name") or f"Product {product_id}"), + primary_id=str(grocy_id), + qr_payload=f"{self._base_url}/product/{grocy_id}", + source_app="grocy", + secondary=(), + ) diff --git a/backend/app/services/spoolman_client.py b/backend/app/services/spoolman_client.py new file mode 100644 index 0000000..0a776fc --- /dev/null +++ b/backend/app/services/spoolman_client.py @@ -0,0 +1,74 @@ +"""Spoolman REST API client — filament-spool lookup by id. + +Spoolman is intended for trusted-network deployment and requires no +authentication. The label title is composed from the spool's filament +vendor + name; primary_id is prefixed with '#' to read like an entity id +on the printed label. +""" + +from __future__ import annotations + +import math +from typing import Any +from urllib.parse import quote + +import httpx + +from app.schemas.label_data import LabelData + + +class SpoolmanNotFoundError(Exception): + """Raised when no Spoolman spool matches the given id.""" + + +class SpoolmanClient: + """Async client for Spoolman's REST API.""" + + def __init__( + self, + *, + base_url: str, + timeout: float = 5.0, + ) -> None: + self._base_url = base_url.rstrip("/") + self._timeout = timeout + + async def lookup(self, spool_id: str) -> LabelData: + """Return LabelData for `spool_id`, or raise SpoolmanNotFoundError.""" + # TODO(phase6): inject a shared httpx.AsyncClient for connection pooling + # when consumed by the FastAPI request handler. + encoded_id = quote(spool_id, safe="") + url = f"{self._base_url}/api/v1/spool/{encoded_id}" + async with httpx.AsyncClient(timeout=self._timeout) as client: + response = await client.get(url, headers={"Accept": "application/json"}) + + if response.status_code == 404: + raise SpoolmanNotFoundError(f"Spool {spool_id!r} not found") + # 401/403/5xx surface as httpx.HTTPStatusError — callers (AppLookupService) + # decide whether to treat them as configuration errors vs transient failures. + response.raise_for_status() + + payload: dict[str, Any] = response.json() + return self._payload_to_label(payload, spool_id) + + def _payload_to_label(self, payload: dict[str, Any], spool_id: str) -> LabelData: + spoolman_id = payload.get("id") + if spoolman_id is None: + raise ValueError(f"Spoolman response for {spool_id!r} is missing required field 'id'") + filament: dict[str, Any] = payload.get("filament") or {} + vendor: dict[str, Any] = filament.get("vendor") or {} + vendor_name = str(vendor.get("name") or "Unknown") + material = str(filament.get("name") or "Unknown") + + secondary_parts: list[str] = [] + remaining = payload.get("remaining_weight") + if remaining is not None: + secondary_parts.append(f"{math.floor(float(remaining) + 0.5)}g remaining") + + return LabelData( + title=f"{vendor_name} {material}", + primary_id=f"#{spoolman_id}", + qr_payload=f"{self._base_url}/spool/show/{spoolman_id}", + source_app="spoolman", + secondary=tuple(secondary_parts), + ) diff --git a/backend/tests/unit/services/test_grocy_client.py b/backend/tests/unit/services/test_grocy_client.py new file mode 100644 index 0000000..7bef7d0 --- /dev/null +++ b/backend/tests/unit/services/test_grocy_client.py @@ -0,0 +1,109 @@ +import httpx +import pytest +import respx +from app.services.grocy_client import GrocyClient, GrocyNotFoundError + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_product_returns_label_data() -> None: + respx.get("https://grocy.example/api/objects/products/42").mock( + return_value=httpx.Response( + 200, + json={"id": 42, "name": "Milch 1L", "description": "Vollmilch H-Milch"}, + ) + ) + + client = GrocyClient(base_url="https://grocy.example", api_key="grocy-key") + data = await client.lookup("42") + + assert data.title == "Milch 1L" + assert data.primary_id == "42" + assert data.qr_payload == "https://grocy.example/product/42" + assert data.source_app == "grocy" + assert data.secondary == () + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_product_400_raises_not_found() -> None: + """Grocy returns 400 (not 404) for missing products — special-cased.""" + respx.get("https://grocy.example/api/objects/products/999").mock( + return_value=httpx.Response(400, json={"error_message": "No such product"}) + ) + client = GrocyClient(base_url="https://grocy.example", api_key="grocy-key") + with pytest.raises(GrocyNotFoundError, match="999"): + await client.lookup("999") + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_product_404_also_raises_not_found() -> None: + """A future Grocy version returning a proper 404 must also map to GrocyNotFoundError.""" + respx.get("https://grocy.example/api/objects/products/999").mock( + return_value=httpx.Response(404) + ) + client = GrocyClient(base_url="https://grocy.example", api_key="grocy-key") + with pytest.raises(GrocyNotFoundError): + await client.lookup("999") + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_strips_trailing_slash_from_base_url() -> None: + respx.get("https://grocy.example/api/objects/products/7").mock( + return_value=httpx.Response(200, json={"id": 7, "name": "X"}) + ) + client = GrocyClient(base_url="https://grocy.example/", api_key="grocy-key") + data = await client.lookup("7") + assert data.qr_payload == "https://grocy.example/product/7" + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_url_encodes_product_id() -> None: + respx.get("https://grocy.example/api/objects/products/A%2F1").mock( + return_value=httpx.Response(200, json={"id": 1, "name": "Encoded"}) + ) + client = GrocyClient(base_url="https://grocy.example", api_key="grocy-key") + data = await client.lookup("A/1") + assert data.title == "Encoded" + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_5xx_raises_httpx_error() -> None: + respx.get("https://grocy.example/api/objects/products/1").mock(return_value=httpx.Response(500)) + client = GrocyClient(base_url="https://grocy.example", api_key="grocy-key") + with pytest.raises(httpx.HTTPStatusError): + await client.lookup("1") + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_missing_id_raises_value_error() -> None: + """Grocy response without 'id' field must fail loudly.""" + respx.get("https://grocy.example/api/objects/products/1").mock( + return_value=httpx.Response(200, json={"name": "Broken"}) # no id + ) + client = GrocyClient(base_url="https://grocy.example", api_key="grocy-key") + with pytest.raises(ValueError, match="missing required field 'id'"): + await client.lookup("1") + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_sends_grocy_api_key_header() -> None: + """Outgoing request must carry GROCY-API-KEY header (not Bearer).""" + route = respx.get("https://grocy.example/api/objects/products/1").mock( + return_value=httpx.Response(200, json={"id": 1, "name": "x"}) + ) + client = GrocyClient(base_url="https://grocy.example", api_key="my-grocy-key-42") + await client.lookup("1") + + assert route.called + sent = route.calls.last.request + assert sent.headers["GROCY-API-KEY"] == "my-grocy-key-42" + assert sent.headers["Accept"] == "application/json" + # Crucially: NO Authorization header — Grocy doesn't use Bearer. + assert "Authorization" not in sent.headers diff --git a/backend/tests/unit/services/test_spoolman_client.py b/backend/tests/unit/services/test_spoolman_client.py new file mode 100644 index 0000000..5777c6e --- /dev/null +++ b/backend/tests/unit/services/test_spoolman_client.py @@ -0,0 +1,133 @@ +import httpx +import pytest +import respx +from app.services.spoolman_client import SpoolmanClient, SpoolmanNotFoundError + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_spool_returns_label_data() -> None: + respx.get("https://spoolman.example/api/v1/spool/42").mock( + return_value=httpx.Response( + 200, + json={ + "id": 42, + "filament": { + "vendor": {"name": "BambuLab"}, + "name": "PLA Black", + "color_hex": "000000", + }, + "remaining_weight": 850.5, + }, + ) + ) + client = SpoolmanClient(base_url="https://spoolman.example") + data = await client.lookup("42") + + assert data.title == "BambuLab PLA Black" + assert data.primary_id == "#42" + assert data.qr_payload == "https://spoolman.example/spool/show/42" + assert data.source_app == "spoolman" + assert data.secondary == ("851g remaining",) + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_spool_404_raises() -> None: + respx.get("https://spoolman.example/api/v1/spool/999").mock(return_value=httpx.Response(404)) + client = SpoolmanClient(base_url="https://spoolman.example") + with pytest.raises(SpoolmanNotFoundError, match="999"): + await client.lookup("999") + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_spool_without_remaining_weight() -> None: + respx.get("https://spoolman.example/api/v1/spool/1").mock( + return_value=httpx.Response( + 200, + json={"id": 1, "filament": {"vendor": {"name": "V"}, "name": "M"}}, + ) + ) + client = SpoolmanClient(base_url="https://spoolman.example") + data = await client.lookup("1") + assert data.secondary == () + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_spool_with_missing_vendor_name() -> None: + respx.get("https://spoolman.example/api/v1/spool/1").mock( + return_value=httpx.Response( + 200, + json={"id": 1, "filament": {"name": "PLA"}}, + ) + ) + client = SpoolmanClient(base_url="https://spoolman.example") + data = await client.lookup("1") + assert data.title == "Unknown PLA" + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_strips_trailing_slash() -> None: + respx.get("https://spoolman.example/api/v1/spool/7").mock( + return_value=httpx.Response( + 200, json={"id": 7, "filament": {"vendor": {"name": "V"}, "name": "M"}} + ) + ) + client = SpoolmanClient(base_url="https://spoolman.example/") + data = await client.lookup("7") + assert data.qr_payload == "https://spoolman.example/spool/show/7" + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_url_encodes_spool_id() -> None: + respx.get("https://spoolman.example/api/v1/spool/A%2F1").mock( + return_value=httpx.Response( + 200, json={"id": 1, "filament": {"vendor": {"name": "V"}, "name": "M"}} + ) + ) + client = SpoolmanClient(base_url="https://spoolman.example") + await client.lookup("A/1") + # The mock URL matches only if encoding worked. + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_5xx_raises_httpx_error() -> None: + respx.get("https://spoolman.example/api/v1/spool/1").mock(return_value=httpx.Response(503)) + client = SpoolmanClient(base_url="https://spoolman.example") + with pytest.raises(httpx.HTTPStatusError): + await client.lookup("1") + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_missing_id_raises_value_error() -> None: + respx.get("https://spoolman.example/api/v1/spool/1").mock( + return_value=httpx.Response(200, json={"filament": {"vendor": {"name": "V"}, "name": "M"}}) + ) + client = SpoolmanClient(base_url="https://spoolman.example") + with pytest.raises(ValueError, match="missing required field 'id'"): + await client.lookup("1") + + +@pytest.mark.asyncio +@respx.mock +async def test_lookup_sends_no_auth_header() -> None: + """Spoolman intentionally requires no auth — verify no Authorization header.""" + route = respx.get("https://spoolman.example/api/v1/spool/1").mock( + return_value=httpx.Response( + 200, json={"id": 1, "filament": {"vendor": {"name": "V"}, "name": "M"}} + ) + ) + client = SpoolmanClient(base_url="https://spoolman.example") + await client.lookup("1") + + assert route.called + sent = route.calls.last.request + assert "Authorization" not in sent.headers + assert "GROCY-API-KEY" not in sent.headers + assert sent.headers["Accept"] == "application/json" From 31ff882594301dee3aae16dbf2980f3b148453d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 10:26:08 +0000 Subject: [PATCH 2/2] refactor(integrations): shared AppLookupNotFoundError base + clarifying comments and tighter tests - Introduce backend/app/services/errors.py with AppLookupNotFoundError base - SnipeITNotFoundError, GrocyNotFoundError, SpoolmanNotFoundError all inherit from the base so callers can catch any client's not-found in one clause - Add rounding comment to spoolman_client.py explaining math.floor(x+0.5) vs banker's rounding in round() / f"{x:.0f}" - Tighten test_lookup_url_encodes_spool_id to assert source_app and primary_id - Add test_lookup_spool_with_null_filament for fully-null filament path - Add test_not_found_error_is_app_lookup_not_found cross-client inheritance check Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/errors.py | 12 ++++++++++++ backend/app/services/grocy_client.py | 3 ++- backend/app/services/snipeit_client.py | 3 ++- backend/app/services/spoolman_client.py | 8 ++++++-- .../tests/unit/services/test_snipeit_client.py | 15 +++++++++++++++ .../unit/services/test_spoolman_client.py | 18 ++++++++++++++++-- 6 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 backend/app/services/errors.py diff --git a/backend/app/services/errors.py b/backend/app/services/errors.py new file mode 100644 index 0000000..28aa1f5 --- /dev/null +++ b/backend/app/services/errors.py @@ -0,0 +1,12 @@ +"""Shared exception base types for App-Lookup clients.""" + +from __future__ import annotations + + +class AppLookupNotFoundError(Exception): + """Raised when an App-Lookup client cannot find the requested entity. + + All concrete not-found errors (SnipeITNotFoundError, GrocyNotFoundError, + SpoolmanNotFoundError) inherit from this base so callers can catch any + client's not-found in a single ``except AppLookupNotFoundError`` clause. + """ diff --git a/backend/app/services/grocy_client.py b/backend/app/services/grocy_client.py index 586c585..4a8cc3b 100644 --- a/backend/app/services/grocy_client.py +++ b/backend/app/services/grocy_client.py @@ -13,9 +13,10 @@ import httpx from app.schemas.label_data import LabelData +from app.services.errors import AppLookupNotFoundError -class GrocyNotFoundError(Exception): +class GrocyNotFoundError(AppLookupNotFoundError): """Raised when no Grocy product matches the given id.""" diff --git a/backend/app/services/snipeit_client.py b/backend/app/services/snipeit_client.py index 0d8bebe..7290758 100644 --- a/backend/app/services/snipeit_client.py +++ b/backend/app/services/snipeit_client.py @@ -14,9 +14,10 @@ import httpx from app.schemas.label_data import LabelData +from app.services.errors import AppLookupNotFoundError -class SnipeITNotFoundError(Exception): +class SnipeITNotFoundError(AppLookupNotFoundError): """Raised when no Snipe-IT asset matches the given tag.""" diff --git a/backend/app/services/spoolman_client.py b/backend/app/services/spoolman_client.py index 0a776fc..fe583f1 100644 --- a/backend/app/services/spoolman_client.py +++ b/backend/app/services/spoolman_client.py @@ -15,9 +15,10 @@ import httpx from app.schemas.label_data import LabelData +from app.services.errors import AppLookupNotFoundError -class SpoolmanNotFoundError(Exception): +class SpoolmanNotFoundError(AppLookupNotFoundError): """Raised when no Spoolman spool matches the given id.""" @@ -63,7 +64,10 @@ def _payload_to_label(self, payload: dict[str, Any], spool_id: str) -> LabelData secondary_parts: list[str] = [] remaining = payload.get("remaining_weight") if remaining is not None: - secondary_parts.append(f"{math.floor(float(remaining) + 0.5)}g remaining") + # Round half up — Python's round() and f"{x:.0f}" use banker's rounding, + # which would display 850 for 850.5. Wrong for a weight label. + grams = math.floor(float(remaining) + 0.5) + secondary_parts.append(f"{grams}g remaining") return LabelData( title=f"{vendor_name} {material}", diff --git a/backend/tests/unit/services/test_snipeit_client.py b/backend/tests/unit/services/test_snipeit_client.py index 8c0ee61..f6e1e84 100644 --- a/backend/tests/unit/services/test_snipeit_client.py +++ b/backend/tests/unit/services/test_snipeit_client.py @@ -4,6 +4,21 @@ from app.services.snipeit_client import SnipeITClient, SnipeITNotFoundError +def test_not_found_error_is_app_lookup_not_found() -> None: + """All concrete NotFoundErrors must inherit from AppLookupNotFoundError. + + Ensures the aggregator can catch any client's not-found in a single clause. + """ + from app.services.errors import AppLookupNotFoundError + from app.services.grocy_client import GrocyNotFoundError + from app.services.snipeit_client import SnipeITNotFoundError + from app.services.spoolman_client import SpoolmanNotFoundError + + assert issubclass(SnipeITNotFoundError, AppLookupNotFoundError) + assert issubclass(GrocyNotFoundError, AppLookupNotFoundError) + assert issubclass(SpoolmanNotFoundError, AppLookupNotFoundError) + + @pytest.mark.asyncio @respx.mock async def test_lookup_asset_returns_label_data() -> None: diff --git a/backend/tests/unit/services/test_spoolman_client.py b/backend/tests/unit/services/test_spoolman_client.py index 5777c6e..46c45a4 100644 --- a/backend/tests/unit/services/test_spoolman_client.py +++ b/backend/tests/unit/services/test_spoolman_client.py @@ -90,8 +90,10 @@ async def test_lookup_url_encodes_spool_id() -> None: ) ) client = SpoolmanClient(base_url="https://spoolman.example") - await client.lookup("A/1") - # The mock URL matches only if encoding worked. + data = await client.lookup("A/1") + # If encoding worked the mock matched and we got LabelData back. + assert data.source_app == "spoolman" + assert data.primary_id == "#1" @pytest.mark.asyncio @@ -114,6 +116,18 @@ async def test_lookup_missing_id_raises_value_error() -> None: await client.lookup("1") +@pytest.mark.asyncio +@respx.mock +async def test_lookup_spool_with_null_filament() -> None: + """Spoolman with filament: null must produce 'Unknown Unknown' title without crashing.""" + respx.get("https://spoolman.example/api/v1/spool/1").mock( + return_value=httpx.Response(200, json={"id": 1, "filament": None}) + ) + client = SpoolmanClient(base_url="https://spoolman.example") + data = await client.lookup("1") + assert data.title == "Unknown Unknown" + + @pytest.mark.asyncio @respx.mock async def test_lookup_sends_no_auth_header() -> None: