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\GoogleMaps;
14
15
use Geocoder\Collection;
16
use Geocoder\Exception\InvalidCredentials;
17
use Geocoder\Exception\InvalidServerResponse;
18
use Geocoder\Exception\QuotaExceeded;
19
use Geocoder\Exception\UnsupportedOperation;
20
use Geocoder\Http\Provider\AbstractHttpProvider;
21
use Geocoder\Model\AddressBuilder;
22
use Geocoder\Model\AddressCollection;
23
use Geocoder\Provider\GoogleMaps\Model\GoogleAddress;
24
use Geocoder\Provider\Provider;
25
use Geocoder\Query\GeocodeQuery;
26
use Geocoder\Query\ReverseQuery;
27
use Psr\Http\Client\ClientInterface;
28
29
/**
30
 * @author William Durand <[email protected]>
31
 */
32
final class GoogleMaps extends AbstractHttpProvider implements Provider
33
{
34
    /**
35
     * @var string
36
     */
37
    public const GEOCODE_ENDPOINT_URL_SSL = 'https://maps.googleapis.com/maps/api/geocode/json?address=%s';
38
39
    /**
40
     * @var string
41
     */
42
    public const REVERSE_ENDPOINT_URL_SSL = 'https://maps.googleapis.com/maps/api/geocode/json?latlng=%F,%F';
43
44
    /**
45
     * @var string|null
46
     */
47
    private $region;
48
49
    /**
50
     * @var string|null
51
     */
52
    private $apiKey;
53
54
    /**
55
     * @var string|null
56
     */
57
    private $clientId;
58
59
    /**
60
     * @var string|null
61
     */
62
    private $privateKey;
63
64
    /**
65
     * @var string|null
66
     */
67
    private $channel;
68
69
    /**
70
     * Google Maps for Business
71
     * https://developers.google.com/maps/documentation/business/
72
     * Maps for Business is no longer accepting new signups.
73
     *
74
     * @param ClientInterface $client     An HTTP adapter
75
     * @param string          $clientId   Your Client ID
76
     * @param string          $privateKey Your Private Key (optional)
77
     * @param string          $region     Region biasing (optional)
78
     * @param string          $apiKey     Google Geocoding API key (optional)
79
     * @param string          $channel    Google Channel parameter (optional)
80
     *
81
     * @return GoogleMaps
82
     */
83 5
    public static function business(
84
        ClientInterface $client,
85
        string $clientId,
86
        string $privateKey = null,
87
        string $region = null,
88
        string $apiKey = null,
89
        string $channel = null
90
    ) {
91 5
        $provider = new self($client, $region, $apiKey);
92 5
        $provider->clientId = $clientId;
93 5
        $provider->privateKey = $privateKey;
94 5
        $provider->channel = $channel;
95
96 5
        return $provider;
97
    }
98
99
    /**
100
     * @param ClientInterface $client An HTTP adapter
101
     * @param string          $region Region biasing (optional)
102
     * @param string          $apiKey Google Geocoding API key (optional)
103
     */
104 46
    public function __construct(ClientInterface $client, string $region = null, string $apiKey = null)
105
    {
106 46
        parent::__construct($client);
107
108 46
        $this->region = $region;
109 46
        $this->apiKey = $apiKey;
110
    }
111
112 34
    public function geocodeQuery(GeocodeQuery $query): Collection
113
    {
114
        // Google API returns invalid data if IP address given
115
        // This API doesn't handle IPs
116 34
        if (filter_var($query->getText(), FILTER_VALIDATE_IP)) {
117 3
            throw new UnsupportedOperation('The GoogleMaps provider does not support IP addresses, only street addresses.');
118
        }
119
120 31
        $url = sprintf(self::GEOCODE_ENDPOINT_URL_SSL, rawurlencode($query->getText()));
121 31
        if (null !== $bounds = $query->getBounds()) {
122
            $url .= sprintf(
123
                '&bounds=%s,%s|%s,%s',
124
                $bounds->getSouth(),
125
                $bounds->getWest(),
126
                $bounds->getNorth(),
127
                $bounds->getEast()
128
            );
129
        }
130
131 31
        if (null !== $components = $query->getData('components')) {
132 3
            $serializedComponents = is_string($components) ? $components : $this->serializeComponents($components);
133 3
            $url .= sprintf('&components=%s', urlencode($serializedComponents));
134
        }
135
136 31
        return $this->fetchUrl($url, $query->getLocale(), $query->getLimit(), $query->getData('region', $this->region));
137
    }
138
139 11
    public function reverseQuery(ReverseQuery $query): Collection
140
    {
141 11
        $coordinate = $query->getCoordinates();
142 11
        $url = sprintf(self::REVERSE_ENDPOINT_URL_SSL, $coordinate->getLatitude(), $coordinate->getLongitude());
143
144 11
        if (null !== $locationType = $query->getData('location_type')) {
145
            $url .= '&location_type='.urlencode($locationType);
146
        }
147
148 11
        if (null !== $resultType = $query->getData('result_type')) {
149
            $url .= '&result_type='.urlencode($resultType);
150
        }
151
152 11
        return $this->fetchUrl($url, $query->getLocale(), $query->getLimit(), $query->getData('region', $this->region));
153
    }
154
155 20
    public function getName(): string
156
    {
157 20
        return 'google_maps';
158
    }
159
160
    /**
161
     * @param string $locale
162
     *
163
     * @return string query with extra params
164
     */
165 42
    private function buildQuery(string $url, string $locale = null, string $region = null): string
166
    {
167 42
        if (null === $this->apiKey && null === $this->clientId) {
168
            throw new InvalidCredentials('You must provide an API key. Keyless access was removed in June, 2016');
169
        }
170
171 42
        if (null !== $locale) {
172 5
            $url = sprintf('%s&language=%s', $url, $locale);
173
        }
174
175 42
        if (null !== $region) {
176 1
            $url = sprintf('%s&region=%s', $url, $region);
177
        }
178
179 42
        if (null !== $this->apiKey) {
180 37
            $url = sprintf('%s&key=%s', $url, $this->apiKey);
181
        }
182
183 42
        if (null !== $this->clientId) {
184 5
            $url = sprintf('%s&client=%s', $url, $this->clientId);
185
186 5
            if (null !== $this->channel) {
187 1
                $url = sprintf('%s&channel=%s', $url, $this->channel);
188
            }
189
190 5
            if (null !== $this->privateKey) {
191 4
                $url = $this->signQuery($url);
192
            }
193
        }
194
195 42
        return $url;
196
    }
197
198
    /**
199
     * @param string $locale
200
     * @param string $region
201
     *
202
     * @throws InvalidServerResponse
203
     * @throws InvalidCredentials
204
     */
205 42
    private function fetchUrl(string $url, string $locale = null, int $limit, string $region = null): AddressCollection
206
    {
207 42
        $url = $this->buildQuery($url, $locale, $region);
208 42
        $content = $this->getUrlContents($url);
209 26
        $json = $this->validateResponse($url, $content);
210
211
        // no result
212 21
        if (!isset($json->results) || !count($json->results) || 'OK' !== $json->status) {
213 2
            return new AddressCollection([]);
214
        }
215
216 19
        $results = [];
217 19
        foreach ($json->results as $result) {
218 19
            $builder = new AddressBuilder($this->getName());
219 19
            $this->parseCoordinates($builder, $result);
220
221
            // set official Google place id
222 19
            if (isset($result->place_id)) {
223 19
                $builder->setValue('id', $result->place_id);
224
            }
225
226
            // update address components
227 19
            foreach ($result->address_components as $component) {
228 19
                foreach ($component->types as $type) {
229 19
                    $this->updateAddressComponent($builder, $type, $component);
230
                }
231
            }
232
233
            /** @var GoogleAddress $address */
234 19
            $address = $builder->build(GoogleAddress::class);
235 19
            $address = $address->withId($builder->getValue('id'));
236 19
            if (isset($result->geometry->location_type)) {
237 19
                $address = $address->withLocationType($result->geometry->location_type);
238
            }
239 19
            if (isset($result->types)) {
240 19
                $address = $address->withResultType($result->types);
241
            }
242 19
            if (isset($result->formatted_address)) {
243 19
                $address = $address->withFormattedAddress($result->formatted_address);
244
            }
245
246 19
            $results[] = $address
247 19
                ->withStreetAddress($builder->getValue('street_address'))
248 19
                ->withIntersection($builder->getValue('intersection'))
249 19
                ->withPolitical($builder->getValue('political'))
250 19
                ->withColloquialArea($builder->getValue('colloquial_area'))
251 19
                ->withWard($builder->getValue('ward'))
252 19
                ->withNeighborhood($builder->getValue('neighborhood'))
253 19
                ->withPremise($builder->getValue('premise'))
254 19
                ->withSubpremise($builder->getValue('subpremise'))
255 19
                ->withNaturalFeature($builder->getValue('natural_feature'))
256 19
                ->withAirport($builder->getValue('airport'))
257 19
                ->withPark($builder->getValue('park'))
258 19
                ->withPointOfInterest($builder->getValue('point_of_interest'))
259 19
                ->withEstablishment($builder->getValue('establishment'))
260 19
                ->withSubLocalityLevels($builder->getValue('subLocalityLevel', []))
261 19
                ->withPostalCodeSuffix($builder->getValue('postal_code_suffix'))
262 19
                ->withPartialMatch($result->partial_match ?? false);
263
264 19
            if (count($results) >= $limit) {
265 4
                break;
266
            }
267
        }
268
269 19
        return new AddressCollection($results);
270
    }
271
272
    /**
273
     * Update current resultSet with given key/value.
274
     *
275
     * @param string $type   Component type
276
     * @param object $values The component values
277
     */
278 19
    private function updateAddressComponent(AddressBuilder $builder, string $type, $values)
279
    {
280
        switch ($type) {
281 19
            case 'postal_code':
282 16
                $builder->setPostalCode($values->long_name);
283
284 16
                break;
285
286 19
            case 'locality':
287 19
            case 'postal_town':
288 19
                $builder->setLocality($values->long_name);
289
290 19
                break;
291
292 19
            case 'administrative_area_level_1':
293 19
            case 'administrative_area_level_2':
294 19
            case 'administrative_area_level_3':
295 19
            case 'administrative_area_level_4':
296 19
            case 'administrative_area_level_5':
297 18
                $builder->addAdminLevel(intval(substr($type, -1)), $values->long_name, $values->short_name);
298
299 18
                break;
300
301 19
            case 'sublocality_level_1':
302 19
            case 'sublocality_level_2':
303 19
            case 'sublocality_level_3':
304 19
            case 'sublocality_level_4':
305 19
            case 'sublocality_level_5':
306 4
                $subLocalityLevel = $builder->getValue('subLocalityLevel', []);
307 4
                $subLocalityLevel[] = [
308 4
                    'level' => intval(substr($type, -1)),
309 4
                    'name' => $values->long_name,
310 4
                    'code' => $values->short_name,
311 4
                ];
312 4
                $builder->setValue('subLocalityLevel', $subLocalityLevel);
313
314 4
                break;
315
316 19
            case 'country':
317 19
                $builder->setCountry($values->long_name);
318 19
                $builder->setCountryCode($values->short_name);
319
320 19
                break;
321
322 19
            case 'street_number':
323 10
                $builder->setStreetNumber($values->long_name);
324
325 10
                break;
326
327 19
            case 'route':
328 12
                $builder->setStreetName($values->long_name);
329
330 12
                break;
331
332 19
            case 'sublocality':
333 4
                $builder->setSubLocality($values->long_name);
334
335 4
                break;
336
337 19
            case 'street_address':
338 19
            case 'intersection':
339 19
            case 'political':
340 12
            case 'colloquial_area':
341 11
            case 'ward':
342 11
            case 'neighborhood':
343 8
            case 'premise':
344 6
            case 'subpremise':
345 5
            case 'natural_feature':
346 5
            case 'airport':
347 5
            case 'park':
348 5
            case 'point_of_interest':
349 5
            case 'establishment':
350 3
            case 'postal_code_suffix':
351 19
                $builder->setValue($type, $values->long_name);
352
353 19
                break;
354
355
            default:
356
        }
357
    }
358
359
    /**
360
     * Sign a URL with a given crypto key
361
     * Note that this URL must be properly URL-encoded
362
     * src: http://gmaps-samples.googlecode.com/svn/trunk/urlsigning/UrlSigner.php-source.
363
     *
364
     * @param string $query Query to be signed
365
     *
366
     * @return string $query query with signature appended
367
     */
368 4
    private function signQuery(string $query): string
369
    {
370 4
        $url = parse_url($query);
371
372 4
        $urlPartToSign = $url['path'].'?'.$url['query'];
373
374
        // Decode the private key into its binary format
375 4
        $decodedKey = base64_decode(str_replace(['-', '_'], ['+', '/'], $this->privateKey));
376
377
        // Create a signature using the private key and the URL-encoded
378
        // string using HMAC SHA1. This signature will be binary.
379 4
        $signature = hash_hmac('sha1', $urlPartToSign, $decodedKey, true);
380
381 4
        $encodedSignature = str_replace(['+', '/'], ['-', '_'], base64_encode($signature));
382
383 4
        return sprintf('%s&signature=%s', $query, $encodedSignature);
384
    }
385
386
    /**
387
     * Serialize the component query parameter.
388
     */
389 2
    private function serializeComponents(array $components): string
390
    {
391 2
        return implode('|', array_map(function ($name, $value) {
392 2
            return sprintf('%s:%s', $name, $value);
393 2
        }, array_keys($components), $components));
394
    }
395
396
    /**
397
     * Decode the response content and validate it to make sure it does not have any errors.
398
     *
399
     * @param string $content
400
     *
401
     * @return \Stdclass result form json_decode()
402
     *
403
     * @throws InvalidCredentials
404
     * @throws InvalidServerResponse
405
     * @throws QuotaExceeded
406
     */
407 26
    private function validateResponse(string $url, $content)
408
    {
409
        // Throw exception if invalid clientID and/or privateKey used with GoogleMapsBusinessProvider
410 26
        if (false !== strpos($content, "Provided 'signature' is not valid for the provided client ID")) {
411 2
            throw new InvalidCredentials(sprintf('Invalid client ID / API Key %s', $url));
412
        }
413
414 24
        $json = json_decode($content);
415
416
        // API error
417 24
        if (!isset($json)) {
418
            throw InvalidServerResponse::create($url);
419
        }
420
421 24
        if ('REQUEST_DENIED' === $json->status && 'The provided API key is invalid.' === $json->error_message) {
422 2
            throw new InvalidCredentials(sprintf('API key is invalid %s', $url));
423
        }
424
425 22
        if ('REQUEST_DENIED' === $json->status) {
426
            throw new InvalidServerResponse(sprintf('API access denied. Request: %s - Message: %s', $url, $json->error_message));
427
        }
428
429
        // you are over your quota
430 22
        if ('OVER_QUERY_LIMIT' === $json->status) {
431 1
            throw new QuotaExceeded(sprintf('Daily quota exceeded %s', $url));
432
        }
433
434 21
        return $json;
435
    }
436
437
    /**
438
     * Parse coordinates and bounds.
439
     *
440
     * @param \Stdclass $result
441
     */
442 19
    private function parseCoordinates(AddressBuilder $builder, $result)
443
    {
444 19
        $coordinates = $result->geometry->location;
445 19
        $builder->setCoordinates($coordinates->lat, $coordinates->lng);
446
447 19
        if (isset($result->geometry->bounds)) {
448 9
            $builder->setBounds(
449 9
                $result->geometry->bounds->southwest->lat,
450 9
                $result->geometry->bounds->southwest->lng,
451 9
                $result->geometry->bounds->northeast->lat,
452 9
                $result->geometry->bounds->northeast->lng
453 9
            );
454 14
        } elseif (isset($result->geometry->viewport)) {
455 14
            $builder->setBounds(
456 14
                $result->geometry->viewport->southwest->lat,
457 14
                $result->geometry->viewport->southwest->lng,
458 14
                $result->geometry->viewport->northeast->lat,
459 14
                $result->geometry->viewport->northeast->lng
460 14
            );
461
        } elseif ('ROOFTOP' === $result->geometry->location_type) {
462
            // Fake bounds
463
            $builder->setBounds(
464
                $coordinates->lat,
465
                $coordinates->lng,
466
                $coordinates->lat,
467
                $coordinates->lng
468
            );
469
        }
470
    }
471
}
472