Add the missing Product domain endpoints - #410
jolelievre wants to merge 26 commits into
Conversation
b45db49 to
220bcb8
Compare
mattgoud
left a comment
There was a problem hiding this comment.
Reviewed the whole PR resource-by-resource against core develop 👍 Overall this is solid: the consolidation is clean, read/write symmetry is consistent, the custom SetProductImagesForAllShopSerializer correctly replaces the abandoned core #42049, the Rector singular-URI exceptions are sound, the version gating (minVersion: 9.2.0) proves both sides, and the earlier per-endpoint defects (#337 dead 404 mapping, #374 disabledReason/imageUrl) are all fixed. Tests build fixtures through the Admin API with full-JSON assertEquals — nice.
One recurring thing worth addressing before merge, plus a few isolated nits.
Input validation: 500 instead of 4xx on malformed bodies (4 resources)
On several write/search endpoints an invalid body surfaces as a 500 because the domain exception isn't in exceptionToStatus, and there's no testInvalid* covering it (the module convention asks for one):
- ShopProductImages (PUT) — a
shopImagesitem withoutshopIds→array_map('intval', null)TypeError→ 500;imageId <= 0(or missing →0) →ProductImageConstraintException(unmapped) → 500. The write path denormalizes straight into the command viaSetProductImagesForAllShopSerializer, so the resource's#[Assert\NotBlank]on$shopImagesnever runs (dead constraint). Suggest guarding the body in the serializer (or mapping the constraint exception to 422/400). - ProductSuppliers (PUT) — empty
supplierIds→Core\Exception\InvalidArgumentException(unmapped) → 500. - FreeGiftCandidate (GET) —
phrase< 3 chars orlimit <= 0→ProductConstraintException(unmapped) → 500. TheopenapiContextminLength/minimumare doc-only, not enforced. - ProductAttributeGroupList (GET) —
productId <= 0→ProductConstraintException(unmapped); also this operation is missing therequirements: ['productId' => '\d+']its sibling product resources declare.
Adding the exception mappings (+ the \d+ requirement) and a testInvalid* per resource would close this consistently.
Isolated nits (non-blocking)
Resources/Product/Product.php— thevirtualProductFiledoc comment says the plural/products/{productId}/virtual-files, but the actual URI is singularvirtual-file.ProductSuppliersEndpointTest—testSetDefaultSupplierasserts onlydefaultSupplierIdinstead of the full JSON like the other ops; and the supplier fixture uses a raw\Db::getInstance()->getValue(...)to look up the FR country id, slightly at odds with the "no SQL seeding" approach.SetProductImagesForAllShopSerializer::getSupportedTypes()returns'object' => null, '*' => null(non-cacheable for every type) — narrowing it to[SetProductImagesForAllShopCommand::class => true]is cleaner.supportsDenormalizationitself is correctly narrow.ProductStockMovementsdeclaresoffset/limittwice (QueryParameters +openapiContext.parameters) — harmless duplication.
None of these are correctness bugs for well-formed traffic — the 4xx-vs-500 hardening is the one I'd treat as a should-fix.
📋 Summary of changesThis PR consolidates 10+ pending PRs into a single Product-domain endpoint batch, adding 8 new resource classes and 7 new integration test files (~2 500 lines). It exposes suppliers (GET/PUT/PATCH/DELETE ⏱️ Estimated review time35–50 minutes — large surface (8 resource classes, 7 test classes, 2 custom normalizers, PHPStan config, Rector allowlist). Several architectural decisions that need explicit sign-off. 🎯 Scope
🧱 API Platform / CQRS architecture compliance1. Custom denormalizer
|
| Operation | Command | DTO field assumed to match command param |
|---|---|---|
PUT /products/{productId}/suppliers |
SetSuppliersCommand |
supplierIds |
PUT /products/{productId}/default-supplier |
SetProductDefaultSupplierCommand |
defaultSupplierId |
If the Core command constructors use different parameter names, values are silently dropped. The reviewer should verify command signatures in Core match the DTO property names.
8. Read/write asymmetry in ShopProductImages
GET response: [{shopId, images: [{imageId, cover}]}] (keyed by shop).
PUT payload: [{imageId, shopIds}] (keyed by image).
Intentional and documented, but callers cannot round-trip the GET response as a PUT payload. Worth calling out in documentation/changelog.
💡 Improvement suggestions
VirtualProductFileEndpointTest.testDeleteVirtualProductFile — fragile null check
$this->assertArrayNotHasKey('virtualProductFile', $product) silently passes if the serializer emits "virtualProductFile": null instead of omitting the key. Consider $this->assertNull($product['virtualProductFile'] ?? null) or document which serializer config guarantees key omission when null.
ProductSuppliersEndpointTest.testSetDefaultSupplier — partial response assertion
Only $updatedSuppliers['defaultSupplierId'] is checked. For consistency with the rest of the test class (full assertEquals over the whole response), consider asserting all fields.
FreeGiftCandidate.php — no exceptionToStatus
If SearchProductsForFreeGift can surface domain exceptions they should be mapped. If not, a brief comment clarifying that intent would help.
ProductStockMovementsEndpointTest — raw SQL for API client lookup
\Db::getInstance()->getValue('SELECT id_api_client FROM ...') couples the test to the DB schema. If ApiTestCase exposes a helper for this, that should be preferred.
✅ Pre-review checklist
URI & routing
- URI is plural, lowercase, kebab-case (
/suppliers,/shop-images,/stock-movements,/attribute-groups,/free-gift-candidates;stock,virtual-file,default-supplierare singular by documented design decision and correctly added to the Rector exceptions list) - Identifier uses domain name +
Idsuffix (productId,combinationId,virtualProductFileId) - Sub-resources follow parent path
- Bulk operation URI uses
bulk-prefix and pluralIdsparameter (N/A)
Operations & scopes
- Correct operation attribute per HTTP method
- Scope format:
product_read/product_write, singular form
API Resource properties
- All properties strictly typed, scalars/arrays only (no Value Objects)
- Naming conventions respected (
disablednotactive, noisprefix, nolocalizedprefix) -
#[ApiProperty(identifier: true)]on ID property — missing onProductStockMovements(no scalar identifier by design), missing onProductAttributeGroupList.$attributeGroupId(apparent oversight) -
#[LocalizedValue]on localized fields,#[DefaultLanguage]with correctfieldName(where applicable)
CQRS mapping
-
QUERY_MAPPINGdirection: QueryResult field → API field (verified for all new resources) -
CQRSCommandMappingdirection: API field → Command parameter -
CQRSQuerypresent onCQRSCreate/CQRSPartialUpdatewhen full object must be returned — missing onVirtualProductFileCQRSPartialUpdate - No
SerializedName— mappings only
Forbidden practices (CI-enforced)
- No custom normalizers or processors —
SetProductImagesForAllShopSerializeris a new custom denormalizer (forbidden by CONTEXT.md; justified by adder-based command pattern; added to PHPStan allowlist). Requires explicit human sign-off. - No Value Objects in properties
Exception handling & validation
-
ConstraintException→ 422,NotFoundException→ 404 (where applicable) - Correct
validationContextgroups on Create / Update operations — missing onVirtualProductFileCQRSCreate
Multi-shop
-
shopIdspresent when entity is shop-associated;ShopProductImagescorrectly handles multistore context - Shop context (
[_context][shopConstraint],[_context][shopId]) passed when needed
Listing field alignment (N/A — no PaginatedList or CQRSPaginate added in this PR)
- (not applicable)
Integration test
-
testInvalid*withassertValidationErrors— missing inCombinationStockEndpointTest,ProductStockEndpointTest,ProductSuppliersEndpointTest,ShopProductImagesEndpointTest($shopImageshas#[Assert\NotBlank]but no empty-payload 422 test) -
getProtectedEndpoints()lists all URIs (version-gated classes correctly yield unconditionally) -
DatabaseDump::restoreTables()covers all affected tables -
declare(strict_types=1)present in all new test files
|
@ibahloul-ps the review points are addressed in the last commits:
|
Quetzacoalt91
left a comment
There was a problem hiding this comment.
A first set of comments
| // The productId is required in the URI because the update command result is | ||
| // empty, so only URI variables can feed the GetProductForEditing query that | ||
| // builds the full-state response | ||
| uriTemplate: '/products/{productId}/virtual-file/{virtualProductFileId}', |
There was a problem hiding this comment.
Can you please update the PR description to match this URL ? You mistakenly wrote /products/virtual-file/{virtualProductFileId}
There was a problem hiding this comment.
Good catch, even if the productId is not useful here, at least the REST convention is respected
|
@jolelievre, because the relation between the product and virtual files is 1:1, what do you think about these URLs:
This almost follows what you wrote, expect virtual-files is in plural form to follow the URLs of combinations and images. |
|
Regarding the Actually, I force the singular form on purpose (even by adding an exception in the rector rule) because today a product can have only one virtual product, and I wanted to emphasize this But it also makes sense to favor respecting the plural form for convention (even if there's only one). I'll lean to the plural form for another reason though: if in the future we improve the virtual product and allow it to have multiple virtual files, at least the API URLs will natively be able to handle them For the other singular exceptions, I'm waiting for @ibahloul-ps feedback, and then I'll handle them all in one go |
|
@ibahloul-ps @Quetzacoalt91 latest commits:
Unchanged, on purpose:
Question: the DELETE is the only one without |
Quetzacoalt91
left a comment
There was a problem hiding this comment.
Question: the DELETE is the only one without productId, since the create needs it for the command and the update for the response. Add it back for symmetry even though it would be decorative there?
I'd prefer to see the product ID but that's entirely your call. :)
|
Blocked by PrestaShop/PrestaShop#42960 |







What this PR does
Merges the content of #256, #268, #269, #308, #337, #353, #354, #361, #374, #383 and #384 (all authored by @PrestaEdit, co-authored on the commits) into one PR, then reworks the endpoints and tests as a whole.
Endpoints
/products/{productId}/suppliers/products/{productId}/suppliers/products/{productId}/suppliers/products/{productId}/suppliers/products/{productId}/default-supplier/products/{productId}/shop-images/products/{productId}/shop-images/products/{productId}/stock/products/combinations/{combinationId}/stock/products/{productId}/stock-movements/products/{productId}/attribute-groups/products/{productId}/virtual-files/products/{productId}/virtual-files/{virtualProductFileId}/products/virtual-files/{virtualProductFileId}/products/free-gift-candidatesminVersion: 9.2.0)Design decisions
ProductSuppliers,ShopProductImages,VirtualProductFile, ...).CQRSQuery(the shop-images PUT returns the same{productId, shopImages: [{shopId, images: [{imageId, cover}]}]}resource as the GET).default-supplier,stock), with matching Rector keyword exceptions.GetProductIsEnabled,GetAssociatedSuppliers,SearchProductsForAssociation,SearchCombinationsForAssociationandSearchProductCombinationsare intentionally NOT exposed: they duplicate existing endpoints (seeGenerateApiTrackingTableCommand::EXCLUDED_CQRS_CLASSES). This supersedes Add GetProductIsEnabled Admin API endpoint #352 and Add Product associated-suppliers read endpoint #286.Productnow exposesvirtualProductFile(read side of the virtual file endpoints).ProductImageSettingvalue objects even with the bulk setter, so the command is built by a dedicated module denormalizer (SetProductImagesForAllShopSerializer), which works on every supported core version.Tests
Every endpoint has integration coverage where all fixtures are created through the Admin API itself (products, suppliers, attribute groups, attributes, combinations, images, stock) — no ObjectModel, no command-bus seeding, no raw SQL fixtures. Assertions compare the complete JSON structure of each entity with a single
assertEqualsso missing or extra fields fail the test.PATCH /products/{productId}/stockrequires theStockMvtemployee guard from PrestaShop/PrestaShop#41803: without it, any stock update through the Admin API fails with aTypeError(this is also why the original #256 CI was red). The two stock test classes stay red on CI until that core PR is merged into the target branches.The stock-movements endpoint also exposes
apiClientIds/apiClientNames: cores that include PrestaShop/PrestaShop#41803 record which API client created each movement (through the mutation table) and the endpoint returns it. On older cores the fields are empty arrays, and the test assertions are feature-detected accordingly.How to test
Run the module integration suite against a 9.2.x core that includes PrestaShop/PrestaShop#41803, e.g. locally:
Full suite is green locally (587 tests, 4799 assertions) with the #41803 guard applied.
This PR supersedes #256, #268, #269, #308, #337, #352 (excluded), #353, #354, #361, #374, #383, #384 and #286 (excluded).