*/ final class GoogleMaps extends AbstractHttpProvider implements Provider { /** * @var string */ const GEOCODE_ENDPOINT_URL_SSL = 'https://maps.googleapis.com/maps/api/geocode/json?address=%s'; /** * @var string */ const REVERSE_ENDPOINT_URL_SSL = 'https://maps.googleapis.com/maps/api/geocode/json?latlng=%F,%F'; /** * @var string|null */ private $region; /** * @var string|null */ private $apiKey; /** * @var string|null */ private $clientId; /** * @var string|null */ private $privateKey; /** * @var string|null */ private $channel; /** * Google Maps for Business * https://developers.google.com/maps/documentation/business/ * Maps for Business is no longer accepting new signups. * * @param HttpClient $client An HTTP adapter * @param string $clientId Your Client ID * @param string $privateKey Your Private Key (optional) * @param string $region Region biasing (optional) * @param string $apiKey Google Geocoding API key (optional) * @param string $channel Google Channel parameter (optional) * * @return GoogleMaps */ public static function business( HttpClient $client, string $clientId, string $privateKey = null, string $region = null, string $apiKey = null, string $channel = null ) { $provider = new self($client, $region, $apiKey); $provider->clientId = $clientId; $provider->privateKey = $privateKey; $provider->channel = $channel; return $provider; } /** * @param HttpClient $client An HTTP adapter * @param string $region Region biasing (optional) * @param string $apiKey Google Geocoding API key (optional) */ public function __construct(HttpClient $client, string $region = null, string $apiKey = null) { parent::__construct($client); $this->region = $region; $this->apiKey = $apiKey; } public function geocodeQuery(GeocodeQuery $query): Collection { // Google API returns invalid data if IP address given // This API doesn't handle IPs if (filter_var($query->getText(), FILTER_VALIDATE_IP)) { throw new UnsupportedOperation('The GoogleMaps provider does not support IP addresses, only street addresses.'); } $url = sprintf(self::GEOCODE_ENDPOINT_URL_SSL, rawurlencode($query->getText())); if (null !== $bounds = $query->getBounds()) { $url .= sprintf( '&bounds=%s,%s|%s,%s', $bounds->getSouth(), $bounds->getWest(), $bounds->getNorth(), $bounds->getEast() ); } if (null !== $components = $query->getData('components')) { $serializedComponents = is_string($components) ? $components : $this->serializeComponents($components); $url .= sprintf('&components=%s', urlencode($serializedComponents)); } return $this->fetchUrl($url, $query->getLocale(), $query->getLimit(), $query->getData('region', $this->region)); } public function reverseQuery(ReverseQuery $query): Collection { $coordinate = $query->getCoordinates(); $url = sprintf(self::REVERSE_ENDPOINT_URL_SSL, $coordinate->getLatitude(), $coordinate->getLongitude()); if (null !== $locationType = $query->getData('location_type')) { $url .= '&location_type='.urlencode($locationType); } if (null !== $resultType = $query->getData('result_type')) { $url .= '&result_type='.urlencode($resultType); } return $this->fetchUrl($url, $query->getLocale(), $query->getLimit(), $query->getData('region', $this->region)); } /** * {@inheritdoc} */ public function getName(): string { return 'google_maps'; } /** * @param string $url * @param string $locale * * @return string query with extra params */ private function buildQuery(string $url, string $locale = null, string $region = null): string { if (null === $this->apiKey && null === $this->clientId) { throw new InvalidCredentials('You must provide an API key. Keyless access was removed in June, 2016'); } if (null !== $locale) { $url = sprintf('%s&language=%s', $url, $locale); } if (null !== $region) { $url = sprintf('%s®ion=%s', $url, $region); } if (null !== $this->apiKey) { $url = sprintf('%s&key=%s', $url, $this->apiKey); } if (null !== $this->clientId) { $url = sprintf('%s&client=%s', $url, $this->clientId); if (null !== $this->channel) { $url = sprintf('%s&channel=%s', $url, $this->channel); } if (null !== $this->privateKey) { $url = $this->signQuery($url); } } return $url; } /** * @param string $url * @param string $locale * @param int $limit * @param string $region * * @return AddressCollection * * @throws InvalidServerResponse * @throws InvalidCredentials */ private function fetchUrl(string $url, string $locale = null, int $limit, string $region = null): AddressCollection { $url = $this->buildQuery($url, $locale, $region); $content = $this->getUrlContents($url); $json = $this->validateResponse($url, $content); // no result if (!isset($json->results) || !count($json->results) || 'OK' !== $json->status) { return new AddressCollection([]); } $results = []; foreach ($json->results as $result) { $builder = new AddressBuilder($this->getName()); $this->parseCoordinates($builder, $result); // set official Google place id if (isset($result->place_id)) { $builder->setValue('id', $result->place_id); } // update address components foreach ($result->address_components as $component) { foreach ($component->types as $type) { $this->updateAddressComponent($builder, $type, $component); } } /** @var GoogleAddress $address */ $address = $builder->build(GoogleAddress::class); $address = $address->withId($builder->getValue('id')); if (isset($result->geometry->location_type)) { $address = $address->withLocationType($result->geometry->location_type); } if (isset($result->types)) { $address = $address->withResultType($result->types); } if (isset($result->formatted_address)) { $address = $address->withFormattedAddress($result->formatted_address); } $results[] = $address ->withStreetAddress($builder->getValue('street_address')) ->withIntersection($builder->getValue('intersection')) ->withPolitical($builder->getValue('political')) ->withColloquialArea($builder->getValue('colloquial_area')) ->withWard($builder->getValue('ward')) ->withNeighborhood($builder->getValue('neighborhood')) ->withPremise($builder->getValue('premise')) ->withSubpremise($builder->getValue('subpremise')) ->withNaturalFeature($builder->getValue('natural_feature')) ->withAirport($builder->getValue('airport')) ->withPark($builder->getValue('park')) ->withPointOfInterest($builder->getValue('point_of_interest')) ->withEstablishment($builder->getValue('establishment')) ->withSubLocalityLevels($builder->getValue('subLocalityLevel', [])) ->withPostalCodeSuffix($builder->getValue('postal_code_suffix')) ->withPartialMatch($result->partial_match ?? false); if (count($results) >= $limit) { break; } } return new AddressCollection($results); } /** * Update current resultSet with given key/value. * * @param AddressBuilder $builder * @param string $type Component type * @param object $values The component values */ private function updateAddressComponent(AddressBuilder $builder, string $type, $values) { switch ($type) { case 'postal_code': $builder->setPostalCode($values->long_name); break; case 'locality': case 'postal_town': $builder->setLocality($values->long_name); break; case 'administrative_area_level_1': case 'administrative_area_level_2': case 'administrative_area_level_3': case 'administrative_area_level_4': case 'administrative_area_level_5': $builder->addAdminLevel(intval(substr($type, -1)), $values->long_name, $values->short_name); break; case 'sublocality_level_1': case 'sublocality_level_2': case 'sublocality_level_3': case 'sublocality_level_4': case 'sublocality_level_5': $subLocalityLevel = $builder->getValue('subLocalityLevel', []); $subLocalityLevel[] = [ 'level' => intval(substr($type, -1)), 'name' => $values->long_name, 'code' => $values->short_name, ]; $builder->setValue('subLocalityLevel', $subLocalityLevel); break; case 'country': $builder->setCountry($values->long_name); $builder->setCountryCode($values->short_name); break; case 'street_number': $builder->setStreetNumber($values->long_name); break; case 'route': $builder->setStreetName($values->long_name); break; case 'sublocality': $builder->setSubLocality($values->long_name); break; case 'street_address': case 'intersection': case 'political': case 'colloquial_area': case 'ward': case 'neighborhood': case 'premise': case 'subpremise': case 'natural_feature': case 'airport': case 'park': case 'point_of_interest': case 'establishment': case 'postal_code_suffix': $builder->setValue($type, $values->long_name); break; default: } } /** * Sign a URL with a given crypto key * Note that this URL must be properly URL-encoded * src: http://gmaps-samples.googlecode.com/svn/trunk/urlsigning/UrlSigner.php-source. * * @param string $query Query to be signed * * @return string $query query with signature appended */ private function signQuery(string $query): string { $url = parse_url($query); $urlPartToSign = $url['path'].'?'.$url['query']; // Decode the private key into its binary format $decodedKey = base64_decode(str_replace(['-', '_'], ['+', '/'], $this->privateKey)); // Create a signature using the private key and the URL-encoded // string using HMAC SHA1. This signature will be binary. $signature = hash_hmac('sha1', $urlPartToSign, $decodedKey, true); $encodedSignature = str_replace(['+', '/'], ['-', '_'], base64_encode($signature)); return sprintf('%s&signature=%s', $query, $encodedSignature); } /** * Serialize the component query parameter. * * @param array $components * * @return string */ private function serializeComponents(array $components): string { return implode('|', array_map(function ($name, $value) { return sprintf('%s:%s', $name, $value); }, array_keys($components), $components)); } /** * Decode the response content and validate it to make sure it does not have any errors. * * @param string $url * @param string $content * * @return \Stdclass result form json_decode() * * @throws InvalidCredentials * @throws InvalidServerResponse * @throws QuotaExceeded */ private function validateResponse(string $url, $content) { // Throw exception if invalid clientID and/or privateKey used with GoogleMapsBusinessProvider if (false !== strpos($content, "Provided 'signature' is not valid for the provided client ID")) { throw new InvalidCredentials(sprintf('Invalid client ID / API Key %s', $url)); } $json = json_decode($content); // API error if (!isset($json)) { throw InvalidServerResponse::create($url); } if ('REQUEST_DENIED' === $json->status && 'The provided API key is invalid.' === $json->error_message) { throw new InvalidCredentials(sprintf('API key is invalid %s', $url)); } if ('REQUEST_DENIED' === $json->status) { throw new InvalidServerResponse(sprintf('API access denied. Request: %s - Message: %s', $url, $json->error_message)); } // you are over your quota if ('OVER_QUERY_LIMIT' === $json->status) { throw new QuotaExceeded(sprintf('Daily quota exceeded %s', $url)); } return $json; } /** * Parse coordinates and bounds. * * @param AddressBuilder $builder * @param \Stdclass $result */ private function parseCoordinates(AddressBuilder $builder, $result) { $coordinates = $result->geometry->location; $builder->setCoordinates($coordinates->lat, $coordinates->lng); if (isset($result->geometry->bounds)) { $builder->setBounds( $result->geometry->bounds->southwest->lat, $result->geometry->bounds->southwest->lng, $result->geometry->bounds->northeast->lat, $result->geometry->bounds->northeast->lng ); } elseif (isset($result->geometry->viewport)) { $builder->setBounds( $result->geometry->viewport->southwest->lat, $result->geometry->viewport->southwest->lng, $result->geometry->viewport->northeast->lat, $result->geometry->viewport->northeast->lng ); } elseif ('ROOFTOP' === $result->geometry->location_type) { // Fake bounds $builder->setBounds( $coordinates->lat, $coordinates->lng, $coordinates->lat, $coordinates->lng ); } } }