Completed
Push — master ( a21885...2cca59 )
by Tobias
02:50
created

GoogleMaps::serializeComponents()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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