Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/app/services/errors.py
Original file line number Diff line number Diff line change
@@ -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.
"""
70 changes: 70 additions & 0 deletions backend/app/services/grocy_client.py
Original file line number Diff line number Diff line change
@@ -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=(),
)
3 changes: 2 additions & 1 deletion backend/app/services/snipeit_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""


Expand Down
78 changes: 78 additions & 0 deletions backend/app/services/spoolman_client.py
Original file line number Diff line number Diff line change
@@ -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),
)
109 changes: 109 additions & 0 deletions backend/tests/unit/services/test_grocy_client.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions backend/tests/unit/services/test_snipeit_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading