diff --git a/.changeset/chilly-foxes-type.md b/.changeset/chilly-foxes-type.md new file mode 100644 index 0000000000..fabe435e55 --- /dev/null +++ b/.changeset/chilly-foxes-type.md @@ -0,0 +1,5 @@ +--- +"@azure-tools/typespec-client-generator-core": patch +--- + +add MultipartFile type diff --git a/packages/typespec-client-generator-core/src/interfaces.ts b/packages/typespec-client-generator-core/src/interfaces.ts index 3d3500da04..cfef5d2c2e 100644 --- a/packages/typespec-client-generator-core/src/interfaces.ts +++ b/packages/typespec-client-generator-core/src/interfaces.ts @@ -83,7 +83,8 @@ export type SdkType = | SdkEnumValueType | SdkConstantType | SdkUnionType - | SdkModelType; + | SdkModelType + | SdkMultipartFileType; export interface SdkBuiltInType extends SdkTypeBase { kind: SdkBuiltInKinds; @@ -110,7 +111,8 @@ export type SdkBuiltInKinds = | "armId" | "ipAddress" | "azureLocation" - | "etag"; + | "etag" + | "multipartFile"; const SdkDatetimeEncodingsConst = ["rfc3339", "rfc7231", "unixTimestamp"] as const; @@ -130,6 +132,11 @@ export interface SdkDurationType extends SdkTypeBase { wireType: SdkBuiltInType; } +export interface SdkMultipartFileType extends SdkTypeBase { + kind: "multipartFile"; + encode: "binary"; +} + export interface SdkArrayType extends SdkTypeBase { kind: "array"; valueType: SdkType; @@ -188,6 +195,7 @@ export interface SdkModelType extends SdkTypeBase { kind: "model"; properties: SdkModelPropertyType[]; name: string; + isFormDataType: boolean; generatedName?: string; description?: string; details?: string; diff --git a/packages/typespec-client-generator-core/src/lib.ts b/packages/typespec-client-generator-core/src/lib.ts index fb88c7d740..181c9c4d4a 100644 --- a/packages/typespec-client-generator-core/src/lib.ts +++ b/packages/typespec-client-generator-core/src/lib.ts @@ -67,6 +67,13 @@ export const $lib = createTypeSpecLibrary({ wrongType: paramMessage`Encoding '${"encoding"}' cannot be used on type '${"type"}'`, }, }, + "conflicting-multipart-model-usage": { + severity: "error", + messages: { + default: "Invalid encoding", + wrongType: paramMessage`Model '${"modelName"}' cannot be used as both multipart/form-data input and regular body input. You can create a separate model with name 'model ${"modelName"}FormData' extends ${"modelName"} {}`, + }, + }, "discriminator-not-constant": { severity: "error", messages: { diff --git a/packages/typespec-client-generator-core/src/types.ts b/packages/typespec-client-generator-core/src/types.ts index dc245eca8e..8329b9fbcd 100644 --- a/packages/typespec-client-generator-core/src/types.ts +++ b/packages/typespec-client-generator-core/src/types.ts @@ -66,6 +66,7 @@ import { SdkEnumValueType, SdkModelPropertyTypeBase, SdkModelType, + SdkMultipartFileType, SdkTupleType, SdkType, } from "./interfaces.js"; @@ -266,6 +267,13 @@ export function getSdkDurationType(context: SdkContext, type: Scalar): SdkDurati }; } +function getSdkMultipartFileType(context: SdkContext, type: Scalar): SdkMultipartFileType { + return { + ...getSdkTypeBaseHelper(context, type, "multipartFile"), + encode: "binary", + }; +} + export function getSdkArrayOrDict( context: SdkContext, type: Model, @@ -433,8 +441,24 @@ function addDiscriminatorToModelType( export function getSdkModel(context: SdkContext, type: Model, operation?: Operation): SdkModelType { type = getEffectivePayloadType(context, type); let sdkType = context.modelsMap?.get(type) as SdkModelType | undefined; + const httpOperation = operation + ? ignoreDiagnostics(getHttpOperation(context.program, operation)) + : undefined; + const isFormDataType = httpOperation + ? Boolean(httpOperation.parameters.body?.contentTypes.includes("multipart/form-data")) + : false; if (sdkType) { updateModelsMap(context, type, sdkType, operation); + if (isFormDataType !== sdkType.isFormDataType) { + // This means we have a model that is used both for formdata input and for regular body input + reportDiagnostic(context.program, { + code: "conflicting-multipart-model-usage", + target: type, + format: { + modelName: sdkType.name, + }, + }); + } } else { const docWrapper = getDocHelper(context, type); sdkType = { @@ -448,6 +472,7 @@ export function getSdkModel(context: SdkContext, type: Model, operation?: Operat access: undefined, // dummy value since we need to update models map before we can set this usage: UsageFlags.None, // dummy value since we need to update models map before we can set this crossLanguageDefinitionId: getCrossLanguageDefinitionId(type), + isFormDataType, }; updateModelsMap(context, type, sdkType, operation); @@ -650,6 +675,15 @@ export function getClientType(context: SdkContext, type: Type, operation?: Opera if (type.name === "duration") { return getSdkDurationType(context, type); } + const httpOperation = operation + ? ignoreDiagnostics(getHttpOperation(context.program, operation)) + : undefined; + const hasMultipartInput = + httpOperation && + httpOperation.parameters.body?.contentTypes.includes("multipart/form-data"); + if (type.name === "bytes" && hasMultipartInput) { + return getSdkMultipartFileType(context, type); + } const scalarType = getSdkBuiltInType(context, type); // just add default encode, normally encode is on extended scalar and model property addEncodeInfo(context, type, scalarType); diff --git a/packages/typespec-client-generator-core/test/types.test.ts b/packages/typespec-client-generator-core/test/types.test.ts index dbd330f320..e049185f51 100644 --- a/packages/typespec-client-generator-core/test/types.test.ts +++ b/packages/typespec-client-generator-core/test/types.test.ts @@ -1,5 +1,6 @@ import { AzureCoreTestLibrary } from "@azure-tools/typespec-azure-core/testing"; import { Enum, UsageFlags } from "@typespec/compiler"; +import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepEqual, deepStrictEqual, strictEqual } from "assert"; import { beforeEach, describe, it } from "vitest"; import { @@ -1983,6 +1984,83 @@ describe("typespec-client-generator-core: types", () => { strictEqual(models.length, 2); }); }); + describe("SdkMultipartFormType", () => { + it("multipart form basic", async function () { + await runner.compileWithBuiltInService(` + model MultiPartRequest { + id: string; + profileImage: bytes; + } + + op basic(@header contentType: "multipart/form-data", @body body: MultiPartRequest): NoContentResponse; + `); + + const models = Array.from(getAllModels(runner.context)); + strictEqual(models.length, 1); + const model = models[0] as SdkModelType; + strictEqual(model.kind, "model"); + strictEqual(model.isFormDataType, true); + strictEqual(model.name, "MultiPartRequest"); + strictEqual(model.properties.length, 2); + const id = model.properties.find((x) => x.nameInClient === "id")!; + strictEqual(id.kind, "property"); + strictEqual(id.type.kind, "string"); + const profileImage = model.properties.find((x) => x.nameInClient === "profileImage")!; + strictEqual(profileImage.kind, "property"); + strictEqual(profileImage.type.kind, "multipartFile"); + }); + it("multipart conflicting model usage", async function () { + const diagnostics = await runner.diagnose( + ` + @service({title: "Test Service"}) namespace TestService; + model MultiPartRequest { + id: string; + profileImage: bytes; + } + + @post op multipartUse(@header contentType: "multipart/form-data", @body body: MultiPartRequest): NoContentResponse; + @put op jsonUse(@body body: MultiPartRequest): NoContentResponse; + ` + ); + getAllModels(runner.context); + expectDiagnostics(diagnostics, { + code: "@azure-tools/typespec-client-generator-core/conflicting-multipart-model-usage", + }); + + // expectDiagnostics(getAllModels(runner.context), { + // code: "@azure-tools/typespec-client-generator-core/conflicting-multipart-model-usage", + // }); + }); + it("multipart resolving conflicting model usage with spread", async function () { + await runner.compileWithBuiltInService( + ` + model B { + doc: bytes + } + + model A { + ...B + } + + @put op multipartOperation(@header contentType: "multipart/form-data", ...A): void; + @post op normalOperation(...B): void; + ` + ); + const models = Array.from(getAllModels(runner.context)); + strictEqual(models.length, 2); + const modelA = models.find((x) => x.name === "A")!; + strictEqual(modelA.kind, "model"); + strictEqual(modelA.isFormDataType, true); + strictEqual(modelA.properties.length, 1); + strictEqual(modelA.properties[0].type.kind, "multipartFile"); + + const modelB = models.find((x) => x.name === "B")!; + strictEqual(modelB.kind, "model"); + strictEqual(modelB.isFormDataType, false); + strictEqual(modelB.properties.length, 1); + strictEqual(modelB.properties[0].type.kind, "bytes"); + }); + }); describe("SdkTupleType", () => { it("model with tupled properties", async function () { await runner.compileAndDiagnose(`