--- name: external-api-integration description: Use this skill when adding, modifying, extending, or debugging ANY external API integration (MusicBrainz, Last.fm, Discogs, Wikipedia, OpenAI, BraveSearch, Mailgun). Use PROACTIVELY when the user mentions "API", "integration", "HTTP client", "rate limit", "Req", "MusicBrainz", "Last.fm", "Discogs", "Wikipedia", "OpenAI", "Brave", "webhook", "external service", "third-party API", "error response", or asks about API patterns. Also use when adding new API calls to existing integrations or creating worker modules for API operations. --- # External API Integration Project-specific patterns for external API integrations. Every integration follows a consistent three-module architecture with rate limiting, structured error handling, and test stubbing. ## Checklist Before Adding or Modifying an API Integration 1. **Does an `ErrorResponse` module exist?** Every API MUST have a `.API.ErrorResponse` module implementing `MusicLibrary.ErrorResponse` behaviour. 2. **Is rate limiting configured?** Every API uses `Req.RateLimiter` with a per-API interval. Check the architecture doc for existing intervals. 3. **Are test stubs in place?** All HTTP calls must be stubbed via `config/test.exs` using `Req.Test`. Use fixture modules for response data. 4. **Does the worker use `ErrorHandler`?** Workers that call APIs must route errors through `MusicLibrary.Worker.ErrorHandler.to_oban_result/1`. ## Three-Module Architecture Every external API integration follows this pattern: ``` lib/music_library// ├── .ex # Facade: public API, delegates to API module ├── api.ex # API: Req HTTP client, rate-limited ├── api/ │ ├── config.ex # Config: NimbleOptions schema, reads from app env │ ├── error_response.ex # ErrorResponse: implements MusicLibrary.ErrorResponse behaviour │ └── fixtures.ex # Fixtures: test response data (for API-specific fixtures) ``` ### Facade Module (`.ex`) The public interface. No HTTP logic here — delegates to the API module: ```elixir defmodule MusicLibrary.LastFm do @spec get_recent_tracks(keyword()) :: {:ok, [LastFm.Track.t()]} | {:error, term()} def get_recent_tracks(opts \\ []) do LastFm.API.get_recent_tracks(opts) end end ``` ### API Module (`api.ex`) Req-based HTTP client. Uses `Req.RateLimiter` via the request pipeline: ```elixir defmodule MusicLibrary.LastFm.API do @spec get_recent_tracks(keyword()) :: {:ok, [LastFm.Track.t()]} | {:error, term()} def get_recent_tracks(opts) do config = LastFm.API.Config.new!(opts) Req.new( base_url: "https://ws.audioscrobbler.com/2.0", params: [api_key: config.api_key, format: "json"], plugins: [ {Req.RateLimiter, id: :last_fm} ] ) |> Req.get(url: "/", params: [method: "user.getRecentTracks", user: config.user]) |> case do {:ok, %{status: 200, body: body}} -> {:ok, parse_tracks(body)} {:ok, %{status: status, body: body}} -> {:error, LastFm.API.ErrorResponse.new(status, body)} {:error, error} -> {:error, error} end end end ``` Key patterns: - **Config goes first** — build it from opts, validate with NimbleOptions - **Rate limiter as Req plugin** — `{Req.RateLimiter, id: :service_name}` - **Non-200 status codes** are wrapped in `ErrorResponse` structs, not raw maps - **Req-level errors** (timeouts, DNS) are passed through as-is - **Successful responses** are parsed into domain structs ### Config Module (`api/config.ex`) NimbleOptions schema, reads from application env: ```elixir defmodule MusicLibrary.LastFm.API.Config do @schema [ api_key: [type: :string, required: true], shared_secret: [type: :string, required: false], user: [type: :string, required: false] ] def new!(opts) do env = Application.get_env(:music_library, :last_fm, []) opts |> Keyword.merge(env) |> NimbleOptions.validate!(@schema) end end ``` ## ErrorResponse Behaviour Every API must have an `ErrorResponse` module implementing: ```elixir defmodule MusicLibrary.ErrorResponse do @type t :: struct() @callback retryable?(t()) :: boolean() @callback retry_delay_seconds(t()) :: non_neg_integer() | nil end ``` Example implementation: ```elixir defmodule MusicLibrary.LastFm.API.ErrorResponse do defstruct [:status, :body, :kind] @behaviour MusicLibrary.ErrorResponse def new(status, body) do %__MODULE__{ status: status, body: body, kind: MusicLibrary.HttpError.kind(status) # baseline mapping } end @impl true def retryable?(%{kind: :rate_limit}), do: true # 429 def retryable?(%{kind: :server_error}), do: true # 5xx def retryable?(_), do: false @impl true def retry_delay_seconds(%{status: 429, body: %{"error" => _, "message" => msg}}) do MusicLibrary.RetryDelay.parse(msg) # parse Retry-After header end def retry_delay_seconds(_), do: nil end ``` ### HTTP Error Kind Mapping (`MusicLibrary.HttpError`) Baseline mapping used by all APIs: | HTTP status | Kind | |-------------|------| | 429 | `:rate_limit` | | 5xx | `:server_error` | | Timeout | `:timeout` | | 401/403 | `:auth_error` | | 404 | `:not_found` | | 4xx | `:client_error` | | Other | `:unknown` | **Per-API overrides**: Some APIs have quirks that override the baseline: - **MusicBrainz**: HTTP 503 is their rate-limit signal (not a server error) - **OpenAI**: HTTP 429 with body `code: "insufficient_quota"` is `:auth_error` (permanent), not `:rate_limit` Override in the ErrorResponse module by pattern-matching on status + body before falling through to `MusicLibrary.HttpError.kind/1`. ## Rate Limiting ### Intervals (from architecture doc) | API | Interval | |-----|----------| | MusicBrainz | 1000 ms | | Last.fm | 500 ms | | Discogs | 2000 ms | | Wikipedia | 1000 ms | | BraveSearch | 1000 ms | | OpenAI | 250 ms | ### Req.RateLimiter ETS-backed, per-API enforcement via Req request step. Configured per-request: ```elixir Req.new(plugins: [{Req.RateLimiter, id: :music_brainz}]) ``` The `:id` atom corresponds to the `Req.RateLimiter` ETS table entry. In tests, use `Req.RateLimiter.Clock` behaviour with `SystemClock` implementation. ## Worker Error Handling Workers that call APIs follow this flow: ```elixir defmodule MusicLibrary.Worker.FetchArtistInfo do use Oban.Worker @impl true def perform(%{args: %{"musicbrainz_id" => mbid}}) do case Artists.fetch_info(mbid) do {:ok, _artist} -> :ok {:error, :no_english_wikipedia} -> # App-layer permanent failures — match first {:cancel, :no_english_wikipedia} {:error, reason} -> # Forward to ErrorHandler for structured HTTP error handling MusicLibrary.Worker.ErrorHandler.to_oban_result(reason) end end end ``` `ErrorHandler.to_oban_result/1`: - `%ErrorResponse{retryable?: true}` → `{:snooze, seconds}` (Oban snooze + retry) - `%ErrorResponse{retryable?: false}` → `{:cancel, reason}` (permanent) - Other errors → `{:error, reason}` (Oban default retry) ### Worker Return Values | Return | Meaning | Oban Behaviour | |--------|---------|----------------| | `:ok` | Success | Job completed | | `{:error, reason}` | Transient failure | Default retry with backoff | | `{:snooze, seconds}` | Retryable with delay | Retry after `seconds` | | `{:cancel, reason}` | Permanent failure | Job cancelled, never retried | **Never use `{:discard, reason}`** — it's deprecated. Use `{:cancel, reason}`. ## Test Stubs (Req.Test) All API HTTP calls are stubbed in `config/test.exs`: ```elixir # config/test.exs config :req, plug: { Req.Test, json_plug: &YourApp.JsonPlug.json/1 } ``` Each API's fixture module provides realistic response data. When adding a new API call: 1. **Add stub in `config/test.exs`** matching the request pattern 2. **Use fixture data** from the appropriate fixtures module 3. **Cover error cases**: rate limit (429), auth error (401/403), not found (404), server error (5xx) ```elixir # Example: stubbing Last.fm with fixture Req.Test.stub(LastFm.API, fn conn -> case conn.method do "GET" -> Req.Test.json(conn, LastFm.Fixtures.RecentTracks.get()) end end) ``` ### Available API Fixture Modules | Module | API | |--------|-----| | `MusicBrainz.ReleaseFixtures` | MusicBrainz release data | | `MusicBrainz.ReleaseGroupFixtures` | MusicBrainz release group data | | `MusicBrainz.ArtistFixtures` | MusicBrainz artist data | | `Discogs.ArtistFixtures` | Discogs artist profiles | | `LastFm.ArtistFixtures` | Last.fm artist info | | `LastFm.Fixtures.RecentTracks` | Last.fm scrobble history | | `Wikipedia.Fixtures` | Wikipedia extracts | ## Adding a New API Integration Step-by-step: 1. **Create directory** `lib/music_library//` 2. **Create Config** (`api/config.ex`) — NimbleOptions schema 3. **Create ErrorResponse** (`api/error_response.ex`) — implement behaviour, handle quirks 4. **Create API** (`api.ex`) — Req client with rate limiter plugin 5. **Create Facade** (`.ex`) — public functions, delegates to API 6. **Register rate limiter** in `config/config.exs` with interval 7. **Add test stubs** in `config/test.exs` 8. **Create fixture module** in `test/support/fixtures/` 9. **Update architecture doc** — add to External API Integrations table 10. **Add worker** (if needed) — thin wrapper with ErrorHandler