From 378bbd7f6a9717e24e719fb152b2215f65cb5aca Mon Sep 17 00:00:00 2001 From: Sander Coolen Date: Thu, 17 May 2012 16:25:41 +0200 Subject: [PATCH 1/2] [WIP] Retrieval of JSON Schema URIs. Resolving of relative URIs is not working. --- src/JsonSchema/Constraints/Undefined.php | 14 ++ .../InvalidSchemaMediaTypeException.php | 17 +++ .../Exception/JsonDecodingException.php | 40 ++++++ .../Exception/ResourceNotFoundException.php | 17 +++ src/JsonSchema/Uri/Retrievers/Curl.php | 82 +++++++++++ .../Uri/Retrievers/FileGetContents.php | 73 ++++++++++ .../Uri/Retrievers/UriRetrieverInterface.php | 22 +++ src/JsonSchema/Uri/UriResolver.php | 47 +++++++ src/JsonSchema/Validator.php | 43 ++++++ .../JsonSchema/Tests/Uri/UriRetrieverTest.php | 131 ++++++++++++++++++ 10 files changed, 486 insertions(+) create mode 100644 src/JsonSchema/Exception/InvalidSchemaMediaTypeException.php create mode 100644 src/JsonSchema/Exception/JsonDecodingException.php create mode 100644 src/JsonSchema/Exception/ResourceNotFoundException.php create mode 100644 src/JsonSchema/Uri/Retrievers/Curl.php create mode 100644 src/JsonSchema/Uri/Retrievers/FileGetContents.php create mode 100644 src/JsonSchema/Uri/Retrievers/UriRetrieverInterface.php create mode 100644 src/JsonSchema/Uri/UriResolver.php create mode 100644 tests/JsonSchema/Tests/Uri/UriRetrieverTest.php diff --git a/src/JsonSchema/Constraints/Undefined.php b/src/JsonSchema/Constraints/Undefined.php index 94264cca..af99fb63 100644 --- a/src/JsonSchema/Constraints/Undefined.php +++ b/src/JsonSchema/Constraints/Undefined.php @@ -9,6 +9,8 @@ namespace JsonSchema\Constraints; +use JsonSchema\Validator; + /** * The Undefined Constraints * @@ -22,6 +24,9 @@ class Undefined extends Constraint */ public function check($value, $schema = null, $path = null, $i = null) { + if (is_string($schema)) { + $schema = $this->validateUri($value, $schema, $path, $i); + } if (!is_object($schema)) { return; } @@ -115,4 +120,13 @@ protected function validateCommonProperties($value, $schema = null, $path = null } } } + + protected function validateUri($value, $schemaUri = null, $path = null, $i = null) + { + $resolver = new \JsonSchema\Uri\UriResolver(); + + if ($resolver->isValid($schemaUri)) { + return Validator::retrieveUri($schemaUri); + } + } } diff --git a/src/JsonSchema/Exception/InvalidSchemaMediaTypeException.php b/src/JsonSchema/Exception/InvalidSchemaMediaTypeException.php new file mode 100644 index 00000000..2e946770 --- /dev/null +++ b/src/JsonSchema/Exception/InvalidSchemaMediaTypeException.php @@ -0,0 +1,17 @@ + + */ +class Curl implements UriRetrieverInterface +{ + protected $contentType; + protected $messageBody; + + public function __construct() + { + if (!function_exists('curl_init')) { + throw new \RuntimeException("cURL not installed"); + } + } + + public function retrieve($uri) + { + $ch = curl_init(); + + curl_setopt($ch, CURLOPT_URL, $uri); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, array('Accept: ' . Validator::SCHEMA_MEDIA_TYPE)); + + $response = curl_exec($ch); + if (false === $response) { + throw new ResourceNotFoundException('JSON schema not found'); + } + + $this->fetchMessageBody($response); + $this->fetchContentType($response); + + curl_close($ch); + + return $this->messageBody; + } + + /** + * @param string $response cURL HTTP response + */ + private function fetchMessageBody($response) + { + preg_match("/(?:\r\n){2}(.*)$/ms", $response, $match); + $this->messageBody = $match[1]; + } + + /** + * @param string $response cURL HTTP response + * @return boolean Whether the Content-Type header was found or not + */ + protected function fetchContentType($response) + { + if (0 < preg_match("/Content-Type:(\V*)/ims", $response, $match)) { + $this->contentType = trim($match[1]); + + return true; + } + return false; + } + + public function getContentType() + { + return $this->contentType; + } +} \ No newline at end of file diff --git a/src/JsonSchema/Uri/Retrievers/FileGetContents.php b/src/JsonSchema/Uri/Retrievers/FileGetContents.php new file mode 100644 index 00000000..9c508718 --- /dev/null +++ b/src/JsonSchema/Uri/Retrievers/FileGetContents.php @@ -0,0 +1,73 @@ + + */ +class FileGetContents implements UriRetrieverInterface +{ + protected $contentType; + protected $messageBody; + + public function retrieve($uri) + { + $context = stream_context_create(array( + 'http' => array( + 'method' => 'GET', + 'header' => "Accept: " . Validator::SCHEMA_MEDIA_TYPE + ))); + + $response = file_get_contents($uri); + if (false === $response) { + throw new ResourceNotFoundException('JSON schema not found'); + } + + $this->messageBody = $response; + $this->fetchContentType($http_response_header); + + return $this->messageBody; + } + + /** + * @param array $headers HTTP Response Headers + * @return boolean Whether the Content-Type header was found or not + */ + private function fetchContentType(array $headers) + { + foreach ($headers as $header) { + if ($this->contentType = self::getContentTypeMatchInHeader($header)) { + return true; + } + } + + return false; + } + + /** + * @param string $header + * @return string|null + */ + protected static function getContentTypeMatchInHeader($header) + { + if (0 < preg_match("/Content-Type:(\V*)/ims", $header, $match)) { + return trim($match[1]); + } + } + + public function getContentType() + { + return $this->contentType; + } +} \ No newline at end of file diff --git a/src/JsonSchema/Uri/Retrievers/UriRetrieverInterface.php b/src/JsonSchema/Uri/Retrievers/UriRetrieverInterface.php new file mode 100644 index 00000000..44bfaf1b --- /dev/null +++ b/src/JsonSchema/Uri/Retrievers/UriRetrieverInterface.php @@ -0,0 +1,22 @@ + + */ +interface UriRetrieverInterface +{ + public function retrieve($uri); + + public function getContentType(); +} \ No newline at end of file diff --git a/src/JsonSchema/Uri/UriResolver.php b/src/JsonSchema/Uri/UriResolver.php new file mode 100644 index 00000000..5aa178b2 --- /dev/null +++ b/src/JsonSchema/Uri/UriResolver.php @@ -0,0 +1,47 @@ + + */ +class UriResolver +{ + public function parse($uri) + { + preg_match('|^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?|', $uri, $match); + + $components = array(); + if (5 < count($match)) { + $components = array( + 'scheme' => $match[2], + 'host' => $match[4], + 'authority' => $match[5] + ); + } + if (7 < count($match)) { + $components['query'] = $match[7]; + } + if (9 < count($match)) { + $components['fragment'] = $match[9]; + } + + return $components; + } + + public function isValid($uri) + { + $components = $this->parse($uri); + + return !empty($components); + } +} \ No newline at end of file diff --git a/src/JsonSchema/Validator.php b/src/JsonSchema/Validator.php index db492125..9b71da47 100644 --- a/src/JsonSchema/Validator.php +++ b/src/JsonSchema/Validator.php @@ -12,6 +12,11 @@ use JsonSchema\Constraints\Schema; use JsonSchema\Constraints\Constraint; +use JsonSchema\Exception\InvalidSchemaMediaTypeException; +use JsonSchema\Exception\JsonDecodingException; + +use JsonSchema\Uri\Retrievers\UriRetrieverInterface; + /** * A JsonSchema Constraint * @@ -21,6 +26,10 @@ */ class Validator extends Constraint { + const SCHEMA_MEDIA_TYPE = 'application/schema+json'; + + private static $uriRetriever; + /** * Validates the given data against the schema and returns an object containing the results * Both the php object and the schema are supposed to be a result of a json_decode call. @@ -35,4 +44,38 @@ public function check($value, $schema = null, $path = null, $i = null) $this->addErrors($validator->getErrors()); } + + /** + * Sets the URI retriever the validator will use. FileGetContents by default + * + * @param UriRetrieverInterface $retriever + */ + public static function setUriRetriever(UriRetrieverInterface $retriever) + { + self::$uriRetriever = $retriever; + } + + /** + * @param string $uri JSON Schema URI + * @return string JSON Schema contents + * @throws InvalidSchemaMediaType for invalid media types + */ + public static function retrieveUri($uri) + { + if (null === self::$uriRetriever) { + self::setUriRetriever(new Uri\Retrievers\FileGetContents); + } + $contents = self::$uriRetriever->retrieve($uri); + if (self::SCHEMA_MEDIA_TYPE !== self::$uriRetriever->getContentType()) { + throw new InvalidSchemaMediaTypeException(sprintf('Media type %s expected', self::SCHEMA_MEDIA_TYPE)); + } + $jsonSchema = json_decode($contents); + if (JSON_ERROR_NONE < $error = json_last_error()) { + throw new JsonDecodingException($error); + } + + // TODO validate using schema) + $jsonSchema->_id = $uri; + return $jsonSchema; + } } \ No newline at end of file diff --git a/tests/JsonSchema/Tests/Uri/UriRetrieverTest.php b/tests/JsonSchema/Tests/Uri/UriRetrieverTest.php new file mode 100644 index 00000000..370741b5 --- /dev/null +++ b/tests/JsonSchema/Tests/Uri/UriRetrieverTest.php @@ -0,0 +1,131 @@ +validator = new Validator(); + } + + private function getCurlRetrieverMock($returnSchema, $returnMediaType = Validator::SCHEMA_MEDIA_TYPE) + { + $curlRetriever = $this->getMock('JsonSchema\Uri\Retrievers\Curl', array('retrieve', 'getContentType')); + + $curlRetriever->expects($this->once()) + ->method('retrieve') + ->with($this->equalTo('http://some.host.at/somewhere/parent')) + ->will($this->returnValue($returnSchema)); + + $curlRetriever->expects($this->once()) + ->method('getContentType') + ->will($this->returnValue($returnMediaType)); + + return $curlRetriever; + } + + /** + * @dataProvider jsonProvider + */ + public function testChildExtendsParent($childSchema, $parentSchema) + { + $curlRetrieverMock = $this->getCurlRetrieverMock($parentSchema); + + Validator::setUriRetriever($curlRetrieverMock); + + $json = '{"childProp":"infant", "parentProp":false}'; + $decodedJson = json_decode($json); + $decodedJsonSchema = json_decode($childSchema); + + $this->validator->check($decodedJson, $decodedJsonSchema); + $this->assertTrue($this->validator->isValid()); + } + + /** + * @dataProvider jsonProvider + */ + public function testResolveRelativeUri() + { + $this->markTestIncomplete(); + } + + /** + * @dataProvider jsonProvider + * @expectedException JsonSchema\Exception\InvalidSchemaMediaTypeException + */ + public function testInvalidSchemaMediaType($childSchema, $parentSchema) + { + $curlRetrieverMock = $this->getCurlRetrieverMock($parentSchema, 'text/html'); + + Validator::setUriRetriever($curlRetrieverMock); + + $json = '{}'; + $decodedJson = json_decode($json); + $decodedJsonSchema = json_decode($childSchema); + + $this->validator->check($decodedJson, $decodedJsonSchema); + } + + /** + * @dataProvider jsonProvider + * @expectedException JsonSchema\Exception\JsonDecodingException + */ + public function testParentJsonError($childSchema, $parentSchema) + { + $curlRetrieverMock = $this->getCurlRetrieverMock('', 'application/schema+json'); + + Validator::setUriRetriever($curlRetrieverMock); + + $json = '{}'; + $decodedJson = json_decode($json); + $decodedJsonSchema = json_decode($childSchema); + + $this->validator->check($decodedJson, $decodedJsonSchema); + } + + public function jsonProvider() + { + $childSchema = << Date: Thu, 17 May 2012 19:50:49 +0200 Subject: [PATCH 2/2] Added support for relative URIs. Note that UriResolver is nowhere near RFC 3986 compliant --- src/JsonSchema/Constraints/Undefined.php | 11 +- .../Exception/UriResolverException.php | 17 +++ src/JsonSchema/Uri/UriResolver.php | 107 +++++++++++++++++- src/JsonSchema/Validator.php | 2 +- .../JsonSchema/Tests/Uri/UriRetrieverTest.php | 32 +++++- 5 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 src/JsonSchema/Exception/UriResolverException.php diff --git a/src/JsonSchema/Constraints/Undefined.php b/src/JsonSchema/Constraints/Undefined.php index af99fb63..c62e4465 100644 --- a/src/JsonSchema/Constraints/Undefined.php +++ b/src/JsonSchema/Constraints/Undefined.php @@ -24,9 +24,6 @@ class Undefined extends Constraint */ public function check($value, $schema = null, $path = null, $i = null) { - if (is_string($schema)) { - $schema = $this->validateUri($value, $schema, $path, $i); - } if (!is_object($schema)) { return; } @@ -94,6 +91,9 @@ protected function validateCommonProperties($value, $schema = null, $path = null { // if it extends another schema, it must pass that schema as well if (isset($schema->extends)) { + if (is_string($schema->extends)) { + $schema->extends = $this->validateUri($schema->extends, $schema, $path, $i); + } $this->checkUndefined($value, $schema->extends, $path, $i); } @@ -121,12 +121,13 @@ protected function validateCommonProperties($value, $schema = null, $path = null } } - protected function validateUri($value, $schemaUri = null, $path = null, $i = null) + protected function validateUri($schemaUri = null, $schema, $path = null, $i = null) { $resolver = new \JsonSchema\Uri\UriResolver(); if ($resolver->isValid($schemaUri)) { - return Validator::retrieveUri($schemaUri); + $schemaId = property_exists($schema, 'id') ? $schema->id : null; + return Validator::retrieveUri($resolver->resolve($schemaUri, $schemaId)); } } } diff --git a/src/JsonSchema/Exception/UriResolverException.php b/src/JsonSchema/Exception/UriResolverException.php new file mode 100644 index 00000000..6d0b0949 --- /dev/null +++ b/src/JsonSchema/Exception/UriResolverException.php @@ -0,0 +1,17 @@ + $match[2], - 'host' => $match[4], - 'authority' => $match[5] + 'authority' => $match[4], + 'path' => $match[5] ); } if (7 < count($match)) { @@ -38,6 +46,101 @@ public function parse($uri) return $components; } + /** + * Builds a URI based on n array with the main components + * + * @param array $components + * @return string + */ + public function generate(array $components) + { + $uri = $components['scheme'] . '://' + . $components['authority'] + . $components['path']; + + if (array_key_exists('query', $components)) { + $uri .= $components['query']; + } + if (array_key_exists('fragment', $components)) { + $uri .= $components['fragment']; + } + + return $uri; + } + + /** + * Resolves a URI + * + * @param string $uri Absolute or relative + * @param type $baseUri Optional base URI + * @return string + */ + public function resolve($uri, $baseUri = null) + { + $components = $this->parse($uri); + $path = $components['path']; + + if ((array_key_exists('scheme', $components)) && ('http' === $components['scheme'])) { + return $uri; + } + $baseComponents = $this->parse($baseUri); + $basePath = $baseComponents['path']; + + $baseComponents['path'] = self::combineRelativePathWithBasePath($path, $basePath); + + return $this->generate($baseComponents); + } + + /** + * Tries to glue a relative path onto an absolute one + * + * @param string $relativePath + * @param string $basePath + * @return string Merged path + * @throws UriResolverException + */ + private static function combineRelativePathWithBasePath($relativePath, $basePath) + { + $relativePath = self::normalizePath($relativePath); + $basePathSegments = self::getPathSegments($basePath); + + preg_match('|^/?(\.\./(?:\./)*)*|', $relativePath, $match); + $numLevelUp = strlen($match[0]) /3 + 1; + if ($numLevelUp >= count($basePathSegments)) { + throw new UriResolverException(sprintf("Unable to resolve URI '%s' from base '%s'", $relativePath, $basePath)); + } + $basePathSegments = array_slice($basePathSegments, 0, -$numLevelUp); + $path = preg_replace('|^/?(\.\./(\./)*)*|', '', $relativePath); + + return implode(DIRECTORY_SEPARATOR, $basePathSegments) . '/' . $path; + } + + /** + * Normalizes a URI path component by removing dot-slash and double slashes + * + * @param string $path + * @return string + */ + private static function normalizePath($path) + { + $path = preg_replace('|((?parse($uri); diff --git a/src/JsonSchema/Validator.php b/src/JsonSchema/Validator.php index 9b71da47..c98ed229 100644 --- a/src/JsonSchema/Validator.php +++ b/src/JsonSchema/Validator.php @@ -75,7 +75,7 @@ public static function retrieveUri($uri) } // TODO validate using schema) - $jsonSchema->_id = $uri; + $jsonSchema->id = $uri; return $jsonSchema; } } \ No newline at end of file diff --git a/tests/JsonSchema/Tests/Uri/UriRetrieverTest.php b/tests/JsonSchema/Tests/Uri/UriRetrieverTest.php index 370741b5..acf58b69 100644 --- a/tests/JsonSchema/Tests/Uri/UriRetrieverTest.php +++ b/tests/JsonSchema/Tests/Uri/UriRetrieverTest.php @@ -24,12 +24,12 @@ private function getCurlRetrieverMock($returnSchema, $returnMediaType = Validato { $curlRetriever = $this->getMock('JsonSchema\Uri\Retrievers\Curl', array('retrieve', 'getContentType')); - $curlRetriever->expects($this->once()) + $curlRetriever->expects($this->at(0)) ->method('retrieve') ->with($this->equalTo('http://some.host.at/somewhere/parent')) ->will($this->returnValue($returnSchema)); - $curlRetriever->expects($this->once()) + $curlRetriever->expects($this->atLeastOnce()) // index 1 and/or 3 ->method('getContentType') ->will($this->returnValue($returnMediaType)); @@ -56,9 +56,31 @@ public function testChildExtendsParent($childSchema, $parentSchema) /** * @dataProvider jsonProvider */ - public function testResolveRelativeUri() + public function testResolveRelativeUri($childSchema, $parentSchema) { - $this->markTestIncomplete(); + self::setParentSchemaExtendsValue($parentSchema, 'grandparent'); + $curlRetrieverMock = $this->getCurlRetrieverMock($parentSchema); + + $curlRetrieverMock->expects($this->at(2)) + ->method('retrieve') + ->with($this->equalTo('http://some.host.at/somewhere/grandparent')) + ->will($this->returnValue('{"type":"object","title":"grand-parent"}')); + + Validator::setUriRetriever($curlRetrieverMock); + + $json = '{"childProp":"infant", "parentProp":false}'; + $decodedJson = json_decode($json); + $decodedJsonSchema = json_decode($childSchema); + + $this->validator->check($decodedJson, $decodedJsonSchema); + $this->assertTrue($this->validator->isValid()); + } + + private static function setParentSchemaExtendsValue(&$parentSchema, $value) + { + $parentSchemaDecoded = json_decode($parentSchema, true); + $parentSchemaDecoded['extends'] = $value; + $parentSchema = json_encode($parentSchemaDecoded); } /** @@ -71,7 +93,7 @@ public function testInvalidSchemaMediaType($childSchema, $parentSchema) Validator::setUriRetriever($curlRetrieverMock); - $json = '{}'; + $json = '{"childProp":"infant", "parentProp":false}'; $decodedJson = json_decode($json); $decodedJsonSchema = json_decode($childSchema);