Skip to content

Add extension point for schema generation #3919

Description

@theofidry

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} */publicfunctiongetClassType(
?string$className,
string$format,
?bool$readableLink,
?array$serializerContext,
?Schema$schema,
?SchemaFactoryInterface$schemaFactory
): array;
}

From there the TypeFactory can be rewritten:

TypeFactory.php
finalclass TypeFactory implements TypeFactoryInterface
{
use ResourceClassInfoTrait;
/** * @var SchemaFactoryInterface|null */private$schemaFactory;
/** * @var ClassTypeResolver */privateClassTypeResolver$typeResolver;
publicfunction__construct(
ResourceClassResolverInterface$resourceClassResolver = null,
// We inject the type resolver as a serviceClassTypeResolver$typeResolver = null
)
{
$this->resourceClassResolver = $resourceClassResolver;
// I don't get why everything is nullable here but so be it$this->typeResolver = $typeResolver ?? newApiPlatformCoreClassTypeResolver($resourceClassResolver);
}
// ...privatefunctionmakeBasicType(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 codereturn$this->typeResolver->getClassType($type->getClassName(), $format, $readableLink, $serializerContext, $schema, $this->schemaFactory);
default:
return ['type' => 'string'];
}
}
// ...
}

And the existing type resolution extracted:

ApiPlatformCoreClassTypeResolver.php
finalclass ApiPlatformCoreClassTypeResolver implements ClassTypeResolver
{
use ResourceClassInfoTrait;
publicfunction__construct(ResourceClassResolverInterface$resourceClassResolver = null)
{
$this->resourceClassResolver = $resourceClassResolver;
}
publicfunctiongetClassType(
?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 = newSchema($version);
$subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schemaif (null === $schemaFactory) {
thrownew \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
finalclass IdClassTypeResolver implements ClassTypeResolver
{
privateClassTypeResolver$decoratedClassTypeResolver;
publicfunction__construct(ClassTypeResolver$decoratedClassTypeResolver)
{
$this->decoratedClassTypeResolver = $decoratedClassTypeResolver;
}
publicfunctiongetClassType(
?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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions