Added a very complicated AsyncIterableReader class.
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.
This commit is contained in:
1 parent
17a1b4bed3
commit
0d627c9051
125 files changed
+26943
-83
No files matched your search
+471
@@ -0,0 +1,471 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user