diff --git a/core/content-negotiation.md b/core/content-negotiation.md index ec8f060545f..f4286b05554 100644 --- a/core/content-negotiation.md +++ b/core/content-negotiation.md @@ -420,3 +420,37 @@ API Platform automatically adds two HTTP headers to responses for resources: > See [LDP §4.2 / Primer notes on Accept-Post](https://www.w3.org/TR/ldp/#Accept-Post) and typical > exposure via OPTIONS. + +## Content-Type Charset + +> [!NOTE] Behavioral change in API Platform 4.4 (core +> [#8226](https://github.com/api-platform/core/pull/8226)). + +Since 4.4, the `; charset=utf-8` parameter is only appended to the `Content-Type` response header +for media types whose IANA registration actually defines a `charset` parameter: `text/*` types (for +example `text/html`, `text/csv`, `text/xml`) and `application/xml`. JSON-based media types — +`application/json`, `application/ld+json`, `application/hal+json`, `application/vnd.api+json`, +`application/merge-patch+json`, `application/problem+json`, and so on — do not define a `charset` +parameter (per RFC 8259, and the `+json` structured syntax suffix of RFC 6839) and are always UTF-8, +so the parameter is no longer added to them. + +Before 4.4, `; charset=utf-8` was appended unconditionally to every response, including JSON-based +formats. For example, for a JSON-LD response: + +```diff +- Content-Type: application/ld+json; charset=utf-8 ++ Content-Type: application/ld+json +``` + +An XML response is unaffected, as `application/xml` still gets the parameter: + +```http +Content-Type: application/xml; charset=utf-8 +``` + +> [!WARNING] If a client or a test asserts on the exact `Content-Type` header value for a JSON-based +> format, it will break after upgrading to 4.4: the `charset=utf-8` suffix is gone. Update those +> assertions accordingly. + +The rule is implemented in `formatContentType()` in +[`HttpResponseHeadersTrait`](https://github.com/api-platform/core/blob/4.4/src/State/Util/HttpResponseHeadersTrait.php). diff --git a/core/doctrine-filters.md b/core/doctrine-filters.md index e01187408a7..424c033ea7f 100644 --- a/core/doctrine-filters.md +++ b/core/doctrine-filters.md @@ -136,14 +136,20 @@ To add some search filters, choose over this new list: notation) - [PartialSearchFilter](#partial-search-filter) (filter using a `LIKE %value%`; supports nested properties via dot notation) +- [StartSearchFilter](#start-search-filter) (filter using a `LIKE value%`; supports nested + properties via dot notation) +- [EndSearchFilter](#end-search-filter) (filter using a `LIKE %value`; supports nested properties + via dot notation) +- [WordStartSearchFilter](#word-start-search-filter) (filter on a word boundary prefix, matching + fields containing a word that starts with the value; supports nested properties via dot notation) - [ComparisonFilter](#comparison-filter) (filter with comparison operators `gt`, `gte`, `lt`, `lte`, `ne`; replaces `NumericFilter` and `RangeFilter` — it is not a replacement for `DateFilter`, which is kept) - [FreeTextQueryFilter](#free-text-query-filter) (allows you to apply multiple filters to multiple properties of a resource at the same time, using a single parameter in the URL) - [OrFilter](#or-filter) (apply a filter using `orWhere` instead of `andWhere`) -- [ChainFilter](#chain-filter) (compose several filters on a single parameter key, each self-selecting - by value shape) +- [ChainFilter](#chain-filter) (compose several filters on a single parameter key, each + self-selecting by value shape) ### SearchFilter @@ -315,6 +321,164 @@ It will return all chickens where the name contains the substring _tom_. `PartialSearchFilter` supports searching on nested properties using dot notation in the `property` argument. See [Filtering on Nested Properties](#filtering-on-nested-properties). +## Start Search Filter + +The start search filter allows filtering a resource by the beginning of a string property. + +Syntax: `?property=value` + +The value can take any scalar value or array of values. + +This filter can be used on the ApiResource attribute or in the operation attribute, for e.g., the +`#GetCollection()` attribute: + +```php +// api/src/ApiResource/Chicken.php + +#[GetCollection( + parameters: [ + 'name' => new QueryParameter(filter: new StartSearchFilter()), + ], +)] +class Chicken +{ + //... +} +``` + +Given that the endpoint is `/chickens`, you can filter chickens by name with the following query: +`/chikens?name=Ger`. + +It will return all chickens whose name starts with the substring _Ger_ (for e.g. "Gertrude"). + +> [!NOTE] The generated query and the default case sensitivity differ between Doctrine ORM and +> MongoDB ODM: +> +> - **Doctrine ORM** builds a `LOWER(field) LIKE LOWER('value%')` clause and is **case-insensitive +> by default**. Pass `new StartSearchFilter(caseSensitive: true)` for a case-sensitive +> `field LIKE 'value%'` match. +> - **MongoDB ODM** matches with a regular expression anchored at the start of the string (`^value`) +> and is **case-sensitive by default**. Pass `new StartSearchFilter(caseSensitive: false)` to add +> the case-insensitive `i` regex flag. + +`StartSearchFilter` supports filtering on nested properties using dot notation in the `property` +argument. See [Filtering on Nested Properties](#filtering-on-nested-properties). + +This filter replaces the `start` strategy of the deprecated `SearchFilter` +(`#[ApiFilter(SearchFilter::class, strategy: 'start')]`). See the +[migration guide](#migrating-from-apifilter-to-queryparameter). + +> [!NOTE] A Laravel/Eloquent equivalent also exists: +> [`ApiPlatform\Laravel\Eloquent\Filter\StartSearchFilter`](../laravel/filters.md#text). It always +> generates a `LIKE 'value%'` clause (case sensitivity depends on your database collation) and does +> not support nested/relation properties. + +## End Search Filter + +The end search filter allows filtering a resource by the end of a string property. + +Syntax: `?property=value` + +The value can take any scalar value or array of values. + +This filter can be used on the ApiResource attribute or in the operation attribute, for e.g., the +`#GetCollection()` attribute: + +```php +// api/src/ApiResource/Chicken.php + +#[GetCollection( + parameters: [ + 'name' => new QueryParameter(filter: new EndSearchFilter()), + ], +)] +class Chicken +{ + //... +} +``` + +Given that the endpoint is `/chickens`, you can filter chickens by name with the following query: +`/chikens?name=trude`. + +It will return all chickens whose name ends with the substring _trude_ (for e.g. "Gertrude"). + +> [!NOTE] The generated query and the default case sensitivity differ between Doctrine ORM and +> MongoDB ODM: +> +> - **Doctrine ORM** builds a `LOWER(field) LIKE LOWER('%value')` clause and is **case-insensitive +> by default**. Pass `new EndSearchFilter(caseSensitive: true)` for a case-sensitive +> `field LIKE '%value'` match. +> - **MongoDB ODM** matches with a regular expression anchored at the end of the string (`value$`) +> and is **case-sensitive by default**. Pass `new EndSearchFilter(caseSensitive: false)` to add +> the case-insensitive `i` regex flag. + +`EndSearchFilter` supports filtering on nested properties using dot notation in the `property` +argument. See [Filtering on Nested Properties](#filtering-on-nested-properties). + +This filter replaces the `end` strategy of the deprecated `SearchFilter` +(`#[ApiFilter(SearchFilter::class, strategy: 'end')]`). See the +[migration guide](#migrating-from-apifilter-to-queryparameter). + +> [!NOTE] A Laravel/Eloquent equivalent also exists: +> [`ApiPlatform\Laravel\Eloquent\Filter\EndSearchFilter`](../laravel/filters.md#text). It always +> generates a `LIKE '%value'` clause (case sensitivity depends on your database collation) and does +> not support nested/relation properties. + +## Word Start Search Filter + +The word start search filter allows filtering a resource by fields that contain a word starting with +the given value, matching either the beginning of the string or a word boundary further inside it. + +Syntax: `?property=value` + +The value can take any scalar value or array of values. + +This filter can be used on the ApiResource attribute or in the operation attribute, for e.g., the +`#GetCollection()` attribute: + +```php +// api/src/ApiResource/Chicken.php + +#[GetCollection( + parameters: [ + 'name' => new QueryParameter(filter: new WordStartSearchFilter()), + ], +)] +class Chicken +{ + //... +} +``` + +Given that the endpoint is `/chickens`, you can filter chickens by name with the following query: +`/chikens?name=Coq`. + +It matches "Coquette" (the value starts the string) and "Farm Coquette" (the value starts a word +that is not the first one), but not "Silkycoquette" (there, `coq` is inside the word +"silkycoquette", not at the start of a word). + +> [!NOTE] The generated query, and the exact word-boundary semantics, differ between Doctrine ORM +> and MongoDB ODM: +> +> - **Doctrine ORM** builds `field LIKE 'value%' OR field LIKE '% value%'` (word boundaries are +> recognized only on a literal ASCII space) and is **case-insensitive by default** (via +> `LOWER()`). Pass `new WordStartSearchFilter(caseSensitive: true)` for a case-sensitive match. +> - **MongoDB ODM** matches with the regular expression `(^value|\svalue)` — the value at the start +> of the string, or preceded by any whitespace character (space, tab, newline, not only a plain +> space) — and is **case-sensitive by default**. Pass +> `new WordStartSearchFilter(caseSensitive: false)` to add the case-insensitive `i` regex flag. + +`WordStartSearchFilter` supports filtering on nested properties using dot notation in the `property` +argument. See [Filtering on Nested Properties](#filtering-on-nested-properties). + +This filter replaces the `word_start` strategy of the deprecated `SearchFilter` +(`#[ApiFilter(SearchFilter::class, strategy: 'word_start')]`). See the +[migration guide](#migrating-from-apifilter-to-queryparameter). + +> [!NOTE] There is no Laravel/Eloquent equivalent of `WordStartSearchFilter`: the Laravel package +> only ships `PartialSearchFilter`, `StartSearchFilter`, and `EndSearchFilter`. + ## Free Text Query Filter The free text query filter allows filtering allows you to apply a single filter across a list of @@ -357,6 +521,50 @@ This request will return all chickens where: For the `OR` option refer to the [OrFilter](#or-filter). +### Using a Different Filter per Property + +The constructor also accepts an `array` map instead of a single shared +filter, so a single free-text parameter can apply a **different** filter strategy to each property. +This is available identically for Doctrine ORM and MongoDB ODM. + +```php +// api/src/ApiResource/Book.php + +#[GetCollection( + parameters: [ + 'q' => new QueryParameter( + filter: new FreeTextQueryFilter([ + 'title' => new PartialSearchFilter(), + 'isbn' => new ExactFilter(), + ]), + ), + ], +)] +class Book +{ + //... +} +``` + +Given that the endpoint is `/books`, the query `/books?q=vin` will return all books where: + +- the `title` contains the substring "vin" +- **AND** +- the `isbn` is exactly "vin". + +As with the single-filter form, properties are combined with `AND` by default; wrap the whole +`FreeTextQueryFilter` in [`OrFilter`](#or-filter) to combine them with `OR` instead — this still +works when `filter` is a map, since `OrFilter` only changes how the outer `FreeTextQueryFilter` call +combines its results, not which per-property filter is used. + +When `filter` is a map, the `properties` option is not required: it defaults to the map's keys +(`['title', 'isbn']` above). Pass `properties` explicitly only to restrict the search to a subset of +the map's keys. + +> [!NOTE] A property listed in `properties` that has no matching entry in the `filter` map (and, +> symmetrically, a map entry not listed in `properties`) is silently skipped: no filtering criteria +> is added for it, it neither restricts nor is required by the resulting query. + ## Or Filter The or filter allows you to explicitly change the logical condition used by the filter it wraps. Its @@ -565,12 +773,12 @@ parameter key, one per operator. For a parameter named `price`, the generated pa ## Date Filter > [!NOTE] `DateFilter` is a kept filter: there is no modern replacement for it. -> [`ComparisonFilter`](#comparison-filter) only performs plain `gt`/`gte`/`lt`/`lte`/`ne` comparisons -> and does not replicate `DateFilter`'s per-property [`null` management](#managing-null-values), its -> automatic `\DateTime`/`\DateTimeImmutable` binding based on the Doctrine column type, its tolerant -> handling of invalid or empty date values, or its inclusive `before`/`after` versus exclusive -> `strictly_before`/`strictly_after` URL vocabulary. Keep `DateFilter` and declare it through a -> `QueryParameter`. +> [`ComparisonFilter`](#comparison-filter) only performs plain `gt`/`gte`/`lt`/`lte`/`ne` +> comparisons and does not replicate `DateFilter`'s per-property +> [`null` management](#managing-null-values), its automatic `\DateTime`/`\DateTimeImmutable` binding +> based on the Doctrine column type, its tolerant handling of invalid or empty date values, or its +> inclusive `before`/`after` versus exclusive `strictly_before`/`strictly_after` URL vocabulary. +> Keep `DateFilter` and declare it through a `QueryParameter`. The date filter allows filtering a collection by date intervals. @@ -608,8 +816,8 @@ class Offer > [!NOTE] Instantiating a legacy filter with `new` (e.g. `new DateFilter()`) directly inside a > `QueryParameter` logs a cosmetic `ManagerRegistry must be initialized before accessing it.` ALERT > at cache warmup — filtering still works correctly. To silence it, reference the filter by its -> service id (a string) instead of an inline object, or wrap it in a -> [`ChainFilter`](#chain-filter), which suppresses the warmup call. See +> service id (a string) instead of an inline object, or wrap it in a [`ChainFilter`](#chain-filter), +> which suppresses the warmup call. See > [issue #7361](https://github.com/api-platform/core/issues/7361). > > For other syntaxes, for e.g., if you want to new syntax with the ApiResource attribute take a look @@ -749,10 +957,9 @@ class Offer `ChainFilter` is a decorator that composes several filters on a single query parameter key. Each wrapped filter self-selects by the shape of the incoming value: `ComparisonFilter` and `DateFilter` ignore plain scalar values (they only react to their operator-map syntax, e.g. `[gt]`/`[lt]` or -`[before]`/`[after]`), while `ExactFilter` and `PartialSearchFilter` ignore operator-map arrays. This -lets you combine, for -instance, an exact match and a full `DateFilter` on the same property without changing the URL -vocabulary of either. +`[before]`/`[after]`), while `ExactFilter` and `PartialSearchFilter` ignore operator-map arrays. +This lets you combine, for instance, an exact match and a full `DateFilter` on the same property +without changing the URL vocabulary of either. The canonical use case is adding exact-match filtering to a date property while keeping `DateFilter`'s null management and `before`/`after`/`strictly_before`/`strictly_after` semantics @@ -799,8 +1006,8 @@ Given that the collection endpoint is `/people`, both of the following queries w merges the OpenAPI parameters documented by every wrapped filter. Because it manages this injection itself, wrapping a legacy filter in a `ChainFilter` also suppresses the cosmetic `ManagerRegistry must be initialized before accessing it.` ALERT described in the -[#7361 note](#date-filter-using-the-queryparameter-syntax-recommended) — it is a valid alternative to -referencing the filter by service id when you need to compose it with another filter on the same +[#7361 note](#date-filter-using-the-queryparameter-syntax-recommended) — it is a valid alternative +to referencing the filter by service id when you need to compose it with another filter on the same key. ## Boolean Filter @@ -1406,17 +1613,20 @@ The following table shows how to replace each legacy filter. All modern replacem for both Doctrine ORM (`ApiPlatform\Doctrine\Orm\Filter\*`) and MongoDB ODM (`ApiPlatform\Doctrine\Odm\Filter\*`). -| Legacy filter (`AbstractFilter`) | Modern replacement | -| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `SearchFilter` (exact strategy) | [`ExactFilter`](#exact-filter) | -| `SearchFilter` (partial, start, end, word_start strategies) | [`PartialSearchFilter`](#partial-search-filter) | -| `SearchFilter` (relations / IRI matching) | [`IriFilter`](#iri-filter) | -| `BooleanFilter` | [`ExactFilter`](#exact-filter) | -| `NumericFilter` | [`ExactFilter`](#exact-filter) (exact) or [`ComparisonFilter(new ExactFilter())`](#comparison-filter) (range) | -| `OrderFilter` | [`SortFilter`](#sort-filter) | -| `DateFilter` | Kept — declare it through a `QueryParameter`, same class, same `[before]`/`[after]` URL syntax (drop-in) | -| `RangeFilter` | Kept — declare it through a `QueryParameter`, same class, same `[between]` URL syntax (drop-in) | -| `ExistsFilter` | Kept — declare it through a `QueryParameter`, same class (drop-in) | +| Legacy filter (`AbstractFilter`) | Modern replacement | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `SearchFilter` (exact strategy) | [`ExactFilter`](#exact-filter) | +| `SearchFilter` (partial strategy) | [`PartialSearchFilter`](#partial-search-filter) | +| `SearchFilter` (start strategy) | [`StartSearchFilter`](#start-search-filter) | +| `SearchFilter` (end strategy) | [`EndSearchFilter`](#end-search-filter) | +| `SearchFilter` (word_start strategy) | [`WordStartSearchFilter`](#word-start-search-filter) | +| `SearchFilter` (relations / IRI matching) | [`IriFilter`](#iri-filter) | +| `BooleanFilter` | [`ExactFilter`](#exact-filter) | +| `NumericFilter` | [`ExactFilter`](#exact-filter) (exact) or [`ComparisonFilter(new ExactFilter())`](#comparison-filter) (range) | +| `OrderFilter` | [`SortFilter`](#sort-filter) | +| `DateFilter` | Kept — declare it through a `QueryParameter`, same class, same `[before]`/`[after]` URL syntax (drop-in) | +| `RangeFilter` | Kept — declare it through a `QueryParameter`, same class, same `[between]` URL syntax (drop-in) | +| `ExistsFilter` | Kept — declare it through a `QueryParameter`, same class (drop-in) | There are two kinds of migration: @@ -1645,7 +1855,7 @@ Multiple parameters targeting the same relation path share the same JOIN (ORM) o ### Nested Properties with the Legacy ApiFilter Syntax (deprecated) > [!WARNING] The legacy method using the `ApiFilter` attribute is **deprecated** and scheduled for -> **removal** in API Platform **5.0**. We strongly recommend migrating to the new `QueryParameter` +> **removal** in API Platform **6.0**. We strongly recommend migrating to the new `QueryParameter` > syntax described above. For legacy code, the built-in filters that extend `AbstractFilter` support nested properties using @@ -1711,7 +1921,7 @@ The above allows you to find offers by their respective product's color: ## Enabling a Filter for All Properties of a Resource > [!WARNING] The legacy method using the `ApiFilter` attribute is **deprecated** and scheduled for -> **removal** in API Platform **5.0**. We strongly recommend migrating to the new `QueryParameter` +> **removal** in API Platform **6.0**. We strongly recommend migrating to the new `QueryParameter` > syntax, which is detailed in the [Introduction](#introduction). You can use the `:property` > placeholder instead and it is recommended to use a filter for each type of data you are filtering. @@ -2077,7 +2287,7 @@ use Doctrine\ORM\QueryBuilder; class MyCustomFilter implements FilterInterface { - use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 5.0. + use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 6.0. public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { @@ -2098,6 +2308,12 @@ class MyCustomFilter implements FilterInterface } ``` +`BackwardCompatibleFilterDescriptionTrait` supplies a `getDescription()` method that returns an +empty array, satisfying the legacy `FilterInterface::getDescription()` requirement without you +having to implement it by hand. It lets a custom filter keep working with the deprecated filter +chain while you migrate it to `#[QueryParameter]`, and it will be removed in API Platform 6.0 +together with `getDescription()` itself. + #### Implementing a Custom ORM Filter Let's create a concrete filter that allows fetching entities based on the month of a date field (for @@ -2124,7 +2340,7 @@ use Doctrine\ORM\QueryBuilder; class MonthFilter implements FilterInterface { - use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 5.0. + use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 6.0. public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { @@ -2368,7 +2584,7 @@ use Doctrine\ODM\MongoDB\Aggregation\Builder; class MonthFilter implements FilterInterface { - use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 5.0. + use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 6.0. public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { @@ -2410,7 +2626,7 @@ use Doctrine\ODM\MongoDB\Aggregation\Builder; class MonthFilter implements FilterInterface { - use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 5.0. + use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 6.0. public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { diff --git a/core/extending-jsonld-context.md b/core/extending-jsonld-context.md index af54bfa9886..a832704fad7 100644 --- a/core/extending-jsonld-context.md +++ b/core/extending-jsonld-context.md @@ -63,6 +63,62 @@ The generated context will now have your custom attributes set: Note that you do not have to provide the `@id` attribute. If you do not provide an `@id` attribute, the value from `iri` will be used. +### Extending the Context of a Whole Resource + +The `jsonldContext` option is also available on `#[ApiResource]` itself. Its main use is declaring +namespace prefixes once for the whole resource, instead of repeating a full IRI on every property +that needs one: + +```php + 'http://purl.org/dc/terms/'], +)] +class Book +{ + // ... + + #[ApiProperty(types: ['https://schema.org/name'], iris: ['dct:title'])] + public $name; + + // ... +} +``` + +The `dct` prefix is merged into the top-level `@context`, so properties can then reference it +through a compact IRI such as `dct:title`: + +`GET /contexts/Book` + +```json +{ + "@context": { + "@vocab": "http://example.com/apidoc#", + "hydra": "http://www.w3.org/ns/hydra/core#", + "dct": "http://purl.org/dc/terms/", + "name": "dct:title" + } +} +``` + +The resource-level context is merged into `@context` before the per-property entries are added, and +each property's entry is then written to `@context[]`, overwriting anything already +present at that key. In practice, the two never collide because the resource-level context is meant +for prefix declarations, while a property's own entry is keyed by the property name; a collision +only happens if a property is literally named after one of your prefixes, in which case the property +wins. + +If an operation (for example a `Get` or a `Patch`) declares its own `jsonldContext`, that value is +used as-is for that operation instead of the resource's: the two are not merged together, the +operation's `jsonldContext` simply takes precedence. + ## Hydra

Hydra screencast
Watch the Hydra screencast

@@ -122,3 +178,34 @@ resources: ``` + +### The `hydra:memberAssertion` Property + +For every resource exposing a collection operation, the generated Hydra API documentation +(`GET /docs.jsonld`) automatically adds a `hydra:memberAssertion` entry to the entrypoint's +`hydra:supportedProperty` for that collection. It asserts, as an `rdf:type` statement, that every +member returned by the collection is an instance of the resource: + +```json +{ + "@id": "#Entrypoint/books", + "@type": "hydra:Link", + "domain": "#Entrypoint", + "owl:maxCardinality": 1, + "range": "hydra:Collection", + "hydra:memberAssertion": { + "hydra:property": { "@id": "rdf:type" }, + "hydra:object": { "@id": "#Book" } + }, + "hydra:supportedOperation": ["..."] +} +``` + +This assertion is generated automatically for every collection and isn't configurable through +`jsonldContext` or `hydraContext`. + +> [!NOTE] Before API Platform 4.4, the same assertion was expressed as an `owl:equivalentClass` +> restriction (an `owl:onProperty: hydra:member` / `owl:allValuesFrom: #Book` pair nested inside the +> `range` array). `owl:equivalentClass` no longer appears anywhere in the generated Hydra +> documentation: if you parse `range` and expect that structure, read `hydra:memberAssertion` +> instead. diff --git a/core/filters.md b/core/filters.md index d2cdd004f0e..141122feb74 100644 --- a/core/filters.md +++ b/core/filters.md @@ -51,6 +51,19 @@ a new instance: - **`PartialSearchFilter`**: For partial string matching (SQL `LIKE %...%`). Supports dot notation for nested properties. - Usage: `new QueryParameter(filter: PartialSearchFilter::class)` +- **`StartSearchFilter`**: For prefix matching (`LIKE value%`). Supports dot notation for nested + properties. See the [Doctrine Filters documentation](doctrine-filters.md#start-search-filter) for + case-sensitivity defaults. + - Usage: `new QueryParameter(filter: StartSearchFilter::class)` +- **`EndSearchFilter`**: For suffix matching (`LIKE %value`). Supports dot notation for nested + properties. See the [Doctrine Filters documentation](doctrine-filters.md#end-search-filter) for + case-sensitivity defaults. + - Usage: `new QueryParameter(filter: EndSearchFilter::class)` +- **`WordStartSearchFilter`** (Doctrine ORM/ODM only, no Laravel/Eloquent equivalent): Matches + fields containing a word that starts with the value. Supports dot notation for nested properties. + See the [Doctrine Filters documentation](doctrine-filters.md#word-start-search-filter) for + details. + - Usage: `new QueryParameter(filter: WordStartSearchFilter::class)` - **`IriFilter`**: For filtering by IRIs (e.g., relations). Supports dot notation for nested associations. - Usage: `new QueryParameter(filter: IriFilter::class)` @@ -66,8 +79,8 @@ a new instance: - **`OrFilter`**: A decorator that forces a filter to combine criteria with `OR` instead of `AND`. - Usage: `new QueryParameter(filter: new OrFilter(new ExactFilter()), properties: ['name', 'ean'])` -- **`ChainFilter`** (Doctrine ORM/ODM only): Composes several filters on a single parameter key; each - wrapped filter self-selects by the shape of the value. See the +- **`ChainFilter`** (Doctrine ORM/ODM only): Composes several filters on a single parameter key; + each wrapped filter self-selects by the shape of the value. See the [Doctrine Filters documentation](doctrine-filters.md#chain-filter) for details. - Usage: `new QueryParameter(filter: new ChainFilter([new ExactFilter(), new DateFilter()]), property: 'birthdate')` @@ -131,8 +144,9 @@ Instead of repeating the same parameter configuration on every resource, you can default parameters that are automatically applied to all resources. This is done via the `defaults` key in your API Platform configuration. -Add a `parameters` map under `defaults` in your API Platform configuration. Each entry maps a -fully-qualified parameter class name to its options. +Add a `parameters` map under `defaults` in your API Platform configuration. In Symfony, entries can +either use the fully-qualified parameter class name as their key, or use a custom name and specify +the class explicitly with the `class` option. ```yaml # Symfony: api/config/packages/api_platform.yaml @@ -150,6 +164,30 @@ api_platform: description: "API version" ``` +To define multiple global parameters using the same parameter class, use named entries with an +explicit `class`: + +```yaml +# Symfony: api/config/packages/api_platform.yaml +api_platform: + defaults: + parameters: + api_token: + class: ApiPlatform\Metadata\HeaderParameter + key: "API-Token" + required: true + description: "API authentication token" + + request_id: + class: ApiPlatform\Metadata\HeaderParameter + key: "Request-ID" + required: false + description: "A unique request identifier" +``` + +The name of a named entry is only a configuration identifier. The `key` option defines the parameter +name exposed at runtime. + ```php [!NOTE] This is a plain comparison, distinct from -> [`DateFilter`](doctrine-filters.md#date-filter): it does not provide per-property `null` -> management, tolerant handling of invalid or empty values, or the `before`/`after` versus -> `strictly_before`/`strictly_after` vocabulary. Use `DateFilter` when you need those behaviors. +> [!NOTE] This is a plain comparison, distinct from [`DateFilter`](doctrine-filters.md#date-filter): +> it does not provide per-property `null` management, tolerant handling of invalid or empty values, +> or the `before`/`after` versus `strictly_before`/`strictly_after` vocabulary. Use `DateFilter` +> when you need those behaviors. ### Filtering a Single Property @@ -508,6 +546,8 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HeaderParameter; use ApiPlatform\Metadata\QueryParameter; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; #[ApiResource(operations: [ new GetCollection( @@ -520,7 +560,8 @@ use Symfony\Component\Validator\Constraints as Assert; 'X-Request-ID' => new HeaderParameter( description: 'A unique request identifier.', required: true, - constraints: [new Assert\Uuid()] + constraints: [new Assert\Uuid()], + nativeType: new BuiltinType(typeIdentifier: TypeIdentifier::STRING) ) ] ) @@ -531,6 +572,9 @@ class User {} > [!NOTE] When `castToNativeType` is enabled, API Platform infers type validation from the JSON > Schema. +If `constraints` are used then a valid type needs to be passed using `nativeType` named argument. +Otherwise, the values will be passed as an array to each constraints. + The `ApiPlatform\Validator\Util\ParameterValidationConstraints` trait can be used to automatically infer validation constraints from the JSON Schema and OpenAPI definitions of a parameter. @@ -949,7 +993,7 @@ class User {} ## Parameter Security You can secure individual parameters using Symfony expression language. When a security expression -evaluates to `false`, the parameter will be ignored and treated as if it wasn't provided. +evaluates to `false`, a context appropriate `AccessDeniedException` will be thrown. ```php [!CAUTION] If you are migrating from an older version of API Platform, make sure you read the > [Upgrade Guide](upgrade-guide.md). diff --git a/core/mercure.md b/core/mercure.md index 2f8493ace4e..2e2ddd14684 100644 --- a/core/mercure.md +++ b/core/mercure.md @@ -16,6 +16,14 @@ Mercure hub. Then, the Mercure hub dispatches the updates to all connected clien ![Mercure subscriptions](images/mercure-subscriptions.png) +## Which Protocol Version Does Your Hub Speak? + +Mercure has a 1.0 protocol version that changes how subscribers pick which updates to receive: +`match=` and `match_urlpattern=` query parameters replace `topic=`. API Platform talks to 0.x hubs +by default; switching a hub to 1.0 is done by configuring `protocol_version: '1.0'` on the +[MercureBundle](https://symfony.com/doc/current/mercure.html) side, not on this page. The examples +below cover both; check with whoever operates your hub if you're not sure which version it runs. + ## Installing Mercure Support Mercure support is already installed, configured and enabled in @@ -67,6 +75,54 @@ automatically subscribe to Mercure updates when available: [Learn how to use the discovery capabilities of Mercure in your own clients](https://mercure.rocks/docs/ecosystem/awesome). +## Subscribing to Updates + +API Platform publishes each update on the resource's absolute IRI — the same value as the `@id` of +the JSON-LD document the API serves for that resource. This means the topic to subscribe to is +nothing you need to build: it's the `@id` you already have from fetching the resource. + +A minimal browser client fetches the resource, discovers the hub from the `Link: rel="mercure"` +header, and subscribes using the resource's `@id` as the topic: + +```javascript +const response = await fetch("/books/1", { headers: { Accept: "application/ld+json" } }); +const book = await response.json(); + +// Discover the hub through the Link header API Platform adds to the response +const linkHeader = response.headers.get("Link"); +const [, hubUrlString] = linkHeader.match(/<([^>]+)>;\s*rel="mercure"/); +const hubUrl = new URL(hubUrlString); + +hubUrl.searchParams.append("topic", book["@id"]); // Mercure 0.x + +const eventSource = new EventSource(hubUrl); +eventSource.onmessage = (event) => { + console.log(JSON.parse(event.data)); +}; +``` + +If your hub speaks the Mercure 1.0 protocol, subscribe with `match` instead of `topic`: + +```javascript +hubUrl.searchParams.append("match", book["@id"]); // Mercure 1.0 +``` + +To subscribe to every book instead of a single one, 1.0 hubs also accept `match_urlpattern`, with a +[URL Pattern](https://mercure.rocks/docs/1.0/concepts/topics-and-matchers) matching the resources' +IRIs: + +```javascript +hubUrl.searchParams.append("match_urlpattern", "https://api.example.com/books/:id"); // Mercure 1.0 +``` + +`match_urlpattern` has no 0.x equivalent: under 0.x, subscribing to a family of resources at once +means either subscribing to each IRI individually, or using the alternate-topic technique described +below. + +See the [Mercure protocol reference](https://mercure.rocks/docs/1.0/reference/protocol) (1.0) or +[the spec's subscribers section](https://mercure.rocks/spec#subscribers) (0.x) for the full +`topic`/`match`/`match_urlpattern` semantics. + ## Dispatching Private Updates (Authorized Mode) Mercure allows dispatching diff --git a/core/openapi.md b/core/openapi.md index c29b020ed5d..4f4fd577b6c 100644 --- a/core/openapi.md +++ b/core/openapi.md @@ -6,13 +6,15 @@ API Platform natively supports the [OpenAPI](https://www.openapis.org/) API spec

OpenAPI screencast
Watch the OpenAPI screencast

-The specification of the API is available at the `/docs.jsonopenapi` path. By default, OpenAPI v3 is -used. You can also get an OpenAPI v3-compliant version thanks to the `spec_version` query parameter: -`/docs.jsonopenapi?spec_version=3` +The specification of the API is available at the `/docs.jsonopenapi` path. Generated documents +currently target OpenAPI v3.2.0 (`ApiPlatform\OpenApi\OpenApi::VERSION`). If a tool you use does not +yet support v3.2.0, you can ask for the legacy v3.0.0 format instead thanks to the `spec_version` +query parameter: `/docs.jsonopenapi?spec_version=3.0.0` -It also integrates a customized version of [Swagger UI](https://swagger.io/swagger-ui/) and -[ReDoc](https://rebilly.github.io/ReDoc/), some nice tools to display the API documentation in a -user friendly way. +It also integrates a customized version of [Swagger UI](https://swagger.io/swagger-ui/), +[ReDoc](https://rebilly.github.io/ReDoc/) and +[Scalar API Reference](https://github.com/scalar/scalar), some nice tools to display the API +documentation in a user friendly way. ## Using the OpenAPI Command @@ -36,18 +38,19 @@ Create a file containing the specification: bin/console api:openapi:export --output=swagger_docs.json ``` -If you want to use the old OpenAPI v2 (Swagger) JSON format, use: - -```console -bin/console api:swagger:export -``` - -It is also possible to use OpenAPI v3.0.0 format: +By default, `api:openapi:export` generates a document following the current OpenAPI version, +`3.2.0`. It is also possible to use the legacy OpenAPI v3.0.0 format: ```console bin/console api:openapi:export --spec-version=3.0.0 ``` +> [!NOTE] This `--spec-version` option (and the `spec_version` query parameter documented above) +> only switches between the OpenAPI v3.0.0 and v3.2.0 document formats. Do not confuse it with the +> `api_platform.swagger.versions` configuration option: that array (`[3]` by default, and emptied +> automatically when `enable_swagger` is set to `false`) tracks which major OpenAPI versions are +> exposed at all, it does not select between the v3.0.0 and v3.2.0 document formats. + ## Create several versions of a specification You can now decline a same OpenAPI specification in multiple versions using the `x-apiplatform-tags` @@ -608,9 +611,65 @@ resources: ![Impact on Swagger UI](../symfony/images/swagger-ui-2.png) -## Disabling Swagger UI or ReDoc +## Choosing Between Swagger UI, ReDoc and Scalar + +When the HTML format is served, API Platform can render three different documentation UIs: +[Swagger UI](https://swagger.io/swagger-ui/), [ReDoc](https://rebilly.github.io/ReDoc/) and +[Scalar API Reference](https://github.com/scalar/scalar). All three are backed by the same +documentation route (`/docs` by default) and the same generated OpenAPI document; which one is +rendered is chosen with the `ui` query parameter, for instance: + +`/docs?ui=scalar` + +Swagger UI is the default when no `ui` parameter is given. If it is disabled and ReDoc is also +disabled while Scalar remains enabled, Scalar becomes the default UI instead. Every UI's page prints +an "Other API docs" footer linking to the other enabled UIs. + +> [!NOTE] The value expected by `ui` for ReDoc differs between stacks: `ui=re_doc` on Symfony, +> `ui=redoc` on Laravel. `ui=scalar` is the same on both. + +Like Swagger UI and ReDoc, Scalar is enabled by default (when `symfony/twig-bundle` is installed, on +Symfony); see [Disabling Swagger UI, ReDoc or Scalar](#disabling-swagger-ui-redoc-or-scalar) below +to turn it off. + +### Configuring Scalar API Reference + +API Platform builds the object passed to `Scalar.createApiReference()` from the generated OpenAPI +document (`content`) and a default `theme` of `default`. Any key you pass through +`scalar_extra_configuration` is merged on top of it and forwarded as-is, so it accepts any option +supported by [Scalar API Reference](https://github.com/scalar/scalar), such as `theme` or +`darkMode`. + +With Symfony: + +```yaml +# api/config/packages/api_platform.yaml +api_platform: + swagger: + scalar_extra_configuration: + theme: "purple" + darkMode: true +``` + +With Laravel: + +```php + [ + 'extra_configuration' => [ + 'theme' => 'purple', + 'darkMode' => true, + ], + ], +]; +``` + +## Disabling Swagger UI, ReDoc or Scalar -### Disabling Swagger UI or ReDoc with Symfony +### Disabling Swagger UI, ReDoc or Scalar with Symfony To disable Swagger UI (ReDoc will be shown by default): @@ -630,7 +689,16 @@ api_platform: enable_re_doc: false ``` -### Disabling Swagger UI or ReDoc with Laravel +To disable Scalar: + +```yaml +# api/config/packages/api_platform.yaml +api_platform: + # ... + enable_scalar: false +``` + +### Disabling Swagger UI, ReDoc or Scalar with Laravel To disable Swagger UI (ReDoc will be shown by default): @@ -654,6 +722,19 @@ return [ ]; ``` +To disable Scalar: + +```php + [ + 'enabled' => false, + ], +]; +``` + ## Changing the Location of Swagger UI By default, the Swagger UI is available at the API location (when the HTML format is asked) and at @@ -693,7 +774,10 @@ Change `/api_documentation` to the URI you wish Swagger UI to be accessible on. ### Disabling Swagger UI at the API Location -To disable the Swagger UI at the API location, disable both Swagger UI and ReDoc. +To disable all HTML documentation at the API location, disable Swagger UI, ReDoc and Scalar. Note +that disabling only Swagger UI and ReDoc leaves Scalar enabled, and it will be shown instead (see +[Choosing Between Swagger UI, ReDoc and Scalar](#choosing-between-swagger-ui-redoc-and-scalar) +above). With Symfony use: @@ -703,6 +787,7 @@ api_platform: # ... enable_swagger_ui: false enable_re_doc: false + enable_scalar: false ``` Or with Laravel use: @@ -714,6 +799,9 @@ return [ // .... 'enable_swagger_ui' => false, 'enable_re_doc' => false, + 'scalar' => [ + 'enabled' => false, + ], ]; ``` @@ -934,8 +1022,12 @@ CORS requests. ### Sending Credentials with Swagger UI Requests using Symfony -> [!NOTE] This feature is only available with Laravel. You're welcome to contribute the Symfony -> implementation [on GitHub](https://github.com/api-platform/core). +```yaml +# api/config/packages/api_platform.yaml +api_platform: + swagger: + with_credentials: true +``` ### Sending Credentials with Swagger UI Requests using Laravel diff --git a/core/operations.md b/core/operations.md index 780114e47f4..3ae71581b88 100644 --- a/core/operations.md +++ b/core/operations.md @@ -278,6 +278,87 @@ resources: +## Controlling the 404 Response When Data Is Missing + +Available since API Platform 4.4. + +When the provider returns `null` (no matching entity, or a provider that has nothing to give back +for this request), API Platform's `ReadProvider` decides whether to throw a `404 Not Found` or to +let the request through with `null` data. By default, that decision depends on the HTTP method: + +- a `POST` operation never throws: creating a resource does not require one to already exist; +- a `PUT` operation with [`allowCreate`](#upsert-creating-a-resource-with-put) enabled never throws + either, since a missing item is exactly the "create it" case of the upsert behavior; +- every other operation (`GET`, `GetCollection`, `PATCH`, `DELETE`, or a `PUT` without + `allowCreate`) throws a `404 Not Found` when the provider returns `null`. + +Set the `throwOnNotFound` property to `false` to opt out of this default and let the operation +proceed with `null` data, or to `true` to force the `404` even on an operation that would not throw +by default (for instance a `PUT` with `allowCreate: true` for which you still want a strict "must +already exist" semantics). + +A common use case for `throwOnNotFound: false` is an operation whose provider legitimately returns +`null` as valid data, for example a "current user" or "current cart" endpoint that returns `null` +when none is set instead of failing: + + + +```php + + + + + + + + + +``` + + + +With `throwOnNotFound: false`, the `null` value reaches the rest of the pipeline (normalization, +custom processors, and so on) instead of interrupting the request with an exception, so the +controller and later stages must be prepared to handle a `null` resource. + ## Enabling and Disabling Operations If no operation is specified, all default CRUD operations are automatically registered. It is also @@ -589,6 +670,52 @@ resources: +## Setting the Response Status Code at Runtime + +Available since API Platform 4.4. + +The `status` option shown above is static: it is the right tool when the response code for an +operation is fixed and known when you configure it. Sometimes, though, the status code can only be +decided while the request is being handled, for example a state processor that returns +`202 Accepted` when a task is queued for later processing but `200 OK` when it completes +synchronously. + +For this case, `RespondProcessor` reads a `_api_response_status` request attribute before falling +back to the operation's static `status` (or to the framework default). Set it from a custom state +processor to override the status code for the current request only: + +```php +isQueuedForAsyncImport($data)) { + $request->attributes->set('_api_response_status', Response::HTTP_ACCEPTED); + } + + // ... persist $data, return it or a DTO + + return $data; + } +} +``` + +> [!NOTE] The `_api_response_status` attribute always wins over the operation's `status` option, so +> use it only when the code truly depends on runtime conditions. When the status is fixed per +> operation, the static `status` option documented above remains the right tool: it is visible in +> the resource metadata and in the generated OpenAPI/Hydra documentation, while a request attribute +> set at runtime is not. + ## Prefixing All Routes of All Operations Sometimes it's also useful to put a whole resource into its own "namespace" regarding the URI. Let's diff --git a/core/serialization.md b/core/serialization.md index 0a0659ccf4f..d4b077ea094 100644 --- a/core/serialization.md +++ b/core/serialization.md @@ -62,45 +62,15 @@ Just like other Symfony and API Platform components, the Serializer component ca using attributes, XML or YAML. Since attributes are easy to understand, we will use them in the following examples. -> [!NOTE] If you are not using the API Platform Symfony variant, you need to enable annotation -> support in the serializer configuration as outlined below, depending on your Symfony version. - -#### Configuration for Symfony `<= 6.4` - -##### General Case +> [!NOTE] If you are not using the API Platform Symfony variant, you need to enable attribute +> support in the serializer configuration as outlined below. Add the following configuration to your `framework.yaml` file: ```yaml # api/config/packages/framework.yaml framework: - serializer: { enable_annotations: true } -``` - -##### Using Symfony Flex - -If you use [Symfony Flex](https://github.com/symfony/flex) and Symfony `<= 6.4`, simply run the -following command: - -```console -composer req doctrine/annotations -``` - -You're all set! - -#### Configuration for Symfony `>= 7.0` - -If you are using Symfony >= 7.0, -[annotations have been replaced by attributes](https://www.doctrine-project.org/2022/11/04/annotations-to-attributes.html). - -Update your configuration as follows: - -```diff -# api/config/packages/framework.yaml - -framework: -- serializer: { enable_annotations: true } -+ serializer: { enable_attributes: true } + serializer: { enable_attributes: true } ``` #### Additional Syntax Configuration for All Versions diff --git a/laravel/filters.md b/laravel/filters.md index 5c77192d74f..34fb541a48c 100644 --- a/laravel/filters.md +++ b/laravel/filters.md @@ -147,6 +147,35 @@ As shown above the following search filters are available: - `ApiPlatform\Laravel\Eloquent\Filter\StartSearchFilter` queries `LIKE term%` - `ApiPlatform\Laravel\Eloquent\Filter\EndSearchFilter` queries `LIKE %term` +`StartSearchFilter` and `EndSearchFilter` are used the same way as `EqualsFilter` above: + +```php +// app/Models/Book.php + +use ApiPlatform\Laravel\Eloquent\Filter\EndSearchFilter; +use ApiPlatform\Laravel\Eloquent\Filter\StartSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\QueryParameter; + +#[ApiResource] +#[QueryParameter(key: 'titleStartsWith', property: 'title', filter: StartSearchFilter::class)] +#[QueryParameter(key: 'titleEndsWith', property: 'title', filter: EndSearchFilter::class)] +class Book extends Model +{ +} +``` + +`/books?titleStartsWith=The` returns every book whose `title` starts with "The", and +`/books?titleEndsWith=Farm` returns every book whose `title` ends with "Farm". Case sensitivity +depends on your database collation; neither filter exposes a `caseSensitive` option. + +> [!NOTE] Unlike `PartialSearchFilter`, `StartSearchFilter` and `EndSearchFilter` do not support +> filtering across a relationship using dot notation (for e.g. `author.name`): they only apply to a +> property of the resource itself. + +There is no Eloquent equivalent of the Doctrine ORM/ODM `WordStartSearchFilter` (see +[Word Start Search Filter](../core/doctrine-filters.md#word-start-search-filter)). + ### Date The `DateFilter` allows to filter dates with an operator (`eq`, `lt`, `gt`, `lte`, `gte`): diff --git a/symfony/security.md b/symfony/security.md index 005ff7d6b7e..36df0cd507f 100644 --- a/symfony/security.md +++ b/symfony/security.md @@ -371,6 +371,147 @@ _Note 2: You can't use Voters on the collection GET method, use [Collection Filters](https://api-platform.com/docs/core/security/#filtering-collection-according-to-the-current-user-permissions) instead._ +## Exposing Voter Reasons in the Error Response + +Since API Platform 4.4, the variables available to a `security` (and `securityPostDenormalize`, +etc.) expression also include `access_decision`, an instance of +[`Symfony\Component\Security\Core\Authorization\AccessDecision`](https://github.com/symfony/symfony/blob/7.4/src/Symfony/Component/Security/Core/Authorization/AccessDecision.php). +It carries the `isGranted` result together with the `votes` cast by every voter consulted through +`is_granted()`, and exposes `getMessage(): string`, which concatenates `"Access Granted."` or +`"Access Denied."` with every reason attached by a voter whose vote matches the final result. + +A voter extending Symfony's `Voter` base class receives an optional `?Vote $vote` argument in +`voteOnAttribute()`. Call `$vote?->addReason(...)` to explain a denial: + +```php +owner === $token->getUser()) { + return true; + } + + $vote?->addReason(\sprintf('Only the owner (%s) can edit this book.', $subject->owner->getUserIdentifier())); + + return false; + } +} +``` + +The resource only needs to call `is_granted()` as usual — API Platform forwards `access_decision` to +the voters for you: + +```php +getMessage()` to build the 403 response's `detail`: + +```json +{ + "@context": "/contexts/Error", + "@type": "Error", + "title": "An error occurred", + "detail": "Access Denied. Only the owner (alice) can edit this book.", + "status": 403 +} +``` + +> [!WARNING] That `detail` is only populated from `$decision->getMessage()` when the kernel runs +> with `debug: true` (Symfony's `dev` and `test` environments by default). In `prod` +> (`kernel.debug: false`), the client always receives the generic `"Access Denied."` detail instead, +> no matter what your voters added — unless you also configure an explicit `securityMessage`, which +> is always returned verbatim, in every environment. Do not treat `debug: false` as your only +> safeguard: write vote reasons as if any caller could read them, and never put roles, ownership +> details, or other authorization internals in them. + +## Throwing `AccessDeniedException` Directly + +You are not limited to `security` expressions and voters: any provider, processor, or voter can deny +access explicitly by throwing `ApiPlatform\Metadata\Exception\AccessDeniedException`. Unlike a +voter's reason (see above), the `detail` you pass here is always returned to the client, in every +environment: + +```php + + */ +final class BookProcessor implements ProcessorInterface +{ + public function __construct(private readonly Security $security) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if ($data->archived && !$this->security->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedException('Access Denied.', detail: 'Archived books can only be edited by an administrator.'); + } + + // call your persistence layer to save $data + return $data; + } +} +``` + +This produces the same problem response shape: + +```json +{ + "@context": "/contexts/Error", + "@type": "Error", + "title": "An error occurred", + "detail": "Archived books can only be edited by an administrator.", + "status": 403 +} +``` + +> [!WARNING] Because the `detail` you pass to `AccessDeniedException` is always exposed to the +> client, apply the same rule as for `securityMessage`: keep it free of roles, ownership details, or +> any other authorization internals. +> +> [!NOTE] `ApiPlatform\Symfony\Security\Exception\AccessDeniedException` is deprecated since API +> Platform 4.4 in favor of `ApiPlatform\Metadata\Exception\AccessDeniedException` shown above. See +> the [upgrade guide](../core/upgrade-guide.md#security-accessdeniedexception). + ## Configuring the Access Control Error Message By default when API requests are denied, you will get the "Access Denied" message. You can change it