Skip to content
This repository has been archived by the owner on May 12, 2018. It is now read-only.

Commit

Permalink
Improve dereferencing resolution scopes (#41)
Browse files Browse the repository at this point in the history
* Properly resolve references when there isn't an ID

* Improve handling of absolute references in IDs

* Set initial resolution scope for external references

* Allow fragments in schema URIs

* Make dereferencer code more readable
  • Loading branch information
Matt A committed Aug 22, 2016
1 parent abda034 commit 0c80197
Show file tree
Hide file tree
Showing 6 changed files with 274 additions and 57 deletions.
216 changes: 160 additions & 56 deletions src/Dereferencer.php
Expand Up @@ -36,9 +36,12 @@ public function __construct()
*/
public function dereference($schema)
{
// If a string is provided, assume they passed a path.
if (is_string($schema)) {
$schema = $this->loadExternalRef($schema);
$uri = $schema;
$schema = $this->loadExternalRef($uri);
$schema = $this->resolveFragment($uri, $schema);

return $this->crawl($schema, strip_fragment($uri));
}

return $this->crawl($schema);
Expand Down Expand Up @@ -100,48 +103,89 @@ private function registerDefaultWebLoaders()
/**
* Crawl the schema and resolve any references.
*
* @param object $schema
* @param object $schema
* @param string|null $currentUri
*
* @return object
*/
private function crawl($schema)
private function crawl($schema, $currentUri = null)
{
$references = $this->getReferences($schema);

foreach ($references as $path => $ref) {
// resolve
if ($this->isExternalRef($ref)) {
$ref = $this->makeReferenceAbsolute($schema, $path, $ref);
$resolved = $this->loadExternalRef($ref);
$resolved = $this->crawl($resolved);
$resolved = $this->resolveExternalReference($schema, $path, $ref, $currentUri);
} else {
$resolved = new Reference($schema, $ref);
}

// handle any fragments
$fragment = parse_url($ref, PHP_URL_FRAGMENT);
if ($this->isExternalRef($ref) && is_string($fragment)) {
$pointer = new Pointer($resolved);
$resolved = $pointer->get($fragment);
}
$resolved = $this->resolveFragment($ref, $resolved);

// merge
$this->mergeResolvedReference($schema, $resolved, $path);
}

return $schema;
}

/**
* Resolve the external referefence at the given path.
*
* @param object $schema The JSON Schema
* @param string $path A JSON pointer to the $ref's location in the schema.
* @param string $ref The JSON reference
* @param string|null $currentUri The URI of the schema, or null if the schema was loaded from an object.
* @return object The schema with the reference resolved.
*/
private function resolveExternalReference($schema, $path, $ref, $currentUri)
{
$ref = $this->makeReferenceAbsolute($schema, $path, $ref, $currentUri);
$resolved = $this->loadExternalRef($ref);

return $this->crawl($resolved, strip_fragment($ref));
}

/**
* Merge the resolved reference with the schema, at the given path.
*
* @param object $schema The schema to merge the resolved reference with
* @param object $resolved The resolved schema
* @param string $path A JSON pointer to the path where the reference should be merged.
* @return void
*/
private function mergeResolvedReference($schema, $resolved, $path)
{
if ($path === '') {
// Immediately resolve any root references.
if ($path === '') {
while ($resolved instanceof Reference) {
$resolved = $resolved->resolve();
}
while ($resolved instanceof Reference) {
$resolved = $resolved->resolve();
}

// merge
if ($path === '') {
$this->mergeRootRef($schema, $resolved);
} else {
$pointer = new Pointer($schema);
if ($pointer->has($path)) {
$pointer->set($path, $resolved);
}
$this->mergeRootRef($schema, $resolved);
} else {
$pointer = new Pointer($schema);
if ($pointer->has($path)) {
$pointer->set($path, $resolved);
}
}
}

/**
* Check if the reference contains a fragment and resolve
* the pointer. Otherwise returns the original schema.
*
* @param string $ref
* @param object $schema
* @return object
*/
private function resolveFragment($ref, $schema)
{
$fragment = parse_url($ref, PHP_URL_FRAGMENT);
if ($this->isExternalRef($ref) && is_string($fragment)) {
$pointer = new Pointer($schema);
return $pointer->get($fragment);
}

return $schema;
}
Expand Down Expand Up @@ -169,19 +213,21 @@ private function getReferences($schema, $path = '')
}

foreach ($schema as $attribute => $parameter) {
if ($this->isRef($attribute, $parameter)) {
$refs[$path] = $parameter;
}
if (is_object($parameter)) {
$refs = array_merge($refs, $this->getReferences($parameter, $this->pathPush($path, $attribute)));
}
if (is_array($parameter)) {
foreach ($parameter as $k => $v) {
$refs = array_merge(
$refs,
$this->getReferences($v, $this->pathPush($this->pathPush($path, $attribute), $k))
);
}
switch (true) {
case $this->isRef($attribute, $parameter):
$refs[$path] = $parameter;
break;
case is_object($parameter):
$refs = array_merge($refs, $this->getReferences($parameter, $this->pathPush($path, $attribute)));
break;
case is_array($parameter):
foreach ($parameter as $k => $v) {
$refs = array_merge(
$refs,
$this->getReferences($v, $this->pathPush($this->pathPush($path, $attribute), $k))
);
}
break;
}
}

Expand Down Expand Up @@ -213,23 +259,23 @@ private function isRef($attribute, $attributeValue)
}

/**
* @param string $parameter
* @param string $value
*
* @return bool
*/
private function isInternalRef($parameter)
private function isInternalRef($value)
{
return is_string($parameter) && substr($parameter, 0, 1) === '#';
return is_string($value) && substr($value, 0, 1) === '#';
}

/**
* @param string $parameter
* @param string $value
*
* @return bool
*/
private function isExternalRef($parameter)
private function isExternalRef($value)
{
return !$this->isInternalRef($parameter);
return !$this->isInternalRef($value);
}

/**
Expand Down Expand Up @@ -275,7 +321,11 @@ private function validateAbsolutePath($path)
{
if (!preg_match('#^.+\:\/\/.*#', $path)) {
throw new \InvalidArgumentException(
'Your path is missing a valid prefix. The schema path should start with a prefix i.e. "file://".'
sprintf(
'Your path "%s" is missing a valid prefix. ' .
'The schema path should start with a prefix i.e. "file://".',
$path
)
);
}
}
Expand All @@ -297,29 +347,83 @@ private function isRelativeRef($ref)
* Take a relative reference, and prepend the id of the schema and any
* sub schemas to get the absolute url.
*
* @param object $schema
* @param string $path
* @param string $ref
* @param object $schema
* @param string $path
* @param string $ref
* @param string|null $currentUri
*
* @return string
*/
private function makeReferenceAbsolute($schema, $path, $ref)
private function makeReferenceAbsolute($schema, $path, $ref, $currentUri = null)
{
if (!$this->isRelativeRef($ref)) {
return $ref;
}

$pointer = new Pointer($schema);
$baseUrl = $pointer->get('/id');
$scope = $this->getInitialResolutionScope($currentUri);
$scope = $this->getResolvedResolutionScope($schema, $path, $scope);

return $scope . $ref;
}

/**
* Given the URI of the schema, get the intial resolution scope.
*
* If a URI is given, this method returns the URI without the schema filename or any reference fragment.
* I.E, Given 'http://localhost:1234/album.json#/artist', this method would return `http://localhost:1234/`.
*
* @param string|null $uri
* @return string
*/
private function getInitialResolutionScope($uri)
{
return $uri ? strip_fragment(str_replace(basename($uri), '', $uri)) : '';
}

/**
* Given a JSON pointer, walk the path and resolve any found IDs against the parent scope.
*
* @param object $schema The JSON Schema object.
* @param string $path A JSON Pointer to the path we are resolving the scope for.
* @param string $parentScope The initial resolution scope. Usually the URI of the schema.
* @return string The resolved scope
*/
private function getResolvedResolutionScope($schema, $path, $parentScope)
{
$pointer = new Pointer($schema);

// When an id is encountered, an implementation MUST resolve this id against the most
// immediate parent scope. The resolved URI will be the new resolution scope
// for this subschema and all its children, until another id is encountered.

$currentPath = '';
foreach (array_slice(explode('/', $path), 1) as $segment) {
$currentPath .= '/' . $segment;
foreach (explode('/', $path) as $segment) {
if (!empty($segment)) {
$currentPath .= '/' . $segment;
}
if ($pointer->has($currentPath . '/id')) {
$baseUrl .= $pointer->get($currentPath . '/id');
$parentScope = $this->resolveIdAgainstParentScope($pointer->get($currentPath . '/id'), $parentScope);
}
}
$ref = $baseUrl . $ref;

return $ref;
return $parentScope;
}

/**
* Resolve an ID against the parent scope, and return the resolved scope.
*
* @param string $id The ID of the Schema.
* @param string $parentScope The parent scope of the ID.
* @return string
*/
private function resolveIdAgainstParentScope($id, $parentScope)
{
if ($this->isRelativeRef($id)) {
// A relative reference is appended to the current scope.
return $parentScope .= $id;
}

// An absolute reference replaces the scope entirely.
return $id;
}
}
13 changes: 13 additions & 0 deletions src/functions.php
Expand Up @@ -152,3 +152,16 @@ function compare($leftOperand, $rightOperand)
{
return Comparator::compare($leftOperand, $rightOperand);
}

/**
* Removes the fragment from a reference.
*
* @param string $ref
* @return string
*/
function strip_fragment($ref)
{
$fragment = parse_url($ref, PHP_URL_FRAGMENT);

return $fragment ? str_replace($fragment, '', $ref) : $ref;
}
22 changes: 22 additions & 0 deletions tests/DereferencerTest.php
Expand Up @@ -29,6 +29,28 @@ public function testRemote()
$this->assertSame($result->definitions->positiveIntegerDefault0, $result->properties->minItems->resolve());
}

public function testRemoteWithoutId()
{
$deref = new Dereferencer();
$result = $deref->dereference('http://localhost:1234/albums.json');

$this->assertSame('string', $result->items->properties->title->type);
}

public function testRemoteWithoutIdThrowsIfDereferencingAnObject()
{
$this->setExpectedException(\InvalidArgumentException::class);
$deref = new Dereferencer();
$result = $deref->dereference(json_decode('{"$ref": "album.json"}'));
}

public function testRemoteWithFragment()
{
$deref = new Dereferencer();
$result = $deref->dereference('http://localhost:1234/subSchemas.json#/relativeRefToInteger');
$this->assertSame(['type' => 'integer'], (array) $result);
}

public function testRecursiveRootPointer()
{
$deref = new Dereferencer();
Expand Down
17 changes: 17 additions & 0 deletions tests/ValidatorTest.php
Expand Up @@ -34,6 +34,13 @@ public function draft4CoreTests()
}, glob(schema_test_suite_path() . '/draft4/*.json'));
}

public function unofficialTests()
{
return array_map(function ($file) {
return [$file];
}, glob(__DIR__ . '/unofficial/*.json'));
}

/**
* @dataProvider allDraft4Tests
*
Expand Down Expand Up @@ -62,6 +69,16 @@ public function testDraft4CoreTestsPassWithoutBcMath($testFile)
$this->runTestCase($test);
}

/**
* @dataProvider unofficialTests
*/
public function testUnofficialTests($testFile)
{
$test = json_decode(file_get_contents($testFile));

$this->runTestCase($test);
}

/**
* Run a test case from the standard test suite.
*
Expand Down

0 comments on commit 0c80197

Please sign in to comment.