From b8822d91836fbe3d0d7233f90b8005d6582a6ef1 Mon Sep 17 00:00:00 2001 From: Tia Esguerra Date: Thu, 2 Jul 2026 14:25:58 -0700 Subject: [PATCH] feat: add support for image_file in visual_search Admin API Adds multipart/form-data support to the Admin API request layer so visual_search can accept a local file path or Buffer for image_file, in addition to image_url, image_asset_id, and text. The endpoint now always uses POST instead of GET. Co-Authored-By: Claude Sonnet 5 --- lib/api.js | 10 +++- lib/api_client/execute_request.js | 42 +++++++++++++++ lib/utils/handleFileParameter.js | 26 ++++++++++ lib/utils/index.js | 2 + .../api/search/visual_search_spec.js | 52 ++++++++++++++++--- types/index.d.ts | 2 +- 6 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 lib/utils/handleFileParameter.js diff --git a/lib/api.js b/lib/api.js index 1c35f76f..b9569eef 100644 --- a/lib/api.js +++ b/lib/api.js @@ -499,7 +499,15 @@ exports.search = function search(params, callback, options = {}) { exports.visual_search = function visual_search(params, callback, options = {}) { const allowedParams = pickOnlyExistingValues(params, 'image_url', 'image_asset_id', 'text'); - return call_api('get', ['resources', 'visual_search'], allowedParams, callback, options); + const image_file = utils.handleFileParameter(params.image_file); + let requestOptions = options; + if (image_file != null) { + allowedParams.image_file = image_file; + if (typeof image_file === 'object') { + requestOptions = extend({}, options, { content_type: 'multipart' }); + } + } + return call_api('post', ['resources', 'visual_search'], allowedParams, callback, requestOptions); }; exports.search_folders = function search_folders(params, callback, options = {}) { diff --git a/lib/api_client/execute_request.js b/lib/api_client/execute_request.js index ee27c373..056aa492 100644 --- a/lib/api_client/execute_request.js +++ b/lib/api_client/execute_request.js @@ -10,6 +10,44 @@ const { extend, includes, isEmpty } = utils; const agent = config.api_proxy ? new https.Agent(config.api_proxy) : null; +function encodeFieldPart(boundary, name, value) { + return [ + `--${boundary}\r\n`, + `Content-Disposition: form-data; name="${name}"\r\n`, + '\r\n', + `${value}\r\n`, + '' + ].join(''); +} + +function encodeFilePart(boundary, type, name, filename) { + return [ + `--${boundary}\r\n`, + `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n`, + `Content-Type: ${type}\r\n`, + '\r\n', + '' + ].join(''); +} + +function buildMultipartBody(params, boundary) { + const parts = []; + utils.hashToParameters(params).forEach(([key, value]) => { + if (value == null) { + return; + } + if (typeof value === 'object' && Buffer.isBuffer(value.data)) { + parts.push(Buffer.from(encodeFilePart(boundary, 'application/octet-stream', key, value.filename), 'binary')); + parts.push(value.data); + parts.push(Buffer.from('\r\n', 'ascii')); + } else { + parts.push(Buffer.from(encodeFieldPart(boundary, key, value), 'utf8')); + } + }); + parts.push(Buffer.from(`--${boundary}--`, 'ascii')); + return Buffer.concat(parts); +} + function execute_request(method, params, auth, api_url, callback, options = {}) { method = method.toUpperCase(); const deferred = utils.deferredPromise(); @@ -23,6 +61,10 @@ function execute_request(method, params, auth, api_url, callback, options = {}) if (options.content_type === 'json') { query_params = JSON.stringify(params); content_type = 'application/json'; + } else if (options.content_type === 'multipart') { + const boundary = utils.random_public_id(); + query_params = buildMultipartBody(params, boundary); + content_type = `multipart/form-data; boundary=${boundary}`; } else { query_params = querystring.stringify(params); } diff --git a/lib/utils/handleFileParameter.js b/lib/utils/handleFileParameter.js new file mode 100644 index 00000000..58ce69bb --- /dev/null +++ b/lib/utils/handleFileParameter.js @@ -0,0 +1,26 @@ +const fs = require('fs'); +const path = require('path'); +const isRemoteUrl = require('./isRemoteUrl'); + +/** + * Resolves a file parameter for use as an API request parameter. + * Remote URLs and data URIs are returned as-is, to be sent as a plain string parameter. + * Buffers and local file paths are resolved into a {filename, data} pair representing + * binary content, to be sent as a multipart file part. + * @param {string|Buffer} file A remote url, a data URI, a local file path or a Buffer + * @returns {string|{filename: string, data: Buffer}|undefined} + */ +function handleFileParameter(file) { + if (file == null) { + return undefined; + } + if (Buffer.isBuffer(file)) { + return { filename: 'file', data: file }; + } + if (typeof file === 'string' && !isRemoteUrl(file)) { + return { filename: path.basename(file), data: fs.readFileSync(file) }; + } + return file; +} + +module.exports = handleFileParameter; diff --git a/lib/utils/index.js b/lib/utils/index.js index d74c738f..6dc68593 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -42,6 +42,7 @@ const crc32 = require('./crc32'); const ensurePresenceOf = require('./ensurePresenceOf'); const ensureOption = require('./ensureOption').defaults(config()); const entries = require('./entries'); +const handleFileParameter = require('./handleFileParameter'); const isRemoteUrl = require('./isRemoteUrl'); const getSDKVersions = require('./analytics/getSDKVersions'); @@ -1739,6 +1740,7 @@ Object.assign(module.exports, { clone, extend, filter, + handleFileParameter, includes, isArray, isEmpty, diff --git a/test/integration/api/search/visual_search_spec.js b/test/integration/api/search/visual_search_spec.js index a7f2a7ae..4ad7b1bb 100644 --- a/test/integration/api/search/visual_search_spec.js +++ b/test/integration/api/search/visual_search_spec.js @@ -1,3 +1,4 @@ +const sinon = require('sinon'); const helper = require('../../../spechelper'); const cloudinary = require('../../../../cloudinary'); const { @@ -6,34 +7,69 @@ const { } = require('assert'); const {TEST_CLOUD_NAME} = require('../../../testUtils/testConstants'); +function multipartFilePartMatcher(name, filename) { + const expected = `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\nContent-Type: application/octet-stream`; + return (arg) => { + const str = Buffer.isBuffer(arg) ? arg.toString('binary') : String(arg); + return str.includes(expected); + }; +} + describe('Visual search', () => { it('should pass the image_url parameter to the api call', () => { return helper.provideMockObjects(async (mockXHR, writeSpy, requestSpy) => { await cloudinary.v2.api.visual_search({image_url: 'test-image-url'}).catch(helper.ignoreApiFailure); const [calledWithUrl] = requestSpy.firstCall.args; - strictEqual(calledWithUrl.method, 'GET'); - strictEqual(calledWithUrl.path, `/v1_1/${TEST_CLOUD_NAME}/resources/visual_search?image_url=test-image-url`); + strictEqual(calledWithUrl.method, 'POST'); + strictEqual(calledWithUrl.path, `/v1_1/${TEST_CLOUD_NAME}/resources/visual_search`); + sinon.assert.calledWith(writeSpy, sinon.match(helper.apiParamMatcher('image_url', 'test-image-url'))); }); }); - 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(async (mockXHR, writeSpy, requestSpy) => { await cloudinary.v2.api.visual_search({image_asset_id: 'image-asset-id'}).catch(helper.ignoreApiFailure); const [calledWithUrl] = requestSpy.firstCall.args; - strictEqual(calledWithUrl.method, 'GET'); - strictEqual(calledWithUrl.path, `/v1_1/${TEST_CLOUD_NAME}/resources/visual_search?image_asset_id=image-asset-id`); + strictEqual(calledWithUrl.method, 'POST'); + strictEqual(calledWithUrl.path, `/v1_1/${TEST_CLOUD_NAME}/resources/visual_search`); + sinon.assert.calledWith(writeSpy, sinon.match(helper.apiParamMatcher('image_asset_id', 'image-asset-id'))); }); }); - it('should pass the image_url parameter to the api call', () => { + it('should pass the text parameter to the api call', () => { return helper.provideMockObjects(async (mockXHR, writeSpy, requestSpy) => { await cloudinary.v2.api.visual_search({text: 'visual-search-input'}).catch(helper.ignoreApiFailure); const [calledWithUrl] = requestSpy.firstCall.args; - strictEqual(calledWithUrl.method, 'GET'); - strictEqual(calledWithUrl.path, `/v1_1/${TEST_CLOUD_NAME}/resources/visual_search?text=visual-search-input`); + strictEqual(calledWithUrl.method, 'POST'); + strictEqual(calledWithUrl.path, `/v1_1/${TEST_CLOUD_NAME}/resources/visual_search`); + sinon.assert.calledWith(writeSpy, sinon.match(helper.apiParamMatcher('text', 'visual-search-input'))); + }); + }); + + it('should send a local image_file as a multipart request', () => { + return helper.provideMockObjects(async (mockXHR, writeSpy, requestSpy) => { + await cloudinary.v2.api.visual_search({image_file: helper.IMAGE_FILE}).catch(helper.ignoreApiFailure); + + const [calledWithUrl] = requestSpy.firstCall.args; + strictEqual(calledWithUrl.method, 'POST'); + strictEqual(calledWithUrl.path, `/v1_1/${TEST_CLOUD_NAME}/resources/visual_search`); + strictEqual(calledWithUrl.headers['Content-Type'].startsWith('multipart/form-data'), true); + sinon.assert.calledWith(writeSpy, sinon.match(multipartFilePartMatcher('image_file', 'logo.png'))); + }); + }); + + it('should pass a remote image_file url as a plain parameter', () => { + return helper.provideMockObjects(async (mockXHR, writeSpy, requestSpy) => { + await cloudinary.v2.api.visual_search({image_file: helper.IMAGE_URL}).catch(helper.ignoreApiFailure); + + const [calledWithUrl] = requestSpy.firstCall.args; + strictEqual(calledWithUrl.method, 'POST'); + strictEqual(calledWithUrl.path, `/v1_1/${TEST_CLOUD_NAME}/resources/visual_search`); + strictEqual(calledWithUrl.headers['Content-Type'], 'application/x-www-form-urlencoded'); + sinon.assert.calledWith(writeSpy, sinon.match(helper.apiParamMatcher('image_file', helper.IMAGE_URL))); }); }); }); diff --git a/types/index.d.ts b/types/index.d.ts index 432c6e42..90d417bc 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -411,7 +411,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 | Buffer }; export interface ArchiveApiOptions { allow_missing?: boolean;