Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat: spec-compliant PUT method
  • Loading branch information
soyuka committed Jan 11, 2023
commit fad77550082f6bd7ecc1a277f0e6efa4de3bbd70
49 changes: 49 additions & 0 deletions features/main/standard_put.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
Feature: Spec-compliant PUT support
As a client software developer
I need to be able to create or replace resources using the PUT HTTP method

@createSchema
Scenario: Create a new resource
When I add "Content-Type" header equal to "application/ld+json"
And I send a "PUT" request to "/standard_puts/5" with body:
"""
{
"foo": "a",
"bar": "b"
}
"""
Then the response status code should be 201
And the response should be in JSON
And the JSON should be equal to:
"""
{
"@context": "/contexts/StandardPut",
"@id": "/standard_puts/5",
"@type": "StandardPut",
"id": 5,
"foo": "a",
"bar": "b"
}
"""

Scenario: Replace an existing resource
When I add "Content-Type" header equal to "application/ld+json"
And I send a "PUT" request to "/standard_puts/5" with body:
"""
{
"foo": "c"
}
"""
Then the response status code should be 200
And the response should be in JSON
And the JSON should be equal to:
"""
{
"@context": "/contexts/StandardPut",
"@id": "/standard_puts/5",
"@type": "StandardPut",
"id": 5,
"foo": "c",
"bar": ""
}
"""
7 changes: 7 additions & 0 deletions src/Doctrine/Common/State/LinksHandlerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;

trait LinksHandlerTrait
{
private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory;

/**
* @return Link[]
*/
Expand All @@ -48,6 +51,10 @@ private function getLinks(string $resourceClass, Operation $operation, array $co
return [$newLink];
}

if (!$this->resourceMetadataCollectionFactory) {
return [$newLink];
}

// Using GraphQL, it's possible that we won't find a GraphQL Operation of the same type (e.g. it is disabled).
try {
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($linkClass);
Expand Down
75 changes: 66 additions & 9 deletions src/Doctrine/Common/State/PersistProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\Doctrine\Common\State;

use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Util\ClassInfoTrait;
Expand All @@ -24,6 +25,7 @@
final class PersistProcessor implements ProcessorInterface
{
use ClassInfoTrait;
use LinksHandlerTrait;

public function __construct(private readonly ManagerRegistry $managerRegistry)
{
Expand All @@ -38,10 +40,56 @@ public function __construct(private readonly ManagerRegistry $managerRegistry)
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$manager = $this->getManager($data)) {
if (
!\is_object($data) ||
!$manager = $this->managerRegistry->getManagerForClass($class = $this->getObjectClass($data))
) {
return $data;
}

// PUT: reset the existing object managed by Doctrine and merge data sent by the user in it
// This custom logic is needed because EntityManager::merge() has been deprecated and UPSERT isn't supported:
// https://github.com/doctrine/orm/issues/8461#issuecomment-1250233555
if ($operation instanceof HttpOperation && HttpOperation::METHOD_PUT === $operation->getMethod() && ($operation->getExtraProperties()['standard_put'] ?? false)) {
\assert(method_exists($manager, 'getReference'));
// TODO: the call to getReference is most likely to fail with complex identifiers
$newData = isset($context['previous_data']) ? $manager->getReference($class, $uriVariables) : $data;
$identifiers = array_reverse($uriVariables);
$links = $this->getLinks($class, $operation, $context);
$reflectionProperties = $this->getReflectionProperties($data);

if (!isset($context['previous_data'])) {
foreach (array_reverse($links) as $link) {
if ($link->getExpandedValue() || !$link->getFromClass()) {
continue;
}

$identifierProperties = $link->getIdentifiers();
$hasCompositeIdentifiers = 1 < \count($identifierProperties);

foreach ($identifierProperties as $identifierProperty) {
$reflectionProperty = $reflectionProperties[$identifierProperty];
$reflectionProperty->setValue($newData, $this->getIdentifierValue($identifiers, $hasCompositeIdentifiers ? $identifierProperty : null));
}
}
} else {
foreach ($reflectionProperties as $propertyName => $reflectionProperty) {
foreach ($links as $link) {
$identifierProperties = $link->getIdentifiers();
if (\in_array($propertyName, $identifierProperties, true)) {
continue;
}

if (($newValue = $reflectionProperty->getValue($data)) !== $reflectionProperty->getValue($newData)) {
$reflectionProperty->setValue($newData, $newValue);
}
}
}
}

$data = $newData;
}

if (!$manager->contains($data) || $this->isDeferredExplicit($manager, $data)) {
$manager->persist($data);
}
Expand All @@ -52,14 +100,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
return $data;
}

/**
* Gets the Doctrine object manager associated with given data.
*/
private function getManager($data): ?DoctrineObjectManager
{
return \is_object($data) ? $this->managerRegistry->getManagerForClass($this->getObjectClass($data)) : null;
}

/**
* Checks if doctrine does not manage data automatically.
*/
Expand All @@ -72,4 +112,21 @@ private function isDeferredExplicit(DoctrineObjectManager $manager, $data): bool

return false;
}

/**
* Get reflection properties indexed by property name.
*
* @return array<string, \ReflectionProperty>
*/
private function getReflectionProperties(mixed $data): array
{
$ret = [];
$props = (new \ReflectionObject($data))->getProperties(~\ReflectionProperty::IS_STATIC);

foreach ($props as $prop) {
$ret[$prop->getName()] = $prop;
}

return $ret;
}
}
3 changes: 2 additions & 1 deletion src/Doctrine/Odm/State/CollectionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ final class CollectionProvider implements ProviderInterface
/**
* @param AggregationCollectionExtensionInterface[] $collectionExtensions
*/
public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [])
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [])
{
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
}

public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable
Expand Down
3 changes: 2 additions & 1 deletion src/Doctrine/Odm/State/ItemProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ final class ItemProvider implements ProviderInterface
/**
* @param AggregationItemExtensionInterface[] $itemExtensions
*/
public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [])
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [])
{
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
}

public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
Expand Down
3 changes: 2 additions & 1 deletion src/Doctrine/Orm/State/CollectionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ final class CollectionProvider implements ProviderInterface
/**
* @param QueryCollectionExtensionInterface[] $collectionExtensions
*/
public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [])
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [])
{
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
}

public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable
Expand Down
3 changes: 2 additions & 1 deletion src/Doctrine/Orm/State/ItemProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ final class ItemProvider implements ProviderInterface
/**
* @param QueryItemExtensionInterface[] $itemExtensions
*/
public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [])
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [])
{
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
}

public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
Expand Down
10 changes: 9 additions & 1 deletion src/Symfony/EventListener/DeserializeListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace ApiPlatform\Symfony\EventListener;

use ApiPlatform\Api\FormatMatcher;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Serializer\SerializerContextBuilderInterface;
use ApiPlatform\Util\OperationRequestInitiatorTrait;
Expand Down Expand Up @@ -69,7 +70,14 @@ public function onKernelRequest(RequestEvent $event): void

$format = $this->getFormat($request, $operation?->getInputFormats() ?? []);
$data = $request->attributes->get('data');
if (null !== $data) {
if (
null !== $data &&
(
HttpOperation::METHOD_POST === $method ||
HttpOperation::METHOD_PATCH === $method ||
(HttpOperation::METHOD_PUT === $method && !($operation->getExtraProperties()['standard_put'] ?? false))
)
) {
$context[AbstractNormalizer::OBJECT_TO_POPULATE] = $data;
}

Expand Down
19 changes: 14 additions & 5 deletions src/Symfony/EventListener/ReadListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use ApiPlatform\Api\UriVariablesConverterInterface;
use ApiPlatform\Exception\InvalidIdentifierException;
use ApiPlatform\Exception\InvalidUriVariableException;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Serializer\SerializerContextBuilderInterface;
use ApiPlatform\State\ProviderInterface;
Expand All @@ -38,10 +39,12 @@ final class ReadListener
use OperationRequestInitiatorTrait;
use UriVariablesResolverTrait;

public const OPERATION_ATTRIBUTE_KEY = 'read';

public function __construct(private readonly ProviderInterface $provider, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, private readonly ?SerializerContextBuilderInterface $serializerContextBuilder = null, UriVariablesConverterInterface $uriVariablesConverter = null)
{
public function __construct(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we didn't want to have multiple lines in the constructor?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we have constructor promotion and the ability to add a comma after the last parameter, I think we should use multiple lines for clarity and to reduce merge conflicts.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know, it would be great to add it in the CS fixer.

private readonly ProviderInterface $provider,
?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null,
private readonly ?SerializerContextBuilderInterface $serializerContextBuilder = null,
UriVariablesConverterInterface $uriVariablesConverter = null,
) {
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
$this->uriVariablesConverter = $uriVariablesConverter;
}
Expand Down Expand Up @@ -90,7 +93,13 @@ public function onKernelRequest(RequestEvent $event): void
throw new NotFoundHttpException('Invalid identifier value or configuration.', $e);
}

if (null === $data) {
if (
null === $data &&
(
HttpOperation::METHOD_PUT !== $operation->getMethod() ||
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why using a constant here and not elsewhere?

!($operation->getExtraProperties()['allow_create'] ?? false)
)
) {
throw new NotFoundHttpException('Not Found');
}

Expand Down
12 changes: 9 additions & 3 deletions src/Symfony/EventListener/RespondListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

use ApiPlatform\Api\IriConverterInterface;
use ApiPlatform\Api\UrlGeneratorInterface;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Util\OperationRequestInitiatorTrait;
use ApiPlatform\Util\RequestAttributesExtractor;
Expand All @@ -35,8 +36,10 @@ final class RespondListener
'DELETE' => Response::HTTP_NO_CONTENT,
];

public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private readonly ?IriConverterInterface $iriConverter = null)
{
public function __construct(
ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null,
private readonly ?IriConverterInterface $iriConverter = null,
) {
$this->resourceMetadataCollectionFactory = $resourceMetadataFactory;
}

Expand Down Expand Up @@ -78,6 +81,7 @@ public function onKernelView(ViewEvent $event): void
$headers['Accept-Patch'] = $acceptPatch;
}

$method = $request->getMethod();
if (
$this->iriConverter &&
$operation &&
Expand All @@ -86,14 +90,16 @@ public function onKernelView(ViewEvent $event): void
) {
$status = 301;
$headers['Location'] = $this->iriConverter->getIriFromResource($request->attributes->get('data'), UrlGeneratorInterface::ABS_PATH, $operation);
} elseif (HttpOperation::METHOD_PUT === $method && !($attributes['previous_data'] ?? null)) {
$status = Response::HTTP_CREATED;
}

$status ??= self::METHOD_TO_CODE[$request->getMethod()] ?? Response::HTTP_OK;

if ($request->attributes->has('_api_write_item_iri')) {
$headers['Content-Location'] = $request->attributes->get('_api_write_item_iri');

if ((Response::HTTP_CREATED === $status || (300 <= $status && $status < 400)) && $request->isMethod('POST')) {
if ((Response::HTTP_CREATED === $status || (300 <= $status && $status < 400)) && HttpOperation::METHOD_POST === $method) {
$headers['Location'] = $request->attributes->get('_api_write_item_iri');
}
}
Expand Down
20 changes: 14 additions & 6 deletions src/Symfony/EventListener/WriteListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@ final class WriteListener
use OperationRequestInitiatorTrait;
use UriVariablesResolverTrait;

public const OPERATION_ATTRIBUTE_KEY = 'write';

public function __construct(private readonly ProcessorInterface $processor, private readonly IriConverterInterface $iriConverter, private readonly ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?UriVariablesConverterInterface $uriVariablesConverter = null)
{
public function __construct(
private readonly ProcessorInterface $processor,
private readonly IriConverterInterface $iriConverter,
private readonly ResourceClassResolverInterface $resourceClassResolver,
?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null,
?UriVariablesConverterInterface $uriVariablesConverter = null,
) {
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
$this->uriVariablesConverter = $uriVariablesConverter;
}
Expand All @@ -64,15 +67,20 @@ public function onKernelView(ViewEvent $event): void
return;
}

if (!($operation?->canWrite() ?? true) || !$attributes['persist']) {
if (!$attributes['persist'] || !($operation?->canWrite() ?? true)) {
return;
}

if (!$operation?->getProcessor()) {
return;
}

$context = ['operation' => $operation, 'resource_class' => $attributes['resource_class'], 'previous_data' => $attributes['previous_data'] ?? null];
$context = [
'operation' => $operation,
'resource_class' => $attributes['resource_class'],
'previous_data' => $attributes['previous_data'] ?? null,
];

try {
$uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $attributes['resource_class']);
} catch (InvalidIdentifierException $e) {
Expand Down
Loading