diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 135bab5..cb5e2d0 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -17,7 +17,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '7.4' tools: composer:v2 coverage: none env: @@ -44,7 +44,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '7.4' tools: composer:v2 coverage: none env: diff --git a/src/Utilities/TimeFormatter.php b/src/Utilities/TimeFormatter.php index 5884226..6d6bbe4 100644 --- a/src/Utilities/TimeFormatter.php +++ b/src/Utilities/TimeFormatter.php @@ -16,6 +16,9 @@ final class TimeFormatter private const TIME_FORMAT = 'Y-m-d\TH:i:s\Z'; private const TIME_ZONE = 'UTC'; + private const RFC3339_FORMAT = 'Y-m-d\TH:i:sP'; + private const RFC3339_EXTENDED_FORMAT = 'Y-m-d\TH:i:s.uP'; + public static function encode(?DateTimeImmutable $time): ?string { if ($time === null) { @@ -31,45 +34,32 @@ public static function decode(?string $time): ?DateTimeImmutable return null; } - $time = self::trimMicroseconds($time); + $time = \strtoupper($time); + + /** @psalm-suppress UndefinedFunction */ + $decoded = \str_contains($time, '.') + ? DateTimeImmutable::createFromFormat(self::RFC3339_EXTENDED_FORMAT, self::truncateOverPrecision($time), new DateTimeZone(self::TIME_ZONE)) + : DateTimeImmutable::createFromFormat(self::RFC3339_FORMAT, $time, new DateTimeZone(self::TIME_ZONE)); - try { - $decoded = new DateTimeImmutable($time); - } catch (\Throwable $th) { + if ($decoded === false) { throw new ValueError( \sprintf('%s(): Argument #1 ($time) is not a valid RFC3339 timestamp', __METHOD__) ); } - return self::shiftWithTimezone($time, $decoded); + return $decoded; } - private static function trimMicroseconds(string $time): string + private static function truncateOverPrecision(string $time): string { - $microseconds = explode('.', $time, 2); - if (isset($microseconds[1])) { - $microsecondsAndTimezone = explode('+', $microseconds[1], 2); - if (count($microsecondsAndTimezone) === 1) { - $microsecondsAndTimezone = explode('-', $microseconds[1], 2); - } - $timezone = isset($microsecondsAndTimezone[1]) ? sprintf('+%s', $microsecondsAndTimezone[1]) : ''; - $time = sprintf( - "%s.%s%s", - $microseconds[0], - substr($microsecondsAndTimezone[0], 0, 6), - $timezone - ); - } + [$fst, $snd] = explode('.', $time); - return $time; - } + // match the first n digits at the start + \preg_match('/^\d+/', $snd, $matches); - private static function shiftWithTimezone(string $time, DateTimeImmutable $datetime): DateTimeImmutable - { - if (\strpos($time, '+') === false && \strpos($time, '-') === false && \strtoupper(\substr($time, -1)) !== 'Z') { - return $datetime->setTimezone(new \DateTimeZone('UTC')); - } + $digits = $matches[0] ?? ''; - return $datetime; + // datetime portion + period + up to 6 digits + timezone string + return $fst . '.' . substr($digits, 0, 6) . substr($snd, strlen($digits)); } } diff --git a/tests/Unit/Utilities/TimeFormatterTest.php b/tests/Unit/Utilities/TimeFormatterTest.php index 4152fd3..06d8d44 100644 --- a/tests/Unit/Utilities/TimeFormatterTest.php +++ b/tests/Unit/Utilities/TimeFormatterTest.php @@ -19,10 +19,11 @@ public function testEncode(): void ); } - public static function providesDecodeCases(): array + public static function providesValidDecodeCases(): array { return [ // UTC + ['2018-04-05T17:31:00Z', '2018-04-05t17:31:00Z'], ['2018-04-05T17:31:00Z', '2018-04-05T17:31:00Z'], ['1985-04-12T23:20:50.100000Z', '1985-04-12T23:20:50.1Z'], ['1985-04-12T23:20:50.100000Z', '1985-04-12T23:20:50.10Z'], @@ -41,6 +42,7 @@ public static function providesDecodeCases(): array ['1985-04-12T23:20:50.123456Z', '1985-04-12T23:20:50.123456789Z'], // +01:00 + ['2018-04-05T16:31:00Z', '2018-04-05t17:31:00+01:00'], ['2018-04-05T16:31:00Z', '2018-04-05T17:31:00+01:00'], ['1985-04-12T22:20:50.100000Z', '1985-04-12T23:20:50.1+01:00'], ['1985-04-12T22:20:50.100000Z', '1985-04-12T23:20:50.10+01:00'], @@ -57,12 +59,17 @@ public static function providesDecodeCases(): array ['1985-04-12T22:20:50.123456Z', '1985-04-12T23:20:50.1234567+01:00'], ['1985-04-12T22:20:50.123456Z', '1985-04-12T23:20:50.12345678+01:00'], ['1985-04-12T22:20:50.123456Z', '1985-04-12T23:20:50.123456789+01:00'], - ['1985-04-12T22:20:50.123456Z', '1985-04-12T23:20:50.1234567890+01:00'], + + // -05:00 + ['2018-04-05T22:31:00Z', '2018-04-05t17:31:00-05:00'], + ['2018-04-05T22:31:00Z', '2018-04-05T17:31:00-05:00'], + ['1985-04-13T04:20:50.123456Z', '1985-04-12T23:20:50.123456-05:00'], + ['1985-04-13T04:20:50.123456Z', '1985-04-12T23:20:50.123456789-05:00'], ]; } /** - * @dataProvider providesDecodeCases + * @dataProvider providesValidDecodeCases */ public function testDecode(string $expected, string $input): void { @@ -88,7 +95,23 @@ public function testDecodeEmpty(): void ); } - public function testDecodeInvalidTime(): void + public static function providesInvalidDecodeCases(): array + { + return [ + [''], + ['123'], + ['2018asdsdsafd'], + ['2018-04-05'], + ['2018-04-05 17:31:00Z'], + ['2018-04-05T17:31:00.Z'], + ['2018-04-05T17:31:00ZZ'], + ]; + } + + /** + * @dataProvider providesInvalidDecodeCases + */ + public function testDecodeInvalidTime(string $input): void { $this->expectException(ValueError::class); @@ -96,6 +119,6 @@ public function testDecodeInvalidTime(): void 'CloudEvents\\Utilities\\TimeFormatter::decode(): Argument #1 ($time) is not a valid RFC3339 timestamp' ); - TimeFormatter::decode('2018asdsdsafd'); + TimeFormatter::decode($input); } }