Skip to content

Commit

Permalink
feat: allow JSON normalizer to set JSON formatting options
Browse files Browse the repository at this point in the history
By default, the JSON normalizer will only use `JSON_THROW_ON_ERROR` to
encode non-boolean scalar values. There might be use-cases where
projects will need flags like `JSON_JSON_PRESERVE_ZERO_FRACTION`.

This can be achieved by passing these flags to the new
`JsonNormalizer::withOptions()` method:

```php
namespace My\App;

$normalizer = (new \CuyZ\Valinor\MapperBuilder())
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::json())
    ->withOptions(\JSON_PRESERVE_ZERO_FRACTION);

$lowerManhattanAsJson = $normalizer->normalize(
    new \My\App\Coordinates(
        longitude: 40.7128,
        latitude: -74.0000
    )
);

// `$lowerManhattanAsJson` is a valid JSON string representing the data:
// {"longitude":40.7128,"latitude":-74.0000}
```

The method accepts an int-mask of the following `JSON_*` constant
representations:

- `JSON_HEX_QUOT`
- `JSON_HEX_TAG`
- `JSON_HEX_AMP`
- `JSON_HEX_APOS`
- `JSON_INVALID_UTF8_IGNORE`
- `JSON_INVALID_UTF8_SUBSTITUTE`
- `JSON_NUMERIC_CHECK`
- `JSON_PRESERVE_ZERO_FRACTION`
- `JSON_UNESCAPED_LINE_TERMINATORS`
- `JSON_UNESCAPED_SLASHES`
- `JSON_UNESCAPED_UNICODE`

`JSON_THROW_ON_ERROR` is always enforced and thus is not accepted.

See official doc for more information:
https://www.php.net/manual/en/json.constants.php
  • Loading branch information
boesing committed Apr 4, 2024
1 parent 4e04201 commit cd5df97
Show file tree
Hide file tree
Showing 5 changed files with 200 additions and 12 deletions.
46 changes: 46 additions & 0 deletions docs/pages/serialization/normalizing-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,54 @@ $normalizer = (new \CuyZ\Valinor\MapperBuilder())
$normalizer->normalize($users);
```

## Passing `json_encode` flags

By default, the JSON normalizer will only use `JSON_THROW_ON_ERROR` to encode
non-boolean scalar values. There might be use-cases where projects will need
flags like `JSON_JSON_PRESERVE_ZERO_FRACTION`.

This can be achieved by passing these flags to the
`JsonNormalizer::withOptions()` method:

```php
namespace My\App;

$normalizer = (new \CuyZ\Valinor\MapperBuilder())
->normalizer(\CuyZ\Valinor\Normalizer\Format::json())
->withOptions(\JSON_PRESERVE_ZERO_FRACTION);

$lowerManhattanAsJson = $normalizer->normalize(
new \My\App\Coordinates(
longitude: 40.7128,
latitude: -74.0000
)
);

// `$lowerManhattanAsJson` is a valid JSON string representing the data:
// {"longitude":40.7128,"latitude":-74.0000}
```

The method accepts an int-mask of the following `JSON_*` constant
representations ([see official doc for more information]):

- `JSON_HEX_QUOT`
- `JSON_HEX_TAG`
- `JSON_HEX_AMP`
- `JSON_HEX_APOS`
- `JSON_INVALID_UTF8_IGNORE`
- `JSON_INVALID_UTF8_SUBSTITUTE`
- `JSON_NUMERIC_CHECK`
- `JSON_PRESERVE_ZERO_FRACTION`
- `JSON_UNESCAPED_LINE_TERMINATORS`
- `JSON_UNESCAPED_SLASHES`
- `JSON_UNESCAPED_UNICODE`

`JSON_THROW_ON_ERROR` is always enforced and thus is not accepted.

[default transformations]: normalizer.md#supported-transformations

[registered transformers]: extending-normalizer.md

[can be streamed to a PHP resource]: #streaming-to-a-php-resource

[see official doc for more information]: https://www.php.net/manual/en/json.constants.php
13 changes: 11 additions & 2 deletions src/Normalizer/Formatter/JsonFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use function is_scalar;
use function json_encode;

use const JSON_THROW_ON_ERROR;

/** @internal */
final class JsonFormatter implements StreamFormatter
{
Expand All @@ -24,6 +26,7 @@ final class JsonFormatter implements StreamFormatter
*/
public function __construct(
private mixed $resource,
private int $jsonEncodingOptions,
) {}

public function format(mixed $value): void
Expand All @@ -33,7 +36,11 @@ public function format(mixed $value): void
} elseif (is_bool($value)) {
$this->write($value ? 'true' : 'false');
} elseif (is_scalar($value)) {
$this->write(json_encode($value, JSON_THROW_ON_ERROR));
/**
* @phpstan-ignore-next-line / Due to the new json encoding options feature, it is not possible to let SA
* tools understand that JSON_THROW_ON_ERROR is always set.
*/
$this->write(json_encode($value, $this->jsonEncodingOptions));
} elseif (is_iterable($value)) {
// Note: when a generator is formatted, it is considered as a list
// if its first key is 0. This is done early because the first JSON
Expand All @@ -59,7 +66,9 @@ public function format(mixed $value): void
$isFirst = false;

if (! $isList) {
$this->write('"' . $key . '":');
$key = json_encode((string)$key, $this->jsonEncodingOptions);

$this->write($key . ':');
}

$this->format($val);
Expand Down
84 changes: 79 additions & 5 deletions src/Normalizer/JsonNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use CuyZ\Valinor\Normalizer\Formatter\JsonFormatter;
use CuyZ\Valinor\Normalizer\Transformer\RecursiveTransformer;

use RuntimeException;

use function fclose;
Expand All @@ -15,16 +14,91 @@
use function is_resource;
use function stream_get_contents;

use const JSON_HEX_AMP;
use const JSON_HEX_APOS;
use const JSON_HEX_QUOT;
use const JSON_HEX_TAG;
use const JSON_INVALID_UTF8_IGNORE;
use const JSON_INVALID_UTF8_SUBSTITUTE;
use const JSON_NUMERIC_CHECK;
use const JSON_PRESERVE_ZERO_FRACTION;
use const JSON_THROW_ON_ERROR;
use const JSON_UNESCAPED_LINE_TERMINATORS;
use const JSON_UNESCAPED_SLASHES;
use const JSON_UNESCAPED_UNICODE;

/**
* @api
*
* @implements Normalizer<string>
*/
final class JsonNormalizer implements Normalizer
{
private const ACCEPTABLE_JSON_OPTIONS = JSON_HEX_QUOT
| JSON_HEX_TAG
| JSON_HEX_AMP
| JSON_HEX_APOS
| JSON_INVALID_UTF8_IGNORE
| JSON_INVALID_UTF8_SUBSTITUTE
| JSON_NUMERIC_CHECK
| JSON_PRESERVE_ZERO_FRACTION
| JSON_UNESCAPED_LINE_TERMINATORS
| JSON_UNESCAPED_SLASHES
| JSON_UNESCAPED_UNICODE
| JSON_THROW_ON_ERROR;

private RecursiveTransformer $transformer;

private int $jsonEncodingOptions;

/**
* Internal note
* -------------
*
* We could use the `int-mask-of<JsonNormalizer::JSON_*>` annotation
* to let PHPStan infer the type of the accepted options, but some caveats
* were found:
* - SA tools are not able to infer that we end up having only accepted
* options. Might be fixed with https://github.com/phpstan/phpstan/issues/9384
* for PHPStan but Psalm does have some (not all) issues as well.
* - Using this annotation provokes *severe* performance issues when
* running PHPStan analysis, therefore it is preferable to avoid it.
*/
public function __construct(
private RecursiveTransformer $transformer,
) {}
RecursiveTransformer $transformer,
int $jsonEncodingOptions = JSON_THROW_ON_ERROR,
) {
$this->transformer = $transformer;
$this->jsonEncodingOptions = (self::ACCEPTABLE_JSON_OPTIONS & $jsonEncodingOptions) | JSON_THROW_ON_ERROR;
}

/**
* By default, the JSON normalizer will only use `JSON_THROW_ON_ERROR` to
* encode non-boolean scalar values. There might be use-cases where projects
* will need flags like `JSON_JSON_PRESERVE_ZERO_FRACTION`.
*
* This can be achieved by passing these flags to this method:
*
* ```php
* $normalizer = (new \CuyZ\Valinor\MapperBuilder())
* ->normalizer(\CuyZ\Valinor\Normalizer\Format::json())
* ->withOptions(\JSON_PRESERVE_ZERO_FRACTION);
*
* $lowerManhattanAsJson = $normalizer->normalize(
* new \My\App\Coordinates(
* longitude: 40.7128,
* latitude: -74.0000
* )
* );
*
* // `$lowerManhattanAsJson` is a valid JSON string representing the data:
* // {"longitude":40.7128,"latitude":-74.0000}
* ```
*/
public function withOptions(int $options): self
{
return new self($this->transformer, $options);
}

public function normalize(mixed $value): string
{
Expand All @@ -33,7 +107,7 @@ public function normalize(mixed $value): string
/** @var resource $resource */
$resource = fopen('php://memory', 'w');

(new JsonFormatter($resource))->format($value);
(new JsonFormatter($resource, $this->jsonEncodingOptions))->format($value);

rewind($resource);

Expand Down Expand Up @@ -80,6 +154,6 @@ public function streamTo(mixed $resource): StreamNormalizer
throw new RuntimeException('Expected a valid resource, got ' . get_debug_type($resource));
}

return new StreamNormalizer($this->transformer, new JsonFormatter($resource));
return new StreamNormalizer($this->transformer, new JsonFormatter($resource, $this->jsonEncodingOptions));
}
}
65 changes: 61 additions & 4 deletions tests/Integration/Normalizer/NormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@

use function array_merge;

use const JSON_HEX_TAG;
use const JSON_THROW_ON_ERROR;

final class NormalizerTest extends IntegrationTestCase
{
/**
Expand All @@ -42,6 +45,7 @@ public function test_normalize_basic_values_yields_expected_output(
string $expectedJson,
array $transformers = [],
array $transformerAttributes = [],
int $jsonEncodingOptions = JSON_THROW_ON_ERROR,
): void {
$builder = $this->mapperBuilder();

Expand All @@ -56,7 +60,9 @@ public function test_normalize_basic_values_yields_expected_output(
}

$arrayResult = $builder->normalizer(Format::array())->normalize($input);
$jsonResult = $builder->normalizer(Format::json())->normalize($input);
$jsonNormalizer = $builder->normalizer(Format::json())->withOptions($jsonEncodingOptions);

$jsonResult = $jsonNormalizer->normalize($input);

self::assertSame($expectedArray, $arrayResult);
self::assertSame($expectedJson, $jsonResult);
Expand Down Expand Up @@ -141,18 +147,20 @@ public static function normalize_basic_values_yields_expected_output_data_provid

yield 'array of scalar' => [
'input' => [
0 => 'first value',
'string' => 'foo',
'integer' => 42,
'float' => 1337.404,
'boolean' => true,
],
'expected array' => [
0 => 'first value',
'string' => 'foo',
'integer' => 42,
'float' => 1337.404,
'boolean' => true,
],
'expected json' => '{"string":"foo","integer":42,"float":1337.404,"boolean":true}',
'expected json' => '{"0":"first value","string":"foo","integer":42,"float":1337.404,"boolean":true}',
];

yield 'array with transformer' => [
Expand Down Expand Up @@ -303,13 +311,13 @@ public function getIterator(): Traversable
];

yield 'date with default transformer' => [
'input' => new DateTimeImmutable('1971-11-08'),
'input' => new DateTimeImmutable('1971-11-08', new DateTimeZone('UTC')),
'expected array' => '1971-11-08T00:00:00.000000+00:00',
'expected json' => '"1971-11-08T00:00:00.000000+00:00"',
];

yield 'date with transformer' => [
'input' => new DateTimeImmutable('1971-11-08'),
'input' => new DateTimeImmutable('1971-11-08', new DateTimeZone('UTC')),
'expected array' => '1971-11-08',
'expected json' => '"1971-11-08"',
'transformers' => [
Expand Down Expand Up @@ -641,6 +649,28 @@ public function __construct(
SomeKeyTransformerInterface::class,
],
];

yield 'object with float property containing zero fraction' => [
'input' => new class () {
public function __construct(
public float $value = 1.0,
) {}
},
'expected array' => ['value' => 1.0],
'expected_json' => '{"value":1.0}',
'transformers' => [],
'transformerAttributes' => [],
'jsonEncodingOptions' => JSON_PRESERVE_ZERO_FRACTION,
];

yield 'array with key and value containing ampersand' => [
'input' => ['foo&bar' => 'bar&baz'],
'expected array' => ['foo&bar' => 'bar&baz'],
'expected_json' => '{"foo\u0026bar":"bar\u0026baz"}',
'transformers' => [],
'transformerAttributes' => [],
'jsonEncodingOptions' => JSON_HEX_AMP,
];
}

public function test_generator_of_scalar_yields_expected_array(): void
Expand Down Expand Up @@ -1036,6 +1066,33 @@ public function test_giving_invalid_resource_to_json_normalizer_throws_exception
// @phpstan-ignore-next-line
$this->mapperBuilder()->normalizer(Format::json())->streamTo('foo');
}

public function test_json_transformer_will_always_throw_on_error(): void
{
$normalizer = $this->mapperBuilder()->normalizer(Format::json());
self::assertSame(JSON_THROW_ON_ERROR, (fn () => $this->jsonEncodingOptions)->call($normalizer));

$normalizer = $normalizer->withOptions(JSON_HEX_TAG);
self::assertSame(JSON_THROW_ON_ERROR | JSON_HEX_TAG, (fn () => $this->jsonEncodingOptions)->call($normalizer));

$normalizer = $normalizer->withOptions(JSON_HEX_TAG & ~JSON_THROW_ON_ERROR);
self::assertSame(JSON_THROW_ON_ERROR | JSON_HEX_TAG, (fn () => $this->jsonEncodingOptions)->call($normalizer));
}

public function test_json_transformer_only_accepts_acceptable_json_options(): void
{
$normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_FORCE_OBJECT);
self::assertSame(JSON_THROW_ON_ERROR, (fn () => $this->jsonEncodingOptions)->call($normalizer));

$normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_PARTIAL_OUTPUT_ON_ERROR);
self::assertSame(JSON_THROW_ON_ERROR, (fn () => $this->jsonEncodingOptions)->call($normalizer));

$normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_PRETTY_PRINT);
self::assertSame(JSON_THROW_ON_ERROR, (fn () => $this->jsonEncodingOptions)->call($normalizer));

$normalizer = $this->mapperBuilder()->normalizer(Format::json())->withOptions(JSON_FORCE_OBJECT | JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_PRETTY_PRINT);
self::assertSame(JSON_THROW_ON_ERROR, (fn () => $this->jsonEncodingOptions)->call($normalizer));
}
}

final class BasicObject
Expand Down
4 changes: 3 additions & 1 deletion tests/Unit/Normalizer/Formatter/JsonFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

use function fopen;

use const JSON_THROW_ON_ERROR;

final class JsonFormatterTest extends TestCase
{
public function test_invalid_closure_type_given_to_formatter_throws_exception(): void
Expand All @@ -21,6 +23,6 @@ public function test_invalid_closure_type_given_to_formatter_throws_exception():
/** @var resource $resource */
$resource = fopen('php://memory', 'r+');

(new JsonFormatter($resource))->format(fn () => 42);
(new JsonFormatter($resource, JSON_THROW_ON_ERROR))->format(fn () => 42);
}
}

0 comments on commit cd5df97

Please sign in to comment.