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®ion=%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 |