From d190b090413433ad814d75c3fdd813a0f61cdf42 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 13:28:14 +0200 Subject: [PATCH 1/2] test: validate deep payload schemas for every resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests/schemas/contract.schema.json — a hand-authored, additive-tolerant JSON Schema pinning the nested payload of every resource (organizer, location.coordinates, occurrences[], imageUrls, …) with types and the consumer-critical required fields. ContractSchemaTest validates both collection members and item endpoints against it, handling the D6 quirk where the five nested resources return a hydra:Collection with a single member on their item endpoint while Tag/Vocabulary return true items. This is the delta over CollectionContractTest (top-level field set only): a removed/renamed nested field or a changed type now fails CI, while adding a field passes. Adds justinrainbow/json-schema as a dev dependency. --- composer.json | 1 + composer.lock | 16 +-- .../Contract/ContractSchemaTest.php | 112 +++++++++++++++ tests/schemas/contract.schema.json | 129 ++++++++++++++++++ 4 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 tests/ApiPlatform/Contract/ContractSchemaTest.php create mode 100644 tests/schemas/contract.schema.json diff --git a/composer.json b/composer.json index 3363fac..9aae943 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 38d7273..45e99c3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "96179f4d3c4439728b30f58af22cf645", + "content-hash": "3e561e58a156313d3083e9359e0b480d", "packages": [ { "name": "api-platform/core", @@ -7412,16 +7412,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.6.4", + "version": "6.10.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7" + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2eeb75d21cf73211335888e7f5e6fd7440723ec7", - "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", "shasum": "" }, "require": { @@ -7431,7 +7431,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "^23.2", + "json-schema/json-schema-test-suite": "dev-main", "marc-mabe/php-enum-phpstan": "^2.0", "phpspec/prophecy": "^1.19", "phpstan/phpstan": "^1.12", @@ -7481,9 +7481,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.4" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.10.0" }, - "time": "2025-12-19T15:01:32+00:00" + "time": "2026-06-16T20:50:26+00:00" }, { "name": "localheinz/diff", diff --git a/tests/ApiPlatform/Contract/ContractSchemaTest.php b/tests/ApiPlatform/Contract/ContractSchemaTest.php new file mode 100644 index 0000000..c3f72d0 --- /dev/null +++ b/tests/ApiPlatform/Contract/ContractSchemaTest.php @@ -0,0 +1,112 @@ +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 $errors + */ + private static function formatErrors(array $errors): string + { + return implode('; ', array_map( + static fn (array $e) => trim(($e['property'] ?? '').' '.($e['message'] ?? '')), + $errors + )); + } +} diff --git a/tests/schemas/contract.schema.json b/tests/schemas/contract.schema.json new file mode 100644 index 0000000..324aa80 --- /dev/null +++ b/tests/schemas/contract.schema.json @@ -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"}} + } + } + } +} From 826c50e67f316d495c3a5e2f742e595e2306b66d Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 8 Jul 2026 13:32:03 +0200 Subject: [PATCH 2/2] docs: add CHANGELOG entry for deep payload contract schemas --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a5655e..c08fef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)