diff --git a/.chronus/changes/support-nested-api-version-config-2026-08-20.md b/.chronus/changes/support-nested-api-version-config-2026-08-20.md new file mode 100644 index 0000000000..0156b6bebb --- /dev/null +++ b/.chronus/changes/support-nested-api-version-config-2026-08-20.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@azure-tools/typespec-client-generator-core" +--- + +Support nested service namespaces in per-service `api-version` configuration. diff --git a/packages/typespec-client-generator-core/README.md b/packages/typespec-client-generator-core/README.md index c1d7a73fa0..1c12fd6894 100644 --- a/packages/typespec-client-generator-core/README.md +++ b/packages/typespec-client-generator-core/README.md @@ -64,7 +64,7 @@ When set to `true`, the emitter will generate convenience methods for each servi **Type:** `string | object` -Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace's full name to its desired version; services not listed default to their latest version. +Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace to its desired version. Nested namespaces must be represented as nested objects in `tspconfig.yaml`; services not listed default to their latest version. **Options:** diff --git a/packages/typespec-client-generator-core/src/context.ts b/packages/typespec-client-generator-core/src/context.ts index a2b516f6ca..ef20350850 100644 --- a/packages/typespec-client-generator-core/src/context.ts +++ b/packages/typespec-client-generator-core/src/context.ts @@ -231,7 +231,7 @@ export async function createSdkContext< generateProtocolMethods: generateProtocolMethods, generateConvenienceMethods: generateConvenienceMethods, namespaceFlag: context.options["namespace"], - apiVersion: context.options["api-version"], + apiVersion: context.options["api-version"] as TCGCContext["apiVersion"], license: context.options["license"], decoratorsAllowList: [...defaultDecoratorsAllowList, ...(options?.additionalDecorators ?? [])], previewStringRegex: options?.versioning?.previewStringRegex || tcgcContext.previewStringRegex, diff --git a/packages/typespec-client-generator-core/src/interfaces.ts b/packages/typespec-client-generator-core/src/interfaces.ts index a8678efbb3..632401db3f 100644 --- a/packages/typespec-client-generator-core/src/interfaces.ts +++ b/packages/typespec-client-generator-core/src/interfaces.ts @@ -35,6 +35,12 @@ import type { ContextNode } from "./internal-utils.js"; type SourceKind = "RequestParameter" | "RequestBody" | "ResponseBody"; +export type ApiVersionConfig = string | ApiVersionServiceMap; + +export interface ApiVersionServiceMap { + [namespaceSegment: string]: string | ApiVersionServiceMap; +} + export interface TCGCContext { program: Program; diagnostics: readonly Diagnostic[]; diff --git a/packages/typespec-client-generator-core/src/internal-utils.ts b/packages/typespec-client-generator-core/src/internal-utils.ts index 064c49201a..dc9d36e2e0 100644 --- a/packages/typespec-client-generator-core/src/internal-utils.ts +++ b/packages/typespec-client-generator-core/src/internal-utils.ts @@ -70,6 +70,8 @@ import { getParamAlias, } from "./decorators.js"; import type { + ApiVersionConfig, + ApiVersionServiceMap, DecoratorInfo, ExternalTypeInfo, SdkBuiltInType, @@ -98,7 +100,7 @@ export interface TCGCEmitterOptions extends BrandedSdkEmitterOptionsInterface { export interface UnbrandedSdkEmitterOptionsInterface { "generate-protocol-methods"?: boolean; "generate-convenience-methods"?: boolean; - "api-version"?: string | Record; + "api-version"?: ApiVersionConfig; license?: { name: string; company?: string; @@ -1016,9 +1018,10 @@ export function handleVersioningMutationForGlobalNamespace(context: TCGCContext) * - When the option is a string: `latest` is a global keyword; any other string * (a specific version or `all`) applies only to the single service case and is * ignored for multi-service packages. - * - When the option is a record, the version is looked up by the service - * namespace's full name. Services that are not listed return `undefined` - * (meaning "use the latest version"). + * - When the option is a map, the version is looked up first by the service + * namespace's full name and then by traversing nested namespace segments. + * Services that are not listed return `undefined` (meaning "use the latest + * version"). * * Multi-service packages do not support the special `all` value (in either the * string or the record form); it is ignored and treated as `undefined` (use the @@ -1042,14 +1045,34 @@ export function resolveApiVersionForService( // multi-service packages do not support `all`. return isMultiService ? undefined : config; } - // Record case: map each service namespace's full name to a version. + // Map case: map each service namespace's full name or nested segments to a version. if (serviceNamespace === undefined) return undefined; - const version = config[getNamespaceFullName(serviceNamespace)]; + const version = resolveApiVersionFromServiceMap( + config as ApiVersionServiceMap, + getNamespaceFullName(serviceNamespace), + ); // Multi-service packages do not support `all`; fall back to the latest version. if (version === "all" && isMultiService) return undefined; return version; } +function resolveApiVersionFromServiceMap( + config: ApiVersionServiceMap, + namespaceName: string, +): string | undefined { + const flatVersion = config[namespaceName]; + if (typeof flatVersion === "string") return flatVersion; + + let current: string | ApiVersionServiceMap | undefined = config; + for (const segment of namespaceName.split(".")) { + if (typeof current !== "object" || current === null || Array.isArray(current)) { + return undefined; + } + current = current[segment]; + } + return typeof current === "string" ? current : undefined; +} + /** * Find the service namespace that owns the given type. Starts from the type's * versioned namespace and walks up the enclosing namespaces until it reaches a diff --git a/packages/typespec-client-generator-core/src/lib.ts b/packages/typespec-client-generator-core/src/lib.ts index 67302bd7f4..425db9b322 100644 --- a/packages/typespec-client-generator-core/src/lib.ts +++ b/packages/typespec-client-generator-core/src/lib.ts @@ -11,7 +11,7 @@ import type { } from "./internal-utils.js"; // `api-version` accepts either a string (single service / `latest` / `all`) or a -// map from service namespace full name to version (multi-service). +// map from service namespace names or nested namespace segments to versions. const apiVersionSchema = { oneOf: [ { @@ -20,13 +20,22 @@ const apiVersionSchema = { }, { type: "object", - additionalProperties: { type: "string" }, + additionalProperties: { + oneOf: [ + { type: "string" }, + { + type: "object", + additionalProperties: true, + required: [], + }, + ], + }, required: [], nullable: true, }, ], description: - "Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace's full name to its desired version; services not listed default to their latest version.", + "Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace to its desired version. Nested namespaces must be represented as nested objects in `tspconfig.yaml`; services not listed default to their latest version.", } as any; export const UnbrandedSdkEmitterOptions = { diff --git a/packages/typespec-client-generator-core/test/examples/load.test.ts b/packages/typespec-client-generator-core/test/examples/load.test.ts index ded10fd711..6245285e90 100644 --- a/packages/typespec-client-generator-core/test/examples/load.test.ts +++ b/packages/typespec-client-generator-core/test/examples/load.test.ts @@ -469,6 +469,101 @@ it("multiple services without versioning", async () => { strictEqual(biMethod.operation.examples[0].name, "Test operation from ServiceB"); }); +it("multiple nested services use configured api versions to find examples", async () => { + const instance = await SimpleBaseTester.createInstance(); + instance.fs.addTypeSpecFile( + "./Network/examples/v1/NetworkOperations_get.json", + JSON.stringify({ + operationId: "NetworkOperations_get", + title: "Network v1", + responses: { "200": { body: "network-v1" } }, + }), + ); + instance.fs.addTypeSpecFile( + "./Network/examples/v2/NetworkOperations_get.json", + JSON.stringify({ + operationId: "NetworkOperations_get", + title: "Network v2", + responses: { "200": { body: "network-v2" } }, + }), + ); + instance.fs.addTypeSpecFile( + "./Compute/examples/v1/ComputeOperations_get.json", + JSON.stringify({ + operationId: "ComputeOperations_get", + title: "Compute v1", + responses: { "200": { body: "compute-v1" } }, + }), + ); + instance.fs.addTypeSpecFile( + "./Compute/examples/v2/ComputeOperations_get.json", + JSON.stringify({ + operationId: "ComputeOperations_get", + title: "Compute v2", + responses: { "200": { body: "compute-v2" } }, + }), + ); + + const { program } = await instance.compile( + createClientCustomizationInput( + ` + @service + @versioned(Microsoft.Network.Versions) + namespace Microsoft.Network { + enum Versions { + v1, + v2, + } + interface NetworkOperations { + op get(): string; + } + } + + @service + @versioned(Microsoft.Compute.Versions) + namespace Microsoft.Compute { + enum Versions { + v1, + v2, + } + interface ComputeOperations { + op get(): string; + } + } + `, + ` + @client({ + name: "CombinedClient", + service: [Microsoft.Network, Microsoft.Compute], + autoMergeService: true, + }) + namespace Combined; + `, + ), + ); + const context = await createSdkContextForTester(program, { + "api-version": { + Microsoft: { + Network: "v1", + Compute: "v1", + }, + }, + }); + + const client = context.sdkPackage.clients[0]; + const networkClient = client.children?.find((child) => child.name === "NetworkOperations"); + ok(networkClient); + const networkMethod = networkClient.methods[0] as SdkServiceMethod; + strictEqual(networkMethod.operation.examples?.[0].filePath, "v1/NetworkOperations_get.json"); + strictEqual(networkMethod.operation.examples?.[0].name, "Network v1"); + + const computeClient = client.children?.find((child) => child.name === "ComputeOperations"); + ok(computeClient); + const computeMethod = computeClient.methods[0] as SdkServiceMethod; + strictEqual(computeMethod.operation.examples?.[0].filePath, "v1/ComputeOperations_get.json"); + strictEqual(computeMethod.operation.examples?.[0].name, "Compute v1"); +}); + it("multiple services without examples", async () => { const { program } = await SimpleBaseTester.compile( createClientCustomizationInput( diff --git a/packages/typespec-client-generator-core/test/package/api-versions-metadata.test.ts b/packages/typespec-client-generator-core/test/package/api-versions-metadata.test.ts index 6edc2ee10a..755a8af4d1 100644 --- a/packages/typespec-client-generator-core/test/package/api-versions-metadata.test.ts +++ b/packages/typespec-client-generator-core/test/package/api-versions-metadata.test.ts @@ -1,5 +1,8 @@ +import { getDirectoryPath, resolveCompilerOptions } from "@typespec/compiler"; +import { resolveVirtualPath } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { it } from "vitest"; +import { parse } from "yaml"; import { createClientCustomizationInput, createSdkContextForTester, @@ -36,6 +39,84 @@ it("single service with versioning should populate apiVersions map", async () => strictEqual(sdkPackage.metadata.apiVersions.get("WidgetService"), "v3"); }); +it("supports nested service namespaces in tspconfig.yaml", async () => { + const tester = await SimpleBaseTester.createInstance(); + const spec = createClientCustomizationInput( + ` + @service + @versioned(Microsoft.Network.Versions) + namespace Microsoft.Network { + enum Versions { + v1, + v2, + } + op networkTest(): void; + } + + @service + @versioned(Microsoft.Compute.Versions) + namespace Microsoft.Compute { + enum Versions { + v1, + v2, + } + op computeTest(): void; + } + `, + ` + @client({ + name: "CombinedClient", + service: [Microsoft.Network, Microsoft.Compute], + autoMergeService: true, + }) + namespace Combined; + `, + ); + tester.fs.addTypeSpecFile("main.tsp", ""); + tester.fs.addTypeSpecFile( + "tspconfig.yaml", + ` +emit: + - "@azure-tools/typespec-client-generator-core" +options: + "@azure-tools/typespec-client-generator-core": + api-version: + Microsoft: + Network: v1 + Compute: v1 +`, + ); + + const entrypoint = resolveVirtualPath("main.tsp"); + const [compilerOptions, configDiagnostics] = await resolveCompilerOptions( + tester.fs.compilerHost, + { + cwd: getDirectoryPath(entrypoint), + entrypoint, + }, + ); + strictEqual(configDiagnostics.length, 0); + + const [, diagnostics] = await tester.compileAndDiagnose(spec, { + compilerOptions, + }); + strictEqual( + diagnostics.length, + 0, + diagnostics.map((diagnostic) => diagnostic.message).join("\n"), + ); + + const output = [...tester.fs.fs.entries()].find(([path]) => + path.endsWith("tcgc-output.yaml"), + )?.[1]; + ok(output); + const sdkPackage = parse(output); + deepStrictEqual(sdkPackage.metadata.apiVersions, { + "Microsoft.Network": "v1", + "Microsoft.Compute": "v1", + }); +}); + it("multiple services should populate apiVersions map with all services", async () => { const { program } = await SimpleBaseTester.compile( createClientCustomizationInput( diff --git a/packages/typespec-java/README.md b/packages/typespec-java/README.md index fc2147cbeb..c5356c72dc 100644 --- a/packages/typespec-java/README.md +++ b/packages/typespec-java/README.md @@ -229,7 +229,7 @@ When set to `true`, the generated SDK uses `getter` method to access child clien **Type:** `string | object` -Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace's full name to its desired version; services not listed default to their latest version. +Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace to its desired version. Nested namespaces must be represented as nested objects in `tspconfig.yaml`; services not listed default to their latest version. **Options:** diff --git a/packages/typespec-python/README.md b/packages/typespec-python/README.md index afdff948ed..2f16a5c379 100644 --- a/packages/typespec-python/README.md +++ b/packages/typespec-python/README.md @@ -82,7 +82,7 @@ Whether to generate test files, for basic testing of your generated sdks. Defaul **Type:** `string | object` -Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace's full name to its desired version; services not listed default to their latest version. +Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace to its desired version. Nested namespaces must be represented as nested objects in `tspconfig.yaml`; services not listed default to their latest version. **Options:** diff --git a/website/src/content/docs/docs/emitters/clients/typespec-java/reference/emitter.md b/website/src/content/docs/docs/emitters/clients/typespec-java/reference/emitter.md index 7050e998a2..29c0996899 100644 --- a/website/src/content/docs/docs/emitters/clients/typespec-java/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/clients/typespec-java/reference/emitter.md @@ -186,7 +186,7 @@ When set to `true`, the generated SDK uses `getter` method to access child clien **Type:** `string | object` -Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace's full name to its desired version; services not listed default to their latest version. +Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace to its desired version. Nested namespaces must be represented as nested objects in `tspconfig.yaml`; services not listed default to their latest version. **Options:** diff --git a/website/src/content/docs/docs/emitters/clients/typespec-python/reference/emitter.md b/website/src/content/docs/docs/emitters/clients/typespec-python/reference/emitter.md index acf829ab10..dcfe6779f2 100644 --- a/website/src/content/docs/docs/emitters/clients/typespec-python/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/clients/typespec-python/reference/emitter.md @@ -76,7 +76,7 @@ Whether to generate test files, for basic testing of your generated sdks. Defaul **Type:** `string | object` -Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace's full name to its desired version; services not listed default to their latest version. +Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace to its desired version. Nested namespaces must be represented as nested objects in `tspconfig.yaml`; services not listed default to their latest version. **Options:** diff --git a/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/emitter.md b/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/emitter.md index d4c3e4ab8c..758d86b1d4 100644 --- a/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/emitter.md +++ b/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/emitter.md @@ -58,7 +58,7 @@ When set to `true`, the emitter will generate convenience methods for each servi **Type:** `string | object` -Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace's full name to its desired version; services not listed default to their latest version. +Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`. For multi-service packages, provide a map from each service namespace to its desired version. Nested namespaces must be represented as nested objects in `tspconfig.yaml`; services not listed default to their latest version. **Options:**