Summary
An ApiResource that declares an identifier while exposing no item operation makes API Platform register an item route of its own, served by api_platform.action.not_exposed(). Calling it answers:
404 {"detail":"This route does not aim to be called.","class":"ApiPlatform\\Metadata\\Exception\\NotExposedHttpException"}
The URI is then reserved and cannot be implemented by anyone, which is exactly what blocked #427: the core Language resource squatted GET /languages/{langId}, and PrestaShop/PrestaShop#42852 removes it for that reason.
Measured on dev at dcf05ff, with the module installed on a 9.2.x shop, the router serves 40 such routes for this module.
The one that actually costs something
_api_/addresses/{addressId}{._format}_get GET /addresses/{addressId}.{_format}
_controller: api_platform.action.not_exposed()
Address declares a single operation, CQRSDelete on /addresses/{addressId}, together with #[ApiProperty(identifier: true)]. So today:
DELETE /addresses/{addressId} works
GET /addresses/{addressId} 404 "This route does not aim to be called."
GET /addresses works (collection)
A consumer can list addresses and delete one by id, but reading that same id answers a 404 that is not a "not found". And the URI anyone would naturally use to add that read is already taken, so implementing GET /addresses/{addressId} means hitting the same wall #427 hit.
The other 38
The rest generate a snake_case URI derived from the class name, which nobody would request:
/address_lists/{addressId} /api_client_lists/{apiClientId}
/attribute_group_lists/{...} /carrier_lists/{carrierId}
/category_lists/{categoryId} /discount_lists/{discountId}
/product_lists/{productId} /search_alias_lists/{search}
/tax_rule_lists/{taxRuleId} /title_lists/{titleId} ... and 28 more
Low harm individually: they squat nothing anyone wants. They are still worth removing, for two reasons. They answer a confusing 404 to anyone probing the API, and their shape violates the URI convention this repository enforces everywhere else, since CONTEXT.md requires plural kebab-case and these are singular-ish snake_case.
The fix, and the trap in it
Two forms, and they are not interchangeable. I checked both on a running shop.
When the property is not named id, removing the attribute is enough:
// ExampleList.php
- #[ApiProperty(identifier: true)]
public int $categoryId;
When the property is literally id, removing the attribute changes nothing, because API Platform treats a property called id as the identifier by convention. It takes an explicit:
#[ApiProperty(identifier: false)]
public int $id;
In both cases the collection route survives untouched and answers the identical payload. Verified on FoundShop (#422, the id case) and on CustomerPrivateNote and TransformGuestToCustomer (#428, the $customerId case).
Suggested scope
Address, which is the one with a real consequence. Either expose the item GET, or drop the identifier if reading one address by id is deliberately out of scope.
- A sweep over the other 38, mechanical once the rule above is clear.
- A line in
CONTEXT.md so the next resource does not reintroduce it. The check is one command:
bin/console --app-id=admin-api debug:router --format=json \
| python3 -c "import json,sys; d=json.load(sys.stdin); [print(v['path']) for v in d.values() if 'not_exposed' in str(v.get('defaults',{}).get('_controller',''))]"
Three open pull requests reintroduce the pattern right now: #422, #428, and this is worth a note on #423 too, which happens to be free of it. Fixing the rule is cheaper than catching it review by review, since neither CI nor the automated pre-review sees it.
Summary
An
ApiResourcethat declares an identifier while exposing no item operation makes API Platform register an item route of its own, served byapi_platform.action.not_exposed(). Calling it answers:The URI is then reserved and cannot be implemented by anyone, which is exactly what blocked #427: the core
Languageresource squattedGET /languages/{langId}, and PrestaShop/PrestaShop#42852 removes it for that reason.Measured on
devatdcf05ff, with the module installed on a 9.2.x shop, the router serves 40 such routes for this module.The one that actually costs something
Addressdeclares a single operation,CQRSDeleteon/addresses/{addressId}, together with#[ApiProperty(identifier: true)]. So today:A consumer can list addresses and delete one by id, but reading that same id answers a 404 that is not a "not found". And the URI anyone would naturally use to add that read is already taken, so implementing
GET /addresses/{addressId}means hitting the same wall #427 hit.The other 38
The rest generate a snake_case URI derived from the class name, which nobody would request:
Low harm individually: they squat nothing anyone wants. They are still worth removing, for two reasons. They answer a confusing 404 to anyone probing the API, and their shape violates the URI convention this repository enforces everywhere else, since
CONTEXT.mdrequires plural kebab-case and these are singular-ish snake_case.The fix, and the trap in it
Two forms, and they are not interchangeable. I checked both on a running shop.
When the property is not named
id, removing the attribute is enough:When the property is literally
id, removing the attribute changes nothing, because API Platform treats a property calledidas the identifier by convention. It takes an explicit:In both cases the collection route survives untouched and answers the identical payload. Verified on
FoundShop(#422, theidcase) and onCustomerPrivateNoteandTransformGuestToCustomer(#428, the$customerIdcase).Suggested scope
Address, which is the one with a real consequence. Either expose the itemGET, or drop the identifier if reading one address by id is deliberately out of scope.CONTEXT.mdso the next resource does not reintroduce it. The check is one command:Three open pull requests reintroduce the pattern right now: #422, #428, and this is worth a note on #423 too, which happens to be free of it. Fixing the rule is cheaper than catching it review by review, since neither CI nor the automated pre-review sees it.