Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.43% covered (warning)
66.43%
95 / 143
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
QueryGeoSearch
66.43% covered (warning)
66.43%
95 / 143
40.00% covered (danger)
40.00%
4 / 10
67.34
0.00% covered (danger)
0.00%
0 / 1
 factory
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCacheMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 executeGenerator
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parseBbox
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
11.82
 run
18.42% covered (danger)
18.42%
7 / 38
0.00% covered (danger)
0.00%
0 / 1
90.18
 getAllowedParams
95.95% covered (success)
95.95%
71 / 74
0.00% covered (danger)
0.00%
0 / 1
2
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace GeoData\Api;
4
5use GeoData\BoundingBox;
6use GeoData\Coord;
7use GeoData\GeoData;
8use GeoData\Globe;
9use MediaWiki\Api\ApiBase;
10use MediaWiki\Api\ApiPageSet;
11use MediaWiki\Api\ApiQuery;
12use MediaWiki\Api\ApiQueryGeneratorBase;
13use MediaWiki\Page\WikiPage;
14use MediaWiki\Title\NamespaceInfo;
15use MediaWiki\Title\Title;
16use RuntimeException;
17use Wikimedia\ParamValidator\ParamValidator;
18use Wikimedia\ParamValidator\TypeDef\IntegerDef;
19
20class QueryGeoSearch extends ApiQueryGeneratorBase {
21    private const MIN_RADIUS = 10;
22    private const DEFAULT_RADIUS = 500;
23
24    /**
25     * @var Coord|null The center of search area
26     */
27    protected $coord;
28
29    /**
30     * @var BoundingBox Bounding box to search in
31     */
32    protected $bbox;
33
34    /**
35     * @var int Search radius in meter
36     */
37    protected $radius;
38
39    /**
40     * @var int Id of the page to search around, exclude from results
41     */
42    protected $idToExclude;
43
44    public static function factory(
45        ApiQuery $query,
46        string $moduleName,
47        NamespaceInfo $namespaceInfo
48    ): self {
49        $geoDataBackend = $query->getConfig()->get( 'GeoDataBackend' );
50
51        switch ( strtolower( $geoDataBackend ) ) {
52            case 'db':
53                return new QueryGeoSearchDb( $query, $moduleName );
54            case 'elastic':
55                return new QueryGeoSearchElastic( $query, $moduleName, $namespaceInfo );
56            default:
57                throw new RuntimeException( 'GeoDataBackend data backend cannot be empty' );
58        }
59    }
60
61    public function __construct( ApiQuery $query, string $moduleName ) {
62        parent::__construct( $query, $moduleName, 'gs' );
63    }
64
65    public function execute() {
66        $this->run();
67    }
68
69    /** @inheritDoc */
70    public function getCacheMode( $params ) {
71        return 'public';
72    }
73
74    /** @inheritDoc */
75    public function executeGenerator( $resultPageSet ) {
76        $this->run( $resultPageSet );
77    }
78
79    private function parseBbox( string $bbox, Globe $globe ): BoundingBox {
80        $parts = explode( '|', $bbox );
81        $vals = array_map( 'floatval', $parts );
82        if ( count( $parts ) != 4
83            // Pass $parts here for extra validation
84            || !$globe->coordinatesAreValid( $parts[0], $parts[1] )
85            || !$globe->coordinatesAreValid( $parts[2], $parts[3] )
86            || $vals[0] <= $vals[2]
87        ) {
88            $this->dieWithError( 'apierror-geodata-invalidbox', 'invalid-bbox' );
89        }
90        $bbox = BoundingBox::newFromNumbers( $vals[0], $vals[1], $vals[2], $vals[3], $globe );
91        $area = $bbox->area();
92        $maxRadius = $this->getConfig()->get( 'MaxGeoSearchRadius' );
93        if ( $area > $maxRadius * $maxRadius * 4 || $area < 100 ) {
94            $this->dieWithError( 'apierror-geodata-boxtoobig', 'toobig' );
95        }
96
97        return $bbox;
98    }
99
100    /**
101     * @param ApiPageSet|null $resultPageSet
102     */
103    protected function run( $resultPageSet = null ): void {
104        $params = $this->extractRequestParams();
105
106        $globe = new Globe( $params['globe'] );
107        $this->requireOnlyOneParameter( $params, 'coord', 'page', 'bbox' );
108        if ( isset( $params['coord'] ) ) {
109            $arr = explode( '|', $params['coord'] );
110            if ( count( $arr ) != 2 || !$globe->coordinatesAreValid( $arr[0], $arr[1] ) ) {
111                $this->dieWithError( 'apierror-geodata-badcoord', 'invalid-coord' );
112            }
113            $this->coord = new Coord( floatval( $arr[0] ), floatval( $arr[1] ), $globe );
114        } elseif ( isset( $params['page'] ) ) {
115            $t = Title::newFromText( $params['page'] );
116            if ( !$t || !$t->canExist() ) {
117                $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ] );
118            }
119            if ( !$t->exists() ) {
120                $this->dieWithError(
121                    [ 'apierror-missingtitle-byname', wfEscapeWikiText( $t->getPrefixedText() ) ], 'missingtitle'
122                );
123            }
124
125            $pageId = $t->getArticleID();
126            $this->coord = GeoData::getPageCoordinates( $pageId );
127            if ( !$this->coord ) {
128                $this->dieWithError( 'apierror-geodata-nocoord', 'no-coordinates' );
129            }
130            $this->idToExclude = $pageId;
131        } elseif ( isset( $params['bbox'] ) ) {
132            $this->bbox = $this->parseBbox( $params['bbox'], $globe );
133            // Even when using bbox, we need a center to sort by distance
134            $this->coord = $this->bbox->center();
135        } else {
136            $this->dieDebug( __METHOD__, 'Logic error' );
137        }
138
139        // retrieve some fields only if page set needs them
140        if ( $resultPageSet === null ) {
141            $this->addTables( 'page' );
142            $this->addFields( [ 'page_id', 'page_namespace', 'page_title' ] );
143        } else {
144            $pageQuery = WikiPage::getQueryInfo();
145            $this->addTables( $pageQuery['tables'] );
146            $this->addFields( $pageQuery['fields'] );
147            $this->addJoinConds( $pageQuery['joins'] );
148        }
149        $this->addWhereFld( 'page_namespace', $params['namespace'] );
150
151        $this->radius = intval( $params['radius'] );
152
153        if ( $resultPageSet === null ) {
154            $this->getResult()->addIndexedTagName( [ 'query', $this->getModuleName() ],
155                $this->getModulePrefix()
156            );
157        }
158    }
159
160    /** @inheritDoc */
161    public function getAllowedParams() {
162        $propTypes = [ 'type', 'name', 'dim', 'country', 'region', 'globe' ];
163        $primaryTypes = [ 'primary', 'secondary', 'all' ];
164        $config = $this->getConfig();
165        $maxRadius = $config->get( 'MaxGeoSearchRadius' );
166
167        $params = [
168            'coord' => [
169                ParamValidator::PARAM_TYPE => 'string',
170                ApiBase::PARAM_HELP_MSG_APPEND => [
171                    'geodata-api-help-coordinates-format',
172                ],
173            ],
174            'page' => [
175                ParamValidator::PARAM_TYPE => 'string',
176            ],
177            'bbox' => [
178                ParamValidator::PARAM_TYPE => 'string',
179            ],
180            'radius' => [
181                ParamValidator::PARAM_TYPE => 'integer',
182                ParamValidator::PARAM_DEFAULT => min( self::DEFAULT_RADIUS, $maxRadius ),
183                IntegerDef::PARAM_MIN => self::MIN_RADIUS,
184                IntegerDef::PARAM_MAX => $maxRadius,
185                ApiBase::PARAM_RANGE_ENFORCE => true,
186            ],
187            'maxdim' => [
188                ParamValidator::PARAM_TYPE => 'integer',
189            ],
190            'sort' => [
191                ParamValidator::PARAM_TYPE => [ 'distance', 'relevance' ],
192                ParamValidator::PARAM_DEFAULT => 'distance',
193                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
194            ],
195            'limit' => [
196                ParamValidator::PARAM_DEFAULT => 10,
197                ParamValidator::PARAM_TYPE => 'limit',
198                IntegerDef::PARAM_MIN => 1,
199                IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1,
200                IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2
201            ],
202            'globe' => [
203                ParamValidator::PARAM_TYPE => [
204                    Globe::EARTH,
205                    Globe::MOON,
206                    Globe::MARS,
207                    Globe::VENUS,
208                ],
209                ParamValidator::PARAM_DEFAULT => Globe::EARTH,
210            ],
211            'namespace' => [
212                ParamValidator::PARAM_TYPE => 'namespace',
213                ParamValidator::PARAM_DEFAULT => NS_MAIN,
214                ParamValidator::PARAM_ISMULTI => true,
215            ],
216            'prop' => [
217                ParamValidator::PARAM_TYPE => $propTypes,
218                ParamValidator::PARAM_DEFAULT => 'globe',
219                ParamValidator::PARAM_ISMULTI => true,
220                ApiBase::PARAM_HELP_MSG_PER_VALUE => array_map( static function ( $i ) use ( $propTypes ) {
221                    return 'apihelp-query+coordinates-paramvalue-prop-' . $propTypes[$i];
222                }, array_flip( $propTypes ) ),
223            ],
224            'primary' => [
225                ParamValidator::PARAM_TYPE => $primaryTypes,
226                ParamValidator::PARAM_DEFAULT => 'primary',
227                ApiBase::PARAM_HELP_MSG_PER_VALUE => array_map( static function ( $i ) use ( $primaryTypes ) {
228                    return 'apihelp-query+coordinates-paramvalue-primary-' . $primaryTypes[$i];
229                }, array_flip( $primaryTypes ) ),
230            ],
231        ];
232        if ( $config->get( 'GeoDataDebug' ) ) {
233            $params['debug'] = [
234                ParamValidator::PARAM_TYPE => 'boolean',
235            ];
236        }
237        return $params;
238    }
239
240    /** @inheritDoc */
241    protected function getExamplesMessages() {
242        return [
243            'action=query&list=geosearch&gsradius=10000&gscoord=37.786971|-122.399677'
244                => 'apihelp-query+geosearch-example-1',
245            'action=query&list=geosearch&gsbbox=37.8|-122.3|37.7|-122.4'
246                => 'apihelp-query+geosearch-example-2',
247        ];
248    }
249
250    /** @inheritDoc */
251    public function getHelpUrls() {
252        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:GeoData#list.3Dgeosearch';
253    }
254}