Skip to content

Commit

Permalink
[core] Port "New multipart/form-data primitive in core-client-rest" (A…
Browse files Browse the repository at this point in the history
…zure#29047) to ts-http-runtime
  • Loading branch information
timovv committed Apr 25, 2024
1 parent 8cd5bc2 commit 29ac06d
Show file tree
Hide file tree
Showing 6 changed files with 638 additions and 125 deletions.
2 changes: 1 addition & 1 deletion sdk/core/core-client-rest/test/multipart.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { describe, it, assert } from "vitest";
import { PartDescriptor, buildBodyPart } from "../src/multipart.js";
import { stringToUint8Array } from "../../core-util/dist/commonjs/bytesEncoding.js";
import { stringToUint8Array } from "@azure/core-util";

describe("multipart buildBodyPart", () => {
describe("content-type calculation", () => {
Expand Down
200 changes: 200 additions & 0 deletions sdk/core/ts-http-runtime/src/client/multipart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { BodyPart, MultipartRequestBody, RawHttpHeadersInput } from "../interfaces.js";
import { RestError } from "../restError.js";
import { createHttpHeaders } from "../httpHeaders.js";
import { stringToUint8Array } from "../util/bytesEncoding.js";
import { isBinaryBody } from "../util/typeGuards.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/ts-http-runtime/src/client/sendRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
// Licensed under the MIT license.

import {
FormDataMap,
FormDataValue,
HttpClient,
HttpMethods,
MultipartRequestBody,
PipelineRequest,
PipelineResponse,
RequestBodyType,
Expand All @@ -17,7 +16,7 @@ import { createPipelineRequest } from "../pipelineRequest.js";
import { getCachedDefaultHttpsClient } from "./clientHelpers.js";
import { isReadableStream } from "../util/typeGuards.js";
import { HttpResponse, RequestParameters } from "./common.js";
import { createFile } from "../util/file.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
18 changes: 18 additions & 0 deletions sdk/core/ts-http-runtime/src/util/typeGuards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ export function isWebReadableStream(x: unknown): x is ReadableStream {
);
}

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)
);
}

export function isReadableStream(x: unknown): x is ReadableStream | NodeJS.ReadableStream {
return isNodeReadableStream(x) || isWebReadableStream(x);
}
Expand Down
Loading

0 comments on commit 29ac06d

Please sign in to comment.