From 1af59a4a0e50528a3ad9b30e689f1d5ba16d932e Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 29 Jul 2026 21:05:28 -0400 Subject: [PATCH 1/3] fix(http-server-csharp): don't emit auth scheme models and fix enum member rendering Auth scheme models referenced by @useAuth (e.g. `model X is ApiKeyAuth<...>`) were emitted as C# payload classes. They are now excluded from model discovery, aligning with the OpenAPI3 emitter which treats them as security metadata. Also fixes a property typed as an enum member (e.g. `kind: Color.red`) rendering as an unresolved refkey symbol; it now uses the parent enum type, with a primitive-type fallback for non-emitted std-lib enums. Fixes https://github.com/microsoft/typespec/issues/11449 --- ...er-csharp-auth-models-2026-7-29-16-40-0.md | 7 +++ .../src/components/models/models.test.tsx | 37 ++++++++++++++ .../type-expression/type-expression.test.tsx | 48 ++++++++++++++++++- .../type-expression/type-expression.tsx | 29 ++++++++++- .../src/service-resolution.ts | 38 +++++++++++++-- 5 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 .chronus/changes/fix-server-csharp-auth-models-2026-7-29-16-40-0.md diff --git a/.chronus/changes/fix-server-csharp-auth-models-2026-7-29-16-40-0.md b/.chronus/changes/fix-server-csharp-auth-models-2026-7-29-16-40-0.md new file mode 100644 index 00000000000..28710e4fe7d --- /dev/null +++ b/.chronus/changes/fix-server-csharp-auth-models-2026-7-29-16-40-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-server-csharp" +--- + +Do not generate C# model classes for authentication scheme models (e.g. models referenced by `@useAuth`), aligning with the OpenAPI3 emitter which treats them as security metadata. Also fix a property typed as an enum member (e.g. `kind: Color.red`) rendering as an unresolved symbol; it now uses the parent enum type. diff --git a/packages/http-server-csharp/src/components/models/models.test.tsx b/packages/http-server-csharp/src/components/models/models.test.tsx index 455e42fd8b9..6e7f6e8302d 100644 --- a/packages/http-server-csharp/src/components/models/models.test.tsx +++ b/packages/http-server-csharp/src/components/models/models.test.tsx @@ -2,9 +2,12 @@ import { Tester } from "#test/tester.js"; import { type Children } from "@alloy-js/core"; import { createCSharpNamePolicy, SourceFile } from "@alloy-js/csharp"; import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; import { Output } from "@typespec/emitter-framework"; import { ClassDeclaration } from "@typespec/emitter-framework/csharp"; +import { HttpCanonicalizer } from "@typespec/http-canonicalization"; import { beforeEach, expect, it } from "vitest"; +import { resolveServiceTypes } from "../../service-resolution.js"; let runner: TesterInstance; @@ -126,3 +129,37 @@ it("renders a model with nullable union property", async () => { } `); }); + +it("does not emit a model class for an @useAuth scheme model", async () => { + await runner.compile(` + @service(#{ title: "Contoso" }) + @useAuth(MyKeyAuth) + namespace Contoso { + model MyKeyAuth is ApiKeyAuth; + model Widget { + id: string; + } + @route("/widgets") interface Widgets { + @get list(): Widget[]; + } + } + `); + const tk = $(runner.program); + const resolution = resolveServiceTypes(runner.program, tk, new HttpCanonicalizer(tk)); + + // Auth scheme models are security metadata and must be excluded from the + // emitted payload models (only the regular `Widget` model remains). + expect(resolution.models.map((m) => m.name)).toEqual(["Widget"]); + expect( + + {resolution.models.map((m) => ( + + ))} + , + ).toRenderTo(` + class Widget + { + public required string Id { get; set; } + } + `); +}); diff --git a/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx b/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx index 58941787009..36068ff521f 100644 --- a/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx +++ b/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx @@ -1,12 +1,13 @@ import { Tester } from "#test/tester.js"; import { type Children } from "@alloy-js/core"; +import * as cs from "@alloy-js/csharp"; import { createCSharpNamePolicy, SourceFile } from "@alloy-js/csharp"; -import type { ModelProperty } from "@typespec/compiler"; +import type { EnumMember, ModelProperty } from "@typespec/compiler"; import { type TesterInstance } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { Experimental_ComponentOverrides, Output } from "@typespec/emitter-framework"; import { beforeEach, describe, expect, it } from "vitest"; -import { createServerScalarOverrides, TypeExpression } from "./type-expression.jsx"; +import { createServerScalarOverrides, efRefkey, TypeExpression } from "./type-expression.jsx"; let runner: TesterInstance; @@ -190,3 +191,46 @@ describe("literal types", () => { ).toRenderTo("bool"); }); }); + +describe("enum member types", () => { + it("renders a property typed as an enum member using the parent enum type", async () => { + const { test } = await runner.compile(` + enum Color { + red, + green, + } + model Test { + @test test: Color.red; + } + `); + const member = (test as ModelProperty).type as EnumMember; + // The enum declaration must exist in the render tree for the reference to + // resolve; here it stands in for the emitted `Color` enum file. + expect( + + + Red + + + + , + ).toRenderTo(` + enum Color + { + Red + } + Color + `); + }); + + it("falls back to the primitive value type for non-emitted std-lib enum members", async () => { + // `AuthType` lives in the TypeSpec.Http std library and is never emitted, + // so a member reference must not become an unresolved enum reference. + const type = await compileType("TypeSpec.Http.AuthType.apiKey"); + expect( + + + , + ).toRenderTo("string"); + }); +}); diff --git a/packages/http-server-csharp/src/components/type-expression/type-expression.tsx b/packages/http-server-csharp/src/components/type-expression/type-expression.tsx index 0f912d78cb9..be6a9860b29 100644 --- a/packages/http-server-csharp/src/components/type-expression/type-expression.tsx +++ b/packages/http-server-csharp/src/components/type-expression/type-expression.tsx @@ -1,5 +1,5 @@ import { code, type Children } from "@alloy-js/core"; -import type { Scalar, Type } from "@typespec/compiler"; +import { isStdNamespace, type Namespace, type Scalar, type Type } from "@typespec/compiler"; import type { Typekit } from "@typespec/compiler/typekit"; import { Experimental_ComponentOverridesConfig, useTsp } from "@typespec/emitter-framework"; import { @@ -43,6 +43,18 @@ export function TypeExpression(props: TypeExpressionProps): Children { } catch { return code`${type.name ?? "object"}`; } + case "EnumMember": { + // A property typed as a specific enum member (e.g. `kind: Color.red`) uses + // the parent enum type in C#. Std-lib enums (e.g. auth `AuthType`) are not + // emitted, so fall back to the member's underlying primitive value type. + if (isInStdLibNamespace(type.enum.namespace)) { + if (typeof type.value === "number") { + return Number.isInteger(type.value) ? code`int` : code`double`; + } + return code`string`; + } + return code`${efRefkey(type.enum)}`; + } case "Tuple": // Tuple of values — use the type of the first element as array if (type.values.length > 0) { @@ -158,6 +170,21 @@ export function TypeExpression(props: TypeExpressionProps): Children { } } +/** + * Returns true when the namespace (or any of its ancestors) is a TypeSpec + * standard-library namespace. Enums declared under the std library (e.g. + * `TypeSpec.Http.AuthType`) are never emitted, so references to their members + * must fall back to a primitive type instead of an (unresolved) enum reference. + */ +function isInStdLibNamespace(namespace: Namespace | undefined): boolean { + let current = namespace; + while (current) { + if (isStdNamespace(current)) return true; + current = current.namespace; + } + return false; +} + function resolveUnionType($: Typekit, union: import("@typespec/compiler").Union): Children { // Named unions that qualify as enums should reference the enum type if (isUnionEnum(union)) { diff --git a/packages/http-server-csharp/src/service-resolution.ts b/packages/http-server-csharp/src/service-resolution.ts index 6c8aea74213..281c6147799 100644 --- a/packages/http-server-csharp/src/service-resolution.ts +++ b/packages/http-server-csharp/src/service-resolution.ts @@ -11,6 +11,7 @@ import { type Union, } from "@typespec/compiler"; import type { Typekit } from "@typespec/compiler/typekit"; +import { getAllHttpServices, resolveAuthentication } from "@typespec/http"; import type { HttpCanonicalizer, OperationHttpCanonicalization, @@ -78,7 +79,11 @@ export function resolveServiceTypes( collectEnumsFromNamespaces(globalNs, enums, unionEnums); // Phase 5: Model discovery (namespace models + operation-referenced models) - const models = getServiceModels($, globalNs); + // Auth scheme models (e.g. those referenced by `@useAuth`) are protocol metadata, + // 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); // Phase 6: Canonicalize all HTTP operations const canonicalOpsMap = canonicalizeAllInterfaces(canonicalizer, interfaces); @@ -154,14 +159,18 @@ function canonicalizeAllInterfaces( /** * Retrieves all models from the program that should be emitted. * Includes namespace-level models AND models referenced by operations. + * + * @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): Model[] { +function getServiceModels($: Typekit, globalNs: TspNamespace, authModels: Set): Model[] { const models: Model[] = []; const seen = new Set(); function addModel(model: Model) { if (seen.has(model)) return; seen.add(model); + if (authModels.has(model)) return; if (shouldEmitModel($, model)) { models.push(model); } @@ -173,7 +182,7 @@ function getServiceModels($: Typekit, globalNs: TspNamespace): Model[] { } for (const ns of globalNs.namespaces.values()) { if (isStdNamespace(ns)) continue; - collectModelsFromNamespace($, ns, models, seen); + collectModelsFromNamespace($, ns, models, seen, authModels); } // Walk operations to discover referenced models (template instantiations, etc.) @@ -267,16 +276,35 @@ function collectModelsFromNamespace( ns: TspNamespace, models: Model[], seen: Set, + authModels: Set, ): void { for (const model of ns.models?.values() ?? []) { - if (!seen.has(model) && shouldEmitModel($, model)) { + if (!seen.has(model) && !authModels.has(model) && shouldEmitModel($, model)) { seen.add(model); models.push(model); } } for (const childNs of ns.namespaces?.values() ?? []) { - collectModelsFromNamespace($, childNs, models, seen); + collectModelsFromNamespace($, childNs, models, seen, authModels); + } +} + +/** + * Collects the models that back authentication schemes for every HTTP service. + * These correspond to `@useAuth` scheme models (e.g. `ApiKeyAuth`, `BearerAuth`) + * and are emitted as security metadata, not as payload model classes. + */ +function getAuthSchemeModels(program: Program): Set { + const authModels = new Set(); + const [services] = getAllHttpServices(program); + for (const service of services) { + for (const scheme of resolveAuthentication(service).schemes) { + if (scheme.model) { + authModels.add(scheme.model); + } + } } + return authModels; } function shouldEmitModel($: Typekit, model: Model): boolean { From 265b02a76077d4f9b72d4b6ba7efbcc2cddc103b Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 30 Jul 2026 07:16:25 -0400 Subject: [PATCH 2/3] test(http-server-csharp): avoid cspell-flagged word in auth model test --- .../http-server-csharp/src/components/models/models.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http-server-csharp/src/components/models/models.test.tsx b/packages/http-server-csharp/src/components/models/models.test.tsx index 6e7f6e8302d..6c9242efc60 100644 --- a/packages/http-server-csharp/src/components/models/models.test.tsx +++ b/packages/http-server-csharp/src/components/models/models.test.tsx @@ -135,7 +135,7 @@ it("does not emit a model class for an @useAuth scheme model", async () => { @service(#{ title: "Contoso" }) @useAuth(MyKeyAuth) namespace Contoso { - model MyKeyAuth is ApiKeyAuth; + model MyKeyAuth is ApiKeyAuth; model Widget { id: string; } From 33b5bfd3cb35b3c8b0cca24252e779df212439b9 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 30 Jul 2026 07:34:50 -0400 Subject: [PATCH 3/3] test(http-server-csharp): simplify auth model test and use t. extractor --- .../src/components/models/models.test.tsx | 15 ++++++--------- .../type-expression/type-expression.test.tsx | 14 +++++++------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/http-server-csharp/src/components/models/models.test.tsx b/packages/http-server-csharp/src/components/models/models.test.tsx index 6c9242efc60..1b117faed47 100644 --- a/packages/http-server-csharp/src/components/models/models.test.tsx +++ b/packages/http-server-csharp/src/components/models/models.test.tsx @@ -132,16 +132,13 @@ it("renders a model with nullable union property", async () => { it("does not emit a model class for an @useAuth scheme model", async () => { await runner.compile(` - @service(#{ title: "Contoso" }) + @service @useAuth(MyKeyAuth) - namespace Contoso { - model MyKeyAuth is ApiKeyAuth; - model Widget { - id: string; - } - @route("/widgets") interface Widgets { - @get list(): Widget[]; - } + namespace Contoso; + + model MyKeyAuth is ApiKeyAuth; + model Widget { + id: string; } `); const tk = $(runner.program); diff --git a/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx b/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx index 36068ff521f..712cbc6010f 100644 --- a/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx +++ b/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx @@ -2,8 +2,8 @@ import { Tester } from "#test/tester.js"; import { type Children } from "@alloy-js/core"; import * as cs from "@alloy-js/csharp"; import { createCSharpNamePolicy, SourceFile } from "@alloy-js/csharp"; -import type { EnumMember, ModelProperty } from "@typespec/compiler"; -import { type TesterInstance } from "@typespec/compiler/testing"; +import type { ModelProperty } from "@typespec/compiler"; +import { t, type TesterInstance } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { Experimental_ComponentOverrides, Output } from "@typespec/emitter-framework"; import { beforeEach, describe, expect, it } from "vitest"; @@ -194,21 +194,21 @@ describe("literal types", () => { describe("enum member types", () => { it("renders a property typed as an enum member using the parent enum type", async () => { - const { test } = await runner.compile(` - enum Color { + const { Color } = await runner.compile(t.code` + enum ${t.enum("Color")} { red, green, } model Test { - @test test: Color.red; + test: Color.red; } `); - const member = (test as ModelProperty).type as EnumMember; + const member = Color.members.get("red")!; // The enum declaration must exist in the render tree for the reference to // resolve; here it stands in for the emitted `Color` enum file. expect( - + Red