You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by gl...@apache.org on 2021/09/13 09:19:49 UTC

[couchdb-nano] 01/01: fix multipart/related requests when uploading multiple attachments. Fixes issue#238

This is an automated email from the ASF dual-hosted git repository.

glynnbird pushed a commit to branch issue#238
in repository https://gitbox.apache.org/repos/asf/couchdb-nano.git

commit eb2c54dfbbdef288ebcafd2c3a2590678b151718
Author: Glynn Bird <gl...@apache.org>
AuthorDate: Mon Sep 13 10:18:24 2021 +0100

    fix multipart/related requests when uploading multiple attachments. Fixes issue#238
---
 lib/multipart.js              | 53 ++++++++++++++++++++++++++++++++++++++++
 lib/nano.js                   | 14 +++++++----
 test/multipart.insert.test.js |  9 ++++---
 test/multipart.test.js        | 56 +++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 125 insertions(+), 7 deletions(-)

diff --git a/lib/multipart.js b/lib/multipart.js
new file mode 100644
index 0000000..cf7f946
--- /dev/null
+++ b/lib/multipart.js
@@ -0,0 +1,53 @@
+const CRLF = '\r\n'
+const DASHES = '--'
+
+// generate the payload, boundary and header for a multipart/related request
+// to upload binary attachments to CouchDB.
+// https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html
+class MultiPartFactory {
+  // constructor
+  constructor (parts) {
+    // generate a unique id that forms the boundary between parts
+    this.boundary = this.uuid()
+    const bufferList = []
+
+    // for each part to be processed
+    for (const part of parts) {
+      // start with the boundary e.g. --0559337432997171\r\n
+      bufferList.push(Buffer.from(DASHES + this.boundary + CRLF))
+
+      // state the type and length of the following part
+      bufferList.push(Buffer.from(`content-type: ${part.content_type}${CRLF}`))
+      bufferList.push(Buffer.from(`content-length: ${part.data.length}${CRLF}`))
+
+      // two \r\n marks start of the part itself
+      bufferList.push(Buffer.from(CRLF))
+
+      // output the string/buffer
+      bufferList.push(typeof part.data === 'string' ? Buffer.from(part.data) : part.data)
+
+      // followed by /r/n
+      bufferList.push(Buffer.from(CRLF))
+    }
+
+    // right at the end we have an end marker e.g. --0559337432997171--\r\n
+    bufferList.push(Buffer.from(DASHES + this.boundary + DASHES + CRLF))
+
+    // buid up a single Buffer from the array of bits
+    this.data = Buffer.concat(bufferList)
+
+    // calculate the Content-Type header required to send with this request
+    this.header = `multipart/related; boundary=${this.boundary}`
+  }
+
+  uuid () {
+    const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('')
+    let retval = ''
+    for (let i = 0; i < 16; i++) {
+      retval += chars[Math.floor(Math.random() * chars.length)]
+    }
+    return retval
+  }
+}
+
+module.exports = MultiPartFactory
diff --git a/lib/nano.js b/lib/nano.js
index 1f58559..0b819ea 100644
--- a/lib/nano.js
+++ b/lib/nano.js
@@ -27,6 +27,7 @@ const SCRUBBED_STR = 'XXXXXX'
 const defaultHttpAgent = new http.Agent(AGENT_DEFAULTS)
 const defaultHttpsAgent = new https.Agent(AGENT_DEFAULTS)
 const ChangesReader = require('./changesreader.js')
+const MultiPartFactory = require('./multipart.js')
 
 function isEmpty (val) {
   return val == null || !(Object.keys(val) || val).length
@@ -282,7 +283,11 @@ module.exports = exports = function dbScope (cfg) {
     }
 
     if (opts.multipart) {
-      req.multipart = opts.multipart
+      // generate the multipart/related body, header and boundary to
+      // upload multiple binary attachments in one request
+      const mp = new MultiPartFactory(opts.multipart)
+      opts.contentType = mp.header
+      req.body = mp.data
     }
 
     req.headers = Object.assign(req.headers, opts.headers, cfg.defaultHeaders)
@@ -891,12 +896,13 @@ module.exports = exports = function dbScope (cfg) {
           /* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
           content_type: att.content_type
         }
-        multipart.push({ body: att.data })
+        multipart.push(att)
       })
 
       multipart.unshift({
-        'content-type': 'application/json',
-        body: JSON.stringify(doc)
+        content_type: 'application/json',
+        data: JSON.stringify(doc),
+        name: 'document'
       })
 
       return relax({
diff --git a/test/multipart.insert.test.js b/test/multipart.insert.test.js
index 8c682f6..4f84462 100644
--- a/test/multipart.insert.test.js
+++ b/test/multipart.insert.test.js
@@ -53,7 +53,8 @@ afterEach(() => {
 test('should be able to insert a document with attachments #1 - multipart PUT /db/id - db.multipart.insert', async () => {
   // mocks
   const response = { ok: true, id: '8s8g8h8h9', rev: '1-123' }
-  const scope = nock(COUCH_URL, { reqheaders: { 'content-type': h => h.includes('multipart/related') } })
+  const scope = nock(COUCH_URL)
+    .matchHeader('content-type', h => h.includes('multipart/related'))
     .put('/db/docid')
     .reply(200, response)
 
@@ -66,7 +67,8 @@ test('should be able to insert a document with attachments #1 - multipart PUT /d
 
 test('should be able to insert a document with attachments #2 - multipart PUT /db/id - db.multipart.insert', async () => {
   const response = { ok: true, id: '8s8g8h8h9', rev: '1-123' }
-  const scope = nock(COUCH_URL, { reqheaders: { 'content-type': h => h.includes('multipart/related') } })
+  const scope = nock(COUCH_URL)
+    .matchHeader('content-type', h => h.includes('multipart/related'))
     .put('/db/docid')
     .reply(200, response)
 
@@ -83,7 +85,8 @@ test('should be able to handle 404 - db.multipart.insert', async () => {
     error: 'not_found',
     reason: 'missing'
   }
-  const scope = nock(COUCH_URL, { reqheaders: { 'content-type': h => h.includes('multipart/related') } })
+  const scope = nock(COUCH_URL)
+    .matchHeader('content-type', h => h.includes('multipart/related'))
     .put('/db/docid')
     .reply(404, response)
 
diff --git a/test/multipart.test.js b/test/multipart.test.js
new file mode 100644
index 0000000..f1173f8
--- /dev/null
+++ b/test/multipart.test.js
@@ -0,0 +1,56 @@
+const MultiPartFactory = require('../lib/multipart.js')
+const textAttachment = { name: 'test.txt', data: 'Hello\r\nWorld!', content_type: 'text/plain' }
+const anotherTextAttachment = { name: 'test2.txt', data: 'the quick brown fox', content_type: 'text/plain' }
+
+test('should return different boundary each time', async () => {
+  const mf1 = new MultiPartFactory([])
+  const mf2 = new MultiPartFactory([])
+  const mf3 = new MultiPartFactory([])
+
+  expect(typeof mf1.boundary).toBe('string')
+  expect(typeof mf2.boundary).toBe('string')
+  expect(typeof mf3.boundary).toBe('string')
+  expect(mf1.boundary.length).toBe(16)
+  expect(mf2.boundary.length).toBe(16)
+  expect(mf3.boundary.length).toBe(16)
+  expect(mf1).not.toEqual(mf2)
+  expect(mf1).not.toEqual(mf3)
+  expect(mf2).not.toEqual(mf3)
+})
+
+test('should return boundary in header', async () => {
+  const mf1 = new MultiPartFactory([])
+  const boundary = mf1.boundary
+  const header = mf1.header
+  expect(header).toEqual(`multipart/related; boundary=${boundary}`)
+})
+
+test('should handle single attachments', async () => {
+  const mf1 = new MultiPartFactory([textAttachment])
+  expect(typeof mf1.data).toEqual('object')
+  expect(Buffer.isBuffer(mf1.data)).toEqual(true)
+  const lines = mf1.data.toString().split('\r\n')
+  expect(lines).toContain(`--${mf1.boundary}`)
+  expect(lines).toContain('content-type: text/plain')
+  expect(lines).toContain('content-length: 13')
+  expect(lines).toContain('')
+  expect(lines).toContain('Hello')
+  expect(lines).toContain('World!')
+  expect(lines).toContain(`--${mf1.boundary}--`)
+})
+
+test('should handle two attachments', async () => {
+  const mf1 = new MultiPartFactory([textAttachment, anotherTextAttachment])
+  expect(typeof mf1.data).toEqual('object')
+  expect(Buffer.isBuffer(mf1.data)).toEqual(true)
+  const lines = mf1.data.toString().split('\r\n')
+  expect(lines).toContain(`--${mf1.boundary}`)
+  expect(lines).toContain('content-type: text/plain')
+  expect(lines).toContain('content-length: 13')
+  expect(lines).toContain('')
+  expect(lines).toContain('Hello')
+  expect(lines).toContain('World!')
+  expect(lines).toContain('content-length: 19')
+  expect(lines).toContain('the quick brown fox')
+  expect(lines).toContain(`--${mf1.boundary}--`)
+})