diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 5296bda25513e..9aec80d8a96c4 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -237,6 +237,7 @@ class FrameworkExtension extends Extension private bool $mailerConfigEnabled = false; private bool $httpClientConfigEnabled = false; private bool $notifierConfigEnabled = false; + private bool $serializerConfigEnabled = false; private bool $propertyAccessConfigEnabled = false; private static bool $lockConfigEnabled = false; @@ -386,7 +387,7 @@ public function load(array $configs, ContainerBuilder $container) $container->getDefinition('exception_listener')->replaceArgument(3, $config['exceptions']); - if ($this->isConfigEnabled($container, $config['serializer'])) { + if ($this->serializerConfigEnabled = $this->isConfigEnabled($container, $config['serializer'])) { if (!class_exists(\Symfony\Component\Serializer\Serializer::class)) { throw new LogicException('Serializer support cannot be enabled as the Serializer component is not installed. Try running "composer require symfony/serializer-pack".'); } @@ -516,7 +517,7 @@ public function load(array $configs, ContainerBuilder $container) $this->registerNotifierConfiguration($config['notifier'], $container, $loader); } - // profiler depends on form, validation, translation, messenger, mailer, http-client, notifier being registered + // profiler depends on form, validation, translation, messenger, mailer, http-client, notifier, serializer being registered $this->registerProfilerConfiguration($config['profiler'], $container, $loader); $this->addAnnotatedClassesToCompile([ @@ -798,6 +799,10 @@ private function registerProfilerConfiguration(array $config, ContainerBuilder $ $loader->load('notifier_debug.php'); } + if ($this->serializerConfigEnabled) { + $loader->load('serializer_debug.php'); + } + $container->setParameter('profiler_listener.only_exceptions', $config['only_exceptions']); $container->setParameter('profiler_listener.only_main_requests', $config['only_main_requests']); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer_debug.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer_debug.php new file mode 100644 index 0000000000000..28fc4ea48c514 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer_debug.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Debug\TraceableSerializer; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('debug.serializer', TraceableSerializer::class) + ->decorate('serializer', null, 255) + ->args([ + service('debug.serializer.inner'), + service('serializer.data_collector'), + ]) + + ->set('serializer.data_collector', SerializerDataCollector::class) + ->tag('data_collector', [ + 'template' => '@WebProfiler/Collector/serializer.html.twig', + 'id' => 'serializer', + ]) + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 512512b4612a3..f915572ea730e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -85,7 +85,7 @@ "symfony/mime": "<5.4", "symfony/property-info": "<5.4", "symfony/property-access": "<5.4", - "symfony/serializer": "<5.4", + "symfony/serializer": "<6.1", "symfony/security-csrf": "<5.4", "symfony/security-core": "<5.4", "symfony/stopwatch": "<5.4", diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/serializer.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/serializer.html.twig new file mode 100644 index 0000000000000..c9197742f065a --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/serializer.html.twig @@ -0,0 +1,228 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% import _self as helper %} + +{% block menu %} + + {{ include('@WebProfiler/Icon/validator.svg') }} + Serializer + +{% endblock %} + +{% block panel %} +

Serializer

+ {% if not collector.handledCount %} +
+

Nothing was handled by the serializer for this request.

+
+ {% else %} +
+
+ {{ collector.handledCount }} + Handled +
+ +
+ {{ '%.2f'|format(collector.totalTime * 1000) }} ms + Total time +
+
+ +
+ {{ helper.render_serialize_tab(collector.data, true) }} + {{ helper.render_serialize_tab(collector.data, false) }} + + {{ helper.render_normalize_tab(collector.data, true) }} + {{ helper.render_normalize_tab(collector.data, false) }} + + {{ helper.render_encode_tab(collector.data, true) }} + {{ helper.render_encode_tab(collector.data, false) }} +
+ {% endif %} +{% endblock %} + +{% macro render_serialize_tab(collectorData, serialize) %} + {% set data = serialize ? collectorData.serialize : collectorData.deserialize %} + {% set cellPrefix = serialize ? 'serialize' : 'deserialize' %} + +
+

{{ serialize ? 'serialize' : 'deserialize' }} {{ data|length }}

+
+ {% if not data|length %} +
+

Nothing was {{ serialize ? 'serialized' : 'deserialized' }}.

+
+ {% else %} + + + + + + + + + + + + {% for item in data %} + + + + + + + + {% endfor %} + +
DataContextNormalizerEncoderTime
{{ helper.render_data_cell(item, loop.index, cellPrefix) }}{{ helper.render_context_cell(item, loop.index, cellPrefix) }}{{ helper.render_normalizer_cell(item, loop.index, cellPrefix) }}{{ helper.render_encoder_cell(item, loop.index, cellPrefix) }}{{ helper.render_time_cell(item) }}
+ {% endif %} +
+
+{% endmacro %} + +{% macro render_normalize_tab(collectorData, normalize) %} + {% set data = normalize ? collectorData.normalize : collectorData.denormalize %} + {% set cellPrefix = normalize ? 'normalize' : 'denormalize' %} + +
+

{{ normalize ? 'normalize' : 'denormalize' }} {{ data|length }}

+
+ {% if not data|length %} +
+

Nothing was {{ normalize ? 'normalized' : 'denormalized' }}.

+
+ {% else %} + + + + + + + + + + + {% for item in data %} + + + + + + + {% endfor %} + +
DataContextNormalizerTime
{{ helper.render_data_cell(item, loop.index, cellPrefix) }}{{ helper.render_context_cell(item, loop.index, cellPrefix) }}{{ helper.render_normalizer_cell(item, loop.index, cellPrefix) }}{{ helper.render_time_cell(item) }}
+ {% endif %} +
+
+{% endmacro %} + +{% macro render_encode_tab(collectorData, encode) %} + {% set data = encode ? collectorData.encode : collectorData.decode %} + {% set cellPrefix = encode ? 'encode' : 'decode' %} + +
+

{{ encode ? 'encode' : 'decode' }} {{ data|length }}

+
+ {% if not data|length %} +
+

Nothing was {{ encode ? 'encoded' : 'decoded' }}.

+
+ {% else %} + + + + + + + + + + + {% for item in data %} + + + + + + + {% endfor %} + +
DataContextEncoderTime
{{ helper.render_data_cell(item, loop.index, cellPrefix) }}{{ helper.render_context_cell(item, loop.index, cellPrefix) }}{{ helper.render_encoder_cell(item, loop.index, cellPrefix) }}{{ helper.render_time_cell(item) }}
+ {% endif %} +
+
+{% endmacro %} + +{% macro render_data_cell(item, index, method) %} + {% set data_id = 'data-' ~ method ~ '-' ~ index %} + + {{ item.dataType }} + +
+ Show contents +
+ {{ profiler_dump(item.data) }} +
+
+{% endmacro %} + +{% macro render_context_cell(item, index, method) %} + {% set context_id = 'context-' ~ method ~ '-' ~ index %} + + {% if item.type %} + Type: {{ item.type }} +
Format: {{ item.format ? item.format : 'none' }}
+ {% else %} + Format: {{ item.format ? item.format : 'none' }} + {% endif %} + +
+ Show context +
+ {{ profiler_dump(item.context) }} +
+
+{% endmacro %} + +{% macro render_normalizer_cell(item, index, method) %} + {% set nested_normalizers_id = 'nested-normalizers-' ~ method ~ '-' ~ index %} + + {{ item.normalizer.class }} ({{ '%.2f'|format(item.normalizer.time * 1000) }} ms) + + {% if item.normalization|length > 1 %} +
+ Show nested normalizers +
+ +
+
+ {% endif %} +{% endmacro %} + +{% macro render_encoder_cell(item, index, method) %} + {% set nested_encoders_id = 'nested-encoders-' ~ method ~ '-' ~ index %} + + {{ item.encoder.class }} ({{ '%.2f'|format(item.encoder.time * 1000) }} ms) + + {% if item.encoding|length > 1 %} +
+ Show nested encoders +
+ +
+
+ {% endif %} +{% endmacro %} + +{% macro render_time_cell(item) %} + {{ '%.2f'|format(item.time * 1000) }} ms +{% endmacro %} diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 28b194edb8fca..9bee69dbd4fbb 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 6.1 --- + * Add `TraceableSerializer`, `TraceableNormalizer`, `TraceableEncoder` and `SerializerDataCollector` to integrate with the web profiler * Add the ability to create contexts using context builders * Set `Context` annotation as not final * Deprecate `ContextAwareNormalizerInterface`, use `NormalizerInterface` instead diff --git a/src/Symfony/Component/Serializer/DataCollector/SerializerDataCollector.php b/src/Symfony/Component/Serializer/DataCollector/SerializerDataCollector.php new file mode 100644 index 0000000000000..57a8aecab02be --- /dev/null +++ b/src/Symfony/Component/Serializer/DataCollector/SerializerDataCollector.php @@ -0,0 +1,239 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\DataCollector; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; +use Symfony\Component\Serializer\Debug\TraceableSerializer; +use Symfony\Component\VarDumper\Cloner\Data; + +/** + * @author Mathias Arlaud + * + * @final + * + * @internal + */ +class SerializerDataCollector extends DataCollector implements LateDataCollectorInterface +{ + private array $collected = []; + + public function reset(): void + { + $this->data = []; + $this->collected = []; + } + + /** + * {@inheritDoc} + */ + public function collect(Request $request, Response $response, \Throwable $exception = null): void + { + // Everything is collected during the request, and formatted on kernel terminate. + } + + /** + * {@inheritDoc} + */ + public function getName(): string + { + return 'serializer'; + } + + public function getData(): Data|array + { + return $this->data; + } + + public function getHandledCount(): int + { + return array_sum(array_map('count', $this->data)); + } + + public function getTotalTime(): float + { + $totalTime = 0; + + foreach ($this->data as $handled) { + $totalTime += array_sum(array_map(fn (array $el): float => $el['time'], $handled)); + } + + return $totalTime; + } + + public function collectSerialize(string $traceId, mixed $data, string $format, array $context, float $time): void + { + unset($context[TraceableSerializer::DEBUG_TRACE_ID]); + + $this->collected[$traceId] = array_merge( + $this->collected[$traceId] ?? [], + compact('data', 'format', 'context', 'time'), + ['method' => 'serialize'], + ); + } + + public function collectDeserialize(string $traceId, mixed $data, string $type, string $format, array $context, float $time): void + { + unset($context[TraceableSerializer::DEBUG_TRACE_ID]); + + $this->collected[$traceId] = array_merge( + $this->collected[$traceId] ?? [], + compact('data', 'format', 'type', 'context', 'time'), + ['method' => 'deserialize'], + ); + } + + public function collectNormalize(string $traceId, mixed $data, ?string $format, array $context, float $time): void + { + unset($context[TraceableSerializer::DEBUG_TRACE_ID]); + + $this->collected[$traceId] = array_merge( + $this->collected[$traceId] ?? [], + compact('data', 'format', 'context', 'time'), + ['method' => 'normalize'], + ); + } + + public function collectDenormalize(string $traceId, mixed $data, string $type, ?string $format, array $context, float $time): void + { + unset($context[TraceableSerializer::DEBUG_TRACE_ID]); + + $this->collected[$traceId] = array_merge( + $this->collected[$traceId] ?? [], + compact('data', 'format', 'type', 'context', 'time'), + ['method' => 'denormalize'], + ); + } + + public function collectEncode(string $traceId, mixed $data, ?string $format, array $context, float $time): void + { + unset($context[TraceableSerializer::DEBUG_TRACE_ID]); + + $this->collected[$traceId] = array_merge( + $this->collected[$traceId] ?? [], + compact('data', 'format', 'context', 'time'), + ['method' => 'encode'], + ); + } + + public function collectDecode(string $traceId, mixed $data, ?string $format, array $context, float $time): void + { + unset($context[TraceableSerializer::DEBUG_TRACE_ID]); + + $this->collected[$traceId] = array_merge( + $this->collected[$traceId] ?? [], + compact('data', 'format', 'context', 'time'), + ['method' => 'decode'], + ); + } + + public function collectNormalization(string $traceId, string $normalizer, float $time): void + { + $method = 'normalize'; + + $this->collected[$traceId]['normalization'][] = compact('normalizer', 'method', 'time'); + } + + public function collectDenormalization(string $traceId, string $normalizer, float $time): void + { + $method = 'denormalize'; + + $this->collected[$traceId]['normalization'][] = compact('normalizer', 'method', 'time'); + } + + public function collectEncoding(string $traceId, string $encoder, float $time): void + { + $method = 'encode'; + + $this->collected[$traceId]['encoding'][] = compact('encoder', 'method', 'time'); + } + + public function collectDecoding(string $traceId, string $encoder, float $time): void + { + $method = 'decode'; + + $this->collected[$traceId]['encoding'][] = compact('encoder', 'method', 'time'); + } + + /** + * {@inheritDoc} + */ + public function lateCollect(): void + { + $this->data = [ + 'serialize' => [], + 'deserialize' => [], + 'normalize' => [], + 'denormalize' => [], + 'encode' => [], + 'decode' => [], + ]; + + foreach ($this->collected as $collected) { + $data = [ + 'data' => $this->cloneVar($collected['data']), + 'dataType' => get_debug_type($collected['data']), + 'type' => $collected['type'] ?? null, + 'format' => $collected['format'], + 'time' => $collected['time'], + 'context' => $this->cloneVar($collected['context']), + 'normalization' => [], + 'encoding' => [], + ]; + + if (isset($collected['normalization'])) { + $mainNormalization = array_pop($collected['normalization']); + + $data['normalizer'] = ['time' => $mainNormalization['time']] + $this->getMethodLocation($mainNormalization['normalizer'], $mainNormalization['method']); + + foreach ($collected['normalization'] as $normalization) { + if (!isset($data['normalization'][$normalization['normalizer']])) { + $data['normalization'][$normalization['normalizer']] = ['time' => 0, 'calls' => 0] + $this->getMethodLocation($normalization['normalizer'], $normalization['method']); + } + + ++$data['normalization'][$normalization['normalizer']]['calls']; + $data['normalization'][$normalization['normalizer']]['time'] += $normalization['time']; + } + } + + if (isset($collected['encoding'])) { + $mainEncoding = array_pop($collected['encoding']); + + $data['encoder'] = ['time' => $mainEncoding['time']] + $this->getMethodLocation($mainEncoding['encoder'], $mainEncoding['method']); + + foreach ($collected['encoding'] as $encoding) { + if (!isset($data['encoding'][$encoding['encoder']])) { + $data['encoding'][$encoding['encoder']] = ['time' => 0, 'calls' => 0] + $this->getMethodLocation($encoding['encoder'], $encoding['method']); + } + + ++$data['encoding'][$encoding['encoder']]['calls']; + $data['encoding'][$encoding['encoder']]['time'] += $encoding['time']; + } + } + + $this->data[$collected['method']][] = $data; + } + } + + private function getMethodLocation(string $class, string $method): array + { + $reflection = new \ReflectionClass($class); + + return [ + 'class' => $reflection->getShortName(), + 'file' => $reflection->getFileName(), + 'line' => $reflection->getMethod($method)->getStartLine(), + ]; + } +} diff --git a/src/Symfony/Component/Serializer/Debug/TraceableEncoder.php b/src/Symfony/Component/Serializer/Debug/TraceableEncoder.php new file mode 100644 index 0000000000000..cd4a351c6804a --- /dev/null +++ b/src/Symfony/Component/Serializer/Debug/TraceableEncoder.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Debug; + +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Component\Serializer\Encoder\EncoderInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Tests\Encoder\NormalizationAwareEncoder; + +/** + * Collects some data about encoding. + * + * @author Mathias Arlaud + * + * @final + * + * @internal + */ +class TraceableEncoder implements EncoderInterface, DecoderInterface, SerializerAwareInterface +{ + public function __construct( + private EncoderInterface|DecoderInterface $encoder, + private SerializerDataCollector $dataCollector, + ) { + } + + /** + * {@inheritDoc} + */ + public function encode(mixed $data, string $format, array $context = []): string + { + if (!$this->encoder instanceof EncoderInterface) { + throw new \BadMethodCallException(sprintf('The "%s()" method cannot be called as nested encoder doesn\'t implements "%s".', __METHOD__, EncoderInterface::class)); + } + + $startTime = microtime(true); + $encoded = $this->encoder->encode($data, $format, $context); + $time = microtime(true) - $startTime; + + if ($traceId = ($context[TraceableSerializer::DEBUG_TRACE_ID] ?? null)) { + $this->dataCollector->collectEncoding($traceId, \get_class($this->encoder), $time); + } + + return $encoded; + } + + /** + * {@inheritDoc} + * + * @param array $context + */ + public function supportsEncoding(string $format /*, array $context = [] */): bool + { + if (!$this->encoder instanceof EncoderInterface) { + return false; + } + + $context = \func_num_args() > 1 ? func_get_arg(1) : []; + + return $this->encoder->supportsEncoding($format, $context); + } + + /** + * {@inheritDoc} + */ + public function decode(string $data, string $format, array $context = []): mixed + { + if (!$this->encoder instanceof DecoderInterface) { + throw new \BadMethodCallException(sprintf('The "%s()" method cannot be called as nested encoder doesn\'t implements "%s".', __METHOD__, DecoderInterface::class)); + } + + $startTime = microtime(true); + $encoded = $this->encoder->decode($data, $format, $context); + $time = microtime(true) - $startTime; + + if ($traceId = ($context[TraceableSerializer::DEBUG_TRACE_ID] ?? null)) { + $this->dataCollector->collectDecoding($traceId, \get_class($this->encoder), $time); + } + + return $encoded; + } + + /** + * {@inheritDoc} + * + * @param array $context + */ + public function supportsDecoding(string $format /*, array $context = [] */): bool + { + if (!$this->encoder instanceof DecoderInterface) { + return false; + } + + $context = \func_num_args() > 1 ? func_get_arg(1) : []; + + return $this->encoder->supportsDecoding($format, $context); + } + + /** + * {@inheritDoc} + */ + public function setSerializer(SerializerInterface $serializer) + { + if (!$this->encoder instanceof SerializerAwareInterface) { + return; + } + + $this->encoder->setSerializer($serializer); + } + + public function needsNormalization(): bool + { + return !$this->encoder instanceof NormalizationAwareEncoder; + } +} diff --git a/src/Symfony/Component/Serializer/Debug/TraceableNormalizer.php b/src/Symfony/Component/Serializer/Debug/TraceableNormalizer.php new file mode 100644 index 0000000000000..e32475e3cd859 --- /dev/null +++ b/src/Symfony/Component/Serializer/Debug/TraceableNormalizer.php @@ -0,0 +1,155 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Debug; + +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Collects some data about normalization. + * + * @author Mathias Arlaud + * + * @final + * + * @internal + */ +class TraceableNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, NormalizerAwareInterface, DenormalizerAwareInterface, CacheableSupportsMethodInterface +{ + public function __construct( + private NormalizerInterface|DenormalizerInterface $normalizer, + private SerializerDataCollector $dataCollector, + ) { + } + + /** + * {@inheritDoc} + */ + public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + if (!$this->normalizer instanceof NormalizerInterface) { + throw new \BadMethodCallException(sprintf('The "%s()" method cannot be called as nested normalizer doesn\'t implements "%s".', __METHOD__, NormalizerInterface::class)); + } + + $startTime = microtime(true); + $normalized = $this->normalizer->normalize($object, $format, $context); + $time = microtime(true) - $startTime; + + if ($traceId = ($context[TraceableSerializer::DEBUG_TRACE_ID] ?? null)) { + $this->dataCollector->collectNormalization($traceId, \get_class($this->normalizer), $time); + } + + return $normalized; + } + + /** + * {@inheritDoc} + * + * @param array $context + */ + public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */): bool + { + if (!$this->normalizer instanceof NormalizerInterface) { + return false; + } + + $context = \func_num_args() > 2 ? func_get_arg(2) : []; + + return $this->normalizer->supportsNormalization($data, $format, $context); + } + + /** + * {@inheritDoc} + */ + public function denormalize(mixed $data, string $type, string $format = null, array $context = []): mixed + { + if (!$this->normalizer instanceof DenormalizerInterface) { + throw new \BadMethodCallException(sprintf('The "%s()" method cannot be called as nested normalizer doesn\'t implements "%s".', __METHOD__, DenormalizerInterface::class)); + } + + $startTime = microtime(true); + $denormalized = $this->normalizer->denormalize($data, $type, $format, $context); + $time = microtime(true) - $startTime; + + if ($traceId = ($context[TraceableSerializer::DEBUG_TRACE_ID] ?? null)) { + $this->dataCollector->collectDenormalization($traceId, \get_class($this->normalizer), $time); + } + + return $denormalized; + } + + /** + * {@inheritDoc} + * + * @param array $context + */ + public function supportsDenormalization(mixed $data, string $type, string $format = null /*, array $context = [] */): bool + { + if (!$this->normalizer instanceof DenormalizerInterface) { + return false; + } + + $context = \func_num_args() > 3 ? func_get_arg(3) : []; + + return $this->normalizer->supportsDenormalization($data, $type, $format, $context); + } + + /** + * {@inheritDoc} + */ + public function setSerializer(SerializerInterface $serializer) + { + if (!$this->normalizer instanceof SerializerAwareInterface) { + return; + } + + $this->normalizer->setSerializer($serializer); + } + + /** + * {@inheritDoc} + */ + public function setNormalizer(NormalizerInterface $normalizer) + { + if (!$this->normalizer instanceof NormalizerAwareInterface) { + return; + } + + $this->normalizer->setNormalizer($normalizer); + } + + /** + * {@inheritDoc} + */ + public function setDenormalizer(DenormalizerInterface $denormalizer) + { + if (!$this->normalizer instanceof DenormalizerAwareInterface) { + return; + } + + $this->normalizer->setDenormalizer($denormalizer); + } + + /** + * {@inheritDoc} + */ + public function hasCacheableSupportsMethod(): bool + { + return $this->normalizer instanceof CacheableSupportsMethodInterface && $this->normalizer->hasCacheableSupportsMethod(); + } +} diff --git a/src/Symfony/Component/Serializer/Debug/TraceableSerializer.php b/src/Symfony/Component/Serializer/Debug/TraceableSerializer.php new file mode 100644 index 0000000000000..d98d0017b69fc --- /dev/null +++ b/src/Symfony/Component/Serializer/Debug/TraceableSerializer.php @@ -0,0 +1,182 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Debug; + +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Component\Serializer\Encoder\EncoderInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Collects some data about serialization. + * + * @author Mathias Arlaud + * + * @final + * @internal + */ +class TraceableSerializer implements SerializerInterface, NormalizerInterface, DenormalizerInterface, EncoderInterface, DecoderInterface +{ + public const DEBUG_TRACE_ID = 'debug_trace_id'; + + public function __construct( + private SerializerInterface&NormalizerInterface&DenormalizerInterface&EncoderInterface&DecoderInterface $serializer, + private SerializerDataCollector $dataCollector, + ) { + } + + /** + * {@inheritdoc} + */ + final public function serialize(mixed $data, string $format, array $context = []): string + { + $context[self::DEBUG_TRACE_ID] = $traceId = uniqid(); + + $startTime = microtime(true); + $result = $this->serializer->serialize($data, $format, $context); + $time = microtime(true) - $startTime; + + $this->dataCollector->collectSerialize($traceId, $data, $format, $context, $time); + + return $result; + } + + /** + * {@inheritdoc} + */ + final public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed + { + $context[self::DEBUG_TRACE_ID] = $traceId = uniqid(); + + $startTime = microtime(true); + $result = $this->serializer->deserialize($data, $type, $format, $context); + $time = microtime(true) - $startTime; + + $this->dataCollector->collectDeserialize($traceId, $data, $type, $format, $context, $time); + + return $result; + } + + /** + * {@inheritdoc} + */ + final public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $context[self::DEBUG_TRACE_ID] = $traceId = uniqid(); + + $startTime = microtime(true); + $result = $this->serializer->normalize($object, $format, $context); + $time = microtime(true) - $startTime; + + $this->dataCollector->collectNormalize($traceId, $object, $format, $context, $time); + + return $result; + } + + /** + * {@inheritdoc} + */ + final public function denormalize(mixed $data, string $type, string $format = null, array $context = []): mixed + { + $context[self::DEBUG_TRACE_ID] = $traceId = uniqid(); + + $startTime = microtime(true); + $result = $this->serializer->denormalize($data, $type, $format, $context); + $time = microtime(true) - $startTime; + + $this->dataCollector->collectDenormalize($traceId, $data, $type, $format, $context, $time); + + return $result; + } + + /** + * {@inheritdoc} + */ + final public function encode(mixed $data, string $format, array $context = []): string + { + $context[self::DEBUG_TRACE_ID] = $traceId = uniqid(); + + $startTime = microtime(true); + $result = $this->serializer->encode($data, $format, $context); + $time = microtime(true) - $startTime; + + $this->dataCollector->collectEncode($traceId, $data, $format, $context, $time); + + return $result; + } + + /** + * {@inheritdoc} + */ + final public function decode(string $data, string $format, array $context = []): mixed + { + $context[self::DEBUG_TRACE_ID] = $traceId = uniqid(); + + $startTime = microtime(true); + $result = $this->serializer->decode($data, $format, $context); + $time = microtime(true) - $startTime; + + $this->dataCollector->collectDecode($traceId, $data, $format, $context, $time); + + return $result; + } + + /** + * {@inheritdoc} + * + * @param array $context + */ + final public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */): bool + { + $context = \func_num_args() > 2 ? \func_get_arg(2) : []; + + return $this->serializer->supportsNormalization($data, $format, $context); + } + + /** + * {@inheritdoc} + * + * @param array $context + */ + final public function supportsDenormalization(mixed $data, string $type, string $format = null /*, array $context = [] */): bool + { + $context = \func_num_args() > 3 ? \func_get_arg(3) : []; + + return $this->serializer->supportsDenormalization($data, $type, $format, $context); + } + + /** + * {@inheritdoc} + * + * @param array $context + */ + final public function supportsEncoding(string $format /*, array $context = [] */): bool + { + $context = \func_num_args() > 1 ? \func_get_arg(1) : []; + + return $this->serializer->supportsEncoding($format, $context); + } + + /** + * {@inheritdoc} + * + * @param array $context + */ + final public function supportsDecoding(string $format /*, array $context = [] */): bool + { + $context = \func_num_args() > 1 ? \func_get_arg(1) : []; + + return $this->serializer->supportsDecoding($format, $context); + } +} diff --git a/src/Symfony/Component/Serializer/DependencyInjection/SerializerPass.php b/src/Symfony/Component/Serializer/DependencyInjection/SerializerPass.php index 58ade72fe8e48..00bd0eea030f2 100644 --- a/src/Symfony/Component/Serializer/DependencyInjection/SerializerPass.php +++ b/src/Symfony/Component/Serializer/DependencyInjection/SerializerPass.php @@ -16,6 +16,9 @@ use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\RuntimeException; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Serializer\Debug\TraceableEncoder; +use Symfony\Component\Serializer\Debug\TraceableNormalizer; /** * Adds all services with the tags "serializer.encoder" and "serializer.normalizer" as @@ -34,18 +37,34 @@ public function process(ContainerBuilder $container) return; } - if (!$normalizers = $this->findAndSortTaggedServices('serializer.normalizer', $container)) { + if (!$normalizers = $container->findTaggedServiceIds('serializer.normalizer')) { throw new RuntimeException('You must tag at least one service as "serializer.normalizer" to use the "serializer" service.'); } + if ($container->getParameter('kernel.debug') && $container->hasDefinition('serializer.data_collector')) { + foreach (array_keys($normalizers) as $normalizer) { + $container->register('debug.'.$normalizer, TraceableNormalizer::class) + ->setDecoratedService($normalizer, null, 255) + ->setArguments([new Reference('debug.'.$normalizer.'.inner'), new Reference('serializer.data_collector')]); + } + } + $serializerDefinition = $container->getDefinition('serializer'); - $serializerDefinition->replaceArgument(0, $normalizers); + $serializerDefinition->replaceArgument(0, $this->findAndSortTaggedServices('serializer.normalizer', $container)); - if (!$encoders = $this->findAndSortTaggedServices('serializer.encoder', $container)) { + if (!$encoders = $container->findTaggedServiceIds('serializer.encoder')) { throw new RuntimeException('You must tag at least one service as "serializer.encoder" to use the "serializer" service.'); } - $serializerDefinition->replaceArgument(1, $encoders); + if ($container->getParameter('kernel.debug') && $container->hasDefinition('serializer.data_collector')) { + foreach (array_keys($encoders) as $encoder) { + $container->register('debug.'.$encoder, TraceableEncoder::class) + ->setDecoratedService($encoder, null, 255) + ->setArguments([new Reference('debug.'.$encoder.'.inner'), new Reference('serializer.data_collector')]); + } + } + + $serializerDefinition->replaceArgument(1, $this->findAndSortTaggedServices('serializer.encoder', $container)); if (!$container->hasParameter('serializer.default_context')) { return; diff --git a/src/Symfony/Component/Serializer/Encoder/ChainEncoder.php b/src/Symfony/Component/Serializer/Encoder/ChainEncoder.php index dec57e4945d52..70d7c9fd10645 100644 --- a/src/Symfony/Component/Serializer/Encoder/ChainEncoder.php +++ b/src/Symfony/Component/Serializer/Encoder/ChainEncoder.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Serializer\Encoder; +use Symfony\Component\Serializer\Debug\TraceableEncoder; use Symfony\Component\Serializer\Exception\RuntimeException; /** @@ -61,6 +62,10 @@ public function needsNormalization(string $format, array $context = []): bool { $encoder = $this->getEncoder($format, $context); + if ($encoder instanceof TraceableEncoder) { + return $encoder->needsNormalization(); + } + if (!$encoder instanceof NormalizationAwareInterface) { return true; } diff --git a/src/Symfony/Component/Serializer/Tests/DataCollector/SerializerDataCollectorTest.php b/src/Symfony/Component/Serializer/Tests/DataCollector/SerializerDataCollectorTest.php new file mode 100644 index 0000000000000..7b22b6064e0d2 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/DataCollector/SerializerDataCollectorTest.php @@ -0,0 +1,293 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\DataCollector; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Encoder\CsvEncoder; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; + +class SerializerDataCollectorTest extends TestCase +{ + public function testCollectSerialize() + { + $dataCollector = new SerializerDataCollector(); + + $dataCollector->collectSerialize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0); + $dataCollector->collectDeserialize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 1.0); + + $dataCollector->lateCollect(); + $collectedData = $this->castCollectedData($dataCollector->getData()); + + $this->assertSame([[ + 'data' => 'data', + 'dataType' => 'string', + 'type' => null, + 'format' => 'format', + 'time' => 1.0, + 'context' => ['foo' => 'bar'], + 'normalization' => [], + 'encoding' => [], + ]], $collectedData['serialize']); + + $this->assertSame([[ + 'data' => 'data', + 'dataType' => 'string', + 'type' => 'type', + 'format' => 'format', + 'time' => 1.0, + 'context' => ['foo' => 'bar'], + 'normalization' => [], + 'encoding' => [], + ]], $collectedData['deserialize']); + } + + public function testCollectNormalize() + { + $dataCollector = new SerializerDataCollector(); + + $dataCollector->collectNormalize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0); + $dataCollector->collectDenormalize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 1.0); + + $dataCollector->lateCollect(); + $collectedData = $this->castCollectedData($dataCollector->getData()); + + $this->assertSame([[ + 'data' => 'data', + 'dataType' => 'string', + 'type' => null, + 'format' => 'format', + 'time' => 1.0, + 'context' => ['foo' => 'bar'], + 'normalization' => [], + 'encoding' => [], + ]], $collectedData['normalize']); + + $this->assertSame([[ + 'data' => 'data', + 'dataType' => 'string', + 'type' => 'type', + 'format' => 'format', + 'time' => 1.0, + 'context' => ['foo' => 'bar'], + 'normalization' => [], + 'encoding' => [], + ]], $collectedData['denormalize']); + } + + public function testCollectEncode() + { + $dataCollector = new SerializerDataCollector(); + + $dataCollector->collectEncode('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0); + $dataCollector->collectDecode('traceIdTwo', 'data', 'format', ['foo' => 'bar'], 1.0); + + $dataCollector->lateCollect(); + $collectedData = $this->castCollectedData($dataCollector->getData()); + + $this->assertSame([[ + 'data' => 'data', + 'dataType' => 'string', + 'type' => null, + 'format' => 'format', + 'time' => 1.0, + 'context' => ['foo' => 'bar'], + 'normalization' => [], + 'encoding' => [], + ]], $collectedData['encode']); + + $this->assertSame([[ + 'data' => 'data', + 'dataType' => 'string', + 'type' => null, + 'format' => 'format', + 'time' => 1.0, + 'context' => ['foo' => 'bar'], + 'normalization' => [], + 'encoding' => [], + ]], $collectedData['decode']); + } + + public function testCollectNormalization() + { + $dataCollector = new SerializerDataCollector(); + + $dataCollector->collectNormalize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 20.0); + $dataCollector->collectDenormalize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 20.0); + + $dataCollector->collectNormalization('traceIdOne', DateTimeNormalizer::class, 1.0); + $dataCollector->collectNormalization('traceIdOne', DateTimeNormalizer::class, 2.0); + $dataCollector->collectNormalization('traceIdOne', ObjectNormalizer::class, 5.0); + $dataCollector->collectNormalization('traceIdOne', ObjectNormalizer::class, 10.0); + + $dataCollector->collectNormalization('traceIdTwo', DateTimeNormalizer::class, 1.0); + $dataCollector->collectNormalization('traceIdTwo', DateTimeNormalizer::class, 2.0); + $dataCollector->collectNormalization('traceIdTwo', ObjectNormalizer::class, 5.0); + $dataCollector->collectNormalization('traceIdTwo', ObjectNormalizer::class, 10.0); + + $dataCollector->lateCollect(); + $collectedData = $dataCollector->getData(); + + $this->assertSame(10.0, $collectedData['normalize'][0]['normalizer']['time']); + $this->assertSame('ObjectNormalizer', $collectedData['normalize'][0]['normalizer']['class']); + $this->assertArrayHasKey('file', $collectedData['normalize'][0]['normalizer']); + $this->assertArrayHasKey('line', $collectedData['normalize'][0]['normalizer']); + + $this->assertSame(3.0, $collectedData['normalize'][0]['normalization'][DateTimeNormalizer::class]['time']); + $this->assertSame(2, $collectedData['normalize'][0]['normalization'][DateTimeNormalizer::class]['calls']); + $this->assertSame('DateTimeNormalizer', $collectedData['normalize'][0]['normalization'][DateTimeNormalizer::class]['class']); + $this->assertArrayHasKey('file', $collectedData['normalize'][0]['normalization'][DateTimeNormalizer::class]); + $this->assertArrayHasKey('line', $collectedData['normalize'][0]['normalization'][DateTimeNormalizer::class]); + + $this->assertSame(5.0, $collectedData['normalize'][0]['normalization'][ObjectNormalizer::class]['time']); + $this->assertSame(1, $collectedData['normalize'][0]['normalization'][ObjectNormalizer::class]['calls']); + $this->assertSame('ObjectNormalizer', $collectedData['normalize'][0]['normalization'][ObjectNormalizer::class]['class']); + $this->assertArrayHasKey('file', $collectedData['normalize'][0]['normalization'][ObjectNormalizer::class]); + $this->assertArrayHasKey('line', $collectedData['normalize'][0]['normalization'][ObjectNormalizer::class]); + + $this->assertSame(10.0, $collectedData['denormalize'][0]['normalizer']['time']); + $this->assertSame('ObjectNormalizer', $collectedData['denormalize'][0]['normalizer']['class']); + $this->assertArrayHasKey('file', $collectedData['denormalize'][0]['normalizer']); + $this->assertArrayHasKey('line', $collectedData['denormalize'][0]['normalizer']); + + $this->assertSame(3.0, $collectedData['denormalize'][0]['normalization'][DateTimeNormalizer::class]['time']); + $this->assertSame(2, $collectedData['denormalize'][0]['normalization'][DateTimeNormalizer::class]['calls']); + $this->assertSame('DateTimeNormalizer', $collectedData['denormalize'][0]['normalization'][DateTimeNormalizer::class]['class']); + $this->assertArrayHasKey('file', $collectedData['denormalize'][0]['normalization'][DateTimeNormalizer::class]); + $this->assertArrayHasKey('line', $collectedData['denormalize'][0]['normalization'][DateTimeNormalizer::class]); + + $this->assertSame(5.0, $collectedData['denormalize'][0]['normalization'][ObjectNormalizer::class]['time']); + $this->assertSame(1, $collectedData['denormalize'][0]['normalization'][ObjectNormalizer::class]['calls']); + $this->assertSame('ObjectNormalizer', $collectedData['denormalize'][0]['normalization'][ObjectNormalizer::class]['class']); + $this->assertArrayHasKey('file', $collectedData['denormalize'][0]['normalization'][ObjectNormalizer::class]); + $this->assertArrayHasKey('line', $collectedData['denormalize'][0]['normalization'][ObjectNormalizer::class]); + } + + public function testCollectEncoding() + { + $dataCollector = new SerializerDataCollector(); + + $dataCollector->collectEncode('traceIdOne', 'data', 'format', ['foo' => 'bar'], 20.0); + $dataCollector->collectDecode('traceIdTwo', 'data', 'format', ['foo' => 'bar'], 20.0); + + $dataCollector->collectEncoding('traceIdOne', JsonEncoder::class, 1.0); + $dataCollector->collectEncoding('traceIdOne', JsonEncoder::class, 2.0); + $dataCollector->collectEncoding('traceIdOne', CsvEncoder::class, 5.0); + $dataCollector->collectEncoding('traceIdOne', CsvEncoder::class, 10.0); + + $dataCollector->collectDecoding('traceIdTwo', JsonEncoder::class, 1.0); + $dataCollector->collectDecoding('traceIdTwo', JsonEncoder::class, 2.0); + $dataCollector->collectDecoding('traceIdTwo', CsvEncoder::class, 5.0); + $dataCollector->collectDecoding('traceIdTwo', CsvEncoder::class, 10.0); + + $dataCollector->lateCollect(); + $collectedData = $dataCollector->getData(); + + $this->assertSame(10.0, $collectedData['encode'][0]['encoder']['time']); + $this->assertSame('CsvEncoder', $collectedData['encode'][0]['encoder']['class']); + $this->assertArrayHasKey('file', $collectedData['encode'][0]['encoder']); + $this->assertArrayHasKey('line', $collectedData['encode'][0]['encoder']); + + $this->assertSame(3.0, $collectedData['encode'][0]['encoding'][JsonEncoder::class]['time']); + $this->assertSame(2, $collectedData['encode'][0]['encoding'][JsonEncoder::class]['calls']); + $this->assertSame('JsonEncoder', $collectedData['encode'][0]['encoding'][JsonEncoder::class]['class']); + $this->assertArrayHasKey('file', $collectedData['encode'][0]['encoding'][JsonEncoder::class]); + $this->assertArrayHasKey('line', $collectedData['encode'][0]['encoding'][JsonEncoder::class]); + + $this->assertSame(5.0, $collectedData['encode'][0]['encoding'][CsvEncoder::class]['time']); + $this->assertSame(1, $collectedData['encode'][0]['encoding'][CsvEncoder::class]['calls']); + $this->assertSame('CsvEncoder', $collectedData['encode'][0]['encoding'][CsvEncoder::class]['class']); + $this->assertArrayHasKey('file', $collectedData['encode'][0]['encoding'][CsvEncoder::class]); + $this->assertArrayHasKey('line', $collectedData['encode'][0]['encoding'][CsvEncoder::class]); + + $this->assertSame(10.0, $collectedData['decode'][0]['encoder']['time']); + $this->assertSame('CsvEncoder', $collectedData['decode'][0]['encoder']['class']); + $this->assertArrayHasKey('file', $collectedData['decode'][0]['encoder']); + $this->assertArrayHasKey('line', $collectedData['decode'][0]['encoder']); + + $this->assertSame(3.0, $collectedData['decode'][0]['encoding'][JsonEncoder::class]['time']); + $this->assertSame(2, $collectedData['decode'][0]['encoding'][JsonEncoder::class]['calls']); + $this->assertSame('JsonEncoder', $collectedData['decode'][0]['encoding'][JsonEncoder::class]['class']); + $this->assertArrayHasKey('file', $collectedData['decode'][0]['encoding'][JsonEncoder::class]); + $this->assertArrayHasKey('line', $collectedData['decode'][0]['encoding'][JsonEncoder::class]); + + $this->assertSame(5.0, $collectedData['decode'][0]['encoding'][CsvEncoder::class]['time']); + $this->assertSame(1, $collectedData['decode'][0]['encoding'][CsvEncoder::class]['calls']); + $this->assertSame('CsvEncoder', $collectedData['decode'][0]['encoding'][CsvEncoder::class]['class']); + $this->assertArrayHasKey('file', $collectedData['decode'][0]['encoding'][CsvEncoder::class]); + $this->assertArrayHasKey('line', $collectedData['decode'][0]['encoding'][CsvEncoder::class]); + } + + public function testCountHandled() + { + $dataCollector = new SerializerDataCollector(); + + $dataCollector->collectSerialize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0); + $dataCollector->collectDeserialize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 1.0); + $dataCollector->collectNormalize('traceIdThree', 'data', 'format', ['foo' => 'bar'], 20.0); + $dataCollector->collectDenormalize('traceIdFour', 'data', 'type', 'format', ['foo' => 'bar'], 20.0); + $dataCollector->collectEncode('traceIdFive', 'data', 'format', ['foo' => 'bar'], 20.0); + $dataCollector->collectDecode('traceIdSix', 'data', 'format', ['foo' => 'bar'], 20.0); + $dataCollector->collectSerialize('traceIdSeven', 'data', 'format', ['foo' => 'bar'], 1.0); + + $dataCollector->lateCollect(); + + $this->assertSame(7, $dataCollector->getHandledCount()); + } + + public function testGetTotalTime() + { + $dataCollector = new SerializerDataCollector(); + + $dataCollector->collectSerialize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0); + $dataCollector->collectDeserialize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 2.0); + $dataCollector->collectNormalize('traceIdThree', 'data', 'format', ['foo' => 'bar'], 3.0); + $dataCollector->collectDenormalize('traceIdFour', 'data', 'type', 'format', ['foo' => 'bar'], 4.0); + $dataCollector->collectEncode('traceIdFive', 'data', 'format', ['foo' => 'bar'], 5.0); + $dataCollector->collectDecode('traceIdSix', 'data', 'format', ['foo' => 'bar'], 6.0); + $dataCollector->collectSerialize('traceIdSeven', 'data', 'format', ['foo' => 'bar'], 7.0); + + $dataCollector->lateCollect(); + + $this->assertSame(28.0, $dataCollector->getTotalTime()); + } + + public function testReset() + { + $dataCollector = new SerializerDataCollector(); + + $dataCollector->collectSerialize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0); + $dataCollector->lateCollect(); + + $this->assertNotSame([], $dataCollector->getData()); + + $dataCollector->reset(); + $this->assertSame([], $dataCollector->getData()); + } + + /** + * Cast cloned vars to be able to test nested values. + */ + private function castCollectedData(array $collectedData): array + { + foreach ($collectedData as $method => $collectedMethodData) { + foreach ($collectedMethodData as $i => $collected) { + $collectedData[$method][$i]['data'] = $collected['data']->getValue(); + $collectedData[$method][$i]['context'] = $collected['context']->getValue(true); + } + } + + return $collectedData; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Debug/TraceableEncoderTest.php b/src/Symfony/Component/Serializer/Tests/Debug/TraceableEncoderTest.php new file mode 100644 index 0000000000000..aa2393bbe7f69 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Debug/TraceableEncoderTest.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Debug; + +use BadMethodCallException; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Debug\TraceableEncoder; +use Symfony\Component\Serializer\Debug\TraceableSerializer; +use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Component\Serializer\Encoder\EncoderInterface; + +class TraceableEncoderTest extends TestCase +{ + public function testForwardsToEncoder() + { + $encoder = $this->createMock(EncoderInterface::class); + $encoder + ->expects($this->once()) + ->method('encode') + ->with('data', 'format', $this->isType('array')) + ->willReturn('encoded'); + + $decoder = $this->createMock(DecoderInterface::class); + $decoder + ->expects($this->once()) + ->method('decode') + ->with('data', 'format', $this->isType('array')) + ->willReturn('decoded'); + + $this->assertSame('encoded', (new TraceableEncoder($encoder, new SerializerDataCollector()))->encode('data', 'format')); + $this->assertSame('decoded', (new TraceableEncoder($decoder, new SerializerDataCollector()))->decode('data', 'format')); + } + + public function testCollectEncodingData() + { + $encoder = $this->createMock(EncoderInterface::class); + $decoder = $this->createMock(DecoderInterface::class); + + $dataCollector = $this->createMock(SerializerDataCollector::class); + $dataCollector + ->expects($this->once()) + ->method('collectEncoding') + ->with($this->isType('string'), \get_class($encoder), $this->isType('float')); + $dataCollector + ->expects($this->once()) + ->method('collectDecoding') + ->with($this->isType('string'), \get_class($decoder), $this->isType('float')); + + (new TraceableEncoder($encoder, $dataCollector))->encode('data', 'format', [TraceableSerializer::DEBUG_TRACE_ID => 'debug']); + (new TraceableEncoder($decoder, $dataCollector))->decode('data', 'format', [TraceableSerializer::DEBUG_TRACE_ID => 'debug']); + } + + public function testNotCollectEncodingDataIfNoDebugTraceId() + { + $encoder = $this->createMock(EncoderInterface::class); + $decoder = $this->createMock(DecoderInterface::class); + + $dataCollector = $this->createMock(SerializerDataCollector::class); + $dataCollector->expects($this->never())->method('collectEncoding'); + $dataCollector->expects($this->never())->method('collectDecoding'); + + (new TraceableEncoder($encoder, $dataCollector))->encode('data', 'format'); + (new TraceableEncoder($decoder, $dataCollector))->decode('data', 'format'); + } + + public function testCannotEncodeIfNotEncoder() + { + $this->expectException(BadMethodCallException::class); + + (new TraceableEncoder($this->createMock(DecoderInterface::class), new SerializerDataCollector()))->encode('data', 'format'); + } + + public function testCannotDecodeIfNotDecoder() + { + $this->expectException(BadMethodCallException::class); + + (new TraceableEncoder($this->createMock(EncoderInterface::class), new SerializerDataCollector()))->decode('data', 'format'); + } + + public function testSupports() + { + $encoder = $this->createMock(EncoderInterface::class); + $encoder->method('supportsEncoding')->willReturn(true); + + $decoder = $this->createMock(DecoderInterface::class); + $decoder->method('supportsDecoding')->willReturn(true); + + $traceableEncoder = new TraceableEncoder($encoder, new SerializerDataCollector()); + $traceableDecoder = new TraceableEncoder($decoder, new SerializerDataCollector()); + + $this->assertTrue($traceableEncoder->supportsEncoding('data')); + $this->assertTrue($traceableDecoder->supportsDecoding('data')); + $this->assertFalse($traceableEncoder->supportsDecoding('data')); + $this->assertFalse($traceableDecoder->supportsEncoding('data')); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Debug/TraceableNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Debug/TraceableNormalizerTest.php new file mode 100644 index 0000000000000..dc99a03c03b22 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Debug/TraceableNormalizerTest.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Debug; + +use BadMethodCallException; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Debug\TraceableNormalizer; +use Symfony\Component\Serializer\Debug\TraceableSerializer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +class TraceableNormalizerTest extends TestCase +{ + public function testForwardsToNormalizer() + { + $normalizer = $this->createMock(NormalizerInterface::class); + $normalizer + ->expects($this->once()) + ->method('normalize') + ->with('data', 'format', $this->isType('array')) + ->willReturn('normalized'); + + $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer + ->expects($this->once()) + ->method('denormalize') + ->with('data', 'type', 'format', $this->isType('array')) + ->willReturn('denormalized'); + + $this->assertSame('normalized', (new TraceableNormalizer($normalizer, new SerializerDataCollector()))->normalize('data', 'format')); + $this->assertSame('denormalized', (new TraceableNormalizer($denormalizer, new SerializerDataCollector()))->denormalize('data', 'type', 'format')); + } + + public function testCollectNormalizationData() + { + $normalizer = $this->createMock(NormalizerInterface::class); + $denormalizer = $this->createMock(DenormalizerInterface::class); + + $dataCollector = $this->createMock(SerializerDataCollector::class); + $dataCollector + ->expects($this->once()) + ->method('collectNormalization') + ->with($this->isType('string'), \get_class($normalizer), $this->isType('float')); + $dataCollector + ->expects($this->once()) + ->method('collectDenormalization') + ->with($this->isType('string'), \get_class($denormalizer), $this->isType('float')); + + (new TraceableNormalizer($normalizer, $dataCollector))->normalize('data', 'format', [TraceableSerializer::DEBUG_TRACE_ID => 'debug']); + (new TraceableNormalizer($denormalizer, $dataCollector))->denormalize('data', 'type', 'format', [TraceableSerializer::DEBUG_TRACE_ID => 'debug']); + } + + public function testNotCollectNormalizationDataIfNoDebugTraceId() + { + $normalizer = $this->createMock(NormalizerInterface::class); + $denormalizer = $this->createMock(DenormalizerInterface::class); + + $dataCollector = $this->createMock(SerializerDataCollector::class); + $dataCollector->expects($this->never())->method('collectNormalization'); + $dataCollector->expects($this->never())->method('collectDenormalization'); + + (new TraceableNormalizer($normalizer, $dataCollector))->normalize('data', 'format'); + (new TraceableNormalizer($denormalizer, $dataCollector))->denormalize('data', 'type', 'format'); + } + + public function testCannotNormalizeIfNotNormalizer() + { + $this->expectException(BadMethodCallException::class); + + (new TraceableNormalizer($this->createMock(DenormalizerInterface::class), new SerializerDataCollector()))->normalize('data'); + } + + public function testCannotDenormalizeIfNotDenormalizer() + { + $this->expectException(BadMethodCallException::class); + + (new TraceableNormalizer($this->createMock(NormalizerInterface::class), new SerializerDataCollector()))->denormalize('data', 'type'); + } + + public function testSupports() + { + $normalizer = $this->createMock(NormalizerInterface::class); + $normalizer->method('supportsNormalization')->willReturn(true); + + $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer->method('supportsDenormalization')->willReturn(true); + + $traceableNormalizer = new TraceableNormalizer($normalizer, new SerializerDataCollector()); + $traceableDenormalizer = new TraceableNormalizer($denormalizer, new SerializerDataCollector()); + + $this->assertTrue($traceableNormalizer->supportsNormalization('data')); + $this->assertTrue($traceableDenormalizer->supportsDenormalization('data', 'type')); + $this->assertFalse($traceableNormalizer->supportsDenormalization('data', 'type')); + $this->assertFalse($traceableDenormalizer->supportsNormalization('data')); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Debug/TraceableSerializerTest.php b/src/Symfony/Component/Serializer/Tests/Debug/TraceableSerializerTest.php new file mode 100644 index 0000000000000..ae8a01623cdbf --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Debug/TraceableSerializerTest.php @@ -0,0 +1,194 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Debug; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Debug\TraceableSerializer; +use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Component\Serializer\Encoder\EncoderInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +class TraceableSerializerTest extends TestCase +{ + public function testForwardsToSerializer() + { + $serializer = $this->createMock(Serializer::class); + $serializer + ->expects($this->once()) + ->method('serialize') + ->with('data', 'format', $this->isType('array')) + ->willReturn('serialized'); + $serializer + ->expects($this->once()) + ->method('deserialize') + ->with('data', 'type', 'format', $this->isType('array')) + ->willReturn('deserialized'); + $serializer + ->expects($this->once()) + ->method('normalize') + ->with('data', 'format', $this->isType('array')) + ->willReturn('normalized'); + $serializer + ->expects($this->once()) + ->method('denormalize') + ->with('data', 'type', 'format', $this->isType('array')) + ->willReturn('denormalized'); + $serializer + ->expects($this->once()) + ->method('encode') + ->with('data', 'format', $this->isType('array')) + ->willReturn('encoded'); + $serializer + ->expects($this->once()) + ->method('decode') + ->with('data', 'format', $this->isType('array')) + ->willReturn('decoded'); + + $traceableSerializer = new TraceableSerializer($serializer, new SerializerDataCollector()); + + $this->assertSame('serialized', $traceableSerializer->serialize('data', 'format')); + $this->assertSame('deserialized', $traceableSerializer->deserialize('data', 'type', 'format')); + $this->assertSame('normalized', $traceableSerializer->normalize('data', 'format')); + $this->assertSame('denormalized', $traceableSerializer->denormalize('data', 'type', 'format')); + $this->assertSame('encoded', $traceableSerializer->encode('data', 'format')); + $this->assertSame('decoded', $traceableSerializer->decode('data', 'format')); + } + + public function testCollectData() + { + $dataCollector = $this->createMock(SerializerDataCollector::class); + $dataCollector + ->expects($this->once()) + ->method('collectSerialize') + ->with($this->isType('string'), 'data', 'format', $this->isType('array'), $this->isType('float')); + $dataCollector + ->expects($this->once()) + ->method('collectDeserialize') + ->with($this->isType('string'), 'data', 'type', 'format', $this->isType('array'), $this->isType('float')); + $dataCollector + ->expects($this->once()) + ->method('collectNormalize') + ->with($this->isType('string'), 'data', 'format', $this->isType('array'), $this->isType('float')); + $dataCollector + ->expects($this->once()) + ->method('collectDenormalize') + ->with($this->isType('string'), 'data', 'type', 'format', $this->isType('array'), $this->isType('float')); + $dataCollector + ->expects($this->once()) + ->method('collectEncode') + ->with($this->isType('string'), 'data', 'format', $this->isType('array'), $this->isType('float')); + $dataCollector + ->expects($this->once()) + ->method('collectDecode') + ->with($this->isType('string'), 'data', 'format', $this->isType('array'), $this->isType('float')); + + $traceableSerializer = new TraceableSerializer(new Serializer(), $dataCollector); + + $traceableSerializer->serialize('data', 'format'); + $traceableSerializer->deserialize('data', 'type', 'format'); + $traceableSerializer->normalize('data', 'format'); + $traceableSerializer->denormalize('data', 'type', 'format'); + $traceableSerializer->encode('data', 'format'); + $traceableSerializer->decode('data', 'format'); + } + + public function testAddDebugTraceIdInContext() + { + $serializer = $this->createMock(Serializer::class); + + foreach (['serialize', 'deserialize', 'normalize', 'denormalize', 'encode', 'decode'] as $method) { + $serializer->method($method)->willReturnCallback(function (): string { + $context = func_get_arg(\func_num_args() - 1); + $this->assertIsString($context[TraceableSerializer::DEBUG_TRACE_ID]); + + return ''; + }); + } + + $traceableSerializer = new TraceableSerializer($serializer, new SerializerDataCollector()); + + $traceableSerializer->serialize('data', 'format'); + $traceableSerializer->deserialize('data', 'format', 'type'); + $traceableSerializer->normalize('data', 'format'); + $traceableSerializer->denormalize('data', 'format'); + $traceableSerializer->encode('data', 'format'); + $traceableSerializer->decode('data', 'format'); + } +} + +class Serializer implements SerializerInterface, NormalizerInterface, DenormalizerInterface, EncoderInterface, DecoderInterface +{ + public function serialize(mixed $data, string $format, array $context = []): string + { + return 'serialized'; + } + + public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed + { + return 'deserialized'; + } + + public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return 'normalized'; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */): bool + { + return true; + } + + public function denormalize(mixed $data, string $type, string $format = null, array $context = []): mixed + { + return 'denormalized'; + } + + /** + * @param array $context + */ + public function supportsDenormalization(mixed $data, string $type, string $format = null /*, array $context = [] */): bool + { + return true; + } + + public function encode(mixed $data, string $format, array $context = []): string + { + return 'encoded'; + } + + /** + * @param array $context + */ + public function supportsEncoding(string $format /*, array $context = [] */): bool + { + return true; + } + + public function decode(string $data, string $format, array $context = []): mixed + { + return 'decoded'; + } + + /** + * @param array $context + */ + public function supportsDecoding(string $format /*, array $context = [] */): bool + { + return true; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/DependencyInjection/SerializerPassTest.php b/src/Symfony/Component/Serializer/Tests/DependencyInjection/SerializerPassTest.php index e9b1efd228d10..92c258a822af1 100644 --- a/src/Symfony/Component/Serializer/Tests/DependencyInjection/SerializerPassTest.php +++ b/src/Symfony/Component/Serializer/Tests/DependencyInjection/SerializerPassTest.php @@ -15,6 +15,8 @@ use Symfony\Component\DependencyInjection\Argument\BoundArgument; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Serializer\Debug\TraceableEncoder; +use Symfony\Component\Serializer\Debug\TraceableNormalizer; use Symfony\Component\Serializer\DependencyInjection\SerializerPass; /** @@ -29,6 +31,7 @@ public function testThrowExceptionWhenNoNormalizers() $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('You must tag at least one service as "serializer.normalizer" to use the "serializer" service'); $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); $container->register('serializer'); $serializerPass = new SerializerPass(); @@ -40,6 +43,7 @@ public function testThrowExceptionWhenNoEncoders() $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('You must tag at least one service as "serializer.encoder" to use the "serializer" service'); $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); $container->register('serializer') ->addArgument([]) ->addArgument([]); @@ -52,6 +56,7 @@ public function testThrowExceptionWhenNoEncoders() public function testServicesAreOrderedAccordingToPriority() { $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); $definition = $container->register('serializer')->setArguments([null, null]); $container->register('n2')->addTag('serializer.normalizer', ['priority' => 100])->addTag('serializer.encoder', ['priority' => 100]); @@ -73,6 +78,7 @@ public function testServicesAreOrderedAccordingToPriority() public function testBindSerializerDefaultContext() { $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); $container->register('serializer')->setArguments([null, null]); $container->setParameter('serializer.default_context', ['enable_max_depth' => true]); $definition = $container->register('n1')->addTag('serializer.normalizer')->addTag('serializer.encoder'); @@ -83,4 +89,32 @@ public function testBindSerializerDefaultContext() $bindings = $definition->getBindings(); $this->assertEquals($bindings['array $defaultContext'], new BoundArgument(['enable_max_depth' => true], false)); } + + public function testNormalizersAndEncodersAreDecoredAndOrderedWhenCollectingData() + { + $container = new ContainerBuilder(); + + $container->setParameter('kernel.debug', true); + $container->register('serializer.data_collector'); + + $container->register('serializer')->setArguments([null, null]); + $container->register('n')->addTag('serializer.normalizer'); + $container->register('e')->addTag('serializer.encoder'); + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $traceableNormalizerDefinition = $container->getDefinition('debug.n'); + $traceableEncoderDefinition = $container->getDefinition('debug.e'); + + $this->assertEquals(TraceableNormalizer::class, $traceableNormalizerDefinition->getClass()); + $this->assertEquals(['n', null, 255], $traceableNormalizerDefinition->getDecoratedService()); + $this->assertEquals(new Reference('debug.n.inner'), $traceableNormalizerDefinition->getArgument(0)); + $this->assertEquals(new Reference('serializer.data_collector'), $traceableNormalizerDefinition->getArgument(1)); + + $this->assertEquals(TraceableEncoder::class, $traceableEncoderDefinition->getClass()); + $this->assertEquals(['e', null, 255], $traceableEncoderDefinition->getDecoratedService()); + $this->assertEquals(new Reference('debug.e.inner'), $traceableEncoderDefinition->getArgument(0)); + $this->assertEquals(new Reference('serializer.data_collector'), $traceableEncoderDefinition->getArgument(1)); + } } diff --git a/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php b/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php index 6f999f612ba19..848087145bafe 100644 --- a/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Tests\Encoder; use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Debug\TraceableEncoder; use Symfony\Component\Serializer\Encoder\ChainEncoder; use Symfony\Component\Serializer\Encoder\EncoderInterface; use Symfony\Component\Serializer\Encoder\NormalizationAwareInterface; @@ -86,6 +87,21 @@ public function testNeedsNormalizationNormalizationAware() $this->assertFalse($sut->needsNormalization(self::FORMAT_1)); } + + public function testNeedsNormalizationTraceableEncoder() + { + $traceableEncoder = $this->createMock(TraceableEncoder::class); + $traceableEncoder->method('needsNormalization')->willReturn(true); + $traceableEncoder->method('supportsEncoding')->willReturn(true); + + $this->assertTrue((new ChainEncoder([$traceableEncoder]))->needsNormalization('format')); + + $traceableEncoder = $this->createMock(TraceableEncoder::class); + $traceableEncoder->method('needsNormalization')->willReturn(false); + $traceableEncoder->method('supportsEncoding')->willReturn(true); + + $this->assertFalse((new ChainEncoder([$traceableEncoder]))->needsNormalization('format')); + } } class NormalizationAwareEncoder implements EncoderInterface, NormalizationAwareInterface