Add OpenAPI 3.1 webhook support and per-webhook security - #22
Draft
andreashasse wants to merge 5 commits into
Draft
andreashasse wants to merge 5 commits into
andreashasse wants to merge 5 commits into
Conversation
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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phoenix integration for the webhook support and per-operation security added in andreashasse/spectra#188 and andreashasse/spectral#38.
mix.exstemporarily points:spectraand:spectralat their webhook branches withoverride: true(lock at spectra1931e32, spectralc022b54). Both must be reverted to hex versions before merge, once the upstream PRs land and are released —mainis 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
:webhooksoption in either of two places:A spec generated without the option is unchanged and emits no
webhookskey (covered by a test).The entry is a declarative map converted into a
Spectral.OpenAPIwebhook internally, so callers never touch the builder API that phoenix_spectral otherwise hides. A malformed entry raisesFunctionClauseErrorrather than producing a broken spec, per the repo's crash-on-bad-code convention.Per-webhook security
:securityinside a webhook's:docoverrides 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
:securitythis way. Their operation docs come from the controller'sspectralannotation, andspectra:function_doc()carries onlysummary,descriptionanddeprecated. 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/4taking webhooks positionally. Three problems:/4default itsoptionsargument while/3exists — the default generates a dead/3clause — so every webhook caller was forced to pass a trailing[]that did nothing.OpenAPIControlleralready tookwebhooks:as an option, so the same data was an option in one place and a positional argument in the other.tagsandexternalDocsare both queued in spectra'stodo.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:webhooksin that same list means a typo likewebhook: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 raiseArgumentError. 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 aKeywordfunction. Back-compat requires[:pre_encoded, webhooks: [...]]to work, andKeyword.pop/2raises aFunctionClauseErroron a list mixing bare atoms with tuples. A bare encode-option list keeps working unchanged.Scope:
:spectra_openapi.to_openapi/4andSpectral.OpenAPI.to_openapi/4stay 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-Signatureheader as a parameter rather than a security scheme) are not exposed through the declarative entry. The README documents theSpectral.OpenAPIescape 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,:webhookscoexisting with encode options in either order, a raise on an unrecognized option, a bare encode-option list still working, the:webhookscontroller 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-formattedall clean,mix compile --force --warnings-as-errorswarning-free, and theexample/app's 17 integration tests pass.Kept current with both upstreams and
mainmainhas 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/mainis 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.exsresolves to this PR's branch deps rather thanmain's hex versions (neither released version has webhook support yet);mix.lockandexample/mix.locktakemain's side wholesale and were then regenerated withmix deps.get, so the upgraded transitive deps (plug, plug_crypto, telemetry, thousand_island) are kept and only the two git deps differ frommain.CHANGELOG.mdkeeps both this PR's## [Unreleased]entries and the released sections below them. Everything else auto-merged, includinglib/phoenix_spectral.ex, where this PR's:webhooksoption plumbing sits alongsidemain's type-reference and content-type work.