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));
+ };
+}