From 126e7d853cd56f5aa53e52db024bafa62027db0a Mon Sep 17 00:00:00 2001 From: Christian Lopez Espinola Date: Wed, 27 May 2026 18:19:21 +0200 Subject: [PATCH 1/3] fix: annotate resolved schemas with dialect-aware id keyword UriRetriever::retrieve() unconditionally stamped resolved schemas with the draft-04 "id" keyword. Draft-6 renamed this to "$id", but the annotation was never updated. Detect the dialect from the root schema's $schema keyword before pointer resolution. Draft-3/4 schemas get "id"; Draft-6+ and schemas without an explicit $schema keyword get "$id" (matching the library's default). Internally safe because SchemaStorage::findSchemaIdInObject() already reads both keywords. Fixes the issue for external consumers of resolved schemas (e.g. strict Draft-7 validators like Ajv that reject the unknown "id" keyword). Fixes #911 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/JsonSchema/Uri/UriRetriever.php | 9 ++++++++- tests/Uri/UriRetrieverTest.php | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/JsonSchema/Uri/UriRetriever.php b/src/JsonSchema/Uri/UriRetriever.php index 4d52937a..929ff66b 100644 --- a/src/JsonSchema/Uri/UriRetriever.php +++ b/src/JsonSchema/Uri/UriRetriever.php @@ -11,6 +11,7 @@ namespace JsonSchema\Uri; +use JsonSchema\DraftIdentifiers; use JsonSchema\Exception\InvalidSchemaMediaTypeException; use JsonSchema\Exception\JsonDecodingException; use JsonSchema\Exception\ResourceNotFoundException; @@ -182,11 +183,17 @@ public function retrieve($uri, $baseUri = null, $translate = true) $jsonSchema = $this->loadSchema($fetchUri); + // Detect dialect from the root schema's $schema keyword before + // resolvePointer() may walk into a sub-schema. + $dialect = isset($jsonSchema->{'$schema'}) ? $jsonSchema->{'$schema'} : null; + $usesDollarId = !in_array($dialect, [DraftIdentifiers::DRAFT_3, DraftIdentifiers::DRAFT_4], true); + // Use the JSON pointer if specified $jsonSchema = $this->resolvePointer($jsonSchema, $resolvedUri); if ($jsonSchema instanceof \stdClass) { - $jsonSchema->id = $resolvedUri; + $idKeyword = $usesDollarId ? '$id' : 'id'; + $jsonSchema->{$idKeyword} = $resolvedUri; } return $jsonSchema; diff --git a/tests/Uri/UriRetrieverTest.php b/tests/Uri/UriRetrieverTest.php index e14f2205..3eb655e7 100644 --- a/tests/Uri/UriRetrieverTest.php +++ b/tests/Uri/UriRetrieverTest.php @@ -339,7 +339,8 @@ public function testRetrieveSchemaFromPackage(): void $this->assertNotFalse($schema); // check that the schema was loaded & processed correctly - $this->assertEquals('454f423bd7edddf0bc77af4130ed9161', md5(json_encode($schema))); + $this->assertEquals('object', $schema->type); + $this->assertObjectHasAttribute('$id', $schema); } public function testInvalidContentTypeEndpointsDefault(): void From 2ee15c1dd6974dbff2126dc26f5b238e1cc77887 Mon Sep 17 00:00:00 2001 From: Christian Lopez Espinola Date: Wed, 27 May 2026 22:06:26 +0200 Subject: [PATCH 2/3] fix: normalize $schema URI fragment when detecting dialect for id keyword The dialect comparison now strips the trailing # from the $schema value and compares against withoutFragment() constants, so schemas declaring e.g. "http://json-schema.org/draft-04/schema" (without #) correctly get `id` instead of falling through to `$id`. Adds 5 tests covering Draft-04 and Draft-07 with/without fragment, plus the no-$schema default. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/JsonSchema/Uri/UriRetriever.php | 4 +- tests/Uri/UriRetrieverTest.php | 45 +++++++++++++++++++ .../draft04-schema-with-fragment.json | 9 ++++ .../draft04-schema-without-fragment.json | 9 ++++ .../draft07-schema-without-fragment.json | 9 ++++ tests/fixtures/draft07-schema.json | 9 ++++ 6 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/draft04-schema-with-fragment.json create mode 100644 tests/fixtures/draft04-schema-without-fragment.json create mode 100644 tests/fixtures/draft07-schema-without-fragment.json create mode 100644 tests/fixtures/draft07-schema.json diff --git a/src/JsonSchema/Uri/UriRetriever.php b/src/JsonSchema/Uri/UriRetriever.php index 929ff66b..3e9a71c1 100644 --- a/src/JsonSchema/Uri/UriRetriever.php +++ b/src/JsonSchema/Uri/UriRetriever.php @@ -185,8 +185,8 @@ public function retrieve($uri, $baseUri = null, $translate = true) // Detect dialect from the root schema's $schema keyword before // resolvePointer() may walk into a sub-schema. - $dialect = isset($jsonSchema->{'$schema'}) ? $jsonSchema->{'$schema'} : null; - $usesDollarId = !in_array($dialect, [DraftIdentifiers::DRAFT_3, DraftIdentifiers::DRAFT_4], true); + $dialect = isset($jsonSchema->{'$schema'}) ? rtrim($jsonSchema->{'$schema'}, '#') : null; + $usesDollarId = !in_array($dialect, [DraftIdentifiers::DRAFT_3()->withoutFragment(), DraftIdentifiers::DRAFT_4()->withoutFragment()], true); // Use the JSON pointer if specified $jsonSchema = $this->resolvePointer($jsonSchema, $resolvedUri); diff --git a/tests/Uri/UriRetrieverTest.php b/tests/Uri/UriRetrieverTest.php index 3eb655e7..dc36810e 100644 --- a/tests/Uri/UriRetrieverTest.php +++ b/tests/Uri/UriRetrieverTest.php @@ -343,6 +343,51 @@ public function testRetrieveSchemaFromPackage(): void $this->assertObjectHasAttribute('$id', $schema); } + public function testRetrieveDraft04SchemaWithFragmentUsesId(): void + { + $retriever = new UriRetriever(); + $schema = $retriever->retrieve('package://tests/fixtures/draft04-schema-with-fragment.json'); + + $this->assertObjectHasAttribute('id', $schema); + $this->assertObjectNotHasAttribute('$id', $schema); + } + + public function testRetrieveDraft04SchemaWithoutFragmentUsesId(): void + { + $retriever = new UriRetriever(); + $schema = $retriever->retrieve('package://tests/fixtures/draft04-schema-without-fragment.json'); + + $this->assertObjectHasAttribute('id', $schema); + $this->assertObjectNotHasAttribute('$id', $schema); + } + + public function testRetrieveDraft07SchemaWithFragmentUsesDollarId(): void + { + $retriever = new UriRetriever(); + $schema = $retriever->retrieve('package://tests/fixtures/draft07-schema.json'); + + $this->assertObjectHasAttribute('$id', $schema); + $this->assertObjectNotHasAttribute('id', $schema); + } + + public function testRetrieveDraft07SchemaWithoutFragmentUsesDollarId(): void + { + $retriever = new UriRetriever(); + $schema = $retriever->retrieve('package://tests/fixtures/draft07-schema-without-fragment.json'); + + $this->assertObjectHasAttribute('$id', $schema); + $this->assertObjectNotHasAttribute('id', $schema); + } + + public function testRetrieveSchemaWithoutDialectDefaultsToDollarId(): void + { + $retriever = new UriRetriever(); + $schema = $retriever->retrieve('package://tests/fixtures/foobar.json'); + + $this->assertObjectHasAttribute('$id', $schema); + $this->assertObjectNotHasAttribute('id', $schema); + } + public function testInvalidContentTypeEndpointsDefault(): void { $mock = $this->createMock(\JsonSchema\Uri\Retrievers\UriRetrieverInterface::class); diff --git a/tests/fixtures/draft04-schema-with-fragment.json b/tests/fixtures/draft04-schema-with-fragment.json new file mode 100644 index 00000000..a80ee898 --- /dev/null +++ b/tests/fixtures/draft04-schema-with-fragment.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } +} diff --git a/tests/fixtures/draft04-schema-without-fragment.json b/tests/fixtures/draft04-schema-without-fragment.json new file mode 100644 index 00000000..e67b850f --- /dev/null +++ b/tests/fixtures/draft04-schema-without-fragment.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema", + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } +} diff --git a/tests/fixtures/draft07-schema-without-fragment.json b/tests/fixtures/draft07-schema-without-fragment.json new file mode 100644 index 00000000..52bf4972 --- /dev/null +++ b/tests/fixtures/draft07-schema-without-fragment.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } +} diff --git a/tests/fixtures/draft07-schema.json b/tests/fixtures/draft07-schema.json new file mode 100644 index 00000000..39f88b39 --- /dev/null +++ b/tests/fixtures/draft07-schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } +} From 25ec63df11d5b6b8d9b8cb75a7559accc18a2868 Mon Sep 17 00:00:00 2001 From: Christian Lopez Espinola Date: Wed, 3 Jun 2026 21:11:40 +0200 Subject: [PATCH 3/3] test: assert resolved id/$id value in URI retriever tests Per review feedback, the retriever tests confirmed only the presence of the id/$id attribute, not its value. Add an assertion for the stamped resolved URI to each affected test. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Uri/UriRetrieverTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Uri/UriRetrieverTest.php b/tests/Uri/UriRetrieverTest.php index dc36810e..cf37d77d 100644 --- a/tests/Uri/UriRetrieverTest.php +++ b/tests/Uri/UriRetrieverTest.php @@ -341,6 +341,7 @@ public function testRetrieveSchemaFromPackage(): void // check that the schema was loaded & processed correctly $this->assertEquals('object', $schema->type); $this->assertObjectHasAttribute('$id', $schema); + $this->assertEquals('package://tests/fixtures/foobar.json', $schema->{'$id'}); } public function testRetrieveDraft04SchemaWithFragmentUsesId(): void @@ -350,6 +351,7 @@ public function testRetrieveDraft04SchemaWithFragmentUsesId(): void $this->assertObjectHasAttribute('id', $schema); $this->assertObjectNotHasAttribute('$id', $schema); + $this->assertEquals('package://tests/fixtures/draft04-schema-with-fragment.json', $schema->id); } public function testRetrieveDraft04SchemaWithoutFragmentUsesId(): void @@ -359,6 +361,7 @@ public function testRetrieveDraft04SchemaWithoutFragmentUsesId(): void $this->assertObjectHasAttribute('id', $schema); $this->assertObjectNotHasAttribute('$id', $schema); + $this->assertEquals('package://tests/fixtures/draft04-schema-without-fragment.json', $schema->id); } public function testRetrieveDraft07SchemaWithFragmentUsesDollarId(): void @@ -368,6 +371,7 @@ public function testRetrieveDraft07SchemaWithFragmentUsesDollarId(): void $this->assertObjectHasAttribute('$id', $schema); $this->assertObjectNotHasAttribute('id', $schema); + $this->assertEquals('package://tests/fixtures/draft07-schema.json', $schema->{'$id'}); } public function testRetrieveDraft07SchemaWithoutFragmentUsesDollarId(): void @@ -377,6 +381,7 @@ public function testRetrieveDraft07SchemaWithoutFragmentUsesDollarId(): void $this->assertObjectHasAttribute('$id', $schema); $this->assertObjectNotHasAttribute('id', $schema); + $this->assertEquals('package://tests/fixtures/draft07-schema-without-fragment.json', $schema->{'$id'}); } public function testRetrieveSchemaWithoutDialectDefaultsToDollarId(): void @@ -386,6 +391,7 @@ public function testRetrieveSchemaWithoutDialectDefaultsToDollarId(): void $this->assertObjectHasAttribute('$id', $schema); $this->assertObjectNotHasAttribute('id', $schema); + $this->assertEquals('package://tests/fixtures/foobar.json', $schema->{'$id'}); } public function testInvalidContentTypeEndpointsDefault(): void