Problem/Motivation

See parent meta for motivations on introducing this component.

At the moment, in order to resolve callables with various DX features (such as service notation), this is either being implemented manually or using the controller_resolver service. The problem is this is a strange coupling, we shouldn't need a service that is primarily designed to deal with Request objects to resolve callbacks for completely unrelated subsystems.

Proposed resolution

A new service called "callback_resolver" that standardises the approach for resolving callbacks, decoupled from controllers.
Down the track: use the callback_resolver ControllerResolver in places where a callable is required from a "definition".

Remaining tasks

  • #64 Make a decision on moving \Drupal\Core\Security\DoTrustedCallbackTrait to this class
  • #64 Make the test data return different values to ensure correct logic is called
  • #66 Use this in \Drupal\Core\Controller\ControllerResolver as an example

User interface changes

API changes

Data model changes

Issue fork drupal-2982949

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

Sam152 created an issue. See original summary.

sam152’s picture

sam152’s picture

Title: Extract code that deals with invoking callables from ControllerResolver into a new component » Introduce CallbackResolver to help standardise the DX and error handling for callbacks across various subsystems
Issue summary: View changes
Status: Active » Needs review
StatusFileSize
new7.99 KB

Initial pass at this.

sam152’s picture

Responding to the comments from @joachim in #2982950: [meta] Standardize the approach for capturing and invoking callables across various subsystems.

Currently ControllerResolver automatically understands the difference between static notation and ones resolved using the class resolver, see the following (uncommitted) test case:

diff --git a/core/tests/Drupal/Tests/Core/Controller/ControllerResolverTest.php b/core/tests/Drupal/Tests/Core/Controller/ControllerResolverTest.php
index 8d0fccdd6b..2b3106d056 100644
--- a/core/tests/Drupal/Tests/Core/Controller/ControllerResolverTest.php
+++ b/core/tests/Drupal/Tests/Core/Controller/ControllerResolverTest.php
@@ -112,6 +112,8 @@ public function providerTestCreateController() {
     return [
       // Tests class::method.
       ['Drupal\Tests\Core\Controller\MockController::getResult', 'Drupal\Tests\Core\Controller\MockController', 'This is a regular controller.'],
+      // Tests static class::method.
+      ['Drupal\Tests\Core\Controller\MockController::getStaticResult', 'Drupal\Tests\Core\Controller\MockController', 'This is a static method controller.'],
       // Tests service:method.
       ['some_service:getResult', 'Drupal\Tests\Core\Controller\MockController', 'This is a regular controller.'],
       // Tests a class with injection.
@@ -258,6 +260,10 @@ public function getResult() {
     return 'This is a regular controller.';
   }
 
+  public static function getStaticResult() {
+    return 'This is a static method controller.';
+  }
+
   public function getControllerWithRequestAndRouteMatch(RouteMatchInterface $route_match, Request $request) {
     return 'this is another example controller';
   }

So I think we should do the same thing here and that two methods is not necessary. Once we support all the things that the current ControllerResolver does, we can pull that logic out of that class and make it use this new service.

sam152’s picture

Issue summary: View changes
sam152’s picture

Another thought, perhaps this should be CallableResolver. When it comes to controllers for example, it's not technically a callback, but in all cases we want to be returning a callable that can be used for various purposes.

sam152’s picture

StatusFileSize
new7.2 KB
new11.64 KB

Adding support for callables with classes resolved by the class resolver. At this point, I think it'd be able to totally resolve the custom callables stuff built into CallbackResolver.

sam152’s picture

StatusFileSize
new10.53 KB
new11.77 KB

Moving to "callable resolver", fixing some docs and exception messages.

sam152’s picture

Title: Introduce CallbackResolver to help standardise the DX and error handling for callbacks across various subsystems » Introduce CallableResolver to help standardise the DX and error handling for callbacks across various subsystems
sam152’s picture

StatusFileSize
new579 bytes
new11.79 KB

Was missing part of the service definition.

The last submitted patch, 8: 2982949-8.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

The last submitted patch, 9: 2982949-9.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

joachim’s picture

Status: Needs review » Needs work
  1. +++ b/core/lib/Drupal/Core/Utility/CallableResolver.php
    @@ -0,0 +1,89 @@
    + * as service notation, defined in the form of service_name:method.
    

    I would put bullet points here to explain exactly how a string is resolved into a callback, and in what order things are tried.

    But doing that should probably wait until we've actually got the code stable.

  2. +++ b/core/lib/Drupal/Core/Utility/CallableResolver.php
    @@ -0,0 +1,89 @@
    +  public function invokeFromDefinition($definition, $arguments = []) {
    

    Do we need this?

    call_user_func_array() is notoriously slow, and this method would be stuck using it until we can use variadics, which is when we drop PHP 5 support, which is something like at least a year away.

Also, I wrote the following by mistake on the parent issue:

One of the problems we face in unifying this is that different places use 'ThisClass::thisMethod' to mean different things.

In FieldConfigInterface::setDefaultValueCallback(), a value of 'ThisClass::thisMethod' simply is a PHP callable, representing a static call to thisMethod() on the class ThisClass.

However, in the routing system, putting 'ThisClass::thisMethod' as the controller for a route will cause ControllerResolver to use the DI ClassResolver to instantiate ThisClass, and call thisMethod() on the instance:

  protected function createController($controller) {
    // Controller in the service:method notation.
    $count = substr_count($controller, ':');
    if ($count == 1) {
      list($class_or_service, $method) = explode(':', $controller, 2);
    }
    // Controller in the class::method notation.
    elseif (strpos($controller, '::') !== FALSE) {
      list($class_or_service, $method) = explode('::', $controller, 2);
    }
    else {
      throw new \LogicException(sprintf('Unable to parse the controller name "%s".', $controller));
    }

    $controller = $this->classResolver->getInstanceFromDefinition($class_or_service);

So given that, I think our new CallbackResolver will need two public methods, one for each behaviour: resolveCallbackWithStatic(), and resolveCallbackWithInstantiation().

Then I'd have a single protected method resolveCallback(bool $instantiate_static_notation), where the parameter controls the behaviour.

The two public methods are then just thin wrappers that pass the parameter value, which means we get code re-use, but also we don't require developers to know what a boolean parameter means.

sam152’s picture

1. Good point, few better docs would be good here.
2. Hm, it would be a handy convenience method based on the usages I can see. Callables are often invoked as soon as they are resolved. We can always deprecate methods and add new ones there is something new we can utilise in php7.
3. I think we're already supporting both of these:

+++ b/core/tests/Drupal/Tests/Core/Utility/CallableResolverTest.php
@@ -0,0 +1,218 @@
+      'Static function' => [
+        static::class .  '::staticMethod',
+      ],
...
+      'Non-static function, instantiated by class resolver, container injection' => [
+        '\Drupal\Tests\Core\Utility\MockContainerInjection::getResult',
+      ],
joachim’s picture

> Hm, it would be a handy convenience method based on the usages I can see. Callables are often invoked as soon as they are resolved.

True, but without variadics, I don't think it's that nice DX, as you have to wrap up your parameters into an array:

$result = $service->invokeCallable($callable, [$param1, $param2]);

With variadics, it becomes:

$result = $service->invokeCallable($callable, $param1, $param2);

There's the performance issue too, so really think we should leave that to a follow-up to be postponed until we can use variadics.

> 3. I think we're already supporting both of these:

I can't see that in the code.

And those two cases in the test look the same to me:

> + static::class . '::staticMethod',

The code will receive a string that looks like \MyNamespace\MyClass::staticMethod

> + '\Drupal\Tests\Core\Utility\MockContainerInjection::getResult',

Same here.

And the service will instatiate the class with the class resolver service and call the method on the instantiated object.

But for baseFieldDefinitions() we actually want a static call. The callable string '\MyNamespace\MyClass::staticMethod' should be called as \MyNamespace\MyClass::staticMethod().

sam152’s picture

There's the performance issue too, so really think we should leave that to a follow-up to be postponed until we can use variadics.

We could always use func_get_args to support ($callable, $param1, $param2) syntax in the meantime, then file a follow-up for variadics, but I'm not too opinionated on this. I think worrying about the performance too much would be a bit premature.

I can't see that in the code.

Ah, right you are! We should test that the static method is definitely being called in static context. I'd like to investigate doing that automatically, a flag for static methods would be quite ugly, in a lot of cases if we're using the resolver in a context where the callable has been supplied by a user, maybe in the context for a BaseFieldDefinition::setDefaultValueCallback, we might not know if it's static or not and we don't necessarily want to ask the user either.

So remaining tasks are:

  • Look at variadic-like syntax.
  • Fix test coverage for static method calls (and try to fix this).
  • Better docs.
sam152’s picture

Status: Needs work » Needs review
StatusFileSize
new5.6 KB
new13.03 KB

Updating to reflect #17. Another reason to avoid the flag is: if we are aware that our input is a static method in a particular notation, we can always avoid the callable resolver altogether and invoke it straight away.

Version: 8.6.x-dev » 8.7.x-dev

Drupal 8.6.0-alpha1 will be released the week of July 16, 2018, which means new developments and disruptive changes should now be targeted against the 8.7.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

joachim’s picture

Just a thought that occurs to me as I write tests for #2975503: allow FieldConfigInterface::setDefaultValueCallback() to accept a callback in service notation -- tests here should check that the service method default callback is not called statically. Stack overflow says the way to do this is:

      $static = !(isset($this) && get_class($this) == __CLASS__);
sam152’s picture

StatusFileSize
new694 bytes
new13.21 KB

Good point. Adding that coverage now. This further validates that the following test cases are invoked non-statically:

  • Non-static function, array notation, with object
  • Non-static function, instantiated by class resolver
  • Service notation
joachim’s picture

Status: Needs review » Needs work
+++ b/core/lib/Drupal/Core/Utility/CallableResolver.php
@@ -0,0 +1,107 @@
+ * - Static methods: @code '\FooClass::staticMethod' @endcode
+ * - Non-static methods, instantiated with the class resolver:
+ *   @code '\DependencyInjectedClass::method' @endcode
      try {
        if ((new \ReflectionClass($class))->getMethod($method)->isStatic()) {
          return $definition;
        }

I don't think we should do it like this. Reflection is expensive, and we want the callable resolver to be something fast, that other components can use without worrying about performance.

I still think we should handle this the way I suggested, with two methods, as I suggested previously:

> So given that, I think our new CallbackResolver will need two public methods, one for each behaviour: resolveCallbackWithStatic(), and resolveCallbackWithInstantiation().

Users of this service will know whether they want 'MyClass::myMethod' to mean an instantiation, or a static call. It'll be up to them to document what they mean in their own APIs.

    // Support using classes as callables if the __invoke method exists on the
    // class.
    if (method_exists($definition, '__invoke')) {
      return new $definition();
    }

Is there anywhere in core that does this? If not, I would say this is YAGNI, and we should remove it until we do find we need it.

  public function invokeFromDefinition($definition) {

I still think this is surplus to what we need here, and until we can use variadics, it has a performance cost.

      'Static function' => [
        static::class .  '::staticMethod',
      ],

I think for legibility, it would be better for all the callables in the test to be explicit, so I would add another class for these methods to be on.

sam152’s picture

I think I could agree with everything except resolveCallbackWithStatic and resolveCallbackWithInstantiation.

If we take #2975503: allow FieldConfigInterface::setDefaultValueCallback() to accept a callback in service notation as an example, in that scenario if we're invoking a callable, are you saying fields can only accept either static or instantiated callbacks but not both? And it's up to the field system to choose one of those and document which one it supports? IMO that defeats the whole purpose. I think reflection is slow, but in super relative terms and relative to the cost of actually invoking a callable I think it's quite minor.

To be honest, I think unless this gets integrated at a very low level where it's being called thousands of times a page load, it wont matter. And if at some point that was the case, we could look at introducing such methods. Until then, I think it's an acceptable cost to bear, given the utility it provides.

joachim’s picture

> in that scenario if we're invoking a callable, are you saying fields can only accept either static or instantiated callbacks but not both?

Yup. I'm saying exactly that :)

The Field system currently treats a callable for FieldConfigInterface::setDefaultValueCallback() of the form 'MyClass::myMethod' as a static call. There is no way to get an object instantiated.

Meanwhile, the routing system treats a controller declared in mymodule.routing.yml as 'MyClass::myMethod' as an object to be instantiated with \Drupal\Core\DependencyInjection\ClassResolver.

In the field case, I imagine it was designed like this because you typically want the method for the default value for a base field to be on entity class, but when the Field system calls that method, it does not have an entity to hand. So it must be a static.

For the routing case, my guess is that it's following the convention from Symfony.

So we have two systems that treat the same syntax differently, and I think the simplest thing is to let them just say to the CallbackResolver what they want to be done with it.

sam152’s picture

I'm not really a fan of that from a DX perspective and it goes against the goal of standardisation. As someone providing an API with callables, I don't want to have to make and document this decision and as a user of an API providing callables I don't want to have to be aware of different contexts. I'm not really convinced of the performance argument, given all of the examples so far are not examples of callables being invoked thousands of times per request.

If you'd like to pursue it, please feel free. I think #21 is more or less what I had in mind, so I'm not interested in taking it further than that.

joachim’s picture

> I'm not really a fan of that from a DX perspective and it goes against the goal of standardisation.

I agree that having the string 'MyClass::myMethod' not result in the same kind of call in different systems is not very good DX, but that situation currently exists. This issue is not adding to or changing this situation.

This proposed CallableResolver is a very low-level API. Most developers shouldn't encounter it. It's something for other systems to use, that then have to declare and document their own APIs.

And the Field system and routing systems *already* document (or should!) what it means when the string 'MyClass::myMethod' is given to their APIs.

And in fact, because we can't break BC, we can't go changing what the string 'MyClass::myMethod' means to either of those systems. For Field system, it must remain that it means a static call. For routing, it must remain that it means an instantiation. So we have to deal with that difference.

This issue is about sliding a new piece of common code at the bottom of existing systems, in such a way that the existing systems change to use our new code, but consumers of the existing systems don't notice or have to care about the change.

Because the existing systems have different ideas about what things mean, we need to account for that.

joachim’s picture

I think the root cause of why we are disagreeing on how to write this service is that we're approaching it from very different angles.

I think this service should be a consolidation and refactoring of code that's currently duplicated in different parts of core.

It seems to me that you're trying to write a utility class that covers all cases.

I think that approach is over-stretching, and as we can see from the use cases we have to satisfy, it's over-complicating.

sam152’s picture

I think this service should be a consolidation and refactoring of code that's currently duplicated in different parts of core. It seems to me that you're trying to write a utility class that covers all cases.

I think that's a fair analysis. Not criticising your efforts in any way, it's probably also a positive step, it's just not one I'm interested in working on. Not sure if those changes would be appropriate for this issue or a new one.

I think for the systems mentioned, it would be great if we had the same set of tools. I think the performance and BC risks are acceptable, given they can be evaluated properly when integrated with the service and static methods will continue to be called as static. For low level code, we would always have the chance to integrate callables differently. I do see this issue as a tested utility that will allow developers to create richer APIs in the future.

Maybe a review from a framework manager would help focus this discussion?

There could also be an acceptable middle ground that accounts for both use cases. We could use bitwise operators to include or exclude the types of callables that would be resolved. Maybe something like the following:

public function getCallableFromDefinition($definition, $included_formats = CallableResolverInterface::ALL);

And a series of constants:

  • CallableResolverInterface::STATIC
  • CallableResolverInterface::INSTANTIATED
  • CallableResolverInterface::SERVICE_NOTATION
  • CallableResolverInterface::INVOKABLE

Then bitwise could be used to indicate which types are checked and used:

$resolver->getCallableFromDefinition($definition, CallableResolverInterface::STATIC | CallableResolverInterface::SERVICE_NOTATION);

That would allow more control in the hands of the user. But I would still hope most systems would choose to use all available methods.

sam152’s picture

I think some outside opinions would be really helpful.

sam152’s picture

@joachim If you're going to be at Drupal Europe, maybe we could catch up and come to a resolution on this issue?

joachim’s picture

Yup, good idea!

sam152’s picture

Here is a blackfire report comparing:

  1. Primed cache.
  2. Logged in user, with minimal permissions accessing /node/add/article.
  3. Callable resolve integrated into \Drupal\Core\Field\BaseFieldDefinition::getDefaultValue vs what's already in core.
  4. Hacked the method to invoke the callable 1000 times.

Invoking it once failed to register on the report.

https://blackfire.io/profiles/compare/8fbaf49c-d94c-4c00-bece-1b254bf8fb...

So the way I'm interpreting the results is, on a form with 1000 fields with default values, about a 5% cost to the total page would be added. Or 0.005% change for the typical use case of one default author.

sam152’s picture

Status: Needs work » Needs review

NR based on discussions with @joachim yesterday, we agreed the only blocker to accepting an approach which covered the use case of all callables was the speed of reflection.

sam152’s picture

@joachim any chance of another review of this? :)

longwave’s picture

FWIW I have been tracking this issue for a while and would like to see something like this implemented, and I tend to agree with @Sam152 on the point that this is supposed to be a DX improvement, so we shouldn't make the DX overcomplicated by adding multiple methods or flags if at all possible. I understand @joachim has performance concerns but I think if we are to take that into account we need a concrete example where this will be a problem, #32 is quite a contrived example but seems to imply there won't be a real world performance hit anywhere? If it turns out there is a critical path that is affected, then we are also free just not to use this new method.

sam152’s picture

Thanks for following up on this @longwave, much appreciated. @joachim and I had a very similar conversation in Drupal Europe to that same effect.

The go-to example was integrating this into the renderer. Since the critical path heavily depends on it, we could make the call at that point in time (with profiling or otherwise) if it was an appropriate use case or not.

sam152’s picture

Issue tags: +DrupalSouth 2018
larowlan’s picture

+++ b/core/tests/Drupal/Tests/Core/Utility/CallableResolverTest.php
@@ -0,0 +1,236 @@
+    if (!isset($this)) {
+      throw new \Exception('Non-static method called statically.');

doesn't php enforce this?

sam152’s picture

Looks like it throws a deprecated notice in 7 and a warning in 5: https://3v4l.org/MYd7M, however I don't think it failed our tests.

Version: 8.7.x-dev » 8.8.x-dev

Drupal 8.7.0-alpha1 will be released the week of March 11, 2019, which means new developments and disruptive changes should now be targeted against the 8.8.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

alexpott’s picture

I think this makes sense +1 to the idea.

  1. +++ b/core/lib/Drupal/Core/Utility/CallableResolver.php
    @@ -0,0 +1,107 @@
    +  public function invokeFromDefinition($definition) {
    +    $callable = $this->getCallableFromDefinition($definition);
    +    $arguments = func_get_args();
    +    array_shift($arguments);
    +    return call_user_func_array($callable, $arguments);
    +  }
    

    As 8.8.x is going to PHP7 (not sure of the minimum version yet but they all support this) let's use a variadic argument here...

      public function invokeFromDefinition($definition, ...$arguments) {
        $callable = $this->getCallableFromDefinition($definition);
        return call_user_func_array($callable, $arguments);
      }
    

    Also the interface documentation can be updated to use variadic documentation.

  2. Sometimes I ponder about whether this sort of service should have an interface. I mean swapping this out or decorating this feels like an amazingly small edge case that we should not make easy because in all likelihood supporting will introduce all sorts of bugs. I.e. imagine if two contrib modules have competing versions.
  3. The reflection kinda bothers me - can we test early for callability and avoid reflection?
joachim’s picture

> The reflection kinda bothers me - can we test early for callability and avoid reflection?

The reason the reflection is in the patch is that in Drupal there is an inconsistency in what we mean by some kinds of callable strings.

Specifically, the string 'MySpace\MyClass::myMethod' does not always mean the same thing.

- In FieldConfigInterface::setDefaultValueCallback(), a value of 'MySpace\MyClass::myMethod' simply is a PHP callable, representing a static call.
- In the routing system, putting 'MySpace\MyClass::myMethod' as the controller for a route will cause ControllerResolver to use the DI ClassResolver to instantiate MySpace\MyClass, and call thisMethod() on the instance.

So the problem is how the new service can handle both techniques.

The options are basically:

1. Provide two methods on the service.

2. Provide a 2nd optional parameter that can specify exactly which kinds of callable techniques are allowed

3. Use reflection.

In light of the more recent issues about callable security, option 2 might be the best, as it could also allow users of the service to enforce their security rules.

sam152’s picture

Thanks for the review!

#41.1: Sounds like a great idea.
#41.2: I usually default to creating an interface for things that are technically swappable, but in this case I agree that a single implementation makes sense here.
#41.3: Unfortunately, we have to test for static-ness before attempting to pass the class off to $this->classResolver->getInstanceFromDefinition, since a static method doesn't need an instance and there is no way of guaranteeing that the factory will actually be able to create an instance. #22 to #32 discuss options in some detail. There is a test class in the patch which exposes these semantics:

+++ b/core/tests/Drupal/Tests/Core/Utility/CallableResolverTest.php
@@ -0,0 +1,236 @@
+class UninstantiableMockStaticCallable {
+
+  public function __construct() {
+    throw new \Exception(sprintf('The class %s should not require instantiation for the static method to be called.', __CLASS__));
+  }
+
+  public static function staticMethod($suffix) {
+    return 'foo' . $suffix;
+  }
+
+}

#42: The signature of TrustedCallbackInterface::trustedCallbacks in #2966327: Limit what can be called by a callback in render arrays to reduce the risk of RCE is static, so the same security could be applied for both static and non-static calls.

+++ b/core/lib/Drupal/Core/Security/TrustedCallbackInterface.php
@@ -0,0 +1,41 @@
+  /**
+   * Lists the trusted callbacks provided by the implementing class.
+   *
+   * Trusted callbacks are public methods on the implementing class and can be
+   * invoked via
+   * \Drupal\Core\Security\TrustedCallbackTrait::doTrustedCallback().
+   *
+   * @return string[]
+   *   List of the trusted callbacks provided by the implementing class.
+   *
+   * @see \Drupal\Core\Security\TrustedCallbackTrait::doTrustedCallback()
+   */
+  public static function trustedCallbacks();
sam152’s picture

Issue tags: -Needs framework manager review, -DrupalSouth 2018
StatusFileSize
new3.34 KB
new12.54 KB

Updating patch.

kim.pepper’s picture

  +++ b/core/lib/Drupal/Core/Utility/CallableResolver.php
  @@ -0,0 +1,127 @@
  +  public function getCallableFromDefinition($definition) {

Does the order of resolution have an impact on performance? Ie. is_callable() could come first? It all looks pretty simple, but thought I'd ask the question.

Can we have an interface? I know this has been discussed ad nauseam, but if we are creating a new service definition, then it's a public interface right?

Can we justify its usefulness by including real usage of this by replacing it in core somewhere?

sam152’s picture

Hey @kim.pepper, thanks for the review.

Yeah, unfortunately is_callable doesn't delineate between methods that can actually be called without any instantiation and methods which require a instance via a factory first. Here is a simple example

<?php
class foo { public function __construct($cant_create_me_without_a_factory) {} public function bar() { }}
var_export(is_callable("foo::bar"));
> true

@alexpott was in favour of no interface for this class in #41.

Re: usages, the child issues and summary of #2982950: [meta] Standardize the approach for capturing and invoking callables across various subsystems are gathering some of the use-cases. I'd be fine to add one of em in here, but was just considering scope etc.

joachim’s picture

I've not read #2966327: Limit what can be called by a callback in render arrays to reduce the risk of RCE in detail, but it sounds like the requirement there is to fine-control which types of callable are allowed.

Given that, should we revisit the plan outlined in #28, where @Sam152 proposed we allow the users of this service to specify precisely which type of callable are ok?

dsnopek’s picture

#2966327: Limit what can be called by a callback in render arrays to reduce the risk of RCE is specifically about limiting the callbacks used in render arrays to functions that were written to be render array callbacks. This is important in that context because there are places where a user can enter Twig templates in the UI, where it's possible to build and render render arrays, ie. we're dealing with callbacks from user input.

This issue is way more low-level and seems like it's mainly about situations where only a developer who can edit code files can setup callbacks. So, I'm not sure this issue needs to dig into limiting callbacks that were setup in user input - the validation of the callbacks can be done before calling the APIs built in this issue, which probably makes sense, given that getting callbacks from user input is an edge case that hopefully won't come up very often. :-)

joachim’s picture

I've just noticed in passing that Drupal\Core\Render\Renderer uses ControllerResolver::getControllerFromDefinition() to resolve callbacks in render arrays, which doesn't look right!

sam152’s picture

I can't see a reason why ::getCallableFromDefinition introduced in this issue and ::doTrustedCallback in the other issue wouldn't be completely compatible. The former resolves various "Drupal" callable formats into a PHP-style callables and the latter is able to verify all the PHP-style callable formats. So in theory you could easily resolve some callable which required a Drupal factory and then also verify it was a trusted callback.

We could also expand CallableResolver::invokeFromDefinition to support verifying "trusted callbacks", however we have freedom to explore that with additional BC argument defaults, so I don't really see either issue as blocking each other?

I don't think there was any other actionable feedback as far as I could see?

joachim’s picture

Status: Needs review » Needs work

Coming back to this issue as I'm working with options fields, and finding that the 'allowed_values_function' can't use service notation...

Just some nitpicks:

  1. +++ b/core/lib/Drupal/Core/Utility/CallableResolver.php
    @@ -0,0 +1,127 @@
    +      } catch(\ReflectionException $e) {
    

    The catch shouldn't be coddled.

  2. +++ b/core/lib/Drupal/Core/Utility/CallableResolver.php
    @@ -0,0 +1,127 @@
    +        throw new \InvalidArgumentException(sprintf('The callable definition provided was invalid. No method "%s" was not found on the class "%s".', $method, $class));
    

    Is this exception message accurate? Are there other cases in which is_callable() would be false here?

  3. +++ b/core/lib/Drupal/Core/Utility/CallableResolver.php
    @@ -0,0 +1,127 @@
    +    // If the definition is natively a callable, we can return it immediately.
    +    if (is_callable($definition)) {
    +      return $definition;
    +    }
    +
    +    // Support using classes as callables if the __invoke method exists on the
    +    // class.
    +    if (method_exists($definition, '__invoke')) {
    +      return new $definition();
    +    }
    +
    +    // Support the service notation syntax.
    

    I think we should check for service notation before we treat the $definition as either a callable or a class name.

joachim’s picture

Here's a patch with the changes from my review in #51 and a few other tweaks too.

I've changed the first line of the class docs, and also made it clearer in the class docs code example that you can use a service name with a '.' in it because I think that's useful to see.

Status: Needs review » Needs work

Version: 8.8.x-dev » 8.9.x-dev

Drupal 8.8.0-alpha1 will be released the week of October 14th, 2019, which means new developments and disruptive changes should now be targeted against the 8.9.x-dev branch. (Any changes to 8.9.x will also be committed to 9.0.x in preparation for Drupal 9’s release, but some changes like significant feature additions will be deferred to 9.1.x.). For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

Version: 8.9.x-dev » 9.1.x-dev

Drupal 8.9.0-beta1 was released on March 20, 2020. 8.9.x is the final, long-term support (LTS) minor release of Drupal 8, which means new developments and disruptive changes should now be targeted against the 9.1.x-dev branch. For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

Version: 9.1.x-dev » 9.2.x-dev

Drupal 9.1.0-alpha1 will be released the week of October 19, 2020, which means new developments and disruptive changes should now be targeted for the 9.2.x-dev branch. For more information see the Drupal 9 minor version schedule and the Allowed changes during the Drupal 9 release cycle.

FatherShawn made their first commit to this issue’s fork.

fathershawn’s picture

Created an issue fork with #52 applied to investigate the test failure

fathershawn’s picture

Status: Needs work » Needs review

Updated test class for deprecations per https://www.drupal.org/node/3056869 and https://www.drupal.org/node/3114724

Updated the exception message to match the message now returned

fathershawn’s picture

Tests pass for me locally using:

  • PHP 7.4.12
  • phpunit 8.5.13

Investigating the failures

joachim’s picture

Status: Needs review » Reviewed & tested by the community

Tests pass.

Patch looks good!

catch’s picture

Status: Reviewed & tested by the community » Needs work

Let's try to add the return type hint.

Also wondering if the order of resolution is the most efficient we can do - i.e. could we move is_callable() checks in front of things checking exception and possibly hitting exceptions.

Also can't see immediately what the next step for this is, do we need to open up follow-ups under #2982950: [meta] Standardize the approach for capturing and invoking callables across various subsystems to start using this?

fathershawn’s picture

And should we move the work from \Drupal\Core\Security\DoTrustedCallbackTrait into this class? In the course of working on #2966711: Limit what can be called by a callback in form arrays I've needed to add that Trait to FormBuilder and FormValidator, so we are now using the trait in 3 places.

joachim’s picture

> Also wondering if the order of resolution is the most efficient we can do - i.e. could we move is_callable() checks in front of things checking exception and possibly hitting exceptions.

Yes, definitely! I could have sworn we have all the string manipulations and is_callable() being done before wading into reflection.

FWIW I'm still not keen on the use of reflection here.

> And should we move the work from \Drupal\Core\Security\DoTrustedCallbackTrait into this class?

Yes, that's maybe a good idea.

Though I am torn between saying it should be done in a follow-up, and saying we should do it here to make sure the API is consistent.

Something like add a matching pair of methods getTrustedCallableFromDefinition() and invokeTrustedCallableFromDefinition()?

tim.plunkett’s picture

I agree we should update DoTrustedCallbackTrait within this issue. Updating other "end-user" calls within core is one thing, but that's pretty low-level on it's own.

Also I'm not 100% clear on what the future interaction between this and ControllerResolver will be. It'd be good to make that change here too, IMO.


Hiding the patches since this issue is using MRs now.

Version: 9.2.x-dev » 9.3.x-dev

Drupal 9.2.0-alpha1 will be released the week of May 3, 2021, which means new developments and disruptive changes should now be targeted for the 9.3.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.3.x-dev » 9.4.x-dev

Drupal 9.3.0-rc1 was released on November 26, 2021, which means new developments and disruptive changes should now be targeted for the 9.4.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.4.x-dev » 9.5.x-dev

Drupal 9.4.0-alpha1 was released on May 6, 2022, which means new developments and disruptive changes should now be targeted for the 9.5.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

andypost’s picture

Version: 9.5.x-dev » 10.1.x-dev

Drupal 9.5.0-beta2 and Drupal 10.0.0-beta2 were released on September 29, 2022, which means new developments and disruptive changes should now be targeted for the 10.1.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

kim.pepper’s picture

kim.pepper’s picture

Status: Needs work » Needs review

I made quite a few changes to address some of the feedback.

  • Move the is_callable() check to the top for quick exit.
  • Removed the reflection to just rely on the class resolver and is_callable()
  • Removed the invokeFromDefinition() method
  • Removed the dependency on the container

One side effect is that the error messages are a bit more generic without the extra service container lookups. Not sure whether that is a worthwhile trade off?

kim.pepper’s picture

I think invoking the callbacks should be out of scope. Having a look at how we use them, they are pretty simple wrappers around DoTrustedCallbackTrait that are specific to Renderer etc. I think we want to keep this more generic.

kim.pepper’s picture

Issue summary: View changes

Updating IS with remaining tasks.

joachim’s picture

It's been ages since I looked at this.

Was there a resolution to the problem of how to deal with the 'Class::method' syntax, where different systems currently handle that in different ways?

kim.pepper’s picture

Pretty sure its just :: is for static or instance method. The class resolver takes care of creating new instances for us.

joachim’s picture

> Pretty sure its just :: is for static or instance method.

That's the thing though -- in some systems, Class::method means a static call, and in others, it means instantiate the class and call $object->method.

andypost’s picture

Added more callables to test and rebased

kim.pepper’s picture

Re: #78 Not sure it matters? Calling is_callable() on the string '\Drupal\Tests\Core\Utility\NoInstantiationMockStaticCallable::staticMethod' returns true if it's a static method. If not, it drops down to where we instantiate an object.

sam152’s picture

That's the thing though -- in some systems, Class::method means a static call, and in others, it means instantiate the class and call $object->method.

Pre PHP 8 is_callable would return true for public methods that required instantiation, but this changed: https://3v4l.org/d93fd - created this snippet 24h ago because I was similarly confused 😅

Hence the reflection is no longer required and false from is_callable can accurately signal the need for instantiation or the container.

kim.pepper’s picture

Changed ControllerResolver to use CallableResolver and added a CR.

kim.pepper’s picture

Issue summary: View changes
kim.pepper’s picture

Hence the reflection is no longer required and false from is_callable can accurately signal the need for instantiation or the container.

Whew! 😅

kim.pepper’s picture

So this looks like a genuine fail for Drupal\Tests\ckeditor5\FunctionalJavascript\MediaTest.

When embedding a media item into a ckeditor5 field, I get the following error in logs:

NOTICE: PHP message: Uncaught PHP Exception ReflectionException: "Function \Drupal\media\Controller\MediaFilterController::formatUsesMediaEmbedFilter() does not exist" at /data/app/core/lib/Drupal/Component/Utility/ArgumentsResolver.php line 122

\Drupal\media\Controller\MediaFilterController::formatUsesMediaEmbedFilter() does exist

Is there something we are missing to handle static methods in:

  protected function getReflector(callable $callable) {
    return is_array($callable) ? new \ReflectionMethod($callable[0], $callable[1]) : new \ReflectionFunction($callable);
  }

?

kim.pepper’s picture

Confirmed! Added a check for a string static method callable with "::" syntax.

kim.pepper’s picture

StatusFileSize
new864 bytes

Here's a test-only patch for the ArgumentsResolver to show that callables that are static methods in string "::" format aren't currently supported.

joachim’s picture

> Pre PHP 8 is_callable would return true for public methods that required instantiation, but this changed: https://3v4l.org/d93fd - created this snippet 24h ago because I was similarly confused 😅

Oh that's NICE!

It works for this too:

$callable = Foo::class . '::bar';

Status: Needs review » Needs work

The last submitted patch, 87: 2982949-87-test-only.patch, failed testing. View results

joachim’s picture

The change in PHP 8 is documented here BTW: https://www.php.net/manual/en/migration80.incompatible.php

kim.pepper’s picture

Status: Needs work » Needs review

Tests passed so needs review.

larowlan’s picture

Status: Needs review » Needs work

Left some comments on the MR - looking great - some solid test coverage too

Re #64 I don't think moving the trait into this service is needed. Traits should be reused, so I don't see the harm in there being N classes that use it.

However I do think we should be using this in \Drupal\Core\Security\DoTrustedCallbackTrait::doTrustedCallback

I think to enable that we'd need a $callableResolver property in that trait and ::getCallableResolver method that first checked the property, and if it wasn't set reached out to the \Drupal singleton. That way classes using the trait can use DI to the property eventually, but if they don't, the class will be fetched from the container. We'd have to update the usages of the trait in core to set that property.

andypost’s picture

kim.pepper’s picture

Status: Needs work » Needs review

All feedback has been addressed or at least commented on.

kim.pepper’s picture

Issue summary: View changes

Should we split static methods in strings '::' into arrays first? That we we can do the ArgumentResolver change in a follow up.

joachim’s picture

Ah, the change to ArgumentResolver is so that it can handle getting the method reflector for a method given in 'Class::method' form? That makes sense now :)

I'd keep it in.

andypost’s picture

Re #95 there's interesting numbers - #3274867-21: Add TrustedCallback attribute

TL'DR callable as array faster ~50% vs string

PS only first-class callable faster, see #3259716: Replace usages of static::class . '::methodName' to first-class callable syntax static::method(...)

kim.pepper’s picture

#97 are you saying we should split by "::" first up?

kim.pepper’s picture

@andypost I'm not sure how much of a micro-optimisation that is? Currently static methods in "::" string format will be returned by the first line:

    if (is_callable($definition)) {
      return $definition;
    }
andypost’s picture

Status: Needs review » Reviewed & tested by the community

@kim.pepper no need to change it as PHP internals will do that on call, I see no reason in changing callable as the most performant case is first-class callable but it's just preemptive optimization ATM

Looks ready to go

dieterholvoet’s picture

I noticed service notation without specifying a method name (with an __invoke method) is missing from the documentation/tests. Is this supported yet? If not, do you think it should be supported? It seems potentially useful to me.

larowlan’s picture

Status: Reviewed & tested by the community » Needs work

Yes I think we should support #101

andypost’s picture

Status: Needs work » Needs review

Added test and docs for service with __invoke()

PS: rebased and merge commits are gone, /cc @kim.pepper

smustgrave’s picture

Status: Needs review » Reviewed & tested by the community

Seems this was already previously reviewed.

Point #101 appears to have been addressed so remarking this for ya.

Version: 10.1.x-dev » 11.x-dev

Drupal core is moving towards using a “main” branch. As an interim step, a new 11.x branch has been opened, as Drupal.org infrastructure cannot currently fully support a branch named main. New developments and disruptive changes should now be targeted for the 11.x branch, which currently accepts only minor-version allowed changes. For more information, see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

larowlan’s picture

There's an unresolved comment on the MR about whether we should be using this in a few places

\Drupal\Core\Render\Renderer::doCallback
\Drupal\Core\Menu\MenuLinkTree::transform (and \Drupal\toolbar\Menu\ToolbarMenuLinkTree which subclasses it)
\Drupal\Core\Routing\RouteBuilder::rebuild
\Drupal\Core\Access\CustomAccessCheck::access
\Drupal\user\PermissionHandler::buildPermissionsYaml instead of the controller resolver?

Can we get follow up issues created for that?

elber made their first commit to this issue’s fork.

larowlan’s picture

Status: Reviewed & tested by the community » Needs work

Needs reroll

kim.pepper’s picture

Status: Needs work » Needs review

Rebased on 11.x

kim.pepper’s picture

Hiding patch as it makes it look like the MR is failing when its not.

andypost’s picture

Status: Needs review » Reviewed & tested by the community

Back to RTBC, thanks

andypost’s picture

There's some measurements of different types of callables https://gist.github.com/donquixote/85efcca90056111e967dd14cb1f9de9c

Which means we should try re-use a callable as possible

andypost’s picture

kim.pepper’s picture

The last MR comment can be resolved.

larowlan’s picture

Issue credits

  • larowlan committed c4cf5949 on 11.x
    Issue #2982949 by kim.pepper, Sam152, andypost, FatherShawn, elber,...
larowlan’s picture

Status: Reviewed & tested by the community » Fixed

Committed to 11.x and published the change record.

The child issues are now good to go.

larowlan’s picture

I also added an extra CR announcing the new feature - https://www.drupal.org/node/3368504 please review and amend if appropriate

fathershawn’s picture

Grateful and excited to see this committed. I'll rebase the work I started on #2966711: Limit what can be called by a callback in form arrays onto 11.x-dev and update it in light of this work.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.