Skip to content

Commit 40aa6fc

Browse files
tadeleshtadeleshCopilotiscai-msft
authored
feat(typespec-client-generator-core): support per-service api-version map for multi-service packages (#4650)
Fixes #4009 ## Summary Extends the TCGC `api-version` emitter option to accept either a `string` or a `Record<string, string>` (a map from each service namespace's full name to its desired version), enabling per-service version selection for multi-service packages. ## Behavior - **`string`** (e.g. `"2024-10-01"`): applies to single-service packages only. Ignored for multi-service. - **`"latest"`**: global keyword, applies regardless of service count. - **`"all"`**: single-service only. **Multi-service packages do not support `all`** (in either string or map form) — it is ignored and each service falls back to its latest version. - **`Record<string, string>`** (e.g. `{ "ServiceA": "av1", "ServiceB": "bv2" }`): maps each service namespace full name to a version. Services not listed default to their latest version. ## Implementation notes - Added `resolveApiVersionForService` as the single source of truth for resolving the version that applies to a given service. - `getServiceNamespaces` is memoized on the context (`__serviceNamespaces`) since it is consulted on the per-type hot path via `getAvailableApiVersions`. - `handleVersioningMutationForGlobalNamespace` now mutates each service independently using its resolved version. ## Validation - Added multi-service map tests (per-service versions, fallback to latest, `all` ignored). - Full TCGC suite: 1330 passed / 2 skipped. TypeScript build and ESLint clean. --------- Co-authored-by: tadelesh <chenjieshi@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: iscai-msft <isabellavcai@gmail.com>
1 parent 0823cd9 commit 40aa6fc

11 files changed

Lines changed: 289 additions & 75 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: feature
3+
packages:
4+
- "@azure-tools/typespec-client-generator-core"
5+
---
6+
7+
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.

packages/typespec-client-generator-core/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,14 @@ When set to `true`, the emitter will generate convenience methods for each servi
6262

6363
### `api-version`
6464

65-
**Type:** `string`
65+
**Type:** `string | object`
66+
67+
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.
68+
69+
**Options:**
6670

67-
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`.
71+
- `string`
72+
- `object`
6873

6974
### `license`
7075

packages/typespec-client-generator-core/src/cache.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export function prepareClientAndOperationCache(context: TCGCContext): void {
4141

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

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

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

6261
context.__packageVersionEnum!.set(serviceNs, versions[0].enumMember.enum);
6362
context.__packageVersions!.set(

packages/typespec-client-generator-core/src/interfaces.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface TCGCContext {
4141
generateConvenienceMethods?: boolean;
4242
examplesDir?: string;
4343
namespaceFlag?: string;
44-
apiVersion?: string;
44+
apiVersion?: string | Record<string, string>;
4545
license?: {
4646
name: string;
4747
company?: string;

packages/typespec-client-generator-core/src/internal-utils.ts

Lines changed: 114 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export interface TCGCEmitterOptions extends BrandedSdkEmitterOptionsInterface {
9898
export interface UnbrandedSdkEmitterOptionsInterface {
9999
"generate-protocol-methods"?: boolean;
100100
"generate-convenience-methods"?: boolean;
101-
"api-version"?: string;
101+
"api-version"?: string | Record<string, string>;
102102
license?: {
103103
name: string;
104104
company?: string;
@@ -364,6 +364,16 @@ export function filterApiVersionsWithDecorators(
364364
type: Type,
365365
apiVersions: string[],
366366
): string[] {
367+
// The service namespace is only needed to resolve a per-service version map.
368+
const isMultiService = context.getPackageVersions().size > 1;
369+
const serviceNamespace =
370+
typeof context.apiVersion === "object" ? getServiceNamespaceForType(context, type) : undefined;
371+
const apiVersion = resolveApiVersionForService(context, serviceNamespace, isMultiService);
372+
// index of the explicitly specified version in the list; -1 means latest / not found
373+
const apiVersionIndex =
374+
apiVersion === undefined || apiVersion === "latest" || apiVersion === "all"
375+
? -1
376+
: apiVersions.indexOf(apiVersion);
367377
const addedOnVersions = getAddedOnVersions(context.program, type)?.map((x) => x.value) ?? [];
368378
const removedOnVersions = getRemovedOnVersions(context.program, type)?.map((x) => x.value) ?? [];
369379
let added: boolean = addedOnVersions.length ? false : true;
@@ -381,13 +391,8 @@ export function filterApiVersionsWithDecorators(
381391
removeCounter++;
382392
}
383393
if (added) {
384-
// only add version smaller than config
385-
if (
386-
context.apiVersion === undefined ||
387-
context.apiVersion === "latest" ||
388-
context.apiVersion === "all" ||
389-
apiVersions.indexOf(context.apiVersion) >= i
390-
) {
394+
// only add version smaller than config (or all versions when no explicit version applies)
395+
if (apiVersionIndex < 0 || apiVersionIndex >= i) {
391396
retval.push(version);
392397
}
393398
}
@@ -671,14 +676,13 @@ export function getHttpOperationResponseHeaders(
671676
export function removeVersionsLargerThanExplicitlySpecified(
672677
context: TCGCContext,
673678
versions: { value: string | number }[],
679+
serviceNamespace: Namespace | undefined,
680+
isMultiService: boolean,
674681
): void {
675682
// filter with specific api version
676-
if (
677-
context.apiVersion !== undefined &&
678-
context.apiVersion !== "latest" &&
679-
context.apiVersion !== "all"
680-
) {
681-
const index = versions.findIndex((version) => version.value === context.apiVersion);
683+
const apiVersion = resolveApiVersionForService(context, serviceNamespace, isMultiService);
684+
if (apiVersion !== undefined && apiVersion !== "latest" && apiVersion !== "all") {
685+
const index = versions.findIndex((version) => version.value === apiVersion);
682686
if (index >= 0) {
683687
versions.splice(index + 1, versions.length - index - 1);
684688
}
@@ -689,9 +693,15 @@ export function filterPreviewVersion(
689693
context: TCGCContext,
690694
sdkVersionsEnum: SdkEnumType,
691695
defaultApiVersion: string,
696+
serviceNamespace?: Namespace,
692697
): void {
693698
// if they explicitly set an api version, remove larger versions
694-
removeVersionsLargerThanExplicitlySpecified(context, sdkVersionsEnum.values);
699+
removeVersionsLargerThanExplicitlySpecified(
700+
context,
701+
sdkVersionsEnum.values,
702+
serviceNamespace,
703+
context.getPackageVersions().size > 1,
704+
);
695705
if (!context.previewStringRegex.test(defaultApiVersion)) {
696706
sdkVersionsEnum.values = sdkVersionsEnum.values.filter((v) => {
697707
if (typeof v.value !== "string") {
@@ -942,66 +952,54 @@ function getVersioningMutator(
942952
export function handleVersioningMutationForGlobalNamespace(context: TCGCContext): Namespace {
943953
const globalNamespace = context.program.getGlobalNamespaceType();
944954

945-
// First consider explicit clients
955+
// Compute the set of service namespaces the SDK targets. This runs before the
956+
// client/operation cache (and thus context.getPackageVersions()) is available,
957+
// so the set is derived directly from explicit `@client`s or `@service`s.
946958
const servicesNs = new Set<Namespace>();
947959
listScopedDecoratorData(context, clientKey).forEach((v, k) => {
948-
// See all explicit clients that in TypeSpec program
949960
if (!unsafe_Realm.realmForType.has(k)) {
950961
(v as SdkClient).services.forEach((s) => servicesNs.add(s));
951962
}
952963
});
953-
954-
// Then see the original services
955964
if (servicesNs.size === 0) {
956965
listServices(context.program).map((v) => servicesNs.add(v.type));
957966
}
958967

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

962-
// Multi services' client should not honor the specific api-version set in config
963-
if (
964-
servicesNs.size > 1 &&
965-
context.apiVersion !== undefined &&
966-
context.apiVersion !== "latest" &&
967-
context.apiVersion !== "all"
968-
) {
969-
context.apiVersion = undefined;
970-
}
971-
972-
// Explicit all API version setting, thus no versioning mutation needed
973-
if (context.apiVersion === "all") return globalNamespace;
971+
const isMultiService = servicesNs.size > 1;
974972

975973
// Compose service mutators
976974
const mutators: unsafe_MutatorWithNamespace[] = [];
977975

978976
for (const serviceNs of servicesNs) {
977+
// Resolve the api-version config that applies to this specific service.
978+
const serviceApiVersion = resolveApiVersionForService(context, serviceNs, isMultiService);
979+
980+
// Explicit `all` setting for this service, keep all its versions (no mutation).
981+
if (serviceApiVersion === "all") continue;
982+
979983
const versions = getVersions(context.program, serviceNs)[1]?.getVersions();
980-
// If the service has no versioning, no mutation needed
981-
if (!versions || versions.length === 0) return globalNamespace;
984+
// If the service has no versioning, no mutation needed for it
985+
if (!versions || versions.length === 0) continue;
982986

983-
// Single service needs to filter versions based on `apiVersion` config
984-
if (servicesNs.size === 1) {
985-
removeVersionsLargerThanExplicitlySpecified(context, versions);
986-
}
987+
// Filter versions based on the `apiVersion` config resolved for this service
988+
removeVersionsLargerThanExplicitlySpecified(context, versions, serviceNs, isMultiService);
987989

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

990-
// Fix apiVersion setting problem only if there's only one service
991-
if (servicesNs.size === 1) {
992-
if (
993-
context.apiVersion !== undefined &&
994-
context.apiVersion !== "latest" &&
995-
context.apiVersion !== "all" &&
996-
!versionsValues.includes(context.apiVersion)
997-
) {
998-
reportDiagnostic(context.program, {
999-
code: "api-version-undefined",
1000-
format: { version: context.apiVersion },
1001-
target: serviceNs,
1002-
});
1003-
context.apiVersion = versionsValues[versionsValues.length - 1];
1004-
}
992+
// Report when the explicitly specified version does not exist; fall back to the latest
993+
if (
994+
serviceApiVersion !== undefined &&
995+
serviceApiVersion !== "latest" &&
996+
!versionsValues.includes(serviceApiVersion)
997+
) {
998+
reportDiagnostic(context.program, {
999+
code: "api-version-undefined",
1000+
format: { version: serviceApiVersion },
1001+
target: serviceNs,
1002+
});
10051003
}
10061004

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

1021+
/**
1022+
* Resolve the `api-version` config that applies to a specific service namespace.
1023+
*
1024+
* - When the option is a string: `latest` is a global keyword; any other string
1025+
* (a specific version or `all`) applies only to the single service case and is
1026+
* ignored for multi-service packages.
1027+
* - When the option is a record, the version is looked up by the service
1028+
* namespace's full name. Services that are not listed return `undefined`
1029+
* (meaning "use the latest version").
1030+
*
1031+
* Multi-service packages do not support the special `all` value (in either the
1032+
* string or the record form); it is ignored and treated as `undefined` (use the
1033+
* latest version of each service).
1034+
*
1035+
* The returned value can be a specific version, the special values `latest` /
1036+
* `all`, or `undefined`.
1037+
*/
1038+
export function resolveApiVersionForService(
1039+
context: TCGCContext,
1040+
serviceNamespace: Namespace | undefined,
1041+
isMultiService: boolean,
1042+
): string | undefined {
1043+
const config = context.apiVersion;
1044+
if (config === undefined) return undefined;
1045+
if (typeof config === "string") {
1046+
// `latest` is a global keyword that applies regardless of how many services
1047+
// the package targets.
1048+
if (config === "latest") return "latest";
1049+
// `all` and specific version strings only apply to the single service case;
1050+
// multi-service packages do not support `all`.
1051+
return isMultiService ? undefined : config;
1052+
}
1053+
// Record case: map each service namespace's full name to a version.
1054+
if (serviceNamespace === undefined) return undefined;
1055+
const version = config[getNamespaceFullName(serviceNamespace)];
1056+
// Multi-service packages do not support `all`; fall back to the latest version.
1057+
if (version === "all" && isMultiService) return undefined;
1058+
return version;
1059+
}
1060+
1061+
/**
1062+
* Find the service namespace that owns the given type. Starts from the type's
1063+
* versioned namespace and walks up the enclosing namespaces until it reaches a
1064+
* known service namespace. Returns `undefined` if none is found.
1065+
*
1066+
* Must only be called after the client/operation cache is prepared, since the
1067+
* service namespaces are read from `context.getPackageVersions()`.
1068+
*/
1069+
function getServiceNamespaceForType(
1070+
context: TCGCContext,
1071+
type: Type | undefined,
1072+
): Namespace | undefined {
1073+
if (type === undefined) return undefined;
1074+
const services = context.getPackageVersions();
1075+
if (services.size === 0) return undefined;
1076+
1077+
let current: Namespace | undefined = getVersions(context.program, type)[0];
1078+
while (current) {
1079+
if (services.has(current)) return current;
1080+
current = current.namespace;
1081+
}
1082+
return undefined;
1083+
}
1084+
10231085
export function resolveDuplicateGenearatedName(
10241086
context: TCGCContext,
10251087
type: Union | Model | TspLiteralType,

packages/typespec-client-generator-core/src/lib.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ import {
55
UnbrandedSdkEmitterOptionsInterface,
66
} from "./internal-utils.js";
77

8+
// `api-version` accepts either a string (single service / `latest` / `all`) or a
9+
// map from service namespace full name to version (multi-service).
10+
const apiVersionSchema = {
11+
oneOf: [
12+
{
13+
type: "string",
14+
nullable: true,
15+
},
16+
{
17+
type: "object",
18+
additionalProperties: { type: "string" },
19+
required: [],
20+
nullable: true,
21+
},
22+
],
23+
description:
24+
"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.",
25+
} as any;
26+
827
export const UnbrandedSdkEmitterOptions = {
928
"generate-protocol-methods": {
1029
"generate-protocol-methods": {
@@ -23,12 +42,7 @@ export const UnbrandedSdkEmitterOptions = {
2342
},
2443
},
2544
"api-version": {
26-
"api-version": {
27-
type: "string",
28-
nullable: true,
29-
description:
30-
"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`.",
31-
},
45+
"api-version": apiVersionSchema,
3246
},
3347
license: {
3448
license: {

packages/typespec-client-generator-core/src/public-utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,12 @@ export function getDefaultApiVersion(
7474
): Version | undefined {
7575
try {
7676
const versions = getVersions(context.program, serviceNamespace)[1]!.getVersions();
77-
removeVersionsLargerThanExplicitlySpecified(context, versions);
77+
removeVersionsLargerThanExplicitlySpecified(
78+
context,
79+
versions,
80+
serviceNamespace,
81+
context.getPackageVersions().size > 1,
82+
);
7883
// follow versioning principals of the versioning library and return last in list
7984
return versions[versions.length - 1];
8085
} catch (e) {

packages/typespec-client-generator-core/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2467,7 +2467,7 @@ export function handleAllTypes(context: TCGCContext): [void, readonly Diagnostic
24672467
} else {
24682468
sdkVersionsEnum = diagnostics.pipe(getSdkEnumWithDiagnostics(context, versionEnum));
24692469
}
2470-
filterPreviewVersion(context, sdkVersionsEnum, versions?.at(-1) || "");
2470+
filterPreviewVersion(context, sdkVersionsEnum, versions?.at(-1) || "", service);
24712471
diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.ApiVersionEnum, sdkVersionsEnum));
24722472
}
24732473
}

packages/typespec-client-generator-core/test/clients/structure.test.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,7 +1208,7 @@ it("one client from multiple services with `@clientLocation`", async () => {
12081208
strictEqual(biOperationApiVersionParam.correspondingMethodParams[0], biApiVersionParam);
12091209
});
12101210

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

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

12891291
const biClient = client.children!.find((c) => c.name === "BI");
12901292
ok(biClient);

0 commit comments

Comments
 (0)