Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
47.69% covered (danger)
47.69%
31 / 65
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ModuleManager
47.69% covered (danger)
47.69%
31 / 65
37.50% covered (danger)
37.50%
3 / 8
98.71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getRouteFiles
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 initRouteFiles
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 hasApiSpecs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getApiSpecs
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
6.20
 getApiSpecDefs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 populateFromFile
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 getModuleDefinitionInfo
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Rest\Module;
4
5use MediaWiki\Config\ServiceOptions;
6use MediaWiki\MainConfigNames;
7use MediaWiki\Rest\JsonLocalizer;
8use MediaWiki\Rest\ResponseFactory;
9use Wikimedia\ObjectCache\BagOStuff;
10
11/**
12 * Manages information related to REST modules, such as routes and specs
13 *
14 * TODO: consider whether some code in Router or SpecBasedModule might be better placed here.
15 * TODO: consider whether we should inject a ModuleManager instance into Router for shared caching.
16 *
17 * @since 1.46
18 */
19class ModuleManager {
20    // These modules will be enabled. No config entry is needed.
21    private const CORE_ROUTE_FILES = [
22        'includes/Rest/coreRoutes.json',
23    ];
24
25    // These specs will be available in the Rest Sandbox. No config change is needed.
26    private const CORE_SPECS = [
27        'mw-extra' => [
28            'url' => '/rest.php/specs/v0/module/-',
29            'name' => 'MediaWiki REST API (routes not in modules)',
30        ]
31    ];
32
33    /** Seconds to persist module definitions on cache */
34    private const MODULE_DEFINITION_TTL = 60;
35
36    /** @var string[]|null */
37    private ?array $routeFiles = null;
38
39    private string $extensionDirectory;
40
41    private array $restApiAdditionalRouteFiles;
42    private array $restSandboxSpecs;
43
44    private string $scriptPath;
45
46    /** Persistent local server/host cache (e.g. APCu) */
47    private BagOStuff $srvCache;
48
49    private ResponseFactory $responseFactory;
50
51    /**
52     * @internal
53     */
54    public const CONSTRUCTOR_OPTIONS = [
55        MainConfigNames::ExtensionDirectory,
56        MainConfigNames::RestAPIAdditionalRouteFiles,
57        MainConfigNames::RestSandboxSpecs,
58        MainConfigNames::ScriptPath
59    ];
60
61    /**
62     * @param ServiceOptions $options
63     * @param BagOStuff $srvCache Optional BagOStuff instance to an APC-style cache.
64     * @param ResponseFactory $responseFactory
65     *
66     * @internal
67     */
68    public function __construct( ServiceOptions $options, BagOStuff $srvCache, ResponseFactory $responseFactory ) {
69        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
70
71        $this->extensionDirectory = $options->get( MainConfigNames::ExtensionDirectory );
72        $this->restApiAdditionalRouteFiles = $options->get( MainConfigNames::RestAPIAdditionalRouteFiles );
73        $this->restSandboxSpecs = $options->get( MainConfigNames::RestSandboxSpecs );
74        $this->scriptPath = $options->get( MainConfigNames::ScriptPath );
75
76        $this->srvCache = $srvCache;
77        $this->responseFactory = $responseFactory;
78    }
79
80    /**
81     * @return string[]
82     */
83    public function getRouteFiles(): array {
84        if ( $this->routeFiles === null ) {
85            $this->routeFiles = $this->initRouteFiles();
86        }
87
88        return $this->routeFiles;
89    }
90
91    /**
92     * @return string[]
93     */
94    private function initRouteFiles(): array {
95        global $IP;
96
97        // Always include the "official" routes. Include additional routes if specified.
98        // Routes in extensions are added to RestAPIAdditionalRouteFiles by ExtensionProcessor.
99        $routeFiles = array_merge(
100            self::CORE_ROUTE_FILES,
101            $this->restApiAdditionalRouteFiles
102        );
103
104        foreach ( $routeFiles as &$file ) {
105            if ( str_starts_with( $file, '/' ) ) {
106                // Allow absolute paths on non-Windows
107            } elseif ( str_starts_with( $file, 'extensions/' ) ) {
108                // Support hacks like Wikibase.ci.php
109                $file = substr_replace( $file, $this->extensionDirectory,
110                    0, strlen( 'extensions' ) );
111            } else {
112                $file = "$IP/$file";
113            }
114        }
115
116        return $routeFiles;
117    }
118
119    /**
120     * Returns true if any api specs are available, or false otherwise.
121     *
122     * @return bool
123     */
124    public function hasApiSpecs(): bool {
125        return $this->getApiSpecDefs() !== [];
126    }
127
128    /**
129     * Returns the available choices for APIs to explore.
130     *
131     * @return array<string,array<string,string>>
132     */
133    public function getApiSpecs(): array {
134        $specs = $this->getApiSpecDefs();
135
136        foreach ( $specs as $key => &$spec ) {
137            // Translate any message keys from config to a displayable name string
138            $localizer = new JsonLocalizer( $this->responseFactory );
139            if ( isset( $spec['msg'] ) ) {
140                $spec['name'] = $localizer->getFormattedMessage( $spec['msg'] );
141                unset( $spec['msg'] );
142            }
143
144            // Extract values from module definition files. Only load a file if necessary.
145            if ( isset( $spec['file'] ) ) {
146                $spec = $this->populateFromFile( $spec, $this->scriptPath );
147            } elseif ( !isset( $spec['name'] ) ) {
148                // If we were otherwise unable to get a name, use the key
149                $spec['name'] = $key;
150            }
151        }
152
153        return $specs;
154    }
155
156    /**
157     * Gets the available API spec definition information.
158     * These need further processing before use by the REST Sandbox.
159     *
160     * @see MainConfigSchema::RestSandboxSpecs for the structure of the array
161     *
162     * @return array<string,array<string,string>>
163     */
164    private function getApiSpecDefs(): array {
165        $coreSpecs = self::CORE_SPECS;
166
167        // Adjust core specs and merge with specs from config, giving config priority
168        foreach ( $coreSpecs as &$coreSpec ) {
169            if ( isset( $coreSpec['url'] ) ) {
170                $coreSpec['url'] = $this->scriptPath . $coreSpec['url'];
171            }
172        }
173
174        return array_merge( $coreSpecs, $this->restSandboxSpecs );
175    }
176
177    /**
178     * Populates any missing spec details from the input module definition file
179     *
180     * @param array<string,string> $spec The sandbox spec. Must have a 'file' key.
181     * @param string $scriptPath
182     *
183     * @return array<string,string>
184     */
185    private function populateFromFile( array $spec, string $scriptPath ): array {
186        $hasUrl = isset( $spec['url'] );
187        $hasName = isset( $spec['name'] ) || isset( $spec['msg'] );
188        if ( !$hasUrl || !$hasName ) {
189            // Get any missing information from the module definition file, giving config priority
190            $moduleDefInfo = $this->getModuleDefinitionInfo( $spec['file'] );
191            if ( !$hasName ) {
192                $spec['name'] = $moduleDefInfo['title'];
193            }
194
195            if ( !$hasUrl ) {
196                $spec['url'] = $scriptPath . '/rest.php/specs/v0/module/' . $moduleDefInfo['moduleId'];
197            }
198        }
199
200        return $spec;
201    }
202
203    /**
204     * Gets necessary info from the module definition info, from cache if possible,
205     * from the definition file otherwise.
206     *
207     * @param string $file The module definition file to load
208     *
209     * @return array<string,string>
210     */
211    private function getModuleDefinitionInfo( string $file ): array {
212        $key = $this->srvCache->makeKey(
213            __CLASS__,
214            'definition',
215            sha1( $file ),
216            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
217            (int)@filemtime( $file ),
218            implode( ',', $this->responseFactory->getLangCodes() )
219        );
220
221        return $this->srvCache->getWithSetCallback(
222            $key,
223            self::MODULE_DEFINITION_TTL,
224            function () use ( $file ) {
225                $md = SpecBasedModule::loadModuleDefinition( $file, $this->responseFactory );
226                return [
227                    'moduleId' => $md['moduleId'],
228                    'title' => $md['info']['title']
229                ];
230            }
231        );
232    }
233}