Skip to content

Commit 8cd5bc2

Browse files
authored
[core] New multipart/form-data primitive in core-client-rest (#29047)
### Packages impacted by this PR - `@azure/core-client-rest` ### Issues associated with this PR - Resolves #28971 ### Describe the problem that is addressed by this PR - Major bump of `@azure-rest/core-client` to 2.0.0 due to introduced behavioral breaking change. - The expected body shape for `multipart/form-data` is now an array of `PartDescriptor`, which has the following fields: - `headers` and `body` representing the multipart request headers and body - Convenience fields for MIME header values: - `contentType`, for the `Content-Type` MIME header - `name`, `filename`, `dispositionType`, for constructing the `Content-Disposition` MIME header - These convenience values take precedence over any existing MIME information (name and filename) present in the request body (i.e. the `name` property on `File` and the `type` property on both `File` and `Blob`) - If the headers are set explicitly in the `headers` bag, the headers bag value takes precedence above all. - Implemented part serialization flowchart more or less as described in the Loop, with a couple of notable differences (and other less notable ones): - `string` values are put directly on the wire regardless of content type; this allows for customers to pass pre-serialized JSON to the service - If no content type is specified, and we cannot infer the content type, we default to `application/json` (i.e. there is no situation where we would throw a "cannot encode type" error) - Added support for `FormData` objects. If a `FormData` object is encountered, it is passed directly to `core-rest-pipeline` for it to handle. ### Are there test cases added in this PR? _(If not, why?)_ Yes ### To do - [ ] Port Core change to ts-http-runtime before merging
1 parent 73b9faa commit 8cd5bc2

File tree

8 files changed

+734
-153
lines changed

8 files changed

+734
-153
lines changed

common/config/rush/pnpm-lock.yaml

Lines changed: 98 additions & 41 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sdk/core/core-client-rest/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
# Release History
22

3-
## 1.4.1 (Unreleased)
3+
## 2.0.0 (Unreleased)
44

55
### Features Added
66

77
### Breaking Changes
88

9+
- Changed the format accepted for `multipart/form-data` requests.
10+
911
### Bugs Fixed
1012

1113
### Other Changes

sdk/core/core-client-rest/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@azure-rest/core-client",
3-
"version": "1.4.1",
3+
"version": "2.0.0",
44
"description": "Core library for interfacing with Azure Rest Clients",
55
"sdk-type": "client",
66
"type": "module",
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { isReadableStream } from "./isReadableStream.js";
5+
6+
export function isBinaryBody(
7+
body: unknown,
8+
): body is
9+
| Uint8Array
10+
| NodeJS.ReadableStream
11+
| ReadableStream<Uint8Array>
12+
| (() => NodeJS.ReadableStream)
13+
| (() => ReadableStream<Uint8Array>)
14+
| Blob {
15+
return (
16+
body !== undefined &&
17+
(body instanceof Uint8Array ||
18+
isReadableStream(body) ||
19+
typeof body === "function" ||
20+
body instanceof Blob)
21+
);
22+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import {
5+
BodyPart,
6+
MultipartRequestBody,
7+
RawHttpHeadersInput,
8+
RestError,
9+
createHttpHeaders,
10+
} from "@azure/core-rest-pipeline";
11+
import { stringToUint8Array } from "@azure/core-util";
12+
import { isBinaryBody } from "./helpers/isBinaryBody.js";
13+
14+
/**
15+
* Describes a single part in a multipart body.
16+
*/
17+
export interface PartDescriptor {
18+
/**
19+
* Content type of this part. If set, this value will be used to set the Content-Type MIME header for this part, although explicitly
20+
* setting the Content-Type header in the headers bag will override this value. If set to `null`, no content type will be inferred from
21+
* the body field. Otherwise, the value of the Content-Type MIME header will be inferred based on the type of the body.
22+
*/
23+
contentType?: string | null;
24+
25+
/**
26+
* The disposition type of this part (for example, "form-data" for parts making up a multipart/form-data request). If set, this value
27+
* will be used to set the Content-Disposition MIME header for this part, in addition to the `name` and `filename` properties.
28+
* If the `name` or `filename` properties are set while `dispositionType` is left undefined, `dispositionType` will default to "form-data".
29+
*
30+
* Explicitly setting the Content-Disposition header in the headers bag will override this value.
31+
*/
32+
dispositionType?: string;
33+
34+
/**
35+
* The field name associated with this part. This value will be used to construct the Content-Disposition header,
36+
* along with the `dispositionType` and `filename` properties, if the header has not been set in the `headers` bag.
37+
*/
38+
name?: string;
39+
40+
/**
41+
* The file name of the content if it is a file. This value will be used to construct the Content-Disposition header,
42+
* along with the `dispositionType` and `name` properties, if the header has not been set in the `headers` bag.
43+
*/
44+
filename?: string;
45+
46+
/**
47+
* The multipart headers for this part of the multipart body. Values of the Content-Type and Content-Disposition headers set in the headers bag
48+
* will take precedence over those computed from the request body or the contentType, dispositionType, name, and filename fields on this object.
49+
*/
50+
headers?: RawHttpHeadersInput;
51+
52+
/**
53+
* The body of this part of the multipart request.
54+
*/
55+
body?: unknown;
56+
}
57+
58+
type MultipartBodyType = BodyPart["body"];
59+
60+
type HeaderValue = RawHttpHeadersInput[string];
61+
62+
/**
63+
* Get value of a header in the part descriptor ignoring case
64+
*/
65+
function getHeaderValue(descriptor: PartDescriptor, headerName: string): HeaderValue | undefined {
66+
if (descriptor.headers) {
67+
const actualHeaderName = Object.keys(descriptor.headers).find(
68+
(x) => x.toLowerCase() === headerName.toLowerCase(),
69+
);
70+
if (actualHeaderName) {
71+
return descriptor.headers[actualHeaderName];
72+
}
73+
}
74+
75+
return undefined;
76+
}
77+
78+
function getPartContentType(descriptor: PartDescriptor): HeaderValue | undefined {
79+
const contentTypeHeader = getHeaderValue(descriptor, "content-type");
80+
if (contentTypeHeader) {
81+
return contentTypeHeader;
82+
}
83+
84+
// Special value of null means content type is to be omitted
85+
if (descriptor.contentType === null) {
86+
return undefined;
87+
}
88+
89+
if (descriptor.contentType) {
90+
return descriptor.contentType;
91+
}
92+
93+
const { body } = descriptor;
94+
95+
if (body === null || body === undefined) {
96+
return undefined;
97+
}
98+
99+
if (typeof body === "string" || typeof body === "number" || typeof body === "boolean") {
100+
return "text/plain; charset=UTF-8";
101+
}
102+
103+
if (body instanceof Blob) {
104+
return body.type || "application/octet-stream";
105+
}
106+
107+
if (isBinaryBody(body)) {
108+
return "application/octet-stream";
109+
}
110+
111+
// arbitrary non-text object -> generic JSON content type by default. We will try to JSON.stringify the body.
112+
return "application/json; charset=UTF-8";
113+
}
114+
115+
/**
116+
* Enclose value in quotes and escape special characters, for use in the Content-Disposition header
117+
*/
118+
function escapeDispositionField(value: string): string {
119+
return JSON.stringify(value);
120+
}
121+
122+
function getContentDisposition(descriptor: PartDescriptor): HeaderValue | undefined {
123+
const contentDispositionHeader = getHeaderValue(descriptor, "content-disposition");
124+
if (contentDispositionHeader) {
125+
return contentDispositionHeader;
126+
}
127+
128+
if (
129+
descriptor.dispositionType === undefined &&
130+
descriptor.name === undefined &&
131+
descriptor.filename === undefined
132+
) {
133+
return undefined;
134+
}
135+
136+
const dispositionType = descriptor.dispositionType ?? "form-data";
137+
138+
let disposition = dispositionType;
139+
if (descriptor.name) {
140+
disposition += `; name=${escapeDispositionField(descriptor.name)}`;
141+
}
142+
143+
let filename: string | undefined = undefined;
144+
if (descriptor.filename) {
145+
filename = descriptor.filename;
146+
} else if (typeof File !== "undefined" && descriptor.body instanceof File) {
147+
const filenameFromFile = (descriptor.body as File).name;
148+
if (filenameFromFile !== "") {
149+
filename = filenameFromFile;
150+
}
151+
}
152+
153+
if (filename) {
154+
disposition += `; filename=${escapeDispositionField(filename)}`;
155+
}
156+
157+
return disposition;
158+
}
159+
160+
function normalizeBody(body?: unknown, contentType?: HeaderValue): MultipartBodyType {
161+
if (body === undefined) {
162+
// zero-length body
163+
return new Uint8Array([]);
164+
}
165+
166+
// binary and primitives should go straight on the wire regardless of content type
167+
if (isBinaryBody(body)) {
168+
return body;
169+
}
170+
if (typeof body === "string" || typeof body === "number" || typeof body === "boolean") {
171+
return stringToUint8Array(String(body), "utf-8");
172+
}
173+
174+
// stringify objects for JSON-ish content types e.g. application/json, application/merge-patch+json, application/vnd.oci.manifest.v1+json, application.json; charset=UTF-8
175+
if (contentType && /application\/(.+\+)?json(;.+)?/i.test(String(contentType))) {
176+
return stringToUint8Array(JSON.stringify(body), "utf-8");
177+
}
178+
179+
throw new RestError(`Unsupported body/content-type combination: ${body}, ${contentType}`);
180+
}
181+
182+
export function buildBodyPart(descriptor: PartDescriptor): BodyPart {
183+
const contentType = getPartContentType(descriptor);
184+
const contentDisposition = getContentDisposition(descriptor);
185+
const headers = createHttpHeaders(descriptor.headers ?? {});
186+
187+
if (contentType) {
188+
headers.set("content-type", contentType);
189+
}
190+
if (contentDisposition) {
191+
headers.set("content-disposition", contentDisposition);
192+
}
193+
194+
const body = normalizeBody(descriptor.body, contentType);
195+
196+
return {
197+
headers,
198+
body,
199+
};
200+
}
201+
202+
export function buildMultipartBody(parts: PartDescriptor[]): MultipartRequestBody {
203+
return { parts: parts.map(buildBodyPart) };
204+
}

sdk/core/core-client-rest/src/sendRequest.ts

Lines changed: 14 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,21 @@
22
// Licensed under the MIT license.
33

44
import {
5-
FormDataMap,
6-
FormDataValue,
75
HttpClient,
86
HttpMethods,
7+
MultipartRequestBody,
98
Pipeline,
109
PipelineRequest,
1110
PipelineResponse,
1211
RequestBodyType,
1312
RestError,
14-
createFile,
1513
createHttpHeaders,
1614
createPipelineRequest,
1715
} from "@azure/core-rest-pipeline";
1816
import { getCachedDefaultHttpsClient } from "./clientHelpers.js";
1917
import { isReadableStream } from "./helpers/isReadableStream.js";
2018
import { HttpResponse, RequestParameters } from "./common.js";
19+
import { PartDescriptor, buildMultipartBody } from "./multipart.js";
2120

2221
/**
2322
* Helper function to send request used by the client
@@ -103,8 +102,8 @@ function buildPipelineRequest(
103102
options: InternalRequestParameters = {},
104103
): PipelineRequest {
105104
const requestContentType = getRequestContentType(options);
106-
const { body, formData } = getRequestBody(options.body, requestContentType);
107-
const hasContent = body !== undefined || formData !== undefined;
105+
const { body, multipartBody } = getRequestBody(options.body, requestContentType);
106+
const hasContent = body !== undefined || multipartBody !== undefined;
108107

109108
const headers = createHttpHeaders({
110109
...(options.headers ? options.headers : {}),
@@ -119,7 +118,7 @@ function buildPipelineRequest(
119118
url,
120119
method,
121120
body,
122-
formData,
121+
multipartBody,
123122
headers,
124123
allowInsecureConnection: options.allowInsecureConnection,
125124
tracingOptions: options.tracingOptions,
@@ -136,7 +135,7 @@ function buildPipelineRequest(
136135

137136
interface RequestBody {
138137
body?: RequestBodyType;
139-
formData?: FormDataMap;
138+
multipartBody?: MultipartRequestBody;
140139
}
141140

142141
/**
@@ -147,6 +146,10 @@ function getRequestBody(body?: unknown, contentType: string = ""): RequestBody {
147146
return { body: undefined };
148147
}
149148

149+
if (typeof FormData !== "undefined" && body instanceof FormData) {
150+
return { body };
151+
}
152+
150153
if (isReadableStream(body)) {
151154
return { body };
152155
}
@@ -163,9 +166,10 @@ function getRequestBody(body?: unknown, contentType: string = ""): RequestBody {
163166

164167
switch (firstType) {
165168
case "multipart/form-data":
166-
return isRLCFormDataInput(body)
167-
? { formData: processFormData(body) }
168-
: { body: JSON.stringify(body) };
169+
if (Array.isArray(body)) {
170+
return { multipartBody: buildMultipartBody(body as PartDescriptor[]) };
171+
}
172+
return { body: JSON.stringify(body) };
169173
case "text/plain":
170174
return { body: String(body) };
171175
default:
@@ -176,59 +180,6 @@ function getRequestBody(body?: unknown, contentType: string = ""): RequestBody {
176180
}
177181
}
178182

179-
/**
180-
* Union of possible input types for multipart/form-data values that are accepted by RLCs.
181-
* This extends the default FormDataValue type to include Uint8Array, which is accepted as an input by RLCs.
182-
*/
183-
type RLCFormDataValue = FormDataValue | Uint8Array;
184-
185-
/**
186-
* Input shape for a form data body type as generated by an RLC
187-
*/
188-
type RLCFormDataInput = Record<string, RLCFormDataValue | RLCFormDataValue[]>;
189-
190-
function isRLCFormDataValue(value: unknown): value is RLCFormDataValue {
191-
return (
192-
typeof value === "string" ||
193-
value instanceof Uint8Array ||
194-
// We don't do `instanceof Blob` since we should also accept polyfills of e.g. File in Node.
195-
typeof (value as Blob).stream === "function"
196-
);
197-
}
198-
199-
function isRLCFormDataInput(body: unknown): body is RLCFormDataInput {
200-
return (
201-
body !== undefined &&
202-
body instanceof Object &&
203-
Object.values(body).every(
204-
(value) =>
205-
isRLCFormDataValue(value) || (Array.isArray(value) && value.every(isRLCFormDataValue)),
206-
)
207-
);
208-
}
209-
210-
function processFormDataValue(value: RLCFormDataValue): FormDataValue {
211-
return value instanceof Uint8Array ? createFile(value, "blob") : value;
212-
}
213-
214-
/**
215-
* Checks if binary data is in Uint8Array format, if so wrap it in a Blob
216-
* to send over the wire
217-
*/
218-
function processFormData(formData: RLCFormDataInput): FormDataMap {
219-
const processedFormData: FormDataMap = {};
220-
221-
for (const element in formData) {
222-
const value = formData[element];
223-
224-
processedFormData[element] = Array.isArray(value)
225-
? value.map(processFormDataValue)
226-
: processFormDataValue(value);
227-
}
228-
229-
return processedFormData;
230-
}
231-
232183
/**
233184
* Prepares the response body
234185
*/

0 commit comments

Comments
 (0)