Skip to content

Commit

Permalink
Add multipart explicit part support in autorest (#902)
Browse files Browse the repository at this point in the history
Add support for microsoft/typespec#3342 in
autorest emitter
  • Loading branch information
timotheeguerin committed Jun 3, 2024
1 parent 9b7a6a2 commit 8713698
Show file tree
Hide file tree
Showing 16 changed files with 284 additions and 54 deletions.
8 changes: 8 additions & 0 deletions .chronus/changes/uptake-multipartv2-2024-4-22-16-3-12.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: internal
packages:
- "@azure-tools/typespec-azure-resource-manager"
- "@azure-tools/typespec-client-generator-core"
- "@azure-tools/typespec-azure-core"
---
8 changes: 8 additions & 0 deletions .chronus/changes/uptake-multipartv2-2024-4-22-16-3-12.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: fix
packages:
- "@azure-tools/typespec-autorest"
---

Add support for new multipart constructs in http library
2 changes: 1 addition & 1 deletion core
Submodule core updated 112 files
2 changes: 1 addition & 1 deletion eng/pipelines/templates/install.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ parameters:
steps:
- task: UseDotNet@2
inputs:
version: 6.0.x
version: 8.0.x

- task: NodeTool@0
inputs:
Expand Down
136 changes: 96 additions & 40 deletions packages/typespec-autorest/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,10 @@ import {
Authentication,
HttpAuth,
HttpOperation,
HttpOperationBody,
HttpOperationMultipartBody,
HttpOperationParameters,
HttpOperationResponse,
HttpOperationResponseBody,
HttpStatusCodeRange,
HttpStatusCodesEntry,
MetadataInfo,
Expand Down Expand Up @@ -128,6 +129,7 @@ import { sortWithJsonSchema } from "./json-schema-sorter/sorter.js";
import { createDiagnostic, reportDiagnostic } from "./lib.js";
import {
OpenAPI2Document,
OpenAPI2FileSchema,
OpenAPI2FormDataParameter,
OpenAPI2HeaderDefinition,
OpenAPI2OAuth2FlowType,
Expand Down Expand Up @@ -717,7 +719,7 @@ export async function getOpenAPIForService(
openapiResponse["x-ms-error-response"] = true;
}
const contentTypes: string[] = [];
let body: HttpOperationResponseBody | undefined;
let body: HttpOperationBody | HttpOperationMultipartBody | undefined;
for (const data of response.responses) {
if (data.headers && Object.keys(data.headers).length > 0) {
openapiResponse.headers ??= {};
Expand All @@ -739,13 +741,7 @@ export async function getOpenAPIForService(
}

if (body) {
const isBinary = contentTypes.every((t) => isBinaryPayload(body!.type, t));
openapiResponse.schema = isBinary
? { type: "file" }
: getSchemaOrRef(body.type, {
visibility: Visibility.Read,
ignoreMetadataAnnotations: body.isExplicit && body.containsMetadataAnnotations,
});
openapiResponse.schema = getSchemaForResponseBody(body, contentTypes);
}

for (const contentType of contentTypes) {
Expand All @@ -755,6 +751,24 @@ export async function getOpenAPIForService(
currentEndpoint.responses![statusCode] = openapiResponse;
}

function getSchemaForResponseBody(
body: HttpOperationBody | HttpOperationMultipartBody,
contentTypes: string[]
): OpenAPI2Schema | OpenAPI2FileSchema {
const isBinary = contentTypes.every((t) => isBinaryPayload(body!.type, t));
if (isBinary) {
return { type: "file" };
}
if (body.bodyKind === "multipart") {
// OpenAPI2 doesn't support multipart responses, so we just return a string schema
return { type: "string" };
}
return getSchemaOrRef(body.type, {
visibility: Visibility.Read,
ignoreMetadataAnnotations: body.isExplicit && body.containsMetadataAnnotations,
});
}

function getResponseHeader(prop: ModelProperty): OpenAPI2HeaderDefinition {
const header: any = {};
populateParameter(header, prop, "header", {
Expand Down Expand Up @@ -954,39 +968,81 @@ export async function getOpenAPIForService(
}

if (methodParams.body && !isVoidType(methodParams.body.type)) {
const isBinary = isBinaryPayload(methodParams.body.type, consumes);
const schemaContext = {
visibility,
ignoreMetadataAnnotations:
methodParams.body.isExplicit && methodParams.body.containsMetadataAnnotations,
};
const schema = isBinary
? { type: "string", format: "binary" }
: getSchemaOrRef(methodParams.body.type, schemaContext);

if (currentConsumes.has("multipart/form-data")) {
const bodyModelType = methodParams.body.type;
// Assert, this should never happen. Rest library guard against that.
compilerAssert(bodyModelType.kind === "Model", "Body should always be a Model.");
if (bodyModelType) {
for (const param of bodyModelType.properties.values()) {
emitParameter(param, "formData", schemaContext, getJsonName(param));
}
emitBodyParameters(methodParams.body, visibility);
}
}

function emitBodyParameters(
body: HttpOperationBody | HttpOperationMultipartBody,
visibility: Visibility
) {
switch (body.bodyKind) {
case "single":
emitSingleBodyParameters(body, visibility);
break;
case "multipart":
emitMultipartBodyParameters(body, visibility);
break;
}
}

function emitSingleBodyParameters(body: HttpOperationBody, visibility: Visibility) {
const isBinary = isBinaryPayload(body.type, body.contentTypes);
const schemaContext = {
visibility,
ignoreMetadataAnnotations: body.isExplicit && body.containsMetadataAnnotations,
};
const schema = isBinary
? { type: "string", format: "binary" }
: getSchemaOrRef(body.type, schemaContext);

if (currentConsumes.has("multipart/form-data")) {
const bodyModelType = body.type;
// Assert, this should never happen. Rest library guard against that.
compilerAssert(bodyModelType.kind === "Model", "Body should always be a Model.");
if (bodyModelType) {
for (const param of bodyModelType.properties.values()) {
emitParameter(param, "formData", schemaContext, getJsonName(param));
}
}
} else if (body.property) {
emitParameter(
body.property,
"body",
{ visibility, ignoreMetadataAnnotations: false },
getJsonName(body.property),
schema
);
} else {
currentEndpoint.parameters.push({
name: "body",
in: "body",
schema,
required: true,
});
}
}

function emitMultipartBodyParameters(body: HttpOperationMultipartBody, visibility: Visibility) {
for (const [index, part] of body.parts.entries()) {
const partName = part.name ?? `part${index}`;
let schema = getFormDataSchema(
part.body.type,
{ visibility, ignoreMetadataAnnotations: false },
partName
);
if (schema) {
if (part.multi) {
schema = {
type: "array",
items: schema.type === "file" ? { type: "string", format: "binary" } : schema,
};
}
} else if (methodParams.body.parameter) {
emitParameter(
methodParams.body.parameter,
"body",
{ visibility, ignoreMetadataAnnotations: false },
getJsonName(methodParams.body.parameter),
schema
);
} else {
currentEndpoint.parameters.push({
name: "body",
in: "body",
schema,
required: true,
name: partName,
in: "formData",
required: !part.optional,
...schema,
});
}
}
Expand Down
14 changes: 13 additions & 1 deletion packages/typespec-autorest/src/openapi2-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,18 @@ export type OpenAPI2Schema = Extensions & {
"x-ms-mutability"?: string[];
};

export type OpenAPI2FileSchema = {
type: "file";
format?: string;
title?: string;
description?: string;
default?: unknown;
required?: string[];
readonly?: boolean;
externalDocs?: OpenAPI2ExternalDocs;
example?: unknown;
};

export type OpenAPI2ParameterType = OpenAPI2Parameter["in"];

export interface OpenAPI2HeaderDefinition {
Expand Down Expand Up @@ -465,7 +477,7 @@ export interface OpenAPI2Response {
/** A short description of the response. Commonmark syntax can be used for rich text representation */
description: string;
/** A definition of the response structure. It can be a primitive, an array or an object. If this field does not exist, it means no content is returned as part of the response. As an extension to the Schema Object, its root type value may also be "file". This SHOULD be accompanied by a relevant produces mime-type. */
schema?: OpenAPI2Schema;
schema?: OpenAPI2Schema | OpenAPI2FileSchema;
/** A list of headers that are sent with the response. */
headers?: Record<string, OpenAPI2HeaderDefinition>;
/** An example of the response message. */
Expand Down
100 changes: 99 additions & 1 deletion packages/typespec-autorest/test/multipart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,105 @@ import { deepStrictEqual } from "assert";
import { describe, it } from "vitest";
import { openApiFor } from "./test-host.js";

describe("typespec-autorest: multipart", () => {
it("model properties are spread into individual parameters", async () => {
const res = await openApiFor(
`
model Form { name: HttpPart<string>, profileImage: HttpPart<bytes> }
op upload(@header contentType: "multipart/form-data", @multipartBody body: Form): void;
`
);
const op = res.paths["/"].post;
deepStrictEqual(op.parameters, [
{
in: "formData",
name: "name",
required: true,
type: "string",
},
{
in: "formData",
name: "profileImage",
required: true,
type: "file",
},
]);
});

it("part of type `bytes` produce `type: file`", async () => {
const res = await openApiFor(
`
op upload(@header contentType: "multipart/form-data", @multipartBody body: { profileImage: HttpPart<bytes> }): void;
`
);
const op = res.paths["/"].post;
deepStrictEqual(op.parameters, [
{
in: "formData",
name: "profileImage",
required: true,
type: "file",
},
]);
});

it("part of type `bytes[]` produce `type: array, items: { type: string, format: binary }`", async () => {
const res = await openApiFor(
`
op upload(@header contentType: "multipart/form-data", @multipartBody _: { profileImage: HttpPart<bytes>[]}): void;
`
);
const op = res.paths["/"].post;
deepStrictEqual(op.parameters, [
{
in: "formData",
name: "profileImage",
required: true,
type: "array",
items: {
type: "string",
format: "binary",
},
},
]);
});

it("part of type `string` produce `type: string`", async () => {
const res = await openApiFor(
`
op upload(@header contentType: "multipart/form-data", @multipartBody body: { name: HttpPart<string> }): void;
`
);
const op = res.paths["/"].post;
deepStrictEqual(op.parameters, [
{
in: "formData",
name: "name",
required: true,
type: "string",
},
]);
});

// https://github.com/Azure/typespec-azure/issues/3860
it("part of type `object` produce `type: string`", async () => {
const res = await openApiFor(
`
#suppress "@azure-tools/typespec-autorest/unsupported-multipart-type" "For test"
op upload(@header contentType: "multipart/form-data", @multipartBody _: { address: HttpPart<{city: string, street: string}>}): void;
`
);
const op = res.paths["/"].post;
deepStrictEqual(op.parameters, [
{
in: "formData",
name: "address",
required: true,
type: "string",
},
]);
});

describe("legacy implicit form", () => {
it("part of type `bytes` produce `type: file`", async () => {
const res = await openApiFor(
`
Expand Down
4 changes: 2 additions & 2 deletions packages/typespec-azure-core/src/lro-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,8 @@ export function getLroOperationInfo(
}
if (targetParameters.body) {
const body = targetParameters.body;
if (body.parameter) {
targetProperties.set(body.parameter.name, body.parameter);
if (body.bodyKind === "single" && body.property) {
targetProperties.set(body.property.name, body.property);
} else if (body.type.kind === "Model") {
for (const [name, param] of getAllProperties(body.type)) {
targetProperties.set(name, param);
Expand Down
2 changes: 1 addition & 1 deletion packages/typespec-azure-core/test/test-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export async function getSimplifiedOperations(
params: {
params: r.parameters.parameters.map(({ type, name }) => ({ type, name })),
body:
r.parameters.body?.parameter?.name ??
r.parameters.body?.property?.name ??
(r.parameters.body?.type?.kind === "Model"
? Array.from(r.parameters.body.type.properties.keys())
: undefined),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ interface WidgetParts {
reorderParts is Operations.LongRunningResourceCollectionAction<
WidgetPart,
WidgetPartReorderRequest,
TypeSpec.Http.AcceptedResponse
never
>;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Program, createRule } from "@typespec/compiler";

import { getLroMetadata } from "@azure-tools/typespec-azure-core";
import { HttpOperationBody, HttpOperationResponse } from "@typespec/http";
import {
HttpOperationBody,
HttpOperationMultipartBody,
HttpOperationResponse,
} from "@typespec/http";
import { ArmResourceOperation } from "../operations.js";
import { getArmResources } from "../resource.js";

Expand All @@ -20,7 +24,7 @@ export const armPostResponseCodesRule = createRule({
create(context) {
function getResponseBody(
response: HttpOperationResponse | undefined
): HttpOperationBody | undefined {
): HttpOperationBody | HttpOperationMultipartBody | undefined {
if (response === undefined) return undefined;
if (response.responses.length > 1) {
throw new Error("Multiple responses are not supported.");
Expand Down
Loading

0 comments on commit 8713698

Please sign in to comment.