Passed
Push — master ( eb2990...8e77a6 )
by Tobias
25:20 queued 02:27
created

Mapbox.php (1 issue)

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;
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
        if (null !== $locationType = $query->getData('location_type')) {
192
            $urlParameters['types'] = is_array($locationType) ? implode(',', $locationType) : $locationType;
193
        } else {
194
            $urlParameters['types'] = self::DEFAULT_TYPE;
195
        }
196
197
        if ($urlParameters) {
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
        if (null !== $locationType = $query->getData('location_type')) {
215
            $urlParameters['types'] = is_array($locationType) ? implode(',', $locationType) : $locationType;
0 ignored issues
show
Comprehensibility Best Practice introduced by
$urlParameters was never initialized. Although not strictly required by PHP, it is generally a good practice to add $urlParameters = array(); before regardless.
Loading history...
216
        } else {
217
            $urlParameters['types'] = self::DEFAULT_TYPE;
218
        }
219
220
        if ($urlParameters) {
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