@apistudio/apim-cli
Version:
CLI for API Management Products
378 lines (348 loc) • 12.9 kB
text/typescript
/**
* Copyright IBM Corp. 2024, 2025
*/
import { Assert } from '@apic/api-model/test/common/Assert.js';
import { VCM } from '../engine/variable-context-manager/context-manager.js';
import { EnvironmentVariable } from '../models/interface.js';
import { Assertion, AssertionFactory } from './assertion.factory.js';
import { Environment, EnvironmentFactory } from './environment.factory.js';
import { Gateway, GatewayFactory } from './gateway.factory.js';
import { TestFactory } from './test.factory.js';
import { Test } from '../schemas/test.schema.js';
type ExtendedAsset = Assert & { stopOnFail: boolean; if?: string | boolean };
export class ModelFactory {
private tests = new Map<string, Test>();
private environments = new Map<string, Environment>();
private assertions = new Map<string, Assertion>();
private gateways = new Map<string, Gateway>();
private testFactory: TestFactory = new TestFactory();
constructor() {}
create(raw: any[]): void {
raw?.forEach((ele: any) => {
if (!ele?.kind) {
this.gateways.set('gateway', new GatewayFactory().create(ele));
return;
}
const {
kind,
metadata: { name, namespace, version },
} = ele;
const key = this.buildModelKey(kind, namespace, name, version);
switch (kind) {
case 'test':
this.tests.set(key, this.testFactory.create(ele));
break;
case 'assertion':
this.assertions.set(key, new AssertionFactory().create(ele));
break;
case 'environment':
this.environments.set(key, new EnvironmentFactory().create(ele));
break;
default:
throw new Error(`Unsupported kind: ${ele.kind}`);
}
});
}
destroy(): void {
this.tests.forEach((test) => {
if (test.vcmId) VCM.deleteContext(test.vcmId);
});
this.tests.clear();
this.environments.clear();
this.assertions.clear();
}
getTest(
namespace: string,
name: string,
version: string,
endpoint?: string,
): Test | undefined {
return this.tests.get(
this.buildModelKey('test', namespace, name, version, endpoint),
);
}
getAllTests(): Test[] {
return Array.from(this.tests.values());
}
getAllTestsWithKey(): { key: string; test: Test }[] {
return Array.from(this.tests.entries()).map(([key, test]) => ({
key,
test,
}));
}
getEnvironment(
namespace: string,
name: string,
version: string,
): Environment | undefined {
return this.environments.get(
this.buildModelKey('environment', namespace, name, version),
);
}
getAllEnvironment(): Environment[] {
return Array.from(this.environments.values());
}
getAssertions(
namespace: string,
name: string,
version: string,
): Assertion | undefined {
return this.assertions.get(
this.buildModelKey('assertion', namespace, name, version),
);
}
getAllAssertions(): Assertion[] {
return Array.from(this.assertions.values());
}
getGateway(): Gateway | undefined {
return this.gateways.get('gateway');
}
private buildModelKey(
kind: string,
namespace: string,
name: string,
version: string,
id?: string,
): string {
return id
? `${kind}::${namespace}::${name}::${version}::${id}`
: `${kind}::${namespace}::${name}::${version}`;
}
resolveRefs() {
// For Test collection logic needs to be updated based on test created by resolve environments
let tests: { key: string; test: Test }[] = this.getAllTestsWithKey();
tests.map(({ test }) => {
// Resolve assertions ref
this.resolveAssertions(test);
});
// For gateway collection logic needs to be updated based on test created by assertions
tests = this.getAllTestsWithKey();
tests.map(({ test, key }) => {
// Resolve api ref
this.resolveAPI(test, key);
});
// For Environment collection logic needs to be updated based on test created by gateways
tests = this.getAllTestsWithKey();
tests.map(({ test, key }) => {
// Resolve environment ref
this.resolveEnvironment(test, key);
});
}
// Resolve API reference with gateway endpoint if available.
// If ref is not available, function will be skipped.
private resolveAPI(test: Test, testKey: string) {
try {
if (test.spec?.api?.$ref) {
let refKeys = test.spec.api.$ref;
const gateway = this.getGateway();
if (!Array.isArray(refKeys)) {
refKeys = [refKeys];
}
refKeys.forEach((refKey: string) => {
const endpoints = gateway?.[refKey];
if (!endpoints) {
throw new Error(`Reference variable '${refKey}' not defined`);
}
delete test.spec.api.$ref;
const { kind, metadata } = test;
const { name, namespace, version } = metadata ?? {};
endpoints.forEach((endpoint: string) => {
test.spec!.api!.$endpoint = endpoint;
// Create a deep copy of the test to avoid modifying the original
const testCopy = JSON.parse(JSON.stringify(test));
const parsed = this.testFactory.create(testCopy);
const key = this.buildModelKey(
kind!,
namespace!,
name!,
version!,
`${refKey}:${endpoint}`,
);
this.tests.set(key, parsed);
});
});
// Need to delete the test based on environment name too
this.tests.delete(testKey);
}
} catch (error) {
throw error;
}
}
// Resolve API reference with environment if available.
// If ref is not available, function will be skipped.
private resolveEnvironment(test: Test, testKey: string) {
try {
// check $ref is array to create test collections
// for consistency make the string to array
if (test.spec.environment) {
let environments: any[] = [];
if (Array.isArray(test.spec.environment)) {
// Format 1: array of objects with $ref
const refArray = test.spec.environment as Array<{ $ref?: string }>;
environments = refArray.flatMap((refObj) => {
if (!refObj || !refObj.$ref) return [];
const ref = refObj.$ref;
const [namespace, name, version] = ref.split(':');
const environment = this.getEnvironment(namespace, name, version);
if (!environment) {
throw new Error(`${ref} is not available in environment`);
}
if (
!environment.metadata?.name ||
!environment.metadata?.version ||
!environment.metadata?.namespace
) {
throw new Error(
`Environment ${namespace}:${name}:${version} has incomplete metadata`,
);
}
return [environment];
});
} else if (test.spec.environment.$ref) {
const ref = test.spec.environment.$ref;
const [namespace, name, version] = ref.split(':');
const environment = this.getEnvironment(namespace, name, version);
if (!environment) {
throw new Error(`${ref} is not available in environment`);
}
if (
!environment.metadata?.name ||
!environment.metadata?.version ||
!environment.metadata?.namespace
) {
throw new Error(
`Environment ${namespace}:${name}:${version} has incomplete metadata`,
);
}
environments = [environment];
} else {
// actual data is present instead of references to files
return;
}
const { kind: testKind, metadata } = test;
const {
name: testName,
namespace: testNamespace,
version: testVersion,
} = metadata ?? {};
environments.forEach((environment) => {
// Create a deep copy of the test to avoid modifying the original
const testCopy = JSON.parse(JSON.stringify(test));
const envSpecVariables: EnvironmentVariable[] = (environment.spec
?.variables ?? []) as EnvironmentVariable[];
// Build the new environment structure for the test copy
testCopy.spec.environment = {
variables: [
{
metadata: {
name: environment.metadata.name,
version: environment.metadata.version,
namespace: environment.metadata.namespace,
},
kind: 'environment',
spec: {
variables: envSpecVariables,
},
},
],
};
// New test model. Generates new vcmId.
const parsedTest = this.testFactory.create(testCopy);
// TestFactory.create() only loads variables from testCopy.spec.environment.
VCM.loadEnv(parsedTest.vcmId!, envSpecVariables);
const key = this.buildModelKey(
testKind!,
testNamespace!,
testName!,
testVersion!,
`${testKey}${environment.metadata.namespace}:${environment.metadata.name}:${environment.metadata.version}`,
);
this.tests.set(key, parsedTest);
});
// Based on environment key, new test got created.
// So test with default settings are removed from registry.
this.tests.delete(testKey);
}
} catch (error) {
throw error;
}
}
// Resolve API reference with assertions if available.
// If ref is not available, function will be skipped.
private resolveAssertions(test: Test) {
try {
const requests = test.spec.request;
requests.forEach((request) => {
if (request.assertions) {
let assertions: any[] = [];
// Handle both formats of assertions
if (Array.isArray(request.assertions)) {
// Format 1: array of objects with $ref
const refArray = request.assertions as Array<{ $ref?: string }>;
assertions = refArray.flatMap((refObj) => {
if (!refObj || !refObj.$ref) return [];
const ref = refObj.$ref;
const [namespace, name, version] = ref.split(':');
const assertionModel = this.getAssertions(
namespace,
name,
version,
);
if (!assertionModel) {
throw new Error(`${ref} is not available in assertions`);
}
return [assertionModel];
});
} else if (request.assertions.$ref) {
// Format 2: single assertion with direct $ref property
const ref = request.assertions.$ref;
const [namespace, name, version] = ref.split(':');
const assertionModel = this.getAssertions(namespace, name, version);
if (!assertionModel) {
throw new Error(`${ref} is not available in assertions`);
}
assertions = [assertionModel];
} else if (request.assertions.assertions) {
// Format with expressions already defined
assertions = request.assertions.assertions;
}
// Due to APIC model definition we are forced to have below
request.assertions = {
//Looping over each items in assertion list
assertions: assertions.map((data) => {
//Checking for required fields
if (!data.metadata || !data.spec) {
throw new Error('Assertion data is missing required fields');
}
//Creating new object from data with metadata, kind and apiVersion
return {
metadata: {
name: data.metadata.name!,
version: data.metadata.version!,
namespace: data.metadata.namespace!,
},
kind: 'assertion' as const,
apiVersion: data.apiVersion,
spec: data.spec.map((a: Assert) => {
const extended = a as ExtendedAsset;
return {
name: a.name ?? '',
key: a.key ?? '',
action: a.action ?? '',
value: a.value,
...(extended.stopOnFail ? { stopOnFail: true } : {}),
...(extended.if !== undefined ? { if: extended.if } : {}),
};
}),
};
}),
};
}
});
// Create a new test instance to validate the schema
this.testFactory.create(test);
} catch (error) {
throw error;
}
}
}