api-platform / core

The server component of API Platform: hypermedia and GraphQL APIs in minutes
https://api-platform.com
MIT License
2.45k stars 874 forks source link

Custom DateTime format for a property #482

Closed teohhanhui closed 8 years ago

teohhanhui commented 8 years ago

@dunglas has added DateTimeNormalizer to the Symfony Serializer [symfony/symfony#16411] and we're now using it [#422], but how shall we specify the format to use for each property (other than resorting to custom normalizers)?

soyuka commented 8 years ago

Hmm but by using this workaround, input date format (ie PUT/POST requests) won't parse the Y-m-d format. Also, this might leads to buggy (de)normalization because of the default format.

IMO date format should be the same out/in anyway so that you could do something like:

curl -XGET api/endpoints/1 | curl -d @- api2/endpoints

So :-1: on @aledeg workaround :smirk_cat:

aledeg commented 8 years ago

I know, it's a dirty workaround

Edit: I deleted it

teohhanhui commented 8 years ago

No need for this kind of workaround. I suggest decorating the ItemNormalizer for the entity where you need to customize the DateTime format. Actual example:

<?php
// src/AppBundle/Serializer/Normalizer/FlightNormalizer.php
namespace AppBundle\Serializer\Normalizer;

use AppBundle\Entity\Flight;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerAwareTrait;
use Symfony\Component\Serializer\SerializerInterface;

class FlightNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
{
    use SerializerAwareTrait {
        setSerializer as baseSetSerializer;
    }

    /**
     * @var NormalizerInterface
     */
    private $decorated;

    /**
     * @param NormalizerInterface $decorated
     */
    public function __construct(NormalizerInterface $decorated)
    {
        $this->decorated = $decorated;
    }

    /**
     * {@inheritdoc}
     */
    public function supportsNormalization($data, $format = null) : bool
    {
        return $this->decorated->supportsNormalization($data, $format);
    }

    /**
     * {@inheritdoc}
     */
    public function normalize($object, $format = null, array $context = [])
    {
        if (!$object instanceof Flight) {
            return $this->decorated->normalize($object, $format, $context);
        }

        $data = $this->decorated->normalize($object, $format, $context);

        if (null !== $object->getArrivalTime()) {
            $data['arrivalTime'] = $object->getArrivalTime()->format('Hi');
        }

        if (null !== $object->getDepartureTime()) {
            $data['departureTime'] = $object->getDepartureTime()->format('Hi');
        }

        return $data;
    }

    /**
     * {@inheritdoc}
     */
    public function supportsDenormalization($data, $type, $format = null) : bool
    {
        return $this->decorated->supportsDenormalization($data, $type, $format);
    }

    /**
     * {@inheritdoc}
     */
    public function denormalize($data, $class, $format = null, array $context = [])
    {
        if (Flight::class !== $class) {
            return $this->decorated->denormalize($data, $class, $format, $context);
        }

        $object = $this->decorated->denormalize($data, $class, $format, $context);

        if (isset($data['arrivalTime'])) {
            $object->setArrivalTime(\DateTime::createFromFormat('Hi', $data['arrivalTime']));
        }

        if (isset($data['departureTime'])) {
            $object->setDepartureTime(\DateTime::createFromFormat('Hi', $data['departureTime']));
        }

        return $object;
    }

    /**
     * {@inheritdoc}
     */
    public function setSerializer(SerializerInterface $serializer)
    {
        $this->baseSetSerializer($serializer);

        if ($this->decorated instanceof SerializerAwareInterface) {
            $this->decorated->setSerializer($serializer);
        }
    }
}
teohhanhui commented 8 years ago

But what we should be able to do is to configure the format at the property (@ApiProperty) level, or in the serializer configuration. I'm not sure which is more semantically correct.

soyuka commented 8 years ago

I'd say that it should be property-configured, but the format would have to be used in the serializer.

Nice example btw, thx

dunglas commented 8 years ago

What is the real life use case to have different format per property instead of relying on a standard date format? It looks weird.

dunglas commented 8 years ago

The first solution proposed by @teohhanhui https://github.com/api-platform/core/issues/482#issuecomment-223538238 looks the cleanest one to me.

teohhanhui commented 8 years ago

What is the real life use case to have different format per property instead of relying on a standard date format?

It's still ISO8601. We don't always desire the full format. Sometimes we only want to accept the date part, sometimes the time part (see example of flight times above...).

dunglas commented 8 years ago

Ok got it. The cleanest way to do that is probably to add some metadata to the Symfony Serializer Component to be able to change the format per property (annotations, XML, YAML). It would benefit to all Serializer users (FOSRest...).

AlexandreHagen commented 8 years ago

Maybe like JMSSerializerBundle http://jmsyst.com/bundles/JMSSerializerBundle/master/configuration#extension-reference

dunglas commented 8 years ago

Should be added to the Symfony Serializer, not here.

Simperfit commented 8 years ago

@dunglas Do we have a way to add it to the serializer

teohhanhui commented 8 years ago

@Simperfit It's already possible to pass a custom datetime format for normalization, but not for denormalization.

I've opened a PR here: symfony/symfony#20217

teohhanhui commented 8 years ago

But we also need a way to build serialization context for relations, because currently our SerializerContextBuilder only works at the resource level.

Or in other words we need to support this:

/**
 * @ApiProperty(attributes={
 *     "normalization_context"={
 *         "datetime_format"="Y-m-d|",
 *     },
 * })
 */
protected $validFrom;
teohhanhui commented 7 years ago

Coming back to this, I think the most user friendly way is for datetime_format to be supported in Serializer metadata (but it probably needs to be something more generic - context in configuration?). What do you think @dunglas?

bendavies commented 7 years ago

@teohhanhui did this get any further?

bendavies commented 7 years ago

@teohhanhui for your FlightNormalizer above, what service are you passing to the constructor?

bendavies commented 7 years ago

worked it out. inject api_platform.serializer.normalizer.item, but your decorating normalizer must have a higher priority than api_platform.serializer.normalizer.item:

<service id="app.normalizer.invoice" class="App\Bundle\InvoiceNormalizer" public="false">
    <argument type="service" id="api_platform.serializer.normalizer.item" />
    <tag name="serializer.normalizer" priority="16" />
</service>
teohhanhui commented 7 years ago

@bendavies No, that's not how you decorate a service.

bendavies commented 7 years ago

@teohhanhui of course thanks for the reminder. But that's the correct service to decorate?

teohhanhui commented 7 years ago

Yes. :smile:

Perni1984 commented 7 years ago

@teohhanhui thanks a lot for posting your solution with decorating the ItemNormalizer. We did that now in our project, but unfortunately the generated api/swagger documentation still shows the wrong format (normal Datetime string instead of custom formatted one). Do you have an idea how to fix that?

anacicconi commented 7 years ago

Hi @teohhanhui, I'm decorating the ItemNormalizer for a specific entity just like your example. However, I can't find in the documentation a way to tell api platform to use my normalizer instead of the custom one for some operations. I thought about creating a custom controller that calls my normalizer and does the job but maybe it's just configuration? Thank you!

ksom commented 7 years ago

@teohhanhui Can you please give the service configuration that you used to decorate the service? We've tried to do this but it doesn't work. Here, what we have in service configuration:

AppBundle\Serializer\Normalizer\DateNormalizer:
    class: AppBundle\Serializer\Normalizer\DateNormalizer
    decorates: api_platform.serializer.normalizer.item
    arguments: ['@AppBundle\Serializer\Normalizer\DateNormalizer.inner']
    public: false

This seems to override the service as well, but our new Normalizer tries to normalize fields instead of entities. Also, supportsNormalization always returns false for our decorator

What version of ApiPlatform are you using for this example? We are currently using "api-platform/core": "^2.0@rc" for this project.

Thanks per advance.

yobud commented 7 years ago

With @ksom, we had to change the normalizer given in this issue this way to get it working :

    public function supportsNormalization($data, $format = null) : bool
    {
        return $data instanceof \DateTime;
        // instead of return $this->decorated->supportsNormalization($data, $format);
    }

    public function normalize($object, $format = null, array $context = [])
    {
        return $object->format('Y-m-d');
    }

But this is formatting all dates, instead of just the ones we want for only some api resources. Thought ItemNormalizer expected an item, but it receives all fields one by one.

htaoufikallah commented 5 years ago

@teohhanhui is your solution implemented? if so how can I use it?

/**
 * @ApiProperty(attributes={
 *     "normalization_context"={
 *         "datetime_format"="Y-m-d|",
 *     },
 * })
 */
protected $validFrom;
teohhanhui commented 5 years ago

@hictao Unfortunately not. And I don't think there's per-property (de)normalization context support in Symfony Serializer either?

teohhanhui commented 5 years ago

The closest thing I can find is this comment: https://github.com/symfony/symfony/issues/23932#issuecomment-323688557

htaoufikallah commented 5 years ago

the JMS serializer has it https://stackoverflow.com/a/41572845 but for the symfony serializer I can only find this in the doc https://symfony.com/doc/current/components/serializer.html#using-callbacks-to-serialize-properties-with-object-instances introduced in the 4.2 version but I don't know how to use it with api platform

teohhanhui commented 5 years ago

Please see my comments above. Use a decorator. But you might need to adjust the code since it's been a while...

htaoufikallah commented 5 years ago

Thank you @teohhanhui I did it by following your example with some modifications here is the final code that works

<?php

namespace App\Serializer;

use App\Entity\Schedule;
use Symfony\Component\Serializer\Exception\BadMethodCallException;
use Symfony\Component\Serializer\Exception\CircularReferenceException;
use Symfony\Component\Serializer\Exception\ExtraAttributesException;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Exception\RuntimeException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerAwareTrait;
use Symfony\Component\Serializer\SerializerInterface;

class ScheduleNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
{
  use SerializerAwareTrait {
    setSerializer as baseSetSerializer;
  }

  /**
   * @var NormalizerInterface
   */
  private $normalizer;
  /**
   * @var DenormalizerInterface
   */
  private $denormalizer;

  /**
   * @param NormalizerInterface $normalizer
   * @param DenormalizerInterface $denormalizer
   */
  public function __construct(NormalizerInterface $normalizer, DenormalizerInterface $denormalizer)
  {
    $this->normalizer = $normalizer;
    $this->denormalizer = $denormalizer;
  }

  /**
   * Normalizes an object into a set of arrays/scalars.
   *
   * @param mixed $object Object to normalize
   * @param string $format Format the normalization result will be encoded as
   * @param array $context Context options for the normalizer
   *
   * @return array|string|int|float|bool
   *
   * @throws InvalidArgumentException   Occurs when the object given is not an attempted type for the normalizer
   * @throws CircularReferenceException Occurs when the normalizer detects a circular reference when no circular
   *                                    reference handler can fix it
   * @throws LogicException             Occurs when the normalizer is not called in an expected context
   */
  public function normalize($object, $format = null, array $context = array())
  {
    if ($context['resource_class'] !== Schedule::class) {
      return $this->normalizer->normalize($object, $format, $context);
    }
    $data = $this->normalizer->normalize($object, $format, $context);

    if (is_array($data) && null !== $object->getStartTime()) {
      $data['startTime'] = $object->getStartTime()->format('Hi');
    }

    if (is_array($data) && null !== $object->getEndTime()) {
      $data['endTime'] = $object->getEndTime()->format('Hi');
    }

    return $data;
  }

  /**
   * Checks whether the given class is supported for normalization by this normalizer.
   *
   * @param mixed $data Data to normalize
   * @param string $format The format being (de-)serialized from or into
   *
   * @return bool
   */
  public function supportsNormalization($data, $format = null)
  {
    return $this->normalizer->supportsNormalization($data, $format);
  }

  /**
   * Sets the owning Serializer object.
   * @param SerializerInterface $serializer
   */
  public function setSerializer(SerializerInterface $serializer)
  {
    $this->baseSetSerializer($serializer);

    if ($this->normalizer instanceof SerializerAwareInterface) {
      $this->normalizer->setSerializer($serializer);
    }
  }

  /**
   * Denormalizes data back into an object of the given class.
   *
   * @param mixed $data Data to restore
   * @param string $class The expected class to instantiate
   * @param string $format Format the given data was extracted from
   * @param array $context Options available to the denormalizer
   *
   * @return object
   *
   * @throws BadMethodCallException   Occurs when the normalizer is not called in an expected context
   * @throws InvalidArgumentException Occurs when the arguments are not coherent or not supported
   * @throws UnexpectedValueException Occurs when the item cannot be hydrated with the given data
   * @throws ExtraAttributesException Occurs when the item doesn't have attribute to receive given data
   * @throws LogicException           Occurs when the normalizer is not supposed to denormalize
   * @throws RuntimeException         Occurs if the class cannot be instantiated
   */
  public function denormalize($data, $class, $format = null, array $context = array())
  {
    if ($context['resource_class'] !== Schedule::class) {
      return $this->denormalizer->denormalize($data, $class, $format, $context);
    }

    /** @var Schedule $object */
    $object = $this->denormalizer->denormalize($data, $class, $format, $context);

    if (is_array($data) && isset($data['startTime'])) {
      $object->setStartTime(\DateTime::createFromFormat('Hi', $data['startTime']));
    }

    if (is_array($data) && isset($data['endTime'])) {
      $object->setEndTime(\DateTime::createFromFormat('Hi', $data['endTime']));
    }

    return $object;
  }

  /**
   * Checks whether the given class is supported for denormalization by this normalizer.
   *
   * @param mixed $data Data to denormalize from
   * @param string $type The class to which the data should be denormalized
   * @param string $format The format being deserialized from
   *
   * @return bool
   */
  public function supportsDenormalization($data, $type, $format = null)
  {
    return $this->denormalizer->supportsDenormalization($data, $type, $format);
  }
}

And here is the service declaration:

App\Serializer\ScheduleNormalizer:
        decorates: api_platform.serializer.normalizer.item
        arguments: ['@App\Serializer\ScheduleNormalizer.inner']
        public: false
        tags:
            - { name: 'serializer.normalizer', priority: 64 }

And I hope that a magic property annotation is added later.

teohhanhui commented 5 years ago

Please follow https://github.com/api-platform/core/issues/1922 for property-level (de)nornalization_context.

htaoufikallah commented 5 years ago

@teohhanhui thank you, I'll try to implement it

tomsykes commented 4 years ago

The problem with the suggested normalizer above is that it needs code duplicating for each of the properties that will be using your custom format. I have an alternative, more generic suggestion...

I spent quite some time investigating how API platform was getting the class metadata, and where it was deciding what type a property should be, and therefore how to norm/de-norm it. The result is that it gets the property type from doctrine. So, in the instance of the various doctrine datatypes, Api platform uses/expects a DateTime/DateTimeInterface object.

By creating a custom DBAL type mapping (or overriding existing ones), we can tell Doctrine that we're using a different class in php for a database type. In my project, I have overridden the 'date' doctrine type (rather then creating a custom one, but it would be very similar) as I wanted all "date" fields to follow the same format on my API.

doctrine:
  dbal:
    types:
      date: App\DBAL\DateType

My DateType class looks like this:

use App\Entity\Date;

class DateType extends \Doctrine\DBAL\Types\DateType
{
    public function convertToPHPValue($value, AbstractPlatform $platform)
    {
        if ($value === null || $value instanceof DateTimeInterface) {
            return $value;
        }

        $val = Date::createFromFormat('!' . $platform->getDateFormatString(), $value);
        if (! $val) {
            throw ConversionException::conversionFailedFormat($value, $this->getName(), $platform->getDateFormatString());
        }

        return $val;
    }
}

Which uses my Date class:

class Date extends \DateTime
{
    public static function fromDateTime(\DateTimeInterface $dateTime) {
        return (new Date())->setTimestamp($dateTime->getTimestamp());
    }

    public function toDateTime() {
        return (new \DateTime())->setTimestamp($this->getTimestamp());
    }

    public static function createFromFormat($format, $time, DateTimeZone $timezone = null)
    {
        return self::fromDateTime(\DateTime::createFromFormat($format, $time, $timezone));
    }
}

Create a normalizer for the new class (I copied mine directly from the Symfony DateTimeNormalizer), and updated the format, and the handled types.

Finally, if you want the swagger/openapi context to report the right format, update the ApiProperty annotation:

/**
 * @ApiProperty(attributes={
 *     "openapi_context"={"format"="date"}
 * })
 */
mrtnzagustin commented 3 years ago

@tomsykes i have a similar case .. can you give me the full code of your custom DateTimeNormalizer and other neded configs? I have a custom datePk class. Serialization works fine but i have problems with deserialization -> "Cannot create an instance of "App\DBAL\Type\DatePK" (my custom date pk class) from serialized data"

tomsykes commented 3 years ago

Sure, you can find it here https://github.com/Department-for-Transport-External/NTS-API/blob/master/src/Serializer/Normalizer/DateNormalizer.php

It's essentially a direct copy of the Serializer component's existing DateTimeNormalizer, but with the types swapped for the entity type.

mrtnzagustin commented 3 years ago

@tomsykes thanks!! but that's not a public repo, sorry!

tomsykes commented 3 years ago

Well, that's embarrassing - that's what happens when I make assumptions!

<?php

namespace App\Serializer\Normalizer;

use App\Entity\Date;
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;

/**
 * Normalizes an object implementing the {@see \DateTimeInterface} to a date string.
 * Denormalizes a date string to an instance of {@see \DateTime} or {@see \DateTimeImmutable}.
 *
 * @author Kévin Dunglas <dunglas@gmail.com>
 */
class DateNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface
{
    const FORMAT_KEY = 'datetime_format';
    const TIMEZONE_KEY = 'datetime_timezone';

    private $defaultContext = [
        self::FORMAT_KEY => 'Y-m-d',
        self::TIMEZONE_KEY => null,
    ];

    private static $supportedTypes = [
        Date::class => true,
    ];

    public function __construct(array $defaultContext = [])
    {
        $this->defaultContext = array_merge($this->defaultContext, $defaultContext);
    }

    /**
     * {@inheritdoc}
     *
     * @throws InvalidArgumentException
     */
    public function normalize($object, $format = null, array $context = [])
    {
        if (!$object instanceof Date) {
            throw new InvalidArgumentException('The object must implement the "\DateTimeInterface".');
        }

        $dateTimeFormat = $context[self::FORMAT_KEY] ?? $this->defaultContext[self::FORMAT_KEY];
        $timezone = $this->getTimezone($context);

        if (null !== $timezone) {
            $object = clone $object;
            $object = $object->setTimezone($timezone);
        }

        return $object->format($dateTimeFormat);
    }

    /**
     * {@inheritdoc}
     */
    public function supportsNormalization($data, $format = null)
    {
        return $data instanceof Date;
    }

    /**
     * {@inheritdoc}
     *
     * @throws NotNormalizableValueException
     */
    public function denormalize($data, $type, $format = null, array $context = [])
    {
        $dateTimeFormat = $context[self::FORMAT_KEY] ?? null;
        $timezone = $this->getTimezone($context);

        if ('' === $data || null === $data) {
            throw new NotNormalizableValueException('The data is either an empty string or null, you should pass a string that can be parsed with the passed format or a valid DateTime string.');
        }

        if (null !== $dateTimeFormat) {
            $object = Date::createFromFormat($dateTimeFormat, $data, $timezone);

            if (false !== $object) {
                return $object;
            }

            $dateTimeErrors = Date::getLastErrors();

            throw new NotNormalizableValueException(sprintf('Parsing datetime string "%s" using format "%s" resulted in %d errors:'."\n".'%s', $data, $dateTimeFormat, $dateTimeErrors['error_count'], implode("\n", $this->formatDateTimeErrors($dateTimeErrors['errors']))));
        }

        try {
            return new Date($data, $timezone);
        } catch (\Exception $e) {
            throw new NotNormalizableValueException($e->getMessage(), $e->getCode(), $e);
        }
    }

    /**
     * {@inheritdoc}
     */
    public function supportsDenormalization($data, $type, $format = null)
    {
        return isset(self::$supportedTypes[$type]);
    }

    /**
     * {@inheritdoc}
     */
    public function hasCacheableSupportsMethod(): bool
    {
        return __CLASS__ === static::class;
    }

    /**
     * Formats datetime errors.
     *
     * @param array $errors
     * @return string[]
     */
    private function formatDateTimeErrors(array $errors): array
    {
        $formattedErrors = [];

        foreach ($errors as $pos => $message) {
            $formattedErrors[] = sprintf('at position %d: %s', $pos, $message);
        }

        return $formattedErrors;
    }

    private function getTimezone(array $context): ?\DateTimeZone
    {
        $dateTimeZone = $context[self::TIMEZONE_KEY] ?? $this->defaultContext[self::TIMEZONE_KEY];

        if (null === $dateTimeZone) {
            return null;
        }

        return $dateTimeZone instanceof \DateTimeZone ? $dateTimeZone : new \DateTimeZone($dateTimeZone);
    }
}
mrtnzagustin commented 3 years ago

Problem fixed, thanks @tomsykes !

frost-byte commented 3 years ago

@tomsykes Could you also post your service declaration for your normalizer? I assume you decorated it. I was wondering if you added tags and specified anything for autowire, public and autoconfigure.

tomsykes commented 3 years ago

Hi @frost-byte It doesn't have any dependencies, so autowiring should take care of that - as such I don't have a service config for it. It wasn't decorated. Sorry I can't be more helpful, it was over a year since I last looked at it, and I'm slammed with work at the moment.

mrtnzagustin commented 3 years ago

@frost-byte if you have a custom class i think you just have to do something similar to https://github.com/api-platform/core/issues/482#issuecomment-451032488

frost-byte commented 3 years ago

@tomsykes No worries, I realized afterwards that you had simply shared the same code as the default DateTime Normalizer.

@mrtnzagustin That example was meant to serialize a specific class that contained some date fields, I was trying to confirm that I had the correct service declaration for a serializer for a custom date type.

Turns out fixing another issue removed the need for it.

darius-v commented 3 months ago

What is the real life use case to have different format per property instead of relying on a standard date format? It looks weird.

to not break the api. It it was done like this and users use that api, need to keep that so that integration would not be broken.