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