update
This commit is contained in:
36
README.md
36
README.md
@@ -1274,13 +1274,18 @@ a leer ese documento y no este código.
|
||||
|
||||
Dos cosas lo sujetan:
|
||||
|
||||
**Un test de contrato** ([`PublicApiContractTest`](tests/Feature/Api/PublicApiContractTest.php))
|
||||
que llama a cada endpoint público con datos sembrados y compara la respuesta con
|
||||
el esquema del `openapi.yaml`, clave a clave. La regla es asimétrica a
|
||||
propósito: **todo lo que la API manda tiene que estar documentado** —siempre—, y
|
||||
lo que el documento promete tiene que llegar en los endpoints de detalle, salvo
|
||||
lo que está marcado `nullable`, que es como este documento dice «puede no
|
||||
estar».
|
||||
**Dos tests de contrato**, uno por mitad de la API, comparten la comparación en
|
||||
sí ([`VerifiesOpenApiContract`](tests/VerifiesOpenApiContract.php)) porque la
|
||||
pregunta es la misma dos veces: si la forma que el documento promete es la que
|
||||
la API manda. Lo que cambia es todo lo que rodea la pregunta, no la
|
||||
comparación.
|
||||
|
||||
[`PublicApiContractTest`](tests/Feature/Api/PublicApiContractTest.php) llama a
|
||||
cada endpoint público con datos sembrados y compara la respuesta con el esquema
|
||||
del `openapi.yaml`, clave a clave. La regla es asimétrica a propósito: **todo lo
|
||||
que la API manda tiene que estar documentado** —siempre—, y lo que el documento
|
||||
promete tiene que llegar en los endpoints de detalle, salvo lo que está marcado
|
||||
`nullable`, que es como este documento dice «puede no estar».
|
||||
|
||||
Al escribirlo aparecieron cuatro desajustes que llevaban ahí un tiempo:
|
||||
`meta_title` y `meta_description` sin documentar en proyectos y servicios,
|
||||
@@ -1292,6 +1297,19 @@ era la API: el alt vive dentro de la imagen, que es donde lo necesita un `<img>`
|
||||
y dos nombres para el mismo texto es cómo un frontend acaba leyendo el que
|
||||
resulta ser el accidental.
|
||||
|
||||
[`AdminApiContractTest`](tests/Feature/Api/AdminApiContractTest.php) hace lo
|
||||
mismo con `/v1/*`, autenticado como `super_admin` para que un permiso de menos
|
||||
nunca se confunda con una forma equivocada, y añade una comprobación que la
|
||||
pública no necesita: el **sobre de paginación** (`links`, `meta`) de cada
|
||||
listado contra lo documentado, y que `/v1/roles` —el único listado que Laravel
|
||||
no pagina— no lleve ninguno de los dos. Escribirlo encontró cuatro desajustes
|
||||
más: `featured_image_alt` sin documentar en absoluto en la respuesta de página
|
||||
(la petición de escritura sí lo tenía, con otra forma), `permissions` de
|
||||
usuario documentado solo en prosa en vez de con `nullable: true`, y el sobre de
|
||||
paginación real de Laravel —que manda `from`, `to`, `path` y un `links` de
|
||||
primer nivel— documentado con la mitad de esas claves y sin ese `links` en
|
||||
absoluto.
|
||||
|
||||
**Tipos generados** para el otro lado:
|
||||
|
||||
```bash
|
||||
@@ -1346,7 +1364,7 @@ php artisan site:maintenance --up
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
php artisan test # 391 tests, 1121 aserciones
|
||||
php artisan test # 394 tests, 1128 aserciones
|
||||
```
|
||||
|
||||
En cada push y cada pull request, [`.github/workflows/tests.yml`](.github/workflows/tests.yml)
|
||||
@@ -1569,7 +1587,7 @@ docker compose -f docker-compose.test.yml run --rm test
|
||||
|
||||
Levanta un Postgres en memoria, corre la suite entera contra él y lo tira. Es la
|
||||
comprobación que convierte «debería funcionar» en «funciona», y conviene hacerla
|
||||
antes del primer despliegue. **391 tests en verde contra Postgres 17.**
|
||||
antes del primer despliegue. **394 tests en verde contra Postgres 17.**
|
||||
|
||||
Usa una etapa propia de la imagen: la de producción se construye con `--no-dev`
|
||||
y ahí no está ni el runner de tests.
|
||||
|
||||
@@ -1198,6 +1198,7 @@ paths:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/User" }
|
||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
@@ -2075,8 +2076,16 @@ components:
|
||||
deleted_at: { type: string, format: date-time, nullable: true }
|
||||
category: { $ref: "#/components/schemas/Category" }
|
||||
author: { $ref: "#/components/schemas/User" }
|
||||
featured_image_alt:
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
What the image shows, resolved into the language above — unlike
|
||||
`PageWrite`'s field of the same name, which carries every
|
||||
language at once.
|
||||
translations:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Present only when `?with=translations` was passed.
|
||||
additionalProperties:
|
||||
type: object
|
||||
@@ -2128,6 +2137,13 @@ components:
|
||||
pages_count: { type: integer }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
translations:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Present only when `?with=translations` was passed.
|
||||
additionalProperties:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
|
||||
CategoryWrite:
|
||||
type: object
|
||||
@@ -2160,6 +2176,7 @@ components:
|
||||
permissions:
|
||||
type: array
|
||||
items: { type: string }
|
||||
nullable: true
|
||||
description: Effective local permissions. Returned by `/v1/me` only.
|
||||
remote_roles:
|
||||
type: array
|
||||
@@ -2221,18 +2238,45 @@ components:
|
||||
|
||||
PaginationMeta:
|
||||
type: object
|
||||
description: |
|
||||
Laravel's own paginator shape, sent as-is — nothing on this side
|
||||
trims it down, so this describes exactly what `->paginate()` sends
|
||||
rather than a curated subset of it.
|
||||
properties:
|
||||
current_page: { type: integer }
|
||||
from: { type: integer, nullable: true, description: Null on an empty page. }
|
||||
last_page: { type: integer }
|
||||
links:
|
||||
type: array
|
||||
description: One entry per page number shown by a "1 2 3 … 9" control, plus prev/next.
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
url: { type: string, nullable: true }
|
||||
label: { type: string, description: "Already translated — e.g. «Anterior», not «Previous»." }
|
||||
page: { type: integer, nullable: true }
|
||||
active: { type: boolean }
|
||||
path: { type: string }
|
||||
per_page: { type: integer }
|
||||
to: { type: integer, nullable: true, description: Null on an empty page. }
|
||||
total: { type: integer }
|
||||
|
||||
PaginationLinks:
|
||||
type: object
|
||||
description: The four addresses a client actually navigates with, ready to use verbatim.
|
||||
properties:
|
||||
first: { type: string }
|
||||
last: { type: string }
|
||||
prev: { type: string, nullable: true }
|
||||
next: { type: string, nullable: true }
|
||||
|
||||
PageCollection:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Page" }
|
||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||
|
||||
CategoryCollection:
|
||||
@@ -2241,4 +2285,5 @@ components:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Category" }
|
||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||
|
||||
179
tests/Feature/Api/AdminApiContractTest.php
Normal file
179
tests/Feature/Api/AdminApiContractTest.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Page;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
use Tests\VerifiesOpenApiContract;
|
||||
|
||||
/**
|
||||
* The administration API answers with what the specification says it does —
|
||||
* `PublicApiContractTest`'s other half.
|
||||
*
|
||||
* The frontend reads the public document from another repository, in a
|
||||
* framework not yet chosen; whoever writes it will never see this code, only
|
||||
* the specification. That case does not apply here — an admin API caller is
|
||||
* inside this codebase, or at least inside this organisation — but the
|
||||
* underlying failure is identical: a field renamed in a `Http\Resources\*`
|
||||
* class is a change nothing else in this suite notices, because every other
|
||||
* test exercises behaviour, not shape.
|
||||
*
|
||||
* Unlike the public listings, an admin index and its matching show endpoint
|
||||
* send the same full record — none of the four resources here has a
|
||||
* "lightweight for a listing" projection — so both are held to the stricter
|
||||
* rule throughout: everything documented and not `nullable` must be present.
|
||||
*/
|
||||
class AdminApiContractTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase, VerifiesOpenApiContract;
|
||||
|
||||
protected User $caller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->loadOpenApiSpec();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
// super_admin, so a permission gap never masquerades as a shape
|
||||
// mismatch: every request here must reach the resource, not a 403.
|
||||
$this->caller = User::factory()->create();
|
||||
$this->caller->syncRoles(['super_admin']);
|
||||
|
||||
$this->withHeaders($this->apiHeadersFor($this->caller));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{path: string}>
|
||||
*/
|
||||
public static function paginatedEndpoints(): array
|
||||
{
|
||||
return [
|
||||
'pages' => ['path' => '/v1/pages'],
|
||||
'categories' => ['path' => '/v1/categories'],
|
||||
'users' => ['path' => '/v1/users'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function every_admin_record_answers_in_the_shape_it_documents(): void
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
$category = Category::factory()->create();
|
||||
|
||||
// One request, one documented path — the URL a real caller hits and
|
||||
// the path key that describes it, side by side rather than derived
|
||||
// from one another, because guessing the second from the first is
|
||||
// exactly the kind of cleverness that breaks quietly.
|
||||
$cases = [
|
||||
'pages' => ['/v1/pages', '/v1/pages'],
|
||||
'page' => ["/v1/pages/{$page->id}", '/v1/pages/{page}'],
|
||||
'categories' => ['/v1/categories', '/v1/categories'],
|
||||
'category' => ["/v1/categories/{$category->id}", '/v1/categories/{category}'],
|
||||
'users' => ['/v1/users', '/v1/users'],
|
||||
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
||||
'me' => ['/v1/me', '/v1/me'],
|
||||
'roles' => ['/v1/roles', '/v1/roles'],
|
||||
];
|
||||
|
||||
$problems = [];
|
||||
|
||||
foreach ($cases as $name => [$url, $documentedPath]) {
|
||||
$response = $this->getJson('/api'.$url);
|
||||
|
||||
if ($response->status() !== 200) {
|
||||
$problems[] = "{$name}: answered {$response->status()}";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = $response->json('data');
|
||||
$record = array_is_list((array) $payload) ? ($payload[0] ?? null) : $payload;
|
||||
|
||||
if (! is_array($record)) {
|
||||
$problems[] = "{$name}: no record to compare against";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$schema = $this->recordSchema($documentedPath);
|
||||
|
||||
foreach (array_diff(array_keys($record), $this->documentedKeys($schema)) as $undocumented) {
|
||||
$problems[] = "{$name}: sends `{$undocumented}`, which the specification does not describe";
|
||||
}
|
||||
|
||||
foreach (array_diff($this->alwaysPresent($schema), array_keys($record)) as $missing) {
|
||||
$problems[] = "{$name}: documents `{$missing}`, which it does not send";
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $problems, "The admin API and its specification disagree:\n".implode("\n", $problems));
|
||||
}
|
||||
|
||||
/**
|
||||
* The pagination envelope every listing here uses — `data`, `links`,
|
||||
* `meta` — checked the same way the record inside it is: nothing sent
|
||||
* that is not documented.
|
||||
*
|
||||
* Worth its own test rather than folding into the one above: the record
|
||||
* comparison already proves the endpoints work at all, and a failure here
|
||||
* points at a completely different place to fix — the collection schema,
|
||||
* not a `Resource` class.
|
||||
*/
|
||||
#[Test]
|
||||
public function the_pagination_envelope_matches_what_is_documented(): void
|
||||
{
|
||||
Page::factory()->create();
|
||||
Category::factory()->create();
|
||||
|
||||
$problems = [];
|
||||
|
||||
foreach (self::paginatedEndpoints() as $name => $endpoint) {
|
||||
$response = $this->getJson('/api'.$endpoint['path'])->assertOk();
|
||||
|
||||
$envelope = $this->responseSchema($endpoint['path']);
|
||||
$documented = $this->documentedKeys($envelope);
|
||||
|
||||
foreach (array_diff(array_keys($response->json()), $documented) as $undocumented) {
|
||||
$problems[] = "{$name}: envelope sends `{$undocumented}`, which the specification does not describe";
|
||||
}
|
||||
|
||||
$metaSchema = $this->resolve($envelope['properties']['meta'] ?? []);
|
||||
$meta = (array) $response->json('meta');
|
||||
|
||||
foreach (array_diff(array_keys($meta), $this->documentedKeys($metaSchema)) as $undocumented) {
|
||||
$problems[] = "{$name}: meta sends `{$undocumented}`, which the specification does not describe";
|
||||
}
|
||||
|
||||
foreach (array_diff($this->alwaysPresent($metaSchema), array_keys($meta)) as $missing) {
|
||||
$problems[] = "{$name}: meta documents `{$missing}`, which it does not send";
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $problems, "The pagination envelope and its specification disagree:\n".implode("\n", $problems));
|
||||
}
|
||||
|
||||
/**
|
||||
* `/v1/roles` is the one listing here Laravel never paginates —
|
||||
* `RoleController::index()` calls `->get()`, not `->paginate()` — so
|
||||
* proving it carries no stray `meta`/`links` is the other half of the
|
||||
* envelope test above.
|
||||
*/
|
||||
#[Test]
|
||||
public function the_unpaginated_roles_listing_carries_no_pagination_envelope(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/roles')->assertOk();
|
||||
|
||||
$this->assertSame(['data'], array_keys($response->json()));
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ use App\Models\Technology;
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Tests\TestCase;
|
||||
use Tests\VerifiesOpenApiContract;
|
||||
|
||||
/**
|
||||
* The public API answers with what the specification says it answers with.
|
||||
@@ -34,12 +34,7 @@ use Tests\TestCase;
|
||||
*/
|
||||
class PublicApiContractTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $spec;
|
||||
use RefreshDatabase, VerifiesOpenApiContract;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -47,7 +42,7 @@ class PublicApiContractTest extends TestCase
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
|
||||
$this->spec = (array) Yaml::parseFile(public_path('openapi.yaml'));
|
||||
$this->loadOpenApiSpec();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,14 +166,15 @@ class PublicApiContractTest extends TestCase
|
||||
continue;
|
||||
}
|
||||
|
||||
$documented = $this->documentedKeys($endpoint['path']);
|
||||
$schema = $this->recordSchema($endpoint['path']);
|
||||
$documented = $this->documentedKeys($schema);
|
||||
|
||||
foreach (array_diff(array_keys($record), $documented) as $undocumented) {
|
||||
$problems[] = "{$name}: sends `{$undocumented}`, which the specification does not describe";
|
||||
}
|
||||
|
||||
if ($endpoint['detail']) {
|
||||
foreach (array_diff($this->alwaysPresent($endpoint['path']), array_keys($record)) as $missing) {
|
||||
foreach (array_diff($this->alwaysPresent($schema), array_keys($record)) as $missing) {
|
||||
$problems[] = "{$name}: documents `{$missing}`, which it does not send";
|
||||
}
|
||||
}
|
||||
@@ -186,90 +182,4 @@ class PublicApiContractTest extends TestCase
|
||||
|
||||
$this->assertSame([], $problems, "The public API and its specification disagree:\n".implode("\n", $problems));
|
||||
}
|
||||
|
||||
/**
|
||||
* The field names the specification promises for one endpoint's records.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function documentedKeys(string $path): array
|
||||
{
|
||||
return array_keys($this->recordSchema($path)['properties'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The schema of a single record, whichever way the endpoint wraps it.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function recordSchema(string $path): array
|
||||
{
|
||||
$schema = $this->resolve(
|
||||
$this->spec['paths'][$path]['get']['responses'][200]['content']['application/json']['schema'] ?? [],
|
||||
);
|
||||
|
||||
$data = $this->resolve($schema['properties']['data'] ?? []);
|
||||
|
||||
// A listing documents its records under `items`; a detail endpoint
|
||||
// describes them directly.
|
||||
if (($data['type'] ?? null) === 'array') {
|
||||
$data = $this->resolve($data['items'] ?? []);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* The documented fields a detail endpoint must always answer with.
|
||||
*
|
||||
* 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 — a member's `role` exists only while they are
|
||||
* nested under a project, and the schema says so. The other direction
|
||||
* stays strict for every field either way: whatever the API sends has to
|
||||
* be described, nullable or not.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function alwaysPresent(string $path): array
|
||||
{
|
||||
$record = $this->recordSchema($path);
|
||||
|
||||
return array_keys(array_filter(
|
||||
(array) ($record['properties'] ?? []),
|
||||
fn (array $property) => ($property['nullable'] ?? false) !== true,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
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