Bug 1109751 - Consume FormData support in Fetch API. r=baku, a=lizzard
authorNikhil Marathe <nsm.nikhil@gmail.com>
Fri, 03 Apr 2015 22:55:15 -0700
changeset 265507 c3453f50cb496d1b2400f13c6577b7462ae4dadf
parent 265506 1ef718672044800e99de45897ba1b3e02b9c4a0b
child 265508 58b5c2f98c1c5196cf3af2262f6db7a8e4c8c7bc
push id4718
push userraliiev@mozilla.com
push dateMon, 11 May 2015 18:39:53 +0000
treeherdermozilla-beta@c20c4ef55f08 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbaku, lizzard
bugs1109751
milestone39.0a2
Bug 1109751 - Consume FormData support in Fetch API. r=baku, a=lizzard
dom/base/URLSearchParams.h
dom/bindings/Errors.msg
dom/fetch/Fetch.cpp
dom/fetch/Fetch.h
dom/tests/mochitest/fetch/fetch_test_framework.js
dom/tests/mochitest/fetch/mochitest.ini
dom/tests/mochitest/fetch/test_fetch_basic.html
dom/tests/mochitest/fetch/test_fetch_basic_http.html
dom/tests/mochitest/fetch/test_fetch_cors.html
dom/tests/mochitest/fetch/test_formdataparsing.html
dom/tests/mochitest/fetch/test_formdataparsing.js
dom/tests/mochitest/fetch/test_headers.html
dom/tests/mochitest/fetch/test_request.html
dom/tests/mochitest/fetch/test_request.js
dom/tests/mochitest/fetch/test_response.html
dom/tests/mochitest/fetch/utils.js
dom/tests/mochitest/fetch/worker_wrapper.js
dom/webidl/Fetch.webidl
--- a/dom/base/URLSearchParams.h
+++ b/dom/base/URLSearchParams.h
@@ -75,16 +75,27 @@ public:
 
   void Delete(const nsAString& aName);
 
   void Stringify(nsString& aRetval) const
   {
     Serialize(aRetval);
   }
 
+  typedef void (*ParamFunc)(const nsString& aName, const nsString& aValue,
+                            void* aClosure);
+
+  void
+  ForEach(ParamFunc aFunc, void* aClosure)
+  {
+    for (uint32_t i = 0; i < mSearchParams.Length(); ++i) {
+      aFunc(mSearchParams[i].mKey, mSearchParams[i].mValue, aClosure);
+    }
+  }
+
 private:
   void AppendInternal(const nsAString& aName, const nsAString& aValue);
 
   void DeleteAll();
 
   void DecodeString(const nsACString& aInput, nsAString& aOutput);
   void ConvertString(const nsACString& aInput, nsAString& aOutput);
 
--- a/dom/bindings/Errors.msg
+++ b/dom/bindings/Errors.msg
@@ -68,8 +68,9 @@ MSG_DEF(MSG_FETCH_FAILED, 0, JSEXN_TYPEE
 MSG_DEF(MSG_NO_BODY_ALLOWED_FOR_GET_AND_HEAD, 0, JSEXN_TYPEERR, "HEAD or GET Request cannot have a body.")
 MSG_DEF(MSG_DEFINE_NON_CONFIGURABLE_PROP_ON_WINDOW, 0, JSEXN_TYPEERR, "Not allowed to define a non-configurable property on the WindowProxy object")
 MSG_DEF(MSG_INVALID_ZOOMANDPAN_VALUE_ERROR, 0, JSEXN_RANGEERR, "Invalid zoom and pan value.")
 MSG_DEF(MSG_INVALID_TRANSFORM_ANGLE_ERROR, 0, JSEXN_RANGEERR, "Invalid transform angle.")
 MSG_DEF(MSG_INVALID_RESPONSE_STATUSCODE_ERROR, 0, JSEXN_RANGEERR, "Invalid response status code.")
 MSG_DEF(MSG_INVALID_REDIRECT_STATUSCODE_ERROR, 0, JSEXN_RANGEERR, "Invalid redirect status code.")
 MSG_DEF(MSG_INVALID_URL_SCHEME, 2, JSEXN_TYPEERR, "{0} URL {1} must be either http:// or https://.")
 MSG_DEF(MSG_RESPONSE_URL_IS_NULL, 0, JSEXN_TYPEERR, "Cannot set Response.finalURL when Response.url is null.")
+MSG_DEF(MSG_BAD_FORMDATA, 0, JSEXN_TYPEERR, "Could not parse content as FormData.")
--- a/dom/fetch/Fetch.cpp
+++ b/dom/fetch/Fetch.cpp
@@ -7,18 +7,20 @@
 
 #include "nsIDocument.h"
 #include "nsIGlobalObject.h"
 #include "nsIStreamLoader.h"
 #include "nsIThreadRetargetableRequest.h"
 #include "nsIUnicodeDecoder.h"
 #include "nsIUnicodeEncoder.h"
 
+#include "nsCharSeparatedTokenizer.h"
 #include "nsDOMString.h"
 #include "nsNetUtil.h"
+#include "nsReadableUtils.h"
 #include "nsStreamUtils.h"
 #include "nsStringStream.h"
 
 #include "mozilla/ErrorResult.h"
 #include "mozilla/dom/EncodingUtils.h"
 #include "mozilla/dom/Exceptions.h"
 #include "mozilla/dom/FetchDriver.h"
 #include "mozilla/dom/File.h"
@@ -510,16 +512,392 @@ ExtractFromURLSearchParams(const URLSear
                            nsIInputStream** aStream,
                            nsCString& aContentType)
 {
   nsAutoString serialized;
   aParams.Stringify(serialized);
   aContentType = NS_LITERAL_CSTRING("application/x-www-form-urlencoded;charset=UTF-8");
   return NS_NewStringInputStream(aStream, serialized);
 }
+
+void
+FillFormData(const nsString& aName, const nsString& aValue, void* aFormData)
+{
+  MOZ_ASSERT(aFormData);
+  nsFormData* fd = static_cast<nsFormData*>(aFormData);
+  fd->Append(aName, aValue);
+}
+
+/**
+ * A simple multipart/form-data parser as defined in RFC 2388 and RFC 2046.
+ * This does not respect any encoding specified per entry, using UTF-8
+ * throughout. This is as the Fetch spec states in the consume body algorithm.
+ * Borrows some things from Necko's nsMultiMixedConv, but is simpler since
+ * unlike Necko we do not have to deal with receiving incomplete chunks of data.
+ *
+ * This parser will fail the entire parse on any invalid entry, so it will
+ * never return a partially filled FormData.
+ * The content-disposition header is used to figure out the name and filename
+ * entries. The inclusion of the filename parameter decides if the entry is
+ * inserted into the nsFormData as a string or a File.
+ *
+ * File blobs are copies of the underlying data string since we cannot adopt
+ * char* chunks embedded within the larger body without significant effort.
+ * FIXME(nsm): Bug 1127552 - We should add telemetry to calls to formData() and
+ * friends to figure out if Fetch ends up copying big blobs to see if this is
+ * worth optimizing.
+ */
+class MOZ_STACK_CLASS FormDataParser
+{
+private:
+  nsRefPtr<nsFormData> mFormData;
+  nsCString mMimeType;
+  nsCString mData;
+
+  // Entry state, reset in START_PART.
+  nsCString mName;
+  nsCString mFilename;
+  nsCString mContentType;
+
+  enum
+  {
+    START_PART,
+    PARSE_HEADER,
+    PARSE_BODY,
+  } mState;
+
+  nsIGlobalObject* mParentObject;
+
+  // Reads over a boundary and sets start to the position after the end of the
+  // boundary. Returns false if no boundary is found immediately.
+  bool
+  PushOverBoundary(const nsACString& aBoundaryString,
+                   nsACString::const_iterator& aStart,
+                   nsACString::const_iterator& aEnd)
+  {
+    // We copy the end iterator to keep the original pointing to the real end
+    // of the string.
+    nsACString::const_iterator end(aEnd);
+    const char* beginning = aStart.get();
+    if (FindInReadable(aBoundaryString, aStart, end)) {
+      // We either should find the body immediately, or after 2 chars with the
+      // 2 chars being '-', everything else is failure.
+      if ((aStart.get() - beginning) == 0) {
+        aStart.advance(aBoundaryString.Length());
+        return true;
+      }
+
+      if ((aStart.get() - beginning) == 2) {
+        if (*(--aStart) == '-' && *(--aStart) == '-') {
+          aStart.advance(aBoundaryString.Length() + 2);
+          return true;
+        }
+      }
+    }
+
+    return false;
+  }
+
+  // Reads over a CRLF and positions start after it.
+  bool
+  PushOverLine(nsACString::const_iterator& aStart)
+  {
+    if (*aStart == nsCRT::CR && (aStart.size_forward() > 1) && *(++aStart) == nsCRT::LF) {
+      ++aStart; // advance to after CRLF
+      return true;
+    }
+
+    return false;
+  }
+
+  bool
+  FindCRLF(nsACString::const_iterator& aStart,
+           nsACString::const_iterator& aEnd)
+  {
+    nsACString::const_iterator end(aEnd);
+    return FindInReadable(NS_LITERAL_CSTRING("\r\n"), aStart, end);
+  }
+
+  bool
+  ParseHeader(nsACString::const_iterator& aStart,
+              nsACString::const_iterator& aEnd,
+              bool* aWasEmptyHeader)
+  {
+    MOZ_ASSERT(aWasEmptyHeader);
+    // Set it to a valid value here so we don't forget later.
+    *aWasEmptyHeader = false;
+
+    const char* beginning = aStart.get();
+    nsACString::const_iterator end(aEnd);
+    if (!FindCRLF(aStart, end)) {
+      return false;
+    }
+
+    if (aStart.get() == beginning) {
+      *aWasEmptyHeader = true;
+      return true;
+    }
+
+    nsAutoCString header(beginning, aStart.get() - beginning);
+
+    nsACString::const_iterator headerStart, headerEnd;
+    header.BeginReading(headerStart);
+    header.EndReading(headerEnd);
+    if (!FindCharInReadable(':', headerStart, headerEnd)) {
+      return false;
+    }
+
+    nsAutoCString headerName(StringHead(header, headerStart.size_backward()));
+    headerName.CompressWhitespace();
+    if (!NS_IsValidHTTPToken(headerName)) {
+      return false;
+    }
+
+    nsAutoCString headerValue(Substring(++headerStart, headerEnd));
+    if (!NS_IsReasonableHTTPHeaderValue(headerValue)) {
+      return false;
+    }
+    headerValue.CompressWhitespace();
+
+    if (headerName.LowerCaseEqualsLiteral("content-disposition")) {
+      nsCCharSeparatedTokenizer tokenizer(headerValue, ';');
+      bool seenFormData = false;
+      while (tokenizer.hasMoreTokens()) {
+        const nsDependentCSubstring& token = tokenizer.nextToken();
+        if (token.IsEmpty()) {
+          continue;
+        }
+
+        if (token.EqualsLiteral("form-data")) {
+          seenFormData = true;
+          continue;
+        }
+
+        if (seenFormData &&
+            StringBeginsWith(token, NS_LITERAL_CSTRING("name="))) {
+          mName = StringTail(token, token.Length() - 5);
+          mName.Trim(" \"");
+          continue;
+        }
+
+        if (seenFormData &&
+            StringBeginsWith(token, NS_LITERAL_CSTRING("filename="))) {
+          mFilename = StringTail(token, token.Length() - 9);
+          mFilename.Trim(" \"");
+          continue;
+        }
+      }
+
+      if (mName.IsVoid()) {
+        // Could not parse a valid entry name.
+        return false;
+      }
+    } else if (headerName.LowerCaseEqualsLiteral("content-type")) {
+      mContentType = headerValue;
+    }
+
+    return true;
+  }
+
+  // The end of a body is marked by a CRLF followed by the boundary. So the
+  // CRLF is part of the boundary and not the body, but any prior CRLFs are
+  // part of the body. This will position the iterator at the beginning of the
+  // boundary (after the CRLF).
+  bool
+  ParseBody(const nsACString& aBoundaryString,
+            nsACString::const_iterator& aStart,
+            nsACString::const_iterator& aEnd)
+  {
+    const char* beginning = aStart.get();
+
+    // Find the boundary marking the end of the body.
+    nsACString::const_iterator end(aEnd);
+    if (!FindInReadable(aBoundaryString, aStart, end)) {
+      return false;
+    }
+
+    // We found a boundary, strip the just prior CRLF, and consider
+    // everything else the body section.
+    if (aStart.get() - beginning < 2) {
+      // Only the first entry can have a boundary right at the beginning. Even
+      // an empty body will have a CRLF before the boundary. So this is
+      // a failure.
+      return false;
+    }
+
+    // Check that there is a CRLF right before the boundary.
+    aStart.advance(-2);
+
+    // Skip optional hyphens.
+    if (*aStart == '-' && *(aStart.get()+1) == '-') {
+      if (aStart.get() - beginning < 2) {
+        return false;
+      }
+
+      aStart.advance(-2);
+    }
+
+    if (*aStart != nsCRT::CR || *(aStart.get()+1) != nsCRT::LF) {
+      return false;
+    }
+
+    nsAutoCString body(beginning, aStart.get() - beginning);
+
+    // Restore iterator to after the \r\n as we promised.
+    // We do not need to handle the extra hyphens case since our boundary
+    // parser in PushOverBoundary()
+    aStart.advance(2);
+
+    if (!mFormData) {
+      mFormData = new nsFormData();
+    }
+
+    NS_ConvertUTF8toUTF16 name(mName);
+
+    if (mFilename.IsVoid()) {
+      mFormData->Append(name, NS_ConvertUTF8toUTF16(body));
+    } else {
+      // Unfortunately we've to copy the data first since all our strings are
+      // going to free it. We also need fallible alloc, so we can't just use
+      // ToNewCString().
+      char* copy = static_cast<char*>(NS_Alloc(body.Length()));
+      if (!copy) {
+        NS_WARNING("Failed to copy File entry body.");
+        return false;
+      }
+      nsCString::const_iterator bodyIter, bodyEnd;
+      body.BeginReading(bodyIter);
+      body.EndReading(bodyEnd);
+      char *p = copy;
+      while (bodyIter != bodyEnd) {
+        *p++ = *bodyIter++;
+      }
+      p = nullptr;
+
+      nsRefPtr<File> file =
+        File::CreateMemoryFile(mParentObject,
+                               reinterpret_cast<void *>(copy), body.Length(),
+                               NS_ConvertUTF8toUTF16(mFilename),
+                               NS_ConvertUTF8toUTF16(mContentType), /* aLastModifiedDate */ 0);
+      Optional<nsAString> dummy;
+      mFormData->Append(name, *file, dummy);
+    }
+
+    return true;
+  }
+
+public:
+  FormDataParser(const nsACString& aMimeType, const nsACString& aData, nsIGlobalObject* aParent)
+    : mMimeType(aMimeType), mData(aData), mState(START_PART), mParentObject(aParent)
+  {
+  }
+
+  bool
+  Parse()
+  {
+    // Determine boundary from mimetype.
+    const char* boundaryId = nullptr;
+    boundaryId = strstr(mMimeType.BeginWriting(), "boundary");
+    if (!boundaryId) {
+      return false;
+    }
+
+    boundaryId = strchr(boundaryId, '=');
+    if (!boundaryId) {
+      return false;
+    }
+
+    // Skip over '='.
+    boundaryId++;
+
+    char *attrib = (char *) strchr(boundaryId, ';');
+    if (attrib) *attrib = '\0';
+
+    nsAutoCString boundaryString(boundaryId);
+    if (attrib) *attrib = ';';
+
+    boundaryString.Trim(" \"");
+
+    if (boundaryString.Length() == 0) {
+      return false;
+    }
+
+    nsACString::const_iterator start, end;
+    mData.BeginReading(start);
+    // This should ALWAYS point to the end of data.
+    // Helpers make copies.
+    mData.EndReading(end);
+
+    while (start != end) {
+      switch(mState) {
+        case START_PART:
+          mName.SetIsVoid(true);
+          mFilename.SetIsVoid(true);
+          mContentType = NS_LITERAL_CSTRING("text/plain");
+
+          // MUST start with boundary.
+          if (!PushOverBoundary(boundaryString, start, end)) {
+            return false;
+          }
+
+          if (start != end && *start == '-') {
+            // End of data.
+            if (!mFormData) {
+              mFormData = new nsFormData();
+            }
+            return true;
+          }
+
+          if (!PushOverLine(start)) {
+            return false;
+          }
+          mState = PARSE_HEADER;
+          break;
+
+        case PARSE_HEADER:
+          bool emptyHeader;
+          if (!ParseHeader(start, end, &emptyHeader)) {
+            return false;
+          }
+
+          if (!PushOverLine(start)) {
+            return false;
+          }
+
+          mState = emptyHeader ? PARSE_BODY : PARSE_HEADER;
+          break;
+
+        case PARSE_BODY:
+          if (mName.IsVoid()) {
+            NS_WARNING("No content-disposition header with a valid name was "
+                       "found. Failing at body parse.");
+            return false;
+          }
+
+          if (!ParseBody(boundaryString, start, end)) {
+            return false;
+          }
+
+          mState = START_PART;
+          break;
+
+        default:
+          MOZ_CRASH("Invalid case");
+      }
+    }
+
+    NS_NOTREACHED("Should never reach here.");
+    return false;
+  }
+
+  already_AddRefed<nsFormData> FormData()
+  {
+    return mFormData.forget();
+  }
+};
 } // anonymous namespace
 
 nsresult
 ExtractByteStreamFromBody(const OwningArrayBufferOrArrayBufferViewOrBlobOrFormDataOrUSVStringOrURLSearchParams& aBodyInit,
                           nsIInputStream** aStream,
                           nsCString& aContentType)
 {
   MOZ_ASSERT(aStream);
@@ -1137,16 +1515,48 @@ FetchBody<Derived>::ContinueConsumeBody(
         return;
       }
 
       localPromise->MaybeResolve(blob);
       // File takes over ownership.
       autoFree.Reset();
       return;
     }
+    case CONSUME_FORMDATA: {
+      nsCString data;
+      data.Adopt(reinterpret_cast<char*>(aResult), aResultLength);
+      autoFree.Reset();
+
+      if (StringBeginsWith(mMimeType, NS_LITERAL_CSTRING("multipart/form-data"))) {
+        FormDataParser parser(mMimeType, data, DerivedClass()->GetParentObject());
+        if (!parser.Parse()) {
+          ErrorResult result;
+          result.ThrowTypeError(MSG_BAD_FORMDATA);
+          localPromise->MaybeReject(result);
+          return;
+        }
+
+        nsRefPtr<nsFormData> fd = parser.FormData();
+        MOZ_ASSERT(fd);
+        localPromise->MaybeResolve(fd);
+      } else if (StringBeginsWith(mMimeType,
+                                  NS_LITERAL_CSTRING("application/x-www-form-urlencoded"))) {
+        nsRefPtr<URLSearchParams> params = new URLSearchParams();
+        params->ParseInput(data, /* aObserver */ nullptr);
+
+        nsRefPtr<nsFormData> fd = new nsFormData(DerivedClass()->GetParentObject());
+        params->ForEach(FillFormData, static_cast<void*>(fd));
+        localPromise->MaybeResolve(fd);
+      } else {
+        ErrorResult result;
+        result.ThrowTypeError(MSG_BAD_FORMDATA);
+        localPromise->MaybeReject(result);
+      }
+      return;
+    }
     case CONSUME_TEXT:
       // fall through handles early exit.
     case CONSUME_JSON: {
       StreamDecoder decoder;
       decoder.AppendText(reinterpret_cast<char*>(aResult), aResultLength);
 
       nsString& decoded = decoder.GetText();
       if (mConsumeType == CONSUME_TEXT) {
--- a/dom/fetch/Fetch.h
+++ b/dom/fetch/Fetch.h
@@ -109,16 +109,22 @@ public:
 
   already_AddRefed<Promise>
   Blob(ErrorResult& aRv)
   {
     return ConsumeBody(CONSUME_BLOB, aRv);
   }
 
   already_AddRefed<Promise>
+  FormData(ErrorResult& aRv)
+  {
+    return ConsumeBody(CONSUME_FORMDATA, aRv);
+  }
+
+  already_AddRefed<Promise>
   Json(ErrorResult& aRv)
   {
     return ConsumeBody(CONSUME_JSON, aRv);
   }
 
   already_AddRefed<Promise>
   Text(ErrorResult& aRv)
   {
@@ -155,17 +161,17 @@ protected:
 
   void
   SetMimeType(ErrorResult& aRv);
 private:
   enum ConsumeType
   {
     CONSUME_ARRAYBUFFER,
     CONSUME_BLOB,
-    // FormData not supported right now,
+    CONSUME_FORMDATA,
     CONSUME_JSON,
     CONSUME_TEXT,
   };
 
   Derived*
   DerivedClass() const
   {
     return static_cast<Derived*>(const_cast<FetchBody*>(this));
--- a/dom/tests/mochitest/fetch/fetch_test_framework.js
+++ b/dom/tests/mochitest/fetch/fetch_test_framework.js
@@ -42,45 +42,8 @@ function testScript(script) {
       info(e.message);
       return Promise.resolve();
     })
     .then(function() {
       SimpleTest.finish();
     });
 }
 
-// Utilities
-// =========
-
-// Helper that uses FileReader or FileReaderSync based on context and returns
-// a Promise that resolves with the text or rejects with error.
-function readAsText(blob) {
-  if (typeof FileReader !== "undefined") {
-    return new Promise(function(resolve, reject) {
-      var fs = new FileReader();
-      fs.onload = function() {
-        resolve(fs.result);
-      }
-      fs.onerror = reject;
-      fs.readAsText(blob);
-    });
-  } else {
-    var fs = new FileReaderSync();
-    return Promise.resolve(fs.readAsText(blob));
-  }
-}
-
-function readAsArrayBuffer(blob) {
-  if (typeof FileReader !== "undefined") {
-    return new Promise(function(resolve, reject) {
-      var fs = new FileReader();
-      fs.onload = function() {
-        resolve(fs.result);
-      }
-      fs.onerror = reject;
-      fs.readAsArrayBuffer(blob);
-    });
-  } else {
-    var fs = new FileReaderSync();
-    return Promise.resolve(fs.readAsArrayBuffer(blob));
-  }
-}
-
--- a/dom/tests/mochitest/fetch/mochitest.ini
+++ b/dom/tests/mochitest/fetch/mochitest.ini
@@ -1,18 +1,21 @@
 [DEFAULT]
 support-files =
   fetch_test_framework.js
   test_fetch_basic.js
   test_fetch_basic_http.js
   test_fetch_cors.js
+  test_formdataparsing.js
   test_headers_common.js
   test_request.js
   test_response.js
+  utils.js
   worker_wrapper.js
 
 [test_headers.html]
 [test_headers_mainthread.html]
 [test_fetch_basic.html]
 [test_fetch_basic_http.html]
 [test_fetch_cors.html]
+[test_formdataparsing.html]
 [test_request.html]
 [test_response.html]
--- a/dom/tests/mochitest/fetch/test_fetch_basic.html
+++ b/dom/tests/mochitest/fetch/test_fetch_basic.html
@@ -8,15 +8,16 @@
   <title>Bug 1039846 - Test fetch() function in worker</title>
   <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
 </head>
 <body>
 <p id="display"></p>
 <div id="content" style="display: none"></div>
 <pre id="test"></pre>
+<script type="text/javascript" src="utils.js"> </script>
 <script type="text/javascript" src="fetch_test_framework.js"> </script>
 <script class="testbody" type="text/javascript">
 testScript("test_fetch_basic.js");
 </script>
 </body>
 </html>
 
--- a/dom/tests/mochitest/fetch/test_fetch_basic_http.html
+++ b/dom/tests/mochitest/fetch/test_fetch_basic_http.html
@@ -8,15 +8,16 @@
   <title>Bug 1039846 - Test fetch() http fetching in worker</title>
   <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
 </head>
 <body>
 <p id="display"></p>
 <div id="content" style="display: none"></div>
 <pre id="test"></pre>
+<script type="text/javascript" src="utils.js"> </script>
 <script type="text/javascript" src="fetch_test_framework.js"> </script>
 <script class="testbody" type="text/javascript">
 testScript("test_fetch_basic_http.js");
 </script>
 </body>
 </html>
 
--- a/dom/tests/mochitest/fetch/test_fetch_cors.html
+++ b/dom/tests/mochitest/fetch/test_fetch_cors.html
@@ -8,15 +8,16 @@
   <title>Bug 1039846 - Test fetch() CORS mode</title>
   <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
 </head>
 <body>
 <p id="display"></p>
 <div id="content" style="display: none"></div>
 <pre id="test"></pre>
+<script type="text/javascript" src="utils.js"> </script>
 <script type="text/javascript" src="fetch_test_framework.js"> </script>
 <script class="testbody" type="text/javascript">
 testScript("test_fetch_cors.js");
 </script>
 </body>
 </html>
 
new file mode 100644
--- /dev/null
+++ b/dom/tests/mochitest/fetch/test_formdataparsing.html
@@ -0,0 +1,23 @@
+<!--
+  Any copyright is dedicated to the Public Domain.
+  http://creativecommons.org/publicdomain/zero/1.0/
+-->
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>Bug 1109751 - Test FormData parsing</title>
+  <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<div id="content" style="display: none"></div>
+<pre id="test"></pre>
+<script type="text/javascript" src="utils.js"> </script>
+<script type="text/javascript" src="fetch_test_framework.js"> </script>
+<script class="testbody" type="text/javascript">
+testScript("test_formdataparsing.js");
+</script>
+</body>
+</html>
+
new file mode 100644
--- /dev/null
+++ b/dom/tests/mochitest/fetch/test_formdataparsing.js
@@ -0,0 +1,283 @@
+var boundary = "1234567891011121314151617";
+
+// fn(body) should create a Body subclass with content body treated as
+// FormData and return it.
+function testFormDataParsing(fn) {
+
+  function makeTest(shouldPass, input, testFn) {
+    var obj = fn(input);
+    return obj.formData().then(function(fd) {
+      ok(shouldPass, "Expected test to be valid FormData for " + input);
+      if (testFn) {
+        return testFn(fd);
+      }
+    }, function(e) {
+      if (shouldPass) {
+        ok(false, "Expected test to pass for " + input);
+      } else {
+        ok(e.name == "TypeError", "Error should be a TypeError.");
+      }
+    });
+  }
+
+  // [shouldPass?, input, testFn]
+  var tests =
+    [
+      [ true,
+
+        boundary +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          is(fd.get("greeting"), '"hello"');
+        }
+      ],
+      [ false,
+
+        // Invalid disposition.
+        boundary +
+        '\r\nContent-Disposition: form-datafoobar; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ true,
+
+        '--' +
+        boundary +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          is(fd.get("greeting"), '"hello"');
+        }
+      ],
+      [ false,
+        boundary + "\r\n\r\n" + boundary + '-',
+      ],
+      [ false,
+        // No valid ending.
+        boundary + "\r\n\r\n" + boundary,
+      ],
+      [ false,
+
+        // One '-' prefix is not allowed. 2 or none.
+        '-' +
+        boundary +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        'invalid' +
+        boundary +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary + 'suffix' +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary + 'suffix' +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        // Partial boundary
+        boundary.substr(3) +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // Missing '\n' at beginning.
+        '\rContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // No form-data.
+        '\r\nContent-Disposition: mixed; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // No headers.
+        '\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // No content-disposition.
+        '\r\nContent-Dispositypo: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // No name.
+        '\r\nContent-Disposition: form-data;\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // Missing empty line between headers and body.
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        // Empty entry followed by valid entry.
+        boundary + "\r\n\r\n" + boundary +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // Header followed by empty line, but empty body not followed by
+        // newline.
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n' +
+        boundary + '-',
+      ],
+      [ true,
+
+        boundary +
+        // Empty body followed by newline.
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          is(fd.get("greeting"), "", "Empty value is allowed.");
+        }
+      ],
+      [ false,
+        boundary +
+        // Value is boundary itself.
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n' +
+        boundary + '\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+        boundary +
+        // Variant of above with no valid ending boundary.
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n' +
+        boundary
+      ],
+      [ true,
+        boundary +
+        // Unquoted filename with empty body.
+        '\r\nContent-Disposition: form-data; name="file"; filename=file1.txt\r\n\r\n\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          var f = fd.get("file");
+          ok(f instanceof File, "Entry with filename attribute should be read as File.");
+          is(f.name, "file1.txt", "Filename should match.");
+          is(f.type, "text/plain", "Default content-type should be text/plain.");
+          return readAsText(f).then(function(text) {
+            is(text, "", "File should be empty.");
+          });
+        }
+      ],
+      [ true,
+        boundary +
+        // Quoted filename with empty body.
+        '\r\nContent-Disposition: form-data; name="file"; filename="file1.txt"\r\n\r\n\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          var f = fd.get("file");
+          ok(f instanceof File, "Entry with filename attribute should be read as File.");
+          is(f.name, "file1.txt", "Filename should match.");
+          is(f.type, "text/plain", "Default content-type should be text/plain.");
+          return readAsText(f).then(function(text) {
+            is(text, "", "File should be empty.");
+          });
+        }
+      ],
+      [ false,
+        boundary +
+        // Invalid filename
+        '\r\nContent-Disposition: form-data; name="file"; filename="[\n@;xt"\r\n\r\n\r\n' +
+        boundary + '-',
+      ],
+      [ true,
+        boundary +
+        '\r\nContent-Disposition: form-data; name="file"; filename="[@;xt"\r\n\r\n\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          var f = fd.get("file");
+          ok(f instanceof File, "Entry with filename attribute should be read as File.");
+          is(f.name, "[@", "Filename should match.");
+        }
+      ],
+      [ true,
+        boundary +
+        '\r\nContent-Disposition: form-data; name="file"; filename="file with   spaces"\r\n\r\n\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          var f = fd.get("file");
+          ok(f instanceof File, "Entry with filename attribute should be read as File.");
+          is(f.name, "file with spaces", "Filename should match.");
+        }
+      ],
+      [ true,
+        boundary + '\r\n' +
+        'Content-Disposition: form-data; name="file"; filename="xml.txt"\r\n' +
+        'content-type       : application/xml\r\n' +
+        '\r\n' +
+        '<body>foobar\r\n\r\n</body>\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          var f = fd.get("file");
+          ok(f instanceof File, "Entry with filename attribute should be read as File.");
+          is(f.name, "xml.txt", "Filename should match.");
+          is(f.type, "application/xml", "content-type should be application/xml.");
+          return readAsText(f).then(function(text) {
+            is(text, "<body>foobar\r\n\r\n</body>", "File should have correct text.");
+          });
+        }
+      ],
+    ];
+
+  var promises = [];
+  for (var i = 0; i < tests.length; ++i) {
+    var test = tests[i];
+    promises.push(makeTest(test[0], test[1], test[2]));
+  }
+
+  return Promise.all(promises);
+}
+
+function makeRequest(body) {
+  var req = new Request("", { method: 'post', body: body,
+                              headers: {
+                                'Content-Type': 'multipart/form-data; boundary=' + boundary
+                              }});
+  return req;
+}
+
+function makeResponse(body) {
+  var res = new Response(body, { headers: {
+                                   'Content-Type': 'multipart/form-data; boundary=' + boundary
+                                 }});
+  return res;
+}
+
+function runTest() {
+  return Promise.all([testFormDataParsing(makeRequest),
+                      testFormDataParsing(makeResponse)]);
+}
--- a/dom/tests/mochitest/fetch/test_headers.html
+++ b/dom/tests/mochitest/fetch/test_headers.html
@@ -3,14 +3,15 @@
 <!DOCTYPE HTML>
 <html>
 <head>
   <title>Test Fetch Headers - Basic</title>
   <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
 </head>
 <body>
+<script type="text/javascript" src="utils.js"> </script>
 <script type="text/javascript" src="fetch_test_framework.js"> </script>
 <script class="testbody" type="text/javascript">
 testScript("test_headers_common.js");
 </script>
 </body>
 </html>
--- a/dom/tests/mochitest/fetch/test_request.html
+++ b/dom/tests/mochitest/fetch/test_request.html
@@ -8,15 +8,16 @@
   <title>Bug XXXXXX - Test Request object in worker</title>
   <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
 </head>
 <body>
 <p id="display"></p>
 <div id="content" style="display: none"></div>
 <pre id="test"></pre>
+<script type="text/javascript" src="utils.js"> </script>
 <script type="text/javascript" src="fetch_test_framework.js"> </script>
 <script class="testbody" type="text/javascript">
 testScript("test_request.js");
 </script>
 </body>
 </html>
 
--- a/dom/tests/mochitest/fetch/test_request.js
+++ b/dom/tests/mochitest/fetch/test_request.js
@@ -215,18 +215,18 @@ function testBodyUsed() {
     return req.blob().then((v) => {
       ok(false, "Attempting to read body again should fail.");
     }, (e) => {
       ok(true, "Attempting to read body again should fail.");
     })
   });
 }
 
+var text = "κόσμε";
 function testBodyCreation() {
-  var text = "κόσμε";
   var req1 = new Request("", { method: 'post', body: text });
   var p1 = req1.text().then(function(v) {
     ok(typeof v === "string", "Should resolve to string");
     is(text, v, "Extracted string should match");
   });
 
   var req2 = new Request("", { method: 'post', body: new Uint8Array([72, 101, 108, 108, 111]) });
   var p2 = req2.text().then(function(v) {
@@ -238,31 +238,66 @@ function testBodyCreation() {
     is("Hello", v, "Extracted string should match");
   });
 
   var reqblob = new Request("", { method: 'post', body: new Blob([text]) });
   var pblob = reqblob.text().then(function(v) {
     is(v, text, "Extracted string should match");
   });
 
+  // FormData has its own function since it has blobs and files.
+
   var params = new URLSearchParams();
   params.append("item", "Geckos");
   params.append("feature", "stickyfeet");
   params.append("quantity", "700");
   var req3 = new Request("", { method: 'post', body: params });
   var p3 = req3.text().then(function(v) {
     var extracted = new URLSearchParams(v);
     is(extracted.get("item"), "Geckos", "Param should match");
     is(extracted.get("feature"), "stickyfeet", "Param should match");
     is(extracted.get("quantity"), "700", "Param should match");
   });
 
   return Promise.all([p1, p2, p2b, pblob, p3]);
 }
 
+function testFormDataBodyCreation() {
+  var f1 = new FormData();
+  f1.append("key", "value");
+  f1.append("foo", "bar");
+
+  var r1 = new Request("", { method: 'post', body: f1 })
+  // Since f1 is serialized immediately, later additions should not show up.
+  f1.append("more", "stuff");
+  var p1 = r1.formData().then(function(fd) {
+    ok(fd instanceof FormData, "Valid FormData extracted.");
+    ok(fd.has("key"), "key should exist.");
+    ok(fd.has("foo"), "foo should exist.");
+    ok(!fd.has("more"), "more should not exist.");
+  });
+
+  f1.append("blob", new Blob([text]));
+  var r2 = new Request("", { method: 'post', body: f1 });
+  f1.delete("key");
+  var p2 = r2.formData().then(function(fd) {
+    ok(fd instanceof FormData, "Valid FormData extracted.");
+    ok(fd.has("more"), "more should exist.");
+
+    var b = fd.get("blob");
+    ok(b instanceof Blob, "blob entry should be a Blob.");
+
+    return readAsText(b).then(function(output) {
+      is(output, text, "Blob contents should match.");
+    });
+  });
+
+  return Promise.all([p1, p2]);
+}
+
 function testBodyExtraction() {
   var text = "κόσμε";
   var newReq = function() { return new Request("", { method: 'post', body: text }); }
   return newReq().text().then(function(v) {
     ok(typeof v === "string", "Should resolve to string");
     is(text, v, "Extracted string should match");
   }).then(function() {
     return newReq().blob().then(function(v) {
@@ -278,17 +313,79 @@ function testBodyExtraction() {
       ok(true, "Invalid json should reject");
     })
   }).then(function() {
     return newReq().arrayBuffer().then(function(v) {
       ok(v instanceof ArrayBuffer, "Should resolve to ArrayBuffer");
       var dec = new TextDecoder();
       is(dec.decode(new Uint8Array(v)), text, "UTF-8 decoded ArrayBuffer should match original");
     });
-  })
+  }).then(function() {
+    return newReq().formData().then(function(v) {
+      ok(false, "invalid FormData read should fail.");
+    }, function(e) {
+      ok(e.name == "TypeError", "invalid FormData read should fail.");
+    });
+  });
+}
+
+function testFormDataBodyExtraction() {
+  // URLSearchParams translates to application/x-www-form-urlencoded.
+  var params = new URLSearchParams();
+  params.append("item", "Geckos");
+  params.append("feature", "stickyfeet");
+  params.append("quantity", "700");
+  params.append("quantity", "800");
+
+  var req = new Request("", { method: 'POST', body: params });
+  var p1 = req.formData().then(function(fd) {
+    ok(fd.has("item"));
+    ok(fd.has("feature"));
+    var entries = fd.getAll("quantity");
+    is(entries.length, 2, "Entries with same name are correctly handled.");
+    is(entries[0], "700", "Entries with same name are correctly handled.");
+    is(entries[1], "800", "Entries with same name are correctly handled.");
+  });
+
+  var f1 = new FormData();
+  f1.append("key", "value");
+  f1.append("foo", "bar");
+  f1.append("blob", new Blob([text]));
+  var r2 = new Request("", { method: 'post', body: f1 });
+  var p2 = r2.formData().then(function(fd) {
+    ok(fd.has("key"));
+    ok(fd.has("foo"));
+    ok(fd.has("blob"));
+    var entries = fd.getAll("blob");
+    is(entries.length, 1, "getAll returns all items.");
+    is(entries[0].name, "blob", "Filename should be blob.");
+  });
+
+  var ws = "\r\n\r\n\r\n\r\n";
+  f1.set("key", new File([ws], 'file name has spaces.txt', { type: 'new/lines' }));
+  var r3 = new Request("", { method: 'post', body: f1 });
+  var p3 = r3.formData().then(function(fd) {
+    ok(fd.has("foo"));
+    ok(fd.has("blob"));
+    var entries = fd.getAll("blob");
+    is(entries.length, 1, "getAll returns all items.");
+    is(entries[0].name, "blob", "Filename should be blob.");
+
+    ok(fd.has("key"));
+    var f = fd.get("key");
+    ok(f instanceof File, "entry should be a File.");
+    is(f.name, "file name has spaces.txt", "File name should match.");
+    is(f.type, "new/lines", "File type should match.");
+    is(f.size, ws.length, "File size should match.");
+    return readAsText(f).then(function(text) {
+      is(text, ws, "File contents should match.");
+    });
+  });
+
+  return Promise.all([p1, p2, p3]);
 }
 
 // mode cannot be set to "CORS-with-forced-preflight" from javascript.
 function testModeCorsPreflightEnumValue() {
   try {
     var r = new Request(".", { mode: "cors-with-forced-preflight" });
     ok(false, "Creating Request with mode cors-with-forced-preflight should fail.");
   } catch(e) {
@@ -315,12 +412,14 @@ function runTest() {
   testMethod();
   testBug1109574();
   testModeCorsPreflightEnumValue();
 
   return Promise.resolve()
     .then(testBodyCreation)
     .then(testBodyUsed)
     .then(testBodyExtraction)
+    .then(testFormDataBodyCreation)
+    .then(testFormDataBodyExtraction)
     .then(testUsedRequest)
     .then(testClone())
     // Put more promise based tests here.
 }
--- a/dom/tests/mochitest/fetch/test_response.html
+++ b/dom/tests/mochitest/fetch/test_response.html
@@ -8,15 +8,16 @@
   <title>Bug 1039846 - Test Response object in worker</title>
   <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
 </head>
 <body>
 <p id="display"></p>
 <div id="content" style="display: none"></div>
 <pre id="test"></pre>
+<script type="text/javascript" src="utils.js"> </script>
 <script type="text/javascript" src="fetch_test_framework.js"> </script>
 <script class="testbody" type="text/javascript">
 testScript("test_response.js");
 </script>
 </body>
 </html>
 
new file mode 100644
--- /dev/null
+++ b/dom/tests/mochitest/fetch/utils.js
@@ -0,0 +1,37 @@
+// Utilities
+// =========
+
+// Helper that uses FileReader or FileReaderSync based on context and returns
+// a Promise that resolves with the text or rejects with error.
+function readAsText(blob) {
+  if (typeof FileReader !== "undefined") {
+    return new Promise(function(resolve, reject) {
+      var fs = new FileReader();
+      fs.onload = function() {
+        resolve(fs.result);
+      }
+      fs.onerror = reject;
+      fs.readAsText(blob);
+    });
+  } else {
+    var fs = new FileReaderSync();
+    return Promise.resolve(fs.readAsText(blob));
+  }
+}
+
+function readAsArrayBuffer(blob) {
+  if (typeof FileReader !== "undefined") {
+    return new Promise(function(resolve, reject) {
+      var fs = new FileReader();
+      fs.onload = function() {
+        resolve(fs.result);
+      }
+      fs.onerror = reject;
+      fs.readAsArrayBuffer(blob);
+    });
+  } else {
+    var fs = new FileReaderSync();
+    return Promise.resolve(fs.readAsArrayBuffer(blob));
+  }
+}
+
--- a/dom/tests/mochitest/fetch/worker_wrapper.js
+++ b/dom/tests/mochitest/fetch/worker_wrapper.js
@@ -1,8 +1,10 @@
+importScripts("utils.js");
+
 function ok(a, msg) {
   postMessage({type: 'status', status: !!a, msg: a + ": " + msg });
 }
 
 function is(a, b, msg) {
   postMessage({type: 'status', status: a === b, msg: a + " === " + b + ": " + msg });
 }
 
--- a/dom/webidl/Fetch.webidl
+++ b/dom/webidl/Fetch.webidl
@@ -12,18 +12,18 @@ typedef (ArrayBuffer or ArrayBufferView 
 
 [NoInterfaceObject, Exposed=(Window,Worker)]
 interface Body {
   readonly attribute boolean bodyUsed;
   [Throws]
   Promise<ArrayBuffer> arrayBuffer();
   [Throws]
   Promise<Blob> blob();
-  // FIXME(nsm): Bug 739173 FormData is not supported in workers.
-  // Promise<FormData> formData();
+  [Throws]
+  Promise<FormData> formData();
   [Throws]
   Promise<JSON> json();
   [Throws]
   Promise<USVString> text();
 };
 
 [NoInterfaceObject, Exposed=(Window,Worker)]
 interface GlobalFetch {