Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.61% covered (success)
94.61%
193 / 204
66.67% covered (warning)
66.67%
12 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
ModuleSpecHandler
94.61% covered (success)
94.61%
193 / 204
66.67% covered (warning)
66.67%
12 / 18
60.56
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
86.84% covered (warning)
86.84%
33 / 38
0.00% covered (danger)
0.00%
0 / 1
9.18
 getInfoSpec
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
7
 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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getServerSpec
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 getModuleRouteUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getLocalModuleSandboxUrl
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 getPathsSpec
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getRouteSpec
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
3.01
 getOpenApiSecurityRequirements
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 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%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 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\Response;
14use MediaWiki\Rest\ResponseFactory;
15use MediaWiki\Rest\SimpleHandler;
16use MediaWiki\Rest\Validator\Validator;
17use MediaWiki\Session\SessionManagerInterface;
18use Wikimedia\Message\MessageValue;
19use Wikimedia\ParamValidator\ParamValidator;
20
21/**
22 * Core REST API endpoint that outputs an OpenAPI spec of a set of routes.
23 */
24class ModuleSpecHandler extends SimpleHandler {
25
26    public const MODULE_SPEC_PATH = '/coredev/v0/specs/module/{module}';
27
28    /**
29     * @internal
30     */
31    private const CONSTRUCTOR_OPTIONS = [
32        MainConfigNames::RightsUrl,
33        MainConfigNames::RightsText,
34        MainConfigNames::EmergencyContact,
35        MainConfigNames::Sitename,
36        MainConfigNames::CanonicalServer,
37        MainConfigNames::RestExternalModules,
38        MainConfigNames::RestLocalModuleTestBaseUrl,
39        MainConfigNames::RestTermsOfServiceUrl,
40    ];
41
42    private readonly ServiceOptions $options;
43
44    public function __construct(
45        Config $config,
46        private readonly SessionManagerInterface $sessionManager,
47    ) {
48        $options = new ServiceOptions( self::CONSTRUCTOR_OPTIONS, $config );
49        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
50        $this->options = $options;
51    }
52
53    /**
54     * @param string $moduleName
55     * @param string $version
56     *
57     * @return array|Response OpenAPI operation object, or Response object if redirect is needed
58     */
59    public function run( $moduleName, $version = '' ): array|Response {
60        // TODO: implement caching, get cache key from Router.
61
62        if ( $version !== '' ) {
63            $moduleName .= '/' . $version;
64        }
65
66        $mode = null;
67        if ( $moduleName === '-' ) {
68            // Hack that allows us to fetch a spec for the empty module prefix
69            $moduleName = '';
70            $mode = ModuleMode::PUBLISHED;
71        }
72
73        // Suppress OpenAPI spec for HIDDEN or DISABLED modules. This is not a security or
74        // protection mechanism. MediaWiki is open source, so callers can learn the details of
75        // its endpoints.  This is just a way to hide the spec in cases where it should not be
76        // available.
77        $mode ??= $this->getRouter()->getModuleManager()->getModuleMode( $moduleName );
78        if ( $mode === ModuleMode::HIDDEN || $mode === ModuleMode::DISABLED ) {
79            throw new LocalizedHttpException(
80                MessageValue::new( 'rest-unavailable-spec' )->params( $moduleName ),
81                403
82            );
83        }
84
85        // If this is an external module, redirect to its spec
86        $restExternalModules = $this->options->get( MainConfigNames::RestExternalModules );
87        $em = $restExternalModules[$moduleName] ?? null;
88        if ( $em ) {
89            $response = $this->getResponseFactory()->createPermanentRedirect( $em['spec'] );
90            return $response;
91        }
92
93        $module = $this->getRouter()->getModule( $moduleName );
94        if ( !$module ) {
95            throw new LocalizedHttpException(
96                MessageValue::new( 'rest-unknown-module' )->params( $moduleName ),
97                404
98            );
99        }
100
101        $spec = [
102            'openapi' => '3.0.0',
103            'info' => $this->getInfoSpec( $module ),
104            'servers' => $this->getServerSpec( $module ),
105            'externalDocs' => $module->getOpenApiExternalDocs(),
106            'tags' => $module->getOpenApiTags(),
107            'paths' => $this->getPathsSpec( $module ),
108            'components' => $this->getComponentsSpec(),
109        ];
110
111        unset( $spec['info']['deprecationSettings'] );
112
113        if ( !$spec['externalDocs'] ) {
114            unset( $spec['externalDocs'] );
115        }
116
117        if ( empty( $spec['tags'] ) ) {
118            unset( $spec['tags'] );
119        }
120
121        return $spec;
122    }
123
124    /**
125     * @see https://spec.openapis.org/oas/v3.0.0#info-object
126     */
127    private function getInfoSpec( Module $module ): array {
128        // Modules manage their own version see SpecBasedModule::getOpenApiInfo()
129        // and ExtraRoutesModule::getOpenApiInfo().
130        // We add a blank string as the version fallback here so the required info.version field is
131        // always present in the spec, even when empty, as required by the OpenAPI specification.
132        // A blank version still raises an error in OpenAPI spec linters, such as the WMF spectral linter.
133
134        $prefix = $module->getPathPrefix();
135
136        if ( $prefix === '' ) {
137            $title = $this->getJsonLocalizer()->getFormattedMessage( 'rest-default-module' );
138        } else {
139            $moduleStr = $this->getJsonLocalizer()->getFormattedMessage( 'rest-module' );
140            $title = "$prefix " . $moduleStr;
141        }
142
143        $info = $module->getOpenApiInfo() + [
144            'title' => $title,
145            'version' => '',
146            'license' => $this->getLicenseSpec(),
147            'contact' => $this->getContactSpec(),
148        ];
149
150        $sandboxUrl = $this->getLocalModuleSandboxUrl( $module );
151        if ( $sandboxUrl !== null ) {
152            $host = parse_url( $sandboxUrl, PHP_URL_HOST );
153            if ( isset( $info['description'] ) && $info['description'] !== '' ) {
154                $info['description'] = $this->getJsonLocalizer()->getFormattedMessage(
155                    new MessageValue(
156                        'rest-sandbox-recommend-test-server-with-description',
157                        [ $info['description'], $host ]
158                    )
159                );
160            } else {
161                $info['description'] = $this->getJsonLocalizer()->getFormattedMessage(
162                    new MessageValue( 'rest-sandbox-recommend-test-server', [ $host ] )
163                );
164            }
165        }
166
167        $termsOfService = $this->options->get( MainConfigNames::RestTermsOfServiceUrl );
168        if ( is_string( $termsOfService ) && $termsOfService !== '' ) {
169            $info['termsOfService'] = $termsOfService;
170        }
171
172        return $info;
173    }
174
175    private function getLicenseSpec(): array {
176        return [
177            'name' => $this->options->get( MainConfigNames::RightsText ),
178            'url' => $this->options->get( MainConfigNames::RightsUrl ),
179        ];
180    }
181
182    private function getContactSpec(): array {
183        $contact = [
184            'name' => $this->options->get( MainConfigNames::Sitename ),
185            'url' => $this->options->get( MainConfigNames::CanonicalServer ),
186        ];
187
188        $email = $this->options->get( MainConfigNames::EmergencyContact );
189        // OpenAPI requires contact.email to be a valid email address. Keep the rest
190        // of the contact object intact and omit the field when the configured value
191        // does not satisfy that format.
192        if ( is_string( $email ) && filter_var( $email, FILTER_VALIDATE_EMAIL ) !== false ) {
193            $contact['email'] = $email;
194        }
195
196        return $contact;
197    }
198
199    private function getServerSpec( Module $module ): array {
200        $prodUrl = $this->getModuleRouteUrl( $module );
201        $sandboxUrl = $this->getLocalModuleSandboxUrl( $module );
202
203        if ( $sandboxUrl !== null ) {
204            $localizer = $this->getJsonLocalizer();
205            return [
206                [
207                    'url' => $prodUrl,
208                    'description' => $localizer->getFormattedMessage( 'rest-sandbox-server-production' ),
209                ],
210                [
211                    'url' => $sandboxUrl,
212                    'description' => $localizer->getFormattedMessage( 'rest-sandbox-server-sandbox' ),
213                ]
214            ];
215        }
216
217        return [
218            [
219                'url' => $prodUrl,
220            ]
221        ];
222    }
223
224    /**
225     * Get the absolute entry point route URL for the given module's path prefix.
226     *
227     * @param Module $module The REST module to resolve the prefix for
228     * @return string The absolute route URL (e.g., https://en.wikipedia.org/w/rest.php/specs/v0)
229     */
230    private function getModuleRouteUrl( Module $module ): string {
231        $prefix = $module->getPathPrefix();
232        if ( $prefix !== '' ) {
233            $prefix = "/$prefix";
234        }
235        return $this->getRouter()->getRouteUrl( $prefix );
236    }
237
238    /**
239     * Get the absolute sandbox route URL for the given module, if configured via $wgRestLocalModuleTestBaseUrl.
240     *
241     * @param Module $module The REST module to resolve the sandbox URL for
242     * @return string|null The absolute sandbox route URL, or null if not configured
243     */
244    private function getLocalModuleSandboxUrl( Module $module ): ?string {
245        $sandboxBaseUrl = $this->options->get( MainConfigNames::RestLocalModuleTestBaseUrl );
246        if ( $sandboxBaseUrl === null || $sandboxBaseUrl === '' ) {
247            return null;
248        }
249        $prefix = $module->getPathPrefix();
250        if ( $prefix !== '' ) {
251            return rtrim( $sandboxBaseUrl, '/' ) . '/' . $prefix;
252        }
253        return $sandboxBaseUrl;
254    }
255
256    private function getPathsSpec( Module $module ): array {
257        $specs = [];
258        $usedOpIds = [];
259
260        // XXX: We currently don't support meta-data on OpenAPI path objects
261        //      (summary, description).
262
263        foreach ( $module->getDefinedPaths() as $path => $methods ) {
264            foreach ( $methods as $mth ) {
265                $key = strtolower( $mth );
266                $mth = strtoupper( $mth );
267                $specs[ $path ][ $key ] = $this->getRouteSpec( $module, $path, $mth, $usedOpIds );
268            }
269        }
270
271        return $specs;
272    }
273
274    /**
275     * Build the OpenAPI operation object for a single route.
276     *
277     * Operation IDs are arbitrary opaque strings required to be unique within
278     * this spec, but they carry no meaning outside it and need not be unique
279     * across different OpenAPI specs generated by other modules or wikis.
280     *
281     * @param Module $module
282     * @param string $path Route path, e.g. "/v1/page/{title}"
283     * @param string $method HTTP method (case-insensitive)
284     * @param array &$usedOpIds Operation IDs already assigned in this spec,
285     *   updated in-place to include the ID assigned here
286     * @return array OpenAPI operation object
287     */
288    private function getRouteSpec( Module $module, string $path, string $method, array &$usedOpIds ): array {
289        $request = new RequestData( [ 'method' => $method ] );
290        $handler = $module->getHandlerForPath( $path, $request, false );
291
292        $operationSpec = $handler->getOpenApiSpec( $method );
293
294        $operationSpec['security'] = $this->getOpenApiSecurityRequirements( $handler );
295
296        // If the spec already contains an explicit operationId (e.g. set in the JSON
297        // definition file via $oasKeys), respect it. Otherwise auto-generate one.
298        if ( !isset( $operationSpec['operationId'] ) ) {
299            $baseId = self::generateOperationId(
300                $method,
301                $operationSpec['summary'] ?? null,
302                $path
303            );
304            $operationId = $baseId;
305            $counter = 2;
306            while ( in_array( $operationId, $usedOpIds, true ) ) {
307                $operationId = $baseId . $counter;
308                $counter++;
309            }
310            $operationSpec['operationId'] = $operationId;
311        }
312
313        $usedOpIds[] = $operationSpec['operationId'];
314
315        return $operationSpec;
316    }
317
318    /**
319     * Build the OpenAPI security requirements array for a route's handler.
320     *
321     * Each session provider contributes a single requirement object grouping all of
322     * its schemes together (logical AND); distinct providers are emitted as separate
323     * objects (logical OR). Routes that do not require write access additionally allow
324     * unauthenticated access, represented by a leading empty requirement object ({}).
325     *
326     * needsWriteAccess() is used as a heuristic for whether anonymous access is allowed,
327     * since it is the only signal Handler exposes today. It is not a guarantee: some
328     * handlers that don't need write access still reject anonymous requests internally
329     * (e.g. ReadingLists' ListsHandler-derived endpoints, which require a logged-in user
330     * even though they are read-only). Such routes will be spec'd as allowing anonymous
331     * access even though the handler will reject anonymous requests at runtime.
332     *
333     * Grouping relies on the "{providerBaseName}-{subKey}" naming convention produced by
334     * SessionManager::getAllOpenApiSecuritySchemes(), where the provider's sanitized class
335     * name forms the base and the per-scheme suffix (subKey) contains no hyphen. The last
336     * hyphen therefore separates the provider base name from the suffix.
337     *
338     * @see https://spec.openapis.org/oas/v3.0.0#security-requirement-object
339     * @param Handler $handler
340     * @return array<int, array|\stdClass> OpenAPI security requirement objects
341     */
342    private function getOpenApiSecurityRequirements( Handler $handler ): array {
343        $requirements = [];
344
345        // Read-only endpoints are assumed to permit anonymous access. This is only a
346        // heuristic; see the note above for known exceptions.
347        if ( !$handler->needsWriteAccess() ) {
348            $requirements[] = (object)[]; // Represents {} in JSON.
349        }
350
351        // Group schemes by provider (AND within a provider, OR across providers).
352        $providerGroups = [];
353        foreach ( $this->sessionManager->getAllOpenApiSecuritySchemes() as $schemeName => $_ ) {
354            $lastDash = strrpos( $schemeName, '-' );
355            $baseName = $lastDash !== false ? substr( $schemeName, 0, $lastDash ) : $schemeName;
356            $providerGroups[$baseName][$schemeName] = [];
357        }
358
359        foreach ( $providerGroups as $groupSchemes ) {
360            $requirements[] = $groupSchemes;
361        }
362
363        return $requirements;
364    }
365
366    /**
367     * Generate an operationId for an operation.
368     *
369     * Uses the summary when available (more readable), falls back to the path.
370     *
371     * @param string $method HTTP method (case-insensitive; will be normalized to lowercase)
372     * @param string|null $summary Localized summary, or null/empty if absent
373     * @param string $path Route path, e.g. "/v1/page/{title}"
374     * @return string camelCase operationId
375     */
376    private static function generateOperationId(
377        string $method,
378        ?string $summary,
379        string $path
380    ): string {
381        if ( $summary !== null && trim( $summary ) !== '' ) {
382            return self::summaryToOperationId( $method, $summary );
383        }
384        return self::pathToOperationId( $method, $path );
385    }
386
387    /**
388     * Derive an operationId from the HTTP method and operation summary.
389     *
390     * Converts the summary to camelCase and prepends the HTTP method in lowercase.
391     * Example: method=GET, summary="Search pages" â†’ "getSearchPages"
392     *
393     * @param string $method HTTP method (case-insensitive)
394     * @param string $summary The operation summary string
395     * @return string camelCase operationId
396     */
397    private static function summaryToOperationId( string $method, string $summary ): string {
398        // Replace any non-alphanumeric character with a space, then split into words.
399        $clean = preg_replace( '/[^a-zA-Z0-9]/', ' ', $summary );
400        $words = preg_split( '/\s+/', trim( $clean ), -1, PREG_SPLIT_NO_EMPTY );
401        $id = strtolower( $method );
402        foreach ( $words as $word ) {
403            $id .= ucfirst( strtolower( $word ) );
404        }
405        return $id;
406    }
407
408    /**
409     * Derive an operationId from the HTTP method and route path.
410     * Used as a fallback when no summary is available.
411     *
412     * Path parameters ({name}) become "ByName". Hyphens, underscores and other
413     * non-alphanumeric characters act as word separators.
414     * Example: method=GET, path="/v1/page/{title}/links" â†’ "getV1PageByTitleLinks"
415     *
416     * @param string $method HTTP method (case-insensitive)
417     * @param string $path Route path, e.g. "/v1/page/{title}/links"
418     * @return string camelCase operationId
419     */
420    private static function pathToOperationId( string $method, string $path ): string {
421        $segments = explode( '/', trim( $path, '/' ) );
422        $id = strtolower( $method );
423        foreach ( $segments as $segment ) {
424            if ( $segment === '' ) {
425                continue;
426            }
427            // Convert {paramName} to "ByParamname"
428            if ( preg_match( '/^\{(.+)\}$/', $segment, $matches ) ) {
429                $id .= 'By' . ucfirst( strtolower( $matches[1] ) );
430            } else {
431                // Split on any non-alphanumeric char and ucfirst each word
432                $clean = preg_replace( '/[^a-zA-Z0-9]/', ' ', $segment );
433                $words = preg_split( '/\s+/', trim( $clean ), -1, PREG_SPLIT_NO_EMPTY );
434                foreach ( $words as $word ) {
435                    $id .= ucfirst( strtolower( $word ) );
436                }
437            }
438        }
439        return $id;
440    }
441
442    private function getComponentsSpec(): array {
443        $components = [];
444
445        // Resolve x-i18n-message references
446        $resolvedComponents = $this->getJsonLocalizer()->localizeJson(
447            ResponseFactory::getResponseComponents()
448        );
449
450        // XXX: also collect reusable components from handler specs (but how to avoid name collisions?).
451        $componentsSources = [
452            [ 'schemas' => Validator::getParameterTypeSchemas() ],
453            $resolvedComponents
454        ];
455
456        // 2D merge
457        foreach ( $componentsSources as $cmps ) {
458            foreach ( $cmps as $name => $cmp ) {
459                $components[$name] = array_merge( $components[$name] ?? [], $cmp );
460            }
461        }
462
463        // Security schemes are declared by the installed session providers. Resolve any
464        // localizable descriptions (MessageValue); plain-string descriptions, e.g. from
465        // third-party providers, are emitted as-is. The key is omitted when no provider
466        // declares schemes, to avoid an empty securitySchemes object.
467        $securitySchemes = $this->sessionManager->getAllOpenApiSecuritySchemes();
468        foreach ( $securitySchemes as &$scheme ) {
469            if ( isset( $scheme['description'] ) && $scheme['description'] instanceof MessageValue ) {
470                $scheme['description'] = $this->getJsonLocalizer()->getFormattedMessage( $scheme['description'] );
471            }
472        }
473        unset( $scheme );
474        if ( $securitySchemes ) {
475            $components['securitySchemes'] = $securitySchemes;
476        }
477
478        return $components;
479    }
480
481    protected function getResponseBodySchemaFileName( string $method ): ?string {
482        return __DIR__ . '/Schema/ModuleSpec.json';
483    }
484
485    /** @inheritDoc */
486    public function needsWriteAccess() {
487        return false;
488    }
489
490    /** @inheritDoc */
491    public function getParamSettings() {
492        return [
493            'module' => [
494                self::PARAM_SOURCE => 'path',
495                ParamValidator::PARAM_TYPE => 'string',
496                ParamValidator::PARAM_REQUIRED => true,
497                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-module-spec-module' ),
498            ],
499            'version' => [
500                self::PARAM_SOURCE => 'path',
501                ParamValidator::PARAM_TYPE => 'string',
502                ParamValidator::PARAM_DEFAULT => '',
503                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-module-spec-version' ),
504            ],
505        ];
506    }
507
508}