Skip to content

Commit

Permalink
Merge pull request #30 from loadimpact/feature/multipart-form-data
Browse files Browse the repository at this point in the history
feature/multipart form data
  • Loading branch information
legander authored Apr 3, 2020
2 parents b98ed39 + eb38b60 commit 4665de9
Show file tree
Hide file tree
Showing 18 changed files with 453 additions and 286 deletions.
458 changes: 245 additions & 213 deletions example/full.har

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion src/aid.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ function nought (value) {
)
}

function parseContentType (str = '') {
const [mimeType, ...rest] = str.split(';').map(s => s.trim())
const params = Object.fromEntries(rest.map(s => s.split('=')))

return {
...params,
mimeType
}
}

function getContentTypeValue (str = '') {
return str.split(';')[0]
}

function isBlacklistedHeader (headerName = '') {
const HEADERS_BLACKLIST = ['Content-Length']
const [name] = headerName.split(';')
Expand All @@ -48,5 +62,7 @@ module.exports = {
emptyObject,
isString,
extrinsic,
nought
nought,
parseContentType,
getContentTypeValue
}
3 changes: 2 additions & 1 deletion src/make.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ function paramsState () {

function postState () {
return {
species: null
species: null,
boundary: null
}
}

Expand Down
24 changes: 22 additions & 2 deletions src/parse/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const headers = require('./headers')
const postData = require('./postData')
const queryString = require('./queryString')
const state = require('./state/request')
const { emptyObject } = require('../aid')
const { emptyObject, getContentTypeValue } = require('../aid')

function request (node, spec) {
spec.method = node.method.toUpperCase()
Expand All @@ -21,16 +21,36 @@ function request (node, spec) {
contentType(node.postData.mimeType, spec.headers)
}
state(spec)

if (spec.state.post.boundary) {
addBoundary(spec.state.post.boundary, spec.headers)
}
}

// Fallback to content type from postData
// Preserves explicit header which potentially has more information
function contentType (mimeType, headers) {
if (!headers.has('Content-Type')) {
const item = { value: mimeType }
const items = new Set([ item ])
const items = new Set([item])
headers.set('Content-Type', items)
}
}

function addBoundary (boundary, headers) {
if (headers.has('Content-Type')) {
const items = [...headers.get('Content-Type').values()]
const newItems = items.map(item => {
const value = getContentTypeValue(item.value)
if (value === 'multipart/form-data') {
return { value: `${value}; boundary=${boundary}` }
}

return item
})

headers.set('Content-Type', new Set(newItems))
}
}

module.exports = request
12 changes: 12 additions & 0 deletions src/parse/state/post.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
const { generateBoundary } = require('emailjs-mime-builder/dist/utils')
const { PostSpecies } = require('../../enum')
const { parseContentType } = require('../../aid')

function post (spec) {
const state = spec.state.post
state.species = species(spec.post)
state.boundary = boundary(spec.post, state.species)
}

function species (spec) {
Expand All @@ -15,4 +18,13 @@ function species (spec) {
}
}

function boundary (spec, species) {
const { mimeType, boundary } = parseContentType(spec.type)
if (species === PostSpecies.Structured && mimeType === 'multipart/form-data') {
return boundary || generateBoundary(1, Date.now().toString() + Math.random())
} else {
return null
}
}

module.exports = post
22 changes: 14 additions & 8 deletions src/render/post/multipart/fixed.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ const note = require('../../note/map')
const string = require('../../string')

// Multipart encoded post data without variable
function fixed (params) {
return [
description(params),
value(params)
].filter(item => item).join(`\n`)
function fixed (spec) {
return [description(spec.post.params), value(spec.post.params, spec.state.post.boundary)]
.filter(item => item)
.join(`\n`)
}

function value (params) {
function value (params, boundary) {
const message = new MimeBuilder('multipart/form-data')
for (const [ name, items ] of params) {

if (boundary) {
message.boundary = boundary
}

for (const [name, items] of params) {
for (const item of items) {
message.createChild(item.contentType, { filename: item.fileName })
message
.createChild(item.contentType, { filename: item.fileName })
.setHeader('Content-Disposition', `form-data; name=${name}`)
.setHeader('Content-Transfer-Encoding', item.fileName ? 'base64' : 'quoted-printable')
.setContent(item.value || '')
}
}
Expand Down
10 changes: 9 additions & 1 deletion src/render/post/multipart/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
const fixed = require('./fixed')
const resolved = require('./resolved/arg')
const { UnrecognizedError } = require('../../../error')

function multipart (spec) {
if (spec.state.params.variable) {
// Throw error here until emails-js-builder has runtime support via jslib.
throw new UnrecognizedError(
{ name: 'UnrecognizedStructuredPostType' },
`Unrecognized resolved post data structure MIME type: ${spec.post.type}`
)

// eslint-disable-next-line no-unreachable
return resolved()
} else {
return fixed(spec.post.params)
return fixed(spec)
}
}

Expand Down
1 change: 1 addition & 0 deletions src/render/post/multipart/resolved/pre.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function entry (name, item) {
logic.push('' +
`body.createChild(${args.join(`, `)})
.setHeader("Content-Disposition", ${disposition(name)})
.setHeader("Content-Transfer-Encoding", "${item.fileName ? 'base64' : 'quoted-printable'}")
.setContent(${value(item)});`)
return logic.join(`\n`)
}
Expand Down
10 changes: 5 additions & 5 deletions src/render/post/structured.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
// const multipart = require('./multipart')
const multipart = require('./multipart')
const url = require('./url')
const { UnrecognizedError } = require('../../error')
const { getContentTypeValue } = require('../../aid')

function structured (spec) {
switch (spec.post.type) {
switch (getContentTypeValue(spec.post.type)) {
case 'application/x-www-form-urlencoded':
return url(spec)
// NOTE: Not supported yet..
// case 'multipart/form-data':
// return multipart(spec)
case 'multipart/form-data':
return multipart(spec)
default:
throw new UnrecognizedError(
{ name: 'UnrecognizedStructuredPostType' },
Expand Down
4 changes: 2 additions & 2 deletions src/validate/postData.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const params = require('./params')
const { empty, emptyObject, seralizeURLSearchParams } = require('../aid')
const { empty, emptyObject, seralizeURLSearchParams, getContentTypeValue } = require('../aid')
const { InvalidArchiveError } = require('../error')

/*
Expand Down Expand Up @@ -76,7 +76,7 @@ function validate (node, i) {
![
'application/x-www-form-urlencoded',
'multipart/form-data'
].includes(node.mimeType)
].includes(getContentTypeValue(node.mimeType))
) {
throw new InvalidArchiveError(
{ name: 'InvalidPostDataType' },
Expand Down
6 changes: 3 additions & 3 deletions src/validate/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const headers = require('./headers')
const isPlainObject = require('is-plain-object')
const postData = require('./postData')
const queryString = require('./queryString')
const { empty, emptyObject } = require('../aid')
const { empty, emptyObject, getContentTypeValue } = require('../aid')
const { absoluteUrl, variableStart } = require('../expression')
const { InvalidArchiveError } = require('../error')

Expand Down Expand Up @@ -116,8 +116,8 @@ function relation (node, i) {
node.headers.findIndex(findContentType) !== -1
) {
const header = node.headers.find(findContentType)
const headerType = header.value ? header.value.split(';')[0] : ''
const postType = node.postData.mimeType ? node.postData.mimeType.split(';')[0] : ''
const headerType = getContentTypeValue(header.value)
const postType = getContentTypeValue(node.postData.mimeType)
if (headerType !== postType) {
throw new InvalidArchiveError(
{ name: 'InconsistentContentType' },
Expand Down
20 changes: 10 additions & 10 deletions standalone.js

Large diffs are not rendered by default.

78 changes: 55 additions & 23 deletions test/int/render/post/multipart/fixed.js
Original file line number Diff line number Diff line change
@@ -1,47 +1,79 @@
import test from 'ava'
import isolate from 'helper/isolate'
const [ fixed, { comment, note, string } ] =
isolate(test, 'render/post/multipart/fixed', {
comment: 'render/comment',
note: 'render/note/map',
string: 'render/string'
})
import { requestSpec as makeRequestSpec } from 'make'

const [fixed, { comment, note, string }] = isolate(test, 'render/post/multipart/fixed', {
comment: 'render/comment',
note: 'render/note/map',
string: 'render/string'
})

test.serial('result', t => {
const spec = makeRequestSpec()
spec.post.params = new Map()
string.returns('rendered')
const result = fixed(new Map())
const result = fixed(spec)
t.is(result, 'rendered')
})

test.serial('build', t => {
const params = new Map()
.set('search', new Set([ { value: 'kitten' } ]))
fixed(params)
t.true(
string.firstCall.args[0].startsWith('Content-Type: multipart/form-data;')
const spec = makeRequestSpec()
spec.post.params = new Map().set('search', new Set([{ value: 'kitten' }]))
fixed(spec)
t.true(string.firstCall.args[0].startsWith('Content-Type: multipart/form-data;'))
})

test.serial('field with fileName', t => {
const spec = makeRequestSpec()
spec.post.params = new Map().set(
'search',
new Set([{ value: 'kitten', contentType: 'text/csv', fileName: 'data.csv' }])
)
fixed(spec)
const result = string.firstCall.args[0].split('\r\n')
t.is(result[7], 'Content-Type: text/csv')
t.is(result[8], 'Content-Disposition: form-data; name=search; filename=data.csv')
t.is(result[9], 'Content-Transfer-Encoding: base64')
t.is(result[11], 'a2l0dGVu')
})

test.serial('field', t => {
const params = new Map()
.set('search', new Set([ { value: 'kitten' } ]))
fixed(params)
const spec = makeRequestSpec()
spec.post.params = new Map().set(
'search',
new Set([{ value: 'kitten' }])
)
fixed(spec)
const result = string.firstCall.args[0].split('\r\n')
t.is(result[7], 'Content-Disposition: form-data; name=search')
t.is(result[8], 'Content-Transfer-Encoding: base64')
t.is(result[10], 'a2l0dGVu')
t.is(result[8], 'Content-Transfer-Encoding: quoted-printable')
t.is(result[10], 'kitten')
})

test.serial('boundary', t => {
const spec = makeRequestSpec()
spec.state.post.boundary = 'foobar'
spec.post.params = new Map().set(
'search',
new Set([{ value: 'kitten' }])
)
fixed(spec)
const result = string.firstCall.args[0].split('\r\n')
t.is(result[5], '--foobar')
t.is(result[10], '--foobar--')
})

test.serial('comment', t => {
note.returns('-search- Find kittens')
comment.returns('// -search- Find kittens')
string.returns('"search=kitten"')
const params = new Map()
.set('search', new Set([
{ value: 'kitten', comment: 'Find kittens' }
]))
const result = fixed(params)
t.deepEqual(note.firstCall.args[0], params)
const spec = makeRequestSpec()
spec.post.params = new Map().set(
'search',
new Set([{ value: 'kitten', comment: 'Find kittens' }])
)
const result = fixed(spec)
t.deepEqual(note.firstCall.args[0], spec.post.params)
t.is(comment.firstCall.args[0], '-search- Find kittens')
t.true(result.startsWith(`// -search- Find kittens`))
})
15 changes: 15 additions & 0 deletions test/unit/parse/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ test.serial('headers', t => {
t.true(headers.calledOnce)
})

test.serial('headers boundary', t => {
const spec = makeRequestSpec()
spec.state.post.boundary = 'foobar'
spec.headers = new Map().set('Content-Type', new Set([{ value: 'multipart/form-data' }]))
request(
{
method: 'POST',
url: 'http://example.com',
headers: [{ name: 'Content-Type', value: 'multipart/form-data' }]
},
spec
)
t.is([...spec.headers.get('Content-Type')][0].value, 'multipart/form-data; boundary=foobar')
})

test.serial('postData', t => {
request({
method: 'GET',
Expand Down
16 changes: 16 additions & 0 deletions test/unit/parse/state/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,19 @@ test('structured', t => {
post(spec)
t.is(spec.state.post.species, PostSpecies.Structured)
})

test('generated boundary', t => {
const spec = makeRequestSpec()
spec.post.type = 'multipart/form-data'
spec.post.params = [ {}, {}, {} ]
post(spec)
t.not(spec.state.post.boundary, null)
})

test('existing boundary is respected', t => {
const spec = makeRequestSpec()
spec.post.type = 'multipart/form-data; boundary=----someKindOfLongBoundary'
spec.post.params = [ {}, {}, {} ]
post(spec)
t.is(spec.state.post.boundary, '----someKindOfLongBoundary')
})
Loading

0 comments on commit 4665de9

Please sign in to comment.