Skip to content

Commit

Permalink
fix: multipart form handling (mcollina#241)
Browse files Browse the repository at this point in the history
Fix Content-Disposition header writing by exposing `form-data` `.append()` method options
in the `options` key of the form object when the type is `file`.
This allows passing a custom `filename` or `filepath` for a file. By default the `filename`
is set to the file name passed in the `path` key.

See:
* https://github.com/form-data/form-data/tree/v2.5.1#alternative-submission-methods
* https://github.com/form-data/form-data/tree/v2.5.1#void-append-string-field-mixed-value--mixed-options-
  • Loading branch information
dnlup authored and mcollina committed Jan 26, 2020
1 parent e30a77a commit 72b88a0
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 10 deletions.
6 changes: 5 additions & 1 deletion README.md
Expand Up @@ -68,8 +68,12 @@ Available options:
Note: This option needs to be used with the '-H/--headers' option in some frameworks
-F/--form FORM
Upload a form (multipart/form-data). The form options can be a JSON string like
'{ "field_1": { "type": "text", "value": "a text value"}, "field_2": { "type": "file", "path": "path to the file" } }'
'{ "field 1": { "type": "text", "value": "a text value"}, "field 2": { "type": "file", "path": "path to the file" } }'
or a path to a JSON file containing the form options.
When uploading a file the default filename value can be overridden by using the corresponding option:
'{ "field name": { "type": "file", "path": "path to the file", "options": { "filename": "myfilename" } } }'
Passing the filepath to the form can be done by using the corresponding option:
'{ "field name": { "type": "file", "path": "path to the file", "options": { "filepath": "/some/path/myfilename" } } }'
-i/--input FILE
The body of the request. See '-b/body' for more details.
-H/--headers K=V
Expand Down
6 changes: 5 additions & 1 deletion help.txt
Expand Up @@ -29,8 +29,12 @@ Available options:
The body of the request.
-F/--form FORM
Upload a form (multipart/form-data). The form options can be a JSON string like
'{ "field_1": { "type": "text", "value": "a text value"}, "field_2": { "type": "file", "path": "path to the file" } }'
'{ "field 1": { "type": "text", "value": "a text value"}, "field 2": { "type": "file", "path": "path to the file" } }'
or a path to a JSON file containing the form options.
When uploading a file the default filename value can be overridden by using the corresponding option:
'{ "field name": { "type": "file", "path": "path to the file", "options": { "filename": "myfilename" } } }'
Passing the filepath to the form can be done by using the corresponding option:
'{ "field name": { "type": "file", "path": "path to the file", "options": { "filepath": "/some/path/myfilename" } } }'
-i/--input FILE
The body of the request.
-H/--headers K=V
Expand Down
7 changes: 5 additions & 2 deletions lib/multipart.js
@@ -1,6 +1,6 @@
'use strict'

const { resolve } = require('path')
const { resolve, basename } = require('path')
const { readFileSync } = require('fs')
const FormData = require('form-data')

Expand Down Expand Up @@ -29,8 +29,11 @@ module.exports = (options) => {
if (!path) {
throw new Error(`Missing key 'path' in form object for key '${key}'`)
}
const opts = obj[key] && obj[key].options
const buffer = readFileSync(path)
form.append(key, buffer)
form.append(key, buffer, Object.assign({}, {
filename: basename(path)
}, opts))
break
}
case 'text': {
Expand Down
11 changes: 8 additions & 3 deletions test/helper.js
Expand Up @@ -132,16 +132,20 @@ function startTlsServer () {
return server
}

function startMultipartServer () {
function startMultipartServer (opts = {}, test = () => {}) {
const server = http.createServer(handle)
const allowed = ['POST', 'PUT']
function handle (req, res) {
if (allowed.includes(req.method)) {
const bboy = new BusBoy({ headers: req.headers })
const bboy = new BusBoy({ headers: req.headers, ...opts })
const fileData = []
const payload = {}
bboy.on('file', (fieldname, file, filename, encoding, mimetype) => {
payload[fieldname] = {}
payload[fieldname] = {
filename,
encoding,
mimetype
}
file.on('data', data => fileData.push(data))
})
bboy.on('field', (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => {
Expand All @@ -151,6 +155,7 @@ function startMultipartServer () {
res.statusCode = fileData.length ? 201 : 400
res.write(JSON.stringify(payload))
res.end()
test(payload)
})
req.pipe(bboy)
} else {
Expand Down
113 changes: 110 additions & 3 deletions test/runMultipart.test.js
Expand Up @@ -7,7 +7,6 @@ const { writeFile } = require('fs')
const { promisify } = require('util')
const run = require('../lib/run')
const helper = require('./helper')
const server = helper.startMultipartServer()
const writef = promisify(writeFile)

test('run should return an error with invalid form options', async t => {
Expand Down Expand Up @@ -43,17 +42,20 @@ test('run should return an error with invalid form options', async t => {
message: 'Missing key \'value\' in form object for key \'image\''
},
{
name: 'JSON options missing value in text type',
name: 'JSON options with not supported type',
value: '{ "image": { "type": "random" }}',
message: 'A \'type\' key with value \'text\' or \'file\' should be specified'
},
{
name: 'JS Object missing value in text type',
name: 'JS Object with not supported type',
value: { image: { type: 'random' } },
message: 'A \'type\' key with value \'text\' or \'file\' should be specified'
}
]

const server = helper.startMultipartServer()
t.tearDown(() => server.close())

for (const c of cases) {
t.test(c.name, async t => {
const [err] = await new Promise((resolve) => {
Expand All @@ -76,6 +78,9 @@ test('run should return an error with invalid form options', async t => {
})

test('run should take form options as a JSON string or a JS Object', async t => {
const server = helper.startMultipartServer()
t.tearDown(() => server.close())

const form = {
image: {
type: 'file',
Expand Down Expand Up @@ -131,6 +136,9 @@ test('run should take form options as a JSON string or a JS Object', async t =>
})

test('run should use a custom method if `options.method` is passed', t => {
const server = helper.startMultipartServer()
t.tearDown(() => server.close())

const form = {
image: {
type: 'file',
Expand All @@ -155,3 +163,102 @@ test('run should use a custom method if `options.method` is passed', t => {
t.end()
})
})

test('run should set filename', t => {
const server = helper.startMultipartServer(null, payload => {
t.equal('j5.jpeg', payload.image.filename)
})
t.tearDown(() => server.close())

const form = {
image: {
type: 'file',
path: require.resolve('./j5.jpeg')
},
name: {
type: 'text',
value: 'j5'
}
}
run({
url: 'http://localhost:' + server.address().port,
method: 'POST',
connections: 1,
amount: 1,
form
}, (err, res) => {
t.equal(null, err)
t.equal(0, res.errors, 'result should not have errors')
t.equal(1, res['2xx'], 'result status code should be 2xx')
t.equal(0, res.non2xx, 'result status code should be 2xx')
t.end()
})
})

test('run should allow overriding filename', t => {
const server = helper.startMultipartServer(null, payload => {
t.equal('testfilename.jpeg', payload.image.filename)
})
t.tearDown(() => server.close())

const form = {
image: {
type: 'file',
path: require.resolve('./j5.jpeg'),
options: {
filename: 'testfilename.jpeg'
}
},
name: {
type: 'text',
value: 'j5'
}
}
run({
url: 'http://localhost:' + server.address().port,
method: 'POST',
connections: 1,
amount: 1,
form
}, (err, res) => {
t.equal(null, err)
t.equal(0, res.errors, 'result should not have errors')
t.equal(1, res['2xx'], 'result status code should be 2xx')
t.equal(0, res.non2xx, 'result status code should be 2xx')
t.end()
})
})

test('run should allow overriding filename with file path', t => {
const server = helper.startMultipartServer({ preservePath: true }, payload => {
t.equal('some/path/testfilename.jpeg', payload.image.filename)
})
t.tearDown(() => server.close())

const form = {
image: {
type: 'file',
path: require.resolve('./j5.jpeg'),
options: {
filepath: 'some/path/testfilename.jpeg'
}
},
name: {
type: 'text',
value: 'j5'
}
}
run({
url: 'http://localhost:' + server.address().port,
method: 'POST',
connections: 1,
amount: 1,
form
}, (err, res) => {
t.equal(null, err)
t.equal(0, res.errors, 'result should not have errors')
t.equal(1, res['2xx'], 'result status code should be 2xx')
t.equal(0, res.non2xx, 'result status code should be 2xx')
t.end()
})
})

0 comments on commit 72b88a0

Please sign in to comment.