Skip to content
Merged
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"
---

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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -126,3 +129,34 @@ 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
@useAuth(MyKeyAuth)
namespace Contoso;

model MyKeyAuth is ApiKeyAuth<ApiKeyLocation.header, "x-api-key">;
model Widget {
id: string;
}
`);
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(
<Wrapper>
{resolution.models.map((m) => (
<ClassDeclaration type={m} />
))}
</Wrapper>,
).toRenderTo(`
class Widget
{
public required string Id { get; set; }
}
`);
});
Original file line number Diff line number Diff line change
@@ -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 TesterInstance } from "@typespec/compiler/testing";
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";
import { createServerScalarOverrides, TypeExpression } from "./type-expression.jsx";
import { createServerScalarOverrides, efRefkey, TypeExpression } from "./type-expression.jsx";

let runner: TesterInstance;

Expand Down Expand Up @@ -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 { Color } = await runner.compile(t.code`
enum ${t.enum("Color")} {
red,
green,
}
model Test {
test: Color.red;
}
`);
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(
<Wrapper>
<cs.EnumDeclaration name="Color" refkey={efRefkey(Color)}>
Red
</cs.EnumDeclaration>
<hbr />
<TypeExpression type={member} />
</Wrapper>,
).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(
<Wrapper>
<TypeExpression type={type} />
</Wrapper>,
).toRenderTo("string");
});
});
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand Down
38 changes: 33 additions & 5 deletions packages/http-server-csharp/src/service-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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>): Model[] {
const models: Model[] = [];
const seen = new Set<Model>();

function addModel(model: Model) {
if (seen.has(model)) return;
seen.add(model);
if (authModels.has(model)) return;
if (shouldEmitModel($, model)) {
models.push(model);
}
Expand All @@ -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.)
Expand Down Expand Up @@ -267,16 +276,35 @@ function collectModelsFromNamespace(
ns: TspNamespace,
models: Model[],
seen: Set<Model>,
authModels: Set<Model>,
): 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<Model> {
const authModels = new Set<Model>();
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 {
Expand Down
Loading