Skip to content

Add OpenAPI 3.1 webhook support and per-operation security - #188

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

Emits the OpenAPI 3.1 top-level webhooks key, and adds per-operation security so webhooks can authenticate differently from the API. Closes the webhooks item in todo.md.

What a webhook is here

A webhook is keyed by an event name rather than a URL path, because the consumer owns the URL this API calls. The direction is inverted relative to an endpoint: the request body is the payload the API sends out, and the responses describe what the consumer is expected to return.

API

%% New builders
spectra_openapi:webhook(Name, Method)      -> webhook_spec().
spectra_openapi:webhook(Name, Method, Doc) -> webhook_spec().
spectra_openapi:to_openapi(Metadata, Endpoints, Webhooks, Options) ->
    {ok, json:encode_value() | iodata()} | {error, [spectra:error()]}.

endpoints_to_openapi/2,3 are unchanged and delegate to to_openapi/4 with no webhooks, so existing specs emit no webhooks key (covered by a test).

add_response/2, with_request_body/3,4 and with_parameter/3 were widened to endpoint_spec() | webhook_spec(), so both kinds build through the same pipe rather than gaining webhook-specific twins.

Per-operation security

The Doc map now takes a security key, emitted on that operation alone and overriding the global default. security => [] opts an operation out entirely. This is what lets an API authenticate inbound calls one way and sign its outgoing webhooks another:

Meta = #{
    security_schemes => #{
        ~"bearer_auth" => #{type => ~"http", scheme => ~"bearer"},
        ~"webhook_signature" =>
            #{type => ~"apiKey", in => ~"header", name => ~"x-signature"}
    },
    security => [#{~"bearer_auth" => []}]        %% the API's own auth
},
Webhook = spectra_openapi:webhook(~"userCreated", post, #{
    security => [#{~"webhook_signature" => []}]  %% overrides it for this webhook
}).

Webhooks still inherit the global default when they declare nothing, which is what OpenAPI specifies — top-level security applies to every operation, webhooks included. This replaces the "per-operation security is not supported yet" limitation the first draft of this PR documented.

The change is two type lines, because generate_operation/2 already starts from the Doc map and copies it into the emitted operation. Adding security to endpoint_doc() without also adding it to openapi_operation() silently dropped it at encode time — the first attempt did exactly that and a test caught it — so a comment on endpoint_doc() now records that the two must stay in step.

Behaviour decisions

  • path and query parameters are rejected on webhooks, raising {parameter_location_not_supported_on_webhook, Name, In}. The consumer owns the URL, so there is nothing to template into and no query string this API controls. header and cookie are accepted.
  • Responses are optional, matching OpenAPI 3.1, where Operation.responses is not required.
  • One event name can carry several methods, since a webhook value is a Path Item Object exactly like a paths entry.
  • Schemas are shared with endpoints — a type used by both is emitted once into components/schemas.
  • paths stays required in openapi_spec(); a webhook-only spec emits "paths": {}, which is valid 3.1. A sibling document type with optional paths was considered and rejected: Erlang map types can't be composed, so every future root-level field (top-level tags and externalDocs are both queued in todo.md) would need editing in two places, with no compiler signal on a miss.

Tests

Eleven new EUnit tests: top-level placement, schema sharing between an endpoint and a webhook, several methods under one name, header parameters, path/query rejection, a responseless webhook, absence of the key without webhooks, a webhook overriding the global security requirement while the top-level default and an untouched endpoint keep theirs, an endpoint overriding it too, security => [] opting a webhook out, and full openapi-spec-validator 3.1 passes on webhook-bearing specs.

Where a test asserts something about OpenAPI 3.1 itself — "responses are optional", "an empty requirement clears the default" — it runs the validator rather than only checking shape, since the validator is the only thing that can falsify such a claim. Schema-sharing and opt-out assert against the really-serialized JSON so $ref and security are checked in the form a consumer resolves.

Verified locally on the merge head, on OTP 28 to match the current .tool-versions pin: rebar3 eunit (768 tests), rebar3 proper (9/9 properties), dialyzer, xref, hank, check_app_calls and fmt --check all clean. elp is not installed in this container, so type_check (eqwalizer) and lint run in CI, across the full OTP 27/28/29 matrix.

Kept current with main

main has moved twice while this sat open — 0.14.0, then 0.14.1 plus the OTP/rebar3/ELP pin bump — and both are merged in here.

Each time the only conflict was CHANGELOG.md: this PR's ## [Unreleased] entries against the newly released section. Both are kept, Unreleased on top; README.md auto-merged.

0.14.0 included #189, which changed schema generation so -spectra() doc annotations survive inlining into another schema. That plausibly reaches the output these tests assert on, so the whole suite was re-run against each merge rather than assumed — no assertion has needed a change.

Not in this PR

elli_openapi is pinned to spectra ~> 0.8.2, six minors behind, so it cannot consume this without a separate upgrade. Wrapper support follows in andreashasse/spectral#38 and andreashasse/phoenix_spectral#22.

Teach spectra_openapi to emit the OpenAPI 3.1 top-level `webhooks` key.

New builders:
- webhook/2,3 creates a webhook specification, keyed by an event name
  rather than a URL path, because the consumer owns the URL the API calls.
- to_openapi/4 generates a spec from both endpoints and webhooks.
  endpoints_to_openapi/2,3 are unchanged and delegate with no webhooks,
  so existing specs emit no `webhooks` key.

The direction is inverted relative to an endpoint: the request body is the
payload this API sends out, and the responses describe what the consumer is
expected to send back. add_response/2, with_request_body/3,4 and
with_parameter/3 accept webhooks as well as endpoints, so both build the
same way, and webhook schemas share components/schemas with endpoints -
a type used by both is emitted once.

with_parameter/3 raises {parameter_location_not_supported_on_webhook,
Name, In} for path and query parameters. The consumer owns the URL, so
there is nothing to template into and no query string this API controls;
emitting such a parameter would document something that can never be
satisfied.

Responses are optional, matching OpenAPI 3.1, and one event name can carry
several methods since a webhook value is a Path Item Object just like a
paths entry.

Known limitation, documented in the README: a global `security`
requirement is emitted at the top level and so applies to webhook
operations too, even though its meaning is inverted there. Per-operation
security is not supported yet.

Adds EUnit coverage for the top-level placement, schema sharing between an
endpoint and a webhook (asserted against the real serialized JSON so the
$ref is checked as a consumer would resolve it), several methods under one
name, header parameters, path/query rejection, responseless webhooks,
absence of the key without webhooks, and a full openapi-spec-validator
3.1 pass.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Erhgwu59rBxiYw1fVQGbcr

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new “webhook without responses” behavior is asserted but not validated against the same OpenAPI 3.1 validator used elsewhere, so compliance/tooling compatibility should be confirmed in tests.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds first-class OpenAPI 3.1 webhook generation to spectra_openapi, emitting the top-level webhooks object and enabling webhook specs to share the same builder pipeline and schema/components generation as regular endpoints.

Changes:

  • Added spectra_openapi:webhook/2,3 and spectra_openapi:to_openapi/4 (with endpoints_to_openapi/2,3 delegating to to_openapi/4 with no webhooks).
  • Widened builders (add_response/2, with_request_body/3,4, with_parameter/3) to work with both endpoints and webhooks, including webhook-specific parameter-location rejection.
  • Added EUnit coverage for webhook emission/shape, schema sharing, and related behaviors; updated docs and changelog/todo accordingly.
File summaries
File Description
src/spectra_openapi.erl Introduces webhook builders/types and extends OpenAPI generation to optionally include top-level webhooks.
test/openapi_json_test.erl Adds tests covering webhook serialization, schema sharing, multi-method webhooks, parameter rules, and validator pass for webhook-bearing specs.
README.md Documents new webhook API and generation entrypoint (to_openapi/4).
CHANGELOG.md Notes the new webhook support and related API additions.
todo.md Marks the OpenAPI 3.1 webhooks item as completed.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread test/openapi_json_test.erl Outdated
claude added 2 commits August 30, 2026 15:54
webhook_without_responses_test asserted only that the operation has no
responses key. But "responses are optional" is a claim about OpenAPI 3.1
itself, so the openapi-spec-validator is the thing that can falsify it -
without that step, a regression where a responseless operation stopped
validating would go unnoticed.

Confirmed the validator does accept it, so the behaviour was right; the
test just was not checking the part that matters.

Raised by Copilot's review on #188.
The Doc map given to endpoint/3 and webhook/3 now accepts a security key
holding a list of Security Requirement Objects. It is emitted on that
operation alone and overrides the global security default; security => []
opts the operation out of the global requirement entirely.

This is what lets an API authenticate inbound calls one way and sign its
outgoing webhooks another - previously a global requirement decorated
every webhook operation with no way to override it, which was documented
as a known limitation.

Webhooks still inherit the global default when they declare nothing, which
is what OpenAPI specifies: top-level security applies to every operation,
webhooks included.

The change is two type lines, because generate_operation/2 already starts
from the Doc map and copies it into the emitted operation. Adding security
to endpoint_doc() without also adding it to openapi_operation() would have
silently dropped it at encode time, so both were needed - a comment on
endpoint_doc() now records that the two must stay in step.

Adds EUnit coverage for a webhook overriding the global requirement while
the top-level default and an untouched endpoint keep theirs, an endpoint
overriding it too (this is a per-operation field, not a webhook-specific
one), and security => [] opting a webhook out, asserted against the real
serialized JSON and validated by openapi-spec-validator.
@andreashasse andreashasse changed the title Add OpenAPI 3.1 webhook support Add OpenAPI 3.1 webhook support and per-operation security Aug 30, 2026
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.

3 participants