diff --git a/.chronus/changes/http-server-csharp-include-operations-controller-2026-7-30.md b/.chronus/changes/http-server-csharp-include-operations-controller-2026-7-30.md new file mode 100644 index 00000000000..1fcaaa360eb --- /dev/null +++ b/.chronus/changes/http-server-csharp-include-operations-controller-2026-7-30.md @@ -0,0 +1,14 @@ +--- +changeKind: feature +packages: + - "@typespec/http-server-csharp" +--- + +Add `include-operations-controller` emitter option (boolean, default `false`) to control whether a controller and business-logic interface are generated for the ARM Operations endpoint. Set to `true` to include the Operations controller. + +```yaml +# tspconfig.yaml +options: + "@typespec/http-server-csharp": + include-operations-controller: true +``` diff --git a/packages/http-server-csharp/src/emitter.tsx b/packages/http-server-csharp/src/emitter.tsx index 869c4b98a22..c506a244294 100644 --- a/packages/http-server-csharp/src/emitter.tsx +++ b/packages/http-server-csharp/src/emitter.tsx @@ -41,8 +41,14 @@ export async function $onEmit(context: EmitContext) const serviceName = resolution.serviceNamespaceName ?? "ServiceProject"; const projectName = options["project-name"] ?? "ServiceProject"; + // Filter out Operations controller/interface unless explicitly requested + const includeOperationsController = options["include-operations-controller"] ?? false; + const interfaces = includeOperationsController + ? resolution.interfaces + : resolution.interfaces.filter((iface) => !isOperationsInterface(iface.name)); + // Report diagnostic warnings (pre-pass before rendering) - reportEmitterDiagnostics(context.program, resolution.interfaces, resolution.canonicalOpsMap); + reportEmitterDiagnostics(context.program, interfaces, resolution.canonicalOpsMap); // Resolve OpenAPI path for SwaggerUI let openApiPath: string | undefined = options["openapi-path"]; @@ -52,10 +58,8 @@ export async function $onEmit(context: EmitContext) const effectiveUseSwaggerUI = useSwaggerUI && !!openApiPath; // Collect interface names for mock registration - const interfaceNames = resolution.interfaces.map((iface) => iface.name); - const interfaceRegistrations = resolution.interfaces.map( - (iface) => `I${iface.name}, ${iface.name}`, - ); + const interfaceNames = interfaces.map((iface) => iface.name); + const interfaceRegistrations = interfaces.map((iface) => `I${iface.name}, ${iface.name}`); // Resolve ports for project files let httpPort = options["http-port"] ?? 5000; @@ -88,7 +92,7 @@ export async function $onEmit(context: EmitContext) /> @@ -99,7 +103,7 @@ export async function $onEmit(context: EmitContext) /> @@ -128,3 +132,8 @@ export async function $onEmit(context: EmitContext) const overwrite = options.overwrite ?? false; await writeOutputWithOverwrite(context.program, output, context.emitterOutputDir, overwrite); } + +/** Returns true for interfaces whose name is exactly "Operations". */ +function isOperationsInterface(name: string): boolean { + return name === "Operations"; +} diff --git a/packages/http-server-csharp/src/lib.ts b/packages/http-server-csharp/src/lib.ts index e7333b613e5..e894f2828e7 100644 --- a/packages/http-server-csharp/src/lib.ts +++ b/packages/http-server-csharp/src/lib.ts @@ -21,6 +21,8 @@ export interface CSharpServiceEmitterOptions { "https-port"?: number; /** Specifies the collection type to use: 'array' or 'enumerable'. The default is 'array'. */ "collection-type"?: "array" | "enumerable"; + /** When true, generates a controller and business-logic interface for the ARM Operations endpoint. Default is false. */ + "include-operations-controller"?: boolean; } const EmitterOptionsSchema: JSONSchemaType = { @@ -96,6 +98,13 @@ const EmitterOptionsSchema: JSONSchemaType = { description: "Specifies the collection type to use: 'array' or 'enumerable'. The default is 'array'.", }, + "include-operations-controller": { + type: "boolean", + nullable: true, + default: false, + description: + "When true, generates a controller and business-logic interface for the ARM Operations endpoint. Default is false.", + }, }, required: [], }; diff --git a/packages/http-server-csharp/test/include-operations-controller.test.ts b/packages/http-server-csharp/test/include-operations-controller.test.ts new file mode 100644 index 00000000000..98ab7832963 --- /dev/null +++ b/packages/http-server-csharp/test/include-operations-controller.test.ts @@ -0,0 +1,114 @@ +import { TestFileSystem, TesterInstance } from "@typespec/compiler/testing"; +import assert from "assert"; +import { beforeEach, describe, it } from "vitest"; +import { CSharpServiceEmitterOptions } from "../src/lib.js"; +import { ApiTester, compileAndDiagnose, getStandardService } from "./test-host.js"; + +function assertFileEmitted(fs: TestFileSystem, fileName: string): void { + const result = [...fs.fs.entries()].filter((e) => e[0].includes(`/${fileName}`)); + assert.strictEqual( + result.length, + 1, + `Expected ${fileName} to be emitted, but it was not found (${result.length} matches)`, + ); +} + +function assertFileNotEmitted(fs: TestFileSystem, fileName: string): void { + const result = [...fs.fs.entries()].filter((e) => e[0].includes(`/${fileName}`)); + assert.strictEqual(result.length, 0, `Expected ${fileName} to not be emitted, but it was`); +} + +async function compile( + tester: TesterInstance, + code: string, + emitterOptions: CSharpServiceEmitterOptions = { "skip-format": true }, +): Promise { + const [result] = await compileAndDiagnose(tester, getStandardService(code), emitterOptions); + return result.fs; +} + +let tester: TesterInstance; + +beforeEach(async () => { + tester = await ApiTester.createInstance(); +}); + +describe("include-operations-controller option", () => { + it("excludes the Operations controller and interface by default", async () => { + const fs = await compile( + tester, + ` + interface Operations { + @route("/operations") @get list(): string[]; + } + interface Widgets { + @route("/widgets") @get list(): string[]; + } + `, + ); + + assertFileNotEmitted(fs, "OperationsController.cs"); + assertFileNotEmitted(fs, "IOperations.cs"); + assertFileEmitted(fs, "WidgetsController.cs"); + assertFileEmitted(fs, "IWidgets.cs"); + }); + + it("includes the Operations controller and interface when option is true", async () => { + const fs = await compile( + tester, + ` + interface Operations { + @route("/operations") @get list(): string[]; + } + interface Widgets { + @route("/widgets") @get list(): string[]; + } + `, + { "skip-format": true, "include-operations-controller": true }, + ); + + assertFileEmitted(fs, "OperationsController.cs"); + assertFileEmitted(fs, "IOperations.cs"); + assertFileEmitted(fs, "WidgetsController.cs"); + assertFileEmitted(fs, "IWidgets.cs"); + }); + + it("does not exclude synthetic namespace-level Operations interfaces (only exact 'Operations' name is excluded)", async () => { + // Namespace-level operations produce a synthetic `${ns.name}Operations` interface + // (e.g. ContosoOperations). These are kept; only an interface literally named "Operations" is excluded. + const [result] = await compileAndDiagnose( + tester, + ` + @service(#{title: "Contoso"}) + namespace Contoso { + @route("/operations") @get op listOps(): string[]; + interface Widgets { + @route("/widgets") @get list(): string[]; + } + } + `, + { "skip-format": true }, + ); + const fs = result.fs; + + // Synthetic ContosoOperations interface is NOT named "Operations" so it is still emitted + assertFileEmitted(fs, "ContosoOperationsController.cs"); + assertFileEmitted(fs, "IContosoOperations.cs"); + assertFileEmitted(fs, "WidgetsController.cs"); + assertFileEmitted(fs, "IWidgets.cs"); + }); + + it("does not affect non-Operations interfaces when option is false (default)", async () => { + const fs = await compile( + tester, + ` + interface Widgets { + @route("/widgets") @get list(): string[]; + } + `, + ); + + assertFileEmitted(fs, "WidgetsController.cs"); + assertFileEmitted(fs, "IWidgets.cs"); + }); +});