Skip to content

Commit eeba480

Browse files
fix(metadata): handle union/intersect types
1 parent ebebc46 commit eeba480

36 files changed

+1050
-310
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
Feature: Union/Intersect types
2+
3+
Scenario Outline: Create a resource with union type
4+
When I add "Content-Type" header equal to "application/ld+json"
5+
And I add "Accept" header equal to "application/ld+json"
6+
And I send a "POST" request to "/issue-5452/books" with body:
7+
"""
8+
{
9+
"number": <number>,
10+
"isbn": "978-3-16-148410-0"
11+
}
12+
"""
13+
Then the response status code should be 201
14+
And the response should be in JSON
15+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
16+
And the JSON should be valid according to this schema:
17+
"""
18+
{
19+
"type": "object",
20+
"properties": {
21+
"@type": {
22+
"type": "string",
23+
"pattern": "^Book$"
24+
},
25+
"@context": {
26+
"type": "string",
27+
"pattern": "^/contexts/Book$"
28+
},
29+
"@id": {
30+
"type": "string",
31+
"pattern": "^/.well-known/genid/.+$"
32+
},
33+
"number": {
34+
"type": "<type>"
35+
},
36+
"isbn": {
37+
"type": "string",
38+
"pattern": "^978-3-16-148410-0$"
39+
}
40+
},
41+
"required": [
42+
"@type",
43+
"@context",
44+
"@id",
45+
"number",
46+
"isbn"
47+
]
48+
}
49+
"""
50+
Examples:
51+
| number | type |
52+
| "1" | string |
53+
| 1 | integer |
54+
55+
Scenario: Create a resource with valid intersect type
56+
When I add "Content-Type" header equal to "application/ld+json"
57+
And I send a "POST" request to "/issue-5452/books" with body:
58+
"""
59+
{
60+
"number": 1,
61+
"isbn": "978-3-16-148410-0",
62+
"author": "/issue-5452/authors/1"
63+
}
64+
"""
65+
Then the response status code should be 201
66+
And the response should be in JSON
67+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
68+
And the JSON should be valid according to this schema:
69+
"""
70+
{
71+
"type": "object",
72+
"properties": {
73+
"@type": {
74+
"type": "string",
75+
"pattern": "^Book$"
76+
},
77+
"@context": {
78+
"type": "string",
79+
"pattern": "^/contexts/Book$"
80+
},
81+
"@id": {
82+
"type": "string",
83+
"pattern": "^/.well-known/genid/.+$"
84+
},
85+
"number": {
86+
"type": "integer"
87+
},
88+
"isbn": {
89+
"type": "string",
90+
"pattern": "^978-3-16-148410-0$"
91+
},
92+
"author": {
93+
"type": "string",
94+
"pattern": "^/issue-5452/authors/1$"
95+
}
96+
},
97+
"required": [
98+
"@type",
99+
"@context",
100+
"@id",
101+
"number",
102+
"isbn",
103+
"author"
104+
]
105+
}
106+
"""
107+
108+
Scenario: Create a resource with invalid intersect type
109+
When I add "Content-Type" header equal to "application/ld+json"
110+
And I send a "POST" request to "/issue-5452/books" with body:
111+
"""
112+
{
113+
"number": 1,
114+
"isbn": "978-3-16-148410-0",
115+
"library": "/issue-5452/libraries/1"
116+
}
117+
"""
118+
Then the response status code should be 400
119+
And the response should be in JSON
120+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
121+
And the JSON node "hydra:description" should be equal to 'Could not denormalize object of type "ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\ActivableInterface", no supporting normalizer found.'

src/Elasticsearch/Filter/AbstractFilter.php

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -93,46 +93,70 @@ protected function getMetadata(string $resourceClass, string $property): array
9393
return $noop;
9494
}
9595

96-
$type = $propertyMetadata->getBuiltinTypes()[0] ?? null;
96+
$types = $propertyMetadata->getBuiltinTypes();
9797

98-
if (null === $type) {
98+
if (null === $types) {
9999
return $noop;
100100
}
101101

102102
++$index;
103-
$builtinType = $type->getBuiltinType();
104103

105-
if (Type::BUILTIN_TYPE_OBJECT !== $builtinType && Type::BUILTIN_TYPE_ARRAY !== $builtinType) {
106-
if ($totalProperties === $index) {
107-
break;
104+
// check each type before deciding if it's noop or not
105+
// e.g: maybe the first type is noop, but the second is valid
106+
$isNoop = false;
107+
108+
foreach ($types as $type) {
109+
$builtinType = $type->getBuiltinType();
110+
111+
if (Type::BUILTIN_TYPE_OBJECT !== $builtinType && Type::BUILTIN_TYPE_ARRAY !== $builtinType) {
112+
if ($totalProperties === $index) {
113+
break 2;
114+
}
115+
116+
$isNoop = true;
117+
118+
continue;
108119
}
109120

110-
return $noop;
111-
}
121+
if ($type->isCollection() && null === $type = $type->getCollectionValueTypes()[0] ?? null) {
122+
$isNoop = true;
112123

113-
if ($type->isCollection() && null === $type = $type->getCollectionValueTypes()[0] ?? null) {
114-
return $noop;
115-
}
124+
continue;
125+
}
126+
127+
if (Type::BUILTIN_TYPE_ARRAY === $builtinType && Type::BUILTIN_TYPE_OBJECT !== $type->getBuiltinType()) {
128+
if ($totalProperties === $index) {
129+
break 2;
130+
}
131+
132+
$isNoop = true;
116133

117-
if (Type::BUILTIN_TYPE_ARRAY === $builtinType && Type::BUILTIN_TYPE_OBJECT !== $type->getBuiltinType()) {
118-
if ($totalProperties === $index) {
119-
break;
134+
continue;
120135
}
121136

122-
return $noop;
123-
}
137+
if (null === $className = $type->getClassName()) {
138+
$isNoop = true;
124139

125-
if (null === $className = $type->getClassName()) {
126-
return $noop;
140+
continue;
141+
}
142+
143+
if ($isResourceClass = $this->resourceClassResolver->isResourceClass($className)) {
144+
$currentResourceClass = $className;
145+
} elseif ($totalProperties !== $index) {
146+
$isNoop = true;
147+
148+
continue;
149+
}
150+
151+
$hasAssociation = $totalProperties === $index && $isResourceClass;
152+
$isNoop = false;
153+
154+
break;
127155
}
128156

129-
if ($isResourceClass = $this->resourceClassResolver->isResourceClass($className)) {
130-
$currentResourceClass = $className;
131-
} elseif ($totalProperties !== $index) {
157+
if ($isNoop) {
132158
return $noop;
133159
}
134-
135-
$hasAssociation = $totalProperties === $index && $isResourceClass;
136160
}
137161

138162
return [$type, $hasAssociation, $currentResourceClass, $currentProperty];

src/Elasticsearch/Util/FieldDatatypeTrait.php

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -59,30 +59,27 @@ private function getNestedFieldPath(string $resourceClass, string $property): ?s
5959
return null;
6060
}
6161

62-
// TODO: 3.0 allow multiple types
63-
$type = $propertyMetadata->getBuiltinTypes()[0] ?? null;
64-
65-
if (null === $type) {
66-
return null;
67-
}
68-
69-
if (
70-
Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType()
71-
&& null !== ($nextResourceClass = $type->getClassName())
72-
&& $this->resourceClassResolver->isResourceClass($nextResourceClass)
73-
) {
74-
$nestedPath = $this->getNestedFieldPath($nextResourceClass, implode('.', $properties));
75-
76-
return null === $nestedPath ? $nestedPath : "$currentProperty.$nestedPath";
77-
}
78-
79-
if (
80-
null !== ($type = $type->getCollectionValueTypes()[0] ?? null)
81-
&& Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType()
82-
&& null !== ($className = $type->getClassName())
83-
&& $this->resourceClassResolver->isResourceClass($className)
84-
) {
85-
return $currentProperty;
62+
$types = $propertyMetadata->getBuiltinTypes() ?? [];
63+
64+
foreach ($types as $type) {
65+
if (
66+
Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType()
67+
&& null !== ($nextResourceClass = $type->getClassName())
68+
&& $this->resourceClassResolver->isResourceClass($nextResourceClass)
69+
) {
70+
$nestedPath = $this->getNestedFieldPath($nextResourceClass, implode('.', $properties));
71+
72+
return null === $nestedPath ? $nestedPath : "$currentProperty.$nestedPath";
73+
}
74+
75+
if (
76+
null !== ($type = $type->getCollectionValueTypes()[0] ?? null)
77+
&& Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType()
78+
&& null !== ($className = $type->getClassName())
79+
&& $this->resourceClassResolver->isResourceClass($className)
80+
) {
81+
return $currentProperty;
82+
}
8683
}
8784

8885
return null;

src/GraphQl/Type/FieldsBuilder.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,17 +213,23 @@ public function getResourceObjectTypeFields(?string $resourceClass, Operation $o
213213
'denormalization_groups' => $operation->getDenormalizationContext()['groups'] ?? null,
214214
];
215215
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $context);
216+
$propertyTypes = $propertyMetadata->getBuiltinTypes();
216217

217218
if (
218-
null === ($propertyType = $propertyMetadata->getBuiltinTypes()[0] ?? null)
219+
!$propertyTypes
219220
|| (!$input && false === $propertyMetadata->isReadable())
220221
|| ($input && $operation instanceof Mutation && false === $propertyMetadata->isWritable())
221222
) {
222223
continue;
223224
}
224225

225-
if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) {
226-
$fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration;
226+
// guess union/intersect types: check each type until finding a valid one
227+
foreach ($propertyTypes as $propertyType) {
228+
if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) {
229+
$fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration;
230+
// stop at the first valid type
231+
break;
232+
}
227233
}
228234
}
229235
}

src/Hal/Serializer/ItemNormalizer.php

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -138,31 +138,42 @@ private function getComponents(object $object, ?string $format, array $context):
138138
foreach ($attributes as $attribute) {
139139
$propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
140140

141-
// TODO: 3.0 support multiple types, default value of types will be [] instead of null
142-
$type = $propertyMetadata->getBuiltinTypes()[0] ?? null;
143-
$isOne = $isMany = false;
144-
145-
if (null !== $type) {
146-
if ($type->isCollection()) {
147-
$valueType = $type->getCollectionValueTypes()[0] ?? null;
148-
$isMany = null !== $valueType && ($className = $valueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
149-
} else {
150-
$className = $type->getClassName();
151-
$isOne = $className && $this->resourceClassResolver->isResourceClass($className);
141+
$types = $propertyMetadata->getBuiltinTypes() ?? [];
142+
143+
// prevent declaring $attribute as attribute if it's already declared as relationship
144+
$isRelationship = false;
145+
146+
foreach ($types as $type) {
147+
$isOne = $isMany = false;
148+
149+
if (null !== $type) {
150+
if ($type->isCollection()) {
151+
$valueType = $type->getCollectionValueTypes()[0] ?? null;
152+
$isMany = null !== $valueType && ($className = $valueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
153+
} else {
154+
$className = $type->getClassName();
155+
$isOne = $className && $this->resourceClassResolver->isResourceClass($className);
156+
}
152157
}
153-
}
154158

155-
if (!$isOne && !$isMany) {
156-
$components['states'][] = $attribute;
157-
continue;
158-
}
159+
if (!$isOne && !$isMany) {
160+
// don't declare it as an attribute too quick: maybe the next type is a valid resource
161+
continue;
162+
}
163+
164+
$relation = ['name' => $attribute, 'cardinality' => $isOne ? 'one' : 'many'];
165+
if ($propertyMetadata->isReadableLink()) {
166+
$components['embedded'][] = $relation;
167+
}
159168

160-
$relation = ['name' => $attribute, 'cardinality' => $isOne ? 'one' : 'many'];
161-
if ($propertyMetadata->isReadableLink()) {
162-
$components['embedded'][] = $relation;
169+
$components['links'][] = $relation;
170+
$isRelationship = true;
163171
}
164172

165-
$components['links'][] = $relation;
173+
// if all types are not relationships, declare it as an attribute
174+
if (!$isRelationship) {
175+
$components['states'][] = $attribute;
176+
}
166177
}
167178

168179
if (false !== $context['cache_key']) {

0 commit comments

Comments
 (0)