Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
65.88% |
56 / 85 |
|
37.50% |
3 / 8 |
CRAP | |
0.00% |
0 / 1 |
SpecBasedModule | |
65.88% |
56 / 85 |
|
37.50% |
3 / 8 |
38.51 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
getConfigHash | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getModuleDefinition | |
45.00% |
9 / 20 |
|
0.00% |
0 / 1 |
11.99 | |||
getRouteFileTimestamp | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getDefinedPaths | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
initRoutes | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
makeRouteInfo | |
96.77% |
30 / 31 |
|
0.00% |
0 / 1 |
3 | |||
getOpenApiInfo | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Rest\Module; |
4 | |
5 | use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface; |
6 | use MediaWiki\Rest\Handler\RedirectHandler; |
7 | use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException; |
8 | use MediaWiki\Rest\Reporter\ErrorReporter; |
9 | use MediaWiki\Rest\ResponseFactory; |
10 | use MediaWiki\Rest\RouteDefinitionException; |
11 | use MediaWiki\Rest\Router; |
12 | use MediaWiki\Rest\Validator\Validator; |
13 | use 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 | */ |
39 | class 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 | } |