diff --git a/src/Resolver.php b/src/Resolver.php index 1e2601c..969e83d 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -29,12 +29,16 @@ class Resolver { /** - * Schema resolution stack. Each item on the stack is an array - * containing an uri and a schema. + * @var stdClass + */ + private $rootSchema; + + /** + * Stack of URIs used for resolving relative URIs. * - * @var array + * @var Uri[] */ - private $stack = []; + private $uriStack = []; /** * Schema cache. Each schema visited at a given URI is stored @@ -59,40 +63,54 @@ class Resolver */ public function initialize(stdClass $schema, Uri $uri) { - $this->registerSchema($schema, $uri); - $this->stack = [[$uri, $schema]]; + if ($uri->isAbsolute() && !$uri->hasPointer()) { + $this->registerSchema($schema, $uri); + } + + $this->rootSchema = $schema; + $this->uriStack = [$uri]; } /** - * Returns the URI of the current schema. + * Returns URI of root schema. * * @return Uri * * @throws EmptyStackException */ - public function getCurrentUri() + public function getRootUri() { - if (count($this->stack) === 0) { + if (count($this->uriStack) === 0) { throw new EmptyStackException(); } - return end($this->stack)[0]; + return reset($this->uriStack); } /** - * Returns the current schema. + * Returns root schema. * - * @return stdClass + * @return stdClass|null + */ + public function getRootSchema() + { + return $this->rootSchema; + } + + /** + * Returns the URI of the current schema. + * + * @return Uri * * @throws EmptyStackException */ - public function getCurrentSchema() + public function getCurrentUri() { - if (count($this->stack) === 0) { + if (count($this->uriStack) === 0) { throw new EmptyStackException(); } - return end($this->stack)[1]; + return end($this->uriStack); } /** @@ -109,24 +127,22 @@ public function setPreFetchHook(Closure $preFetchHook) /** * Pushes an URI and its associated schema onto the resolution stack, - * making them the current URI/schema pair. If no schema is passed, the - * current schema is reused (useful when entering a resolution scope - * within the current schema). + * making them the current URI/schema pair. * * @param Uri $uri * @param stdClass $schema * * @throws EmptyStackException */ - public function enter(Uri $uri, stdClass $schema = null) + public function enter(Uri $uri, stdClass $schema) { $currentUri = $this->getCurrentUri(); + $resolvedUri = $uri->resolveAgainst($currentUri); + $this->uriStack[] = $resolvedUri; - if (!$uri->isAbsolute()) { - $uri->resolveAgainst($currentUri); + if ($resolvedUri->isAbsolute() && !$resolvedUri->hasPointer()) { + $this->registerSchema($schema, $resolvedUri); } - - $this->stack[] = [$uri, $schema ?: $this->getCurrentSchema()]; } /** @@ -137,11 +153,11 @@ public function enter(Uri $uri, stdClass $schema = null) */ public function leave() { - if (count($this->stack) === 0) { + if (count($this->uriStack) === 0) { throw new EmptyStackException(); } - array_pop($this->stack); + array_pop($this->uriStack); } /** @@ -161,17 +177,18 @@ public function resolve(stdClass $reference) $baseUri = $this->getCurrentUri(); $uri = new Uri($reference->{'$ref'}); - if (!$uri->isAbsolute()) { - $uri->resolveAgainst($baseUri); - } - - $identifier = $uri->getPrimaryResourceIdentifier(); - - if (!isset($this->schemas[$identifier])) { - $schema = $this->fetchSchemaAt($identifier); - $this->registerSchema($schema, $uri); + if ($baseUri->getPrimaryResourceIdentifier() === '' && $uri->getPrimaryResourceIdentifier() === '') { + $schema = $this->getRootSchema(); } else { - $schema = $this->schemas[$identifier]; + $uri = $uri->resolveAgainst($baseUri); + $identifier = $uri->getPrimaryResourceIdentifier(); + + if (isset($this->schemas[$identifier])) { + $schema = $this->schemas[$identifier]; + } else { + $schema = $this->fetchSchemaAt($identifier); + $this->registerSchema($schema, $uri); + } } $resolved = $this->resolvePointer($schema, $uri); @@ -188,15 +205,23 @@ public function resolve(stdClass $reference) } /** - * Caches a schema reference for future use. + * Registers a schema reference for future use. * * @param stdClass $schema * @param Uri $uri */ - private function registerSchema(stdClass $schema, Uri $uri) + public function registerSchema(stdClass $schema, Uri $uri) { - if (!isset($this->schemas[$uri->getPrimaryResourceIdentifier()])) { - $this->schemas[$uri->getPrimaryResourceIdentifier()] = $schema; + if (!$uri->isAbsolute()) { + throw new \LogicException('Unable to register schema without absolute URI'); + } + + $identifier = $uri->getPrimaryResourceIdentifier(); + + if (!isset($this->schemas[$identifier])) { + $this->schemas[$identifier] = $schema; + } elseif (!Utils::areEqual($this->schemas[$identifier], $schema)) { + throw new \LogicException('Different schema is already registered with given URI'); } } diff --git a/src/Uri.php b/src/Uri.php index 406139d..21c5e07 100644 --- a/src/Uri.php +++ b/src/Uri.php @@ -74,7 +74,7 @@ public function getRawUri() */ public function getRawPointer() { - return isset($this->parts['fragment']) ? $this->parts['fragment'] : ''; + return $this->parts['fragment']; } /** @@ -125,6 +125,14 @@ public function getPointerSegments() return $this->segments; } + /** + * @return bool + */ + public function hasPointer() + { + return !empty($this->segments); + } + /** * Returns the primary resource identifier part of the URI, i.e. everything * excluding its fragment part. @@ -146,26 +154,20 @@ public function getPrimaryResourceIdentifier() * * @param Uri $uri * - * @return string + * @return Uri */ public function resolveAgainst(Uri $uri) { if ($this->isAbsolute()) { - throw new \LogicException( - 'Cannot resolve against another URI: URI is already absolute' - ); - } - - if (!$uri->isAbsolute()) { + return $this; + } elseif (!$uri->isAbsolute()) { throw new \LogicException( 'Cannot resolve against another URI: reference URI is not absolute' ); + } else { + $resolvedUri = $this->buildResolvedUriAgainst($uri); + return new self($resolvedUri); } - - $resolved = $this->buildResolvedUriAgainst($uri); - $this->buildFromRawUri($resolved); - - return $resolved; } /** @@ -233,17 +235,13 @@ private function buildSegments() { $segments = []; - if (isset($this->parts['fragment'])) { - $rawSegments = explode('/', $this->parts['fragment']); + if (substr($this->parts['fragment'], 0, 1) === '/') { + $rawSegments = explode('/', substr($this->parts['fragment'], 1)); foreach ($rawSegments as $segment) { - $segment = trim($segment); - - if ($segment !== '') { - $segment = str_replace('~1', '/', $segment); - $segment = str_replace('~0', '~', $segment); - $segments[] = $segment; - } + $segment = str_replace('~1', '/', $segment); + $segment = str_replace('~0', '~', $segment); + $segments[] = $segment; } } @@ -254,16 +252,20 @@ private function buildPrimaryIdentifier() { $identifier = ''; - if ($this->parts['scheme']) { + if ($this->parts['scheme'] !== '') { $identifier .= $this->parts['scheme'].'://'; } $identifier .= $this->authority.$this->parts['path']; - if ($this->parts['query']) { + if ($this->parts['query'] !== '') { $identifier .= '?'.$this->parts['query']; } + if ($this->parts['fragment'] !== '' && $this->parts['fragment'][0] !== '/') { + $identifier .= '#'.$this->parts['fragment']; + } + return $identifier; } diff --git a/src/Utils.php b/src/Utils.php index 334bd27..7f32e51 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -125,6 +125,10 @@ public static function lastJsonErrorMessage() private static function doAreEqual($a, $b, array $stack) { + if ($a === $b) { + return true; + } + // keep track of object references to avoid infinite recursion if (is_object($a)) { if (in_array($a, $stack)) { diff --git a/src/Walker.php b/src/Walker.php index 051ca17..6b9cdfa 100644 --- a/src/Walker.php +++ b/src/Walker.php @@ -87,7 +87,7 @@ private function doResolveReferences(stdClass $schema, Uri $uri) $inScope = false; if (property_exists($schema, 'id') && is_string($schema->id)) { - $this->resolver->enter(new Uri($schema->id)); + $this->resolver->enter(new Uri($schema->id), $schema); $inScope = true; } diff --git a/tests/Data/schemas/valid/root-reference-2.json b/tests/Data/schemas/valid/root-reference-2.json deleted file mode 100644 index 0473e64..0000000 --- a/tests/Data/schemas/valid/root-reference-2.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "foo": { - "bar": { - "$ref": "#/" - } - } -} diff --git a/tests/Data/schemas/valid/root-reference-3.json b/tests/Data/schemas/valid/root-reference-3.json deleted file mode 100644 index 4d01798..0000000 --- a/tests/Data/schemas/valid/root-reference-3.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "foo": { - "bar": { - "$ref": "#///" - } - } -} diff --git a/tests/Data/schemas/valid/root-reference-1.json b/tests/Data/schemas/valid/root-reference.json similarity index 100% rename from tests/Data/schemas/valid/root-reference-1.json rename to tests/Data/schemas/valid/root-reference.json diff --git a/tests/Data/schemas/valid/scoped-references.json b/tests/Data/schemas/valid/scoped-references.json index 0bf2a61..4bbb923 100644 --- a/tests/Data/schemas/valid/scoped-references.json +++ b/tests/Data/schemas/valid/scoped-references.json @@ -6,14 +6,6 @@ }, "bar": { "$ref": "/valid/exclusiveMinimum-not-present.json" - }, - "baz": { - "id": "exclusiveMaximum-not-present.json", - "oneOf": [ - { - "$ref": "#" - } - ] } } } diff --git a/tests/ResolverTest.php b/tests/ResolverTest.php index 5894897..5d0ad0a 100644 --- a/tests/ResolverTest.php +++ b/tests/ResolverTest.php @@ -24,12 +24,46 @@ protected function setUp() $this->resolver = new Resolver(); } + public function testBasicStackHandling() + { + $schemaA = new stdClass(); + $uriA = new Uri('file:///foo/bar/a'); + $this->resolver->initialize($schemaA, $uriA); + + $this->assertSame($schemaA, $this->resolver->getRootSchema()); + $this->assertSame($uriA, $this->resolver->getRootUri()); + $this->assertSame($uriA, $this->resolver->getCurrentUri()); + + $schemaB = new stdClass(); + $uriB = new Uri('file:///foo/bar/b'); + $this->resolver->enter($uriB, $schemaB); + $this->assertSame($schemaA, $this->resolver->getRootSchema()); + $this->assertSame($uriA, $this->resolver->getRootUri()); + $this->assertSame($uriB, $this->resolver->getCurrentUri()); + } + + public function testRegisterSchemaWithTwoEqualSchemas() + { + $this->resolver->registerSchema((object) ['type' => 'string'], new Uri('file:///foo/bar')); + $this->resolver->registerSchema((object) ['type' => 'string'], new Uri('file:///foo/bar')); + + } + + /** + * @expectedException \LogicException + */ + public function testRegisterSchemaThrowsIfAlreadyRegisteredWithDifferentSchema() + { + $this->resolver->registerSchema((object) ['type' => 'string'], new Uri('file:///foo/bar')); + $this->resolver->registerSchema((object) ['type' => 'array'], new Uri('file:///foo/bar')); + } + /** * @expectedException \JVal\Exception\Resolver\EmptyStackException */ - public function testGetCurrentSchemaThrowsIfStackIsEmpty() + public function testGetRootUriThrowsIfStackIsEmpty() { - $this->resolver->getCurrentSchema(); + $this->resolver->getRootUri(); } /** @@ -77,6 +111,22 @@ public function testResolveChain(stdClass $schema, $pointerUri, stdClass $resolv $this->assertSame($actual[1], $resolved); } + /** + * @dataProvider chainProvider + * + * @param stdClass $schema + * @param string $pointerUri + * @param stdClass $resolved + */ + public function testResolveChainWithoutAbsoluteUri(stdClass $schema, $pointerUri, stdClass $resolved) + { + $this->resolver->initialize($schema, new Uri('')); + $reference = new stdClass(); + $reference->{'$ref'} = $pointerUri; + $actual = $this->resolver->resolve($reference); + $this->assertSame($actual[1], $resolved); + } + /** * @dataProvider unresolvablePointerPropertyProvider * @expectedException \JVal\Exception\Resolver\UnresolvedPointerPropertyException @@ -223,9 +273,7 @@ public function testResolveThrowsOnInvalidRemoteSchema() public function rootRefProvider() { return [ - ['valid/root-reference-1'], - ['valid/root-reference-2'], - ['valid/root-reference-3'], + ['valid/root-reference'], ]; } @@ -234,12 +282,10 @@ public function chainProvider() $schema = $this->loadSchema('valid/resolution-chains'); return [ - [$schema, '#foo', $schema->foo], [$schema, '#/foo', $schema->foo], [$schema, '#/foo/baz', $schema->foo->baz], [$schema, '#/bar/baz', $schema->bar->baz], [$schema, '#/bar/baz/bat', $schema->bar->baz->bat], - [$schema, '#/bar/baz/bat/', $schema->bar->baz->bat], [$schema, '#/bat/0', $schema->bat[0]], [$schema, '#/bat/1/quz/0', $schema->bat[1]->quz[0]], [$schema, '#/with%25percent', $schema->{'with%percent'}], @@ -254,9 +300,9 @@ public function unresolvablePointerPropertyProvider() $schema = $this->loadSchema('valid/resolution-chains'); return [ - [$schema, '#nope'], + [$schema, '#/nope'], [$schema, '#/foo/nope'], - [$schema, '#bar/baz/nope'], + [$schema, '#/bar/baz/nope'], ]; } @@ -296,7 +342,7 @@ public function invalidPointerTargetProvider() return [ [$schema, '#/foo/bat'], - [$schema, '#bat'], + [$schema, '#/bat'], ]; } diff --git a/tests/UriTest.php b/tests/UriTest.php index eddd0b6..dac6b6f 100644 --- a/tests/UriTest.php +++ b/tests/UriTest.php @@ -43,15 +43,6 @@ public function testPointerSegments($uri, array $expectedSegments) $this->assertEquals($expectedSegments, $pointer->getPointerSegments()); } - /** - * @expectedException \LogicException - */ - public function testCannotResolveAgainstOtherUriIfAlreadyAbsolute() - { - $pointer = new Uri('http://localhost'); - $pointer->resolveAgainst(new Uri('file:///foo')); - } - /** * @expectedException \LogicException */ @@ -72,18 +63,21 @@ public function testResolveAgainstAnotherUri($uri, $againstUri, $expectedResolve { $pointer = new Uri($uri); $resolved = $pointer->resolveAgainst(new Uri($againstUri)); - $this->assertEquals($expectedResolved, $resolved); + $this->assertEquals($expectedResolved, $resolved->getRawUri()); } - public function testResolveAgainstUriChangesInternalState() + public function testResolveAgainstUriDoesNotChangeInternalState() { - $pointer = new Uri('#quz/123'); - $against = new Uri('http://localhost:1234/foo/bar#baz'); - $pointer->resolveAgainst($against); - $this->assertEquals('http://localhost:1234/foo/bar#quz/123', $pointer->getRawUri()); - $this->assertEquals('http', $pointer->getScheme()); - $this->assertEquals('http://localhost:1234/foo/bar', $pointer->getPrimaryResourceIdentifier()); - $this->assertEquals(['quz', '123'], $pointer->getPointerSegments()); + $pointer = new Uri('#/quz/123'); + $against = new Uri('http://localhost:1234/foo/bar#/baz'); + $resolved = $pointer->resolveAgainst($against); + + $this->assertEquals('#/quz/123', $pointer->getRawUri()); + + $this->assertEquals('http://localhost:1234/foo/bar#/quz/123', $resolved->getRawUri()); + $this->assertEquals('http', $resolved->getScheme()); + $this->assertEquals('http://localhost:1234/foo/bar', $resolved->getPrimaryResourceIdentifier()); + $this->assertEquals(['quz', '123'], $resolved->getPointerSegments()); } /** @@ -128,16 +122,19 @@ public function uriSegmentsProvider() ['http://foo.bar/baz?foo=bar#/foo/bar', ['foo', 'bar']], ['//localhost/foo#/bar/baz', ['bar', 'baz']], ['/quz', []], - ['/quz/#//', []], - ['/quz/#//foo/1%25/bar', ['foo', '1%', 'bar']], - ['/quz/#//foo/1~02/bar', ['foo', '1~2', 'bar']], - ['/quz/#//foo/1~12/bar', ['foo', '1/2', 'bar']], + ['/quz#', []], + ['/quz#/', ['']], + ['/quz#//', ['', '']], + ['/quz#/foo/1%25/bar', ['foo', '1%', 'bar']], + ['/quz#/foo/1~02/bar', ['foo', '1~2', 'bar']], + ['/quz#/foo/1~12/bar', ['foo', '1/2', 'bar']], ]; } public function againstUriProvider() { return [ + ['http://localhost', 'file:///foo', 'http://localhost'], ['foo.json', 'http://localhost/bar', 'http://localhost/foo.json'], ['foo.json', 'http://localhost/bar/baz', 'http://localhost/bar/foo.json'], ['/foo.json', 'http://localhost/bar/baz', 'http://localhost/foo.json'], diff --git a/tests/WalkerTest.php b/tests/WalkerTest.php index be277a2..6002726 100644 --- a/tests/WalkerTest.php +++ b/tests/WalkerTest.php @@ -107,11 +107,6 @@ public function testResolveReferencesWithScopeChanges() $this->assertObjectHasAttribute('bar', $resolved->properties); $this->assertObjectHasAttribute('minimum', $resolved->properties->bar); - - $this->assertObjectHasAttribute('baz', $resolved->properties); - $this->assertObjectHasAttribute('oneOf', $resolved->properties->baz); - $this->assertArrayHasKey(0, $resolved->properties->baz->oneOf); - $this->assertObjectHasAttribute('maximum', $resolved->properties->baz->oneOf[0]); } public function testApplyConstraintsWithRecursiveReference()