Support typed embedded relation with willdurand/hateoas 3.0 (#1510)

* allow typed embedded relation with hateoas 3.0

* symfony/framework-bundle 4.2.7 is broken

https://github.com/symfony/symfony/pull/31156

* internal public methods
This commit is contained in:
Asmir Mustafic 2019-05-02 10:02:16 +02:00 committed by Guilhem N
parent 101648bb8f
commit eb255010a0
10 changed files with 192 additions and 18 deletions

View File

@ -12,10 +12,9 @@
namespace Nelmio\ApiDocBundle\ModelDescriber; namespace Nelmio\ApiDocBundle\ModelDescriber;
use EXSyst\Component\Swagger\Schema; use EXSyst\Component\Swagger\Schema;
use Hateoas\Configuration\Metadata\ClassMetadata;
use Hateoas\Configuration\Relation; use Hateoas\Configuration\Relation;
use Hateoas\Serializer\Metadata\RelationPropertyMetadata; use Hateoas\Serializer\Metadata\RelationPropertyMetadata;
use JMS\Serializer\Exclusion\GroupsExclusionStrategy;
use JMS\Serializer\SerializationContext;
use Metadata\MetadataFactoryInterface; use Metadata\MetadataFactoryInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait;
@ -48,39 +47,43 @@ class BazingaHateoasModelDescriber implements ModelDescriberInterface, ModelRegi
{ {
$this->JMSModelDescriber->describe($model, $schema); $this->JMSModelDescriber->describe($model, $schema);
/**
* @var ClassMetadata
*/
$metadata = $this->getHateoasMetadata($model); $metadata = $this->getHateoasMetadata($model);
if (null === $metadata) { if (null === $metadata) {
return; return;
} }
$groupsExclusion = null !== $model->getGroups() ? new GroupsExclusionStrategy($model->getGroups()) : null;
$schema->setType('object'); $schema->setType('object');
$context = $this->JMSModelDescriber->getSerializationContext($model);
foreach ($metadata->getRelations() as $relation) { foreach ($metadata->getRelations() as $relation) {
if (!$relation->getEmbedded() && !$relation->getHref()) { if (!$relation->getEmbedded() && !$relation->getHref()) {
continue; continue;
} }
$item = new RelationPropertyMetadata($relation->getExclusion(), $relation);
if (null !== $groupsExclusion && $relation->getExclusion()) { if (null !== $context->getExclusionStrategy() && $context->getExclusionStrategy()->shouldSkipProperty($item, $context)) {
$item = new RelationPropertyMetadata($relation->getExclusion(), $relation); continue;
// filter groups
if ($groupsExclusion->shouldSkipProperty($item, SerializationContext::create())) {
continue;
}
} }
$name = $relation->getName(); $context->pushPropertyMetadata($item);
$relationSchema = $schema->getProperties()->get($relation->getEmbedded() ? '_embedded' : '_links'); $embedded = $relation->getEmbedded();
$relationSchema = $schema->getProperties()->get($embedded ? '_embedded' : '_links');
$properties = $relationSchema->getProperties(); $properties = $relationSchema->getProperties();
$relationSchema->setReadOnly(true); $relationSchema->setReadOnly(true);
$name = $relation->getName();
$property = $properties->get($name); $property = $properties->get($name);
$property->setType('object');
if ($embedded && method_exists($embedded, 'getType') && $embedded->getType()) {
$this->JMSModelDescriber->describeItem($embedded->getType(), $property, $context);
} else {
$property->setType('object');
}
if ($relation->getHref()) { if ($relation->getHref()) {
$subProperties = $property->getProperties(); $subProperties = $property->getProperties();
@ -89,6 +92,8 @@ class BazingaHateoasModelDescriber implements ModelDescriberInterface, ModelRegi
$this->setAttributeProperties($relation, $subProperties); $this->setAttributeProperties($relation, $subProperties);
} }
$context->popPropertyMetadata();
} }
} }
@ -119,7 +124,7 @@ class BazingaHateoasModelDescriber implements ModelDescriberInterface, ModelRegi
foreach ($relation->getAttributes() as $attribute => $value) { foreach ($relation->getAttributes() as $attribute => $value) {
$subSubProp = $subProperties->get($attribute); $subSubProp = $subProperties->get($attribute);
switch (gettype($value)) { switch (gettype($value)) {
case 'integer': case 'integer' :
$subSubProp->setType('integer'); $subSubProp->setType('integer');
$subSubProp->setDefault($value); $subSubProp->setDefault($value);

View File

@ -118,7 +118,10 @@ class JMSModelDescriber implements ModelDescriberInterface, ModelRegistryAwareIn
$context->popClassMetadata(); $context->popClassMetadata();
} }
private function getSerializationContext(Model $model): SerializationContext /**
* @internal
*/
public function getSerializationContext(Model $model): SerializationContext
{ {
if (isset($this->contexts[$model->getHash()])) { if (isset($this->contexts[$model->getHash()])) {
$context = $this->contexts[$model->getHash()]; $context = $this->contexts[$model->getHash()];
@ -178,7 +181,11 @@ class JMSModelDescriber implements ModelDescriberInterface, ModelRegistryAwareIn
return false; return false;
} }
private function describeItem(array $type, $property, Context $context) /**
* @internal
* @return void
*/
public function describeItem(array $type, $property, Context $context)
{ {
$nestedTypeInfo = $this->getNestedTypeInArray($type); $nestedTypeInfo = $this->getNestedTypeInArray($type);
if (null !== $nestedTypeInfo) { if (null !== $nestedTypeInfo) {

View File

@ -11,6 +11,8 @@
namespace Nelmio\ApiDocBundle\Tests\Functional; namespace Nelmio\ApiDocBundle\Tests\Functional;
use Hateoas\Configuration\Embedded;
class BazingaFunctionalTest extends WebTestCase class BazingaFunctionalTest extends WebTestCase
{ {
public function testModelComplexDocumentationBazinga() public function testModelComplexDocumentationBazinga()
@ -57,12 +59,60 @@ class BazingaFunctionalTest extends WebTestCase
'route' => [ 'route' => [
'type' => 'object', 'type' => 'object',
], ],
'embed_with_group' => [
'type' => 'object',
],
], ],
], ],
], ],
], $this->getModel('BazingaUser')->toArray()); ], $this->getModel('BazingaUser')->toArray());
} }
public function testWithGroup()
{
$this->assertEquals([
'type' => 'object',
'properties' => [
'_embedded' => [
'readOnly' => true,
'properties' => [
'embed_with_group' => [
'type' => 'object',
],
],
],
],
], $this->getModel('BazingaUser_grouped')->toArray());
}
public function testWithType()
{
try {
new \ReflectionMethod(Embedded::class, 'getType');
} catch (\ReflectionException $e) {
$this->markTestSkipped('Typed embedded properties require at least willdurand/hateoas 3.0');
}
$this->assertEquals([
'type' => 'object',
'properties' => [
'_embedded' => [
'readOnly' => true,
'properties' => [
'typed_bazinga_users' => [
'items' => [
'$ref' => '#/definitions/BazingaUser',
],
'type' => 'array',
],
'typed_bazinga_name' => [
'type' => 'string',
],
],
],
],
], $this->getModel('BazingaUserTyped')->toArray());
}
protected static function createKernel(array $options = []) protected static function createKernel(array $options = [])
{ {
return new TestKernel(true, true); return new TestKernel(true, true);

View File

@ -32,4 +32,16 @@ class BazingaController
public function userAction() public function userAction()
{ {
} }
/**
* @Route("/api/bazinga_foo", methods={"GET"})
* @SWG\Response(
* response=200,
* description="Success",
* @Model(type=BazingaUser::class, groups={"foo"})
* )
*/
public function userGroupAction()
{
}
} }

View File

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Tests\Functional\Controller;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Tests\Functional\EntityExcluded\BazingaUserTyped;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Swagger\Annotations as SWG;
/**
* @Route(host="api.example.com")
*/
class BazingaTypedController
{
/**
* @Route("/api/bazinga_typed", methods={"GET"})
* @SWG\Response(
* response=200,
* description="Success",
* @Model(type=BazingaUserTyped::class)
* )
*/
public function userTypedAction()
{
}
}

View File

@ -18,7 +18,21 @@ use Hateoas\Configuration\Annotation as Hateoas;
* *
* @Hateoas\Relation(name="example", attributes={"str_att":"bar", "float_att":5.6, "bool_att": false}, href="http://www.example.com") * @Hateoas\Relation(name="example", attributes={"str_att":"bar", "float_att":5.6, "bool_att": false}, href="http://www.example.com")
* @Hateoas\Relation(name="route", href=@Hateoas\Route("foo")) * @Hateoas\Relation(name="route", href=@Hateoas\Route("foo"))
* @Hateoas\Relation(name="route", attributes={"foo":"bar"}, embedded=@Hateoas\Embedded("expr(service('xx'))")) * @Hateoas\Relation(
* name="route",
* attributes={"foo":"bar"},
* embedded=@Hateoas\Embedded(
* "expr(service('xx'))"
* )
* )
* @Hateoas\Relation(
* name="embed_with_group",
* attributes={"foo":"with_groups"},
* exclusion=@Hateoas\Exclusion(groups={"foo"}),
* embedded=@Hateoas\Embedded(
* "expr(service('xx'))"
* )
* )
*/ */
class BazingaUser class BazingaUser
{ {

View File

@ -0,0 +1,34 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Tests\Functional\EntityExcluded;
use Hateoas\Configuration\Annotation as Hateoas;
/**
* @Hateoas\Relation(
* name="typed_bazinga_users",
* embedded=@Hateoas\Embedded(
* "expr(service('zz'))",
* type="array<Nelmio\ApiDocBundle\Tests\Functional\Entity\BazingaUser>"
* )
* )
* @Hateoas\Relation(
* name="typed_bazinga_name",
* embedded=@Hateoas\Embedded(
* "expr(service('yy'))",
* type="string"
* )
* )
*/
class BazingaUserTyped
{
}

View File

@ -54,6 +54,7 @@ class SwaggerUiTest extends WebTestCase
'Dummy' => $expected['definitions']['Dummy'], 'Dummy' => $expected['definitions']['Dummy'],
'Test' => ['type' => 'string'], 'Test' => ['type' => 'string'],
'JMSPicture_mini' => ['type' => 'object'], 'JMSPicture_mini' => ['type' => 'object'],
'BazingaUser_grouped' => ['type' => 'object'],
]; ];
yield ['/docs/test', 'test', $expected]; yield ['/docs/test', 'test', $expected];

View File

@ -14,8 +14,10 @@ namespace Nelmio\ApiDocBundle\Tests\Functional;
use ApiPlatform\Core\Bridge\Symfony\Bundle\ApiPlatformBundle; use ApiPlatform\Core\Bridge\Symfony\Bundle\ApiPlatformBundle;
use Bazinga\Bundle\HateoasBundle\BazingaHateoasBundle; use Bazinga\Bundle\HateoasBundle\BazingaHateoasBundle;
use FOS\RestBundle\FOSRestBundle; use FOS\RestBundle\FOSRestBundle;
use Hateoas\Configuration\Embedded;
use JMS\SerializerBundle\JMSSerializerBundle; use JMS\SerializerBundle\JMSSerializerBundle;
use Nelmio\ApiDocBundle\NelmioApiDocBundle; use Nelmio\ApiDocBundle\NelmioApiDocBundle;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\BazingaUser;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\NestedGroup\JMSPicture; use Nelmio\ApiDocBundle\Tests\Functional\Entity\NestedGroup\JMSPicture;
use Nelmio\ApiDocBundle\Tests\Functional\ModelDescriber\VirtualTypeClassDoesNotExistsHandlerDefinedDescriber; use Nelmio\ApiDocBundle\Tests\Functional\ModelDescriber\VirtualTypeClassDoesNotExistsHandlerDefinedDescriber;
use Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle; use Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle;
@ -88,6 +90,12 @@ class TestKernel extends Kernel
if ($this->useBazinga) { if ($this->useBazinga) {
$routes->import(__DIR__.'/Controller/BazingaController.php', '/', 'annotation'); $routes->import(__DIR__.'/Controller/BazingaController.php', '/', 'annotation');
try {
new \ReflectionMethod(Embedded::class, 'getType');
$routes->import(__DIR__.'/Controller/BazingaTypedController.php', '/', 'annotation');
} catch (\ReflectionException $e) {
}
} }
} }
@ -172,6 +180,11 @@ class TestKernel extends Kernel
'type' => JMSPicture::class, 'type' => JMSPicture::class,
'groups' => ['mini'], 'groups' => ['mini'],
], ],
[
'alias' => 'BazingaUser_grouped',
'type' => BazingaUser::class,
'groups' => ['foo'],
],
], ],
], ],
]); ]);

View File

@ -51,6 +51,9 @@
"api-platform/core": "For using an API oriented framework.", "api-platform/core": "For using an API oriented framework.",
"friendsofsymfony/rest-bundle": "For using the parameters annotations." "friendsofsymfony/rest-bundle": "For using the parameters annotations."
}, },
"conflict": {
"symfony/framework-bundle": "4.2.7"
},
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Nelmio\\ApiDocBundle\\": "" "Nelmio\\ApiDocBundle\\": ""