diff --git a/.changesets/1791215442-c54f190b.yaml b/.changesets/1791215442-c54f190b.yaml new file mode 100644 index 00000000..7c010765 --- /dev/null +++ b/.changesets/1791215442-c54f190b.yaml @@ -0,0 +1,10 @@ +id: 1791215442-c54f190b +features: + - publicExports +targets: + - typescriptv2 +type: fix +bump: patch +description: export streaming and per-variant params types for SSE operations with union request bodies, accept the union-level streaming params in method overloads, and deduplicate SSE params aliases (explicit exports now take precedence over generated SSE params aliases on name collisions) +author: 2ynn +date: "2026-10-05" diff --git a/pkg/generate/snapshots/namespaces/public_exports_test.go b/pkg/generate/snapshots/namespaces/public_exports_test.go index 64f75ca0..a8cae6a1 100644 --- a/pkg/generate/snapshots/namespaces/public_exports_test.go +++ b/pkg/generate/snapshots/namespaces/public_exports_test.go @@ -1088,3 +1088,481 @@ export * from "./exports.js"; Expected: expectedSnapshot, }) } + +const sseBodyVariantPublicExportsSpec = `openapi: 3.1.0 +info: + title: SSE Body Variant Public Exports + version: 1.0.0 +servers: + - url: https://api.example.com +paths: + /widgets: + post: + operationId: createWidget + x-speakeasy-group: widgets + x-speakeasy-sse-overload: true + x-speakeasy-exports: + - group: widgets + name: WidgetCreateParams + representation: input + description: >- + SSE overload with a union-of-objects body: the export's namespace + carries the union-level and per-variant params, including region. + parameters: + - name: region + in: query + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateWidgetBody" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Widget" + text/event-stream: + schema: + $ref: "#/components/schemas/WidgetEvent" + /{region}/widgets/{id}: + get: + operationId: getWidget + x-speakeasy-group: widgets + x-speakeasy-sse-overload: true + x-speakeasy-exports: + - group: widgets + name: WidgetGetParams + representation: input + description: >- + SSE overload without a body: no per-variant params are exported. + parameters: + - name: region + in: path + required: true + schema: + type: string + default: us + - name: id + in: path + required: true + schema: + type: string + - name: stream + in: query + schema: + type: boolean + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Widget" + text/event-stream: + schema: + $ref: "#/components/schemas/WidgetEvent" +components: + schemas: + CreateWidgetBody: + oneOf: + - $ref: "#/components/schemas/CreateBasicWidget" + - $ref: "#/components/schemas/CreateCustomWidget" + CreateBasicWidget: + type: object + description: >- + Explicit exports named like per-variant params types keep targeting + this body model. + x-speakeasy-exports: + - group: widgets + name: CreateBasicWidgetParamsStreaming + representation: input + - group: widgets + name: CreateBasicWidgetParamsNonStreaming + representation: input + required: [color, label] + properties: + color: + type: string + label: + type: string + stream: + type: boolean + CreateCustomWidget: + type: object + required: [template, label] + properties: + template: + type: string + label: + type: string + stream: + type: boolean + Widget: + type: object + required: [id] + properties: + id: + type: string + WidgetEvent: + type: object + required: [data] + properties: + data: + type: object + required: [type] + properties: + type: + type: string` + +const sseBodyVariantParamsCheck = `import type { Widgets } from "./resources.js"; +import type * as MP from "./models/operations/method-params.js"; +import type { CreateBasicWidget } from "./models/create-basic-widget.js"; +import type { Widgets as WidgetsSDK } from "./sdk/widgets.js"; + +type Mutual = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type Idx = [A] extends [Record] ? 1 : 0; +type Same = + Mutual extends true + ? Mutual extends true + ? Mutual, Required> extends true + ? Mutual, Idx> + : false + : false + : false; +type IsStream = [Awaited] extends [AsyncIterable] ? true : false; + +export const unionStreaming: Same< + Widgets.WidgetCreateParamsStreaming, + MP.CreateWidgetParamsStreaming +> = true; +export const unionNonStreaming: Same< + Widgets.WidgetCreateParams.WidgetCreateParamsNonStreaming, + MP.CreateWidgetParamsNonStreaming +> = true; +export const variantStreaming: Same< + Widgets.WidgetCreateParams.CreateBasicWidgetParamsStreaming, + MP.CreateBasicWidgetParamsStreaming +> = true; +export const variantBase: Same< + Widgets.WidgetCreateParams.CreateCustomWidgetParams, + MP.CreateCustomWidgetParams +> = true; +export const bodyOnly: Same< + Widgets.CreateBasicWidgetParamsStreaming, + CreateBasicWidget +> = true; + +declare const sdk: WidgetsSDK; +declare const unionStreamingParams: Widgets.WidgetCreateParamsStreaming; +declare const unionNonStreamingParams: Widgets.WidgetCreateParamsNonStreaming; +declare const variantStreamingParams: Widgets.WidgetCreateParams.CreateCustomWidgetParamsStreaming; + +export function overloadChecks() { + const unionStream = sdk.createWidget(unionStreamingParams); + const unionJSON = sdk.createWidget(unionNonStreamingParams); + const variantStream = sdk.createWidget(variantStreamingParams); + const streams: IsStream = true; + const json: IsStream = false; + const variantStreams: IsStream = true; + return [streams, json, variantStreams]; +} +` + +func TestSnapTsPublicExportsSSEBodyVariants(t *testing.T) { + t.Parallel() + + genYaml := `typescript: + packageName: public-exports + methodSignature: params-object + flattenRequests: true + maxMethodParams: 999 +` + + expectedSnapshot := `--- src/models/operations/method-params.ts --- +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + * Generated under the AGPL-3.0-only license. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { CreateBasicWidget } from "../create-basic-widget.js"; +import type { CreateCustomWidget } from "../create-custom-widget.js"; +import type { CreateWidgetRequest } from "./create-widget.js"; +import type { GetWidgetRequest } from "./get-widget.js"; + +export type CreateWidgetParams = + & Omit< + CreateWidgetRequest, + "body" + > + & CreateWidgetRequest["body"]; + +export type CreateWidgetParamsNonStreaming = CreateWidgetParams & { + stream?: false | undefined; +}; + +export type CreateWidgetParamsStreaming = CreateWidgetParams & { stream: true }; + +export type CreateBasicWidgetParams = + & Omit< + CreateWidgetRequest, + "body" + > + & CreateBasicWidget; + +export type CreateBasicWidgetParamsNonStreaming = CreateBasicWidgetParams & { + stream?: false | undefined; +}; + +export type CreateBasicWidgetParamsStreaming = CreateBasicWidgetParams & { + stream: true; +}; + +export type CreateCustomWidgetParams = + & Omit< + CreateWidgetRequest, + "body" + > + & CreateCustomWidget; + +export type CreateCustomWidgetParamsNonStreaming = CreateCustomWidgetParams & { + stream?: false | undefined; +}; + +export type CreateCustomWidgetParamsStreaming = CreateCustomWidgetParams & { + stream: true; +}; + +export type GetWidgetParams = Omit< + GetWidgetRequest, + "id" | "region" +>; + +export type GetWidgetParamsNonStreaming = GetWidgetParams & { + stream?: false | undefined; +}; + +export type GetWidgetParamsStreaming = GetWidgetParams & { stream: true }; + + +--- src/resources.ts --- +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + * Generated under the AGPL-3.0-only license. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { CreateBasicWidget as CreateBasicWidget$Import } from "./models/create-basic-widget.js"; +import type { + CreateBasicWidgetParams as CreateBasicWidgetParams$Import, + CreateBasicWidgetParamsNonStreaming + as CreateBasicWidgetParamsNonStreaming$Import, + CreateBasicWidgetParamsStreaming as CreateBasicWidgetParamsStreaming$Import, + CreateCustomWidgetParams as CreateCustomWidgetParams$Import, + CreateCustomWidgetParamsNonStreaming + as CreateCustomWidgetParamsNonStreaming$Import, + CreateCustomWidgetParamsStreaming as CreateCustomWidgetParamsStreaming$Import, + CreateWidgetParams as CreateWidgetParams$Import, + CreateWidgetParamsNonStreaming as CreateWidgetParamsNonStreaming$Import, + CreateWidgetParamsStreaming as CreateWidgetParamsStreaming$Import, + GetWidgetParams as GetWidgetParams$Import, + GetWidgetParamsNonStreaming as GetWidgetParamsNonStreaming$Import, + GetWidgetParamsStreaming as GetWidgetParamsStreaming$Import, +} from "./models/operations/method-params.js"; +type CreateBasicWidgetParamsNonStreaming$ = CreateBasicWidget$Import; +type CreateBasicWidgetParamsStreaming$ = CreateBasicWidget$Import; +type WidgetCreateParams$ = CreateWidgetParams$Import; +type WidgetGetParams$ = GetWidgetParams$Import; +type WidgetCreateParamsNonStreaming$ = CreateWidgetParamsNonStreaming$Import; +type WidgetCreateParamsStreaming$ = CreateWidgetParamsStreaming$Import; +type CreateBasicWidgetParams$ = CreateBasicWidgetParams$Import; +type CreateBasicWidgetParamsNonStreaming$2 = + CreateBasicWidgetParamsNonStreaming$Import; +type CreateBasicWidgetParamsStreaming$2 = + CreateBasicWidgetParamsStreaming$Import; +type CreateCustomWidgetParams$ = CreateCustomWidgetParams$Import; +type CreateCustomWidgetParamsNonStreaming$ = + CreateCustomWidgetParamsNonStreaming$Import; +type CreateCustomWidgetParamsStreaming$ = + CreateCustomWidgetParamsStreaming$Import; +type WidgetGetParamsNonStreaming$ = GetWidgetParamsNonStreaming$Import; +type WidgetGetParamsStreaming$ = GetWidgetParamsStreaming$Import; +export type CreateBasicWidgetParamsNonStreaming = + CreateBasicWidgetParamsNonStreaming$; +export type CreateBasicWidgetParamsStreaming = + CreateBasicWidgetParamsStreaming$; +export type WidgetCreateParams = WidgetCreateParams$; +export type WidgetCreateParamsNonStreaming = WidgetCreateParamsNonStreaming$; +export type WidgetCreateParamsStreaming = WidgetCreateParamsStreaming$; +export type WidgetGetParams = WidgetGetParams$; +export type WidgetGetParamsNonStreaming = WidgetGetParamsNonStreaming$; +export type WidgetGetParamsStreaming = WidgetGetParamsStreaming$; +export declare namespace Widgets { + export type CreateBasicWidgetParamsNonStreaming = + CreateBasicWidgetParamsNonStreaming$; + export type CreateBasicWidgetParamsStreaming = + CreateBasicWidgetParamsStreaming$; + export type WidgetCreateParams = WidgetCreateParams$; + export type WidgetCreateParamsNonStreaming = WidgetCreateParamsNonStreaming$; + export type WidgetCreateParamsStreaming = WidgetCreateParamsStreaming$; + export type WidgetGetParams = WidgetGetParams$; + export type WidgetGetParamsNonStreaming = WidgetGetParamsNonStreaming$; + export type WidgetGetParamsStreaming = WidgetGetParamsStreaming$; + export namespace WidgetCreateParams { + export type CreateBasicWidgetParams = CreateBasicWidgetParams$; + export type CreateBasicWidgetParamsNonStreaming = + CreateBasicWidgetParamsNonStreaming$2; + export type CreateBasicWidgetParamsStreaming = + CreateBasicWidgetParamsStreaming$2; + export type CreateCustomWidgetParams = CreateCustomWidgetParams$; + export type CreateCustomWidgetParamsNonStreaming = + CreateCustomWidgetParamsNonStreaming$; + export type CreateCustomWidgetParamsStreaming = + CreateCustomWidgetParamsStreaming$; + export type WidgetCreateParamsNonStreaming = + WidgetCreateParamsNonStreaming$; + export type WidgetCreateParamsStreaming = WidgetCreateParamsStreaming$; + } + export namespace WidgetGetParams { + export type WidgetGetParamsNonStreaming = WidgetGetParamsNonStreaming$; + export type WidgetGetParamsStreaming = WidgetGetParamsStreaming$; + } +} + + +--- src/sdk/widgets.ts --- +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + * Generated under the AGPL-3.0-only license. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { widgetsCreateWidget } from "../funcs/widgets-create-widget.js"; +import { widgetsGetWidget } from "../funcs/widgets-get-widget.js"; +import { EventStream } from "../lib/event-streams.js"; +import { ClientSDK, RequestOptions } from "../lib/sdks.js"; +import * as models from "../models/index.js"; +import * as operations from "../models/operations/index.js"; +import { + CreateBasicWidgetParamsNonStreaming, + CreateBasicWidgetParamsStreaming, + CreateCustomWidgetParamsNonStreaming, + CreateCustomWidgetParamsStreaming, + CreateWidgetParams, + CreateWidgetParamsNonStreaming, + CreateWidgetParamsStreaming, + GetWidgetParams, + GetWidgetParamsNonStreaming, + GetWidgetParamsStreaming, +} from "../models/operations/method-params.js"; +import { unwrapAsync } from "../types/fp.js"; + +export class Widgets extends ClientSDK { + /** + * SSE overload with a union-of-objects body: the export's namespace carries the union-level and per-variant params, including region. + */ + async createWidget( + params: CreateBasicWidgetParamsNonStreaming, + options?: RequestOptions, + ): Promise; + async createWidget( + params: CreateCustomWidgetParamsNonStreaming, + options?: RequestOptions, + ): Promise; + async createWidget( + params: CreateBasicWidgetParamsStreaming, + options?: RequestOptions, + ): Promise>; + async createWidget( + params: CreateCustomWidgetParamsStreaming, + options?: RequestOptions, + ): Promise>; + async createWidget( + params: CreateWidgetParamsNonStreaming, + options?: RequestOptions, + ): Promise; + async createWidget( + params: CreateWidgetParamsStreaming, + options?: RequestOptions, + ): Promise>; + async createWidget( + params: CreateWidgetParams, + options?: RequestOptions, + ): Promise; + async createWidget( + params: CreateWidgetParams, + options?: RequestOptions, + ): Promise { + const { region, ...body } = params; + return unwrapAsync(widgetsCreateWidget( + this, + body, + region, + options, + )); + } + + /** + * SSE overload without a body: no per-variant params are exported. + */ + async getWidget( + region: string | undefined, + id: string, + params?: GetWidgetParamsNonStreaming, + options?: RequestOptions, + ): Promise; + async getWidget( + region: string | undefined, + id: string, + params: GetWidgetParamsStreaming, + options?: RequestOptions, + ): Promise>; + async getWidget( + region: string | undefined, + id: string, + params?: GetWidgetParams, + options?: RequestOptions, + ): Promise; + async getWidget( + region: string | undefined, + id: string, + params?: GetWidgetParams, + options?: RequestOptions, + ): Promise { + return unwrapAsync(widgetsGetWidget( + this, + region, + id, + params?.stream, + options, + )); + } +} + + +` // end of snapshot + + snaptest.DoTestSnapshot(t, snaptest.Options{ + Spec: sseBodyVariantPublicExportsSpec, + GenYaml: genYaml, + IncludeGlobs: []string{ + "src/resources.ts", + "src/models/operations/method-params.ts", + "src/sdk/widgets.ts", + }, + Expected: expectedSnapshot, + AfterGenerate: func(t *testing.T, tempDir string) { + t.Helper() + require.NoError(t, os.WriteFile( + filepath.Join(tempDir, "src", "__params_check.ts"), + []byte(sseBodyVariantParamsCheck), + 0o644, + )) + }, + }) +} diff --git a/templates/templates/typescriptv2/includes/method-signature.ts b/templates/templates/typescriptv2/includes/method-signature.ts index bb77672b..c7c34ab8 100644 --- a/templates/templates/typescriptv2/includes/method-signature.ts +++ b/templates/templates/typescriptv2/includes/method-signature.ts @@ -416,16 +416,11 @@ function tsImportMethodParamsTypes( )}/method-params.js`; addImport(path, state.ParamsName, typeImport); if (state.SSE) { - // With per-variant overloads the union NonStreaming/Streaming aliases - // are not referenced by the method. - if (state.BodyVariants.length > 0) { - for (const variant of state.BodyVariants) { - addImport(path, `${variant.ParamsName}NonStreaming`, typeImport); - addImport(path, `${variant.ParamsName}Streaming`, typeImport); - } - } else { - addImport(path, `${state.ParamsName}NonStreaming`, typeImport); - addImport(path, `${state.ParamsName}Streaming`, typeImport); + addImport(path, `${state.ParamsName}NonStreaming`, typeImport); + addImport(path, `${state.ParamsName}Streaming`, typeImport); + for (const variant of state.BodyVariants) { + addImport(path, `${variant.ParamsName}NonStreaming`, typeImport); + addImport(path, `${variant.ParamsName}Streaming`, typeImport); } } return ""; diff --git a/templates/templates/typescriptv2/includes/templating.ts b/templates/templates/typescriptv2/includes/templating.ts index 99129e0b..3eface32 100644 --- a/templates/templates/typescriptv2/includes/templating.ts +++ b/templates/templates/typescriptv2/includes/templating.ts @@ -644,13 +644,16 @@ function buildTypeScriptPublicExportFile(): return node; }; - const addAlias = ( + const hasMember = ( members: TypeScriptPublicExportMember[], aliasName: string, + ): boolean => members.some((member) => member.Name === aliasName); + + const addIntermediate = ( + aliasName: string, path: string, sourceName: string, - canInterface = false, - ) => { + ): string => { const intermediate = uniquePublicExportAlias( `${aliasName}$`, usedIdentifiers, @@ -659,16 +662,50 @@ function buildTypeScriptPublicExportFile(): Name: intermediate, Target: importBinding(path, sourceName), }); + return intermediate; + }; + + const addMember = ( + members: TypeScriptPublicExportMember[], + aliasName: string, + intermediate: string, + canInterface: boolean, + registerFlat: boolean, + ) => { members.push({ Name: aliasName, Intermediate: intermediate, CanInterface: canInterface, }); - if (!flatByName.has(aliasName)) { + if (registerFlat && !flatByName.has(aliasName)) { flatByName.set(aliasName, intermediate); } }; + const addAlias = ( + members: TypeScriptPublicExportMember[], + aliasName: string, + path: string, + sourceName: string, + canInterface = false, + registerFlat = true, + ) => { + addMember( + members, + aliasName, + addIntermediate(aliasName, path, sourceName), + canInterface, + registerFlat, + ); + }; + + const deferredSSEExports: { + node: TypeScriptPublicExportNamespaceBuilder; + aliasName: string; + paramsPath: string; + paramsState: TSMethodParamsState; + }[] = []; + for (const { group, keys, parts } of groups) { const node = nodeForParts(keys, parts); @@ -692,32 +729,8 @@ function buildTypeScriptPublicExportFile(): if (paramsState) { const paramsPath = `${importPrefix}${getOperationsLocation()}/method-params.js`; addAlias(node.Members, aliasName, paramsPath, paramsState.ParamsName); - if (paramsState.SSE && paramsState.BodyVariants.length === 0) { - const variantNode = namespaceNode(node, aliasName, aliasName); - addAlias( - node.Members, - `${aliasName}NonStreaming`, - paramsPath, - `${paramsState.ParamsName}NonStreaming`, - ); - addAlias( - variantNode.Members, - `${aliasName}NonStreaming`, - paramsPath, - `${paramsState.ParamsName}NonStreaming`, - ); - addAlias( - node.Members, - `${aliasName}Streaming`, - paramsPath, - `${paramsState.ParamsName}Streaming`, - ); - addAlias( - variantNode.Members, - `${aliasName}Streaming`, - paramsPath, - `${paramsState.ParamsName}Streaming`, - ); + if (paramsState.SSE) { + deferredSSEExports.push({ node, aliasName, paramsPath, paramsState }); } continue; } @@ -734,6 +747,46 @@ function buildTypeScriptPublicExportFile(): } } + // Deferred so explicit exports keep their names; per-variant params stay + // nested-only so they never claim a flat alias. + const sseSuffixes = ["NonStreaming", "Streaming"]; + for (const { + node, + aliasName, + paramsPath, + paramsState, + } of deferredSSEExports) { + const variantNode = + node.ChildrenByKey.get(aliasName) ?? + node.Children.get(aliasName) ?? + namespaceNode(node, aliasName, aliasName); + for (const suffix of sseSuffixes) { + const name = `${aliasName}${suffix}`; + const targets = [node.Members, variantNode.Members].filter( + (members) => !hasMember(members, name), + ); + if (targets.length === 0) { + continue; + } + const intermediate = addIntermediate( + name, + paramsPath, + `${paramsState.ParamsName}${suffix}`, + ); + for (const members of targets) { + addMember(members, name, intermediate, false, members === node.Members); + } + } + for (const variant of paramsState.BodyVariants) { + for (const suffix of ["", ...sseSuffixes]) { + const name = `${variant.ParamsName}${suffix}`; + if (!hasMember(variantNode.Members, name)) { + addAlias(variantNode.Members, name, paramsPath, name, false, false); + } + } + } + } + const finalizeNamespace = ( node: TypeScriptPublicExportNamespaceBuilder, rootNamespace: boolean, diff --git a/templates/templates/typescriptv2/method.ts.stmpl b/templates/templates/typescriptv2/method.ts.stmpl index 9397749d..15f15e2e 100644 --- a/templates/templates/typescriptv2/method.ts.stmpl +++ b/templates/templates/typescriptv2/method.ts.stmpl @@ -20,7 +20,6 @@ {{- $paramsOpt := "" }}{{- if not $paramsState.ParamsRequired }}{{- $paramsOpt = "?" }}{{- end }} {{- if $sseTypes }} {{ $tsdoc }} -{{- if $paramsState.BodyVariants }} {{- range $variant := $paramsState.BodyVariants }} {{if not $useAPIPromise}}async {{end}}{{$methodName}}( {{- range $field := tsMethodPositionalFields $op }} @@ -61,7 +60,6 @@ options?: {{ $methodOptions }}, ): {{if $useAPIPromise}}API{{end}}Promise<{{$sseTypes.sseReturnType}}>; {{- end }} -{{- else }} {{if not $useAPIPromise}}async {{end}}{{$methodName}}( {{- range $field := tsMethodPositionalFields $op }} {{- $fieldType := resolveOutbound ( @@ -98,7 +96,6 @@ params: {{$paramsState.ParamsName}}Streaming, options?: {{ $methodOptions }}, ): {{if $useAPIPromise}}API{{end}}Promise<{{$sseTypes.sseReturnType}}>; -{{- end }} {{if not $useAPIPromise}}async {{end}}{{$methodName}}( {{- range $field := tsMethodPositionalFields $op }} {{sanitizeFieldName $field.Name}}: {{(resolveOutbound (