From 844a0497792bfddfe363c95181b49b58c6e3cba4 Mon Sep 17 00:00:00 2001 From: Nadeem Patwekar Date: Thu, 18 Dec 2025 12:28:08 +0530 Subject: [PATCH 1/7] feat: add plugin support for request/response interception in Contentstack HTTP client --- lib/contentstack.js | 23 ++ lib/core/Util.js | 23 ++ lib/core/contentstackHTTPClient.js | 119 +++++++++- test/unit/ContentstackHTTPClient-test.js | 286 +++++++++++++++++++++++ types/contentstackClient.d.ts | 29 ++- 5 files changed, 477 insertions(+), 3 deletions(-) diff --git a/lib/contentstack.js b/lib/contentstack.js index 9d377999..be04944d 100644 --- a/lib/contentstack.js +++ b/lib/contentstack.js @@ -168,6 +168,29 @@ import { getContentstackEndpoint } from '@contentstack/utils' * const client = contentstack.client({ region: 'eu' }) * * @prop {string=} params.feature - Feature identifier for user agent header + * @prop {Array=} params.plugins - Optional array of plugin objects. Each plugin must have `onRequest` and `onResponse` methods. + * @example //Set plugins to intercept and modify requests/responses + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client({ + * plugins: [ + * { + * onRequest: (request) => { + * // Return modified request + * return { + * ...request, + * headers: { + * ...request.headers, + * 'X-Custom-Header': 'value' + * } + * } + * }, + * onResponse: (response) => { + * // Return modified response + * return response + * } + * } + * ] + * }) * @returns {ContentstackClient} Instance of ContentstackClient */ export function client (params = {}) { diff --git a/lib/core/Util.js b/lib/core/Util.js index 23a2f449..522e8147 100644 --- a/lib/core/Util.js +++ b/lib/core/Util.js @@ -236,3 +236,26 @@ export const validateAndSanitizeConfig = (config) => { url: config.url.trim() // Sanitize URL by removing whitespace } } + +/** + * Normalizes and validates plugin array + * @param {Array|undefined} plugins - Array of plugin objects + * @returns {Array} Normalized array of plugins + */ +export function normalizePlugins (plugins) { + if (!plugins) { + return [] + } + + if (!Array.isArray(plugins)) { + return [] + } + + return plugins.filter(plugin => { + if (!plugin || typeof plugin !== 'object') { + return false + } + // Plugin must have both onRequest and onResponse methods + return typeof plugin.onRequest === 'function' && typeof plugin.onResponse === 'function' + }) +} diff --git a/lib/core/contentstackHTTPClient.js b/lib/core/contentstackHTTPClient.js index ca41ea8c..1a88d42a 100644 --- a/lib/core/contentstackHTTPClient.js +++ b/lib/core/contentstackHTTPClient.js @@ -2,7 +2,7 @@ import axios from 'axios' import clonedeep from 'lodash/cloneDeep' import Qs from 'qs' import { ConcurrencyQueue } from './concurrency-queue' -import { isHost } from './Util' +import { isHost, normalizePlugins } from './Util' export default function contentstackHttpClient (options) { const defaultConfig = { @@ -109,6 +109,11 @@ export default function contentstackHttpClient (options) { const instance = axios.create(axiosOptions) instance.httpClientParams = options instance.concurrencyQueue = new ConcurrencyQueue({ axios: instance, config }) + + // Normalize and store plugins + const plugins = normalizePlugins(config.plugins) + + // Request interceptor for versioning strategy (must run first) instance.interceptors.request.use((request) => { if (request.versioningStrategy && request.versioningStrategy === 'path') { request.baseURL = request.baseURL.replace('{api-version}', version) @@ -117,5 +122,117 @@ export default function contentstackHttpClient (options) { } return request }) + + // Request interceptor for plugins (runs after versioning) + if (plugins.length > 0) { + instance.interceptors.request.use( + (request) => { + // Run all onRequest hooks sequentially, using return values + let currentRequest = request + for (const plugin of plugins) { + try { + if (typeof plugin.onRequest === 'function') { + const result = plugin.onRequest(currentRequest) + // Use returned value if provided, otherwise use current request + if (result !== undefined) { + currentRequest = result + } + } + } catch (error) { + // Log error and continue with next plugin + if (config.logHandler) { + config.logHandler('error', { + name: 'PluginError', + message: `Error in plugin onRequest: ${error.message}`, + error: error + }) + } + } + } + return currentRequest + }, + (error) => { + // Handle request errors - run plugins even on error + let currentConfig = error.config + for (const plugin of plugins) { + try { + if (typeof plugin.onRequest === 'function' && currentConfig) { + const result = plugin.onRequest(currentConfig) + // Use returned value if provided, otherwise use current config + if (result !== undefined) { + currentConfig = result + error.config = currentConfig + } + } + } catch (pluginError) { + if (config.logHandler) { + config.logHandler('error', { + name: 'PluginError', + message: `Error in plugin onRequest (error handler): ${pluginError.message}`, + error: pluginError + }) + } + } + } + return Promise.reject(error) + } + ) + + // Response interceptor for plugins + instance.interceptors.response.use( + (response) => { + // Run all onResponse hooks sequentially for successful responses + // Use return values from plugins + let currentResponse = response + for (const plugin of plugins) { + try { + if (typeof plugin.onResponse === 'function') { + const result = plugin.onResponse(currentResponse) + // Use returned value if provided, otherwise use current response + if (result !== undefined) { + currentResponse = result + } + } + } catch (error) { + // Log error and continue with next plugin + if (config.logHandler) { + config.logHandler('error', { + name: 'PluginError', + message: `Error in plugin onResponse: ${error.message}`, + error: error + }) + } + } + } + return currentResponse + }, + (error) => { + // Handle response errors - run plugins even on error + // Pass the error object (which may contain error.response if server responded) + let currentError = error + for (const plugin of plugins) { + try { + if (typeof plugin.onResponse === 'function') { + const result = plugin.onResponse(currentError) + // Use returned value if provided, otherwise use current error + if (result !== undefined) { + currentError = result + } + } + } catch (pluginError) { + if (config.logHandler) { + config.logHandler('error', { + name: 'PluginError', + message: `Error in plugin onResponse (error handler): ${pluginError.message}`, + error: pluginError + }) + } + } + } + return Promise.reject(currentError) + } + ) + } + return instance } diff --git a/test/unit/ContentstackHTTPClient-test.js b/test/unit/ContentstackHTTPClient-test.js index 1b78a290..8a570c48 100644 --- a/test/unit/ContentstackHTTPClient-test.js +++ b/test/unit/ContentstackHTTPClient-test.js @@ -3,6 +3,7 @@ import contentstackHTTPClient from '../../lib/core/contentstackHTTPClient.js' import { expect } from 'chai' import { describe, it, beforeEach } from 'mocha' import sinon from 'sinon' +import MockAdapter from 'axios-mock-adapter' const logHandlerStub = sinon.stub() describe('Contentstack HTTP Client', () => { @@ -167,4 +168,289 @@ describe('Contentstack HTTP Client', () => { expect(axiosInstance.defaults.headers['x-header-ea']).to.be.equal('ea1,ea2') done() }) + + describe('Plugin Support', () => { + it('should call onRequest hook before request is sent', (done) => { + const onRequestSpy = sinon.spy() + const plugin = { + onRequest: onRequestSpy, + onResponse: () => {} + } + + const axiosInstance = contentstackHTTPClient({ + defaultHostName: 'defaulthost', + plugins: [plugin] + }) + + const mock = new MockAdapter(axiosInstance) + mock.onGet('/test').reply(200, { data: 'test' }) + + axiosInstance.get('/test').then(() => { + expect(onRequestSpy.calledOnce).to.be.true + expect(onRequestSpy.calledWith(sinon.match.object))).to.be.true + done() + }).catch(done) + }) + + it('should use returned request from onRequest hook', (done) => { + const customHeader = 'custom-value' + const plugin = { + onRequest: (request) => { + // Return modified request + return { + ...request, + headers: { + ...request.headers, + 'X-Custom-Header': customHeader + } + } + }, + onResponse: () => {} + } + + const axiosInstance = contentstackHTTPClient({ + defaultHostName: 'defaulthost', + plugins: [plugin] + }) + + const mock = new MockAdapter(axiosInstance) + mock.onGet('/test').reply((config) => { + expect(config.headers['X-Custom-Header']).to.be.equal(customHeader) + return [200, { data: 'test' }] + }) + + axiosInstance.get('/test').then(() => { + done() + }).catch(done) + }) + + it('should call onResponse hook after successful response', (done) => { + const onResponseSpy = sinon.spy() + const plugin = { + onRequest: () => {}, + onResponse: onResponseSpy + } + + const axiosInstance = contentstackHTTPClient({ + defaultHostName: 'defaulthost', + plugins: [plugin] + }) + + const mock = new MockAdapter(axiosInstance) + mock.onGet('/test').reply(200, { data: 'test' }) + + axiosInstance.get('/test').then(() => { + expect(onResponseSpy.calledOnce).to.be.true + expect(onResponseSpy.calledWith(sinon.match.object))).to.be.true + done() + }).catch(done) + }) + + it('should call onResponse hook after error response', (done) => { + const onResponseSpy = sinon.spy() + const plugin = { + onRequest: () => {}, + onResponse: onResponseSpy + } + + const axiosInstance = contentstackHTTPClient({ + defaultHostName: 'defaulthost', + plugins: [plugin] + }) + + const mock = new MockAdapter(axiosInstance) + mock.onGet('/test').reply(500, { error: 'Server Error' }) + + axiosInstance.get('/test').catch((error) => { + expect(onResponseSpy.calledOnce).to.be.true + expect(onResponseSpy.calledWith(sinon.match.object))).to.be.true + done() + }) + }) + + it('should use returned response from onResponse hook', (done) => { + const customData = { modified: true } + const plugin = { + onRequest: () => {}, + onResponse: (response) => { + // Return modified response + return { + ...response, + data: { + ...response.data, + customField: customData + } + } + } + } + + const axiosInstance = contentstackHTTPClient({ + defaultHostName: 'defaulthost', + plugins: [plugin] + }) + + const mock = new MockAdapter(axiosInstance) + mock.onGet('/test').reply(200, { data: 'test' }) + + axiosInstance.get('/test').then((response) => { + expect(response.data.customField).to.deep.equal(customData) + done() + }).catch(done) + }) + + it('should run multiple plugins in sequence with return values', (done) => { + const callOrder = [] + let requestHeader1 = null + let requestHeader2 = null + const plugin1 = { + onRequest: (request) => { + callOrder.push('plugin1-request') + requestHeader1 = 'plugin1-value' + return { + ...request, + headers: { + ...request.headers, + 'X-Plugin1': requestHeader1 + } + } + }, + onResponse: (response) => { + callOrder.push('plugin1-response') + return response + } + } + const plugin2 = { + onRequest: (request) => { + callOrder.push('plugin2-request') + requestHeader2 = 'plugin2-value' + // Should receive request from plugin1 + expect(request.headers['X-Plugin1']).to.be.equal(requestHeader1) + return { + ...request, + headers: { + ...request.headers, + 'X-Plugin2': requestHeader2 + } + } + }, + onResponse: (response) => { + callOrder.push('plugin2-response') + return response + } + } + + const axiosInstance = contentstackHTTPClient({ + defaultHostName: 'defaulthost', + plugins: [plugin1, plugin2] + }) + + const mock = new MockAdapter(axiosInstance) + mock.onGet('/test').reply((config) => { + expect(config.headers['X-Plugin1']).to.be.equal(requestHeader1) + expect(config.headers['X-Plugin2']).to.be.equal(requestHeader2) + return [200, { data: 'test' }] + }) + + axiosInstance.get('/test').then(() => { + expect(callOrder).to.deep.equal(['plugin1-request', 'plugin2-request', 'plugin1-response', 'plugin2-response']) + done() + }).catch(done) + }) + + it('should skip plugin errors and continue with other plugins', (done) => { + const logHandlerSpy = sinon.spy() + const workingPluginSpy = sinon.spy() + const customHeader = 'working-plugin-header' + const errorPlugin = { + onRequest: () => { throw new Error('Plugin error') }, + onResponse: () => { throw new Error('Plugin error') } + } + const workingPlugin = { + onRequest: (request) => { + workingPluginSpy() + return { + ...request, + headers: { + ...request.headers, + 'X-Working': customHeader + } + } + }, + onResponse: (response) => { + workingPluginSpy() + return response + } + } + + const axiosInstance = contentstackHTTPClient({ + defaultHostName: 'defaulthost', + plugins: [errorPlugin, workingPlugin], + logHandler: logHandlerSpy + }) + + const mock = new MockAdapter(axiosInstance) + mock.onGet('/test').reply((config) => { + expect(config.headers['X-Working']).to.be.equal(customHeader) + return [200, { data: 'test' }] + }) + + axiosInstance.get('/test').then(() => { + expect(workingPluginSpy.callCount).to.be.equal(2) // Called for both request and response + expect(logHandlerSpy.called).to.be.true + done() + }).catch(done) + }) + + it('should filter out invalid plugins', (done) => { + const validPluginSpy = sinon.spy() + const validPlugin = { + onRequest: validPluginSpy, + onResponse: () => {} + } + const invalidPlugins = [ + null, + undefined, + {}, + { onRequest: () => {} }, // missing onResponse + { onResponse: () => {} }, // missing onRequest + { onRequest: 'not-a-function', onResponse: () => {} }, + 'not-an-object' + ] + + const axiosInstance = contentstackHTTPClient({ + defaultHostName: 'defaulthost', + plugins: [validPlugin, ...invalidPlugins] + }) + + const mock = new MockAdapter(axiosInstance) + mock.onGet('/test').reply(200, { data: 'test' }) + + axiosInstance.get('/test').then(() => { + expect(validPluginSpy.calledOnce).to.be.true + done() + }).catch(done) + }) + + it('should handle empty plugins array', (done) => { + const axiosInstance = contentstackHTTPClient({ + defaultHostName: 'defaulthost', + plugins: [] + }) + + // Should not throw errors + expect(axiosInstance).to.not.be.undefined + done() + }) + + it('should handle undefined plugins', (done) => { + const axiosInstance = contentstackHTTPClient({ + defaultHostName: 'defaulthost', + plugins: undefined + }) + + // Should not throw errors + expect(axiosInstance).to.not.be.undefined + done() + }) + }) }) diff --git a/types/contentstackClient.d.ts b/types/contentstackClient.d.ts index 0d993fd0..da85c8a0 100644 --- a/types/contentstackClient.d.ts +++ b/types/contentstackClient.d.ts @@ -1,5 +1,5 @@ import { User } from './user' -import { AxiosRequestConfig } from 'axios' +import { AxiosRequestConfig, AxiosRequestConfig as AxiosRequest, AxiosResponse, AxiosError } from 'axios' import { AnyProperty } from './utility/fields' import { Pagination } from './utility/pagination' import { Response } from './contentstackCollection' @@ -22,6 +22,26 @@ export interface RetryDelayOption { customBackoff?: (retryCount: number, error: Error) => number } +/** + * Plugin interface for intercepting and modifying requests and responses + * @interface Plugin + */ +export interface Plugin { + /** + * Called before each request is sent. Should return the request object (modified or original). + * @param {AxiosRequestConfig} request - The axios request configuration object + * @returns {AxiosRequestConfig} The request object to use (return undefined to keep original) + */ + onRequest: (request: AxiosRequest) => AxiosRequest | undefined + /** + * Called after each response is received (both success and error cases). + * Should return the response/error object (modified or original). + * @param {AxiosResponse | AxiosError} response - The axios response object (success) or error object (failure) + * @returns {AxiosResponse | AxiosError} The response/error object to use (return undefined to keep original) + */ + onResponse: (response: AxiosResponse | AxiosError) => AxiosResponse | AxiosError | undefined +} + export interface ContentstackToken { authorization?: string authtoken?: string @@ -46,7 +66,12 @@ export interface ContentstackConfig extends AxiosRequestConfig, ContentstackToke logHandler?: (level: string, data: any) => void application?: string integration?: string - delayMs?: number + delayMs?: number + /** + * Array of plugin objects to intercept and modify requests/responses + * Each plugin must implement onRequest and onResponse methods + */ + plugins?: Plugin[] } export interface LoginDetails { From f34c531c40d95686661290a7e5a976096106ec58 Mon Sep 17 00:00:00 2001 From: Nadeem Patwekar Date: Thu, 18 Dec 2025 14:03:02 +0530 Subject: [PATCH 2/7] fix: correct assertions in Contentstack HTTP client tests and enhance error handling in response plugin --- test/unit/ContentstackHTTPClient-test.js | 39 +++++++++++++++++++----- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/test/unit/ContentstackHTTPClient-test.js b/test/unit/ContentstackHTTPClient-test.js index 8a570c48..e04e4b94 100644 --- a/test/unit/ContentstackHTTPClient-test.js +++ b/test/unit/ContentstackHTTPClient-test.js @@ -186,8 +186,10 @@ describe('Contentstack HTTP Client', () => { mock.onGet('/test').reply(200, { data: 'test' }) axiosInstance.get('/test').then(() => { + // eslint-disable-next-line no-unused-expressions expect(onRequestSpy.calledOnce).to.be.true - expect(onRequestSpy.calledWith(sinon.match.object))).to.be.true + // eslint-disable-next-line no-unused-expressions + expect(onRequestSpy.calledWith(sinon.match.object)).to.be.true done() }).catch(done) }) @@ -240,8 +242,10 @@ describe('Contentstack HTTP Client', () => { mock.onGet('/test').reply(200, { data: 'test' }) axiosInstance.get('/test').then(() => { + // eslint-disable-next-line no-unused-expressions expect(onResponseSpy.calledOnce).to.be.true - expect(onResponseSpy.calledWith(sinon.match.object))).to.be.true + // eslint-disable-next-line no-unused-expressions + expect(onResponseSpy.calledWith(sinon.match.object)).to.be.true done() }).catch(done) }) @@ -250,21 +254,36 @@ describe('Contentstack HTTP Client', () => { const onResponseSpy = sinon.spy() const plugin = { onRequest: () => {}, - onResponse: onResponseSpy + onResponse: (error) => { + onResponseSpy(error) + return error + } } const axiosInstance = contentstackHTTPClient({ defaultHostName: 'defaulthost', - plugins: [plugin] + plugins: [plugin], + retryOnError: false, + retryLimit: 0, + retryOnHttpServerError: false, // Disable HTTP server error retries + maxNetworkRetries: 0 // Disable network retries }) const mock = new MockAdapter(axiosInstance) mock.onGet('/test').reply(500, { error: 'Server Error' }) - axiosInstance.get('/test').catch((error) => { - expect(onResponseSpy.calledOnce).to.be.true - expect(onResponseSpy.calledWith(sinon.match.object))).to.be.true + axiosInstance.get('/test').catch(() => { + // Plugin should be called for the error + // eslint-disable-next-line no-unused-expressions + expect(onResponseSpy.called).to.be.true + if (onResponseSpy.called) { + // eslint-disable-next-line no-unused-expressions + expect(onResponseSpy.calledWith(sinon.match.object)).to.be.true + } done() + }).catch((err) => { + // Ensure done is called even if there's an unexpected error + done(err) }) }) @@ -390,12 +409,15 @@ describe('Contentstack HTTP Client', () => { const mock = new MockAdapter(axiosInstance) mock.onGet('/test').reply((config) => { + // eslint-disable-next-line no-unused-expressions expect(config.headers['X-Working']).to.be.equal(customHeader) return [200, { data: 'test' }] }) axiosInstance.get('/test').then(() => { + // eslint-disable-next-line no-unused-expressions expect(workingPluginSpy.callCount).to.be.equal(2) // Called for both request and response + // eslint-disable-next-line no-unused-expressions expect(logHandlerSpy.called).to.be.true done() }).catch(done) @@ -426,6 +448,7 @@ describe('Contentstack HTTP Client', () => { mock.onGet('/test').reply(200, { data: 'test' }) axiosInstance.get('/test').then(() => { + // eslint-disable-next-line no-unused-expressions expect(validPluginSpy.calledOnce).to.be.true done() }).catch(done) @@ -438,6 +461,7 @@ describe('Contentstack HTTP Client', () => { }) // Should not throw errors + // eslint-disable-next-line no-unused-expressions expect(axiosInstance).to.not.be.undefined done() }) @@ -449,6 +473,7 @@ describe('Contentstack HTTP Client', () => { }) // Should not throw errors + // eslint-disable-next-line no-unused-expressions expect(axiosInstance).to.not.be.undefined done() }) From 5e2d9c2ce4111c781c26f66d7731b72f8f014862 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Tue, 23 Dec 2025 11:04:59 +0530 Subject: [PATCH 3/7] refactor: improve error handling and messaging across various modules --- lib/contentstack.js | 2 +- lib/contentstackClient.js | 8 +++--- lib/core/Util.js | 3 ++- lib/core/concurrency-queue.js | 11 ++++---- lib/core/contentstackHTTPClient.js | 3 ++- lib/core/errorMessages.js | 40 ++++++++++++++++++++++++++++++ lib/core/oauthHandler.js | 21 ++++++++-------- lib/stack/asset/index.js | 5 ++-- lib/stack/contentType/index.js | 3 ++- 9 files changed, 71 insertions(+), 25 deletions(-) create mode 100644 lib/core/errorMessages.js diff --git a/lib/contentstack.js b/lib/contentstack.js index 9d377999..344898fe 100644 --- a/lib/contentstack.js +++ b/lib/contentstack.js @@ -122,7 +122,7 @@ import { getContentstackEndpoint } from '@contentstack/utils' * const client = contentstack.client({ logHandler: (level, data) => { if (level === 'error' && data) { const title = [data.name, data.message].filter((a) => a).join(' - ') - console.error(`[error] ${title}`) + console.error(`An error occurred due to ${title}. Review the details and try again.`) return } console.log(`[${level}] ${data}`) diff --git a/lib/contentstackClient.js b/lib/contentstackClient.js index b7acdecd..3ca17e9f 100644 --- a/lib/contentstackClient.js +++ b/lib/contentstackClient.js @@ -26,15 +26,15 @@ export default function contentstackClient ({ http }) { * const client = contentstack.client() * * client.login({ email: , password: }) - * .then(() => console.log('Logged in successfully')) + * .then(() => console.log('Login successful.')) * * @example * client.login({ email: , password: , tfa_token: }) - * .then(() => console.log('Logged in successfully')) + * .then(() => console.log('Login successful.')) * * @example * client.login({ email: , password: , mfaSecret: }) - * .then(() => console.log('Logged in successfully')) + * .then(() => console.log('Login successful.')) */ function login (requestBody = {}, params = {}) { http.defaults.versioningStrategy = 'path' @@ -210,7 +210,7 @@ export default function contentstackClient ({ http }) { * const client = contentstack.client() * * client.oauth({ appId: , clientId: , redirectUri: , clientSecret: , responseType: , scope: }) - * .then(() => console.log('Logged in successfully')) + * .then(() => console.log('Login successful.')) * */ function oauth (params = {}) { diff --git a/lib/core/Util.js b/lib/core/Util.js index 23a2f449..446045dd 100644 --- a/lib/core/Util.js +++ b/lib/core/Util.js @@ -1,4 +1,5 @@ import { platform, release } from 'os' +import { ERROR_MESSAGES } from './errorMessages' const HOST_REGEX = /^(?!(?:(?:https?|ftp):\/\/|internal|localhost|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)))(?:[\w-]+\.contentstack\.(?:io|com)(?::[^\/\s:]+)?|[\w-]+(?:\.[\w-]+)*(?::[^\/\s:]+)?)(?![\/?#])$/ // eslint-disable-line @@ -218,7 +219,7 @@ const isAllowedHost = (hostname) => { export const validateAndSanitizeConfig = (config) => { if (!config?.url || typeof config?.url !== 'string') { - throw new Error('Invalid request configuration: missing or invalid URL') + throw new Error(ERROR_MESSAGES.INVALID_URL_CONFIG) } // Validate the URL to prevent SSRF attacks diff --git a/lib/core/concurrency-queue.js b/lib/core/concurrency-queue.js index 453e29e5..a9f94a67 100644 --- a/lib/core/concurrency-queue.js +++ b/lib/core/concurrency-queue.js @@ -1,6 +1,7 @@ import Axios from 'axios' import OAuthHandler from './oauthHandler' import { validateAndSanitizeConfig } from './Util' +import { ERROR_MESSAGES } from './errorMessages' const defaultConfig = { maxRequests: 5, @@ -45,23 +46,23 @@ const defaultConfig = { */ export function ConcurrencyQueue ({ axios, config }) { if (!axios) { - throw Error('Axios instance is not present') + throw Error(ERROR_MESSAGES.AXIOS_INSTANCE_MISSING) } if (config) { if (config.maxRequests && config.maxRequests <= 0) { - throw Error('Concurrency Manager Error: minimum concurrent requests is 1') + throw Error(ERROR_MESSAGES.MIN_CONCURRENT_REQUESTS) } else if (config.retryLimit && config.retryLimit <= 0) { - throw Error('Retry Policy Error: minimum retry limit is 1') + throw Error(ERROR_MESSAGES.MIN_RETRY_LIMIT) } else if (config.retryDelay && config.retryDelay < 300) { - throw Error('Retry Policy Error: minimum retry delay for requests is 300') + throw Error(ERROR_MESSAGES.MIN_RETRY_DELAY) } // Validate network retry configuration if (config.maxNetworkRetries && config.maxNetworkRetries < 0) { throw Error('Network Retry Policy Error: maxNetworkRetries cannot be negative') } if (config.networkRetryDelay && config.networkRetryDelay < 50) { - throw Error('Network Retry Policy Error: minimum network retry delay is 50ms') + throw Error(ERROR_MESSAGES.MIN_NETWORK_RETRY_DELAY) } } diff --git a/lib/core/contentstackHTTPClient.js b/lib/core/contentstackHTTPClient.js index ca41ea8c..2851747b 100644 --- a/lib/core/contentstackHTTPClient.js +++ b/lib/core/contentstackHTTPClient.js @@ -3,6 +3,7 @@ import clonedeep from 'lodash/cloneDeep' import Qs from 'qs' import { ConcurrencyQueue } from './concurrency-queue' import { isHost } from './Util' +import { ERROR_MESSAGES } from './errorMessages' export default function contentstackHttpClient (options) { const defaultConfig = { @@ -11,7 +12,7 @@ export default function contentstackHttpClient (options) { logHandler: (level, data) => { if (level === 'error' && data) { const title = [data.name, data.message].filter((a) => a).join(' - ') - console.error(`[error] ${title}`) + console.error(ERROR_MESSAGES.ERROR_WITH_TITLE(title)) return } console.log(`[${level}] ${data}`) diff --git a/lib/core/errorMessages.js b/lib/core/errorMessages.js new file mode 100644 index 00000000..e1a7a8f8 --- /dev/null +++ b/lib/core/errorMessages.js @@ -0,0 +1,40 @@ +/** + * Centralized error messages for the Contentstack Management SDK. + * All user-facing error messages should be defined here for consistency and maintainability. + */ + +export const ERROR_MESSAGES = { + // Asset errors + ASSET_URL_REQUIRED: 'Asset URL is required. Provide a valid asset URL and try again.', + INVALID_UPLOAD_FORMAT: 'Invalid upload format. Provide a valid file path or Buffer and try again.', + + // OAuth errors + OAUTH_BASE_URL_NOT_SET: 'OAuth base URL is not configured. Set the OAuth base URL and try again.', + NO_REFRESH_TOKEN: 'No refresh token available. Authenticate first and try again.', + ACCESS_TOKEN_REQUIRED: 'Access token is required. Provide a valid access token and try again.', + REFRESH_TOKEN_REQUIRED: 'Refresh token is required. Provide a valid refresh token and try again.', + ORGANIZATION_UID_REQUIRED: 'Organization UID is required. Provide a valid organization UID and try again.', + USER_UID_REQUIRED: 'User UID is required. Provide a valid user UID and try again.', + TOKEN_EXPIRY_REQUIRED: 'Token expiry time is required. Provide a valid expiry time and try again.', + AUTH_CODE_NOT_FOUND: 'Authorization code not found in redirect URL. Verify the redirect URL and try again.', + NO_USER_AUTHORIZATIONS: 'No authorizations found for the current user. Verify user permissions and try again.', + NO_APP_AUTHORIZATIONS: 'No authorizations found for the app. Verify app configuration and try again.', + + // Concurrency queue errors + AXIOS_INSTANCE_MISSING: 'Axios instance is not present. Initialize the HTTP client and try again.', + MIN_CONCURRENT_REQUESTS: 'Concurrency Manager Error: Minimum concurrent requests must be at least 1.', + MIN_RETRY_LIMIT: 'Retry Policy Error: Minimum retry limit must be at least 1.', + MIN_RETRY_DELAY: 'Retry Policy Error: Minimum retry delay must be at least 300ms.', + MIN_NETWORK_RETRY_DELAY: 'Network Retry Policy Error: Minimum network retry delay must be at least 50ms.', + + // Request configuration errors + INVALID_URL_CONFIG: 'Invalid request configuration: URL is missing or invalid. Provide a valid URL and try again.', + + // General errors + ERROR_WITH_TITLE: (title) => `An error occurred due to ${title}. Review the details and try again.`, + + // Content type errors + PARAMETER_NAME_REQUIRED: 'Parameter name is required. Provide a valid parameter name and try again.' +} + +export default ERROR_MESSAGES diff --git a/lib/core/oauthHandler.js b/lib/core/oauthHandler.js index 71cac609..6d64b586 100644 --- a/lib/core/oauthHandler.js +++ b/lib/core/oauthHandler.js @@ -1,4 +1,5 @@ import errorFormatter from './contentstackError' +import { ERROR_MESSAGES } from './errorMessages' /** * @description OAuthHandler class to handle OAuth authorization and token management @@ -91,7 +92,7 @@ export default class OAuthHandler { async authorize () { try { if (!this.OAuthBaseURL) { - throw new Error('OAuthBaseURL is not set') + throw new Error(ERROR_MESSAGES.OAUTH_BASE_URL_NOT_SET) } const baseUrl = `${this.OAuthBaseURL}/#!/apps/${this.appId}/authorize` const authUrl = new URL(baseUrl) @@ -171,7 +172,7 @@ export default class OAuthHandler { const refreshToken = providedRefreshToken || this.axiosInstance.oauth.refreshToken if (!refreshToken) { - throw new Error('No refresh token available. Please authenticate first.') + throw new Error(ERROR_MESSAGES.NO_REFRESH_TOKEN) } const body = new URLSearchParams({ @@ -308,7 +309,7 @@ export default class OAuthHandler { */ setAccessToken (token) { if (!token) { - throw new Error('Access token is required') + throw new Error(ERROR_MESSAGES.ACCESS_TOKEN_REQUIRED) } this.axiosInstance.oauth.accessToken = token } @@ -327,7 +328,7 @@ export default class OAuthHandler { */ setRefreshToken (token) { if (!token) { - throw new Error('Refresh token is required') + throw new Error(ERROR_MESSAGES.REFRESH_TOKEN_REQUIRED) } this.axiosInstance.oauth.refreshToken = token } @@ -346,7 +347,7 @@ export default class OAuthHandler { */ setOrganizationUID (organizationUID) { if (!organizationUID) { - throw new Error('Organization UID is required') + throw new Error(ERROR_MESSAGES.ORGANIZATION_UID_REQUIRED) } this.axiosInstance.oauth.organizationUID = organizationUID } @@ -365,7 +366,7 @@ export default class OAuthHandler { */ setUserUID (userUID) { if (!userUID) { - throw new Error('User UID is required') + throw new Error(ERROR_MESSAGES.USER_UID_REQUIRED) } this.axiosInstance.oauth.userUID = userUID } @@ -384,7 +385,7 @@ export default class OAuthHandler { */ setTokenExpiryTime (expiryTime) { if (!expiryTime) { - throw new Error('Token expiry time is required') + throw new Error(ERROR_MESSAGES.TOKEN_EXPIRY_REQUIRED) } this.axiosInstance.oauth.tokenExpiryTime = expiryTime } @@ -414,7 +415,7 @@ export default class OAuthHandler { errorFormatter(error) } } else { - throw new Error('Authorization code not found in redirect URL.') + throw new Error(ERROR_MESSAGES.AUTH_CODE_NOT_FOUND) } } @@ -443,11 +444,11 @@ export default class OAuthHandler { const userUid = this.axiosInstance.oauth.userUID const currentUserAuthorization = data?.data?.filter((element) => element.user.uid === userUid) || [] if (currentUserAuthorization.length === 0) { - throw new Error('No authorizations found for current user!') + throw new Error(ERROR_MESSAGES.NO_USER_AUTHORIZATIONS) } return currentUserAuthorization[0].authorization_uid // filter authorizations by current logged in user } else { - throw new Error('No authorizations found for the app!') + throw new Error(ERROR_MESSAGES.NO_APP_AUTHORIZATIONS) } } catch (error) { errorFormatter(error) diff --git a/lib/stack/asset/index.js b/lib/stack/asset/index.js index 69e81484..5dc0ec7e 100644 --- a/lib/stack/asset/index.js +++ b/lib/stack/asset/index.js @@ -10,6 +10,7 @@ import { unpublish } from '../../entity' import { Folder } from './folders' import error from '../../core/contentstackError' +import { ERROR_MESSAGES } from '../../core/errorMessages' import FormData from 'form-data' import { createReadStream } from 'fs' @@ -289,7 +290,7 @@ export function Asset (http, data = {}) { } || { responseType } const requestUrl = url || this.url if (!requestUrl || requestUrl === undefined) { - throw new Error('Asset URL can not be empty') + throw new Error(ERROR_MESSAGES.ASSET_URL_REQUIRED) } return http.get(requestUrl, headers) } catch (err) { @@ -338,7 +339,7 @@ export function createFormData (data) { formData.append('asset[upload]', uploadStream) } } else { - throw new Error('Invalid upload format. Must be a file path or Buffer.') + throw new Error(ERROR_MESSAGES.INVALID_UPLOAD_FORMAT) } return formData } diff --git a/lib/stack/contentType/index.js b/lib/stack/contentType/index.js index aaa2b0e5..ffd50f1f 100644 --- a/lib/stack/contentType/index.js +++ b/lib/stack/contentType/index.js @@ -10,6 +10,7 @@ import { } from '../../entity' import { Entry } from './entry/index' import error from '../../core/contentstackError' +import { ERROR_MESSAGES } from '../../core/errorMessages' import FormData from 'form-data' import { createReadStream } from 'fs' @@ -214,7 +215,7 @@ export function ContentType (http, data = {}) { */ this.generateUid = (name) => { if (!name) { - throw new TypeError('Expected parameter name') + throw new TypeError(ERROR_MESSAGES.PARAMETER_NAME_REQUIRED) } return name.replace(/[^A-Z0-9]+/gi, '_').toLowerCase() } From 83a8119290d70954f9d8ec9391e1209fb773ed93 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Tue, 23 Dec 2025 12:24:11 +0530 Subject: [PATCH 4/7] fix: enhance error messages for asset, concurrency queue, content type, and OAuth handler tests --- test/unit/asset-test.js | 4 ++-- test/unit/concurrency-Queue-test.js | 8 ++++---- test/unit/contentType-test.js | 2 +- test/unit/oauthHandler-test.js | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/test/unit/asset-test.js b/test/unit/asset-test.js index 3970b200..6a7171cb 100644 --- a/test/unit/asset-test.js +++ b/test/unit/asset-test.js @@ -401,7 +401,7 @@ describe('Contentstack Asset test', () => { done() }) .catch((err) => { - expect(err.message).to.be.equal('Asset URL can not be empty') + expect(err.message).to.be.equal('Asset URL is required. Provide a valid asset URL and try again.') done() }) }) @@ -415,7 +415,7 @@ describe('Contentstack Asset test', () => { }) .download({ responseType: 'blob' }) .catch((err) => { - expect(err.message).to.be.equal('Asset URL can not be empty') + expect(err.message).to.be.equal('Asset URL is required. Provide a valid asset URL and try again.') done() }) }) diff --git a/test/unit/concurrency-Queue-test.js b/test/unit/concurrency-Queue-test.js index 6ccf4831..4871fec1 100644 --- a/test/unit/concurrency-Queue-test.js +++ b/test/unit/concurrency-Queue-test.js @@ -189,7 +189,7 @@ describe('Concurrency queue test', () => { new ConcurrencyQueue({ axios: undefined }) expect.fail('Undefined axios should fail') } catch (error) { - expect(error.message).to.be.equal('Axios instance is not present') + expect(error.message).to.be.equal('Axios instance is not present. Initialize the HTTP client and try again.') done() } }) @@ -232,7 +232,7 @@ describe('Concurrency queue test', () => { makeConcurrencyQueue({ maxRequests: -10 }) expect.fail('Negative concurrency queue should fail') } catch (error) { - expect(error.message).to.be.equal('Concurrency Manager Error: minimum concurrent requests is 1') + expect(error.message).to.be.equal('Concurrency Manager Error: Minimum concurrent requests must be at least 1.') done() } }) @@ -242,7 +242,7 @@ describe('Concurrency queue test', () => { makeConcurrencyQueue({ retryLimit: -10 }) expect.fail('Negative retry limit should fail') } catch (error) { - expect(error.message).to.be.equal('Retry Policy Error: minimum retry limit is 1') + expect(error.message).to.be.equal('Retry Policy Error: Minimum retry limit must be at least 1.') done() } }) @@ -252,7 +252,7 @@ describe('Concurrency queue test', () => { makeConcurrencyQueue({ retryDelay: 10 }) expect.fail('Retry delay should be min 300ms') } catch (error) { - expect(error.message).to.be.equal('Retry Policy Error: minimum retry delay for requests is 300') + expect(error.message).to.be.equal('Retry Policy Error: Minimum retry delay must be at least 300ms.') done() } }) diff --git a/test/unit/contentType-test.js b/test/unit/contentType-test.js index a1be2162..6624bb20 100644 --- a/test/unit/contentType-test.js +++ b/test/unit/contentType-test.js @@ -88,7 +88,7 @@ describe('Contentstack ContentType test', () => { it('ContentType generate UID from content type name test', done => { const contentType = makeContentType() - expect(contentType.generateUid.bind(contentType, null)).to.throw('Expected parameter name') + expect(contentType.generateUid.bind(contentType, null)).to.throw('Parameter name is required. Provide a valid parameter name and try again.') expect(contentType.generateUid('Test Name')).to.be.equal('test_name') expect(contentType.generateUid('Test @Name')).to.be.equal('test_name') expect(contentType.generateUid('12 Test Name')).to.be.equal('12_test_name') diff --git a/test/unit/oauthHandler-test.js b/test/unit/oauthHandler-test.js index 5088a303..e1e774ef 100644 --- a/test/unit/oauthHandler-test.js +++ b/test/unit/oauthHandler-test.js @@ -288,7 +288,7 @@ describe('OAuthHandler', () => { await oauthHandler.getOauthAppAuthorization() throw new Error('Expected error not thrown') } catch (error) { - expect(error.message).to.equal('No authorizations found for current user!') + expect(error.message).to.equal('No authorizations found for the current user. Verify user permissions and try again.') } }) @@ -301,7 +301,7 @@ describe('OAuthHandler', () => { await oauthHandler.getOauthAppAuthorization() throw new Error('Expected error not thrown') } catch (error) { - expect(error.message).to.equal('No authorizations found for the app!') + expect(error.message).to.equal('No authorizations found for the app. Verify app configuration and try again.') } }) }) From e7a1f32d57c12407b912b30291f4111484f51c08 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Thu, 8 Jan 2026 12:26:51 +0530 Subject: [PATCH 5/7] chore: update license year and refine error message for HTTP client requirement --- LICENSE | 2 +- lib/core/errorMessages.js | 2 +- test/unit/concurrency-Queue-test.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index 3851325f..4ea4612e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2012-2025 Contentstack +Copyright (c) 2012-2026 Contentstack Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/lib/core/errorMessages.js b/lib/core/errorMessages.js index e1a7a8f8..c7aabe1b 100644 --- a/lib/core/errorMessages.js +++ b/lib/core/errorMessages.js @@ -21,7 +21,7 @@ export const ERROR_MESSAGES = { NO_APP_AUTHORIZATIONS: 'No authorizations found for the app. Verify app configuration and try again.', // Concurrency queue errors - AXIOS_INSTANCE_MISSING: 'Axios instance is not present. Initialize the HTTP client and try again.', + AXIOS_INSTANCE_MISSING: 'HTTP client is required. Initialize the HTTP client and try again.', MIN_CONCURRENT_REQUESTS: 'Concurrency Manager Error: Minimum concurrent requests must be at least 1.', MIN_RETRY_LIMIT: 'Retry Policy Error: Minimum retry limit must be at least 1.', MIN_RETRY_DELAY: 'Retry Policy Error: Minimum retry delay must be at least 300ms.', diff --git a/test/unit/concurrency-Queue-test.js b/test/unit/concurrency-Queue-test.js index 4871fec1..f10cc9c7 100644 --- a/test/unit/concurrency-Queue-test.js +++ b/test/unit/concurrency-Queue-test.js @@ -189,7 +189,7 @@ describe('Concurrency queue test', () => { new ConcurrencyQueue({ axios: undefined }) expect.fail('Undefined axios should fail') } catch (error) { - expect(error.message).to.be.equal('Axios instance is not present. Initialize the HTTP client and try again.') + expect(error.message).to.be.equal('HTTP client is required. Initialize the HTTP client and try again.') done() } }) From 956c7c95d73138c8d9c2510449c7a62758efa074 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 9 Jan 2026 12:12:24 +0530 Subject: [PATCH 6/7] version bump --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6462cf3d..c9f1e55a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [v1.27.2](https://github.com/contentstack/contentstack-management-javascript/tree/v1.27.2) (2026-01-12) + - Enhancement + - Improved error messages + ## [v1.27.1](https://github.com/contentstack/contentstack-management-javascript/tree/v1.27.1) (2026-01-5) - Fix - Resolve qs dependency version diff --git a/package.json b/package.json index 6d415628..4e395998 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/management", - "version": "1.27.1", + "version": "1.27.2", "description": "The Content Management API is used to manage the content of your Contentstack account", "main": "./dist/node/contentstack-management.js", "browser": "./dist/web/contentstack-management.js", From 1344d28c214d1b7711cc42962d373b589278833a Mon Sep 17 00:00:00 2001 From: sunil-lakshman <104969541+sunil-lakshman@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:48:52 +0530 Subject: [PATCH 7/7] Added params support in entry variants update --- CHANGELOG.md | 1 + lib/stack/contentType/entry/variants/index.js | 7 ++++++- package-lock.json | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f1e55a..4e4108fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [v1.27.2](https://github.com/contentstack/contentstack-management-javascript/tree/v1.27.2) (2026-01-12) - Enhancement - Improved error messages + - params support in entry variants update ## [v1.27.1](https://github.com/contentstack/contentstack-management-javascript/tree/v1.27.1) (2026-01-5) - Fix diff --git a/lib/stack/contentType/entry/variants/index.js b/lib/stack/contentType/entry/variants/index.js index 9c651558..1f7efed4 100644 --- a/lib/stack/contentType/entry/variants/index.js +++ b/lib/stack/contentType/entry/variants/index.js @@ -19,6 +19,8 @@ export function Variants (http, data) { * @description The Update a variant call updates an existing variant for the selected content type. * @memberof Variants * @func update + * @param {Object} data - The variant data to update. + * @param {Object} [params={}] - Optional query parameters. * @returns {Promise} Response Object. * @example * import * as contentstack from '@contentstack/management' @@ -38,13 +40,16 @@ export function Variants (http, data) { * client.stack({ api_key: 'api_key'}).contentType('content_type_uid').entry('entry_uid').variants('uid').update(data) * .then((variants) => console.log(variants)) */ - this.update = async (data) => { + this.update = async (data, params = {}) => { try { const response = await http.put(this.urlPath, data, { headers: { ...cloneDeep(this.stackHeaders) + }, + params: { + ...cloneDeep(params) } }) if (response.data) { diff --git a/package-lock.json b/package-lock.json index 27962188..f8b2033e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@contentstack/management", - "version": "1.27.1", + "version": "1.27.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@contentstack/management", - "version": "1.27.1", + "version": "1.27.2", "license": "MIT", "dependencies": { "@contentstack/utils": "^1.6.3",