| name | openapi-spec |
| description | Create, adjust, or inspect OpenAPI declarations for Blockscout API v2 endpoints. Use this skill whenever the user asks to: add an OpenAPI spec to an endpoint that lacks one, update a spec after controller/view changes, audit or fix an existing OpenAPI declaration, or work with open_api_spex annotations in the Blockscout codebase. Also trigger when the user mentions 'swagger', 'openapi', 'open_api_spex', 'API spec', 'API schema', or 'operation macro', or when debugging failures like 'response schema mismatch', 'CastAndValidate rejection', 'json_response validation error', 'Unexpected field', or extra/missing keys in API responses. |
| allowed-tools | ["Bash(.claude/skills/openapi-spec/scripts/generate-spec.sh *)","Bash(oastools *)"] |
OpenAPI Spec Authoring for Blockscout API v2
This skill covers three workflows for Blockscout's OpenAPI declarations:
- Create — add a declaration for an endpoint that has none
- Adjust — update a declaration after parameters or response changed
- Inspect & Fix — audit an existing declaration for correctness issues
Blockscout uses the open_api_spex library (v3.22+) to define OpenAPI 3.0 specs inline in Elixir code. There are no hand-written spec files — the spec is derived entirely from annotations in controllers and schema modules, then assembled at runtime via router introspection.
Key file locations
All paths are relative to apps/block_scout_web/lib/block_scout_web/. Most endpoints live under the flat v2 layout, but annotated endpoints also exist outside of it — the table below calls out every tree that contributes to the generated spec.
| What | Where |
|---|
| V2 controllers (flat) | controllers/api/v2/<domain>_controller.ex |
| V2 proxy controllers | controllers/api/v2/proxy/<domain>_controller.ex (routed under /v2/proxy) |
| V2 chain-type-nested controllers | controllers/api/v2/<chain>/<domain>_controller.ex (e.g. controllers/api/v2/ethereum/deposit_controller.ex) |
| Account controllers (Private spec) | controllers/account/api/v2/<domain>_controller.ex |
| Legacy controllers | controllers/api/legacy/<domain>_controller.ex (routed under /legacy) |
| V2 schema modules | schemas/api/v2/<domain>.ex and schemas/api/v2/<domain>/*.ex |
| V2 chain-type schema subdirs | schemas/api/v2/<chain>/*.ex (e.g. schemas/api/v2/{arbitrum,beacon,celo,optimism,scroll,zilliqa,mud}/*.ex) |
| V2 proxy schemas | schemas/api/v2/proxy/*.ex |
| Account schemas (Private spec) | schemas/api/v2/account/*.ex |
| Legacy schemas | schemas/api/legacy/*.ex |
| Parameter helpers | schemas/api/v2/general.ex (all helpers centralized here) |
| Error responses | schemas/api/v2/error_responses.ex |
| Schema helper | schemas/helper.ex (extend_schema/2) |
| Leaf type schemas | schemas/api/v2/general/*.ex (AddressHash, FullHash, IntegerString, etc.) |
| API router | routers/api_router.ex |
| V2 sub-routers forwarded from the API router | routers/tokens_api_v2_router.ex, routers/smart_contracts_api_v2_router.ex, routers/api_key_v2_router.ex, routers/utils_api_v2_router.ex, routers/address_badges_v2_router.ex |
| Account router (Private spec) | routers/account_router.ex |
| Views (flat v2) | views/api/v2/<domain>_view.ex |
| Legacy views | views/api/legacy/<domain>_view.ex |
| Paging helper | paging_helper.ex (delete_parameters_from_next_page_params/1) |
| Spec aggregators | specs/public.ex (public spec + tag registry), specs/private.ex (account/private spec) |
| Global aliases/imports | The file block_scout_web.ex — look for :controller quote block |
| V2 tests | ../../test/block_scout_web/controllers/api/v2/<domain>_controller_test.exs |
| Legacy tests | ../../test/block_scout_web/controllers/api/legacy/<domain>_controller_test.exs |
On router coverage of the public spec: specs/public.ex builds paths via Paths.from_router(ApiRouter), which picks up everything reachable from api_router.ex — including endpoints declared in the sub-routers that api_router.ex forwards to (api-key, utils, address-badges). Only TokensApiV2Router and SmartContractsApiV2Router need the extra Paths.from_routes(...) merges in public.ex because their prefixes are stripped by Phoenix forward and must be re-added. A new annotated endpoint placed in any of the other sub-routers needs no extra wiring beyond the forward that already exists in api_router.ex.
Core patterns
The operation macro
Every annotated controller action has an operation/2 call (from OpenApiSpex.ControllerSpecs):
operation :action_name,
summary: "Short summary for the endpoint",
description: "Longer description of what it does.",
parameters: [some_path_param() | base_params()],
responses: [
ok: {"Success description", "application/json", Schemas.SomeDomain.Response},
not_found: NotFoundResponse.response(),
unprocessable_entity: JsonErrorResponse.response()
]
For POST/PUT/PATCH endpoints, add request_body: — see references/request-body-security-headers.md.
The three-way parameter coupling
Path parameters must be consistent across three locations or the endpoint breaks:
| Location | Form | Example |
|---|
| Phoenix route segment | String with : prefix | get("/:transaction_hash_param", ...) |
%Parameter{} struct | Atom in :name field | %Parameter{name: :transaction_hash_param, in: :path} |
| Controller action head | Atom key in pattern match | def transaction(conn, %{transaction_hash_param: value}) |
CastAndValidate reads string keys from conn.path_params, converts them to atoms using the Parameter :name, and places them in conn.params. The controller then pattern-matches on those atoms.
Response schema ↔ view correlation
There is no runtime validation that view output matches the response schema. Alignment is enforced only at test time: every json_response/2 call in a ConnCase test automatically validates the response body against the OpenAPI spec. This means:
- Schemas with
additionalProperties: false catch extra keys the view emits
- The
required list catches missing keys
- Type/pattern checks catch type mismatches
If a view emits a key not in the schema (or vice versa), tests will fail.
CastAndValidate's effect on params (string keys → atom keys)
When CastAndValidate processes an action with a real operation spec (not operation :action, false), it transforms all params before the action runs:
- String keys become atom keys:
%{"id" => "42"} → %{id: 42}
- Values are cast to declared types: strings become integers, booleans, etc., based on the parameter's
%Schema{type: ...}
Actions declared with operation :action, false are skipped — they receive the original string-keyed params from Phoenix unchanged.
This matters most for pagination. The paging_options/1 function in chain.ex has parallel clauses for both forms:
- String-key clauses (e.g.,
%{"id" => id_string} when is_binary(id_string)) — used by actions without a spec
- Atom-key clauses (e.g.,
%{id: id}) — used by actions with a real spec
When promoting an action from operation :action, false to a real spec, the string-key paging_options clause will stop matching. You must ensure a corresponding atom-key clause exists. See Workflow A, Step 4b for details.
base_params() — always include
base_params() returns [api_key_param(), key_param()] — two optional query parameters (apikey, key) present on every public API operation. Always include it.
Common composition patterns:
# Simple — no extra params
parameters: base_params()
# With a path param (prepend via cons)
parameters: [address_hash_param() | base_params()]
# With paging params (append via ++)
parameters: base_params() ++ define_paging_params(["index", "block_number"])
# Combined
parameters: [transaction_hash_param() | base_params()] ++ [token_type_param()] ++ define_paging_params(["index", "block_number"])
Controller prerequisites
Every annotated controller needs:
use OpenApiSpex.ControllerSpecs # injects operation/2, tags/1
plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) # validates incoming params
tags(["domain-tag"]) # groups operations in spec
These are typically near the top of the controller module, after use BlockScoutWeb, :controller.
Tag naming: kebab-case. Tag strings use kebab-case ("internal-transactions", "main-page", "smart-contracts", "token-transfers", "account-abstraction"), not snake_case. Multi-word controller module names such as InternalTransactionController still map to the kebab-case plural tag, not to the module name.
Tag registry (specs/public.ex)
The order of tag groups in the generated public spec is not derived from the controllers — it's declared explicitly in specs/public.ex and has a fixed three-part shape:
- Base tags — the
@default_api_categories list at the top of specs/public.ex, always present regardless of chain type.
- Chain-type-specific tags — returned by
chain_type_category/0, whose clauses are keyed on @chain_identity ({:optimism, :celo}, {:optimism, nil}, {:scroll, nil}, {:zilliqa, nil}, …). For chains without OpenAPI coverage this is an empty list.
"legacy" — hard-coded trailer pinned last.
If a new annotated controller introduces a brand-new tag, the agent must register it in the right group, or the tag will still appear in the spec (via controller-side tags(...)) but with no ordering guarantee and no entry in the top-level tags: list:
- Base endpoint → append the kebab-case tag to
@default_api_categories.
- Chain-type endpoint → add it inside the relevant
case @chain_identity branch, matching the existing patterns (module-attribute + defp for static lists, full defp body when the tag set depends on a runtime flag such as mud_enabled?()).
- Legacy endpoint → no action;
"legacy" is already the trailer.
Tags that are already covered by an existing group (e.g. another addresses endpoint) need no change.
Verification
After creating or modifying a declaration, verify it using these methods in order. Each catches a different class of issues, and earlier steps are faster — so run them first to get quick feedback before committing to a full test run.
1. Compile (mix compile)
Compiling the block_scout_web app verifies structural validity: schema modules exist, operation names match controller action function names, and all referenced modules resolve. This is the fastest check and catches typos, missing modules, and wiring errors.
Run via devcontainer if mix is not available on the host.
2. Generate the spec (generate-spec.sh)
This exercises OpenApiSpex.resolve_schema_modules/1, which resolves all schema module references and inlines them into the full spec. It catches issues that compilation alone misses: circular references, malformed schema structures, and resolution failures.
.claude/skills/openapi-spec/scripts/generate-spec.sh
See references/spec-generation-and-verification.md for script options (chain-specific generation, custom output path) and oastools commands for inspecting the result.
2a. Audit for spec-wide convention drift (optional)
After regeneration, sweep the spec for convention violations a single-endpoint test run won't catch: missing additionalProperties: false, missing :unprocessable_entity, tag casing, etc. See references/oastools-audit-recipes.md — the quick sweep is recipes A, B, F, I.
The generated spec is cache-like, so regeneration must come first. A stale .ai/tmp/openapi_public.yaml produces false positives for every recipe that counts violations — e.g., it may report tag-casing hits that no longer exist in the codebase.
2b. Tag audit (run after creating, moving, or retagging operations)
mix test does not check tags. Run Recipe O (registry coverage — Step 4e) and Recipe P (URL prefix vs operation tag — Step 4d) from references/oastools-audit-recipes.md.
3. Run controller tests (mix test)
Run the specific controller test file. Every json_response/2 call automatically validates the response body against the OpenAPI schema. This catches response-level issues: extra keys (via additionalProperties: false), missing required keys, and type mismatches.
mix test apps/block_scout_web/test/block_scout_web/controllers/api/v2/<domain>_controller_test.exs
If tests fail with schema validation errors, the view output doesn't match the declared schema — fix the discrepancy.
4. Code cross-referencing (for Inspect & Fix workflow)
Manually or via grep, compare the controller's consumed parameters against declared parameters, and the view's output keys against schema properties. This catches logical issues that tests might miss (e.g., an undeclared optional parameter that works at runtime but isn't documented, or a schema property that's declared but never emitted by the view).
See references/inspection-checklist.md for the systematic approach.
Workflow A: Create a new declaration
Use this when an endpoint exists (route + controller action + view) but has no operation/2 annotation.
Step 1: Gather context
Read these files in parallel to understand the endpoint:
- Router — find the route definition. Note the HTTP method, path segments (especially
:param_name segments), and which controller/action it maps to.
- Controller — read the action function. Note what keys it destructures from
params and conn.body_params, what data it fetches, and what view template it renders.
- View — read the render function and any
prepare_* helper it calls. Note every key in the output map — these become schema properties. Trace all code paths, not just the default: look for case/cond/pattern-match branches in the render function and its helpers that produce different map shapes depending on a field value. When found, note the discriminator field and the distinct set of keys each branch emits — these indicate a polymorphic sub-object that needs special handling in Step 3.
- Existing schemas — glob
schemas/api/v2/<domain>* to see if schema modules already exist for this domain.
- Peer precedent (optional) —
oastools walk operations -tag <domain> -q .ai/tmp/openapi_public.yaml lists sibling endpoints already in the spec. Useful before choosing between schema reuse and new schemas in Step 3.
Step 2: Find or create parameter definitions
For each parameter the controller reads:
-
Check if a helper already exists. Grep general.ex for a function matching the parameter name:
# For a path param named :address_hash_param
grep "def address_hash_param" in general.ex
Read references/parameter-discovery.md for naming conventions and discovery patterns.
-
If no helper exists, decide:
- Reusable across controllers? Add a new helper function to
general.ex following the naming conventions in references/parameter-discovery.md.
- Domain-specific but used by multiple operations in the same controller? Add a private helper function in the controller itself. This avoids polluting
general.ex with chain-specific concerns while preventing duplication across operations.
- Truly one-off (single operation)? Define an inline
%OpenApiSpex.Parameter{} struct directly in the operation macro arguments.
-
For pagination parameters, use define_paging_params(field_names) — pass the cursor field names as strings, and always include "items_count" (the next_page_params helper adds it to every cursor automatically). See references/parameter-discovery.md section "The define_paging_params factory" for details.
Step 3: Create or locate response schema
- Check if a schema module exists for the response entity. Glob
schemas/api/v2/<domain>*.ex.
- If schemas exist in the same domain, compare their properties against the new view's output keys to detect subset/superset relationships (recipe N in
references/oastools-audit-recipes.md gives a mechanical candidate list across all component schemas):