diff --git a/History.md b/History.md
index 35259befeea..2410810d337 100644
--- a/History.md
+++ b/History.md
@@ -2,6 +2,7 @@ unreleased
==========
* Add `express.raw` to parse bodies into `Buffer`
+ * Add `express.text` to parse bodies into string
* Improve error message for non-strings to `res.sendFile`
* Improve error message for `null`/`undefined` to `res.status`
* Support multiple hosts in `X-Forwarded-Host`
diff --git a/lib/express.js b/lib/express.js
index f618ccc125a..d188a16db70 100644
--- a/lib/express.js
+++ b/lib/express.js
@@ -79,6 +79,7 @@ exports.json = bodyParser.json
exports.query = require('./middleware/query');
exports.raw = bodyParser.raw
exports.static = require('serve-static');
+exports.text = bodyParser.text
exports.urlencoded = bodyParser.urlencoded
/**
diff --git a/test/exports.js b/test/exports.js
index f43df44c34c..7624a8c8641 100644
--- a/test/exports.js
+++ b/test/exports.js
@@ -24,6 +24,11 @@ describe('exports', function(){
assert.equal(express.static.length, 2)
})
+ it('should expose text middleware', function () {
+ assert.equal(typeof express.text, 'function')
+ assert.equal(express.text.length, 1)
+ })
+
it('should expose urlencoded middleware', function () {
assert.equal(typeof express.urlencoded, 'function')
assert.equal(express.urlencoded.length, 1)
diff --git a/test/express.text.js b/test/express.text.js
new file mode 100644
index 00000000000..7c92f90e5aa
--- /dev/null
+++ b/test/express.text.js
@@ -0,0 +1,441 @@
+
+var assert = require('assert')
+var Buffer = require('safe-buffer').Buffer
+var express = require('..')
+var request = require('supertest')
+
+describe('express.text()', function () {
+ before(function () {
+ this.app = createApp()
+ })
+
+ it('should parse text/plain', function (done) {
+ request(this.app)
+ .post('/')
+ .set('Content-Type', 'text/plain')
+ .send('user is tobi')
+ .expect(200, '"user is tobi"', done)
+ })
+
+ it('should 400 when invalid content-length', function (done) {
+ var app = express()
+
+ app.use(function (req, res, next) {
+ req.headers['content-length'] = '20' // bad length
+ next()
+ })
+
+ app.use(express.text())
+
+ app.post('/', function (req, res) {
+ res.json(req.body)
+ })
+
+ request(app)
+ .post('/')
+ .set('Content-Type', 'text/plain')
+ .send('user')
+ .expect(400, /content length/, done)
+ })
+
+ it('should handle Content-Length: 0', function (done) {
+ request(createApp({ limit: '1kb' }))
+ .post('/')
+ .set('Content-Type', 'text/plain')
+ .set('Content-Length', '0')
+ .expect(200, '""', done)
+ })
+
+ it('should handle empty message-body', function (done) {
+ request(createApp({ limit: '1kb' }))
+ .post('/')
+ .set('Content-Type', 'text/plain')
+ .set('Transfer-Encoding', 'chunked')
+ .send('')
+ .expect(200, '""', done)
+ })
+
+ it('should handle duplicated middleware', function (done) {
+ var app = express()
+
+ app.use(express.text())
+ app.use(express.text())
+
+ app.post('/', function (req, res) {
+ res.json(req.body)
+ })
+
+ request(app)
+ .post('/')
+ .set('Content-Type', 'text/plain')
+ .send('user is tobi')
+ .expect(200, '"user is tobi"', done)
+ })
+
+ describe('with defaultCharset option', function () {
+ it('should change default charset', function (done) {
+ var app = createApp({ defaultCharset: 'koi8-r' })
+ var test = request(app).post('/')
+ test.set('Content-Type', 'text/plain')
+ test.write(Buffer.from('6e616d6520697320cec5d4', 'hex'))
+ test.expect(200, '"name is нет"', done)
+ })
+
+ it('should honor content-type charset', function (done) {
+ var app = createApp({ defaultCharset: 'koi8-r' })
+ var test = request(app).post('/')
+ test.set('Content-Type', 'text/plain; charset=utf-8')
+ test.write(Buffer.from('6e616d6520697320e8aeba', 'hex'))
+ test.expect(200, '"name is 论"', done)
+ })
+ })
+
+ describe('with limit option', function () {
+ it('should 413 when over limit with Content-Length', function (done) {
+ var buf = Buffer.alloc(1028, '.')
+ request(createApp({ limit: '1kb' }))
+ .post('/')
+ .set('Content-Type', 'text/plain')
+ .set('Content-Length', '1028')
+ .send(buf.toString())
+ .expect(413, done)
+ })
+
+ it('should 413 when over limit with chunked encoding', function (done) {
+ var buf = Buffer.alloc(1028, '.')
+ var app = createApp({ limit: '1kb' })
+ var test = request(app).post('/')
+ test.set('Content-Type', 'text/plain')
+ test.set('Transfer-Encoding', 'chunked')
+ test.write(buf.toString())
+ test.expect(413, done)
+ })
+
+ it('should accept number of bytes', function (done) {
+ var buf = Buffer.alloc(1028, '.')
+ request(createApp({ limit: 1024 }))
+ .post('/')
+ .set('Content-Type', 'text/plain')
+ .send(buf.toString())
+ .expect(413, done)
+ })
+
+ it('should not change when options altered', function (done) {
+ var buf = Buffer.alloc(1028, '.')
+ var options = { limit: '1kb' }
+ var app = createApp(options)
+
+ options.limit = '100kb'
+
+ request(app)
+ .post('/')
+ .set('Content-Type', 'text/plain')
+ .send(buf.toString())
+ .expect(413, done)
+ })
+
+ it('should not hang response', function (done) {
+ var buf = Buffer.alloc(10240, '.')
+ var app = createApp({ limit: '8kb' })
+ var test = request(app).post('/')
+ test.set('Content-Type', 'text/plain')
+ test.write(buf)
+ test.write(buf)
+ test.write(buf)
+ test.expect(413, done)
+ })
+ })
+
+ describe('with inflate option', function () {
+ describe('when false', function () {
+ before(function () {
+ this.app = createApp({ inflate: false })
+ })
+
+ it('should not accept content-encoding', function (done) {
+ var test = request(this.app).post('/')
+ test.set('Content-Encoding', 'gzip')
+ test.set('Content-Type', 'text/plain')
+ test.write(Buffer.from('1f8b080000000000000bcb4bcc4d55c82c5678b16e170072b3e0200b000000', 'hex'))
+ test.expect(415, 'content encoding unsupported', done)
+ })
+ })
+
+ describe('when true', function () {
+ before(function () {
+ this.app = createApp({ inflate: true })
+ })
+
+ it('should accept content-encoding', function (done) {
+ var test = request(this.app).post('/')
+ test.set('Content-Encoding', 'gzip')
+ test.set('Content-Type', 'text/plain')
+ test.write(Buffer.from('1f8b080000000000000bcb4bcc4d55c82c5678b16e170072b3e0200b000000', 'hex'))
+ test.expect(200, '"name is 论"', done)
+ })
+ })
+ })
+
+ describe('with type option', function () {
+ describe('when "text/html"', function () {
+ before(function () {
+ this.app = createApp({ type: 'text/html' })
+ })
+
+ it('should parse for custom type', function (done) {
+ request(this.app)
+ .post('/')
+ .set('Content-Type', 'text/html')
+ .send('tobi')
+ .expect(200, '"tobi"', done)
+ })
+
+ it('should ignore standard type', function (done) {
+ request(this.app)
+ .post('/')
+ .set('Content-Type', 'text/plain')
+ .send('user is tobi')
+ .expect(200, '{}', done)
+ })
+ })
+
+ describe('when ["text/html", "text/plain"]', function () {
+ before(function () {
+ this.app = createApp({ type: ['text/html', 'text/plain'] })
+ })
+
+ it('should parse "text/html"', function (done) {
+ request(this.app)
+ .post('/')
+ .set('Content-Type', 'text/html')
+ .send('tobi')
+ .expect(200, '"tobi"', done)
+ })
+
+ it('should parse "text/plain"', function (done) {
+ request(this.app)
+ .post('/')
+ .set('Content-Type', 'text/plain')
+ .send('tobi')
+ .expect(200, '"tobi"', done)
+ })
+
+ it('should ignore "text/xml"', function (done) {
+ request(this.app)
+ .post('/')
+ .set('Content-Type', 'text/xml')
+ .send('tobi')
+ .expect(200, '{}', done)
+ })
+ })
+
+ describe('when a function', function () {
+ it('should parse when truthy value returned', function (done) {
+ var app = createApp({ type: accept })
+
+ function accept (req) {
+ return req.headers['content-type'] === 'text/vnd.something'
+ }
+
+ request(app)
+ .post('/')
+ .set('Content-Type', 'text/vnd.something')
+ .send('user is tobi')
+ .expect(200, '"user is tobi"', done)
+ })
+
+ it('should work without content-type', function (done) {
+ var app = createApp({ type: accept })
+
+ function accept (req) {
+ return true
+ }
+
+ var test = request(app).post('/')
+ test.write('user is tobi')
+ test.expect(200, '"user is tobi"', done)
+ })
+
+ it('should not invoke without a body', function (done) {
+ var app = createApp({ type: accept })
+
+ function accept (req) {
+ throw new Error('oops!')
+ }
+
+ request(app)
+ .get('/')
+ .expect(404, done)
+ })
+ })
+ })
+
+ describe('with verify option', function () {
+ it('should assert value is function', function () {
+ assert.throws(createApp.bind(null, { verify: 'lol' }),
+ /TypeError: option verify must be function/)
+ })
+
+ it('should error from verify', function (done) {
+ var app = createApp({ verify: function (req, res, buf) {
+ if (buf[0] === 0x20) throw new Error('no leading space')
+ } })
+
+ request(app)
+ .post('/')
+ .set('Content-Type', 'text/plain')
+ .send(' user is tobi')
+ .expect(403, 'no leading space', done)
+ })
+
+ it('should allow custom codes', function (done) {
+ var app = createApp({ verify: function (req, res, buf) {
+ if (buf[0] !== 0x20) return
+ var err = new Error('no leading space')
+ err.status = 400
+ throw err
+ } })
+
+ request(app)
+ .post('/')
+ .set('Content-Type', 'text/plain')
+ .send(' user is tobi')
+ .expect(400, 'no leading space', done)
+ })
+
+ it('should allow pass-through', function (done) {
+ var app = createApp({ verify: function (req, res, buf) {
+ if (buf[0] === 0x20) throw new Error('no leading space')
+ } })
+
+ request(app)
+ .post('/')
+ .set('Content-Type', 'text/plain')
+ .send('user is tobi')
+ .expect(200, '"user is tobi"', done)
+ })
+
+ it('should 415 on unknown charset prior to verify', function (done) {
+ var app = createApp({ verify: function (req, res, buf) {
+ throw new Error('unexpected verify call')
+ } })
+
+ var test = request(app).post('/')
+ test.set('Content-Type', 'text/plain; charset=x-bogus')
+ test.write(Buffer.from('00000000', 'hex'))
+ test.expect(415, 'unsupported charset "X-BOGUS"', done)
+ })
+ })
+
+ describe('charset', function () {
+ before(function () {
+ this.app = createApp()
+ })
+
+ it('should parse utf-8', function (done) {
+ var test = request(this.app).post('/')
+ test.set('Content-Type', 'text/plain; charset=utf-8')
+ test.write(Buffer.from('6e616d6520697320e8aeba', 'hex'))
+ test.expect(200, '"name is 论"', done)
+ })
+
+ it('should parse codepage charsets', function (done) {
+ var test = request(this.app).post('/')
+ test.set('Content-Type', 'text/plain; charset=koi8-r')
+ test.write(Buffer.from('6e616d6520697320cec5d4', 'hex'))
+ test.expect(200, '"name is нет"', done)
+ })
+
+ it('should parse when content-length != char length', function (done) {
+ var test = request(this.app).post('/')
+ test.set('Content-Type', 'text/plain; charset=utf-8')
+ test.set('Content-Length', '11')
+ test.write(Buffer.from('6e616d6520697320e8aeba', 'hex'))
+ test.expect(200, '"name is 论"', done)
+ })
+
+ it('should default to utf-8', function (done) {
+ var test = request(this.app).post('/')
+ test.set('Content-Type', 'text/plain')
+ test.write(Buffer.from('6e616d6520697320e8aeba', 'hex'))
+ test.expect(200, '"name is 论"', done)
+ })
+
+ it('should 415 on unknown charset', function (done) {
+ var test = request(this.app).post('/')
+ test.set('Content-Type', 'text/plain; charset=x-bogus')
+ test.write(Buffer.from('00000000', 'hex'))
+ test.expect(415, 'unsupported charset "X-BOGUS"', done)
+ })
+ })
+
+ describe('encoding', function () {
+ before(function () {
+ this.app = createApp({ limit: '10kb' })
+ })
+
+ it('should parse without encoding', function (done) {
+ var test = request(this.app).post('/')
+ test.set('Content-Type', 'text/plain')
+ test.write(Buffer.from('6e616d6520697320e8aeba', 'hex'))
+ test.expect(200, '"name is 论"', done)
+ })
+
+ it('should support identity encoding', function (done) {
+ var test = request(this.app).post('/')
+ test.set('Content-Encoding', 'identity')
+ test.set('Content-Type', 'text/plain')
+ test.write(Buffer.from('6e616d6520697320e8aeba', 'hex'))
+ test.expect(200, '"name is 论"', done)
+ })
+
+ it('should support gzip encoding', function (done) {
+ var test = request(this.app).post('/')
+ test.set('Content-Encoding', 'gzip')
+ test.set('Content-Type', 'text/plain')
+ test.write(Buffer.from('1f8b080000000000000bcb4bcc4d55c82c5678b16e170072b3e0200b000000', 'hex'))
+ test.expect(200, '"name is 论"', done)
+ })
+
+ it('should support deflate encoding', function (done) {
+ var test = request(this.app).post('/')
+ test.set('Content-Encoding', 'deflate')
+ test.set('Content-Type', 'text/plain')
+ test.write(Buffer.from('789ccb4bcc4d55c82c5678b16e17001a6f050e', 'hex'))
+ test.expect(200, '"name is 论"', done)
+ })
+
+ it('should be case-insensitive', function (done) {
+ var test = request(this.app).post('/')
+ test.set('Content-Encoding', 'GZIP')
+ test.set('Content-Type', 'text/plain')
+ test.write(Buffer.from('1f8b080000000000000bcb4bcc4d55c82c5678b16e170072b3e0200b000000', 'hex'))
+ test.expect(200, '"name is 论"', done)
+ })
+
+ it('should fail on unknown encoding', function (done) {
+ var test = request(this.app).post('/')
+ test.set('Content-Encoding', 'nulls')
+ test.set('Content-Type', 'text/plain')
+ test.write(Buffer.from('000000000000', 'hex'))
+ test.expect(415, 'unsupported content encoding "nulls"', done)
+ })
+ })
+})
+
+function createApp (options) {
+ var app = express()
+
+ app.use(express.text(options))
+
+ app.use(function (err, req, res, next) {
+ res.status(err.status || 500)
+ res.send(err.message)
+ })
+
+ app.post('/', function (req, res) {
+ res.json(req.body)
+ })
+
+ return app
+}