Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.19% covered (success)
92.19%
59 / 64
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiPageConfigLoader
92.19% covered (success)
92.19%
59 / 64
62.50% covered (warning)
62.50%
5 / 8
18.15
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 makeCacheKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 invalidate
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 load
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 loadFromWanCache
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 removeCustomFlags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadUncached
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 fetchConfig
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
6.01
1<?php
2
3namespace GrowthExperiments\Config;
4
5use GrowthExperiments\Config\Validation\ConfigValidatorFactory;
6use GrowthExperiments\Util;
7use MediaWiki\Api\ApiRawMessage;
8use MediaWiki\Content\JsonContent;
9use MediaWiki\Http\HttpRequestFactory;
10use MediaWiki\Json\FormatJson;
11use MediaWiki\Linker\LinkTarget;
12use MediaWiki\Revision\RevisionLookup;
13use MediaWiki\Revision\RevisionRecord;
14use MediaWiki\Revision\SlotRecord;
15use MediaWiki\Title\TitleFactory;
16use MediaWiki\Utils\UrlUtils;
17use StatusValue;
18use Wikimedia\LightweightObjectStore\ExpirationAwareness;
19use Wikimedia\ObjectCache\HashBagOStuff;
20use Wikimedia\ObjectCache\WANObjectCache;
21use Wikimedia\Rdbms\DBAccessObjectUtils;
22use Wikimedia\Rdbms\IDBAccessObject;
23
24/**
25 * This class allows callers to fetch various variables
26 * from JSON pages stored on-wiki (the pages need to have JSON
27 * as their content model). It is currently used for configuration
28 * of NewcomerTasks (see [[:w:cs:MediaWiki:NewcomerTasks]] as an example).
29 *
30 * The MediaWiki pages need to be formatted like this:
31 * {
32 *         "ConfigVariable": "value",
33 *         "OtherConfigVariable": "value"
34 * }
35 *
36 * Previously present in GrowthExperiments
37 * as \GrowthExperiments\NewcomerTasks\ConfigurationLoader\PageLoader,
38 * generalized to this class taking care about config in general.
39 */
40class WikiPageConfigLoader implements ICustomReadConstants {
41
42    private ConfigValidatorFactory $configValidatorFactory;
43    private HttpRequestFactory $requestFactory;
44    private RevisionLookup $revisionLookup;
45    private TitleFactory $titleFactory;
46    private WANObjectCache $cache;
47    private HashBagOStuff $inProcessCache;
48    private UrlUtils $urlUtils;
49    /**
50     * @var bool Hack to disable DB access in non-database tests. The proper replacement to this would be a
51     * NullConfigLoader or similar class, and the ServiceWiring code would determine which one to use.
52     */
53    private bool $isTestWithStorageDisabled;
54
55    /**
56     * @param WANObjectCache $cache
57     * @param ConfigValidatorFactory $configValidatorFactory
58     * @param HttpRequestFactory $requestFactory
59     * @param RevisionLookup $revisionLookup
60     * @param TitleFactory $titleFactory
61     * @param UrlUtils $urlUtils
62     * @param bool $isTestWithStorageDisabled
63     */
64    public function __construct(
65        WANObjectCache $cache,
66        ConfigValidatorFactory $configValidatorFactory,
67        HttpRequestFactory $requestFactory,
68        RevisionLookup $revisionLookup,
69        TitleFactory $titleFactory,
70        UrlUtils $urlUtils,
71        bool $isTestWithStorageDisabled
72    ) {
73        $this->cache = $cache;
74        $this->inProcessCache = new HashBagOStuff();
75        $this->configValidatorFactory = $configValidatorFactory;
76        $this->requestFactory = $requestFactory;
77        $this->revisionLookup = $revisionLookup;
78        $this->titleFactory = $titleFactory;
79        $this->urlUtils = $urlUtils;
80        $this->isTestWithStorageDisabled = $isTestWithStorageDisabled;
81    }
82
83    /**
84     * @param LinkTarget $configPage
85     * @return string
86     */
87    private function makeCacheKey( LinkTarget $configPage ) {
88        return $this->cache->makeKey( 'GrowthExperiments',
89            'config', $configPage->getNamespace(), $configPage->getDBkey() );
90    }
91
92    /**
93     * @param LinkTarget $configPage
94     */
95    public function invalidate( LinkTarget $configPage ) {
96        $cacheKey = $this->makeCacheKey( $configPage );
97        $this->cache->delete( $cacheKey );
98        $this->inProcessCache->delete( $cacheKey );
99    }
100
101    /**
102     * Load the configured page, with caching.
103     * @param LinkTarget $configPage
104     * @param int $flags bit field, see IDBAccessObject::READ_XXX
105     * @return array|StatusValue The content of the configuration page (as JSON
106     *   data in PHP-native format), or a StatusValue on error.
107     */
108    public function load( LinkTarget $configPage, int $flags = 0 ) {
109        if (
110            DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST ) ||
111            // This is a custom flag, but bitfield logic should work regardless.
112            DBAccessObjectUtils::hasFlags( $flags, self::READ_UNCACHED )
113        ) {
114            // User does not want to used cached data, invalidate the cache.
115            $this->invalidate( $configPage );
116        }
117
118        // WANObjectCache has an in-process cache (pcTTL), but it is not subject
119        // to invalidation, which breaks WikiPageConfigLoaderTest.
120        return $this->inProcessCache->getWithSetCallback(
121            $this->makeCacheKey( $configPage ),
122            ExpirationAwareness::TTL_INDEFINITE,
123            function () use ( $configPage, $flags ) {
124                return $this->loadFromWanCache( $configPage, $flags );
125            }
126        );
127    }
128
129    /**
130     * Load configuration from the WAN cache
131     *
132     * @param LinkTarget $configPage
133     * @param int $flags bit field, see IDBAccessObject::READ_XXX
134     * @return array|StatusValue The content of the configuration page (as JSON
135     *   data in PHP-native format), or a StatusValue on error.
136     */
137    private function loadFromWanCache( LinkTarget $configPage, int $flags = 0 ) {
138        return $this->cache->getWithSetCallback(
139            $this->makeCacheKey( $configPage ),
140            // Cache config for a day; cache is invalidated by ConfigHooks::onPageSaveComplete
141            // and WikiPageConfigWriter::save when config files are changed.,
142            ExpirationAwareness::TTL_DAY,
143            function ( $oldValue, &$ttl ) use ( $configPage, $flags ) {
144                $result = $this->loadUncached( $configPage, $flags );
145                if ( $result instanceof StatusValue ) {
146                    // error should not be cached
147                    $ttl = ExpirationAwareness::TTL_UNCACHEABLE;
148                }
149                return $result;
150            }
151        );
152    }
153
154    /**
155     *
156     * @param int $flags Bitfield consisting of READ_* constants
157     * @return int Bitfield consisting only of standard IDBAccessObject READ_* constants.
158     */
159    private function removeCustomFlags( int $flags ): int {
160        return $flags & ~self::READ_UNCACHED;
161    }
162
163    /**
164     * Load the configuration page, bypassing caching.
165     *
166     * Caller is responsible for caching the result if desired.
167     *
168     * @param LinkTarget $configPage
169     * @param int $flags
170     * @return array|StatusValue
171     */
172    private function loadUncached( LinkTarget $configPage, int $flags = 0 ) {
173        $result = false;
174        $status = $this->fetchConfig( $configPage, $this->removeCustomFlags( $flags ) );
175        if ( $status->isOK() ) {
176            $result = $status->getValue();
177            $status->merge(
178                $this->configValidatorFactory
179                    ->newConfigValidator( $configPage )
180                    ->validate( $result )
181            );
182        }
183        if ( !$status->isOK() ) {
184            $result = $status;
185        }
186
187        return $result;
188    }
189
190    /**
191     * Fetch the contents of the configuration page, without caching.
192     *
193     * Result is not validated with a config validator.
194     *
195     * @param LinkTarget $configPage
196     * @param int $flags bit field, see IDBAccessObject::READ_XXX; do NOT pass READ_UNCACHED
197     * @return StatusValue Status object, with the configuration (as JSON data) on success.
198     */
199    private function fetchConfig( LinkTarget $configPage, int $flags ) {
200        // TODO: Move newcomer-tasks-* messages to...somewhere more generic
201
202        if ( $configPage->isExternal() ) {
203            $url = Util::getRawUrl( $configPage, $this->titleFactory, $this->urlUtils );
204            return Util::getJsonUrl( $this->requestFactory, $url );
205        } else {
206            $revision = $this->isTestWithStorageDisabled
207                ? null
208                : $this->revisionLookup->getRevisionByTitle( $configPage, 0, $flags );
209            if ( !$revision ) {
210                // The configuration page does not exist. Pretend it does not configure anything
211                // specific (failure mode and empty-page behavior is equal, see T325236).
212                return StatusValue::newGood( $this->configValidatorFactory
213                    ->newConfigValidator( $configPage )
214                    ->getDefaultContent()
215                );
216            }
217            $content = $revision->getContent( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC );
218            if ( !$content || !$content instanceof JsonContent ) {
219                return StatusValue::newFatal( new ApiRawMessage(
220                    'The configuration title has no content or is not JSON content.',
221                    'newcomer-tasks-configuration-loader-content-error'
222                ) );
223            }
224            return FormatJson::parse( $content->getText(), FormatJson::FORCE_ASSOC );
225        }
226    }
227}