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