feat(state): apply uri variable provider values - #8491
Conversation
A parameter provider declared on a uri variable ran, but its value was discarded: handlePathParameters() received $uriVariables by value and only returned the Operation, so the array forwarded to the Doctrine links handler kept the raw route value. Transforming a uri variable was therefore only possible through a global UriVariableTransformerInterface service, which cannot tell which variable it is transforming. Write the resolved value back so uriVariables: ['x' => new Link(provider: ...)] transforms the value used to query the resource, as QueryParameter already does for filters. ReadLinkParameterProvider is excluded: it sets the value to a hydrated resource for security expressions, which must not reach the query as an identifier. It declares this via PreservesUriVariableInterface, so user providers doing the same can opt out too. Providers are otherwise assumed to transform their value, which keeps callable providers working. In listeners mode ParameterProvider was wired standalone with no decorated inner and called separately by ReadListener, so a write-back could not cross the two calls. It now decorates the read chain in both modes, which also stops ReadListener from handing ReadProvider a stale Operation.
| private readonly ProviderInterface $provider, | ||
| ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, | ||
| ?UriVariablesConverterInterface $uriVariablesConverter = null, | ||
| private readonly ?ProviderInterface $parameterProvider = null, |
There was a problem hiding this comment.
we need to keep this for BC layer and deprecate adding this argument
|
Follow-up for the Doctrine/Eloquent side of this: #8494. Since the last review pass, two changes:
uriVariables: [
'id' => new Link(
provider: ReadLinkParameterProvider::class,
fromClass: Dummy::class,
extraProperties: ['write_uri_variable' => true],
),
]
This works for resources whose provider does not hit a persistence layer. Enabling it on a Doctrine-backed link still fails (
Note on verification: local functional runs are currently blocked by an unrelated environment issue (a concurrent git worktree whose testbench |
The resolved resource is kept out of the uri variables by default because Doctrine queries the resource with an identifier. A custom provider is often easier to write against the resource itself, so allow opting in: per service through the ReadLinkParameterProvider constructor, or per link through the `write_uri_variable` extra property. PreservesUriVariableInterface therefore carries a method rather than being a pure marker, so the decision can depend on the parameter. Opting in on a Doctrine-backed link still fails, as the links handler binds the identifier with an explicit type; making getIdentifierValue() resource aware is tracked separately. Also restore ReadListener's $parameterProvider argument. The class is public, so removing it breaks the BC promise even though the bundle no longer passes it. Passing it now triggers a deprecation, it will be removed in 6.0.
1ceaa6c to
1a36c46
Compare
Alternative approach to #8431, which adds the current
parameterNameto theUriVariableTransformerInterfacecontext so a transformer can tell which uri variable it is transforming.The use case there (base64-encoded uri variable that must be decoded before querying) should not need a global transformer service at all: a uri variable is a
Parameter, so it can already carry its ownprovider. That mechanism is already invoked for uri variables — it just has no effect on the query.The bug
ParameterProvider::handlePathParameters()calls the provider, but received$uriVariablesby value and returned only theOperation. The array forwarded to$this->decorated->provide($operation, $uriVariables, $context)— and from there toDoctrine\Orm\State\LinksHandlerTrait::handleLinks()— kept the raw route value. So a provider could mutate the parameter, and the query ignored it.The value was observable via
$operation->getUriVariables()['x']->getValue()(see the existingLinkParameterProviderResourcefixture), which is why this went unnoticed: it only bites when Doctrine does the querying.What this does
Writes the resolved value back, so this works:
#[Get( uriTemplate: '/blips/{targetClass}', uriVariables: [ 'targetClass' => new Link( fromClass: self::class, identifiers: ['name'], provider: [self::class, 'decodeName'], ), ], )] class Blip { public static function decodeName(Parameter $parameter, array $parameters = [], array $context = []): void { $parameter->setValue(base64_decode((string) $parameter->getValue(), true)); } }Per-parameter and scoped by declaration — no
supportsTransformation()guessing, and no need to know the parameter name, because the provider is attached to it.PreservesUriVariableInterfaceOne exclusion is needed. For a uri variable the value slot has two consumers that want different things:
LinksHandlerTrait::handleLinks()wants the scalar identifier (WHERE name = :x)SecurityParameterProviderreads$parameter->getValue()as the object forsecurity:expressionsReadLinkParameterProviderdeliberately puts a hydrated resource in the value for the second consumer, and that must not become the identifier. This cannot be detected by inspecting the value:Uuid,UlidandDateTimeare all legitimate uri-variable identifiers, so "is it an object?" is not a test.So the provider declares its intent.
ReadLinkParameterProviderimplements the new markerPreservesUriVariableInterface; anything else is assumed to transform its value.The polarity is deliberate — transform is the default, resolvers opt out. A positive/opt-in marker would exclude static callable providers (
provider: [self::class, 'decodeName']), which cannot implement an interface.Note this leaves the
?dummy=1query parameter case untouched: there the hydrated entity in the value slot is correct and there is no competing consumer.Listeners mode
The write-back alone fixed only the default stack. In listeners mode
api_platform.state_provider.parameterwas registered withnullas its decorated inner and called separately byReadListener:Two independent calls with the listener's own array, and
ProviderInterface::provide()takes$uriVariablesby value — so nothing could propagate.ParameterProvidernow decorates the read chain in both modes (at-10, outermost; access checkers sit at0,ParameterValidatorProviderat110), preserving the previous call ordering.Side effect:
ReadListeneralso used to discard theOperationreturned byParameterProviderand handReadProvidera stale one. Going through the chain fixes that too.BC
Targeting
main(5.0) rather than 4.4 on purpose. A user-written provider that hydrates entities the wayReadLinkParameterProviderdoes, without implementing the marker, will now feed an object into its query — silent on a minor, documented on a major. Needs an upgrade note.ReadListener's unused 4th constructor argument is removed (no in-tree call site passed it).Tests
tests/Functional/Parameters/UriVariableParameterProviderTest.phpreproduces the use case with a Doctrine-backed resource; it 404s before the fix (query runs withQmxpcA==) and passes after.Verified locally with and without
USE_SYMFONY_LISTENERS=1:UriVariableParameterProviderTest,LinkProviderParameterTest,SecurityTest,ParameterProviderTest,ValidationTest(38 each), plusReadListenerTest. php-cs-fixer and PHPStan clean. Broader regression scope left to CI.Open for review
ApiPlatform\State\ParameterProvider._api_uri_variables. It currently does not, in either mode, so generated IRIs keep the original (encoded) value and still round-trip to the same URL.