diff --git a/src/ssdlc_demo/main.py b/src/ssdlc_demo/main.py index ec34223..c375c9c 100644 --- a/src/ssdlc_demo/main.py +++ b/src/ssdlc_demo/main.py @@ -1,4 +1,5 @@ -from fastapi import FastAPI, Response, status +import requests +from fastapi import FastAPI, HTTPException, Response, status from pydantic import BaseModel from .logging_config import configure_logging @@ -16,6 +17,28 @@ class EchoResponse(BaseModel): app = FastAPI(title="SSDLCDemo", version="0.1.0") +class AnimeInfo(BaseModel): + id: int + name: str + altName: str | None = None + + +class CharacterInfo(BaseModel): + id: int + name: str + + +class AnimechanData(BaseModel): + content: str + anime: AnimeInfo + character: CharacterInfo + + +class AnimechanResponse(BaseModel): + status: str + data: AnimechanData + + @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} @@ -25,3 +48,47 @@ def health() -> dict[str, str]: def echo(payload: EchoRequest, response: Response) -> EchoResponse: # Any validation is handled by Pydantic models return EchoResponse(message=payload.message) + + +@app.get("/anime/quote", response_model=AnimechanResponse) +def get_random_anime_quote() -> AnimechanResponse: + """Fetch a random anime quote from Animechan and return it. + + Docs: https://animechan.io/ + Endpoint used: https://api.animechan.io/v1/quotes/random + """ + try: + res = requests.get( + "https://api.animechan.io/v1/quotes/random", + timeout=5, + ) + except requests.RequestException as exc: # network, DNS, timeout + raise HTTPException( + status_code=502, + detail="Upstream Animechan unavailable", + ) from exc + + if res.status_code != 200: + raise HTTPException( + status_code=502, + detail="Animechan returned non-200 status", + ) + + try: + payload = res.json() + except ValueError as exc: + raise HTTPException( + status_code=502, + detail="Invalid JSON from Animechan", + ) from exc + + # Validate and coerce to our response model + try: + validated = AnimechanResponse.model_validate(payload) + except Exception as exc: + raise HTTPException( + status_code=502, + detail="Unexpected Animechan schema", + ) from exc + + return validated diff --git a/tests/test_anime.py b/tests/test_anime.py new file mode 100644 index 0000000..bdc1ff7 --- /dev/null +++ b/tests/test_anime.py @@ -0,0 +1,72 @@ +from fastapi.testclient import TestClient + +from ssdlc_demo.main import app + + +def test_anime_quote_success(monkeypatch) -> None: + class DummyResponse: + status_code = 200 + + def json(self): + return { + "status": "success", + "data": { + "content": "Test quote", + "anime": {"id": 1, "name": "Naruto", "altName": "NARUTO"}, + "character": {"id": 2, "name": "Naruto Uzumaki"}, + }, + } + + def fake_get(url, timeout=5): # type: ignore[no-untyped-def] + assert url == "https://api.animechan.io/v1/quotes/random" + assert timeout == 5 + return DummyResponse() + + import ssdlc_demo.main as main_mod + + monkeypatch.setattr(main_mod.requests, "get", fake_get) + + client = TestClient(app) + res = client.get("/anime/quote") + assert res.status_code == 200 + body = res.json() + assert body["status"] == "success" + assert body["data"]["content"] == "Test quote" + + +def test_anime_quote_upstream_error(monkeypatch) -> None: + class DummyResponse: + status_code = 500 + + def json(self): + return {} + + def fake_get(url, timeout=5): # type: ignore[no-untyped-def] + return DummyResponse() + + import ssdlc_demo.main as main_mod + + monkeypatch.setattr(main_mod.requests, "get", fake_get) + + client = TestClient(app) + res = client.get("/anime/quote") + assert res.status_code == 502 + + +def test_anime_quote_invalid_json(monkeypatch) -> None: + class DummyResponse: + status_code = 200 + + def json(self): # type: ignore[no-untyped-def] + raise ValueError("invalid json") + + def fake_get(url, timeout=5): # type: ignore[no-untyped-def] + return DummyResponse() + + import ssdlc_demo.main as main_mod + + monkeypatch.setattr(main_mod.requests, "get", fake_get) + + client = TestClient(app) + res = client.get("/anime/quote") + assert res.status_code == 502