diff --git a/.chronus/changes/tcgc-multiple-service-2025-11-10-15-45-5.md b/.chronus/changes/tcgc-multiple-service-2025-11-10-15-45-5.md new file mode 100644 index 0000000000..e31a517e0c --- /dev/null +++ b/.chronus/changes/tcgc-multiple-service-2025-11-10-15-45-5.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-client-generator-core" +--- + +Add support for a single client from multiple services \ No newline at end of file diff --git a/packages/typespec-client-generator-core/design-docs/multiple-services.md b/packages/typespec-client-generator-core/design-docs/multiple-services.md new file mode 100644 index 0000000000..2c10908b19 --- /dev/null +++ b/packages/typespec-client-generator-core/design-docs/multiple-services.md @@ -0,0 +1,176 @@ +# Multiple Service Support in TypeSpec Client Generator Core + +## Background + +Previously, TCGC [client](./client.md) only supported generating a client from a single service. However, in real-world scenarios, a single package often contains multiple services. TCGC must support multiple services within one package to address these needs. + +## User Scenario + +1. Merging multiple services' namespaces into one client + +This scenario is common in Azure management services. For example, the compute team maintains several services: `Compute`, `Disk`, `Gallery`, and `Sku`. These services share the same endpoint and credential but have different versioning. When migrating these services into TypeSpec, services team wants to follow the existing way to generate SDK: geneate one SDK with a single client that could manage all these services with different versioning, instead of generate multiple SDKs for these multiple services. Therefore, TCGC must support auto-merging operations and nested namespaces/interfaces from multiple services with different versioning into one client. + +For example, given two services `Disk` and `Gallery`, with Python SDK, the generated client code should look like: + +```python +client = ComputeManagementClient(credential=DefaultAzureCredential(), subscription_id="{subscription-id}") +client.disks.list_by_resource_group(resource_group_name="myResourceGroup") # this operation will use API version defined in Disk service +client.galleries.list(location="eastus") # this operation will use API version defined in Gallery service +``` + +## First Step Design + +The initial design focuses on the scenario of auto-merging multiple services into a single client. For other scenarios, such as redefining client hierarchy for multiple services, we will consider them in future iterations. + +### Syntax Proposal + +Previously, the `service` property of the `@client` option only supported a single service. We propose extending it to accept an array of services. + +For example, given two services, the original service specs are: + +```typespec title="ServiceA/main.tsp" +@service +@versioned(VersionsA) +namespace ServiceA; + +enum VersionsA { + av1, + av2, +} +interface AI { + @route("/aTest") + aTest(@query("api-version") apiVersion: VersionsA): void; +} +``` + +```typespec title="ServiceB/main.tsp" +@service +@versioned(VersionsB) +namespace ServiceB; + +enum VersionsB { + bv1, + bv2, +} +interface BI { + @route("/bTest") + bTest(@query("api-version") apiVersion: VersionsB): void; +} +``` + +To define a combined client: + +```typespec title="client.tsp" +import "./ServiceA/main.tsp"; +import "./ServiceB/main.tsp"; +import "@azure-tools/typespec-client-generator-core"; + +using Azure.ClientGenerator.Core; + +@client({ + name: "CombineClient", + service: [ServiceA, ServiceB], +}) +@useDependency(ServiceA.VersionsA.av2, ServiceB.VersionsB.bv2) // optional +namespace CombineClient; +``` + +**Explanation:** + +- `@client` with the `service` property as an array indicates a combined client. +- `@useDependency` specifies the version for each service: + - If all services are unversioned, `@useDependency` can be omitted. + - If any service is versioned and `@useDependency` is not set, TCGC will use the latest version for each service by default. +- Only one `@client` with multiple services can be defined per package. Defining multiple such clients or mixing with `@operationGroup` is not supported. + +### TCGC Behavior + +When TCGC detects multiple services in one client, it will: + +1. Create the root client for the combined client. If any service is versioned, the root client's initialization method will have an `apiVersion` parameter with no default value. The `apiVersions` property and the `apiVersion` parameter for the root client will be empty (since multiple services' API versions cannot be combined). The root client's endpoint and credential parameters will be created based on the first sub-service, which means all sub-services must share the same endpoint and credential. +2. Create sub-clients for each service's nested namespaces or interfaces. Each sub-client will have its own `apiVersion` property and initialization method if the service is versioned. +3. Operations directly under each service's namespace are placed under the root client. Operations under nested namespaces or interfaces are placed under the corresponding sub-clients. +4. Decorators such as `@clientLocation`, `@convenientAPI`, `@protocolAPI`, `@moveTo`, and `@scope` work as usual. +5. All other TCGC logic remains unchanged. +6. Since TCGC does not check if merging multiple services will cause sub-clients, models, operations, or other name conflicts, emitters must handle these conflicts appropriately. + +For the example above, TCGC will generate types like: + +```yaml +clients: + - &a1 + kind: client + name: CombineClient + apiVersions: [] + clientInitialization: + kind: clientinitialization + parameters: + - kind: endpoint + name: endpoint + isGeneratedName: true + onClient: true + - kind: method + name: apiVersion + apiVersions: [] + clientDefaultValue: undefined + isGeneratedName: false + onClient: true + name: CombineClientOptions + isGeneratedName: true + initializedBy: individually + children: + - kind: client + name: AI + parent: *a1 + apiVersions: + - av1 + - av2 + initialization: + kind: clientinitialization + parameters: + - kind: endpoint + name: endpoint + isGeneratedName: true + onClient: true + - kind: method + name: apiVersion + apiVersions: + - av1 + - av2 + clientDefaultValue: av2 + isGeneratedName: false + onClient: true + name: AIOptions + isGeneratedName: true + initializedBy: parent + methods: + - kind: basic + name: aTest + - kind: client + name: BI + parent: *a1 + apiVersions: + - bv1 + - bv2 + initialization: + kind: clientinitialization + parameters: + - kind: endpoint + name: endpoint + isGeneratedName: true + onClient: true + - kind: method + name: apiVersion + apiVersions: + - bv1 + - bv2 + clientDefaultValue: bv2 + isGeneratedName: false + onClient: true + name: BIOptions + isGeneratedName: true + initializedBy: parent + methods: + - kind: basic + name: bTest +``` diff --git a/packages/typespec-client-generator-core/lib/decorators.tsp b/packages/typespec-client-generator-core/lib/decorators.tsp index fb6c0752ef..239bdd6de9 100644 --- a/packages/typespec-client-generator-core/lib/decorators.tsp +++ b/packages/typespec-client-generator-core/lib/decorators.tsp @@ -163,10 +163,10 @@ extern dec client(target: Namespace | Interface, options?: ClientOptions, scope? */ model ClientOptions { /** - * The service that this client is generated for. If not specified, TCGC will look up the first parent namespace decorated with `@service` for the target. + * The services that this client is generated for. If not specified, TCGC will look up the first parent namespace decorated with `@service` for the target. * The namespace should be decorated with `@service`. */ - service?: Namespace; + service?: Namespace | Namespace[]; /** * The name of the client. If not specified, the default name will be `Client`. diff --git a/packages/typespec-client-generator-core/src/cache.ts b/packages/typespec-client-generator-core/src/cache.ts index 30e32d8876..74dddd9d9f 100644 --- a/packages/typespec-client-generator-core/src/cache.ts +++ b/packages/typespec-client-generator-core/src/cache.ts @@ -1,17 +1,18 @@ import { - getNamespaceFullName, + compilerAssert, + Enum, Interface, isService, isTemplateDeclaration, isTemplateDeclarationOrInstance, Namespace, Operation, - Program, } from "@typespec/compiler"; +import { unsafe_Realm } from "@typespec/compiler/experimental"; +import { getVersionDependencies, getVersions } from "@typespec/versioning"; import { getClientLocation, getClientNameOverride, isInScope } from "./decorators.js"; -import { LanguageScopes, SdkClient, SdkOperationGroup, TCGCContext } from "./interfaces.js"; +import { SdkClient, SdkOperationGroup, TCGCContext } from "./interfaces.js"; import { - AllScopes, clientKey, clientLocationKey, getScopedDecoratorData, @@ -20,6 +21,7 @@ import { listScopedDecoratorData, omitOperation, operationGroupKey, + removeVersionsLargerThanExplicitlySpecified, } from "./internal-utils.js"; import { reportDiagnostic } from "./lib.js"; import { getLibraryName } from "./public-utils.js"; @@ -41,26 +43,107 @@ export function prepareClientAndOperationCache(context: TCGCContext): void { // create clients const clients = getOrCreateClients(context); + // handle versioning with mutated types + context.__packageVersions = new Map(); + context.__packageVersionEnum = new Map(); + + if (clients.length === 1 && Array.isArray(clients[0].service)) { + // multi-service client + const versionDependencies = getVersionDependencies( + context.program, + clients[0]!.type as Namespace, + ); + + for (const specificService of clients[0].service) { + if (context.__packageVersions.has(specificService)) { + continue; + } + + const versions = getVersions(context.program, specificService)[1]?.getVersions(); + if (!versions) { + context.__packageVersions.set(specificService, []); + continue; + } + + context.__packageVersionEnum.set(specificService, versions[0].enumMember.enum); + + const versionDependency = versionDependencies?.get(specificService); + + compilerAssert( + versionDependency !== undefined && "name" in versionDependency, + "Client with multiple services is missing version dependency declaration.", + ); + + let end = false; + context.__packageVersions.set( + specificService, + versions + .map((version) => version.value) + .filter((v) => { + if (end) return false; + if (v === versionDependency.value) end = true; + return true; + }), + ); + } + } else if (clients.length > 0) { + // single-service client + const versions = getVersions( + context.program, + clients[0].service as Namespace, + )[1]?.getVersions(); + + if (!versions || versions.length === 0) { + context.__packageVersions.set(clients[0].service as Namespace, []); + } else { + context.__packageVersionEnum.set( + clients[0].service as Namespace, + versions[0].enumMember.enum, + ); + + removeVersionsLargerThanExplicitlySpecified(context, versions); + + const filteredVersions = versions.map((version) => version.value); + context.__packageVersions.set(clients[0].service as Namespace, filteredVersions); + } + } + // create operation groups for each client for (const client of clients) { const groups: SdkOperationGroup[] = []; - // iterate client's interfaces and namespaces to find operation groups - if (client.type.kind === "Namespace") { - for (const subItem of client.type.namespaces.values()) { - const og = createOperationGroup(context, subItem, `${client.name}`, client); - if (og) { - groups.push(og); - } + if (Array.isArray(client.service)) { + // Multiple services case will auto-merge all the services and add their nested operation groups + for (const specificService of client.service) { + createFirstLevelOperationGroup(context, specificService, specificService); } - for (const subItem of client.type.interfaces.values()) { - if (isTemplateDeclaration(subItem)) { - // Skip template interfaces - continue; + } else { + // Single service case needs to use the client type since it could contain customizations + createFirstLevelOperationGroup(context, client.type, client.service); + } + + function createFirstLevelOperationGroup( + context: TCGCContext, + type: Namespace | Interface, + service: Namespace, + ) { + // iterate client's interfaces and namespaces to find operation groups + if (type.kind === "Namespace") { + for (const subItem of type.namespaces.values()) { + const og = createOperationGroup(context, subItem, `${client.name}`, service, client); + if (og) { + groups.push(og); + } } - const og = createOperationGroup(context, subItem, `${client.name}`, client); - if (og) { - groups.push(og); + for (const subItem of type.interfaces.values()) { + if (isTemplateDeclaration(subItem)) { + // Skip template interfaces + continue; + } + const og = createOperationGroup(context, subItem, `${client.name}`, service, client); + if (og) { + groups.push(og); + } } } } @@ -74,32 +157,61 @@ export function prepareClientAndOperationCache(context: TCGCContext): void { // create operation group for `@clientLocation` of string value // if no explicit `@client` or `@operationGroup` if (!hasExplicitClientOrOperationGroup(context)) { - const newOperationGroupNames = new Set(); - [...listScopedDecoratorData(context, clientLocationKey).values()].map((target) => { - if (typeof target === "string") { - if ( - clients[0].subOperationGroups.some( - (og) => og.type && getLibraryName(context, og.type) === target, - ) - ) { - // do not create a new operation group if it already exists - return; + const newOperationGroupWithService = new Map(); + listScopedDecoratorData(context, clientLocationKey).forEach((v, k) => { + // only deal with mutated types or without mutation + if ( + (!context.__mutatedRealm && !unsafe_Realm.realmForType.has(k)) || + (context.__mutatedRealm && context.__mutatedRealm.hasType(k)) + ) { + if (typeof v === "string") { + if ( + clients[0].subOperationGroups.some( + (og) => og.type && getLibraryName(context, og.type) === v, + ) + ) { + // do not create a new operation group if it already exists + return; + } + if (newOperationGroupWithService.has(v)) { + if ( + newOperationGroupWithService.has(v) && + Array.isArray(clients[0].service) && + findService(clients[0].service, k as Operation) !== + newOperationGroupWithService.get(v) + ) { + // multiple services case: need to ensure operations with same client location are from the same service + reportDiagnostic(context.program, { + code: "client-location-new-operation-group-multi-service", + target: k, + }); + } + return; + } + + newOperationGroupWithService.set( + v, + Array.isArray(clients[0].service) + ? findService(clients[0].service, k as Operation) + : clients[0].service, + ); } - newOperationGroupNames.add(target); } }); - for (const ogName of newOperationGroupNames) { - const og: SdkOperationGroup = { - kind: "SdkOperationGroup", - groupPath: `${clients[0].name}.${ogName}`, - service: clients[0].service, - subOperationGroups: [], - parent: clients[0], - }; - context.__rawClientsOperationGroupsCache.set(ogName, og); - clients[0].subOperationGroups!.push(og); - context.__clientToOperationsCache.set(og, []); + if (newOperationGroupWithService.size > 0) { + newOperationGroupWithService.forEach((service, ogName) => { + const og: SdkOperationGroup = { + kind: "SdkOperationGroup", + groupPath: `${clients[0].name}.${ogName}`, + service: service, + subOperationGroups: [], + parent: clients[0], + }; + context.__rawClientsOperationGroupsCache!.set(ogName, og); + clients[0].subOperationGroups!.push(og); + context.__clientToOperationsCache!.set(og, []); + }); } } @@ -109,7 +221,14 @@ export function prepareClientAndOperationCache(context: TCGCContext): void { const group = queue.shift()!; if (group.type) { // operations directly under the group - const operations = [...group.type.operations.values()]; + const operations = []; + if (group.kind === "SdkClient" && Array.isArray(group.service)) { + // multi-service client + operations.push(...group.service.flatMap((service) => [...service.operations.values()])); + } else { + // single-service client or operation group + operations.push(...group.type.operations.values()); + } // when there is explicitly `@operationGroup` or `@client` // operations under namespace or interface that are not decorated with `@operationGroup` or `@client` @@ -202,6 +321,24 @@ export function prepareClientAndOperationCache(context: TCGCContext): void { } } +/** + * Find the service namespace that contains the given operation. + * @param services + * @param operation + * @returns + */ +function findService(services: Namespace[], operation: Operation): Namespace { + let namespace = operation.namespace; + while (namespace) { + if (services.includes(namespace)) { + return namespace; + } + namespace = namespace.namespace; + } + // fallback to the first service + return services[0]; +} + /** * Get or create the TCGC clients. * If user has explicitly defined `@client` then we will use those clients. @@ -256,7 +393,6 @@ function getOrCreateClients(context: TCGCContext): SdkClient[] { name: clientName, service: service, type: service, - crossLanguageDefinitionId: getNamespaceFullName(service), subOperationGroups: [], }, ]; @@ -277,18 +413,10 @@ function createOperationGroup( context: TCGCContext, type: Namespace | Interface, groupPathPrefix: string, + service: Namespace, parent?: SdkClient | SdkOperationGroup, ): SdkOperationGroup | undefined { let operationGroup: SdkOperationGroup | undefined; - const service = - findOperationGroupService(context.program, type, context.emitterName) ?? (type as any); - if (!isService(context.program, service)) { - reportDiagnostic(context.program, { - code: "client-service", - format: { name: type.name }, - target: type, - }); - } if (hasExplicitClientOrOperationGroup(context)) { operationGroup = getScopedDecoratorData(context, operationGroupKey, type); if (operationGroup) { @@ -303,6 +431,7 @@ function createOperationGroup( context, type, operationGroup.groupPath, + service, operationGroup, ) ?? []; } @@ -322,8 +451,13 @@ function createOperationGroup( if (operationGroup && type.kind === "Namespace") { operationGroup.subOperationGroups = - buildHierarchyOfOperationGroups(context, type, operationGroup.groupPath, operationGroup) ?? - []; + buildHierarchyOfOperationGroups( + context, + type, + operationGroup.groupPath, + service, + operationGroup, + ) ?? []; } } @@ -336,42 +470,23 @@ function createOperationGroup( return operationGroup; } -function findOperationGroupService( - program: Program, - client: Namespace | Interface, - scope: LanguageScopes, -): Namespace | Interface | undefined { - let current: Namespace | undefined = client as any; - while (current) { - if (isService(program, current)) { - // we don't check scoped clients here, because we want to find the service for the client - return current; - } - const client = program.stateMap(clientKey).get(current); - if (client && (client[scope] || client[AllScopes])) { - return (client[scope] ?? client[AllScopes]).service; - } - current = current.namespace; - } - return undefined; -} - function buildHierarchyOfOperationGroups( context: TCGCContext, type: Namespace, groupPathPrefix: string, + service: Namespace, parent?: SdkClient | SdkOperationGroup, ): SdkOperationGroup[] | undefined { // build hierarchy of operation group const subOperationGroups: SdkOperationGroup[] = []; type.namespaces.forEach((ns) => { - const subOperationGroup = createOperationGroup(context, ns, groupPathPrefix, parent); + const subOperationGroup = createOperationGroup(context, ns, groupPathPrefix, service, parent); if (subOperationGroup) { subOperationGroups.push(subOperationGroup); } }); type.interfaces.forEach((i) => { - const subOperationGroup = createOperationGroup(context, i, groupPathPrefix, parent); + const subOperationGroup = createOperationGroup(context, i, groupPathPrefix, service, parent); if (subOperationGroup) { subOperationGroups.push(subOperationGroup); } @@ -382,7 +497,10 @@ function buildHierarchyOfOperationGroups( return undefined; } -function isArm(service: Namespace): boolean { +function isArm(service: Namespace[] | Namespace): boolean { + if (Array.isArray(service)) { + return service.some((s) => isArm(s)); + } return service.decorators.some( (decorator) => decorator.decorator.name === "$armProviderNamespace", ); diff --git a/packages/typespec-client-generator-core/src/clients.ts b/packages/typespec-client-generator-core/src/clients.ts index 7ff059339a..0107251a8c 100644 --- a/packages/typespec-client-generator-core/src/clients.ts +++ b/packages/typespec-client-generator-core/src/clients.ts @@ -26,7 +26,7 @@ import { } from "./interfaces.js"; import { createGeneratedName, - getAvailableApiVersions, + getActualClientType, getClientDoc, getTypeDecorators, getValueTypeValue, @@ -66,8 +66,8 @@ function getEndpointTypeFromSingleServer< methodParameterSegments: [], type: getSdkBuiltInType(context, $(context.program).builtin.url), isApiVersionParam: false, - apiVersions: context.getApiVersionsForType(client.__raw.type ?? client.__raw.service), - crossLanguageDefinitionId: `${getCrossLanguageDefinitionId(context, client.__raw.service)}.endpoint`, + apiVersions: client.apiVersions, + crossLanguageDefinitionId: `${client.crossLanguageDefinitionId}.endpoint`, decorators: [], access: "public", flatten: false, @@ -92,8 +92,8 @@ function getEndpointTypeFromSingleServer< if (sdkParam.isApiVersionParam && apiVersionInfo.clientDefaultValue) { sdkParam.clientDefaultValue = apiVersionInfo.clientDefaultValue; } - sdkParam.apiVersions = getAvailableApiVersions(context, param, client.__raw.type); - sdkParam.crossLanguageDefinitionId = `${getCrossLanguageDefinitionId(context, client.__raw.service)}.${param.name}`; + sdkParam.apiVersions = client.apiVersions; + sdkParam.crossLanguageDefinitionId = `${client.crossLanguageDefinitionId}.${param.name}`; } else { diagnostics.add( createDiagnostic({ @@ -133,7 +133,9 @@ function getSdkEndpointParameter; } else { @@ -168,10 +171,11 @@ function getSdkEndpointParameter = { __raw: client, kind: "client", @@ -198,15 +203,15 @@ export function createSdkClientType { + if (!this.__packageVersions) { + prepareClientAndOperationCache(this); } - removeVersionsLargerThanExplicitlySpecified(this, versions); - - this.__packageVersions = versions.map((version) => version.value); - - if ( - this.apiVersion !== undefined && - this.apiVersion !== "latest" && - this.apiVersion !== "all" && - !this.__packageVersions.includes(this.apiVersion) - ) { - reportDiagnostic(this.program, { - code: "api-version-undefined", - format: { version: this.apiVersion }, - target: service.type, - }); - this.apiVersion = this.__packageVersions[this.__packageVersions.length - 1]; - } - return this.__packageVersions; + return this.__packageVersions!; }, - getPackageVersionEnum(): Enum | undefined { - if (this.__packageVersionEnum) { - return this.__packageVersionEnum; - } - const namespaces = listAllServiceNamespaces(this); - if (namespaces.length === 0) { - return undefined; + getPackageVersionEnum(): Map { + if (!this.__packageVersionEnum) { + prepareClientAndOperationCache(this); } - return getVersions(this.program, namespaces[0])[1]?.getVersions()?.[0].enumMember.enum; + return this.__packageVersionEnum!; }, getClients(): SdkClient[] { if (!this.__rawClientsOperationGroupsCache) { @@ -225,7 +189,6 @@ export async function createSdkContext< sdkPackage: undefined!, generateProtocolMethods: generateProtocolMethods, generateConvenienceMethods: generateConvenienceMethods, - examplesDir: context.options["examples-dir"], namespaceFlag: context.options["namespace"], apiVersion: context.options["api-version"], license: context.options["license"], @@ -235,6 +198,19 @@ export async function createSdkContext< flattenUnionAsEnum: options?.flattenUnionAsEnum ?? true, enableLegacyHierarchyBuilding: options?.enableLegacyHierarchyBuilding ?? true, }; + + if (context.options["examples-dir"]) { + const normalizeExamplesDir = normalizePath(context.options["examples-dir"]); + if (isAbsolute(normalizeExamplesDir)) { + sdkContext.examplesDir = getRelativePathFromDirectory( + context.program.projectRoot, + normalizeExamplesDir, + false, + ); + } else { + sdkContext.examplesDir = normalizeExamplesDir; + } + } sdkContext.sdkPackage = diagnostics.pipe(createSdkPackage(sdkContext)); for (const client of sdkContext.sdkPackage.clients) { diagnostics.pipe(await handleClientExamples(sdkContext, client)); diff --git a/packages/typespec-client-generator-core/src/decorators.ts b/packages/typespec-client-generator-core/src/decorators.ts index 074c395e90..ed1c02c4a0 100644 --- a/packages/typespec-client-generator-core/src/decorators.ts +++ b/packages/typespec-client-generator-core/src/decorators.ts @@ -26,7 +26,8 @@ import { } from "@typespec/compiler"; import { SyntaxKind, type Node } from "@typespec/compiler/ast"; import { $ } from "@typespec/compiler/typekit"; -import { getHttpOperation } from "@typespec/http"; +import { getAuthentication, getHttpOperation, getServers } from "@typespec/http"; +import { $useDependency, getVersions } from "@typespec/versioning"; import { AccessDecorator, AlternateTypeDecorator, @@ -74,6 +75,8 @@ import { findRootSourceProperty, getScopedDecoratorData, hasExplicitClientOrOperationGroup, + isSameAuth, + isSameServers, listAllUserDefinedNamespaces, negationScopesKey, omitOperation, @@ -179,17 +182,85 @@ export const $client: ClientDecorator = ( const explicitName = options?.kind === "Model" ? options?.properties.get("name")?.type : undefined; const name: string = explicitName?.kind === "String" ? explicitName.value : target.name; - let service = options?.kind === "Model" ? options?.properties.get("service")?.type : undefined; - - if (service?.kind !== "Namespace") { + let service: Namespace | Namespace[] | undefined = undefined; + const serviceConfig = + options?.kind === "Model" ? options?.properties.get("service")?.type : undefined; + + if (serviceConfig?.kind === "Namespace") { + service = serviceConfig; + } else if ( + serviceConfig?.kind === "Tuple" && + serviceConfig.values.every((v) => v.kind === "Namespace") + ) { + if (target.kind === "Interface") { + reportDiagnostic(context.program, { + code: "invalid-client-service-multiple", + target: context.decoratorTarget, + }); + return; + } + service = serviceConfig.values; + // validate all services has same server definition + let servers = undefined; + let auth = undefined; + let isSame = true; + for (const svc of service) { + const currentServers = getServers(context.program, svc); + if (currentServers === undefined) continue; + if (servers === undefined) { + servers = currentServers; + } else { + isSame = isSameServers(servers, currentServers); + if (!isSame) { + break; + } + } + } + for (const svc of service) { + const currentAuth = getAuthentication(context.program, svc); + if (currentAuth === undefined) continue; + if (auth === undefined) { + auth = currentAuth; + } else { + isSame = isSameAuth(auth, currentAuth); + if (!isSame) { + break; + } + } + } + if (!isSame) { + reportDiagnostic(context.program, { + code: "inconsistent-multiple-service", + target: context.decoratorTarget, + }); + return; + } + // no explicit versioning dependency + if ( + !target.decorators.some( + (d) => + d.definition?.name === "@useDependency" && + getNamespaceFullName(d.definition?.namespace) === "TypeSpec.Versioning", + ) + ) { + const versionRecords = []; + // collect the latest version enum member from each service + for (const svc of service) { + const versions = getVersions(context.program, svc)[1]?.getVersions(); + if (versions && versions.length > 0) { + versionRecords.push(versions[versions.length - 1].enumMember); + } + } + // set the versioning dependency + if (versionRecords.length > 0) { + context.call($useDependency, target, ...versionRecords); + } + } + } else { service = findClientService(context.program, target); } - if ( - service === undefined || - service.kind !== "Namespace" || - !judgeService(context.program, service) - ) { + if (service === undefined) { reportDiagnostic(context.program, { code: "client-service", format: { name }, @@ -203,7 +274,6 @@ export const $client: ClientDecorator = ( name, service, type: target, - crossLanguageDefinitionId: `${getNamespaceFullName(service)}.${name}`, subOperationGroups: [], }; setScopedDecoratorData(context, $client, clientKey, target, client, scope); @@ -218,10 +288,7 @@ function judgeService(program: Program, type: Namespace): boolean { ); } -function findClientService( - program: Program, - client: Namespace | Interface, -): Namespace | Interface | undefined { +function findClientService(program: Program, client: Namespace | Interface): Namespace | undefined { let current: Namespace | undefined = client as any; while (current) { if (judgeService(program, current)) { @@ -301,7 +368,12 @@ export function isOperationGroup(context: TCGCContext, type: Namespace | Interfa if (type.kind === "Interface" && !isTemplateDeclaration(type)) { return true; } - if (type.kind === "Namespace" && !type.decorators.some((t) => t.decorator.name === "$service")) { + if ( + type.kind === "Namespace" && + !type.decorators.some( + (d) => d.definition?.name === "@service" && d.definition?.namespace.name === "TypeSpec", + ) + ) { return true; } return false; @@ -729,7 +801,11 @@ export const $override = ( // Apply the alternate type to the original parameter const overrideParam = overrideParams[index]; overrideParam.decorators - .filter((d) => d.decorator.name === "$alternateType") + .filter( + (d) => + d.definition?.name === "@alternateType" && + getNamespaceFullName(d.definition?.namespace) === namespace, + ) .map((d) => context.call( $alternateType, diff --git a/packages/typespec-client-generator-core/src/example.ts b/packages/typespec-client-generator-core/src/example.ts index ed357fe73b..715c797a47 100644 --- a/packages/typespec-client-generator-core/src/example.ts +++ b/packages/typespec-client-generator-core/src/example.ts @@ -54,85 +54,116 @@ async function checkExamplesDirExists(host: CompilerHost, dir: string) { * Load all examples for a client * * @param context - * @param apiVersion * @returns a map of all operations' examples, key is operation's operation id, * value is a map of examples, key is example's title, value is example's details */ async function loadExamples( context: TCGCContext, - apiVersion: string | undefined, ): Promise<[Map>, readonly Diagnostic[]]> { const diagnostics = createDiagnosticCollector(); - const examplesBaseDir = - context.examplesDir ?? resolvePath(context.program.projectRoot, "examples"); - - const exampleDir = apiVersion - ? resolvePath(examplesBaseDir, apiVersion) - : resolvePath(examplesBaseDir); - if (!(await checkExamplesDirExists(context.program.host, exampleDir))) { - if (context.examplesDir) { - diagnostics.add( - createDiagnostic({ - code: "example-loading", - messageId: "noDirectory", - format: { directory: exampleDir }, - target: NoTarget, - }), - ); - } - return diagnostics.wrap(new Map()); - } - const map = new Map>(); - const exampleFiles = await searchExampleJsonFiles(context.program, exampleDir); - for (const fileName of exampleFiles) { - try { - const exampleFile = await context.program.host.readFile(resolvePath(exampleDir, fileName)); - const example = JSON.parse(exampleFile.text); - if (!example.operationId || !example.title) { + const apiVersions = context.getPackageVersions(); + const exampleDirs: string[][] = []; + if (apiVersions.size <= 1) { + // single service case + const apiVersion = + apiVersions.size === 1 ? apiVersions.values().next().value?.at(-1) : undefined; + const examplesBaseDir = resolvePath( + context.program.projectRoot, + context.examplesDir ?? "./examples", + ); + const exampleDir = apiVersion + ? resolvePath(examplesBaseDir, apiVersion) + : resolvePath(examplesBaseDir); + if (!(await checkExamplesDirExists(context.program.host, exampleDir))) { + if (context.examplesDir) { diagnostics.add( createDiagnostic({ code: "example-loading", - messageId: "noOperationId", - format: { filename: fileName }, + messageId: "noDirectory", + format: { directory: exampleDir }, target: NoTarget, }), ); - continue; } + return diagnostics.wrap(new Map()); + } + exampleDirs.push([exampleDir, examplesBaseDir]); + } else { + // multiple services case, we need to load examples from sub service folders + for (const [service, versions] of apiVersions) { + const apiVersion = versions.length > 0 ? versions[versions.length - 1] : undefined; + const examplesBaseDir = resolvePath( + context.program.projectRoot, + service.name, + context.examplesDir ?? "./examples", + ); + const exampleDir = apiVersion + ? resolvePath(examplesBaseDir, apiVersion) + : resolvePath(examplesBaseDir); - if (!map.has(example.operationId.toLowerCase())) { - map.set(example.operationId.toLowerCase(), {}); + if (await checkExamplesDirExists(context.program.host, exampleDir)) { + exampleDirs.push([exampleDir, examplesBaseDir]); } - const examples = map.get(example.operationId.toLowerCase())!; + } + } + + const map = new Map>(); + for (const [exampleDir, examplesBaseDir] of exampleDirs) { + const exampleFiles = await searchExampleJsonFiles(context.program, exampleDir); + for (const fileName of exampleFiles) { + try { + const exampleFile = await context.program.host.readFile(resolvePath(exampleDir, fileName)); + const example = JSON.parse(exampleFile.text); + if (!example.operationId || !example.title) { + diagnostics.add( + createDiagnostic({ + code: "example-loading", + messageId: "noOperationId", + format: { filename: fileName }, + target: NoTarget, + }), + ); + continue; + } - if (example.title in examples) { + if (!map.has(example.operationId.toLowerCase())) { + map.set(example.operationId.toLowerCase(), {}); + } + const examples = map.get(example.operationId.toLowerCase())!; + + if (example.title in examples) { + diagnostics.add( + createDiagnostic({ + code: "duplicate-example-file", + target: NoTarget, + format: { + filename: fileName, + operationId: example.operationId, + title: example.title, + }, + }), + ); + } + + examples[example.title] = { + relativePath: getRelativePathFromDirectory( + examplesBaseDir, + resolvePath(exampleDir, fileName), + false, + ), + data: example, + }; + } catch (err) { diagnostics.add( createDiagnostic({ - code: "duplicate-example-file", + code: "example-loading", + messageId: "default", + format: { filename: fileName, error: err?.toString() ?? "" }, target: NoTarget, - format: { - filename: fileName, - operationId: example.operationId, - title: example.title, - }, }), ); } - - examples[example.title] = { - relativePath: apiVersion ? resolvePath(apiVersion, fileName) : fileName, - data: example, - }; - } catch (err) { - diagnostics.add( - createDiagnostic({ - code: "example-loading", - messageId: "default", - format: { filename: fileName, error: err?.toString() ?? "" }, - target: NoTarget, - }), - ); } } return diagnostics.wrap(map); @@ -171,10 +202,7 @@ export async function handleClientExamples( ): Promise<[void, readonly Diagnostic[]]> { const diagnostics = createDiagnosticCollector(); - const packageVersions = context.getPackageVersions(); - const examples = diagnostics.pipe( - await loadExamples(context, packageVersions[packageVersions.length - 1]), - ); + const examples = diagnostics.pipe(await loadExamples(context)); const clientQueue = [client]; while (clientQueue.length > 0) { const client = clientQueue.pop()!; diff --git a/packages/typespec-client-generator-core/src/http.ts b/packages/typespec-client-generator-core/src/http.ts index b346239ec2..21ac26da3f 100644 --- a/packages/typespec-client-generator-core/src/http.ts +++ b/packages/typespec-client-generator-core/src/http.ts @@ -34,6 +34,7 @@ import { getResponseAsBool } from "./decorators.js"; import { CollectionFormat, SdkBodyParameter, + SdkClientType, SdkCookieParameter, SdkHeaderParameter, SdkHttpErrorResponse, @@ -52,6 +53,7 @@ import { } from "./interfaces.js"; import { compareModelProperties, + getActualClientType, getAvailableApiVersions, getClientDoc, getCorrespondingClientParam, @@ -86,11 +88,12 @@ export function getSdkHttpOperation( context: TCGCContext, httpOperation: HttpOperation, methodParameters: SdkMethodParameter[], + client: SdkClientType, ): [SdkHttpOperation, readonly Diagnostic[]] { const tk = $(context.program); const diagnostics = createDiagnosticCollector(); const { responses, exceptions } = diagnostics.pipe( - getSdkHttpResponseAndExceptions(context, httpOperation), + getSdkHttpResponseAndExceptions(context, httpOperation, client), ); if (getResponseAsBool(context, httpOperation.operation)) { // we make sure valid responses and 404 responses are booleans @@ -117,7 +120,7 @@ export function getSdkHttpOperation( apiVersions: getAvailableApiVersions( context, httpOperation.operation, - httpOperation.operation, + getActualClientType(client.__raw), ), headers: [], __raw: (responses[0] || exceptions[0]).__raw, @@ -497,6 +500,7 @@ export function getSdkHttpParameter( function getSdkHttpResponseAndExceptions( context: TCGCContext, httpOperation: HttpOperation, + client: SdkClientType, ): [ { responses: SdkHttpResponse[]; @@ -579,7 +583,7 @@ function getSdkHttpResponseAndExceptions( apiVersions: getAvailableApiVersions( context, httpOperation.operation, - httpOperation.operation, + getActualClientType(client.__raw), ), description: response.description, }; diff --git a/packages/typespec-client-generator-core/src/interfaces.ts b/packages/typespec-client-generator-core/src/interfaces.ts index b18fa9134d..f24742798e 100644 --- a/packages/typespec-client-generator-core/src/interfaces.ts +++ b/packages/typespec-client-generator-core/src/interfaces.ts @@ -15,6 +15,7 @@ import { Program, Type, } from "@typespec/compiler"; +import { unsafe_Realm } from "@typespec/compiler/experimental"; import { HttpAuth, HttpOperation, @@ -73,15 +74,16 @@ export interface TCGCContext { __httpOperationExamples: Map; __pagedResultSet: Set; __mutatedGlobalNamespace?: Namespace; // the root of all tsp namespaces for this instance. Starting point for traversal, so we don't call mutation multiple times - __packageVersions?: string[]; // the package versions from the service versioning config and api version setting in tspconfig. - __packageVersionEnum?: Enum; // the enum type that contains all the package versions. + __mutatedRealm?: unsafe_Realm; // the realm that contains all mutated types for this instance + __packageVersions?: Map; // the package versions (for each service) from the service versioning config and api version setting in tspconfig. + __packageVersionEnum?: Map; // the enum type that contains all the package versions (for each service). __externalPackageToVersions?: Map; getMutatedGlobalNamespace(): Namespace; getApiVersionsForType(type: Type): string[]; setApiVersionsForType(type: Type, apiVersions: string[]): void; - getPackageVersions(): string[]; - getPackageVersionEnum(): Enum | undefined; + getPackageVersions(service?: Namespace): Map; + getPackageVersionEnum(): Map; getClients(): SdkClient[]; getClientOrOperationGroup(type: Namespace | Interface): SdkClient | SdkOperationGroup | undefined; getOperationsForClient(client: SdkClient | SdkOperationGroup): Operation[]; @@ -101,10 +103,8 @@ export interface SdkContext< export interface SdkClient { kind: "SdkClient"; name: string; - service: Namespace; + service: Namespace | Namespace[]; type: Namespace | Interface; - /** Unique ID for the current type. */ - crossLanguageDefinitionId: string; subOperationGroups: SdkOperationGroup[]; } diff --git a/packages/typespec-client-generator-core/src/internal-utils.ts b/packages/typespec-client-generator-core/src/internal-utils.ts index e57a8d1e6e..3a6a2db4b1 100644 --- a/packages/typespec-client-generator-core/src/internal-utils.ts +++ b/packages/typespec-client-generator-core/src/internal-utils.ts @@ -16,6 +16,7 @@ import { getSummary, getVisibilityForClass, ignoreDiagnostics, + Interface, isNeverType, isNullType, isVoidType, @@ -35,15 +36,23 @@ import { import { unsafe_mutateSubgraphWithNamespace, unsafe_MutatorWithNamespace, + unsafe_Realm, } from "@typespec/compiler/experimental"; import { $ } from "@typespec/compiler/typekit"; -import { HttpOperation, HttpOperationResponseContent, HttpPayloadBody } from "@typespec/http"; +import { + Authentication, + HttpOperation, + HttpOperationResponseContent, + HttpPayloadBody, + HttpServer, +} from "@typespec/http"; import { getAddedOnVersions, getRemovedOnVersions, getVersioningMutators, getVersions, } from "@typespec/versioning"; +import assert from "assert"; import { getAlternateType, getClientDocExplicit, @@ -67,7 +76,7 @@ import { SdkType, TCGCContext, } from "./interfaces.js"; -import { createDiagnostic, createStateSymbol } from "./lib.js"; +import { createDiagnostic, createStateSymbol, reportDiagnostic } from "./lib.js"; import { getSdkBasicServiceMethod } from "./methods.js"; import { getCrossLanguageDefinitionId, @@ -112,8 +121,17 @@ export const omitOperation = createStateSymbol("omitOperation"); export const overrideKey = createStateSymbol("override"); export function hasExplicitClientOrOperationGroup(context: TCGCContext): boolean { + // Multiple services case is not considered explicit client. It is auto-merged. + const explicitClients = listScopedDecoratorData(context, clientKey); + let multiServices = false; + explicitClients.forEach((value) => { + if (Array.isArray((value as SdkClient).service)) { + multiServices = true; + } + }); + return ( - listScopedDecoratorData(context, clientKey).size > 0 || + (explicitClients.size > 0 && !multiServices) || listScopedDecoratorData(context, operationGroupKey).size > 0 ); } @@ -289,7 +307,7 @@ export function getAvailableApiVersions( return explicitlyDecorated; } context.setApiVersionsForType(type, wrapperApiVersions); - return context.getApiVersionsForType(type); + return wrapperApiVersions; } /** @@ -479,7 +497,7 @@ export function getNullOption(type: Union): Type | undefined { */ export function createGeneratedName( context: TCGCContext, - type: Namespace | Operation, + type: Interface | Namespace | Operation, suffix: string, ): string { return `${getCrossLanguageDefinitionId(context, type).split(".").at(-1)}${suffix}`; @@ -541,7 +559,11 @@ export function filterApiVersionsInEnum( ): void { // if they explicitly set an api version, remove larger versions removeVersionsLargerThanExplicitlySpecified(context, sdkVersionsEnum.values); - const defaultApiVersion = getDefaultApiVersion(context, client.service); + const clientNamespaceType = getActualClientType(client); + const defaultApiVersion = getDefaultApiVersion( + context, + clientNamespaceType.kind === "Interface" ? clientNamespaceType.namespace! : clientNamespaceType, + ); if (!context.previewStringRegex.test(defaultApiVersion?.value || "")) { sdkVersionsEnum.values = sdkVersionsEnum.values.filter((v) => { if (typeof v.value !== "string") { @@ -741,14 +763,13 @@ export function getStreamAsBytes( function getVersioningMutator( context: TCGCContext, service: Namespace, - apiVersion: string, -): unsafe_MutatorWithNamespace { + apiVersion?: string, +): unsafe_MutatorWithNamespace | undefined { const versionMutator = getVersioningMutators(context.program, service); - compilerAssert( - versionMutator !== undefined && versionMutator.kind !== "transient", - "Versioning service should not get undefined or transient versioning mutator", - ); - + if (!versionMutator) return undefined; + if (versionMutator.kind === "transient") { + return versionMutator.mutator; + } const mutators = versionMutator.snapshots .filter((snapshot) => apiVersion === snapshot.version.value) .map((x) => x.mutator); @@ -759,16 +780,85 @@ function getVersioningMutator( export function handleVersioningMutationForGlobalNamespace(context: TCGCContext): Namespace { const globalNamespace = context.program.getGlobalNamespaceType(); - const allApiVersions = context.getPackageVersions(); - if (allApiVersions.length === 0 || context.apiVersion === "all") return globalNamespace; + const services = listServices(context.program); + + // No service, thus no versioning mutation needed + if (services.length === 0) return globalNamespace; + + // Explicit all API version setting, thus no versioning mutation needed + if (context.apiVersion === "all") return globalNamespace; + + const explicitClientNamespaces: Namespace[] = []; + const explicitServices = new Set(); + listScopedDecoratorData(context, clientKey).forEach((v, k) => { + if (!unsafe_Realm.realmForType.has(k)) { + const sdkClient = v as SdkClient; + if (Array.isArray(sdkClient.service)) { + explicitClientNamespaces.push(k as Namespace); + sdkClient.service.forEach((s) => explicitServices.add(s)); + } else { + explicitServices.add(sdkClient.service); + } + } + }); - const mutator = getVersioningMutator( - context, - listServices(context.program)[0].type, - allApiVersions[allApiVersions.length - 1], - ); + let mutator: unsafe_MutatorWithNamespace | undefined; + + // No explicit clients (choose first service) or explicit client with one service + if (explicitClientNamespaces.length === 0 || explicitServices.size === 1) { + const serviceNamespace = + explicitClientNamespaces.length === 0 + ? services[0].type + : explicitServices.values().next().value!; + const versions = getVersions(context.program, serviceNamespace)[1]?.getVersions(); + // If the single service has no versioning, no mutation needed + if (!versions) return globalNamespace; + + // Filter versions based on `apiVersion` config + removeVersionsLargerThanExplicitlySpecified(context, versions); + const versionsValues = versions.map((v) => v.value); + + // Fix apiVersion setting problem only if there's only one service + 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: services[0].type, + }); + context.apiVersion = versionsValues[versionsValues.length - 1]; + } + + mutator = getVersioningMutator( + context, + serviceNamespace, + versionsValues[versionsValues.length - 1], + ); + } + // Explicit clients with multiple services + else { + // Currently we do not support multiple explicit clients with multiple services + if (explicitClientNamespaces.length > 1 && explicitServices.size > 1) { + reportDiagnostic(context.program, { + code: "multiple-explicit-clients-multiple-services", + format: {}, + target: services[0].type, + }); + return globalNamespace; + } + + mutator = getVersioningMutator(context, explicitClientNamespaces[0]); + } + + if (!mutator) return globalNamespace; const subgraph = unsafe_mutateSubgraphWithNamespace(context.program, [mutator], globalNamespace); compilerAssert(subgraph.type.kind === "Namespace", "Should not have mutated to another type"); + compilerAssert(subgraph.realm !== null, "Should have a realm after mutation"); + context.__mutatedRealm = subgraph.realm; return subgraph.type; } @@ -928,3 +1018,121 @@ export function getTcgcLroMetadata( context: TCGCContext, operation: Operation, methodParameters: SdkMethodParameter[], + client: SdkClientType, ): [TServiceOperation, readonly Diagnostic[]] { const diagnostics = createDiagnosticCollector(); const httpOperation = getHttpOperationWithCache(context, operation); if (httpOperation) { const sdkHttpOperation = diagnostics.pipe( - getSdkHttpOperation(context, httpOperation, methodParameters), + getSdkHttpOperation(context, httpOperation, methodParameters, client), ) as TServiceOperation; return diagnostics.wrap(sdkHttpOperation); } @@ -362,6 +365,7 @@ function getSdkLroServiceMethod( context, metadata.__raw.operation, baseServiceMethod.parameters, + client, ), ), }); @@ -632,7 +636,7 @@ export function getSdkBasicServiceMethod(context, operation, methodParameters), + getSdkServiceOperation(context, operation, methodParameters, client), ); const response = getSdkMethodResponse(context, operation, serviceOperation, client); const name = getLibraryName(context, operation); diff --git a/packages/typespec-client-generator-core/src/package.ts b/packages/typespec-client-generator-core/src/package.ts index 60af13ed56..da6c710407 100644 --- a/packages/typespec-client-generator-core/src/package.ts +++ b/packages/typespec-client-generator-core/src/package.ts @@ -14,7 +14,11 @@ import { SdkUnionType, TCGCContext, } from "./interfaces.js"; -import { filterApiVersionsWithDecorators, getTypeDecorators } from "./internal-utils.js"; +import { + filterApiVersionsWithDecorators, + getActualClientType, + getTypeDecorators, +} from "./internal-utils.js"; import { getLicenseInfo } from "./license.js"; import { getCrossLanguagePackageId, getNamespaceFromType } from "./public-utils.js"; import { getAllReferencedTypes, handleAllTypes } from "./types.js"; @@ -39,7 +43,12 @@ export function createSdkPackage( namespaces: [], licenseInfo: getLicenseInfo(context), metadata: { - apiVersion: context.apiVersion === "all" ? "all" : versions[versions.length - 1], + apiVersion: + context.apiVersion === "all" && versions.size === 1 + ? "all" + : versions.size === 1 + ? [...versions.values()][0].at(-1) + : undefined, }, }; organizeNamespaces(context, sdkPackage); @@ -113,22 +122,26 @@ function populateApiVersionInformation(context: TCGCContext): void { if (context.__rawClientsOperationGroupsCache === undefined) { prepareClientAndOperationCache(context); } - for (const clientOperationGroup of context.__rawClientsOperationGroupsCache!.values()) { - context.setApiVersionsForType( - clientOperationGroup.type ?? clientOperationGroup.service, - filterApiVersionsWithDecorators( + + // Get the package versions map once (this handles both single and multi-service scenarios) + const packageVersions = context.getPackageVersions(); + + for (const client of context.__rawClientsOperationGroupsCache!.values()) { + const clientType = getActualClientType(client); + + // Multiple service case. Set empty result. + if (Array.isArray(client.service)) { + context.setApiVersionsForType(clientType, []); + context.__clientApiVersionDefaultValueCache.set(client, undefined); + } else { + const versions = filterApiVersionsWithDecorators( context, - clientOperationGroup.type ?? clientOperationGroup.service, - context.getPackageVersions(), - ), - ); + clientType, + packageVersions.get(client.service) || [], + ); + context.setApiVersionsForType(clientType, versions); - const clientApiVersions = context.getApiVersionsForType( - clientOperationGroup.type ?? clientOperationGroup.service, - ); - context.__clientApiVersionDefaultValueCache.set( - clientOperationGroup, - clientApiVersions[clientApiVersions.length - 1], - ); + context.__clientApiVersionDefaultValueCache.set(client, versions[versions.length - 1]); + } } } diff --git a/packages/typespec-client-generator-core/src/public-utils.ts b/packages/typespec-client-generator-core/src/public-utils.ts index 845cd6af63..e854618139 100644 --- a/packages/typespec-client-generator-core/src/public-utils.ts +++ b/packages/typespec-client-generator-core/src/public-utils.ts @@ -102,13 +102,13 @@ export function isApiVersion(context: TCGCContext, type: ModelProperty): boolean return override; } // if the service is not versioning, then no api version parameter - const versionEnum = context.getPackageVersionEnum(); - if (!versionEnum) { + const versionEnumSets = [...context.getPackageVersionEnum().values()]; + if (versionEnumSets.length === 0) { return false; } // if the parameter type is the version enum or named as "apiVersion" or "api-version", then it is api version return ( - type.type === versionEnum || + versionEnumSets.some((versionEnum) => type.type === versionEnum) || type.name.toLowerCase().includes("apiversion") || type.name.toLowerCase().includes("api-version") ); diff --git a/packages/typespec-client-generator-core/src/types.ts b/packages/typespec-client-generator-core/src/types.ts index fab206468c..e1441c6b8d 100644 --- a/packages/typespec-client-generator-core/src/types.ts +++ b/packages/typespec-client-generator-core/src/types.ts @@ -66,7 +66,7 @@ import { SdkArrayType, SdkBuiltInKinds, SdkBuiltInType, - SdkClient, + SdkClientType, SdkConstantType, SdkCredentialParameter, SdkCredentialType, @@ -76,11 +76,11 @@ import { SdkEnumType, SdkEnumValueType, SdkHeaderParameter, + SdkHttpOperation, SdkModelPropertyType, SdkModelPropertyTypeBase, SdkModelType, SdkNullableType, - SdkOperationGroup, SdkTupleType, SdkType, SdkUnionType, @@ -784,7 +784,7 @@ function addDiscriminatorToModelType( onClient: false, apiVersions: discriminatorProperty ? getAvailableApiVersions(context, discriminatorProperty.__raw!, type) - : getAvailableApiVersions(context, type, type), + : model.apiVersions, isApiVersionParam: false, isMultipartFileInput: false, // discriminator property cannot be a file flatten: false, // discriminator properties can not be flattened @@ -1177,14 +1177,15 @@ function getSdkVisibility(context: TCGCContext, type: ModelProperty): Visibility function getSdkCredentialType( context: TCGCContext, - client: SdkClient | SdkOperationGroup, + client: SdkClientType, authentication: Authentication, ): SdkCredentialType | SdkUnionType { const credentialTypes: SdkCredentialType[] = []; for (const option of authentication.options) { for (const scheme of option.schemes) { credentialTypes.push({ - __raw: client.service, + // Multiple services only deal with the first server config + __raw: Array.isArray(client.__raw.service) ? client.__raw.service[0] : client.__raw.service, kind: "credential", scheme: scheme, decorators: [], @@ -1192,16 +1193,19 @@ function getSdkCredentialType( } } if (credentialTypes.length > 1) { - const namespace = getClientNamespace(context, client.service); + // Multiple services only deal with the first server config + const service = Array.isArray(client.__raw.service) + ? client.__raw.service[0] + : client.__raw.service; return { - __raw: client.service, + __raw: service, kind: "union", variantTypes: credentialTypes, - name: createGeneratedName(context, client.service, "CredentialUnion"), + name: createGeneratedName(context, service, "CredentialUnion"), isGeneratedName: true, - namespace, - clientNamespace: namespace, - crossLanguageDefinitionId: `${getCrossLanguageDefinitionId(context, client.service)}.CredentialUnion`, + namespace: client.namespace, + clientNamespace: client.namespace, + crossLanguageDefinitionId: `${client.crossLanguageDefinitionId}.CredentialUnion`, decorators: [], access: "public", usage: UsageFlags.None, @@ -1212,9 +1216,13 @@ function getSdkCredentialType( export function getSdkCredentialParameter( context: TCGCContext, - client: SdkClient | SdkOperationGroup, + client: SdkClientType, ): SdkCredentialParameter | undefined { - const auth = getAuthentication(context.program, client.service); + // Multiple services only deal with the first server config + const service = Array.isArray(client.__raw.service) + ? client.__raw.service[0] + : client.__raw.service; + const auth = getAuthentication(context.program, service); if (!auth) return undefined; return { type: getSdkCredentialType(context, client, auth), @@ -1222,11 +1230,11 @@ export function getSdkCredentialParameter( name: "credential", isGeneratedName: true, doc: "Credential used to authenticate requests to the service.", - apiVersions: getAvailableApiVersions(context, client.service, client.type), + apiVersions: client.apiVersions, onClient: true, optional: false, isApiVersionParam: false, - crossLanguageDefinitionId: `${getCrossLanguageDefinitionId(context, client.service)}.credential`, + crossLanguageDefinitionId: `${client.crossLanguageDefinitionId}.credential`, decorators: [], access: "public", flatten: false, @@ -1957,29 +1965,35 @@ export function handleAllTypes(context: TCGCContext): [void, readonly Diagnostic } } // server parameters - const servers = getServers(context.program, client.service); + const services = Array.isArray(client.service) ? client.service : [client.service]; + // Multiple services only deal with the first server config + const servers = getServers(context.program, services[0]); if (servers !== undefined && servers[0].parameters !== undefined) { for (const param of servers[0].parameters.values()) { const sdkType = diagnostics.pipe(getClientTypeWithDiagnostics(context, param)); diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Input, sdkType)); } } - // versioned enums - const [_, versionMap] = getVersions(context.program, client.service); - if (versionMap && versionMap.getVersions()[0]) { - // create sdk enum for versions enum - let sdkVersionsEnum: SdkEnumType; - const explicitApiVersions = getExplicitClientApiVersions(context, client.service); - if (explicitApiVersions) { - // add additional api versions to the enum - sdkVersionsEnum = diagnostics.pipe(getSdkEnumWithDiagnostics(context, explicitApiVersions)); - } else { - sdkVersionsEnum = diagnostics.pipe( - getSdkEnumWithDiagnostics(context, versionMap.getVersions()[0].enumMember.enum), - ); + for (const service of services) { + // versioned enums + const [_, versionMap] = getVersions(context.program, service); + if (versionMap && versionMap.getVersions()[0]) { + // create sdk enum for versions enum + let sdkVersionsEnum: SdkEnumType; + const explicitApiVersions = getExplicitClientApiVersions(context, service); + if (explicitApiVersions) { + // add additional api versions to the enum + sdkVersionsEnum = diagnostics.pipe( + getSdkEnumWithDiagnostics(context, explicitApiVersions), + ); + } else { + sdkVersionsEnum = diagnostics.pipe( + getSdkEnumWithDiagnostics(context, versionMap.getVersions()[0].enumMember.enum), + ); + } + filterApiVersionsInEnum(context, client, sdkVersionsEnum); + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.ApiVersionEnum, sdkVersionsEnum)); } - filterApiVersionsInEnum(context, client, sdkVersionsEnum); - diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.ApiVersionEnum, sdkVersionsEnum)); } } // update for orphan models/enums/unions diff --git a/packages/typespec-client-generator-core/test/clients/structure.test.ts b/packages/typespec-client-generator-core/test/clients/structure.test.ts index 1d0965ff7f..9b22a33784 100644 --- a/packages/typespec-client-generator-core/test/clients/structure.test.ts +++ b/packages/typespec-client-generator-core/test/clients/structure.test.ts @@ -762,3 +762,997 @@ it("optional params propagated", async () => { `, ); }); + +it("one client from multiple services", async () => { + await runner.compileWithCustomization( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { + av1, + av2, + } + interface AI { + @route("/aTest") + aTest(@query("api-version") apiVersion: VersionsA): void; + } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { + bv1, + bv2, + } + interface BI { + @route("/bTest") + bTest(@query("api-version") apiVersion: VersionsB): void; + } + }`, + ` + @client( + { + name: "CombineClient", + service: [ServiceA, ServiceB], + } + ) + @useDependency(ServiceA.VersionsA.av2, ServiceB.VersionsB.bv2) + namespace CombineClient; + `, + ); + const sdkPackage = runner.context.sdkPackage; + strictEqual(sdkPackage.clients.length, 1); + const aVersionsEnum = sdkPackage.enums.find((e) => e.name === "VersionsA"); + ok(aVersionsEnum); + const bVersionsEnum = sdkPackage.enums.find((e) => e.name === "VersionsB"); + ok(bVersionsEnum); + const client = sdkPackage.clients[0]; + strictEqual(client.name, "CombineClient"); + // For root client of multiple services, the `apiVersions` will be empty. + strictEqual(client.apiVersions.length, 0); + strictEqual(client.children!.length, 2); + strictEqual(client.clientInitialization.parameters.length, 2); + ok(client.clientInitialization.parameters.find((p) => p.name === "endpoint")); + const apiVersionParam = client.clientInitialization.parameters.find( + (p) => p.name === "apiVersion", + ); + ok(apiVersionParam); + strictEqual(apiVersionParam.apiVersions.length, 0); + strictEqual(apiVersionParam.clientDefaultValue, undefined); + const aiClient = client.children!.find((c) => c.name === "AI"); + ok(aiClient); + + // AI client should have api versions from ServiceA + strictEqual(aiClient.apiVersions.length, 2); + deepStrictEqual(aiClient.apiVersions, ["av1", "av2"]); + strictEqual(aiClient.clientInitialization.parameters.length, 2); + strictEqual(aiClient.clientInitialization.parameters[0].name, "endpoint"); + strictEqual(aiClient.clientInitialization.parameters[1].name, "apiVersion"); + const aiApiVersionParam = aiClient.clientInitialization.parameters[1]; + strictEqual(aiApiVersionParam.isApiVersionParam, true); + strictEqual(aiApiVersionParam.onClient, true); + strictEqual(aiApiVersionParam.clientDefaultValue, "av2"); + + // AI client should have aTest method with VersionsA api version + strictEqual(aiClient.methods.length, 1); + const aiMethod = aiClient.methods[0]; + strictEqual(aiMethod.name, "aTest"); + strictEqual(aiMethod.parameters.length, 0); + const aiOperation = aiMethod.operation; + strictEqual(aiOperation.parameters.length, 1); + const aiOperationApiVersionParam = aiOperation.parameters.find((p) => p.isApiVersionParam); + ok(aiOperationApiVersionParam); + strictEqual(aiOperationApiVersionParam.correspondingMethodParams.length, 1); + strictEqual(aiOperationApiVersionParam.correspondingMethodParams[0], aiApiVersionParam); + + const biClient = client.children!.find((c) => c.name === "BI"); + ok(biClient); + + strictEqual(biClient.apiVersions.length, 2); + deepStrictEqual(biClient.apiVersions, ["bv1", "bv2"]); + strictEqual(biClient.clientInitialization.parameters.length, 2); + strictEqual(biClient.clientInitialization.parameters[0].name, "endpoint"); + strictEqual(biClient.clientInitialization.parameters[1].name, "apiVersion"); + const biApiVersionParam = biClient.clientInitialization.parameters[1]; + strictEqual(biApiVersionParam.isApiVersionParam, true); + strictEqual(biApiVersionParam.onClient, true); + strictEqual(biApiVersionParam.clientDefaultValue, "bv2"); + + // BI client should have bTest method with VersionsB api version + const biMethod = biClient.methods[0]; + strictEqual(biMethod.name, "bTest"); + strictEqual(biMethod.parameters.length, 0); + const biOperation = biMethod.operation; + strictEqual(biOperation.parameters.length, 1); + const biOperationApiVersionParam = biOperation.parameters.find((p) => p.isApiVersionParam); + ok(biOperationApiVersionParam); + strictEqual(biOperationApiVersionParam.correspondingMethodParams.length, 1); + strictEqual(biOperationApiVersionParam.correspondingMethodParams[0], biApiVersionParam); +}); + +it("one client from multiple services with no versioning", async () => { + await runner.compileWithCustomization( + ` + @service + namespace ServiceA { + interface AI { + @route("/aTest") + aTest(): void; + } + } + @service + namespace ServiceB { + interface BI { + @route("/bTest") + bTest(): void; + } + }`, + ` + @client( + { + name: "CombineClient", + service: [ServiceA, ServiceB], + } + ) + namespace CombineClient; + `, + ); + const sdkPackage = runner.context.sdkPackage; + strictEqual(sdkPackage.clients.length, 1); + const client = sdkPackage.clients[0]; + strictEqual(client.name, "CombineClient"); + // For root client of multiple services, the `apiVersions` will be empty. + strictEqual(client.apiVersions.length, 0); + strictEqual(client.children!.length, 2); + strictEqual(client.clientInitialization.parameters.length, 1); + ok(client.clientInitialization.parameters.find((p) => p.name === "endpoint")); + + const aiClient = client.children!.find((c) => c.name === "AI"); + ok(aiClient); + + // AI client should have no api versions + strictEqual(aiClient.apiVersions.length, 0); + strictEqual(aiClient.clientInitialization.parameters.length, 1); + strictEqual(aiClient.clientInitialization.parameters[0].name, "endpoint"); + + // AI client should have aTest method with no api version + const aiMethod = aiClient.methods[0]; + strictEqual(aiMethod.name, "aTest"); + strictEqual(aiMethod.parameters.length, 0); + const aiOperation = aiMethod.operation; + strictEqual(aiOperation.parameters.length, 0); + + const biClient = client.children!.find((c) => c.name === "BI"); + ok(biClient); + + // BI client should have no api versions + strictEqual(biClient.apiVersions.length, 0); + strictEqual(biClient.clientInitialization.parameters.length, 1); + strictEqual(biClient.clientInitialization.parameters[0].name, "endpoint"); + + // BI client should have bTest method with no api version + const biMethod = biClient.methods[0]; + strictEqual(biMethod.name, "bTest"); + strictEqual(biMethod.parameters.length, 0); + const biOperation = biMethod.operation; + strictEqual(biOperation.parameters.length, 0); +}); + +it("one client from multiple services without version dependency", async () => { + await runner.compileWithCustomization( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { + av1, + av2, + } + interface AI { + @route("/aTest") + aTest(@query("api-version") apiVersion: VersionsA): void; + } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { + bv1, + bv2, + } + interface BI { + @route("/bTest") + bTest(@query("api-version") apiVersion: VersionsB): void; + } + }`, + ` + @client( + { + name: "CombineClient", + service: [ServiceA, ServiceB], + } + ) + namespace CombineClient; + `, + ); + const sdkPackage = runner.context.sdkPackage; + strictEqual(sdkPackage.clients.length, 1); + const aVersionsEnum = sdkPackage.enums.find((e) => e.name === "VersionsA"); + ok(aVersionsEnum); + const bVersionsEnum = sdkPackage.enums.find((e) => e.name === "VersionsB"); + ok(bVersionsEnum); + const client = sdkPackage.clients[0]; + strictEqual(client.name, "CombineClient"); + // For root client of multiple services, the `apiVersions` will be empty. + strictEqual(client.apiVersions.length, 0); + strictEqual(client.children!.length, 2); + strictEqual(client.clientInitialization.parameters.length, 2); + ok(client.clientInitialization.parameters.find((p) => p.name === "endpoint")); + const apiVersionParam = client.clientInitialization.parameters.find( + (p) => p.name === "apiVersion", + ); + ok(apiVersionParam); + strictEqual(apiVersionParam.apiVersions.length, 0); + strictEqual(apiVersionParam.clientDefaultValue, undefined); + + const aiClient = client.children!.find((c) => c.name === "AI"); + ok(aiClient); + + // AI client should have api versions from ServiceA + strictEqual(aiClient.apiVersions.length, 2); + deepStrictEqual(aiClient.apiVersions, ["av1", "av2"]); + strictEqual(aiClient.clientInitialization.parameters.length, 2); + strictEqual(aiClient.clientInitialization.parameters[0].name, "endpoint"); + strictEqual(aiClient.clientInitialization.parameters[1].name, "apiVersion"); + const aiApiVersionParam = aiClient.clientInitialization.parameters[1]; + strictEqual(aiApiVersionParam.isApiVersionParam, true); + strictEqual(aiApiVersionParam.onClient, true); + strictEqual(aiApiVersionParam.clientDefaultValue, "av2"); + + // AI client should have aTest method with VersionsA api version + strictEqual(aiClient.methods.length, 1); + const aiMethod = aiClient.methods[0]; + strictEqual(aiMethod.name, "aTest"); + strictEqual(aiMethod.parameters.length, 0); + const aiOperation = aiMethod.operation; + strictEqual(aiOperation.parameters.length, 1); + const aiOperationApiVersionParam = aiOperation.parameters.find((p) => p.isApiVersionParam); + ok(aiOperationApiVersionParam); + strictEqual(aiOperationApiVersionParam.correspondingMethodParams.length, 1); + strictEqual(aiOperationApiVersionParam.correspondingMethodParams[0], aiApiVersionParam); + + const biClient = client.children!.find((c) => c.name === "BI"); + ok(biClient); + + strictEqual(biClient.apiVersions.length, 2); + deepStrictEqual(biClient.apiVersions, ["bv1", "bv2"]); + strictEqual(biClient.clientInitialization.parameters.length, 2); + strictEqual(biClient.clientInitialization.parameters[0].name, "endpoint"); + strictEqual(biClient.clientInitialization.parameters[1].name, "apiVersion"); + const biApiVersionParam = biClient.clientInitialization.parameters[1]; + strictEqual(biApiVersionParam.isApiVersionParam, true); + strictEqual(biApiVersionParam.onClient, true); + strictEqual(biApiVersionParam.clientDefaultValue, "bv2"); + + // BI client should have bTest method with VersionsB api version + const biMethod = biClient.methods[0]; + strictEqual(biMethod.name, "bTest"); + strictEqual(biMethod.parameters.length, 0); + const biOperation = biMethod.operation; + strictEqual(biOperation.parameters.length, 1); + const biOperationApiVersionParam = biOperation.parameters.find((p) => p.isApiVersionParam); + ok(biOperationApiVersionParam); + strictEqual(biOperationApiVersionParam.correspondingMethodParams.length, 1); + strictEqual(biOperationApiVersionParam.correspondingMethodParams[0], biApiVersionParam); +}); + +it("one client from multiple services with `@clientLocation`", async () => { + await runner.compileWithCustomization( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { + av1, + av2, + } + interface AI { + @route("/aTest") + aTest(@query("api-version") apiVersion: VersionsA): void; + } + + interface AI2 { + @route("/aTest2") + aTest2(@query("api-version") apiVersion: VersionsA): void; + } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { + bv1, + bv2, + } + interface BI { + @route("/bTest") + bTest(@query("api-version") apiVersion: VersionsB): void; + } + }`, + ` + @client( + { + name: "CombineClient", + service: [ServiceA, ServiceB], + } + ) + @useDependency(ServiceA.VersionsA.av2, ServiceB.VersionsB.bv2) + namespace CombineClient; + + @@clientLocation(ServiceA.AI2.aTest2, ServiceA.AI); + @@clientLocation(ServiceB.BI.bTest, "BI2"); + `, + ); + const sdkPackage = runner.context.sdkPackage; + strictEqual(sdkPackage.clients.length, 1); + const aVersionsEnum = sdkPackage.enums.find((e) => e.name === "VersionsA"); + ok(aVersionsEnum); + const bVersionsEnum = sdkPackage.enums.find((e) => e.name === "VersionsB"); + ok(bVersionsEnum); + const client = sdkPackage.clients[0]; + strictEqual(client.name, "CombineClient"); + // For root client of multiple services, the `apiVersions` will be empty. + strictEqual(client.apiVersions.length, 0); + strictEqual(client.children!.length, 2); + strictEqual(client.clientInitialization.parameters.length, 2); + ok(client.clientInitialization.parameters.find((p) => p.name === "endpoint")); + const apiVersionParam = client.clientInitialization.parameters.find( + (p) => p.name === "apiVersion", + ); + ok(apiVersionParam); + strictEqual(apiVersionParam.apiVersions.length, 0); + strictEqual(apiVersionParam.clientDefaultValue, undefined); + + const aiClient = client.children!.find((c) => c.name === "AI"); + ok(aiClient); + + // AI client should have api versions from ServiceA + strictEqual(aiClient.apiVersions.length, 2); + deepStrictEqual(aiClient.apiVersions, ["av1", "av2"]); + strictEqual(aiClient.clientInitialization.parameters.length, 2); + strictEqual(aiClient.clientInitialization.parameters[0].name, "endpoint"); + strictEqual(aiClient.clientInitialization.parameters[1].name, "apiVersion"); + const aiApiVersionParam = aiClient.clientInitialization.parameters[1]; + strictEqual(aiApiVersionParam.isApiVersionParam, true); + strictEqual(aiApiVersionParam.onClient, true); + strictEqual(aiApiVersionParam.clientDefaultValue, "av2"); + + // AI client should have aTest method with VersionsA api version + strictEqual(aiClient.methods.length, 2); + const aiMethod = aiClient.methods[0]; + strictEqual(aiMethod.name, "aTest"); + strictEqual(aiMethod.parameters.length, 0); + const aiOperation = aiMethod.operation; + strictEqual(aiOperation.parameters.length, 1); + const aiOperationApiVersionParam = aiOperation.parameters.find((p) => p.isApiVersionParam); + ok(aiOperationApiVersionParam); + strictEqual(aiOperationApiVersionParam.correspondingMethodParams.length, 1); + strictEqual(aiOperationApiVersionParam.correspondingMethodParams[0], aiApiVersionParam); + + // AI client should have aTest2 method with VersionsA api version + const aiMethod2 = aiClient.methods[1]; + strictEqual(aiMethod2.name, "aTest2"); + strictEqual(aiMethod2.parameters.length, 0); + const aiOperation2 = aiMethod2.operation; + strictEqual(aiOperation2.parameters.length, 1); + const aiOperation2ApiVersionParam = aiOperation2.parameters.find((p) => p.isApiVersionParam); + ok(aiOperation2ApiVersionParam); + strictEqual(aiOperation2ApiVersionParam.correspondingMethodParams.length, 1); + strictEqual(aiOperation2ApiVersionParam.correspondingMethodParams[0], aiApiVersionParam); + + const biClient = client.children!.find((c) => c.name === "BI2"); + ok(biClient); + + strictEqual(biClient.apiVersions.length, 2); + deepStrictEqual(biClient.apiVersions, ["bv1", "bv2"]); + strictEqual(biClient.clientInitialization.parameters.length, 2); + strictEqual(biClient.clientInitialization.parameters[0].name, "endpoint"); + strictEqual(biClient.clientInitialization.parameters[1].name, "apiVersion"); + const biApiVersionParam = biClient.clientInitialization.parameters[1]; + strictEqual(biApiVersionParam.isApiVersionParam, true); + strictEqual(biApiVersionParam.onClient, true); + strictEqual(biApiVersionParam.clientDefaultValue, "bv2"); + + // BI client should have bTest method with VersionsB api version + const biMethod = biClient.methods[0]; + strictEqual(biMethod.name, "bTest"); + strictEqual(biMethod.parameters.length, 0); + const biOperation = biMethod.operation; + strictEqual(biOperation.parameters.length, 1); + const biOperationApiVersionParam = biOperation.parameters.find((p) => p.isApiVersionParam); + ok(biOperationApiVersionParam); + strictEqual(biOperationApiVersionParam.correspondingMethodParams.length, 1); + strictEqual(biOperationApiVersionParam.correspondingMethodParams[0], biApiVersionParam); +}); + +it("one client from multiple services with api-version set to latest", async () => { + const runnerWithVersion = await createSdkTestRunner({ + "api-version": "latest", + emitterName: "@azure-tools/typespec-python", + }); + await runnerWithVersion.compileWithCustomization( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { + av1, + av2, + av3, + } + interface AI { + @route("/aTest") + aTest(@query("api-version") apiVersion: VersionsA): void; + } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { + bv1, + bv2, + } + interface BI { + @route("/bTest") + bTest(@query("api-version") apiVersion: VersionsB): void; + } + }`, + ` + @client( + { + name: "CombineClient", + service: [ServiceA, ServiceB], + } + ) + @useDependency(ServiceA.VersionsA.av3, ServiceB.VersionsB.bv2) + namespace CombineClient; + `, + ); + const sdkPackage = runnerWithVersion.context.sdkPackage; + strictEqual(sdkPackage.clients.length, 1); + const client = sdkPackage.clients[0]; + strictEqual(client.name, "CombineClient"); + strictEqual(client.apiVersions.length, 0); + strictEqual(client.children!.length, 2); + + const aiClient = client.children!.find((c) => c.name === "AI"); + ok(aiClient); + strictEqual(aiClient.apiVersions.length, 3); + deepStrictEqual(aiClient.apiVersions, ["av1", "av2", "av3"]); + const aiApiVersionParam = aiClient.clientInitialization.parameters.find( + (p) => p.isApiVersionParam, + ); + ok(aiApiVersionParam); + strictEqual(aiApiVersionParam.clientDefaultValue, "av3"); + + const biClient = client.children!.find((c) => c.name === "BI"); + ok(biClient); + strictEqual(biClient.apiVersions.length, 2); + deepStrictEqual(biClient.apiVersions, ["bv1", "bv2"]); + const biApiVersionParam = biClient.clientInitialization.parameters.find( + (p) => p.isApiVersionParam, + ); + ok(biApiVersionParam); + strictEqual(biApiVersionParam.clientDefaultValue, "bv2"); +}); + +it("one client from multiple services with api-version set to specific version bv1", async () => { + const runnerWithVersion = await createSdkTestRunner({ + "api-version": "bv1", + emitterName: "@azure-tools/typespec-python", + }); + await runnerWithVersion.compileWithCustomization( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { + av1, + av2, + av3, + } + interface AI { + @route("/aTest") + aTest(@query("api-version") apiVersion: VersionsA): void; + } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { + bv1, + bv2, + bv3, + } + interface BI { + @route("/bTest") + bTest(@query("api-version") apiVersion: VersionsB): void; + + @route("/bTest2") + @added(VersionsB.bv2) + bTest2(@query("api-version") apiVersion: VersionsB): void; + } + }`, + ` + @client( + { + name: "CombineClient", + service: [ServiceA, ServiceB], + } + ) + @useDependency(ServiceA.VersionsA.av3, ServiceB.VersionsB.bv1) + namespace CombineClient; + `, + ); + const sdkPackage = runnerWithVersion.context.sdkPackage; + strictEqual(sdkPackage.clients.length, 1); + const client = sdkPackage.clients[0]; + strictEqual(client.name, "CombineClient"); + strictEqual(client.apiVersions.length, 0); + + const aiClient = client.children!.find((c) => c.name === "AI"); + ok(aiClient); + // ServiceA doesn't have bv1, so api-version="bv1" doesn't filter it + // It falls back to useDependency which specifies av3 + strictEqual(aiClient.apiVersions.length, 0); + + const biClient = client.children!.find((c) => c.name === "BI"); + ok(biClient); + // With api-version bv1, we should see only bv1 version + strictEqual(biClient.apiVersions.length, 1); + deepStrictEqual(biClient.apiVersions, ["bv1"]); + const biApiVersionParam = biClient.clientInitialization.parameters.find( + (p) => p.isApiVersionParam, + ); + ok(biApiVersionParam); + strictEqual(biApiVersionParam.clientDefaultValue, "bv1"); + + // bTest2 should not be included since it was added in bv2 + strictEqual(biClient.methods.length, 1); + strictEqual(biClient.methods[0].name, "bTest"); +}); + +it("one client from multiple services with api-version set to all", async () => { + const runnerWithVersion = await createSdkTestRunner({ + "api-version": "all", + emitterName: "@azure-tools/typespec-python", + }); + await runnerWithVersion.compileWithCustomization( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { + av1, + av2, + av3, + } + interface AI { + @route("/aTest") + aTest(@query("api-version") apiVersion: VersionsA): void; + + @route("/aTest2") + @added(VersionsA.av2) + @removed(VersionsA.av3) + aTest2(@query("api-version") apiVersion: VersionsA): void; + } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { + bv1, + bv2, + } + interface BI { + @route("/bTest") + bTest(@query("api-version") apiVersion: VersionsB): void; + + @route("/bTest2") + @added(VersionsB.bv2) + bTest2(@query("api-version") apiVersion: VersionsB): void; + } + }`, + ` + @client( + { + name: "CombineClient", + service: [ServiceA, ServiceB], + } + ) + @useDependency(ServiceA.VersionsA.av3, ServiceB.VersionsB.bv2) + namespace CombineClient; + `, + ); + const sdkPackage = runnerWithVersion.context.sdkPackage; + strictEqual(sdkPackage.clients.length, 1); + const client = sdkPackage.clients[0]; + strictEqual(client.name, "CombineClient"); + strictEqual(client.apiVersions.length, 0); + + const aiClient = client.children!.find((c) => c.name === "AI"); + ok(aiClient); + strictEqual(aiClient.apiVersions.length, 3); + deepStrictEqual(aiClient.apiVersions, ["av1", "av2", "av3"]); + const aiApiVersionParam = aiClient.clientInitialization.parameters.find( + (p) => p.isApiVersionParam, + ); + ok(aiApiVersionParam); + strictEqual(aiApiVersionParam.clientDefaultValue, "av3"); + + // With api-version all, both aTest and aTest2 should be included + strictEqual(aiClient.methods.length, 2); + 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"]); + + const biClient = client.children!.find((c) => c.name === "BI"); + ok(biClient); + strictEqual(biClient.apiVersions.length, 2); + deepStrictEqual(biClient.apiVersions, ["bv1", "bv2"]); + const biApiVersionParam = biClient.clientInitialization.parameters.find( + (p) => p.isApiVersionParam, + ); + ok(biApiVersionParam); + strictEqual(biApiVersionParam.clientDefaultValue, "bv2"); + + // Both bTest and bTest2 should be included + strictEqual(biClient.methods.length, 2); + const bTest = biClient.methods.find((m) => m.name === "bTest"); + ok(bTest); + deepStrictEqual(bTest.apiVersions, ["bv1", "bv2"]); + const bTest2 = biClient.methods.find((m) => m.name === "bTest2"); + ok(bTest2); + deepStrictEqual(bTest2.apiVersions, ["bv2"]); +}); + +it("one client from multiple services with different useDependency versions", async () => { + const runnerWithVersion = await createSdkTestRunner({ + emitterName: "@azure-tools/typespec-python", + }); + await runnerWithVersion.compileWithCustomization( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { + av1, + av2, + av3, + } + interface AI { + @route("/aTest") + aTest(@query("api-version") apiVersion: VersionsA): void; + } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { + bv1, + bv2, + bv3, + } + interface BI { + @route("/bTest") + bTest(@query("api-version") apiVersion: VersionsB): void; + } + }`, + ` + @client( + { + name: "CombineClient", + service: [ServiceA, ServiceB], + } + ) + @useDependency(ServiceA.VersionsA.av1, ServiceB.VersionsB.bv3) + namespace CombineClient; + `, + ); + const sdkPackage = runnerWithVersion.context.sdkPackage; + strictEqual(sdkPackage.clients.length, 1); + const client = sdkPackage.clients[0]; + strictEqual(client.name, "CombineClient"); + strictEqual(client.apiVersions.length, 0); + + const aiClient = client.children!.find((c) => c.name === "AI"); + ok(aiClient); + // useDependency specifies av1, so only av1 should be included + strictEqual(aiClient.apiVersions.length, 1); + deepStrictEqual(aiClient.apiVersions, ["av1"]); + const aiApiVersionParam = aiClient.clientInitialization.parameters.find( + (p) => p.isApiVersionParam, + ); + ok(aiApiVersionParam); + strictEqual(aiApiVersionParam.clientDefaultValue, "av1"); + + const biClient = client.children!.find((c) => c.name === "BI"); + ok(biClient); + // useDependency specifies bv3, so all versions up to bv3 should be included + strictEqual(biClient.apiVersions.length, 3); + deepStrictEqual(biClient.apiVersions, ["bv1", "bv2", "bv3"]); + const biApiVersionParam = biClient.clientInitialization.parameters.find( + (p) => p.isApiVersionParam, + ); + ok(biApiVersionParam); + strictEqual(biApiVersionParam.clientDefaultValue, "bv3"); +}); + +it("one client from multiple services with models shared across services", async () => { + const runnerWithVersion = await createSdkTestRunner({ + "api-version": "latest", + emitterName: "@azure-tools/typespec-python", + }); + await runnerWithVersion.compileWithCustomization( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { + av1, + av2, + } + + model SharedModel { + name: string; + @added(VersionsA.av2) + description?: string; + } + + interface AI { + @route("/aTest") + aTest(@body body: SharedModel, @query("api-version") apiVersion: VersionsA): void; + } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { + bv1, + bv2, + } + + model SharedModel { + id: int32; + @added(VersionsB.bv2) + value?: string; + } + + interface BI { + @route("/bTest") + bTest(@body body: SharedModel, @query("api-version") apiVersion: VersionsB): void; + } + }`, + ` + @client( + { + name: "CombineClient", + service: [ServiceA, ServiceB], + } + ) + @useDependency(ServiceA.VersionsA.av2, ServiceB.VersionsB.bv2) + namespace CombineClient; + `, + ); + const sdkPackage = runnerWithVersion.context.sdkPackage; + strictEqual(sdkPackage.clients.length, 1); + const client = sdkPackage.clients[0]; + strictEqual(client.name, "CombineClient"); + + // Both SharedModel types should exist - one from each service + const models = sdkPackage.models; + strictEqual(models.length, 2); + + const sharedModelA = models.find((m) => m.namespace === "ServiceA"); + ok(sharedModelA); + strictEqual(sharedModelA.name, "SharedModel"); + strictEqual(sharedModelA.properties.length, 2); + const nameProperty = sharedModelA.properties.find((p) => p.name === "name"); + ok(nameProperty); + const descriptionProperty = sharedModelA.properties.find((p) => p.name === "description"); + ok(descriptionProperty); + + const sharedModelB = models.find((m) => m.namespace === "ServiceB"); + ok(sharedModelB); + strictEqual(sharedModelB.name, "SharedModel"); + strictEqual(sharedModelB.properties.length, 2); + const idProperty = sharedModelB.properties.find((p) => p.name === "id"); + ok(idProperty); + const valueProperty = sharedModelB.properties.find((p) => p.name === "value"); + ok(valueProperty); + + const aiClient = client.children!.find((c) => c.name === "AI"); + ok(aiClient); + strictEqual(aiClient.apiVersions.length, 2); + deepStrictEqual(aiClient.apiVersions, ["av1", "av2"]); + + const biClient = client.children!.find((c) => c.name === "BI"); + ok(biClient); + strictEqual(biClient.apiVersions.length, 2); + deepStrictEqual(biClient.apiVersions, ["bv1", "bv2"]); +}); + +it("error: multiple explicit clients with multiple services", async () => { + const [_, diagnostics] = await runner.compileAndDiagnoseWithCustomization( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { + av1, + av2, + } + interface AI { + @route("/aTest") + aTest(@query("api-version") apiVersion: VersionsA): void; + } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { + bv1, + bv2, + } + interface BI { + @route("/bTest") + bTest(@query("api-version") apiVersion: VersionsB): void; + } + }`, + ` + @client( + { + name: "ClientA", + service: [ServiceA, ServiceB], + } + ) + @useDependency(ServiceA.VersionsA.av2, ServiceB.VersionsB.bv2) + namespace ClientA {} + + @client( + { + name: "ClientB", + service: [ServiceA, ServiceB], + } + ) + @useDependency(ServiceA.VersionsA.av2, ServiceB.VersionsB.bv2) + namespace ClientB {} + `, + ); + expectDiagnostics(diagnostics, [ + { + code: "@azure-tools/typespec-client-generator-core/multiple-explicit-clients-multiple-services", + message: "Can not define multiple explicit clients with multiple services.", + }, + { + code: "@azure-tools/typespec-client-generator-core/multiple-explicit-clients-multiple-services", + message: "Can not define multiple explicit clients with multiple services.", + }, + ]); +}); + +it("error: client location to new operation group with multiple services", async () => { + const [_, diagnostics] = await runner.compileAndDiagnoseWithCustomization( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { + av1, + av2, + } + @route("/aTest") + op aTest(@query("api-version") apiVersion: VersionsA): void; + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { + bv1, + bv2, + } + @route("/bTest") + op bTest(@query("api-version") apiVersion: VersionsB): void; + }`, + ` + @client( + { + name: "CombineClient", + service: [ServiceA, ServiceB], + } + ) + @useDependency(ServiceA.VersionsA.av2, ServiceB.VersionsB.bv2) + namespace CombineClient {} + + // Try to move operations from different services to a new operation group that doesn't exist + @@clientLocation(ServiceA.aTest, "NewOperationGroup"); + @@clientLocation(ServiceB.bTest, "NewOperationGroup"); + `, + ); + expectDiagnostics(diagnostics, { + code: "@azure-tools/typespec-client-generator-core/client-location-new-operation-group-multi-service", + message: + "Cannot move operations from different services to a new operation group that doesn't exist.", + }); +}); + +it("error: inconsistent-multiple-service server", async () => { + const [_, diagnostics] = await runner.compileAndDiagnoseWithCustomization( + ` + @service + @server("https://servicea.example.com") + namespace ServiceA { + } + @service + @server("https://serviceb.example.com") + namespace ServiceB { + }`, + ` + @client( + { + name: "CombineClient", + service: [ServiceA, ServiceB], + } + ) + namespace CombineClient {} + `, + ); + expectDiagnostics(diagnostics, [ + { + code: "@azure-tools/typespec-client-generator-core/inconsistent-multiple-service", + message: "All services must have the same server and auth definitions.", + }, + { + code: "@azure-tools/typespec-client-generator-core/multiple-services", + message: + "Multiple services found. Only the first service will be used; others will be ignored.", + }, + ]); +}); + +it("error: inconsistent-multiple-service-servers auth", async () => { + const [_, diagnostics] = await runner.compileAndDiagnoseWithCustomization( + ` + @service + @useAuth(BasicAuth) + namespace ServiceA { + } + @service + @useAuth(BearerAuth) + namespace ServiceB { + }`, + ` + @client( + { + name: "CombineClient", + service: [ServiceA, ServiceB], + } + ) + namespace CombineClient {} + `, + ); + expectDiagnostics(diagnostics, [ + { + code: "@azure-tools/typespec-client-generator-core/inconsistent-multiple-service", + message: "All services must have the same server and auth definitions.", + }, + { + code: "@azure-tools/typespec-client-generator-core/multiple-services", + message: + "Multiple services found. Only the first service will be used; others will be ignored.", + }, + ]); +}); diff --git a/packages/typespec-client-generator-core/test/decorators/client.test.ts b/packages/typespec-client-generator-core/test/decorators/client.test.ts index 092805dbf5..a7626d516c 100644 --- a/packages/typespec-client-generator-core/test/decorators/client.test.ts +++ b/packages/typespec-client-generator-core/test/decorators/client.test.ts @@ -47,7 +47,6 @@ describe("@client", () => { name: "MyClient", service: MyClient, type: MyClient, - crossLanguageDefinitionId: "MyClient.MyClient", subOperationGroups: [], }, ]); @@ -68,7 +67,6 @@ describe("@client", () => { name: "MyClient", service: MyService, type: MyClient, - crossLanguageDefinitionId: "MyService.MyClient", subOperationGroups: [], }, ]); @@ -183,7 +181,6 @@ describe("listClients without @client", () => { name: "MyServiceClient", service: MyService, type: MyService, - crossLanguageDefinitionId: "MyService", subOperationGroups: [], }, ]); @@ -446,7 +443,6 @@ describe("@operationGroup", () => { name: "MyServiceClient", service: MyService, type: MyService, - crossLanguageDefinitionId: "MyService", subOperationGroups: [], }, ]); @@ -1465,31 +1461,3 @@ it("operations under namespace or interface without @client or @operationGroup", const operationGroup = operationGroups[0]; strictEqual(listOperationsInOperationGroup(runner.context, operationGroup).length, 1); }); - -it("multiple @service with @client", async () => { - await runner.compile(` - @service - @client({ name: "MyService1Client" }) - namespace MyService1 { - op foo(): void; - } - - @service - @client({ name: "MyService2Client" }) - namespace MyService2 { - op bar(): void; - } - - @service - @client({ name: "MyService3Client" }) - namespace MyService3 { - op bar(): void; - } - `); - - const clients = listClients(runner.context); - deepStrictEqual(clients.length, 3); - deepStrictEqual(clients[0].name, "MyService1Client"); - deepStrictEqual(clients[1].name, "MyService2Client"); - deepStrictEqual(clients[2].name, "MyService3Client"); -}); 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 248f32ace6..cca095d045 100644 --- a/packages/typespec-client-generator-core/test/examples/load.test.ts +++ b/packages/typespec-client-generator-core/test/examples/load.test.ts @@ -403,3 +403,120 @@ it("teamplate case", async () => { strictEqual(operation.examples?.length, 1); strictEqual(operation.examples![0].filePath, "template.json"); }); + +it("multiple services without versioning", async () => { + runner = await createSdkTestRunner({ + emitterName: "@azure-tools/typespec-java", + "examples-dir": `./examples`, + }); + + await runner.host.addRealTypeSpecFile( + "./ServiceA/examples/AI_aTest.json", + `${__dirname}/multi-service/ServiceA_AI_aTest.json`, + ); + await runner.host.addRealTypeSpecFile( + "./ServiceB/examples/BI_bTest.json", + `${__dirname}/multi-service/ServiceB_BI_bTest.json`, + ); + + await runner.compileWithCustomization( + ` + @service + namespace ServiceA { + interface AI { + @route("/aTest") + aTest(): string; + } + } + @service + namespace ServiceB { + interface BI { + @route("/bTest") + bTest(): string; + } + } + `, + ` + @client({ + name: "CombineClient", + service: [ServiceA, ServiceB], + }) + namespace CombineClient; + `, + ); + + const sdkPackage = runner.context.sdkPackage; + strictEqual(sdkPackage.clients.length, 1); + const client = sdkPackage.clients[0]; + strictEqual(client.name, "CombineClient"); + strictEqual(client.children?.length, 2); + + // Check AI operation group examples + const aiClient = client.children?.find((c) => c.name === "AI"); + ok(aiClient); + const aiMethod = aiClient.methods[0] as SdkServiceMethod; + ok(aiMethod.operation.examples); + strictEqual(aiMethod.operation.examples.length, 1); + strictEqual(aiMethod.operation.examples[0].filePath, "AI_aTest.json"); + strictEqual(aiMethod.operation.examples[0].name, "Test operation from ServiceA"); + + // Check BI operation group examples + const biClient = client.children?.find((c) => c.name === "BI"); + ok(biClient); + const biMethod = biClient.methods[0] as SdkServiceMethod; + ok(biMethod.operation.examples); + strictEqual(biMethod.operation.examples.length, 1); + strictEqual(biMethod.operation.examples[0].filePath, "BI_bTest.json"); + strictEqual(biMethod.operation.examples[0].name, "Test operation from ServiceB"); +}); + +it("multiple services without examples", async () => { + runner = await createSdkTestRunner({ + emitterName: "@azure-tools/typespec-java", + "examples-dir": `./examples`, + }); + + await runner.compileWithCustomization( + ` + @service + namespace ServiceA { + interface AI { + @route("/aTest") + aTest(): string; + } + } + @service + namespace ServiceB { + interface BI { + @route("/bTest") + bTest(): string; + } + } + `, + ` + @client({ + name: "CombineClient", + service: [ServiceA, ServiceB], + }) + namespace CombineClient; + `, + ); + + const sdkPackage = runner.context.sdkPackage; + strictEqual(sdkPackage.clients.length, 1); + const client = sdkPackage.clients[0]; + strictEqual(client.name, "CombineClient"); + strictEqual(client.children?.length, 2); + + // Check AI operation group examples + const aiClient = client.children?.find((c) => c.name === "AI"); + ok(aiClient); + const aiMethod = aiClient.methods[0] as SdkServiceMethod; + strictEqual(aiMethod.operation.examples, undefined); + + // Check BI operation group examples + const biClient = client.children?.find((c) => c.name === "BI"); + ok(biClient); + const biMethod = biClient.methods[0] as SdkServiceMethod; + strictEqual(biMethod.operation.examples, undefined); +}); diff --git a/packages/typespec-client-generator-core/test/examples/multi-service/ServiceA_AI_aTest.json b/packages/typespec-client-generator-core/test/examples/multi-service/ServiceA_AI_aTest.json new file mode 100644 index 0000000000..3796bf270f --- /dev/null +++ b/packages/typespec-client-generator-core/test/examples/multi-service/ServiceA_AI_aTest.json @@ -0,0 +1,12 @@ +{ + "operationId": "AI_aTest", + "title": "Test operation from ServiceA", + "parameters": { + "api-version": "av2" + }, + "responses": { + "200": { + "body": "ServiceA response" + } + } +} diff --git a/packages/typespec-client-generator-core/test/examples/multi-service/ServiceB_BI_bTest.json b/packages/typespec-client-generator-core/test/examples/multi-service/ServiceB_BI_bTest.json new file mode 100644 index 0000000000..acc83e5b4e --- /dev/null +++ b/packages/typespec-client-generator-core/test/examples/multi-service/ServiceB_BI_bTest.json @@ -0,0 +1,12 @@ +{ + "operationId": "BI_bTest", + "title": "Test operation from ServiceB", + "parameters": { + "api-version": "bv2" + }, + "responses": { + "200": { + "body": "ServiceB response" + } + } +} diff --git a/packages/typespec-client-generator-core/test/package/versioning.test.ts b/packages/typespec-client-generator-core/test/package/versioning.test.ts index dd749132cd..59896babaa 100644 --- a/packages/typespec-client-generator-core/test/package/versioning.test.ts +++ b/packages/typespec-client-generator-core/test/package/versioning.test.ts @@ -1,10 +1,9 @@ import { AzureCoreTestLibrary } from "@azure-tools/typespec-azure-core/testing"; -import { Interface } from "@typespec/compiler"; +import { Namespace } from "@typespec/compiler"; import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { beforeEach, it } from "vitest"; import { - getClient, listClients, listOperationGroups, listOperationsInOperationGroup, @@ -73,7 +72,12 @@ it("basic default version", async () => { const sdkPackage = runnerWithVersion.context.sdkPackage; strictEqual(sdkPackage.metadata.apiVersion, "v3"); - deepStrictEqual(runnerWithVersion.context.getPackageVersions(), ["v1", "v2", "v3"]); + deepStrictEqual( + runnerWithVersion.context + .getPackageVersions() + .get(sdkPackage.clients[0].__raw.type as Namespace), + ["v1", "v2", "v3"], + ); strictEqual(sdkPackage.clients.length, 1); const apiVersionParam = sdkPackage.clients[0].clientInitialization.parameters.find( @@ -174,7 +178,12 @@ it("basic latest version", async () => { const sdkPackage = runnerWithVersion.context.sdkPackage; strictEqual(sdkPackage.metadata.apiVersion, "v3"); - deepStrictEqual(runnerWithVersion.context.getPackageVersions(), ["v1", "v2", "v3"]); + deepStrictEqual( + runnerWithVersion.context + .getPackageVersions() + .get(sdkPackage.clients[0].__raw.type as Namespace), + ["v1", "v2", "v3"], + ); strictEqual(sdkPackage.clients.length, 1); const apiVersionParam = sdkPackage.clients[0].clientInitialization.parameters.find( @@ -274,7 +283,12 @@ it("basic v3 version", async () => { const sdkPackage = runnerWithVersion.context.sdkPackage; strictEqual(sdkPackage.metadata.apiVersion, "v3"); - deepStrictEqual(runnerWithVersion.context.getPackageVersions(), ["v1", "v2", "v3"]); + deepStrictEqual( + runnerWithVersion.context + .getPackageVersions() + .get(sdkPackage.clients[0].__raw.type as Namespace), + ["v1", "v2", "v3"], + ); strictEqual(sdkPackage.clients.length, 1); const apiVersionParam = sdkPackage.clients[0].clientInitialization.parameters.find( @@ -374,7 +388,12 @@ it("basic v2 version", async () => { const sdkPackage = runnerWithVersion.context.sdkPackage; strictEqual(sdkPackage.metadata.apiVersion, "v2"); - deepStrictEqual(runnerWithVersion.context.getPackageVersions(), ["v1", "v2"]); + deepStrictEqual( + runnerWithVersion.context + .getPackageVersions() + .get(sdkPackage.clients[0].__raw.type as Namespace), + ["v1", "v2"], + ); strictEqual(sdkPackage.clients.length, 1); const apiVersionParam = sdkPackage.clients[0].clientInitialization.parameters.find( @@ -477,7 +496,12 @@ it("basic v1 version", async () => { const sdkPackage = runnerWithVersion.context.sdkPackage; strictEqual(sdkPackage.metadata.apiVersion, "v1"); - deepStrictEqual(runnerWithVersion.context.getPackageVersions(), ["v1"]); + deepStrictEqual( + runnerWithVersion.context + .getPackageVersions() + .get(sdkPackage.clients[0].__raw.type as Namespace), + ["v1"], + ); strictEqual(sdkPackage.clients.length, 1); const apiVersionParam = sdkPackage.clients[0].clientInitialization.parameters.find( @@ -565,7 +589,12 @@ it("basic all version", async () => { const sdkPackage = runnerWithVersion.context.sdkPackage; strictEqual(sdkPackage.metadata.apiVersion, "all"); - deepStrictEqual(runnerWithVersion.context.getPackageVersions(), ["v1", "v2", "v3"]); + deepStrictEqual( + runnerWithVersion.context + .getPackageVersions() + .get(sdkPackage.clients[0].__raw.type as Namespace), + ["v1", "v2", "v3"], + ); strictEqual(sdkPackage.clients.length, 1); const apiVersionParam = sdkPackage.clients[0].clientInitialization.parameters.find( @@ -1062,10 +1091,7 @@ it("multiple clients", async () => { "api-version": "v1", emitterName: "@azure-tools/typespec-python", }); - - let { A, B } = (await runnerWithVersion.compile(tsp)) as { A: Interface; B: Interface }; - ok(getClient(runnerWithVersion.context, A)); - strictEqual(getClient(runnerWithVersion.context, B), undefined); + await runnerWithVersion.compile(tsp); let clients = listClients(runnerWithVersion.context); strictEqual(clients.length, 1); @@ -1080,12 +1106,7 @@ it("multiple clients", async () => { "api-version": "v2", emitterName: "@azure-tools/typespec-python", }); - - let result = (await runnerWithVersion.compile(tsp)) as { A: Interface; B: Interface }; - A = result.A; - B = result.B; - ok(getClient(runnerWithVersion.context, A)); - ok(getClient(runnerWithVersion.context, B)); + await runnerWithVersion.compile(tsp); clients = listClients(runnerWithVersion.context); strictEqual(clients.length, 2); @@ -1110,12 +1131,7 @@ it("multiple clients", async () => { "api-version": "v3", emitterName: "@azure-tools/typespec-python", }); - - result = (await runnerWithVersion.compile(tsp)) as { A: Interface; B: Interface }; - A = result.A; - B = result.B; - ok(getClient(runnerWithVersion.context, A)); - ok(getClient(runnerWithVersion.context, B)); + await runnerWithVersion.compile(tsp); clients = listClients(runnerWithVersion.context); strictEqual(clients.length, 2); @@ -1427,7 +1443,12 @@ it("version not exist", async () => { const sdkPackage = runnerWithVersion.context.sdkPackage; strictEqual(sdkPackage.metadata.apiVersion, "v3"); - deepStrictEqual(runnerWithVersion.context.getPackageVersions(), ["v1", "v2", "v3"]); + deepStrictEqual( + runnerWithVersion.context + .getPackageVersions() + .get(sdkPackage.clients[0].__raw.type as Namespace), + ["v1", "v2", "v3"], + ); strictEqual(sdkPackage.clients.length, 1); const apiVersionParam = sdkPackage.clients[0].clientInitialization.parameters.find( diff --git a/website/src/content/docs/docs/howtos/Generate client libraries/03client.mdx b/website/src/content/docs/docs/howtos/Generate client libraries/03client.mdx index f8c3c40927..c16045273c 100644 --- a/website/src/content/docs/docs/howtos/Generate client libraries/03client.mdx +++ b/website/src/content/docs/docs/howtos/Generate client libraries/03client.mdx @@ -1997,3 +1997,204 @@ client.download("blobName"); ``` + +### One Client from Multiple Services + +You could define a single client that combines operations from multiple services. This is useful when you want to provide a unified client experience for related services that share a common endpoint, but require different versioning. + +1. **Service Array**: Set all the services that you want to merge in to the `@client` decorator's `service` property (e.g., `service: [ServiceA, ServiceB]`). + +2. **Version Dependencies**: If you want to specify each service's API version used in the single client, you could use `@useDependency` to declare which version of each service the client supports. Otherwise, the latest version of each service will be used by default. + +3. **Operation Group Structure**: Operations and operation groups will be auto merged into the single client (based on operation, interfaces or namespaces from the original services). + +:::caution +All services being merged must share the same endpoint and authentication method. If not, you will get diagnostics error. +You need to ensue that there are no naming conflicts between the services being merged. If there are conflicts, you may need to rename the types in the original services to avoid collisions. +::: + + + +```typespec title="main.tsp" +@service +@versioned(VersionsA) +namespace ServiceA { + enum VersionsA { + av1, + av2, + } + interface AI { + @route("/aTest") + aTest(@query("api-version") apiVersion: VersionsA): void; + } +} + +@service +@versioned(VersionsB) +namespace ServiceB { + enum VersionsB { + bv1, + bv2, + } + interface BI { + @route("/bTest") + bTest(@query("api-version") apiVersion: VersionsB): void; + } +} +``` + +```typespec title="client.tsp" +import "./main.tsp"; +import "@azure-tools/typespec-client-generator-core"; + +using Azure.ClientGenerator.Core; + +@client({ + name: "CombineClient", + service: [ServiceA, ServiceB], +}) +@useDependency(ServiceA.VersionsA.av1, ServiceB.VersionsB.bv2) +namespace CombineClient; +``` + +```python +# generated _client.py +class CombineClient: + def __init__(self, endpoint: str, **kwargs: Any) -> None: + self.ai = AIOperations(endpoint=endpoint, **kwargs) + self.bi = BIOperations(endpoint=endpoint, **kwargs) + +# generated operations/_operations.py +class AIOperations: + def __init__(self, client, api_version: str = "av1") -> None: ... + + @distributed_trace + def a_test(self, **kwargs: Any) -> None: ... + +class BIOperations: + def __init__(self, client, api_version: str = "bv2") -> None: ... + + @distributed_trace + def b_test(self, **kwargs: Any) -> None: ... + +# usage sample +from combine_client import CombineClient + +client = CombineClient(endpoint="") +client.ai.a_test() # uses api-version av1 +client.bi.b_test() # uses api-version bv2 +``` + +```csharp +using CombineClient; + +// The combined client with operation groups for each service +CombineClient client = new CombineClient(new Uri("")); + +// AI operations use ServiceA's api-version (av1) +client.GetAIClient().ATest(); + +// BI operations use ServiceB's api-version (bv2) +client.GetBIClient().BTest(); +``` + +```typescript +import { CombineClient } from "@azure/package-name"; + +const client = new CombineClient(""); + +// AI operations use ServiceA's api-version (av1) +client.ai.aTest(); + +// BI operations use ServiceB's api-version (bv2) +client.bi.bTest(); +``` + +```java +// Client builder class +package combineclient; + +@ServiceClientBuilder( + serviceClients = { + AIClient.class, + BIClient.class, + AIAsyncClient.class, + BIAsyncClient.class }) +public final class CombineClientBuilder implements HttpTrait, + ConfigurationTrait, EndpointTrait { + + public CombineClientBuilder(); + + public AIClient buildAIClient(); + public BIClient buildBIClient(); +} + +// Client classes +@ServiceClient(builder = CombineClientBuilder.class) +public final class AIClient { + // Uses ServiceA's api-version (av1) + public void aTest(); +} + +@ServiceClient(builder = CombineClientBuilder.class) +public final class BIClient { + // Uses ServiceB's api-version (bv2) + public void bTest(); +} + +// Usage +CombineClientBuilder builder = new CombineClientBuilder() + .endpoint(""); + +AIClient aiClient = builder.buildAIClient(); +aiClient.aTest(); // uses api-version av1 + +BIClient biClient = builder.buildBIClient(); +biClient.bTest(); // uses api-version bv2 +``` + +```go +// generated combine_client.go +type CombineClient struct {} + +func NewCombineClient(endpoint string) *CombineClient { + return &CombineClient{} +} + +func (client *CombineClient) NewAIClient() *AIClient { + return &AIClient{} +} + +func (client *CombineClient) NewBIClient() *BIClient { + return &BIClient{} +} + +// generated ai_client.go +type AIClient struct {} + +// Uses ServiceA's api-version (av1) +func (client *AIClient) ATest(ctx context.Context, options *AIClientATestOptions) (AIClientATestResponse, error) {} + +// generated bi_client.go +type BIClient struct {} + +// Uses ServiceB's api-version (bv2) +func (client *BIClient) BTest(ctx context.Context, options *BIClientBTestOptions) (BIClientBTestResponse, error) {} + +// generated options.go +type AIClientATestOptions struct {} +type BIClientBTestOptions struct {} + +// generated response.go +type AIClientATestResponse struct {} +type BIClientBTestResponse struct {} + +// Usage Sample +combineClient := NewCombineClient("") +aiClient := combineClient.NewAIClient() +aiClient.ATest(context.Background(), &AIClientATestOptions{}) // uses api-version av1 +biClient := combineClient.NewBIClient() +biClient.BTest(context.Background(), &BIClientBTestOptions{}) // uses api-version bv2 +``` + + diff --git a/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md b/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md index fa6c4a197f..8342164796 100644 --- a/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md +++ b/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md @@ -88,7 +88,7 @@ Most TCGC types share the following common properties: - **`access`**: Indicates whether the type has public or private accessibility. - **`usage`**: Indicates the type's usage information; its value is a bitmap of [`UsageFlags`](../reference/js-api/enumerations/usageflags/) enumeration. - **`deprecation`**: Indicates whether the type is deprecated and provides the deprecation message. -- **`clientDefaultValue`**: The type's default value if provided. Currently, it only exists in endpoint and API version parameters. +- **`clientDefaultValue`**: The type's default value if provided. Set via the `@clientDefaultValue` decorator or auto-set for endpoint and API version parameters. ### Package @@ -161,7 +161,7 @@ TCGC currently supports one kind of operation: [`SdkHttpOperation`](../reference `SdkHttpOperation` contains verb, path, URI template, query/header/path/cookie/body parameters, responses, and exceptions of an HTTP operation. -Each parameter for an HTTP operation has a `correspondingMethodParams` property to indicate the mapping of one payload parameter with one or more method-level parameters or model properties. This helps emitters determine how to compose the underlying payload with the method's parameters. One body parameter can have several method-level parameter or model property mappings because of the implicit body parameter resolving from the TypeSpec HTTP library. +Each parameter for an HTTP operation has a `methodParameterSegments` property to indicate the mapping of one payload parameter with the path of one or more method-level parameters or model properties. This helps emitters determine how to compose the underlying payload with the method's parameters. One body parameter can have several method-level parameter or model property mapping paths because of the implicit body parameter resolving from the TypeSpec HTTP library. ### Type @@ -209,6 +209,8 @@ For types in TypeSpec, TCGC provides several client types to represent them in a - `discriminatedSubtypes`: List of all subtypes of this discriminated model - For subtypes of discriminated models: - `discriminatorValue`: The instance value for the discriminator for this subtype + - For array properties: + - `arrayEncode`: Indicates the encoding style for array properties (if specified). ### Example types @@ -256,7 +258,7 @@ With `@clientInitialization` decorator, the default behavior may change. New cli ### Method Detection -The methods depend on the combination usage of `Operation`, `@scope` and `@moveTo`. +The methods depend on the combination usage of `Operation`, `@scope`, and `@moveTo`. A client's operations include the `Operation` under the client's `Namespace` or `Interface`, adding any operations with `@moveTo` current client, deducting any operations with `@scope` out of current emitter or `@moveTo` another client. diff --git a/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/data-types.md b/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/data-types.md index 49c8521699..9936f0cac9 100644 --- a/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/data-types.md +++ b/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/data-types.md @@ -31,10 +31,10 @@ model Azure.ClientGenerator.Core.ClientOptions #### Properties -| Name | Type | Description | -| -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| service? | `Namespace` | The service that this client is generated for. If not specified, TCGC will look up the first parent namespace decorated with `@service` for the target.
The namespace should be decorated with `@service`. | -| name? | `string` | The name of the client. If not specified, the default name will be `Client`. | +| Name | Type | Description | +| -------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| service? | `Namespace \| Namespace[]` | The services that this client is generated for. If not specified, TCGC will look up the first parent namespace decorated with `@service` for the target.
The namespace should be decorated with `@service`. | +| name? | `string` | The name of the client. If not specified, the default name will be `Client`. | ### `ExternalType` {#Azure.ClientGenerator.Core.ExternalType}