diff --git a/core/errors.md b/core/errors.md index a31c22173cc..c77db00c53f 100644 --- a/core/errors.md +++ b/core/errors.md @@ -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 @@ -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 diff --git a/core/serialization.md b/core/serialization.md index d4b077ea094..22479d29861 100644 --- a/core/serialization.md +++ b/core/serialization.md @@ -645,6 +645,110 @@ App\ApiResource\Person: +### 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 + BarJoinedA::class, 'b' => BarJoinedB::class])] +abstract class BarJoined +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + // ... +} +``` + +```php + ['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 diff --git a/core/state-providers.md b/core/state-providers.md index 8533803e5d6..0c65b5f454f 100644 --- a/core/state-providers.md +++ b/core/state-providers.md @@ -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 + 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 + 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