@apistudio/apim-cli
Version:
CLI for API Management Products
417 lines (387 loc) • 14.6 kB
text/typescript
/**
* Copyright IBM Corp. 2024, 2025
*/
import JSZip, { JSZipObject } from 'jszip';
import yaml from 'js-yaml';
import { Api_Spec_Ref, SpecObject, ReferenceValidationResult } from '../model/interface.js';
import {
addErrorToResponse,
isValidAsset,
processRef,
updateMapWithMetadata,
validateMinAssets,
checkFileExtension,
errorsArray,
validateSoapGatewayRestriction,
} from '../utils.js';
import { YamlContent, Logger } from '@apic/studio-shared';
import { AppConstants } from '../constants/app.constants.js';
import path from 'path';
import { BuildProjectAssets } from '../build-project-assets.js';
export class ProjectAssetValidator {
private isYamlFileForFolder(entry: JSZipObject, folderName: string): boolean {
return (
!entry.dir && entry.name.startsWith(folderName + path.sep) && checkFileExtension(entry.name)
);
}
private async loadZipFromBuffer(fileBuffer: Buffer): Promise<JSZip> {
Logger.info('Loading ZIP from buffer');
const zip = new JSZip();
return zip.loadAsync(fileBuffer);
}
public async validateAssetUniqueness(fileBuffer: Buffer): Promise<boolean> {
const zipContent = await this.loadZipFromBuffer(fileBuffer);
const newMap = new Map<string, { fileName: string; kind: string }>();
let allValid = true;
await Promise.all(
Object.keys(zipContent.files).map(async (fileName) => {
const entry = zipContent.files[fileName];
if (entry && checkFileExtension(entry.name)) {
const content = await entry.async('string');
const yamlContents = yaml.loadAll(content) as YamlContent[];
for (const yamlContent of yamlContents) {
if (isValidAsset(yamlContent)) {
const namespace = yamlContent.metadata.namespace || 'default';
const name = yamlContent.metadata.name;
const version = yamlContent.metadata.version;
const kind = yamlContent.kind;
const ref = `${namespace}:${name}:${version}`;
if (newMap.has(ref)) {
const existing = newMap.get(ref);
addErrorToResponse(
AppConstants.VALIDATION_ERROR_CODE,
fileName,
`Duplicate asset detected: '${name}' in namespace '${namespace}' with version '${version}' and kind '${kind}'. First seen in file '${existing?.fileName}'.`
);
allValid = false;
} else {
newMap.set(ref, { fileName, kind });
}
}
}
}
})
);
return allValid;
}
private async createProjectAssetReferenceMap(
buffer: Buffer,
folderName: string,
allFolderNames: Set<string>
): Promise<Map<string, boolean>> {
Logger.info(`Creating asset reference map for folder: ${folderName}`);
const zipContent = await this.loadZipFromBuffer(buffer);
const refMap = new Map<string, boolean>();
try {
const obj = new BuildProjectAssets();
const versionMap = await obj.createVersionProcessingMap(buffer);
await this.processYamlFiles(zipContent, folderName, refMap, versionMap);
await this.processYamlFiles(zipContent, 'dependencies', refMap, versionMap, false);
// if unresolved refs are identified after checking in the project and dependencies folders then check in other folders
const hasUnresolvedRefs = Array.from(refMap.values()).some((value) => !value);
if (hasUnresolvedRefs) {
for (const otherFolderName of allFolderNames) {
if (otherFolderName !== folderName && otherFolderName !== 'dependencies') {
await this.processYamlFiles(zipContent, otherFolderName, refMap, versionMap, false);
}
}
}
Logger.info('Successfully processed YAML files');
} catch (err) {
Logger.error('Error processing ZIP', err instanceof Error ? err : new Error(String(err)));
}
return refMap;
}
private async processYamlFiles(
zipContent: JSZip,
folderName: string,
refMap: Map<string, boolean>,
versionMap: Map<string, boolean>,
processDependencies = true
): Promise<void> {
Logger.info(`Processing YAML files in folder: ${folderName}`);
await Promise.all(
Object.keys(zipContent.files).map(async (fileName) => {
const entry = zipContent.files[fileName];
if (entry && this.isYamlFileForFolder(entry, folderName)) {
await this.processYamlFile(entry, refMap, processDependencies, versionMap);
}
})
);
}
private async processYamlFile(
entry: JSZipObject,
refMap: Map<string, boolean>,
processDependencies: boolean,
versionMap: Map<string, boolean>
): Promise<void> {
Logger.info(`Processing YAML file: ${entry.name}`);
const content = await entry.async('string');
try {
const yamlContents = yaml.loadAll(content) as YamlContent[];
for (const yamlContent of yamlContents) {
if (
isValidAsset(yamlContent) &&
!AppConstants.IGNORE_ASSETS_DURING_DEPLOY.includes(yamlContent.kind.toLowerCase())
) {
this.updateReferenceMap(yamlContent, refMap, processDependencies, versionMap);
}
}
Logger.info(`Successfully processed YAML content in file: ${entry.name}`);
} catch (err) {
Logger.error(
`Error parsing YAML in file ${entry.name}`,
err instanceof Error ? err : new Error(String(err))
);
}
}
private updateReferenceMap(
yamlContent: YamlContent,
refMap: Map<string, boolean>,
processDependencies: boolean,
versionMap: Map<string, boolean>
): void {
if (processDependencies) {
this.extractKey(yamlContent, refMap, '$ref', versionMap, processRef);
}
updateMapWithMetadata(yamlContent, refMap);
}
private extractKey(
yamlContent: YamlContent,
refMap: Map<string, boolean>,
keyToExtract: string,
versionMap: Map<string, boolean>,
transformValue?: (value: string) => string
): void {
const extract = (obj: SpecObject) => {
for (const key in obj) {
const value = obj[key];
if (key === keyToExtract && typeof value === 'string') {
const transformedValue = transformValue ? transformValue(value) : value;
if (versionMap.has(value)) {
if (!refMap.has(value)) {
refMap.set(value, true);
}
} else {
if (!refMap.has(transformedValue)) {
refMap.set(transformedValue, false);
}
}
} else if (typeof value === 'object' && value !== null) {
extract(value);
}
}
};
const specOb = JSON.stringify(yamlContent.spec);
extract(yaml.load(specOb) as SpecObject);
}
public async createProjectPathReferenceMap(
buffer: Buffer,
folderName: string
): Promise<Map<string, boolean>> {
Logger.info(`Creating path reference map for folder: ${folderName}`);
const zipContent = await this.loadZipFromBuffer(buffer);
const refMap = new Map<string, boolean>();
try {
const obj = new BuildProjectAssets();
const versionMap = await obj.createVersionProcessingMap(buffer);
for (const fileName in zipContent.files) {
const entry = zipContent.files[fileName];
if (entry && this.isYamlFileForFolder(entry, folderName)) {
const content = await entry.async('string');
try {
const yamlContents = yaml.loadAll(content) as YamlContent[];
for (const yamlContent of yamlContents) {
if (isValidAsset(yamlContent)) {
this.extractKey(yamlContent, refMap, '$path', versionMap, path.normalize);
}
}
Logger.info(`Path extraction completed for file: ${fileName}`);
} catch (err) {
Logger.error(
`Error parsing YAML in file ${fileName}`,
err instanceof Error ? err : new Error(String(err))
);
}
}
}
} catch (err) {
Logger.error('Error loading ZIP', err instanceof Error ? err : new Error(String(err)));
}
return refMap;
}
private async validateApiSpecVaraible(buffer: Buffer, folderName: string): Promise<boolean> {
Logger.info(`Validating API Spec variable in folder: ${folderName}`);
const zipContent = await this.loadZipFromBuffer(buffer);
for (const fileName in zipContent.files) {
const entry = zipContent.files[fileName];
if (
entry &&
!entry.dir &&
fileName.startsWith(folderName + path.sep) &&
checkFileExtension(fileName)
) {
const content = await entry.async('string');
const isValid = await this.checkYamlContent(content, fileName);
if (!isValid) {
return false;
}
}
}
return true;
}
private async checkYamlContent(content: string, fileName: string): Promise<boolean> {
Logger.info(`Checking YAML content in file: ${fileName}`);
try {
const yamlContents = yaml.loadAll(content) as YamlContent[];
for (const yamlContent of yamlContents) {
if (isValidAsset(yamlContent) && yamlContent.kind?.toLowerCase() === 'api') {
const specObj = JSON.stringify(yamlContent.spec);
const apiSpec = yaml.load(specObj) as Api_Spec_Ref;
if (this.isInvalidApiSpec(apiSpec)) {
addErrorToResponse(
AppConstants.VALIDATION_ERROR_CODE,
fileName,
`Validation failed for api - spec field in file ${fileName} `
);
return false;
}
}
}
Logger.info(`Successfully validated YAML content in file: ${fileName}`);
} catch (err) {
Logger.error(
`Error parsing YAML in file ${fileName}`,
err instanceof Error ? err : new Error(String(err))
);
return false;
}
return true;
}
private isInvalidApiSpec(apiSpec: Api_Spec_Ref): boolean {
const apiSpecField = AppConstants.apiSpec;
const apiSpecPathLength = apiSpec[apiSpecField]?.$path?.length ?? 0;
return (
!apiSpec || !apiSpec[apiSpecField] || !apiSpec[apiSpecField].$path || apiSpecPathLength <= 0
);
}
public async validateProjectAssetReference(
buffer: Buffer,
folderName: string,
allFolderNames: Set<string>
): Promise<ReferenceValidationResult> {
Logger.info(`Validating project asset references in folder: ${folderName}`);
let refMap = new Map<string, boolean>();
try {
refMap = await this.createProjectAssetReferenceMap(buffer, folderName, allFolderNames);
const allRefsValid = Array.from(refMap.entries()).every(([key, value]) => {
if (!value) {
addErrorToResponse(
AppConstants.VALIDATION_ERROR_CODE,
key,
`Validation failed for reference ${key}`
);
}
return value;
});
if (!allRefsValid) {
Logger.error('Some references are not valid');
return {
isValid: false,
refMap,
errors: errorsArray.map((err) => err.description),
};
}
return { isValid: true, refMap, errors: [] };
} catch (err) {
Logger.error('Error validating asset', err instanceof Error ? err : new Error(String(err)));
return {
isValid: false,
refMap,
errors: errorsArray.map((err) => err.description),
};
}
}
public async validateProjectPathReference(
buffer: Buffer,
folderName: string,
filePathsInFolder: Set<string>
): Promise<boolean> {
Logger.info(`Validating project path references in folder: ${folderName}`);
try {
const refMap = await this.createProjectPathReferenceMap(buffer, folderName);
refMap.forEach((_, key) => {
if (filePathsInFolder.has(path.normalize(`${folderName}/${key}`))) {
refMap.set(key, true);
}
});
const allRefsValid = Array.from(refMap.entries()).every(([key, value]) => {
if (!value) {
Logger.error(`Validation failed for path ${key} in ${folderName}`);
addErrorToResponse(
AppConstants.VALIDATION_ERROR_CODE,
key,
`Validation failed for path ${key} in ${folderName}`
);
}
return value;
});
if (!allRefsValid) {
Logger.error('Some references are not valid');
return false;
}
return true;
} catch (err) {
Logger.error('Error validating asset', err instanceof Error ? err : new Error(String(err)));
return false;
}
}
public async validateDeploymentAsset(buffer: Buffer) {
try {
const zipContent = await this.loadZipFromBuffer(buffer);
Logger.debug(`Processing ${Object.keys(zipContent.files).length} files`);
for (const fileName in zipContent.files) {
const entry = zipContent.files[fileName];
if (entry && checkFileExtension(fileName)) {
const content = await entry.async('string');
const yamlContents = yaml.loadAll(content) as YamlContent[];
for (const yamlContent of yamlContents) {
if (isValidAsset(yamlContent)) {
return true;
}
}
}
}
} catch (err) {
addErrorToResponse(
AppConstants.VALIDATION_ERROR_CODE,
'ZIP_FILE',
`Error loading zip with minimum assets: ${err}`
);
}
addErrorToResponse(
AppConstants.VALIDATION_ERROR_CODE,
'ZIP_FILE',
'Error loading zip with minimum assets required for deploymnet'
);
return false;
}
public async validateProjectHasMinimumAssets(buffer: Buffer) {
Logger.info('Validating project has minimum assets');
return (await validateMinAssets(buffer)) && (await this.validateDeploymentAsset(buffer));
}
public async validateProjectApiSpecVariable(buffer: Buffer, folderName: string) {
Logger.info(`Validating project API spec variables in folder: ${folderName}`);
return this.validateApiSpecVaraible(buffer, folderName);
}
public async validateSoapApiGatewayRestriction(buffer: Buffer): Promise<boolean> {
Logger.info('Validating SOAP API gateway restrictions');
const result = await validateSoapGatewayRestriction(buffer);
if (!result.isValid && result.errors.length > 0) {
result.errors.forEach((error) => {
addErrorToResponse(AppConstants.VALIDATION_ERROR_CODE, 'SOAP_API_TO_GATEWAY', error);
});
}
return result.isValid;
}
}