Skip to content

Commit 5918da4

Browse files
komik966jpdz8005
authored andcommitted
Constructor relations writtable (api-platform#2178)
* test relations with writable constructor parameters implement constructor params relation denormalization drop identifier getter typehint because of doctrine listeners problem tests for cyclic relationship drop constructor cycle deps (impossible to handle), add iri and nullable param test test put method remove return types from test entities to pass low deps test fix cs fix phpstan adjust to changes in symfony/serializer PR * in swagger mark property as readOnly when is not writable and initializable * mark method internal * fix typo in test
1 parent 350003e commit 5918da4

File tree

10 files changed

+677
-24
lines changed

10 files changed

+677
-24
lines changed
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
Feature: Value object as ApiResource
2+
In order to keep ApiResource immutable
3+
As a client software developer
4+
I need to be able to use class without setters as ApiResource
5+
6+
@createSchema
7+
Scenario: Create Value object resource
8+
When I add "Content-Type" header equal to "application/ld+json"
9+
And I send a "POST" request to "/vo_dummy_cars" with body:
10+
"""
11+
{
12+
"mileage": 1500,
13+
"bodyType": "suv",
14+
"make": "CustomCar",
15+
"insuranceCompany": {
16+
"name": "Safe Drive Company"
17+
},
18+
"drivers": [
19+
{
20+
"firstName": "John",
21+
"lastName": "Doe"
22+
}
23+
]
24+
}
25+
"""
26+
Then the response status code should be 201
27+
And the JSON should be equal to:
28+
"""
29+
{
30+
"@context": "/contexts/VoDummyCar",
31+
"@id": "/vo_dummy_cars/1",
32+
"@type": "VoDummyCar",
33+
"mileage": 1500,
34+
"bodyType": "suv",
35+
"inspections": [],
36+
"make": "CustomCar",
37+
"insuranceCompany": {
38+
"@id": "/vo_dummy_insurance_companies/1",
39+
"@type": "VoDummyInsuranceCompany",
40+
"name": "Safe Drive Company"
41+
},
42+
"drivers": [
43+
{
44+
"@id": "/vo_dummy_drivers/1",
45+
"@type": "VoDummyDriver",
46+
"firstName": "John",
47+
"lastName": "Doe"
48+
}
49+
]
50+
}
51+
"""
52+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
53+
54+
Scenario: Create Value object with IRI and nullable parameter
55+
When I add "Content-Type" header equal to "application/ld+json"
56+
And I send a "POST" request to "/vo_dummy_inspections" with body:
57+
"""
58+
{
59+
"accepted": true,
60+
"car": "/vo_dummy_cars/1"
61+
}
62+
"""
63+
Then the response status code should be 201
64+
And the JSON should be valid according to this schema:
65+
"""
66+
{
67+
"type": "object",
68+
"required": ["accepted", "performed", "car"],
69+
"properties": {
70+
"accepted": {
71+
"enum":[true]
72+
},
73+
"performed": {
74+
"format": "date-time"
75+
},
76+
"car": {
77+
"enum": ["/vo_dummy_cars/1"]
78+
}
79+
}
80+
}
81+
"""
82+
83+
Scenario: Update Value object with writable and non writable property
84+
When I add "Content-Type" header equal to "application/ld+json"
85+
And I send a "PUT" request to "/vo_dummy_inspections/1" with body:
86+
"""
87+
{
88+
"performed": "2018-08-24 00:00:00",
89+
"accepted": false
90+
}
91+
"""
92+
Then the response status code should be 200
93+
And the JSON should be equal to:
94+
"""
95+
{
96+
"@context": "/contexts/VoDummyInspection",
97+
"@id": "/vo_dummy_inspections/1",
98+
"@type": "VoDummyInspection",
99+
"accepted": true,
100+
"car": "/vo_dummy_cars/1",
101+
"performed": "2018-08-24T00:00:00+00:00",
102+
"id": 1
103+
}
104+
"""
105+
106+
@createSchema
107+
Scenario: Create Value object without required params
108+
When I add "Content-Type" header equal to "application/ld+json"
109+
And I send a "POST" request to "/vo_dummy_cars" with body:
110+
"""
111+
{
112+
"mileage": 1500,
113+
"make": "CustomCar",
114+
"insuranceCompany": {
115+
"name": "Safe Drive Company"
116+
}
117+
}
118+
"""
119+
Then the response status code should be 400
120+
And the JSON should be valid according to this schema:
121+
"""
122+
{
123+
"type": "object",
124+
"properties": {
125+
"@context": {
126+
"enum": ["/contexts/Error"]
127+
},
128+
"type": {
129+
"enum": ["hydra:Error"]
130+
},
131+
"hydra:title": {
132+
"enum": ["An error occurred"]
133+
},
134+
"hydra:description": {
135+
"enum": ["Cannot create an instance of ApiPlatform\\Core\\Tests\\Fixtures\\TestBundle\\Entity\\VoDummyCar from serialized data because its constructor requires parameter \"drivers\" to be present."]
136+
}
137+
}
138+
}
139+
"""
140+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
141+
142+
@createSchema
143+
Scenario: Create Value object without default param
144+
When I add "Content-Type" header equal to "application/ld+json"
145+
And I send a "POST" request to "/vo_dummy_cars" with body:
146+
"""
147+
{
148+
"mileage": 1500,
149+
"make": "CustomCar",
150+
"insuranceCompany": {
151+
"name": "Safe Drive Company"
152+
},
153+
"drivers": [
154+
{
155+
"firstName": "John",
156+
"lastName": "Doe"
157+
}
158+
]
159+
}
160+
"""
161+
Then the response status code should be 201
162+
And the JSON should be equal to:
163+
"""
164+
{
165+
"@context": "/contexts/VoDummyCar",
166+
"@id": "/vo_dummy_cars/1",
167+
"@type": "VoDummyCar",
168+
"mileage": 1500,
169+
"bodyType": "coupe",
170+
"inspections": [],
171+
"make": "CustomCar",
172+
"insuranceCompany": {
173+
"@id": "/vo_dummy_insurance_companies/1",
174+
"@type": "VoDummyInsuranceCompany",
175+
"name": "Safe Drive Company"
176+
},
177+
"drivers": [
178+
{
179+
"@id": "/vo_dummy_drivers/1",
180+
"@type": "VoDummyDriver",
181+
"firstName": "John",
182+
"lastName": "Doe"
183+
}
184+
]
185+
}
186+
"""
187+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"

src/Serializer/AbstractItemNormalizer.php

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
use Symfony\Component\PropertyAccess\PropertyAccess;
2828
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
2929
use Symfony\Component\PropertyInfo\Type;
30+
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
31+
use Symfony\Component\Serializer\Exception\RuntimeException;
3032
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
3133
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
3234
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
@@ -127,6 +129,93 @@ public function denormalize($data, $class, $format = null, array $context = [])
127129
return parent::denormalize($data, $class, $format, $context);
128130
}
129131

132+
/**
133+
* Method copy-pasted from symfony/serializer.
134+
* Remove it after symfony/serializer version update @link https://github.com/symfony/symfony/pull/28263.
135+
*
136+
* {@inheritdoc}
137+
*
138+
* @internal
139+
*/
140+
protected function instantiateObject(array &$data, $class, array &$context, \ReflectionClass $reflectionClass, $allowedAttributes, string $format = null)
141+
{
142+
if ($this->classDiscriminatorResolver && $mapping = $this->classDiscriminatorResolver->getMappingForClass($class)) {
143+
if (!isset($data[$mapping->getTypeProperty()])) {
144+
throw new RuntimeException(sprintf('Type property "%s" not found for the abstract object "%s"', $mapping->getTypeProperty(), $class));
145+
}
146+
147+
$type = $data[$mapping->getTypeProperty()];
148+
if (null === ($mappedClass = $mapping->getClassForType($type))) {
149+
throw new RuntimeException(sprintf('The type "%s" has no mapped class for the abstract object "%s"', $type, $class));
150+
}
151+
152+
$class = $mappedClass;
153+
$reflectionClass = new \ReflectionClass($class);
154+
}
155+
156+
if (null !== $object = $this->extractObjectToPopulate($class, $context, static::OBJECT_TO_POPULATE)) {
157+
unset($context[static::OBJECT_TO_POPULATE]);
158+
159+
return $object;
160+
}
161+
162+
$constructor = $this->getConstructor($data, $class, $context, $reflectionClass, $allowedAttributes);
163+
if ($constructor) {
164+
$constructorParameters = $constructor->getParameters();
165+
166+
$params = [];
167+
foreach ($constructorParameters as $constructorParameter) {
168+
$paramName = $constructorParameter->name;
169+
$key = $this->nameConverter ? $this->nameConverter->normalize($paramName) : $paramName;
170+
171+
$allowed = false === $allowedAttributes || \in_array($paramName, $allowedAttributes, true);
172+
$ignored = !$this->isAllowedAttribute($class, $paramName, $format, $context);
173+
if ($constructorParameter->isVariadic()) {
174+
if ($allowed && !$ignored && (isset($data[$key]) || array_key_exists($key, $data))) {
175+
if (!\is_array($data[$paramName])) {
176+
throw new RuntimeException(sprintf('Cannot create an instance of %s from serialized data because the variadic parameter %s can only accept an array.', $class, $constructorParameter->name));
177+
}
178+
179+
$params = array_merge($params, $data[$paramName]);
180+
}
181+
} elseif ($allowed && !$ignored && (isset($data[$key]) || array_key_exists($key, $data))) {
182+
$params[] = $this->createConstructorArgument($data[$key], $key, $constructorParameter, $context, $format);
183+
184+
// Don't run set for a parameter passed to the constructor
185+
unset($data[$key]);
186+
} elseif (isset($context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key])) {
187+
$params[] = $context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key];
188+
} elseif ($constructorParameter->isDefaultValueAvailable()) {
189+
$params[] = $constructorParameter->getDefaultValue();
190+
} else {
191+
throw new MissingConstructorArgumentsException(
192+
sprintf(
193+
'Cannot create an instance of %s from serialized data because its constructor requires parameter "%s" to be present.',
194+
$class,
195+
$constructorParameter->name
196+
)
197+
);
198+
}
199+
}
200+
201+
if ($constructor->isConstructor()) {
202+
return $reflectionClass->newInstanceArgs($params);
203+
}
204+
205+
return $constructor->invokeArgs(null, $params);
206+
}
207+
208+
return new $class();
209+
}
210+
211+
/**
212+
* {@inheritdoc}
213+
*/
214+
protected function createConstructorArgument($parameterData, string $key, \ReflectionParameter $constructorParameter, array &$context, string $format = null)
215+
{
216+
return $this->createAttributeValue($constructorParameter->name, $parameterData, $format, $context);
217+
}
218+
130219
/**
131220
* {@inheritdoc}
132221
*
@@ -167,6 +256,11 @@ protected function getAllowedAttributes($classOrObject, array $context, $attribu
167256
* {@inheritdoc}
168257
*/
169258
protected function setAttributeValue($object, $attribute, $value, $format = null, array $context = [])
259+
{
260+
$this->setValue($object, $attribute, $this->createAttributeValue($attribute, $value, $format, $context));
261+
}
262+
263+
private function createAttributeValue($attribute, $value, $format = null, array $context = [])
170264
{
171265
if (!\is_string($attribute)) {
172266
throw new InvalidValueException('Invalid value provided (invalid IRI?).');
@@ -176,44 +270,29 @@ protected function setAttributeValue($object, $attribute, $value, $format = null
176270
$type = $propertyMetadata->getType();
177271

178272
if (null === $type) {
179-
// No type provided, blindly set the value
180-
$this->setValue($object, $attribute, $value);
181-
182-
return;
273+
// No type provided, blindly return the value
274+
return $value;
183275
}
184276

185277
if (null === $value && $type->isNullable()) {
186-
$this->setValue($object, $attribute, $value);
187-
188-
return;
278+
return $value;
189279
}
190280

191281
if (
192282
$type->isCollection() &&
193283
null !== ($collectionValueType = $type->getCollectionValueType()) &&
194284
null !== $className = $collectionValueType->getClassName()
195285
) {
196-
$this->setValue(
197-
$object,
198-
$attribute,
199-
$this->denormalizeCollection($attribute, $propertyMetadata, $type, $className, $value, $format, $context)
200-
);
201-
202-
return;
286+
return $this->denormalizeCollection($attribute, $propertyMetadata, $type, $className, $value, $format, $context);
203287
}
204288

205289
if (null !== $className = $type->getClassName()) {
206-
$this->setValue(
207-
$object,
208-
$attribute,
209-
$this->denormalizeRelation($attribute, $propertyMetadata, $className, $value, $format, $this->createChildContext($context, $attribute))
210-
);
211-
212-
return;
290+
return $this->denormalizeRelation($attribute, $propertyMetadata, $className, $value, $format, $this->createChildContext($context, $attribute));
213291
}
214292

215293
$this->validateType($attribute, $type, $value, $format);
216-
$this->setValue($object, $attribute, $value);
294+
295+
return $value;
217296
}
218297

219298
/**

src/Swagger/Serializer/DocumentationNormalizer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ private function getPropertySchema(PropertyMetadata $propertyMetadata, \ArrayObj
445445
{
446446
$propertySchema = new \ArrayObject($propertyMetadata->getAttributes()['swagger_context'] ?? []);
447447

448-
if (false === $propertyMetadata->isWritable()) {
448+
if (false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) {
449449
$propertySchema['readOnly'] = true;
450450
}
451451

0 commit comments

Comments
 (0)