Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: created new multiparser #52

Merged
merged 3 commits into from
Nov 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 11 additions & 32 deletions api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { compress as brotli } from 'https://deno.land/x/[email protected]/mod.ts'
import { FormDataReader } from 'https://deno.land/x/[email protected]/multipart.ts'
import { gzipEncode } from 'https://deno.land/x/[email protected]/mod.ts'
import log from './log.ts'
import { multiParser } from './multiparser.ts'
import { ServerRequest } from './std.ts'
import type { APIRequest, FormDataBody } from './types.ts'

Expand Down Expand Up @@ -90,43 +90,22 @@ export class Request extends ServerRequest implements APIRequest {
async decodeBody(type: "form-data"): Promise<FormDataBody>
async decodeBody(type: string): Promise<any> {
if (type === "text") {
try {
const buff: Uint8Array = await Deno.readAll(this.body);
const encoded = new TextDecoder("utf-8").decode(buff);
return encoded;
} catch (err) {
console.error("Failed to parse the request body.", err);
}
const buff: Uint8Array = await Deno.readAll(this.body);
const encoded = new TextDecoder("utf-8").decode(buff);
return encoded;
}

if (type === "json") {
try {
const buff: Uint8Array = await Deno.readAll(this.body);
const encoded = new TextDecoder("utf-8").decode(buff);
const json = JSON.parse(encoded);
return json;
} catch (err) {
console.error("Failed to parse the request body.", err);
}
const buff: Uint8Array = await Deno.readAll(this.body);
const encoded = new TextDecoder("utf-8").decode(buff);
const json = JSON.parse(encoded);
return json;
}

if (type === "form-data") {
try {
const boundary = this.headers.get("content-type");

if (!boundary) throw new Error("Failed to get the content-type")

const reader = new FormDataReader(boundary, this.body);
const { fields, files } = await reader.read({ maxSize: 1024 * 1024 * 10 });

return {
get: (key: string) => fields[key],
getFile: (key: string) => files?.find(i => i.name === key)
}

} catch (err) {
console.error("Failed to parse the request form-data", err)
}
const contentType = this.headers.get("content-type") as string
const form = await multiParser(this.body, contentType);
return form;
}
}

Expand Down
198 changes: 198 additions & 0 deletions multiparser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { bytes } from "./std.ts";
import { FormDataBody, FormFile } from "./types.ts";

const encoder = new TextEncoder();
const decoder = new TextDecoder();

const encode = {
contentType: encoder.encode("Content-Type"),
filename: encoder.encode("filename"),
name: encoder.encode("name"),
dashdash: encoder.encode("--"),
boundaryEqual: encoder.encode("boundary="),
returnNewline2: encoder.encode("\r\n\r\n"),
carriageReturn: encoder.encode("\r"),
};

export async function multiParser(
body: Deno.Reader,
contentType: string
): Promise<FormDataBody> {
let buf = await Deno.readAll(body);
let boundaryByte = getBoundary(contentType);

if (!boundaryByte) {
throw new Error("No boundary data information");
}

// Generate an array of Uint8Array
const pieces = getFieldPieces(buf, boundaryByte!);

// Set all the pieces into one single object
const form = getForm(pieces);

return form;
}

function createFormData(): FormDataBody {
return {
fields: {},
files: [],
getFile(key: string) {
return this.files.find((i) => i.name === key);
},
get(key: string) {
return this.fields[key];
},
};
}

function getForm(pieces: Uint8Array[]) {
let form: FormDataBody = createFormData();

for (let piece of pieces) {
const { headerByte, contentByte } = splitPiece(piece);
const headers = getHeaders(headerByte);

// it's a string field
if (typeof headers === "string") {
// empty content, discard it
if (contentByte.byteLength === 1 && contentByte[0] === 13) {
continue;
}

// headers = "field1"
else {
form.fields[headers] = decoder.decode(contentByte);
}
}

// it's a file field
else {
let file: FormFile = {
name: headers.name,
filename: headers.filename,
contentType: headers.contentType,
size: contentByte.byteLength,
content: contentByte,
};

form.files.push(file);
}
}
return form;
}

function getHeaders(headerByte: Uint8Array) {
let contentTypeIndex = bytes.findIndex(headerByte, encode.contentType);

// no contentType, it may be a string field, return name only
if (contentTypeIndex < 0) {
return getNameOnly(headerByte);
}

// file field, return with name, filename and contentType
else {
return getHeaderNContentType(headerByte, contentTypeIndex);
}
}

function getHeaderNContentType(
headerByte: Uint8Array,
contentTypeIndex: number,
) {
let headers: Record<string, string> = {};

let contentDispositionByte = headerByte.slice(0, contentTypeIndex - 2);
headers = getHeaderOnly(contentDispositionByte);

// jump over <Content-Type: >
let contentTypeByte = headerByte.slice(
contentTypeIndex + encode.contentType.byteLength + 2,
);

headers.contentType = decoder.decode(contentTypeByte);
return headers;
}

function getHeaderOnly(headerLineByte: Uint8Array) {
let headers: Record<string, string> = {};

let filenameIndex = bytes.findIndex(headerLineByte, encode.filename);
if (filenameIndex < 0) {
headers.name = getNameOnly(headerLineByte);
} else {
headers = getNameNFilename(headerLineByte, filenameIndex);
}
return headers;
}

function getNameNFilename(headerLineByte: Uint8Array, filenameIndex: number) {
// fetch filename first
let nameByte = headerLineByte.slice(0, filenameIndex - 2);
let filenameByte = headerLineByte.slice(
filenameIndex + encode.filename.byteLength + 2,
headerLineByte.byteLength - 1,
);

let name = getNameOnly(nameByte);
let filename = decoder.decode(filenameByte);
return { name, filename };
}

function getNameOnly(headerLineByte: Uint8Array) {
let nameIndex = bytes.findIndex(headerLineByte, encode.name);

// jump <name="> and get string inside double quote => "string"
let nameByte = headerLineByte.slice(
nameIndex + encode.name.byteLength + 2,
headerLineByte.byteLength - 1,
);

return decoder.decode(nameByte);
}

function splitPiece(piece: Uint8Array) {
const contentIndex = bytes.findIndex(piece, encode.returnNewline2);
const headerByte = piece.slice(0, contentIndex);
const contentByte = piece.slice(contentIndex + 4);

return { headerByte, contentByte };
}

function getFieldPieces(
buf: Uint8Array,
boundaryByte: Uint8Array,
): Uint8Array[] {
const startBoundaryByte = bytes.concat(encode.dashdash, boundaryByte);
const endBoundaryByte = bytes.concat(startBoundaryByte, encode.dashdash);

const pieces = [];

while (!bytes.hasPrefix(buf, endBoundaryByte)) {
// jump over boundary + '\r\n'
buf = buf.slice(startBoundaryByte.byteLength + 2);
let boundaryIndex = bytes.findIndex(buf, startBoundaryByte);

// get field content piece
pieces.push(buf.slice(0, boundaryIndex - 1));
buf = buf.slice(boundaryIndex);
}

return pieces;
}

function getBoundary(contentType: string): Uint8Array | undefined {
let contentTypeByte = encoder.encode(contentType);
let boundaryIndex = bytes.findIndex(contentTypeByte, encode.boundaryEqual);

if (boundaryIndex >= 0) {
// jump over 'boundary=' to get the real boundary
let boundary = contentTypeByte.slice(
boundaryIndex + encode.boundaryEqual.byteLength,
);
return boundary;
} else {
return undefined;
}
}
34 changes: 34 additions & 0 deletions multiparser_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { assertEquals } from "https://deno.land/std/testing/asserts.ts";
import { multiParser } from "./multiparser.ts";

const encoder = new TextEncoder();

const contentType = "multipart/form-data; boundary=ALEPH-BOUNDARY";
const simpleString = '--ALEPH-BOUNDARY\rContent-Disposition: form-data; name="string_1"\r\n\r\nsimple string here\r--ALEPH-BOUNDARY--';
const complexString = 'some text to be ignored\r\r--ALEPH-BOUNDARY\rContent-Disposition: form-data; name="id"\r\n\r\n666\r--ALEPH-BOUNDARY\rContent-Disposition: form-data; name="title"\r\n\r\nHello World\r--ALEPH-BOUNDARY\rContent-Disposition: form-data; name="multiline"\r\n\r\nworld,\n hello\r--ALEPH-BOUNDARY\rContent-Disposition: form-data; name="file1"; filename="file_name.ext"\rContent-Type: video/mp2t\r\n\r\nsome random data\r--ALEPH-BOUNDARY--\rmore text to be ignored to be ignored\r';

Deno.test(`basic multiparser string`, async () => {
const buff = new Deno.Buffer(encoder.encode(simpleString));
const multiForm = await multiParser(buff, contentType);

assertEquals(multiForm.get("string_1"), "simple string here");
});

Deno.test(`complex multiparser string`, async () => {
const buff = new Deno.Buffer(encoder.encode(complexString));
const multiFrom = await multiParser(buff, contentType);

// Asseting multiple string values
assertEquals(multiFrom.get("id"), "666");
assertEquals(multiFrom.get("title"), "Hello World");
assertEquals(multiFrom.get("multiline"), "world,\n hello");

// Asserting a file information
const file = multiFrom.getFile("file1");

if (!file) { return }

assertEquals(file.name, "file1");
assertEquals(file.contentType, "video/mp2t");
assertEquals(file.size, 16);
});
1 change: 1 addition & 0 deletions std.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * as bytes from 'https://deno.land/[email protected]/bytes/mod.ts'
export { Untar } from 'https://deno.land/[email protected]/archive/tar.ts'
export * as colors from 'https://deno.land/[email protected]/fmt/colors.ts'
export { ensureDir } from 'https://deno.land/[email protected]/fs/ensure_dir.ts'
Expand Down
10 changes: 6 additions & 4 deletions types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,10 @@ export interface RouterURL {
* The form data body
*/
export interface FormDataBody {
get(key: string): string
getFile(key: string): FormFile
fields: Record<string, string>;
files: FormFile[];
get(key: string): string | undefined;
getFile(key: string): FormFile | undefined;
}

/**
Expand All @@ -126,5 +128,5 @@ export interface FormFile {
content: Uint8Array
contentType: string
filename: string
originalName: string
}
size: number
}