From bc0745027591fe64f10eb4da788deb131fd6b674 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 28 May 2026 12:04:50 -0500 Subject: [PATCH 01/13] fix(openapi): add type discriminator to DestinationUpdate union (#920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger: the DestinationUpdate union lost its discriminator in 8be278c5 (May 2025), when `type` wasn't on PATCH. Server has since added `type` acceptance and RFC 7396 merge-patch (bd7701b5), 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) --- docs/apis/openapi.yaml | 301 +++++++++++++++++- .../tests/destinations/aws-kinesis.test.ts | 8 +- .../tests/destinations/aws-s3.test.ts | 8 +- .../tests/destinations/aws-sqs.test.ts | 8 +- .../destinations/azure-servicebus.test.ts | 8 +- .../tests/destinations/gcp-pubsub.test.ts | 9 +- .../tests/destinations/hookdeck.test.ts | 7 +- .../tests/destinations/rabbitmq.test.ts | 8 +- .../destinations/webhook-merge-patch.test.ts | 26 +- .../tests/destinations/webhook.test.ts | 7 +- spec-sdk-tests/tests/events.test.ts | 8 +- spec-sdk-tests/tests/tenants.test.ts | 4 +- spec-sdk-tests/utils/sdk-client.ts | 10 + 13 files changed, 367 insertions(+), 45 deletions(-) diff --git a/docs/apis/openapi.yaml b/docs/apis/openapi.yaml index 0832890e4..5bfb6fea7 100644 --- a/docs/apis/openapi.yaml +++ b/docs/apis/openapi.yaml @@ -1909,8 +1909,25 @@ components: kafka: "#/components/schemas/DestinationCreateKafka" # Type-Specific Destination Update Schemas (for Request Bodies) + # Type-Specific Partial Schemas for PATCH Request Bodies + # All fields are optional — RFC 7396 JSON merge-patch semantics apply: + # omit a field to leave it unchanged; include a field to update it in place. + WebhookConfigUpdate: + type: object + description: Partial Webhook config for PATCH updates (RFC 7396 merge-patch). + properties: + url: + type: string + format: url + description: The URL to send the webhook events to. + example: "https://example.com/webhooks/user" + custom_headers: + type: string + description: JSON string of custom HTTP headers to include with every webhook request. + example: '{"x-api-key":"secret123","x-tenant-id":"customer-456"}' WebhookCredentialsUpdate: type: object + description: Partial Webhook credentials for PATCH updates (RFC 7396 merge-patch). properties: secret: type: string @@ -1925,17 +1942,207 @@ components: rotate_secret: type: boolean description: Set to true to rotate the secret. The current secret becomes the previous_secret, and a new secret is generated. `previous_secret_invalid_at` defaults to 24h if not provided. + AWSSQSConfigUpdate: + type: object + description: Partial AWS SQS config for PATCH updates (RFC 7396 merge-patch). + properties: + endpoint: + type: string + format: url + description: Optional. Custom AWS endpoint URL (e.g., for LocalStack or specific regions). + example: "https://sqs.us-east-1.amazonaws.com" + queue_url: + type: string + format: url + description: The URL of the SQS queue. + example: "https://sqs.us-east-1.amazonaws.com/123456789012/my-queue" + AWSSQSCredentialsUpdate: + type: object + description: Partial AWS SQS credentials for PATCH updates (RFC 7396 merge-patch). + properties: + key: + type: string + description: AWS Access Key ID. + secret: + type: string + description: AWS Secret Access Key. + session: + type: string + description: Optional AWS Session Token (for temporary credentials). + RabbitMQConfigUpdate: + type: object + description: Partial RabbitMQ config for PATCH updates (RFC 7396 merge-patch). + properties: + server_url: + type: string + description: RabbitMQ server address (host:port). + exchange: + type: string + description: The exchange to publish messages to. + tls: + type: string + enum: ["true", "false"] + description: Whether to use TLS connection (amqps). + RabbitMQCredentialsUpdate: + type: object + description: Partial RabbitMQ credentials for PATCH updates (RFC 7396 merge-patch). + properties: + username: + type: string + description: RabbitMQ username. + password: + type: string + description: RabbitMQ password. + HookdeckCredentialsUpdate: + type: object + description: Partial Hookdeck credentials for PATCH updates (RFC 7396 merge-patch). + properties: + token: + type: string + description: Hookdeck authentication token. + AWSKinesisConfigUpdate: + type: object + description: Partial AWS Kinesis config for PATCH updates (RFC 7396 merge-patch). + properties: + stream_name: + type: string + description: The name of the AWS Kinesis stream. + region: + type: string + description: The AWS region where the Kinesis stream is located. + endpoint: + type: string + format: url + description: Optional. Custom AWS endpoint URL (e.g., for LocalStack or VPC endpoints). + partition_key_template: + type: string + description: Optional. JMESPath template to extract the partition key from the event payload. + AWSKinesisCredentialsUpdate: + type: object + description: Partial AWS Kinesis credentials for PATCH updates (RFC 7396 merge-patch). + properties: + key: + type: string + description: AWS Access Key ID. + secret: + type: string + description: AWS Secret Access Key. + session: + type: string + description: Optional AWS Session Token (for temporary credentials). + AzureServiceBusConfigUpdate: + type: object + description: Partial Azure Service Bus config for PATCH updates (RFC 7396 merge-patch). + properties: + name: + type: string + description: The name of the Azure Service Bus queue or topic to publish messages to. + AzureServiceBusCredentialsUpdate: + type: object + description: Partial Azure Service Bus credentials for PATCH updates (RFC 7396 merge-patch). + properties: + connection_string: + type: string + description: The connection string for the Azure Service Bus namespace. + AWSS3ConfigUpdate: + type: object + description: Partial AWS S3 config for PATCH updates (RFC 7396 merge-patch). + properties: + bucket: + type: string + description: The name of your AWS S3 bucket. + region: + type: string + pattern: "^[a-z]{2}-[a-z]+-[0-9]+$" + description: The AWS region where your bucket is located. + key_template: + type: string + description: JMESPath expression for generating S3 object keys. + storage_class: + type: string + description: The storage class for the S3 objects. + AWSS3CredentialsUpdate: + type: object + description: Partial AWS S3 credentials for PATCH updates (RFC 7396 merge-patch). + properties: + key: + type: string + description: AWS Access Key ID. + secret: + type: string + description: AWS Secret Access Key. + session: + type: string + description: Optional AWS Session Token (for temporary credentials). + GCPPubSubConfigUpdate: + type: object + description: Partial GCP Pub/Sub config for PATCH updates (RFC 7396 merge-patch). + properties: + project_id: + type: string + description: The GCP project ID. + topic: + type: string + description: The Pub/Sub topic name. + endpoint: + type: string + description: Optional. Custom endpoint URL (e.g., localhost:8085 for emulator). + GCPPubSubCredentialsUpdate: + type: object + description: Partial GCP Pub/Sub credentials for PATCH updates (RFC 7396 merge-patch). + properties: + service_account_json: + type: string + description: Service account key JSON. The entire JSON key file content as a string. + KafkaConfigUpdate: + type: object + description: Partial Kafka config for PATCH updates (RFC 7396 merge-patch). + properties: + brokers: + type: string + description: Comma-separated list of Kafka broker addresses. + topic: + type: string + description: The Kafka topic to publish messages to. + sasl_mechanism: + type: string + enum: [plain, scram-sha-256, scram-sha-512] + description: SASL authentication mechanism. + tls: + type: string + enum: ["true", "false"] + description: Whether to enable TLS for the connection. + partition_key_template: + type: string + description: Optional JMESPath template to extract the partition key from the event payload. + KafkaCredentialsUpdate: + type: object + description: Partial Kafka credentials for PATCH updates (RFC 7396 merge-patch). + properties: + username: + type: string + description: SASL username for authentication. + password: + type: string + description: SASL password for authentication. + DestinationUpdateWebhook: type: object x-docs-type: "Webhook" # Properties duplicated from DestinationUpdateBase + required: [type] properties: + type: + type: string + enum: [webhook] + description: Destination type discriminator. Must equal the existing destination's type — type itself cannot be changed via PATCH. + example: "webhook" topics: $ref: "#/components/schemas/Topics" filter: $ref: "#/components/schemas/Filter" config: - $ref: "#/components/schemas/WebhookConfig" # URL is required here, but PATCH means it's optional in the request + $ref: "#/components/schemas/WebhookConfigUpdate" credentials: $ref: "#/components/schemas/WebhookCredentialsUpdate" delivery_metadata: @@ -1977,15 +2184,21 @@ components: type: object x-docs-type: "AWS SQS" # Properties duplicated from DestinationUpdateBase + required: [type] properties: + type: + type: string + enum: [aws_sqs] + description: Destination type discriminator. Must equal the existing destination's type — type itself cannot be changed via PATCH. + example: "aws_sqs" topics: $ref: "#/components/schemas/Topics" filter: $ref: "#/components/schemas/Filter" config: - $ref: "#/components/schemas/AWSSQSConfig" # queue_url is required here, but PATCH means it's optional + $ref: "#/components/schemas/AWSSQSConfigUpdate" credentials: - $ref: "#/components/schemas/AWSSQSCredentials" # key/secret required here, but PATCH means optional + $ref: "#/components/schemas/AWSSQSCredentialsUpdate" delivery_metadata: type: object additionalProperties: @@ -2025,15 +2238,21 @@ components: type: object x-docs-type: "RabbitMQ" # Properties duplicated from DestinationUpdateBase + required: [type] properties: + type: + type: string + enum: [rabbitmq] + description: Destination type discriminator. Must equal the existing destination's type — type itself cannot be changed via PATCH. + example: "rabbitmq" topics: $ref: "#/components/schemas/Topics" filter: $ref: "#/components/schemas/Filter" config: - $ref: "#/components/schemas/RabbitMQConfig" # server_url/exchange required here, but PATCH means optional + $ref: "#/components/schemas/RabbitMQConfigUpdate" credentials: - $ref: "#/components/schemas/RabbitMQCredentials" # username/password required here, but PATCH means optional + $ref: "#/components/schemas/RabbitMQCredentialsUpdate" delivery_metadata: type: object additionalProperties: @@ -2073,14 +2292,20 @@ components: type: object x-docs-type: "Hookdeck Event Gateway" # Properties duplicated from DestinationUpdateBase + # Hookdeck has no updatable `config`. + required: [type] properties: + type: + type: string + enum: [hookdeck] + description: Destination type discriminator. Must equal the existing destination's type — type itself cannot be changed via PATCH. + example: "hookdeck" topics: $ref: "#/components/schemas/Topics" filter: $ref: "#/components/schemas/Filter" - config: {} # Empty config, cannot be updated credentials: - $ref: "#/components/schemas/HookdeckCredentials" # token required here, but PATCH means optional + $ref: "#/components/schemas/HookdeckCredentialsUpdate" delivery_metadata: type: object additionalProperties: @@ -2120,15 +2345,21 @@ components: type: object x-docs-type: "AWS Kinesis" # Properties duplicated from DestinationUpdateBase + required: [type] properties: + type: + type: string + enum: [aws_kinesis] + description: Destination type discriminator. Must equal the existing destination's type — type itself cannot be changed via PATCH. + example: "aws_kinesis" topics: $ref: "#/components/schemas/Topics" filter: $ref: "#/components/schemas/Filter" config: - $ref: "#/components/schemas/AWSKinesisConfig" # stream_name/region required here, but PATCH means optional + $ref: "#/components/schemas/AWSKinesisConfigUpdate" credentials: - $ref: "#/components/schemas/AWSKinesisCredentials" # key/secret required here, but PATCH means optional + $ref: "#/components/schemas/AWSKinesisCredentialsUpdate" delivery_metadata: type: object additionalProperties: @@ -2168,15 +2399,21 @@ components: type: object x-docs-type: "Azure Service Bus" # Properties duplicated from DestinationUpdateBase + required: [type] properties: + type: + type: string + enum: [azure_servicebus] + description: Destination type discriminator. Must equal the existing destination's type — type itself cannot be changed via PATCH. + example: "azure_servicebus" topics: $ref: "#/components/schemas/Topics" filter: $ref: "#/components/schemas/Filter" config: - $ref: "#/components/schemas/AzureServiceBusConfig" # name required here, but PATCH means optional + $ref: "#/components/schemas/AzureServiceBusConfigUpdate" credentials: - $ref: "#/components/schemas/AzureServiceBusCredentials" # connection_string required here, but PATCH means optional + $ref: "#/components/schemas/AzureServiceBusCredentialsUpdate" delivery_metadata: type: object additionalProperties: @@ -2217,15 +2454,21 @@ components: type: object x-docs-type: "AWS S3" # Properties duplicated from DestinationUpdateBase + required: [type] properties: + type: + type: string + enum: [aws_s3] + description: Destination type discriminator. Must equal the existing destination's type — type itself cannot be changed via PATCH. + example: "aws_s3" topics: $ref: "#/components/schemas/Topics" filter: $ref: "#/components/schemas/Filter" config: - $ref: "#/components/schemas/AWSS3Config" # bucket/region required here, but PATCH means optional + $ref: "#/components/schemas/AWSS3ConfigUpdate" credentials: - $ref: "#/components/schemas/AWSS3Credentials" # key/secret required here, but PATCH means optional + $ref: "#/components/schemas/AWSS3CredentialsUpdate" delivery_metadata: type: object additionalProperties: @@ -2265,15 +2508,21 @@ components: type: object x-docs-type: "GCP PubSub" # Properties duplicated from DestinationUpdateBase + required: [type] properties: + type: + type: string + enum: [gcp_pubsub] + description: Destination type discriminator. Must equal the existing destination's type — type itself cannot be changed via PATCH. + example: "gcp_pubsub" topics: $ref: "#/components/schemas/Topics" filter: $ref: "#/components/schemas/Filter" config: - $ref: "#/components/schemas/GCPPubSubConfig" # project_id/topic required here, but PATCH means optional + $ref: "#/components/schemas/GCPPubSubConfigUpdate" credentials: - $ref: "#/components/schemas/GCPPubSubCredentials" # service_account_json required here, but PATCH means optional + $ref: "#/components/schemas/GCPPubSubCredentialsUpdate" delivery_metadata: type: object additionalProperties: @@ -2313,15 +2562,21 @@ components: type: object x-docs-type: "Apache Kafka" # Properties duplicated from DestinationUpdateBase + required: [type] properties: + type: + type: string + enum: [kafka] + description: Destination type discriminator. Must equal the existing destination's type — type itself cannot be changed via PATCH. + example: "kafka" topics: $ref: "#/components/schemas/Topics" filter: $ref: "#/components/schemas/Filter" config: - $ref: "#/components/schemas/KafkaConfig" # brokers/topic/sasl_mechanism required here, but PATCH means optional + $ref: "#/components/schemas/KafkaConfigUpdate" credentials: - $ref: "#/components/schemas/KafkaCredentials" # username/password required here, but PATCH means optional + $ref: "#/components/schemas/KafkaCredentialsUpdate" delivery_metadata: type: object additionalProperties: @@ -2370,6 +2625,18 @@ components: - $ref: "#/components/schemas/DestinationUpdateGCPPubSub" - $ref: "#/components/schemas/DestinationUpdateRabbitMQ" - $ref: "#/components/schemas/DestinationUpdateKafka" + discriminator: + propertyName: type + mapping: + webhook: "#/components/schemas/DestinationUpdateWebhook" + aws_sqs: "#/components/schemas/DestinationUpdateAWSSQS" + rabbitmq: "#/components/schemas/DestinationUpdateRabbitMQ" + hookdeck: "#/components/schemas/DestinationUpdateHookdeck" + aws_kinesis: "#/components/schemas/DestinationUpdateAWSKinesis" + azure_servicebus: "#/components/schemas/DestinationUpdateAzureServiceBus" + aws_s3: "#/components/schemas/DestinationUpdateAWSS3" + gcp_pubsub: "#/components/schemas/DestinationUpdateGCPPubSub" + kafka: "#/components/schemas/DestinationUpdateKafka" # Event Schemas PublishRequest: type: object diff --git a/spec-sdk-tests/tests/destinations/aws-kinesis.test.ts b/spec-sdk-tests/tests/destinations/aws-kinesis.test.ts index 949d59ff9..07717fca6 100644 --- a/spec-sdk-tests/tests/destinations/aws-kinesis.test.ts +++ b/spec-sdk-tests/tests/destinations/aws-kinesis.test.ts @@ -246,7 +246,7 @@ describe('AWS Kinesis Destinations - Contract Tests (SDK-based validation)', () try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); @@ -321,12 +321,13 @@ describe('AWS Kinesis Destinations - Contract Tests (SDK-based validation)', () try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); it('should update destination topics', async () => { const updated = await client.updateDestination(destinationId, { + type: 'aws_kinesis', topics: ['user.created', 'user.updated'], }); @@ -341,6 +342,7 @@ describe('AWS Kinesis Destinations - Contract Tests (SDK-based validation)', () // See TEST_STATUS.md for detailed analysis it.skip('should update destination config', async () => { const updated = await client.updateDestination(destinationId, { + type: 'aws_kinesis', config: { streamName: 'updated-stream', }, @@ -355,6 +357,7 @@ describe('AWS Kinesis Destinations - Contract Tests (SDK-based validation)', () it('should update destination credentials', async () => { const updated = await client.updateDestination(destinationId, { + type: 'aws_kinesis', credentials: { key: 'AKIAIOSFODNN7UPDATED', secret: 'updatedSecretKey', @@ -368,6 +371,7 @@ describe('AWS Kinesis Destinations - Contract Tests (SDK-based validation)', () let errorThrown = false; try { await client.updateDestination('non-existent-id-12345', { + type: 'aws_kinesis', topics: ['test'], }); } catch (error: any) { diff --git a/spec-sdk-tests/tests/destinations/aws-s3.test.ts b/spec-sdk-tests/tests/destinations/aws-s3.test.ts index f4aa62c2d..64bfe8e6f 100644 --- a/spec-sdk-tests/tests/destinations/aws-s3.test.ts +++ b/spec-sdk-tests/tests/destinations/aws-s3.test.ts @@ -246,7 +246,7 @@ describe('AWS S3 Destinations - Contract Tests (SDK-based validation)', () => { try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); @@ -321,12 +321,13 @@ describe('AWS S3 Destinations - Contract Tests (SDK-based validation)', () => { try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); it('should update destination topics', async () => { const updated = await client.updateDestination(destinationId, { + type: 'aws_s3', topics: ['user.created', 'user.updated'], }); @@ -338,6 +339,7 @@ describe('AWS S3 Destinations - Contract Tests (SDK-based validation)', () => { it('should update destination config', async () => { const updated = await client.updateDestination(destinationId, { + type: 'aws_s3', config: { bucket: 'updated-bucket', }, @@ -352,6 +354,7 @@ describe('AWS S3 Destinations - Contract Tests (SDK-based validation)', () => { it('should update destination credentials', async () => { const updated = await client.updateDestination(destinationId, { + type: 'aws_s3', credentials: { key: 'AKIAIOSFODNN7UPDATED', secret: 'updatedSecretKey', @@ -365,6 +368,7 @@ describe('AWS S3 Destinations - Contract Tests (SDK-based validation)', () => { let errorThrown = false; try { await client.updateDestination('non-existent-id-12345', { + type: 'aws_s3', topics: ['test'], }); } catch (error: any) { diff --git a/spec-sdk-tests/tests/destinations/aws-sqs.test.ts b/spec-sdk-tests/tests/destinations/aws-sqs.test.ts index c2631551e..84769d799 100644 --- a/spec-sdk-tests/tests/destinations/aws-sqs.test.ts +++ b/spec-sdk-tests/tests/destinations/aws-sqs.test.ts @@ -213,7 +213,7 @@ describe('AWS SQS Destinations - Contract Tests (SDK-based validation)', () => { try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); @@ -286,12 +286,13 @@ describe('AWS SQS Destinations - Contract Tests (SDK-based validation)', () => { try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); it('should update destination topics', async () => { const updated = await client.updateDestination(destinationId, { + type: 'aws_sqs', topics: ['user.created', 'user.updated'], }); @@ -303,6 +304,7 @@ describe('AWS SQS Destinations - Contract Tests (SDK-based validation)', () => { it('should update destination config', async () => { const updated = await client.updateDestination(destinationId, { + type: 'aws_sqs', config: { queueUrl: 'https://sqs.us-west-2.amazonaws.com/123456789012/updated-queue', }, @@ -319,6 +321,7 @@ describe('AWS SQS Destinations - Contract Tests (SDK-based validation)', () => { it('should update destination credentials', async () => { const updated = await client.updateDestination(destinationId, { + type: 'aws_sqs', credentials: { key: 'AKIAIOSFODNN7UPDATED', secret: 'updatedSecretKey', @@ -332,6 +335,7 @@ describe('AWS SQS Destinations - Contract Tests (SDK-based validation)', () => { let errorThrown = false; try { await client.updateDestination('non-existent-id-12345', { + type: 'aws_sqs', topics: ['test'], }); } catch (error: any) { diff --git a/spec-sdk-tests/tests/destinations/azure-servicebus.test.ts b/spec-sdk-tests/tests/destinations/azure-servicebus.test.ts index 75b13ab4d..8fb13c675 100644 --- a/spec-sdk-tests/tests/destinations/azure-servicebus.test.ts +++ b/spec-sdk-tests/tests/destinations/azure-servicebus.test.ts @@ -213,7 +213,7 @@ describe('Azure Service Bus Destinations - Contract Tests (SDK-based validation) try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); @@ -286,12 +286,13 @@ describe('Azure Service Bus Destinations - Contract Tests (SDK-based validation) try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); it('should update destination topics', async () => { const updated = await client.updateDestination(destinationId, { + type: 'azure_servicebus', topics: ['user.created', 'user.updated'], }); @@ -303,6 +304,7 @@ describe('Azure Service Bus Destinations - Contract Tests (SDK-based validation) it('should update destination config', async () => { const updated = await client.updateDestination(destinationId, { + type: 'azure_servicebus', config: { name: 'updated-queue', }, @@ -317,6 +319,7 @@ describe('Azure Service Bus Destinations - Contract Tests (SDK-based validation) it('should update destination credentials', async () => { const updated = await client.updateDestination(destinationId, { + type: 'azure_servicebus', credentials: { connectionString: 'Endpoint=sb://updated.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=updatedkey', @@ -330,6 +333,7 @@ describe('Azure Service Bus Destinations - Contract Tests (SDK-based validation) let errorThrown = false; try { await client.updateDestination('non-existent-id-12345', { + type: 'azure_servicebus', topics: ['test'], }); } catch (error: any) { diff --git a/spec-sdk-tests/tests/destinations/gcp-pubsub.test.ts b/spec-sdk-tests/tests/destinations/gcp-pubsub.test.ts index 815d94d1c..1dc1884df 100644 --- a/spec-sdk-tests/tests/destinations/gcp-pubsub.test.ts +++ b/spec-sdk-tests/tests/destinations/gcp-pubsub.test.ts @@ -330,7 +330,7 @@ describe('GCP Pub/Sub Destinations - Contract Tests (SDK-based validation)', () try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); @@ -458,12 +458,13 @@ describe('GCP Pub/Sub Destinations - Contract Tests (SDK-based validation)', () try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); it('should update destination topics', async () => { const updated = await client.updateDestination(destinationId, { + type: 'gcp_pubsub', topics: ['user.created', 'user.updated'], }); @@ -475,6 +476,7 @@ describe('GCP Pub/Sub Destinations - Contract Tests (SDK-based validation)', () it('should update destination config', async () => { const updated = await client.updateDestination(destinationId, { + type: 'gcp_pubsub', config: { topic: 'updated-topic-name', }, @@ -489,6 +491,7 @@ describe('GCP Pub/Sub Destinations - Contract Tests (SDK-based validation)', () it('should update destination credentials', async () => { const updated = await client.updateDestination(destinationId, { + type: 'gcp_pubsub', credentials: { serviceAccountJson: '{"type":"service_account","projectId":"updated"}', }, @@ -501,6 +504,7 @@ describe('GCP Pub/Sub Destinations - Contract Tests (SDK-based validation)', () let errorThrown = false; try { await client.updateDestination('non-existent-id-12345', { + type: 'gcp_pubsub', topics: '*', }); } catch (error: any) { @@ -522,6 +526,7 @@ describe('GCP Pub/Sub Destinations - Contract Tests (SDK-based validation)', () let errorThrown = false; try { await client.updateDestination(destinationId, { + type: 'gcp_pubsub', config: { // Missing required fields }, diff --git a/spec-sdk-tests/tests/destinations/hookdeck.test.ts b/spec-sdk-tests/tests/destinations/hookdeck.test.ts index 38bc6b2c5..9c859dab9 100644 --- a/spec-sdk-tests/tests/destinations/hookdeck.test.ts +++ b/spec-sdk-tests/tests/destinations/hookdeck.test.ts @@ -177,7 +177,7 @@ describe('Hookdeck Destinations - Contract Tests (SDK-based validation)', () => try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); @@ -250,12 +250,13 @@ describe('Hookdeck Destinations - Contract Tests (SDK-based validation)', () => try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); it('should update destination topics', async () => { const updated = await client.updateDestination(destinationId, { + type: 'hookdeck', topics: ['user.created', 'user.updated'], }); @@ -267,6 +268,7 @@ describe('Hookdeck Destinations - Contract Tests (SDK-based validation)', () => it('should update destination credentials', async () => { const updated = await client.updateDestination(destinationId, { + type: 'hookdeck', credentials: { token: 'hk_updated_token', }, @@ -279,6 +281,7 @@ describe('Hookdeck Destinations - Contract Tests (SDK-based validation)', () => let errorThrown = false; try { await client.updateDestination('non-existent-id-12345', { + type: 'hookdeck', topics: ['test'], }); } catch (error: any) { diff --git a/spec-sdk-tests/tests/destinations/rabbitmq.test.ts b/spec-sdk-tests/tests/destinations/rabbitmq.test.ts index 88d355a27..e0be73829 100644 --- a/spec-sdk-tests/tests/destinations/rabbitmq.test.ts +++ b/spec-sdk-tests/tests/destinations/rabbitmq.test.ts @@ -246,7 +246,7 @@ describe('RabbitMQ Destinations - Contract Tests (SDK-based validation)', () => try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); @@ -321,12 +321,13 @@ describe('RabbitMQ Destinations - Contract Tests (SDK-based validation)', () => try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); it('should update destination topics', async () => { const updated = await client.updateDestination(destinationId, { + type: 'rabbitmq', topics: ['user.created', 'user.updated'], }); @@ -338,6 +339,7 @@ describe('RabbitMQ Destinations - Contract Tests (SDK-based validation)', () => it('should update destination config', async () => { const updated = await client.updateDestination(destinationId, { + type: 'rabbitmq', config: { exchange: 'updated-exchange', }, @@ -352,6 +354,7 @@ describe('RabbitMQ Destinations - Contract Tests (SDK-based validation)', () => it('should update destination credentials', async () => { const updated = await client.updateDestination(destinationId, { + type: 'rabbitmq', credentials: { username: 'newuser', password: 'newpass', @@ -365,6 +368,7 @@ describe('RabbitMQ Destinations - Contract Tests (SDK-based validation)', () => let errorThrown = false; try { await client.updateDestination('non-existent-id-12345', { + type: 'rabbitmq', topics: ['test'], }); } catch (error: any) { diff --git a/spec-sdk-tests/tests/destinations/webhook-merge-patch.test.ts b/spec-sdk-tests/tests/destinations/webhook-merge-patch.test.ts index 40339334f..fbd5a4c39 100644 --- a/spec-sdk-tests/tests/destinations/webhook-merge-patch.test.ts +++ b/spec-sdk-tests/tests/destinations/webhook-merge-patch.test.ts @@ -18,12 +18,11 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { } }); - after(async () => { - for (const id of createdDestinations) { - try { - await client.deleteDestination(id); - } catch {} - } + after(async function () { + this.timeout(30000); + await Promise.all( + createdDestinations.map((id) => client.deleteDestination(id).catch(() => {})) + ); try { await client.deleteTenant(); } catch {} @@ -42,6 +41,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { const id = await createDest({ metadata: { env: 'prod' } }); const updated = await client.updateDestination(id, { + type: 'webhook', metadata: { env: 'prod', team: 'platform' }, }); @@ -52,6 +52,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { const id = await createDest({ metadata: { env: 'prod' } }); const updated = await client.updateDestination(id, { + type: 'webhook', metadata: { env: 'staging' }, }); @@ -62,6 +63,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { const id = await createDest({ metadata: { env: 'prod', region: 'us-east-1' } }); const updated = await client.updateDestination(id, { + type: 'webhook', metadata: { env: 'prod', region: null }, }); @@ -73,6 +75,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { const id = await createDest({ metadata: { env: 'prod' } }); const updated = await client.updateDestination(id, { + type: 'webhook', metadata: null, }); @@ -87,6 +90,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { const id = await createDest({ metadata: { env: 'prod' } }); const updated = await client.updateDestination(id, { + type: 'webhook', metadata: {}, }); @@ -97,6 +101,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { const id = await createDest({ metadata: { env: 'prod' } }); const updated = await client.updateDestination(id, { + type: 'webhook', topics: ['*'], }); @@ -109,6 +114,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { }); const updated = await client.updateDestination(id, { + type: 'webhook', metadata: { keep: 'v', remove: null, update: 'new', add: 'v' }, }); @@ -124,6 +130,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { const id = await createDest({ deliveryMetadata: { source: 'outpost' } }); const updated = await client.updateDestination(id, { + type: 'webhook', deliveryMetadata: { source: 'outpost', version: '1.0' }, }); @@ -136,6 +143,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { }); const updated = await client.updateDestination(id, { + type: 'webhook', deliveryMetadata: { source: 'outpost', version: null }, }); @@ -146,6 +154,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { const id = await createDest({ deliveryMetadata: { source: 'outpost' } }); const updated = await client.updateDestination(id, { + type: 'webhook', deliveryMetadata: null, }); @@ -161,6 +170,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { const id = await createDest({ deliveryMetadata: { source: 'outpost' } }); const updated = await client.updateDestination(id, { + type: 'webhook', deliveryMetadata: {}, }); @@ -177,6 +187,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { }); const updated = await client.updateDestination(id, { + type: 'webhook', filter: { body: { status: 'active' } }, }); @@ -189,6 +200,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { }); const updated = await client.updateDestination(id, { + type: 'webhook', filter: {}, }); @@ -205,6 +217,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { }); const updated = await client.updateDestination(id, { + type: 'webhook', filter: null, }); @@ -221,6 +234,7 @@ describe('Webhook Destinations - Merge-Patch Semantics (RFC 7396)', () => { }); const updated = await client.updateDestination(id, { + type: 'webhook', topics: ['*'], }); diff --git a/spec-sdk-tests/tests/destinations/webhook.test.ts b/spec-sdk-tests/tests/destinations/webhook.test.ts index 2828cc580..0dad9a3d1 100644 --- a/spec-sdk-tests/tests/destinations/webhook.test.ts +++ b/spec-sdk-tests/tests/destinations/webhook.test.ts @@ -180,7 +180,7 @@ describe('Webhook Destinations - Contract Tests (SDK-based validation)', () => { try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); @@ -253,12 +253,13 @@ describe('Webhook Destinations - Contract Tests (SDK-based validation)', () => { try { await client.deleteDestination(destinationId); } catch (error) { - console.warn('Failed to cleanup destination:', error); + console.warn('Failed to cleanup destination:', error instanceof Error ? error.message : String(error)); } }); it('should update destination topics', async () => { const updated = await client.updateDestination(destinationId, { + type: 'webhook', topics: ['user.created', 'user.updated'], }); @@ -270,6 +271,7 @@ describe('Webhook Destinations - Contract Tests (SDK-based validation)', () => { it('should update destination config', async () => { const updated = await client.updateDestination(destinationId, { + type: 'webhook', config: { url: 'https://updated.example.com/webhook', }, @@ -286,6 +288,7 @@ describe('Webhook Destinations - Contract Tests (SDK-based validation)', () => { let errorThrown = false; try { await client.updateDestination('non-existent-id-12345', { + type: 'webhook', topics: ['test'], }); } catch (error: any) { diff --git a/spec-sdk-tests/tests/events.test.ts b/spec-sdk-tests/tests/events.test.ts index 4868fb1d9..b101e3cb9 100644 --- a/spec-sdk-tests/tests/events.test.ts +++ b/spec-sdk-tests/tests/events.test.ts @@ -168,7 +168,7 @@ describe('Events (PR #491)', () => { limit: 5, }); expect(response).to.not.be.undefined; - expect(response?.models).to.be.an('array'); + expect(response?.result?.models).to.be.an('array'); }); it('should list events by tenant', async function () { @@ -191,7 +191,7 @@ describe('Events (PR #491)', () => { const response = await sdk.events.list({ tenantId: client.getTenantId(), }); - return response?.models || []; + return response?.result?.models || []; }, 30000, 5000 @@ -209,7 +209,7 @@ describe('Events (PR #491)', () => { const response = await sdk.events.list({ tenantId: client.getTenantId(), }); - const events = response?.models || []; + const events = response?.result?.models || []; if (events.length === 0) { console.warn('No events found - skipping single event test'); @@ -253,7 +253,7 @@ describe('Events (PR #491)', () => { destinationId: destinationId, eventId, }); - return response?.models ?? []; + return response?.result?.models ?? []; }, 45000, 5000 diff --git a/spec-sdk-tests/tests/tenants.test.ts b/spec-sdk-tests/tests/tenants.test.ts index cb04cef57..c44c2f41d 100644 --- a/spec-sdk-tests/tests/tenants.test.ts +++ b/spec-sdk-tests/tests/tenants.test.ts @@ -19,8 +19,8 @@ describe('Tenants - List with request object', () => { const result = await sdk.tenants.list({ limit: 5 }); expect(result).to.not.be.undefined; - expect(result?.models).to.be.an('array'); - (result?.models ?? []).forEach((t: { id?: string }, i: number) => { + expect(result?.result?.models).to.be.an('array'); + (result?.result?.models ?? []).forEach((t: { id?: string }, i: number) => { expect(t, `tenant[${i}]`).to.be.an('object'); if (t.id != null) expect(t.id, `tenant[${i}].id`).to.be.a('string'); }); diff --git a/spec-sdk-tests/utils/sdk-client.ts b/spec-sdk-tests/utils/sdk-client.ts index 85199a191..0e959f469 100644 --- a/spec-sdk-tests/utils/sdk-client.ts +++ b/spec-sdk-tests/utils/sdk-client.ts @@ -46,6 +46,16 @@ export class SdkClient { serverURL: baseURL, apiKey: config.apiKey || process.env.API_KEY || '', timeoutMs: config.timeout || 10000, + retryConfig: { + strategy: 'backoff', + backoff: { + initialInterval: 250, + maxInterval: 4000, + exponent: 2, + maxElapsedTime: 20000, + }, + retryConnectionErrors: true, + }, }); } From ea0ff25925bb81bb741ff7d415f00030d2b83865 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 29 May 2026 00:07:27 +0700 Subject: [PATCH 02/13] fix(spec): expose event.data on Attempt when include=event.data (#917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(spec-sdk): fix paginator response shape (response.result.models) Speakeasy CLI 1.741.7 → 1.753.0 (commit 3f311e0d, 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) * test(spec-sdk): assert event.data is exposed when include=event.data Co-Authored-By: Claude Opus 4.7 (1M context) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- docs/apis/openapi.yaml | 2 +- spec-sdk-tests/tests/events.test.ts | 34 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/apis/openapi.yaml b/docs/apis/openapi.yaml index 5bfb6fea7..7c78213fe 100644 --- a/docs/apis/openapi.yaml +++ b/docs/apis/openapi.yaml @@ -2802,8 +2802,8 @@ components: event: nullable: true oneOf: - - $ref: "#/components/schemas/EventSummary" - $ref: "#/components/schemas/EventFull" + - $ref: "#/components/schemas/EventSummary" description: The associated event object. Only present when include=event or include=event.data. destination: nullable: true diff --git a/spec-sdk-tests/tests/events.test.ts b/spec-sdk-tests/tests/events.test.ts index b101e3cb9..fe6e08324 100644 --- a/spec-sdk-tests/tests/events.test.ts +++ b/spec-sdk-tests/tests/events.test.ts @@ -265,5 +265,39 @@ describe('Events (PR #491)', () => { expect(attempt.status).to.equal('success', 'Delivery to mock.hookdeck.com should succeed'); console.log(`Event ${eventId} generated attempt; status: ${attempt.status}`); }); + + it('listAttempts with include=event.data should expose event.data on the parsed attempt', async function () { + this.timeout(60000); + + const sdk: Outpost = client.getSDK(); + + const payload = { marker: `include-event-data-${Date.now()}` }; + const publishResponse = await sdk.publish({ + tenantId: client.getTenantId(), + topic: TEST_TOPICS[0], + data: payload, + }); + const eventId = publishResponse.id; + + const attempts = await pollForAttempts( + async () => { + const response = await sdk.destinations.listAttempts({ + tenantId: client.getTenantId(), + destinationId: destinationId, + eventId, + include: ['event.data', 'response_data'], + }); + return response?.result?.models ?? []; + }, + 45000, + 5000 + ); + + expect(attempts.length).to.be.at.least(1, 'Expected at least one attempt'); + const attempt = attempts[0]; + expect(attempt.responseData, 'response_data should be populated when include=response_data').to.exist; + expect(attempt.event, 'event should be populated when include=event.data').to.exist; + expect((attempt.event as { data?: unknown }).data, 'event.data should be populated when include=event.data').to.deep.equal(payload); + }); }); }); From 030a980999e9da3ce88ec3d6b6c2e2395e18b3c7 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 29 May 2026 00:07:58 +0700 Subject: [PATCH 03/13] 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) --- .speakeasy/workflow.yaml | 3 - sdks/schemas/pagination-config-overlay.yaml | 85 --------------------- 2 files changed, 88 deletions(-) delete mode 100644 sdks/schemas/pagination-config-overlay.yaml diff --git a/.speakeasy/workflow.yaml b/.speakeasy/workflow.yaml index 574cb3e3b..edb8a2bdc 100644 --- a/.speakeasy/workflow.yaml +++ b/.speakeasy/workflow.yaml @@ -8,7 +8,6 @@ sources: - location: ./sdks/schemas/speakeasy-modifications-overlay.yaml - location: ./sdks/schemas/security-collapse-overlay.yaml - location: ./sdks/schemas/error-types.yaml - - location: ./sdks/schemas/pagination-config-overlay.yaml registry: location: registry.speakeasyapi.dev/hookdeck-dev/outpost/outpost-api Outpost API (Go): @@ -18,7 +17,6 @@ sources: - location: ./sdks/schemas/speakeasy-modifications-overlay.yaml - location: ./sdks/schemas/security-collapse-overlay.yaml - location: ./sdks/schemas/error-types.yaml - - location: ./sdks/schemas/pagination-config-overlay.yaml - location: ./sdks/schemas/go-array-params-overlay.yaml registry: location: registry.speakeasyapi.dev/hookdeck-dev/outpost/outpost-api @@ -29,7 +27,6 @@ sources: - location: ./sdks/schemas/speakeasy-modifications-overlay.yaml - location: ./sdks/schemas/security-collapse-overlay.yaml - location: ./sdks/schemas/error-types.yaml - - location: ./sdks/schemas/pagination-config-overlay.yaml - location: ./sdks/schemas/python-pagination-fixes-overlay.yaml registry: location: registry.speakeasyapi.dev/hookdeck-dev/outpost/outpost-api diff --git a/sdks/schemas/pagination-config-overlay.yaml b/sdks/schemas/pagination-config-overlay.yaml deleted file mode 100644 index ea0d59e48..000000000 --- a/sdks/schemas/pagination-config-overlay.yaml +++ /dev/null @@ -1,85 +0,0 @@ -overlay: 1.0.0 -x-speakeasy-jsonpath: rfc9535 -info: - title: Pagination Config - version: 0.0.1 - x-speakeasy-metadata: - type: pagination-config - description: "Configures cursor-based pagination for all list endpoints (shared across all SDKs)" -extends: ../../docs/apis/openapi.yaml -actions: - # Pagination config: GET /tenants - - target: $["paths"]["/tenants"]["get"] - update: - x-speakeasy-pagination: - type: cursor - inputs: - - name: next - in: parameters - type: cursor - - name: limit - in: parameters - type: limit - outputs: - nextCursor: $.pagination.next - results: $.models - x-speakeasy-metadata: - type: pagination-config - description: "Configure cursor-based pagination for tenants listing" - - # Pagination config: GET /events - - target: $["paths"]["/events"]["get"] - update: - x-speakeasy-pagination: - type: cursor - inputs: - - name: next - in: parameters - type: cursor - - name: limit - in: parameters - type: limit - outputs: - nextCursor: $.pagination.next - results: $.models - x-speakeasy-metadata: - type: pagination-config - description: "Configure cursor-based pagination for events listing" - - # Pagination config: GET /attempts - - target: $["paths"]["/attempts"]["get"] - update: - x-speakeasy-pagination: - type: cursor - inputs: - - name: next - in: parameters - type: cursor - - name: limit - in: parameters - type: limit - outputs: - nextCursor: $.pagination.next - results: $.models - x-speakeasy-metadata: - type: pagination-config - description: "Configure cursor-based pagination for attempts listing" - - # Pagination config: GET /tenants/.../destinations/.../attempts - - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}/attempts"]["get"] - update: - x-speakeasy-pagination: - type: cursor - inputs: - - name: next - in: parameters - type: cursor - - name: limit - in: parameters - type: limit - outputs: - nextCursor: $.pagination.next - results: $.models - x-speakeasy-metadata: - type: pagination-config - description: "Configure cursor-based pagination for destination attempts listing" From 4675bc9c1957d3ae3e01ca0bdb4f7f7f01cc2ecd Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 28 May 2026 18:25:47 +0100 Subject: [PATCH 04/13] test(spec-sdk): revert paginator wrapper accessors after pagination overlay drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- spec-sdk-tests/tests/events.test.ts | 10 +++++----- spec-sdk-tests/tests/tenants.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/spec-sdk-tests/tests/events.test.ts b/spec-sdk-tests/tests/events.test.ts index fe6e08324..8113b68b2 100644 --- a/spec-sdk-tests/tests/events.test.ts +++ b/spec-sdk-tests/tests/events.test.ts @@ -168,7 +168,7 @@ describe('Events (PR #491)', () => { limit: 5, }); expect(response).to.not.be.undefined; - expect(response?.result?.models).to.be.an('array'); + expect(response?.models).to.be.an('array'); }); it('should list events by tenant', async function () { @@ -191,7 +191,7 @@ describe('Events (PR #491)', () => { const response = await sdk.events.list({ tenantId: client.getTenantId(), }); - return response?.result?.models || []; + return response?.models || []; }, 30000, 5000 @@ -209,7 +209,7 @@ describe('Events (PR #491)', () => { const response = await sdk.events.list({ tenantId: client.getTenantId(), }); - const events = response?.result?.models || []; + const events = response?.models || []; if (events.length === 0) { console.warn('No events found - skipping single event test'); @@ -253,7 +253,7 @@ describe('Events (PR #491)', () => { destinationId: destinationId, eventId, }); - return response?.result?.models ?? []; + return response?.models ?? []; }, 45000, 5000 @@ -287,7 +287,7 @@ describe('Events (PR #491)', () => { eventId, include: ['event.data', 'response_data'], }); - return response?.result?.models ?? []; + return response?.models ?? []; }, 45000, 5000 diff --git a/spec-sdk-tests/tests/tenants.test.ts b/spec-sdk-tests/tests/tenants.test.ts index c44c2f41d..cb04cef57 100644 --- a/spec-sdk-tests/tests/tenants.test.ts +++ b/spec-sdk-tests/tests/tenants.test.ts @@ -19,8 +19,8 @@ describe('Tenants - List with request object', () => { const result = await sdk.tenants.list({ limit: 5 }); expect(result).to.not.be.undefined; - expect(result?.result?.models).to.be.an('array'); - (result?.result?.models ?? []).forEach((t: { id?: string }, i: number) => { + expect(result?.models).to.be.an('array'); + (result?.models ?? []).forEach((t: { id?: string }, i: number) => { expect(t, `tenant[${i}]`).to.be.an('object'); if (t.id != null) expect(t.id, `tenant[${i}].id`).to.be.a('string'); }); From 2d2bffa40b8a7a30e08b57eddb5f112c51c3d327 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 28 May 2026 19:59:09 +0100 Subject: [PATCH 05/13] 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) --- .github/workflows/spec-sdk-tests.yml | 198 +++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 .github/workflows/spec-sdk-tests.yml diff --git a/.github/workflows/spec-sdk-tests.yml b/.github/workflows/spec-sdk-tests.yml new file mode 100644 index 000000000..acb5d1560 --- /dev/null +++ b/.github/workflows/spec-sdk-tests.yml @@ -0,0 +1,198 @@ +# Runs the spec-sdk-tests contract suite against a freshly-built local Outpost. +# Catches OpenAPI ↔ SDK ↔ server drift (which is what motivated this workflow — +# see issue #921 for context and the bugs that surfaced without it). +# +# Triggers: workflow_dispatch, weekly cron (drift safety net), and PRs touching +# spec / SDK / handler / wiring paths. +name: Spec SDK tests + +on: + workflow_dispatch: + schedule: + # Weekly drift safety net — Mondays 06:00 UTC. + - cron: "0 6 * * 1" + pull_request: + paths: + - "docs/apis/openapi.yaml" + - "sdks/outpost-typescript/**" + - "spec-sdk-tests/**" + - ".speakeasy/**" + - "sdks/schemas/**" + - "internal/apirouter/**" + - "internal/destregistry/**" + - "internal/models/**" + - "cmd/outpost/**" + - ".github/workflows/spec-sdk-tests.yml" + +jobs: + spec-sdk-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: outpost + POSTGRES_PASSWORD: outpost + POSTGRES_DB: outpost + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U outpost" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + rabbitmq: + image: rabbitmq:3-management + ports: + - 5672:5672 + options: >- + --health-cmd "rabbitmq-diagnostics -q ping" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + env: + OUTPOST_BIN: /tmp/outpost + # Test-only secrets. Don't reuse anywhere. + OUTPOST_API_KEY: ci-test-api-key + OUTPOST_TEST_TENANT: ci-test-tenant + # spec-sdk-tests/.env expects these + TEST_TOPICS: "user.created,user.updated,order.created,heartbeat" + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + + - name: Build outpost binary + run: go build -o "$OUTPOST_BIN" ./cmd/outpost + + - name: Write outpost config + run: | + cat > /tmp/.outpost.yaml < /tmp/outpost-api.log 2>&1 & + echo $! > /tmp/outpost-api.pid + + - name: Start outpost delivery worker + env: + CONFIG: /tmp/.outpost.yaml + SERVICE: delivery + run: | + "$OUTPOST_BIN" > /tmp/outpost-delivery.log 2>&1 & + echo $! > /tmp/outpost-delivery.pid + + - name: Wait for /healthz + run: | + for i in {1..60}; do + if curl -sf http://localhost:3333/healthz >/dev/null; then + echo "Outpost API is healthy after ${i}s" + exit 0 + fi + sleep 1 + done + echo "::error::Outpost API did not become healthy within 60s" + echo "--- api log ---" + cat /tmp/outpost-api.log || true + echo "--- delivery log ---" + cat /tmp/outpost-delivery.log || true + exit 1 + + - name: Build TypeScript SDK + working-directory: sdks/outpost-typescript + run: | + npm ci + npm run build + + - name: Install spec-sdk-tests dependencies + working-directory: spec-sdk-tests + run: npm ci + + - name: Configure spec-sdk-tests .env + run: | + cat > spec-sdk-tests/.env </dev/null || true + kill "$(cat /tmp/outpost-delivery.pid)" 2>/dev/null || true From 468756f3386064f077a75f07120dab751c06e9b4 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 28 May 2026 20:09:09 +0100 Subject: [PATCH 06/13] =?UTF-8?q?ci(spec-sdk-tests):=20fix=20YAML=20?= =?UTF-8?q?=E2=80=94=20use=20block=20scalar=20for=20migrations=20run=20ste?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/spec-sdk-tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/spec-sdk-tests.yml b/.github/workflows/spec-sdk-tests.yml index acb5d1560..09a24e1b4 100644 --- a/.github/workflows/spec-sdk-tests.yml +++ b/.github/workflows/spec-sdk-tests.yml @@ -126,7 +126,8 @@ jobs: - name: Run migrations env: CONFIG: /tmp/.outpost.yaml - run: "$OUTPOST_BIN" migrate apply --yes + run: | + "$OUTPOST_BIN" migrate apply --yes - name: Start outpost API env: From 46a3630aafc4ec19f7c4a35afd4318d7c9ef0071 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 28 May 2026 22:34:15 +0100 Subject: [PATCH 07/13] ci(spec-sdk-tests): use outpost-server in singular mode, not the CLI wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/spec-sdk-tests.yml | 43 +++++++++++----------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/.github/workflows/spec-sdk-tests.yml b/.github/workflows/spec-sdk-tests.yml index 09a24e1b4..a34ffde43 100644 --- a/.github/workflows/spec-sdk-tests.yml +++ b/.github/workflows/spec-sdk-tests.yml @@ -65,7 +65,8 @@ jobs: --health-retries 10 env: - OUTPOST_BIN: /tmp/outpost + OUTPOST_CLI: /tmp/outpost + OUTPOST_SERVER: /tmp/outpost-server # Test-only secrets. Don't reuse anywhere. OUTPOST_API_KEY: ci-test-api-key OUTPOST_TEST_TENANT: ci-test-tenant @@ -86,8 +87,10 @@ jobs: with: node-version: "20" - - name: Build outpost binary - run: go build -o "$OUTPOST_BIN" ./cmd/outpost + - name: Build outpost binaries + run: | + go build -o "$OUTPOST_CLI" ./cmd/outpost + go build -o "$OUTPOST_SERVER" ./cmd/outpost-server - name: Write outpost config run: | @@ -127,23 +130,14 @@ jobs: env: CONFIG: /tmp/.outpost.yaml run: | - "$OUTPOST_BIN" migrate apply --yes - - - name: Start outpost API - env: - CONFIG: /tmp/.outpost.yaml - SERVICE: api - run: | - "$OUTPOST_BIN" > /tmp/outpost-api.log 2>&1 & - echo $! > /tmp/outpost-api.pid + "$OUTPOST_CLI" migrate apply --yes - - name: Start outpost delivery worker + - name: Start outpost (singular mode — api + log + delivery) env: CONFIG: /tmp/.outpost.yaml - SERVICE: delivery run: | - "$OUTPOST_BIN" > /tmp/outpost-delivery.log 2>&1 & - echo $! > /tmp/outpost-delivery.pid + "$OUTPOST_SERVER" > /tmp/outpost.log 2>&1 & + echo $! > /tmp/outpost.pid - name: Wait for /healthz run: | @@ -155,10 +149,8 @@ jobs: sleep 1 done echo "::error::Outpost API did not become healthy within 60s" - echo "--- api log ---" - cat /tmp/outpost-api.log || true - echo "--- delivery log ---" - cat /tmp/outpost-delivery.log || true + echo "--- outpost log ---" + cat /tmp/outpost.log || true exit 1 - name: Build TypeScript SDK @@ -184,16 +176,13 @@ jobs: working-directory: spec-sdk-tests run: npm test - - name: Dump server logs on failure + - name: Dump server log on failure if: failure() run: | - echo "--- api log ---" - cat /tmp/outpost-api.log || true - echo "--- delivery log ---" - cat /tmp/outpost-delivery.log || true + echo "--- outpost log ---" + cat /tmp/outpost.log || true - name: Stop outpost if: always() run: | - kill "$(cat /tmp/outpost-api.pid)" 2>/dev/null || true - kill "$(cat /tmp/outpost-delivery.pid)" 2>/dev/null || true + kill "$(cat /tmp/outpost.pid)" 2>/dev/null || true From d3f2abb950d85b48d4290729f6086ced34736be8 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 29 May 2026 10:06:50 +0100 Subject: [PATCH 08/13] ci(spec-sdk-tests): use npm install for spec-sdk-tests deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/spec-sdk-tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/spec-sdk-tests.yml b/.github/workflows/spec-sdk-tests.yml index a34ffde43..030698a67 100644 --- a/.github/workflows/spec-sdk-tests.yml +++ b/.github/workflows/spec-sdk-tests.yml @@ -161,7 +161,9 @@ jobs: - name: Install spec-sdk-tests dependencies working-directory: spec-sdk-tests - run: npm ci + # spec-sdk-tests/.gitignore excludes package-lock.json, so `npm ci` + # doesn't work — use `npm install` instead. + run: npm install - name: Configure spec-sdk-tests .env run: | From ef7998faf43f24e1beae322ecf23cc726c5587bd Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 29 May 2026 10:13:21 +0100 Subject: [PATCH 09/13] ci(spec-sdk-tests): regen TS SDK from the PR's spec before running tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/spec-sdk-tests.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/spec-sdk-tests.yml b/.github/workflows/spec-sdk-tests.yml index 030698a67..4f3013b73 100644 --- a/.github/workflows/spec-sdk-tests.yml +++ b/.github/workflows/spec-sdk-tests.yml @@ -153,11 +153,18 @@ jobs: cat /tmp/outpost.log || true exit 1 - - name: Build TypeScript SDK - working-directory: sdks/outpost-typescript - run: | - npm ci - npm run build + # Without this regen, we'd be testing whatever SDK src/ is checked + # into the branch — which can lag the spec arbitrarily. The whole + # point of this workflow is to validate spec ↔ SDK ↔ server agree, + # so we regen the SDK from this PR's spec before running tests. + - name: Install Speakeasy CLI + run: curl -fsSL https://go.speakeasy.com/cli-install.sh | sh + + - name: Regenerate + build TypeScript SDK from this PR's spec + env: + SPEAKEASY_API_KEY: ${{ secrets.SPEAKEASY_API_KEY }} + # Same script developers run locally — keeps CI and local in sync. + run: ./spec-sdk-tests/scripts/regenerate-sdk.sh TS - name: Install spec-sdk-tests dependencies working-directory: spec-sdk-tests From f84b5fba9a451a89d10be493d4736f52f364931c Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 29 May 2026 10:20:35 +0100 Subject: [PATCH 10/13] 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. --- .github/workflows/spec-sdk-tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/spec-sdk-tests.yml b/.github/workflows/spec-sdk-tests.yml index 4f3013b73..92443a9da 100644 --- a/.github/workflows/spec-sdk-tests.yml +++ b/.github/workflows/spec-sdk-tests.yml @@ -45,7 +45,9 @@ jobs: --health-retries 10 redis: - image: redis:7-alpine + # redis-stack-server bundles RediSearch, which `tenants.list` needs + # (vanilla redis returns 501 "list tenant feature is not enabled"). + image: redis/redis-stack-server:latest ports: - 6379:6379 options: >- From ed603561463df4fcd11ba865c1afc342b1ecbaec Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 29 May 2026 10:26:27 +0100 Subject: [PATCH 11/13] 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. --- .github/workflows/spec-sdk-tests.yml | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/spec-sdk-tests.yml b/.github/workflows/spec-sdk-tests.yml index 92443a9da..4b1859577 100644 --- a/.github/workflows/spec-sdk-tests.yml +++ b/.github/workflows/spec-sdk-tests.yml @@ -1,9 +1,28 @@ # Runs the spec-sdk-tests contract suite against a freshly-built local Outpost. -# Catches OpenAPI ↔ SDK ↔ server drift (which is what motivated this workflow — -# see issue #921 for context and the bugs that surfaced without it). # -# Triggers: workflow_dispatch, weekly cron (drift safety net), and PRs touching -# spec / SDK / handler / wiring paths. +# WHAT THIS WORKFLOW VALIDATES +# "Does this PR's server code implement what this PR's spec says?" +# Regenerates the TS SDK from this PR's `docs/apis/openapi.yaml`, builds +# `outpost-server` from this PR's source, and runs the spec-sdk-tests +# contract suite against them. Catches drift introduced by the PR. +# +# WHAT IT DOES NOT VALIDATE +# - Drift between managed/deployed Outpost and the spec (deploy drift). +# - Compatibility of older SDK releases against the current server. +# - Post-deploy smoke against managed. +# These are separate concerns — managed-vs-spec asks a different question +# from PR-vs-itself, and conflating them creates "merge through red" +# pressure on every breaking spec change. They belong in separate +# workflows (see issue #921 for the design discussion). +# +# WHY THIS SHAPE +# - Local server + regen'd SDK gives fast, deterministic PR feedback and +# doesn't pollute managed with test resources or hit its rate limits. +# - Triggering only on paths that can plausibly change request/response +# shape keeps CI cheap. +# +# Triggers: workflow_dispatch, weekly cron (drift safety net within this +# workflow's scope), and PRs touching spec / SDK / handler / wiring paths. name: Spec SDK tests on: From 4919db1e2cdf650c15f4c6784eff789520a465a5 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 29 May 2026 11:11:07 +0100 Subject: [PATCH 12/13] ci(spec-sdk-tests): drop the weekly cron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .github/workflows/spec-sdk-tests.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/spec-sdk-tests.yml b/.github/workflows/spec-sdk-tests.yml index 4b1859577..3c2062b84 100644 --- a/.github/workflows/spec-sdk-tests.yml +++ b/.github/workflows/spec-sdk-tests.yml @@ -21,15 +21,15 @@ # - Triggering only on paths that can plausibly change request/response # shape keeps CI cheap. # -# Triggers: workflow_dispatch, weekly cron (drift safety net within this -# workflow's scope), and PRs touching spec / SDK / handler / wiring paths. + +# Triggers: workflow_dispatch and PRs touching spec / SDK / handler / wiring +# paths. No cron — between PRs, `main`'s state hasn't changed, so a scheduled +# run would re-test what we last tested. The "deploy drift" question that a +# cron would help with is out of scope for this workflow anyway (see above). name: Spec SDK tests on: workflow_dispatch: - schedule: - # Weekly drift safety net — Mondays 06:00 UTC. - - cron: "0 6 * * 1" pull_request: paths: - "docs/apis/openapi.yaml" From 6e83be307d04364dece104a890108e8526cc1095 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 29 May 2026 14:51:47 +0100 Subject: [PATCH 13/13] 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. --- .github/workflows/spec-sdk-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/spec-sdk-tests.yml b/.github/workflows/spec-sdk-tests.yml index 3c2062b84..95ee559d2 100644 --- a/.github/workflows/spec-sdk-tests.yml +++ b/.github/workflows/spec-sdk-tests.yml @@ -41,6 +41,7 @@ on: - "internal/destregistry/**" - "internal/models/**" - "cmd/outpost/**" + - "cmd/outpost-server/**" - ".github/workflows/spec-sdk-tests.yml" jobs: