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 diff --git a/packages/http-server-csharp/src/service.ts b/packages/http-server-csharp/src/service.ts index 2eefa32289..5d411ae728 100644 --- a/packages/http-server-csharp/src/service.ts +++ b/packages/http-server-csharp/src/service.ts @@ -49,7 +49,12 @@ import { MetadataInfo, Visibility, createMetadataInfo, + getHeaderFieldName, getHttpOperation, + getHttpPart, + isHeader, + isPathParam, + isQueryParam, isStatusCode, } from "@typespec/http"; import { getResourceOperation } from "@typespec/rest"; @@ -87,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.program.compilerOptions.noEmit || false; class CSharpCodeEmitter extends CodeTypeEmitter { #metadateMap: Map = new Map(); @@ -231,6 +237,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); @@ -257,6 +268,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; @@ -265,6 +277,7 @@ export async function $onEmit(context: EmitContext) } modelInstantiationContext(model: Model): Context { + if (this.#isMultipartModel(model)) return {}; const modelName: string = getModelInstantiationName( this.emitter.getProgram(), model, @@ -279,6 +292,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) { @@ -290,6 +308,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) { @@ -372,6 +406,21 @@ 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"; + } + + #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] { @@ -537,6 +586,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; } @@ -565,10 +619,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( ${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)});`, + ); + } builder.push(code`${opDecl.value}\n`); this.emitter.emitInterfaceOperation(operation); } @@ -593,9 +655,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.#emitHttpOperationParameters(httpOperation); + const multipart: boolean = this.#isMultipartRequest(httpOperation); + const declParams = !multipart + ? this.#emitHttpOperationParameters(httpOperation) + : 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({ @@ -610,9 +686,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 +708,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(${this.#emitOperationCallParameters(httpOperation, "reader")}); + return ${resultString}(result);` + : `await ${this.emitter.getContext().resourceName}Impl.${operationName}Async(${this.#emitOperationCallParameters(httpOperation, "reader")}); + return ${resultString}();` + } + }`, + ); + } } operationDeclarationContext(operation: Operation, name: string): Context { @@ -763,17 +869,25 @@ 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 (parameter.optional) optionalParams.push(parameter); - else requiredParams.push(parameter); - totalParams++; + 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++; + } } let i = 1; for (const requiredParam of requiredParams) { @@ -782,38 +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 (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 ? ", " : "" + }`, ); } @@ -939,28 +1079,45 @@ export async function $onEmit(context: EmitContext) return [...bodyParam.type.properties.values()]; } - #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 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) && !isNullType(parameter.param.type) && - !isVoidType(parameter.param.type) + !isVoidType(parameter.param.type) && + !contentType ) { 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) { @@ -971,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( @@ -1313,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) { @@ -1352,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) { @@ -1376,16 +1586,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", + ]); + } } } 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..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, @@ -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,89 @@ it("handles implicit request body models correctly", async () => { ], ); }); + +it("handles multipartBody requests and shared routes", async () => { + await compileAndValidateMultiple( + runner, + ` + model Bar { + ...T; + } + model FooRequest { + contents: HttpPart; + other: 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", + [ + "using Microsoft.AspNetCore.WebUtilities;", + "using Microsoft.AspNetCore.Http.Extensions;", + `[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", + [ + "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, + ); +});