Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.13% covered (success)
93.13%
122 / 131
66.67% covered (warning)
66.67%
10 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ModuleSpecHandler
93.13% covered (success)
93.13%
122 / 131
66.67% covered (warning)
66.67%
10 / 15
35.40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 run
85.71% covered (warning)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
6.10
 getInfoSpec
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getLicenseSpec
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getContactSpec
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getServerSpec
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getPathsSpec
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getRouteSpec
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
3.01
 generateOperationId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 summaryToOperationId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 pathToOperationId
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 getComponentsSpec
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 getResponseBodySchemaFileName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 needsWriteAccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getParamSettings
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Rest\Handler;
4
5use MediaWiki\Config\Config;
6use MediaWiki\Config\ServiceOptions;
7use MediaWiki\MainConfigNames;
8use MediaWiki\Rest\Handler;
9use MediaWiki\Rest\LocalizedHttpException;
10use MediaWiki\Rest\Module\Module;
11use MediaWiki\Rest\Module\ModuleMode;
12use MediaWiki\Rest\RequestData;
13use MediaWiki\Rest\ResponseFactory;
14use MediaWiki\Rest\SimpleHandler;
15use MediaWiki\Rest\Validator\Validator;
16use Wikimedia\Message\MessageValue;
17use Wikimedia\ParamValidator\ParamValidator;
18
19/**
20 * Core REST API endpoint that outputs an OpenAPI spec of a set of routes.
21 */
22class ModuleSpecHandler extends SimpleHandler {
23
24    public const MODULE_SPEC_PATH = '/coredev/v0/specs/module/{module}';
25
26    /**
27     * @internal
28     */
29    private const CONSTRUCTOR_OPTIONS = [
30        MainConfigNames::RightsUrl,
31        MainConfigNames::RightsText,
32        MainConfigNames::EmergencyContact,
33        MainConfigNames::Sitename,
34    ];
35
36    private ServiceOptions $options;
37
38    public function __construct( Config $config ) {
39        $options = new ServiceOptions( self::CONSTRUCTOR_OPTIONS, $config );
40        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
41        $this->options = $options;
42    }
43
44    /**
45     * @param string $moduleName
46     * @param string $version
47     */
48    public function run( $moduleName, $version = '' ): array {
49        // TODO: implement caching, get cache key from Router.
50
51        if ( $version !== '' ) {
52            $moduleName .= '/' . $version;
53        }
54
55        if ( $moduleName === '-' ) {
56            // Hack that allows us to fetch a spec for the empty module prefix
57            $moduleName = '';
58        }
59
60        // This will also throw for DISABLED modules, which is the desired behavior.
61        $module = $this->getRouter()->getModule( $moduleName );
62        if ( !$module ) {
63            throw new LocalizedHttpException(
64                MessageValue::new( 'rest-unknown-module' )->params( $moduleName ),
65                404
66            );
67        }
68
69        // Suppress OpenAPI spec for HIDDEN modules. This is not a security or protection
70        // mechanism. MediaWiki is open source, so callers can learn the details of its endpoints.
71        // This is just a way to hide the spec in cases where it should not be available.
72        $mode = $this->getRouter()->getModuleManager()->getModuleMode( $moduleName );
73        if ( $mode === ModuleMode::HIDDEN ) {
74            throw new LocalizedHttpException(
75                MessageValue::new( 'rest-unavailable-spec' )->params( $moduleName ),
76                403
77            );
78        }
79
80        $spec = [
81            'openapi' => '3.0.0',
82            'info' => $this->getInfoSpec( $module ),
83            'servers' => $this->getServerSpec( $module ),
84            'externalDocs' => $module->getOpenApiExternalDocs(),
85            'paths' => $this->getPathsSpec( $module ),
86            'components' => $this->getComponentsSpec(),
87        ];
88
89        unset( $spec['info']['deprecationSettings'] );
90
91        if ( !$spec['externalDocs'] ) {
92            unset( $spec['externalDocs'] );
93        }
94
95        return $spec;
96    }
97
98    /**
99     * @see https://spec.openapis.org/oas/v3.0.0#info-object
100     */
101    private function getInfoSpec( Module $module ): array {
102        // TODO: Let Modules provide their name, description, version, etc
103        $prefix = $module->getPathPrefix();
104
105        if ( $prefix === '' ) {
106            $title = $this->getJsonLocalizer()->getFormattedMessage( 'rest-default-module' );
107        } else {
108            $moduleStr = $this->getJsonLocalizer()->getFormattedMessage( 'rest-module' );
109            $title = "$prefix " . $moduleStr;
110        }
111
112        return $module->getOpenApiInfo() + [
113            'title' => $title,
114            'version' => 'undefined',
115            'license' => $this->getLicenseSpec(),
116            'contact' => $this->getContactSpec(),
117        ];
118    }
119
120    private function getLicenseSpec(): array {
121        // TODO: get terms-of-use URL, not content license.
122
123        return [
124            'name' => $this->options->get( MainConfigNames::RightsText ),
125            'url' => $this->options->get( MainConfigNames::RightsUrl ),
126        ];
127    }
128
129    private function getContactSpec(): array {
130        return [
131            'email' => $this->options->get( MainConfigNames::EmergencyContact ),
132        ];
133    }
134
135    private function getServerSpec( Module $module ): array {
136        $prefix = $module->getPathPrefix();
137
138        if ( $prefix !== '' ) {
139            $prefix = "/$prefix";
140        }
141
142        return [
143            [
144                'url' => $this->getRouter()->getRouteUrl( $prefix ),
145            ]
146        ];
147    }
148
149    private function getPathsSpec( Module $module ): array {
150        $specs = [];
151        $usedOpIds = [];
152
153        // XXX: We currently don't support meta-data on OpenAPI path objects
154        //      (summary, description).
155
156        foreach ( $module->getDefinedPaths() as $path => $methods ) {
157            foreach ( $methods as $mth ) {
158                $key = strtolower( $mth );
159                $mth = strtoupper( $mth );
160                $specs[ $path ][ $key ] = $this->getRouteSpec( $module, $path, $mth, $usedOpIds );
161            }
162        }
163
164        return $specs;
165    }
166
167    /**
168     * Build the OpenAPI operation object for a single route.
169     *
170     * Operation IDs are arbitrary opaque strings required to be unique within
171     * this spec, but they carry no meaning outside it and need not be unique
172     * across different OpenAPI specs generated by other modules or wikis.
173     *
174     * @param Module $module
175     * @param string $path Route path, e.g. "/v1/page/{title}"
176     * @param string $method HTTP method (case-insensitive)
177     * @param array &$usedOpIds Operation IDs already assigned in this spec,
178     *   updated in-place to include the ID assigned here
179     * @return array OpenAPI operation object
180     */
181    private function getRouteSpec( Module $module, string $path, string $method, array &$usedOpIds ): array {
182        $request = new RequestData( [ 'method' => $method ] );
183        $handler = $module->getHandlerForPath( $path, $request, false );
184
185        $operationSpec = $handler->getOpenApiSpec( $method );
186
187        // If the spec already contains an explicit operationId (e.g. set in the JSON
188        // definition file via $oasKeys), respect it. Otherwise auto-generate one.
189        if ( !isset( $operationSpec['operationId'] ) ) {
190            $baseId = self::generateOperationId(
191                $method,
192                $operationSpec['summary'] ?? null,
193                $path
194            );
195            $operationId = $baseId;
196            $counter = 2;
197            while ( in_array( $operationId, $usedOpIds, true ) ) {
198                $operationId = $baseId . $counter;
199                $counter++;
200            }
201            $operationSpec['operationId'] = $operationId;
202        }
203
204        $usedOpIds[] = $operationSpec['operationId'];
205
206        return $operationSpec;
207    }
208
209    /**
210     * Generate an operationId for an operation.
211     *
212     * Uses the summary when available (more readable), falls back to the path.
213     *
214     * @param string $method HTTP method (case-insensitive; will be normalized to lowercase)
215     * @param string|null $summary Localized summary, or null/empty if absent
216     * @param string $path Route path, e.g. "/v1/page/{title}"
217     * @return string camelCase operationId
218     */
219    private static function generateOperationId(
220        string $method,
221        ?string $summary,
222        string $path
223    ): string {
224        if ( $summary !== null && trim( $summary ) !== '' ) {
225            return self::summaryToOperationId( $method, $summary );
226        }
227        return self::pathToOperationId( $method, $path );
228    }
229
230    /**
231     * Derive an operationId from the HTTP method and operation summary.
232     *
233     * Converts the summary to camelCase and prepends the HTTP method in lowercase.
234     * Example: method=GET, summary="Search pages" → "getSearchPages"
235     *
236     * @param string $method HTTP method (case-insensitive)
237     * @param string $summary The operation summary string
238     * @return string camelCase operationId
239     */
240    private static function summaryToOperationId( string $method, string $summary ): string {
241        // Replace any non-alphanumeric character with a space, then split into words.
242        $clean = preg_replace( '/[^a-zA-Z0-9]/', ' ', $summary );
243        $words = preg_split( '/\s+/', trim( $clean ), -1, PREG_SPLIT_NO_EMPTY );
244        $id = strtolower( $method );
245        foreach ( $words as $word ) {
246            $id .= ucfirst( strtolower( $word ) );
247        }
248        return $id;
249    }
250
251    /**
252     * Derive an operationId from the HTTP method and route path.
253     * Used as a fallback when no summary is available.
254     *
255     * Path parameters ({name}) become "ByName". Hyphens, underscores and other
256     * non-alphanumeric characters act as word separators.
257     * Example: method=GET, path="/v1/page/{title}/links" → "getV1PageByTitleLinks"
258     *
259     * @param string $method HTTP method (case-insensitive)
260     * @param string $path Route path, e.g. "/v1/page/{title}/links"
261     * @return string camelCase operationId
262     */
263    private static function pathToOperationId( string $method, string $path ): string {
264        $segments = explode( '/', trim( $path, '/' ) );
265        $id = strtolower( $method );
266        foreach ( $segments as $segment ) {
267            if ( $segment === '' ) {
268                continue;
269            }
270            // Convert {paramName} to "ByParamname"
271            if ( preg_match( '/^\{(.+)\}$/', $segment, $matches ) ) {
272                $id .= 'By' . ucfirst( strtolower( $matches[1] ) );
273            } else {
274                // Split on any non-alphanumeric char and ucfirst each word
275                $clean = preg_replace( '/[^a-zA-Z0-9]/', ' ', $segment );
276                $words = preg_split( '/\s+/', trim( $clean ), -1, PREG_SPLIT_NO_EMPTY );
277                foreach ( $words as $word ) {
278                    $id .= ucfirst( strtolower( $word ) );
279                }
280            }
281        }
282        return $id;
283    }
284
285    private function getComponentsSpec(): array {
286        $components = [];
287
288        // Resolve x-i18n-message references
289        $resolvedComponents = $this->getJsonLocalizer()->localizeJson(
290            ResponseFactory::getResponseComponents()
291        );
292
293        // XXX: also collect reusable components from handler specs (but how to avoid name collisions?).
294        $componentsSources = [
295            [ 'schemas' => Validator::getParameterTypeSchemas() ],
296            $resolvedComponents
297        ];
298
299        // 2D merge
300        foreach ( $componentsSources as $cmps ) {
301            foreach ( $cmps as $name => $cmp ) {
302                $components[$name] = array_merge( $components[$name] ?? [], $cmp );
303            }
304        }
305
306        return $components;
307    }
308
309    protected function getResponseBodySchemaFileName( string $method ): ?string {
310        return __DIR__ . '/Schema/ModuleSpec.json';
311    }
312
313    /** @inheritDoc */
314    public function needsWriteAccess() {
315        return false;
316    }
317
318    /** @inheritDoc */
319    public function getParamSettings() {
320        return [
321            'module' => [
322                self::PARAM_SOURCE => 'path',
323                ParamValidator::PARAM_TYPE => 'string',
324                ParamValidator::PARAM_REQUIRED => true,
325                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-module-spec-module' ),
326            ],
327            'version' => [
328                self::PARAM_SOURCE => 'path',
329                ParamValidator::PARAM_TYPE => 'string',
330                ParamValidator::PARAM_DEFAULT => '',
331                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-module-spec-version' ),
332            ],
333        ];
334    }
335
336}