update
This commit is contained in:
131
tests/VerifiesOpenApiContract.php
Normal file
131
tests/VerifiesOpenApiContract.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* Compares an actual JSON response against what `public/openapi.yaml` says
|
||||
* it should look like.
|
||||
*
|
||||
* Shared between the public and the admin contract tests because the
|
||||
* question is the same one twice: does the shape the document promises match
|
||||
* the shape the API sends. What differs between the two is everything
|
||||
* *around* the question — how a caller authenticates, what a listing is
|
||||
* allowed to leave out — not the comparison itself.
|
||||
*/
|
||||
trait VerifiesOpenApiContract
|
||||
{
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $schema
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user