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