diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 762a3da16..1d713745f 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog +##### 1.3.0 (2015-09-01) +- Polish: all serializer methods may return a Promise. +- Feature: Form serializer now accepts `application/x-www-form-urlencoded` or `multipart/form-data`. + + ##### 1.2.5 (2015-08-29) - Polish: drop `node-fetch` as a dependency for testing, instead use `http` module directly. diff --git a/lib/dispatch/create.js b/lib/dispatch/create.js index c0d911f08..7c07eb99f 100644 --- a/lib/dispatch/create.js +++ b/lib/dispatch/create.js @@ -15,11 +15,7 @@ import * as updateHelpers from './update_helpers' */ export default function (context) { const { adapter, serializer, recordTypes, transforms } = this - let records = serializer.parseCreate(context) - - if (!records || !records.length) - throw new BadRequestError( - `There are no valid records in the request.`) + let records const { type, meta } = context.request const transform = transforms[type] @@ -30,14 +26,24 @@ export default function (context) { const updates = {} let transaction - // Delete denormalized inverse fields. - for (let field in fields) - if (fields[field][keys.denormalizedInverse]) - for (let record of records) - delete record[field] + return serializer.parseCreate(context) + + .then(results => { + records = results + + if (!records || !records.length) + throw new BadRequestError( + `There are no valid records in the request.`) - return (transform && transform.input ? Promise.all(records.map(record => - transform.input(context, record))) : Promise.resolve(records)) + // Delete denormalized inverse fields. + for (let field in fields) + if (fields[field][keys.denormalizedInverse]) + for (let record of records) + delete record[field] + + return (transform && transform.input ? Promise.all(records.map(record => + transform.input(context, record))) : Promise.resolve(records)) + }) .then(records => Promise.all(records.map(record => { // Enforce the fields. diff --git a/lib/dispatch/end.js b/lib/dispatch/end.js index bb27ed3fd..501176b35 100644 --- a/lib/dispatch/end.js +++ b/lib/dispatch/end.js @@ -49,8 +49,6 @@ export default function (context) { if (records) args.push(records) if (include) args.push(include) - context = serializer.showResponse(...args) - - return context + return serializer.showResponse(...args) }) } diff --git a/lib/dispatch/index.js b/lib/dispatch/index.js index e144b653b..d4ebaa003 100644 --- a/lib/dispatch/index.js +++ b/lib/dispatch/index.js @@ -11,7 +11,7 @@ export { default as include } from './include' export { default as end } from './end' -const defaultMethod = methods.find +const { find: defaultMethod } = methods /*! @@ -37,8 +37,7 @@ export default function dispatch (options, ...args) { // Make sure that IDs are an array of unique, non-falsy values. if (ids) context.request.ids = - [ ...new Set(Array.isArray(ids) ? ids : [ ids ]) ] - .filter(id => id) + [ ...new Set((Array.isArray(ids) ? ids : [ ids ]).filter(id => id)) ] // If a type is unspecified, block the request. if (type === null && method !== defaultMethod && @@ -65,15 +64,15 @@ export default function dispatch (options, ...args) { return new OK(response) }) - .catch(error => { - context = showError(context, nativeErrors.has(error.constructor) ? - new Error(`An internal server error occurred.`) : error) + .catch(error => Promise.resolve( + showError(context, nativeErrors.has(error.constructor) ? + new Error(`An internal server error occurred.`) : error)) + + .then(context => Promise.resolve(processResponse(context, ...args))) - return Promise.resolve(processResponse(context, ...args)) .then(context => { throw Object.assign(error, context.response) - }) - }) + })) } diff --git a/lib/dispatch/update.js b/lib/dispatch/update.js index 2be0ee637..71756eadd 100644 --- a/lib/dispatch/update.js +++ b/lib/dispatch/update.js @@ -19,7 +19,6 @@ import deepEqualOptions from '../common/deep_equal_options' */ export default function (context) { const { adapter, serializer, recordTypes, transforms } = this - const updates = serializer.parseUpdate(context) // Keyed by update, valued by record. const updateMap = new WeakMap() @@ -36,19 +35,26 @@ export default function (context) { const relatedUpdates = {} const transformedUpdates = [] let transaction + let updates - validateUpdates(updates) + return serializer.parseUpdate(context) - // Delete denormalized inverse fields, can't be updated. - for (let field in fields) - if (fields[field][keys.denormalizedInverse]) - for (let update of updates) { - if ('replace' in update) delete update.replace[field] - if ('push' in update) delete update.push[field] - if ('pull' in update) delete update.pull[field] - } + .then(results => { + updates = results + + validateUpdates(updates) - return adapter.find(type, updates.map(update => update.id), null, meta) + // Delete denormalized inverse fields, can't be updated. + for (let field in fields) + if (fields[field][keys.denormalizedInverse]) + for (let update of updates) { + if ('replace' in update) delete update.replace[field] + if ('push' in update) delete update.push[field] + if ('pull' in update) delete update.pull[field] + } + + return adapter.find(type, updates.map(update => update.id), null, meta) + }) .then(records => Promise.all(records.map(record => { const update = find(updates, update => diff --git a/lib/index.js b/lib/index.js index b846647fe..09fb57c23 100644 --- a/lib/index.js +++ b/lib/index.js @@ -5,13 +5,13 @@ import defineArguments from './common/define_arguments' // Static exports. import memory from './adapter/adapters/memory' import adHoc from './serializer/serializers/ad_hoc' -import formUrlEncoded from './serializer/serializers/form_urlencoded' +import { formUrlEncoded, formData } from './serializer/serializers/form' import http from './net/http' import websocket from './net/websocket' const adapters = { memory } -const serializers = { adHoc, formUrlEncoded } +const serializers = { adHoc, formUrlEncoded, formData } const net = { http, websocket } diff --git a/lib/serializer/index.js b/lib/serializer/index.js index 5242fd1fd..aef808356 100644 --- a/lib/serializer/index.js +++ b/lib/serializer/index.js @@ -87,7 +87,7 @@ export default class Serializer { * @param {Object} context * @param {Object[]} [records] * @param {Object} [include] - * @return {Object} + * @return {Promise|Object} */ showResponse (context) { return context @@ -100,7 +100,7 @@ export default class Serializer { * * @param {Object} context * @param {Object} error should be an instance of Error - * @return {Object} + * @return {Promise|Object} */ showError (context) { return context @@ -113,7 +113,7 @@ export default class Serializer { * It should not mutate the context object. * * @param {Object} context - * @return {Object[]} + * @return {Promise|Object[]} */ parseCreate () { return [] @@ -126,7 +126,7 @@ export default class Serializer { * It should not mutate the context object. * * @param {Object} context - * @return {Object[]} + * @return {Promise|Object[]} */ parseUpdate () { return [] diff --git a/lib/serializer/serializers/form/index.js b/lib/serializer/serializers/form/index.js new file mode 100644 index 000000000..2e88cb09f --- /dev/null +++ b/lib/serializer/serializers/form/index.js @@ -0,0 +1,78 @@ +import Busboy from 'busboy' +import stream from 'stream' + + +const formUrlEncodedType = 'application/x-www-form-urlencoded' +const formDataType = 'multipart/form-data' + + +function inherit (Serializer) { + return class FormSerializer extends Serializer { + + processRequest () { + throw new this.errors.UnsupportedError(`Form input only.`) + } + + parseCreate (context) { + const { request: { meta, payload, type } } = context + const { keys, recordTypes, options, castValue } = this + const fields = recordTypes[type] + const busboy = new Busboy({ headers: meta }) + const bufferStream = new stream.PassThrough() + const record = {} + + return new Promise(resolve => { + busboy.on('file', (field, file, filename) => { + const fieldDefinition = fields[field] || {} + const fieldIsArray = fieldDefinition[keys.isArray] + const chunks = [] + + if (fieldIsArray && !(field in record)) record[field] = [] + + file.on('data', chunk => chunks.push(chunk)) + file.on('end', () => { + const data = Buffer.concat(chunks) + data.filename = filename + if (fieldIsArray) { + record[field].push(data) + return + } + record[field] = data + }) + }) + + busboy.on('field', (field, value) => { + const fieldDefinition = fields[field] || {} + const fieldType = fieldDefinition[keys.type] + const fieldIsArray = fieldDefinition[keys.isArray] + + if (fieldIsArray) { + if (!(field in record)) record[field] = [] + record[field].push(castValue(value, fieldType, options)) + return + } + + record[field] = castValue(value, fieldType, options) + }) + + busboy.on('finish', () => resolve([ record ])) + + bufferStream.end(payload) + bufferStream.pipe(busboy) + }) + } + + parseUpdate () { + throw new this.errors.UnsupportedError(`Can not update records.`) + } + + } +} + + +export const formUrlEncoded = Serializer => Object.assign( + inherit(Serializer), { id: formUrlEncodedType }) + + +export const formData = Serializer => Object.assign( + inherit(Serializer), { id: formDataType }) diff --git a/lib/serializer/serializers/form_urlencoded/index.js b/lib/serializer/serializers/form_urlencoded/index.js deleted file mode 100644 index 751160b5f..000000000 --- a/lib/serializer/serializers/form_urlencoded/index.js +++ /dev/null @@ -1,39 +0,0 @@ -import qs from 'querystring' - - -const mediaType = 'application/x-www-form-urlencoded' - - -export default Serializer => Object.assign( -class FormUrlEncodedSerializer extends Serializer { - - processRequest () { - throw new this.errors.UnsupportedError() - } - - parseCreate (context) { - const { keys, recordTypes, options, castValue } = this - const { request: { type, payload } } = context - const fields = recordTypes[type] - const cast = (type, options) => value => castValue(value, type, options) - const record = qs.parse(payload.toString()) - - for (let field in record) { - const value = record[field] - const fieldDefinition = fields[field] || {} - const fieldType = fieldDefinition[keys.type] - const fieldIsArray = fieldDefinition[keys.isArray] - - record[field] = fieldIsArray ? - value.map(cast(fieldType, options)) : - castValue(value, fieldType, options) - } - - return [ record ] - } - - parseUpdate () { - throw new this.errors.UnsupportedError() - } - -}, { id: mediaType }) diff --git a/lib/serializer/singleton.js b/lib/serializer/singleton.js index ec260f7b5..07d07a3fa 100644 --- a/lib/serializer/singleton.js +++ b/lib/serializer/singleton.js @@ -81,7 +81,6 @@ export default class SerializerSingleton extends Serializer { const inputMethods = new Set([ 'parseCreate', 'parseUpdate' ]) -const asynchronousMethods = new Set([ 'processRequest', 'processResponse' ]) // Assign the proxy methods on top of the base methods. @@ -121,16 +120,17 @@ function proxyMethod (options, context, ...args) { // Fail if no serializer was found. else throw new NoopError(`The serializer for "${format}" is unrecognized.`) - const isAsynchronous = asynchronousMethods.has(method) - try { - const result = serializer[method](context, ...args) - return isAsynchronous ? Promise.resolve(result) : result + return Promise.resolve(serializer[method](context, ...args)) } - catch (error) { - if (isAsynchronous) return Promise.reject(error) + catch (e) { + let error = e + + // Only in the special case of input methods, it may be more appropriate to + // throw a BadRequestError. if (nativeErrors.has(error.constructor) && isInput) - throw new BadRequestError(`The request is malformed.`) - throw error + error = new BadRequestError(`The request is malformed.`) + + return Promise.reject(error) } } diff --git a/package.json b/package.json index ae513639e..9548f4ef7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fortune", "description": "High-level I/O for web applications.", - "version": "1.2.5", + "version": "1.3.0", "license": "MIT", "author": { "email": "0x8890@airmail.cc", @@ -33,6 +33,7 @@ "dependencies": { "array-buffer": "^1.0.2", "babel-runtime": "^5.8.20", + "busboy": "^0.2.9", "chalk": "^1.1.1", "clone": "^1.0.2", "deep-equal": "^1.0.1", @@ -49,6 +50,7 @@ "docchi": "^0.11.0", "eslint": "^1.3.1", "eslint-config-0x8890": "^1.0.0", + "form-data": "^0.2.0", "highlight.js": "^8.7.0", "html-minifier": "^0.7.2", "http-server": "^0.8.0", diff --git a/test/index.js b/test/index.js index 4b9fdb36e..566a69639 100644 --- a/test/index.js +++ b/test/index.js @@ -8,6 +8,6 @@ import './integration/methods/create' import './integration/methods/update' import './integration/methods/delete' import './integration/serializers/ad_hoc' -import './integration/serializers/form_urlencoded' +import './integration/serializers/form' import './integration/adapters/memory' import './integration/websocket' diff --git a/test/integration/serializers/form.js b/test/integration/serializers/form.js new file mode 100644 index 000000000..012155741 --- /dev/null +++ b/test/integration/serializers/form.js @@ -0,0 +1,87 @@ +import http from 'http' +import qs from 'querystring' +import FormData from 'form-data' +import { run, comment } from 'tapdance' +import { ok, deepEqual, equal } from '../../helpers' +import httpTest from '../http' +import adHoc from + '../../../lib/serializer/serializers/ad_hoc' +import { formUrlEncoded, formData } from + '../../../lib/serializer/serializers/form' +import testInstance from '../test_instance' +import fortune from '../../../lib' + + +const formUrlEncodedType = 'application/x-www-form-urlencoded' +const options = { + serializers: [ + { type: adHoc }, + { type: formUrlEncoded }, + { type: formData } + ] +} +const test = httpTest.bind(null, options) + + +run(() => { + comment('get anything should fail') + return test('/', { + headers: { 'Accept': formUrlEncodedType } + }, response => { + equal(response.status, 415, 'status is correct') + }) +}) + + +run(() => { + comment('create records using urlencoded data') + return test(`/animal`, { + method: 'post', + headers: { 'Content-Type': formUrlEncodedType }, + body: qs.stringify({ + name: 'Ayy lmao', + nicknames: [ 'ayy', 'lmao' ] + }) + }, response => { + equal(response.status, 201, 'status is correct') + ok(~response.headers['content-type'].indexOf('application/json'), + 'content type is correct') + deepEqual(response.body.map(record => record.name), + [ 'Ayy lmao' ], 'response body is correct') + }) +}) + + +run(() => { + comment('create records using form data') + + const deadbeef = new Buffer('deadbeef', 'hex') + const form = new FormData() + form.append('name', 'Ayy lmao') + form.append('picture', deadbeef, + { filename: 'deadbeef.dump' }) + + return testInstance(options) + .then(store => http.createServer(fortune.net.http(store)).listen(1337)) + .then(() => new Promise((resolve, reject) => + form.submit('http://localhost:1337/animal', (error, response) => error ? + reject(error) : resolve(response)))) + .then(response => { + equal(response.statusCode, 201, 'status is correct') + ok(~response.headers['content-type'].indexOf('application/json'), + 'content type is correct') + + return new Promise(resolve => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.on('end', () => resolve(Buffer.concat(chunks))) + }) + }) + .then(payload => { + const body = JSON.parse(payload.toString()) + deepEqual(body.map(record => record.name), + [ 'Ayy lmao' ], 'name is correct') + deepEqual(body.map(record => record.picture), + [ deadbeef.toString('base64') ], 'picture is correct') + }) +}) diff --git a/test/integration/serializers/form_urlencoded.js b/test/integration/serializers/form_urlencoded.js deleted file mode 100644 index 19cca4451..000000000 --- a/test/integration/serializers/form_urlencoded.js +++ /dev/null @@ -1,43 +0,0 @@ -import qs from 'querystring' -import { run, comment } from 'tapdance' -import { ok, deepEqual, equal } from '../../helpers' -import httpTest from '../http' -import adHoc from - '../../../lib/serializer/serializers/ad_hoc' -import formUrlEncoded from - '../../../lib/serializer/serializers/form_urlencoded' - - -const mediaType = 'application/x-www-form-urlencoded' -const test = httpTest.bind(null, { - serializers: [ { type: adHoc }, { type: formUrlEncoded } ] -}) - - -run(() => { - comment('get anything should fail') - return test('/', { - headers: { 'Accept': mediaType } - }, response => { - equal(response.status, 415, 'status is correct') - }) -}) - - -run(() => { - comment('create records') - return test(`/animal`, { - method: 'post', - headers: { 'Content-Type': mediaType }, - body: qs.stringify({ - name: 'Ayy lmao', - nicknames: [ 'ayy', 'lmao' ] - }) - }, response => { - equal(response.status, 201, 'status is correct') - ok(~response.headers['content-type'].indexOf('application/json'), - 'content type is correct') - deepEqual(response.body.map(record => record.name), - [ 'Ayy lmao' ], 'response body is correct') - }) -})