Skip to content
Closed
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
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,6 @@ jobs:
- name: Update project dependencies
run: |
composer update --no-interaction --no-progress --ansi
# composer why doctrine/reflection
- name: Require Symfony Uid
run: composer require symfony/uid --dev --no-interaction --no-progress --ansi
- name: Install PHPUnit
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 2.7.0

* Doctrine: Better exception to find which resource is linked to an exception (#3965)
* Doctrine: Allow mixed type value for date filter (notice if invalid) (#3870)
* MongoDB: `date_immutable` support (#3940)
* DataProvider: Add `TraversablePaginator` (#3783)
* Swagger UI: Add `swagger_ui_extra_configuration` to Swagger / OpenAPI configuration (#3731)

## 2.6.3

* Identifiers: Re-allow `POST` operations even if no identifier is defined (#4052)
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"doctrine/common": "^2.11 || ^3.0",
"doctrine/data-fixtures": "^1.2.2",
"doctrine/doctrine-bundle": "^1.12 || ^2.0",
"doctrine/mongodb-odm": "^2.0",
"doctrine/mongodb-odm": "^2.1",
"doctrine/mongodb-odm-bundle": "^4.0",
"doctrine/orm": "^2.6.4 || ^3.0",
"elasticsearch/elasticsearch": "^6.0 || ^7.0",
Expand Down Expand Up @@ -86,7 +86,7 @@
},
"conflict": {
"doctrine/common": "<2.7",
"doctrine/mongodb-odm": "<2.0",
"doctrine/mongodb-odm": "<2.1",
"doctrine/persistence": "<1.3"
},
"suggest": {
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0000-subresources-definition.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Subresource Definition

* Status: proposed
* Status: superseded by [0002-resource-definition](0002-resource-definition.md)]
* Deciders: @dunglas, @vincentchalamon, @soyuka, @GregoireHebert, @Deuchnord

## Context and Problem Statement
Expand Down
208 changes: 208 additions & 0 deletions docs/adr/0002-resource-definition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Resource definition

* Status: proposed
* Deciders: @dunglas @soyuka @vincentchalamon @GregoireHebert

## Context and Problem Statement

The API Platform `@ApiResource` annotation was initially created to represent a Resource as defined in [Roy Fiedling's dissertation about REST](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm#sec_5_2_1_1) in corelation with [RFC 7231 about HTTP Semantics](https://httpwg.org/specs/rfc7231.html#resources). This annotation brings some confusion as it mixes concepts of resources and operations. Here we discussed how we could revamp API Platform's resource definition using PHP8 attributes, beeing as close as we can to Roy Fiedling's thesis vocabulary.

## Considered Options

* Declare multiple ApiResource on a PHP Class [see Subresources definition](./0000-subresources-definition.md)
* Declare operations in conjunction with resources using two attributes: `Resource` and `Operation`
* Use HTTP Verb to represent operations with a syntax sugar for collections (`CGET`?)

## Decision Outcome

As Roy Fiedling's thesis states:

> REST uses a resource identifier to identify the particular resource involved in an interaction between components. REST connectors provide a generic interface for accessing and manipulating the value set of a resource, regardless of how the membership function is defined or the type of software that is handling the request.

In API Platform, this resource identifier is also named [IRI (Internationalized Resource Identifiers)](https://tools.ietf.org/html/rfc3987). Following these recommandation, applied to PHP, we came up with the following [PHP 8 attributes](https://www.php.net/manual/en/language.attributes.php):

```php
<?php

#[Get]
#[Post]
class Users
{
#[ApiProperty(iri="hydra:member")]
public User[] $member = [];

public float $averageRate;
}

#[Get]
#[Put]
#[Delete]
class User
{
#[ApiProperty(identifier=true)]
public $id;
}
```

Under the hood, API Platform would declare two routes Representing the `/users` resource:

- GET /users
- POST /users

and three routes representing the `/users/{id}` resource:

- GET /users/{id}
- PUT /users/{id}
- DELETE /users/{id}

For convenience and to ease the upgrade path, these would still be available on a single class:

```php
<?php

#[Resource]
class User {}
```

corresponding to

```php
<?php

#[Get]
#[GetCollection]
#[PostCollection]
#[Put]
#[Delete]
class User {}
```

Verbs declared on a PHP class define API Platform operations. The `Resource` attributes would become optional and the only thing needed is to specify at least a verb and an IRI representing the Resource. Some examples:

<table>
<tr>
<th>
Code
</th>
<th>
Operations
</th>
</tr>
<tr>
<td>
<pre lang="php">
#[Get]
class User {}
</pre>
</td>
<td>
<ul><li>GET /users/{id}</li></ul>
</td>
</tr>
<tr>
<td>
<pre lang="php">
#[GetCollection]
class User {}
</pre>
</td>
<td>
<ul><li>GET /users</li></ul>
</td>
</tr>
<tr>
<td>
<pre lang="php">
#[Get("/users")]
class User {}
</pre>
</td>
<td>
<ul><li>GET /users</li></ul>
</td>
</tr>
<tr>
<td>
<pre lang="php">
#[Post("/users")]
#[Patch("/users/{id}")]
class User {}
</pre>
</td>
<td>
<ul><li>POST /users</li>
<li>PATCH /users/{id}</li>
</td>
</tr>
<tr>
<td>
<pre lang="php">
// See these as aliases to the `/users/{id}` Resource:
#[Get("/companies/{companyId}/users/{id}")]
#[Delete("/companies/{companyId}/users/{id}")]
class User {}
</pre>
</td>
<td>
<ul><li>GET /companies/{companyId}/users/{id}</li>
<li>DELETE /companies/{companyId}/users/{id}</li>
</td>
</tr>
</table>

To link these operations with identifiers, refer to [Resource Identifiers decision record](./0001-resource-identifiers), for example:

```php
<?php
use Company;

#[Get("/companies/{companyId}/users/{id}", [identifiers=["companyId" => [Company::class, "id"], "id" => [User::class, "id"]]])]
class User {
#[ApiProperty(identifier=true)]
public $id;
public Company $company;
}
```

The `Resource` attribute could be used to set defaults properties on operations:

```php
<?php

#[Resource(normalization_context=["groups"= [....]])]
#[Get("/users/{id}")]
class User {}
```

These properties can also be specified directly on the verb attribute:

```php
<?php

#[Get("/users/{id}", normalizationContext=["group"])]
class User {}
```

Internally, HTTP verbs are aliases to the Resource Attribute holding a method and a default path. The Resource Attribute is a reflection of the underlying metadata:

```php
<?php
class Resource {
public string $iri;
public bool $mercure;
public string $security;
public bool $paginationEnabled;
// etc.

/** this was named $attributes before **/
public array $extraProperties;
}
```

For GraphQL, `Query`, `Mutation` and `Subscription` will be added.

## Options declined

An `Operation` attribute was proposed but we want to keep the code base small and decided verb attributes where sufficient.

The initially proposed `CGet` will be named `GetCollection`. It is only a shortcut to what **used to be called** `collectionOperation` on the `GET` verb. To remove confusion around `collectionOperations` and `itemOperations`, these terms will be deprecated in the code-base. To distant ourselves from the `CRUD` pattern, we also declined `List` and `Create` as we want to focus on Resource based architectures. `RPC` routes will be easy to add using the `Post` verb if required.
1 change: 0 additions & 1 deletion features/doctrine/date_filter.feature
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,6 @@ Feature: Date filter on collections
And the JSON node "hydra:member[0].dateIncludeNullBeforeAndAfter" should be equal to "2015-04-02T00:00:00+00:00"
And the JSON node "hydra:member[1].dateIncludeNullBeforeAndAfter" should be null

@!mongodb
@createSchema
Scenario: Get collection filtered by date that is an immutable date variant
Given there are 30 dummyimmutabledate objects with dummyDate
Expand Down
14 changes: 14 additions & 0 deletions src/Bridge/Doctrine/Common/Filter/DateFilterTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace ApiPlatform\Core\Bridge\Doctrine\Common\Filter;

use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Core\Exception\InvalidArgumentException;

/**
* Trait for filtering the collection by date intervals.
Expand Down Expand Up @@ -79,4 +80,17 @@ protected function getFilterDescription(string $property, string $period): array
],
];
}

private function normalizeValue($value, string $operator): ?string
{
if (false === \is_string($value)) {
$this->getLogger()->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(sprintf('Invalid value for "[%s]", expected string', $operator)),
]);

return null;
}

return $value;
}
}
18 changes: 16 additions & 2 deletions src/Bridge/Doctrine/Common/Util/IdentifierManagerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

use ApiPlatform\Core\Exception\InvalidIdentifierException;
use ApiPlatform\Core\Exception\PropertyNotFoundException;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type as DBALType;
use Doctrine\ODM\MongoDB\DocumentManager;
Expand All @@ -29,6 +30,7 @@ trait IdentifierManagerTrait
{
private $propertyNameCollectionFactory;
private $propertyMetadataFactory;
private $resourceMetadataFactory;

/**
* Transform and check the identifier, composite or not.
Expand Down Expand Up @@ -74,7 +76,13 @@ private function normalizeIdentifiers($id, ObjectManager $manager, string $resou

$identifier = null === $identifiersMap ? $identifierValues[$i] ?? null : $identifiersMap[$propertyName] ?? null;
if (null === $identifier) {
throw new PropertyNotFoundException(sprintf('Invalid identifier "%s", "%s" was not found.', $id, $propertyName));
$exceptionMessage = sprintf('Invalid identifier "%s", "%s" was not found', $id, $propertyName);
if ($this->resourceMetadataFactory instanceof ResourceMetadataFactoryInterface) {
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
$exceptionMessage .= sprintf(' for resource "%s"', $resourceMetadata->getShortName());
}

throw new PropertyNotFoundException($exceptionMessage.'.');
}

$doctrineTypeName = $doctrineClassMetadata->getTypeOfField($propertyName);
Expand All @@ -87,7 +95,13 @@ private function normalizeIdentifiers($id, ObjectManager $manager, string $resou
$identifier = MongoDbType::getType($doctrineTypeName)->convertToPHPValue($identifier);
}
} catch (ConversionException $e) {
throw new InvalidIdentifierException(sprintf('Invalid value "%s" provided for an identifier.', $propertyName), $e->getCode(), $e);
$exceptionMessage = sprintf('Invalid value "%s" provided for an identifier', $propertyName);
if ($this->resourceMetadataFactory instanceof ResourceMetadataFactoryInterface) {
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
$exceptionMessage .= sprintf(' for resource "%s"', $resourceMetadata->getShortName());
}

throw new InvalidIdentifierException($exceptionMessage.'.', $e->getCode(), $e);
}

$identifiers[$propertyName] = $identifier;
Expand Down
9 changes: 8 additions & 1 deletion src/Bridge/Doctrine/MongoDbOdm/Filter/DateFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class DateFilter extends AbstractFilter implements DateFilterInterface

public const DOCTRINE_DATE_TYPES = [
MongoDbType::DATE => true,
MongoDbType::DATE_IMMUTABLE => true,
];

/**
Expand Down Expand Up @@ -107,8 +108,14 @@ protected function filterProperty(string $property, $values, Builder $aggregatio
/**
* Adds the match stage according to the chosen null management.
*/
private function addMatch(Builder $aggregationBuilder, string $field, string $operator, string $value, string $nullManagement = null): void
private function addMatch(Builder $aggregationBuilder, string $field, string $operator, $value, string $nullManagement = null): void
{
$value = $this->normalizeValue($value, $operator);

if (null === $value) {
return;
}

try {
$value = new \DateTime($value);
} catch (\Exception $e) {
Expand Down
1 change: 1 addition & 0 deletions src/Bridge/Doctrine/MongoDbOdm/Filter/SearchFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ protected function getType(string $doctrineType): string
case MongoDbType::BOOLEAN:
return 'bool';
case MongoDbType::DATE:
case MongoDbType::DATE_IMMUTABLE:
return \DateTimeInterface::class;
case MongoDbType::FLOAT:
return 'float';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ public function getTypes($class, $property, array $context = [])
switch ($typeOfField) {
case MongoDbType::DATE:
return [new Type(Type::BUILTIN_TYPE_OBJECT, $nullable, 'DateTime')];
case MongoDbType::DATE_IMMUTABLE:
return [new Type(Type::BUILTIN_TYPE_OBJECT, $nullable, 'DateTimeImmutable')];
case MongoDbType::HASH:
return [new Type(Type::BUILTIN_TYPE_ARRAY, $nullable, null, true)];
case MongoDbType::COLLECTION:
Expand Down
8 changes: 7 additions & 1 deletion src/Bridge/Doctrine/Orm/Filter/DateFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,15 @@ protected function filterProperty(string $property, $values, QueryBuilder $query
*
* @param string|DBALType $type
*/
protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, string $operator, string $value, string $nullManagement = null, $type = null)
protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, string $operator, $value, string $nullManagement = null, $type = null)
{
$type = (string) $type;
$value = $this->normalizeValue($value, $operator);

if (null === $value) {
return;
}

try {
$value = false === strpos($type, '_immutable') ? new \DateTime($value) : new \DateTimeImmutable($value);
} catch (\Exception $e) {
Expand Down
Loading