diff --git a/.github/workflows/spec-sdk-tests.yml b/.github/workflows/spec-sdk-tests.yml new file mode 100644 index 00000000..95ee559d --- /dev/null +++ b/.github/workflows/spec-sdk-tests.yml @@ -0,0 +1,219 @@ +# Runs the spec-sdk-tests contract suite against a freshly-built local Outpost. +# +# 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 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: + pull_request: + paths: + - "docs/apis/openapi.yaml" + - "sdks/outpost-typescript/**" + - "spec-sdk-tests/**" + - ".speakeasy/**" + - "sdks/schemas/**" + - "internal/apirouter/**" + - "internal/destregistry/**" + - "internal/models/**" + - "cmd/outpost/**" + - "cmd/outpost-server/**" + - ".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: + # 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: >- + --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_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 + # 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 binaries + run: | + go build -o "$OUTPOST_CLI" ./cmd/outpost + go build -o "$OUTPOST_SERVER" ./cmd/outpost-server + + - name: Write outpost config + run: | + cat > /tmp/.outpost.yaml < /tmp/outpost.log 2>&1 & + echo $! > /tmp/outpost.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 "--- outpost log ---" + cat /tmp/outpost.log || true + exit 1 + + # 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 + # 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: | + cat > spec-sdk-tests/.env </dev/null || true diff --git a/.speakeasy/workflow.yaml b/.speakeasy/workflow.yaml index 574cb3e3..edb8a2bd 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/docs/apis/openapi.yaml b/docs/apis/openapi.yaml index 0832890e..7c78213f 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 @@ -2535,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/sdks/schemas/pagination-config-overlay.yaml b/sdks/schemas/pagination-config-overlay.yaml deleted file mode 100644 index ea0d59e4..00000000 --- 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" diff --git a/spec-sdk-tests/tests/destinations/aws-kinesis.test.ts b/spec-sdk-tests/tests/destinations/aws-kinesis.test.ts index 949d59ff..07717fca 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 f4aa62c2..64bfe8e6 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 c2631551..84769d79 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 75b13ab4..8fb13c67 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 815d94d1..1dc1884d 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 38bc6b2c..9c859dab 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 88d355a2..e0be7382 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 40339334..fbd5a4c3 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 2828cc58..0dad9a3d 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 4868fb1d..8113b68b 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?.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); + }); }); }); diff --git a/spec-sdk-tests/utils/sdk-client.ts b/spec-sdk-tests/utils/sdk-client.ts index 85199a19..0e959f46 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, + }, }); }