Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/tcgc-api-version-map-2026-5-17-10-55-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@azure-tools/typespec-client-generator-core"
---

Support a per-service `api-version` map for multi-service packages. The `api-version` emitter option now accepts either a string (applied to single service packages, or the `latest`/`all` keywords) or a map from each service namespace's full name to its desired version. Services not listed in the map default to their latest version.
9 changes: 7 additions & 2 deletions packages/typespec-client-generator-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,14 @@ When set to `true`, the emitter will generate convenience methods for each servi

### `api-version`

**Type:** `string`
**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.

**Options:**

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`.
- `string`
- `object`

### `license`

Expand Down
7 changes: 3 additions & 4 deletions packages/typespec-client-generator-core/src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function prepareClientAndOperationCache(context: TCGCContext): void {

const servicesNs = new Set<Namespace>();
clients.forEach((c) => c.services.forEach((s) => servicesNs.add(s)));
const isMultiService = servicesNs.size > 1;

// handle versioning with mutated types
context.__packageVersions = new Map<Namespace, string[]>();
Expand All @@ -54,10 +55,8 @@ export function prepareClientAndOperationCache(context: TCGCContext): void {
continue;
}

// Single service needs to filter versions based on `apiVersion` config
if (servicesNs.size === 1) {
removeVersionsLargerThanExplicitlySpecified(context, versions);
}
// Filter versions based on the resolved `apiVersion` config for this service
removeVersionsLargerThanExplicitlySpecified(context, versions, serviceNs, isMultiService);

context.__packageVersionEnum!.set(serviceNs, versions[0].enumMember.enum);
context.__packageVersions!.set(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export interface TCGCContext {
generateConvenienceMethods?: boolean;
examplesDir?: string;
namespaceFlag?: string;
apiVersion?: string;
apiVersion?: string | Record<string, string>;
license?: {
name: string;
company?: string;
Expand Down
166 changes: 114 additions & 52 deletions packages/typespec-client-generator-core/src/internal-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export interface TCGCEmitterOptions extends BrandedSdkEmitterOptionsInterface {
export interface UnbrandedSdkEmitterOptionsInterface {
"generate-protocol-methods"?: boolean;
"generate-convenience-methods"?: boolean;
"api-version"?: string;
"api-version"?: string | Record<string, string>;
license?: {
name: string;
company?: string;
Expand Down Expand Up @@ -364,6 +364,16 @@ export function filterApiVersionsWithDecorators(
type: Type,
apiVersions: string[],
): string[] {
// The service namespace is only needed to resolve a per-service version map.
const isMultiService = context.getPackageVersions().size > 1;
const serviceNamespace =
typeof context.apiVersion === "object" ? getServiceNamespaceForType(context, type) : undefined;
const apiVersion = resolveApiVersionForService(context, serviceNamespace, isMultiService);
// index of the explicitly specified version in the list; -1 means latest / not found
const apiVersionIndex =
apiVersion === undefined || apiVersion === "latest" || apiVersion === "all"
? -1
Comment thread
tadelesh marked this conversation as resolved.
: apiVersions.indexOf(apiVersion);
const addedOnVersions = getAddedOnVersions(context.program, type)?.map((x) => x.value) ?? [];
const removedOnVersions = getRemovedOnVersions(context.program, type)?.map((x) => x.value) ?? [];
let added: boolean = addedOnVersions.length ? false : true;
Expand All @@ -381,13 +391,8 @@ export function filterApiVersionsWithDecorators(
removeCounter++;
}
if (added) {
// only add version smaller than config
if (
context.apiVersion === undefined ||
context.apiVersion === "latest" ||
context.apiVersion === "all" ||
apiVersions.indexOf(context.apiVersion) >= i
) {
// only add version smaller than config (or all versions when no explicit version applies)
if (apiVersionIndex < 0 || apiVersionIndex >= i) {
retval.push(version);
}
}
Expand Down Expand Up @@ -671,14 +676,13 @@ export function getHttpOperationResponseHeaders(
export function removeVersionsLargerThanExplicitlySpecified(
context: TCGCContext,
versions: { value: string | number }[],
serviceNamespace: Namespace | undefined,
isMultiService: boolean,
): void {
// filter with specific api version
if (
context.apiVersion !== undefined &&
context.apiVersion !== "latest" &&
context.apiVersion !== "all"
) {
const index = versions.findIndex((version) => version.value === context.apiVersion);
const apiVersion = resolveApiVersionForService(context, serviceNamespace, isMultiService);
if (apiVersion !== undefined && apiVersion !== "latest" && apiVersion !== "all") {
const index = versions.findIndex((version) => version.value === apiVersion);
if (index >= 0) {
versions.splice(index + 1, versions.length - index - 1);
}
Expand All @@ -689,9 +693,15 @@ export function filterPreviewVersion(
context: TCGCContext,
sdkVersionsEnum: SdkEnumType,
defaultApiVersion: string,
serviceNamespace?: Namespace,
): void {
// if they explicitly set an api version, remove larger versions
removeVersionsLargerThanExplicitlySpecified(context, sdkVersionsEnum.values);
removeVersionsLargerThanExplicitlySpecified(
context,
sdkVersionsEnum.values,
serviceNamespace,
context.getPackageVersions().size > 1,
);
if (!context.previewStringRegex.test(defaultApiVersion)) {
sdkVersionsEnum.values = sdkVersionsEnum.values.filter((v) => {
if (typeof v.value !== "string") {
Expand Down Expand Up @@ -942,66 +952,54 @@ function getVersioningMutator(
export function handleVersioningMutationForGlobalNamespace(context: TCGCContext): Namespace {
const globalNamespace = context.program.getGlobalNamespaceType();

// First consider explicit clients
// Compute the set of service namespaces the SDK targets. This runs before the
// client/operation cache (and thus context.getPackageVersions()) is available,
// so the set is derived directly from explicit `@client`s or `@service`s.
const servicesNs = new Set<Namespace>();
listScopedDecoratorData(context, clientKey).forEach((v, k) => {
// See all explicit clients that in TypeSpec program
if (!unsafe_Realm.realmForType.has(k)) {
(v as SdkClient).services.forEach((s) => servicesNs.add(s));
}
});

// Then see the original services
if (servicesNs.size === 0) {
listServices(context.program).map((v) => servicesNs.add(v.type));
}

// No service, thus no versioning mutation needed
if (servicesNs.size === 0) return globalNamespace;

// Multi services' client should not honor the specific api-version set in config
if (
servicesNs.size > 1 &&
context.apiVersion !== undefined &&
context.apiVersion !== "latest" &&
context.apiVersion !== "all"
) {
context.apiVersion = undefined;
}

// Explicit all API version setting, thus no versioning mutation needed
if (context.apiVersion === "all") return globalNamespace;
const isMultiService = servicesNs.size > 1;

// Compose service mutators
const mutators: unsafe_MutatorWithNamespace[] = [];

for (const serviceNs of servicesNs) {
// Resolve the api-version config that applies to this specific service.
const serviceApiVersion = resolveApiVersionForService(context, serviceNs, isMultiService);

// Explicit `all` setting for this service, keep all its versions (no mutation).
if (serviceApiVersion === "all") continue;

const versions = getVersions(context.program, serviceNs)[1]?.getVersions();
// If the service has no versioning, no mutation needed
if (!versions || versions.length === 0) return globalNamespace;
// If the service has no versioning, no mutation needed for it
if (!versions || versions.length === 0) continue;

// Single service needs to filter versions based on `apiVersion` config
if (servicesNs.size === 1) {
removeVersionsLargerThanExplicitlySpecified(context, versions);
}
// Filter versions based on the `apiVersion` config resolved for this service
removeVersionsLargerThanExplicitlySpecified(context, versions, serviceNs, isMultiService);

const versionsValues = versions.map((v) => v.value);

// Fix apiVersion setting problem only if there's only one service
if (servicesNs.size === 1) {
if (
context.apiVersion !== undefined &&
context.apiVersion !== "latest" &&
context.apiVersion !== "all" &&
!versionsValues.includes(context.apiVersion)
) {
reportDiagnostic(context.program, {
code: "api-version-undefined",
format: { version: context.apiVersion },
target: serviceNs,
});
context.apiVersion = versionsValues[versionsValues.length - 1];
}
// Report when the explicitly specified version does not exist; fall back to the latest
if (
serviceApiVersion !== undefined &&
serviceApiVersion !== "latest" &&
!versionsValues.includes(serviceApiVersion)
) {
reportDiagnostic(context.program, {
code: "api-version-undefined",
format: { version: serviceApiVersion },
target: serviceNs,
});
}

// Get service mutator according to the version setting
Expand All @@ -1020,6 +1018,70 @@ export function handleVersioningMutationForGlobalNamespace(context: TCGCContext)
return subgraph.type;
}

/**
* Resolve the `api-version` config that applies to a specific service namespace.
*
* - 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").
*
* 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
* latest version of each service).
*
* The returned value can be a specific version, the special values `latest` /
* `all`, or `undefined`.
Comment thread
weidongxu-microsoft marked this conversation as resolved.
*/
export function resolveApiVersionForService(
context: TCGCContext,
serviceNamespace: Namespace | undefined,
isMultiService: boolean,
): string | undefined {
const config = context.apiVersion;
if (config === undefined) return undefined;
if (typeof config === "string") {
// `latest` is a global keyword that applies regardless of how many services
// the package targets.
if (config === "latest") return "latest";
// `all` and specific version strings only apply to the single service case;
// multi-service packages do not support `all`.
return isMultiService ? undefined : config;
}
// Record case: map each service namespace's full name to a version.
if (serviceNamespace === undefined) return undefined;
const version = config[getNamespaceFullName(serviceNamespace)];
Comment thread
weidongxu-microsoft marked this conversation as resolved.
// Multi-service packages do not support `all`; fall back to the latest version.
if (version === "all" && isMultiService) return undefined;
return version;
}

/**
* 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
* known service namespace. Returns `undefined` if none is found.
*
* Must only be called after the client/operation cache is prepared, since the
* service namespaces are read from `context.getPackageVersions()`.
*/
function getServiceNamespaceForType(
context: TCGCContext,
type: Type | undefined,
): Namespace | undefined {
if (type === undefined) return undefined;
const services = context.getPackageVersions();
if (services.size === 0) return undefined;

let current: Namespace | undefined = getVersions(context.program, type)[0];
while (current) {
if (services.has(current)) return current;
current = current.namespace;
}
return undefined;
}

export function resolveDuplicateGenearatedName(
context: TCGCContext,
type: Union | Model | TspLiteralType,
Expand Down
26 changes: 20 additions & 6 deletions packages/typespec-client-generator-core/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ import {
UnbrandedSdkEmitterOptionsInterface,
} 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).
const apiVersionSchema = {
oneOf: [
{
type: "string",
nullable: true,
},
{
type: "object",
additionalProperties: { type: "string" },
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.",
} as any;

export const UnbrandedSdkEmitterOptions = {
"generate-protocol-methods": {
"generate-protocol-methods": {
Expand All @@ -23,12 +42,7 @@ export const UnbrandedSdkEmitterOptions = {
},
},
"api-version": {
"api-version": {
type: "string",
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`.",
},
"api-version": apiVersionSchema,
},
license: {
license: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ export function getDefaultApiVersion(
): Version | undefined {
try {
const versions = getVersions(context.program, serviceNamespace)[1]!.getVersions();
removeVersionsLargerThanExplicitlySpecified(context, versions);
removeVersionsLargerThanExplicitlySpecified(
context,
versions,
serviceNamespace,
context.getPackageVersions().size > 1,
);
// follow versioning principals of the versioning library and return last in list
return versions[versions.length - 1];
} catch (e) {
Expand Down
2 changes: 1 addition & 1 deletion packages/typespec-client-generator-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2467,7 +2467,7 @@ export function handleAllTypes(context: TCGCContext): [void, readonly Diagnostic
} else {
sdkVersionsEnum = diagnostics.pipe(getSdkEnumWithDiagnostics(context, versionEnum));
}
filterPreviewVersion(context, sdkVersionsEnum, versions?.at(-1) || "");
filterPreviewVersion(context, sdkVersionsEnum, versions?.at(-1) || "", service);
diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.ApiVersionEnum, sdkVersionsEnum));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1208,7 +1208,7 @@ it("one client from multiple services with `@clientLocation`", async () => {
strictEqual(biOperationApiVersionParam.correspondingMethodParams[0], biApiVersionParam);
});

it("one client from multiple services with api-version set to all", async () => {
it("one client from multiple services with api-version set to all (not supported, falls back to latest)", async () => {
const { program } = await SimpleBaseTester.compile(
createClientCustomizationInput(
`
Expand Down Expand Up @@ -1277,14 +1277,16 @@ it("one client from multiple services with api-version set to all", async () =>
ok(aiApiVersionParam);
strictEqual(aiApiVersionParam.clientDefaultValue, "av3");

// With api-version all, both aTest and aTest2 should be included
strictEqual(aiClient.methods.length, 2);
// Multi-service does not support `all`; it is ignored and each service uses
// its latest version. ServiceA projects to av3, where aTest2 is removed.
strictEqual(aiClient.methods.length, 1);
const aTest = aiClient.methods.find((m) => m.name === "aTest");
ok(aTest);
deepStrictEqual(aTest.apiVersions, ["av1", "av2", "av3"]);
const aTest2 = aiClient.methods.find((m) => m.name === "aTest2");
ok(aTest2);
deepStrictEqual(aTest2.apiVersions, ["av2"]);
strictEqual(
aiClient.methods.find((m) => m.name === "aTest2"),
undefined,
);

const biClient = client.children!.find((c) => c.name === "BI");
ok(biClient);
Expand Down
Loading
Loading