Skip to content

Commit

Permalink
Implement Body.formData for fetch (denoland#1393)
Browse files Browse the repository at this point in the history
  • Loading branch information
kevinkassimo authored and ry committed Dec 21, 2018
1 parent 317fddb commit cbee289
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 1 deletion.
141 changes: 140 additions & 1 deletion js/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ import { Headers } from "./headers";
import * as io from "./io";
import { read, close } from "./files";
import { Buffer } from "./buffer";
import { FormData } from "./form_data";

function getHeaderValueParams(value: string): Map<string, string> {
const params = new Map();
// Forced to do so for some Map constructor param mismatch
value
.split(";")
.slice(1)
.map(s => s.trim().split("="))
.filter(arr => arr.length > 1)
.map(([k, v]) => [k, v.replace(/^"([^"]*)"$/, "$1")])
.forEach(([k, v]) => params.set(k, v));
return params;
}

function hasHeaderValueOf(s: string, value: string) {
return new RegExp(`^${value}[\t\s]*;?`).test(s);
}

class Body implements domTypes.Body, domTypes.ReadableStream, io.ReadCloser {
bodyUsed = false;
Expand Down Expand Up @@ -60,8 +78,129 @@ class Body implements domTypes.Body, domTypes.ReadableStream, io.ReadCloser {
});
}

// ref: https://fetch.spec.whatwg.org/#body-mixin
async formData(): Promise<domTypes.FormData> {
return notImplemented();
const formData = new FormData();
const enc = new TextEncoder();
if (hasHeaderValueOf(this.contentType, "multipart/form-data")) {
const params = getHeaderValueParams(this.contentType);
if (!params.has("boundary")) {
// TypeError is required by spec
throw new TypeError("multipart/form-data must provide a boundary");
}
// ref: https://tools.ietf.org/html/rfc2046#section-5.1
const boundary = params.get("boundary")!;
const dashBoundary = `--${boundary}`;
const delimiter = `\r\n${dashBoundary}`;
const closeDelimiter = `${delimiter}--`;

const body = await this.text();
let bodyParts: string[];
const bodyEpilogueSplit = body.split(closeDelimiter);
if (bodyEpilogueSplit.length < 2) {
bodyParts = [];
} else {
// discard epilogue
const bodyEpilogueTrimmed = bodyEpilogueSplit[0];
// first boundary treated special due to optional prefixed \r\n
const firstBoundaryIndex = bodyEpilogueTrimmed.indexOf(dashBoundary);
if (firstBoundaryIndex < 0) {
throw new TypeError("Invalid boundary");
}
const bodyPreambleTrimmed = bodyEpilogueTrimmed
.slice(firstBoundaryIndex + dashBoundary.length)
.replace(/^[\s\r\n\t]+/, ""); // remove transport-padding CRLF
// trimStart might not be available
// Be careful! body-part allows trailing \r\n!
// (as long as it is not part of `delimiter`)
bodyParts = bodyPreambleTrimmed
.split(delimiter)
.map(s => s.replace(/^[\s\r\n\t]+/, ""));
// TODO: LWSP definition is actually trickier,
// but should be fine in our case since without headers
// we should just discard the part
}
for (const bodyPart of bodyParts) {
const headers = new Headers();
const headerOctetSeperatorIndex = bodyPart.indexOf("\r\n\r\n");
if (headerOctetSeperatorIndex < 0) {
continue; // Skip unknown part
}
const headerText = bodyPart.slice(0, headerOctetSeperatorIndex);
const octets = bodyPart.slice(headerOctetSeperatorIndex + 4);

// TODO: use textproto.readMIMEHeader from deno_std
const rawHeaders = headerText.split("\r\n");
for (const rawHeader of rawHeaders) {
const sepIndex = rawHeader.indexOf(":");
if (sepIndex < 0) {
continue; // Skip this header
}
const key = rawHeader.slice(0, sepIndex);
const value = rawHeader.slice(sepIndex + 1);
headers.set(key, value);
}
if (!headers.has("content-disposition")) {
continue; // Skip unknown part
}
// Content-Transfer-Encoding Deprecated
const contentDisposition = headers.get("content-disposition")!;
const partContentType = headers.get("content-type") || "text/plain";
// TODO: custom charset encoding (needs TextEncoder support)
// const contentTypeCharset =
// getHeaderValueParams(partContentType).get("charset") || "";
if (!hasHeaderValueOf(contentDisposition, "form-data")) {
continue; // Skip, might not be form-data
}
const dispositionParams = getHeaderValueParams(contentDisposition);
if (!dispositionParams.has("name")) {
continue; // Skip, unknown name
}
const dispositionName = dispositionParams.get("name")!;
if (dispositionParams.has("filename")) {
const filename = dispositionParams.get("filename")!;
const blob = new DenoBlob([enc.encode(octets)], {
type: partContentType
});
// TODO: based on spec
// https://xhr.spec.whatwg.org/#dom-formdata-append
// https://xhr.spec.whatwg.org/#create-an-entry
// Currently it does not meantion how I could pass content-type
// to the internally created file object...
formData.append(dispositionName, blob, filename);
} else {
formData.append(dispositionName, octets);
}
}
return formData;
} else if (
hasHeaderValueOf(this.contentType, "application/x-www-form-urlencoded")
) {
// From https://github.com/github/fetch/blob/master/fetch.js
// Copyright (c) 2014-2016 GitHub, Inc. MIT License
const body = await this.text();
try {
body
.trim()
.split("&")
.forEach(bytes => {
if (bytes) {
const split = bytes.split("=");
const name = split.shift()!.replace(/\+/g, " ");
const value = split.join("=").replace(/\+/g, " ");
formData.append(
decodeURIComponent(name),
decodeURIComponent(value)
);
}
});
} catch (e) {
throw new TypeError("Invalid form urlencoded format");
}
return formData;
} else {
throw new TypeError("Invalid form data");
}
}

// tslint:disable-next-line:no-any
Expand Down
24 changes: 24 additions & 0 deletions js/fetch_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,30 @@ testPerm({ net: true }, async function fetchEmptyInvalid() {
assertEqual(err.name, "InvalidUri");
});

testPerm({ net: true }, async function fetchMultipartFormDataSuccess() {
const response = await fetch(
"http:https://localhost:4545/tests/subdir/multipart_form_data.txt"
);
const formData = await response.formData();
assert(formData.has("field_1"));
assertEqual(formData.get("field_1").toString(), "value_1 \r\n");
assert(formData.has("field_2"));
const file = formData.get("field_2") as File;
assertEqual(file.name, "file.js");
// Currently we cannot read from file...
});

testPerm({ net: true }, async function fetchURLEncodedFormDataSuccess() {
const response = await fetch(
"http:https://localhost:4545/tests/subdir/form_urlencoded.txt"
);
const formData = await response.formData();
assert(formData.has("field_1"));
assertEqual(formData.get("field_1").toString(), "Hi");
assert(formData.has("field_2"));
assertEqual(formData.get("field_2").toString(), "<Deno>");
});

// TODO(ry) The following tests work but are flaky. There's a race condition
// somewhere. Here is what one of these flaky failures looks like:
//
Expand Down
1 change: 1 addition & 0 deletions tests/subdir/form_urlencoded.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
field_1=Hi&field_2=%3CDeno%3E
26 changes: 26 additions & 0 deletions tools/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,30 @@


class ContentTypeHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
def do_GET(self):
if "multipart_form_data.txt" in self.path:
self.protocol_version = 'HTTP/1.1'
self.send_response(200, 'OK')
self.send_header('Content-type',
'multipart/form-data;boundary=boundary')
self.end_headers()
self.wfile.write(
bytes('Preamble\r\n'
'--boundary\t \r\n'
'Content-Disposition: form-data; name="field_1"\r\n'
'\r\n'
'value_1 \r\n'
'\r\n--boundary\r\n'
'Content-Disposition: form-data; name="field_2"; '
'filename="file.js"\r\n'
'Content-Type: text/javascript\r\n'
'\r\n'
'console.log("Hi")'
'\r\n--boundary--\r\n'
'Epilogue'))
return
return SimpleHTTPServer.SimpleHTTPRequestHandler.do_GET(self)

def guess_type(self, path):
if ".t1." in path:
return "text/typescript"
Expand All @@ -32,6 +56,8 @@ def guess_type(self, path):
return "text/ecmascript"
if ".j4." in path:
return "application/x-javascript"
if "form_urlencoded" in path:
return "application/x-www-form-urlencoded"
return SimpleHTTPServer.SimpleHTTPRequestHandler.guess_type(self, path)


Expand Down

0 comments on commit cbee289

Please sign in to comment.