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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## [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
- Resolve qs dependency version
Expand Down
25 changes: 24 additions & 1 deletion lib/contentstack.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down Expand Up @@ -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<Object>=} 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 = {}) {
Expand Down
8 changes: 4 additions & 4 deletions lib/contentstackClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ export default function contentstackClient ({ http }) {
* const client = contentstack.client()
*
* client.login({ email: <emailid>, password: <password> })
* .then(() => console.log('Logged in successfully'))
* .then(() => console.log('Login successful.'))
*
* @example
* client.login({ email: <emailid>, password: <password>, tfa_token: <tfa_token> })
* .then(() => console.log('Logged in successfully'))
* .then(() => console.log('Login successful.'))
*
* @example
* client.login({ email: <emailid>, password: <password>, mfaSecret: <mfa_secret> })
* .then(() => console.log('Logged in successfully'))
* .then(() => console.log('Login successful.'))
*/
function login (requestBody = {}, params = {}) {
http.defaults.versioningStrategy = 'path'
Expand Down Expand Up @@ -210,7 +210,7 @@ export default function contentstackClient ({ http }) {
* const client = contentstack.client()
*
* client.oauth({ appId: <appId>, clientId: <clientId>, redirectUri: <redirectUri>, clientSecret: <clientSecret>, responseType: <responseType>, scope: <scope> })
* .then(() => console.log('Logged in successfully'))
* .then(() => console.log('Login successful.'))
*
*/
function oauth (params = {}) {
Expand Down
26 changes: 25 additions & 1 deletion lib/core/Util.js
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -236,3 +237,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'
})
}
11 changes: 6 additions & 5 deletions lib/core/concurrency-queue.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -53,23 +54,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)
}
}

Expand Down
122 changes: 120 additions & 2 deletions lib/core/contentstackHTTPClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ 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'
import { ERROR_MESSAGES } from './errorMessages'

export default function contentstackHttpClient (options) {
const defaultConfig = {
Expand All @@ -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}`)
Expand Down Expand Up @@ -109,6 +110,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)
Expand All @@ -117,5 +123,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
}
40 changes: 40 additions & 0 deletions lib/core/errorMessages.js
Original file line number Diff line number Diff line change
@@ -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: '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.',
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
Loading
Loading