Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
77 / 77
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
ObjectFactory
100.00% covered (success)
100.00%
77 / 77
100.00% covered (success)
100.00%
5 / 5
38
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createObject
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getObjectFromSpec
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
1 / 1
27
 validateSpec
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 expandClosures
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
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
21namespace Wikimedia\ObjectFactory;
22
23use Closure;
24use InvalidArgumentException;
25use Psr\Container\ContainerInterface;
26use 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 */
84class 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}