The class was already implemented in "https://deno.land/std@0.144.0/streams/conversion.ts". So, I litterally lost 1 day of work! I hate myself.
472 lines
15 KiB
TypeScript
472 lines
15 KiB
TypeScript
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
|
|
|
|
import {
|
|
createHttpError,
|
|
errors,
|
|
readerFromStreamReader,
|
|
Status,
|
|
} from "./deps.ts";
|
|
import { isMediaType } from "./isMediaType.ts";
|
|
import { FormDataReader } from "./multipart.ts";
|
|
import type { ServerRequestBody } from "./types.d.ts";
|
|
import { assert } from "./util.ts";
|
|
|
|
/** The type of the body, where:
|
|
*
|
|
* - `"bytes"` - the body is provided as a promise which resolves to an
|
|
* {@linkcode Uint8Array}. This is essentially a "raw" body type.
|
|
* - `"form"` - the body was decoded as a form with the contents provided as a
|
|
* promise which resolves with a {@linkcode URLSearchParams}.
|
|
* - `"form-data"` - the body was decoded as a multi-part form data and the
|
|
* contents are provided as a promise which resolves with a
|
|
* {@linkcode FormDataReader}.
|
|
* - `"json"` - the body was decoded as JSON, where the contents are provided as
|
|
* the result of using `JSON.parse()` on the string contents of the body.
|
|
* - `"text"` - the body was decoded as text, where the contents are provided as
|
|
* a string.
|
|
* - `"reader"` - the body is provided as {@linkcode Deno.Reader} interface for
|
|
* reading the "raw" body.
|
|
* - `"stream"` - the body is provided as a
|
|
* {@linkcode ReadableStream<Uint8Array>} for reading the "raw" body.
|
|
* - `"undefined"` - there is no request body or it could not be decoded.
|
|
*/
|
|
export type BodyType =
|
|
| "bytes"
|
|
| "form"
|
|
| "form-data"
|
|
| "json"
|
|
| "text"
|
|
| "reader"
|
|
| "stream"
|
|
| "undefined";
|
|
|
|
/** The tagged type for `"bytes"` bodies. */
|
|
export type BodyBytes = {
|
|
readonly type: "bytes";
|
|
readonly value: Promise<Uint8Array>;
|
|
};
|
|
/** The tagged type for `"json"` bodies. */
|
|
export type BodyJson = {
|
|
readonly type: "json";
|
|
// deno-lint-ignore no-explicit-any
|
|
readonly value: Promise<any>;
|
|
};
|
|
/** The tagged type for `"form"` bodies. */
|
|
export type BodyForm = {
|
|
readonly type: "form";
|
|
readonly value: Promise<URLSearchParams>;
|
|
};
|
|
/** The tagged type for `"form-data"` bodies. */
|
|
export type BodyFormData = {
|
|
readonly type: "form-data";
|
|
readonly value: FormDataReader;
|
|
};
|
|
/** The tagged type for `"text"` bodies. */
|
|
export type BodyText = {
|
|
readonly type: "text";
|
|
readonly value: Promise<string>;
|
|
};
|
|
/** The tagged type for `"undefined"` bodies. */
|
|
export type BodyUndefined = {
|
|
readonly type: "undefined";
|
|
readonly value: undefined;
|
|
};
|
|
/** The tagged type for `"reader"` bodies. */
|
|
export type BodyReader = {
|
|
readonly type: "reader";
|
|
readonly value: Deno.Reader;
|
|
};
|
|
/** The tagged type for `"stream"` bodies. */
|
|
export type BodyStream = {
|
|
readonly type: "stream";
|
|
readonly value: ReadableStream<Uint8Array>;
|
|
};
|
|
|
|
/** The type returned from the `.body()` function, which is a tagged union type
|
|
* of all the different types of bodies which can be identified by the `.type`
|
|
* property which will be of type {@linkcode BodyType} and the `.value`
|
|
* property being a `Promise` which resolves with the appropriate value, or
|
|
* `undefined` if there is no body. */
|
|
export type Body =
|
|
| BodyBytes
|
|
| BodyJson
|
|
| BodyForm
|
|
| BodyFormData
|
|
| BodyText
|
|
| BodyUndefined;
|
|
|
|
type BodyValueGetter = () => Body["value"];
|
|
|
|
/** When setting the `contentTypes` property of {@linkcode BodyOptions}, provide
|
|
* additional content types which can influence how the body is decoded. This
|
|
* is specifically designed to allow a server to support custom or specialized
|
|
* media types that are not part of the public database. */
|
|
export interface BodyOptionsContentTypes {
|
|
/** Content types listed here will always return an Uint8Array. */
|
|
bytes?: string[];
|
|
/** Content types listed here will be parsed as a JSON string. */
|
|
json?: string[];
|
|
/** Content types listed here will be parsed as form data and return
|
|
* `URLSearchParameters` as the value of the body. */
|
|
form?: string[];
|
|
/** Content types listed here will be parsed as from data and return a
|
|
* `FormDataBody` interface as the value of the body. */
|
|
formData?: string[];
|
|
/** Content types listed here will be parsed as text. */
|
|
text?: string[];
|
|
}
|
|
|
|
/** Options which can be used when accessing the `.body()` of a request.
|
|
*
|
|
* @template T the {@linkcode BodyType} to attempt to use when decoding the
|
|
* request body.
|
|
*/
|
|
export interface BodyOptions<T extends BodyType = BodyType> {
|
|
/** When reading a non-streaming body, set a limit whereby if the content
|
|
* length is greater then the limit or not set, reading the body will throw.
|
|
*
|
|
* This is to prevent malicious requests where the body exceeds the capacity
|
|
* of the server. Set the limit to 0 to allow unbounded reads. The default
|
|
* is 10 Mib. */
|
|
limit?: number;
|
|
/** Instead of utilizing the content type of the request, attempt to parse the
|
|
* body as the type specified. The value has to be of {@linkcode BodyType}. */
|
|
type?: T;
|
|
/** A map of extra content types to determine how to parse the body. */
|
|
contentTypes?: BodyOptionsContentTypes;
|
|
}
|
|
|
|
export interface BodyContentTypes {
|
|
json?: string[];
|
|
form?: string[];
|
|
text?: string[];
|
|
}
|
|
|
|
const DEFAULT_LIMIT = 10_485_760; // 10mb
|
|
|
|
const defaultBodyContentTypes = {
|
|
json: ["json", "application/*+json", "application/csp-report"],
|
|
form: ["urlencoded"],
|
|
formData: ["multipart"],
|
|
text: ["text"],
|
|
};
|
|
|
|
function resolveType(
|
|
contentType: string,
|
|
contentTypes: BodyOptionsContentTypes,
|
|
): BodyType {
|
|
const contentTypesJson = [
|
|
...defaultBodyContentTypes.json,
|
|
...(contentTypes.json ?? []),
|
|
];
|
|
const contentTypesForm = [
|
|
...defaultBodyContentTypes.form,
|
|
...(contentTypes.form ?? []),
|
|
];
|
|
const contentTypesFormData = [
|
|
...defaultBodyContentTypes.formData,
|
|
...(contentTypes.formData ?? []),
|
|
];
|
|
const contentTypesText = [
|
|
...defaultBodyContentTypes.text,
|
|
...(contentTypes.text ?? []),
|
|
];
|
|
if (contentTypes.bytes && isMediaType(contentType, contentTypes.bytes)) {
|
|
return "bytes";
|
|
} else if (isMediaType(contentType, contentTypesJson)) {
|
|
return "json";
|
|
} else if (isMediaType(contentType, contentTypesForm)) {
|
|
return "form";
|
|
} else if (isMediaType(contentType, contentTypesFormData)) {
|
|
return "form-data";
|
|
} else if (isMediaType(contentType, contentTypesText)) {
|
|
return "text";
|
|
}
|
|
return "bytes";
|
|
}
|
|
|
|
const decoder = new TextDecoder(undefined, { fatal: true });
|
|
|
|
export class RequestBody {
|
|
#body: ReadableStream<Uint8Array> | null;
|
|
#formDataReader?: FormDataReader;
|
|
#headers: Headers;
|
|
#jsonBodyReviver?: (key: string, value: unknown) => unknown;
|
|
#stream?: ReadableStream<Uint8Array>;
|
|
#readAllBody?: Promise<Uint8Array>;
|
|
#readBody: () => Promise<Uint8Array>;
|
|
#type?: "bytes" | "form-data" | "reader" | "stream" | "undefined";
|
|
|
|
#exceedsLimit(limit: number): boolean {
|
|
if (!limit || limit === Infinity) {
|
|
return false;
|
|
}
|
|
if (!this.#body) {
|
|
return false;
|
|
}
|
|
const contentLength = this.#headers.get("content-length") ?? "0";
|
|
const parsed = parseInt(contentLength, 10);
|
|
if (isNaN(parsed)) {
|
|
return true;
|
|
}
|
|
return parsed > limit;
|
|
}
|
|
|
|
#parse(type: BodyType, limit: number): BodyValueGetter {
|
|
switch (type) {
|
|
case "form":
|
|
this.#type = "bytes";
|
|
if (this.#exceedsLimit(limit)) {
|
|
return () =>
|
|
Promise.reject(
|
|
createHttpError(
|
|
Status.BadRequest,
|
|
`Body exceeds a limit of ${limit}.`,
|
|
{ expose: false },
|
|
),
|
|
);
|
|
}
|
|
return async () =>
|
|
new URLSearchParams(
|
|
decoder.decode(await this.#valuePromise()).replace(/\+/g, " "),
|
|
);
|
|
case "form-data":
|
|
this.#type = "form-data";
|
|
return () => {
|
|
const contentType = this.#headers.get("content-type");
|
|
assert(contentType);
|
|
const readableStream = this.#body ?? new ReadableStream();
|
|
try {
|
|
return this.#formDataReader ??
|
|
(this.#formDataReader = new FormDataReader(
|
|
contentType,
|
|
readerFromStreamReader(
|
|
(readableStream as ReadableStream<Uint8Array>).getReader(),
|
|
),
|
|
));
|
|
} catch (err) {
|
|
const message = err instanceof Error
|
|
? err.message
|
|
: "Malformed request body.";
|
|
throw createHttpError(
|
|
Status.BadRequest,
|
|
message,
|
|
{ expose: false },
|
|
);
|
|
}
|
|
};
|
|
case "json":
|
|
this.#type = "bytes";
|
|
if (this.#exceedsLimit(limit)) {
|
|
return () =>
|
|
Promise.reject(createHttpError(
|
|
Status.BadRequest,
|
|
`Body exceeds a limit of ${limit}.`,
|
|
{ expose: false },
|
|
));
|
|
}
|
|
return async () => {
|
|
const value = await this.#valuePromise();
|
|
try {
|
|
return value.length
|
|
? JSON.parse(
|
|
decoder.decode(await this.#valuePromise()),
|
|
this.#jsonBodyReviver,
|
|
)
|
|
: null;
|
|
} catch (err) {
|
|
const message = err instanceof Error
|
|
? err.message
|
|
: "Malformed request body.";
|
|
throw createHttpError(
|
|
Status.BadRequest,
|
|
message,
|
|
{ expose: false },
|
|
);
|
|
}
|
|
};
|
|
case "bytes":
|
|
this.#type = "bytes";
|
|
if (this.#exceedsLimit(limit)) {
|
|
return () =>
|
|
Promise.reject(createHttpError(
|
|
Status.BadRequest,
|
|
`Body exceeds a limit of ${limit}.`,
|
|
{ expose: false },
|
|
));
|
|
}
|
|
return () => this.#valuePromise();
|
|
case "text":
|
|
this.#type = "bytes";
|
|
if (this.#exceedsLimit(limit)) {
|
|
return () =>
|
|
Promise.reject(createHttpError(
|
|
Status.BadRequest,
|
|
`Body exceeds a limit of ${limit}.`,
|
|
{ expose: false },
|
|
));
|
|
}
|
|
return async () => {
|
|
try {
|
|
return decoder.decode(await this.#valuePromise());
|
|
} catch (err) {
|
|
const message = err instanceof Error
|
|
? err.message
|
|
: "Malformed request body.";
|
|
throw createHttpError(
|
|
Status.BadRequest,
|
|
message,
|
|
{ expose: false },
|
|
);
|
|
}
|
|
};
|
|
default:
|
|
throw createHttpError(
|
|
Status.InternalServerError,
|
|
`Invalid body type: "${type}"`,
|
|
{ expose: true },
|
|
);
|
|
}
|
|
}
|
|
|
|
#validateGetArgs(
|
|
type: BodyType | undefined,
|
|
contentTypes: BodyOptionsContentTypes,
|
|
) {
|
|
if (type === "reader" && this.#type && this.#type !== "reader") {
|
|
throw new TypeError(
|
|
`Body already consumed as "${this.#type}" and cannot be returned as a reader.`,
|
|
);
|
|
}
|
|
if (type === "stream" && this.#type && this.#type !== "stream") {
|
|
throw new TypeError(
|
|
`Body already consumed as "${this.#type}" and cannot be returned as a stream.`,
|
|
);
|
|
}
|
|
if (type === "form-data" && this.#type && this.#type !== "form-data") {
|
|
throw new TypeError(
|
|
`Body already consumed as "${this.#type}" and cannot be returned as a stream.`,
|
|
);
|
|
}
|
|
if (this.#type === "reader" && type !== "reader") {
|
|
throw new TypeError(
|
|
"Body already consumed as a reader and can only be returned as a reader.",
|
|
);
|
|
}
|
|
if (this.#type === "stream" && type !== "stream") {
|
|
throw new TypeError(
|
|
"Body already consumed as a stream and can only be returned as a stream.",
|
|
);
|
|
}
|
|
if (this.#type === "form-data" && type !== "form-data") {
|
|
throw new TypeError(
|
|
"Body already consumed as form data and can only be returned as form data.",
|
|
);
|
|
}
|
|
if (type && Object.keys(contentTypes).length) {
|
|
throw new TypeError(
|
|
`"type" and "contentTypes" cannot be specified at the same time`,
|
|
);
|
|
}
|
|
}
|
|
|
|
#valuePromise() {
|
|
return this.#readAllBody ?? (this.#readAllBody = this.#readBody());
|
|
}
|
|
|
|
constructor(
|
|
{ body, readBody }: ServerRequestBody,
|
|
headers: Headers,
|
|
jsonBodyReviver?: (key: string, value: unknown) => unknown,
|
|
) {
|
|
this.#body = body;
|
|
this.#headers = headers;
|
|
this.#jsonBodyReviver = jsonBodyReviver;
|
|
this.#readBody = readBody;
|
|
}
|
|
|
|
get(
|
|
{ limit = DEFAULT_LIMIT, type, contentTypes = {} }: BodyOptions = {},
|
|
): Body | BodyReader | BodyStream {
|
|
this.#validateGetArgs(type, contentTypes);
|
|
if (type === "reader") {
|
|
if (!this.#body) {
|
|
this.#type = "undefined";
|
|
throw new TypeError(
|
|
`Body is undefined and cannot be returned as "reader".`,
|
|
);
|
|
}
|
|
this.#type = "reader";
|
|
return {
|
|
type,
|
|
value: readerFromStreamReader(this.#body.getReader()),
|
|
};
|
|
}
|
|
if (type === "stream") {
|
|
if (!this.#body) {
|
|
this.#type = "undefined";
|
|
throw new TypeError(
|
|
`Body is undefined and cannot be returned as "stream".`,
|
|
);
|
|
}
|
|
this.#type = "stream";
|
|
const streams =
|
|
((this.#stream ?? this.#body) as ReadableStream<Uint8Array>)
|
|
.tee();
|
|
this.#stream = streams[1];
|
|
return { type, value: streams[0] };
|
|
}
|
|
if (!this.has()) {
|
|
this.#type = "undefined";
|
|
} else if (!this.#type) {
|
|
const encoding = this.#headers.get("content-encoding") ??
|
|
"identity";
|
|
if (encoding !== "identity") {
|
|
throw new errors.UnsupportedMediaType(
|
|
`Unsupported content-encoding: ${encoding}`,
|
|
);
|
|
}
|
|
}
|
|
if (this.#type === "undefined" && (!type || type === "undefined")) {
|
|
return { type: "undefined", value: undefined };
|
|
}
|
|
if (!type) {
|
|
const contentType = this.#headers.get("content-type");
|
|
if (!contentType) {
|
|
throw createHttpError(
|
|
Status.BadRequest,
|
|
"The Content-Type header is missing from the request",
|
|
);
|
|
}
|
|
type = resolveType(contentType, contentTypes);
|
|
}
|
|
assert(type);
|
|
const body: Body = Object.create(null);
|
|
Object.defineProperties(body, {
|
|
type: {
|
|
value: type,
|
|
configurable: true,
|
|
enumerable: true,
|
|
},
|
|
value: {
|
|
get: this.#parse(type, limit),
|
|
configurable: true,
|
|
enumerable: true,
|
|
},
|
|
});
|
|
return body;
|
|
}
|
|
|
|
/** Returns if the request might have a body or not, without attempting to
|
|
* consume it.
|
|
*
|
|
* **WARNING** This is an unreliable API. In HTTP/2 it is not possible to
|
|
* determine if certain HTTP methods have a body or not without attempting to
|
|
* read the body. As of Deno 1.16.1 and later, for HTTP/1.1 aligns to the
|
|
* HTTP/2 behaviour.
|
|
*/
|
|
has(): boolean {
|
|
return this.#body != null;
|
|
}
|
|
}
|