aboutsummaryrefslogtreecommitdiffstats
path: root/includes/Settings/Source/FileSource.php
blob: e1aafc1383833b4338518d271fe441c566141e8b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
<?php

namespace MediaWiki\Settings\Source;

use MediaWiki\Settings\Cache\CacheableSource;
use MediaWiki\Settings\SettingsBuilderException;
use MediaWiki\Settings\Source\Format\JsonFormat;
use MediaWiki\Settings\Source\Format\SettingsFormat;
use MediaWiki\Settings\Source\Format\YamlFormat;
use UnexpectedValueException;
use Wikimedia\AtEase\AtEase;

/**
 * Settings loaded from a local file path.
 *
 * @since 1.38
 */
class FileSource implements CacheableSource {
	private const BUILT_IN_FORMATS = [
		JsonFormat::class,
		YamlFormat::class,
	];

	/**
	 * Cache expiry TTL for file sources (24 hours).
	 *
	 * @see getExpiryTtl()
	 * @see CacheableSource::getExpiryTtl()
	 */
	private const EXPIRY_TTL = 60 * 60 * 24;

	/**
	 * Early expiry weight. This value influences the margin by which
	 * processes are selected to expire cached local-file settings early to
	 * avoid cache stampedes. Changes to this value are not likely to be
	 * necessary as time spent loading from local files should not have much
	 * variation and should already be well served by the default early expiry
	 * calculation.
	 *
	 * @see getExpiryWeight()
	 * @see CacheableSource::getExpiryWeight()
	 */
	private const EXPIRY_WEIGHT = 1.0;

	/**
	 * Format to use for reading the file, if given.
	 *
	 * @var ?SettingsFormat
	 */
	private $format;

	/**
	 * Path to local file.
	 * @var string
	 */
	private $path;

	/**
	 * Constructs a new FileSource for the given path and possibly a custom format
	 * to decode the contents. If no format is given, the built-in formats will be
	 * tried and the first one that supports the file extension will be used.
	 *
	 * Built-in formats:
	 *  - JsonFormat
	 *  - YamlFormat
	 *
	 * <code>
	 * <?php
	 * $source = new FileSource( 'my/settings.json' );
	 * $source->load();
	 * </code>
	 *
	 * While a specialized caller may want to pass a specialized format
	 *
	 * <code>
	 * <?php
	 * $source = new FileSource(
	 *     'my/settings.toml',
	 *     new TomlFormat()
	 * );
	 * $source->load();
	 * </code>
	 *
	 * @param string $path
	 * @param SettingsFormat|null $format
	 */
	public function __construct( string $path, SettingsFormat $format = null ) {
		$this->path = $path;
		$this->format = $format;
	}

	/**
	 * Loads contents from the file and decodes them using the first format
	 * to claim support for the file's extension.
	 *
	 * @throws SettingsBuilderException
	 * @return array
	 */
	public function load(): array {
		$ext = pathinfo( $this->path, PATHINFO_EXTENSION );

		// If there's only one format, don't bother to match the file
		// extension.
		if ( $this->format ) {
			return $this->readAndDecode( $this->format );
		}

		foreach ( self::BUILT_IN_FORMATS as $format ) {
			if ( call_user_func( [ $format, 'supportsFileExtension' ], $ext ) ) {
				return $this->readAndDecode( new $format() );
			}
		}

		throw new SettingsBuilderException(
			"None of the built-in formats are suitable for '{path}'",
			[
				'path' => $this->path,
			]
		);
	}

	/**
	 * The cache expiry TTL (in seconds) for this file source.
	 *
	 * @return int
	 */
	public function getExpiryTtl(): int {
		return self::EXPIRY_TTL;
	}

	/**
	 * Coefficient used in determining early expiration of cached settings to
	 * avoid stampedes.
	 *
	 * @return float
	 */
	public function getExpiryWeight(): float {
		return self::EXPIRY_WEIGHT;
	}

	/**
	 * Returns a hash key computed from the file's inode, size, and last
	 * modified timestamp.
	 *
	 * @return string
	 */
	public function getHashKey(): string {
		$stat = stat( $this->path );

		if ( $stat === false ) {
			throw new SettingsBuilderException(
				"Failed to stat file '{path}'",
				[ 'path' => $this->path ]
			);
		}

		return sprintf( '%x-%x-%x', $stat['ino'], $stat['size'], $stat['mtime'] );
	}

	/**
	 * Returns this file source as a string.
	 *
	 * @return string
	 */
	public function __toString(): string {
		return $this->path;
	}

	/**
	 * Reads and decodes the file contents using the given format.
	 *
	 * @param SettingsFormat $format
	 *
	 * @return array
	 * @throws SettingsBuilderException
	 */
	private function readAndDecode( SettingsFormat $format ): array {
		$contents = AtEase::quietCall( 'file_get_contents', $this->path );

		if ( $contents === false ) {
			if ( !is_readable( $this->path ) ) {
				throw new SettingsBuilderException(
					"File '{path}' is not readable",
					[ 'path' => $this->path ]
				);
			}

			if ( is_dir( $this->path ) ) {
				throw new SettingsBuilderException(
					"'{path}' is a directory, not a file",
					[ 'path' => $this->path ]
				);
			}

			throw new SettingsBuilderException(
				"Failed to read file '{path}'",
				[ 'path' => $this->path ]
			);
		}

		try {
			return $format->decode( $contents );
		} catch ( UnexpectedValueException $e ) {
			throw new SettingsBuilderException(
				"Failed to decode file '{path}': {message}",
				[
					'path' => $this->path,
					'message' => $e->getMessage()
				]
			);
		}
	}
}