Skip to content

Commit 52ca111

Browse files
committed
Add DateTimeUtcNormalizer which de/normalizes datetimes in a more modern way.
This gives use various things which are not possible with the builtin one: * Only allow ISO strings as input which have a valid offset, so there is no guessing of the format or the timezone. * Also allow milli and microseconds optionally as input. * Always convert input to UTC. * When writing strings include milliseconds by default. While we don't need it in many cases, it can't hurt. Browsers do it, and by including them we make sure clients can handle them. * We always write UTC strings and always with the "Z" suffix, same as browsers. The goal is good defaults without leaving much room for user errors. Can be opt-in per property via DateTimeUtcNormalizer::CONTEXT_KEY.
1 parent c30e642 commit 52ca111

5 files changed

Lines changed: 473 additions & 0 deletions

File tree

src/Resources/config/services.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ services:
126126
arguments:
127127
$debug: '%kernel.debug%'
128128

129+
Dbp\Relay\CoreBundle\Serializer\DateTimeUtcNormalizer:
130+
autowire: true
131+
autoconfigure: true
132+
tags:
133+
- { name: serializer.normalizer, priority: -900 }
134+
129135
Dbp\Relay\CoreBundle\Authorization\Serializer\EntityNormalizer:
130136
autowire: true
131137
autoconfigure: true
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Dbp\Relay\CoreBundle\Serializer;
6+
7+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
8+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
9+
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
10+
11+
/**
12+
* Normalizer/denormalizer for {@see \DateTimeInterface} that enforces UTC and strict ISO 8601 with timezone.
13+
*
14+
* Opt in per property:
15+
* #[Context([DateTimeUtcNormalizer::CONTEXT_KEY => true])]
16+
* public \DateTimeInterface $createdAt;
17+
*
18+
* Normalization output format: 2024-01-01T12:00:00.000Z (always includes milliseconds)
19+
*
20+
* Denormalization accepts ISO 8601 strings with an explicit timezone (Z or +HH:MM).
21+
* Both milliseconds (3 digits) and microseconds (6 digits) are accepted.
22+
* The parsed value is always converted to UTC.
23+
*/
24+
class DateTimeUtcNormalizer implements NormalizerInterface, DenormalizerInterface
25+
{
26+
public const CONTEXT_KEY = 'relay_core_datetime_utc';
27+
28+
private const FORMAT_MILLISECONDS = 'Y-m-d\TH:i:s.v\Z';
29+
30+
private const DENORM_FORMATS = [
31+
'Y-m-d\TH:i:s.uP',
32+
'Y-m-d\TH:i:sP',
33+
];
34+
35+
private static \DateTimeZone $utc;
36+
37+
public function normalize(mixed $data, ?string $format = null, array $context = []): string
38+
{
39+
assert($data instanceof \DateTimeInterface);
40+
41+
$utc = \DateTimeImmutable::createFromInterface($data)->setTimezone(self::utc());
42+
43+
return $utc->format(self::FORMAT_MILLISECONDS);
44+
}
45+
46+
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
47+
{
48+
return $data instanceof \DateTimeInterface && ($context[self::CONTEXT_KEY] ?? false) === true;
49+
}
50+
51+
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): \DateTimeInterface
52+
{
53+
assert(is_string($data));
54+
55+
foreach (self::DENORM_FORMATS as $fmt) {
56+
$dt = $this->createFromFormat($fmt, $data, $type);
57+
if ($dt !== false) {
58+
if ($dt instanceof \DateTimeImmutable) {
59+
return $dt->setTimezone(self::utc());
60+
}
61+
62+
if ($dt instanceof \DateTime) {
63+
$dt->setTimezone(self::utc());
64+
65+
return $dt;
66+
}
67+
68+
throw new \RuntimeException('Unsupported DateTimeInterface implementation.');
69+
}
70+
}
71+
72+
throw NotNormalizableValueException::createForUnexpectedDataType(
73+
sprintf(
74+
'The value "%s" is not a valid ISO 8601 datetime string with timezone. '
75+
.'Expected format: Y-m-d\TH:i:sP or Y-m-d\TH:i:s.uP (e.g. "2024-01-01T12:00:00Z" or "2024-01-01T12:00:00+05:30").',
76+
$data,
77+
),
78+
$data,
79+
['string'],
80+
$context['deserialization_path'] ?? null,
81+
true,
82+
);
83+
}
84+
85+
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
86+
{
87+
return in_array($type, [\DateTimeInterface::class, \DateTimeImmutable::class, \DateTime::class], true)
88+
&& is_string($data)
89+
&& ($context[self::CONTEXT_KEY] ?? false) === true;
90+
}
91+
92+
public function getSupportedTypes(?string $format): array
93+
{
94+
// We can't cache on the type, since the type is also handled by Symfony when no
95+
// CONTEXT_KEY is set.
96+
return [
97+
\DateTimeInterface::class => false,
98+
\DateTimeImmutable::class => false,
99+
\DateTime::class => false,
100+
];
101+
}
102+
103+
private static function utc(): \DateTimeZone
104+
{
105+
return self::$utc ??= new \DateTimeZone('UTC');
106+
}
107+
108+
private function createFromFormat(string $format, string $value, string $type): \DateTimeInterface|false
109+
{
110+
if ($type === \DateTime::class) {
111+
return \DateTime::createFromFormat($format, $value);
112+
}
113+
114+
return \DateTimeImmutable::createFromFormat($format, $value);
115+
}
116+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Dbp\Relay\CoreBundle\Tests\Serializer;
6+
7+
use Dbp\Relay\CoreBundle\Serializer\DateTimeUtcNormalizer;
8+
use PHPUnit\Framework\TestCase;
9+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
10+
11+
class DateTimeUtcNormalizerTest extends TestCase
12+
{
13+
private DateTimeUtcNormalizer $normalizer;
14+
private array $optInContext;
15+
16+
protected function setUp(): void
17+
{
18+
$this->normalizer = new DateTimeUtcNormalizer();
19+
$this->optInContext = [DateTimeUtcNormalizer::CONTEXT_KEY => true];
20+
}
21+
22+
// -------------------------------------------------------------------------
23+
// supportsNormalization
24+
// -------------------------------------------------------------------------
25+
26+
public function testSupportsNormalizationForDateTimeImmutableWithContextKey(): void
27+
{
28+
$dt = new \DateTimeImmutable();
29+
30+
self::assertTrue($this->normalizer->supportsNormalization($dt, context: $this->optInContext));
31+
}
32+
33+
public function testSupportsNormalizationForDateTimeWithContextKey(): void
34+
{
35+
$dt = new \DateTime();
36+
37+
self::assertTrue($this->normalizer->supportsNormalization($dt, context: $this->optInContext));
38+
}
39+
40+
public function testSupportsNormalizationReturnsFalseWithoutContextKey(): void
41+
{
42+
self::assertFalse($this->normalizer->supportsNormalization(new \DateTimeImmutable()));
43+
}
44+
45+
public function testSupportsNormalizationReturnsFalseForNonDateTimeInterface(): void
46+
{
47+
self::assertFalse($this->normalizer->supportsNormalization(new \stdClass(), context: $this->optInContext));
48+
}
49+
50+
// -------------------------------------------------------------------------
51+
// normalize
52+
// -------------------------------------------------------------------------
53+
54+
public function testNormalizesUtcDateTimeImmutableWithoutMilliseconds(): void
55+
{
56+
$dt = new \DateTimeImmutable('2024-01-01T12:00:00', new \DateTimeZone('UTC'));
57+
58+
self::assertSame('2024-01-01T12:00:00.000Z', $this->normalizer->normalize($dt));
59+
}
60+
61+
public function testNormalizesUtcDateTimeImmutableWithMilliseconds(): void
62+
{
63+
$dt = new \DateTimeImmutable('2024-01-01T12:00:00.123000', new \DateTimeZone('UTC'));
64+
65+
self::assertSame('2024-01-01T12:00:00.123Z', $this->normalizer->normalize($dt));
66+
}
67+
68+
public function testNormalizesNonUtcDateTimeImmutableToUtc(): void
69+
{
70+
// 15:00:00 UTC+5 → 10:00:00 UTC
71+
$dt = new \DateTimeImmutable('2024-01-01T15:00:00', new \DateTimeZone('+05:00'));
72+
73+
self::assertSame('2024-01-01T10:00:00.000Z', $this->normalizer->normalize($dt));
74+
}
75+
76+
public function testNormalizesNonUtcDateTimeImmutableWithMillisecondsToUtc(): void
77+
{
78+
$dt = new \DateTimeImmutable('2024-01-01T15:00:00.456000', new \DateTimeZone('+05:00'));
79+
80+
self::assertSame('2024-01-01T10:00:00.456Z', $this->normalizer->normalize($dt));
81+
}
82+
83+
public function testNormalizesNonUtcDateTimeToUtc(): void
84+
{
85+
$dt = new \DateTime('2024-01-01T15:00:00', new \DateTimeZone('+05:00'));
86+
87+
self::assertSame('2024-01-01T10:00:00.000Z', $this->normalizer->normalize($dt));
88+
}
89+
90+
// -------------------------------------------------------------------------
91+
// supportsDenormalization
92+
// -------------------------------------------------------------------------
93+
94+
public function testSupportsDenormalizationForDateTimeImmutableStringWithContextKey(): void
95+
{
96+
self::assertTrue($this->normalizer->supportsDenormalization(
97+
'2024-01-01T12:00:00Z',
98+
\DateTimeImmutable::class,
99+
context: $this->optInContext,
100+
));
101+
}
102+
103+
public function testSupportsDenormalizationForDateTimeStringWithContextKey(): void
104+
{
105+
self::assertTrue($this->normalizer->supportsDenormalization(
106+
'2024-01-01T12:00:00Z',
107+
\DateTime::class,
108+
context: $this->optInContext,
109+
));
110+
}
111+
112+
public function testSupportsDenormalizationForDateTimeInterfaceStringWithContextKey(): void
113+
{
114+
self::assertTrue($this->normalizer->supportsDenormalization(
115+
'2024-01-01T12:00:00Z',
116+
\DateTimeInterface::class,
117+
context: $this->optInContext,
118+
));
119+
}
120+
121+
public function testSupportsDenormalizationReturnsFalseWithoutContextKey(): void
122+
{
123+
self::assertFalse($this->normalizer->supportsDenormalization(
124+
'2024-01-01T12:00:00Z',
125+
\DateTimeImmutable::class,
126+
));
127+
}
128+
129+
public function testSupportsDenormalizationReturnsFalseForWrongType(): void
130+
{
131+
self::assertFalse($this->normalizer->supportsDenormalization(
132+
'2024-01-01T12:00:00Z',
133+
\stdClass::class,
134+
context: $this->optInContext,
135+
));
136+
}
137+
138+
public function testSupportsDenormalizationReturnsFalseForNonString(): void
139+
{
140+
self::assertFalse($this->normalizer->supportsDenormalization(
141+
12345,
142+
\DateTimeImmutable::class,
143+
context: $this->optInContext,
144+
));
145+
}
146+
147+
// -------------------------------------------------------------------------
148+
// denormalize
149+
// -------------------------------------------------------------------------
150+
151+
public function testDenormalizesIsoStringWithZSuffix(): void
152+
{
153+
$result = $this->normalizer->denormalize('2024-01-01T12:00:00Z', \DateTimeImmutable::class);
154+
155+
self::assertInstanceOf(\DateTimeImmutable::class, $result);
156+
self::assertSame('2024-01-01T12:00:00Z', $result->format('Y-m-d\TH:i:s\Z'));
157+
self::assertSame('UTC', $result->getTimezone()->getName());
158+
}
159+
160+
public function testDenormalizesIsoStringWithPositiveOffsetToUtc(): void
161+
{
162+
// 15:00:00+05:30 → 09:30:00 UTC
163+
$result = $this->normalizer->denormalize('2024-01-01T15:00:00+05:30', \DateTimeImmutable::class);
164+
165+
self::assertInstanceOf(\DateTimeImmutable::class, $result);
166+
self::assertSame('2024-01-01T09:30:00Z', $result->format('Y-m-d\TH:i:s\Z'));
167+
self::assertSame('UTC', $result->getTimezone()->getName());
168+
}
169+
170+
public function testDenormalizesIsoStringWithMilliseconds(): void
171+
{
172+
$result = $this->normalizer->denormalize('2024-01-01T12:00:00.123Z', \DateTimeImmutable::class);
173+
174+
self::assertInstanceOf(\DateTimeImmutable::class, $result);
175+
self::assertSame('123', $result->format('v'));
176+
self::assertSame('UTC', $result->getTimezone()->getName());
177+
}
178+
179+
public function testDenormalizesIsoStringWithMicroseconds(): void
180+
{
181+
$result = $this->normalizer->denormalize('2024-01-01T12:00:00.123456Z', \DateTimeImmutable::class);
182+
183+
self::assertInstanceOf(\DateTimeImmutable::class, $result);
184+
self::assertSame('123456', $result->format('u'));
185+
self::assertSame('UTC', $result->getTimezone()->getName());
186+
}
187+
188+
public function testDenormalizesIsoStringToDateTime(): void
189+
{
190+
$result = $this->normalizer->denormalize('2024-01-01T12:00:00.123456Z', \DateTime::class);
191+
192+
self::assertInstanceOf(\DateTime::class, $result);
193+
self::assertSame('123456', $result->format('u'));
194+
self::assertSame('UTC', $result->getTimezone()->getName());
195+
}
196+
197+
public function testDenormalizesIsoStringToDateTimeInterface(): void
198+
{
199+
$result = $this->normalizer->denormalize('2024-01-01T12:00:00Z', \DateTimeInterface::class);
200+
201+
self::assertInstanceOf(\DateTimeInterface::class, $result);
202+
self::assertSame('2024-01-01T12:00:00Z', $result->format('Y-m-d\TH:i:s\Z'));
203+
self::assertSame('UTC', $result->getTimezone()->getName());
204+
}
205+
206+
public function testDenormalizationThrowsForStringWithoutTimezone(): void
207+
{
208+
$this->expectException(NotNormalizableValueException::class);
209+
210+
$this->normalizer->denormalize('2024-01-01T12:00:00', \DateTimeImmutable::class);
211+
}
212+
213+
public function testDenormalizationThrowsForInvalidString(): void
214+
{
215+
$this->expectException(NotNormalizableValueException::class);
216+
217+
$this->normalizer->denormalize('not-a-datetime', \DateTimeImmutable::class);
218+
}
219+
}

0 commit comments

Comments
 (0)