-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[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
- Loading branch information
Showing
8 changed files
with
734 additions
and
153 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) }; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.