fix(openapi): add type discriminator to DestinationUpdate union#920
Merged
leggetter merged 1 commit intoMay 28, 2026
Merged
Conversation
Trigger: the DestinationUpdate union lost its discriminator in 8be278c (May 2025), when `type` wasn't on PATCH. Server has since added `type` acceptance and RFC 7396 merge-patch (bd7701b), making the missing discriminator an active bug — Hookdeck's permissive `config: {}` variant greedily matched any partial-config payload, the SDK sent it through with no field remap (camelCase reached the server), and merge-patch silently no-op'd. All non-Webhook partial config updates affected. Spec: `type` becomes a required, enum-locked discriminator on every DestinationUpdate* variant; 17 new `*ConfigUpdate`/`*CredentialsUpdate` companions model the now-explicit partial PATCH shape; Hookdeck's `config: {}` removed. Impact: * Breaking for typed SDK consumers: `update()` must include `type` after regen. Server is more lenient (still accepts PATCH without `type`), so raw HTTP callers keep working but are spec-noncompliant. * SDK regen left to the bot — separate PR. CI compile-check on this PR will fail until that lands; tests were validated locally against a 1.4.1 regen. spec-sdk-tests (knock-on): `type` added to 46 update sites; paginator shape fixed (supersedes fix/spec-sdk-tests-paginator-shape); SDK retries enabled; cleanup parallelized; cleanup-error logging hardened. Test status (managed Outpost, local regen'd SDK): 133→152 passing, 14→0 failing. spec-sdk-tests have no CI today; follow-up PR will add one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
alexluong
approved these changes
May 28, 2026
This was referenced May 28, 2026
4 tasks
leggetter
added a commit
that referenced
this pull request
May 28, 2026
…verlay drop Trigger: #919 (merged into umbrella) drops the x-speakeasy-pagination overlay, so the TS SDK no longer wraps list responses in PageIterator. events.list()/tenants.list() return EventPaginatedResult/TenantPaginatedResult directly — accessor is `response.models`, not `response.result.models`. Tests in #920 had been adapted to the wrapper shape; this realigns them back to the flat shape post-regen. Local run after fresh TS SDK regen: 153 passing / 0 failing / 15 pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 tasks
alexluong
added a commit
that referenced
this pull request
May 29, 2026
…event.data oneOf (#924) * fix(openapi): add type discriminator to DestinationUpdate union (#920) Trigger: the DestinationUpdate union lost its discriminator in 8be278c (May 2025), when `type` wasn't on PATCH. Server has since added `type` acceptance and RFC 7396 merge-patch (bd7701b), making the missing discriminator an active bug — Hookdeck's permissive `config: {}` variant greedily matched any partial-config payload, the SDK sent it through with no field remap (camelCase reached the server), and merge-patch silently no-op'd. All non-Webhook partial config updates affected. Spec: `type` becomes a required, enum-locked discriminator on every DestinationUpdate* variant; 17 new `*ConfigUpdate`/`*CredentialsUpdate` companions model the now-explicit partial PATCH shape; Hookdeck's `config: {}` removed. Impact: * Breaking for typed SDK consumers: `update()` must include `type` after regen. Server is more lenient (still accepts PATCH without `type`), so raw HTTP callers keep working but are spec-noncompliant. * SDK regen left to the bot — separate PR. CI compile-check on this PR will fail until that lands; tests were validated locally against a 1.4.1 regen. spec-sdk-tests (knock-on): `type` added to 46 update sites; paginator shape fixed (supersedes fix/spec-sdk-tests-paginator-shape); SDK retries enabled; cleanup parallelized; cleanup-error logging hardened. Test status (managed Outpost, local regen'd SDK): 133→152 passing, 14→0 failing. spec-sdk-tests have no CI today; follow-up PR will add one. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(spec): expose event.data on Attempt when include=event.data (#917) * test(spec-sdk): fix paginator response shape (response.result.models) Speakeasy CLI 1.741.7 → 1.753.0 (commit 3f311e0, 2026-03-13) changed the generated TS SDK return type for list operations from the flat paginated body to a PageIterator wrapper: ListEventsResponse now wraps the body in `result`, so callers access `response.result.models` instead of `response.models`. The shape change was a side-effect of the Speakeasy version bump and wasn't surfaced in the SDK regen PR changelog (which only flagged the unrelated `request` query-param restyle). Tests in spec-sdk-tests/tests/{events,tenants}.test.ts still used the pre-bump accessor and failed to compile against the current SDK. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(spec-sdk): assert event.data is exposed when include=event.data Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(spec): order EventFull before EventSummary in Attempt.event oneOf Speakeasy's TS generator emits zod unions in declaration order. The non-strict EventSummary matched first and silently stripped event.data from the parsed attempt — losing the payload added by include=event.data. Declaring EventFull first lets it match when data is present, with EventSummary as the fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(sdks): drop x-speakeasy-pagination overlay from all SDK sources (#919) The shared pagination-config-overlay added x-speakeasy-pagination to list endpoints, which Speakeasy then converted into PageIterator wrappers (TS), res.Next() helpers (Go), and res.next() chaining (Python). In TS the wrapper changed the response shape to response.result.models, which is a noisy regression for the common single-page case. In Go and Python the helper saves one line at the cost of hiding which request params get carried across the cursor call. Drop the overlay from all three sources for symmetric, explicit DX: callers paginate manually using res.pagination.next on the flat response. SDK regeneration is intentionally left out of this commit so the workflow change can be reviewed in isolation. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(spec-sdk): revert paginator wrapper accessors after pagination overlay drop Trigger: #919 (merged into umbrella) drops the x-speakeasy-pagination overlay, so the TS SDK no longer wraps list responses in PageIterator. events.list()/tenants.list() return EventPaginatedResult/TenantPaginatedResult directly — accessor is `response.models`, not `response.result.models`. Tests in #920 had been adapted to the wrapper shape; this realigns them back to the flat shape post-regen. Local run after fresh TS SDK regen: 153 passing / 0 failing / 15 pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Alex Luong <alex.luong@hookdeck.com>
This was referenced May 29, 2026
leggetter
added a commit
that referenced
this pull request
May 29, 2026
… a local Outpost (#925) * fix(openapi): add type discriminator to DestinationUpdate union (#920) Trigger: the DestinationUpdate union lost its discriminator in 8be278c (May 2025), when `type` wasn't on PATCH. Server has since added `type` acceptance and RFC 7396 merge-patch (bd7701b), making the missing discriminator an active bug — Hookdeck's permissive `config: {}` variant greedily matched any partial-config payload, the SDK sent it through with no field remap (camelCase reached the server), and merge-patch silently no-op'd. All non-Webhook partial config updates affected. Spec: `type` becomes a required, enum-locked discriminator on every DestinationUpdate* variant; 17 new `*ConfigUpdate`/`*CredentialsUpdate` companions model the now-explicit partial PATCH shape; Hookdeck's `config: {}` removed. Impact: * Breaking for typed SDK consumers: `update()` must include `type` after regen. Server is more lenient (still accepts PATCH without `type`), so raw HTTP callers keep working but are spec-noncompliant. * SDK regen left to the bot — separate PR. CI compile-check on this PR will fail until that lands; tests were validated locally against a 1.4.1 regen. spec-sdk-tests (knock-on): `type` added to 46 update sites; paginator shape fixed (supersedes fix/spec-sdk-tests-paginator-shape); SDK retries enabled; cleanup parallelized; cleanup-error logging hardened. Test status (managed Outpost, local regen'd SDK): 133→152 passing, 14→0 failing. spec-sdk-tests have no CI today; follow-up PR will add one. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(spec): expose event.data on Attempt when include=event.data (#917) * test(spec-sdk): fix paginator response shape (response.result.models) Speakeasy CLI 1.741.7 → 1.753.0 (commit 3f311e0, 2026-03-13) changed the generated TS SDK return type for list operations from the flat paginated body to a PageIterator wrapper: ListEventsResponse now wraps the body in `result`, so callers access `response.result.models` instead of `response.models`. The shape change was a side-effect of the Speakeasy version bump and wasn't surfaced in the SDK regen PR changelog (which only flagged the unrelated `request` query-param restyle). Tests in spec-sdk-tests/tests/{events,tenants}.test.ts still used the pre-bump accessor and failed to compile against the current SDK. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(spec-sdk): assert event.data is exposed when include=event.data Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(spec): order EventFull before EventSummary in Attempt.event oneOf Speakeasy's TS generator emits zod unions in declaration order. The non-strict EventSummary matched first and silently stripped event.data from the parsed attempt — losing the payload added by include=event.data. Declaring EventFull first lets it match when data is present, with EventSummary as the fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(sdks): drop x-speakeasy-pagination overlay from all SDK sources (#919) The shared pagination-config-overlay added x-speakeasy-pagination to list endpoints, which Speakeasy then converted into PageIterator wrappers (TS), res.Next() helpers (Go), and res.next() chaining (Python). In TS the wrapper changed the response shape to response.result.models, which is a noisy regression for the common single-page case. In Go and Python the helper saves one line at the cost of hiding which request params get carried across the cursor call. Drop the overlay from all three sources for symmetric, explicit DX: callers paginate manually using res.pagination.next on the flat response. SDK regeneration is intentionally left out of this commit so the workflow change can be reviewed in isolation. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(spec-sdk): revert paginator wrapper accessors after pagination overlay drop Trigger: #919 (merged into umbrella) drops the x-speakeasy-pagination overlay, so the TS SDK no longer wraps list responses in PageIterator. events.list()/tenants.list() return EventPaginatedResult/TenantPaginatedResult directly — accessor is `response.models`, not `response.result.models`. Tests in #920 had been adapted to the wrapper shape; this realigns them back to the flat shape post-regen. Local run after fresh TS SDK regen: 153 passing / 0 failing / 15 pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(spec-sdk-tests): add workflow that runs the contract suite against a local Outpost Closes #921. Trigger: PRs touching the spec, TS SDK, Speakeasy config, handlers, destination providers, models, or server entry. Plus workflow_dispatch and a weekly cron as drift safety net. Job: spin up Postgres/Redis/RabbitMQ as service containers, build the outpost binary from source, run migrations, start api + delivery in background, wait for /healthz, build the TS SDK from this PR's spec state, then run `npm test` in spec-sdk-tests pointed at localhost. Notes: * Uses RabbitMQ for the message queue (default in .outpost.yaml.dev). * spec-sdk-tests/.env is written inline so we never need to commit a CI-specific .env to the repo. * Server logs are dumped on failure for debuggability. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(spec-sdk-tests): fix YAML — use block scalar for migrations run step The quoted-string form 'run: "$OUTPOST_BIN" migrate apply --yes' confuses the YAML parser (treats the leading quote as opening a scalar). Block-scalar form matches the other run: steps in the file. * ci(spec-sdk-tests): use outpost-server in singular mode, not the CLI wrapper The 'outpost' binary is just a CLI wrapper that delegates 'serve' to a separate 'outpost-server' binary. Running it without a subcommand prints help and exits — which is why /healthz never came up. Switch to building both binaries: 'outpost' for migrations, then 'outpost-server' for the actual server. Drop SERVICE=api / SERVICE=delivery in favour of singular mode (empty SERVICE → api + log + delivery in one process), which is enough for the contract tests and halves the moving parts. * ci(spec-sdk-tests): use npm install for spec-sdk-tests deps spec-sdk-tests/.gitignore excludes package-lock.json (intentional — the test suite isn't published, and treating SDK contract tests as production-locked dependencies adds noise to PRs without value). npm ci needs a lock file; switch to npm install. * ci(spec-sdk-tests): regen TS SDK from the PR's spec before running tests The whole point of this workflow is to validate that spec ↔ SDK ↔ server agree. If we test against the checked-in SDK src/ (which can lag the spec arbitrarily, since regen is bot-driven), we're testing the wrong thing — drift hides instead of surfacing. Add a Speakeasy regen step before the existing build. Now any PR that changes docs/apis/openapi.yaml gets its tests run against an SDK regen'd from that PR's spec, exercising the actual contract this CI exists to validate. Uses the SPEAKEASY_API_KEY secret already configured for the publish workflows. * ci(spec-sdk-tests): use redis-stack-server for RediSearch tenants.list requires RediSearch; vanilla redis:7-alpine returns 501 'list tenant feature is not enabled'. Swap to the official redis/redis-stack-server image which bundles RediSearch. Same port (6379), same redis-cli ping for health check. * ci(spec-sdk-tests): expand header with explicit scope and rationale Makes it clear at the top of the file what this workflow validates (PR's server vs PR's spec), what it does not (managed drift, SDK version compat, post-deploy smoke), and why we picked this shape over alternatives. Spares future maintainers from re-litigating the design decision. * ci(spec-sdk-tests): drop the weekly cron The cron was copied from the docs-eval pattern out of habit. In this workflow it adds essentially no signal — between PRs, main's state hasn't changed, so a scheduled run re-tests what we last tested. The "deploy drift" question a cron would help with is out of scope for this workflow anyway (it's about PR's spec vs PR's server, not managed vs main). * ci(spec-sdk-tests): add cmd/outpost-server/** to trigger paths Workflow builds and runs ./cmd/outpost-server but the path filter only watched cmd/outpost/** (the CLI wrapper). Changes to the actual server entrypoint could land without triggering the contract suite. Spotted by Copilot review on #925. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Alex Luong <alex.luong@hookdeck.com>
Merged
5 tasks
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.
Summary
Two distinct-but-intertwined bugs in destination PATCH, both rooted in the spec. This PR fixes both at the source.
Bug 1 — Wire-serialization (runtime, silent failure)
PATCH requests that try to update part of a non-Webhook destination's
configsilently fail. The API returns200 OK, but the destination's config is unchanged.Concretely — calling the TS SDK to change an AWS SQS destination's queue URL:
The TS-facing field is
queueUrl(camelCase), which the SQS outbound schema is supposed to remap toqueue_url(snake_case) on the wire. But the request actually goes out as{ "config": { "queueUrl": "..." } }— camelCase reaches the server. Merge-patch doesn't recognize the key, leavesqueue_urluntouched, and the response shows the old queue.Why: the
DestinationUpdateoneOfhas nodiscriminator(removed in8be278c5, May 2025, whentypewasn't yet accepted on PATCH). Without one, the SDK picks a union variant by structural shape-matching.DestinationUpdateHookdeck'sconfig: {}(intent: "no updatable config"; OpenAPI: "any value allowed") wins for every partial-config payload because it accepts anything. Hookdeck's variant has no field-name remap — that's the actual mis-serialization. Affects AWS SQS, S3, Kinesis, RabbitMQ, GCP Pub/Sub, Azure Service Bus, and Kafka partial-config updates.Bug 2 — Typing (compile-time, blocks correct usage)
Even fully correct payloads couldn't be expressed as partial updates. The spec referenced the full
*Config/*Credentialsschemas — which require their fields — from theDestinationUpdate*variants. So:The OpenAPI source even acknowledges the gap with inline comments like
# queue_url is required here, but PATCH means it's optional. The fix has been a TODO note in production.Fix
type(required, enum-locked) to everyDestinationUpdate*variant anddiscriminator: propertyName: typeon the union (mirrorsDestinationCreate) → resolves Bug 1.*ConfigUpdate/*CredentialsUpdatecompanion schemas (all-optional fields, RFC 7396 merge-patch documented) and reference these fromDestinationUpdate*variants → resolves Bug 2.config: {}fromDestinationUpdateHookdeck. Hookdeck has no updatable config;HookdeckCredentialsUpdatehandles partial credential updates.Server-side merge-patch landed in
bd7701b5(April 2026), so the runtime side is already correct — only the spec needed to catch up.TS SDK typing after regen
DestinationUpdatebecomes a true discriminated union —type: "aws_sqs"literal types narrowconfigto the matching*ConfigUpdate. Examples:IDE autocomplete on
config:now shows only fields valid for the chosentype.(Zod note: the regen emits
z.union([…]), notz.discriminatedUnion(…). Functionally equivalent here — every variant requirestype: z.literal("…"), so only the matching variant parses. Speakeasy's choice; not worth chasing in this PR.)Spec changes
typeadded to everyDestinationUpdate*variant: required, enum-locked, documented as immutable on PATCH.discriminator: propertyName: typeadded toDestinationUpdate.*ConfigUpdate/*CredentialsUpdatecompanion schemas — all fields optional, RFC 7396 merge-patch documented. Variants now reference these instead of the full*Config/*Credentials.config: {}removed fromDestinationUpdateHookdeck.Consumer impact
update()payloads must includetype: '<destination>'. Compile-error otherwise.typeat runtime — calls keep working but are spec-noncompliant. Server-side enforcement is a separate decision.What's NOT in this PR (deliberately)
.github/workflows/sdk_generation_outpost_*.yaml. Tests here were validated locally against a TS regen (1.4.1); CI compile-check on this PR will fail until the regen PR lands.type— separate decision.spec-sdk-tests— confirmed none exists today (verified across.github/workflowsand.speakeasy). Follow-up PR.spec-sdk-tests changes (knock-on)
type: '<destination>'inserted into 46updateDestinationpayloads across 9 files.response.result.models) inevents.test.ts/tenants.test.ts— supersedesfix/spec-sdk-tests-paginator-shape.utils/sdk-client.ts— managed-Outpost rate limits no longer flake tests.afterhook (15 destinations × serial DELETE was exceeding mocha's 10s hook timeout).console.warn('Failed to cleanup destination:', error)→ safeString(error)form in all 17 cleanup hooks (nodeutil.inspectwas crashing on SDK errors with getter-only descriptors).Test status
Against managed Outpost (
api.outpost.hookdeck.com), locally with regen'd TS SDK 1.4.1:The 15 pending are the same skipped tests as before (Hookdeck token verification, GCP Pub/Sub validation gaps — pre-existing backend limitations).
Also of note:
spec-sdk-tests/TEST_STATUS.mdhad attributed the AWS Kinesis partial-config update failure to a "backend bug." It's the same SDK-side dispatch issue this PR fixes — the Kinesis test should pass after regen and can be un-skipped in a follow-up.Test plan
swagger-cli validate) — re-run on final branch state.spec-sdk-testsrun against managed Outpost — re-run on final branch state: 152 passing / 0 failing / 15 pending.spec-sdk-testsCI workflow — tracked in Add CI workflow for spec-sdk-tests (SDK acceptance testing) #921.typeserver-side (follow-up).🤖 Generated with Claude Code