Skip to content
Merged
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
15 changes: 11 additions & 4 deletions core/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,14 @@ to use an [Error Provider](https://api-platform.com/docs/guides/error-provider/)

The decision works like this, if you are using API Platform with Symfony:

1. We look at `exception_to_status` and take one if there's a match
1. We look at `exception_to_status` and take one if there's a match. By default, this configuration
maps the following exceptions (see `addExceptionToStatusSection()` in
[`Configuration.php`](https://github.com/api-platform/core/blob/main/src/Symfony/Bundle/DependencyInjection/Configuration.php)):
- `Symfony\Component\Serializer\Exception\ExceptionInterface` => 400
- `ApiPlatform\Metadata\Exception\InvalidArgumentException` => 400
- `Doctrine\ORM\OptimisticLockException` => 409 (Doctrine ORM only)
- `Doctrine\DBAL\Exception\UniqueConstraintViolationException` => 422 (Doctrine ORM only, since
API Platform 5.0, see [#8478](https://github.com/api-platform/core/pull/8478))
2. If your exception is a `Symfony\Component\HttpKernel\Exception\HttpExceptionInterface` we get its
status.
3. If the exception is a `ApiPlatform\Metadata\Exception\ProblemExceptionInterface` and there is a
Expand Down Expand Up @@ -131,9 +138,9 @@ api_platform:
exception_to_status:
# The 4 following handlers are registered by default, keep those lines to prevent unexpected side effects
Symfony\Component\Serializer\Exception\ExceptionInterface: 400 # Use a raw status code (recommended)
ApiPlatform\Exception\InvalidArgumentException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST
ApiPlatform\ParameterValidator\Exception\ValidationExceptionInterface: 400
Doctrine\ORM\OptimisticLockException: 409
ApiPlatform\Metadata\Exception\InvalidArgumentException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST
Doctrine\ORM\OptimisticLockException: 409 # Doctrine ORM only
Doctrine\DBAL\Exception\UniqueConstraintViolationException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_UNPROCESSABLE_ENTITY # Doctrine ORM only, since API Platform 5.0

# Validation exception
ApiPlatform\Validator\Exception\ValidationException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_UNPROCESSABLE_ENTITY
Expand Down
104 changes: 104 additions & 0 deletions core/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,110 @@ App\ApiResource\Person:

</code-selector>

### Doctrine Inheritance Relations

Whether a relation is embedded or serialized as an IRI is decided by checking the serialization
groups declared on the properties of the **related resource class**. When that related class is the
parent of a Doctrine inheritance hierarchy, a group declared only on a discriminator subclass used
to be invisible to this check: the relation was always serialized as an IRI, even though the
concrete object returned at runtime did have a matching property.

Since API Platform 5.0, API Platform also checks the discriminator map for Doctrine ORM `JOINED` and
`SINGLE_TABLE` inheritance and MongoDB ODM `SINGLE_COLLECTION` inheritance. If any subclass listed
in the map declares a property in a group that matches the current normalization or denormalization
context, the relation is embedded as if that group had been declared directly on the parent class.

```php
<?php
// api/src/Entity/BarJoined.php
namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;

#[ApiResource]
#[ORM\Entity]
#[ORM\InheritanceType('JOINED')]
#[ORM\DiscriminatorColumn(name: 'discr', type: 'string')]
#[ORM\DiscriminatorMap(['a' => BarJoinedA::class, 'b' => BarJoinedB::class])]
abstract class BarJoined
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

// ...
}
```

```php
<?php
// api/src/Entity/BarJoinedA.php
namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource]
#[ORM\Entity]
class BarJoinedA extends BarJoined
{
#[ORM\Column]
#[Groups(['foo'])]
private ?string $y = null;

// ...
}
```

```php
<?php
// api/src/Entity/Foo.php
namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(operations: [new Get(normalizationContext: ['groups' => ['foo']])])]
#[ORM\Entity]
class Foo
{
#[ORM\ManyToOne(targetEntity: BarJoined::class)]
#[Groups(['foo'])]
private ?BarJoined $barJoined = null;

// ...
}
```

`Foo::$barJoined` has the `foo` group, but `BarJoined` (the abstract parent) has no property in that
group; only its subclass `BarJoinedA` does. Before 5.0, `GET /foos/1` always returned `barJoined` as
an IRI. Since 5.0, because `BarJoinedA::$y` is in the `foo` group and `BarJoinedA` is part of
`BarJoined`'s discriminator map, the relation is now embedded:

```json
{
"@id": "/foos/1",
"barJoined": {
"@type": "BarJoinedA",
"y": "y_value"
}
}
```

This is a behavior change for existing APIs using these Doctrine ORM or MongoDB ODM inheritance
strategies when a discriminator subclass declares a group also used for normalization or
denormalization elsewhere: relations that used to serialize as an IRI may now be embedded after
upgrading to 5.0. The new logic only fills in a link status that is not already decided; it never
overrides an explicit choice. To keep the pre-5.0 IRI-only behavior, force it explicitly as
described in
[Force IRI with relations of the same type](#force-iri-with-relations-of-the-same-type-parentchilds-relations)
above, for example `#[ApiProperty(readableLink: false, writableLink: false)]` on `Foo::$barJoined`.

### Plain Identifiers for Symfony

Instead of sending an IRI to set a relation, you may want to send a plain identifier. To do so, you
Expand Down
130 changes: 130 additions & 0 deletions core/state-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,136 @@ With pagination enabled (the default), it returns a Relay connection:
}
```

## Applying Provider Values to URI Variables

A `Link` used in `uriVariables` can declare a `provider`, either a callable (a static method, as
used for operation-level `provider`) or a service implementing
`ApiPlatform\State\ParameterProviderInterface`. The value set by the provider remains available on
the `Parameter`. Since API Platform 5.0, that value also replaces the URI variable used to fetch the
resource. This lets a provider transform an identifier before the resource lookup happens, for
example to decode it:

```php
<?php
// api/src/Entity/Base64UriVariableDummy.php
namespace App\Entity;

use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Parameter;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[Get]
#[Get(
uriTemplate: '/base64_uri_variable_dummies/encoded/{encodedName}',
uriVariables: [
'encodedName' => new Link(
fromClass: self::class,
identifiers: ['name'],
provider: [self::class, 'decodeName'],
),
],
)]
class Base64UriVariableDummy
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
public ?int $id = null;

#[ORM\Column]
public string $name;

public static function decodeName(Parameter $parameter, array $parameters = [], array $context = []): void
{
$parameter->setValue(base64_decode((string) $parameter->getValue(), true));
}
}
```

`GET /base64_uri_variable_dummies/encoded/QmxpcA==` now queries the entity with `name` equal to
`Blip`, the decoded value, instead of the raw base64 string: the Doctrine state provider fetches the
resource using the value the `decodeName` provider set, not the original path segment.

A provider that does not implement
`ApiPlatform\State\ParameterProvider\PreservesUriVariableInterface` is assumed to transform its
parameter's value this way, and the result becomes the URI variable used to query the resource. This
is a behavior change for any custom `Link` provider that used to call `$parameter->setValue()` only
to communicate a value to a later provider or to a custom state provider/processor without intending
it to replace the identifier used for the resource lookup: since 5.0, that value is now also used to
fetch the resource, unless the provider opts out.

### Opting Out with `PreservesUriVariableInterface`

Some providers resolve a URI variable to a whole linked resource instead of transforming an
identifier, typically to run a security expression against it. For example,
[`ReadLinkParameterProvider`](filters.md#readlinkparameterprovider) leaves the resolved resource on
the `Parameter`. Replacing the URI variable with that resource object would break the resource
lookup, which still expects an identifier. Such a provider must implement
`PreservesUriVariableInterface` so the original URI variable value is left untouched by default:

```php
<?php

namespace ApiPlatform\State\ParameterProvider;

use ApiPlatform\Metadata\Parameter;

interface PreservesUriVariableInterface
{
public function preservesUriVariable(Parameter $parameter): bool;
}
```

`ReadLinkParameterProvider::preservesUriVariable()` reads the `write_uri_variable` extra property
(it falls back to a constructor flag when the property is absent), so it preserves the URI variable
by default. Setting `write_uri_variable: true` on the `Link` opts back into writing: the resolved
resource then replaces the URI variable, and a custom `provider` on the operation can work directly
with that resource instead of an identifier:

```php
<?php
// api/src/ApiResource/WriteUriVariableLinkResource.php
namespace App\ApiResource;

use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ParameterProvider\ReadLinkParameterProvider;
use App\Entity\Dummy;

#[Get(
uriTemplate: '/write_uri_variable_link_resources/{id}',
uriVariables: [
'id' => new Link(
provider: ReadLinkParameterProvider::class,
fromClass: Dummy::class,
extraProperties: ['write_uri_variable' => true],
),
],
provider: [self::class, 'provide'],
)]
class WriteUriVariableLinkResource
{
public string $id;
public string $dummyName;

public static function provide(Operation $operation, array $uriVariables = []): self
{
$resource = new self();
$resource->id = '1';
// $uriVariables['id'] is the resolved Dummy entity, not its identifier,
// because write_uri_variable replaced the URI variable with it.
$resource->dummyName = $uriVariables['id']->getName();

return $resource;
}
}
```

Without `write_uri_variable: true`, `ReadLinkParameterProvider` still resolves and exposes the
linked `Dummy` through `$operation->getParameters()->get('id')->getValue()` (useful for a `security`
expression on the `Link`), but `$uriVariables['id']` stays the raw identifier from the URI.

## Registering Services Without Autowiring (only for the Symfony variant)

The services in the previous examples are automatically registered because
Expand Down
Loading