Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.54% covered (warning)
68.54%
61 / 89
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecBasedModule
68.54% covered (warning)
68.54%
61 / 89
44.44% covered (danger)
44.44%
4 / 9
37.07
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getConfigHash
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getModuleDefinition
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 loadModuleDefinition
41.18% covered (danger)
41.18%
7 / 17
0.00% covered (danger)
0.00%
0 / 1
10.09
 getRouteFileTimestamp
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getDefinedPaths
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 initRoutes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 makeRouteInfo
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
3
 getOpenApiInfo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Rest\Module;
4
5use MediaWiki\HookContainer\HookContainer;
6use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
7use MediaWiki\Rest\Handler\RedirectHandler;
8use MediaWiki\Rest\JsonLocalizer;
9use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException;
10use MediaWiki\Rest\Reporter\ErrorReporter;
11use MediaWiki\Rest\ResponseFactory;
12use MediaWiki\Rest\RouteDefinitionException;
13use MediaWiki\Rest\Router;
14use MediaWiki\Rest\Validator\Validator;
15use Wikimedia\ObjectFactory\ObjectFactory;
16
17/**
18 * A Module that is based on a module definition file similar to an OpenAPI spec.
19 * @see docs/rest/mwapi-1.0.json for the schema of module definition files.
20 *
21 * Just like an OpenAPI spec, the module definition file contains a "paths"
22 * section that maps paths and HTTP methods to operations. Each operation
23 * then specifies the PHP class that will handle the request under the "handler"
24 * key. The value of the "handler" key is an object spec for use with
25 * ObjectFactory::createObject.
26 *
27 * The following fields are supported as a shorthand notation:
28 * - "redirect": the route represents a redirect and will be handled by
29 *   the RedirectHandler class. The redirect is specified as a JSON object
30 *   that specifies the target "path", and optional the redirect "code".
31 *   If a redirect is defined, the "handler" key must be omitted.
32 *
33 * More shorthands may be added in the future.
34 *
35 * Route definitions can contain additional fields to configure the handler.
36 * The handler can access the route definition by calling getConfig().
37 *
38 * @internal
39 * @since 1.43
40 */
41class SpecBasedModule extends MatcherBasedModule {
42
43    private string $definitionFile;
44
45    private ?array $moduleDef = null;
46
47    private ?int $routeFileTimestamp = null;
48
49    private ?string $configHash = null;
50
51    /**
52     * @internal
53     */
54    public function __construct(
55        string $definitionFile,
56        Router $router,
57        string $pathPrefix,
58        ResponseFactory $responseFactory,
59        BasicAuthorizerInterface $basicAuth,
60        ObjectFactory $objectFactory,
61        Validator $restValidator,
62        ErrorReporter $errorReporter,
63        HookContainer $hookContainer
64    ) {
65        parent::__construct(
66            $router,
67            $pathPrefix,
68            $responseFactory,
69            $basicAuth,
70            $objectFactory,
71            $restValidator,
72            $errorReporter,
73            $hookContainer
74        );
75        $this->definitionFile = $definitionFile;
76    }
77
78    /**
79     * Get a config version hash for cache invalidation
80     */
81    protected function getConfigHash(): string {
82        if ( $this->configHash === null ) {
83            $this->configHash = md5( json_encode( [
84                'class' => __CLASS__,
85                'version' => 1,
86                'fileTimestamps' => $this->getRouteFileTimestamp()
87            ] ) );
88        }
89        return $this->configHash;
90    }
91
92    /**
93     * Load the module definition file.
94     */
95    private function getModuleDefinition(): array {
96        if ( $this->moduleDef !== null ) {
97            return $this->moduleDef;
98        }
99
100        $this->routeFileTimestamp = filemtime( $this->definitionFile );
101        $this->moduleDef = static::loadModuleDefinition( $this->definitionFile, $this->responseFactory );
102
103        return $this->moduleDef;
104    }
105
106    /**
107     * Load the module definition file.
108     *
109     * @param string $file The module definition file to load
110     * @param ResponseFactory $responseFactory
111     *
112     * @return array
113     */
114    public static function loadModuleDefinition( string $file, ResponseFactory $responseFactory ): array {
115        $moduleDef = static::loadJsonFile( $file );
116
117        if ( !$moduleDef ) {
118            throw new ModuleConfigurationException(
119                'Malformed module definition file: ' . $file
120            );
121        }
122
123        if ( !isset( $moduleDef['mwapi'] ) ) {
124            throw new ModuleConfigurationException(
125                'Missing mwapi version field in ' . $file
126            );
127        }
128
129        // Require a supported version of mwapi
130        if ( version_compare( $moduleDef['mwapi'], '1.0.0', '<' ) ||
131            version_compare( $moduleDef['mwapi'], '1.1.999', '>' )
132        ) {
133            throw new ModuleConfigurationException(
134                "Unsupported mwapi version {$moduleDef['mwapi']} in "
135                . $file
136            );
137        }
138
139        $localizer = new JsonLocalizer( $responseFactory );
140        return $localizer->localizeJson( $moduleDef );
141    }
142
143    /**
144     * Get last modification times of the module definition file.
145     */
146    private function getRouteFileTimestamp(): int {
147        if ( $this->routeFileTimestamp === null ) {
148            $this->routeFileTimestamp = filemtime( $this->definitionFile );
149        }
150        return $this->routeFileTimestamp;
151    }
152
153    /**
154     * @unstable for testing
155     *
156     * @return array[]
157     */
158    public function getDefinedPaths(): array {
159        $paths = [];
160        $moduleDef = $this->getModuleDefinition();
161
162        foreach ( $moduleDef['paths'] as $path => $pSpec ) {
163            $paths[$path] = [];
164            foreach ( $pSpec as $method => $opSpec ) {
165                $paths[$path][] = strtoupper( $method );
166            }
167        }
168
169        return $paths;
170    }
171
172    protected function initRoutes(): void {
173        $moduleDef = $this->getModuleDefinition();
174
175        // The structure is similar to OpenAPI, see docs/rest/mwapi.1.0.json
176        foreach ( $moduleDef['paths'] as $path => $pathSpec ) {
177            foreach ( $pathSpec as $method => $opSpec ) {
178                $info = $this->makeRouteInfo( $path, $opSpec );
179                $this->addRoute( $method, $path, $info );
180            }
181        }
182    }
183
184    /**
185     * Generate a route info array to be stored in the matcher tree,
186     * in the form expected by MatcherBasedModule::addRoute()
187     * and ultimately Module::getHandlerForPath().
188     */
189    private function makeRouteInfo( string $path, array $opSpec ): array {
190        static $objectSpecKeys = [
191            'class',
192            'factory',
193            'services',
194            'optional_services',
195            'args',
196        ];
197
198        static $oasKeys = [
199            'parameters',
200            'responses',
201            'summary',
202            'description',
203            'tags',
204            'externalDocs',
205            'deprecationSettings'
206        ];
207
208        if ( isset( $opSpec['redirect'] ) ) {
209            // Redirect shorthand
210            $opSpec['handler'] = [
211                'class' => RedirectHandler::class,
212                'redirect' => $opSpec['redirect'],
213            ];
214            unset( $opSpec['redirect'] );
215        }
216
217        $handlerSpec = $opSpec['handler'] ?? null;
218        if ( !$handlerSpec ) {
219            throw new RouteDefinitionException( 'Missing handler spec' );
220        }
221
222        $info = [
223            'spec' => array_intersect_key( $handlerSpec, array_flip( $objectSpecKeys ) ),
224            'config' => array_diff_key( $handlerSpec, array_flip( $objectSpecKeys ) ),
225            'openApiSpec' => array_intersect_key( $opSpec, array_flip( $oasKeys ) ),
226            'path' => $path,
227        ];
228
229        return $info;
230    }
231
232    /** @inheritDoc */
233    public function getOpenApiInfo() {
234        $def = $this->getModuleDefinition();
235        return $def['info'] ?? [];
236    }
237
238}