/** * Mapping Validator * * Validates InputMapping values for publish validation. * Checks expression syntax, secret reference validity, and type compatibility. */ import type { PublishError, MappingValue, InputMapping } from '../types'; import { isExpr, isSecretRef } from '../types'; import { validateExpressionSource } from '../expressionEngine'; import { secretNameSchema } from '../../secrets'; /** * Options for mapping validation. */ export interface MappingValidationOptions { /** * Step path for error reporting (e.g., "root.steps[0]") */ stepPath: string; /** * Step ID for error reporting */ stepId: string; /** * Field name being validated (e.g., "inputMapping") */ fieldName: string; /** * Optional set of known secret names to validate against. * If provided, will warn about references to unknown secrets. */ knownSecrets?: Set; /** * Optional action input schema to validate required fields. */ requiredFields?: string[]; } /** * Result of mapping validation. */ export interface MappingValidationResult { errors: PublishError[]; warnings: PublishError[]; /** * Set of secret names referenced in the mapping. * Useful for checking if all secrets exist before publishing. */ secretRefs: Set; } type JsonSchema = Record; const isObjectLike = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); /** * Validate a single MappingValue. * * @param value - The value to validate * @param keyPath - The JSON path to this value (e.g., "inputMapping.to") * @param options - Validation options * @param result - Result accumulator */ function validateMappingValue( value: MappingValue, keyPath: string, options: MappingValidationOptions, result: MappingValidationResult ): void { // Handle expressions: { $expr: "..." } if (isExpr(value)) { if (!value.$expr || value.$expr.trim() === '') { result.errors.push({ severity: 'error', stepPath: options.stepPath, stepId: options.stepId, code: 'EMPTY_EXPRESSION', message: `Empty expression at ${keyPath}` }); return; } try { validateExpressionSource(value.$expr); } catch (error) { result.errors.push({ severity: 'error', stepPath: options.stepPath, stepId: options.stepId, code: 'INVALID_EXPRESSION', message: `Invalid expression at ${keyPath}: ${error instanceof Error ? error.message : 'Unknown error'}` }); } return; } // Handle secret references: { $secret: "SECRET_NAME" } if (isSecretRef(value)) { const secretName = value.$secret; // Validate secret name format const nameValidation = secretNameSchema.safeParse(secretName); if (!nameValidation.success) { result.errors.push({ severity: 'error', stepPath: options.stepPath, stepId: options.stepId, code: 'INVALID_SECRET_NAME', message: `Invalid secret name format at ${keyPath}: ${nameValidation.error.issues[0]?.message ?? 'Invalid format'}` }); return; } // Track the secret reference result.secretRefs.add(secretName); // Warn if secret is not in the known list (optional check) if (options.knownSecrets && !options.knownSecrets.has(secretName)) { result.warnings.push({ severity: 'warning', stepPath: options.stepPath, stepId: options.stepId, code: 'UNKNOWN_SECRET', message: `Secret "${secretName}" referenced at ${keyPath} may not exist` }); } return; } // Handle literal values (recursively validate nested objects/arrays) if (Array.isArray(value)) { value.forEach((item, index) => { validateMappingValue(item as MappingValue, `${keyPath}[${index}]`, options, result); }); return; } if (value !== null && typeof value === 'object') { // Check for invalid special keys const obj = value as Record; for (const key of Object.keys(obj)) { if (key.startsWith('$') && key !== '$expr' && key !== '$secret') { result.warnings.push({ severity: 'warning', stepPath: options.stepPath, stepId: options.stepId, code: 'UNKNOWN_SPECIAL_KEY', message: `Unknown special key "${key}" at ${keyPath}.${key} - did you mean $expr or $secret?` }); } validateMappingValue(obj[key] as MappingValue, `${keyPath}.${key}`, options, result); } return; } // Primitive literals (string, number, boolean, null) are always valid } function resolveSchemaRef(schema: JsonSchema, root: JsonSchema): JsonSchema { if (!schema || typeof schema !== 'object') return schema; const ref = (schema as { $ref?: string }).$ref; if (!ref || !root || typeof root !== 'object') return schema; if (!ref.startsWith('#/definitions/')) return schema; const key = ref.split('/').pop() ?? ''; const definitions = (root as { definitions?: Record }).definitions; const resolved = definitions?.[key]; if (!resolved) return schema; return resolveSchemaRef(resolved, root); } function mergeAllOf(schema: JsonSchema, root: JsonSchema): JsonSchema { const allOf = (schema as { allOf?: JsonSchema[] }).allOf; if (!Array.isArray(allOf) || allOf.length === 0) return schema; const base: JsonSchema = { ...schema }; delete (base as { allOf?: JsonSchema[] }).allOf; const merged: JsonSchema = { ...base }; for (const part of allOf) { const resolved = resolveSchemaRef(part, root); const mergedResolved = mergeAllOf(resolved, root); const props = (mergedResolved as { properties?: Record }).properties; if (props) { merged.properties = { ...(merged as { properties?: Record }).properties, ...props }; } const required = (mergedResolved as { required?: string[] }).required; if (required) { merged.required = Array.from(new Set([ ...((merged as { required?: string[] }).required ?? []), ...required ])); } } return merged; } function normalizeSchema(schema: JsonSchema, root: JsonSchema): JsonSchema { const resolved = resolveSchemaRef(schema, root); const merged = mergeAllOf(resolved, root); return merged; } function isObjectSchema(schema: JsonSchema): boolean { const type = (schema as { type?: string | string[] }).type; if (type === 'object') return true; if (Array.isArray(type) && type.includes('object')) return true; return !!(schema as { properties?: Record }).properties; } function shouldRecurseIntoValue(value: unknown): value is Record { if (!isObjectLike(value)) return false; if (isExpr(value) || isSecretRef(value)) return false; if (Array.isArray(value)) return false; return true; } function validateRequiredAgainstSchema( mapping: InputMapping | undefined, schema: JsonSchema | undefined, options: MappingValidationOptions ): PublishError[] { const errors: PublishError[] = []; if (!schema) return errors; const rootSchema = schema; const rootMapping = mapping ?? {}; const validateObject = (currentSchema: JsonSchema, currentMapping: Record, pathPrefix: string) => { const normalized = normalizeSchema(currentSchema, rootSchema); if (!isObjectSchema(normalized)) return; const properties = (normalized as { properties?: Record }).properties ?? {}; const required = (normalized as { required?: string[] }).required ?? []; for (const key of required) { if (!Object.prototype.hasOwnProperty.call(currentMapping, key)) { errors.push({ severity: 'error', stepPath: options.stepPath, stepId: options.stepId, code: 'MISSING_REQUIRED_MAPPING', message: `Required field "${pathPrefix}.${key}" is not mapped in ${options.fieldName}` }); } } for (const [key, propSchema] of Object.entries(properties)) { if (!Object.prototype.hasOwnProperty.call(currentMapping, key)) continue; const value = currentMapping[key]; if (!shouldRecurseIntoValue(value)) continue; validateObject(propSchema, value, `${pathPrefix}.${key}`); } }; validateObject(rootSchema, rootMapping as Record, options.fieldName); return errors; } /** * Validate an InputMapping. * * @param mapping - The mapping to validate * @param options - Validation options * @returns Validation result with errors, warnings, and secret references */ export function validateInputMapping( mapping: InputMapping | undefined, options: MappingValidationOptions ): MappingValidationResult { const result: MappingValidationResult = { errors: [], warnings: [], secretRefs: new Set() }; if (!mapping) { // Check if there are required fields that aren't mapped if (options.requiredFields && options.requiredFields.length > 0) { for (const field of options.requiredFields) { result.errors.push({ severity: 'error', stepPath: options.stepPath, stepId: options.stepId, code: 'MISSING_REQUIRED_MAPPING', message: `Required field "${field}" is not mapped in ${options.fieldName}` }); } } return result; } // Check for required fields if (options.requiredFields) { const mappedFields = new Set(Object.keys(mapping)); for (const field of options.requiredFields) { if (!mappedFields.has(field)) { result.errors.push({ severity: 'error', stepPath: options.stepPath, stepId: options.stepId, code: 'MISSING_REQUIRED_MAPPING', message: `Required field "${field}" is not mapped in ${options.fieldName}` }); } } } // Validate each mapping entry for (const [key, value] of Object.entries(mapping)) { const keyPath = `${options.fieldName}.${key}`; validateMappingValue(value, keyPath, options, result); } return result; } /** * Validate required mappings against a JSON schema (deep required fields). * Only enforces nested required fields when the parent is mapped as an object literal. */ export function validateInputMappingSchema( mapping: InputMapping | undefined, schema: JsonSchema | undefined, options: MappingValidationOptions ): PublishError[] { return validateRequiredAgainstSchema(mapping, schema, options); } /** * Collect all secret references from a mapping (non-validating). * Useful for quick extraction without full validation. * * @param mapping - The mapping to scan * @returns Set of secret names referenced */ export function collectSecretRefs(mapping: InputMapping | undefined): Set { const refs = new Set(); if (!mapping) return refs; const scan = (value: unknown): void => { if (!value || typeof value !== 'object') return; if (isSecretRef(value as MappingValue)) { refs.add((value as { $secret: string }).$secret); return; } if (Array.isArray(value)) { value.forEach(scan); return; } Object.values(value as Record).forEach(scan); }; Object.values(mapping).forEach(scan); return refs; } /** * Collect all secret references from an arbitrary config object. * Scans deeply for { $secret: "..." } patterns. * * @param config - The config object to scan * @returns Set of secret names referenced */ export function collectSecretRefsFromConfig(config: unknown): Set { const refs = new Set(); const scan = (value: unknown): void => { if (!value || typeof value !== 'object') return; if (isSecretRef(value as MappingValue)) { refs.add((value as { $secret: string }).$secret); return; } if (Array.isArray(value)) { value.forEach(scan); return; } Object.values(value as Record).forEach(scan); }; scan(config); return refs; }