Skip to content

Commit

Permalink
[core] New multipart/form-data primitive in core-client-rest (#29047)
Browse files Browse the repository at this point in the history
### 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
  • Loading branch information
timovv committed Apr 25, 2024
1 parent 73b9faa commit 8cd5bc2
Show file tree
Hide file tree
Showing 8 changed files with 734 additions and 153 deletions.
139 changes: 98 additions & 41 deletions common/config/rush/pnpm-lock.yaml

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion sdk/core/core-client-rest/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Release History

## 1.4.1 (Unreleased)
## 2.0.0 (Unreleased)

### Features Added

### Breaking Changes

- Changed the format accepted for `multipart/form-data` requests.

### Bugs Fixed

### Other Changes
Expand Down
2 changes: 1 addition & 1 deletion sdk/core/core-client-rest/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@azure-rest/core-client",
"version": "1.4.1",
"version": "2.0.0",
"description": "Core library for interfacing with Azure Rest Clients",
"sdk-type": "client",
"type": "module",
Expand Down
22 changes: 22 additions & 0 deletions sdk/core/core-client-rest/src/helpers/isBinaryBody.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { isReadableStream } from "./isReadableStream.js";

export function isBinaryBody(
body: unknown,
): body is
| Uint8Array
| NodeJS.ReadableStream
| ReadableStream<Uint8Array>
| (() => NodeJS.ReadableStream)
| (() => ReadableStream<Uint8Array>)
| Blob {
return (
body !== undefined &&
(body instanceof Uint8Array ||
isReadableStream(body) ||
typeof body === "function" ||
body instanceof Blob)
);
}
204 changes: 204 additions & 0 deletions sdk/core/core-client-rest/src/multipart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import {
BodyPart,
MultipartRequestBody,
RawHttpHeadersInput,
RestError,
createHttpHeaders,
} from "@azure/core-rest-pipeline";
import { stringToUint8Array } from "@azure/core-util";
import { isBinaryBody } from "./helpers/isBinaryBody.js";

/**
* Describes a single part in a multipart body.
*/
export interface PartDescriptor {
/**
* Content type of this part. If set, this value will be used to set the Content-Type MIME header for this part, although explicitly
* setting the Content-Type header in the headers bag will override this value. If set to `null`, no content type will be inferred from
* the body field. Otherwise, the value of the Content-Type MIME header will be inferred based on the type of the body.
*/
contentType?: string | null;

/**
* The disposition type of this part (for example, "form-data" for parts making up a multipart/form-data request). If set, this value
* will be used to set the Content-Disposition MIME header for this part, in addition to the `name` and `filename` properties.
* If the `name` or `filename` properties are set while `dispositionType` is left undefined, `dispositionType` will default to "form-data".
*
* Explicitly setting the Content-Disposition header in the headers bag will override this value.
*/
dispositionType?: string;

/**
* The field name associated with this part. This value will be used to construct the Content-Disposition header,
* along with the `dispositionType` and `filename` properties, if the header has not been set in the `headers` bag.
*/
name?: string;

/**
* The file name of the content if it is a file. This value will be used to construct the Content-Disposition header,
* along with the `dispositionType` and `name` properties, if the header has not been set in the `headers` bag.
*/
filename?: string;

/**
* The multipart headers for this part of the multipart body. Values of the Content-Type and Content-Disposition headers set in the headers bag
* will take precedence over those computed from the request body or the contentType, dispositionType, name, and filename fields on this object.
*/
headers?: RawHttpHeadersInput;

/**
* The body of this part of the multipart request.
*/
body?: unknown;
}

type MultipartBodyType = BodyPart["body"];

type HeaderValue = RawHttpHeadersInput[string];

/**
* Get value of a header in the part descriptor ignoring case
*/
function getHeaderValue(descriptor: PartDescriptor, headerName: string): HeaderValue | undefined {
if (descriptor.headers) {
const actualHeaderName = Object.keys(descriptor.headers).find(
(x) => x.toLowerCase() === headerName.toLowerCase(),
);
if (actualHeaderName) {
return descriptor.headers[actualHeaderName];
}
}

return undefined;
}

function getPartContentType(descriptor: PartDescriptor): HeaderValue | undefined {
const contentTypeHeader = getHeaderValue(descriptor, "content-type");
if (contentTypeHeader) {
return contentTypeHeader;
}

// Special value of null means content type is to be omitted
if (descriptor.contentType === null) {
return undefined;
}

if (descriptor.contentType) {
return descriptor.contentType;
}

const { body } = descriptor;

if (body === null || body === undefined) {
return undefined;
}

if (typeof body === "string" || typeof body === "number" || typeof body === "boolean") {
return "text/plain; charset=UTF-8";
}

if (body instanceof Blob) {
return body.type || "application/octet-stream";
}

if (isBinaryBody(body)) {
return "application/octet-stream";
}

// arbitrary non-text object -> generic JSON content type by default. We will try to JSON.stringify the body.
return "application/json; charset=UTF-8";
}

/**
* Enclose value in quotes and escape special characters, for use in the Content-Disposition header
*/
function escapeDispositionField(value: string): string {
return JSON.stringify(value);
}

function getContentDisposition(descriptor: PartDescriptor): HeaderValue | undefined {
const contentDispositionHeader = getHeaderValue(descriptor, "content-disposition");
if (contentDispositionHeader) {
return contentDispositionHeader;
}

if (
descriptor.dispositionType === undefined &&
descriptor.name === undefined &&
descriptor.filename === undefined
) {
return undefined;
}

const dispositionType = descriptor.dispositionType ?? "form-data";

let disposition = dispositionType;
if (descriptor.name) {
disposition += `; name=${escapeDispositionField(descriptor.name)}`;
}

let filename: string | undefined = undefined;
if (descriptor.filename) {
filename = descriptor.filename;
} else if (typeof File !== "undefined" && descriptor.body instanceof File) {
const filenameFromFile = (descriptor.body as File).name;
if (filenameFromFile !== "") {
filename = filenameFromFile;
}
}

if (filename) {
disposition += `; filename=${escapeDispositionField(filename)}`;
}

return disposition;
}

function normalizeBody(body?: unknown, contentType?: HeaderValue): MultipartBodyType {
if (body === undefined) {
// zero-length body
return new Uint8Array([]);
}

// binary and primitives should go straight on the wire regardless of content type
if (isBinaryBody(body)) {
return body;
}
if (typeof body === "string" || typeof body === "number" || typeof body === "boolean") {
return stringToUint8Array(String(body), "utf-8");
}

// 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
if (contentType && /application\/(.+\+)?json(;.+)?/i.test(String(contentType))) {
return stringToUint8Array(JSON.stringify(body), "utf-8");
}

throw new RestError(`Unsupported body/content-type combination: ${body}, ${contentType}`);
}

export function buildBodyPart(descriptor: PartDescriptor): BodyPart {
const contentType = getPartContentType(descriptor);
const contentDisposition = getContentDisposition(descriptor);
const headers = createHttpHeaders(descriptor.headers ?? {});

if (contentType) {
headers.set("content-type", contentType);
}
if (contentDisposition) {
headers.set("content-disposition", contentDisposition);
}

const body = normalizeBody(descriptor.body, contentType);

return {
headers,
body,
};
}

export function buildMultipartBody(parts: PartDescriptor[]): MultipartRequestBody {
return { parts: parts.map(buildBodyPart) };
}
77 changes: 14 additions & 63 deletions sdk/core/core-client-rest/src/sendRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,21 @@
// Licensed under the MIT license.

import {
FormDataMap,
FormDataValue,
HttpClient,
HttpMethods,
MultipartRequestBody,
Pipeline,
PipelineRequest,
PipelineResponse,
RequestBodyType,
RestError,
createFile,
createHttpHeaders,
createPipelineRequest,
} from "@azure/core-rest-pipeline";
import { getCachedDefaultHttpsClient } from "./clientHelpers.js";
import { isReadableStream } from "./helpers/isReadableStream.js";
import { HttpResponse, RequestParameters } from "./common.js";
import { PartDescriptor, buildMultipartBody } from "./multipart.js";

/**
* Helper function to send request used by the client
Expand Down Expand Up @@ -103,8 +102,8 @@ function buildPipelineRequest(
options: InternalRequestParameters = {},
): PipelineRequest {
const requestContentType = getRequestContentType(options);
const { body, formData } = getRequestBody(options.body, requestContentType);
const hasContent = body !== undefined || formData !== undefined;
const { body, multipartBody } = getRequestBody(options.body, requestContentType);
const hasContent = body !== undefined || multipartBody !== undefined;

const headers = createHttpHeaders({
...(options.headers ? options.headers : {}),
Expand All @@ -119,7 +118,7 @@ function buildPipelineRequest(
url,
method,
body,
formData,
multipartBody,
headers,
allowInsecureConnection: options.allowInsecureConnection,
tracingOptions: options.tracingOptions,
Expand All @@ -136,7 +135,7 @@ function buildPipelineRequest(

interface RequestBody {
body?: RequestBodyType;
formData?: FormDataMap;
multipartBody?: MultipartRequestBody;
}

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

if (typeof FormData !== "undefined" && body instanceof FormData) {
return { body };
}

if (isReadableStream(body)) {
return { body };
}
Expand All @@ -163,9 +166,10 @@ function getRequestBody(body?: unknown, contentType: string = ""): RequestBody {

switch (firstType) {
case "multipart/form-data":
return isRLCFormDataInput(body)
? { formData: processFormData(body) }
: { body: JSON.stringify(body) };
if (Array.isArray(body)) {
return { multipartBody: buildMultipartBody(body as PartDescriptor[]) };
}
return { body: JSON.stringify(body) };
case "text/plain":
return { body: String(body) };
default:
Expand All @@ -176,59 +180,6 @@ function getRequestBody(body?: unknown, contentType: string = ""): RequestBody {
}
}

/**
* Union of possible input types for multipart/form-data values that are accepted by RLCs.
* This extends the default FormDataValue type to include Uint8Array, which is accepted as an input by RLCs.
*/
type RLCFormDataValue = FormDataValue | Uint8Array;

/**
* Input shape for a form data body type as generated by an RLC
*/
type RLCFormDataInput = Record<string, RLCFormDataValue | RLCFormDataValue[]>;

function isRLCFormDataValue(value: unknown): value is RLCFormDataValue {
return (
typeof value === "string" ||
value instanceof Uint8Array ||
// We don't do `instanceof Blob` since we should also accept polyfills of e.g. File in Node.
typeof (value as Blob).stream === "function"
);
}

function isRLCFormDataInput(body: unknown): body is RLCFormDataInput {
return (
body !== undefined &&
body instanceof Object &&
Object.values(body).every(
(value) =>
isRLCFormDataValue(value) || (Array.isArray(value) && value.every(isRLCFormDataValue)),
)
);
}

function processFormDataValue(value: RLCFormDataValue): FormDataValue {
return value instanceof Uint8Array ? createFile(value, "blob") : value;
}

/**
* Checks if binary data is in Uint8Array format, if so wrap it in a Blob
* to send over the wire
*/
function processFormData(formData: RLCFormDataInput): FormDataMap {
const processedFormData: FormDataMap = {};

for (const element in formData) {
const value = formData[element];

processedFormData[element] = Array.isArray(value)
? value.map(processFormDataValue)
: processFormDataValue(value);
}

return processedFormData;
}

/**
* Prepares the response body
*/
Expand Down
Loading

0 comments on commit 8cd5bc2

Please sign in to comment.