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 new file mode 100644 index 0000000..4a8cc3b --- /dev/null +++ b/backend/app/services/grocy_client.py @@ -0,0 +1,70 @@ +"""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 +from app.services.errors import AppLookupNotFoundError + + +class GrocyNotFoundError(AppLookupNotFoundError): + """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/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 new file mode 100644 index 0000000..fe583f1 --- /dev/null +++ b/backend/app/services/spoolman_client.py @@ -0,0 +1,78 @@ +"""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 +from app.services.errors import AppLookupNotFoundError + + +class SpoolmanNotFoundError(AppLookupNotFoundError): + """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: + # 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}", + 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_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 new file mode 100644 index 0000000..46c45a4 --- /dev/null +++ b/backend/tests/unit/services/test_spoolman_client.py @@ -0,0 +1,147 @@ +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") + 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 +@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_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: + """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"