diff --git a/docs/pages/serialization/normalizing-json.md b/docs/pages/serialization/normalizing-json.md index b341b4d6..a935d92e 100644 --- a/docs/pages/serialization/normalizing-json.md +++ b/docs/pages/serialization/normalizing-json.md @@ -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 diff --git a/src/Normalizer/Formatter/JsonFormatter.php b/src/Normalizer/Formatter/JsonFormatter.php index 930daf9f..feec8356 100644 --- a/src/Normalizer/Formatter/JsonFormatter.php +++ b/src/Normalizer/Formatter/JsonFormatter.php @@ -16,6 +16,8 @@ use function is_scalar; use function json_encode; +use const JSON_THROW_ON_ERROR; + /** @internal */ final class JsonFormatter implements StreamFormatter { @@ -24,6 +26,7 @@ final class JsonFormatter implements StreamFormatter */ public function __construct( private mixed $resource, + private int $jsonEncodingOptions, ) {} public function format(mixed $value): void @@ -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 @@ -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); diff --git a/src/Normalizer/JsonNormalizer.php b/src/Normalizer/JsonNormalizer.php index b8716f26..872955ed 100644 --- a/src/Normalizer/JsonNormalizer.php +++ b/src/Normalizer/JsonNormalizer.php @@ -6,7 +6,6 @@ use CuyZ\Valinor\Normalizer\Formatter\JsonFormatter; use CuyZ\Valinor\Normalizer\Transformer\RecursiveTransformer; - use RuntimeException; use function fclose; @@ -15,6 +14,19 @@ 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 * @@ -22,9 +34,71 @@ */ 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` 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 { @@ -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); @@ -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)); } } diff --git a/tests/Integration/Normalizer/NormalizerTest.php b/tests/Integration/Normalizer/NormalizerTest.php index d120799e..64a48859 100644 --- a/tests/Integration/Normalizer/NormalizerTest.php +++ b/tests/Integration/Normalizer/NormalizerTest.php @@ -29,6 +29,9 @@ use function array_merge; +use const JSON_HEX_TAG; +use const JSON_THROW_ON_ERROR; + final class NormalizerTest extends IntegrationTestCase { /** @@ -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(); @@ -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); @@ -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' => [ @@ -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' => [ @@ -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 @@ -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 diff --git a/tests/Unit/Normalizer/Formatter/JsonFormatterTest.php b/tests/Unit/Normalizer/Formatter/JsonFormatterTest.php index 47c530d8..2af117c7 100644 --- a/tests/Unit/Normalizer/Formatter/JsonFormatterTest.php +++ b/tests/Unit/Normalizer/Formatter/JsonFormatterTest.php @@ -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 @@ -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); } }