Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add multipart explicit part support in autorest #902

Merged
merged 24 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b364a0e
Add multipart part support
timotheeguerin May 22, 2024
cd51e44
Merge
timotheeguerin May 22, 2024
1d9e6fb
Fix
timotheeguerin May 22, 2024
05b574a
Create uptake-multipartv2-2024-4-22-16-3-12.md
timotheeguerin May 22, 2024
a33fac1
Create uptake-multipartv2-2024-4-22-16-3-12.2.md
timotheeguerin May 22, 2024
257ec9a
fix
timotheeguerin May 22, 2024
1df54a6
Update uptake-multipartv2-2024-4-22-16-3-12.2.md
timotheeguerin May 22, 2024
b4db659
Merge branch 'main' of https://github.com/Azure/typespec-azure into u…
timotheeguerin May 23, 2024
6324fb4
bump
timotheeguerin May 23, 2024
81a1033
Merge branch 'main' into uptake/multipartv2
timotheeguerin May 24, 2024
d20035e
.
timotheeguerin May 24, 2024
e8deb13
Merge branch 'main' of https://github.com/Azure/typespec-azure into u…
timotheeguerin Jun 3, 2024
fb1259f
merge
timotheeguerin Jun 3, 2024
43cee82
missing refs
timotheeguerin Jun 3, 2024
c92d90a
fix
timotheeguerin Jun 3, 2024
f6d05b7
Address CR comment
timotheeguerin Jun 3, 2024
4aa13ae
Merge branch 'main' into uptake/multipartv2
timotheeguerin Jun 3, 2024
cac0d76
bump core
timotheeguerin Jun 3, 2024
df150aa
fix
timotheeguerin Jun 3, 2024
ef4df00
Use main
timotheeguerin Jun 3, 2024
bee24cb
Merge branch 'main' into uptake/multipartv2
timotheeguerin Jun 3, 2024
5050157
fix
timotheeguerin Jun 3, 2024
79e26a0
Merge branch 'uptake/multipartv2' of https://github.com/timotheegueri…
timotheeguerin Jun 3, 2024
ce50051
use dotnet 8
timotheeguerin Jun 3, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading