diff --git a/src/JsonSchema/Constraints/Undefined.php b/src/JsonSchema/Constraints/Undefined.php index 94264cca..c62e4465 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 * @@ -89,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); } @@ -115,4 +120,14 @@ protected function validateCommonProperties($value, $schema = null, $path = null } } } + + protected function validateUri($schemaUri = null, $schema, $path = null, $i = null) + { + $resolver = new \JsonSchema\Uri\UriResolver(); + + if ($resolver->isValid($schemaUri)) { + $schemaId = property_exists($schema, 'id') ? $schema->id : null; + return Validator::retrieveUri($resolver->resolve($schemaUri, $schemaId)); + } + } } 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..85cc94b8 --- /dev/null +++ b/src/JsonSchema/Uri/UriResolver.php @@ -0,0 +1,150 @@ + + */ +class UriResolver +{ + /** + * Parses a URI into five main components + * + * @param string $uri + * @return array + */ + public function parse($uri) + { + preg_match('|^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?|', $uri, $match); + + $components = array(); + if (5 < count($match)) { + $components = array( + 'scheme' => $match[2], + 'authority' => $match[4], + 'path' => $match[5] + ); + } + if (7 < count($match)) { + $components['query'] = $match[7]; + } + if (9 < count($match)) { + $components['fragment'] = $match[9]; + } + + 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); + + return !empty($components); + } +} \ No newline at end of file diff --git a/src/JsonSchema/Validator.php b/src/JsonSchema/Validator.php index db492125..c98ed229 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..acf58b69 --- /dev/null +++ b/tests/JsonSchema/Tests/Uri/UriRetrieverTest.php @@ -0,0 +1,153 @@ +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->at(0)) + ->method('retrieve') + ->with($this->equalTo('http://some.host.at/somewhere/parent')) + ->will($this->returnValue($returnSchema)); + + $curlRetriever->expects($this->atLeastOnce()) // index 1 and/or 3 + ->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($childSchema, $parentSchema) + { + 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); + } + + /** + * @dataProvider jsonProvider + * @expectedException JsonSchema\Exception\InvalidSchemaMediaTypeException + */ + public function testInvalidSchemaMediaType($childSchema, $parentSchema) + { + $curlRetrieverMock = $this->getCurlRetrieverMock($parentSchema, 'text/html'); + + Validator::setUriRetriever($curlRetrieverMock); + + $json = '{"childProp":"infant", "parentProp":false}'; + $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 = <<