diff --git a/.chronus/changes/fix-csharp-emitter-status-codes-2026-03-04-00-54-22.md b/.chronus/changes/fix-csharp-emitter-status-codes-2026-03-04-00-54-22.md new file mode 100644 index 00000000000..22be7cedb04 --- /dev/null +++ b/.chronus/changes/fix-csharp-emitter-status-codes-2026-03-04-00-54-22.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-server-csharp" +--- + +Fix controller generation to use correct ASP.NET Core result methods for non-200/204 status codes. Previously all operations returned `Ok(...)` or `NoContent()` regardless of the declared status code. Now operations returning 202 use `Accepted(...)`, and other status codes use `StatusCode(code, ...)`. diff --git a/packages/http-server-csharp/src/lib/service.ts b/packages/http-server-csharp/src/lib/service.ts index 6f9b671f398..db6615db3d4 100644 --- a/packages/http-server-csharp/src/lib/service.ts +++ b/packages/http-server-csharp/src/lib/service.ts @@ -103,6 +103,7 @@ import { getCSharpType, getCSharpTypeForIntrinsic, getCSharpTypeForScalar, + getControllerReturnStatement, getFreePort, getHttpDeclParameters, getImports, @@ -873,7 +874,7 @@ export async function $onEmit(context: EmitContext) }); const hasResponseValue = response.name !== "void"; - const resultString = `${status === 204 ? "NoContent" : "Ok"}`; + const returnStatement = getControllerReturnStatement(status, hasResponseValue); if (!this.#isMultipartRequest(httpOperation)) { return this.emitter.result.declaration( operation.name, @@ -887,9 +888,9 @@ export async function $onEmit(context: EmitContext) ${ hasResponseValue ? `var result = await ${this.emitter.getContext().resourceName}Impl.${operationName}Async(${getBusinessLogicCallParameters(parameters)}); - return ${resultString}(result);` + ${returnStatement}` : `await ${this.emitter.getContext().resourceName}Impl.${operationName}Async(${getBusinessLogicCallParameters(parameters)}); - return ${resultString}();` + ${returnStatement}` } }`, ); @@ -915,9 +916,9 @@ export async function $onEmit(context: EmitContext) ${ hasResponseValue ? `var result = await ${this.emitter.getContext().resourceName}Impl.${operationName}Async(${getBusinessLogicCallParameters(parameters)}); - return ${resultString}(result);` + ${returnStatement}` : `await ${this.emitter.getContext().resourceName}Impl.${operationName}Async(${getBusinessLogicCallParameters(parameters)}); - return ${resultString}();` + ${returnStatement}` } }`, ); @@ -956,7 +957,7 @@ export async function $onEmit(context: EmitContext) }); const hasResponseValue = response.name !== "void"; - const resultString = `${status === 204 ? "NoContent" : "Ok"}`; + const returnStatement = getControllerReturnStatement(status, hasResponseValue); return this.emitter.result.declaration( operation.name, code` @@ -969,9 +970,9 @@ export async function $onEmit(context: EmitContext) ${ hasResponseValue ? `var result = await ${this.emitter.getContext().resourceName}Impl.${operationName}Async(${getBusinessLogicCallParameters(parameters)}); - return ${resultString}(result);` + ${returnStatement}` : `await ${this.emitter.getContext().resourceName}Impl.${operationName}Async(${getBusinessLogicCallParameters(parameters)}); - return ${resultString}();` + ${returnStatement}` } }`, ); diff --git a/packages/http-server-csharp/src/lib/utils.ts b/packages/http-server-csharp/src/lib/utils.ts index ead1828c0f7..2b64cb84fea 100644 --- a/packages/http-server-csharp/src/lib/utils.ts +++ b/packages/http-server-csharp/src/lib/utils.ts @@ -1032,6 +1032,31 @@ export function getCSharpStatusCode(entry: HttpStatusCodesEntry): string | undef } } +/** + * Returns the full return statement for a controller action based on the HTTP status code. + * Maps well-known status codes to their idiomatic ASP.NET Core ControllerBase methods, + * and falls back to `StatusCode(code, ...)` for all other numeric codes. + */ +export function getControllerReturnStatement( + status: HttpStatusCodesEntry, + hasValue: boolean, +): string { + if (typeof status === "number") { + switch (status) { + case 200: + return hasValue ? "return Ok(result);" : "return Ok();"; + case 202: + return hasValue ? "return Accepted(result);" : "return Accepted();"; + case 204: + return "return NoContent();"; + default: + return hasValue ? `return StatusCode(${status}, result);` : `return StatusCode(${status});`; + } + } + // Fallback for ranges and "*" + return hasValue ? "return Ok(result);" : "return Ok();"; +} + export function isEmptyResponseModel(program: Program, model: Type): boolean { if (model.kind !== "Model") return false; if (model.properties.size === 0) return true; diff --git a/packages/http-server-csharp/test/generation.test.ts b/packages/http-server-csharp/test/generation.test.ts index 7f5c0175c66..130ed3a0408 100644 --- a/packages/http-server-csharp/test/generation.test.ts +++ b/packages/http-server-csharp/test/generation.test.ts @@ -2023,6 +2023,53 @@ model FileAttachmentMultipartRequest { ); }); +it("Produces Accepted result for 202 response with body", async () => { + await compileAndValidateMultiple( + tester, + ` + model AcceptedResponse { + @statusCode statusCode: 202; + jobId: string; + } + + @post + op startJob(): AcceptedResponse; + `, + [["ContosoOperationsController.cs", ["return Accepted(result)"]]], + ); +}); + +it("Produces Accepted result for 202 response without body", async () => { + await compileAndValidateMultiple( + tester, + ` + model AcceptedNoBodyResponse { + @statusCode statusCode: 202; + } + + @post + op startJob(): AcceptedNoBodyResponse; + `, + [["ContosoOperationsController.cs", ["return Accepted()"]]], + ); +}); + +it("Produces StatusCode result for 201 response with body", async () => { + await compileAndValidateMultiple( + tester, + ` + model CreatedResponse { + @statusCode statusCode: 201; + id: string; + } + + @post + op createResource(): CreatedResponse; + `, + [["ContosoOperationsController.cs", ["return StatusCode(201, result)"]]], + ); +}); + const multipartSpec = ` @error model NotFoundErrorResponse {