Skip to content

Commit

Permalink
Enhancement: Implement SchemaNormalizer
Browse files Browse the repository at this point in the history
  • Loading branch information
localheinz committed Jan 13, 2018
1 parent 7019af0 commit a09293b
Show file tree
Hide file tree
Showing 28 changed files with 2,880 additions and 0 deletions.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ This package comes with the following normalizers:
* [`Localheinz\Json\Normalizer\IndentNormalizer`](#indentnormalizer)
* [`Localheinz\Json\Normalizer\JsonEncodeNormalizer`](#jsonencodenormalizer)
* [`Localheinz\Json\Normalizer\NoFinalNewLineNormalizer`](#nofinalnewlinenormalizer)
* [`Localheinz\Json\Normalizer\SchemaNormalizer`](#schemanormalizer)

:bulb: All of these normalizers implement the `Localheinz\Json\Normalizer\NormalizerInterface`.

Expand Down Expand Up @@ -221,6 +222,52 @@ $normalized = $normalizer->normalize($json);

The normalized version will now not have a final new line or any whitespace at the end.

### `SchemaNormalizer`

If you want to rebuild a JSON file according to a JSON schema, you can use the `SchemaNormalizer`.

Let's assume the following schema

```json
{
"type": "object",
"additionalProperties": false,
"properties": {
"name" : {
"type" : "string"
},
"role" : {
"type" : "string"
}
}
}
```

exists at `/schema/example.json`.

```php
use Localheinz\Json\Normalizer;

$json = <<<'JSON'
{
"url": "https://localheinz.com",
"name": "Andreas Möller"
}
JSON;

$normalizer = new Normalizer\SchemaNormalizer('file:///schema/example.json');

$normalized = $normalizer->normalize($json);
```

The normalized version will now be structured according to the JSON
schema (in this simple case, properties will be reordered). Internally,
the `SchemaNormalizer` uses [`justinrainbow/json-schema`](https://github.com/justinrainbow/json-schema)
to resolve schemas, as well as to ensure (before and after normalization)
that the JSON document is valid.

:bulb: For more information about JSON schema, visit [json-schema.org](http://json-schema.org).

## Contributing

Please have a look at [`CONTRIBUTING.md`](.github/CONTRIBUTING.md).
Expand Down
228 changes: 228 additions & 0 deletions src/SchemaNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
<?php

declare(strict_types=1);

/**
* Copyright (c) 2018 Andreas Möller.
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*
* @see https://github.com/localheinz/json-normalizer
*/

namespace Localheinz\Json\Normalizer;

use JsonSchema\Constraints;
use JsonSchema\Exception;
use JsonSchema\SchemaStorage;

final class SchemaNormalizer implements NormalizerInterface
{
/**
* @var string
*/
private $schemaUri;

/**
* @var SchemaStorage
*/
private $schemaStorage;

/**
* @var Validator\SchemaValidatorInterface
*/
private $schemaValidator;

public function __construct(
string $schemaUri,
SchemaStorage $schemaStorage = null,
Validator\SchemaValidatorInterface $schemaValidator = null
) {
if (null === $schemaStorage) {
$schemaStorage = new SchemaStorage();
}

if (null === $schemaValidator) {
$schemaValidator = new Validator\SchemaValidator(new \JsonSchema\Validator(new Constraints\Factory(
$schemaStorage,
$schemaStorage->getUriRetriever()
)));
}

$this->schemaUri = $schemaUri;
$this->schemaStorage = $schemaStorage;
$this->schemaValidator = $schemaValidator;
}

public function normalize(string $json): string
{
$decoded = \json_decode($json);

if (null === $decoded && JSON_ERROR_NONE !== \json_last_error()) {
throw new \InvalidArgumentException(\sprintf(
'"%s" is not valid JSON.',
$json
));
}

try {
$schema = $this->schemaStorage->getSchema($this->schemaUri);
} catch (Exception\UriResolverException $exception) {
throw new \RuntimeException(\sprintf(
'Schema URI "%s" could not be resolved.',
$this->schemaUri
));
} catch (Exception\ResourceNotFoundException $exception) {
throw new \RuntimeException(\sprintf(
'Schema URI "%s" does not reference a document that could be read.',
$this->schemaUri
));
} catch (Exception\InvalidSchemaMediaTypeException $exception) {
throw new \RuntimeException(\sprintf(
'Schema URI "%s" does not reference a document with media type "application/schema+json".',
$this->schemaUri
));
} catch (Exception\JsonDecodingException $exception) {
throw new \RuntimeException(\sprintf(
'Schema URI "%s" does not reference a document with valid JSON syntax.',
$this->schemaUri
));
}

if (!$this->schemaValidator->isValid($decoded, $schema)) {
throw new \InvalidArgumentException(\sprintf(
'Original is not valid according to schema "%s".',
$this->schemaUri
));
}

$normalized = $this->normalizeData(
$decoded,
$schema
);

if (!$this->schemaValidator->isValid($normalized, $schema)) {
throw new \RuntimeException(\sprintf(
'Normalized is not valid according to schema "%s".',
$this->schemaUri
));
}

return \json_encode($normalized);
}

/**
* @param array|\stdClass $data
* @param \stdClass $schema
*
* @throws \InvalidArgumentException
*
* @return array|bool|int|string
*/
private function normalizeData($data, \stdClass $schema)
{
if (\is_array($data)) {
return $this->normalizeArray(
$data,
$schema
);
}

if ($data instanceof \stdClass) {
return $this->normalizeObject(
$data,
$schema
);
}

return $data;
}

private function normalizeArray(array $array, \stdClass $arraySchema): array
{
if (!$this->hasItemDefinition($arraySchema)) {
return $array;
}

$itemSchema = $arraySchema->items;

return \array_map(function ($item) use ($itemSchema) {
return $this->normalizeData(
$item,
$itemSchema
);
}, $array);
}

private function normalizeObject(\stdClass $object, \stdClass $objectSchema): \stdClass
{
if ($this->hasReferenceDefinition($objectSchema)) {
$objectSchema = $this->schemaStorage->resolveRefSchema($objectSchema);
}

if (!$this->hasPropertyDefinitions($objectSchema)) {
return $object;
}

$normalized = new \stdClass();

/** @var \stdClass[] $objectProperties */
$objectProperties = \array_intersect_key(
\get_object_vars($objectSchema->properties),
\get_object_vars($object)
);

foreach ($objectProperties as $name => $valueSchema) {
if ($valueSchema instanceof \stdClass && $this->hasReferenceDefinition($valueSchema)) {
$valueSchema = $this->schemaStorage->resolveRefSchema($valueSchema);
}

$value = $object->{$name};

if ($valueSchema instanceof \stdClass && !\is_scalar($value)) {
$value = $this->normalizeData(
$value,
$valueSchema
);
}

$normalized->{$name} = $value;

unset($object->{$name});
}

$remainingProperties = \get_object_vars($object);

if (\count($remainingProperties)) {
\ksort($remainingProperties);

foreach ($remainingProperties as $name => $value) {
$normalized->{$name} = $value;
}
}

return $normalized;
}

private function hasPropertyDefinitions(\stdClass $schema): bool
{
return \property_exists($schema, 'type')
&& 'object' === $schema->type
&& \property_exists($schema, 'properties')
&& $schema->properties instanceof \stdClass;
}

private function hasItemDefinition(\stdClass $schema): bool
{
return \property_exists($schema, 'type')
&& 'array' === $schema->type
&& \property_exists($schema, 'items')
&& $schema->items instanceof \stdClass;
}

private function hasReferenceDefinition(\stdClass $schema): bool
{
return \property_exists($schema, '$ref') && \is_string($schema->{'$ref'});
}
}
Empty file removed test/Bench/.gitkeep
Empty file.
64 changes: 64 additions & 0 deletions test/Bench/SchemaNormalizerBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

/**
* Copyright (c) 2018 Andreas Möller.
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*
* @see https://github.com/localheinz/json-normalizer
*/

namespace Localheinz\Json\Normalizer\Test\Bench;

use Localheinz\Json\Normalizer\SchemaNormalizer;
use PhpBench\Benchmark\Metadata\Annotations\Iterations;
use PhpBench\Benchmark\Metadata\Annotations\Revs;

final class SchemaNormalizerBench
{
/**
* @Revs(5)
* @Iterations(1)
*/
public function benchNormalizeProjectComposerFile()
{
$this->normalize(
__DIR__ . '/../../composer.json',
$this->localComposerSchema()
);
}

/**
* @see https://github.com/search?utf8=✓&q=repositories+filename%3Acomposer.json+size%3A%3E25000+path%3A%2F+&type=Code
*
* @Revs(5)
* @Iterations(1)
*/
public function benchNormalizeLargeComposerFile()
{
$this->normalize(
__DIR__ . '/../Fixture/LargeComposerFile/composer.json',
$this->localComposerSchema()
);
}

private function normalize(string $file, string $schemaUri)
{
$original = \file_get_contents($file);

$normalizer = new SchemaNormalizer($schemaUri);

$normalizer->normalize($original);
}

private function localComposerSchema(): string
{
return \sprintf(
'file://%s',
__DIR__ . '/../Fixture/composer-schema.json'
);
}
}
Loading

0 comments on commit a09293b

Please sign in to comment.