From cf649ca987f52fbc09ceb1624ce65501d0cdc1b2 Mon Sep 17 00:00:00 2001 From: Patrick Leary Date: Tue, 19 Nov 2024 11:23:39 -0500 Subject: [PATCH] endpoints using upload without file parameters will post JSON; fix for fields parameter when value string in multipart requests --- .github/workflows/CI.yml | 2 ++ build/inaturalistjs.js | 62 ++++++++++++++++++++++++------------- lib/inaturalist_api.js | 67 ++++++++++++++++++++++++++-------------- package-lock.json | 12 +++---- package.json | 2 +- test/inaturalist_api.js | 63 +++++++++++++++++++++++++++++++++++++ 6 files changed, 157 insertions(+), 51 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 3d67a32..b9f6beb 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -25,6 +25,8 @@ jobs: - run: npm run coverage + - run: npm run eslint + notify: name: Notify Slack needs: build diff --git a/build/inaturalistjs.js b/build/inaturalistjs.js index 75281ad..7cdd262 100644 --- a/build/inaturalistjs.js +++ b/build/inaturalistjs.js @@ -242,29 +242,13 @@ var iNaturalistAPI = /*#__PURE__*/function () { } // get the right host to send requests var host = iNaturalistAPI.methodHostPrefix(options); - // make the request + // prepare the request var body; if (options.upload) { - body = new LocalFormData(); - // Before params get "flattened" extract the fields and encode them as a - // single JSON string, which the server can handle - var fields = interpolated.remainingParams.fields; - if (fields) { - delete interpolated.remainingParams.fields; - body.append("fields", JSON.stringify(fields)); - } - // multipart requests reference all nested parameter names as strings - // so flatten arrays into "arr[0]" and objects into "obj[prop]" - params = iNaturalistAPI.flattenMultipartParams(interpolated.remainingParams); - Object.keys(params).forEach(function (k) { - // FormData params can include options like file upload sizes - if (params[k] && params[k].type === "custom" && params[k].value) { - body.append(k, params[k].value, params[k].options); - } else { - body.append(k, typeof params[k] === "boolean" ? params[k].toString() : params[k]); - } - }); - } else { + body = iNaturalistAPI.multipartBodyForResuest(interpolated.remainingParams); + } + // if there is no multipart request body, prepare it as a JSON request body + if (body === null || _typeof(body) !== "object") { headers["Content-Type"] = "application/json"; body = JSON.stringify(interpolated.remainingParams); } @@ -286,6 +270,42 @@ var iNaturalistAPI = /*#__PURE__*/function () { var url = "".concat(host, "/").concat(thisRoute).concat(query); return localFetch(url, fetchOpts).then(iNaturalistAPI.thenText).then(iNaturalistAPI.thenJson); } + }, { + key: "multipartBodyForResuest", + value: function multipartBodyForResuest(parameters) { + var body = new LocalFormData(); + var bodyContainsObjects = false; + // Before params get "flattened" extract the fields and encode them as a + // single JSON string, which the server can handle + var fields = parameters.fields; + if (fields) { + body.append("fields", _typeof(fields) === "object" ? JSON.stringify(fields) : fields); + } + // multipart requests reference all nested parameter names as strings + // so flatten arrays into "arr[0]" and objects into "obj[prop]" + var params = iNaturalistAPI.flattenMultipartParams(parameters); + Object.keys(params).forEach(function (k) { + if (k.match(/^fields\[/)) { + return; + } + // FormData params can include options like file upload sizes + if (params[k] && params[k].type === "custom" && params[k].value) { + body.append(k, params[k].value, params[k].options); + bodyContainsObjects = true; + } else { + if (params[k] !== null && _typeof(params[k]) === "object") { + bodyContainsObjects = true; + } + body.append(k, typeof params[k] === "boolean" ? params[k].toString() : params[k]); + } + }); + // there are no parameters with type object, so there are no files in this + // request. Return null as this request does not need to be multipart + if (!bodyContainsObjects) { + return null; + } + return body; + } // a variant of post using the http PUT method }, { diff --git a/lib/inaturalist_api.js b/lib/inaturalist_api.js index 2d38956..2026fc9 100644 --- a/lib/inaturalist_api.js +++ b/lib/inaturalist_api.js @@ -128,7 +128,7 @@ const iNaturalistAPI = class iNaturalistAPI { static post( route, p, opts ) { const options = { ...( opts || { } ) }; - let params = { ...( p || { } ) }; + const params = { ...( p || { } ) }; // interpolate path params, e.g. /:id => /1 const interpolated = iNaturalistAPI.interpolateRouteParams( route, params ); if ( interpolated.err ) { return interpolated.err; } @@ -160,29 +160,13 @@ const iNaturalistAPI = class iNaturalistAPI { } // get the right host to send requests const host = iNaturalistAPI.methodHostPrefix( options ); - // make the request + // prepare the request let body; if ( options.upload ) { - body = new LocalFormData( ); - // Before params get "flattened" extract the fields and encode them as a - // single JSON string, which the server can handle - const { fields } = interpolated.remainingParams; - if ( fields ) { - delete interpolated.remainingParams.fields; - body.append( "fields", JSON.stringify( fields ) ); - } - // multipart requests reference all nested parameter names as strings - // so flatten arrays into "arr[0]" and objects into "obj[prop]" - params = iNaturalistAPI.flattenMultipartParams( interpolated.remainingParams ); - Object.keys( params ).forEach( k => { - // FormData params can include options like file upload sizes - if ( params[k] && params[k].type === "custom" && params[k].value ) { - body.append( k, params[k].value, params[k].options ); - } else { - body.append( k, ( typeof params[k] === "boolean" ) ? params[k].toString( ) : params[k] ); - } - } ); - } else { + body = iNaturalistAPI.multipartBodyForResuest( interpolated.remainingParams ); + } + // if there is no multipart request body, prepare it as a JSON request body + if ( body === null || typeof ( body ) !== "object" ) { headers["Content-Type"] = "application/json"; body = JSON.stringify( interpolated.remainingParams ); } @@ -207,6 +191,41 @@ const iNaturalistAPI = class iNaturalistAPI { .then( iNaturalistAPI.thenJson ); } + static multipartBodyForResuest( parameters ) { + const body = new LocalFormData( ); + let bodyContainsObjects = false; + // Before params get "flattened" extract the fields and encode them as a + // single JSON string, which the server can handle + const { fields } = parameters; + if ( fields ) { + body.append( "fields", typeof ( fields ) === "object" ? JSON.stringify( fields ) : fields ); + } + // multipart requests reference all nested parameter names as strings + // so flatten arrays into "arr[0]" and objects into "obj[prop]" + const params = iNaturalistAPI.flattenMultipartParams( parameters ); + Object.keys( params ).forEach( k => { + if ( k.match( /^fields\[/ ) ) { + return; + } + // FormData params can include options like file upload sizes + if ( params[k] && params[k].type === "custom" && params[k].value ) { + body.append( k, params[k].value, params[k].options ); + bodyContainsObjects = true; + } else { + if ( params[k] !== null && typeof ( params[k] ) === "object" ) { + bodyContainsObjects = true; + } + body.append( k, ( typeof params[k] === "boolean" ) ? params[k].toString( ) : params[k] ); + } + } ); + // there are no parameters with type object, so there are no files in this + // request. Return null as this request does not need to be multipart + if ( !bodyContainsObjects ) { + return null; + } + return body; + } + // a variant of post using the http PUT method static head( route, params, opts = { } ) { const options = { ...opts, method: "head" }; @@ -278,7 +297,9 @@ const iNaturalistAPI = class iNaturalistAPI { if ( params === null ) { return params; } if ( typeof params === "object" ) { if ( !params.constructor || params.constructor.name === "Object" ) { - if ( params.type === "custom" ) { return { [keyPrefix]: params }; } + if ( params.type === "custom" ) { + return { [keyPrefix]: params }; + } const flattenedParams = { }; Object.keys( params ).forEach( k => { const newPrefix = keyPrefix ? `${keyPrefix}[${k}]` : k; diff --git a/package-lock.json b/package-lock.json index 526403d..8711d1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3218,9 +3218,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "dependencies": { "path-key": "^3.1.0", @@ -6767,9 +6767,9 @@ "dev": true }, "node_modules/path-to-regexp": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", - "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true }, "node_modules/pathval": { diff --git a/package.json b/package.json index 09ff680..1f39235 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inaturalistjs", - "version": "2.13.0", + "version": "2.14.0", "description": "inaturalistjs", "author": "iNaturalist", "license": "MIT", diff --git a/test/inaturalist_api.js b/test/inaturalist_api.js index c79be33..c5dbb63 100644 --- a/test/inaturalist_api.js +++ b/test/inaturalist_api.js @@ -89,6 +89,69 @@ describe( "iNaturalistAPI", ( ) => { } ); } ); + describe( "multipartBodyForResuest", ( ) => { + it( "returns FormData if there are custom blob fields", ( ) => { + const requestParameters = { + customField: { + type: "custom", + value: new Blob( ) + } + }; + const body = iNaturalistAPI.multipartBodyForResuest( requestParameters ); + expect( body.constructor.name ).to.eq( "FormData" ); + } ); + + it( "returns FormData if there are blob fields", ( ) => { + const requestParameters = { + blobField: new Blob( ) + }; + const body = iNaturalistAPI.multipartBodyForResuest( requestParameters ); + expect( body.constructor.name ).to.eq( "FormData" ); + } ); + + it( "returns fields as a JSON string", ( ) => { + const fields = { + field1: true, + field2: true + }; + const requestParameters = { + fields, + stringField: "string", + blobField: new Blob( ) + }; + const body = iNaturalistAPI.multipartBodyForResuest( requestParameters ); + expect( body.constructor.name ).to.eq( "FormData" ); + expect( body.get( "fields" ) ).to.eq( JSON.stringify( fields ) ); + } ); + + it( "returns null if there are no fields that need multipart requests", ( ) => { + const fields = { + field1: true, + field2: true + }; + const requestParameters = { + fields, + stringField: "string", + someObject: { + nestedField: 101 + } + }; + const body = iNaturalistAPI.multipartBodyForResuest( requestParameters ); + expect( body ).to.be.null; + } ); + + it( "strings fields remain strings", ( ) => { + const fields = "all"; + const requestParameters = { + fields, + blobField: new Blob( ) + }; + const body = iNaturalistAPI.multipartBodyForResuest( requestParameters ); + expect( body.constructor.name ).to.eq( "FormData" ); + expect( body.get( "fields" ) ).to.eq( fields ); + } ); + } ); + describe( "headers", ( ) => { it( "should include Content-Type for post", done => { nock( "http://localhost:3000", { reqheaders: { "Content-Type": "application/json" } } )