Completed
Push — master ( 1e8813...9c7303 )
by Tobias
01:15
created

GoogleMaps::buildQuery()   B

Complexity

Conditions 9
Paths 41

Size

Total Lines 32

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 9.0197

Importance

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