Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add datetime denormalizer #228

Merged
merged 10 commits into from
Apr 18, 2024
29 changes: 29 additions & 0 deletions src/ConstraintViolation/InvalidDate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Spiks\UserInputProcessor\ConstraintViolation;

use Spiks\UserInputProcessor\Pointer;

class InvalidDate implements ConstraintViolationInterface
{
public function __construct(private readonly Pointer $pointer, private readonly string $description)
{
}

public static function getType(): string
{
return 'date_is_not_valid';
}

public function getDescription(): string
{
return $this->description;
}

public function getPointer(): Pointer
{
return $this->pointer;
}
}
29 changes: 29 additions & 0 deletions src/ConstraintViolation/InvalidDateRange.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Spiks\UserInputProcessor\ConstraintViolation;

use Spiks\UserInputProcessor\Pointer;

class InvalidDateRange implements ConstraintViolationInterface
{
public function __construct(private readonly Pointer $pointer)
{
}

public static function getType(): string
{
return 'date_range_is_not_valid';
}

public function getDescription(): string
{
return 'Date range is not valid.';
}

public function getPointer(): Pointer
{
return $this->pointer;
}
}
29 changes: 29 additions & 0 deletions src/ConstraintViolation/InvalidTimeZone.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Spiks\UserInputProcessor\ConstraintViolation;

use Spiks\UserInputProcessor\Pointer;

class InvalidTimeZone implements ConstraintViolationInterface
{
public function __construct(private readonly Pointer $pointer, private readonly string $description)
{
}

public static function getType(): string
{
return 'timezone_is_not_valid';
}

public function getDescription(): string
{
return $this->description;
}

public function getPointer(): Pointer
{
return $this->pointer;
}
}
14 changes: 14 additions & 0 deletions src/DateTimeRange.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Spiks\UserInputProcessor;

use DateTimeImmutable;

class DateTimeRange
{
public function __construct(public readonly DateTimeImmutable $from, public readonly DateTimeImmutable $to)
{
}
}
49 changes: 49 additions & 0 deletions src/Denormalizer/DateDenormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Spiks\UserInputProcessor\Denormalizer;

use DateTimeImmutable;
use DateTimeZone;
use Spiks\UserInputProcessor\ConstraintViolation\InvalidDate;
use Spiks\UserInputProcessor\Exception\ValidationError;
use Spiks\UserInputProcessor\Pointer;

class DateDenormalizer
{
private const DATE_FORMAT = 'Y-m-d';

private const DATE_TIME_ZONE = 'UTC';

public function __construct(private readonly StringDenormalizer $stringDenormalizer)
{
}

/**
* Validates and denormalizes passed data.
*
* It expects `$data` to be date string type. `$data` must be formatted ISO 8601('Y-m-d')
*
* @param mixed $data Data to validate and denormalize
* @param Pointer $pointer Pointer containing path to current field
*
* @psalm-return DateTimeImmutable The same datetime as the one that was passed to `$data` argument
*
* @throws ValidationError If `$data` does not meet the requirements of the denormalizer
*/
public function denormalize(mixed $data, Pointer $pointer): DateTimeImmutable
{
$stringDate = $this->stringDenormalizer->denormalize(data: $data, pointer: $pointer, minLength: 1);

$dateTime = DateTimeImmutable::createFromFormat(self::DATE_FORMAT, $stringDate);

if (false === $dateTime || $dateTime->format(format: self::DATE_FORMAT) !== $stringDate) {
throw new ValidationError([new InvalidDate($pointer, sprintf('date is not valid: %s', $stringDate))]);
}

$timeZone = new DateTimeZone(self::DATE_TIME_ZONE);

return $dateTime->setTimezone($timeZone);
}
}
121 changes: 121 additions & 0 deletions src/Denormalizer/DateRangeDenormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

declare(strict_types=1);

namespace Spiks\UserInputProcessor\Denormalizer;

use DateTimeImmutable;
use DateTimeZone;
use LogicException;
use Spiks\UserInputProcessor\ConstraintViolation\InvalidDateRange;
use Spiks\UserInputProcessor\DateTimeRange;
use Spiks\UserInputProcessor\Exception\ValidationError;
use Spiks\UserInputProcessor\ObjectField;
use Spiks\UserInputProcessor\Pointer;

/**
* The denormalizer returns a correctly calculated interval, taking into account the time zone offset.
* The offset will be performed relative to the UTC.
*
* @example For data : from `2000-01-01` to `2001-01-01` timeZone `Europe/Moscow`
* result: DateTimeRange{
* from: new DateTimeImmutable('1999-12-31 21:00:00'),
* to: new DateTimeImmutable('2001-01-01 20:59:59'),
* }
*/
class DateRangeDenormalizer
{
public function __construct(
private readonly DateDenormalizer $dateDenormalizer,
private readonly ObjectDenormalizer $objectDenormalizer,
private readonly TimeZoneDenormalizer $timeZoneDenormalizer
) {
}

/**
* Validates and denormalizes passed data.
*
* It expects `$data` to be an array. The array should be of the following scheme:
* array{
* from: date ISO 8601 ('Y-m-d'),
* to: date ISO 8601 ('Y-m-d'),
* timeZone: string of timezone (https://www.php.net/manual/en/timezones.php)
* }
*
* @param mixed $data Data to validate and denormalize
* @param Pointer $pointer Pointer containing path to current field
*
* @psalm-return DateTimeRange The DateTimeRange object containing a time interval adjusted for the time zone.
*
* @throws ValidationError If `$data` does not meet the requirements of the denormalizer
*/
public function denormalize(mixed $data, Pointer $pointer): DateTimeRange
{
/**
* @psalm-var array{
* from: DateTimeImmutable,
* to: DateTimeImmutable,
* timeZone: DateTimeZone,
* } $dateTimeRange
*/
$dateTimeRange = $this->objectDenormalizer->denormalize($data, $pointer, [
'from' => new ObjectField(
fn(mixed $data, Pointer $pointer): DateTimeImmutable => $this->dateDenormalizer->denormalize(
$data,
$pointer
)
),
'to' => new ObjectField(
fn(mixed $data, Pointer $pointer): DateTimeImmutable => $this->dateDenormalizer->denormalize(
$data,
$pointer
)
),
'timeZone' => new ObjectField(
fn(mixed $data, Pointer $pointer): DateTimeZone => $this->timeZoneDenormalizer->denormalize(
$data,
$pointer
)
),
]);

if ($dateTimeRange['to'] < $dateTimeRange['from']) {
throw new ValidationError([new InvalidDateRange(pointer: $pointer)]);
}

$from = $this->getLowerBound(date: $dateTimeRange['from'], timeZone: $dateTimeRange['timeZone']);
$to = $this->getUpperBound(date: $dateTimeRange['to'], timeZone: $dateTimeRange['timeZone']);

return new DateTimeRange(from: $from, to: $to);
}

/**
* @throws ValidationError If `$data` does not meet the requirements of the denormalizer
*/
private function getLowerBound(DateTimeImmutable $date, DateTimeZone $timeZone): DateTimeImmutable
{
if (0 !== $date->getOffset()) {
throw new LogicException('Timezone of argument `$date` must be UTC timezone');
}

$offsetSeconds = $timeZone->getOffset(new DateTimeImmutable());
$fromTimestamp = $date->setTime(hour: 0, minute: 0)->getTimestamp() - $offsetSeconds;

return (new DateTimeImmutable())->setTimestamp($fromTimestamp);
}

/**
* @throws ValidationError If `$data` does not meet the requirements of the denormalizer
*/
private function getUpperBound(DateTimeImmutable $date, DateTimeZone $timeZone): DateTimeImmutable
{
if (0 !== $date->getOffset()) {
throw new LogicException('Timezone of argument `$date` must be UTC timezone');
}

$offsetSeconds = $timeZone->getOffset(new DateTimeImmutable());
$fromTimestamp = $date->setTime(23, 59, 59)->getTimestamp() - $offsetSeconds;

return (new DateTimeImmutable())->setTimestamp($fromTimestamp);
}
}
45 changes: 45 additions & 0 deletions src/Denormalizer/DateTimeDenormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Spiks\UserInputProcessor\Denormalizer;

use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Spiks\UserInputProcessor\ConstraintViolation\InvalidDate;
use Spiks\UserInputProcessor\Exception\ValidationError;
use Spiks\UserInputProcessor\Pointer;

class DateTimeDenormalizer
{
private const DATE_TIME_FORMAT = DateTimeInterface::RFC3339;

public function __construct(private readonly StringDenormalizer $stringDenormalizer)
{
}

/**
* Validates and denormalizes passed data.
*
* It expects `$data` to be datetime string type. `$data` must be formatted RFC3339('Y-m-d\TH:i:sP')
*
* @param mixed $data Data to validate and denormalize
* @param Pointer $pointer Pointer containing path to current field
*
* @psalm-return DateTimeImmutable The same datetime as the one that was passed to `$data` argument
*
* @throws ValidationError If `$data` does not meet the requirements of the denormalizer
*/
public function denormalize(mixed $data, Pointer $pointer): DateTimeImmutable
{
$stringDate = $this->stringDenormalizer->denormalize($data, $pointer, 1);
$dateTime = DateTimeImmutable::createFromFormat(self::DATE_TIME_FORMAT, $stringDate);

if (false === $dateTime || $dateTime->format(self::DATE_TIME_FORMAT) !== $stringDate) {
throw new ValidationError([new InvalidDate($pointer, sprintf('date is not valid: %s', $stringDate))]);
}

return $dateTime->setTimezone(new DateTimeZone('UTC'));
}
}
45 changes: 45 additions & 0 deletions src/Denormalizer/TimeZoneDenormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Spiks\UserInputProcessor\Denormalizer;

use DateTimeZone;
use Exception;
use Spiks\UserInputProcessor\ConstraintViolation\InvalidTimeZone;
use Spiks\UserInputProcessor\Exception\ValidationError;
use Spiks\UserInputProcessor\Pointer;

class TimeZoneDenormalizer
{
public function __construct(private readonly StringDenormalizer $stringDenormalizer)
{
}

/**
* Validates and denormalizes passed data.
*
* It expects `$data` to be string of timezone (https://www.php.net/manual/en/timezones.php).
*
* @param mixed $data Data to validate and denormalize
* @param Pointer $pointer Pointer containing path to current field
*
* @throws ValidationError If `$data` does not meet the requirements of the denormalizer
*
* @return DateTimeZone The same string as the one that was passed to `$data` argument
*/
public function denormalize(mixed $data, Pointer $pointer): DateTimeZone
{
$stringTimeZone = $this->stringDenormalizer->denormalize($data, $pointer, 1);

try {
$timeZone = new DateTimeZone($stringTimeZone);
} catch (Exception) {
throw new ValidationError([
new InvalidTimeZone($pointer, sprintf('time zone is not valid: %s', $stringTimeZone)),
]);
}

return $timeZone;
}
}
Loading
Loading