diff --git a/packages/typespec-client-generator-core/design-docs/client.md b/packages/typespec-client-generator-core/design-docs/client.md index bd88e7198d..8c55354db2 100644 --- a/packages/typespec-client-generator-core/design-docs/client.md +++ b/packages/typespec-client-generator-core/design-docs/client.md @@ -71,7 +71,7 @@ sub_client.do_something() The entrance of TCGC is `SdkPackage` which represents a complete package and includes clients, models, etc. The clients depend on the combination usage of namespace, interface, `@service`, `@client`. -If there is no explicitly defined `@client`, then the first namespaces with `@service` will be a client. The nested namespaces and interfaces under that namespace will be a sub client with hierarchy. +If there is no explicitly defined `@client`, then each namespace with `@service` will be a separate root client. The nested namespaces and interfaces under each service namespace will be sub clients with hierarchy. - Example 1: diff --git a/packages/typespec-client-generator-core/design-docs/multiple-services.md b/packages/typespec-client-generator-core/design-docs/multiple-services.md index 17bd69edcf..66adcf2294 100644 --- a/packages/typespec-client-generator-core/design-docs/multiple-services.md +++ b/packages/typespec-client-generator-core/design-docs/multiple-services.md @@ -87,7 +87,7 @@ namespace CombineClient; 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. +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. For cross-service clients, the `apiVersions` property will be an empty array `[]`, and a new `apiVersionsMap` property will store a map of service namespace full qualified names to their API versions (e.g., `{"ServiceA": ["av1", "av2"], "ServiceB": ["bv1", "bv2"]}`). 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. If services have different `@server` or `@useAuth` definitions, TCGC will report a diagnostic error. 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. If multiple services have nested namespaces or interfaces with the same name, TCGC will automatically merge them into a single operation group. The merged operation group will have empty `apiVersions` and a `string` type for the API version parameter, and will contain operations from all the services. 4. 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. @@ -102,7 +102,10 @@ clients: - &a1 kind: client name: CombineClient - apiVersions: [] + apiVersions: [] # Empty for cross-service clients + apiVersionsMap: # Map of service namespace to API versions + ServiceA: [av1, av2] + ServiceB: [bv1, bv2] clientInitialization: kind: clientinitialization parameters: @@ -113,6 +116,9 @@ clients: - kind: method name: apiVersion apiVersions: [] + apiVersionsMap: + ServiceA: [av1, av2] + ServiceB: [bv1, bv2] clientDefaultValue: undefined isGeneratedName: false onClient: true @@ -250,3 +256,691 @@ The resulting `SharedGroup` operation group will have: - `apiVersions: []` - API version parameter with `type.kind === "string"` - Operations from both ServiceA and ServiceB + +## Extended Design: Advanced Client Hierarchy Customization + +The first step design focuses on explicitly merging multiple services into one client using `@client` with an array of services. This section extends the previous [client hierarchy design](./client.md) to clarify the behavior when no explicit `@client` is defined and to provide additional scenarios. + +### Scenario 0: Multiple Services Without Explicit `@client` (Default Behavior) + +When there are multiple `@service` namespaces and no explicit `@client` decorator is defined, TCGC will automatically create a separate root client for each `@service` namespace. This matches the single-service behavior where each `@service` namespace becomes its own client. + +#### Syntax Proposal + +No `client.tsp` file is needed. Each `@service` namespace automatically becomes a root client: + +```typespec title="main.tsp" +@service +@versioned(VersionsA) +namespace ServiceA { + enum VersionsA { + av1, + av2, + } + + interface Operations { + opA(): void; + } + + namespace SubNamespace { + op subOpA(): void; + } +} + +@service +@versioned(VersionsB) +namespace ServiceB { + enum VersionsB { + bv1, + bv2, + } + + interface Operations { + opB(): void; + } + + namespace SubNamespace { + op subOpB(): void; + } +} +``` + +#### TCGC Behavior + +TCGC will automatically generate two independent root clients, each with their own API versions and children: + +According to [client.md](./client.md), the default value of `initializedBy` for a root client is `InitializedBy.individually`, while for a sub client it is `InitializedBy.parent`. + +```yaml +clients: + - &a1 + kind: client + name: ServiceAClient + apiVersions: [av1, av2] + clientInitialization: + initializedBy: individually + children: + - kind: client + name: Operations + parent: *a1 + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: opA + - kind: client + name: SubNamespace + parent: *a1 + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: subOpA + - &a2 + kind: client + name: ServiceBClient + apiVersions: [bv1, bv2] + clientInitialization: + initializedBy: individually + children: + - kind: client + name: Operations + parent: *a2 + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: opB + - kind: client + name: SubNamespace + parent: *a2 + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: subOpB +``` + +#### Python SDK Example + +```python +# ServiceA client (auto-generated name) +client_a = ServiceAClient(endpoint="endpoint", credential=AzureKeyCredential("key")) +client_a.operations.op_a() +client_a.sub_namespace.sub_op_a() + +# ServiceB client (auto-generated name) +client_b = ServiceBClient(endpoint="endpoint", credential=AzureKeyCredential("key")) +client_b.operations.op_b() +client_b.sub_namespace.sub_op_b() +``` + +### Explicit Client Definition Scenarios + +When explicit `@client` decorators are used, TCGC follows the explicitly defined client hierarchy. The key design principle is: + +- **If the `@client` namespace is empty**: TCGC auto-merges all services' nested namespaces/interfaces into the current client as children. +- **If the `@client` namespace contains nested `@client` decorators**: TCGC uses the explicitly defined client hierarchy instead of auto-merging. + +### Scenario 1: Explicit Client Names for Multiple Services + +Users may want to explicitly define clients for each service with custom names. Without explicit `@client`, TCGC would use the service namespace name with a `Client` suffix (e.g., `ServiceAClient`). With explicit `@client`, users can customize the client name. + +#### Syntax Proposal + +Define multiple `@client` decorators, each targeting one service with a custom name: + +```typespec title="main.tsp" +@service +@versioned(VersionsA) +namespace ServiceA { + enum VersionsA { + av1, + av2, + } + + interface Operations { + opA(): void; + } + + namespace SubNamespace { + op subOpA(): void; + } +} + +@service +@versioned(VersionsB) +namespace ServiceB { + enum VersionsB { + bv1, + bv2, + } + + interface Operations { + opB(): void; + } + + namespace SubNamespace { + op subOpB(): void; + } +} +``` + +```typespec title="client.tsp" +import "./main.tsp"; +import "@azure-tools/typespec-client-generator-core"; + +using Azure.ClientGenerator.Core; + +@client({ + name: "MyServiceAClient", // Custom name instead of default "ServiceAClient" + service: ServiceA, +}) +namespace MyServiceAClient; + +@client({ + name: "MyServiceBClient", // Custom name instead of default "ServiceBClient" + service: ServiceB, +}) +namespace MyServiceBClient; +``` + +#### TCGC Behavior + +This creates two independent root clients with custom names, each with their own service hierarchy: + +According to [client.md](./client.md), the default value of `initializedBy` for a root client is `InitializedBy.individually`, while for a sub client it is `InitializedBy.parent`. + +```yaml +clients: + - &a1 + kind: client + name: MyServiceAClient + apiVersions: [av1, av2] + clientInitialization: + initializedBy: individually + children: + - kind: client + name: Operations + parent: *a1 + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: opA + - kind: client + name: SubNamespace + parent: *a1 + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: subOpA + - &a2 + kind: client + name: MyServiceBClient + apiVersions: [bv1, bv2] + clientInitialization: + initializedBy: individually + children: + - kind: client + name: Operations + parent: *a2 + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: opB + - kind: client + name: SubNamespace + parent: *a2 + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: subOpB +``` + +#### Python SDK Example + +```python +# ServiceA client with custom name +client_a = MyServiceAClient(endpoint="endpoint", credential=AzureKeyCredential("key")) +client_a.operations.op_a() +client_a.sub_namespace.sub_op_a() + +# ServiceB client with custom name +client_b = MyServiceBClient(endpoint="endpoint", credential=AzureKeyCredential("key")) +client_b.operations.op_b() +client_b.sub_namespace.sub_op_b() +``` + +### Scenario 1.5: Mixing Multi-Service and Single-Service Clients + +Users may want to combine multiple services into one client while keeping another service as a separate client. This scenario shows how to mix multi-service clients with single-service clients. + +#### Syntax Proposal + +Define clients where one client targets multiple services and another targets a single service: + +```typespec title="main.tsp" +@service +@versioned(VersionsA) +namespace ServiceA { + enum VersionsA { av1, av2 } + + interface Operations { + opA(): void; + } +} + +@service +@versioned(VersionsB) +namespace ServiceB { + enum VersionsB { bv1, bv2 } + + interface Operations { + opB(): void; + } +} + +@service +@versioned(VersionsC) +namespace ServiceC { + enum VersionsC { cv1, cv2 } + + interface Operations { + opC(): void; + } +} +``` + +```typespec title="client.tsp" +import "./main.tsp"; +import "@azure-tools/typespec-client-generator-core"; + +using Azure.ClientGenerator.Core; + +// Multi-service client combining ServiceA and ServiceB +@client({ + name: "CombinedABClient", + service: [ServiceA, ServiceB], +}) +namespace CombinedABClient; + +// Single-service client for ServiceC +@client({ + name: "ServiceCClient", + service: ServiceC, +}) +namespace ServiceCClient; +``` + +#### TCGC Behavior + +This creates two root clients: + +1. `CombinedABClient`: A multi-service client that auto-merges ServiceA and ServiceB content (since the namespace is empty) +2. `ServiceCClient`: A single-service client for ServiceC + +According to [client.md](./client.md), the default value of `initializedBy` for a root client is `InitializedBy.individually`, while for a sub client it is `InitializedBy.parent`. + +```yaml +clients: + - &combined + kind: client + name: CombinedABClient + apiVersions: [] # Empty for cross-service clients + apiVersionsMap: + ServiceA: [av1, av2] + ServiceB: [bv1, bv2] + clientInitialization: + initializedBy: individually + children: + - kind: client + name: Operations # Merged from both ServiceA and ServiceB + parent: *combined + apiVersions: [] # Empty because operations come from different services + apiVersionsMap: + ServiceA: [av1, av2] + ServiceB: [bv1, bv2] + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: opA + - kind: basic + name: opB + - &serviceC + kind: client + name: ServiceCClient + apiVersions: [cv1, cv2] + clientInitialization: + initializedBy: individually + children: + - kind: client + name: Operations + parent: *serviceC + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: opC +``` + +#### Python SDK Example + +```python +# Combined client for ServiceA and ServiceB +combined_client = CombinedABClient(endpoint="endpoint", credential=AzureKeyCredential("key")) +combined_client.operations.op_a() # From ServiceA +combined_client.operations.op_b() # From ServiceB + +# Separate client for ServiceC +service_c_client = ServiceCClient(endpoint="endpoint", credential=AzureKeyCredential("key")) +service_c_client.operations.op_c() +``` + +### Scenario 2: Services as Direct Children (No Deep Auto-Merge) + +In the first step design, when combining multiple services with an empty client namespace, all nested namespaces/interfaces from all services are auto-merged into the root client as children. Some users prefer to keep each service's namespace as a direct child of the root client without deep merging. + +#### Endpoint and Credential Limitations + +When combining multiple services into a single client, all services must share the same endpoint and credential configuration. The root client's endpoint and credential parameters are created based on the first service in the array. If services have different `@server` or `@useAuth` definitions, emitters should report a diagnostic error. + +#### Syntax Proposal + +Use nested `@client` decorators to explicitly define each service as a child client: + +```typespec title="client.tsp" +import "./main.tsp"; +import "@azure-tools/typespec-client-generator-core"; + +using Azure.ClientGenerator.Core; + +@client({ + name: "CombineClient", + service: [ServiceA, ServiceB], +}) +namespace CombineClient { + @client({ + name: "ComputeClient", + service: ServiceA, + }) + namespace Compute; + + @client({ + name: "DiskClient", + service: ServiceB, + }) + namespace Disk; +} +``` + +#### TCGC Behavior + +When the client namespace has nested `@client` decorators, TCGC will use the explicitly defined client hierarchy: + +1. Create the root client for the combined client. +2. Each nested `@client` becomes a child of the root client. +3. Since the nested client namespaces (`Compute` and `Disk`) are empty, TCGC auto-merges each service's content into its respective child client. +4. No automatic merging across services occurs at the root level. + +```yaml +clients: + - &root + kind: client + name: CombineClient + apiVersions: [] # Empty for cross-service clients + apiVersionsMap: # Map of service namespace to API versions + ServiceA: [av1, av2] + ServiceB: [bv1, bv2] + clientInitialization: + initializedBy: individually + children: + - &compute + kind: client + name: ComputeClient + parent: *root + apiVersions: [av1, av2] + clientInitialization: + initializedBy: parent + children: + - kind: client + name: Operations + parent: *compute + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: opA + - kind: client + name: SubNamespace + parent: *compute + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: subOpA + - &disk + kind: client + name: DiskClient + parent: *root + apiVersions: [bv1, bv2] + clientInitialization: + initializedBy: parent + children: + - kind: client + name: Operations + parent: *disk + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: opB + - kind: client + name: SubNamespace + parent: *disk + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: subOpB +``` + +#### Python SDK Example + +```python +client = CombineClient(endpoint="endpoint", credential=AzureKeyCredential("key")) + +# Access ServiceA operations via ComputeClient +client.compute.operations.op_a() +client.compute.sub_namespace.sub_op_a() + +# Access ServiceB operations via DiskClient +client.disk.operations.op_b() +client.disk.sub_namespace.sub_op_b() +``` + +### Scenario 3: Fully Customized Client Hierarchy + +For maximum flexibility, users can fully customize how operations from different services are organized into client hierarchies. This uses nested `@client` decorators with explicit operation mapping. + +#### Endpoint and Credential Limitations + +Same as Scenario 2: when combining multiple services into a single client, all services must share the same endpoint and credential configuration. The root client's endpoint and credential parameters are created based on the first service in the array. + +#### Syntax Proposal + +Use nested `@client` decorators with explicit operation references to create a custom client hierarchy: + +```typespec title="client.tsp" +import "./main.tsp"; +import "@azure-tools/typespec-client-generator-core"; + +using Azure.ClientGenerator.Core; + +@client({ + name: "CustomClient", + service: [ServiceA, ServiceB], +}) +namespace CustomClient { + // Custom child client combining operations from both services + @client({ + name: "SharedOperations", + service: [ServiceA, ServiceB], + }) + interface SharedOperations { + opA is ServiceA.Operations.opA; + opB is ServiceB.Operations.opB; + } + + // Custom child client with operations from ServiceA only + @client({ + name: "ServiceAOnly", + service: ServiceA, + }) + interface ServiceAOnly { + subOpA is ServiceA.SubNamespace.subOpA; + } + + // Custom child client with operations from ServiceB only + @client({ + name: "ServiceBOnly", + service: ServiceB, + }) + interface ServiceBOnly { + subOpB is ServiceB.SubNamespace.subOpB; + } +} +``` + +#### TCGC Behavior + +When explicit `@client` decorators are nested within the root client: + +1. TCGC uses the explicitly defined client hierarchy instead of auto-generating from service structure. +2. Each nested `@client` becomes a child of the root client. +3. Operations referenced via `is` keyword are mapped to their original service operations. +4. Since the root client namespace contains nested `@client` decorators, no auto-discovery from service namespaces occurs. + +```yaml +clients: + - &root + kind: client + name: CustomClient + apiVersions: [] # Empty for cross-service clients + apiVersionsMap: # Map of service namespace to API versions + ServiceA: [av1, av2] + ServiceB: [bv1, bv2] + clientInitialization: + initializedBy: individually + children: + - kind: client + name: SharedOperations + parent: *root + apiVersions: [] # Empty because operations come from different services + apiVersionsMap: # Map because operations come from different services + ServiceA: [av1, av2] + ServiceB: [bv1, bv2] + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: opA + - kind: basic + name: opB + - kind: client + name: ServiceAOnly + parent: *root + apiVersions: [av1, av2] + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: subOpA + - kind: client + name: ServiceBOnly + parent: *root + apiVersions: [bv1, bv2] + clientInitialization: + initializedBy: parent + methods: + - kind: basic + name: subOpB +``` + +#### Python SDK Example + +```python +client = CustomClient(endpoint="endpoint", credential=AzureKeyCredential("key")) + +# Access shared operations from both services +client.shared_operations.op_a() # Uses ServiceA's API version +client.shared_operations.op_b() # Uses ServiceB's API version + +# Access ServiceA-only operations +client.service_a_only.sub_op_a() + +# Access ServiceB-only operations +client.service_b_only.sub_op_b() +``` + +### Summary of Client Hierarchy Behavior + +| Scenario | Client Namespace Content | Result | +| -------------------- | ----------------------------------- | ---------------------------------------------------------------- | +| First step design | Empty | All services' nested items auto-merged as root client's children | +| Services as children | Nested `@client` (empty namespaces) | Each nested client auto-merges its service's content | +| Fully customized | Nested `@client` with explicit ops | Only explicitly defined clients and operations are used | + +### Interaction with Existing Decorators + +The nested `@client` approach works alongside existing customization decorators: + +- **`@clientInitialization`**: Still controls how each client is initialized. Can be applied to both auto-merged and explicitly defined clients. +- **`@clientLocation`**: Can move operations between clients regardless of namespace content. +- **`@operationGroup`** (deprecated): The same functionality can be achieved using nested `@client`. Migration path: convert `@operationGroup` to nested `@client`. + +### Validation Rules + +1. When the root client namespace is empty: + - Only services listed in the `service` array of the `@client` decorator are included in the client + - Content from these listed services is auto-merged into the root client + - Same-named namespaces/interfaces across the listed services are merged together + +2. When the root client namespace has nested `@client` decorators: + - Only explicitly defined clients are created at the root level + - Each nested `@client` with an empty namespace auto-merges its service's content + - Each nested `@client` with explicit operations uses only those operations + - Operations not referenced by any explicit client are omitted from the SDK + +### Changes Needed + +1. **Update `interfaces.ts`**: + - Add new `apiVersionsMap` property to `SdkClientType` with type `Record` (key is service namespace full qualified name, value is API versions array) + - Keep existing `apiVersions` property as `string[]` for backward compatibility + - For cross-service clients, `apiVersions` will be empty `[]` and `apiVersionsMap` will contain the mapping + +2. **Update `cache.ts` logic**: + - In `getOrCreateClients`: When no explicit `@client` is defined, create a separate root client for each `@service` namespace (not just the first one) + - In `prepareClientAndOperationCache`: Check if the client namespace has nested `@client` decorators before auto-merging + - When nested `@client` decorators exist, use the explicitly defined hierarchy + - When the namespace is empty, auto-merge services' content (existing behavior for explicit multi-service clients) + +3. **Update `internal-utils.ts`**: + - Modify `hasExplicitClientOrOperationGroup` to properly detect nested `@client` decorators within multi-service client namespaces + - Currently it returns `false` when a client has multiple services, but should return `true` if the namespace contains nested `@client` decorators + +4. **Update `clients.ts`**: + - Update `createSdkClientType` to populate `apiVersionsMap` when the client spans multiple services + - Keep `apiVersions` as empty array for cross-service clients + - Update endpoint and credential parameter creation to validate that all services share the same `@server` and `@useAuth` definitions + +5. **Update `decorators.ts`**: + - Add validation in `@client` decorator to ensure services combined into a single client have compatible endpoint and credential configurations + +6. **Add validation diagnostics**: + - Add diagnostic when services combined into a client have different `@server` definitions + - Add diagnostic when services combined into a client have different `@useAuth` definitions