Completed
Push — master ( 0d6d33...5d031b )
by Tobias
04:08
created

GoogleMaps::parseCoordinates()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 29
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 4.3244

Importance

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