Completed
Branch master (8e77a6)
by Tobias
45:12 queued 20:15
created

Mapbox::geocodeQuery()   B

Complexity

Conditions 6
Paths 13

Size

Total Lines 33

Duplication

Lines 5
Ratio 15.15 %

Importance

Changes 0
Metric Value
dl 5
loc 33
rs 8.7697
c 0
b 0
f 0
cc 6
nc 13
nop 1
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
    public function __construct(
153
        HttpClient $client,
154
        string $accessToken,
155
        string $country = null,
156
        string $geocodingMode = self::GEOCODING_MODE_PLACES
157
    ) {
158
        parent::__construct($client);
159
160
        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
        $this->client = $client;
165
        $this->accessToken = $accessToken;
166
        $this->country = $country;
167
        $this->geocodingMode = $geocodingMode;
168
    }
169
170
    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
        if (filter_var($query->getText(), FILTER_VALIDATE_IP)) {
175
            throw new UnsupportedOperation('The Mapbox provider does not support IP addresses, only street addresses.');
176
        }
177
178
        $url = sprintf(self::GEOCODE_ENDPOINT_URL_SSL, $this->geocodingMode, rawurlencode($query->getText()));
179
180
        $urlParameters = [];
181
        if ($query->getBounds()) {
182
            $urlParameters['bbox'] = sprintf(
183
                '%s,%s|%s,%s',
184
                $query->getBounds()->getWest(),
185
                $query->getBounds()->getSouth(),
186
                $query->getBounds()->getEast(),
187
                $query->getBounds()->getNorth()
188
            );
189
        }
190
191 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...
192
            $urlParameters['types'] = is_array($locationType) ? implode(',', $locationType) : $locationType;
193
        } else {
194
            $urlParameters['types'] = self::DEFAULT_TYPE;
195
        }
196
197
        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...
198
            $url .= '?'.http_build_query($urlParameters);
199
        }
200
201
        return $this->fetchUrl($url, $query->getLimit(), $query->getLocale(), $query->getData('country', $this->country));
202
    }
203
204
    public function reverseQuery(ReverseQuery $query): Collection
205
    {
206
        $coordinate = $query->getCoordinates();
207
        $url = sprintf(
208
            self::REVERSE_ENDPOINT_URL_SSL,
209
            $this->geocodingMode,
210
            $coordinate->getLongitude(),
211
            $coordinate->getLatitude()
212
        );
213
214 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...
215
            $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...
216
        } else {
217
            $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...
218
        }
219
220
        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...
221
            $url .= '?'.http_build_query($urlParameters);
222
        }
223
224
        return $this->fetchUrl($url, $query->getLimit(), $query->getLocale(), $query->getData('country', $this->country));
225
    }
226
227
    /**
228
     * {@inheritdoc}
229
     */
230
    public function getName(): string
231
    {
232
        return 'mapbox';
233
    }
234
235
    /**
236
     * @param string      $url
237
     * @param int         $limit
238
     * @param string|null $locale
239
     * @param string|null $country
240
     *
241
     * @return string query with extra params
242
     */
243
    private function buildQuery(string $url, int $limit, string $locale = null, string $country = null): string
244
    {
245
        $parameters = array_filter([
246
            'country' => $country,
247
            'language' => $locale,
248
            'limit' => $limit,
249
            'access_token' => $this->accessToken,
250
        ]);
251
252
        $separator = parse_url($url, PHP_URL_QUERY) ? '&' : '?';
253
254
        return $url.$separator.http_build_query($parameters);
255
    }
256
257
    /**
258
     * @param string      $url
259
     * @param int         $limit
260
     * @param string|null $locale
261
     * @param string|null $country
262
     *
263
     * @return AddressCollection
264
     */
265
    private function fetchUrl(string $url, int $limit, string $locale = null, string $country = null): AddressCollection
266
    {
267
        $url = $this->buildQuery($url, $limit, $locale, $country);
268
        $content = $this->getUrlContents($url);
269
        $json = $this->validateResponse($url, $content);
270
271
        // no result
272
        if (!isset($json['features']) || !count($json['features'])) {
273
            return new AddressCollection([]);
274
        }
275
276
        $results = [];
277
        foreach ($json['features'] as $result) {
278
            if (!array_key_exists('context', $result)) {
279
                break;
280
            }
281
282
            $builder = new AddressBuilder($this->getName());
283
            $this->parseCoordinates($builder, $result);
284
285
            // set official Mapbox place id
286
            if (isset($result['id'])) {
287
                $builder->setValue('id', $result['id']);
288
            }
289
290
            // set official Mapbox place id
291
            if (isset($result['text'])) {
292
                $builder->setValue('street_name', $result['text']);
293
            }
294
295
            // update address components
296
            foreach ($result['context'] as $component) {
297
                $this->updateAddressComponent($builder, $component['id'], $component);
298
            }
299
300
            /** @var MapboxAddress $address */
301
            $address = $builder->build(MapboxAddress::class);
302
            $address = $address->withId($builder->getValue('id'));
303
            if (isset($result['address'])) {
304
                $address = $address->withStreetNumber($result['address']);
305
            }
306
            if (isset($result['place_type'])) {
307
                $address = $address->withResultType($result['place_type']);
308
            }
309
            if (isset($result['place_name'])) {
310
                $address = $address->withFormattedAddress($result['place_name']);
311
            }
312
            $address = $address->withStreetName($builder->getValue('street_name'));
313
            $address = $address->withNeighborhood($builder->getValue('neighborhood'));
314
            $results[] = $address;
315
316
            if (count($results) >= $limit) {
317
                break;
318
            }
319
        }
320
321
        return new AddressCollection($results);
322
    }
323
324
    /**
325
     * Update current resultSet with given key/value.
326
     *
327
     * @param AddressBuilder $builder
328
     * @param string         $type    Component type
329
     * @param array          $value   The component value
330
     */
331
    private function updateAddressComponent(AddressBuilder $builder, string $type, array $value)
332
    {
333
        $typeParts = explode('.', $type);
334
        $type = reset($typeParts);
335
336
        switch ($type) {
337
            case 'postcode':
338
                $builder->setPostalCode($value['text']);
339
340
                break;
341
342
            case 'locality':
343
                $builder->setLocality($value['text']);
344
345
                break;
346
347
            case 'country':
348
                $builder->setCountry($value['text']);
349
                $builder->setCountryCode(strtoupper($value['short_code']));
350
351
                break;
352
353
            case 'neighborhood':
354
                $builder->setValue($type, $value['text']);
355
356
                break;
357
358
            case 'place':
359
                $builder->addAdminLevel(1, $value['text']);
360
                $builder->setLocality($value['text']);
361
362
                break;
363
364
            case 'region':
365
                $code = null;
366
                if (!empty($value['short_code']) && preg_match('/[A-z]{2}-/', $value['short_code'])) {
367
                    $code = preg_replace('/[A-z]{2}-/', '', $value['short_code']);
368
                }
369
                $builder->addAdminLevel(2, $value['text'], $code);
370
371
                break;
372
373
            default:
374
        }
375
    }
376
377
    /**
378
     * Decode the response content and validate it to make sure it does not have any errors.
379
     *
380
     * @param string $url
381
     * @param string $content
382
     *
383
     * @return array
384
     */
385
    private function validateResponse(string $url, $content): array
386
    {
387
        $json = json_decode($content, true);
388
389
        // API error
390
        if (!isset($json) || JSON_ERROR_NONE !== json_last_error()) {
391
            throw InvalidServerResponse::create($url);
392
        }
393
394
        return $json;
395
    }
396
397
    /**
398
     * Parse coordinats and bounds.
399
     *
400
     * @param AddressBuilder $builder
401
     * @param array          $result
402
     */
403
    private function parseCoordinates(AddressBuilder $builder, array $result)
404
    {
405
        $coordinates = $result['geometry']['coordinates'];
406
        $builder->setCoordinates($coordinates[1], $coordinates[0]);
407
408
        if (isset($result['bbox'])) {
409
            $builder->setBounds(
410
                $result['bbox'][1],
411
                $result['bbox'][0],
412
                $result['bbox'][3],
413
                $result['bbox'][2]
414
            );
415
        }
416
    }
417
}
418