diff --git a/admin-api/contribute-to-core-api.md b/admin-api/contribute-to-core-api.md new file mode 100644 index 0000000000..21b3dc4448 --- /dev/null +++ b/admin-api/contribute-to-core-api.md @@ -0,0 +1,1154 @@ +--- +title: Create new API endpoints using CQRS +menuTitle: Contribute to Core API +showOnHomepage: true +weight: 200 +--- + +# Create new API endpoints using CQRS + +This guide explains how to create new endpoints for an entity. In this case we chose `AttributeGroup` as an example, and you can check this [pull request as an example](https://github.com/PrestaShop/ps_apiresources/pull/55) in PrestaShop's `ps_apiresources` module. + +{{% notice note %}} +For the new Admin API, although the whole architecture is inside the core of PrestaShop the definition of all the endpoints are in the [ps_apiresources](https://github.com/PrestaShop/ps_apiresources) module, externalizing the endpoints in a module allows us to make them evolve outside the core release cycle, so it can be updated and improved more frequently. +{{% /notice %}} + +{{% notice warning %}} +This documentation is based on the most recent modifications and bugfixes done for PrestaShop `9.0.1`, so to contribute new core endpoints please make sure you use at least this version, or the `9.0.x` branch which should be even more up-to-date. +{{% /notice %}} + +## ๐Ÿ“‹ Prerequisites + +- Basic knowledge of PHP and OOP +- PrestaShop development environment configured +- Git installed and configured +- PHPUnit knowledge for testing +- Understanding of [CQRS (Command Query Responsibility Segregation) pattern]({{< relref "/9/development/architecture/domain/cqrs">}}) +- Understanding of the [Grid component]({{< relref "/9/development/components/grid">}}) +- Understanding of [OAuth2]({{< relref "/9/admin-api/oauth">}}) +- Understanding of how our [CQRS architecture integrates with API Platform]({{< relref "/9/admin-api/resource_server/api-platform">}}) +- Understanding of [API Resources and our customer operations]({{< relref "/9/admin-api/resource_server/api-resources">}}) +- Understanding the convention defined in our [CQRS API guidelines](https://github.com/PrestaShop/ADR/blob/master/0023-cqrs-api-guidelines.md) + +## ๐ŸŽฏ Objective + +Create REST API endpoints to manage attribute groups (AttributeGroup) with complete CRUD operations and comprehensive PHPUnit integration test coverage. + +## ๐Ÿ—๏ธ Project Structure + +``` +ps_apiresources/ +โ”œโ”€โ”€ src/ +โ”‚ โ””โ”€โ”€ ApiPlatform/ +โ”‚ โ””โ”€โ”€ Resources/ +โ”‚ โ””โ”€โ”€ Attribute/ # The namespace contains the larger domain Attribute (that combines AttributeGroup and AttributeValue) +โ”‚ โ”œโ”€โ”€ AttributeGroup.php # Resource for single operations +โ”‚ โ””โ”€โ”€ AttributeGroupList.php # Resource for listing +โ”œโ”€โ”€ tests/ +โ”‚ โ””โ”€โ”€ Integration/ +โ”‚ โ””โ”€โ”€ ApiPlatform/ +โ”‚ โ””โ”€โ”€ Resources/ +โ”‚ โ””โ”€โ”€ AttributeGroupEndpointTest.php +``` + +## ๐Ÿ“ Implementation Steps + +The new admin API is based on APIPlatform, we use some API Resources which are classes used to define our endpoint configuration: +- the field names used in API resources will define the format of our API ant its json field names +- a READ operation on a single entity is linked to a CQRS query +- a WRITE operation on a single entity (or multiple) is linked to a CQRS command +- a LIST operation is linked to a [Grid data factory]({{< relref "/9/development/components/grid/#grid-data">}}) service + +### 1. Create and fetch a single resource + +#### Create the API Resource object that defines our expected format + +Create the file `src/ApiPlatform/Resources/AttributeGroup/AttributeGroup.php`, here is the simple DTO with the naming we are expecting: + +```php + '\d+'], + CQRSQuery: GetAttributeGroupForEditing::class, + scopes: ['attribute_group_read'] + ), + // POST /attributes/group + new CQRSCreate( + uriTemplate: '/attributes/group', + CQRSCommand: AddAttributeGroupCommand::class, + # Define a CQRSQuery to use after the command has been executed to return a response with the updated data + CQRSQuery: GetAttributeGroupForEditing::class, + scopes: ['attribute_group_write'] + ), + ], +)] +class AttributeGroup +{ + #[ApiProperty(identifier: true)] + public int $attributeGroupId; + + public array $names; + + public array $publicNames; + + public bool $isColorGroup; + + public string $groupType; + + public int $position; +} +``` + +#### Define custom mapping + +Now we face a problem, the name of the fields in our API resource are not identical with the CQRS objects they are mapped to: +- the command `AddAttributeGroupCommand` use `localizedNames` and `localizedPublicNames` instead of `names` and `publicNames` respectively +- the query result `EditableAttributeGroup` (returned by our query) uses `name` and `publicName` + +So we have to explain to our core architecture how to map these data if we want to keep the target naming on our API resource. This is done using mapping (you can read more about [custom mapping]({{< relref "/9/admin-api/resource_server/api-resources#custom-mapping">}})), here is the API resource adapted with the proper mapping, we use class protected const to reuse the mapping in several operations more easily: + +```php +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/attributes/group/{attributeGroupId}', + CQRSQuery: GetAttributeGroupForEditing::class, + scopes: [ + 'attribute_group_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/attributes/group', + CQRSCommand: AddAttributeGroupCommand::class, + CQRSQuery: GetAttributeGroupForEditing::class, + scopes: [ + 'attribute_group_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + ], +)] +class AttributeGroup +{ + #[ApiProperty(identifier: true)] + public int $attributeGroupId; + + public array $names; + + public array $publicNames; + + public string $type; + + public array $shopIds; + + public int $position; + + public const QUERY_MAPPING = [ + '[name]' => '[names]', + '[publicName]' => '[publicNames]', + '[associatedShopIds]' => '[shopIds]', + ]; + + public const COMMAND_MAPPING = [ + '[names]' => '[localizedNames]', + '[publicNames]' => '[localizedPublicNames]', + '[shopIds]' => '[associatedShopIds]', + ]; +} +``` + +#### Handle localized values + +One last thing to handle for our API to be easy to use, at this point you'll notice that the localize values are indexed by `Language ID` using the value in the DB. These Ids can change on each shop depending on when they were installed, which other languages are present and so on. + +```json +{ + "names": { + "1": "english value", + "3": "french value" + } +} +``` + +You can find which ID is associated to which language by using the `/admin-api/languages` API, but it's not very convenient as you will still need to handle the mapping yourself when posting/fetching some date. +It is much more convenient if the localized values are index by locale value like this: + +```json +{ + "names": { + "en-US": "english value", + "fr-FR": "french value" + } +} +``` + +That's why we introduced a customer PHP attribute `PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue` that you can simply add on the field that must be handled specifically, and internally the core will handle the automatic convertion of `locale-to-id` and `id-to-locale` for both read and write operations: + +```php +... +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue +... + +#[ApiResource( + ... +)] +class AttributeGroup +{ + ... + + #[LocalizedValue] + public array $names; + + #[LocalizedValue] + public array $publicNames; + + ... +} +``` + +You nw have two endpoints that allow you to create and fetch an AttributeGroup, and the format looks like this: + +```json +{ + "attributeGroupId": 1, + "names": { + "en-US": "Size", + "fr-FR": "Taille" + }, + "publicNames": { + "en-US": "Size", + "fr-FR": "Taille" + }, + "type": "select", + "shopIds": [ + 1 + ] +} +``` + +### 2. Update and delete a single resource + +For update and delete endpoints the principle is similar, we are creating a `DELETE` and `PATCH` endpoint: +- to update the AttributeGroup, we will use the `EditAttributeGroupCommand` mapped with a `CQRSPartialUpdate` operation, it requires a scope `attribute_group_write` to be used + - the `CQRSPartialUpdate` is used for `PATCH` requests that can update the entity partially, in opposition with a `PUT` request that updates the whole entity so the full JSON must be provided at each time + - if you want to create a `PUT` request use the `CQRSUpdate` instead + - to know which HTTP method you should use you will need to check the implementation of the CQRS commands to see if it allows optional values +- to delete the AttributeGroup, we will use the `DeleteAttributeGroupCommand` mapped with a `CQRSDelete` operation, it requires a scope `attribute_group_write` to be used + +```php +... +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +... + +#[ApiResource( + operations: [ + ... + new CQRSPartialUpdate( + uriTemplate: '/attributes/group/{attributeGroupId}', + CQRSCommand: EditAttributeGroupCommand::class, + CQRSQuery: GetAttributeGroupForEditing::class, + scopes: [ + 'attribute_group_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/attributes/group/{attributeGroupId}', + CQRSCommand: DeleteAttributeGroupCommand::class, + scopes: [ + 'attribute_group_write', + ], + ), + ], +)] +class AttributeGroup +{ + ... +} +``` + +### 3. Bulk deletion + +For bulk action we create a new dedicated resource with only one array field `$attributeGroupIds` + +```php + Response::HTTP_NOT_FOUND, + ], +)] +class BulkAttributeGroups +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $attributeGroupIds; +} + +``` + +### 4. Errors and validation + +#### Exception returned + +The CQRS layer include some internal check and validation that ensures the consistency of the domain, when an error or a constraint is detected it throws an exception. But such exception is displayed automatically by API Platform and returned as a server error with a 500 HTTP code, whereas depending on the exception there is no problem the API does exactly what it's supposed to but the HTTP code is not adapted. + +To adapt such cases API Platform allows defining a mapping between an exception and an HTTP code, CQRS handlers usually follow a naming convention so you should be able to find the proper exception easily, for example: +- `{Domain}NotFoundException` is triggered when we try to access or modify an entity that doesn't exist in the database, it should match a 404 Not Found code +- `{Domain}ConstraintException` is triggered when the data used for creation or udpate is not valid with the domain rules and constraints, it should match with a 422 Unprocessable Entity code + +The mapping can be defined on the API resource with the `exceptionToStatus`: + +```php + +... +use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Exception\AttributeGroupConstraintException; +use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Exception\AttributeGroupNotFoundException; +use Symfony\Component\HttpFoundation\Response; +... + +#[ApiResource( + operations: [ + ... + ], + exceptionToStatus: [ + AttributeGroupConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + AttributeGroupNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class AttributeGroup +{ + ... +} +``` + +#### API Resource validation + +The exception mapping is convenient to get proper HTTP code, with it's not ideal regarding the data validation. Luckily API Platform includes data validation in their internal process (based on the Symfony validator), to do that you can use `Constraints` attributes on each field (like you may have done on Doctrine entities). +You can also use validation groups, which is very convenient when your constraint are different on creation and on update for example (especially when you handle partial update). You can find more about validation: +- on the [Symfony documentation](https://symfony.com/doc/6.4/validation.html) +- on the [API Platform documentation](https://api-platform.com/docs/symfony/validation/) + +And here is an example with our `AttributeGroup` example that now includes validation: + +```php +... +use Symfony\Component\Validator\Constraints as Assert; +... + +#[ApiResource( + operations: [ + ... + new CQRSCreate( + uriTemplate: '/attributes/group', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddAttributeGroupCommand::class, + CQRSQuery: GetAttributeGroupForEditing::class, + scopes: [ + 'attribute_group_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/attributes/group/{attributeGroupId}', + validationContext: ['groups' => ['Default', 'Update']], + CQRSCommand: EditAttributeGroupCommand::class, + CQRSQuery: GetAttributeGroupForEditing::class, + scopes: [ + 'attribute_group_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + ... + ], +)] +class AttributeGroup +{ + #[ApiProperty(identifier: true)] + public int $attributeGroupId; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'names')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'names', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::TYPE_CATALOG_NAME, + ]), + ])] + public array $names; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'publicNames')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'publicNames', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::TYPE_CATALOG_NAME, + ]), + ])] + public array $publicNames; + + #[Assert\Choice(choices: [AttributeGroupType::ATTRIBUTE_GROUP_TYPE_COLOR, AttributeGroupType::ATTRIBUTE_GROUP_TYPE_SELECT, AttributeGroupType::ATTRIBUTE_GROUP_TYPE_RADIO])] + public string $type; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank(allowNull: true)] + public array $shopIds; + + public int $position; +} +``` + +{{% notice note %}} +When you need to know which constraint apply on your entity you can search for its associated form type which usually already contain some for form inline errors, and you can adapt them on your API resource. In our `AttributeGroup` example this [form type](https://github.com/PrestaShop/PrestaShop/blob/2b035743b75e04a8ca1ea7a9c7212a93f5ed6892/src/PrestaShopBundle/Form/Admin/Sell/Catalog/AttributeGroupType.php) was used for reference. +{{% /notice %}} + +### 5. Create the List API Resource (AttributeGroupList.php) + +For the listing API we use the Grid component that is used on Symfony migrated pages, so any migrated page should already have the appropriate Grid data factory. +To find the service name you need to look into the Symfony controller related to the entity you are targeting: +1. Find the `AttributeGroupController` and check which [Grid factory service it relies on](https://github.com/PrestaShop/PrestaShop/blob/c5102424bc44c8fabbdfa1477bbb275fd75d4efe/src/PrestaShopBundle/Controller/Admin/Sell/Catalog/AttributeGroupController.php#L66) +2. In our case it is `prestashop.core.grid.factory.attribute_group` now we need to search for its service definition +3. In this service definition you can find the associated [Grid Data factory service](https://github.com/PrestaShop/PrestaShop/blob/f3d3a87a16e8bb36df151727f2f135ae2fd8dfb1/src/PrestaShopBundle/Resources/config/services/core/grid/grid_factory.yml#L327) +4. In our case it is `prestashop.core.grid.data.factory.attribute_group_decorator` that we'll need to configure our endpoint +5. You can check that [this service](https://github.com/PrestaShop/PrestaShop/blob/f3d3a87a16e8bb36df151727f2f135ae2fd8dfb1/src/PrestaShopBundle/Resources/config/services/core/grid/grid_data_factory.yml#L416) is based on the [AttributeGroupGridDataFactory](https://github.com/PrestaShop/PrestaShop/blob/a5d084be8476afc856e9306db7a9b4e94836a252/src/Core/Grid/Data/Factory/AttributeGroupGridDataFactory.php#L36) that implements the `GridDataFactoryInterface` + +Now you can create the file `src/ApiPlatform/Resources/AttributeGroup/AttributeGroupList.php`, we usually use another API resource class for the listing because the returned data is usually smaller than on the single point: +- to list the AttributeGroups we use the `prestashop.core.grid.data.factory.attribute_group_decorator` service mapped with a `PaginatedList` operation + +```php + '[id_attribute_group]', + ], + ), + ] +)] +class AttributeGroupList +{ + #[ApiProperty(identifier: true)] + public int $attributeGroupId; + + public string $name; + + public int $values; + + public int $position; + + public const MAPPING = [ + '[id_attribute_group]' => '[attributeGroupId]', + ]; +} +``` + +Note that here we also use two different mappings: +- `ApiResourceMapping` to map the grid data (usually in snake case from the DB) into our API resource (usually in camel case) +- `filtersMapping` to map the filters and order parameter in the request, this allows us using `orderBy=attributeGroupId` (consistent with our API contract), instead of `orderBy=id_attribute_group` (DB format expected by the Grid data factory) + +This API returns a paginated list which base format is consistent with all other APIs (of course the item themselves vary): + +```json +{ + "totalItems": 4, + "sortOrder": "asc", + "limit": 50, + "filters": [], + "items": [ + { + "attributeGroupId": 1, + "name": "Size", + "values": 4, + "position": 0 + }, + { + "attributeGroupId": 2, + "name": "Color", + "values": 14, + "position": 1 + }, + { + "attributeGroupId": 3, + "name": "Dimension", + "values": 3, + "position": 2 + }, + { + "attributeGroupId": 4, + "name": "Paper Type", + "values": 4, + "position": 3 + } + ] +} +``` + +## ๐Ÿงช PHPUnit Testing Strategy + +### Test Configuration and Setup + +To run the tests locally you can clone the module repository, and you can run the tests from its root folder + +{{% notice note %}} +The following step sets-up an environment to run the tests, it needs a working database to install al the default fixtures, and of course persist the data that will be read/written by the API. +Keep in mind that the default fixtures are inserted in the database (like for our other integration tests), so you already have a few products, categories and so on inside it. +{{% /notice %}} + +#### Module only + +To run the test the module needs a PrestaShop core base to be executed into, we provide some tools to install a shop in a `/tmp` folder + +```bash +# Setup your tests in local, it will: +# - clone the repository +# - build the assets +# - install a shop with fixtures data (a working DB is needed) +composer setup-local-tests +``` + +If the DB setting is not adapted to your environment you can modify them in the `test/local-parameters/parameters.yml` file. + +By default, the branch clone is the `develop` branch, in case you want to use another one you can use additional parameters: + +```bash +# To test with 9.0.x branch +composer setup-local-tests -- --force --core-branch=9.0.x + +# To test with a branch from your fork (in this example fork: jolelievre branch: product-api) +composer setup-local-tests -- --force --core-branch=jolelievre:product-api +``` + +To run the full suite of tests you can use this command: +```bash +composer run-module-tests +``` + +When you need to run one test class specifically (convenient while developing) you can run this command: + +```bash +# Only run tests for AttributeGroupEndpointTest +php -d date.timezone=UTC ./vendor/bin/phpunit -c tests/Integration/phpunit-local.xml --filter=AttributeGroupEndpointTest +``` + +*Note* When you modify the API resource some part may be cached and are not updated, the you don't understand why your tests are failing, in those cases you can try and clear the cache: + +```bash +composer clear-test-cache +``` + +#### Run tests from the core + +You'll need a clone of the PrestaShop repository with a working dev environment (not described here). +By default, you already have the `ps_apiresources` module in your `modules` folder, but if you plan on contributing on the module you should remove the initial folder (installed by composer) and clone the [module repository](https://github.com/PrestaShop/ps_apiresources) in the `modules` folder. This way you can create branches, commits and push to your fork. + +We don't recommend using symbolic links as it will create some errors, the module folder must really be in the `modules` folder. + +Then you can use the composer command: + +```bash +# This command performs several tasks +# - prepare the test DB +# - prepare the autoloader of the module +# - runs the integration tests from the module +composer api-module-tests +``` + +When you need to run one test class specifically (convenient while developing) you can run this command: +```bash +# Only run tests for AttributeGroupEndpointTest +php -d date.timezone=UTC ./vendor/phpunit/phpunit/phpunit -c modules/ps_apiresources/tests/Integration/phpunit-ci.xml --filter=AttributeGroupEndpointTest +``` + +### Integration Tests + +Integration tests **must** test the actual API endpoints and their behavior with the PrestaShop system. To help build them we created a base class `PsApiResourcesTest\Integration\ApiPlatform\ApiTestCase` the provides some helper methods: + +| Method name | Action | Parameters | +|---------------------|---------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `getItem` | Performs a `GET` request, by default check that a 200 code is returned and parse JSON response | `string $endpointUrl`: URL of the API endpoint
`array $scopes = []`: List of scopes to use in the token
`?int $expectedHttpCode = null` HTTP code expected after request, default value is deducted automatically
`?array $requestOptions = null` Additional options for the request (special headers, extra parameters) | +| `createItem` | Performs a `POST` request, by default checks that a 201 code is returned and parse JSON response | `string $endpointUrl`: URL of the API endpoint
`array $data` Data of the created entity
`array $scopes = []`: List of scopes to use in the token
`?int $expectedHttpCode = null` HTTP code expected after request, default value is deducted automatically
`?array $requestOptions = null` Additional options for the request (special headers, extra parameters) | +| `updateItem` | Performs a `PUT` request, by default checks that a 200 code is returned and parse JSON response | `string $endpointUrl`: URL of the API endpoint
`array $data` Full data of the updated entity
`array $scopes = []`: List of scopes to use in the token
`?int $expectedHttpCode = null` HTTP code expected after request, default value is deducted automatically
`?array $requestOptions = null` Additional options for the request (special headers, extra parameters) | +| `partialUpdateItem` | Performs a `PATCH` request, by default check that a 200 code is returned and parse JSON response | `string $endpointUrl`: URL of the API endpoint
`array $data` Partial data of the updated entity
`array $scopes = []`: List of scopes to use in the token
`?int $expectedHttpCode = null` HTTP code expected after request, default value is deducted automatically
`?array $requestOptions = null` Additional options for the request (special headers, extra parameters) | +| `deleteItem` | Performs a `DELETE` request, by default check that a 204 code is returned and response is empty | `string $endpointUrl`: URL of the API endpoint
`array $scopes = []`: List of scopes to use in the token
`?int $expectedHttpCode = null` HTTP code expected after request, default value is deducted automatically
`?array $requestOptions = null` Additional options for the request (special headers, extra parameters) | +| `listItems` | Performs a `GET` request to list entities, parse the JSON response and check the paginated format | `string $listUrl`: URL of the API endpoint
`array $scopes = []`: List of scopes to use in the token
`array $filters = []` List of filters | +| `countItems` | Performs a `GET` request to list entities, but only returns the count | `string $listUrl`: URL of the API endpoint
`array $scopes = []`: List of scopes to use in the token
`array $filters = []` List of filters | + +These helper methods make testing easier because they handle internally the creation of an `APIClient` with the required scopes, then they request an access token with the scopes and automatically include it in the header of the request, they also perform basic check and decode the JSON response. + +You will also have to implement the abstract `getProtectedEndpoints` method (see below), it returns the list of endpoints protected via scopes (with the associated HTTP method), the class will automatically loop through them try to access them with a bearer token but without the required scopes, and it excepts to have a 401 response. +It ensures that you didn't forget to setup the appropriate scopes, adn that they will not be removed by mistake in the future. + +### CRUD Integration Test + +Create `tests/Integration/ApiPlatform/Resources/AttributeGroupEndpointTest.php`: + +```php + [ + 'GET', + '/attributes/group/1', + ]; + + yield 'create endpoint' => [ + 'POST', + '/attributes/group', + ]; + + yield 'patch endpoint' => [ + 'PATCH', + '/attributes/group/1', + ]; + + yield 'delete endpoint' => [ + 'DELETE', + '/attributes/group/1', + ]; + + yield 'list endpoint' => [ + 'GET', + '/attributes/groups', + ]; + + yield 'bulk delete endpoint' => [ + 'PUT', + '/attributes/groups/delete', + ]; + } + + public function testAddAttributeGroup(): int + { + $itemsCount = $this->countItems('/attributes/groups', ['attribute_group_read']); + + $postData = [ + 'names' => [ + 'en-US' => 'name en', + 'fr-FR' => 'name fr', + ], + 'publicNames' => [ + 'en-US' => 'public name en', + 'fr-FR' => 'public name fr', + ], + 'type' => 'select', + 'shopIds' => [1], + ]; + + // Create an attribute group, the POST endpoint returns the created item as JSON + $attributeGroup = $this->createItem('/attributes/group', $postData, ['attribute_group_write']); + $this->assertArrayHasKey('attributeGroupId', $attributeGroup); + $attributeGroupId = $attributeGroup['attributeGroupId']; + + // We assert the returned data matches what was posted (plus the ID) + $this->assertEquals( + ['attributeGroupId' => $attributeGroupId] + $postData, + $attributeGroup + ); + + $newItemsCount = $this->countItems('/attributes/groups', ['attribute_group_read']); + $this->assertEquals($itemsCount + 1, $newItemsCount); + + return $attributeGroupId; + } + + /** + * @depends testAddAttributeGroup + * + * @param int $attributeGroupId + * + * @return int + */ + public function testGetAttributeGroup(int $attributeGroupId): int + { + $attributeGroup = $this->getItem('/attributes/group/' . $attributeGroupId, ['attribute_group_read']); + $this->assertEquals([ + 'attributeGroupId' => $attributeGroupId, + 'names' => [ + 'en-US' => 'name en', + 'fr-FR' => 'name fr', + ], + 'publicNames' => [ + 'en-US' => 'public name en', + 'fr-FR' => 'public name fr', + ], + 'type' => 'select', + 'shopIds' => [1], + ], $attributeGroup); + + return $attributeGroupId; + } + + /** + * @depends testGetAttributeGroup + * + * @param int $attributeGroupId + * + * @return int + */ + public function testPartialUpdateAttributeGroup(int $attributeGroupId): int + { + $patchData = [ + 'names' => [ + 'en-US' => 'updated name en', + 'fr-FR' => 'updated name fr', + ], + 'publicNames' => [ + 'en-US' => 'updated public name en', + 'fr-FR' => 'updated public name fr', + ], + 'type' => 'radio', + 'shopIds' => [1], + ]; + + $updatedAttributeGroup = $this->partialUpdateItem('/attributes/group/' . $attributeGroupId, $patchData, ['attribute_group_write']); + $this->assertEquals(['attributeGroupId' => $attributeGroupId] + $patchData, $updatedAttributeGroup); + + // We check that when we GET the item it is updated as expected + $attributeGroup = $this->getItem('/attributes/group/' . $attributeGroupId, ['attribute_group_read']); + $this->assertEquals(['attributeGroupId' => $attributeGroupId] + $patchData, $attributeGroup); + + // Test partial update + $partialUpdateData = [ + 'names' => [ + 'fr-FR' => 'updated nom fr', + ], + 'publicNames' => [ + 'en-US' => 'updated public nom en', + ], + ]; + $expectedUpdatedData = [ + 'attributeGroupId' => $attributeGroupId, + 'names' => [ + 'en-US' => 'updated name en', + 'fr-FR' => 'updated nom fr', + ], + 'publicNames' => [ + 'en-US' => 'updated public nom en', + 'fr-FR' => 'updated public name fr', + ], + 'type' => 'radio', + 'shopIds' => [1], + ]; + $updatedAttributeGroup = $this->partialUpdateItem('/attributes/group/' . $attributeGroupId, $partialUpdateData, ['attribute_group_write']); + $this->assertEquals($expectedUpdatedData, $updatedAttributeGroup); + + return $attributeGroupId; + } + + /** + * @depends testPartialUpdateAttributeGroup + * + * @param int $attributeGroupId + * + * @return int + */ + public function testListAttributeGroups(int $attributeGroupId): int + { + // List by attributeGroupId in descending order so the created one comes first (and test ordering at the same time) + $paginatedAttributeGroups = $this->listItems('/attributes/groups?orderBy=attributeGroupId&sortOrder=desc', ['attribute_group_read']); + $this->assertGreaterThanOrEqual(1, $paginatedAttributeGroups['totalItems']); + + // Check the details to make sure filters mapping is correct + $this->assertEquals('attributeGroupId', $paginatedAttributeGroups['orderBy']); + + // Test attribute should be the first returned in the list + $testAttributeGroup = $paginatedAttributeGroups['items'][0]; + + // Position should be at least 3 since there are three groups in the default fixtures data + $this->assertGreaterThanOrEqual(3, $testAttributeGroup['position']); + $position = $testAttributeGroup['position']; + $expectedAttributeGroup = [ + 'attributeGroupId' => $attributeGroupId, + 'name' => 'updated name en', + 'values' => 0, + 'position' => $position, + ]; + $this->assertEquals($expectedAttributeGroup, $testAttributeGroup); + + $filteredAttributeGroups = $this->listItems('/attributes/groups', ['attribute_group_read'], [ + 'attributeGroupId' => $attributeGroupId, + ]); + $this->assertEquals(1, $filteredAttributeGroups['totalItems']); + + $testAttributeGroup = $filteredAttributeGroups['items'][0]; + $this->assertEquals($expectedAttributeGroup, $testAttributeGroup); + + // Check the filters details + $this->assertEquals([ + 'attributeGroupId' => $attributeGroupId, + ], $filteredAttributeGroups['filters']); + + return $attributeGroupId; + } + + /** + * @depends testListAttributeGroups + * + * @param int $attributeGroupId + */ + public function testRemoveAttributeGroup(int $attributeGroupId): void + { + // Delete the item + $return = $this->deleteItem('/attributes/group/' . $attributeGroupId, ['attribute_group_write']); + // This endpoint return empty response and 204 HTTP code + $this->assertNull($return); + + // Getting the item should result in a 404 now + $this->getItem('/attributes/group/' . $attributeGroupId, ['attribute_group_read'], Response::HTTP_NOT_FOUND); + } + + public function testBulkRemoveAttributeGroups(): void + { + $attributeGroups = $this->listItems('/attributes/groups', ['attribute_group_read']); + + // There are four attribute groups in default fixtures + $this->assertEquals(4, $attributeGroups['totalItems']); + + // We remove the first two attribute groups + $removeAttributeGroupIds = [ + $attributeGroups['items'][0]['attributeGroupId'], + $attributeGroups['items'][2]['attributeGroupId'], + ]; + + $this->updateItem('/attributes/groups/delete', [ + 'attributeGroupIds' => $removeAttributeGroupIds, + ], ['attribute_group_write'], Response::HTTP_NO_CONTENT); + + // Assert the provided attribute groups have been removed + foreach ($removeAttributeGroupIds as $attributeGroupId) { + $this->getItem('/attributes/group/' . $attributeGroupId, ['attribute_group_read'], Response::HTTP_NOT_FOUND); + } + + // Only two attribute group remain + $this->assertEquals(2, $this->countItems('/attributes/groups', ['attribute_group_read'])); + } + + public function testInvalidAttributeGroup(): void + { + $attributeGroupInvalidData = [ + 'names' => [ + // en-US (default language) value is missing + // < character is forbidden + 'fr-FR' => 'name fr<', + ], + 'publicNames' => [ + // en-US (default language) value is missing + // < character is forbidden + 'fr-FR' => 'public name fr<', + ], + // Type is not in the expected choices + 'type' => 'random', + // ShopId must not be empty + 'shopIds' => [], + ]; + + // Creating with invalid data should return a response with invalid constraint messages and use an http code 422 + $validationErrorsResponse = $this->createItem('/attributes/group', $attributeGroupInvalidData, ['attribute_group_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + $this->assertIsArray($validationErrorsResponse); + $this->assertValidationErrors([ + [ + 'propertyPath' => 'names', + 'message' => 'The field names is required at least in your default language.', + ], + [ + 'propertyPath' => 'names[fr-FR]', + 'message' => '"name fr<" is invalid', + ], + [ + 'propertyPath' => 'publicNames', + 'message' => 'The field publicNames is required at least in your default language.', + ], + [ + 'propertyPath' => 'publicNames[fr-FR]', + 'message' => '"public name fr<" is invalid', + ], + [ + 'propertyPath' => 'type', + 'message' => 'The value you selected is not a valid choice.', + ], + [ + 'propertyPath' => 'shopIds', + 'message' => 'This value should not be blank.', + ], + ], $validationErrorsResponse); + + // Now create a valid attribute group to test the validation on PATCH request + $validAttributeGroup = $this->createItem('/attributes/group', [ + 'names' => [ + 'en-US' => 'name en', + 'fr-FR' => 'name fr', + ], + 'publicNames' => [ + 'en-US' => 'name en', + 'fr-FR' => 'name fr', + ], + 'type' => 'select', + 'shopIds' => [1], + ], ['attribute_group_write']); + + $attributeGroupId = $validAttributeGroup['attributeGroupId']; + $invalidUpdateData = [ + // Only the provided data is validated (we only get one invalid error) + [ + 'data' => [ + 'names' => [ + 'en-US' => 'name en<', + ], + ], + 'expectedErrors' => [ + [ + 'propertyPath' => 'names[en-US]', + 'message' => '"name en<" is invalid', + ], + ], + ], + // We can partially update only one language, the DefaultLanguage constraint doesn't block because en-US is not specified + [ + 'data' => [ + 'names' => [ + 'fr-FR' => 'name fr<', + ], + ], + 'expectedErrors' => [ + [ + 'propertyPath' => 'names[fr-FR]', + 'message' => '"name fr<" is invalid', + ], + ], + ], + // However trying to force empty value is forbidden + [ + 'data' => [ + 'names' => [ + 'en-US' => '', + ], + ], + 'expectedErrors' => [ + [ + 'propertyPath' => 'names', + 'message' => 'The field names is required at least in your default language.', + ], + ], + ], + // SAme for publicNames + [ + 'data' => [ + 'publicNames' => [ + 'en-US' => '', + ], + ], + 'expectedErrors' => [ + [ + 'propertyPath' => 'publicNames', + 'message' => 'The field publicNames is required at least in your default language.', + ], + ], + ], + [ + 'data' => [ + 'shopIds' => [ + ], + ], + 'expectedErrors' => [ + [ + 'propertyPath' => 'shopIds', + 'message' => 'This value should not be blank.', + ], + ], + ], + [ + 'data' => [ + 'type' => 'toto', + ], + 'expectedErrors' => [ + [ + 'propertyPath' => 'type', + 'message' => 'The value you selected is not a valid choice.', + ], + ], + ], + ]; + foreach ($invalidUpdateData as $updateData) { + $validationErrorsResponse = $this->partialUpdateItem('/attributes/group/' . $attributeGroupId, $updateData['data'], ['attribute_group_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + $this->assertValidationErrors($updateData['expectedErrors'], $validationErrorsResponse); + } + } +} +``` + +## ๐Ÿ” Key Points to Remember + +### Naming Conventions +- **CamelCase** for API Resource properties +- **snake_case** for database mapping +- **PascalCase** for class names + +### Data Mapping +- `ApiResourceMapping`: transforms DB data to API format +- `filtersMapping`: transforms API filters to grid format +- `CQRSQueryMapping`: for CQRS queries +- `CQRSCommandMapping`: for CQRS commands + +### Security Scopes +- `_read`: for read operations (GET) +- `_write`: for write operations (POST, PUT, PATCH, DELETE) + +### URI Templates +- Use consistent and descriptive names +- Follow REST conventions: + - `GET /resource/{id}`: retrieve single item + - `GET /resources`: list items + - `POST /resource`: create item + - `PUT/PATCH /resource/{id}`: update item + - `DELETE /resource/{id}`: delete item + +## ๐Ÿ“š Useful Resources + +- [PrestaShop API Resources Documentation]({{< relref "/9/admin-api/resource_server/api-resources">}}) +- [API Platform Documentation](https://api-platform.com/docs/) +- [PHPUnit Documentation](https://phpunit.de/documentation.html) +- [CQRS Pattern](https://martinfowler.com/bliki/CQRS.html) +- [PrestaShop Contributing Guidelines]({{< relref "/9/contribute">}}) +- [Symfony Testing Best Practices](https://symfony.com/doc/current/testing.html) + +## ๐ŸŽ‰ Conclusion + +Following this guide will help you create a comprehensive PR for adding API endpoints to PrestaShop with proper test coverage. Remember to: + +1. Follow PrestaShop coding standards +2. Write comprehensive tests (integration) +3. Achieve good test coverage +4. Document your changes properly +5. Follow the team's review process + +Good luck with your contribution! ๐Ÿš€ diff --git a/admin-api/resource_server/api-resources.md b/admin-api/resource_server/api-resources.md index c8d64b3203..94289fe74d 100644 --- a/admin-api/resource_server/api-resources.md +++ b/admin-api/resource_server/api-resources.md @@ -272,7 +272,7 @@ use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; uriTemplate: '/api-client/{apiClientId}', requirements: ['apiClientId' => '\d+'], output: false, - CQRSQuery: DeleteApiClientCommand::class, + CQRSCommand: DeleteApiClientCommand::class, scopes: ['api_client_write'] ), ],