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..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,6 +8,7 @@ import { isHttpMetadata, SdkBodyParameter, SdkBuiltInKinds, + SdkClientType, SdkContext, SdkHeaderParameter, SdkHttpOperation, @@ -31,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"; @@ -73,6 +75,7 @@ export function fromSdkServiceMethod( uri: string, rootApiVersions: string[], namespace: string, + client?: SdkClientType, ): InputServiceMethod | undefined { let method = sdkContext.__typeCache.methods.get(sdkMethod); if (method) { @@ -88,6 +91,7 @@ export function fromSdkServiceMethod( uri, rootApiVersions, namespace, + client, ); break; case "paging": @@ -97,6 +101,7 @@ export function fromSdkServiceMethod( uri, rootApiVersions, namespace, + client, ); pagingServiceMethod.pagingMetadata = loadPagingServiceMetadata( sdkContext, @@ -104,6 +109,7 @@ export function fromSdkServiceMethod( rootApiVersions, uri, namespace, + client, ); method = pagingServiceMethod; break; @@ -114,6 +120,7 @@ export function fromSdkServiceMethod( uri, rootApiVersions, namespace, + client, ); lroServiceMethod.lroMetadata = loadLongRunningMetadata(sdkContext, sdkMethod); method = lroServiceMethod; @@ -125,6 +132,7 @@ export function fromSdkServiceMethod( uri, rootApiVersions, namespace, + client, ); lroPagingMethod.lroMetadata = loadLongRunningMetadata(sdkContext, sdkMethod); lroPagingMethod.pagingMetadata = loadPagingServiceMetadata( @@ -133,6 +141,7 @@ export function fromSdkServiceMethod( rootApiVersions, uri, namespace, + client, ); method = lroPagingMethod; break; @@ -158,6 +167,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 +186,13 @@ export function fromSdkServiceMethodOperation( generateConvenience = false; } + const includeRootSlash = resolveIncludeRootSlash(method, client); + const operationPath = method.operation.path; + const path = + !includeRootSlash && operationPath.length > 0 && operationPath[0] === "/" + ? operationPath.substring(1) + : operationPath; + operation = { name: method.name, resourceName: @@ -190,7 +207,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 +258,7 @@ function createServiceMethod( uri: string, rootApiVersions: string[], namespace: string, + client?: SdkClientType, ): T { return { kind: method.kind, @@ -249,7 +267,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 +720,7 @@ function loadPagingServiceMetadata( rootApiVersions: string[], uri: string, namespace: string, + client?: SdkClientType, ): InputPagingServiceMetadata { let nextLink: InputNextLink | undefined; if (method.pagingMetadata.nextLinkSegments) { @@ -723,6 +742,7 @@ function loadPagingServiceMetadata( uri, rootApiVersions, namespace, + client, ); } @@ -985,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; } @@ -998,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, }); @@ -1006,3 +1026,27 @@ function getCollectionHeaderPrefix( } return value; } + +function resolveIncludeRootSlash( + method: SdkServiceMethod, + client?: SdkClientType, +): boolean { + // First check the method/operation level + const methodOption = getClientOptions(method, ClientOptions.includeRootSlash); + if (methodOption !== undefined) { + return methodOption !== false; + } + + // Walk up the client hierarchy + let current: SdkClientType | undefined = client; + while (current) { + const clientOption = getClientOptions(current, ClientOptions.includeRootSlash); + if (clientOption !== undefined) { + return clientOption !== false; + } + current = current.parent; + } + + // Default: include root slash + return true; +} 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 ddaea32c419..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 @@ -465,4 +465,265 @@ 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"); + }); + + 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"); + }); + }); });