Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.42% covered (warning)
85.42%
82 / 96
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiQueryMapData
85.42% covered (warning)
85.42%
82 / 96
33.33% covered (danger)
33.33%
3 / 9
41.25
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
85.29% covered (warning)
85.29%
29 / 34
0.00% covered (danger)
0.00%
0 / 1
12.46
 filterGroups
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 normalizeGeoJson
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
6.29
 getAllowedParams
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getCacheMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getParserOutput
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
9.04
1<?php
2
3namespace Kartographer\Api;
4
5use FlaggableWikiPage;
6use FlaggedRevs;
7use Kartographer\State;
8use MediaWiki\Api\ApiBase;
9use MediaWiki\Api\ApiQuery;
10use MediaWiki\Api\ApiQueryBase;
11use MediaWiki\Extension\FlaggedRevs\Backend\FlaggedRevsParserCacheFactory;
12use MediaWiki\Json\FormatJson;
13use MediaWiki\Logger\LoggerFactory;
14use MediaWiki\Page\PageIdentity;
15use MediaWiki\Page\WikiPageFactory;
16use MediaWiki\Parser\ParserOptions;
17use MediaWiki\Parser\ParserOutput;
18use MediaWiki\Registration\ExtensionRegistry;
19use Wikimedia\ParamValidator\ParamValidator;
20use Wikimedia\ParamValidator\TypeDef\IntegerDef;
21
22/**
23 * @license MIT
24 */
25class ApiQueryMapData extends ApiQueryBase {
26
27    public function __construct(
28        ApiQuery $query,
29        string $moduleName,
30        private readonly WikiPageFactory $wikiPageFactory,
31        private readonly ?FlaggedRevsParserCacheFactory $flaggedRevsParserCacheFactory,
32    ) {
33        parent::__construct( $query, $moduleName, 'mpd' );
34    }
35
36    /** @inheritDoc */
37    public function execute() {
38        $params = $this->extractRequestParams();
39        $limit = $params['limit'];
40
41        $groupIds = $params['groups'] === '' ? [] : explode( '|', $params['groups'] );
42        $titles = $this->getPageSet()->getGoodPages();
43        if ( !$titles ) {
44            return;
45        }
46
47        $revisionToPageMap = $this->getPageSet()->getLiveRevisionIDs();
48        $revIds = array_flip( $revisionToPageMap );
49        // Note: It's probably possible to merge data from multiple revisions of the same page
50        // because of the way group IDs are unique. Intentionally not implemented yet.
51        if ( count( $revisionToPageMap ) > count( $revIds ) ) {
52            $this->dieWithError( 'apierror-kartographer-conflicting-revids' );
53        }
54
55        $count = 0;
56        foreach ( $titles as $pageId => $title ) {
57            if ( ++$count > $limit ) {
58                $this->setContinueEnumParameter( 'continue', $pageId );
59                break;
60            }
61
62            $revId = $revIds[$pageId] ?? null;
63            if ( $revId ) {
64                // This isn't strictly needed, but the only way a consumer can distinguish an
65                // endpoint that supports revids from an endpoint that doesn't
66                $this->getResult()->addValue( [ 'query', 'pages', $pageId ], 'revid', $revId );
67            }
68
69            $parserOptions = ParserOptions::newFromAnon();
70
71            if ( $params[ 'parser' ] === 'parsoid' ) {
72                $parserOptions->setUseParsoid( true );
73            } elseif ( $params[ 'parser' ] === 'legacy' ) {
74                $parserOptions->setUseParsoid( false );
75            }
76
77            $parserOutput = $this->getParserOutput( $title, $revId, $parserOptions );
78            $state = $parserOutput ? State::getState( $parserOutput ) : null;
79            if ( !$state ) {
80                continue;
81            }
82            $data = $state->getData();
83
84            $result = $this->filterGroups( $data, $groupIds, $revId !== null );
85            $this->normalizeGeoJson( $result );
86            $result = FormatJson::encode( $result, false, FormatJson::ALL_OK );
87
88            $fit = $this->addPageSubItem( $pageId, $result );
89            if ( !$fit ) {
90                $this->setContinueEnumParameter( 'continue', $pageId );
91            }
92        }
93    }
94
95    /**
96     * @param array<string,array> $data All groups
97     * @param string[] $groupIds requested groups or empty to disable filtering
98     * @param bool $isStrict If true, log missing groups
99     * @return array<string,?array> Filtered groups, with the same keys as $data
100     */
101    private function filterGroups( array $data, array $groupIds, bool $isStrict ): array {
102        if ( !$groupIds ) {
103            return $data;
104        }
105        return array_reduce( $groupIds,
106            static function ( $result, $groupId ) use ( $data, $isStrict ) {
107                if ( array_key_exists( $groupId, $data ) ) {
108                    $result[$groupId] = $data[$groupId];
109                } else {
110                    // Temporary logging, remove when not needed any more
111                    if ( $isStrict && str_starts_with( $groupId, '_' ) ) {
112                        LoggerFactory::getInstance( 'Kartographer' )->notice( 'Group id not found in revision' );
113                    }
114
115                    // Let the client know there is no data found for this group
116                    $result[$groupId] = null;
117                }
118                return $result;
119            }, [] );
120    }
121
122    /**
123     * ExtensionData are stored as serialized JSON strings and deserialized with
124     * {@see FormatJson::FORCE_ASSOC} set, see {@see JsonCodec::unserialize}. This means empty
125     * objects are serialized as "{}" but deserialized as empty arrays. We need to revert this.
126     * Luckily we know everything about the data that can end here: thanks to
127     * {@see SimpleStyleParser} it's guaranteed to be valid GeoJSON.
128     *
129     * @param array &$data
130     */
131    private function normalizeGeoJson( array &$data ): void {
132        foreach ( $data as $key => &$value ) {
133            // Properties that must be objects according to schemas/geojson.json
134            if ( $value === [] && ( $key === 'geometry' || $key === 'properties' ) ) {
135                $value = (object)[];
136            } elseif ( is_array( $value ) ) {
137                // Note: No need to dive deeper when objects are deserialized as objects.
138                $this->normalizeGeoJson( $value );
139            }
140        }
141    }
142
143    /** @inheritDoc */
144    public function getAllowedParams() {
145        return [
146            'groups' => [
147                ParamValidator::PARAM_TYPE => 'string',
148                ParamValidator::PARAM_DEFAULT => '',
149            ],
150            'limit' => [
151                ParamValidator::PARAM_TYPE => 'limit',
152                ParamValidator::PARAM_DEFAULT => 10,
153                IntegerDef::PARAM_MIN => 1,
154                IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1,
155                IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2
156            ],
157            'continue' => [
158                ParamValidator::PARAM_TYPE => 'integer',
159                ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
160            ],
161            'parser' => [
162                ParamValidator::PARAM_TYPE => [
163                    'parsoid',
164                    'legacy'
165                ],
166                ParamValidator::PARAM_DEFAULT => null,
167                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
168            ]
169        ];
170    }
171
172    /** @inheritDoc */
173    public function getExamplesMessages() {
174        return [
175            'action=query&prop=mapdata&titles=Metallica' => 'apihelp-query+mapdata-example-1',
176            'action=query&prop=mapdata&titles=Metallica&mpdgroups=group1|group2'
177                => 'apihelp-query+mapdata-example-2',
178        ];
179    }
180
181    /** @inheritDoc */
182    public function getCacheMode( $params ) {
183        return 'public';
184    }
185
186    /** @inheritDoc */
187    public function isInternal() {
188        return true;
189    }
190
191    /**
192     * Wrap parsing logic to accomplish a cache workaround
193     *
194     * TODO: Once T307342 is resolved, MediaWiki core will be able to dynamically select the
195     * correct cache.  Until then, we're explicitly using the FlaggedRevs stable-revision cache to
196     * avoid an unnecessary parse, and to avoid polluting the RevisionOutputCache.
197     *
198     * @param PageIdentity $title
199     * @param int|null $requestedRevId
200     *
201     * @return ParserOutput|false
202     */
203    private function getParserOutput( PageIdentity $title, ?int $requestedRevId, ParserOptions $parserOptions ) {
204        if ( ExtensionRegistry::getInstance()->isLoaded( 'FlaggedRevs' ) && $this->flaggedRevsParserCacheFactory ) {
205            $page = FlaggableWikiPage::newInstance( $title );
206            $isOldRev = $requestedRevId && $requestedRevId !== $page->getLatest();
207            $latestRevMayBeSpecial = FlaggedRevs::inclusionSetting() === FR_INCLUDES_STABLE;
208
209            if ( $isOldRev || $latestRevMayBeSpecial ) {
210                $requestedRevId = $requestedRevId ?: $page->getLatest();
211                if ( $requestedRevId === $page->getStable() ) {
212                    // This is the stable revision, so we need to use the special FlaggedRevs cache.
213                    $parserCache = $this->flaggedRevsParserCacheFactory->getParserCache( $parserOptions );
214                    $parserOutput = $parserCache->get( $page, $parserOptions );
215                    if ( $parserOutput ) {
216                        return $parserOutput;
217                    }
218                }
219            }
220        } else {
221            $page = $this->wikiPageFactory->newFromTitle( $title );
222        }
223
224        // This is the line that will replace the whole function, once the workaround can be
225        // removed.
226        //
227        // Note: This might give slightly different results than a FlaggedRevs parse of the stable
228        // revision, for example a mapframe template will use its latest revision rather than the
229        // stable template revision.
230        return $page->getParserOutput( $parserOptions, $requestedRevId );
231    }
232
233}