From 3945c7a8ee817810937c5731da93fc811b42994a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:17:10 +0000 Subject: [PATCH 1/2] feat(http-server-csharp): add exclude-interfaces emitter option Co-authored-by: abatishchev <351644+abatishchev@users.noreply.github.com> --- ...ver-csharp-exclude-interfaces-2026-7-30.md | 15 +++ packages/http-server-csharp/src/emitter.tsx | 19 +-- packages/http-server-csharp/src/lib.ts | 9 ++ .../test/exclude-interfaces.test.ts | 115 ++++++++++++++++++ 4 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 .chronus/changes/http-server-csharp-exclude-interfaces-2026-7-30.md create mode 100644 packages/http-server-csharp/test/exclude-interfaces.test.ts diff --git a/.chronus/changes/http-server-csharp-exclude-interfaces-2026-7-30.md b/.chronus/changes/http-server-csharp-exclude-interfaces-2026-7-30.md new file mode 100644 index 00000000000..1698191e46b --- /dev/null +++ b/.chronus/changes/http-server-csharp-exclude-interfaces-2026-7-30.md @@ -0,0 +1,15 @@ +--- +changeKind: feature +packages: + - "@typespec/http-server-csharp" +--- + +Add `exclude-interfaces` emitter option to skip controller and business-logic interface generation for specified TypeSpec interfaces. + +```yaml +# tspconfig.yaml +options: + "@typespec/http-server-csharp": + exclude-interfaces: + - Operations +``` diff --git a/packages/http-server-csharp/src/emitter.tsx b/packages/http-server-csharp/src/emitter.tsx index 869c4b98a22..5e9c881cd9f 100644 --- a/packages/http-server-csharp/src/emitter.tsx +++ b/packages/http-server-csharp/src/emitter.tsx @@ -41,8 +41,15 @@ export async function $onEmit(context: EmitContext) const serviceName = resolution.serviceNamespaceName ?? "ServiceProject"; const projectName = options["project-name"] ?? "ServiceProject"; + // Filter out excluded interfaces + const excludedInterfaces = new Set(options["exclude-interfaces"] ?? []); + const interfaces = + excludedInterfaces.size > 0 + ? resolution.interfaces.filter((iface) => !excludedInterfaces.has(iface.name)) + : resolution.interfaces; + // 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 +59,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 +93,7 @@ export async function $onEmit(context: EmitContext) /> @@ -99,7 +104,7 @@ export async function $onEmit(context: EmitContext) /> diff --git a/packages/http-server-csharp/src/lib.ts b/packages/http-server-csharp/src/lib.ts index e7333b613e5..e00c65d8767 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"; + /** A list of TypeSpec interface names to exclude from controller and business-logic interface generation. */ + "exclude-interfaces"?: string[]; } const EmitterOptionsSchema: JSONSchemaType = { @@ -96,6 +98,13 @@ const EmitterOptionsSchema: JSONSchemaType = { description: "Specifies the collection type to use: 'array' or 'enumerable'. The default is 'array'.", }, + "exclude-interfaces": { + type: "array", + items: { type: "string" }, + nullable: true, + description: + "A list of TypeSpec interface names to exclude from controller and business-logic interface generation.", + }, }, required: [], }; diff --git a/packages/http-server-csharp/test/exclude-interfaces.test.ts b/packages/http-server-csharp/test/exclude-interfaces.test.ts new file mode 100644 index 00000000000..e2e3b67f738 --- /dev/null +++ b/packages/http-server-csharp/test/exclude-interfaces.test.ts @@ -0,0 +1,115 @@ +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("exclude-interfaces option", () => { + it("generates all controllers and interfaces when no exclusions are specified", async () => { + const fs = await compile( + tester, + ` + interface Operations { + @route("/operations") @get list(): string[]; + } + interface Widgets { + @route("/widgets") @get list(): string[]; + } + `, + ); + + assertFileEmitted(fs, "OperationsController.cs"); + assertFileEmitted(fs, "IOperations.cs"); + assertFileEmitted(fs, "WidgetsController.cs"); + assertFileEmitted(fs, "IWidgets.cs"); + }); + + it("skips controller and interface files for excluded interfaces", async () => { + const fs = await compile( + tester, + ` + interface Operations { + @route("/operations") @get list(): string[]; + } + interface Widgets { + @route("/widgets") @get list(): string[]; + } + `, + { "skip-format": true, "exclude-interfaces": ["Operations"] }, + ); + + assertFileNotEmitted(fs, "OperationsController.cs"); + assertFileNotEmitted(fs, "IOperations.cs"); + assertFileEmitted(fs, "WidgetsController.cs"); + assertFileEmitted(fs, "IWidgets.cs"); + }); + + it("excludes multiple interfaces when multiple names are provided", async () => { + const fs = await compile( + tester, + ` + interface Operations { + @route("/operations") @get list(): string[]; + } + interface Widgets { + @route("/widgets") @get list(): string[]; + } + interface Gadgets { + @route("/gadgets") @get list(): string[]; + } + `, + { "skip-format": true, "exclude-interfaces": ["Operations", "Widgets"] }, + ); + + assertFileNotEmitted(fs, "OperationsController.cs"); + assertFileNotEmitted(fs, "IOperations.cs"); + assertFileNotEmitted(fs, "WidgetsController.cs"); + assertFileNotEmitted(fs, "IWidgets.cs"); + assertFileEmitted(fs, "GadgetsController.cs"); + assertFileEmitted(fs, "IGadgets.cs"); + }); + + it("has no effect when the excluded interface name does not exist", async () => { + const fs = await compile( + tester, + ` + interface Widgets { + @route("/widgets") @get list(): string[]; + } + `, + { "skip-format": true, "exclude-interfaces": ["Operations"] }, + ); + + assertFileEmitted(fs, "WidgetsController.cs"); + assertFileEmitted(fs, "IWidgets.cs"); + }); +}); From a0cdc89d59b351435c0d7c5a3a90e93b48bef333 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:53:04 +0000 Subject: [PATCH 2/2] feat(http-server-csharp): replace exclude-interfaces with include-operations-controller boolean option Co-authored-by: abatishchev <351644+abatishchev@users.noreply.github.com> --- ...ver-csharp-exclude-interfaces-2026-7-30.md | 15 ------ ...include-operations-controller-2026-7-30.md | 14 +++++ packages/http-server-csharp/src/emitter.tsx | 16 +++--- packages/http-server-csharp/src/lib.ts | 12 ++--- ... => include-operations-controller.test.ts} | 53 +++++++++---------- 5 files changed, 56 insertions(+), 54 deletions(-) delete mode 100644 .chronus/changes/http-server-csharp-exclude-interfaces-2026-7-30.md create mode 100644 .chronus/changes/http-server-csharp-include-operations-controller-2026-7-30.md rename packages/http-server-csharp/test/{exclude-interfaces.test.ts => include-operations-controller.test.ts} (64%) diff --git a/.chronus/changes/http-server-csharp-exclude-interfaces-2026-7-30.md b/.chronus/changes/http-server-csharp-exclude-interfaces-2026-7-30.md deleted file mode 100644 index 1698191e46b..00000000000 --- a/.chronus/changes/http-server-csharp-exclude-interfaces-2026-7-30.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -changeKind: feature -packages: - - "@typespec/http-server-csharp" ---- - -Add `exclude-interfaces` emitter option to skip controller and business-logic interface generation for specified TypeSpec interfaces. - -```yaml -# tspconfig.yaml -options: - "@typespec/http-server-csharp": - exclude-interfaces: - - Operations -``` 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 5e9c881cd9f..c506a244294 100644 --- a/packages/http-server-csharp/src/emitter.tsx +++ b/packages/http-server-csharp/src/emitter.tsx @@ -41,12 +41,11 @@ export async function $onEmit(context: EmitContext) const serviceName = resolution.serviceNamespaceName ?? "ServiceProject"; const projectName = options["project-name"] ?? "ServiceProject"; - // Filter out excluded interfaces - const excludedInterfaces = new Set(options["exclude-interfaces"] ?? []); - const interfaces = - excludedInterfaces.size > 0 - ? resolution.interfaces.filter((iface) => !excludedInterfaces.has(iface.name)) - : resolution.interfaces; + // 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, interfaces, resolution.canonicalOpsMap); @@ -133,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 e00c65d8767..e894f2828e7 100644 --- a/packages/http-server-csharp/src/lib.ts +++ b/packages/http-server-csharp/src/lib.ts @@ -21,8 +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"; - /** A list of TypeSpec interface names to exclude from controller and business-logic interface generation. */ - "exclude-interfaces"?: string[]; + /** When true, generates a controller and business-logic interface for the ARM Operations endpoint. Default is false. */ + "include-operations-controller"?: boolean; } const EmitterOptionsSchema: JSONSchemaType = { @@ -98,12 +98,12 @@ const EmitterOptionsSchema: JSONSchemaType = { description: "Specifies the collection type to use: 'array' or 'enumerable'. The default is 'array'.", }, - "exclude-interfaces": { - type: "array", - items: { type: "string" }, + "include-operations-controller": { + type: "boolean", nullable: true, + default: false, description: - "A list of TypeSpec interface names to exclude from controller and business-logic interface generation.", + "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/exclude-interfaces.test.ts b/packages/http-server-csharp/test/include-operations-controller.test.ts similarity index 64% rename from packages/http-server-csharp/test/exclude-interfaces.test.ts rename to packages/http-server-csharp/test/include-operations-controller.test.ts index e2e3b67f738..98ab7832963 100644 --- a/packages/http-server-csharp/test/exclude-interfaces.test.ts +++ b/packages/http-server-csharp/test/include-operations-controller.test.ts @@ -33,8 +33,8 @@ beforeEach(async () => { tester = await ApiTester.createInstance(); }); -describe("exclude-interfaces option", () => { - it("generates all controllers and interfaces when no exclusions are specified", async () => { +describe("include-operations-controller option", () => { + it("excludes the Operations controller and interface by default", async () => { const fs = await compile( tester, ` @@ -47,13 +47,13 @@ describe("exclude-interfaces option", () => { `, ); - assertFileEmitted(fs, "OperationsController.cs"); - assertFileEmitted(fs, "IOperations.cs"); + assertFileNotEmitted(fs, "OperationsController.cs"); + assertFileNotEmitted(fs, "IOperations.cs"); assertFileEmitted(fs, "WidgetsController.cs"); assertFileEmitted(fs, "IWidgets.cs"); }); - it("skips controller and interface files for excluded interfaces", async () => { + it("includes the Operations controller and interface when option is true", async () => { const fs = await compile( tester, ` @@ -64,41 +64,41 @@ describe("exclude-interfaces option", () => { @route("/widgets") @get list(): string[]; } `, - { "skip-format": true, "exclude-interfaces": ["Operations"] }, + { "skip-format": true, "include-operations-controller": true }, ); - assertFileNotEmitted(fs, "OperationsController.cs"); - assertFileNotEmitted(fs, "IOperations.cs"); + assertFileEmitted(fs, "OperationsController.cs"); + assertFileEmitted(fs, "IOperations.cs"); assertFileEmitted(fs, "WidgetsController.cs"); assertFileEmitted(fs, "IWidgets.cs"); }); - it("excludes multiple interfaces when multiple names are provided", async () => { - const fs = await compile( + 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, ` - interface Operations { - @route("/operations") @get list(): string[]; - } - interface Widgets { - @route("/widgets") @get list(): string[]; - } - interface Gadgets { - @route("/gadgets") @get list(): string[]; + @service(#{title: "Contoso"}) + namespace Contoso { + @route("/operations") @get op listOps(): string[]; + interface Widgets { + @route("/widgets") @get list(): string[]; + } } `, - { "skip-format": true, "exclude-interfaces": ["Operations", "Widgets"] }, + { "skip-format": true }, ); + const fs = result.fs; - assertFileNotEmitted(fs, "OperationsController.cs"); - assertFileNotEmitted(fs, "IOperations.cs"); - assertFileNotEmitted(fs, "WidgetsController.cs"); - assertFileNotEmitted(fs, "IWidgets.cs"); - assertFileEmitted(fs, "GadgetsController.cs"); - assertFileEmitted(fs, "IGadgets.cs"); + // 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("has no effect when the excluded interface name does not exist", async () => { + it("does not affect non-Operations interfaces when option is false (default)", async () => { const fs = await compile( tester, ` @@ -106,7 +106,6 @@ describe("exclude-interfaces option", () => { @route("/widgets") @get list(): string[]; } `, - { "skip-format": true, "exclude-interfaces": ["Operations"] }, ); assertFileEmitted(fs, "WidgetsController.cs");