$npx -y skills add ocbunknown/fastapi-claude-template --skill api-clientUse when integrating an external HTTP API (Stripe, GitHub, Twilio, a payment provider, etc.) into the project. Enforces the Client + Service split — Client lives in infrastructure and returns raw vendor-shaped Pydantic responses, Service lives in application and maps raw response
| 1 | # Integrating an external HTTP API |
| 2 | |
| 3 | An external API integration is **two objects**, not one: |
| 4 | |
| 5 | 1. **Client** (`infrastructure/http/clients/<vendor>/`) — thin HTTP wrapper. Returns **raw vendor-shaped Pydantic models** that mirror the API's JSON 1:1. No business logic, no transformation, no domain types. |
| 6 | 2. **Service** (`application/v1/services/<vendor>.py`) — holds a Client via its port, calls it, and **maps raw responses into application `Result` types**. All business rules (enum mapping, filtering, multi-call orchestration) live here. The service **never** returns `Create*Type` / `Update*Type` — persistence shape is the use case's job. |
| 7 | |
| 8 | Use cases depend on the **Service**. In rare cases (debug/proxy/admin) they can depend on the **Client port** directly. Examples below use a Stripe-shaped payment provider — substitute your vendor. |
| 9 | |
| 10 | ## Layer split |
| 11 | |
| 12 | The project's rule (`CLAUDE.md`): `application/` **cannot** import from `infrastructure/`; `infrastructure/` **can** import from `application/`. Every type application-code reads must live in `application/`. |
| 13 | |
| 14 | ``` |
| 15 | src/ |
| 16 | ├── application/ |
| 17 | │ ├── common/interfaces/<vendor>/ |
| 18 | │ │ ├── port.py ← Protocol <Vendor>Client |
| 19 | │ │ ├── types.py ← VENDOR enums/literals |
| 20 | │ │ └── responses.py ← raw Pydantic DTOs (1:1 with API JSON) |
| 21 | │ ├── v1/results/<entity>.py ← application Result (PaymentResult, …) |
| 22 | │ └── v1/services/<vendor>.py ← <Vendor>Service: raw → Result |
| 23 | │ |
| 24 | └── infrastructure/http/clients/<vendor>/ |
| 25 | ├── client.py ← <Vendor>API(Client) — implements port structurally |
| 26 | └── endpoints.py ← StrEnum of URL paths |
| 27 | ``` |
| 28 | |
| 29 | Raw response DTOs and vendor enums live in `application/` because the service in application reads them. They become **part of the port contract** — the same pattern as `JWT`/`TokenType`, `Cache`/`CacheKey`: the port owns its vocabulary. |
| 30 | |
| 31 | ## The Client — raw and dumb |
| 32 | |
| 33 | Jobs: know the vendor's URL/auth, send HTTP, parse JSON into application-defined DTOs, translate HTTP errors into application exceptions. **Never** contains business branching or domain types. |
| 34 | |
| 35 | ```python |
| 36 | # src/infrastructure/http/clients/stripe/endpoints.py |
| 37 | from enum import StrEnum |
| 38 | |
| 39 | class StripeEndpoints(StrEnum): |
| 40 | CHARGES = "/v1/charges" |
| 41 | CUSTOMERS = "/v1/customers" |
| 42 | ``` |
| 43 | |
| 44 | ```python |
| 45 | # src/infrastructure/http/clients/stripe/client.py |
| 46 | from enum import StrEnum |
| 47 | from typing import Optional, Unpack |
| 48 | |
| 49 | from src.application.common.interfaces.stripe import responses as res |
| 50 | from src.application.common.interfaces.stripe.types import StripeChargeStatus, StripeCurrency |
| 51 | from src.infrastructure.http.clients.base import Client |
| 52 | from src.infrastructure.http.clients.stripe.endpoints import StripeEndpoints |
| 53 | from src.infrastructure.http.provider.aiohttp import ParamsType |
| 54 | from src.infrastructure.http.provider.base import AsyncProvider |
| 55 | from src.settings.core import StripeSettings |
| 56 | |
| 57 | |
| 58 | class StripeAPI(Client): |
| 59 | __slots__ = ("_settings",) |
| 60 | |
| 61 | def __init__( |
| 62 | self, |
| 63 | settings: StripeSettings, |
| 64 | provider: AsyncProvider | None = None, |
| 65 | *, |
| 66 | proxy: str | None = None, |
| 67 | **options: Unpack[ParamsType], |
| 68 | ) -> None: |
| 69 | super().__init__(settings.base_url, provider, proxy=proxy, **options) |
| 70 | self._settings = settings |
| 71 | |
| 72 | async def get_charge_by_id(self, charge_id: str) -> res.ChargeResponse: |
| 73 | payload = await ( |
| 74 | await self._provider.make_request( |
| 75 | "GET", |
| 76 | self.endpoint_url(f"{StripeEndpoints.CHARGES}/{charge_id}"), |
| 77 | ) |
| 78 | ).json() |
| 79 | return res.ChargeResponse(**payload) |
| 80 | |
| 81 | async def list_charges( |
| 82 | self, |
| 83 | *, |
| 84 | status: Optional[StripeChargeStatus] = None, |
| 85 | currency: Optional[StripeCurrency] = None, |
| 86 | limit: Optional[int] = None, |
| 87 | ) -> list[res.ChargeResponse]: |
| 88 | params = { |
| 89 | k: v.value if isinstance(v, StrEnum) else v |
| 90 | for k, v in {"status": status, "currency": currency, "limit": limit}.items() |
| 91 | if v is not None |
| 92 | } |
| 93 | payload = await ( |
| 94 | await self._provider.make_request( |
| 95 | "GET", self.endpoint_url(StripeEndpoints.CHARGES), params=params, |
| 96 | ) |
| 97 | ).json() |
| 98 | return [res.ChargeResponse(**item) for item in payload["data"]] |
| 99 | ``` |
| 100 | |
| 101 | **`StripeAPI` does NOT inherit from the `StripeClient` protocol.** Python's `Protocol` is structural — matching method signatures make `StripeAPI` satisfy `StripeClient` automatically. Explicit in |