Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/http-server-csharp"
---

Fix generated class/interface names incorrectly receiving a `_2` suffix (e.g. `OperationsController_2`, `IOperations_2`, `ErrorResponse_2`) when a non-service library namespace (such as `Azure.ResourceManager`) defines types with the same name as types in the `@service` namespace. The emitter now restricts its collection of interfaces and models to `@service`-decorated namespaces, preventing the naming collision.
24 changes: 20 additions & 4 deletions packages/http-server-csharp/src/service-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Interface,
isStdNamespace,
isTemplateDeclaration,
listServices,
type Operation,
type Namespace as TspNamespace,
} from "@typespec/compiler";
Expand All @@ -12,12 +13,16 @@ import { getCSharpIdentifier, NameCasingType } from "./utils/naming.js";
* Collects all interfaces from the service namespace(s).
* Also creates synthetic interfaces for namespace-level operations
* (following the old emitter pattern: `${namespaceName}Operations`).
*
* Only collects from `@service`-decorated namespaces to avoid including interfaces
* from library namespaces (e.g. Azure.ResourceManager) that share the same interface
* names, which would cause duplicate class names with a `_2` suffix in the generated C#.
* Falls back to scanning all non-std namespaces when no `@service` is present (e.g. tests).
*/
export function getServiceInterfaces(
program: ReturnType<typeof useTsp>["$"]["program"],
): Interface[] {
const interfaces: Interface[] = [];
const globalNs = program.getGlobalNamespaceType();

function collectFromNamespace(ns: TspNamespace): void {
// Collect explicit TypeSpec interfaces
Expand Down Expand Up @@ -61,9 +66,20 @@ export function getServiceInterfaces(
}
}

for (const ns of globalNs.namespaces.values()) {
if (isStdNamespace(ns)) continue;
collectFromNamespace(ns);
const services = listServices(program);
if (services.length > 0) {
// Restrict to @service-decorated namespaces to avoid picking up interfaces from
// library namespaces (e.g. Azure.ResourceManager) that would cause duplicate names.
for (const service of services) {
collectFromNamespace(service.type);
}
} else {
// Fallback: no @service decorator – scan all non-std top-level namespaces as before.
const globalNs = program.getGlobalNamespaceType();
for (const ns of globalNs.namespaces.values()) {
if (isStdNamespace(ns)) continue;
collectFromNamespace(ns);
}
}
return interfaces;
}
Expand Down
82 changes: 59 additions & 23 deletions packages/http-server-csharp/src/service-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
getNamespaceFullName,
isStdNamespace,
isTemplateDeclaration,
listServices,
type Enum,
type Interface,
type Model,
Expand Down Expand Up @@ -83,7 +84,7 @@ export function resolveServiceTypes(
// not payload data, so they must not be emitted as C# model classes (aligns with
// the OpenAPI3 emitter, which emits them under `components.securitySchemes`).
const authModels = getAuthSchemeModels(program);
const models = getServiceModels($, globalNs, authModels);
const models = getServiceModels($, program, authModels);

// Phase 6: Canonicalize all HTTP operations
const canonicalOpsMap = canonicalizeAllInterfaces(canonicalizer, interfaces);
Expand Down Expand Up @@ -160,10 +161,20 @@ function canonicalizeAllInterfaces(
* Retrieves all models from the program that should be emitted.
* Includes namespace-level models AND models referenced by operations.
*
* Only seeds the initial collection from `@service`-decorated namespaces to avoid
* picking up models from library namespaces (e.g. Azure.ResourceManager.CommonTypes)
* that share names with service models and would cause duplicate class names with a
* `_2` suffix. Models that are transitively referenced by service operations are still
* discovered and emitted via discoverReferencedModels / discoverModelsInType regardless
* of which namespace they live in.
*
* Falls back to scanning all non-std namespaces when no `@service` is present.
*
* @param authModels Models that back authentication schemes; these are excluded
* from emission because they represent protocol metadata rather than payloads.
*/
function getServiceModels($: Typekit, globalNs: TspNamespace, authModels: Set<Model>): Model[] {
function getServiceModels($: Typekit, program: Program, authModels: Set<Model>): Model[] {
const globalNs = program.getGlobalNamespaceType();
const models: Model[] = [];
const seen = new Set<Model>();

Expand All @@ -176,30 +187,55 @@ function getServiceModels($: Typekit, globalNs: TspNamespace, authModels: Set<Mo
}
}

// Collect from namespaces
for (const model of globalNs.models.values()) {
addModel(model);
}
for (const ns of globalNs.namespaces.values()) {
if (isStdNamespace(ns)) continue;
collectModelsFromNamespace($, ns, models, seen, authModels);
}
const services = listServices(program);
if (services.length > 0) {

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.

lets reformulate this as we do not want to include type that are not defined in the service namespace unless they are referenced.

// Restrict initial collection to @service-decorated namespaces to avoid picking up
// models from library namespaces (e.g. Azure.ResourceManager) that would otherwise
// cause duplicate class name collisions (the infamous `_2` suffix bug).
for (const service of services) {
collectModelsFromNamespace($, service.type, models, seen, authModels);
}

// Walk operations to discover referenced models (template instantiations, etc.)
const visited = new Set<Type>();
for (const ns of globalNs.namespaces.values()) {
if (isStdNamespace(ns)) continue;
discoverReferencedModels($, ns, addModel, visited);
}
// Walk operations in service namespaces to discover referenced models.
const visited = new Set<Type>();
for (const service of services) {
discoverReferencedModels($, service.type, addModel, visited);
}

// Walk all collected model properties to discover anonymous sub-models
const modelsSnapshot = [...models];
for (const model of modelsSnapshot) {
for (const prop of model.properties.values()) {
discoverModelsInType($, prop.type, addModel, visited);
// Walk all collected model properties to discover anonymous sub-models.
const modelsSnapshot = [...models];
for (const model of modelsSnapshot) {
for (const prop of model.properties.values()) {
discoverModelsInType($, prop.type, addModel, visited);
}
if (model.baseModel) {
discoverModelsInType($, model.baseModel, addModel, visited);
}
}
} else {
// Fallback: no @service decorator – behave as before and scan all non-std namespaces.
for (const model of globalNs.models.values()) {
addModel(model);
}
for (const ns of globalNs.namespaces.values()) {
if (isStdNamespace(ns)) continue;
collectModelsFromNamespace($, ns, models, seen, authModels);
}

const visited = new Set<Type>();
for (const ns of globalNs.namespaces.values()) {
if (isStdNamespace(ns)) continue;
discoverReferencedModels($, ns, addModel, visited);
}
if (model.baseModel) {
discoverModelsInType($, model.baseModel, addModel, visited);

const modelsSnapshot = [...models];
for (const model of modelsSnapshot) {
for (const prop of model.properties.values()) {
discoverModelsInType($, prop.type, addModel, visited);
}
if (model.baseModel) {
discoverModelsInType($, model.baseModel, addModel, visited);
}
}
}

Expand Down
124 changes: 124 additions & 0 deletions packages/http-server-csharp/test/service-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Regression tests for service-discovery issues.
// These tests verify that the emitter correctly restricts collection to @service-decorated
// namespaces and does not pick up types from library/helper namespaces.

import { TesterInstance, TestFileSystem } from "@typespec/compiler/testing";
import assert from "assert";
import { beforeEach, it } from "vitest";
import { ApiTester, compileAndDiagnose } from "./test-host.js";

function getOutputFiles(fs: TestFileSystem): Map<string, string> {
return fs.fs;
}

let tester: TesterInstance;

beforeEach(async () => {
tester = await ApiTester.createInstance();
});

// Regression test for https://github.com/microsoft/typespec/issues/11493
// When a library namespace (e.g. Azure.ResourceManager) defines an interface with the
// same name as one in the user's @service namespace (e.g. "Operations"), the emitter
// must NOT collect the library interface. Previously both were collected and the second
// got a `_2` suffix, causing a class/constructor name mismatch in the generated C#.
it("does not emit _2 suffix when a non-service namespace has an interface with the same name", async () => {
const spec = `
// Simulate a library namespace (e.g. Azure.ResourceManager) that has its own

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.

Simplify this test, the azure mention are irrelevant, here we just want to test that we don't include unreachable types that are not in the service namespace
Do not include extra info keep the bare minimum needed in this test

// Operations interface – the emitter must ignore it.
namespace LibraryNs {
interface Operations {
@get @route("/lib-ops") listLibOps(): void;
}
}

@service(#{ title: "MyService" })
namespace MyService {
interface Operations {
@get @route("/service-ops") listServiceOps(): void;
}
}
`;

const [result] = await compileAndDiagnose(tester, spec, { "skip-format": true });

// The service interface should be generated without any _2 suffix.
const files = getOutputFiles(result.fs);
const controllerFile = [...files.entries()].find(([k]) => k.includes("OperationsController.cs"));
const interfaceFile = [...files.entries()].find(([k]) => k.includes("IOperations.cs"));

assert.ok(controllerFile, "OperationsController.cs should be emitted");
assert.ok(interfaceFile, "IOperations.cs should be emitted");

const [, controllerContents] = controllerFile!;
const [, interfaceContents] = interfaceFile!;

assert.ok(

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.

those expect feel very speciifc to this instance and could easily not be testing the actual problem if the dedup logic change.
We should maybe simplify that to just check the whole set of generated models and make sure there is no unexpected one

!controllerContents.includes("_2"),
`OperationsController.cs must not contain '_2', but got:\n${controllerContents}`,
);
assert.ok(
!interfaceContents.includes("_2"),
`IOperations.cs must not contain '_2', but got:\n${interfaceContents}`,
);

// Sanity-check that the correct class/interface names are present.
assert.ok(
controllerContents.includes("public partial class OperationsController"),
"Controller class should be named OperationsController",
);
assert.ok(
interfaceContents.includes("public interface IOperations"),
"Business-logic interface should be named IOperations",
);

// The library namespace interface should NOT produce a controller file.
const libraryControllerFile = [...files.entries()].find(([k]) =>
k.includes("LibraryNsOperationsController.cs"),
);
assert.ok(!libraryControllerFile, "LibraryNs controller should not be emitted");
});

// Regression test for https://github.com/microsoft/typespec/issues/11493 (model variant)
// When a library namespace defines a model with the same name as a service model, the
// emitter must not emit both, which would produce a `_2` suffix on the second class.
it("does not emit _2 suffix when a non-service namespace has a model with the same name", async () => {
const spec = `
// Library namespace model with the same name as the service model.
namespace LibraryNs {
model ErrorResponse {
code: string;
}
}

@service(#{ title: "MyService" })
namespace MyService {
@error
model ErrorResponse {
@statusCode code: 400;
message: string;
}

interface Ops {
@get @route("/items") list(): void;
}
}
`;

const [result] = await compileAndDiagnose(tester, spec, { "skip-format": true });

const files = getOutputFiles(result.fs);
const modelFile = [...files.entries()].find(([k]) => k.includes("ErrorResponse.cs"));

assert.ok(modelFile, "ErrorResponse.cs should be emitted");

const [, modelContents] = modelFile!;
assert.ok(
!modelContents.includes("_2"),
`ErrorResponse.cs must not contain '_2', but got:\n${modelContents}`,
);
assert.ok(
modelContents.includes("public partial class ErrorResponse"),
"Model class should be named ErrorResponse",
);
});
Loading