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

Add extension point for schema generation #3919

Open
theofidry opened this issue Dec 27, 2020 · 3 comments
Open

Add extension point for schema generation #3919

theofidry opened this issue Dec 27, 2020 · 3 comments
Milestone

Comments

@theofidry
Copy link
Contributor

Follow up of #3918.

When building the doc or dumping the schema, API-Platform does a lot of work to translate the known type of a property into the format we dump it into. For example:

DateTimeInterface -> 
[
  'type' => 'string',
  'format' => 'date-time',
]

'int' -> 
[
  'type' => number,
]

Trying to solve #3918 in a more correct way, I'm running into two limitations:

*The current (2.5.9) set supported (for JsonSchema):

  • DateTimeInterface
  • DateInterval
  • Ramsey\Uuid\UuidInterface
  • resource class
  • others: try to build a schema out of it

The first limitation needs to first be addressed in PropertyInfo so there is no direct actionable right now. However for the second one, IMO there should be an extension point to allow one to hook in their own type resolver. I would avoid a inheritance hell there so I would like to suggest to introduce a new interface:

interface ClassTypeResolver
{
    /**
     * Gets the JSON Schema document which specifies the data type corresponding to the given PHP
     * class, and recursively adds needed new schema to the current schema if provided.
     *
     * @param class-string|null $className
     *
     * @return array{type: "string"}|array{type: string, format: string}|array{$ref: mixed}
     */
    public function getClassType(
        ?string $className,
        string $format,
        ?bool $readableLink,
        ?array $serializerContext,
        ?Schema $schema,
        ?SchemaFactoryInterface $schemaFactory
    ): array;
}

From there the TypeFactory can be rewritten:

TypeFactory.php
final class TypeFactory implements TypeFactoryInterface
{
    use ResourceClassInfoTrait;

    /**
     * @var SchemaFactoryInterface|null
     */
    private $schemaFactory;

    /**
     * @var ClassTypeResolver
     */
    private ClassTypeResolver $typeResolver;

    public function __construct(
        ResourceClassResolverInterface $resourceClassResolver = null,
        // We inject the type resolver as a service
        ClassTypeResolver $typeResolver = null
    )
    {
        $this->resourceClassResolver = $resourceClassResolver;
        // I don't get why everything is nullable here but so be it
        $this->typeResolver = $typeResolver ?? new ApiPlatformCoreClassTypeResolver($resourceClassResolver);
    }

    // ...

    private function makeBasicType(Type $type, string $format = 'json', ?bool $readableLink = null, ?array $serializerContext = null, Schema $schema = null): array
    {
        switch ($type->getBuiltinType()) {
            case Type::BUILTIN_TYPE_INT:
                return ['type' => 'integer'];
            case Type::BUILTIN_TYPE_FLOAT:
                return ['type' => 'number'];
            case Type::BUILTIN_TYPE_BOOL:
                return ['type' => 'boolean'];
            case Type::BUILTIN_TYPE_OBJECT:
                // This is the changed code
                return $this->typeResolver->getClassType($type->getClassName(), $format, $readableLink, $serializerContext, $schema, $this->schemaFactory);
            default:
                return ['type' => 'string'];
        }
    }

    // ...
}

And the existing type resolution extracted:

ApiPlatformCoreClassTypeResolver.php
final class ApiPlatformCoreClassTypeResolver implements ClassTypeResolver
{
    use ResourceClassInfoTrait;

    public function __construct(ResourceClassResolverInterface $resourceClassResolver = null)
    {
        $this->resourceClassResolver = $resourceClassResolver;
    }

    public function getClassType(
        ?string $className,
        string $format,
        ?bool $readableLink,
        ?array $serializerContext,
        ?Schema $schema,
        ?SchemaFactoryInterface $schemaFactory
    ): array {
        if (null === $className) {
            return ['type' => 'string'];
        }

        if (is_a($className, \DateTimeInterface::class, true)) {
            return [
                'type' => 'string',
                'format' => 'date-time',
            ];
        }
        if (is_a($className, \DateInterval::class, true)) {
            return [
                'type' => 'string',
                'format' => 'duration',
            ];
        }
        if (is_a($className, UuidInterface::class, true)) {
            return [
                'type' => 'string',
                'format' => 'uuid',
            ];
        }

        // Skip if $schema is null (filters only support basic types)
        if (null === $schema) {
            return ['type' => 'string'];
        }

        if ($this->isResourceClass($className) && true !== $readableLink) {
            return [
                'type' => 'string',
                'format' => 'iri-reference',
            ];
        }

        $version = $schema->getVersion();

        $subSchema = new Schema($version);
        $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema

        if (null === $schemaFactory) {
            throw new \LogicException('The schema factory must be injected by calling the "setSchemaFactory" method.');
        }

        $subSchema = $schemaFactory->buildSchema($className, $format, Schema::TYPE_OUTPUT, null, null, $subSchema, $serializerContext);

        return ['$ref' => $subSchema['$ref']];
    }
}

From there it is easy to hook your own type resolver, e.g. mine:

IdClassTypeResolver
final class IdClassTypeResolver implements ClassTypeResolver
{
    private ClassTypeResolver $decoratedClassTypeResolver;

    public function __construct(ClassTypeResolver $decoratedClassTypeResolver)
    {
        $this->decoratedClassTypeResolver = $decoratedClassTypeResolver;
    }

    public function getClassType(
        ?string $className,
        string $format,
        ?bool $readableLink,
        ?array $serializerContext,
        ?Schema $schema,
        ?SchemaFactoryInterface $schemaFactory
    ): array {
        if (null === $className) {
            return $this->decoratedClassTypeResolver->getClassType(
                $className,
                $format,
                $readableLink,
                $serializerContext,
                $schema,
                $schemaFactory,
            );
        }

        if (is_a($className, IntId::class, true)) {
            return [
                'type' => 'number',
            ];
        }

        if (is_a($className, UuidId::class, true)) {
            return [
                'type' => 'string',
                'format' => 'uuid',
            ];
        }

        if (is_a($className, StringId::class, true)) {
            return [
                'type' => 'string',
                'format' => 'string',
            ];
        }

        if (is_a($className, Id::class, true)) {
            return [
                'type' => 'string',
                'format' => 'string',
            ];
        }

        return $this->decoratedClassTypeResolver->getClassType(
            $className,
            $format,
            $readableLink,
            $serializerContext,
            $schema,
            $schemaFactory,
        );
    }
}

Note that I did this only for the JsonSchema and not 100% sure of the implications for the other one.

WDYT?

@alanpoulain
Copy link
Member

Sure, this is definitely a good refactoring.
You can also take example of how the GraphQL subsystem has done it (for instance to choose the same names or conventions):

@theofidry
Copy link
Contributor Author

theofidry commented Jan 3, 2021

PropertyInfo is quite limited regarding the support of generics and union types; There is symfony/symfony#38093 for partially addressing this

Actually this is an API-Platform limitation: https://github.com/api-platform/core/blob/da500b2/src/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyMetadataFactory.php#L52-L57

@soyuka soyuka added this to the 2.7 milestone Feb 6, 2021
@majermi4
Copy link

Is anybody working on this one or can I try making a PR for this? :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants