From 76c04c7820b057a61723c5f6bbf3e1b9fd150946 Mon Sep 17 00:00:00 2001 From: Patryk Konior <97894263+cloudinary-pkoniu@users.noreply.github.com> Date: Mon, 22 Apr 2024 08:29:34 +0200 Subject: [PATCH] feat: selective response for admin and search api (#659) * feat: selective response for admin and search api * chore: pr fixes * chore: pr fixes * feat: allow adding multiple fields and with_field values * test: fixed specs --- lib/api.js | 14 ++--- lib/v2/search.js | 25 ++++++-- test/integration/api/admin/api_spec.js | 70 +++++++++++++++++++++- test/integration/api/search/search_spec.js | 30 +++++++++- test/unit/config.spec.js | 20 ------- test/unit/search/search_spec.js | 46 +++++++++++++- types/index.d.ts | 8 ++- 7 files changed, 175 insertions(+), 38 deletions(-) diff --git a/lib/api.js b/lib/api.js index 45120649..65a3992f 100644 --- a/lib/api.js +++ b/lib/api.js @@ -45,21 +45,21 @@ exports.resources = function resources(callback, options = {}) { if ((options.start_at != null) && Object.prototype.toString.call(options.start_at) === '[object Date]') { options.start_at = options.start_at.toUTCString(); } - return call_api("get", uri, pickOnlyExistingValues(options, "next_cursor", "max_results", "prefix", "tags", "context", "direction", "moderations", "start_at", "metadata"), callback, options); + return call_api("get", uri, pickOnlyExistingValues(options, "next_cursor", "max_results", "prefix", "tags", "context", "direction", "moderations", "start_at", "metadata", "fields"), callback, options); }; exports.resources_by_tag = function resources_by_tag(tag, callback, options = {}) { let resource_type, uri; resource_type = options.resource_type || "image"; uri = ["resources", resource_type, "tags", tag]; - return call_api("get", uri, pickOnlyExistingValues(options, "next_cursor", "max_results", "tags", "context", "direction", "moderations", "metadata"), callback, options); + return call_api("get", uri, pickOnlyExistingValues(options, "next_cursor", "max_results", "tags", "context", "direction", "moderations", "metadata", "fields"), callback, options); }; exports.resources_by_context = function resources_by_context(key, value, callback, options = {}) { let params, resource_type, uri; resource_type = options.resource_type || "image"; uri = ["resources", resource_type, "context"]; - params = pickOnlyExistingValues(options, "next_cursor", "max_results", "tags", "context", "direction", "moderations", "metadata"); + params = pickOnlyExistingValues(options, "next_cursor", "max_results", "tags", "context", "direction", "moderations", "metadata", "fields"); params.key = key; if (value != null) { params.value = value; @@ -71,7 +71,7 @@ exports.resources_by_moderation = function resources_by_moderation(kind, status, let resource_type, uri; resource_type = options.resource_type || "image"; uri = ["resources", resource_type, "moderations", kind, status]; - return call_api("get", uri, pickOnlyExistingValues(options, "next_cursor", "max_results", "tags", "context", "direction", "moderations", "metadata"), callback, options); + return call_api("get", uri, pickOnlyExistingValues(options, "next_cursor", "max_results", "tags", "context", "direction", "moderations", "metadata", "fields"), callback, options); }; exports.resource_by_asset_id = function resource_by_asset_id(asset_id, callback, options = {}) { @@ -82,7 +82,7 @@ exports.resource_by_asset_id = function resource_by_asset_id(asset_id, callback, exports.resources_by_asset_folder = function resources_by_asset_folder(asset_folder, callback, options = {}) { let params, uri; uri = ["resources", 'by_asset_folder']; - params = pickOnlyExistingValues(options, "next_cursor", "max_results", "tags", "context", "moderations"); + params = pickOnlyExistingValues(options, "next_cursor", "max_results", "tags", "context", "moderations", "fields"); params.asset_folder = asset_folder; return call_api("get", uri, params, callback, options); }; @@ -90,7 +90,7 @@ exports.resources_by_asset_folder = function resources_by_asset_folder(asset_fol exports.resources_by_asset_ids = function resources_by_asset_ids(asset_ids, callback, options = {}) { let params, uri; uri = ["resources", "by_asset_ids"]; - params = pickOnlyExistingValues(options, "tags", "context", "moderations"); + params = pickOnlyExistingValues(options, "tags", "context", "moderations", "fields"); params["asset_ids[]"] = asset_ids; return call_api("get", uri, params, callback, options); } @@ -100,7 +100,7 @@ exports.resources_by_ids = function resources_by_ids(public_ids, callback, optio resource_type = options.resource_type || "image"; type = options.type || "upload"; uri = ["resources", resource_type, type]; - params = pickOnlyExistingValues(options, "tags", "context", "moderations"); + params = pickOnlyExistingValues(options, "tags", "context", "moderations", "fields"); params["public_ids[]"] = public_ids; return call_api("get", uri, params, callback, options); }; diff --git a/lib/v2/search.js b/lib/v2/search.js index a7feb980..72934af3 100644 --- a/lib/v2/search.js +++ b/lib/v2/search.js @@ -15,7 +15,8 @@ const Search = class Search { this.query_hash = { sort_by: [], aggregate: [], - with_field: [] + with_field: [], + fields: [] }; this._ttl = 300; } @@ -44,6 +45,10 @@ const Search = class Search { return this.instance().with_field(value); } + static fields(value) { + return this.instance().fields(value); + } + static sort_by(field_name, dir = 'asc') { return this.instance().sort_by(field_name, dir); } @@ -82,12 +87,24 @@ const Search = class Search { } with_field(value) { - const found = this.query_hash.with_field.find(v => v === value); - - if (!found) { + if (Array.isArray(value)) { + this.query_hash.with_field = this.query_hash.with_field.concat(value); + } else { this.query_hash.with_field.push(value); } + this.query_hash.with_field = Array.from(new Set(this.query_hash.with_field)); + return this; + } + + fields(value) { + if (Array.isArray(value)) { + this.query_hash.fields = this.query_hash.fields.concat(value); + } else { + this.query_hash.fields.push(value); + } + + this.query_hash.fields = Array.from(new Set(this.query_hash.fields)); return this; } diff --git a/test/integration/api/admin/api_spec.js b/test/integration/api/admin/api_spec.js index f5bdc827..227026c1 100644 --- a/test/integration/api/admin/api_spec.js +++ b/test/integration/api/admin/api_spec.js @@ -17,6 +17,8 @@ const retry = require('../../../testUtils/helpers/retry'); const {shouldTestFeature} = require("../../../spechelper"); const API_V2 = cloudinary.v2.api; const DYNAMIC_FOLDERS = helper.DYNAMIC_FOLDERS; +const assert = require('assert'); +const {only} = require("../../../../lib/utils"); const { TIMEOUT, @@ -425,6 +427,51 @@ describe("api", function () { })); }); }); + + describe('selective response', () => { + const expectedKeys = ['public_id', 'asset_id', 'folder', 'tags'].sort(); + + it('should allow listing', async () => { + const {resources} = await cloudinary.v2.api.resources({fields: ['tags']}) + const actualKeys = Object.keys(resources[0]); + assert.deepStrictEqual(actualKeys.sort(), expectedKeys); + }); + + it('should allow listing by public_ids', async () => { + const {resources} = await cloudinary.v2.api.resources_by_ids([PUBLIC_ID], {fields: ['tags']}) + const actualKeys = Object.keys(resources[0]); + assert.deepStrictEqual(actualKeys.sort(), expectedKeys); + }); + + it('should allow listing by tag', async () => { + const {resources} = await cloudinary.v2.api.resources_by_tag(TEST_TAG, {fields: ['tags']}) + const actualKeys = Object.keys(resources[0]); + assert.deepStrictEqual(actualKeys.sort(), expectedKeys); + }); + + it('should allow listing by context', async () => { + const {resources} = await cloudinary.v2.api.resources_by_context(contextKey, "test", {fields: ['tags']}) + const actualKeys = Object.keys(resources[0]); + assert.deepStrictEqual(actualKeys.sort(), expectedKeys); + }); + + it('should allow listing by moderation', async () => { + await uploadImage({ + moderation: 'manual', + tags: [TEST_TAG] + }); + const {resources} = await cloudinary.v2.api.resources_by_moderation('manual', 'pending', {fields: ['tags']}) + const actualKeys = Object.keys(resources[0]); + assert.deepStrictEqual(actualKeys.sort(), expectedKeys); + }); + + it('should allow listing by asset_ids', async () => { + const {asset_id} = await uploadImage(); + const {resources} = await cloudinary.v2.api.resources_by_asset_ids([asset_id], {fields: ['tags']}) + const actualKeys = Object.keys(resources[0]); + assert.deepStrictEqual(actualKeys.sort(), expectedKeys); + }); + }); }); describe("backup resource", function () { this.timeout(TIMEOUT.MEDIUM); @@ -1530,5 +1577,26 @@ describe("api", function () { arg => arg.agent instanceof https.Agent )); }); - }) + }); + describe('config hide_sensitive', () => { + it("should hide API key and secret upon error when `hide_sensitive` is true", async function () { + try { + cloudinary.config({hide_sensitive: true}); + const result = await cloudinary.v2.api.resource("?"); + expect(result).fail(); + } catch (err) { + expect(err.request_options).not.to.have.property("auth"); + } + }); + + it("should hide Authorization header upon error when `hide_sensitive` is true", async function () { + try { + cloudinary.config({hide_sensitive: true}); + const result = await cloudinary.v2.api.resource("?", { oauth_token: 'irrelevant' }); + expect(result).fail(); + } catch (err) { + expect(err.request_options.headers).not.to.have.property("Authorization"); + } + }); + }); }); diff --git a/test/integration/api/search/search_spec.js b/test/integration/api/search/search_spec.js index ef05ca7e..58528854 100644 --- a/test/integration/api/search/search_spec.js +++ b/test/integration/api/search/search_spec.js @@ -5,6 +5,7 @@ const testConstants = require('../../../testUtils/testConstants'); const describe = require('../../../testUtils/suite'); const exp = require("constants"); const cluster = require("cluster"); +const assert = require("assert"); const { TIMEOUT, TAGS, @@ -122,7 +123,7 @@ describe("search_api", function () { }); }); - it('Should eliminate duplicate fields when using sort_by, aggregate or with_fields', function () { + it('Should eliminate duplicate fields when using sort_by, aggregate, with_field or fields', function () { // This test ensures we can't push duplicate values into sort_by, aggregate or with_fields const search_query = cloudinary.v2.search.max_results(10).expression(`tags:${SEARCH_TAG}`) .sort_by('public_id', 'asc') @@ -137,16 +138,26 @@ describe("search_api", function () { .with_field('foo') .with_field('foo') .with_field('foo2') + .with_field(['foo', 'foo2', 'foo3']) + .fields('foo') + .fields('foo') + .fields('foo2') + .fields(['foo', 'foo2', 'foo3']) .to_query(); expect(search_query.aggregate.length).to.be(2); - expect(search_query.with_field.length).to.be(2); + expect(search_query.with_field.length).to.be(3); + expect(search_query.fields.length).to.be(3); expect(search_query.sort_by.length).to.be(1); expect(search_query.aggregate[0]).to.be('foo'); expect(search_query.aggregate[1]).to.be('foo2'); expect(search_query.with_field[0]).to.be('foo'); expect(search_query.with_field[1]).to.be('foo2'); + expect(search_query.with_field[2]).to.be('foo3'); + expect(search_query.fields[0]).to.be('foo'); + expect(search_query.fields[1]).to.be('foo2'); + expect(search_query.fields[2]).to.be('foo3'); expect(search_query.sort_by[0].public_id).to.be('desc'); }); @@ -176,5 +187,20 @@ describe("search_api", function () { }); }); }); + + it('should only include selected keys when using fields', function () { + return cloudinary.v2.search.expression(`tags:${SEARCH_TAG}`).fields('context') + .execute() + .then(function (results) { + expect(results.resources.length).to.eql(3); + results.resources.forEach(function (res) { + const alwaysIncluded = ['public_id', 'asset_id', 'created_at', 'status', 'type', 'resource_type', 'folder']; + const additionallyIncluded = ['context']; + const expectedKeys = [...alwaysIncluded, ...additionallyIncluded]; + const actualKeys = Object.keys(res); + assert.deepStrictEqual(actualKeys.sort(), expectedKeys.sort()); + }); + }); + }); }); }); diff --git a/test/unit/config.spec.js b/test/unit/config.spec.js index 720eccb0..11fb26ad 100644 --- a/test/unit/config.spec.js +++ b/test/unit/config.spec.js @@ -30,26 +30,6 @@ describe("config", function () { expect(config.hide_sensitive).to.eql(true) }); - it("should hide API key and secret upon error when `hide_sensitive` is true", async function () { - try { - cloudinary.config({hide_sensitive: true}); - const result = await cloudinary.v2.api.resource("?"); - expect(result).fail(); - } catch (err) { - expect(err.request_options).not.to.have.property("auth"); - } - }); - - it("should hide Authorization header upon error when `hide_sensitive` is true", async function () { - try { - cloudinary.config({hide_sensitive: true}); - const result = await cloudinary.v2.api.resource("?", { oauth_token: 'irrelevant' }); - expect(result).fail(); - } catch (err) { - expect(err.request_options.headers).not.to.have.property("Authorization"); - } - }); - it("should allow nested values in CLOUDINARY_URL", function () { process.env.CLOUDINARY_URL = "cloudinary://key:secret@test123?foo[bar]=value"; cloudinary.config(true); diff --git a/test/unit/search/search_spec.js b/test/unit/search/search_spec.js index 353b1aa2..72b9de96 100644 --- a/test/unit/search/search_spec.js +++ b/test/unit/search/search_spec.js @@ -15,7 +15,8 @@ describe('Search', () => { 'max_results', 'next_cursor', 'aggregate', - 'with_field' + 'with_field', + 'fields' ].forEach(method => expect(instance).to.eql(instance[method]('emptyarg'))); }); @@ -62,12 +63,53 @@ describe('Search', () => { }); it('should add with_field to query', function () { - var query = cloudinary.v2.search.with_field('context').with_field('tags').to_query(); + const query = cloudinary.v2.search.with_field('context').with_field('tags').to_query(); expect(query).to.eql({ with_field: ['context', 'tags'] }); }); + it('should allow adding multiple with_field values to query', function () { + const query = cloudinary.v2.search.with_field(['context', 'tags']).to_query(); + expect(query).to.eql({ + with_field: ['context', 'tags'] + }); + }); + + it('should remove duplicates with_field values from query', () => { + const search = cloudinary.v2.search.with_field(['field1', 'field1', 'field2']); + search.with_field('field1'); + search.with_field('field3'); + const query = search.to_query(); + expect(query).to.eql({ + with_field: ['field1', 'field2', 'field3'] + }); + }); + + it('should add fields to query', function () { + const query = cloudinary.v2.search.fields('context').fields('tags').to_query(); + expect(query).to.eql({ + fields: ['context', 'tags'] + }); + }); + + it('should allow adding multiple fields values to query', function () { + const query = cloudinary.v2.search.fields(['context', 'tags']).to_query(); + expect(query).to.eql({ + fields: ['context', 'tags'] + }); + }); + + it('should remove duplicates fields values from query', () => { + const search = cloudinary.v2.search.fields(['field1', 'field1', 'field2']); + search.fields('field1'); + search.fields('field3'); + const query = search.to_query(); + expect(query).to.eql({ + fields: ['field1', 'field2', 'field3'] + }); + }); + it('should run without an expression', function () { assert.doesNotThrow( () => { diff --git a/types/index.d.ts b/types/index.d.ts index 64c721da..3c1ba77d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1395,7 +1395,9 @@ declare module 'cloudinary' { to_query(value?: string): search; - with_field(value?: string): search; + with_field(value?: string | Array): search; + + fields(value?: string | Array): search; to_url(newTtl?: number, next_cursor?: string, options?: ConfigOptions): string; @@ -1413,7 +1415,9 @@ declare module 'cloudinary' { static ttl(newTtl: number): search; - static with_field(args?: string): search; + static with_field(args?: string | Array): search; + + static fields(args?: string | Array): search; } /****************************** Provisioning API *************************************/