* * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Enterprise * INGROUP: MokoStandards.Enterprise * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /lib/Enterprise/ConfigValidator.php * BRIEF: Validate project config against plugin JSON schema */ declare(strict_types=1); namespace MokoEnterprise; /** * Configuration Validator * * Validates moko-platform configuration files (YAML, JSON, HCL) * against expected schemas and reports errors. * * @since 04.00.00 */ class ConfigValidator { /** @var array */ private array $errors = []; /** @var array */ private array $warnings = []; /** * Validate config data against a JSON schema. * * @param array $config Config to validate * @param array $schema JSON Schema definition * @return bool True if valid */ public function validate(array $config, array $schema): bool { $this->errors = []; $this->warnings = []; $this->validateNode($config, $schema, ''); return empty($this->errors); } /** @return array */ public function getErrors(): array { return $this->errors; } /** @return array */ public function getWarnings(): array { return $this->warnings; } /** * @param mixed $data * @param array $schema */ private function validateNode( mixed $data, array $schema, string $path ): void { $type = $schema['type'] ?? null; if ($type !== null && !$this->checkType($data, $type)) { $actual = gettype($data); $this->errors[] = $path === '' ? "Root must be {$type}, got {$actual}" : "{$path}: expected {$type}, got {$actual}"; return; } if ($type === 'object') { $this->validateObject($data, $schema, $path); } if ($type === 'array' && isset($schema['items'])) { $this->validateArray($data, $schema, $path); } if (isset($schema['enum'])) { $this->validateEnum($data, $schema['enum'], $path); } if ($type === 'string') { $this->validateString($data, $schema, $path); } if ($type === 'integer' || $type === 'number') { $this->validateNumber($data, $schema, $path); } } /** * @param array $data * @param array $schema */ private function validateObject( array $data, array $schema, string $path ): void { $properties = $schema['properties'] ?? []; $required = $schema['required'] ?? []; foreach ($required as $field) { if (!array_key_exists($field, $data)) { $fieldPath = $path === '' ? $field : "{$path}.{$field}"; $this->errors[] = "{$fieldPath}: required field missing"; } } foreach ($properties as $field => $fieldSchema) { if (!array_key_exists($field, $data)) { continue; } $fieldPath = $path === '' ? $field : "{$path}.{$field}"; $this->validateNode($data[$field], $fieldSchema, $fieldPath); } $known = array_keys($properties); foreach (array_keys($data) as $field) { if (!in_array($field, $known, true)) { $fieldPath = $path === '' ? $field : "{$path}.{$field}"; $this->warnings[] = "{$fieldPath}: unknown property"; } } } /** * @param array $data * @param array $schema */ private function validateArray( array $data, array $schema, string $path ): void { $itemSchema = $schema['items']; foreach ($data as $i => $item) { $this->validateNode( $item, $itemSchema, "{$path}[{$i}]" ); } if ( isset($schema['minItems']) && count($data) < $schema['minItems'] ) { $this->errors[] = "{$path}: " . "needs at least {$schema['minItems']} items"; } } /** * @param mixed $data * @param array $allowed */ private function validateEnum( mixed $data, array $allowed, string $path ): void { if (!in_array($data, $allowed, true)) { $values = implode(', ', $allowed); $label = $path ?: 'value'; $this->errors[] = "{$label}: " . "'{$data}' not in [{$values}]"; } } /** * @param array $schema */ private function validateString( mixed $data, array $schema, string $path ): void { if (!is_string($data)) { return; } if ( isset($schema['minLength']) && strlen($data) < $schema['minLength'] ) { $this->errors[] = "{$path}: " . "too short (min {$schema['minLength']})"; } if ( isset($schema['pattern']) && !preg_match('/' . $schema['pattern'] . '/', $data) ) { $this->errors[] = "{$path}: " . "does not match pattern {$schema['pattern']}"; } } /** * @param array $schema */ private function validateNumber( mixed $data, array $schema, string $path ): void { if (!is_numeric($data)) { return; } if (isset($schema['minimum']) && $data < $schema['minimum']) { $this->errors[] = "{$path}: " . "below minimum {$schema['minimum']}"; } if (isset($schema['maximum']) && $data > $schema['maximum']) { $this->errors[] = "{$path}: " . "above maximum {$schema['maximum']}"; } } private function checkType(mixed $data, string $type): bool { return match ($type) { 'object' => is_array($data), 'array' => is_array($data) && array_is_list($data), 'string' => is_string($data), 'integer' => is_int($data), 'number' => is_int($data) || is_float($data), 'boolean' => is_bool($data), 'null' => is_null($data), default => true, }; } }