*/ protected array $spec; protected function setUp(): void { parent::setUp(); $this->withHeaders(['Accept-Language' => 'es']); $this->spec = (array) Yaml::parseFile(public_path('openapi.yaml')); } /** * Enough of everything for each endpoint to answer with a full record * rather than an empty list, which would prove nothing at all. */ protected function seedTheSite(): void { $client = Client::factory()->create(); $project = Project::factory()->published()->create([ 'client_id' => $client->id, 'title' => ['es' => 'Mi proyecto'], 'slug' => ['es' => 'mi-proyecto'], ]); Page::factory()->published()->create([ 'title' => ['es' => 'Quiénes somos'], 'slug' => ['es' => 'quienes-somos'], ]); Member::factory()->create([ 'name' => 'Ada Lovelace', 'slug' => 'ada-lovelace', 'is_public' => true, ]); Call::factory()->create([ 'project_id' => $project->id, 'title' => ['es' => 'Backend'], 'slug' => ['es' => 'backend'], ]); Service::factory()->create([ 'title' => ['es' => 'Desarrollo'], 'slug' => ['es' => 'desarrollo'], ]); Testimonial::factory()->create(); Talk::factory()->create(); // Attached to the project on purpose: `/technologies` lists only what // is actually in use, so a loose one would leave that endpoint with // nothing to compare against. $project->technologies()->attach(Technology::factory()->create()); } /** * Endpoint => whether it answers with one whole record. * * A detail endpoint returns the full shape, so every documented field has * to be there. A listing legitimately leaves out the heavy ones — the * specification says as much, field by field — so it is only held to the * rule that everything it does send is documented. * * @return array */ public static function endpoints(): array { return [ 'projects' => ['path' => '/public/v1/projects', 'detail' => false], 'project' => ['path' => '/public/v1/projects/{slug}', 'detail' => true], 'pages' => ['path' => '/public/v1/pages', 'detail' => false], 'page' => ['path' => '/public/v1/pages/{slug}', 'detail' => true], 'members' => ['path' => '/public/v1/members', 'detail' => false], 'member' => ['path' => '/public/v1/members/{slug}', 'detail' => true], 'calls' => ['path' => '/public/v1/calls', 'detail' => false], 'call' => ['path' => '/public/v1/calls/{slug}', 'detail' => true], 'services' => ['path' => '/public/v1/services', 'detail' => false], 'service' => ['path' => '/public/v1/services/{slug}', 'detail' => true], 'clients' => ['path' => '/public/v1/clients', 'detail' => false], 'testimonials' => ['path' => '/public/v1/testimonials', 'detail' => false], 'talks' => ['path' => '/public/v1/talks', 'detail' => false], 'technologies' => ['path' => '/public/v1/technologies', 'detail' => false], ]; } /** * The URLs the seeded records actually live at. * * @return array */ protected function slugs(): array { return [ '/public/v1/projects/{slug}' => '/public/v1/projects/mi-proyecto', '/public/v1/pages/{slug}' => '/public/v1/pages/quienes-somos', '/public/v1/members/{slug}' => '/public/v1/members/ada-lovelace', '/public/v1/calls/{slug}' => '/public/v1/calls/backend', '/public/v1/services/{slug}' => '/public/v1/services/desarrollo', ]; } #[Test] public function every_public_endpoint_answers_in_the_shape_it_documents(): void { $this->seedTheSite(); $problems = []; foreach (self::endpoints() as $name => $endpoint) { $url = $this->slugs()[$endpoint['path']] ?? $endpoint['path']; $response = $this->getJson('/api'.$url); if ($response->status() !== 200) { $problems[] = "{$name}: answered {$response->status()}"; continue; } $payload = $response->json('data'); // A listing that came back empty would pass every check below // without having compared anything. $record = array_is_list((array) $payload) ? ($payload[0] ?? null) : $payload; if (! is_array($record)) { $problems[] = "{$name}: no record to compare against"; continue; } $documented = $this->documentedKeys($endpoint['path']); 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) { $problems[] = "{$name}: documents `{$missing}`, which it does not send"; } } } $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 */ 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 */ 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 */ 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 $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; } }