Completed
Push — master ( eb8c69...eb1738 )
by Tobias
02:41
created

GoogleMaps::geocodeQuery()   A

Complexity

Conditions 5
Paths 7

Size

Total Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 6.6

Importance

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