Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
77 / 77 |
|
100.00% |
5 / 5 |
CRAP | |
100.00% |
1 / 1 |
ObjectFactory | |
100.00% |
77 / 77 |
|
100.00% |
5 / 5 |
38 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
createObject | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getObjectFromSpec | |
100.00% |
54 / 54 |
|
100.00% |
1 / 1 |
27 | |||
validateSpec | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
7 | |||
expandClosures | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | namespace Wikimedia\ObjectFactory; |
22 | |
23 | use Closure; |
24 | use InvalidArgumentException; |
25 | use Psr\Container\ContainerInterface; |
26 | use UnexpectedValueException; |
27 | |
28 | /** |
29 | * Construct objects based on a specification array. |
30 | * |
31 | * Contents of the specification array are as follows: |
32 | * |
33 | * 'factory' => callable, |
34 | * 'class' => string, |
35 | * |
36 | * The specification array must contain either a 'class' key with string value |
37 | * that specifies the class name to instantiate or a 'factory' key with a |
38 | * callable (is_callable() === true). If both are passed, 'factory' takes |
39 | * precedence but an InvalidArgumentException will be thrown if the resulting |
40 | * object is not an instance of the named class. |
41 | * |
42 | * 'args' => array, |
43 | * 'closure_expansion' => bool, // default true |
44 | * 'spec_is_arg' => bool, // default false |
45 | * 'services' => (string|null)[], // default empty |
46 | * 'optional_services' => (string|null)[], // default empty |
47 | * |
48 | * The 'args' key, if provided, specifies arguments to pass to the constructor/callable. |
49 | * Values in 'args' which are Closure instances will be expanded by invoking |
50 | * them with no arguments before passing the resulting value on to the |
51 | * constructor/callable. This can be used to pass live objects to the |
52 | * constructor/callable. This behavior can be suppressed by adding |
53 | * closure_expansion => false to the specification. |
54 | * |
55 | * If 'spec_is_arg' => true is in the specification, 'args' is ignored. The |
56 | * entire spec array is passed to the constructor/callable instead. |
57 | * |
58 | * If 'services' is supplied and non-empty (and a service container is available), |
59 | * the named services are requested from the PSR-11 service container and |
60 | * prepended before 'args'. `null` values in 'services' are passed to the constructor |
61 | * unchanged. |
62 | * |
63 | * Optional services declared via 'optional_services' are handled the same, |
64 | * except that if the service is not available from the service container |
65 | * `null` is passed as a parameter instead. Optional services are appended |
66 | * directly after the normal required services. |
67 | * |
68 | * If any extra arguments are passed in the options to getObjectFromSpec() or |
69 | * createObject(), these are prepended before the 'services' and 'args'. |
70 | * |
71 | * 'calls' => array |
72 | * |
73 | * The specification may also contain a 'calls' key that describes method |
74 | * calls to make on the newly created object before returning it. This |
75 | * pattern is often known as "setter injection". The value of this key is |
76 | * expected to be an associative array with method names as keys and |
77 | * argument lists as values. The argument list will be expanded (or not) |
78 | * in the same way as the 'args' key for the main object. |
79 | * |
80 | * Note these calls are not passed the extra arguments. |
81 | * |
82 | * @copyright © 2014 Wikimedia Foundation and contributors |
83 | */ |
84 | class ObjectFactory { |
85 | |
86 | /** @var ContainerInterface Service container */ |
87 | protected ContainerInterface $serviceContainer; |
88 | |
89 | /** |
90 | * @param ContainerInterface $serviceContainer Service container |
91 | */ |
92 | public function __construct( ContainerInterface $serviceContainer ) { |
93 | $this->serviceContainer = $serviceContainer; |
94 | } |
95 | |
96 | /** |
97 | * Instantiate an object based on a specification array. |
98 | * |
99 | * This calls getObjectFromSpec(), with the ContainerInterface that was |
100 | * passed to the constructor passed as `$options['serviceContainer']`. |
101 | * |
102 | * @phan-template T |
103 | * @phpcs:disable Generic.Files.LineLength |
104 | * @phan-param class-string<T>|callable(mixed ...$args):T|array{class?:class-string<T>,factory?:callable(mixed ...$args):T,args?:array,services?:array<string|null>,optional_services?:array<string|null>,calls?:string[],closure_expansion?:bool,spec_is_arg?:bool} $spec |
105 | * @phan-param array{allowClassName?:bool,allowCallable?:bool,extraArgs?:array,assertClass?:string} $options |
106 | * @phpcs:enable |
107 | * @phan-return T|object |
108 | * |
109 | * @param array|string|callable $spec Specification array, or (when the respective |
110 | * $options flag is set) a class name or callable. Allowed fields (see class |
111 | * documentation for more details): |
112 | * - 'class': (string) Class of the object to create. If 'factory' is also specified, |
113 | * it will be used to validate the object. |
114 | * - 'factory': (callable) Factory method for creating the object. |
115 | * - 'args': (array) Arguments to pass to the constructor or the factory method. |
116 | * - 'services': (array of string/null) List of services to pass as arguments. Each |
117 | * name will be looked up in the container given to ObjectFactory in its constructor, |
118 | * and the results prepended to the argument list. Null values are passed unchanged. |
119 | * - 'optional_services': (array of string/null) Handled the same as services, but if |
120 | * the service is unavailable from the service container the parameter is set to 'null' |
121 | * instead of causing an error. |
122 | * - 'calls': (array) A list of calls to perform on the created object, for setter |
123 | * injection. Keys of the array are method names and values are argument lists |
124 | * (as arrays). These arguments are not affected by any of the other specification |
125 | * fields that manipulate constructor arguments. |
126 | * - 'closure_expansion': (bool, default true) Whether to expand (execute) closures |
127 | * in 'args'. |
128 | * - 'spec_is_arg': (bool, default false) When true, 'args' is ignored and the entire |
129 | * specification array is passed as an argument. |
130 | * One of 'class' and 'factory' is required. |
131 | * @param array $options Allowed keys are |
132 | * - 'allowClassName': (bool) If set and truthy, $spec may be a string class name. |
133 | * In this case, it will be treated as if it were `[ 'class' => $spec ]`. |
134 | * - 'allowCallable': (bool) If set and truthy, $spec may be a callable. In this |
135 | * case, it will be treated as if it were `[ 'factory' => $spec ]`. |
136 | * - 'extraArgs': (array) Extra arguments to pass to the constructor/callable. These |
137 | * will come before services and normal args. |
138 | * - 'assertClass': (string) Throw an UnexpectedValueException if the spec |
139 | * does not create an object of this class. |
140 | * @return object |
141 | * @throws InvalidArgumentException when object specification is not valid. |
142 | * @throws UnexpectedValueException when the factory returns a non-object, or |
143 | * the object is not an instance of the specified class. |
144 | */ |
145 | public function createObject( $spec, array $options = [] ) { |
146 | $options['serviceContainer'] = $this->serviceContainer; |
147 | // ObjectFactory::getObjectFromSpec accepts an array, not just a callable (phan bug) |
148 | // @phan-suppress-next-line PhanTypeInvalidCallableArraySize |
149 | return static::getObjectFromSpec( $spec, $options ); |
150 | } |
151 | |
152 | /** |
153 | * Instantiate an object based on a specification array. |
154 | * |
155 | * @phan-template T |
156 | * @phpcs:disable Generic.Files.LineLength |
157 | * @phan-param class-string<T>|callable(mixed ...$args):T|array{class?:class-string<T>,factory?:callable(mixed ...$args):T,args?:array,services?:array<string|null>,optional_services?:array<string|null>,calls?:string[],closure_expansion?:bool,spec_is_arg?:bool} $spec |
158 | * @phan-param array{allowClassName?:bool,allowCallable?:bool,extraArgs?:array,assertClass?:string,serviceContainer?:ContainerInterface} $options |
159 | * @phpcs:enable |
160 | * @phan-return T|object |
161 | * |
162 | * @param array|string|callable $spec As for createObject(). |
163 | * @param array $options As for createObject(). Additionally: |
164 | * - 'serviceContainer': (ContainerInterface) PSR-11 service container to use |
165 | * to handle 'services'. |
166 | * @return object |
167 | * @throws InvalidArgumentException when object specification is not valid. |
168 | * @throws InvalidArgumentException when $spec['services'] or $spec['optional_services'] |
169 | * is used without $options['serviceContainer'] being set and implementing ContainerInterface. |
170 | * @throws UnexpectedValueException when the factory returns a non-object, or |
171 | * the object is not an instance of the specified class. |
172 | */ |
173 | public static function getObjectFromSpec( $spec, array $options = [] ) { |
174 | $spec = static::validateSpec( $spec, $options ); |
175 | |
176 | $expandArgs = !isset( $spec['closure_expansion'] ) || $spec['closure_expansion']; |
177 | |
178 | if ( !empty( $spec['spec_is_arg'] ) ) { |
179 | $args = [ $spec ]; |
180 | } else { |
181 | $args = $spec['args'] ?? []; |
182 | |
183 | // $args should be a non-associative array; show nice error if that's not the case |
184 | if ( $args && array_keys( $args ) !== range( 0, count( $args ) - 1 ) ) { |
185 | throw new InvalidArgumentException( '\'args\' cannot be an associative array' ); |
186 | } |
187 | |
188 | if ( $expandArgs ) { |
189 | $args = static::expandClosures( $args ); |
190 | } |
191 | } |
192 | |
193 | $services = []; |
194 | if ( !empty( $spec['services'] ) || !empty( $spec['optional_services'] ) ) { |
195 | $container = $options['serviceContainer'] ?? null; |
196 | if ( !$container instanceof ContainerInterface ) { |
197 | throw new InvalidArgumentException( |
198 | '\'services\' and \'optional_services\' cannot be used without a service container' |
199 | ); |
200 | } |
201 | |
202 | if ( !empty( $spec['services'] ) ) { |
203 | foreach ( $spec['services'] as $service ) { |
204 | $services[] = $service === null ? null : $container->get( $service ); |
205 | } |
206 | } |
207 | |
208 | if ( !empty( $spec['optional_services'] ) ) { |
209 | foreach ( $spec['optional_services'] as $service ) { |
210 | if ( $service !== null && $container->has( $service ) ) { |
211 | $services[] = $container->get( $service ); |
212 | } else { |
213 | // Either $service was null, or the service was not available |
214 | $services[] = null; |
215 | } |
216 | } |
217 | } |
218 | } |
219 | |
220 | $args = array_merge( |
221 | $options['extraArgs'] ?? [], |
222 | $services, |
223 | $args |
224 | ); |
225 | |
226 | if ( isset( $spec['factory'] ) ) { |
227 | $obj = $spec['factory']( ...$args ); |
228 | if ( !is_object( $obj ) ) { |
229 | throw new UnexpectedValueException( '\'factory\' did not return an object' ); |
230 | } |
231 | // @phan-suppress-next-line PhanRedundantCondition |
232 | if ( isset( $spec['class'] ) && !$obj instanceof $spec['class'] ) { |
233 | throw new UnexpectedValueException( |
234 | '\'factory\' was expected to return an instance of ' . $spec['class'] |
235 | . ', got ' . get_class( $obj ) |
236 | ); |
237 | } |
238 | } elseif ( isset( $spec['class'] ) ) { |
239 | $clazz = $spec['class']; |
240 | $obj = new $clazz( ...$args ); |
241 | } else { |
242 | throw new InvalidArgumentException( |
243 | 'Provided specification lacks both \'factory\' and \'class\' parameters.' |
244 | ); |
245 | } |
246 | |
247 | // @phan-suppress-next-line PhanRedundantCondition |
248 | if ( isset( $options['assertClass'] ) && !$obj instanceof $options['assertClass'] ) { |
249 | throw new UnexpectedValueException( |
250 | 'Expected instance of ' . $options['assertClass'] . ', got ' . get_class( $obj ) |
251 | ); |
252 | } |
253 | |
254 | if ( isset( $spec['calls'] ) && is_array( $spec['calls'] ) ) { |
255 | // Call additional methods on the newly created object |
256 | foreach ( $spec['calls'] as $method => $margs ) { |
257 | if ( $expandArgs ) { |
258 | $margs = static::expandClosures( $margs ); |
259 | } |
260 | call_user_func_array( [ $obj, $method ], $margs ); |
261 | } |
262 | } |
263 | |
264 | return $obj; |
265 | } |
266 | |
267 | /** |
268 | * Convert a string or callable to a spec array |
269 | * |
270 | * @param array|string|callable $spec As for createObject() or getObjectFromSpec() |
271 | * @param array $options As for createObject() or getObjectFromSpec() |
272 | * @return array Specification array |
273 | * @throws InvalidArgumentException when object specification does not |
274 | * contain 'class' or 'factory' keys |
275 | */ |
276 | protected static function validateSpec( $spec, array $options ): array { |
277 | if ( is_callable( $spec ) ) { |
278 | if ( empty( $options['allowCallable'] ) ) { |
279 | throw new InvalidArgumentException( |
280 | 'Passing a raw callable is not allowed here. Use [ \'factory\' => $callable ] instead.' |
281 | ); |
282 | } |
283 | return [ 'factory' => $spec ]; |
284 | } |
285 | if ( is_string( $spec ) && class_exists( $spec ) ) { |
286 | if ( empty( $options['allowClassName'] ) ) { |
287 | throw new InvalidArgumentException( |
288 | 'Passing a raw class name is not allowed here. Use [ \'class\' => $classname ] instead.' |
289 | ); |
290 | } |
291 | return [ 'class' => $spec ]; |
292 | } |
293 | |
294 | if ( !is_array( $spec ) ) { |
295 | throw new InvalidArgumentException( 'Provided specification is not an array.' ); |
296 | } |
297 | |
298 | return $spec; |
299 | } |
300 | |
301 | /** |
302 | * Iterate a list and call any closures it contains. |
303 | * |
304 | * @param array $list List of things |
305 | * |
306 | * @return array List with any Closures replaced with their output |
307 | */ |
308 | protected static function expandClosures( array $list ): array { |
309 | return array_map( static function ( $value ) { |
310 | if ( $value instanceof Closure ) { |
311 | // If $value is a Closure, call it. |
312 | return $value(); |
313 | } |
314 | |
315 | return $value; |
316 | }, $list ); |
317 | } |
318 | |
319 | } |