From 3a4c6872ece8ed3a6ff4ad9eaf94f0526805f6e8 Mon Sep 17 00:00:00 2001 From: Mark Cowlishaw Date: Thu, 19 Dec 2024 13:24:10 -0800 Subject: [PATCH 1/6] Add mutipart support and fix template instantiation names --- packages/http-server-csharp/src/service.ts | 92 ++++++++++++++++--- packages/http-server-csharp/src/utils.ts | 2 +- .../test/generation.test.ts | 74 ++++++++++++++- 3 files changed, 149 insertions(+), 19 deletions(-) diff --git a/packages/http-server-csharp/src/service.ts b/packages/http-server-csharp/src/service.ts index 2eefa32289..c5b0cc4d93 100644 --- a/packages/http-server-csharp/src/service.ts +++ b/packages/http-server-csharp/src/service.ts @@ -49,7 +49,9 @@ import { MetadataInfo, Visibility, createMetadataInfo, + getHeaderFieldName, getHttpOperation, + isHeader, isStatusCode, } from "@typespec/http"; import { getResourceOperation } from "@typespec/rest"; @@ -372,6 +374,12 @@ export async function $onEmit(context: EmitContext) return this.#getTypeInfoForTsType(property.type); } + #isMultipartRequest(operation: HttpOperation): boolean { + const body = operation.parameters.body; + if (body === undefined) return false; + return body.bodyKind === "multipart"; + } + #getTypeInfoForUnion( union: Union, ): [EmitterOutput, string | boolean | undefined, boolean] { @@ -565,10 +573,18 @@ export async function $onEmit(context: EmitContext) name, NameCasingType.Method, ); - const opDecl = this.emitter.result.declaration( - opName, - code`${doc ? `${formatComment(doc)}\n` : ""}${returnType.name === "void" ? "Task" : `Task<${returnType.getTypeReference(context.scope)}>`} ${opName}Async( ${this.#emitInterfaceOperationParameters(operation, opName, "")});`, - ); + let opDecl: Declaration; + if (this.#isMultipartRequest(httpOp)) { + opDecl = this.emitter.result.declaration( + opName, + code`${doc ? `${formatComment(doc)}\n` : ""}${returnType.name === "void" ? "Task" : `Task<${returnType.getTypeReference(context.scope)}>`} ${opName}Async( MultipartReader reader);`, + ); + } else { + opDecl = this.emitter.result.declaration( + opName, + code`${doc ? `${formatComment(doc)}\n` : ""}${returnType.name === "void" ? "Task" : `Task<${returnType.getTypeReference(context.scope)}>`} ${opName}Async( ${this.#emitInterfaceOperationParameters(operation, opName, "")});`, + ); + } builder.push(code`${opDecl.value}\n`); this.emitter.emitInterfaceOperation(operation); } @@ -595,7 +611,9 @@ export async function $onEmit(context: EmitContext) ); const doc = getDoc(this.emitter.getProgram(), operation); const [httpOperation, _] = getHttpOperation(this.emitter.getProgram(), operation); - const declParams = this.#emitHttpOperationParameters(httpOperation); + const declParams = !this.#isMultipartRequest(httpOperation) + ? this.#emitHttpOperationParameters(httpOperation) + : "HttpRequest request, Stream body"; const responseInfo = this.#getOperationResponse(httpOperation); let status: string = "200"; let response: CSharpType = new CSharpType({ @@ -610,9 +628,10 @@ export async function $onEmit(context: EmitContext) const hasResponseValue = response.name !== "void"; const resultString = `${status === "204" ? "NoContent" : "Ok"}`; - return this.emitter.result.declaration( - operation.name, - code` + if (!this.#isMultipartRequest(httpOperation)) { + return this.emitter.result.declaration( + operation.name, + code` ${doc ? `${formatComment(doc)}` : ""} [${getOperationVerbDecorator(httpOperation)}] [Route("${httpOperation.path}")] @@ -631,7 +650,36 @@ export async function $onEmit(context: EmitContext) return ${resultString}();` } }`, - ); + ); + } else { + return this.emitter.result.declaration( + operation.name, + code` + ${doc ? `${formatComment(doc)}` : ""} + [${getOperationVerbDecorator(httpOperation)}] + [Route("${httpOperation.path}")] + [Consumes("multipart/form-data")] + ${this.emitter.emitOperationReturnType(operation)} + public virtual async Task ${operationName}(${declParams}) + { + var boundary = request.GetMultipartBoundary(); + if (boundary == null) + { + return BadRequest("Request missing multipart boundary"); + } + + + var reader = new MultipartReader(boundary, body); + ${ + hasResponseValue + ? `var result = await ${this.emitter.getContext().resourceName}Impl.${operationName}Async(reader); + return ${resultString}(result);` + : `await ${this.emitter.getContext().resourceName}Impl.${operationName}Async(reader); + return ${resultString}();` + } + }`, + ); + } } operationDeclarationContext(operation: Operation, name: string): Context { @@ -771,9 +819,11 @@ export async function $onEmit(context: EmitContext) const optionalParams: ModelProperty[] = []; let totalParams = 0; for (const [_, parameter] of operation.parameters.properties) { - if (parameter.optional) optionalParams.push(parameter); - else requiredParams.push(parameter); - totalParams++; + if (!this.#isContentTypeHeader(parameter)) { + if (parameter.optional) optionalParams.push(parameter); + else requiredParams.push(parameter); + totalParams++; + } } let i = 1; for (const requiredParam of requiredParams) { @@ -798,7 +848,10 @@ export async function $onEmit(context: EmitContext) //const pathParameters = operation.parameters.parameters.filter((p) => p.type === "path"); for (const parameter of operation.parameters.parameters) { i++; - if (parameter.param.type.kind !== "Intrinsic" || parameter.param.type.name !== "never") { + if ( + !this.#isContentTypeHeader(parameter.param) && + (parameter.param.type.kind !== "Intrinsic" || parameter.param.type.name !== "never") + ) { signature.push( code`${this.#emitOperationSignatureParameter(operation, parameter)}${ i < operation.parameters.parameters.length || bodyParam !== undefined ? ", " : "" @@ -939,17 +992,28 @@ export async function $onEmit(context: EmitContext) return [...bodyParam.type.properties.values()]; } + #isContentTypeHeader(parameter: ModelProperty): boolean { + const program = this.emitter.getProgram(); + return ( + isHeader(program, parameter) && + (parameter.name === "contentType" || + getHeaderFieldName(program, parameter) === "Content-type") + ); + } + #emitOperationCallParameters(operation: HttpOperation): EmitterOutput { const signature = new StringBuilder(); let i = 0; const bodyParameters = this.#getBodyParameters(operation); //const pathParameters = operation.parameters.parameters.filter((p) => p.type === "path"); for (const parameter of operation.parameters.parameters) { + const contentType: boolean = this.#isContentTypeHeader(parameter.param); i++; if ( !isNeverType(parameter.param.type) && !isNullType(parameter.param.type) && - !isVoidType(parameter.param.type) + !isVoidType(parameter.param.type) && + !contentType ) { signature.push( code`${this.#emitOperationCallParameter(operation, parameter)}${ diff --git a/packages/http-server-csharp/src/utils.ts b/packages/http-server-csharp/src/utils.ts index ff667b0ca0..0f33e167d7 100644 --- a/packages/http-server-csharp/src/utils.ts +++ b/packages/http-server-csharp/src/utils.ts @@ -156,7 +156,7 @@ export function getCSharpType( } let name: string = type.name; if (isTemplateInstance(type)) { - name = getFriendlyName(program, type)!; + name = getModelInstantiationName(program, type, name); } return { type: new CSharpType({ diff --git a/packages/http-server-csharp/test/generation.test.ts b/packages/http-server-csharp/test/generation.test.ts index 78328daf5b..211be881e2 100644 --- a/packages/http-server-csharp/test/generation.test.ts +++ b/packages/http-server-csharp/test/generation.test.ts @@ -718,7 +718,6 @@ it("Handles user-defined model templates", async () => { name: string; } - @friendlyName("{name}ListResults", Item) model ResponsePage { items: Item[]; nextLink?: string; @@ -728,12 +727,19 @@ it("Handles user-defined model templates", async () => { } `, [ - ["IMyServiceOperations.cs", ["interface IMyServiceOperations"]], + [ + "IMyServiceOperations.cs", + ["interface IMyServiceOperations", "Task FooAsync( );"], + ], [ "MyServiceOperationsControllerBase.cs", - ["public abstract partial class MyServiceOperationsControllerBase: ControllerBase"], + [ + "public abstract partial class MyServiceOperationsControllerBase: ControllerBase", + "[ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(ResponsePageToy))]", + "public virtual async Task Foo()", + ], ], - ["ToyListResults.cs", ["public partial class ToyListResults"]], + ["ResponsePageToy.cs", ["public partial class ResponsePageToy"]], ], ); }); @@ -1098,3 +1104,63 @@ it("handles implicit request body models correctly", async () => { ], ); }); + +it("handles multipartBody requests and shared routes", async () => { + await compileAndValidateMultiple( + runner, + ` + model FooRequest { + contents: HttpPart; + } + model FooJsonRequest { + mediaType: string; + filename: string; + contents: bytes; + } + + @sharedRoute + @route("/foo") + @post + op fooBinary( + @header("content-type") contentType: "multipart/form-data", + @multipartBody body: FooRequest + ): void; + + @sharedRoute + @route("/foo") + @post + op fooJson( + @header("content-type") contentType: "application/json", + @body body: FooJsonRequest + ): void; + `, + [ + [ + "FooJsonRequest.cs", + [ + "public partial class FooJsonRequest", + "public string MediaType { get; set; }", + "public string Filename { get; set; }", + "public byte[] Contents { get; set; }", + ], + ], + [ + "ContosoOperationsControllerBase.cs", + [ + `[Consumes("multipart/form-data")]`, + "public virtual async Task FooBinary(HttpRequest request, Stream body)", + ".FooBinaryAsync(reader)", + "public virtual async Task FooJson(FooJsonRequest body)", + ".FooJsonAsync(body)", + ], + ], + [ + "IContosoOperations.cs", + [ + "Task FooBinaryAsync( MultipartReader reader);", + "Task FooJsonAsync( FooJsonRequest body);", + ], + ], + ], + ); +}); From 476783a9b73fde862d3bac80ff66b0c265f5ec32 Mon Sep 17 00:00:00 2001 From: Mark Cowlishaw Date: Thu, 19 Dec 2024 17:09:34 -0800 Subject: [PATCH 2/6] handle multipart requests --- packages/http-server-csharp/src/service.ts | 57 ++++++++++++++++++- .../test/generation.test.ts | 28 ++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/http-server-csharp/src/service.ts b/packages/http-server-csharp/src/service.ts index c5b0cc4d93..1d35d82ba9 100644 --- a/packages/http-server-csharp/src/service.ts +++ b/packages/http-server-csharp/src/service.ts @@ -51,6 +51,7 @@ import { createMetadataInfo, getHeaderFieldName, getHttpOperation, + getHttpPart, isHeader, isStatusCode, } from "@typespec/http"; @@ -233,6 +234,11 @@ export async function $onEmit(context: EmitContext) } modelDeclaration(model: Model, name: string): EmitterOutput { + const parts = this.#getMultipartParts(model); + if (parts.length > 0) { + parts.forEach((p) => this.emitter.emitType(p)); + return ""; + } const className = ensureCSharpIdentifier(this.emitter.getProgram(), model, name); const namespace = this.emitter.getContext().namespace; const doc = getDoc(this.emitter.getProgram(), model); @@ -259,6 +265,7 @@ export async function $onEmit(context: EmitContext) } modelDeclarationContext(model: Model, name: string): Context { + if (this.#isMultipartModel(model)) return {}; const modelName = ensureCSharpIdentifier(this.emitter.getProgram(), model, name); const modelFile = this.emitter.createSourceFile(`models/${modelName}.cs`); modelFile.meta[this.#sourceTypeKey] = CSharpSourceType.Model; @@ -267,6 +274,7 @@ export async function $onEmit(context: EmitContext) } modelInstantiationContext(model: Model): Context { + if (this.#isMultipartModel(model)) return {}; const modelName: string = getModelInstantiationName( this.emitter.getProgram(), model, @@ -281,6 +289,11 @@ export async function $onEmit(context: EmitContext) } modelInstantiation(model: Model, name: string): EmitterOutput { + const parts = this.#getMultipartParts(model); + if (parts.length > 0) { + parts.forEach((p) => this.emitter.emitType(p)); + return ""; + } const program = this.emitter.getProgram(); const recordType = getRecordType(program, model); if (recordType !== undefined) { @@ -292,6 +305,22 @@ export async function $onEmit(context: EmitContext) return this.modelDeclaration(model, className); } + #getMultipartParts(model: Model): Type[] { + const parts: Type[] = [...model.properties.values()] + .flatMap((p) => getHttpPart(this.emitter.getProgram(), p.type)?.type) + .filter((t) => t !== undefined); + if (model.baseModel) { + return parts.concat(this.#getMultipartParts(model.baseModel)); + } + + return parts; + } + + #isMultipartModel(model: Model): boolean { + const multipartTypes = this.#getMultipartParts(model); + return multipartTypes.length > 0; + } + modelProperties(model: Model): EmitterOutput { const result: StringBuilder = new StringBuilder(); for (const [_, prop] of model.properties) { @@ -380,6 +409,15 @@ export async function $onEmit(context: EmitContext) return body.bodyKind === "multipart"; } + #hasMultipartOperation(iface: Interface): boolean { + for (const [_, operation] of iface.operations) { + const [httpOp, _] = getHttpOperation(this.emitter.getProgram(), operation); + if (this.#isMultipartRequest(httpOp)) return true; + } + + return false; + } + #getTypeInfoForUnion( union: Union, ): [EmitterOutput, string | boolean | undefined, boolean] { @@ -545,6 +583,11 @@ export async function $onEmit(context: EmitContext) ]); context.file.imports.set("System.Threading.Tasks", ["System.Threading.Tasks"]); context.file.imports.set("Microsoft.AspNetCore.Mvc", ["Microsoft.AspNetCore.Mvc"]); + if (this.#hasMultipartOperation(iface)) { + context.file.imports.set("Microsoft.AspNetCore.WebUtilities", [ + "Microsoft.AspNetCore.WebUtilities", + ]); + } context.file.imports.set(modelNamespace, [modelNamespace]); return context; } @@ -609,11 +652,23 @@ export async function $onEmit(context: EmitContext) name, NameCasingType.Method, ); + const doc = getDoc(this.emitter.getProgram(), operation); const [httpOperation, _] = getHttpOperation(this.emitter.getProgram(), operation); - const declParams = !this.#isMultipartRequest(httpOperation) + const multipart: boolean = this.#isMultipartRequest(httpOperation); + const declParams = !multipart ? this.#emitHttpOperationParameters(httpOperation) : "HttpRequest request, Stream body"; + + if (multipart) { + const context = this.emitter.getContext(); + context.file.imports.set("Microsoft.AspNetCore.WebUtilities", [ + "Microsoft.AspNetCore.WebUtilities", + ]); + context.file.imports.set("Microsoft.AspNetCore.Http.Extensions", [ + "Microsoft.AspNetCore.Http.Extensions", + ]); + } const responseInfo = this.#getOperationResponse(httpOperation); let status: string = "200"; let response: CSharpType = new CSharpType({ diff --git a/packages/http-server-csharp/test/generation.test.ts b/packages/http-server-csharp/test/generation.test.ts index 211be881e2..d9a80d6b23 100644 --- a/packages/http-server-csharp/test/generation.test.ts +++ b/packages/http-server-csharp/test/generation.test.ts @@ -6,7 +6,7 @@ import { getPropertySource, getSourceModel } from "../src/utils.js"; import { createCSharpServiceEmitterTestRunner, getStandardService } from "./test-host.js"; function getGeneratedFile(runner: BasicTestRunner, fileName: string): [string, string] { - const result = [...runner.fs.entries()].filter((e) => e[0].includes(fileName)); + const result = [...runner.fs.entries()].filter((e) => e[0].includes(`/${fileName}`)); assert.strictEqual( result === null || result === undefined, false, @@ -1109,8 +1109,12 @@ it("handles multipartBody requests and shared routes", async () => { await compileAndValidateMultiple( runner, ` + model Bar { + ...T; + } model FooRequest { contents: HttpPart; + other: HttpPart>; } model FooJsonRequest { mediaType: string; @@ -1147,6 +1151,8 @@ it("handles multipartBody requests and shared routes", async () => { [ "ContosoOperationsControllerBase.cs", [ + "using Microsoft.AspNetCore.WebUtilities;", + "using Microsoft.AspNetCore.Http.Extensions;", `[Consumes("multipart/form-data")]`, "public virtual async Task FooBinary(HttpRequest request, Stream body)", ".FooBinaryAsync(reader)", @@ -1157,10 +1163,30 @@ it("handles multipartBody requests and shared routes", async () => { [ "IContosoOperations.cs", [ + "using Microsoft.AspNetCore.WebUtilities;", "Task FooBinaryAsync( MultipartReader reader);", "Task FooJsonAsync( FooJsonRequest body);", ], ], + [ + "BarFooJsonRequest.cs", + [ + "public partial class BarFooJsonRequest", + "public string MediaType { get; set; }", + "public string Filename { get; set; }", + "public byte[] Contents { get; set; }", + ], + ], ], ); + + const files = [...runner.fs.keys()]; + assert.deepStrictEqual( + files.some((k) => k.endsWith("HttpPartFile.cs")), + false, + ); + assert.deepStrictEqual( + files.some((k) => k.endsWith("FooRequest.cs")), + false, + ); }); From 4c53d994a78b8612d851ecffd1fa5a962e80b0be Mon Sep 17 00:00:00 2001 From: Mark Cowlishaw Date: Thu, 19 Dec 2024 17:24:10 -0800 Subject: [PATCH 3/6] Create multipart-01-2024-11-20-1-16-27.md --- .chronus/changes/multipart-01-2024-11-20-1-16-27.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .chronus/changes/multipart-01-2024-11-20-1-16-27.md diff --git a/.chronus/changes/multipart-01-2024-11-20-1-16-27.md b/.chronus/changes/multipart-01-2024-11-20-1-16-27.md new file mode 100644 index 0000000000..3dd1dcae89 --- /dev/null +++ b/.chronus/changes/multipart-01-2024-11-20-1-16-27.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/http-server-csharp" +--- + +Handle multipart operations in c-sharp service emitter From 2d36499847c003304632b3c6fab2920b245038f6 Mon Sep 17 00:00:00 2001 From: Mark Cowlishaw Date: Fri, 20 Dec 2024 18:58:10 -0800 Subject: [PATCH 4/6] Fix multipart business logic interface and bugs --- packages/http-server-csharp/src/lib.ts | 8 + packages/http-server-csharp/src/service.ts | 190 +++++++++++++++------ 2 files changed, 145 insertions(+), 53 deletions(-) diff --git a/packages/http-server-csharp/src/lib.ts b/packages/http-server-csharp/src/lib.ts index 948ab5a39d..6c5a89dee5 100644 --- a/packages/http-server-csharp/src/lib.ts +++ b/packages/http-server-csharp/src/lib.ts @@ -5,6 +5,8 @@ export interface CSharpServiceEmitterOptions { "skip-format"?: boolean; /** Choose which service artifacts to emit. Default is 'all'.*/ "output-type"?: "models" | "all"; + /** Do not emit */ + "no-emit"?: boolean; } const EmitterOptionsSchema: JSONSchemaType = { @@ -25,6 +27,12 @@ const EmitterOptionsSchema: JSONSchemaType = { description: "Chooses which service artifacts to emit. choices include 'models' or 'all' artifacts.", }, + "no-emit": { + type: "boolean", + nullable: true, + default: false, + description: "Do not emit code", + }, }, required: [], }; diff --git a/packages/http-server-csharp/src/service.ts b/packages/http-server-csharp/src/service.ts index 1d35d82ba9..c24b095b75 100644 --- a/packages/http-server-csharp/src/service.ts +++ b/packages/http-server-csharp/src/service.ts @@ -53,6 +53,8 @@ import { getHttpOperation, getHttpPart, isHeader, + isPathParam, + isQueryParam, isStatusCode, } from "@typespec/http"; import { getResourceOperation } from "@typespec/rest"; @@ -90,6 +92,7 @@ export async function $onEmit(context: EmitContext) let _unionCounter: number = 0; const controllers = new Map(); const NoResourceContext: string = "RPCOperations"; + const doNotEmit: boolean = context.options["no-emit"] || false; class CSharpCodeEmitter extends CodeTypeEmitter { #metadateMap: Map = new Map(); @@ -620,12 +623,12 @@ export async function $onEmit(context: EmitContext) if (this.#isMultipartRequest(httpOp)) { opDecl = this.emitter.result.declaration( opName, - code`${doc ? `${formatComment(doc)}\n` : ""}${returnType.name === "void" ? "Task" : `Task<${returnType.getTypeReference(context.scope)}>`} ${opName}Async( MultipartReader reader);`, + code`${doc ? `${formatComment(doc)}\n` : ""}${returnType.name === "void" ? "Task" : `Task<${returnType.getTypeReference(context.scope)}>`} ${opName}Async( ${this.#emitInterfaceOperationParameters(operation, "MultipartReader reader")});`, ); } else { opDecl = this.emitter.result.declaration( opName, - code`${doc ? `${formatComment(doc)}\n` : ""}${returnType.name === "void" ? "Task" : `Task<${returnType.getTypeReference(context.scope)}>`} ${opName}Async( ${this.#emitInterfaceOperationParameters(operation, opName, "")});`, + code`${doc ? `${formatComment(doc)}\n` : ""}${returnType.name === "void" ? "Task" : `Task<${returnType.getTypeReference(context.scope)}>`} ${opName}Async( ${this.#emitInterfaceOperationParameters(operation)});`, ); } builder.push(code`${opDecl.value}\n`); @@ -658,7 +661,7 @@ export async function $onEmit(context: EmitContext) const multipart: boolean = this.#isMultipartRequest(httpOperation); const declParams = !multipart ? this.#emitHttpOperationParameters(httpOperation) - : "HttpRequest request, Stream body"; + : this.#emitHttpOperationParameters(httpOperation, "HttpRequest request, Stream body"); if (multipart) { const context = this.emitter.getContext(); @@ -727,9 +730,9 @@ export async function $onEmit(context: EmitContext) var reader = new MultipartReader(boundary, body); ${ hasResponseValue - ? `var result = await ${this.emitter.getContext().resourceName}Impl.${operationName}Async(reader); + ? `var result = await ${this.emitter.getContext().resourceName}Impl.${operationName}Async(${this.#emitOperationCallParameters(httpOperation, "reader")}); return ${resultString}(result);` - : `await ${this.emitter.getContext().resourceName}Impl.${operationName}Async(reader); + : `await ${this.emitter.getContext().resourceName}Impl.${operationName}Async(${this.#emitOperationCallParameters(httpOperation, "reader")}); return ${resultString}();` } }`, @@ -866,16 +869,22 @@ export async function $onEmit(context: EmitContext) #emitInterfaceOperationParameters( operation: Operation, - operationName: string, - resourceName: string, + bodyParam?: string, ): EmitterOutput { const signature = new StringBuilder(); const requiredParams: ModelProperty[] = []; const optionalParams: ModelProperty[] = []; let totalParams = 0; - for (const [_, parameter] of operation.parameters.properties) { - if (!this.#isContentTypeHeader(parameter)) { - if (parameter.optional) optionalParams.push(parameter); + if (bodyParam !== undefined) totalParams++; + const validParams = [...operation.parameters.properties.entries()].filter(([_, p]) => + isValidParameter(this.emitter.getProgram(), p), + ); + for (const [_, parameter] of validParams) { + if ( + !isContentTypeHeader(this.emitter.getProgram(), parameter) && + (bodyParam === undefined || isHttpMetadata(this.emitter.getProgram(), parameter)) + ) { + if (parameter.optional || parameter.defaultValue) optionalParams.push(parameter); else requiredParams.push(parameter); totalParams++; } @@ -887,41 +896,64 @@ export async function $onEmit(context: EmitContext) code`${paramType} ${ensureCSharpIdentifier(this.emitter.getProgram(), requiredParam, requiredParam.name, NameCasingType.Parameter)}${i++ < totalParams ? ", " : ""}`, ); } + if (bodyParam) { + signature.push(bodyParam); + if (i++ < totalParams) signature.push(", "); + } for (const optionalParam of optionalParams) { const [paramType, _, __] = this.#findPropertyType(optionalParam); signature.push( code`${paramType}? ${ensureCSharpIdentifier(this.emitter.getProgram(), optionalParam, optionalParam.name, NameCasingType.Parameter)}${i++ < totalParams ? ", " : ""}`, ); } + return signature.reduce(); } - #emitHttpOperationParameters(operation: HttpOperation): EmitterOutput { + #emitHttpOperationParameters( + operation: HttpOperation, + bodyParameter?: string, + ): EmitterOutput { const signature = new StringBuilder(); const bodyParam = operation.parameters.body; let i = 0; //const pathParameters = operation.parameters.parameters.filter((p) => p.type === "path"); - for (const parameter of operation.parameters.parameters) { - i++; - if ( - !this.#isContentTypeHeader(parameter.param) && - (parameter.param.type.kind !== "Intrinsic" || parameter.param.type.name !== "never") - ) { + const validParams: HttpOperationParameter[] = operation.parameters.parameters.filter((p) => + isValidParameter(this.emitter.getProgram(), p.param), + ); + const requiredParams: HttpOperationParameter[] = validParams.filter( + (p) => p.type === "path" || (!p.param.optional && p.param.defaultValue === undefined), + ); + const optionalParams: HttpOperationParameter[] = validParams.filter( + (p) => p.type !== "path" && (p.param.optional || p.param.defaultValue !== undefined), + ); + for (const parameter of requiredParams) { + signature.push( + code`${this.#emitOperationSignatureParameter(operation, parameter)}${ + ++i < requiredParams.length || bodyParam !== undefined ? ", " : "" + }`, + ); + } + if (bodyParameter === undefined) { + if (bodyParam !== undefined) { signature.push( - code`${this.#emitOperationSignatureParameter(operation, parameter)}${ - i < operation.parameters.parameters.length || bodyParam !== undefined ? ", " : "" - }`, + code`${this.emitter.emitTypeReference( + this.#metaInfo.getEffectivePayloadType( + bodyParam.type, + Visibility.Create || Visibility.Update, + ), + )} body${optionalParams.length > 0 ? ", " : ""}`, ); } + } else { + signature.push(code`${bodyParameter}${optionalParams.length > 0 ? ", " : ""}`); } - if (bodyParam !== undefined) { + i = 0; + for (const parameter of optionalParams) { signature.push( - code`${this.emitter.emitTypeReference( - this.#metaInfo.getEffectivePayloadType( - bodyParam.type, - Visibility.Create & Visibility.Update, - ), - )} body`, + code`${this.#emitOperationSignatureParameter(operation, parameter)}${ + ++i < optionalParams.length ? ", " : "" + }`, ); } @@ -1047,22 +1079,28 @@ export async function $onEmit(context: EmitContext) return [...bodyParam.type.properties.values()]; } - #isContentTypeHeader(parameter: ModelProperty): boolean { - const program = this.emitter.getProgram(); - return ( - isHeader(program, parameter) && - (parameter.name === "contentType" || - getHeaderFieldName(program, parameter) === "Content-type") - ); - } - - #emitOperationCallParameters(operation: HttpOperation): EmitterOutput { + #emitOperationCallParameters( + operation: HttpOperation, + bodyParameter: string = "body", + ): EmitterOutput { const signature = new StringBuilder(); let i = 0; const bodyParameters = this.#getBodyParameters(operation); //const pathParameters = operation.parameters.parameters.filter((p) => p.type === "path"); - for (const parameter of operation.parameters.parameters) { - const contentType: boolean = this.#isContentTypeHeader(parameter.param); + const valid = operation.parameters.parameters.filter((p) => + isValidParameter(this.emitter.getProgram(), p.param), + ); + const required: HttpOperationParameter[] = valid.filter( + (p) => p.type === "path" || (!p.param.optional && p.param.defaultValue === undefined), + ); + const optional: HttpOperationParameter[] = valid.filter( + (p) => p.type !== "path" && (p.param.optional || p.param.defaultValue !== undefined), + ); + for (const parameter of required) { + const contentType: boolean = isContentTypeHeader( + this.emitter.getProgram(), + parameter.param, + ); i++; if ( !isNeverType(parameter.param.type) && @@ -1072,14 +1110,14 @@ export async function $onEmit(context: EmitContext) ) { signature.push( code`${this.#emitOperationCallParameter(operation, parameter)}${ - i < operation.parameters.parameters.length || bodyParameters !== undefined ? ", " : "" + i < valid.length || bodyParameters !== undefined ? ", " : "" }`, ); } } if (bodyParameters !== undefined) { if (bodyParameters.length === 1) { - signature.push(code`body`); + signature.push(code`${bodyParameter}`); } else { let j = 0; for (const parameter of bodyParameters) { @@ -1090,11 +1128,32 @@ export async function $onEmit(context: EmitContext) parameter.name, NameCasingType.Property, ); - signature.push(code`body?.${propertyName}${j < bodyParameters.length ? ", " : ""}`); + signature.push( + code`${bodyParameter}?.${propertyName}${j < bodyParameters.length || i < valid.length ? ", " : ""}`, + ); } } } + for (const parameter of optional) { + const contentType: boolean = isContentTypeHeader( + this.emitter.getProgram(), + parameter.param, + ); + i++; + if ( + !isNeverType(parameter.param.type) && + !isNullType(parameter.param.type) && + !isVoidType(parameter.param.type) && + !contentType + ) { + signature.push( + code`${this.#emitOperationCallParameter(operation, parameter)}${ + i < valid.length ? ", " : "" + }`, + ); + } + } return signature.reduce(); } #emitOperationCallParameter( @@ -1432,6 +1491,30 @@ export async function $onEmit(context: EmitContext) if (model.kind !== "Model") return false; return model.properties.size === 1 && isStatusCode(program, [...model.properties.values()][0]); } + + function isContentTypeHeader(program: Program, parameter: ModelProperty): boolean { + return ( + isHeader(program, parameter) && + (parameter.name === "contentType" || + getHeaderFieldName(program, parameter) === "Content-type") + ); + } + + function isValidParameter(program: Program, parameter: ModelProperty): boolean { + return ( + !isContentTypeHeader(program, parameter) && + (parameter.type.kind !== "Intrinsic" || parameter.type.name !== "never") + ); + } + + /** Determine whether the given parameter is http metadata */ + function isHttpMetadata(program: Program, property: ModelProperty) { + return ( + isPathParam(program, property) || + isHeader(program, property) || + isQueryParam(program, property) + ); + } function processNameSpace(program: Program, target: Namespace, service?: Service | undefined) { if (!service) service = getService(program, target); if (service) { @@ -1495,16 +1578,17 @@ export async function $onEmit(context: EmitContext) const ns = context.program.checker.getGlobalNamespaceType(); const options = emitter.getOptions(); processNameSpace(context.program, ns); - - await ensureCleanDirectory(context.program, options.emitterOutputDir); - await emitter.writeOutput(); - if (options["skip-format"] === undefined || options["skip-format"] === false) { - await execFile("dotnet", [ - "format", - "whitespace", - emitter.getOptions().emitterOutputDir, - "--include-generated", - "--folder", - ]); + if (!doNotEmit) { + await ensureCleanDirectory(context.program, options.emitterOutputDir); + await emitter.writeOutput(); + if (options["skip-format"] === undefined || options["skip-format"] === false) { + await execFile("dotnet", [ + "format", + "whitespace", + emitter.getOptions().emitterOutputDir, + "--include-generated", + "--folder", + ]); + } } } From edd2f3a3b6a42979c69be505c99779d51f42dd0f Mon Sep 17 00:00:00 2001 From: Mark Cowlishaw Date: Fri, 20 Dec 2024 19:04:32 -0800 Subject: [PATCH 5/6] Hook into compiler noEmit option --- packages/http-server-csharp/src/lib.ts | 8 -------- packages/http-server-csharp/src/service.ts | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/http-server-csharp/src/lib.ts b/packages/http-server-csharp/src/lib.ts index 6c5a89dee5..948ab5a39d 100644 --- a/packages/http-server-csharp/src/lib.ts +++ b/packages/http-server-csharp/src/lib.ts @@ -5,8 +5,6 @@ export interface CSharpServiceEmitterOptions { "skip-format"?: boolean; /** Choose which service artifacts to emit. Default is 'all'.*/ "output-type"?: "models" | "all"; - /** Do not emit */ - "no-emit"?: boolean; } const EmitterOptionsSchema: JSONSchemaType = { @@ -27,12 +25,6 @@ const EmitterOptionsSchema: JSONSchemaType = { description: "Chooses which service artifacts to emit. choices include 'models' or 'all' artifacts.", }, - "no-emit": { - type: "boolean", - nullable: true, - default: false, - description: "Do not emit code", - }, }, required: [], }; diff --git a/packages/http-server-csharp/src/service.ts b/packages/http-server-csharp/src/service.ts index c24b095b75..d3ac0929df 100644 --- a/packages/http-server-csharp/src/service.ts +++ b/packages/http-server-csharp/src/service.ts @@ -92,7 +92,7 @@ export async function $onEmit(context: EmitContext) let _unionCounter: number = 0; const controllers = new Map(); const NoResourceContext: string = "RPCOperations"; - const doNotEmit: boolean = context.options["no-emit"] || false; + const doNotEmit: boolean = context.program.compilerOptions.noEmit || false; class CSharpCodeEmitter extends CodeTypeEmitter { #metadateMap: Map = new Map(); From d9609a0981df1620673e8045ca52e9c24745a350 Mon Sep 17 00:00:00 2001 From: Mark Cowlishaw Date: Fri, 20 Dec 2024 19:16:21 -0800 Subject: [PATCH 6/6] Remove created types --- packages/http-server-csharp/src/service.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/http-server-csharp/src/service.ts b/packages/http-server-csharp/src/service.ts index d3ac0929df..5d411ae728 100644 --- a/packages/http-server-csharp/src/service.ts +++ b/packages/http-server-csharp/src/service.ts @@ -1554,10 +1554,18 @@ export async function $onEmit(context: EmitContext) entityKind: "Type", isFinished: true, }); - for (const [_, op] of nsOps) { - op.interface = iface; + + try { + for (const [_, op] of nsOps) { + op.interface = iface; + } + emitter.emitType(iface); + } finally { + for (const [_, op] of nsOps) { + op.interface = undefined; + } + target.interfaces.delete(iface.name); } - emitter.emitType(iface); } for (const [_, sub] of target.namespaces) {