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 ApiBase;
6use ApiQuery;
7use ApiQueryBase;
8use ExtensionRegistry;
9use FlaggableWikiPage;
10use FlaggedRevs;
11use FlaggedRevsParserCache;
12use FormatJson;
13use Kartographer\State;
14use MediaWiki\Logger\LoggerFactory;
15use MediaWiki\Page\PageIdentity;
16use MediaWiki\Page\WikiPageFactory;
17use MediaWiki\Parser\ParserOutput;
18use ParserOptions;
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    /**
31     * @param ApiQuery $query
32     * @param string $moduleName
33     * @param WikiPageFactory $pageFactory
34     * @param FlaggedRevsParserCache|null $parserCache
35     */
36    public function __construct( ApiQuery $query, $moduleName,
37        WikiPageFactory $pageFactory,
38        ?FlaggedRevsParserCache $parserCache
39    ) {
40        parent::__construct( $query, $moduleName, 'mpd' );
41        $this->pageFactory = $pageFactory;
42        $this->parserCache = $parserCache;
43    }
44
45    /** @inheritDoc */
46    public function execute() {
47        $params = $this->extractRequestParams();
48        $limit = $params['limit'];
49        $groupIds = $params['groups'] === '' ? [] : explode( '|', $params['groups'] );
50        $titles = $this->getPageSet()->getGoodPages();
51        if ( !$titles ) {
52            return;
53        }
54
55        $revisionToPageMap = $this->getPageSet()->getLiveRevisionIDs();
56        $revIds = array_flip( $revisionToPageMap );
57        // Note: It's probably possible to merge data from multiple revisions of the same page
58        // because of the way group IDs are unique. Intentionally not implemented yet.
59        if ( count( $revisionToPageMap ) > count( $revIds ) ) {
60            $this->dieWithError( 'apierror-kartographer-conflicting-revids' );
61        }
62
63        $count = 0;
64        foreach ( $titles as $pageId => $title ) {
65            if ( ++$count > $limit ) {
66                $this->setContinueEnumParameter( 'continue', $pageId );
67                break;
68            }
69
70            $revId = $revIds[$pageId] ?? null;
71            if ( $revId ) {
72                // This isn't strictly needed, but the only way a consumer can distinguish an
73                // endpoint that supports revids from an endpoint that doesn't
74                $this->getResult()->addValue( [ 'query', 'pages', $pageId ], 'revid', $revId );
75            }
76
77            $parserOutput = $this->getParserOutput( $title, $revId );
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        ];
162    }
163
164    /** @inheritDoc */
165    public function getExamplesMessages() {
166        return [
167            'action=query&prop=mapdata&titles=Metallica' => 'apihelp-query+mapdata-example-1',
168            'action=query&prop=mapdata&titles=Metallica&mpdgroups=group1|group2'
169                => 'apihelp-query+mapdata-example-2',
170        ];
171    }
172
173    /** @inheritDoc */
174    public function getCacheMode( $params ) {
175        return 'public';
176    }
177
178    /** @inheritDoc */
179    public function isInternal() {
180        return true;
181    }
182
183    /**
184     * Wrap parsing logic to accomplish a cache workaround
185     *
186     * TODO: Once T307342 is resolved, MediaWiki core will be able to dynamically select the
187     * correct cache.  Until then, we're explicitly using the FlaggedRevs stable-revision cache to
188     * avoid an unnecessary parse, and to avoid polluting the RevisionOutputCache.
189     *
190     * @param PageIdentity $title
191     * @param int|null $requestedRevId
192     *
193     * @return ParserOutput|false
194     */
195    private function getParserOutput( PageIdentity $title, ?int $requestedRevId ) {
196        $parserOptions = ParserOptions::newFromAnon();
197
198        if ( ExtensionRegistry::getInstance()->isLoaded( 'FlaggedRevs' ) && $this->parserCache ) {
199            $page = FlaggableWikiPage::newInstance( $title );
200            $isOldRev = $requestedRevId && $requestedRevId !== $page->getLatest();
201            $latestRevMayBeSpecial = FlaggedRevs::inclusionSetting() === FR_INCLUDES_STABLE;
202
203            if ( $isOldRev || $latestRevMayBeSpecial ) {
204                $requestedRevId = $requestedRevId ?: $page->getLatest();
205                if ( $requestedRevId === $page->getStable() ) {
206                    // This is the stable revision, so we need to use the special FlaggedRevs cache.
207                    $parserOutput = $this->parserCache->get( $page, $parserOptions );
208                    if ( $parserOutput ) {
209                        return $parserOutput;
210                    }
211                }
212            }
213        } else {
214            $page = $this->pageFactory->newFromTitle( $title );
215        }
216
217        // This is the line that will replace the whole function, once the workaround can be
218        // removed.
219        //
220        // Note: This might give slightly different results than a FlaggedRevs parse of the stable
221        // revision, for example a mapframe template will use its latest revision rather than the
222        // stable template revision.
223        return $page->getParserOutput( $parserOptions, $requestedRevId );
224    }
225
226}