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

fix errors in emitter including duplicate nullable suffixes, unresolved symbols for multipart content, incompatible types, parameter ordering, and void as a success type
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,175 @@ it("renders a DELETE action with path param", async () => {
}
`);
});

it("does not assign a result for void success unions with error responses", async () => {
const { deletePet } = await runner.compile(t.code`
@error
model ErrorResponse {
code: string;
}

op ServiceOperation<Response>(): Response | ErrorResponse;

interface PetStore {
@route("/pets") @delete ${t.op("deletePet")} is ServiceOperation<void>;
}
`);

const canonOp = canonicalizeOp(deletePet);

expect(
<Wrapper>
<ControllerAction operation={canonOp} implFieldName="PetStoreImpl" />
</Wrapper>,
).toRenderTo(`
using Microsoft.AspNetCore.Mvc;

class TestController
{
[HttpDelete]
[Route("/pets")]
[ProducesResponseType((int)HttpStatusCode.NoContent, Type = typeof(void))]
public virtual async Task<IActionResult> DeletePet()
{
await PetStoreImpl.DeletePetAsync();
return NoContent();
}
}
`);
});

it("preserves result handling for value success unions with error responses", async () => {
const { getPet } = await runner.compile(t.code`
@error
model ErrorResponse {
code: string;
}

op ServiceOperation<Response>(): Response | ErrorResponse;

interface PetStore {
@route("/pets") @get ${t.op("getPet")} is ServiceOperation<string>;
}
`);

const canonOp = canonicalizeOp(getPet);

expect(
<Wrapper>
<ControllerAction operation={canonOp} implFieldName="PetStoreImpl" />
</Wrapper>,
).toRenderTo(`
using Microsoft.AspNetCore.Mvc;

class TestController
{
[HttpGet]
[Route("/pets")]
[ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(string))]
public virtual async Task<IActionResult> GetPet()
{
var result = await PetStoreImpl.GetPetAsync();
return Ok(result);
}
}
`);
});

it("orders request model call arguments to match the business interface", async () => {
const { updatePet } = await runner.compile(t.code`
model UpdatePetRequest {
optionalTag?: string;
age: int32;
}

interface PetStore {
@route("/pets/{petId}") @post ${t.op("updatePet")}(
@path petId: string,
...UpdatePetRequest,
@query apiVersion: string,
): void;
}
`);

const canonOp = canonicalizeOp(updatePet);

expect(
<Wrapper>
<ControllerAction
operation={canonOp}
implFieldName="PetStoreImpl"
requestModel={{ name: "PetStoreUpdatePetRequest", op: canonOp, ifaceName: "PetStore" }}
/>
</Wrapper>,
).toRenderTo(`
using Microsoft.AspNetCore.Mvc;

class TestController
{
[HttpPost]
[Route("/pets/{petId}")]
[ProducesResponseType((int)HttpStatusCode.NoContent, Type = typeof(void))]
public virtual async Task<IActionResult> UpdatePet(
string petId,
PetStoreUpdatePetRequest body,
[FromQuery(Name="apiVersion")]
string apiVersion
)
{
await PetStoreImpl.UpdatePetAsync(petId, body.Age, apiVersion, body.OptionalTag);
return NoContent();
}
}
`);
});

it("orders protocol parameter call arguments to match the business interface", async () => {
const { getPet, businessGetPet } = await runner.compile(t.code`
interface PetStore {
@route("/pets/{petId}") @get ${t.op("getPet")}(
@path petId: string,
@header feature: string,
@query apiVersion: string,
): string;

${t.op("businessGetPet")}(
feature: string,
petId: string,
apiVersion: string,
): string;
}
`);

const canonOp = canonicalizeOp(getPet);

expect(
<Wrapper>
<ControllerAction
operation={canonOp}
businessOperation={businessGetPet}
implFieldName="PetStoreImpl"
/>
</Wrapper>,
).toRenderTo(`
using Microsoft.AspNetCore.Mvc;

class TestController
{
[HttpGet]
[Route("/pets/{petId}")]
[ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(string))]
public virtual async Task<IActionResult> GetPet(
string petId,
[FromHeader(Name="feature")]
string feature,
[FromQuery(Name="apiVersion")]
string apiVersion
)
{
var result = await PetStoreImpl.GetPetAsync(feature, petId, apiVersion);
return Ok(result);
}
}
`);
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { code, type Children } from "@alloy-js/core";
import * as cs from "@alloy-js/csharp";
import { Attribute } from "@alloy-js/csharp";
import { isErrorModel, isVoidType } from "@typespec/compiler";
import { isErrorModel, isVoidType, type Operation } from "@typespec/compiler";
import { useTsp } from "@typespec/emitter-framework";
import type { OperationHttpCanonicalization } from "@typespec/http-canonicalization";
import { AspNetMvc } from "../../utils/csharp-libs.jsx";
Expand All @@ -15,6 +15,8 @@ import { getSuccessStatusCode } from "./response-analysis.js";
export interface ControllerActionProps {
/** The canonicalized HTTP operation to generate an action method for. */
operation: OperationHttpCanonicalization;
/** The operation used to generate the matching business interface method. */
businessOperation?: Operation;
/** The name of the business logic implementation field (e.g., "petStoreImpl"). */
implFieldName: string;
/** Request model info if this operation uses a synthetic request model. */
Expand Down Expand Up @@ -45,13 +47,15 @@ export function ControllerAction(props: ControllerActionProps): Children {
// Map all HTTP parameters (path, query, header) to C# method parameters
const pathParams: ParamInfo[] = [];
const queryHeaderParams: ParamInfo[] = [];
const callArgBySourceName = new Map<string, string>();
for (const p of props.operation.requestParameters.properties) {
if (p.property.isContentTypeProperty) continue;
const isOptional = p.property.sourceType.optional;
const literalDefault = getLiteralDefaultValue(p.property.sourceType.type);
if (p.kind === "path") {
const paramName = namePolicy.getName(p.property.sourceType.name, "parameter");
const attr = getBindingAttribute(p, paramName);
callArgBySourceName.set(p.property.sourceType.name, paramName);
pathParams.push({
name: paramName,
type: <TypeExpression type={p.property.sourceType.type} />,
Expand All @@ -61,8 +65,10 @@ export function ControllerAction(props: ControllerActionProps): Children {
});
} else if (p.kind === "query" || p.kind === "header") {
const attr = getBindingAttribute(p);
const paramName = namePolicy.getName(p.property.sourceType.name, "parameter");
callArgBySourceName.set(p.property.sourceType.name, paramName);
queryHeaderParams.push({
name: namePolicy.getName(p.property.sourceType.name, "parameter"),
name: paramName,
type: <TypeExpression type={p.property.sourceType.type} />,
attributes: attr ? [attr] : undefined,
optional: isOptional,
Expand All @@ -79,6 +85,15 @@ export function ControllerAction(props: ControllerActionProps): Children {
};
// Default: path params, then query/header params (sorted by default presence)
let parameters: ParamInfo[] = [...pathParams, ...queryHeaderParams.sort(sortByDefault)];
const getOrderedCallArgs = () =>
Array.from(
(props.businessOperation ?? props.operation.sourceType).parameters.properties.entries(),
)
.filter(([_, prop]) => !isVoidType(prop.type))
.map(([name, prop]) => ({ arg: callArgBySourceName.get(name), optional: prop.optional }))
.filter((item): item is { arg: string; optional: boolean } => item.arg !== undefined)
.sort((a, b) => (a.optional === b.optional ? 0 : a.optional ? 1 : -1))
.map((item) => item.arg);

// Add body parameter if present (but NOT for GET requests)
const body = props.operation.requestParameters.body;
Expand All @@ -98,10 +113,10 @@ export function ControllerAction(props: ControllerActionProps): Children {

if (isGet) {
// GET requests suppress body parameters entirely
callArgs = parameters.map((p) => p.name).join(", ");
callArgs = getOrderedCallArgs().join(", ");
} else if (isMultipart) {
// Multipart body: don't add body as parameter — we'll create a MultipartReader in the method body
callArgs = [...parameters.map((p) => p.name), "reader"].join(", ");
callArgs = [...getOrderedCallArgs(), "reader"].join(", ");
} else if (isBodyRoot) {
// @bodyRoot — the whole model is the body, no other HTTP params extracted
parameters = [
Expand All @@ -115,36 +130,42 @@ export function ControllerAction(props: ControllerActionProps): Children {
// Call args: path params, then body property accesses, then query/header params
const bodyType = body.bodies[0].type.sourceType;
if (bodyType.kind === "Model") {
const bodyArgs = Array.from(bodyType.properties.values()).map((p) => {
for (const p of bodyType.properties.values()) {
const propName = namePolicy.getName(p.name, "class-property");
return `body.${propName}`;
});
const pathArgNames = pathParams.map((p) => p.name);
const queryArgNames = queryHeaderParams.map((p) => p.name);
callArgs = [...pathArgNames, ...bodyArgs, ...queryArgNames].join(", ");
callArgBySourceName.set(p.name, `body.${propName}`);
}
callArgs = getOrderedCallArgs().join(", ");
} else {
callArgs = parameters.map((p) => p.name).join(", ");
callArgs = getOrderedCallArgs().join(", ");
}
} else if (hasExplicitBody) {
parameters.push({
name: "body",
type: <TypeExpression type={body!.bodies[0].type.sourceType} />,
attributes: [{ name: AspNetMvc.FromBodyAttribute }],
});
callArgs = parameters.map((p) => p.name).join(", ");
const sourceProperty = body.bodies[0].property?.sourceType;
if (sourceProperty) callArgBySourceName.set(sourceProperty.name, "body");
callArgs = sourceProperty
? getOrderedCallArgs().join(", ")
: parameters.map((p) => p.name).join(", ");
} else if (body?.bodyKind === "single" && body.bodies.length > 0) {
parameters.push({
name: "body",
type: <TypeExpression type={body.bodies[0].type.sourceType} />,
attributes: [{ name: AspNetMvc.FromBodyAttribute }],
});
callArgs = parameters.map((p) => p.name).join(", ");
const sourceProperty = body.bodies[0].property?.sourceType;
if (sourceProperty) callArgBySourceName.set(sourceProperty.name, "body");
callArgs = sourceProperty
? getOrderedCallArgs().join(", ")
: parameters.map((p) => p.name).join(", ");
} else {
callArgs = parameters.map((p) => p.name).join(", ");
callArgs = getOrderedCallArgs().join(", ");
}

// Determine the success status code from the response
const { statusCode, hasBody } = getSuccessStatusCode(props.operation);
const { statusCode, hasBody } = getSuccessStatusCode($.program, props.operation);

// Determine response type for ProducesResponseType attribute
const returnType = props.operation.sourceType.returnType;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { isVoidType } from "@typespec/compiler";
import { isErrorModel, isVoidType, type Program } from "@typespec/compiler";
import type { OperationHttpCanonicalization } from "@typespec/http-canonicalization";

/**
* Determines the success HTTP status code and whether the response has a body.
* Checks the original return type for @statusCode properties.
*/
export function getSuccessStatusCode(operation: OperationHttpCanonicalization): {
export function getSuccessStatusCode(
program: Program,
operation: OperationHttpCanonicalization,
): {
statusCode: number | undefined;
hasBody: boolean;
} {
Expand All @@ -18,15 +21,24 @@ export function getSuccessStatusCode(operation: OperationHttpCanonicalization):

// Check union responses - find the first non-error success response
if (returnType.kind === "Union") {
let hasVoidSuccess = false;
for (const variant of returnType.variants.values()) {
const vt = variant.type;
if (isVoidType(vt)) continue;
if (isVoidType(vt)) {
hasVoidSuccess = true;
continue;
}
if (vt.kind === "Model") {
if (isErrorModel(program, vt)) continue;
// Skip models with @error decorator or error-range status codes
const result = analyzeResponseModel(vt);
if (result.statusCode !== undefined && result.statusCode >= 400) continue;
return result;
}
return { statusCode: 200, hasBody: true };
}
if (hasVoidSuccess) {
return { statusCode: 204, hasBody: false };
}
}

Expand Down
Loading
Loading