Code

< 40 %
40-60 %
> 60 %
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 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
        if (null !== $locationType = $query->getData('location_type')) {
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) {
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
        if (null !== $locationType = $query->getData('location_type')) {
220
            $urlParameters['types'] = is_array($locationType) ? implode(',', $locationType) : $locationType;
221
        } else {
222 9
            $urlParameters['types'] = self::DEFAULT_TYPE;
223
        }
224
225 9
        if ($urlParameters) {
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
        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