Skip to content

Discussion: TypeScript SDK Method Parameter Flattening - Options for Improving DX #630

@leggetter

Description

@leggetter

Goal

Investigate if we can achieve this fully flattened structure:

await outpost.destinations.create("acme-corp", {
  type: "webhook",
  topics: ["order.created"],
  config: { url: "https://acme.com/webhooks" },
});

Where tenantId is a direct parameter (first position) and request body fields are flattened.

Problem Summary

Why We Have This Problem

  1. Dual Authentication Support: Outpost supports both Admin API Key and JWT authentication:

    • Admin API Key: Requires tenantId in the path (required parameter)
    • JWT Token: Infers tenantId from the token (optional in path, but validated if provided)
  2. OpenAPI Specification: The OpenAPI spec marks tenant_id as required: true in the path, but includes the description "Required when using AdminApiKey authentication", which implies it's conditionally required.

  3. Global Parameter Override: The speakeasy-modifications-overlay.yaml defines tenant_id as a global parameter, which makes Speakeasy treat it as optional in all method signatures (since globals can be provided at the SDK client level).

  4. Speakeasy Behavior: When a parameter is optional, Speakeasy places optional parameters after required ones when flattening. Since the request body (params) is always required, it appears before the optional tenantId.

Why tenantId is Optional

The tenantId parameter is optional in the generated SDK because:

  1. Global Parameter Definition: In sdks/schemas/speakeasy-modifications-overlay.yaml (lines 254-264), tenant_id is defined as a global parameter. Global parameters are treated as optional in method signatures because they can be provided at the SDK client level.

  2. JWT Authentication Support: When using JWT authentication, the API can infer the tenantId from the token, making it technically optional in the path (though the OpenAPI spec still marks it as required: true).

  3. API Behavior: The API middleware (internal/apirouter/auth_middleware.go:116-120) validates that if a tenantId is provided in the path, it matches the JWT's tenant ID. If no tenantId is provided, it uses the one from the token.

Why tenantId Can't Be First Parameter

When tenantId is optional and we enable flattening (maxMethodParams > 0):

  1. Parameter Ordering: Speakeasy orders parameters as: required parameters first, then optional parameters. Since:

    • Request body (params) is always required
    • tenantId is optional (due to global parameter definition)
    • The result is: create(params, tenantId?, options?) instead of create(tenantId, params, options?)
  2. Flattening Order: The flatteningOrder: parameters-first setting only applies when both parameters are required. When one is optional, Speakeasy prioritizes required parameters.

  3. TypeScript Limitation: TypeScript SDK doesn't have a flattenRequests option like Python, which would flatten request body fields into individual parameters.

Findings

Test 1: maxMethodParams: 0 (Current Configuration)

Result: All parameters bundled into a single request object

async create(
  request: operations.CreateTenantDestinationRequest,
  options?: RequestOptions,
): Promise<components.Destination>

Usage:

await outpost.destinations.create({
  tenantId: "acme-corp",
  params: {
    type: "webhook",
    topics: ["order.created"],
    config: { url: "https://acme.com/webhooks" },
  },
});

Test 2: maxMethodParams: 20 (Flattening Enabled)

Result: Parameters are flattened, but request body fields remain nested

async create(
  params: components.DestinationCreate,
  tenantId?: string | undefined,
  options?: RequestOptions,
): Promise<components.Destination>

Usage:

await outpost.destinations.create(
  { type: "webhook", topics: ["order.created"], config: {...} },
  "acme-corp"
);

Issues:

  1. Request body fields (type, topics, config) are still nested in params object
  2. Parameter order is wrong: params comes before tenantId (despite flatteningOrder: parameters-first)
  3. tenantId is optional because it's defined as a global parameter
  4. TypeScript doesn't have a flattenRequests option like Python

Solution: Making tenantId Required

If We Remove the Global Parameter

If we remove the global parameter definition from the overlay:

  1. tenantId becomes required: Since the OpenAPI spec marks it as required: true, removing the global override will make it required in the SDK.

  2. Parameter order improves: With maxMethodParams: 10 and flatteningOrder: parameters-first, we should get:

    async create(
      tenantId: string,
      params: components.DestinationCreate,
      options?: RequestOptions,
    ): Promise<components.Destination>
  3. JWT authentication impact: JWT users would need to pass tenantId explicitly, even though it's in their token. The API will validate that the path tenantId matches the JWT's tenant ID (security check).

  4. Trade-offs:

    • ✅ Consistent SDK signature for both auth methods
    • ✅ Better parameter ordering (tenantId first)
    • ✅ Security validation (API ensures tenant ID matches JWT)
    • ⚠️ Slightly more verbose for JWT users (must pass tenant ID that's already in token)

Best Available Structure (With Global Parameter Removed)

With maxMethodParams: 10, requestBodyFieldName: "params", and global parameter removed:

await outpost.destinations.create(
  "acme-corp",  // tenantId (required, first parameter)
  {             // params (request body)
    type: "webhook",
    topics: ["order.created"],
    config: { url: "https://acme.com/webhooks" },
  }
);

Note: Request body fields (type, topics, config) remain nested in the params object because TypeScript SDK doesn't support field-level flattening like Python's flattenRequests: true.

Conclusion

What IS Achievable

Parameter-level flattening with correct order:

  • tenantId as first required parameter
  • params as second required parameter
  • options as third optional parameter

What IS NOT Achievable

Field-level flattening:

  • Cannot flatten request body fields (type, topics, config) into individual parameters
  • TypeScript SDK doesn't have flattenRequests option like Python

Recommendation

  1. Remove the global parameter from speakeasy-modifications-overlay.yaml to make tenantId required
  2. Set maxMethodParams: 10 to enable parameter flattening
  3. Keep requestBodyFieldName: "params" for consistent naming
  4. Accept the trade-off: JWT users must pass tenantId explicitly (validated by API)

This gives us the best possible structure:

await outpost.destinations.create("acme-corp", {
  type: "webhook",
  topics: ["order.created"],
  config: { url: "https://acme.com/webhooks" },
});

Alternative Approaches (Not Recommended)

  1. Custom Code Regions: Could manually flatten parameters, but requires maintenance across all SDKs and breaks on regeneration
  2. Keep Global Parameter: Would maintain JWT convenience but prevent tenantId from being first parameter
  3. Feature Request to Speakeasy: Request TypeScript support for request body field flattening similar to Python

Questions for Discussion

  1. Should we prioritize JWT convenience (optional tenantId) or better parameter ordering (required tenantId)?
  2. Is the trade-off of requiring JWT users to pass tenantId explicitly acceptable?
  3. Should we consider different SDK structures for different authentication methods?
  4. Are there other approaches we should consider?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions