diff --git a/BUILD.gn b/BUILD.gn index 6f814c6023..2146828025 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -87,10 +87,12 @@ ts_sources = [ "js/dom_types.ts", "js/errors.ts", "js/fetch.ts", + "js/file.ts", "js/headers.ts", "js/file_info.ts", "js/files.ts", "js/flatbuffers.ts", + "js/form_data.ts", "js/global_eval.ts", "js/globals.ts", "js/io.ts", diff --git a/js/blob.ts b/js/blob.ts index b57452dd59..8dcc48ba27 100644 --- a/js/blob.ts +++ b/js/blob.ts @@ -97,6 +97,12 @@ function toUint8Arrays( ret.push(element[bytesSymbol]); } else if (element instanceof Uint8Array) { ret.push(element); + } else if (element instanceof Uint16Array) { + const uint8 = new Uint8Array(element.buffer); + ret.push(uint8); + } else if (element instanceof Uint32Array) { + const uint8 = new Uint8Array(element.buffer); + ret.push(uint8); } else if (ArrayBuffer.isView(element)) { // Convert view to Uint8Array. const uint8 = new Uint8Array(element.buffer); @@ -105,6 +111,8 @@ function toUint8Arrays( // Create a new Uint8Array view for the given ArrayBuffer. const uint8 = new Uint8Array(element); ret.push(uint8); + } else { + ret.push(enc.encode(String(element))); } } return ret; diff --git a/js/dom_types.ts b/js/dom_types.ts index bd73639ceb..19a3d5fe2a 100644 --- a/js/dom_types.ts +++ b/js/dom_types.ts @@ -34,7 +34,7 @@ type ReferrerPolicy = | "origin-when-cross-origin" | "unsafe-url"; export type BlobPart = BufferSource | Blob | string; -type FormDataEntryValue = File | string; +export type FormDataEntryValue = File | string; export type EventListenerOrEventListenerObject = | EventListener | EventListenerObject; @@ -173,7 +173,7 @@ interface Event { readonly NONE: number; } -interface File extends Blob { +export interface File extends Blob { readonly lastModified: number; readonly name: string; } @@ -242,22 +242,18 @@ interface ReadableStreamReader { releaseLock(): void; } -export interface FormData { +export interface FormData extends DomIterable { append(name: string, value: string | Blob, fileName?: string): void; delete(name: string): void; get(name: string): FormDataEntryValue | null; getAll(name: string): FormDataEntryValue[]; has(name: string): boolean; set(name: string, value: string | Blob, fileName?: string): void; - forEach( - callbackfn: ( - value: FormDataEntryValue, - key: string, - parent: FormData - ) => void, - // tslint:disable-next-line:no-any - thisArg?: any - ): void; +} + +export interface FormDataConstructor { + new (): FormData; + prototype: FormData; } /** A blob object represents a file-like object of immutable, raw data. */ diff --git a/js/file.ts b/js/file.ts new file mode 100644 index 0000000000..8496d6edbd --- /dev/null +++ b/js/file.ts @@ -0,0 +1,24 @@ +// Copyright 2018 the Deno authors. All rights reserved. MIT license. +import * as domTypes from "./dom_types"; +import * as blob from "./blob"; + +export class DenoFile extends blob.DenoBlob implements domTypes.File { + lastModified: number; + name: string; + + constructor( + fileBits: domTypes.BlobPart[], + fileName: string, + options?: domTypes.FilePropertyBag + ) { + options = options || {}; + super(fileBits, options); + + // 4.1.2.1 Replace any "/" character (U+002F SOLIDUS) + // with a ":" (U + 003A COLON) + this.name = String(fileName).replace(/\u002F/g, "\u003A"); + // 4.1.3.3 If lastModified is not provided, set lastModified to the current + // date and time represented in number of milliseconds since the Unix Epoch. + this.lastModified = options.lastModified || Date.now(); + } +} diff --git a/js/file_test.ts b/js/file_test.ts new file mode 100644 index 0000000000..a32c06947b --- /dev/null +++ b/js/file_test.ts @@ -0,0 +1,104 @@ +// Copyright 2018 the Deno authors. All rights reserved. MIT license. +import { test, assert, assertEqual } from "./test_util.ts"; + +function testFirstArgument(arg1, expectedSize) { + const file = new File(arg1, "name"); + assert(file instanceof File); + assertEqual(file.name, "name"); + assertEqual(file.size, expectedSize); + assertEqual(file.type, ""); +} + +test(function fileEmptyFileBits() { + testFirstArgument([], 0); +}); + +test(function fileStringFileBits() { + testFirstArgument(["bits"], 4); +}); + +test(function fileUnicodeStringFileBits() { + testFirstArgument(["𝓽𝓮𝔁𝓽"], 16); +}); + +test(function fileStringObjectFileBits() { + // tslint:disable-next-line no-construct + testFirstArgument([new String("string object")], 13); +}); + +test(function fileEmptyBlobFileBits() { + testFirstArgument([new Blob()], 0); +}); + +test(function fileBlobFileBits() { + testFirstArgument([new Blob(["bits"])], 4); +}); + +test(function fileEmptyFileFileBits() { + testFirstArgument([new File([], "world.txt")], 0); +}); + +test(function fileFileFileBits() { + testFirstArgument([new File(["bits"], "world.txt")], 4); +}); + +test(function fileArrayBufferFileBits() { + testFirstArgument([new ArrayBuffer(8)], 8); +}); + +test(function fileTypedArrayFileBits() { + testFirstArgument([new Uint8Array([0x50, 0x41, 0x53, 0x53])], 4); +}); + +test(function fileVariousFileBits() { + testFirstArgument( + [ + "bits", + new Blob(["bits"]), + new Blob(), + new Uint8Array([0x50, 0x41]), + new Uint16Array([0x5353]), + new Uint32Array([0x53534150]) + ], + 16 + ); +}); + +test(function fileNumberInFileBits() { + testFirstArgument([12], 2); +}); + +test(function fileArrayInFileBits() { + testFirstArgument([[1, 2, 3]], 5); +}); + +test(function fileObjectInFileBits() { + // "[object Object]" + testFirstArgument([{}], 15); +}); + +function testSecondArgument(arg2, expectedFileName) { + const file = new File(["bits"], arg2); + assert(file instanceof File); + assertEqual(file.name, expectedFileName); +} + +test(function fileUsingFileName() { + testSecondArgument("dummy", "dummy"); +}); + +test(function fileUsingSpecialCharacterInFileName() { + testSecondArgument("dummy/foo", "dummy:foo"); +}); + +test(function fileUsingNullFileName() { + testSecondArgument(null, "null"); +}); + +test(function fileUsingNumberFileName() { + testSecondArgument(1, "1"); +}); + +test(function fileUsingEmptyStringFileName() { + testSecondArgument("", ""); +}); diff --git a/js/form_data.ts b/js/form_data.ts new file mode 100644 index 0000000000..6626435201 --- /dev/null +++ b/js/form_data.ts @@ -0,0 +1,107 @@ +// Copyright 2018 the Deno authors. All rights reserved. MIT license. +import * as domTypes from "./dom_types"; +import * as blob from "./blob"; +import * as file from "./file"; +import { DomIterableMixin } from "./mixins/dom_iterable"; + +const dataSymbol = Symbol("data"); + +class FormDataBase { + private [dataSymbol]: Array<[string, domTypes.FormDataEntryValue]> = []; + + /** Appends a new value onto an existing key inside a `FormData` + * object, or adds the key if it does not already exist. + * + * formData.append('name', 'first'); + * formData.append('name', 'second'); + */ + append(name: string, value: string): void; + append(name: string, value: blob.DenoBlob, filename?: string): void; + append(name: string, value: string | blob.DenoBlob, filename?: string): void { + if (value instanceof blob.DenoBlob) { + const dfile = new file.DenoFile([value], filename || name); + this[dataSymbol].push([name, dfile]); + } else { + this[dataSymbol].push([name, String(value)]); + } + } + + /** Deletes a key/value pair from a `FormData` object. + * + * formData.delete('name'); + */ + delete(name: string): void { + let i = 0; + while (i < this[dataSymbol].length) { + if (this[dataSymbol][i][0] === name) { + this[dataSymbol].splice(i, 1); + } else { + i++; + } + } + } + + /** Returns an array of all the values associated with a given key + * from within a `FormData`. + * + * formData.getAll('name'); + */ + getAll(name: string): domTypes.FormDataEntryValue[] { + const values = []; + for (const entry of this[dataSymbol]) { + if (entry[0] === name) { + values.push(entry[1]); + } + } + + return values; + } + + /** Returns the first value associated with a given key from within a + * `FormData` object. + * + * formData.get('name'); + */ + get(name: string): domTypes.FormDataEntryValue | null { + for (const entry of this[dataSymbol]) { + if (entry[0] === name) { + return entry[1]; + } + } + + return null; + } + + /** Returns a boolean stating whether a `FormData` object contains a + * certain key/value pair. + * + * formData.has('name'); + */ + has(name: string): boolean { + return this[dataSymbol].some(entry => entry[0] === name); + } + + /** Sets a new value for an existing key inside a `FormData` object, or + * adds the key/value if it does not already exist. + * + * formData.set('name', 'value'); + */ + set(name: string, value: string): void; + set(name: string, value: blob.DenoBlob, filename?: string): void; + set(name: string, value: string | blob.DenoBlob, filename?: string): void { + this.delete(name); + if (value instanceof blob.DenoBlob) { + const dfile = new file.DenoFile([value], filename || name); + this[dataSymbol].push([name, dfile]); + } else { + this[dataSymbol].push([name, String(value)]); + } + } +} + +// tslint:disable-next-line:variable-name +export const FormData = DomIterableMixin< + string, + domTypes.FormDataEntryValue, + typeof FormDataBase +>(FormDataBase, dataSymbol); diff --git a/js/form_data_test.ts b/js/form_data_test.ts new file mode 100644 index 0000000000..04a05acaf5 --- /dev/null +++ b/js/form_data_test.ts @@ -0,0 +1,92 @@ +// Copyright 2018 the Deno authors. All rights reserved. MIT license. +import { test, assert, assertEqual } from "./test_util.ts"; + +test(function formDataParamsAppendSuccess() { + const formData = new FormData(); + formData.append("a", "true"); + assertEqual(formData.get("a"), "true"); +}); + +test(function formDataParamsDeleteSuccess() { + const formData = new FormData(); + formData.append("a", "true"); + formData.append("b", "false"); + assertEqual(formData.get("b"), "false"); + formData.delete("b"); + assertEqual(formData.get("a"), "true"); + assertEqual(formData.get("b"), null); +}); + +test(function formDataParamsGetAllSuccess() { + const formData = new FormData(); + formData.append("a", "true"); + formData.append("b", "false"); + formData.append("a", "null"); + assertEqual(formData.getAll("a"), ["true", "null"]); + assertEqual(formData.getAll("b"), ["false"]); + assertEqual(formData.getAll("c"), []); +}); + +test(function formDataParamsGetSuccess() { + const formData = new FormData(); + formData.append("a", "true"); + formData.append("b", "false"); + formData.append("a", "null"); + formData.append("d", undefined); + formData.append("e", null); + assertEqual(formData.get("a"), "true"); + assertEqual(formData.get("b"), "false"); + assertEqual(formData.get("c"), null); + assertEqual(formData.get("d"), "undefined"); + assertEqual(formData.get("e"), "null"); +}); + +test(function formDataParamsHasSuccess() { + const formData = new FormData(); + formData.append("a", "true"); + formData.append("b", "false"); + assert(formData.has("a")); + assert(formData.has("b")); + assert(!formData.has("c")); +}); + +test(function formDataParamsSetSuccess() { + const formData = new FormData(); + formData.append("a", "true"); + formData.append("b", "false"); + formData.append("a", "null"); + assertEqual(formData.getAll("a"), ["true", "null"]); + assertEqual(formData.getAll("b"), ["false"]); + formData.set("a", "false"); + assertEqual(formData.getAll("a"), ["false"]); + formData.set("d", undefined); + assertEqual(formData.get("d"), "undefined"); + formData.set("e", null); + assertEqual(formData.get("e"), "null"); +}); + +test(function formDataSetEmptyBlobSuccess() { + const formData = new FormData(); + formData.set("a", new Blob([]), "blank.txt"); + const file = formData.get("a"); + assert(file instanceof File); + if (typeof file !== "string") { + assertEqual(file.name, "blank.txt"); + } +}); + +test(function formDataParamsForEachSuccess() { + const init = [["a", "54"], ["b", "true"]]; + const formData = new FormData(); + for (const [name, value] of init) { + formData.append(name, value); + } + let callNum = 0; + formData.forEach((value, key, parent) => { + assertEqual(formData, parent); + assertEqual(value, init[callNum][1]); + assertEqual(key, init[callNum][0]); + callNum++; + }); + assertEqual(callNum, init.length); +}); diff --git a/js/globals.ts b/js/globals.ts index a09e6ed9b4..5a0ca7cc6e 100644 --- a/js/globals.ts +++ b/js/globals.ts @@ -1,5 +1,7 @@ // Copyright 2018 the Deno authors. All rights reserved. MIT license. import * as blob from "./blob"; +import * as file from "./file"; +import * as formdata from "./form_data"; import * as console_ from "./console"; import * as fetch_ from "./fetch"; import { Headers } from "./headers"; @@ -44,3 +46,5 @@ window.fetch = fetch_.fetch; // runtime library window.Headers = Headers as domTypes.HeadersConstructor; window.Blob = blob.DenoBlob; +window.File = file.DenoFile; +window.FormData = formdata.FormData as domTypes.FormDataConstructor; diff --git a/js/mixins/dom_iterable.ts b/js/mixins/dom_iterable.ts index 0152265893..eb5d08d13d 100644 --- a/js/mixins/dom_iterable.ts +++ b/js/mixins/dom_iterable.ts @@ -21,22 +21,27 @@ export function DomIterableMixin( // Base class in a way where the Symbol `dataSymbol` is defined. So the // runtime code works, but we do lose a little bit of type safety. + // Additionally, we have to not use .keys() nor .values() since the internal + // slot differs in type - some have a Map, which yields [K, V] in + // Symbol.iterator, and some have an Array, which yields V, in this case + // [K, V] too as they are arrays of tuples. + // tslint:disable-next-line:variable-name const DomIterable = class extends Base { *entries(): IterableIterator<[K, V]> { - for (const entry of (this as any)[dataSymbol].entries()) { + for (const entry of (this as any)[dataSymbol]) { yield entry; } } *keys(): IterableIterator { - for (const key of (this as any)[dataSymbol].keys()) { + for (const [key] of (this as any)[dataSymbol]) { yield key; } } *values(): IterableIterator { - for (const value of (this as any)[dataSymbol].values()) { + for (const [, value] of (this as any)[dataSymbol]) { yield value; } } @@ -47,7 +52,7 @@ export function DomIterableMixin( thisArg?: any ): void { callbackfn = callbackfn.bind(thisArg == null ? window : Object(thisArg)); - for (const [key, value] of (this as any)[dataSymbol].entries()) { + for (const [key, value] of (this as any)[dataSymbol]) { callbackfn(value, key, this); } } diff --git a/js/unit_tests.ts b/js/unit_tests.ts index fb3d0018ee..8f9f4d0433 100644 --- a/js/unit_tests.ts +++ b/js/unit_tests.ts @@ -10,7 +10,9 @@ import "./console_test.ts"; import "./copy_file_test.ts"; import "./dir_test"; import "./fetch_test.ts"; +import "./file_test.ts"; import "./files_test.ts"; +import "./form_data_test.ts"; import "./headers_test.ts"; import "./make_temp_dir_test.ts"; import "./metrics_test.ts";