document = json_decode(file_get_contents(dirname(__DIR__, 2) . '/docs/openapi/water-mini-v1.json'), true, 512, JSON_THROW_ON_ERROR); } public function data(string $name, mixed $data): bool { // Match wire JSON object/array distinctions rather than PHP associative arrays. $this->validate($this->document['components']['schemas'][$name], json_decode(json_encode($data, JSON_THROW_ON_ERROR)), $name); return true; } public function resolve(string $reference): array { if (!str_starts_with($reference, '#/')) { throw new RuntimeException('Only local schema references are expected: ' . $reference); } $node = $this->document; foreach (explode('/', substr($reference, 2)) as $part) { $node = $node[str_replace(['~1', '~0'], ['/', '~'], $part)] ?? null; if (!is_array($node)) { throw new RuntimeException('Unresolved reference: ' . $reference); } } return $node; } public function validate(array $schema, mixed $value, string $path = 'data'): void { if (isset($schema['$ref'])) { $this->validate($this->resolve($schema['$ref']), $value, $path); return; } if ($value === null && ($schema['nullable'] ?? false) && isset($schema['type'])) { return; } foreach ($schema['allOf'] ?? [] as $part) { $this->validate($part, $value, $path); } foreach (['oneOf', 'anyOf'] as $union) { if (!isset($schema[$union])) { continue; } $matches = 0; foreach ($schema[$union] as $part) { try { $this->validate($part, $value, $path); $matches++; } catch (RuntimeException) { } } if ($matches === 0 || ($union === 'oneOf' && $matches !== 1)) { throw new RuntimeException($path . ': invalid ' . $union); } } $type = $schema['type'] ?? null; $valid = match ($type) { 'object' => $value instanceof stdClass, 'array' => is_array($value), 'integer' => is_int($value), 'number' => is_int($value) || is_float($value), 'string' => is_string($value), 'boolean' => is_bool($value), null => true, default => false, }; if (!$valid || (isset($schema['enum']) && !in_array($value, $schema['enum'], true))) { throw new RuntimeException($path . ': expected ' . $type); } if ($type === 'object') { foreach ($schema['required'] ?? [] as $key) { if (!property_exists($value, $key)) { throw new RuntimeException($path . ': missing ' . $key); } } foreach (get_object_vars($value) as $key => $item) { $child = $schema['properties'][$key] ?? $schema['additionalProperties'] ?? true; if ($child === false) { throw new RuntimeException($path . ': unexpected ' . $key); } if (is_array($child)) { $this->validate($child, $item, $path . '.' . $key); } } } if ($type === 'array') { if (count($value) < ($schema['minItems'] ?? 0) || count($value) > ($schema['maxItems'] ?? PHP_INT_MAX)) { throw new RuntimeException($path . ': array length'); } if (($schema['uniqueItems'] ?? false) && count(array_unique(array_map('serialize', $value))) !== count($value)) { throw new RuntimeException($path . ': duplicate item'); } foreach ($value as $index => $item) { $this->validate($schema['items'], $item, $path . '[' . $index . ']'); } } if ($type === 'string') { if (mb_strlen($value) < ($schema['minLength'] ?? 0) || mb_strlen($value) > ($schema['maxLength'] ?? PHP_INT_MAX) || (isset($schema['pattern']) && !preg_match('~' . str_replace('~', '\\~', $schema['pattern']) . '~uD', $value))) { throw new RuntimeException($path . ': string constraint'); } } if (($type === 'integer' || $type === 'number') && ($value < ($schema['minimum'] ?? -INF) || $value > ($schema['maximum'] ?? INF))) { throw new RuntimeException($path . ': numeric range'); } } }