diff --git a/contributing/sdks.md b/contributing/sdks.md index e811e1b6..54e21bbc 100644 --- a/contributing/sdks.md +++ b/contributing/sdks.md @@ -545,6 +545,10 @@ cd ../.. # Regenerate SDK (optional, requires Speakeasy CLI) cd spec-sdk-tests ./scripts/regenerate-sdk.sh +# For Go: the script unsets GOMODCACHE/GOCACHE so Go uses your default module cache. +# If you run Speakeasy for Go directly and see "module found but does not contain package", +# unset GOMODCACHE and GOCACHE and retry (e.g. in Cursor the sandbox can set a cache path +# that breaks module resolution). # Run spec-sdk-tests (RECOMMENDED - includes pre-flight checks) cd spec-sdk-tests diff --git a/docs/pages/guides.mdx b/docs/pages/guides.mdx index 5bafc210..abf6b060 100644 --- a/docs/pages/guides.mdx +++ b/docs/pages/guides.mdx @@ -22,6 +22,7 @@ Welcome to the Outpost guides section. These guides will help you get the most o - [Schema Migration](/guides/migration) - [Upgrade to v0.12](/guides/upgrade-v0.12) - [Upgrade to v0.13](/guides/upgrade-v0.13) +- [Upgrade to v0.14](/guides/upgrade-v0.14) ## Next Steps diff --git a/docs/pages/guides/upgrade-v0.14.mdx b/docs/pages/guides/upgrade-v0.14.mdx new file mode 100644 index 00000000..456077c9 --- /dev/null +++ b/docs/pages/guides/upgrade-v0.14.mdx @@ -0,0 +1,185 @@ +--- +title: "Upgrade to v0.14" +--- + +This guide covers breaking changes and migration steps when upgrading from v0.13 to v0.14. + +## Breaking Changes Overview + +| Change | Impact | Action Required | +| --- | --- | --- | +| [PostgreSQL data columns migrated to TEXT](#postgresql-data-columns-migrated-to-text) | Direct queries against Postgres `events` and `attempts` tables | Update any SQL that relies on JSONB operators for `data` / `event_data` columns | +| [Array query parameters](#array-query-parameters) | API query filters & SDK method signatures | Use bracket notation for array filters; update SDK calls | +| [Python SDK: request body and method signatures](#python-sdk-request-body-and-method-signatures) | Python SDK methods that send a request body | Change `params=` to `body=`; use new positional + `body=` signature | +| [List tenants: method rename and request object](#list-tenants-method-rename-and-request-object) | All SDKs — tenants list API | Replace `listTenants(...)` / `ListTenants(...)` with `list(request)`; use a single request object | + +## PostgreSQL Data Columns Migrated to TEXT + +The `events.data` and `attempts.event_data` columns have been migrated from `JSONB` to `TEXT`. This change preserves the original JSON key ordering of webhook payloads, which JSONB normalizes alphabetically. + +**The migration runs automatically when Outpost starts.** No manual migration steps are required. + +If you only interact with Outpost through the API or SDKs, **no action is needed** — the API response format is unchanged. + +If you have custom SQL queries, dashboards, or tooling that reads directly from the Outpost PostgreSQL database, you will need to update any queries that use JSONB-specific operators on these columns. + +## Array Query Parameters + +API query parameters now accept arrays using bracket notation. This affects filters like `tenant_id`, `status`, and `topic` on list endpoints. + +**Single values continue to work as before.** This is a non-breaking addition for API users who don't use arrays. However, the SDK method signatures have changed to accommodate the new array types. + +### API usage + +``` +# Single value (unchanged) +GET /events?tenant_id=tenant_123&topic=user.created + +# Array filter (new) +GET /events?tenant_id[]=tenant_123&tenant_id[]=tenant_456&topic[]=user.created&topic[]=user.updated +``` + +### SDK changes + +The SDKs have been updated to accept both single values and arrays for filter parameters. + +**TypeScript:** + +```ts +// Single value (unchanged) +const events = await outpost.events.list({ + tenantId: "tenant_123", + topic: "user.created", +}); + +// Array filter (new) +const events = await outpost.events.list({ + tenantId: ["tenant_123", "tenant_456"], + topic: ["user.created", "user.updated"], +}); +``` + +**Go:** + +Filter parameters that accept single or multiple values are represented as `[]string`. Pass a slice with one or more values: + +```go +// Single value +res, err := s.Events.List(ctx, operations.ListEventsRequest{ + TenantID: []string{"tenant_123"}, + Topic: []string{"user.created"}, + Limit: outpostgo.Int64(50), +}) + +// Array filter +res, err := s.Events.List(ctx, operations.ListEventsRequest{ + TenantID: []string{"tenant_123", "tenant_456"}, + Topic: []string{"user.created", "user.updated"}, + Limit: outpostgo.Int64(50), +}) +``` + +**Action:** Update SDK dependencies to the latest version. If you pass filter parameters that previously accepted only a single string, verify that your code still works with the updated type signatures. + +## Python SDK: request body and method signatures + +The Python SDK has two breaking changes for methods that send a request body (`destinations.create`, `destinations.update`, `tenants.upsert`): + +1. **Request body parameter renamed:** The keyword argument for the request body is now `body` instead of `params`. You must update every call site. +2. **Method signature shape:** Methods now take path parameters positionally and the request body as the named argument `body=`, instead of a single request object containing `params`. + +**Before (v0.13):** + +```py +# Keyword argument was params= +sdk.destinations.create(tenant_id="acme", params=models.DestinationCreateWebhook(...)) +sdk.destinations.update(tenant_id="acme", destination_id="des_123", params=models.DestinationUpdate(...)) +``` + +**After (v0.14):** + +```py +# Use body= and (for create/update) positional path params + body= +sdk.destinations.create(tenant_id="acme", body=models.DestinationCreateWebhook(...)) +sdk.destinations.update(tenant_id="acme", destination_id="des_123", body=models.DestinationUpdate(...)) +``` + +**TypeScript and Go:** The request body parameter in the method signature is now named `body` (was `params`). This is a type/signature rename only; call sites that pass the body positionally do not need to change. + +## List tenants: method rename and request object + +The "list tenants" API is now exposed as `tenants.list` (or `Tenants.List` / `tenants.list`) with a **single request object** in all SDKs, consistent with `events.list` and `attempts.list`. The previous method names and flattened parameters are no longer available. + +**Before (v0.13):** + +**TypeScript:** + +```ts +const result = await outpost.tenants.listTenants(20, "desc"); +``` + +**Go:** + +```go +res, err := client.Tenants.ListTenants(ctx, outpostgo.Pointer(int64(20)), operations.ListTenantsDirDesc.ToPointer(), nil, nil) +``` + +**Python:** + +```py +res = sdk.tenants.list_tenants(limit=20, direction=models.ListTenantsDir.DESC) +``` + +**After (v0.14):** + +**TypeScript:** + +```ts +const result = await outpost.tenants.list({ limit: 20, dir: "desc" }); +``` + +**Go:** + +```go +res, err := client.Tenants.List(ctx, operations.ListTenantsRequest{ + Limit: outpostgo.Pointer(int64(20)), + Dir: operations.ListTenantsDirDesc.ToPointer(), +}) +``` + +**Python:** + +```py +res = sdk.tenants.list(request=models.ListTenantsRequest(limit=20, direction=models.ListTenantsDir.DESC)) +``` + +**Action:** Replace any call to `listTenants` / `ListTenants` / `list_tenants` with `list` (or `List` in Go) and pass a single request object with `limit`, `dir`, `next`, and `prev` as needed. + +## Other Notable Changes + +These changes are **not breaking** but may be useful to know about. + +### Relaxed destination URL validation + +Destination URL validation has been relaxed to allow: +- URLs with Basic Auth credentials (e.g., `https://user:pass@example.com/webhook`) +- RabbitMQ server URLs with Docker service names (e.g., `amqp://guest:guest@rabbitmq:5672`) + +### Empty `custom_headers` accepted + +Empty `custom_headers` on webhook destinations are now treated as absent instead of triggering validation errors. If you previously worked around the v0.13 validation by omitting `custom_headers`, you can now safely pass an empty object again. + +## Upgrade Checklist + +1. **Before upgrading:** + - [ ] Back up your PostgreSQL database + - [ ] Audit any direct SQL queries against `events.data` or `attempts.event_data` for JSONB operators + - [ ] Update SDK dependencies to the latest version + - [ ] **(Python only)** Replace `params=` with `body=` for `destinations.create`, `destinations.update`, and `tenants.upsert` + - [ ] Replace `tenants.listTenants` / `Tenants.ListTenants` / `tenants.list_tenants` with `tenants.list` (or `Tenants.List`) and pass a single request object + +2. **Upgrade:** + - [ ] Update Outpost to v0.14 and restart — the PostgreSQL migration runs automatically on startup + +3. **After upgrading:** + - [ ] Verify any direct SQL queries against Outpost tables are working as expected diff --git a/examples/sdk-go/auth.go b/examples/sdk-go/auth.go index 702df5f5..bee009ae 100644 --- a/examples/sdk-go/auth.go +++ b/examples/sdk-go/auth.go @@ -8,6 +8,7 @@ import ( "strings" outpostgo "github.com/hookdeck/outpost/sdks/outpost-go" + "github.com/hookdeck/outpost/sdks/outpost-go/models/operations" "github.com/joho/godotenv" ) @@ -68,6 +69,16 @@ func withAdminApiKey(ctx context.Context, apiServerURL string, adminAPIKey strin log.Println("List destinations with Admin Key returned no data or an unexpected response structure.") } + // List tenants (v0.14+: Tenants.List with request object) + tenantsRes, err := adminClient.Tenants.List(ctx, operations.ListTenantsRequest{ + Limit: outpostgo.Pointer(int64(5)), + }) + if err != nil { + log.Printf("List tenants failed (e.g. 501 if RediSearch not available): %v", err) + } else if tenantsRes != nil && tenantsRes.TenantPaginatedResult != nil { + log.Printf("Successfully listed %d tenant(s) (first page).", len(tenantsRes.TenantPaginatedResult.Models)) + } + tokenRes, err := adminClient.Tenants.GetToken(ctx, tenantID) if err != nil { log.Fatalf("Failed to get tenant token: %v", err) diff --git a/examples/sdk-go/go.mod b/examples/sdk-go/go.mod index 59f95e50..5b223c92 100644 --- a/examples/sdk-go/go.mod +++ b/examples/sdk-go/go.mod @@ -13,6 +13,5 @@ replace github.com/hookdeck/outpost/sdks/outpost-go => ../../sdks/outpost-go require ( github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect - github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 // indirect golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b // indirect ) diff --git a/examples/sdk-go/go.sum b/examples/sdk-go/go.sum index 945d513e..05211b7b 100644 --- a/examples/sdk-go/go.sum +++ b/examples/sdk-go/go.sum @@ -4,15 +4,19 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5O github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 h1:R/ZjJpjQKsZ6L/+Gf9WHbt31GG8NMVcpRqUE+1mMIyo= -github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731/go.mod h1:M9R1FoZ3y//hwwnJtO51ypFGwm8ZfpxPT/ZLtO1mcgQ= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hookdeck/outpost/sdks/outpost-go v0.3.0 h1:jN+Pwg7TiIvScnR5aoeHgKItDO+jpA0xn+t2NALOWhU= -github.com/hookdeck/outpost/sdks/outpost-go v0.3.0/go.mod h1:Ljmw6AK9r1rm9U17BBEnLQW1pRYMrutthCTRFTufNCE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b h1:MQE+LT/ABUuuvEZ+YQAMSXindAdUh7slEmAkup74op4= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/sdk-python/README.md b/examples/sdk-python/README.md index 6617cd97..cf3f9727 100644 --- a/examples/sdk-python/README.md +++ b/examples/sdk-python/README.md @@ -14,13 +14,19 @@ The source code for the Python SDK can be found in the [`sdks/outpost-python/`]( ### Setup -1. **Build the local SDK** (this example uses a path dependency pointing at the local SDK source): - ```bash - cd ../../sdks/outpost-python && pip install -e . - cd ../../examples/sdk-python - ``` +**Option A — Using the run script (recommended if Poetry has issues)** +From `examples/sdk-python`, create a `.env` (see below), then: +```bash +./run-auth.sh +``` +This creates a `.venv`, installs the local SDK and dependencies, and runs the auth example. For other commands (e.g. create-destination), activate the venv and run: +```bash +.venv/bin/python app.py create-destination +``` + +**Option B — Using Poetry** -2. **Install dependencies:** +1. **Install dependencies:** ```bash poetry install ``` @@ -42,10 +48,11 @@ The source code for the Python SDK can be found in the [`sdks/outpost-python/`]( ``` Use `API_BASE_URL` for the full API base, or `SERVER_URL` for local. (Note: `.env` is gitignored.) -2. **Run the example script:** - *(Ensure you are inside the Poetry shell activated in the setup step)* +2. **Run the example script:** + If you used **Option A** (run script), use `./run-auth.sh` or `.venv/bin/python app.py auth`. + If you used **Option B** (Poetry), ensure you are inside the Poetry shell, then use `python app.py auth`. - The `app.py` script is now a command-line interface (CLI) that accepts different commands to run specific examples. + The `app.py` script is a command-line interface (CLI) that accepts different commands: * **To run the API Key and tenant-scoped API key auth example:** ```bash diff --git a/examples/sdk-python/example/auth.py b/examples/sdk-python/example/auth.py index eb99ac6f..96b89bd9 100644 --- a/examples/sdk-python/example/auth.py +++ b/examples/sdk-python/example/auth.py @@ -1,7 +1,7 @@ import os import sys from dotenv import load_dotenv -from outpost_sdk import Outpost +from outpost_sdk import Outpost, models def with_tenant_api_key(outpost: Outpost, tenant_api_key: str, tenant_id: str): @@ -41,6 +41,16 @@ def with_admin_api_key(outpost: Outpost, tenant_id: str): ) print(destinations_res) + # List tenants (tenants.list(request) with request object) + try: + tenants_res = outpost.tenants.list(request=models.ListTenantsRequest(limit=5)) + if tenants_res and tenants_res.result and tenants_res.result.models is not None: + print(f"Tenants (first page): {len(tenants_res.result.models)} tenant(s)") + else: + print("Tenants (first page): (no tenants or 501 if RediSearch not available)") + except Exception as list_err: + print(f"List tenants skipped or failed: {list_err}") + token_res = outpost.tenants.get_token(tenant_id=tenant_id) print(f"Tenant token obtained for tenant {tenant_id}:") print(token_res) diff --git a/examples/sdk-python/requirements.txt b/examples/sdk-python/requirements.txt new file mode 100644 index 00000000..90a0d571 --- /dev/null +++ b/examples/sdk-python/requirements.txt @@ -0,0 +1,9 @@ +# Dependencies for the Python example (install after the local SDK). +# From examples/sdk-python: +# python3 -m venv .venv +# .venv/bin/pip install -e ../../sdks/outpost-python +# .venv/bin/pip install -r requirements.txt +# .venv/bin/python app.py auth +python-dotenv>=1.1.0 +typer>=0.16.0 +questionary>=2.1.0 diff --git a/examples/sdk-python/run-auth.sh b/examples/sdk-python/run-auth.sh new file mode 100755 index 00000000..ac8a4017 --- /dev/null +++ b/examples/sdk-python/run-auth.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Run the auth example using a venv (avoids Poetry when lock/metadata issues occur). +# From repo root or examples/sdk-python: ./examples/sdk-python/run-auth.sh +# Or from examples/sdk-python: ./run-auth.sh +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SDK_DIR="$REPO_ROOT/sdks/outpost-python" + +if [[ ! -d .venv ]]; then + echo "Creating .venv..." + python3 -m venv .venv +fi +.venv/bin/pip install -q -e "$SDK_DIR" +.venv/bin/pip install -q -r requirements.txt +echo "Running auth example..." +.venv/bin/python app.py auth diff --git a/examples/sdk-typescript/auth.ts b/examples/sdk-typescript/auth.ts index 8aca6bf4..f322ffc3 100644 --- a/examples/sdk-typescript/auth.ts +++ b/examples/sdk-typescript/auth.ts @@ -96,6 +96,10 @@ const withAdminApiKey = async () => { console.log(destinations); + // List tenants (v0.14+: tenants.list(request) with a single request object) + const tenantsPage = await outpost.tenants.list({ limit: 5 }); + console.log("Tenants (first page):", tenantsPage?.models ?? []); + // Get portal URL (Admin API Key required) try { const portal = await outpost.tenants.getPortalUrl(tenantId); diff --git a/sdks/schemas/speakeasy-modifications-overlay.yaml b/sdks/schemas/speakeasy-modifications-overlay.yaml index d533ec3e..24923104 100644 --- a/sdks/schemas/speakeasy-modifications-overlay.yaml +++ b/sdks/schemas/speakeasy-modifications-overlay.yaml @@ -206,6 +206,16 @@ actions: created_at: 1745611620645 reviewed_at: 1745611624395 type: method-name + - target: $["paths"]["/tenants"]["get"] + update: + x-speakeasy-name-override: list + x-speakeasy-max-method-params: 0 + x-speakeasy-metadata: + after: sdk.tenants.list() + before: sdk.Tenants.listTenants() + created_at: 1745611620645 + reviewed_at: 1745611624395 + type: method-name - target: $["paths"]["/tenants/{tenant_id}"]["get"] update: x-speakeasy-name-override: get diff --git a/spec-sdk-tests/scripts/regenerate-sdk.sh b/spec-sdk-tests/scripts/regenerate-sdk.sh index 77103152..5aa6aaf3 100755 --- a/spec-sdk-tests/scripts/regenerate-sdk.sh +++ b/spec-sdk-tests/scripts/regenerate-sdk.sh @@ -26,6 +26,11 @@ run_ts() { run_go() { echo "Regenerating Go SDK..." + # Use default Go module/cache dirs so go mod tidy and go build succeed. When run inside + # Cursor/sandbox, GOMODCACHE can point at a sandbox path and module resolution fails + # with "module found but does not contain package"; unsetting fixes that. + unset GOMODCACHE + unset GOCACHE speakeasy run -t outpost-go echo "Building Go SDK..." (cd "$REPO_ROOT/sdks/outpost-go" && go build ./...) diff --git a/spec-sdk-tests/tests/events.test.ts b/spec-sdk-tests/tests/events.test.ts index f5ff28dd..2d638235 100644 --- a/spec-sdk-tests/tests/events.test.ts +++ b/spec-sdk-tests/tests/events.test.ts @@ -158,6 +158,19 @@ describe('Events (PR #491)', () => { }); describe('GET /events', () => { + it('should accept array params for tenantId and topic (events.list)', async function () { + const sdk: Outpost = client.getSDK(); + const tid = client.getTenantId(); + // SDK accepts string | string[] for tenantId and topic; verify array form is accepted + const response = await sdk.events.list({ + tenantId: [tid], + topic: [TEST_TOPICS[0]], + limit: 5, + }); + expect(response).to.not.be.undefined; + expect(response?.models).to.be.an('array'); + }); + it('should list events by tenant', async function () { this.timeout(60000); diff --git a/spec-sdk-tests/tests/tenants.test.ts b/spec-sdk-tests/tests/tenants.test.ts new file mode 100644 index 00000000..cb04cef5 --- /dev/null +++ b/spec-sdk-tests/tests/tenants.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import { createSdkClient } from '../utils/sdk-client'; + +/** + * Tenant list tests. + * + * API/SDK: GET /tenants with cursor-based pagination. The SDK exposes + * sdk.tenants.list(request) (v0.14+) with a single request object, consistent with + * events.list and attempts.list. Requires Admin API Key (or Tenant JWT for single-tenant result). + * Requires Redis with RediSearch; may return 501 if not available. + */ + +describe('Tenants - List with request object', () => { + it('should list tenants using tenants.list(request)', async function () { + const client = createSdkClient(); + const sdk = client.getSDK(); + + 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(t, `tenant[${i}]`).to.be.an('object'); + if (t.id != null) expect(t.id, `tenant[${i}].id`).to.be.a('string'); + }); + }); +});