diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..67d3ed5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# EditorConfig is awesome: http://EditorConfig.org + +root = true + +[*] +indent_style = space +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b675a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +bower_components +coverage +.DS_Store +npm-debug.log diff --git a/.jshintignore b/.jshintignore new file mode 100644 index 0000000..526efb4 --- /dev/null +++ b/.jshintignore @@ -0,0 +1,3 @@ +node_modules +bower_components +coverage diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..1648402 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,27 @@ +{ + "maxerr": 100, // Maximum errors before stopping. + "maxlen": 80, // Maximum line length. + "quotmark": "single", // Consistent quotation mark usage. + "bitwise": false, // Prohibit bitwise operators (&, |, ^, etc.). + "curly": true, // Require {} for every new block or scope. + "eqeqeq": true, // Require triple equals i.e. `===`. + "forin": false, // Tolerate `for in` loops without `hasOwnPrototype`. + "immed": true, // Require immediate invocations to be wrapped in parens. E.g. `(function(){})();`. + "latedef": false, // Prohibit variable use before definition. + "newcap": true, // Require capitalization of all constructor functions. E.g. `new F()`. + "noarg": true, // Prohibit use of `arguments.caller` and `arguments.callee`. + "noempty": true, // Prohibit use of empty blocks. + "nonew": true, // Prohibit use of constructors for side-effects. + "undef": true, // Require all non-global variables be declared before they are used. + "unused": true, // Warn when creating a variable but never using it. + "plusplus": false, // Prohibit use of `++` & `--`. + "regexp": false, // Prohibit `.` and `[^...]` in regular expressions. + "strict": false, // Require `use strict` pragma in every file. + "trailing": true, // Prohibit trailing whitespaces. + "boss": true, // Tolerate assignments inside if, for and while. Usually conditions and loops are for comparison, not assignments. + "multistr": false, // Prohibit the use of multi-line strings. + "eqnull": true, // Tolerate use of `== null`. + "expr": true, // Tolerate `ExpressionStatement` as Programs. + "node": true, // Support globals used in a node environment. + "browser": true // Support globals used in a browser environment. +} diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..c075309 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: node_js + +notifications: + email: + on_success: never + on_failure: change + +node_js: + - "0.10" + +after_script: "npm install coveralls@2 && cat ./coverage/lcov.info | coveralls" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2481d2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2015 (c) MuleSoft, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the specific +language governing permissions and limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca8411c --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Osprey Method Handler + +[![NPM version][npm-image]][npm-url] +[![NPM Downloads][downloads-image]][downloads-url] +[![Build status][travis-image]][travis-url] +[![Test coverage][coveralls-image]][coveralls-url] + +Middleware for validating requests and responses based on a [RAML method](https://github.com/raml-org/raml-spec/blob/master/raml-0.8.md#methods) object. + +## Installation + +```sh +npm install osprey-method-handler --save +``` + +## Usage + +```js +var express = require('express'); +var handler = require('osprey-method-handler'); + +express.post('/users', handler({ + headers: {}, + responses: { + '200': { + body: { + 'application/json': { + schema: '...', + example: '...' + } + } + } + }, + body: { + 'application/json': { + schema: '...' + } + } +}), function (req, res) { + res.send('it worked!'); +}); +``` + +## License + +MIT license + +[npm-image]: https://img.shields.io/npm/v/osprey-method-handler.svg?style=flat +[npm-url]: https://npmjs.org/package/osprey-method-handler +[downloads-image]: https://img.shields.io/npm/dm/osprey-method-handler.svg?style=flat +[downloads-url]: https://npmjs.org/package/osprey-method-handler +[travis-image]: https://img.shields.io/travis/mulesoft-labs/osprey-method-handler.svg?style=flat +[travis-url]: https://travis-ci.org/mulesoft-labs/osprey-method-handler +[coveralls-image]: https://img.shields.io/coveralls/mulesoft-labs/osprey-method-handler.svg?style=flat +[coveralls-url]: https://coveralls.io/r/mulesoft-labs/osprey-method-handler?branch=master diff --git a/osprey-method-handler.js b/osprey-method-handler.js new file mode 100644 index 0000000..277f309 --- /dev/null +++ b/osprey-method-handler.js @@ -0,0 +1,411 @@ +var is = require('type-is'); +var router = require('osprey-router'); +var extend = require('xtend'); +var parseurl = require('parseurl'); +var querystring = require('querystring'); +var createError = require('http-errors'); +var lowercaseKeys = require('lowercase-keys'); +var ramlSanitize = require('raml-sanitize')(); +var ramlValidate = require('raml-validate')(); +var isStream = require('is-stream'); + +/** + * Get all default headers. + * + * @type {Object} + */ +var DEFAULT_HEADER_PARAMS = {}; + +// Fill header params with non-required parameters. +require('standard-headers').forEach(function (header) { + DEFAULT_HEADER_PARAMS[header] = { type: 'string' }; +}); + +/** + * Application body parsers and validators. + * + * @type {Array} + */ +var BODY_HANDLERS = [ + ['application/json', jsonBodyHandler], + ['text/xml', xmlBodyHandler], + ['application/x-www-form-urlencoded', urlencodedBodyHandler], + ['multipart/form-data', formDataBodyHandler] +]; + +/** + * Set custom file validation. + * + * @param {Stream} value + * @return {Boolean} + */ +ramlValidate.TYPES.file = function (stream) { + return isStream(stream); +}; + +/** + * Export `ospreyMethodHandler`. + */ +module.exports = ospreyMethodHandler; + +/** + * Create a middleware request/response handler. + * + * @param {Object} schema + * @param {Object} options + * @return {Function} + */ +function ospreyMethodHandler (schema) { + schema = schema || {}; + + var app = router(); + + app.use(headerHandler(schema.headers)); + app.use(queryHandler(schema.queryParameters)); + + if (schema.body) { + app.use(bodyHandler(schema.body)); + } else { + app.use(discardBody); + } + + return app; +} + +/** + * Create query string handling middleware. + * + * @param {Object} queryParameters + * @return {Function} + */ +function queryHandler (queryParameters) { + // Fast query parameters. + if (!queryParameters) { + return function (req, res, next) { + req.url = parseurl(req).pathname; + req.query = {}; + + return next(); + }; + } + + var sanitize = ramlSanitize(queryParameters); + var validate = ramlValidate(queryParameters); + + return function (req, res, next) { + var reqUrl = parseurl(req); + var query = sanitize(querystring.parse(reqUrl.query)); + var result = validate(query); + + if (!result.valid) { + return next(createError(400, 'Invalid query parameters')); + } + + var qs = querystring.stringify(query); + + req.url = reqUrl.pathname + (qs ? '?' + qs : ''); + req.query = query; + + return next(); + }; +} + +/** + * Create a request header handling middleware. + * + * @param {Object} headerParameters + * @return {Function} + */ +function headerHandler (headerParameters) { + var headers = extend(DEFAULT_HEADER_PARAMS, lowercaseKeys(headerParameters)); + + var sanitize = ramlSanitize(headers); + var validate = ramlValidate(headers); + + return function (req, res, next) { + var headers = sanitize(lowercaseKeys(req.headers)); + var result = validate(headers); + + if (!result.valid) { + return next(createError(400, 'Invalid headers')); + } + + // Unsets invalid headers. + req.headers = headers; + + return next(); + }; +} + +/** + * Handle incoming request bodies. + * + * @param {Object} bodies + * @return {Function} + */ +function bodyHandler (bodies) { + var map = {}; + var types = Object.keys(bodies); + + BODY_HANDLERS.forEach(function (handler) { + var type = handler[0]; + var fn = handler[1]; + var result = is.is(type, types); + + if (!result) { + return; + } + + map[result] = fn(bodies[result]); + }); + + return createTypeMiddleware(map); +} + +/** + * Handle JSON requests. + * + * @param {Object} body + * @return {Function} + */ +function jsonBodyHandler (body) { + if (!body || !body.schema) { + throw new TypeError('missing json schema'); + } + + var app = router(); + + app.use(require('body-parser').json({ type: [] })); + app.use(jsonBodyValidationHandler(body.schema)); + + return app; +} + +/** + * Validate bodies as JSON. + * + * @param {String} str + * @return {Function} + */ +function jsonBodyValidationHandler (str) { + var tv4 = require('tv4'); + var schema = JSON.parse(str); + + return function (req, res, next) { + var result = tv4.validateResult(req.body, schema, true, true); + + if (!result.valid) { + return next(createError(400, 'Invalid JSON')); + } + + return next(); + }; +} + +/** + * Handle url encoded form requests. + * + * @param {Object} body + * @return {Function} + */ +function urlencodedBodyHandler (body) { + if (!body || !body.formParameters) { + throw new TypeError('missing url encoded form parameters'); + } + + var app = router(); + + app.use(require('body-parser').urlencoded({ type: [], extended: false })); + app.use(urlencodedBodyValidationHandler(body.formParameters)); + + return app; +} + +/** + * Validate url encoded form bodies. + * + * @param {String} parameters + * @return {String} + */ +function urlencodedBodyValidationHandler (parameters) { + var sanitize = ramlSanitize(parameters); + var validate = ramlValidate(parameters); + + return function (req, res, next) { + var body = sanitize(req.body); + var result = validate(body); + + if (!result.valid) { + return next(createError(400, 'Invalid form body')); + } + + // Discards invalid url encoded parameters. + req.body = body; + + return next(); + }; +} + +/** + * Handle XML requests. + * + * @param {Object} body + * @return {Function} + */ +function xmlBodyHandler (body) { + if (!body || !body.schema) { + throw new TypeError('missing xml schema'); + } + + var app = router(); + + app.use(require('body-parser').text({ type: [] })); + app.use(xmlBodyValidationHandler(body.schema)); + + return app; +} + +/** + * Validate XML request bodies. + * + * @param {String} str + * @return {Function} + */ +function xmlBodyValidationHandler (str) { + var libxml = require('libxmljs'); + var xsdDoc = libxml.parseXml(str); + + return function (req, res, next) { + var xmlDoc = libxml.parseXml(req.body); + + if (!xmlDoc.validate(xsdDoc)) { + // xmlDoc.validationErrors + return next(createError(400, 'Invalid XML')); + } + + // Assign parsed XML document to the body. + req.xml = xmlDoc; + + return next(); + }; +} + +/** + * Handle form data requests. + * + * @param {Object} body + * @return {Function} + */ +function formDataBodyHandler (body) { + if (!body || !body.formParameters) { + throw new TypeError('missing form data form parameters'); + } + + var app = router(); + var Busboy = require('busboy'); + var params = body.formParameters; + var validations = {}; + var sanitizations = {}; + + // Manually create validations and sanitizations. + Object.keys(params).forEach(function (key) { + var param = extend(params[key]); + + // Needed to handle repeat errors asynchronously. + delete param.repeat; + + validations[key] = ramlValidate.rule(param); + sanitizations[key] = ramlSanitize.rule(param); + }); + + app.use(function (req, res, next) { + var received = {}; + var busboy = req.form = new Busboy({ headers: req.headers }); + + // Override `emit` to provide validations. + busboy.emit = function emit (type, name, field, a, b, c) { + if (type === 'field' || type === 'file') { + if (!params.hasOwnProperty(name)) { + // Throw away invalid file streams. + if (type === 'file') { + field.resume(); + } + + return; + } + + // Handle repeat parameters as errors. + if (received[name] && !params[name].repeat) { + busboy.emit('error', createError(400, 'Invalid repeated param')); + + return; + } + + received[name] = true; + + var value = sanitizations[name](field); + var result = validations[name](value); + + if (!result.valid) { + busboy.emit('error', createError(400, 'Invalid form data')); + + return; + } + + return Busboy.prototype.emit.call(this, type, name, value, a, b, c); + } + + if (type === 'finish') { + var missingParams = Object.keys(params).filter(function (key) { + return params[key].required && !received[key]; + }); + + if (missingParams.length) { + busboy.emit('error', createError(400, 'Invalid number of params')); + + return; + } + } + + return Busboy.prototype.emit.apply(this, arguments); + }; + + return next(); + }); + + return app; +} + +/** + * Create a middleware function that accepts requests of the type. + * + * @param {Object} map + * @return {Function} + */ +function createTypeMiddleware (map) { + var types = Object.keys(map); + + return function (req, res, next) { + var type = is(req, types); + + if (!type) { + return next(createError(415, 'Unknown content type')); + } + + var fn = map[type]; + + return fn ? fn(req, res, next) : next(); + }; +} + +/** + * Discard request bodies. + * + * @param {Request} req + * @param {Response} res + * @param {Function} next + */ +function discardBody (req, res, next) { + req.resume(); + req.on('end', next); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..766ca74 --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "osprey-method-handler", + "version": "0.0.0", + "description": "", + "main": "osprey-method-handler.js", + "files": [ + "osprey-method-handler.js", + "LICENSE" + ], + "scripts": { + "jshint": "jshint .", + "mocha": "istanbul cover node_modules/mocha/bin/_mocha -- -R spec --bail", + "test": "npm run jshint && npm run mocha" + }, + "repository": { + "type": "git", + "url": "git://github.com/mulesoft-labs/osprey-method-handler.git" + }, + "keywords": [], + "author": "MuleSoft, Inc.", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/mulesoft-labs/osprey-method-handler/issues" + }, + "homepage": "https://github.com/mulesoft-labs/osprey-method-handler", + "devDependencies": { + "chai": "^1.10.0", + "es6-promise": "^2.0.1", + "finalhandler": "^0.3.3", + "istanbul": "^0.3.5", + "jshint": "^2.6.0", + "mocha": "^2.1.0", + "popsicle": "^0.3.8", + "popsicle-server": "0.0.1", + "pre-commit": "0.0.11", + "sinon": "^1.12.2", + "slow-stream": "0.0.4", + "which": "^1.0.8" + }, + "dependencies": { + "body-parser": "^1.10.2", + "busboy": "^0.2.9", + "http-errors": "^1.2.8", + "is-stream": "^1.0.1", + "libxmljs": "^0.13.0", + "lowercase-keys": "^1.0.0", + "osprey-router": "0.0.2", + "parseurl": "^1.3.0", + "raml-sanitize": "^1.0.3", + "raml-validate": "^1.0.3", + "standard-headers": "0.0.1", + "stream-equal": "^0.1.5", + "tv4": "^1.1.9", + "type-is": "^1.5.5", + "xtend": "^4.0.0" + } +} diff --git a/test.js b/test.js new file mode 100644 index 0000000..e845b96 --- /dev/null +++ b/test.js @@ -0,0 +1,827 @@ +/* global describe, it */ + +/* istanbul ignore next */ +if (!global.Promise) { + require('es6-promise').polyfill(); +} + +var expect = require('chai').expect; +var popsicle = require('popsicle'); +var server = require('popsicle-server'); +var router = require('osprey-router'); +var finalhandler = require('finalhandler'); +var fs = require('fs'); +var join = require('path').join; +var streamEqual = require('stream-equal'); +var handler = require('./'); + +describe('osprey method handler', function () { + it('should return a middleware function', function () { + var middleware = handler(); + + expect(middleware).to.be.a('function'); + expect(middleware.length).to.equal(3); + }); + + describe('headers', function () { + it('should reject invalid headers', function () { + var app = router(); + + app.get('/', handler({ + headers: { + 'X-Header': { + type: 'integer' + } + } + })); + + return popsicle({ + url: '/', + headers: { + 'X-Header': 'abc' + } + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.status).to.equal(400); + }); + }); + + it('should sanitize headers', function () { + var app = router(); + + app.get('/', handler({ + headers: { + date: { + type: 'date' + } + } + }), function (req, res) { + expect(req.headers.date).to.be.an.instanceOf(Date); + + res.end('success'); + }); + + return popsicle({ + url: '/', + headers: { + date: new Date().toString() + } + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal('success'); + expect(res.status).to.equal(200); + }); + }); + }); + + describe('query parameters', function () { + it('should reject invalid query parameters', function () { + var app = router(); + + app.get('/', handler({ + queryParameters: { + a: { + type: 'string' + }, + b: { + type: 'integer' + } + } + })); + + return popsicle('/?a=value&b=value') + .use(server(createServer(app))) + .then(function (res) { + expect(res.status).to.equal(400); + }); + }); + + it('should filter undefined query parameters', function () { + var app = router(); + + app.get('/', handler({ + queryParameters: { + a: { + type: 'string' + } + } + }), function (req, res) { + expect(req.url).to.equal('/?a=value'); + expect(req.query).to.deep.equal({ a: 'value' }); + + res.end('success'); + }); + + return popsicle('/?a=value&b=value') + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal('success'); + expect(res.status).to.equal(200); + }); + }); + + it('should remove all unknown query parameters', function () { + var app = router(); + + app.get('/', handler({ + queryParameters: { + q: { + type: 'string' + } + } + }), function (req, res) { + expect(req.url).to.equal('/'); + expect(req.query).to.deep.equal({}); + + res.end('success'); + }); + + return popsicle('/?a=value&b=value') + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal('success'); + expect(res.status).to.equal(200); + }); + }); + + it('should support empty query strings', function () { + var app = router(); + + app.get('/', handler(), function (req, res) { + expect(req.url).to.equal('/'); + expect(req.query).to.deep.equal({}); + + res.end('success'); + }); + + return popsicle('/') + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal('success'); + expect(res.status).to.equal(200); + }); + }); + }); + + describe('body', function () { + describe('json', function () { + var JSON_SCHEMA = '{"items":{"type":"boolean"}}'; + + it('should throw an error when schema is undefined', function () { + var app = router(); + + expect(function () { + app.get('/', handler({ + body: { + 'application/json': null + } + })); + }).to.throw(/Missing JSON schema/i); + }); + + it('should reject invalid json', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'application/json': { + schema: JSON_SCHEMA + } + } + })); + + return popsicle({ + url: '/', + body: [true, 123] + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.status).to.equal(400); + }); + }); + + it('should parse valid json', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'application/json': { + schema: JSON_SCHEMA + } + } + }), function (req, res) { + expect(req.body).to.deep.equal([true, false]); + + res.end('success'); + }); + + return popsicle({ + url: '/', + body: [true, false] + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal('success'); + expect(res.status).to.equal(200); + }); + }); + }); + + describe('xml', function () { + var XML_SCHEMA = [ + '', + '', + '', + '', + '', + '' + ].join(''); + + it('should throw an error when schema is undefined', function () { + var app = router(); + + expect(function () { + app.get('/', handler({ + body: { + 'text/xml': null + } + })); + }).to.throw(/missing xml schema/i); + }); + + it('should reject invalid xml bodies', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'text/xml': { + schema: XML_SCHEMA + } + } + })); + + return popsicle({ + url: '/', + body: 'A comment', + headers: { + 'Content-Type': 'text/xml' + } + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.status).to.equal(400); + }); + }); + + it('should parse valid xml documents', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'text/xml': { + schema: XML_SCHEMA + } + } + }), function (req, res) { + expect(req.xml.get('/comment/author').text()).to.equal('author'); + expect(req.xml.get('/comment/content').text()).to.equal('nothing'); + + res.end('success'); + }); + + return popsicle({ + url: '/', + body: [ + '', + '', + 'author', + 'nothing', + '' + ].join(''), + headers: { + 'Content-Type': 'text/xml' + } + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal('success'); + expect(res.status).to.equal(200); + }); + }); + }); + + describe('urlencoded', function () { + it('should throw an error when parameters are undefined', function () { + var app = router(); + + expect(function () { + app.get('/', handler({ + body: { + 'application/x-www-form-urlencoded': null + } + })); + }).to.throw(/missing url encoded form parameters/i); + }); + + it('should reject invalid forms', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'application/x-www-form-urlencoded': { + formParameters: { + a: { + type: 'boolean' + } + } + } + } + })); + + return popsicle({ + url: '/', + body: 'a=true&a=123', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.status).to.equal(400); + }); + }); + + it('should parse valid forms', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'application/x-www-form-urlencoded': { + formParameters: { + a: { + type: 'boolean', + repeat: true + } + } + } + } + }), function (req, res) { + expect(req.body).to.deep.equal({ a: [true, true] }); + + res.end('success'); + }); + + return popsicle({ + url: '/', + body: 'a=true&a=123', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal('success'); + expect(res.status).to.equal(200); + }); + }); + }); + + describe('form data', function () { + it('should throw an error when parameters are undefined', function () { + var app = router(); + + expect(function () { + app.get('/', handler({ + body: { + 'multipart/form-data': null + } + })); + }).to.throw(/missing form data form parameters/i); + }); + + it('should reject invalid forms', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'multipart/form-data': { + formParameters: { + username: { + type: 'string', + pattern: '[a-zA-Z]\\w*' + } + } + } + } + }), function (req, res, next) { + req.form.on('error', function (err) { + return next(err); + }); + + req.pipe(req.form); + }); + + return popsicle({ + url: '/', + body: popsicle.form({ + username: '123' + }) + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.status).to.equal(400); + }); + }); + + it('should parse valid forms', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'multipart/form-data': { + formParameters: { + username: { + type: 'string', + pattern: '[a-zA-Z]\\w*' + } + } + } + } + }), function (req, res) { + req.form.on('field', function (name, value) { + expect(name).to.equal('username'); + expect(value).to.equal('blakeembrey'); + + res.end('success'); + }); + + req.pipe(req.form); + }); + + return popsicle({ + url: '/', + body: popsicle.form({ + username: 'blakeembrey' + }) + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal('success'); + expect(res.status).to.equal(200); + }); + }); + + it('should properly sanitize form values', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'multipart/form-data': { + formParameters: { + number: { + type: 'number' + } + } + } + } + }), function (req, res) { + req.form.on('field', function (name, value) { + expect(name).to.equal('number'); + expect(value).to.equal(12345); + + res.end('success'); + }); + + req.pipe(req.form); + }); + + return popsicle({ + url: '/', + body: popsicle.form({ + number: '12345' + }) + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal('success'); + expect(res.status).to.equal(200); + }); + }); + + it('should error with repeated values', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'multipart/form-data': { + formParameters: { + item: { + type: 'string' + } + } + } + } + }), function (req, res, next) { + req.form.on('error', next); + + req.pipe(req.form); + }); + + var form = popsicle.form(); + + form.append('item', 'abc'); + form.append('item', '123'); + + return popsicle({ + url: '/', + body: form + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.status).to.equal(400); + }); + }); + + it('should error if it did not receive all required values', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'multipart/form-data': { + formParameters: { + item: { + type: 'string', + required: true + }, + more: { + type: 'string' + } + } + } + } + }), function (req, res, next) { + req.form.on('error', next); + + req.pipe(req.form); + }); + + var form = popsicle.form(); + + form.append('more', '123'); + + return popsicle({ + url: '/', + body: form + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.status).to.equal(400); + }); + }); + + it('should allow files', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'multipart/form-data': { + formParameters: { + contents: { + type: 'file' + }, + filename: { + type: 'string' + } + } + } + } + }), function (req, res) { + req.form.on('field', function (name, value) { + expect(name).to.equal('filename'); + expect(value).to.equal('LICENSE'); + }); + + req.form.on('file', function (name, stream) { + expect(name).to.equal('contents'); + + return streamEqual( + stream, + fs.createReadStream(join(__dirname, 'LICENSE')), + function (err, equal) { + expect(equal).to.be.true; + + return res.end('success'); + } + ); + }); + + req.pipe(req.form); + }); + + return popsicle({ + url: '/', + body: popsicle.form({ + contents: fs.createReadStream(join(__dirname, 'LICENSE')), + filename: 'LICENSE' + }) + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal('success'); + expect(res.status).to.equal(200); + }); + }); + + it('should ignore unknown files and fields', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'multipart/form-data': { + formParameters: { + file: { + type: 'file' + } + } + } + } + }), function (req, res) { + var callCount = 0; + + function called (name, value) { + callCount++; + expect(name).to.equal('file'); + expect(value).to.be.an('object'); + } + + req.form.on('field', called); + + req.form.on('file', function (name, stream) { + called(name, stream); + + stream.resume(); + }); + + req.form.on('finish', function () { + expect(callCount).to.equal(1); + + res.end('success'); + }); + + req.pipe(req.form); + }); + + return popsicle({ + url: '/', + body: popsicle.form({ + file: fs.createReadStream(join(__dirname, 'LICENSE')), + another: fs.createReadStream(join(__dirname, 'README.md')), + random: 'hello world' + }) + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal('success'); + expect(res.status).to.equal(200); + }); + }); + }); + + describe('unknown', function () { + it('should reject unknown request types', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'application/json': { + schema: '{"items":{"type":"boolean"}}' + } + } + })); + + return popsicle({ + url: '/', + body: 'test', + headers: { + 'Content-Type': 'text/html' + } + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.status).to.equal(415); + }); + }); + + it('should pass unknown bodies through when defined', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'text/html': null + } + }), function (req, res) { + res.end('success'); + }); + + return popsicle({ + url: '/', + body: 'test', + headers: { + 'Content-Type': 'text/html' + } + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal('success'); + expect(res.status).to.equal(200); + }); + }); + }); + + describe('multiple', function () { + it('should parse as the correct content type', function () { + var app = router(); + + app.get('/', handler({ + body: { + 'application/json': { + schema: '{"properties":{"items":{"type":"string"}}' + + ',"required":["items"]}' + }, + 'multipart/form-data': { + formParameters: { + items: { + type: 'boolean', + repeat: true + } + } + } + } + }), function (req, res) { + var callCount = 0; + + req.form.on('field', function (name, value) { + callCount++; + + expect(name).to.equal('items'); + expect(value).to.equal(callCount === 1 ? true : false); + }); + + req.form.on('finish', function () { + expect(callCount).to.equal(2); + + res.end('success'); + }); + + req.pipe(req.form); + }); + + var form = popsicle.form(); + + form.append('items', 'true'); + form.append('items', 'false'); + + return popsicle({ + url: '/', + body: form + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal('success'); + expect(res.status).to.equal(200); + }); + }); + }); + + describe('empty', function () { + it('should discard empty request bodies', function () { + var app = router(); + + app.post('/', handler({}), function (req, res) { + req.pipe(res); + }); + + return popsicle({ + url: '/', + body: popsicle.form({ + file: fs.createReadStream(join(__dirname, 'test.js')) + }), + method: 'post' + }) + .use(server(createServer(app))) + .then(function (res) { + expect(res.body).to.equal(null); + expect(res.status).to.equal(200); + }); + }); + }); + }); +}); + +function createServer (router) { + return function (req, res) { + router(req, res, finalhandler(req, res)); + }; +}