Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.24% covered (warning)
73.24%
52 / 71
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReflectionSchemaSource
73.24% covered (warning)
73.24%
52 / 71
28.57% covered (danger)
28.57%
2 / 7
29.45
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 load
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadAsComponents
91.67% covered (success)
91.67%
33 / 36
0.00% covered (danger)
0.00%
0 / 1
9.05
 loadAsSchema
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 __toString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeComment
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeDynamicDefault
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
11.25
1<?php
2
3namespace MediaWiki\Settings\Source;
4
5use Closure;
6use MediaWiki\Settings\SettingsBuilderException;
7use ReflectionClass;
8use ReflectionException;
9
10/**
11 * Constructs a settings array based on a PHP class by inspecting class
12 * members to construct a schema.
13 *
14 * The value of each constant must be an array structured like a JSON Schema.
15 * For convenience, type declarations support PHPDoc style types in addition to
16 * JSON types. To avoid confusion, use 'list' for sequential arrays and 'map'
17 * for associative arrays.
18 *
19 * Dynamic default values can be declared using the 'dynamicDefault' key.
20 * The structure of the dynamic default declaration is an array with two keys:
21 * - 'callback': this is a PHP callable string or array, closures are not supported.
22 * - 'use': A list of other config variables that the dynamic default depends on.
23 *   The values of these variables will be passed to the callback as parameters.
24 *
25 * The following shorthands can be used with dynamic default declarations:
26 * - if the value for 'use' is empty, it can be omitted.
27 * - if 'callback' is omitted, it is assumed to be a static method "getDefault$name" on
28 *   the same class where $name is the name of the variable.
29 * - if the dynamic default declaration is not an array but a string, that
30 *   string is taken to be the callback, with no parameters.
31 * - if the dynamic default declaration is the boolean value true,
32 *   the callback is assumed to be a static method "getDefault$name" on
33 *   the same class where $name is the name of the variable.
34 *
35 * @since 1.39
36 */
37class ReflectionSchemaSource implements SettingsSource {
38    use JsonSchemaTrait;
39
40    /**
41     * Name of a PHP class
42     * @var string
43     */
44    private $class;
45
46    /**
47     * @var bool
48     */
49    private $includeDoc;
50
51    /**
52     * @param string $class
53     * @param bool $includeDoc
54     */
55    public function __construct( string $class, bool $includeDoc = false ) {
56        $this->class = $class;
57        $this->includeDoc = $includeDoc;
58    }
59
60    /**
61     * @inheritDoc
62     */
63    public function load(): array {
64        return $this->loadAsComponents();
65    }
66
67    /**
68     * @param bool $inlineReferences Whether the references found in the schema `$ref` should
69     * be inlined, meaning resolving its final type and embedding it as a regular schema. No
70     * definitions `$defs` will be returned.
71     * @throws SettingsBuilderException
72     * @return array
73     */
74    public function loadAsComponents( bool $inlineReferences = false ): array {
75        $schemas = [];
76        $defs = [];
77        $obsolete = [];
78
79        try {
80            $class = new ReflectionClass( $this->class );
81            foreach ( $class->getReflectionConstants() as $const ) {
82                if ( !$const->isPublic() ) {
83                    continue;
84                }
85
86                $name = $const->getName();
87                $schema = $const->getValue();
88
89                if ( !is_array( $schema ) ) {
90                    continue;
91                }
92
93                if ( isset( $schema['obsolete'] ) ) {
94                    $obsolete[ $name ] = $schema['obsolete'];
95                    continue;
96                }
97
98                if ( $this->includeDoc ) {
99                    $doc = $const->getDocComment();
100                    if ( $doc ) {
101                        $schema['description'] = $this->normalizeComment( $doc );
102                    }
103                }
104
105                if ( isset( $schema['dynamicDefault'] ) ) {
106                    $schema['dynamicDefault'] =
107                        $this->normalizeDynamicDefault( $name, $schema['dynamicDefault'] );
108                }
109
110                $schema['default'] ??= null;
111
112                $schema = self::normalizeJsonSchema( $schema, $defs, $this->class, $name, $inlineReferences );
113
114                $schemas[ $name ] = $schema;
115            }
116        } catch ( ReflectionException $e ) {
117            throw new SettingsBuilderException(
118                'Failed to load schema from class {class}',
119                [ 'class' => $this->class ],
120                0,
121                $e
122            );
123        }
124
125        return [
126            'config-schema' => $schemas,
127            'schema-definitions' => $defs,
128            'obsolete-config' => $obsolete
129        ];
130    }
131
132    /**
133     * Load the data as a single top-level JSON Schema.
134     *
135     * Returned JSON Schema is for an object, which includes the individual config schemas. The
136     * returned schema may contain `$defs`, which then may be referenced internally in the schema
137     * via `$ref`.
138     *
139     * @param bool $inlineReferences Whether the references found in the schema `$ref` should
140     * be inlined, meaning resolving its final type and embedding it as a regular schema. No
141     * definitions `$defs` will be returned.
142     * @return array
143     */
144    public function loadAsSchema( bool $inlineReferences = false ): array {
145        $info = $this->loadAsComponents( $inlineReferences );
146        $schema = [
147            'type' => 'object',
148            'properties' => $info['config-schema'],
149        ];
150
151        if ( $info['schema-definitions'] ) {
152            $schema['$defs'] = $info['schema-definitions'];
153        }
154
155        return $schema;
156    }
157
158    /**
159     * Returns this file source as a string.
160     *
161     * @return string
162     */
163    public function __toString(): string {
164        return 'class ' . $this->class;
165    }
166
167    private function normalizeComment( string $doc ) {
168        $doc = preg_replace( '/^\s*\/\*+\s*|\s*\*+\/\s*$/', '', $doc );
169        $doc = preg_replace( '/^\s*\**$/m', " ", $doc );
170        $doc = preg_replace( '/^\s*\**[ \t]?/m', '', $doc );
171        return $doc;
172    }
173
174    private function normalizeDynamicDefault( string $name, $spec ) {
175        if ( $spec === true ) {
176            $spec = [ 'callback' => [ $this->class, "getDefault{$name}" ] ];
177        }
178
179        if ( is_string( $spec ) ) {
180            $spec = [ 'callback' => $spec ];
181        }
182
183        if ( !isset( $spec['callback'] ) ) {
184            $spec['callback'] = [ $this->class, "getDefault{$name}" ];
185        }
186
187        // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset per fallback above.
188        if ( $spec['callback'] instanceof Closure ) {
189            throw new SettingsBuilderException(
190                "dynamicDefaults callback for $name must be JSON serializable. " .
191                "Closures are not supported."
192            );
193        }
194
195        if ( !is_callable( $spec['callback'] ) ) {
196            $pretty = var_export( $spec['callback'], true );
197            $pretty = preg_replace( '/\s+/', ' ', $pretty );
198
199            throw new SettingsBuilderException(
200                "dynamicDefaults callback for $name is not callable: " .
201                $pretty
202            );
203        }
204
205        return $spec;
206    }
207
208}