Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
54.90% |
28 / 51 |
|
22.22% |
2 / 9 |
CRAP | |
0.00% |
0 / 1 |
FileSource | |
54.90% |
28 / 51 |
|
22.22% |
2 / 9 |
43.51 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
allowsStaleLoad | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
load | |
50.00% |
6 / 12 |
|
0.00% |
0 / 1 |
6.00 | |||
getExpiryTtl | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getExpiryWeight | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getHashKey | |
42.86% |
3 / 7 |
|
0.00% |
0 / 1 |
2.75 | |||
__toString | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
readAndDecode | |
64.00% |
16 / 25 |
|
0.00% |
0 / 1 |
6.17 | |||
locateInclude | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Settings\Source; |
4 | |
5 | use MediaWiki\Settings\Cache\CacheableSource; |
6 | use MediaWiki\Settings\SettingsBuilderException; |
7 | use MediaWiki\Settings\Source\Format\JsonFormat; |
8 | use MediaWiki\Settings\Source\Format\SettingsFormat; |
9 | use MediaWiki\Settings\Source\Format\YamlFormat; |
10 | use Stringable; |
11 | use UnexpectedValueException; |
12 | use Wikimedia\AtEase\AtEase; |
13 | |
14 | /** |
15 | * Settings loaded from a local file path. |
16 | * |
17 | * @since 1.38 |
18 | */ |
19 | class FileSource implements Stringable, CacheableSource, SettingsIncludeLocator { |
20 | private const BUILT_IN_FORMATS = [ |
21 | JsonFormat::class, |
22 | YamlFormat::class, |
23 | ]; |
24 | |
25 | /** |
26 | * Cache expiry TTL for file sources (24 hours). |
27 | * |
28 | * @see getExpiryTtl() |
29 | * @see CacheableSource::getExpiryTtl() |
30 | */ |
31 | private const EXPIRY_TTL = 60 * 60 * 24; |
32 | |
33 | /** |
34 | * Early expiry weight. This value influences the margin by which |
35 | * processes are selected to expire cached local-file settings early to |
36 | * avoid cache stampedes. Changes to this value are not likely to be |
37 | * necessary as time spent loading from local files should not have much |
38 | * variation and should already be well served by the default early expiry |
39 | * calculation. |
40 | * |
41 | * @see getExpiryWeight() |
42 | * @see CacheableSource::getExpiryWeight() |
43 | */ |
44 | private const EXPIRY_WEIGHT = 1.0; |
45 | |
46 | /** |
47 | * Format to use for reading the file, if given. |
48 | * |
49 | * @var ?SettingsFormat |
50 | */ |
51 | private $format; |
52 | |
53 | /** |
54 | * Path to local file. |
55 | * @var string |
56 | */ |
57 | private $path; |
58 | |
59 | /** |
60 | * Constructs a new FileSource for the given path and possibly a custom format |
61 | * to decode the contents. If no format is given, the built-in formats will be |
62 | * tried and the first one that supports the file extension will be used. |
63 | * |
64 | * Built-in formats: |
65 | * - JsonFormat |
66 | * - YamlFormat |
67 | * |
68 | * <code> |
69 | * <?php |
70 | * $source = new FileSource( 'my/settings.json' ); |
71 | * $source->load(); |
72 | * </code> |
73 | * |
74 | * While a specialized caller may want to pass a specialized format |
75 | * |
76 | * <code> |
77 | * <?php |
78 | * $source = new FileSource( |
79 | * 'my/settings.toml', |
80 | * new TomlFormat() |
81 | * ); |
82 | * $source->load(); |
83 | * </code> |
84 | * |
85 | * @param string $path |
86 | * @param SettingsFormat|null $format |
87 | */ |
88 | public function __construct( string $path, ?SettingsFormat $format = null ) { |
89 | $this->path = $path; |
90 | $this->format = $format; |
91 | } |
92 | |
93 | /** |
94 | * Disallow stale results from file sources in the case of load failure as |
95 | * failing to read from disk would be quite catastrophic and worthy of |
96 | * propagation. |
97 | * |
98 | * @return bool |
99 | */ |
100 | public function allowsStaleLoad(): bool { |
101 | return false; |
102 | } |
103 | |
104 | /** |
105 | * Loads contents from the file and decodes them using the first format |
106 | * to claim support for the file's extension. |
107 | * |
108 | * @throws SettingsBuilderException |
109 | * @return array |
110 | */ |
111 | public function load(): array { |
112 | $ext = pathinfo( $this->path, PATHINFO_EXTENSION ); |
113 | |
114 | // If there's only one format, don't bother to match the file |
115 | // extension. |
116 | if ( $this->format ) { |
117 | return $this->readAndDecode( $this->format ); |
118 | } |
119 | |
120 | foreach ( self::BUILT_IN_FORMATS as $format ) { |
121 | if ( call_user_func( [ $format, 'supportsFileExtension' ], $ext ) ) { |
122 | return $this->readAndDecode( new $format() ); |
123 | } |
124 | } |
125 | |
126 | throw new SettingsBuilderException( |
127 | "None of the built-in formats are suitable for '{path}'", |
128 | [ |
129 | 'path' => $this->path, |
130 | ] |
131 | ); |
132 | } |
133 | |
134 | /** |
135 | * The cache expiry TTL (in seconds) for this file source. |
136 | * |
137 | * @return int |
138 | */ |
139 | public function getExpiryTtl(): int { |
140 | return self::EXPIRY_TTL; |
141 | } |
142 | |
143 | /** |
144 | * Coefficient used in determining early expiration of cached settings to |
145 | * avoid stampedes. |
146 | * |
147 | * @return float |
148 | */ |
149 | public function getExpiryWeight(): float { |
150 | return self::EXPIRY_WEIGHT; |
151 | } |
152 | |
153 | /** |
154 | * Returns a hash key computed from the file's inode, size, and last |
155 | * modified timestamp. |
156 | * |
157 | * @return string |
158 | */ |
159 | public function getHashKey(): string { |
160 | $stat = stat( $this->path ); |
161 | |
162 | if ( $stat === false ) { |
163 | throw new SettingsBuilderException( |
164 | "Failed to stat file '{path}'", |
165 | [ 'path' => $this->path ] |
166 | ); |
167 | } |
168 | |
169 | return sprintf( '%x-%x-%x', $stat['ino'], $stat['size'], $stat['mtime'] ); |
170 | } |
171 | |
172 | /** |
173 | * Returns this file source as a string. |
174 | * |
175 | * @return string |
176 | */ |
177 | public function __toString(): string { |
178 | return $this->path; |
179 | } |
180 | |
181 | /** |
182 | * Reads and decodes the file contents using the given format. |
183 | * |
184 | * @param SettingsFormat $format |
185 | * |
186 | * @return array |
187 | * @throws SettingsBuilderException |
188 | */ |
189 | private function readAndDecode( SettingsFormat $format ): array { |
190 | $contents = AtEase::quietCall( 'file_get_contents', $this->path ); |
191 | |
192 | if ( $contents === false ) { |
193 | if ( !is_readable( $this->path ) ) { |
194 | throw new SettingsBuilderException( |
195 | "File '{path}' is not readable", |
196 | [ 'path' => $this->path ] |
197 | ); |
198 | } |
199 | |
200 | if ( is_dir( $this->path ) ) { |
201 | throw new SettingsBuilderException( |
202 | "'{path}' is a directory, not a file", |
203 | [ 'path' => $this->path ] |
204 | ); |
205 | } |
206 | |
207 | throw new SettingsBuilderException( |
208 | "Failed to read file '{path}'", |
209 | [ 'path' => $this->path ] |
210 | ); |
211 | } |
212 | |
213 | try { |
214 | return $format->decode( $contents ); |
215 | } catch ( UnexpectedValueException $e ) { |
216 | throw new SettingsBuilderException( |
217 | "Failed to decode file '{path}': {message}", |
218 | [ |
219 | 'path' => $this->path, |
220 | 'message' => $e->getMessage() |
221 | ] |
222 | ); |
223 | } |
224 | } |
225 | |
226 | public function locateInclude( string $location ): string { |
227 | return SettingsFileUtils::resolveRelativeLocation( $location, dirname( $this->path ) ); |
228 | } |
229 | } |