diff --git a/Here.php b/Here.php index 340fc51..b299641 100644 --- a/Here.php +++ b/Here.php @@ -32,6 +32,16 @@ */ final class Here extends AbstractHttpProvider implements Provider { + /** + * @var string + */ + public const GS7_GEOCODE_ENDPOINT_URL = 'https://geocode.search.hereapi.com/v1/geocode'; + + /** + * @var string + */ + public const GS7_REVERSE_ENDPOINT_URL = 'https://revgeocode.search.hereapi.com/v1/revgeocode'; + /** * @var string */ @@ -96,43 +106,50 @@ final class Here extends AbstractHttpProvider implements Provider ]; /** - * @var string + * @var string|null */ - private $appId; + private ?string $appId; /** - * @var string + * @var string|null */ - private $appCode; + private ?string $appCode; /** * @var bool */ - private $useCIT; + private bool $useCIT; + + /** + * @var string|null + */ + private ?string $apiKey = null; /** * @var string */ - private $apiKey; + private string $version; /** * @param ClientInterface $client an HTTP adapter - * @param string $appId an App ID - * @param string $appCode an App code + * @param string|null $appId an App ID + * @param string|null $appCode an App code * @param bool $useCIT use Customer Integration Testing environment (CIT) instead of production + * @param string $version version of the API to use ('6.2' or '7') */ - public function __construct(ClientInterface $client, ?string $appId = null, ?string $appCode = null, bool $useCIT = false) + public function __construct(ClientInterface $client, ?string $appId = null, ?string $appCode = null, bool $useCIT = false, string $version = '6.2') { $this->appId = $appId; $this->appCode = $appCode; $this->useCIT = $useCIT; + $this->version = $version; parent::__construct($client); } - public static function createUsingApiKey(ClientInterface $client, string $apiKey, bool $useCIT = false): self + public static function createUsingApiKey(ClientInterface $client, string $apiKey, bool $useCIT = false, string $version = '7'): self { - $client = new self($client, null, null, $useCIT); + $client = new self($client, null, null, $useCIT, $version); $client->apiKey = $apiKey; return $client; @@ -140,11 +157,16 @@ public static function createUsingApiKey(ClientInterface $client, string $apiKey public function geocodeQuery(GeocodeQuery $query): Collection { + // This API doesn't handle IPs if (filter_var($query->getText(), FILTER_VALIDATE_IP)) { throw new UnsupportedOperation('The Here provider does not support IP addresses, only street addresses.'); } + if ('7' === $this->version && null !== $this->apiKey) { + return $this->geocodeQueryGS7($query); + } + $queryParams = $this->withApiCredentials([ 'searchtext' => $query->getText(), 'gen' => 9, @@ -174,8 +196,56 @@ public function geocodeQuery(GeocodeQuery $query): Collection return $this->executeQuery(sprintf('%s?%s', $this->getBaseUrl($query), http_build_query($queryParams)), $query->getLimit()); } + private function geocodeQueryGS7(GeocodeQuery $query): Collection + { + $queryParams = $this->withApiCredentials([ + 'q' => $query->getText(), + 'limit' => $query->getLimit(), + ]); + + // Pass-through for GS7 geo filters / sorting reference point. + // See https://www.here.com/docs/bundle/geocoding-and-search-api-v7-api-reference/page/index.html#/paths/~1geocode/get + if (null !== $at = $query->getData('at')) { + $queryParams['at'] = $at; + } + if (null !== $in = $query->getData('in')) { + $queryParams['in'] = $in; + } + if (null !== $types = $query->getData('types')) { + $queryParams['types'] = $types; + } + + $qq = []; + if (null !== $country = $query->getData('country')) { + $qq[] = 'country=' . $country; + } + if (null !== $state = $query->getData('state')) { + $qq[] = 'state=' . $state; + } + if (null !== $county = $query->getData('county')) { + $qq[] = 'county=' . $county; + } + if (null !== $city = $query->getData('city')) { + $qq[] = 'city=' . $city; + } + + if (!empty($qq)) { + $queryParams['qq'] = implode(';', $qq); + } + + if (null !== $query->getLocale()) { + $queryParams['lang'] = $query->getLocale(); + } + + return $this->executeQuery(sprintf('%s?%s', $this->getBaseUrl($query), http_build_query($queryParams)), $query->getLimit()); + } + public function reverseQuery(ReverseQuery $query): Collection { + if ('7' === $this->version && null !== $this->apiKey) { + return $this->reverseQueryGS7($query); + } + $coordinates = $query->getCoordinates(); $queryParams = $this->withApiCredentials([ @@ -188,12 +258,34 @@ public function reverseQuery(ReverseQuery $query): Collection return $this->executeQuery(sprintf('%s?%s', $this->getBaseUrl($query), http_build_query($queryParams)), $query->getLimit()); } + private function reverseQueryGS7(ReverseQuery $query): Collection + { + $coordinates = $query->getCoordinates(); + + $queryParams = $this->withApiCredentials([ + 'at' => sprintf('%s,%s', $coordinates->getLatitude(), $coordinates->getLongitude()), + 'limit' => $query->getLimit(), + ]); + + if (null !== $query->getLocale()) { + $queryParams['lang'] = $query->getLocale(); + } + + return $this->executeQuery(sprintf('%s?%s', $this->getBaseUrl($query), http_build_query($queryParams)), $query->getLimit()); + } + private function executeQuery(string $url, int $limit): Collection { $content = $this->getUrlContents($url); $json = json_decode($content, true); + if (isset($json['error'])) { + if ('Unauthorized' === $json['error']) { + throw new InvalidCredentials('Invalid or missing api key.'); + } + } + if (isset($json['type'])) { switch ($json['type']['subtype']) { case 'InvalidInputData': @@ -205,6 +297,10 @@ private function executeQuery(string $url, int $limit): Collection } } + if (isset($json['items'])) { + return $this->parseGS7Response($json['items'], $limit); + } + if (!isset($json['Response']) || empty($json['Response'])) { return new AddressCollection([]); } @@ -215,6 +311,112 @@ private function executeQuery(string $url, int $limit): Collection $locations = $json['Response']['View'][0]['Result']; + return $this->parseV6Response($locations, $limit); + } + + private function parseGS7Response(array $items, int $limit): Collection + { + + $results = []; + + foreach ($items as $item) { + $builder = new AddressBuilder($this->getName()); + + $position = $item['position']; + $builder->setCoordinates($position['lat'], $position['lng']); + + if (isset($item['mapView'])) { + $mapView = $item['mapView']; + $builder->setBounds($mapView['south'], $mapView['west'], $mapView['north'], $mapView['east']); + } + + $address = $item['address']; + $builder->setStreetNumber($address['houseNumber'] ?? null); + $builder->setStreetName($address['street'] ?? null); + $builder->setPostalCode($address['postalCode'] ?? null); + $builder->setLocality($address['city'] ?? null); + // GS7 may provide both `district` and `subdistrict`. Prefer `district` for backward compatibility, + // but fall back to `subdistrict` when `district` is missing. + $builder->setSubLocality($address['district'] ?? ($address['subdistrict'] ?? null)); + $builder->setCountryCode($address['countryCode'] ?? null); + $builder->setCountry($address['countryName'] ?? null); + + /** @var HereAddress $hereAddress */ + $hereAddress = $builder->build(HereAddress::class); + $hereAddress = $hereAddress->withLocationId($item['id'] ?? null); + $hereAddress = $hereAddress->withLocationType($item['resultType'] ?? null); + $hereAddress = $hereAddress->withLocationName($item['title'] ?? null); + + $additionalData = []; + if (isset($address['label'])) { + $additionalData[] = ['key' => 'Label', 'value' => $address['label']]; + } + if (isset($address['countryName'])) { + $additionalData[] = ['key' => 'CountryName', 'value' => $address['countryName']]; + } + if (isset($address['state'])) { + $additionalData[] = ['key' => 'StateName', 'value' => $address['state']]; + } + if (isset($address['stateCode'])) { + $additionalData[] = ['key' => 'StateCode', 'value' => $address['stateCode']]; + } + if (isset($address['county'])) { + $additionalData[] = ['key' => 'CountyName', 'value' => $address['county']]; + } + if (isset($address['countyCode'])) { + $additionalData[] = ['key' => 'CountyCode', 'value' => $address['countyCode']]; + } + if (isset($address['district'])) { + $additionalData[] = ['key' => 'District', 'value' => $address['district']]; + } + if (isset($address['subdistrict'])) { + $additionalData[] = ['key' => 'Subdistrict', 'value' => $address['subdistrict']]; + } + if (isset($address['streets'])) { + $additionalData[] = ['key' => 'Streets', 'value' => $address['streets']]; + } + if (isset($address['block'])) { + $additionalData[] = ['key' => 'Block', 'value' => $address['block']]; + } + if (isset($address['subblock'])) { + $additionalData[] = ['key' => 'Subblock', 'value' => $address['subblock']]; + } + if (isset($address['building'])) { + $additionalData[] = ['key' => 'Building', 'value' => $address['building']]; + } + if (isset($address['unit'])) { + $additionalData[] = ['key' => 'Unit', 'value' => $address['unit']]; + } + + // Item metadata + foreach ( + [ + 'politicalView' => 'PoliticalView', + 'houseNumberType' => 'HouseNumberType', + 'addressBlockType' => 'AddressBlockType', + 'localityType' => 'LocalityType', + 'administrativeAreaType' => 'AdministrativeAreaType', + 'distance' => 'Distance', + ] as $sourceKey => $targetKey + ) { + if (isset($item[$sourceKey])) { + $additionalData[] = ['key' => $targetKey, 'value' => $item[$sourceKey]]; + } + } + + $hereAddress = $hereAddress->withAdditionalData($additionalData); + $results[] = $hereAddress; + + if (count($results) >= $limit) { + break; + } + } + + return new AddressCollection($results); + } + + private function parseV6Response(array $locations, int $limit): Collection + { $results = []; foreach ($locations as $loc) { @@ -245,7 +447,7 @@ private function executeQuery(string $url, int $limit): Collection $address = $builder->build(HereAddress::class); $address = $address->withLocationId($location['LocationId'] ?? null); $address = $address->withLocationType($location['LocationType']); - $address = $address->withAdditionalData(array_merge($additionalData, $extraAdditionalData)); + $address = $address->withAdditionalData(array_merge($additionalData ?? [], $extraAdditionalData)); $address = $address->withShape($location['Shape'] ?? null); $results[] = $address; @@ -289,18 +491,13 @@ private function getAdditionalDataParam(GeocodeQuery $query): string */ private function withApiCredentials(array $queryParams): array { - if ( - empty($this->apiKey) - && (empty($this->appId) || empty($this->appCode)) - ) { - throw new InvalidCredentials('Invalid or missing api key.'); - } - if (null !== $this->apiKey) { $queryParams['apiKey'] = $this->apiKey; - } else { + } elseif (!empty($this->appId) && !empty($this->appCode)) { $queryParams['app_id'] = $this->appId; $queryParams['app_code'] = $this->appCode; + } else { + throw new InvalidCredentials('Invalid or missing api key.'); } return $queryParams; @@ -310,6 +507,10 @@ public function getBaseUrl(Query $query): string { $usingApiKey = null !== $this->apiKey; + if ('7' === $this->version && $usingApiKey) { + return ($query instanceof ReverseQuery) ? self::GS7_REVERSE_ENDPOINT_URL : self::GS7_GEOCODE_ENDPOINT_URL; + } + if ($query instanceof ReverseQuery) { if ($this->useCIT) { return $usingApiKey ? self::REVERSE_CIT_ENDPOINT_URL_API_KEY : self::REVERSE_CIT_ENDPOINT_URL_APP_CODE; diff --git a/Model/HereAddress.php b/Model/HereAddress.php index d912472..92ee89d 100644 --- a/Model/HereAddress.php +++ b/Model/HereAddress.php @@ -22,27 +22,27 @@ final class HereAddress extends Address /** * @var string|null */ - private $locationId; + private ?string $locationId = null; /** * @var string|null */ - private $locationType; + private ?string $locationType = null; /** * @var string|null */ - private $locationName; + private ?string $locationName = null; /** * @var array|null */ - private $additionalData; + private ?array $additionalData = []; /** * @var array|null */ - private $shape; + private ?array $shape = []; /** * @return string|null @@ -107,8 +107,10 @@ public function withAdditionalData(?array $additionalData = null): self { $new = clone $this; - foreach ($additionalData as $data) { - $new = $new->addAdditionalData($data['key'], $data['value']); + if (null !== $additionalData) { + foreach ($additionalData as $data) { + $new = $new->addAdditionalData($data['key'], $data['value']); + } } return $new; @@ -136,7 +138,7 @@ public function getAdditionalDataValue(string $name, mixed $default = null): mix public function hasAdditionalDataValue(string $name): bool { - return array_key_exists($name, $this->additionalData); + return null !== $this->additionalData && array_key_exists($name, $this->additionalData); } /** @@ -174,6 +176,6 @@ public function getShapeValue(string $name, mixed $default = null): mixed public function hasShapeValue(string $name): bool { - return array_key_exists($name, $this->shape); + return null !== $this->shape && array_key_exists($name, $this->shape); } } diff --git a/Readme.md b/Readme.md index 808731b..681935e 100644 --- a/Readme.md +++ b/Readme.md @@ -1,5 +1,5 @@ # Here Geocoder provider -[![Build Status](https://travis-ci.org/geocoder-php/here-provider.svg?branch=master)](http://travis-ci.org/geocoder-php/here-provider) +[![Build Status](https://github.com/geocoder-php/here-provider/actions/workflows/provider.yml/badge.svg)](https://github.com/geocoder-php/here-provider/actions) [![Latest Stable Version](https://poser.pugx.org/geocoder-php/here-provider/v/stable)](https://packagist.org/packages/geocoder-php/here-provider) [![Total Downloads](https://poser.pugx.org/geocoder-php/here-provider/downloads)](https://packagist.org/packages/geocoder-php/here-provider) [![Monthly Downloads](https://poser.pugx.org/geocoder-php/here-provider/d/monthly.png)](https://packagist.org/packages/geocoder-php/here-provider) @@ -10,6 +10,16 @@ This is the Here provider from the PHP Geocoder. This is a **READ ONLY** repository. See the [main repo](https://github.com/geocoder-php/Geocoder) for information and documentation. +### Features + +- Support for **HERE Geocoding & Search API v7 (GS7)** via API Key. +- Backward compatibility for **Geocoder API v6.2** (via API Key or App ID/Code). +- Geocoding (Address to Coordinates). +- Reverse Geocoding (Coordinates to Address). +- Support for Structured Geocoding Queries. +- Support for CIT (Customer Integration Testing) environment. +- Normalized response mapping to `HereAddress` model. + You can find the [documentation for the provider here](https://developer.here.com/documentation/geocoder/dev_guide/topics/resources.html). @@ -21,28 +31,51 @@ composer require geocoder-php/here-provider ## Using -New applications on the Here platform use the `api_key` authentication method. +New applications on the Here platform use the `api_key` authentication method. This provider uses the **HERE Geocoding & Search API v7 (GS7)** by default when an API Key is provided via `createUsingApiKey`. ```php $httpClient = new \Http\Discovery\Psr18Client(); -// You must provide an API key +// By default, this uses GS7 v1 endpoints $provider = \Geocoder\Provider\Here\Here::createUsingApiKey($httpClient, 'your-api-key'); $result = $geocoder->geocodeQuery(GeocodeQuery::create('Buckingham Palace, London')); ``` -If you're using the legacy `app_code` authentication method, use the constructor on the provider like so: +### Using Legacy Geocoder API v6.2 with API Key + +If you need to continue using the legacy v6.2 API with an API Key (e.g., for specific parameters or response shapes), you can specify the version: + +```php +$httpClient = new \Http\Discovery\Psr18Client(); + +// Force use of Geocoder API v6.2 with an API Key +$provider = \Geocoder\Provider\Here\Here::createUsingApiKey($httpClient, 'your-api-key', false, '6.2'); + +$result = $geocoder->geocodeQuery(GeocodeQuery::create('Buckingham Palace, London')); +``` + +If you're using the legacy `app_code` authentication method, use the constructor on the provider like so. This will continue to use the **Geocoder API v6.2** endpoints. ```php $httpClient = new \Http\Discovery\Psr18Client(); -// You must provide both the app_id and app_code +// You must provide both the app_id and app_code - This will use v6.2 endpoints $provider = new \Geocoder\Provider\Here\Here($httpClient, 'app-id', 'app-code'); $result = $geocoder->geocodeQuery(GeocodeQuery::create('Buckingham Palace, London')); ``` +### Migrating to GS7 (v7) + +The transition to GS7 is automatic when using `createUsingApiKey`. Note that some response fields and parameters may differ slightly from v6.2, but the provider maps them to the same `HereAddress` model for backward compatibility. + +Key changes in GS7: +- New base URLs: `*.search.hereapi.com/v1/*` +- Authentication via `apiKey` query parameter. +- Free-form queries use the `q` parameter instead of `searchtext`. +- Reverse geocoding uses the `at` parameter instead of `prox`. + ### Language parameter Define the preferred language of address elements in the result. Without a preferred language, the Here geocoder will return results in an official country language or in a regional primary language so that local people will understand. Language code must be provided according to RFC 4647 standard. diff --git a/Tests/.cached_responses/geocode.search.hereapi.com_1dd988ef910c76b8e01d38d6a97fcc96a8b6b2ed b/Tests/.cached_responses/geocode.search.hereapi.com_1dd988ef910c76b8e01d38d6a97fcc96a8b6b2ed new file mode 100644 index 0000000..247a767 --- /dev/null +++ b/Tests/.cached_responses/geocode.search.hereapi.com_1dd988ef910c76b8e01d38d6a97fcc96a8b6b2ed @@ -0,0 +1,2 @@ +s:81:"{"error":"Unauthorized","error_description":"apiKey invalid. apiKey not found."} +"; \ No newline at end of file diff --git a/Tests/.cached_responses/geocode.search.hereapi.com_315fbb4df6cc10e7aabeacb8b0a8f2577149ab78 b/Tests/.cached_responses/geocode.search.hereapi.com_315fbb4df6cc10e7aabeacb8b0a8f2577149ab78 new file mode 100644 index 0000000..247a767 --- /dev/null +++ b/Tests/.cached_responses/geocode.search.hereapi.com_315fbb4df6cc10e7aabeacb8b0a8f2577149ab78 @@ -0,0 +1,2 @@ +s:81:"{"error":"Unauthorized","error_description":"apiKey invalid. apiKey not found."} +"; \ No newline at end of file diff --git a/Tests/.cached_responses/geocode.search.hereapi.com_552dd6dcb67e685221eeb30edb742f5fe309a1ec b/Tests/.cached_responses/geocode.search.hereapi.com_552dd6dcb67e685221eeb30edb742f5fe309a1ec new file mode 100644 index 0000000..247a767 --- /dev/null +++ b/Tests/.cached_responses/geocode.search.hereapi.com_552dd6dcb67e685221eeb30edb742f5fe309a1ec @@ -0,0 +1,2 @@ +s:81:"{"error":"Unauthorized","error_description":"apiKey invalid. apiKey not found."} +"; \ No newline at end of file diff --git a/Tests/.cached_responses/geocode.search.hereapi.com_5b35384a9065498c0246c4788d1701b5d5162f87 b/Tests/.cached_responses/geocode.search.hereapi.com_5b35384a9065498c0246c4788d1701b5d5162f87 new file mode 100644 index 0000000..247a767 --- /dev/null +++ b/Tests/.cached_responses/geocode.search.hereapi.com_5b35384a9065498c0246c4788d1701b5d5162f87 @@ -0,0 +1,2 @@ +s:81:"{"error":"Unauthorized","error_description":"apiKey invalid. apiKey not found."} +"; \ No newline at end of file diff --git a/Tests/.cached_responses/geocode.search.hereapi.com_74e648fc2acb458a533e134de4d3f35df478ecc4 b/Tests/.cached_responses/geocode.search.hereapi.com_74e648fc2acb458a533e134de4d3f35df478ecc4 new file mode 100644 index 0000000..247a767 --- /dev/null +++ b/Tests/.cached_responses/geocode.search.hereapi.com_74e648fc2acb458a533e134de4d3f35df478ecc4 @@ -0,0 +1,2 @@ +s:81:"{"error":"Unauthorized","error_description":"apiKey invalid. apiKey not found."} +"; \ No newline at end of file diff --git a/Tests/.cached_responses/geocode.search.hereapi.com_9b5966243a757fb96741bfca9a0024d69423d623 b/Tests/.cached_responses/geocode.search.hereapi.com_9b5966243a757fb96741bfca9a0024d69423d623 new file mode 100644 index 0000000..247a767 --- /dev/null +++ b/Tests/.cached_responses/geocode.search.hereapi.com_9b5966243a757fb96741bfca9a0024d69423d623 @@ -0,0 +1,2 @@ +s:81:"{"error":"Unauthorized","error_description":"apiKey invalid. apiKey not found."} +"; \ No newline at end of file diff --git a/Tests/.cached_responses/geocode.search.hereapi.com_c75367c7143854ea21a9920647c40a1b11f20fa9 b/Tests/.cached_responses/geocode.search.hereapi.com_c75367c7143854ea21a9920647c40a1b11f20fa9 new file mode 100644 index 0000000..247a767 --- /dev/null +++ b/Tests/.cached_responses/geocode.search.hereapi.com_c75367c7143854ea21a9920647c40a1b11f20fa9 @@ -0,0 +1,2 @@ +s:81:"{"error":"Unauthorized","error_description":"apiKey invalid. apiKey not found."} +"; \ No newline at end of file diff --git a/Tests/.cached_responses/revgeocode.search.hereapi.com_23013595359639287804846d43a4b304e45eaf9e b/Tests/.cached_responses/revgeocode.search.hereapi.com_23013595359639287804846d43a4b304e45eaf9e new file mode 100644 index 0000000..247a767 --- /dev/null +++ b/Tests/.cached_responses/revgeocode.search.hereapi.com_23013595359639287804846d43a4b304e45eaf9e @@ -0,0 +1,2 @@ +s:81:"{"error":"Unauthorized","error_description":"apiKey invalid. apiKey not found."} +"; \ No newline at end of file diff --git a/Tests/.cached_responses/revgeocode.search.hereapi.com_6c2c10057f25b1cf6b1c98fcb1ead71d1e42932c b/Tests/.cached_responses/revgeocode.search.hereapi.com_6c2c10057f25b1cf6b1c98fcb1ead71d1e42932c new file mode 100644 index 0000000..247a767 --- /dev/null +++ b/Tests/.cached_responses/revgeocode.search.hereapi.com_6c2c10057f25b1cf6b1c98fcb1ead71d1e42932c @@ -0,0 +1,2 @@ +s:81:"{"error":"Unauthorized","error_description":"apiKey invalid. apiKey not found."} +"; \ No newline at end of file diff --git a/Tests/.cached_responses/revgeocode.search.hereapi.com_93cd97b93a39a1a1a61e446f313c91ad6a6a1d22 b/Tests/.cached_responses/revgeocode.search.hereapi.com_93cd97b93a39a1a1a61e446f313c91ad6a6a1d22 new file mode 100644 index 0000000..247a767 --- /dev/null +++ b/Tests/.cached_responses/revgeocode.search.hereapi.com_93cd97b93a39a1a1a61e446f313c91ad6a6a1d22 @@ -0,0 +1,2 @@ +s:81:"{"error":"Unauthorized","error_description":"apiKey invalid. apiKey not found."} +"; \ No newline at end of file diff --git a/Tests/HereTest.php b/Tests/HereTest.php index 3fc9412..8f35d3f 100644 --- a/Tests/HereTest.php +++ b/Tests/HereTest.php @@ -40,7 +40,7 @@ public function testGeocodeWithRealAddress(): void $this->markTestSkipped('You need to configure the HERE_API_KEY value in phpunit.xml'); } - $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY']); + $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY'], false, '6.2'); $results = $provider->geocodeQuery(GeocodeQuery::create('10 avenue Gambetta, Paris, France')->withLocale('fr-FR')); @@ -75,7 +75,7 @@ public function testGeocodeWithDefaultAdditionalData(): void $this->markTestSkipped('You need to configure the HERE_API_KEY value in phpunit.xml'); } - $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY']); + $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY'], false, '6.2'); $results = $provider->geocodeQuery(GeocodeQuery::create('Sant Roc, Santa Coloma de Cervelló, Espanya')->withLocale('ca')); @@ -117,7 +117,7 @@ public function testGeocodeWithAdditionalData(): void $this->markTestSkipped('You need to configure the HERE_API_KEY value in phpunit.xml'); } - $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY']); + $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY'], false, '6.2'); $results = $provider->geocodeQuery(GeocodeQuery::create('Sant Roc, Santa Coloma de Cervelló, Espanya') ->withData('Country2', 'true') @@ -167,7 +167,7 @@ public function testGeocodeWithExtraFilterCountry(): void $this->markTestSkipped('You need to configure the HERE_API_KEY value in phpunit.xml'); } - $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY']); + $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY'], false, '6.2'); $queryBarcelonaFromSpain = GeocodeQuery::create('Barcelona')->withData('country', 'ES')->withLocale('ca'); $queryBarcelonaFromVenezuela = GeocodeQuery::create('Barcelona')->withData('country', 'VE')->withLocale('ca'); @@ -202,7 +202,7 @@ public function testGeocodeWithExtraFilterCity(): void $this->markTestSkipped('You need to configure the HERE_API_KEY value in phpunit.xml'); } - $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY']); + $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY'], false, '6.2'); $queryStreetCity1 = GeocodeQuery::create('Carrer de Barcelona')->withData('city', 'Sant Vicenç dels Horts')->withLocale('ca')->withLimit(1); $queryStreetCity2 = GeocodeQuery::create('Carrer de Barcelona')->withData('city', 'Girona')->withLocale('ca')->withLimit(1); @@ -240,7 +240,7 @@ public function testGeocodeWithExtraFilterCounty(): void $this->markTestSkipped('You need to configure the HERE_API_KEY value in phpunit.xml'); } - $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY']); + $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY'], false, '6.2'); $queryCityRegion1 = GeocodeQuery::create('Cabanes')->withData('county', 'Girona')->withLocale('ca')->withLimit(1); $queryCityRegion2 = GeocodeQuery::create('Cabanes')->withData('county', 'Castelló')->withLocale('ca')->withLimit(1); @@ -274,7 +274,7 @@ public function testReverseWithRealCoordinates(): void $this->markTestSkipped('You need to configure the HERE_API_KEY value in phpunit.xml'); } - $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY']); + $provider = Here::createUsingApiKey($this->getHttpClient($_SERVER['HERE_API_KEY']), $_SERVER['HERE_API_KEY'], false, '6.2'); $results = $provider->reverseQuery(ReverseQuery::fromCoordinates(48.8632156, 2.3887722)); @@ -298,10 +298,272 @@ public function testReverseWithRealCoordinates(): void $this->assertEquals('FRA', $result->getCountry()->getCode()); } - public function testGetName(): void + public function testGetBaseUrlVersion6(): void { - $provider = new Here($this->getMockedHttpClient(), 'appId', 'appCode'); - $this->assertEquals('Here', $provider->getName()); + $provider = Here::createUsingApiKey($this->getMockedHttpClient(), 'apiKey', false, '6.2'); + $query = GeocodeQuery::create('Paris'); + $this->assertEquals(Here::GEOCODE_ENDPOINT_URL_API_KEY, $provider->getBaseUrl($query)); + + $revQuery = ReverseQuery::fromCoordinates(48.8, 2.3); + $this->assertEquals(Here::REVERSE_ENDPOINT_URL_API_KEY, $provider->getBaseUrl($revQuery)); + } + + public function testGetBaseUrlVersion7(): void + { + $provider = Here::createUsingApiKey($this->getMockedHttpClient(), 'apiKey', false, '7'); + $query = GeocodeQuery::create('Paris'); + $this->assertEquals(Here::GS7_GEOCODE_ENDPOINT_URL, $provider->getBaseUrl($query)); + + $revQuery = ReverseQuery::fromCoordinates(48.8, 2.3); + $this->assertEquals(Here::GS7_REVERSE_ENDPOINT_URL, $provider->getBaseUrl($revQuery)); + } + + public function testGetBaseUrlCIT(): void + { + $provider = Here::createUsingApiKey($this->getMockedHttpClient(), 'apiKey', true, '6.2'); + $query = GeocodeQuery::create('Paris'); + $this->assertEquals(Here::GEOCODE_CIT_ENDPOINT_API_KEY, $provider->getBaseUrl($query)); + } + + public function testGeocodeGS7Mapping(): void + { + $json = '{ + "items": [ + { + "title": "Avenue Gambetta, 75020 Paris, France", + "id": "here:af:streetsection:9k8l", + "resultType": "street", + "address": { + "label": "Avenue Gambetta, 75020 Paris, France", + "countryCode": "FRA", + "countryName": "France", + "state": "Île-de-France", + "county": "Paris", + "city": "Paris", + "district": "20e Arrondissement", + "street": "Avenue Gambetta", + "postalCode": "75020", + "houseNumber": "10" + }, + "position": { + "lat": 48.8653, + "lng": 2.39844 + }, + "mapView": { + "west": 2.39673, + "south": 48.86417, + "east": 2.40015, + "north": 48.86642 + } + } + ] + }'; + + $provider = Here::createUsingApiKey($this->getMockedHttpClient($json), 'apiKey'); + $results = $provider->geocodeQuery(GeocodeQuery::create('10 avenue Gambetta, Paris, France')); + + $this->assertCount(1, $results); + /** @var HereAddress $result */ + $result = $results->first(); + + $this->assertEquals(48.8653, $result->getCoordinates()->getLatitude()); + $this->assertEquals(2.39844, $result->getCoordinates()->getLongitude()); + $this->assertEquals(48.86417, $result->getBounds()->getSouth()); + $this->assertEquals(2.39673, $result->getBounds()->getWest()); + $this->assertEquals(48.86642, $result->getBounds()->getNorth()); + $this->assertEquals(2.40015, $result->getBounds()->getEast()); + $this->assertEquals('10', $result->getStreetNumber()); + $this->assertEquals('Avenue Gambetta', $result->getStreetName()); + $this->assertEquals('75020', $result->getPostalCode()); + $this->assertEquals('Paris', $result->getLocality()); + $this->assertEquals('20e Arrondissement', $result->getSubLocality()); + $this->assertEquals('FRA', $result->getCountry()->getCode()); + $this->assertEquals('France', $result->getCountry()->getName()); + $this->assertEquals('here:af:streetsection:9k8l', $result->getLocationId()); + $this->assertEquals('street', $result->getLocationType()); + $this->assertEquals('Avenue Gambetta, 75020 Paris, France', $result->getLocationName()); + $this->assertEquals('France', $result->getAdditionalDataValue('CountryName')); + $this->assertEquals('Île-de-France', $result->getAdditionalDataValue('StateName')); + $this->assertEquals('Paris', $result->getAdditionalDataValue('CountyName')); + } + + public function testReverseGS7Mapping(): void + { + $json = '{ + "items": [ + { + "title": "Avenue Gambetta, 75020 Paris, France", + "id": "here:af:streetsection:9k8l", + "resultType": "street", + "address": { + "label": "Avenue Gambetta, 75020 Paris, France", + "countryCode": "FRA", + "countryName": "France", + "state": "Île-de-France", + "county": "Paris", + "city": "Paris", + "district": "20e Arrondissement", + "street": "Avenue Gambetta", + "postalCode": "75020" + }, + "position": { + "lat": 48.8632, + "lng": 2.3888 + }, + "mapView": { + "west": 2.3885, + "south": 48.8631, + "east": 2.3889, + "north": 48.8633 + } + } + ] + }'; + + $provider = Here::createUsingApiKey($this->getMockedHttpClient($json), 'apiKey'); + $results = $provider->reverseQuery(ReverseQuery::fromCoordinates(48.8632, 2.3888)); + + $this->assertCount(1, $results); + /** @var HereAddress $result */ + $result = $results->first(); + + $this->assertEquals(48.8632, $result->getCoordinates()->getLatitude()); + $this->assertEquals(2.3888, $result->getCoordinates()->getLongitude()); + $this->assertEquals('Avenue Gambetta', $result->getStreetName()); + $this->assertEquals('75020', $result->getPostalCode()); + $this->assertEquals('Paris', $result->getLocality()); + } + + public function testParseGS7ResponseWithoutMapView(): void + { + $json = '{ + "items": [ + { + "title": "Avenue Gambetta, 75020 Paris, France", + "id": "here:af:streetsection:9k8l", + "resultType": "street", + "address": { + "label": "Avenue Gambetta, 75020 Paris, France", + "countryCode": "FRA" + }, + "position": { + "lat": 48.8653, + "lng": 2.39844 + } + } + ] + }'; + + $provider = Here::createUsingApiKey($this->getMockedHttpClient($json), 'apiKey'); + $results = $provider->geocodeQuery(GeocodeQuery::create('10 avenue Gambetta, Paris, France')); + + $this->assertCount(1, $results); + /** @var HereAddress $result */ + $result = $results->first(); + $this->assertNull($result->getBounds()); + } + + public function testParseV6ResponseWithDisplayPosition(): void + { + $json = '{ + "Response": { + "View": [ + { + "Result": [ + { + "Location": { + "LocationId": "NT_lP.Bf9f-N7Y.I.M.V.I.M.V", + "LocationType": "street", + "DisplayPosition": { + "Latitude": 48.8653, + "Longitude": 2.39844 + }, + "MapView": { + "TopLeft": {"Latitude": 48.86642, "Longitude": 2.39673}, + "BottomRight": {"Latitude": 48.86417, "Longitude": 2.40015} + }, + "Address": { + "Country": "FRA" + } + } + } + ] + } + ] + } + }'; + + $provider = new Here($this->getMockedHttpClient($json), 'appId', 'appCode'); + $results = $provider->geocodeQuery(GeocodeQuery::create('10 avenue Gambetta, Paris, France')); + + $this->assertCount(1, $results); + /** @var HereAddress $result */ + $result = $results->first(); + $this->assertEquals(48.8653, $result->getCoordinates()->getLatitude()); + } + + public function testParseV6ResponseWithoutAdditionalData(): void + { + $json = '{ + "Response": { + "View": [ + { + "Result": [ + { + "Location": { + "LocationId": "NT_lP.Bf9f-N7Y.I.M.V.I.M.V", + "LocationType": "street", + "DisplayPosition": {"Latitude": 48.8653, "Longitude": 2.39844}, + "MapView": { + "TopLeft": {"Latitude": 48.86642, "Longitude": 2.39673}, + "BottomRight": {"Latitude": 48.86417, "Longitude": 2.40015} + }, + "Address": { + "Country": "FRA" + } + } + } + ] + } + ] + } + }'; + + $provider = new Here($this->getMockedHttpClient($json), 'appId', 'appCode'); + $results = $provider->geocodeQuery(GeocodeQuery::create('10 avenue Gambetta, Paris, France')); + + $this->assertCount(1, $results); + /** @var HereAddress $result */ + $result = $results->first(); + $this->assertNull($result->getCountry()->getName()); + } + + public function testGeocodeV6WithAllStructuredParams(): void + { + $provider = new Here($this->getMockedHttpClient('{"Response": {"View": []}}'), 'appId', 'appCode'); + $query = GeocodeQuery::create('Paris') + ->withData('country', 'FRA') + ->withData('state', 'IDF') + ->withData('county', 'Paris') + ->withData('city', 'Paris') + ->withLocale('fr-FR'); + + $provider->geocodeQuery($query); + $this->addToAssertionCount(1); + } + + public function testGeocodeGS7WithAllStructuredParams(): void + { + $provider = Here::createUsingApiKey($this->getMockedHttpClient('{"items": []}'), 'apiKey'); + $query = GeocodeQuery::create('Paris') + ->withData('country', 'FRA') + ->withData('state', 'IDF') + ->withData('county', 'Paris') + ->withData('city', 'Paris') + ->withLocale('fr-FR'); + + $provider->geocodeQuery($query); + $this->addToAssertionCount(1); } public function testGeocodeWithInvalidData(): void @@ -349,6 +611,93 @@ public function testGeocodeInvalidApiKey(): void $provider->geocodeQuery(GeocodeQuery::create('New York')); } + public function testGeocodeGS7InvalidApiKey(): void + { + $this->expectException(\Geocoder\Exception\InvalidCredentials::class); + $this->expectExceptionMessage('Invalid or missing api key.'); + + $provider = Here::createUsingApiKey( + $this->getMockedHttpClient( + '{ + "error": "Unauthorized" + }' + ), + 'apiKey' + ); + $provider->geocodeQuery(GeocodeQuery::create('New York')); + } + + public function testGeocodeWithInvalidInputData(): void + { + $this->expectException(\Geocoder\Exception\InvalidArgument::class); + $this->expectExceptionMessage('Input parameter validation failed.'); + + $provider = new Here( + $this->getMockedHttpClient( + '{ + "type": { + "subtype": "InvalidInputData" + } + }' + ), + 'appId', + 'appCode' + ); + $provider->geocodeQuery(GeocodeQuery::create('New York')); + } + + public function testGeocodeWithQuotaExceeded(): void + { + $this->expectException(\Geocoder\Exception\QuotaExceeded::class); + $this->expectExceptionMessage('Valid request but quota exceeded.'); + + $provider = new Here( + $this->getMockedHttpClient( + '{ + "type": { + "subtype": "QuotaExceeded" + } + }' + ), + 'appId', + 'appCode' + ); + $provider->geocodeQuery(GeocodeQuery::create('New York')); + } + + public function testGeocodeWithNoCredentials(): void + { + $this->expectException(\Geocoder\Exception\InvalidCredentials::class); + $this->expectExceptionMessage('Invalid or missing api key.'); + + $provider = new Here($this->getMockedHttpClient()); + $provider->geocodeQuery(GeocodeQuery::create('New York')); + } + + public function testGeocodeGS7WithNoResults(): void + { + $provider = Here::createUsingApiKey($this->getMockedHttpClient('{"items": []}'), 'apiKey'); + $results = $provider->geocodeQuery(GeocodeQuery::create('New York')); + + $this->assertCount(0, $results); + } + + public function testGeocodeV6WithNoResponse(): void + { + $provider = new Here($this->getMockedHttpClient('{}'), 'appId', 'appCode'); + $results = $provider->geocodeQuery(GeocodeQuery::create('New York')); + + $this->assertCount(0, $results); + } + + public function testGeocodeV6WithEmptyView(): void + { + $provider = new Here($this->getMockedHttpClient('{"Response": {"View": []}}'), 'appId', 'appCode'); + $results = $provider->geocodeQuery(GeocodeQuery::create('New York')); + + $this->assertCount(0, $results); + } + public function testGeocodeWithRealIPv6(): void { $this->expectException(\Geocoder\Exception\UnsupportedOperation::class); @@ -364,6 +713,6 @@ public function getProvider(): Here $this->markTestSkipped('You need to configure the HERE_API_KEY value in phpunit.xml'); } - return Here::createUsingApiKey($this->getHttpClient(), $_SERVER['HERE_API_KEY']); + return Here::createUsingApiKey($this->getHttpClient(), $_SERVER['HERE_API_KEY'], false, '6.2'); } } diff --git a/Tests/IntegrationTest.php b/Tests/IntegrationTest.php index 5129e3d..7778252 100644 --- a/Tests/IntegrationTest.php +++ b/Tests/IntegrationTest.php @@ -35,7 +35,7 @@ class IntegrationTest extends ProviderIntegrationTest protected function createProvider(ClientInterface $httpClient, bool $useCIT = false) { - return Here::createUsingApiKey($httpClient, $this->getApiKey(), $useCIT); + return Here::createUsingApiKey($httpClient, $this->getApiKey(), $useCIT, '6.2'); } protected function getCacheDir(): string @@ -66,7 +66,7 @@ private function getCachedHttpClient() protected function getApiKey(): string { - return $_SERVER['HERE_APP_ID']; + return $_SERVER['HERE_API_KEY'] ?? 'missing'; } protected function getAppId(): string diff --git a/Tests/Model/HereAddressTest.php b/Tests/Model/HereAddressTest.php new file mode 100644 index 0000000..d8e4bff --- /dev/null +++ b/Tests/Model/HereAddressTest.php @@ -0,0 +1,75 @@ +createAddress(); + + $address = $address->withLocationId('id123'); + $this->assertEquals('id123', $address->getLocationId()); + + $address = $address->withLocationType('street'); + $this->assertEquals('street', $address->getLocationType()); + + $address = $address->withLocationName('Paris'); + $this->assertEquals('Paris', $address->getLocationName()); + } + + public function testAdditionalData(): void + { + $address = $this->createAddress(); + + $data = [ + ['key' => 'foo', 'value' => 'bar'], + ['key' => 'baz', 'value' => 123], + ]; + + $address = $address->withAdditionalData($data); + + $this->assertTrue($address->hasAdditionalDataValue('foo')); + $this->assertEquals('bar', $address->getAdditionalDataValue('foo')); + $this->assertEquals(123, $address->getAdditionalDataValue('baz')); + $this->assertFalse($address->hasAdditionalDataValue('missing')); + $this->assertEquals('default', $address->getAdditionalDataValue('missing', 'default')); + } + + public function testShape(): void + { + $address = $this->createAddress(); + + $shape = [ + 'type' => 'Point', + 'coordinates' => [1.2, 3.4], + ]; + + $address = $address->withShape($shape); + + $this->assertTrue($address->hasShapeValue('type')); + $this->assertEquals('Point', $address->getShapeValue('type')); + $this->assertEquals([1.2, 3.4], $address->getShapeValue('coordinates')); + $this->assertFalse($address->hasShapeValue('missing')); + $this->assertEquals('default', $address->getShapeValue('missing', 'default')); + } + + public function testEmptyShape(): void + { + $address = $this->createAddress(); + $address = $address->withShape([]); + $this->assertNull($address->getShapeValue('any')); + } +} + diff --git a/composer.json b/composer.json index 12b36e3..92c4e27 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ } ], "require": { - "php": "^8.0", + "php": "^8.2", "geocoder-php/common-http": "^4.0", "willdurand/geocoder": "^4.0|^5.0" }, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 8a67c7b..3ee47a4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,23 +1,28 @@ - - - - ./ - - - ./Tests - ./vendor - - - - - - - - - - - ./Tests/ - - + + + + ./Tests/ + + + + + ./ + + + ./Tests + ./vendor + + + + + + + +