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