@apistudio/apim-cli
Version:
CLI for API Management Products
215 lines (188 loc) • 6.54 kB
text/typescript
/**
* Copyright IBM Corp. 2024, 2025
*/
/**
* Manages all the variable contexts with unique context.
*/
import { EnvVar, VariableContext } from './variable-context.js';
type EnvInput =
| Record<string, any>
| Record<string, EnvVar>
| EnvVar[]
| Array<Record<string, any>>;
export class ContextManager {
private contexts = new Map<string, VariableContext>();
// This will be accessible for entire
private globalContext = new VariableContext();
createContext(contextId: string): VariableContext {
if (!this.contexts.has(contextId)) {
this.contexts.set(contextId, new VariableContext());
}
return this.contexts.get(contextId)!;
}
getContext(contextId: string): VariableContext {
return this.createContext(contextId)!;
}
deleteContext(contextId: string): void {
this.contexts.delete(contextId);
}
listContexts(): string[] {
return [...this.contexts.keys()];
}
getGlobalContext(): VariableContext {
return this.globalContext;
}
// Don't use this unless we want to clean context manager
clearAll(): void {
this.contexts.clear();
this.globalContext.clear();
}
loadEnv(contextId: string, envVars: EnvInput): void {
const context = this.createContext(contextId);
// If it's array of key-value pairs with `key`, `value`, `isSecret`
if (
Array.isArray(envVars) &&
envVars.every((e) => 'key' in e && 'value' in e)
) {
for (const env of envVars) {
context.setEnvVariable(env.key, env.value, env.isSecret as boolean);
}
return;
}
// If it's array of plain objects
const envArray = Array.isArray(envVars) ? envVars : [envVars];
for (const env of envArray) {
for (const [key, val] of Object.entries(env)) {
if (
val &&
typeof val === 'object' &&
'value' in val &&
'isSecret' in val
) {
context.setEnvVariable(key, val.value, val.isSecret as boolean);
} else {
context.setEnvVariable(key, val);
}
}
}
}
// Resolve variable name with the value store in context
// which will return the resolved value in the passed input
resolve(contextId: string, input: any): any {
if (input == undefined) {
return input;
}
const context = this.contexts.get(contextId);
if (!context) throw new Error(`Context '${contextId}' not found.`);
if (Array.isArray(input)) {
return input.map((item) => this.resolve(contextId, item));
} else if (typeof FormData !== 'undefined' && input instanceof FormData) {
return input;
} else if (typeof input === 'object' && input !== null) {
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(input)) {
result[key] = this.resolve(contextId, value);
}
return result;
}
// for primitive types
return this.resolveValue(contextId, input);
}
// For any string which have expression like "result.0.name" instead of variable
// typeof input parameter is set to any, in runtime it should be only string.
// to validate unit test, it is set to any
resolveExpression(contextId: string, input: any): any {
const context = this.contexts.get(contextId);
if (!context) throw new Error(`Context '${contextId}' not found.`);
if (typeof input !== 'string') {
throw new Error(`${input} should be a string expression`);
}
const result = this.resolvePath(contextId, input);
return result;
}
// ResolveValue function will understand whether the input is variable or statement with variable
// based on that, resolveExpression will be invoked.
private resolveValue(contextId: string, input: any): any {
// If input is non string, then it won't be a variable
if (typeof input !== 'string' || !input.includes('${')) {
return input;
}
const fullVar = input.match(/^\$\{([^{}]+)\}$/);
if (fullVar) {
return this.resolvePath(contextId, fullVar[1]);
}
return this.replaceRecursiveExpression(contextId, input);
}
// For mixed string: recursively replace ALL ${...} patterns until none left
private replaceRecursiveExpression(contextId: string, input: string) {
if (!input) {
return input;
}
while (input.includes('${')) {
input = input.replace(/\$\{([^{}]*)\}/g, (_: any, expr: any) => {
try {
const value = this.resolvePath(contextId, expr);
// If value is undefined, return empty string
if (value === undefined) {
return '';
}
// Handle circular structure in JSON stringification
return typeof value === 'object'
? this.safeStringify(value)
: String(value);
} catch (e) {
console.error(e);
return ''; // Return empty string on error
}
});
}
return input;
}
// Safely stringify objects handling circular references
private safeStringify(obj: any): string {
try {
// Use a WeakSet to track objects that have been seen
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
// If value is an object and not null
if (typeof value === 'object' && value !== null) {
// If we've seen this object before, return a placeholder to avoid circular reference
if (seen.has(value)) {
return '[Circular Reference]';
}
// Add the value to our set of seen objects
seen.add(value);
}
return value;
});
} catch {
// Fallback if JSON.stringify still fails
return '[Complex Object]';
}
}
private resolvePath(contextId: string, expr: string): any {
if (!expr) {
return expr;
}
const context = this.createContext(contextId);
const global = this.getGlobalContext();
const [baseKey, ...pathParts] = expr.split('.');
let resolved = context?.getValue(baseKey) ?? global?.getValue(baseKey);
if (resolved === undefined) {
return undefined;
}
for (const part of pathParts) {
const key = /^\d+$/.test(part) ? Number(part) : part;
if (resolved === undefined || resolved === null) {
return undefined;
}
// If the key doesn't exist in the object, return undefined instead of throwing an error
if (!(key in resolved)) {
return undefined;
}
resolved = resolved[key];
}
return resolved;
}
}
export const VCM = new ContextManager();