Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/Metadata/Resource/Factory/LinkFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\Metadata\Resource\Factory;

use ApiPlatform\Metadata\Exception\PropertyNotFoundException;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Metadata;
Expand Down Expand Up @@ -141,6 +142,10 @@ public function completeLink(Link $link): Link
$link = $link->withCompositeIdentifier(true);
}

if (null === $link->getSchema() && null !== ($schema = $this->getSchemaFromLinkedProperty($link))) {
$link = $link->withSchema($schema);
}

return $link;
}

Expand Down Expand Up @@ -198,4 +203,41 @@ private function getPropertyClassType(?Type $type): ?string

return TypeHelper::getClassName($type);
}

private function getSchemaFromLinkedProperty(Link $link): ?array
{
$resourceClass = $link->getFromClass() ?? $link->getToClass();
$property = $link->getFromProperty() ?? $link->getToProperty() ?? $link->getParameterName();
if (!$resourceClass || !$property) {
return null;
}

try {
$schema = $this->propertyMetadataFactory->create($resourceClass, $property)->getSchema();
} catch (PropertyNotFoundException) {
return null;
}

if (null === $schema || !isset($schema['type'])) {
return null;
}

if (!\is_array($schema['type'])) {
$schema['type'] = [$schema['type']];
}

// Properties can be nullable, but the uriVariable won't be
$schema['type'] = array_diff($schema['type'], ['null']);

// Link types are limited to primitive types
$schema['type'] = array_values(array_filter($schema['type'], static fn ($type) => \in_array($type, ['string', 'number', 'integer', 'boolean'], true)));
if (!$schema['type']) {
unset($schema['type']);
} elseif (1 === \count($schema['type'])) {
$schema['type'] = $schema['type'][0];
}

// only allow the type/format fields
return array_intersect_key($schema, array_flip(['type', 'format'])) ?: null;
}
}
39 changes: 39 additions & 0 deletions src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,45 @@ public function testCompleteLink(): void
);
}

#[DataProvider('provideCompleteLinkSchemaCases')]
public function testCompleteLinkSchema(Link $link, ?array $propertySchema, ?array $expectedSchema): void
{
$propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class);
$propertyNameCollectionFactory->expects($this->never())->method('create');
$propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class);
$propertyMetadataFactory->expects($this->any())->method('create')->with(AttributeResource::class, 'uuid')->willReturn(new ApiProperty(schema: $propertySchema));
$resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class);
$linkFactory = new LinkFactory($propertyNameCollectionFactory, $propertyMetadataFactory, $resourceClassResolver);

self::assertEquals(
null === $expectedSchema ? $link : $link->withSchema($expectedSchema),
$linkFactory->completeLink($link)
);
}

public static function provideCompleteLinkSchemaCases(): \Generator
{
$schema = ['type' => 'string', 'format' => 'uuid'];

yield 'fromClass and toProperty' => [new Link(toProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), $schema, $schema];
yield 'fromClass and parameterName' => [new Link(parameterName: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), $schema, $schema];
yield 'fromClass and fromProperty' => [new Link(fromProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), $schema, $schema];
yield 'no class' => [new Link(parameterName: 'uuid', identifiers: ['uuid']), $schema, null];
yield 'no property' => [new Link(fromClass: AttributeResource::class, identifiers: ['uuid']), $schema, null];

yield 'existing schema - string type is preserved' => [
new Link(fromProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid'], schema: ['type' => 'string', 'format' => 'custom']),
$schema,
['type' => 'string', 'format' => 'custom'],
];
yield 'existing schema - array type is ignored' => [new Link(fromProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), ['type' => 'array'], null];
yield 'nullable type is filtered' => [
new Link(fromProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']),
['type' => ['string', 'null'], 'format' => 'uuid'],
['type' => 'string', 'format' => 'uuid'],
];
}

public function testCreateLinksFromAttributesWithExtendedLink(): void
{
$propertyNameCollectionFactory = new PropertyInfoPropertyNameCollectionFactory(new PropertyInfoExtractor([new ReflectionExtractor()]));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public function testCreate(): void
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
$propertyNameCollectionFactoryProphecy->create(Argument::cetera())->willReturn(new PropertyNameCollection());
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
$propertyMetadataFactoryProphecy->create(Argument::cetera())->willReturn(new ApiProperty());
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
$linkFactory = new LinkFactory($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceClassResolverProphecy->reveal());
$pathSegmentNameGeneratorProphecy = $this->prophesize(PathSegmentNameGeneratorInterface::class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkSchema;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;

#[ApiResource(
operations: [
new Get(
uriTemplate: '/link_schema_parents/{parentId}/children/{id}',
uriVariables: [
'parentId' => new Link(
fromProperty: 'id',
fromClass: LinkSchemaParent::class
),
'id' => new Link(
fromClass: LinkSchemaChild::class,
identifiers: ['id']
),
],
provider: [LinkSchemaChild::class, 'provide']
),
new Get(
uriTemplate: '/link_schema_parents_by_code/{parentCode}/children/{id}',
uriVariables: [
'parentCode' => new Link(
fromProperty: 'code',
fromClass: LinkSchemaParent::class
),
'id' => new Link(
fromClass: LinkSchemaChild::class,
identifiers: ['id']
),
],
provider: [LinkSchemaChild::class, 'provide']
),
]
)]
class LinkSchemaChild
{
#[ApiProperty(identifier: true)]
public ?int $id = null;

public ?string $title = null;

public static function provide(): self
{
return new self();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkSchema;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use Symfony\Component\Uid\Uuid;

#[ApiResource(
operations: [
new Get(
uriTemplate: '/link_schema_parents/{id}',
provider: [LinkSchemaParent::class, 'provide']
),
]
)]
class LinkSchemaParent
{
#[ApiProperty(identifier: true)]
public ?Uuid $id = null;

public ?int $code = null;

public ?string $name = null;

public static function provide(): self
{
return new self();
}
}
89 changes: 89 additions & 0 deletions tests/Functional/OpenApiLinkSchemaTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Tests\Functional;

use ApiPlatform\Test\ApiTestCase;
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkSchema\LinkSchemaChild;
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkSchema\LinkSchemaParent;
use ApiPlatform\Tests\SetupClassResourcesTrait;

final class OpenApiLinkSchemaTest extends ApiTestCase
{
use SetupClassResourcesTrait;

protected static ?bool $alwaysBootKernel = false;

/**
* @return class-string[]
*/
public static function getResources(): array
{
return [
LinkSchemaParent::class,
LinkSchemaChild::class,
];
}

public function testLinkSchemaIsInferredFromLinkedProperty(): void
{
$response = self::createClient()->request('GET', '/docs', [
'headers' => ['Accept' => 'application/vnd.openapi+json'],
]);
$this->assertResponseIsSuccessful();

$json = $response->toArray();

$this->assertArrayHasKey('/link_schema_parents/{parentId}/children/{id}', $json['paths']);
$parameters = $json['paths']['/link_schema_parents/{parentId}/children/{id}']['get']['parameters'];

$this->assertCount(2, $parameters);
$this->assertSame(['parentId', 'id'], array_column($parameters, 'name'));
$this->assertSame($parameters[0]['name'], 'parentId');
$this->assertSame($parameters[1]['name'], 'id');

$parentParam = $parameters[0];
$idParam = $parameters[1];

$this->assertNotNull($parentParam);
$this->assertSame('path', $parentParam['in']);
$this->assertTrue($parentParam['required']);
$this->assertSame(['type' => 'string', 'format' => 'uuid'], $parentParam['schema']);

$this->assertNotNull($idParam);
$this->assertSame('path', $idParam['in']);
$this->assertTrue($idParam['required']);
$this->assertSame(['type' => 'integer'], $idParam['schema']);
}

public function testLinkSchemaIsInferredFromLinkedNonIdentifierProperty(): void
{
$response = self::createClient()->request('GET', '/docs', [
'headers' => ['Accept' => 'application/vnd.openapi+json'],
]);
$this->assertResponseIsSuccessful();

$json = $response->toArray();

$this->assertArrayHasKey('/link_schema_parents_by_code/{parentCode}/children/{id}', $json['paths']);
$byCodeParameters = $json['paths']['/link_schema_parents_by_code/{parentCode}/children/{id}']['get']['parameters'];

$this->assertSame($byCodeParameters[0]['name'], 'parentCode');
$parentCodeParam = $byCodeParameters[0];

$this->assertNotNull($parentCodeParam);
$this->assertSame('path', $parentCodeParam['in']);
$this->assertTrue($parentCodeParam['required']);
$this->assertSame(['type' => 'integer'], $parentCodeParam['schema']);
}
}
Loading