Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is too azure specific, this emitter shouldn't have any reference to azure in its options and handling of things.
I think we might be better of having a generic omit-types: string[] option which lets you exclude any types from being generated. This is still not ideal as this really should be defined with decorators but this is not something we'll have soon so this can unblock in the meantime

Original file line number Diff line number Diff line change
@@ -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
```
23 changes: 16 additions & 7 deletions packages/http-server-csharp/src/emitter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,14 @@ export async function $onEmit(context: EmitContext<CSharpServiceEmitterOptions>)
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"];
Expand All @@ -52,10 +58,8 @@ export async function $onEmit(context: EmitContext<CSharpServiceEmitterOptions>)
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;
Expand Down Expand Up @@ -88,7 +92,7 @@ export async function $onEmit(context: EmitContext<CSharpServiceEmitterOptions>)
/>
</SourceDirectory>
<ControllersAndInterfaces
interfaces={resolution.interfaces}
interfaces={interfaces}
canonicalOpsMap={resolution.canonicalOpsMap}
/>
</SourceDirectory>
Expand All @@ -99,7 +103,7 @@ export async function $onEmit(context: EmitContext<CSharpServiceEmitterOptions>)
/>
<Show when={emitMocks}>
<MockImplementations
interfaces={resolution.interfaces}
interfaces={interfaces}
canonicalOpsMap={resolution.canonicalOpsMap}
/>
</Show>
Expand Down Expand Up @@ -128,3 +132,8 @@ export async function $onEmit(context: EmitContext<CSharpServiceEmitterOptions>)
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";
}
9 changes: 9 additions & 0 deletions packages/http-server-csharp/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CSharpServiceEmitterOptions> = {
Expand Down Expand Up @@ -96,6 +98,13 @@ const EmitterOptionsSchema: JSONSchemaType<CSharpServiceEmitterOptions> = {
description:
"Specifies the collection type to use: 'array' or 'enumerable'. The default is 'array'.",
},
"include-operations-controller": {
type: "boolean",
nullable: true,
default: false,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: this is a breaking change

description:
"When true, generates a controller and business-logic interface for the ARM Operations endpoint. Default is false.",
},
},
required: [],
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TestFileSystem> {
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");
});
});
Loading