Skip to content

Commit 4290e55

Browse files
glynnbirdGlynn Bird
andauthored
fix multipart/related requests when uploading multiple attachments. Fixes issue#238 (#279)
Co-authored-by: Glynn Bird <glynnbird@apache.org>
1 parent 8552352 commit 4290e55

File tree

4 files changed

+125
-7
lines changed

4 files changed

+125
-7
lines changed

lib/multipart.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const CRLF = '\r\n'
2+
const DASHES = '--'
3+
4+
// generate the payload, boundary and header for a multipart/related request
5+
// to upload binary attachments to CouchDB.
6+
// https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html
7+
class MultiPartFactory {
8+
// constructor
9+
constructor (parts) {
10+
// generate a unique id that forms the boundary between parts
11+
this.boundary = this.uuid()
12+
const bufferList = []
13+
14+
// for each part to be processed
15+
for (const part of parts) {
16+
// start with the boundary e.g. --0559337432997171\r\n
17+
bufferList.push(Buffer.from(DASHES + this.boundary + CRLF))
18+
19+
// state the type and length of the following part
20+
bufferList.push(Buffer.from(`content-type: ${part.content_type}${CRLF}`))
21+
bufferList.push(Buffer.from(`content-length: ${part.data.length}${CRLF}`))
22+
23+
// two \r\n marks start of the part itself
24+
bufferList.push(Buffer.from(CRLF))
25+
26+
// output the string/buffer
27+
bufferList.push(typeof part.data === 'string' ? Buffer.from(part.data) : part.data)
28+
29+
// followed by /r/n
30+
bufferList.push(Buffer.from(CRLF))
31+
}
32+
33+
// right at the end we have an end marker e.g. --0559337432997171--\r\n
34+
bufferList.push(Buffer.from(DASHES + this.boundary + DASHES + CRLF))
35+
36+
// buid up a single Buffer from the array of bits
37+
this.data = Buffer.concat(bufferList)
38+
39+
// calculate the Content-Type header required to send with this request
40+
this.header = `multipart/related; boundary=${this.boundary}`
41+
}
42+
43+
uuid () {
44+
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('')
45+
let retval = ''
46+
for (let i = 0; i < 16; i++) {
47+
retval += chars[Math.floor(Math.random() * chars.length)]
48+
}
49+
return retval
50+
}
51+
}
52+
53+
module.exports = MultiPartFactory

lib/nano.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const SCRUBBED_STR = 'XXXXXX'
2727
const defaultHttpAgent = new http.Agent(AGENT_DEFAULTS)
2828
const defaultHttpsAgent = new https.Agent(AGENT_DEFAULTS)
2929
const ChangesReader = require('./changesreader.js')
30+
const MultiPartFactory = require('./multipart.js')
3031

3132
function isEmpty (val) {
3233
return val == null || !(Object.keys(val) || val).length
@@ -282,7 +283,11 @@ module.exports = exports = function dbScope (cfg) {
282283
}
283284

284285
if (opts.multipart) {
285-
req.multipart = opts.multipart
286+
// generate the multipart/related body, header and boundary to
287+
// upload multiple binary attachments in one request
288+
const mp = new MultiPartFactory(opts.multipart)
289+
opts.contentType = mp.header
290+
req.body = mp.data
286291
}
287292

288293
req.headers = Object.assign(req.headers, opts.headers, cfg.defaultHeaders)
@@ -891,12 +896,13 @@ module.exports = exports = function dbScope (cfg) {
891896
/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
892897
content_type: att.content_type
893898
}
894-
multipart.push({ body: att.data })
899+
multipart.push(att)
895900
})
896901

897902
multipart.unshift({
898-
'content-type': 'application/json',
899-
body: JSON.stringify(doc)
903+
content_type: 'application/json',
904+
data: JSON.stringify(doc),
905+
name: 'document'
900906
})
901907

902908
return relax({

test/multipart.insert.test.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ afterEach(() => {
5353
test('should be able to insert a document with attachments #1 - multipart PUT /db/id - db.multipart.insert', async () => {
5454
// mocks
5555
const response = { ok: true, id: '8s8g8h8h9', rev: '1-123' }
56-
const scope = nock(COUCH_URL, { reqheaders: { 'content-type': h => h.includes('multipart/related') } })
56+
const scope = nock(COUCH_URL)
57+
.matchHeader('content-type', h => h.includes('multipart/related'))
5758
.put('/db/docid')
5859
.reply(200, response)
5960

@@ -66,7 +67,8 @@ test('should be able to insert a document with attachments #1 - multipart PUT /d
6667

6768
test('should be able to insert a document with attachments #2 - multipart PUT /db/id - db.multipart.insert', async () => {
6869
const response = { ok: true, id: '8s8g8h8h9', rev: '1-123' }
69-
const scope = nock(COUCH_URL, { reqheaders: { 'content-type': h => h.includes('multipart/related') } })
70+
const scope = nock(COUCH_URL)
71+
.matchHeader('content-type', h => h.includes('multipart/related'))
7072
.put('/db/docid')
7173
.reply(200, response)
7274

@@ -83,7 +85,8 @@ test('should be able to handle 404 - db.multipart.insert', async () => {
8385
error: 'not_found',
8486
reason: 'missing'
8587
}
86-
const scope = nock(COUCH_URL, { reqheaders: { 'content-type': h => h.includes('multipart/related') } })
88+
const scope = nock(COUCH_URL)
89+
.matchHeader('content-type', h => h.includes('multipart/related'))
8790
.put('/db/docid')
8891
.reply(404, response)
8992

test/multipart.test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
const MultiPartFactory = require('../lib/multipart.js')
2+
const textAttachment = { name: 'test.txt', data: 'Hello\r\nWorld!', content_type: 'text/plain' }
3+
const anotherTextAttachment = { name: 'test2.txt', data: 'the quick brown fox', content_type: 'text/plain' }
4+
5+
test('should return different boundary each time', async () => {
6+
const mf1 = new MultiPartFactory([])
7+
const mf2 = new MultiPartFactory([])
8+
const mf3 = new MultiPartFactory([])
9+
10+
expect(typeof mf1.boundary).toBe('string')
11+
expect(typeof mf2.boundary).toBe('string')
12+
expect(typeof mf3.boundary).toBe('string')
13+
expect(mf1.boundary.length).toBe(16)
14+
expect(mf2.boundary.length).toBe(16)
15+
expect(mf3.boundary.length).toBe(16)
16+
expect(mf1).not.toEqual(mf2)
17+
expect(mf1).not.toEqual(mf3)
18+
expect(mf2).not.toEqual(mf3)
19+
})
20+
21+
test('should return boundary in header', async () => {
22+
const mf1 = new MultiPartFactory([])
23+
const boundary = mf1.boundary
24+
const header = mf1.header
25+
expect(header).toEqual(`multipart/related; boundary=${boundary}`)
26+
})
27+
28+
test('should handle single attachments', async () => {
29+
const mf1 = new MultiPartFactory([textAttachment])
30+
expect(typeof mf1.data).toEqual('object')
31+
expect(Buffer.isBuffer(mf1.data)).toEqual(true)
32+
const lines = mf1.data.toString().split('\r\n')
33+
expect(lines).toContain(`--${mf1.boundary}`)
34+
expect(lines).toContain('content-type: text/plain')
35+
expect(lines).toContain('content-length: 13')
36+
expect(lines).toContain('')
37+
expect(lines).toContain('Hello')
38+
expect(lines).toContain('World!')
39+
expect(lines).toContain(`--${mf1.boundary}--`)
40+
})
41+
42+
test('should handle two attachments', async () => {
43+
const mf1 = new MultiPartFactory([textAttachment, anotherTextAttachment])
44+
expect(typeof mf1.data).toEqual('object')
45+
expect(Buffer.isBuffer(mf1.data)).toEqual(true)
46+
const lines = mf1.data.toString().split('\r\n')
47+
expect(lines).toContain(`--${mf1.boundary}`)
48+
expect(lines).toContain('content-type: text/plain')
49+
expect(lines).toContain('content-length: 13')
50+
expect(lines).toContain('')
51+
expect(lines).toContain('Hello')
52+
expect(lines).toContain('World!')
53+
expect(lines).toContain('content-length: 19')
54+
expect(lines).toContain('the quick brown fox')
55+
expect(lines).toContain(`--${mf1.boundary}--`)
56+
})

0 commit comments

Comments
 (0)