Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand Down
42 changes: 42 additions & 0 deletions lib/api_client/execute_request.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
}
Expand Down
26 changes: 26 additions & 0 deletions lib/utils/handleFileParameter.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions lib/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -1739,6 +1740,7 @@ Object.assign(module.exports, {
clone,
extend,
filter,
handleFileParameter,
includes,
isArray,
isEmpty,
Expand Down
52 changes: 44 additions & 8 deletions test/integration/api/search/visual_search_spec.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const sinon = require('sinon');
const helper = require('../../../spechelper');
const cloudinary = require('../../../../cloudinary');
const {
Expand All @@ -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)));
});
});
});
2 changes: 1 addition & 1 deletion types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading