From 81ca12ea608cd5c14a4ad4f98c2080f764b55063 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:47:18 +0000 Subject: [PATCH 1/4] Initial plan From 7812a0d57b4134d546c95cace33fac38ae5b5ee4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:01:41 +0000 Subject: [PATCH 2/4] feat: add includeRootSlash client option support to C# emitter Add support for @clientOption("includeRootSlash") in the http-client-csharp emitter. When set to false, strips the leading '/' from operation paths. Supports client-level, sub-client-level, and operation-level configuration, with child elements able to override parent settings. Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../emitter/src/lib/client-converter.ts | 4 +- .../emitter/src/lib/operation-converter.ts | 47 ++++- .../test/Unit/operation-converter.test.ts | 166 ++++++++++++++++++ 3 files changed, 214 insertions(+), 3 deletions(-) diff --git a/packages/http-client-csharp/emitter/src/lib/client-converter.ts b/packages/http-client-csharp/emitter/src/lib/client-converter.ts index c37171e0137..b96cc2b1d66 100644 --- a/packages/http-client-csharp/emitter/src/lib/client-converter.ts +++ b/packages/http-client-csharp/emitter/src/lib/client-converter.ts @@ -77,7 +77,9 @@ function fromSdkClient( doc: client.doc, summary: client.summary, methods: client.methods - .map((m) => fromSdkServiceMethod(sdkContext, m, uri, rootApiVersions, client.namespace)) + .map((m) => + fromSdkServiceMethod(sdkContext, m, uri, rootApiVersions, client.namespace, client), + ) .filter((m) => m !== undefined), parameters: clientParameters, initializedBy: client.clientInitialization.initializedBy, diff --git a/packages/http-client-csharp/emitter/src/lib/operation-converter.ts b/packages/http-client-csharp/emitter/src/lib/operation-converter.ts index a8effdf26fd..83cc6e501cf 100644 --- a/packages/http-client-csharp/emitter/src/lib/operation-converter.ts +++ b/packages/http-client-csharp/emitter/src/lib/operation-converter.ts @@ -8,6 +8,7 @@ import { isHttpMetadata, SdkBodyParameter, SdkBuiltInKinds, + SdkClientType as SdkClientTypeOfT, SdkContext, SdkHeaderParameter, SdkHttpOperation, @@ -67,12 +68,15 @@ import { fromSdkHttpExamples } from "./example-converter.js"; import { fromSdkType } from "./type-converter.js"; import { getClientNamespaceString, isReadOnly } from "./utils.js"; +type SdkClientType = SdkClientTypeOfT; + export function fromSdkServiceMethod( sdkContext: CSharpEmitterContext, sdkMethod: SdkServiceMethod, uri: string, rootApiVersions: string[], namespace: string, + client?: SdkClientType, ): InputServiceMethod | undefined { let method = sdkContext.__typeCache.methods.get(sdkMethod); if (method) { @@ -88,6 +92,7 @@ export function fromSdkServiceMethod( uri, rootApiVersions, namespace, + client, ); break; case "paging": @@ -97,6 +102,7 @@ export function fromSdkServiceMethod( uri, rootApiVersions, namespace, + client, ); pagingServiceMethod.pagingMetadata = loadPagingServiceMetadata( sdkContext, @@ -104,6 +110,7 @@ export function fromSdkServiceMethod( rootApiVersions, uri, namespace, + client, ); method = pagingServiceMethod; break; @@ -114,6 +121,7 @@ export function fromSdkServiceMethod( uri, rootApiVersions, namespace, + client, ); lroServiceMethod.lroMetadata = loadLongRunningMetadata(sdkContext, sdkMethod); method = lroServiceMethod; @@ -125,6 +133,7 @@ export function fromSdkServiceMethod( uri, rootApiVersions, namespace, + client, ); lroPagingMethod.lroMetadata = loadLongRunningMetadata(sdkContext, sdkMethod); lroPagingMethod.pagingMetadata = loadPagingServiceMetadata( @@ -133,6 +142,7 @@ export function fromSdkServiceMethod( rootApiVersions, uri, namespace, + client, ); method = lroPagingMethod; break; @@ -158,6 +168,7 @@ export function fromSdkServiceMethodOperation( method: SdkServiceMethod, uri: string, rootApiVersions: string[], + client?: SdkClientType, ): InputOperation { let operation = sdkContext.__typeCache.operations.get(method.operation); if (operation) { @@ -176,6 +187,11 @@ export function fromSdkServiceMethodOperation( generateConvenience = false; } + const includeRootSlash = resolveIncludeRootSlash(method, client); + const path = includeRootSlash + ? method.operation.path + : method.operation.path.replace(/^\//, ""); + operation = { name: method.name, resourceName: @@ -190,7 +206,7 @@ export function fromSdkServiceMethodOperation( responses: fromSdkHttpOperationResponses(sdkContext, method.operation.responses), httpMethod: parseHttpRequestMethod(method.operation.verb), uri: uri, - path: method.operation.path, + path: path, externalDocsUrl: getExternalDocs(sdkContext, method.operation.__raw.operation)?.url, requestMediaTypes: getRequestMediaTypes(method.operation), bufferResponse: true, @@ -241,6 +257,7 @@ function createServiceMethod( uri: string, rootApiVersions: string[], namespace: string, + client?: SdkClientType, ): T { return { kind: method.kind, @@ -249,7 +266,7 @@ function createServiceMethod( apiVersions: method.apiVersions, doc: method.doc, summary: method.summary, - operation: fromSdkServiceMethodOperation(sdkContext, method, uri, rootApiVersions), + operation: fromSdkServiceMethodOperation(sdkContext, method, uri, rootApiVersions, client), parameters: fromSdkServiceMethodParameters(sdkContext, method, rootApiVersions, namespace), response: fromSdkServiceMethodResponse(sdkContext, method.response), exception: method.exception @@ -702,6 +719,7 @@ function loadPagingServiceMetadata( rootApiVersions: string[], uri: string, namespace: string, + client?: SdkClientType, ): InputPagingServiceMetadata { let nextLink: InputNextLink | undefined; if (method.pagingMetadata.nextLinkSegments) { @@ -723,6 +741,7 @@ function loadPagingServiceMetadata( uri, rootApiVersions, namespace, + client, ); } @@ -1006,3 +1025,27 @@ function getCollectionHeaderPrefix( } return value; } + +function resolveIncludeRootSlash( + method: SdkServiceMethod, + client?: SdkClientType, +): boolean { + // First check the method/operation level + const methodOption = getClientOptions(method, "includeRootSlash"); + if (methodOption !== undefined) { + return methodOption !== false; + } + + // Walk up the client hierarchy + let current: SdkClientType | undefined = client; + while (current) { + const clientOption = getClientOptions(current, "includeRootSlash"); + if (clientOption !== undefined) { + return clientOption !== false; + } + current = current.parent; + } + + // Default: include root slash + return true; +} diff --git a/packages/http-client-csharp/emitter/test/Unit/operation-converter.test.ts b/packages/http-client-csharp/emitter/test/Unit/operation-converter.test.ts index ddaea32c419..a260e8c32de 100644 --- a/packages/http-client-csharp/emitter/test/Unit/operation-converter.test.ts +++ b/packages/http-client-csharp/emitter/test/Unit/operation-converter.test.ts @@ -465,4 +465,170 @@ describe("Operation Converter", () => { }); }); }); + + describe("includeRootSlash client option", () => { + it("should strip leading slash from operation path when includeRootSlash is false on client", async () => { + const program = await typeSpecCompile( + ` + #suppress "@azure-tools/typespec-client-generator-core/client-option" "test" + @clientOption("includeRootSlash", false, "csharp") + interface MyClient { + @route("?restype=container") + op getContainer(): void; + } + `, + runner, + { IsTCGCNeeded: true }, + ); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + + const myClient = root.clients[0].children?.find((c) => c.name === "MyClient"); + ok(myClient); + const operation = myClient.methods[0].operation; + strictEqual(operation.path, "?restype=container"); + }); + + it("should keep leading slash when includeRootSlash is not set (default)", async () => { + const program = await typeSpecCompile( + ` + @route("/foo/bar") + op test(): void; + `, + runner, + { IsTCGCNeeded: true }, + ); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + + const operation = root.clients[0].methods[0].operation; + strictEqual(operation.path, "/foo/bar"); + }); + + it("should strip leading slash from operation path when includeRootSlash is false on operation", async () => { + const program = await typeSpecCompile( + ` + #suppress "@azure-tools/typespec-client-generator-core/client-option" "test" + @clientOption("includeRootSlash", false, "csharp") + @route("/foo/bar") + op test(): void; + `, + runner, + { IsTCGCNeeded: true }, + ); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + + const operation = root.clients[0].methods[0].operation; + strictEqual(operation.path, "foo/bar"); + }); + + it("should allow sub-client to override parent client includeRootSlash option", async () => { + const program = await typeSpecCompile( + ` + #suppress "@azure-tools/typespec-client-generator-core/client-option" "test" + @clientOption("includeRootSlash", false, "csharp") + @route("/root") + interface ParentClient { + @route("/parent-op") + op parentOp(): void; + } + + #suppress "@azure-tools/typespec-client-generator-core/client-option" "test" + @clientOption("includeRootSlash", true, "csharp") + @route("/child") + interface ChildClient { + @route("/child-op") + op childOp(): void; + } + `, + runner, + { IsTCGCNeeded: true, IsNamespaceNeeded: true }, + ); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + + // Parent client operations should have no leading slash + const parentClient = root.clients[0].children?.find((c) => c.name === "ParentClient"); + ok(parentClient); + const parentOp = parentClient.methods[0].operation; + strictEqual(parentOp.path, "root/parent-op"); + + // Child client operations should keep leading slash (override) + const childClient = root.clients[0].children?.find((c) => c.name === "ChildClient"); + ok(childClient); + const childOp = childClient.methods[0].operation; + strictEqual(childOp.path, "/child/child-op"); + }); + + it("should allow operation to override client includeRootSlash option", async () => { + const program = await typeSpecCompile( + ` + #suppress "@azure-tools/typespec-client-generator-core/client-option" "test" + @clientOption("includeRootSlash", false, "csharp") + interface MyClient { + @route("/op1") + op op1(): void; + + #suppress "@azure-tools/typespec-client-generator-core/client-option" "test" + @clientOption("includeRootSlash", true, "csharp") + @route("/op2") + op op2(): void; + } + `, + runner, + { IsTCGCNeeded: true }, + ); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + + const myClient = root.clients[0].children?.find((c) => c.name === "MyClient"); + ok(myClient); + + // op1 should inherit client's includeRootSlash=false + const op1 = myClient.methods.find((m) => m.name === "op1"); + ok(op1); + strictEqual(op1.operation.path, "op1"); + + // op2 should override with includeRootSlash=true + const op2 = myClient.methods.find((m) => m.name === "op2"); + ok(op2); + strictEqual(op2.operation.path, "/op2"); + }); + + it("should inherit includeRootSlash from parent client when sub-client does not set it", async () => { + const program = await typeSpecCompile( + ` + #suppress "@azure-tools/typespec-client-generator-core/client-option" "test" + @clientOption("includeRootSlash", false, "csharp") + @service(#{ + title: "Test Service", + }) + namespace TestService; + + @route("/sub") + interface SubClient { + @route("/sub-op") + op subOp(): void; + } + `, + runner, + { IsTCGCNeeded: true, IsNamespaceNeeded: false }, + ); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + + // Root client has includeRootSlash=false, sub-client inherits + const subClient = root.clients[0].children?.find((c) => c.name === "SubClient"); + ok(subClient); + const subOp = subClient.methods[0].operation; + strictEqual(subOp.path, "sub/sub-op"); + }); + }); }); From a0aab2211c4a0cff227ffaddf3c98eb9c3b0b7be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:05:01 +0000 Subject: [PATCH 3/4] refactor: address PR review feedback - Remove duplicate SdkClientType alias from operation-converter.ts (use SdkClientType directly) - Make slash removal more explicit: check first character instead of regex - Create ClientOptions type with const strings for supported option names - Add comprehensive test with multiple sub-clients and mixed per-operation values Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../emitter/src/lib/operation-converter.ts | 33 ++++--- .../emitter/src/type/client-options.ts | 13 +++ .../test/Unit/operation-converter.test.ts | 97 +++++++++++++++++++ 3 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 packages/http-client-csharp/emitter/src/type/client-options.ts diff --git a/packages/http-client-csharp/emitter/src/lib/operation-converter.ts b/packages/http-client-csharp/emitter/src/lib/operation-converter.ts index 83cc6e501cf..f6f6bc2610c 100644 --- a/packages/http-client-csharp/emitter/src/lib/operation-converter.ts +++ b/packages/http-client-csharp/emitter/src/lib/operation-converter.ts @@ -8,7 +8,7 @@ import { isHttpMetadata, SdkBodyParameter, SdkBuiltInKinds, - SdkClientType as SdkClientTypeOfT, + SdkClientType, SdkContext, SdkHeaderParameter, SdkHttpOperation, @@ -32,6 +32,7 @@ import { getDeprecated, isErrorModel, NoTarget } from "@typespec/compiler"; import { HttpStatusCodeRange } from "@typespec/http"; import { getResourceOperation } from "@typespec/rest"; import { CSharpEmitterContext } from "../sdk-context.js"; +import { ClientOptions } from "../type/client-options.js"; import { collectionFormatToDelimMap } from "../type/collection-format.js"; import { HttpResponseHeader } from "../type/http-response-header.js"; import { InputConstant } from "../type/input-constant.js"; @@ -68,15 +69,13 @@ import { fromSdkHttpExamples } from "./example-converter.js"; import { fromSdkType } from "./type-converter.js"; import { getClientNamespaceString, isReadOnly } from "./utils.js"; -type SdkClientType = SdkClientTypeOfT; - export function fromSdkServiceMethod( sdkContext: CSharpEmitterContext, sdkMethod: SdkServiceMethod, uri: string, rootApiVersions: string[], namespace: string, - client?: SdkClientType, + client?: SdkClientType, ): InputServiceMethod | undefined { let method = sdkContext.__typeCache.methods.get(sdkMethod); if (method) { @@ -168,7 +167,7 @@ export function fromSdkServiceMethodOperation( method: SdkServiceMethod, uri: string, rootApiVersions: string[], - client?: SdkClientType, + client?: SdkClientType, ): InputOperation { let operation = sdkContext.__typeCache.operations.get(method.operation); if (operation) { @@ -188,9 +187,11 @@ export function fromSdkServiceMethodOperation( } const includeRootSlash = resolveIncludeRootSlash(method, client); - const path = includeRootSlash - ? method.operation.path - : method.operation.path.replace(/^\//, ""); + const operationPath = method.operation.path; + const path = + !includeRootSlash && operationPath.length > 0 && operationPath[0] === "/" + ? operationPath.substring(1) + : operationPath; operation = { name: method.name, @@ -257,7 +258,7 @@ function createServiceMethod( uri: string, rootApiVersions: string[], namespace: string, - client?: SdkClientType, + client?: SdkClientType, ): T { return { kind: method.kind, @@ -719,7 +720,7 @@ function loadPagingServiceMetadata( rootApiVersions: string[], uri: string, namespace: string, - client?: SdkClientType, + client?: SdkClientType, ): InputPagingServiceMetadata { let nextLink: InputNextLink | undefined; if (method.pagingMetadata.nextLinkSegments) { @@ -1004,7 +1005,7 @@ function getCollectionHeaderPrefix( sdkContext: CSharpEmitterContext, p: SdkHeaderParameter, ): string | undefined { - const value = getClientOptions(p, "collectionHeaderPrefix"); + const value = getClientOptions(p, ClientOptions.collectionHeaderPrefix); if (value === undefined) { return undefined; } @@ -1017,7 +1018,7 @@ function getCollectionHeaderPrefix( sdkContext.logger.reportDiagnostic({ code: "general-warning", format: { - message: `The 'collectionHeaderPrefix' client option must be a string value, but got '${typeof value}'. The option will be ignored.`, + message: `The '${ClientOptions.collectionHeaderPrefix}' client option must be a string value, but got '${typeof value}'. The option will be ignored.`, }, target: p.__raw ?? NoTarget, }); @@ -1028,18 +1029,18 @@ function getCollectionHeaderPrefix( function resolveIncludeRootSlash( method: SdkServiceMethod, - client?: SdkClientType, + client?: SdkClientType, ): boolean { // First check the method/operation level - const methodOption = getClientOptions(method, "includeRootSlash"); + const methodOption = getClientOptions(method, ClientOptions.includeRootSlash); if (methodOption !== undefined) { return methodOption !== false; } // Walk up the client hierarchy - let current: SdkClientType | undefined = client; + let current: SdkClientType | undefined = client; while (current) { - const clientOption = getClientOptions(current, "includeRootSlash"); + const clientOption = getClientOptions(current, ClientOptions.includeRootSlash); if (clientOption !== undefined) { return clientOption !== false; } diff --git a/packages/http-client-csharp/emitter/src/type/client-options.ts b/packages/http-client-csharp/emitter/src/type/client-options.ts new file mode 100644 index 00000000000..df0adc51929 --- /dev/null +++ b/packages/http-client-csharp/emitter/src/type/client-options.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +/** + * Constants for the supported client option names used with the @clientOption decorator. + * @internal + */ +export const ClientOptions = { + /** Controls whether the root slash is included in the operation path. */ + includeRootSlash: "includeRootSlash", + /** Sets a prefix for collection header parameters. */ + collectionHeaderPrefix: "collectionHeaderPrefix", +} as const; diff --git a/packages/http-client-csharp/emitter/test/Unit/operation-converter.test.ts b/packages/http-client-csharp/emitter/test/Unit/operation-converter.test.ts index a260e8c32de..bb2d0d8123e 100644 --- a/packages/http-client-csharp/emitter/test/Unit/operation-converter.test.ts +++ b/packages/http-client-csharp/emitter/test/Unit/operation-converter.test.ts @@ -630,5 +630,102 @@ describe("Operation Converter", () => { const subOp = subClient.methods[0].operation; strictEqual(subOp.path, "sub/sub-op"); }); + + it("should handle multiple sub-clients with different includeRootSlash values per operation", async () => { + const program = await typeSpecCompile( + ` + #suppress "@azure-tools/typespec-client-generator-core/client-option" "test" + @clientOption("includeRootSlash", false, "csharp") + interface BlobClient { + @route("/list") + op list(): void; + + #suppress "@azure-tools/typespec-client-generator-core/client-option" "test" + @clientOption("includeRootSlash", true, "csharp") + @route("/get") + op get(): void; + + @route("/delete") + op delete(): void; + } + + #suppress "@azure-tools/typespec-client-generator-core/client-option" "test" + @clientOption("includeRootSlash", true, "csharp") + interface ContainerClient { + @route("/create") + op create(): void; + + #suppress "@azure-tools/typespec-client-generator-core/client-option" "test" + @clientOption("includeRootSlash", false, "csharp") + @route("/remove") + op remove(): void; + + @route("/info") + op info(): void; + } + + interface DefaultClient { + @route("/ping") + op ping(): void; + + #suppress "@azure-tools/typespec-client-generator-core/client-option" "test" + @clientOption("includeRootSlash", false, "csharp") + @route("/status") + op status(): void; + } + `, + runner, + { IsTCGCNeeded: true }, + ); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + + // BlobClient: client-level includeRootSlash=false + const blobClient = root.clients[0].children?.find((c) => c.name === "BlobClient"); + ok(blobClient); + + const listOp = blobClient.methods.find((m) => m.name === "list"); + ok(listOp); + strictEqual(listOp.operation.path, "list"); + + const getOp = blobClient.methods.find((m) => m.name === "get"); + ok(getOp); + strictEqual(getOp.operation.path, "/get"); + + const deleteOp = blobClient.methods.find((m) => m.name === "delete"); + ok(deleteOp); + strictEqual(deleteOp.operation.path, "delete"); + + // ContainerClient: client-level includeRootSlash=true + const containerClient = root.clients[0].children?.find( + (c) => c.name === "ContainerClient", + ); + ok(containerClient); + + const createOp = containerClient.methods.find((m) => m.name === "create"); + ok(createOp); + strictEqual(createOp.operation.path, "/create"); + + const removeOp = containerClient.methods.find((m) => m.name === "remove"); + ok(removeOp); + strictEqual(removeOp.operation.path, "remove"); + + const infoOp = containerClient.methods.find((m) => m.name === "info"); + ok(infoOp); + strictEqual(infoOp.operation.path, "/info"); + + // DefaultClient: no client-level option (default includeRootSlash=true) + const defaultClient = root.clients[0].children?.find((c) => c.name === "DefaultClient"); + ok(defaultClient); + + const pingOp = defaultClient.methods.find((m) => m.name === "ping"); + ok(pingOp); + strictEqual(pingOp.operation.path, "/ping"); + + const statusOp = defaultClient.methods.find((m) => m.name === "status"); + ok(statusOp); + strictEqual(statusOp.operation.path, "status"); + }); }); }); From d7999879842346dd9f777ac8cb57535c49e1c22c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:39:20 +0000 Subject: [PATCH 4/4] style: run npm run format to fix formatting issues Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../emitter/test/Unit/operation-converter.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/http-client-csharp/emitter/test/Unit/operation-converter.test.ts b/packages/http-client-csharp/emitter/test/Unit/operation-converter.test.ts index bb2d0d8123e..729f33cee25 100644 --- a/packages/http-client-csharp/emitter/test/Unit/operation-converter.test.ts +++ b/packages/http-client-csharp/emitter/test/Unit/operation-converter.test.ts @@ -698,9 +698,7 @@ describe("Operation Converter", () => { strictEqual(deleteOp.operation.path, "delete"); // ContainerClient: client-level includeRootSlash=true - const containerClient = root.clients[0].children?.find( - (c) => c.name === "ContainerClient", - ); + const containerClient = root.clients[0].children?.find((c) => c.name === "ContainerClient"); ok(containerClient); const createOp = containerClient.methods.find((m) => m.name === "create");