RuntimeContainer.php
223 lines
| 1 | <?php |
| 2 | |
| 3 | declare( strict_types=1 ); |
| 4 | |
| 5 | namespace Automattic\WooCommerce\Internal\DependencyManagement; |
| 6 | |
| 7 | use Automattic\WooCommerce\Blocks\Package as BlocksPackage; |
| 8 | use Automattic\WooCommerce\StoreApi\StoreApi; |
| 9 | use Automattic\WooCommerce\Utilities\StringUtil; |
| 10 | |
| 11 | /** |
| 12 | * Dependency injection container used at runtime. |
| 13 | * |
| 14 | * This is a simple container that doesn't implement explicit class registration. |
| 15 | * Instead, all the classes in the Automattic\WooCommerce namespace can be resolved |
| 16 | * and are considered as implicitly registered as single-instance classes |
| 17 | * (so each class will be instantiated only once and the instance will be cached). |
| 18 | */ |
| 19 | class RuntimeContainer { |
| 20 | /** |
| 21 | * The root namespace of all WooCommerce classes in the `src` directory. |
| 22 | * |
| 23 | * @var string |
| 24 | */ |
| 25 | const WOOCOMMERCE_NAMESPACE = 'Automattic\\WooCommerce\\'; |
| 26 | |
| 27 | /** |
| 28 | * Cache of classes already resolved. |
| 29 | * |
| 30 | * @var array |
| 31 | */ |
| 32 | protected array $resolved_cache; |
| 33 | |
| 34 | /** |
| 35 | * A copy of the initial resolved classes cache passed to the constructor. |
| 36 | * |
| 37 | * @var array |
| 38 | */ |
| 39 | protected array $initial_resolved_cache; |
| 40 | |
| 41 | /** |
| 42 | * Initializes a new instance of the class. |
| 43 | * |
| 44 | * @param array $initial_resolved_cache Dictionary of class name => instance, to be used as the starting point for the resolved classes cache. |
| 45 | */ |
| 46 | public function __construct( array $initial_resolved_cache ) { |
| 47 | $this->initial_resolved_cache = $initial_resolved_cache; |
| 48 | $this->resolved_cache = $initial_resolved_cache; |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * Get an instance of a class. |
| 53 | * |
| 54 | * ContainerException will be thrown in these cases: |
| 55 | * |
| 56 | * - $class_name is outside the WooCommerce root namespace (and wasn't included in the initial resolve cache). |
| 57 | * - The class referred by $class_name doesn't exist. |
| 58 | * - Recursive resolution condition found. |
| 59 | * - Reflection exception thrown when instantiating or initializing the class. |
| 60 | * |
| 61 | * A "recursive resolution condition" happens when class A depends on class B and at the same time class B depends on class A, directly or indirectly; |
| 62 | * without proper handling this would lead to an infinite loop. |
| 63 | * |
| 64 | * Note that this method throwing ContainerException implies that code fixes are needed, it's not an error condition that's recoverable at runtime. |
| 65 | * |
| 66 | * @template T of object |
| 67 | * @param string $class_name Class name. |
| 68 | * @phpstan-param class-string<T> $class_name |
| 69 | * |
| 70 | * @return T Object instance. |
| 71 | * @throws ContainerException Error when resolving the class to an object instance. |
| 72 | * @throws \Exception Exception thrown in the constructor or in the 'init' method of one of the resolved classes. |
| 73 | */ |
| 74 | public function get( string $class_name ) { |
| 75 | $class_name = trim( $class_name, '\\' ); |
| 76 | $resolve_chain = array(); |
| 77 | // @phpstan-ignore return.type (get_core uses reflection to instantiate the correct class type at runtime) |
| 78 | return $this->get_core( $class_name, $resolve_chain ); |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Core function to get an instance of a class. |
| 83 | * |
| 84 | * @param string $class_name The class name. |
| 85 | * @param array $resolve_chain Classes already resolved in this resolution chain. Passed between recursive calls to the method in order to detect a recursive resolution condition. |
| 86 | * @return object The resolved object. |
| 87 | * @throws ContainerException Error when resolving the class to an object instance. |
| 88 | */ |
| 89 | protected function get_core( string $class_name, array &$resolve_chain ) { |
| 90 | if ( isset( $this->resolved_cache[ $class_name ] ) ) { |
| 91 | return $this->resolved_cache[ $class_name ]; |
| 92 | } |
| 93 | |
| 94 | // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped |
| 95 | |
| 96 | if ( in_array( $class_name, $resolve_chain, true ) ) { |
| 97 | throw new ContainerException( "Recursive resolution of class '$class_name'. Resolution chain: " . implode( ', ', $resolve_chain ) ); |
| 98 | } |
| 99 | |
| 100 | if ( ! $this->is_class_allowed( $class_name ) ) { |
| 101 | throw new ContainerException( "Attempt to get an instance of class '$class_name', which is not in the " . self::WOOCOMMERCE_NAMESPACE . ' namespace. Did you forget to add a namespace import?' ); |
| 102 | } |
| 103 | |
| 104 | if ( ! class_exists( $class_name ) ) { |
| 105 | throw new ContainerException( "Attempt to get an instance of class '$class_name', which doesn't exist." ); |
| 106 | } |
| 107 | |
| 108 | // Account for the containers used by the Store API and Blocks. |
| 109 | if ( StringUtil::starts_with( $class_name, 'Automattic\WooCommerce\StoreApi\\' ) ) { |
| 110 | return StoreApi::container()->get( $class_name ); |
| 111 | } |
| 112 | if ( StringUtil::starts_with( $class_name, 'Automattic\WooCommerce\Blocks\\' ) ) { |
| 113 | return BlocksPackage::container()->get( $class_name ); |
| 114 | } |
| 115 | |
| 116 | $resolve_chain[] = $class_name; |
| 117 | |
| 118 | try { |
| 119 | $instance = $this->instantiate_class_using_reflection( $class_name, $resolve_chain ); |
| 120 | } catch ( \ReflectionException $e ) { |
| 121 | throw new ContainerException( "Reflection error when resolving '$class_name': (" . get_class( $e ) . ") {$e->getMessage()}", 0, $e ); |
| 122 | } |
| 123 | |
| 124 | // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped |
| 125 | |
| 126 | $this->resolved_cache[ $class_name ] = $instance; |
| 127 | |
| 128 | return $instance; |
| 129 | } |
| 130 | |
| 131 | // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber |
| 132 | /** |
| 133 | * Get an instance of a class using reflection. |
| 134 | * This method recursively calls 'get_core' (which in turn calls this method) for each of the arguments |
| 135 | * in the 'init' method of the resolved class (if the method is public and non-static). |
| 136 | * |
| 137 | * @param string $class_name The name of the class to resolve. |
| 138 | * @param array $resolve_chain Classes already resolved in this resolution chain. Passed between recursive calls to the method in order to detect a recursive resolution condition. |
| 139 | * @return object The resolved object. |
| 140 | * |
| 141 | * @throws ContainerException The 'init' method has invalid arguments. |
| 142 | * @throws \ReflectionException Something went wrong when using reflection to get information about the class to resolve. |
| 143 | */ |
| 144 | private function instantiate_class_using_reflection( string $class_name, array &$resolve_chain ): object { |
| 145 | $ref_class = new \ReflectionClass( $class_name ); |
| 146 | |
| 147 | // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped |
| 148 | |
| 149 | $constructor = $ref_class->getConstructor(); |
| 150 | if ( ! is_null( $constructor ) ) { |
| 151 | if ( ! $constructor->isPublic() ) { |
| 152 | throw new ContainerException( "Error resolving '$class_name': the class doesn't have a public constructor." ); |
| 153 | } |
| 154 | $constructor_arguments = $constructor->getParameters(); |
| 155 | foreach ( $constructor_arguments as $argument ) { |
| 156 | if ( ! $argument->isOptional() ) { |
| 157 | throw new ContainerException( "Error resolving '$class_name': the class constructor has non-optional arguments." ); |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | $instance = $ref_class->newInstance(); |
| 163 | if ( ! $ref_class->hasMethod( 'init' ) ) { |
| 164 | return $instance; |
| 165 | } |
| 166 | |
| 167 | $init_method = $ref_class->getMethod( 'init' ); |
| 168 | if ( ! $init_method->isPublic() || $init_method->isStatic() ) { |
| 169 | return $instance; |
| 170 | } |
| 171 | |
| 172 | $init_args = $init_method->getParameters(); |
| 173 | $init_arg_instances = array_map( |
| 174 | function ( \ReflectionParameter $arg ) use ( $class_name, &$resolve_chain ) { |
| 175 | $arg_type = $arg->getType(); |
| 176 | if ( ! ( $arg_type instanceof \ReflectionNamedType ) ) { |
| 177 | throw new ContainerException( "Error resolving '$class_name': argument '\${$arg->getName()}' doesn't have a type declaration." ); |
| 178 | } |
| 179 | if ( $arg_type->isBuiltin() ) { |
| 180 | throw new ContainerException( "Error resolving '$class_name': argument '\${$arg->getName()}' is not of a class type." ); |
| 181 | } |
| 182 | if ( $arg->isPassedByReference() ) { |
| 183 | throw new ContainerException( "Error resolving '$class_name': argument '\${$arg->getName()}' is passed by reference." ); |
| 184 | } |
| 185 | return $this->get_core( $arg_type->getName(), $resolve_chain ); |
| 186 | }, |
| 187 | $init_args |
| 188 | ); |
| 189 | |
| 190 | // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped |
| 191 | |
| 192 | $init_method->invoke( $instance, ...$init_arg_instances ); |
| 193 | |
| 194 | return $instance; |
| 195 | } |
| 196 | // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber |
| 197 | |
| 198 | /** |
| 199 | * Tells if the 'get' method can be used to resolve a given class. |
| 200 | * |
| 201 | * Note that 'get' can throw an exception even if this method returns true, |
| 202 | * for example for classes that are in the correct namespace but don't have a public constructor. |
| 203 | * |
| 204 | * @param string $class_name The class name. |
| 205 | * @return bool True if the class with the supplied name can be resolved with 'get'. |
| 206 | */ |
| 207 | public function has( string $class_name ): bool { |
| 208 | $class_name = trim( $class_name, '\\' ); |
| 209 | return $this->is_class_allowed( $class_name ) || isset( $this->resolved_cache[ $class_name ] ); |
| 210 | } |
| 211 | |
| 212 | /** |
| 213 | * Checks to see whether a class is allowed to be registered. |
| 214 | * |
| 215 | * @param string $class_name The class to check. |
| 216 | * |
| 217 | * @return bool True if the class is allowed to be registered, false otherwise. |
| 218 | */ |
| 219 | protected function is_class_allowed( string $class_name ): bool { |
| 220 | return StringUtil::starts_with( $class_name, self::WOOCOMMERCE_NAMESPACE, false ); |
| 221 | } |
| 222 | } |
| 223 |