diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 495ea2bc77c..c6e831f511b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,7 @@ jobs: composer global require soyuka/pmu composer global config allow-plugins.soyuka/pmu true --no-interaction composer global link . + composer require --dev doctrine/mongodb-odm-bundle - run: composer check-dependencies php-cs-fixer: @@ -89,7 +90,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: intl, bcmath, curl, openssl, mbstring, mongodb + extensions: intl, bcmath, curl, openssl, mbstring ini-values: memory_limit=-1 tools: composer coverage: none @@ -149,6 +150,7 @@ jobs: composer global require soyuka/pmu composer global config allow-plugins.soyuka/pmu true --no-interaction composer global link . + composer require --dev doctrine/mongodb-odm-bundle - name: Cache PHPStan results uses: actions/cache@v4 with: @@ -200,7 +202,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, mongodb + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite coverage: pcov ini-values: memory_limit=-1 - name: Get composer cache directory @@ -217,6 +219,9 @@ jobs: composer global require soyuka/pmu composer global config allow-plugins.soyuka/pmu true --no-interaction composer global link . + - name: Force PHPUnit 11.5 for PHP 8.2 + if: matrix.php == '8.2' + run: composer require phpunit/phpunit:^11.5 --dev --with-all-dependencies - name: Clear test app cache run: tests/Fixtures/app/console cache:clear --ansi - name: Run PHPUnit tests @@ -288,12 +293,17 @@ jobs: with: php-version: ${{ matrix.php.version }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, mongodb + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite ini-values: memory_limit=-1 - name: PMU run: | composer global require soyuka/pmu composer global config allow-plugins.soyuka/pmu true --no-interaction + - name: Force PHPUnit 11.5 for PHP 8.2 + if: matrix.php.version == '8.2' + run: | + cd $(composer ${{matrix.component}} --cwd) + composer require phpunit/phpunit:^11.5 --dev --with-all-dependencies - name: Linking if: ${{ !matrix.php.lowest && !matrix.php.minimal-changes }} run: | @@ -368,7 +378,7 @@ jobs: with: php-version: ${{ matrix.php.version }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, mongodb + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite ini-values: memory_limit=-1 - name: Linking run: | @@ -408,7 +418,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, mongodb + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite coverage: pcov ini-values: memory_limit=-1 - name: Get composer cache directory @@ -425,6 +435,9 @@ jobs: composer global require soyuka/pmu composer global config allow-plugins.soyuka/pmu true --no-interaction composer global link . + - name: Force PHPUnit 11.5 for PHP 8.2 + if: matrix.php == '8.2' + run: composer require phpunit/phpunit:^11.5 --dev --with-all-dependencies - name: Clear test app cache run: tests/Fixtures/app/console cache:clear --ansi - name: Run Behat tests (PHP ${{ matrix.php }}) @@ -492,7 +505,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, pdo_pgsql, mongodb + extensions: intl, bcmath, curl, openssl, mbstring, pdo_pgsql coverage: none ini-values: memory_limit=-1 - name: Get composer cache directory @@ -543,7 +556,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, pdo_mysql, mongodb + extensions: intl, bcmath, curl, openssl, mbstring, pdo_mysql coverage: none ini-values: memory_limit=-1 - name: Get composer cache directory @@ -825,7 +838,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, mongodb + extensions: intl, bcmath, curl, openssl, mbstring coverage: none ini-values: memory_limit=-1 - name: Get composer cache directory @@ -878,7 +891,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, mongodb + extensions: intl, bcmath, curl, openssl, mbstring coverage: none ini-values: memory_limit=-1 - name: Get composer cache directory @@ -918,7 +931,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, mongodb + extensions: intl, bcmath, curl, openssl, mbstring coverage: none ini-values: memory_limit=-1 - name: Get composer cache directory @@ -957,7 +970,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, mongodb + extensions: intl, bcmath, curl, openssl, mbstring coverage: none ini-values: memory_limit=-1 - name: Get composer cache directory @@ -1000,7 +1013,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, mongodb + extensions: intl, bcmath, curl, openssl, mbstring coverage: none ini-values: memory_limit=-1 - name: Install additional packages @@ -1048,7 +1061,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, fileinfo, mongodb + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, fileinfo coverage: none ini-values: memory_limit=-1 - name: Get composer cache directory @@ -1098,7 +1111,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, mongodb + extensions: intl, bcmath, curl, openssl, mbstring coverage: none ini-values: memory_limit=-1 - name: Get composer cache directory @@ -1142,7 +1155,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, mongodb + extensions: intl, bcmath, curl, openssl, mbstring coverage: none ini-values: memory_limit=-1 - name: Install additional packages @@ -1314,7 +1327,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, mongodb + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite ini-values: memory_limit=-1 - name: Setup node uses: actions/setup-node@v4 @@ -1369,8 +1382,12 @@ jobs: with: php-version: ${{ matrix.php }} tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, mongodb + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite ini-values: memory_limit=-1 + - name: Force PHPUnit 11.5 for PHP 8.2 + if: matrix.php == '8.2' + working-directory: 'src/Laravel' + run: composer require phpunit/phpunit:^11.5 --dev --with-all-dependencies - name: Update project dependencies run: | composer global require soyuka/pmu @@ -1395,7 +1412,7 @@ jobs: with: php-version: 8.4 tools: pecl, composer - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, mongodb + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite ini-values: memory_limit=-1 - name: Update project dependencies run: | diff --git a/CRUSH.md b/CRUSH.md new file mode 100644 index 00000000000..7c3fac6802e --- /dev/null +++ b/CRUSH.md @@ -0,0 +1,47 @@ +# API Platform Core Development + +This document provides guidelines for developing on the API Platform core. + +## Laravel development: + +Everything goes inside `src/Laravel`. +Tests need to run at `src/Laravel/vendor/bin/phpunit`. + +## Development Commands + +- **Run all tests:** + ```bash + vendor/bin/phpunit + ``` + +- **Run a single test file:** + ```bash + vendor/bin/phpunit tests/Path/To/YourTest.php + ``` + +- **Lint files:** + ```bash + vendor/bin/php-cs-fixer fix --dry-run --diff + ``` + +- **Fix linting issues:** + ```bash + vendor/bin/php-cs-fixer fix + ``` + +- **Run static analysis:** + ```bash + vendor/bin/phpstan analyse + ``` + +## Code Style + +- **Standard:** Follow PSR-12 and the rules in `.php-cs-fixer.dist.php`. +- **Imports:** Use `use` statements for all classes, and group them by namespace. +- **Naming:** + - Classes: `PascalCase` + - Methods: `camelCase` + - Variables: `camelCase` +- **Types:** Use strict types (`declare(strict_types=1);`) in all PHP files. Use type hints for all arguments and return types where possible. +- **Error Handling:** Use exceptions for error handling. + diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 00000000000..06005057ca6 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,39 @@ +# To run this make sure you have php zts installed +# In the `php.ini` set output_buffering = Off (or else you may not get the same measures) +# Run `php-fpm -F` (it should default to 127.0.0.1:9000) +# Run `frankenphp start --config Caddyfile` (do not use `run` or printing in the terminal will slow down the server) +# You now have 2 servers: +# - localhost:8080 with PHP FPM +# - localhost:8081 with FrankenPHP +# +# Note that with no worker mode the performances on HTTP Request (sequentially !) will be likely the same. +{ + frankenphp +} + +localhost:8080 { + header { + Access-Control-Allow-Origin * + Access-Control-Allow-Credentials true + Access-Control-Allow-Methods * + Access-Control-Allow-Headers * + defer + } + root * public/ + php_fastcgi 127.0.0.1:9000 + file_server +} + +localhost:8081 { + header { + Access-Control-Allow-Origin * + Access-Control-Allow-Credentials true + Access-Control-Allow-Methods * + Access-Control-Allow-Headers * + defer + } + root * public/ + file_server + log + php_server +} diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 00000000000..556f1178558 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,38 @@ +You are a code assistant that only writes tests on the API Platform code base. You do not need to fix bugs only write the test we ask for. You do not need to run the test. + +Language is PHP, project is API Platform. User will always specify either Symfony or Laravel as tests are not in the same directories. + +IMPORTANT: avoid changing existing fixtures as they may alter current tests behavior. If a fixture already exist just invent new names, we don't really care about business logic here we only do tests for a framework. + +# API Platform Core Development + +This document provides guidelines for developing on the API Platform core. + +## Laravel development: + +Everything goes inside `src/Laravel`. +Tests need to run at `src/Laravel/vendor/bin/phpunit`. +Fixtures are located at `src/Laravel/workbench/app/` you can either write a DTO as ApiResource inside the `ApiResource` directory in the workbench, or add an Eloquent `Model` that gets declared as a resource with the correct attribute. + +You'll add only functional testing inside `src/Laravel/Tests/`, we recommend to inspire from @src/Laravel/Tests/EloquentTest.php or @src/Laravel/Tests/JsonLdTest.php. + +## Symfony development: + +Fixtures are located at `tests/Fixtures/TestBundle/ApiResource/` +Entities at `tests/Fixtures/TestBundle/Entity/` and they almost always see their equivalent in `tests/Fixtures/TestBundle/Document/` +Functional tests at `tests/Functional`, unit tests are in more specific directories of each component inside `src/Component/Tests`. + +## Development Commands + +You can not run command only the user can. Don't attempt to run phpunit or else. + +## Code Style + +- **Standard:** Follow PSR-12 and the rules in `.php-cs-fixer.dist.php`. +- **Imports:** Use `use` statements for all classes, and group them by namespace. +- **Naming:** + - Classes: `PascalCase` + - Methods: `camelCase` + - Variables: `camelCase` +- **Types:** Use strict types (`declare(strict_types=1);`) in all PHP files. Use type hints for all arguments and return types where possible. +- **Error Handling:** Use exceptions for error handling. diff --git a/composer.json b/composer.json index a0f3bbc9e45..1df67d8036d 100644 --- a/composer.json +++ b/composer.json @@ -123,15 +123,11 @@ "willdurand/negotiation": "^3.1" }, "require-dev": { - "ext-mongodb": "^1.21 || ^2.0", "behat/behat": "^3.11", "behat/mink": "^1.9", - "doctrine/cache": "^1.11 || ^2.1", "doctrine/common": "^3.2.2", "doctrine/dbal": "^4.0", "doctrine/doctrine-bundle": "^2.11 || ^3.1", - "doctrine/mongodb-odm": "^2.10", - "doctrine/mongodb-odm-bundle": "^5.0", "doctrine/orm": "^2.17 || ^3.0", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", "friends-of-behat/mink-browserkit-driver": "^1.3.1", @@ -156,7 +152,7 @@ "phpstan/phpstan-doctrine": "^2.0", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-symfony": "^2.0", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "psr/log": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", @@ -216,8 +212,5 @@ "symfony/json-streamer": "To use the JSON Streamer component.", "webonyx/graphql-php": "To support GraphQL." }, - "type": "library", - "repositories": [ - {"type": "vcs", "url": "https://github.com/soyuka/phpunit"} - ] + "type": "library" } diff --git a/filters.md b/filters.md new file mode 100644 index 00000000000..a41a7c5bbfe --- /dev/null +++ b/filters.md @@ -0,0 +1,869 @@ +# Parameters and Filters + +For documentation on the specific filter implementations available for your persistence layer, +please refer to the following pages: + +- **[Doctrine Filters](../core/doctrine-filters.md)** +- **[Elasticsearch Filters](../core/elasticsearch-filters.md)** + +API Platform provides a generic and powerful system to apply filters, sort criteria, and handle +other request parameters. This system is primarily managed through **Parameter attributes** +(`#[QueryParameter]` and `#[HeaderParameter]`), which allow for detailed and explicit configuration +of how an API consumer can interact with a resource. + +These parameters can be linked to **Filters**, which are classes that contain the logic for applying +criteria to your persistence backend (like Doctrine ORM or MongoDB ODM). + +You can declare parameters on a resource class to apply them to all operations, or on a specific +operation for more granular control. When parameters are enabled, they automatically appear in the +Hydra, [OpenAPI](openapi.md) and [GraphQL](graphql.md) documentations. + +

Filtering and Searching screencast
Watch the Filtering & Searching screencast

+ +> [!WARNING] For maximum flexibility and to ensure future compatibility, it is strongly recommended +> to configure your filters via the parameters attribute using `QueryParameter`. The legacy method +> using the `ApiFilter` attribute is not recommended. + +## Declaring Parameters + +The recommended way to define parameters is by using Parameter attributes directly on a resource +class or on an operation. API Platform provides two main types of Parameter attributes based on +their location (matching the OpenAPI `in` configuration): + +- `ApiPlatform\Metadata\QueryParameter`: For URL query parameters (e.g., `?name=value`). +- `ApiPlatform\Metadata\HeaderParameter`: For HTTP headers (e.g., `Custom-Header: value`). + +### List of Available Filters + +When defining a `QueryParameter`, you must specify the filtering logic using the `filter` option. + +Here is a list of available filters you can use. You can pass the filter class name (recommended) or +a new instance: + +- **`DateFilter`**: For filtering by date intervals (e.g., `?createdAt[after]=...`). + - Usage: `new QueryParameter(filter: DateFilter::class)` +- **`ExactFilter`**: For exact value matching. + - Usage: `new QueryParameter(filter: ExactFilter::class)` +- **`PartialSearchFilter`**: For partial string matching (SQL `LIKE %...%`). + - Usage: `new QueryParameter(filter: PartialSearchFilter::class)` +- **`IriFilter`**: For filtering by IRIs (e.g., relations). + - Usage: `new QueryParameter(filter: IriFilter::class)` +- **`BooleanFilter`**: For boolean field filtering. + - Usage: `new QueryParameter(filter: BooleanFilter::class)` +- **`NumericFilter`**: For numeric field filtering. + - Usage: `new QueryParameter(filter: NumericFilter::class)` +- **`RangeFilter`**: For range-based filtering (e.g., prices between X and Y). + - Usage: `new QueryParameter(filter: RangeFilter::class)` +- **`ExistsFilter`**: For checking existence of nullable values. + - Usage: `new QueryParameter(filter: ExistsFilter::class)` +- **`OrderFilter`**: For sorting results. + - Usage: `new QueryParameter(filter: OrderFilter::class)` + +> [!TIP] Always check the specific documentation for your persistence layer (Doctrine ORM, MongoDB +> ODM, Laravel Eloquent) to see the exact namespace and available options for these filters. + +You can declare a parameter on the resource class to make it available for all its operations: + +```php + new QueryParameter(description: 'Filter our friends by name'), + 'Request-ID' => new HeaderParameter(description: 'A unique request identifier') // keys are case insensitive + ] + ) + ] +)] +class Friend +{ + // ... +} +``` + +### Using Filters with DateTime Properties + +When working with `DateTime` or `DateTimeImmutable` properties, the system might default to exact +matching. To enable date ranges (e.g., `after`, `before`), you must explicitly use the `DateFilter`: + +```php + new QueryParameter( + // Use the class string to leverage the service container (recommended) + filter: DateFilter::class, + properties: ['startDate', 'endDate'] + ) + ] + ) +])] +class Event +{ + // ... +} +``` + +This configuration allows clients to filter events by date ranges using queries like: + +- `/events?date[startDate][after]=2023-01-01` +- `/events?date[endDate][before]=2023-12-31` + +### Filtering a Single Property + +Most of the time, a parameter maps directly to a property on your resource. For example, a +`?name=Frodo` query parameter would filter for resources where the `name` property is "Frodo". This +behavior is often handled by built-in or custom filters that you link to the parameter. + +For Hydra, you can map a query parameter to `hydra:freetextQuery` to indicate a general-purpose +search query. + +```php + new QueryParameter(property: 'hydra:freetextQuery', required: true) + ] + ) +])] +class Issue {} +``` + +This will generate the following Hydra `IriTemplateMapping`: + +```json +{ + "@context": "http://www.w3.org/ns/hydra/context.jsonld", + "@type": "IriTemplate", + "template": "http://api.example.com/issues{?q}", + "variableRepresentation": "BasicRepresentation", + "mapping": [ + { + "@type": "IriTemplateMapping", + "variable": "q", + "property": "hydra:freetextQuery", + "required": true + } + ] +} +``` + +### Filtering Multiple Properties with `:property` + +Sometimes you need a generic filter that can operate on multiple properties. You can achieve this by +using the `:property` placeholder in the parameter's `key`. + +```php + new QueryParameter( + filter: 'api_platform.doctrine.orm.search_filter.instance' + ) + ] + ) +])] +class Book +{ + // ... +} +``` + +This configuration creates a dynamic parameter. API clients can now filter on any of the properties +configured in the `SearchFilter` (in this case, `title` and `description`) by using a URL like +`/books?search[title]=Ring` or `/books?search[description]=journey`. + +When using the `:property` placeholder, API Platform automatically creates as many parameters as +there are properties. Each filter will be called by each detected parameter: + +```php +public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void +{ + $parameter = $context['parameter'] ?? null; + dump(key: $parameter->getKey(), value: $parameter->getValue()); + // shows key: search[title], value: Ring +} +``` + +> [!NOTE] We are using `api_platform.doctrine.orm.search_filter.instance` (exists also for ODM). +> Indeed this is a special instance of the search filter where `properties` can be changed during +> runtime. This is considered as "legacy filter" below, in API Platform 4.0 we'll recommend to +> create a custom filter or to use the `PartialSearchFilter`. + +### Restricting Properties with `:property` Placeholders + +Filters that work on a per-parameter basis can also use the `:property` placeholde and use the +parameter's `properties` configuration: + +```php + new QueryParameter( + properties: ['title', 'author'], // Only these properties get parameters created, defaults to all properties + filter: new PartialSearchFilter() + ) + ] + ) +])] +class Book { + // ... +} +``` + +This will create 2 parameters: `search[title]` and `search[author]`, here is an example of the +associated filter for Doctrine ORM: + +```php +getValue(); + + // Get the property for this specific parameter + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + $field = $alias.'.'.$property; + + $parameterName = $queryNameGenerator->generateParameterName($property); + + $queryBuilder + ->andWhere($queryBuilder->expr()->like('LOWER('.$field.')', ':'.$parameterName)) + ->setParameter($parameterName, '%'.strtolower($value).'%'); + } +} +``` + +**How it works:** + +1. API Platform creates individual parameters: `search[title]` and `search[author]` only +2. URLs like `/books?search[description]=foo` are ignored (no parameter exists) +3. Each parameter calls the filter with its specific property via `$parameter->getProperty()` +4. The filter processes only that one property + +This approach is recommended for new filters as it's more flexible and allows true property +restriction via the parameter configuration. + +> [!NOTE] Invalid values are usually ignored by our filters, use [validation](#parameter-validation) +> to trigger errors for wrong parameter values. + +## OpenAPI and JSON Schema + +You have full control over how your parameters are documented in OpenAPI. + +### Customizing the OpenAPI Parameter + +You can pass a fully configured `ApiPlatform\OpenApi\Model\Parameter` object to the `openApi` +property of your parameter attribute. This gives you total control over the generated documentation. + +```php + new QueryParameter( + schema: ['enum' => ['a', 'b'], 'uniqueItems' => true], + castToArray: true, + openApi: new OpenApiParameter(name: 'enum', in: 'query', style: 'deepObject') + ) + ] + ) +])] +class User {} +``` + +### Using JSON Schema and Type Casting + +The `schema` property allows you to define validation rules using JSON Schema keywords. This is +useful for simple validation like ranges, patterns, or enumerations. + +When you define a `schema`, API Platform can often infer the native PHP type of the parameter. For +instance, `['type' => 'boolean']` implies a boolean. If you want to ensure the incoming string value +(e.g., "true", "0") is cast to its actual native type before validation and filtering, set +`castToNativeType` to `true`. + +```php + new QueryParameter( + schema: ['type' => 'boolean'], + castToNativeType: true + ) + ] + ) +])] +class Setting {} +``` + +If you need a custom validation function use the `castFn` property of the `Parameter` class. + +## Parameter Validation + +You can enforce validation rules on your parameters using the `required` property or by attaching +Symfony Validator constraints. + +```php + new QueryParameter( + description: 'Filter by country code.', + constraints: [new Assert\Country()] + ), + 'X-Request-ID' => new HeaderParameter( + description: 'A unique request identifier.', + required: true, + constraints: [new Assert\Uuid()] + ) + ] + ) +])] +class User {} +``` + +> [!NOTE] When `castToNativeType` is enabled, API Platform infers type validation from the JSON +> Schema. + +The `ApiPlatform\Validator\Util\ParameterValidationConstraints` trait can be used to automatically +infer validation constraints from the JSON Schema and OpenAPI definitions of a parameter. + +Here is the list of validation constraints that are automatically inferred from the JSON Schema and +OpenAPI definitions of a parameter. + +### From OpenAPI Definition + +- **`allowEmptyValue`**: If set to `false`, a `Symfony\Component\Validator\Constraints\NotBlank` + constraint is added. + +### From JSON Schema (`schema` property) + +- **`minimum`** / **`maximum`**: + - If both are set, a `Symfony\Component\Validator\Constraints\Range` constraint is added. + - If only `minimum` is set, a `Symfony\Component\Validator\Constraints\GreaterThanOrEqual` + constraint is added. + - If only `maximum` is set, a `Symfony\Component\Validator\Constraints\LessThanOrEqual` + constraint is added. +- **`exclusiveMinimum`** / **`exclusiveMaximum`**: + - If `exclusiveMinimum` is used, it becomes a + `Symfony\Component\Validator\Constraints\GreaterThan` constraint. + - If `exclusiveMaximum` is used, it becomes a `Symfony\Component\Validator\Constraints\LessThan` + constraint. +- **`pattern`**: Becomes a `Symfony\Component\Validator\Constraints\Regex` constraint. +- **`minLength`** / **`maxLength`**: Becomes a `Symfony\Component\Validator\Constraints\Length` + constraint. +- **`multipleOf`**: Becomes a `Symfony\Component\Validator\Constraints\DivisibleBy` constraint. +- **`enum`**: Becomes a `Symfony\Component\Validator\Constraints\Choice` constraint with the + specified values. +- **`minItems`** / **`maxItems`**: Becomes a `Symfony\Component\Validator\Constraints\Count` + constraint (for arrays). +- **`uniqueItems`**: If `true`, becomes a `Symfony\Component\Validator\Constraints\Unique` + constraint (for arrays). +- **`type`**: + - If set to `'array'`, a `Symfony\Component\Validator\Constraints\Type('array')` constraint is + added. + - If `castToNativeType` is also `true`, the schema `type` will add a + `Symfony\Component\Validator\Constraints\Type` constraint for `'boolean'`, `'integer'`, and + `'number'` (as `float`). + +### From the Parameter's `required` Property + +- **`required`**: If set to `true`, a `Symfony\Component\Validator\Constraints\NotNull` constraint + is added. + +### Strict Parameter Validation + +By default, API Platform allows clients to send extra query parameters that are not defined in the +operation's `parameters`. To enforce a stricter contract, you can set +`strictQueryParameterValidation` to `true` on an operation. If an unsupported parameter is sent, API +Platform will return a 400 Bad Request error. + +```php + new QueryParameter(), + ] + ) +])] +class StrictParameters {} +``` + +With this configuration, a request to `/strict_query_parameters?bar=test` will fail with a 400 error +because `bar` is not a supported parameter. + +### Property filter + +> [!NOTE] We strongly recommend using [Vulcain](https://vulcain.rocks) instead of this filter. +> Vulcain is faster, allows a better hit rate, and is supported out of the box in the API Platform +> distribution. [!NOTE] When unsing JSON:API check out the +> [specific SparseFieldset and Sort filters](./content-negotiation/#jsonapi-sparse-fieldset-and-sort-parameters) + +The property filter adds the possibility to select the properties to serialize (sparse fieldsets). + +Syntax: `?properties[]=&properties[][]=` + +You can add as many properties as you need. + +Enable the filter: + +```php + new QueryParameter(filter: PropertyFilter::class)] +)] +class Book +{ + // ... +} +``` + +Three arguments are available to configure the filter: + +- `parameterName` is the query parameter name (default `properties`) +- `overrideDefaultProperties` allows to override the default serialization properties (default + `false`) +- `whitelist` properties whitelist to avoid uncontrolled data exposure (default `null` to allow all + properties) + +Given that the collection endpoint is `/books`, you can filter the serialization properties with the +following query: `/books?properties[]=title&properties[]=author`. If you want to include some +properties of the nested "author" document, use: +`/books?properties[]=title&properties[author][]=name`. + +## Parameter Providers + +Parameter Providers are powerful services that can inspect, transform, or provide values for +parameters. They can even modify the current `Operation` metadata on the fly. A provider is a class +that implements `ApiPlatform\State\ParameterProviderInterface`. + +### `IriConverterParameterProvider` + +This built-in provider takes an IRI string (e.g., `/users/1`) and converts it into the corresponding +Doctrine entity object. It supports both single IRIs and arrays of IRIs. + +```php + new QueryParameter(provider: IriConverterParameterProvider::class), + 'related' => new QueryParameter( + provider: IriConverterParameterProvider::class, + extraProperties: ['fetch_data' => true] // Forces fetching the entity data + ), + ], + provider: [self::class, 'provideDummyFromParameter'], + ) +])] +class WithParameter +{ + public static function provideDummyFromParameter(Operation $operation, array $uriVariables = [], array $context = []): object|array + { + // The value has been transformed from an IRI to an entity by the provider. + $dummy = $operation->getParameters()->get('dummy')->getValue(); + + // If multiple IRIs were provided as an array, this will be an array of entities + $related = $operation->getParameters()->get('related')->getValue(); + + return $dummy; + } +} +``` + +#### Configuration Options + +The `IriConverterParameterProvider` supports the following options in `extraProperties`: + +- **`fetch_data`**: Boolean (default: `false`) - When `true`, forces the IRI converter to fetch the + actual entity data instead of just creating a reference. + +### `ReadLinkParameterProvider` + +This provider must be enabled before it can be used. + +```yaml +api_platform: + enable_link_security: true +``` + +This provider fetches a linked resource from a given identifier. This is useful when you need to +load a related entity to use later, for example in your own state provider. When you have an API +resource with a custom `uriTemplate` that includes parameters, the `ReadLinkParameterProvider` can +automatically resolve the linked resource using the operation's URI template. This is particularly +useful for nested resources or when you need to load a parent resource based on URI variables. + +```php + new Link(schema: ['type' => 'string', 'format' => 'uuid'], property: 'id'), + ], + parameters: [ + 'dummy' => new QueryParameter( + provider: ReadLinkParameterProvider::class, + extraProperties: [ + 'resource_class' => Dummy::class, + 'uri_template' => '/dummies/{id}' // Optional: specify the template for the linked resource + ] + ) + ], + provider: [self::class, 'provideDummyFromParameter'], +)] +class WithParameter +{ + public static function provideDummyFromParameter(Operation $operation, array $uriVariables = [], array $context = []): object|array + { + // The dummy parameter has been resolved to the actual Dummy entity + // based on the parameter value and the specified uri_template + return $operation->getParameters()->get('dummy')->getValue(); + } +} +``` + +The provider will: + +- Take the parameter value (e.g., a UUID or identifier) +- Use the `resource_class` to determine which resource to load +- Optionally use the `uri_template` from `extraProperties` to construct the proper operation for + loading the resource +- Return the loaded entity, making it available in your state provider + +#### ReadLinkParameterProvider Configuration Options + +You can control the behavior of `ReadLinkParameterProvider` with these `extraProperties`: + +- **`resource_class`**: The class of the resource to load +- **`uri_template`**: Optional URI template for the linked resource operation +- **`uri_variable`**: Name of the URI variable to use when building URI variables array +- **`throw_not_found`**: Boolean (default: `true`) - Whether to throw `NotFoundHttpException` when + resource is not found + +```php +'dummy' => new QueryParameter( + provider: ReadLinkParameterProvider::class, + extraProperties: [ + 'resource_class' => Dummy::class, + 'throw_not_found' => false, // Won't throw NotFoundHttpException if resource is missing + 'uri_variable' => 'customId' // Use 'customId' as the URI variable name + ] +) +``` + +### Array Support + +Both `IriConverterParameterProvider` and `ReadLinkParameterProvider` support processing arrays of +values. When you pass an array of identifiers or IRIs, they will return an array of resolved +entities: + +```php +// For IRI converter: ?related[]=/dummies/1&related[]=/dummies/2 +// For ReadLink provider: ?dummies[]=uuid1&dummies[]=uuid2 +'items' => new QueryParameter( + provider: ReadLinkParameterProvider::class, + extraProperties: ['resource_class' => Dummy::class] +) +``` + +### Creating a Custom Parameter Provider + +You can create your own providers to implement any custom logic. A provider must implement +`ParameterProviderInterface`. The `provide` method can modify the parameter's value or even return a +modified `Operation` to alter the request handling flow. + +For instance, a provider could add serialization groups to the normalization context based on a +query parameter: + +```php +getValue(); + if ('extended' === $value) { + $context = $operation->getNormalizationContext(); + $context[AbstractNormalizer::GROUPS][] = 'extended_read'; + return $operation->withNormalizationContext($context); + } + + return $operation; + } +} +``` + +### Changing how to parse Query / Header Parameters + +We use our own algorithm to parse a request's query, if you want to do the parsing of `QUERY_STRING` +yourself, set `_api_query_parameters` in the Request attributes +(`$request->attributes->set('_api_query_parameters', [])`) yourself. By default we use Symfony's +`$request->headers->all()`, you can also set `_api_header_parameters` if you want to parse them +yourself. + +## Creating Custom Filters + +For data-provider-specific filtering (e.g., Doctrine ORM), the recommended way to create a filter is +to implement the corresponding `FilterInterface`. + +For Doctrine ORM, your filter should implement `ApiPlatform\Doctrine\Orm\Filter\FilterInterface`: + +```php +getValue(); + + // The parameter may not be present. + // It's recommended to add validation (e.g., `required: true`) on the Parameter attribute + // if the filter logic depends on the value. + if ($value instanceof ParameterNotFound) { + return; + } + + $alias = $queryBuilder->getRootAliases()[0]; + $parameterName = $queryNameGenerator->generateParameterName('regexp_name'); + + // Access the parameter's property or use the parameter key as fallback + $property = $parameter->getProperty() ?? $parameter->getKey() ?? 'name'; + + // You can also access filter context if the parameter provides it + $filterContext = $parameter->getFilterContext() ?? null; + + $queryBuilder + ->andWhere(sprintf('REGEXP(%s.%s, :%s) = 1', $alias, $property, $parameterName)) + ->setParameter($parameterName, $value); + } + + // For BC, this function is not useful anymore when documentation occurs on the Parameter + public function getDescription(): array { + return []; + } +} +``` + +You can then instantiate this filter directly in your `QueryParameter`: + +```php + new QueryParameter(filter: new RegexpFilter()) + ] + ) +])] +class User {} +``` + +> [!NOTE] A `filter` is either an instanceof `FilterInterface` or a string referencing a filter +> service. + +## Parameter Attribute Reference + +| Property | Description | +| ------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `key` | The name of the parameter (e.g., `name`, `order`). | +| `filter` | The filter service or instance that processes the parameter's value. | +| `provider` | A service that transforms the parameter's value before it's used. | +| `description` | A description for the API documentation. | +| `property` | The resource property this parameter is mapped to. | +| `required` | Whether the parameter is required. | +| `constraints` | Symfony Validator constraints to apply to the value. | +| `schema` | A JSON Schema for validation and documentation. | +| `castToArray` | Casts the parameter value to an array. Useful for query parameters like `foo[]=1&foo[]=2`. Defaults to `true`. | +| `castToNativeType` | Casts the parameter value to its native PHP type based on the `schema`. | +| `openApi` | Customize OpenAPI documentation or hide the parameter (`false`). | +| `hydra` | Hide the parameter from Hydra documentation (`false`). | +| `security` | A [Symfony expression](https://symfony.com/doc/current/security/expressions.html) to control access to the parameter. | + +## 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. + +```php + new QueryParameter( + security: 'is_granted("ROLE_ADMIN")' + ), + 'auth' => new HeaderParameter( + security: '"secured" == auth', + description: 'Only accessible when auth header equals "secured"' + ), + 'secret' => new QueryParameter( + security: '"secured" == secret', + description: 'Only accessible when secret parameter equals "secured"' + ) + ] + ) +])] +class SecureResource +{ + // ... +} +``` + +In the security expressions, you have access to: + +- Parameter values by their key name (e.g., `auth`, `secret`) +- Standard security functions like `is_granted()` +- The current user via `user` +- Request object via `request` diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index b99b133d299..4255d2ff8c0 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -34,7 +34,7 @@ "doctrine/mongodb-odm": "^2.10", "doctrine/orm": "^2.17 || ^3.0", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "symfony/type-info": "^7.3 || ^8.0" }, "conflict": { @@ -76,11 +76,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/Doctrine/Odm/Filter/DateFilter.php b/src/Doctrine/Odm/Filter/DateFilter.php index 227888c80f2..7bc5451d172 100644 --- a/src/Doctrine/Odm/Filter/DateFilter.php +++ b/src/Doctrine/Odm/Filter/DateFilter.php @@ -23,7 +23,6 @@ use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; -use Doctrine\ODM\MongoDB\Types\Type as MongoDbType; /** * The date filter allows to filter a collection by date intervals. @@ -127,8 +126,8 @@ final class DateFilter extends AbstractFilter implements DateFilterInterface, Js use DateFilterTrait; public const DOCTRINE_DATE_TYPES = [ - MongoDbType::DATE => true, - MongoDbType::DATE_IMMUTABLE => true, + 'date' => true, + 'date_immutable' => true, ]; /** diff --git a/src/Doctrine/Odm/PropertyHelperTrait.php b/src/Doctrine/Odm/PropertyHelperTrait.php index 168e0cec6bc..c94220a7483 100644 --- a/src/Doctrine/Odm/PropertyHelperTrait.php +++ b/src/Doctrine/Odm/PropertyHelperTrait.php @@ -47,11 +47,11 @@ protected function getClassMetadata(string $resourceClass): ClassMetadata $managerRegistry = $this->getManagerRegistry(); $manager = $managerRegistry?->getManagerForClass($resourceClass); - if ($manager) { - return $manager->getClassMetadata($resourceClass); + if (!$manager) { + throw new InvalidArgumentException(\sprintf('Unable to get class metadata for resource "%s". No object manager found.', $resourceClass)); } - return new MongoDbOdmClassMetadata($resourceClass); + return $manager->getClassMetadata($resourceClass); } /** diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index 547dfc8f4e3..cbca401032d 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -36,7 +36,7 @@ "doctrine/doctrine-bundle": "^2.11 || ^3.1", "doctrine/mongodb-odm-bundle": "^5.0", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "symfony/cache": "^6.4 || ^7.0 || ^8.0", "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", @@ -76,11 +76,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/Doctrine/Orm/Filter/ExistsFilter.php b/src/Doctrine/Orm/Filter/ExistsFilter.php index 7dbb046630e..5315473587e 100644 --- a/src/Doctrine/Orm/Filter/ExistsFilter.php +++ b/src/Doctrine/Orm/Filter/ExistsFilter.php @@ -135,8 +135,9 @@ public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInt public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { $parameter = $context['parameter'] ?? null; + $propertyKey = $parameter?->getProperty(); - if (null !== ($value = $context['filters'][$parameter?->getProperty()] ?? null)) { + if (null !== $propertyKey && null !== ($value = $context['filters'][$propertyKey] ?? null)) { $this->filterProperty($this->denormalizePropertyName($parameter->getProperty()), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); return; diff --git a/src/Doctrine/Orm/Tests/Util/QueryBuilderHelperTest.php b/src/Doctrine/Orm/Tests/Util/QueryBuilderHelperTest.php index 488d217743f..709e9364a1a 100644 --- a/src/Doctrine/Orm/Tests/Util/QueryBuilderHelperTest.php +++ b/src/Doctrine/Orm/Tests/Util/QueryBuilderHelperTest.php @@ -55,7 +55,6 @@ public function testAddJoinOnce(?string $originAliasForJoinOnce, string $expecte $queryBuilder->getDQLPart('join')[$originAliasForJoinOnce ?? 'f'][0]->getAlias()); } - #[\PHPUnit\Framework\Attributes\DataProvider('provideAddJoinOnce')] public function testAddJoinOnceWithSpecifiedNewAlias(): void { $queryBuilder = new QueryBuilder($this->prophesize(EntityManagerInterface::class)->reveal()); diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 8952b55530c..0fb956e21f9 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -32,7 +32,7 @@ "require-dev": { "doctrine/doctrine-bundle": "^2.11 || ^3.1", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", "symfony/cache": "^6.4 || ^7.0 || ^8.0", @@ -76,11 +76,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/Documentation/composer.json b/src/Documentation/composer.json index 01bbdc9743c..ffc9291d303 100644 --- a/src/Documentation/composer.json +++ b/src/Documentation/composer.json @@ -39,12 +39,6 @@ } }, "require-dev": { - "phpunit/phpunit": "11.5.x-dev" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + "phpunit/phpunit": "^12.2" + } } diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index b23b4dfe7fc..9cb069e9bb4 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -38,7 +38,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev" + "phpunit/phpunit": "^12.2" }, "autoload": { "psr-4": { @@ -76,11 +76,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/GraphQl/Tests/Type/FieldsBuilderTest.php b/src/GraphQl/Tests/Type/FieldsBuilderTest.php index a3b1e9420ff..4874856e287 100644 --- a/src/GraphQl/Tests/Type/FieldsBuilderTest.php +++ b/src/GraphQl/Tests/Type/FieldsBuilderTest.php @@ -48,6 +48,7 @@ use Psr\Container\ContainerInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * @author Alan Poulain @@ -465,50 +466,123 @@ public static function subscriptionFieldsProvider(): array #[\PHPUnit\Framework\Attributes\DataProvider('resourceObjectTypeFieldsProvider')] public function testGetResourceObjectTypeFields(string $resourceClass, Operation $operation, array $properties, bool $input, int $depth, ?array $ioMetadata, array $expectedResourceObjectTypeFields, ?callable $advancedNameConverterFactory = null): void { - $this->resourceClassResolverProphecy->isResourceClass($resourceClass)->willReturn(true); - $this->resourceClassResolverProphecy->isResourceClass('nestedResourceClass')->willReturn(true); - $this->resourceClassResolverProphecy->isResourceClass('nestedResourceNoQueryClass')->willReturn(true); - $this->resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(false); - $this->propertyNameCollectionFactoryProphecy->create($resourceClass)->willReturn(new PropertyNameCollection(array_keys($properties))); - foreach ($properties as $propertyName => $propertyMetadata) { - $this->propertyMetadataFactoryProphecy->create($resourceClass, $propertyName, ['normalization_groups' => null, 'denormalization_groups' => null])->willReturn($propertyMetadata); - $this->typeConverterProphecy->convertPhpType(Type::null(), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn(null); - $this->typeConverterProphecy->convertPhpType(Type::callable(), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn('NotRegisteredType'); - $this->typeConverterProphecy->convertPhpType(Argument::type(Type::class), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn(GraphQLType::string()); - $this->typeConverterProphecy->convertPhpType(Type::list(Type::string()), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn(GraphQLType::nonNull(GraphQLType::listOf(GraphQLType::nonNull(GraphQLType::string())))); - - if ('propertyObject' === $propertyName) { - $this->typeConverterProphecy->convertPhpType(Argument::type(Type::class), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), 'objectClass', $resourceClass, $propertyName, $depth + 1)->willReturn(new ObjectType(['name' => 'objectType', 'fields' => []])); - $this->itemResolverFactoryProphecy->__invoke('objectClass', $resourceClass, $operation, Argument::any())->willReturn(static function (): void { - }); + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturnCallback(function ($class) use ($resourceClass) { + return \in_array($class, [$resourceClass, 'nestedResourceClass', 'nestedResourceNoQueryClass'], true); + }); + + $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->with($resourceClass)->willReturn(new PropertyNameCollection(array_keys($properties))); + + $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturnCallback(function ($class, $propertyName) use ($properties, $resourceClass) { + if ($class === $resourceClass && isset($properties[$propertyName])) { + return $properties[$propertyName]; } - if ('propertyNestedResource' === $propertyName) { - $nestedResourceQueryOperation = new Query(); - $this->resourceMetadataCollectionFactoryProphecy->create('nestedResourceClass')->willReturn(new ResourceMetadataCollection('nestedResourceClass', [(new ApiResource())->withGraphQlOperations(['item_query' => $nestedResourceQueryOperation])])); - $this->typeConverterProphecy->convertPhpType(Argument::type(Type::class), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), 'nestedResourceClass', $resourceClass, $propertyName, $depth + 1)->willReturn(new ObjectType(['name' => 'objectType', 'fields' => []])); - $this->itemResolverFactoryProphecy->__invoke('nestedResourceClass', $resourceClass, $nestedResourceQueryOperation, Argument::any())->willReturn(static function (): void { - }); + + return new ApiProperty(); + }); + + $typeConverter = new class implements TypeConverterInterface { + public function convertType(\Symfony\Component\PropertyInfo\Type $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth): GraphQLType|string|null + { + return null; } - } - $this->typesContainerProphecy->has('NotRegisteredType')->willReturn(false); - $this->typesContainerProphecy->all()->willReturn([]); - $fieldsBuilder = $this->fieldsBuilder; - if ($advancedNameConverterFactory) { - $fieldsBuilder = $this->buildFieldsBuilder($advancedNameConverterFactory($this)); - } + public function resolveType(string $type): ?GraphQLType + { + return null; + } + + public function convertPhpType(Type $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth): GraphQLType|string|null + { + if ($type->isIdentifiedBy(TypeIdentifier::NULL)) { + return null; + } + if ($type->isIdentifiedBy(TypeIdentifier::CALLABLE)) { + return 'NotRegisteredType'; + } + if (method_exists($type, 'isList') && $type->isList()) { + return GraphQLType::nonNull(GraphQLType::listOf(GraphQLType::nonNull(GraphQLType::string()))); + } + if ('objectClass' === $resourceClass) { + return new ObjectType(['name' => 'objectType', 'fields' => []]); + } + if ('nestedResourceClass' === $resourceClass) { + return new ObjectType(['name' => 'objectType', 'fields' => []]); + } + + return GraphQLType::string(); + } + }; + + $itemResolverFactory = $this->createMock(ResolverFactoryInterface::class); + $itemResolverFactory->method('__invoke')->willReturn(static function (): void { + }); + + $nestedResourceQueryOperation = new Query(); + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->method('create')->with('nestedResourceClass')->willReturn( + new ResourceMetadataCollection('nestedResourceClass', [(new ApiResource())->withGraphQlOperations(['item_query' => $nestedResourceQueryOperation])]) + ); + + $typesContainer = $this->createMock(TypesContainerInterface::class); + $typesContainer->method('has')->with('NotRegisteredType')->willReturn(false); + $typesContainer->method('all')->willReturn([]); + + $typeBuilder = $this->createMock(ContextAwareTypeBuilderInterface::class); + + $filterLocator = $this->createMock(ContainerInterface::class); + + $nameConverter = $advancedNameConverterFactory ? $advancedNameConverterFactory($this) : new CustomConverter(); + + $fieldsBuilder = new FieldsBuilder( + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $resourceMetadataCollectionFactory, + $resourceClassResolver, + $typesContainer, + $typeBuilder, + $typeConverter, + $itemResolverFactory, + $filterLocator, + new Pagination(), + $nameConverter, + '__' + ); + $resourceObjectTypeFields = $fieldsBuilder->getResourceObjectTypeFields($resourceClass, $operation, $input, $depth, $ioMetadata); - $this->assertEquals($expectedResourceObjectTypeFields, $resourceObjectTypeFields); + // For fields with closures, we need to check them separately + foreach ($expectedResourceObjectTypeFields as $fieldName => $expectedField) { + $this->assertArrayHasKey($fieldName, $resourceObjectTypeFields, "Field '$fieldName' should exist"); + + // If expected field is just a type (not an array), compare directly + if (!\is_array($expectedField)) { + $this->assertEquals($expectedField, $resourceObjectTypeFields[$fieldName], "Field '$fieldName' should match"); + continue; + } + + foreach ($expectedField as $key => $value) { + if ('resolve' === $key && $value instanceof \Closure) { + $this->assertInstanceOf(\Closure::class, $resourceObjectTypeFields[$fieldName][$key], "Field '$fieldName' resolve should be a closure"); + } else { + $this->assertEquals($value, $resourceObjectTypeFields[$fieldName][$key] ?? null, "Field '$fieldName' key '$key' should match"); + } + } + } + + // Check no extra fields exist + $this->assertSameSize($expectedResourceObjectTypeFields, $resourceObjectTypeFields, 'Number of fields should match'); } public static function resourceObjectTypeFieldsProvider(): iterable { $advancedNameConverterFactory = function (self $that): NameConverterInterface { - $advancedNameConverterProphecy = $that->prophesize(NameConverterInterface::class); - $advancedNameConverterProphecy->normalize('field', \stdClass::class)->willReturn('normalizedField'); + $nameConverter = $that->createMock(NameConverterInterface::class); + $nameConverter->method('normalize')->with('field', \stdClass::class)->willReturn('normalizedField'); - return $advancedNameConverterProphecy->reveal(); + return $nameConverter; }; yield 'query' => [\stdClass::class, (new Query())->withClass(\stdClass::class), diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json index 5f9c3c0d6fc..d7434df70f4 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -36,7 +36,7 @@ "twig/twig": "^1.42.3 || ^2.12 || ^3.0", "symfony/mercure-bundle": "*", "symfony/routing": "^6.4 || ^7.0 || ^8.0", - "phpunit/phpunit": "11.5.x-dev" + "phpunit/phpunit": "^12.2" }, "autoload": { "psr-4": { @@ -83,11 +83,5 @@ "conflict": { "symfony/http-client": "<6.4", "doctrine/inflector": "<2.0" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/Hal/composer.json b/src/Hal/composer.json index 9f0c12a526d..2a86e130062 100644 --- a/src/Hal/composer.json +++ b/src/Hal/composer.json @@ -65,13 +65,7 @@ "test": "./vendor/bin/phpunit" }, "require-dev": { - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "api-platform/json-schema": "^4.1.11" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/HttpCache/composer.json b/src/HttpCache/composer.json index 9ab73411415..7f8ba1096be 100644 --- a/src/HttpCache/composer.json +++ b/src/HttpCache/composer.json @@ -33,7 +33,7 @@ "phpspec/prophecy-phpunit": "^2.2", "symfony/http-client": "^6.4 || ^7.0 || ^8.0", "symfony/type-info": "^7.3 || ^8.0", - "phpunit/phpunit": "11.5.x-dev" + "phpunit/phpunit": "^12.2" }, "autoload": { "psr-4": { @@ -70,11 +70,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/Hydra/composer.json b/src/Hydra/composer.json index 0e2614722c2..c149384e84c 100644 --- a/src/Hydra/composer.json +++ b/src/Hydra/composer.json @@ -40,7 +40,7 @@ "api-platform/doctrine-common": "^4.2", "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev" + "phpunit/phpunit": "^12.2" }, "autoload": { "psr-4": { @@ -77,11 +77,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index 1e4190928c3..c1fa5481aba 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -34,7 +34,7 @@ "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { @@ -72,11 +72,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/JsonLd/composer.json b/src/JsonLd/composer.json index a943fc85f06..114a9a1ca3f 100644 --- a/src/JsonLd/composer.json +++ b/src/JsonLd/composer.json @@ -69,12 +69,6 @@ }, "require-dev": { "symfony/type-info": "^7.3 || ^8.0", - "phpunit/phpunit": "11.5.x-dev" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + "phpunit/phpunit": "^12.2" + } } diff --git a/src/JsonSchema/composer.json b/src/JsonSchema/composer.json index 3e629bd3295..4ac6fa87cbc 100644 --- a/src/JsonSchema/composer.json +++ b/src/JsonSchema/composer.json @@ -34,7 +34,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev" + "phpunit/phpunit": "^12.2" }, "autoload": { "psr-4": { @@ -71,11 +71,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index 5182488443b..92d1c1d880c 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -61,7 +61,7 @@ "orchestra/testbench": "^10.1", "phpdocumentor/type-resolver": "^1.7", "phpstan/phpdoc-parser": "^1.29 || ^2.0", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "symfony/http-client": "^7.4 || ^8.0" }, "autoload": { @@ -127,11 +127,5 @@ "lint": [ "@php vendor/bin/phpstan analyse --verbose --ansi" ] - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/Metadata/composer.json b/src/Metadata/composer.json index 1b723268789..d0554035413 100644 --- a/src/Metadata/composer.json +++ b/src/Metadata/composer.json @@ -41,7 +41,7 @@ "api-platform/state": "^4.2.4", "phpspec/prophecy-phpunit": "^2.2", "phpstan/phpdoc-parser": "^1.29 || ^2.0", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "symfony/config": "^6.4 || ^7.0 || ^8.0", "symfony/routing": "^6.4 || ^7.0 || ^8.0", "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", @@ -88,11 +88,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/OpenApi/composer.json b/src/OpenApi/composer.json index ac0b0ca8213..00153da6c4b 100644 --- a/src/OpenApi/composer.json +++ b/src/OpenApi/composer.json @@ -39,7 +39,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "api-platform/doctrine-common": "^4.2", "api-platform/doctrine-orm": "^4.2", "api-platform/doctrine-odm": "^4.2", @@ -80,11 +80,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/RamseyUuid/composer.json b/src/RamseyUuid/composer.json index 141300f2d33..02df35226f8 100644 --- a/src/RamseyUuid/composer.json +++ b/src/RamseyUuid/composer.json @@ -30,7 +30,7 @@ "phpspec/prophecy-phpunit": "^2.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { @@ -65,11 +65,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/Serializer/Mapping/Loader/PropertyMetadataLoader.php b/src/Serializer/Mapping/Loader/PropertyMetadataLoader.php index 5c25996bdf3..408ffdb0c4e 100644 --- a/src/Serializer/Mapping/Loader/PropertyMetadataLoader.php +++ b/src/Serializer/Mapping/Loader/PropertyMetadataLoader.php @@ -61,7 +61,13 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool $attributesMetadata = $classMetadata->getAttributesMetadata(); foreach ($refl->getAttributes() as $a) { + // Skip attributes whose classes don't exist (e.g., optional dependencies like MongoDB ODM) + if (!class_exists($a->getName()) && !interface_exists($a->getName())) { + continue; + } + $attribute = $a->newInstance(); + if ($attribute instanceof DiscriminatorMap) { $classMetadata->setClassDiscriminatorMapping(new ClassDiscriminatorMapping( method_exists($attribute, 'getTypeProperty') ? $attribute->getTypeProperty() : $attribute->typeProperty, diff --git a/src/Serializer/composer.json b/src/Serializer/composer.json index 7341fcfa431..b6005860cfd 100644 --- a/src/Serializer/composer.json +++ b/src/Serializer/composer.json @@ -38,7 +38,7 @@ "api-platform/openapi": "^4.2", "doctrine/collections": "^2.1", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "symfony/mercure-bundle": "*", "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", "symfony/yaml": "^6.4 || ^7.0 || ^8.0", @@ -83,11 +83,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/State/composer.json b/src/State/composer.json index 591b1e72e4b..87940754b96 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -38,7 +38,7 @@ "require-dev": { "api-platform/serializer": "^4.2.4", "api-platform/validator": "^4.2.4", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", "symfony/type-info": "^7.4 || ^8.0", @@ -90,11 +90,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/Symfony/Tests/EventListener/AddFormatListenerTest.php b/src/Symfony/Tests/EventListener/AddFormatListenerTest.php index 36280edf59e..2b6ab83fa3a 100644 --- a/src/Symfony/Tests/EventListener/AddFormatListenerTest.php +++ b/src/Symfony/Tests/EventListener/AddFormatListenerTest.php @@ -66,7 +66,7 @@ public function testCallProvider(): void } #[DataProvider('provideNonApiAttributes')] - public function testNoCallProvider(...$attributes): void + public function testNoCallProvider(array $attributes): void { $provider = $this->createMock(ProviderInterface::class); $provider->expects($this->never())->method('provide'); @@ -85,13 +85,13 @@ public function testNoCallProvider(...$attributes): void public static function provideNonApiAttributes(): array { return [ - ['_api_respond' => false, '_api_operation_name' => 'dummy'], - [], + [['_api_respond' => false, '_api_operation_name' => 'dummy']], + [[]], ]; } #[DataProvider('provideOperationNotFound')] - public function testNoOperation(...$attributes): void + public function testNoOperation(array $attributes): void { $this->expectException(OperationNotFoundException::class); $provider = $this->createMock(ProviderInterface::class); @@ -111,8 +111,8 @@ public function testNoOperation(...$attributes): void public static function provideOperationNotFound(): array { return [ - ['_api_resource_class' => 'dummy'], - ['_api_resource_class' => 'dummy', '_api_operation_name' => 'dummy'], + [['_api_resource_class' => 'dummy']], + [['_api_resource_class' => 'dummy', '_api_operation_name' => 'dummy']], ]; } } diff --git a/src/Symfony/Tests/EventListener/DeserializeListenerTest.php b/src/Symfony/Tests/EventListener/DeserializeListenerTest.php index 7cb70f4778c..4dda9ff6094 100644 --- a/src/Symfony/Tests/EventListener/DeserializeListenerTest.php +++ b/src/Symfony/Tests/EventListener/DeserializeListenerTest.php @@ -69,7 +69,7 @@ public function testCallProvider(): void } #[DataProvider('provideNonApiAttributes')] - public function testNoCallProvider(...$attributes): void + public function testNoCallProvider(array $attributes): void { $provider = $this->createMock(ProviderInterface::class); $provider->expects($this->never())->method('provide'); @@ -88,8 +88,8 @@ public function testNoCallProvider(...$attributes): void public static function provideNonApiAttributes(): array { return [ - ['_api_receive' => false, '_api_operation_name' => 'dummy'], - [], + [['_api_receive' => false, '_api_operation_name' => 'dummy']], + [[]], ]; } diff --git a/src/Symfony/Tests/EventListener/ReadListenerTest.php b/src/Symfony/Tests/EventListener/ReadListenerTest.php index e25eeabc187..3a008f35578 100644 --- a/src/Symfony/Tests/EventListener/ReadListenerTest.php +++ b/src/Symfony/Tests/EventListener/ReadListenerTest.php @@ -69,7 +69,7 @@ public function testCallProvider(): void } #[DataProvider('provideNonApiAttributes')] - public function testNoCallProvider(...$attributes): void + public function testNoCallProvider(array $attributes): void { $provider = $this->createMock(ProviderInterface::class); $provider->expects($this->never())->method('provide'); @@ -88,8 +88,8 @@ public function testNoCallProvider(...$attributes): void public static function provideNonApiAttributes(): array { return [ - ['_api_receive' => false, '_api_operation_name' => 'dummy'], - [], + [['_api_receive' => false, '_api_operation_name' => 'dummy']], + [[]], ]; } diff --git a/src/Symfony/Tests/EventListener/RespondListenerTest.php b/src/Symfony/Tests/EventListener/RespondListenerTest.php index 55138d2b360..41b1301c23e 100644 --- a/src/Symfony/Tests/EventListener/RespondListenerTest.php +++ b/src/Symfony/Tests/EventListener/RespondListenerTest.php @@ -93,7 +93,7 @@ public function testCallProcessorContext(): void } #[DataProvider('provideNonApiAttributes')] - public function testNoCallProcessor(...$attributes): void + public function testNoCallProcessor(array $attributes): void { $controllerResult = new \stdClass(); $processor = $this->createMock(ProcessorInterface::class); @@ -115,8 +115,8 @@ public function testNoCallProcessor(...$attributes): void public static function provideNonApiAttributes(): array { return [ - ['_api_respond' => false, '_api_operation_name' => 'dummy'], - [], + [['_api_respond' => false, '_api_operation_name' => 'dummy']], + [[]], ]; } } diff --git a/src/Symfony/Tests/EventListener/SerializeListenerTest.php b/src/Symfony/Tests/EventListener/SerializeListenerTest.php index dc9354f0b6c..ad3d1f66694 100644 --- a/src/Symfony/Tests/EventListener/SerializeListenerTest.php +++ b/src/Symfony/Tests/EventListener/SerializeListenerTest.php @@ -92,7 +92,7 @@ public function testCallProcessorContext(): void } #[DataProvider('provideNonApiAttributes')] - public function testNoCallProcessor(...$attributes): void + public function testNoCallProcessor(array $attributes): void { $controllerResult = new \stdClass(); $processor = $this->createMock(ProcessorInterface::class); @@ -114,8 +114,8 @@ public function testNoCallProcessor(...$attributes): void public static function provideNonApiAttributes(): array { return [ - ['_api_respond' => false, '_api_operation_name' => 'dummy'], - [], + [['_api_respond' => false, '_api_operation_name' => 'dummy']], + [[]], ]; } } diff --git a/src/Symfony/Tests/EventListener/ValidateListenerTest.php b/src/Symfony/Tests/EventListener/ValidateListenerTest.php index f367d043882..dacac60ef37 100644 --- a/src/Symfony/Tests/EventListener/ValidateListenerTest.php +++ b/src/Symfony/Tests/EventListener/ValidateListenerTest.php @@ -137,7 +137,7 @@ public function testDeleteForceValidate(): void } #[DataProvider('provideNonApiAttributes')] - public function testNoCallprovider(...$attributes): void + public function testNoCallprovider(array $attributes): void { $controllerResult = new \stdClass(); $provider = $this->createMock(ProviderInterface::class); @@ -159,8 +159,8 @@ public function testNoCallprovider(...$attributes): void public static function provideNonApiAttributes(): array { return [ - ['_api_respond' => false, '_api_operation_name' => 'dummy'], - [], + [['_api_respond' => false, '_api_operation_name' => 'dummy']], + [[]], ]; } } diff --git a/src/Symfony/Tests/EventListener/WriteListenerTest.php b/src/Symfony/Tests/EventListener/WriteListenerTest.php index 715c64fb6b3..2e4b8b8df0c 100644 --- a/src/Symfony/Tests/EventListener/WriteListenerTest.php +++ b/src/Symfony/Tests/EventListener/WriteListenerTest.php @@ -105,7 +105,7 @@ public function testCallProcessorContext(): void } #[DataProvider('provideNonApiAttributes')] - public function testNoCallProcessor(...$attributes): void + public function testNoCallProcessor(array $attributes): void { $controllerResult = new \stdClass(); $processor = $this->createMock(ProcessorInterface::class); @@ -127,8 +127,8 @@ public function testNoCallProcessor(...$attributes): void public static function provideNonApiAttributes(): array { return [ - ['_api_persist' => false, '_api_operation_name' => 'dummy'], - [], + [['_api_persist' => false, '_api_operation_name' => 'dummy']], + [[]], ]; } diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index ecacfbe38c7..1d6b64d322c 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -55,7 +55,7 @@ "api-platform/graphql": "^4.2.3", "api-platform/hal": "^4.2.3", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", "symfony/intl": "^6.4 || ^7.0 || ^8.0", "symfony/mercure-bundle": "*", @@ -119,11 +119,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/src/Validator/composer.json b/src/Validator/composer.json index 90b160b1c2f..6d89e87c5ac 100644 --- a/src/Validator/composer.json +++ b/src/Validator/composer.json @@ -32,7 +32,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev" + "phpunit/phpunit": "^12.2" }, "autoload": { "psr-4": { @@ -66,11 +66,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } diff --git a/tests/Fixtures/TestBundle/ApiResource/AgentApi.php b/tests/Fixtures/TestBundle/ApiResource/AgentApi.php index 9d040921bb1..fc4bca059a7 100644 --- a/tests/Fixtures/TestBundle/ApiResource/AgentApi.php +++ b/tests/Fixtures/TestBundle/ApiResource/AgentApi.php @@ -13,15 +13,12 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; -use ApiPlatform\Doctrine\Odm\Filter\DateFilter as OdmDateFilter; -use ApiPlatform\Doctrine\Odm\State\Options as OdmOptions; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\AgentDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Agent; #[ApiFilter(DateFilter::class, properties: ['birthday'], alias: 'app_filter_date')] @@ -34,16 +31,6 @@ ], stateOptions: new Options(entityClass: Agent::class) )] -#[ApiFilter(OdmDateFilter::class, properties: ['birthday'], alias: 'app_filter_date_odm')] -#[ApiResource( - shortName: 'AgentDocument', - operations: [ - new GetCollection(parameters: [ - 'birthday' => new QueryParameter(filter: 'app_filter_date_odm'), - ]), - ], - stateOptions: new OdmOptions(documentClass: AgentDocument::class) -)] class AgentApi { private ?int $id = null; diff --git a/tests/Fixtures/TestBundle/ApiResourceOdm/AgentDocumentApi.php b/tests/Fixtures/TestBundle/ApiResourceOdm/AgentDocumentApi.php new file mode 100644 index 00000000000..aea8b646f1b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResourceOdm/AgentDocumentApi.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResourceOdm; + +use ApiPlatform\Doctrine\Odm\Filter\DateFilter as OdmDateFilter; +use ApiPlatform\Doctrine\Odm\State\Options as OdmOptions; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\AgentDocument; + +#[ApiFilter(OdmDateFilter::class, properties: ['birthday'], alias: 'app_filter_date_odm')] +#[ApiResource( + shortName: 'AgentDocument', + operations: [ + new GetCollection(parameters: [ + 'birthday' => new QueryParameter(filter: 'app_filter_date_odm'), + ]), + ], + stateOptions: new OdmOptions(documentClass: AgentDocument::class) +)] +class AgentDocumentApi +{ + private ?int $id = null; + + private ?string $name = null; + + private ?\DateTimeInterface $birthday = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): self + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getBirthday(): ?\DateTimeInterface + { + return $this->birthday; + } + + public function setBirthday(?\DateTimeInterface $birthday): self + { + $this->birthday = $birthday; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/ResourceWithSeparatedDocument.php b/tests/Fixtures/TestBundle/ApiResourceOdm/ResourceWithSeparatedDocument.php similarity index 92% rename from tests/Fixtures/TestBundle/ApiResource/ResourceWithSeparatedDocument.php rename to tests/Fixtures/TestBundle/ApiResourceOdm/ResourceWithSeparatedDocument.php index bcf1fbc06d3..b0e2a6bebbf 100644 --- a/tests/Fixtures/TestBundle/ApiResource/ResourceWithSeparatedDocument.php +++ b/tests/Fixtures/TestBundle/ApiResourceOdm/ResourceWithSeparatedDocument.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResourceOdm; use ApiPlatform\Doctrine\Odm\Filter\OrderFilter; use ApiPlatform\Doctrine\Odm\State\Options as ODMOptions; diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index 2a74b0d9b13..a4e7a595086 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -314,7 +314,10 @@ class_exists(NativePasswordHasher::class) ? 'password_hashers' : 'encoders' => [ if ('mongodb' === $this->environment) { $c->prependExtensionConfig('api_platform', [ 'mapping' => [ - 'paths' => ['%kernel.project_dir%/../TestBundle/Resources/config/api_resources_odm'], + 'paths' => [ + '%kernel.project_dir%/../TestBundle/Resources/config/api_resources_odm', + '%kernel.project_dir%/../TestBundle/ApiResourceOdm', + ], ], ]); diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index eb58c616ae8..fcd716c59fb 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -443,10 +443,6 @@ services: tags: - name: 'api_platform.doctrine.orm.links_handler' - ApiPlatform\Tests\Fixtures\TestBundle\State\ODMLinkHandledDummyLinksHandler: - tags: - - name: 'api_platform.doctrine.odm.links_handler' - ApiPlatform\Tests\Fixtures\TestBundle\Serializer\ErrorNormalizer: arguments: [ '@serializer.normalizer.problem' ] tags: diff --git a/tests/Fixtures/app/config/config_mongodb.yml b/tests/Fixtures/app/config/config_mongodb.yml index f948ea309b8..43a9558dff1 100644 --- a/tests/Fixtures/app/config/config_mongodb.yml +++ b/tests/Fixtures/app/config/config_mongodb.yml @@ -26,6 +26,7 @@ api_platform: mapping: paths: - '%kernel.project_dir%/../TestBundle/Model' + - '%kernel.project_dir%/../TestBundle/ApiResourceOdm' services: app.my_dummy_resource.mongodb.boolean_filter: @@ -177,3 +178,7 @@ services: ApiPlatform\Tests\Fixtures\TestBundle\Filter\QueryParameterOdmFilter: tags: [ 'api_platform.filter' ] + + ApiPlatform\Tests\Fixtures\TestBundle\State\ODMLinkHandledDummyLinksHandler: + tags: + - name: 'api_platform.doctrine.odm.links_handler' diff --git a/tests/Functional/Parameters/BooleanFilterTest.php b/tests/Functional/Parameters/BooleanFilterTest.php index 6a2a9e553a2..a856ea33790 100644 --- a/tests/Functional/Parameters/BooleanFilterTest.php +++ b/tests/Functional/Parameters/BooleanFilterTest.php @@ -92,9 +92,9 @@ public function testBooleanFilterWithNullAndEmptyValues(string $url): void public static function booleanFilterNullAndEmptyScenariosProvider(): \Generator { yield 'active_null_value' => ['/filtered_boolean_parameters?active=null']; - yield 'active_empty_value' => ['/filtered_boolean_parameters?active=', 3]; + yield 'active_empty_value' => ['/filtered_boolean_parameters?active=']; yield 'enabled_alias_null_value' => ['/filtered_boolean_parameters?enabled=null']; - yield 'enabled_alias_empty_value' => ['/filtered_boolean_parameters?enabled=', 3]; + yield 'enabled_alias_empty_value' => ['/filtered_boolean_parameters?enabled=']; } /** diff --git a/tests/Functional/Parameters/QueryParameterStateOptionsTest.php b/tests/Functional/Parameters/QueryParameterStateOptionsTest.php index 408b5310622..6c2cdd384e5 100644 --- a/tests/Functional/Parameters/QueryParameterStateOptionsTest.php +++ b/tests/Functional/Parameters/QueryParameterStateOptionsTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\AgentApi; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResourceOdm\AgentDocumentApi; use ApiPlatform\Tests\Fixtures\TestBundle\Document\AgentDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Agent; use ApiPlatform\Tests\SetupClassResourcesTrait; @@ -33,7 +34,13 @@ final class QueryParameterStateOptionsTest extends ApiTestCase */ public static function getResources(): array { - return [AgentApi::class]; + $resources = [AgentApi::class]; + + if (class_exists(DocumentManager::class)) { + $resources[] = AgentDocumentApi::class; + } + + return $resources; } public function testQueryParameterStateOptions(): void