*/ protected array $spec; protected function loadOpenApiSpec(): void { $this->spec = (array) Yaml::parseFile(public_path('openapi.yaml')); } /** * The field names a schema promises, one level deep — enough for a flat * comparison against `array_keys()` on a decoded JSON object. * * @return array */ protected function documentedKeys(array $schema): array { return array_keys($this->resolve($schema)['properties'] ?? []); } /** * The documented fields that must always be present. * * Anything the specification marks `nullable` is left out of this half of * the check — that is the vocabulary this document already uses for a * field that may not be there, whether because the value itself can be * null or because it only appears down one particular route. The other * direction stays strict regardless: whatever is actually sent has to be * described, nullable or not. * * @return array */ protected function alwaysPresent(array $schema): array { $resolved = $this->resolve($schema); return array_keys(array_filter( (array) ($resolved['properties'] ?? []), fn (array $property) => ($property['nullable'] ?? false) !== true, )); } /** * The schema of one record inside a `{"data": ...}` envelope, whichever * way the endpoint wraps it — a bare object for a detail endpoint, or the * `items` of an array for a listing. * * @return array */ protected function recordSchema(string $path, string $method = 'get', int $status = 200): array { $schema = $this->resolve( $this->spec['paths'][$path][$method]['responses'][$status]['content']['application/json']['schema'] ?? [], ); $data = $this->resolve($schema['properties']['data'] ?? []); if (($data['type'] ?? null) === 'array') { $data = $this->resolve($data['items'] ?? []); } return $data; } /** * Everything documented for one path's response, `data` included — for * comparing against the envelope as a whole rather than only the record * inside it. * * @return array */ protected function responseSchema(string $path, string $method = 'get', int $status = 200): array { return $this->resolve( $this->spec['paths'][$path][$method]['responses'][$status]['content']['application/json']['schema'] ?? [], ); } /** * Follow `$ref` and flatten `allOf`, which is how the specification says * "this, plus a couple of fields" without repeating a schema. * * @param array $schema * @return array */ protected function resolve(array $schema): array { if (isset($schema['$ref'])) { $name = basename((string) $schema['$ref']); return $this->resolve((array) ($this->spec['components']['schemas'][$name] ?? [])); } if (isset($schema['allOf'])) { $merged = ['type' => 'object', 'properties' => []]; foreach ((array) $schema['allOf'] as $part) { $resolved = $this->resolve((array) $part); $merged['properties'] = [ ...$merged['properties'], ...($resolved['properties'] ?? []), ]; } return [...$schema, ...$merged]; } return $schema; } }