Completed
Push — master ( 61f832...e592a7 )
by Tobias
01:12
created

Mapbox::geocodeQuery()   B

Complexity

Conditions 8
Paths 37

Size

Total Lines 38

Duplication

Lines 5
Ratio 13.16 %

Code Coverage

Tests 20
CRAP Score 8

Importance

Changes 0
Metric Value
dl 5
loc 38
ccs 20
cts 20
cp 1
rs 8.0675
c 0
b 0
f 0
cc 8
nc 37
nop 1
crap 8
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the Geocoder package.
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 *
10
 * @license    MIT License
11
 */
12
13
namespace Geocoder\Provider\Mapbox;
14
15
use Geocoder\Collection;
16
use Geocoder\Exception\InvalidArgument;
17
use Geocoder\Exception\InvalidServerResponse;
18
use Geocoder\Exception\UnsupportedOperation;
19
use Geocoder\Model\AddressCollection;
20
use Geocoder\Model\AddressBuilder;
21
use Geocoder\Query\GeocodeQuery;
22
use Geocoder\Query\ReverseQuery;
23
use Geocoder\Http\Provider\AbstractHttpProvider;
24
use Geocoder\Provider\Mapbox\Model\MapboxAddress;
25
use Geocoder\Provider\Provider;
26
use Http\Client\HttpClient;
27
28
final class Mapbox extends AbstractHttpProvider implements Provider
29
{
30
    /**
31
     * @var string
32
     */
33
    const GEOCODE_ENDPOINT_URL_SSL = 'https://api.mapbox.com/geocoding/v5/%s/%s.json';
34
35
    /**
36
     * @var string
37
     */
38
    const REVERSE_ENDPOINT_URL_SSL = 'https://api.mapbox.com/geocoding/v5/%s/%F,%F.json';
39
40
    /**
41
     * @var string
42
     */
43
    const GEOCODING_MODE_PLACES = 'mapbox.places';
44
45
    /**
46
     * @var string
47
     */
48
    const GEOCODING_MODE_PLACES_PERMANENT = 'mapbox.places-permanent';
49
50
    /**
51
     * @var array
52
     */
53
    const GEOCODING_MODES = [
54
        self::GEOCODING_MODE_PLACES,
55
        self::GEOCODING_MODE_PLACES_PERMANENT,
56
    ];
57
58
    /**
59
     * @var string
60
     */
61
    const TYPE_COUNTRY = 'country';
62
63
    /**
64
     * @var string
65
     */
66
    const TYPE_REGION = 'region';
67
68
    /**
69
     * @var string
70
     */
71
    const TYPE_POSTCODE = 'postcode';
72
73
    /**
74
     * @var string
75
     */
76
    const TYPE_DISTRICT = 'district';
77
78
    /**
79
     * @var string
80
     */
81
    const TYPE_PLACE = 'place';
82
83
    /**
84
     * @var string
85
     */
86
    const TYPE_LOCALITY = 'locality';
87
88
    /**
89
     * @var string
90
     */
91
    const TYPE_NEIGHBORHOOD = 'neighborhood';
92
93
    /**
94
     * @var string
95
     */
96
    const TYPE_ADDRESS = 'address';
97
98
    /**
99
     * @var string
100
     */
101
    const TYPE_POI = 'poi';
102
103
    /**
104
     * @var string
105
     */
106
    const TYPE_POI_LANDMARK = 'poi.landmark';
107
108
    /**
109
     * @var array
110
     */
111
    const TYPES = [
112
        self::TYPE_COUNTRY,
113
        self::TYPE_REGION,
114
        self::TYPE_POSTCODE,
115
        self::TYPE_DISTRICT,
116
        self::TYPE_PLACE,
117
        self::TYPE_LOCALITY,
118
        self::TYPE_NEIGHBORHOOD,
119
        self::TYPE_ADDRESS,
120
        self::TYPE_POI,
121
        self::TYPE_POI_LANDMARK,
122
    ];
123
124
    const DEFAULT_TYPE = self::TYPE_ADDRESS;
125
126
    /**
127
     * @var HttpClient
128
     */
129
    private $client;
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
130
131
    /**
132
     * @var string
133
     */
134
    private $accessToken;
135
136
    /**
137
     * @var string|null
138
     */
139
    private $country;
140
141
    /**
142
     * @var string
143
     */
144
    private $geocodingMode;
145
146
    /**
147
     * @param HttpClient  $client        An HTTP adapter
148
     * @param string      $accessToken   Your Mapbox access token
149
     * @param string|null $country
150
     * @param string      $geocodingMode
151
     */
152 27
    public function __construct(
153
        HttpClient $client,
154
        string $accessToken,
155
        string $country = null,
156
        string $geocodingMode = self::GEOCODING_MODE_PLACES
157
    ) {
158 27
        parent::__construct($client);
159
160 27
        if (!in_array($geocodingMode, self::GEOCODING_MODES)) {
161
            throw new InvalidArgument('The Mapbox geocoding mode should be either mapbox.places or mapbox.places-permanent.');
162
        }
163
164 27
        $this->client = $client;
165 27
        $this->accessToken = $accessToken;
166 27
        $this->country = $country;
167 27
        $this->geocodingMode = $geocodingMode;
168 27
    }
169
170 17
    public function geocodeQuery(GeocodeQuery $query): Collection
171
    {
172
        // Mapbox API returns invalid data if IP address given
173
        // This API doesn't handle IPs
174 17
        if (filter_var($query->getText(), FILTER_VALIDATE_IP)) {
175 3
            throw new UnsupportedOperation('The Mapbox provider does not support IP addresses, only street addresses.');
176
        }
177
178 14
        $url = sprintf(self::GEOCODE_ENDPOINT_URL_SSL, $this->geocodingMode, rawurlencode($query->getText()));
179
180 14
        $urlParameters = [];
181 14
        if ($query->getBounds()) {
182
            // Format is "minLon,minLat,maxLon,maxLat"
183 3
            $urlParameters['bbox'] = sprintf(
184 3
                '%s,%s,%s,%s',
185 3
                $query->getBounds()->getWest(),
186 3
                $query->getBounds()->getSouth(),
187 3
                $query->getBounds()->getEast(),
188 3
                $query->getBounds()->getNorth()
189
            );
190
        }
191
192 14 View Code Duplication
        if (null !== $locationType = $query->getData('location_type')) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
193 1
            $urlParameters['types'] = is_array($locationType) ? implode(',', $locationType) : $locationType;
194
        } else {
195 13
            $urlParameters['types'] = self::DEFAULT_TYPE;
196
        }
197
198 14
        if (null !== $fuzzyMatch = $query->getData('fuzzy_match')) {
199 2
            $urlParameters['fuzzyMatch'] = $fuzzyMatch ? 'true' : 'false';
200
        }
201
202 14
        if ($urlParameters) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $urlParameters of type array<string,object|inte...le|string|null|boolean> is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
203 14
            $url .= '?'.http_build_query($urlParameters);
204
        }
205
206 14
        return $this->fetchUrl($url, $query->getLimit(), $query->getLocale(), $query->getData('country', $this->country));
207
    }
208
209 9
    public function reverseQuery(ReverseQuery $query): Collection
210
    {
211 9
        $coordinate = $query->getCoordinates();
212 9
        $url = sprintf(
213 9
            self::REVERSE_ENDPOINT_URL_SSL,
214 9
            $this->geocodingMode,
215 9
            $coordinate->getLongitude(),
216 9
            $coordinate->getLatitude()
217
        );
218
219 9 View Code Duplication
        if (null !== $locationType = $query->getData('location_type')) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
220
            $urlParameters['types'] = is_array($locationType) ? implode(',', $locationType) : $locationType;
0 ignored issues
show
Coding Style Comprehensibility introduced by
$urlParameters was never initialized. Although not strictly required by PHP, it is generally a good practice to add $urlParameters = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
221
        } else {
222 9
            $urlParameters['types'] = self::DEFAULT_TYPE;
0 ignored issues
show
Coding Style Comprehensibility introduced by
$urlParameters was never initialized. Although not strictly required by PHP, it is generally a good practice to add $urlParameters = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
223
        }
224
225 9
        if ($urlParameters) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $urlParameters of type array<string,object|inte...le|string|null|boolean> is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
226 9
            $url .= '?'.http_build_query($urlParameters);
227
        }
228
229 9
        return $this->fetchUrl($url, $query->getLimit(), $query->getLocale(), $query->getData('country', $this->country));
230
    }
231
232
    /**
233
     * {@inheritdoc}
234
     */
235 8
    public function getName(): string
236
    {
237 8
        return 'mapbox';
238
    }
239
240
    /**
241
     * @param string      $url
242
     * @param int         $limit
243
     * @param string|null $locale
244
     * @param string|null $country
245
     *
246
     * @return string query with extra params
247
     */
248 23
    private function buildQuery(string $url, int $limit, string $locale = null, string $country = null): string
249
    {
250 23
        $parameters = array_filter([
251 23
            'country' => $country,
252 23
            'language' => $locale,
253 23
            'limit' => $limit,
254 23
            'access_token' => $this->accessToken,
255
        ]);
256
257 23
        $separator = parse_url($url, PHP_URL_QUERY) ? '&' : '?';
258
259 23
        return $url.$separator.http_build_query($parameters);
260
    }
261
262
    /**
263
     * @param string      $url
264
     * @param int         $limit
265
     * @param string|null $locale
266
     * @param string|null $country
267
     *
268
     * @return AddressCollection
269
     */
270 23
    private function fetchUrl(string $url, int $limit, string $locale = null, string $country = null): AddressCollection
271
    {
272 23
        $url = $this->buildQuery($url, $limit, $locale, $country);
273 23
        $content = $this->getUrlContents($url);
274 10
        $json = $this->validateResponse($url, $content);
275
276
        // no result
277 10
        if (!isset($json['features']) || !count($json['features'])) {
278 2
            return new AddressCollection([]);
279
        }
280
281 8
        $results = [];
282 8
        foreach ($json['features'] as $result) {
283 8
            if (!array_key_exists('context', $result)) {
284 1
                break;
285
            }
286
287 7
            $builder = new AddressBuilder($this->getName());
288 7
            $this->parseCoordinates($builder, $result);
289
290
            // set official Mapbox place id
291 7
            if (isset($result['id'])) {
292 7
                $builder->setValue('id', $result['id']);
293
            }
294
295
            // set official Mapbox place id
296 7
            if (isset($result['text'])) {
297 7
                $builder->setValue('street_name', $result['text']);
298
            }
299
300
            // update address components
301 7
            foreach ($result['context'] as $component) {
302 7
                $this->updateAddressComponent($builder, $component['id'], $component);
303
            }
304
305
            /** @var MapboxAddress $address */
306 7
            $address = $builder->build(MapboxAddress::class);
307 7
            $address = $address->withId($builder->getValue('id'));
308 7
            if (isset($result['address'])) {
309 4
                $address = $address->withStreetNumber($result['address']);
310
            }
311 7
            if (isset($result['place_type'])) {
312 7
                $address = $address->withResultType($result['place_type']);
313
            }
314 7
            if (isset($result['place_name'])) {
315 7
                $address = $address->withFormattedAddress($result['place_name']);
316
            }
317 7
            $address = $address->withStreetName($builder->getValue('street_name'));
318 7
            $address = $address->withNeighborhood($builder->getValue('neighborhood'));
319 7
            $results[] = $address;
320
321 7
            if (count($results) >= $limit) {
322 5
                break;
323
            }
324
        }
325
326 8
        return new AddressCollection($results);
327
    }
328
329
    /**
330
     * Update current resultSet with given key/value.
331
     *
332
     * @param AddressBuilder $builder
333
     * @param string         $type    Component type
334
     * @param array          $value   The component value
335
     */
336 7
    private function updateAddressComponent(AddressBuilder $builder, string $type, array $value)
337
    {
338 7
        $typeParts = explode('.', $type);
339 7
        $type = reset($typeParts);
340
341 7
        switch ($type) {
342 7
            case 'postcode':
343 6
                $builder->setPostalCode($value['text']);
344
345 6
                break;
346
347 7
            case 'locality':
348 2
                $builder->setLocality($value['text']);
349
350 2
                break;
351
352 7
            case 'country':
353 7
                $builder->setCountry($value['text']);
354 7
                if (isset($value['short_code'])) {
355 6
                    $builder->setCountryCode(strtoupper($value['short_code']));
356
                }
357
358 7
                break;
359
360 6
            case 'neighborhood':
361 5
                $builder->setValue($type, $value['text']);
362
363 5
                break;
364
365 6
            case 'place':
366 6
                $builder->addAdminLevel(1, $value['text']);
367 6
                $builder->setLocality($value['text']);
368
369 6
                break;
370
371 5
            case 'region':
372 5
                $code = null;
373 5
                if (!empty($value['short_code']) && preg_match('/[A-z]{2}-/', $value['short_code'])) {
374 5
                    $code = preg_replace('/[A-z]{2}-/', '', $value['short_code']);
375
                }
376 5
                $builder->addAdminLevel(2, $value['text'], $code);
377
378 5
                break;
379
380
            default:
381
        }
382 7
    }
383
384
    /**
385
     * Decode the response content and validate it to make sure it does not have any errors.
386
     *
387
     * @param string $url
388
     * @param string $content
389
     *
390
     * @return array
391
     */
392 10
    private function validateResponse(string $url, $content): array
393
    {
394 10
        $json = json_decode($content, true);
395
396
        // API error
397 10
        if (!isset($json) || JSON_ERROR_NONE !== json_last_error()) {
398
            throw InvalidServerResponse::create($url);
399
        }
400
401 10
        return $json;
402
    }
403
404
    /**
405
     * Parse coordinats and bounds.
406
     *
407
     * @param AddressBuilder $builder
408
     * @param array          $result
409
     */
410 7
    private function parseCoordinates(AddressBuilder $builder, array $result)
411
    {
412 7
        $coordinates = $result['geometry']['coordinates'];
413 7
        $builder->setCoordinates($coordinates[1], $coordinates[0]);
414
415 7
        if (isset($result['bbox'])) {
416 1
            $builder->setBounds(
417 1
                $result['bbox'][1],
418 1
                $result['bbox'][0],
419 1
                $result['bbox'][3],
420 1
                $result['bbox'][2]
421
            );
422
        }
423 7
    }
424
}
425