Skip to content

Add OpenAPI 3.1 webhook support and per-webhook security - #22

Draft
andreashasse wants to merge 5 commits into
mainfrom
claude/openapi-webhook-support-sz755w
Draft

andreashasse wants to merge 5 commits into
mainfrom
claude/openapi-webhook-support-sz755w

Conversation

@andreashasse

@andreashasse andreashasse commented Aug 24, 2026 •

Copy link
Copy Markdown
Owner

Phoenix integration for the webhook support and per-operation security added in andreashasse/spectra#188 and andreashasse/spectral#38.

⚠️ Blocked on both upstreams

mix.exs temporarily points :spectra and :spectral at their webhook branches with override: true (lock at spectra 1931e32, spectral c022b54). Both must be reverted to hex versions before merge, once the upstream PRs land and are released — main is on {:spectral, "~> 0.14.0"} and {:spectra, "~> 0.14.0"}.

API

Webhooks describe requests your API sends out, so they have no route in the router and are declared explicitly, as a :webhooks option in either of two places:

use PhoenixSpectral.OpenAPIController,
  router: MyAppWeb.Router,
  title: "My API",
  version: "1.0.0",
  security_schemes: %{
    "bearer_auth" => %{type: "http", scheme: "bearer"},
    "webhook_signature" => %{type: "apiKey", in: "header", name: "x-signature"}
  },
  security: [%{"bearer_auth" => []}],
  webhooks: [
    %{
      name: "userCreated",
      method: :post,
      module: MyApp.Events,
      payload: {:type, :user_created, 0},
      responses: [{200, "Acknowledged"}],
      doc: %{security: [%{"webhook_signature" => []}]}
    }
  ]

# Or directly
PhoenixSpectral.generate_openapi(MyAppWeb.Router, metadata, webhooks: webhooks)

A spec generated without the option is unchanged and emits no webhooks key (covered by a test).

The entry is a declarative map converted into a Spectral.OpenAPI webhook internally, so callers never touch the builder API that phoenix_spectral otherwise hides. A malformed entry raises FunctionClauseError rather than producing a broken spec, per the repo's crash-on-bad-code convention.

Per-webhook security

:security inside a webhook's :doc overrides the global requirement for that webhook alone; [] opts it out entirely. That is what lets the API authenticate inbound calls with a bearer token while signing outgoing webhooks with an HMAC header, as above. A webhook that declares nothing keeps inheriting the global default, which is what OpenAPI specifies — pinned by a test.

Known gap: route-based endpoints cannot set their own :security this way. Their operation docs come from the controller's spectral annotation, and spectra:function_doc() carries only summary, description and deprecated. Endpoints therefore use the global requirement. Extending the annotation path is a separate change; documented in the README rather than worked around.

Why an option and not a positional argument

This started as generate_openapi/4 taking webhooks positionally. Three problems:

  • Elixir will not let /4 default its options argument while /3 exists — the default generates a dead /3 clause — so every webhook caller was forced to pass a trailing [] that did nothing.
  • OpenAPIController already took webhooks: as an option, so the same data was an option in one place and a positional argument in the other.
  • Arity would keep growing: top-level tags and externalDocs are both queued in spectra's todo.md.

The hazard this introduces, and the fix. Spectra reads options with proplists:get_value(pre_encoded, Options, false), which ignores keys it does not know. Carrying :webhooks in that same list means a typo like webhook: would drop every webhook from the spec in silence — the exact failure mode CLAUDE.md forbids, and one the positional form could not have. Unrecognized options therefore raise ArgumentError. That check is part of this change, not a nice-to-have.

Implementation note: the option list is split with Enum.split_with/2, not a Keyword function. Back-compat requires [:pre_encoded, webhooks: [...]] to work, and Keyword.pop/2 raises a FunctionClauseError on a list mixing bare atoms with tuples. A bare encode-option list keeps working unchanged.

Scope: :spectra_openapi.to_openapi/4 and Spectral.OpenAPI.to_openapi/4 stay positional. They are the builder layer mirroring Erlang, where webhooks are a primary input rather than an option.

Known gap

Header parameters on a webhook (documenting the X-Signature header as a parameter rather than a security scheme) are not exposed through the declarative entry. The README documents the Spectral.OpenAPI escape hatch. With per-webhook security now in place this matters less — a signature header is better described as a security scheme anyway.

Tests

Fourteen tests: top-level emission with docs and responses, payload types in components/schemas, optional responses and doc, several methods under one event name, routes still generated alongside webhooks, absence of the key when the option is omitted, a crash on a malformed entry, :webhooks coexisting with encode options in either order, a raise on an unrecognized option, a bare encode-option list still working, the :webhooks controller option served over a real conn, a webhook declaring its own auth while the API keeps the global default, [] opting out, and inheritance when nothing is declared.

Verified locally on the merge head: mix test (138 tests), mix credo --strict, mix ex_dna, mix dialyzer (0 errors), mix format --check-formatted all clean, mix compile --force --warnings-as-errors warning-free, and the example/ app's 17 integration tests pass.

Kept current with both upstreams and main

main has moved a fair way while this sat open — the 0.7.0 release, the move to the spectral 0.14 line, nil-body handling, content-type validation, and a dependency/CI upgrade. origin/main is merged in here, and both branch locks track the upstream webhook branches' own merge heads.

Conflicts were confined to the dependency pins and the changelog. mix.exs resolves to this PR's branch deps rather than main's hex versions (neither released version has webhook support yet); mix.lock and example/mix.lock take main's side wholesale and were then regenerated with mix deps.get, so the upgraded transitive deps (plug, plug_crypto, telemetry, thousand_island) are kept and only the two git deps differ from main. CHANGELOG.md keeps both this PR's ## [Unreleased] entries and the released sections below them. Everything else auto-merged, including lib/phoenix_spectral.ex, where this PR's :webhooks option plumbing sits alongside main's type-reference and content-type work.

claude added 3 commits August 24, 2026 19:37
Webhooks describe requests the API sends out, so they have no route in the
router and are declared explicitly:

- PhoenixSpectral.generate_openapi/4 takes a list of webhook declarations
  alongside the router. generate_openapi/2,3 are unchanged and delegate
  with no webhooks, so existing specs emit no webhooks key.
- PhoenixSpectral.OpenAPIController accepts the same list as a :webhooks
  option, which is how most users serve a spec. Without it generate_openapi/4
  would be unreachable in normal use.

Each entry is a declarative map of :name, :method, :module and :payload,
plus optional :responses and :doc, which is converted into a
Spectral.OpenAPI webhook internally - callers do not touch the builder API.
A malformed entry raises a FunctionClauseError rather than producing a
broken spec, per the crash-on-bad-code convention.

Webhooks are keyed by an event name rather than a URL path, because the
consumer owns the URL. The direction is inverted relative to a route: the
payload is what the API sends, and the responses describe what the consumer
returns. Payload types share components/schemas with the routes.

Header parameters on a webhook are not exposed through the declarative
entry yet; this is documented in the README with the Spectral.OpenAPI
escape hatch.

mix.exs temporarily points spectra and spectral at their webhook branches;
both must be reverted to hex versions before merge.
generate_openapi/4 became generate_openapi/3 with a :webhooks option.

Three reasons the positional form was wrong:

- Elixir will not let /4 default its options argument while /3 exists (the
  default generates a dead /3 clause), so every webhook caller was forced
  to pass a trailing [] that did nothing.
- OpenAPIController already took webhooks as a :webhooks option, so the
  same data was an option in one place and a positional argument in the
  other.
- Arity would keep growing. Top-level tags and externalDocs are both
  queued in spectra's todo, and each would have needed another parameter.

Carrying :webhooks in the option list does introduce a hazard the
positional form did not have: spectra reads options with
proplists:get_value/3, which ignores keys it does not know, so a typo like
webhook: would drop every webhook from the spec in silence. Unrecognized
options therefore now raise ArgumentError, matching the convention that bad
code crashes rather than being quietly swallowed.

The option list is split with Enum.split_with/2 rather than a Keyword
function, because it legitimately mixes bare atoms (:pre_encoded) with
tuples and Keyword.pop/2 raises on that shape. A bare encode-option list
keeps working unchanged.
A webhook entry's :doc map now carries :security, emitted on that
webhook's operation alone and overriding the global :security
requirement; [] opts it out entirely. This is what lets an API
authenticate inbound calls one way and sign its outgoing webhooks
another.

No code change was needed - to_webhook/1 already forwards :doc to
Spectral.OpenAPI.webhook/3, and spectra gained the key upstream. The
tests prove the key survives that forwarding rather than being filtered,
and pin the inheritance behaviour: a webhook that declares nothing keeps
inheriting the global default, which is what OpenAPI specifies.

Route-based endpoints cannot set their own security this way, because
their operation docs come from the controller's spectral annotation and
spectra:function_doc() carries only summary, description and deprecated.
Endpoints therefore use the global requirement. Documented in the README
rather than worked around.

Deps advanced to spectra aabf317 and spectral fb0c2f4.
@andreashasse andreashasse changed the title Add OpenAPI 3.1 webhook support Add OpenAPI 3.1 webhook support and per-webhook security Aug 30, 2026
spectra's webhook branch merged main (0.14.0, which changed how doc
annotations survive schema inlining) and spectral's branch followed.
The locks still pointed at the pre-merge commits, so the suite was
exercising both upstreams as they were before that change rather than
what this PR will actually ship against.

Everything is unchanged and green on the new locks: 102 tests, the
example app's 14 integration tests, credo --strict, ex_dna, dialyzer
(0 errors), format --check and a warning-free forced compile.

Still branch deps - both revert to hex versions before merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Erhgwu59rBxiYw1fVQGbcr
…k-support-sz755w

# Conflicts:
#	CHANGELOG.md
#	example/mix.lock
#	mix.exs
#	mix.lock
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants