From 1f639bef614c5b7c186f01a2900550da5aee8e84 Mon Sep 17 00:00:00 2001 From: cloudinary-pkoniu Date: Tue, 17 Oct 2023 12:09:23 +0200 Subject: [PATCH 1/4] feat: visual search with file upload --- lib-es5/api.js | 17 +++- lib-es5/api_client/execute_request.js | 62 ++++++++++++-- lib/api.js | 17 +++- lib/api_client/execute_request.js | 82 ++++++++++++++++--- package.json | 6 +- .../api/search/visual_search_spec.js | 23 +++++- types/index.d.ts | 2 +- 7 files changed, 186 insertions(+), 23 deletions(-) diff --git a/lib-es5/api.js b/lib-es5/api.js index e20c2942..121363e5 100644 --- a/lib-es5/api.js +++ b/lib-es5/api.js @@ -2,6 +2,7 @@ var utils = require("./utils"); var call_api = require("./api_client/call_api"); +var fs = require("fs"); var extend = utils.extend, pickOnlyExistingValues = utils.pickOnlyExistingValues; @@ -588,10 +589,24 @@ exports.search = function search(params, callback) { return call_api("post", "resources/search", params, callback, options); }; +function handleImageFile(image_file) { + if (Buffer.isBuffer(image_file)) { + return image_file; + } + if (typeof image_file === 'string') { + return fs.createReadStream(image_file); + } + throw new Error('image_file has to be either a path to file or a buffer'); +} + exports.visual_search = function visual_search(params, callback) { var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; - var allowedParams = pickOnlyExistingValues(params, 'image_url', 'image_asset_id', 'text'); + var allowedParams = pickOnlyExistingValues(params, 'image_url', 'image_asset_id', 'text', 'image_file'); + if ('image_file' in allowedParams) { + var imageFileData = handleImageFile(allowedParams.image_file); + return call_api('post', ['resources', 'visual_search'], { image_file: imageFileData }, callback, options); + } return call_api('get', ['resources', 'visual_search'], allowedParams, callback, options); }; diff --git a/lib-es5/api_client/execute_request.js b/lib-es5/api_client/execute_request.js index 40779d48..61696dbb 100644 --- a/lib-es5/api_client/execute_request.js +++ b/lib-es5/api_client/execute_request.js @@ -10,6 +10,7 @@ var Q = require('q'); var url = require('url'); var utils = require("../utils"); var ensureOption = require('../utils/ensureOption').defaults(config()); +var request = require('request'); var extend = utils.extend, includes = utils.includes, @@ -18,6 +19,50 @@ var extend = utils.extend, var agent = config.api_proxy ? new https.Agent(config.api_proxy) : null; +function requestWithUpload(uploadConfig, file, callback) { + var options = _extends({ + method: 'POST', + url: uploadConfig.api_url, + formData: { + image_file: file + } + }, uploadConfig.auth.oauth_token && { headers: { 'Authorization': `Bearer ${uploadConfig.auth.oauth_token}` } }, uploadConfig.auth.key && uploadConfig.auth.secret && { + auth: { + user: uploadConfig.auth.key, + pass: uploadConfig.auth.secret + } + }); + + if (typeof callback === "function") { + return request(options, function (error, response, body) { + if (error) { + return callback(error); + } + return callback({ + statusCode: response.statusCode, + body: JSON.parse(body) + }); + }); + } + + return new Promise(function (resolve, reject) { + var req = request(options, function (error, response, body) { + if (error) { + reject(error); + } else { + resolve({ + statusCode: response.statusCode, + body: JSON.parse(body) + }); + } + }); + + req.on('error', function (error) { + reject(error); + }); + }); +} + function execute_request(method, params, auth, api_url, callback) { var options = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : {}; @@ -31,6 +76,13 @@ function execute_request(method, params, auth, api_url, callback) { var oauth_token = auth.oauth_token; var content_type = 'application/x-www-form-urlencoded'; + if (params.image_file && method.toLowerCase() === 'post') { + return requestWithUpload({ + api_url, + auth + }, params.image_file, callback); + } + if (options.content_type === 'json') { query_params = JSON.stringify(params); content_type = 'application/json'; @@ -164,16 +216,16 @@ function execute_request(method, params, auth, api_url, callback) { } }; - var request = https.request(request_options, handle_response); - request.on("error", function (e) { + var req = https.request(request_options, handle_response); + req.on("error", function (e) { deferred.reject(e); return typeof callback === "function" ? callback({ error: e }) : void 0; }); - request.setTimeout(ensureOption(options, "timeout", 60000)); + req.setTimeout(ensureOption(options, "timeout", 60000)); if (method !== "GET") { - request.write(query_params); + req.write(query_params); } - request.end(); + req.end(); return deferred.promise; } diff --git a/lib/api.js b/lib/api.js index 82edeeb9..46406261 100644 --- a/lib/api.js +++ b/lib/api.js @@ -1,5 +1,6 @@ const utils = require("./utils"); const call_api = require("./api_client/call_api"); +const fs = require("fs"); const { extend, @@ -439,8 +440,22 @@ exports.search = function search(params, callback, options = {}) { return call_api("post", "resources/search", params, callback, options); }; +function handleImageFile(image_file) { + if (Buffer.isBuffer(image_file)) { + return image_file; + } + if (typeof image_file === 'string') { + return fs.createReadStream(image_file); + } + throw new Error('image_file has to be either a path to file or a buffer'); +} + exports.visual_search = function visual_search(params, callback, options = {}) { - const allowedParams = pickOnlyExistingValues(params, 'image_url', 'image_asset_id', 'text'); + const allowedParams = pickOnlyExistingValues(params, 'image_url', 'image_asset_id', 'text', 'image_file'); + if ('image_file' in allowedParams) { + const imageFileData = handleImageFile(allowedParams.image_file); + return call_api('post', ['resources', 'visual_search'], {image_file: imageFileData}, callback, options); + } return call_api('get', ['resources', 'visual_search'], allowedParams, callback, options); }; diff --git a/lib/api_client/execute_request.js b/lib/api_client/execute_request.js index 74917d7e..48beec95 100644 --- a/lib/api_client/execute_request.js +++ b/lib/api_client/execute_request.js @@ -6,11 +6,62 @@ const Q = require('q'); const url = require('url'); const utils = require("../utils"); const ensureOption = require('../utils/ensureOption').defaults(config()); +const request = require('request'); -const { extend, includes, isEmpty } = utils; +const { + extend, + includes, + isEmpty +} = utils; const agent = config.api_proxy ? new https.Agent(config.api_proxy) : null; +function requestWithUpload(uploadConfig, file, callback) { + const options = { + method: 'POST', + url: uploadConfig.api_url, + formData: { + image_file: file + }, + ...(uploadConfig.auth.oauth_token && {headers: {'Authorization': `Bearer ${uploadConfig.auth.oauth_token}`}}), + ...(uploadConfig.auth.key && uploadConfig.auth.secret && { + auth: { + user: uploadConfig.auth.key, + pass: uploadConfig.auth.secret + } + }) + }; + + if (typeof callback === "function") { + return request(options, (error, response, body) => { + if (error) { + return callback(error); + } + return callback({ + statusCode: response.statusCode, + body: JSON.parse(body) + }); + }) + } + + return new Promise((resolve, reject) => { + const req = request(options, (error, response, body) => { + if (error) { + reject(error); + } else { + resolve({ + statusCode: response.statusCode, + body: JSON.parse(body) + }); + } + }); + + req.on('error', (error) => { + reject(error); + }); + }); +} + function execute_request(method, params, auth, api_url, callback, options = {}) { method = method.toUpperCase(); const deferred = Q.defer(); @@ -21,6 +72,13 @@ function execute_request(method, params, auth, api_url, callback, options = {}) let oauth_token = auth.oauth_token; let content_type = 'application/x-www-form-urlencoded'; + if (params.image_file && method.toLowerCase() === 'post') { + return requestWithUpload({ + api_url, + auth + }, params.image_file, callback); + } + if (options.content_type === 'json') { query_params = JSON.stringify(params); content_type = 'application/json'; @@ -69,9 +127,13 @@ function execute_request(method, params, auth, api_url, callback, options = {}) const {hide_sensitive = false} = config(); const sanitizedOptions = {...request_options}; - if (hide_sensitive === true){ - if ("auth" in sanitizedOptions) { delete sanitizedOptions.auth; } - if ("Authorization" in sanitizedOptions.headers) { delete sanitizedOptions.headers.Authorization; } + if (hide_sensitive === true) { + if ("auth" in sanitizedOptions) { + delete sanitizedOptions.auth; + } + if ("Authorization" in sanitizedOptions.headers) { + delete sanitizedOptions.headers.Authorization; + } } if (includes([200, 400, 401, 403, 404, 409, 420, 500], res.statusCode)) { @@ -147,16 +209,16 @@ function execute_request(method, params, auth, api_url, callback, options = {}) } }; - const request = https.request(request_options, handle_response); - request.on("error", function (e) { + const req = https.request(request_options, handle_response); + req.on("error", function (e) { deferred.reject(e); - return typeof callback === "function" ? callback({ error: e }) : void 0; + return typeof callback === "function" ? callback({error: e}) : void 0; }); - request.setTimeout(ensureOption(options, "timeout", 60000)); + req.setTimeout(ensureOption(options, "timeout", 60000)); if (method !== "GET") { - request.write(query_params); + req.write(query_params); } - request.end(); + req.end(); return deferred.promise; } diff --git a/package.json b/package.json index 68dff44f..0500b3ba 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,15 @@ "dependencies": { "cloudinary-core": "^2.13.0", "core-js": "^3.30.1", + "form-data": "^4.0.0", "lodash": "^4.17.21", - "q": "^1.5.1" + "q": "^1.5.1", + "request": "^2.88.2" }, "devDependencies": { + "@types/expect.js": "^0.3.29", "@types/mocha": "^7.0.2", "@types/node": "^13.5.0", - "@types/expect.js": "^0.3.29", "babel-cli": "^6.26.0", "babel-core": "^6.26.3", "babel-plugin-transform-runtime": "^6.23.0", diff --git a/test/integration/api/search/visual_search_spec.js b/test/integration/api/search/visual_search_spec.js index 6bf388a4..34f300c8 100644 --- a/test/integration/api/search/visual_search_spec.js +++ b/test/integration/api/search/visual_search_spec.js @@ -2,7 +2,8 @@ const helper = require('../../../spechelper'); const cloudinary = require('../../../../cloudinary'); const { strictEqual, - deepStrictEqual + deepStrictEqual, + throws } = require('assert'); const {TEST_CLOUD_NAME} = require('../../../testUtils/testConstants'); @@ -17,7 +18,7 @@ describe('Visual search', () => { }); }); - it('should pass the image_url parameter to the api call', () => { + it('should pass the image_asset_id parameter to the api call', () => { return helper.provideMockObjects((mockXHR, writeSpy, requestSpy) => { cloudinary.v2.api.visual_search({image_asset_id: 'image-asset-id'}); @@ -27,7 +28,7 @@ describe('Visual search', () => { }); }); - it('should pass the image_url parameter to the api call', () => { + it('should pass the text parameter to the api call', () => { return helper.provideMockObjects((mockXHR, writeSpy, requestSpy) => { cloudinary.v2.api.visual_search({text: 'visual-search-input'}); @@ -36,4 +37,20 @@ describe('Visual search', () => { strictEqual(calledWithUrl.path, `/v1_1/${TEST_CLOUD_NAME}/resources/visual_search?text=visual-search-input`); }); }); + + describe('with image_file', () => { + it('should allow uploading the file to get search results', async () => { + // todo: once migrated to DI, assert that image_file is passed, don't do actual http request + const searchResult = await cloudinary.v2.api.visual_search({image_file: `${__dirname}/../../../.resources/sample.jpg`}); + deepStrictEqual(searchResult.statusCode, 200); + }); + + it('should throw an error if parameter is not a path or buffer', () => { + try { + cloudinary.v2.api.visual_search({image_file: 420}) + } catch (error) { + strictEqual(error.message, 'image_file has to be either a path to file or a buffer'); + } + }); + }); }); diff --git a/types/index.d.ts b/types/index.d.ts index 0c05329d..fc2e382f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -410,7 +410,7 @@ declare module 'cloudinary' { [futureKey: string]: any; } - export type VisualSearchParams = { image_url: string } | { image_asset_id: string } | { text: string }; + export type VisualSearchParams = { image_url: string } | { image_asset_id: string } | { text: string } | { image_file: string }; export interface ArchiveApiOptions { allow_missing?: boolean; From 4b0174ec5f5a8cb0aa907e33d7d0c51150fd36c1 Mon Sep 17 00:00:00 2001 From: cloudinary-pkoniu Date: Tue, 17 Oct 2023 12:09:58 +0200 Subject: [PATCH 2/4] feat: visual search with file upload --- types/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/index.d.ts b/types/index.d.ts index fc2e382f..bf0097b3 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -410,7 +410,7 @@ declare module 'cloudinary' { [futureKey: string]: any; } - export type VisualSearchParams = { image_url: string } | { image_asset_id: string } | { text: string } | { image_file: string }; + export type VisualSearchParams = { image_url: string } | { image_asset_id: string } | { text: string } | { image_file: string | Buffer }; export interface ArchiveApiOptions { allow_missing?: boolean; From 1f6825186f1e4a98679b979f25c88116a18d5089 Mon Sep 17 00:00:00 2001 From: cloudinary-pkoniu Date: Tue, 17 Oct 2023 12:12:54 +0200 Subject: [PATCH 3/4] feat: visual search with file upload --- types/cloudinary_ts_spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/types/cloudinary_ts_spec.ts b/types/cloudinary_ts_spec.ts index 21c87e5d..464c2aed 100644 --- a/types/cloudinary_ts_spec.ts +++ b/types/cloudinary_ts_spec.ts @@ -555,6 +555,16 @@ cloudinary.v2.api.visual_search({text: 'visual-search-input'}, function (error, console.log(result); }); +// $ExpectType Promise +cloudinary.v2.api.visual_search({image_file: 'path/to/file'}, function (error, result) { + console.log(result); +}); + +// $ExpectType Promise +cloudinary.v2.api.visual_search({image_file: Buffer.from('abc')}, function (error, result) { + console.log(result); +}); + // $ExpectType Promise cloudinary.v2.api.transformation({width: 150, height: 100, crop: 'fill'}, function (error, result) { From 2f251c891e451a096cc2e03e1b9d8afc2b6b79a1 Mon Sep 17 00:00:00 2001 From: cloudinary-pkoniu Date: Tue, 17 Oct 2023 13:11:47 +0200 Subject: [PATCH 4/4] feat: visual search with file upload --- lib/api_client/execute_request.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/api_client/execute_request.js b/lib/api_client/execute_request.js index 48beec95..d5c0d0f5 100644 --- a/lib/api_client/execute_request.js +++ b/lib/api_client/execute_request.js @@ -72,7 +72,7 @@ function execute_request(method, params, auth, api_url, callback, options = {}) let oauth_token = auth.oauth_token; let content_type = 'application/x-www-form-urlencoded'; - if (params.image_file && method.toLowerCase() === 'post') { + if (params.image_file && method.toUpperCase() === 'POST') { return requestWithUpload({ api_url, auth