Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log.

## [Unreleased]

- [PR-44](https://github.com/itk-dev/event-database-api/pull/44)
Validate deep payload schemas (nested objects, field types) for every resource
- [PR-43](https://github.com/itk-dev/event-database-api/pull/43)
Assert filter identities (not counts), sort order, and pagination edge cases
- [PR-42](https://github.com/itk-dev/event-database-api/pull/42)
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"require-dev": {
"ergebnis/composer-normalize": "^2.47",
"friendsofphp/php-cs-fixer": "^3.86",
"justinrainbow/json-schema": "^6.10",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-strict-rules": "^2.0",
Expand Down
16 changes: 8 additions & 8 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

112 changes: 112 additions & 0 deletions tests/ApiPlatform/Contract/ContractSchemaTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

namespace App\Tests\ApiPlatform\Contract;

use App\Tests\ApiPlatform\AbstractApiTestCase;
use JsonSchema\Constraints\Factory;
use JsonSchema\SchemaStorage;
use JsonSchema\Validator;
use PHPUnit\Framework\Attributes\DataProvider;

/**
* Validate the deep payload of every resource — nested objects and their field
* types — against a hand-authored JSON Schema (tests/schemas/contract.schema.json).
*
* This is the delta over CollectionContractTest, which only pins the top-level
* member field set. Here a removed/renamed nested field (e.g. organizer.email)
* or a changed type fails, while adding a field passes (the schema is
* additive-tolerant). Together with the OpenAPI diff gate this is what protects
* consumers through the API Platform upgrade — API Platform's self-generated
* item/collection schema is near-empty (@id/@var/@context only), so it cannot.
*
* D6: the five "nested" resources return a hydra:Collection with a single
* member on their item endpoint, so both collection and item members are
* validated against the same definition; Tag/Vocabulary return true items.
*/
class ContractSchemaTest extends AbstractApiTestCase
{
private const SCHEMA_URI = 'internal://contract.schema.json';
private const SCHEMA_PATH = __DIR__.'/../../schemas/contract.schema.json';

#[DataProvider('collectionProvider')]
public function testCollectionMembersMatchSchema(string $path, string $definition): void
{
$data = $this->get([], $path)->toArray();
self::assertNotEmpty($data['hydra:member'], $path.' needs at least one fixture member');

foreach ($data['hydra:member'] as $i => $member) {
$this->assertMatchesDefinition($member, $definition, $path.' member #'.$i);
}
}

#[DataProvider('itemProvider')]
public function testItemMatchesSchema(string $path, string $definition, bool $collectionWrapped): void
{
$data = $this->get([], $path)->toArray();

if ($collectionWrapped) {
self::assertArrayHasKey('hydra:member', $data, $path.' should return a collection wrapper (D6)');
self::assertNotEmpty($data['hydra:member']);
foreach ($data['hydra:member'] as $member) {
$this->assertMatchesDefinition($member, $definition, $path.' item member');
}

return;
}

$this->assertMatchesDefinition($data, $definition, $path.' item');
}

public static function collectionProvider(): iterable
{
yield 'events' => ['/api/v2/events', 'event'];
yield 'occurrences' => ['/api/v2/occurrences', 'occurrence'];
yield 'daily_occurrences' => ['/api/v2/daily_occurrences', 'occurrence'];
yield 'locations' => ['/api/v2/locations', 'location'];
yield 'organizations' => ['/api/v2/organizations', 'organization'];
yield 'tags' => ['/api/v2/tags', 'tag'];
yield 'vocabularies' => ['/api/v2/vocabularies', 'vocabulary'];
}

public static function itemProvider(): iterable
{
// [path, definition, collectionWrapped]
yield 'events' => ['/api/v2/events/7', 'event', true];
yield 'occurrences' => ['/api/v2/occurrences/10', 'occurrence', true];
yield 'daily_occurrences' => ['/api/v2/daily_occurrences/10', 'occurrence', true];
yield 'locations' => ['/api/v2/locations/4', 'location', true];
yield 'organizations' => ['/api/v2/organizations/9', 'organization', true];
yield 'tags' => ['/api/v2/tags/aros', 'tag', false];
yield 'vocabularies' => ['/api/v2/vocabularies/aarhusguiden', 'vocabulary', false];
}

private function assertMatchesDefinition(array $payload, string $definition, string $message): void
{
$storage = new SchemaStorage();
$schema = json_decode((string) file_get_contents(self::SCHEMA_PATH), false, 512, JSON_THROW_ON_ERROR);
$storage->addSchema(self::SCHEMA_URI, $schema);

$validator = new Validator(new Factory($storage));

// Re-decode as stdClass objects so json-schema distinguishes objects from lists.
$data = json_decode(json_encode($payload, JSON_THROW_ON_ERROR), false, 512, JSON_THROW_ON_ERROR);

$validator->validate($data, (object) ['$ref' => self::SCHEMA_URI.'#/definitions/'.$definition]);

self::assertTrue(
$validator->isValid(),
$message.' failed contract schema "'.$definition.'": '.self::formatErrors($validator->getErrors())
);
}

/**
* @param array<int, array{property?: string, message?: string}> $errors
*/
private static function formatErrors(array $errors): string
{
return implode('; ', array_map(
static fn (array $e) => trim(($e['property'] ?? '').' '.($e['message'] ?? '')),
$errors
));
}
}
129 changes: 129 additions & 0 deletions tests/schemas/contract.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "internal://contract.schema.json",
"$comment": "Deep payload contract for every API resource member. Additive-tolerant (additionalProperties defaults to true): adding a field passes, removing/renaming a required field or changing a type fails. Derived from live payloads and event-database-imports mappings. See ContractSchemaTest.",
"definitions": {
"coordinates": {
"type": "array",
"items": {"type": "number"},
"minItems": 2,
"maxItems": 2
},
"imageUrls": {
"type": "object",
"required": ["small", "medium", "large"],
"properties": {
"small": {"type": "string"},
"medium": {"type": "string"},
"large": {"type": "string"}
}
},
"party": {
"$comment": "Organizer / partner / Organization member — same shape.",
"type": "object",
"required": ["entityId", "name", "email", "url"],
"properties": {
"entityId": {"type": "integer"},
"name": {"type": "string"},
"email": {"type": "string"},
"url": {"type": "string"},
"created": {"type": "string"},
"updated": {"type": "string"}
}
},
"location": {
"type": "object",
"required": ["entityId", "name", "disabilityAccess", "coordinates"],
"properties": {
"entityId": {"type": "integer"},
"name": {"type": "string"},
"image": {"type": ["string", "null"]},
"url": {"type": ["string", "null"]},
"telephone": {"type": ["string", "null"]},
"disabilityAccess": {"type": "boolean"},
"mail": {"type": ["string", "null"]},
"street": {"type": "string"},
"suite": {"type": "string"},
"region": {"type": "string"},
"city": {"type": "string"},
"country": {"type": "string"},
"postalCode": {"type": "string"},
"coordinates": {"$ref": "#/definitions/coordinates"}
}
},
"occurrenceEmbedded": {
"$comment": "Occurrence as embedded inside an event (no back-reference to event).",
"type": "object",
"required": ["entityId", "start", "end"],
"properties": {
"entityId": {"type": "integer"},
"start": {"type": "string"},
"end": {"type": "string"},
"ticketPriceRange": {"type": ["string", "null"]},
"room": {"type": ["string", "null"]},
"status": {"type": ["string", "null"]}
}
},
"event": {
"type": "object",
"required": ["entityId", "title", "publicAccess", "organizer", "location", "occurrences", "tags", "imageUrls"],
"properties": {
"entityId": {"type": "integer"},
"title": {"type": "string"},
"excerpt": {"type": ["string", "null"]},
"description": {"type": ["string", "null"]},
"url": {"type": ["string", "null"]},
"ticketUrl": {"type": ["string", "null"]},
"publicAccess": {"type": "boolean"},
"organizer": {"$ref": "#/definitions/party"},
"partners": {"type": "array", "items": {"$ref": "#/definitions/party"}},
"occurrences": {"type": "array", "items": {"$ref": "#/definitions/occurrenceEmbedded"}},
"dailyOccurrences": {"type": "array", "items": {"$ref": "#/definitions/occurrenceEmbedded"}},
"tags": {"type": "array", "items": {"type": "string"}},
"imageUrls": {"$ref": "#/definitions/imageUrls"},
"created": {"type": "string"},
"updated": {"type": "string"},
"location": {"$ref": "#/definitions/location"}
}
},
"occurrence": {
"$comment": "Occurrence / DailyOccurrence resource member — carries the parent event.",
"type": "object",
"required": ["entityId", "start", "end", "event"],
"properties": {
"entityId": {"type": "integer"},
"start": {"type": "string"},
"end": {"type": "string"},
"ticketPriceRange": {"type": ["string", "null"]},
"room": {"type": ["string", "null"]},
"status": {"type": ["string", "null"]},
"event": {"$ref": "#/definitions/event"}
}
},
"organization": {
"$ref": "#/definitions/party"
},
"tag": {
"type": "object",
"required": ["@id", "@type", "slug", "name"],
"properties": {
"@id": {"type": "string"},
"@type": {"type": "string"},
"slug": {"type": "string"},
"name": {"type": "string"}
}
},
"vocabulary": {
"type": "object",
"required": ["@id", "@type", "slug", "name"],
"properties": {
"@id": {"type": "string"},
"@type": {"type": "string"},
"slug": {"type": "string"},
"name": {"type": "string"},
"description": {"type": ["string", "null"]},
"tags": {"type": "array", "items": {"type": "string"}}
}
}
}
}
Loading