diff --git a/.github/workflows/provider.yml b/.github/workflows/provider.yml index ea7b27fa7..d560704e5 100644 --- a/.github/workflows/provider.yml +++ b/.github/workflows/provider.yml @@ -45,6 +45,7 @@ jobs: - MaxMindBinary - Nominatim - OpenCage + - OpenGeocoding - OpenRouteService - Pelias - Photon diff --git a/.github/workflows/subtree.yml b/.github/workflows/subtree.yml index 577069128..a46467763 100644 --- a/.github/workflows/subtree.yml +++ b/.github/workflows/subtree.yml @@ -113,6 +113,7 @@ jobs: - { folder: MaxMindBinary, repository: maxmind-binary-provider } - { folder: Nominatim, repository: nominatim-provider } - { folder: OpenCage, repository: open-cage-provider } + - { folder: OpenGeocoding, repository: opengeocoding-provider } - { folder: OpenRouteService, repository: openrouteservice-provider } - { folder: Pelias, repository: pelias-provider } - { folder: Photon, repository: photon-provider } diff --git a/README.md b/README.md index 31b250196..3e0458c36 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ Provider | Package | Features | Stats [MapTiler](https://github.com/geocoder-php/maptiler-provider) | `geocoder-php/maptiler-provider` | address, reverse
[Website](https://www.maptiler.com/cloud/geocoding/) | [![Latest Stable Version](https://poser.pugx.org/geocoder-php/maptiler-provider/v/stable)](https://packagist.org/packages/geocoder-php/maptiler-provider)
[![Total Downloads](https://poser.pugx.org/geocoder-php/maptiler-provider/downloads)](https://packagist.org/packages/geocoder-php/maptiler-provider) [Nominatim](https://github.com/geocoder-php/nominatim-provider)
(OpenStreetMap) | `geocoder-php/nominatim-provider` | address, reverse
[Website](http://wiki.openstreetmap.org/wiki/Nominatim) | [![Latest Stable Version](https://poser.pugx.org/geocoder-php/nominatim-provider/v/stable)](https://packagist.org/packages/geocoder-php/nominatim-provider)
[![Total Downloads](https://poser.pugx.org/geocoder-php/nominatim-provider/downloads)](https://packagist.org/packages/geocoder-php/nominatim-provider) [OpenCage](https://github.com/geocoder-php/open-cage-provider) | `geocoder-php/open-cage-provider` | address, reverse
[Website](https://opencagedata.com/) | [![Latest Stable Version](https://poser.pugx.org/geocoder-php/open-cage-provider/v/stable)](https://packagist.org/packages/geocoder-php/open-cage-provider)
[![Total Downloads](https://poser.pugx.org/geocoder-php/open-cage-provider/downloads)](https://packagist.org/packages/geocoder-php/open-cage-provider) +[OpenGeocoding](https://github.com/geocoder-php/opengeocoding-provider) | `geocoder-php/opengeocoding-provider` | address, reverse
[Website](https://opengeocoding.com/) | [![Latest Stable Version](https://poser.pugx.org/geocoder-php/opengeocoding-provider/v/stable)](https://packagist.org/packages/geocoder-php/opengeocoding-provider)
[![Total Downloads](https://poser.pugx.org/geocoder-php/opengeocoding-provider/downloads)](https://packagist.org/packages/geocoder-php/opengeocoding-provider) [OpenRouteService](https://github.com/geocoder-php/openrouteservice-provider) | `geocoder-php/openrouteservice-provider` | address, reverse
[Website](https://openrouteservice.org/) | [![Latest Stable Version](https://poser.pugx.org/geocoder-php/openrouteservice-provider/v/stable)](https://packagist.org/packages/geocoder-php/openrouteservice-provider)
[![Total Downloads](https://poser.pugx.org/geocoder-php/openrouteservice-provider/downloads)](https://packagist.org/packages/geocoder-php/openrouteservice-provider) [Pelias](https://github.com/geocoder-php/pelias-provider) | `geocoder-php/pelias-provider` | address, reverse
[Website](https://pelias.io/) | [![Latest Stable Version](https://poser.pugx.org/geocoder-php/pelias-provider/v/stable)](https://packagist.org/packages/geocoder-php/pelias-provider)
[![Total Downloads](https://poser.pugx.org/geocoder-php/pelias-provider/downloads)](https://packagist.org/packages/geocoder-php/pelias-provider) [Photon](https://github.com/geocoder-php/photon-provider) | `geocoder-php/photon-provider` | address, reverse
[Website](https://photon.komoot.de/) | [![Latest Stable Version](https://poser.pugx.org/geocoder-php/photon-provider/v/stable)](https://packagist.org/packages/geocoder-php/photon-provider)
[![Total Downloads](https://poser.pugx.org/geocoder-php/photon-provider/downloads)](https://packagist.org/packages/geocoder-php/photon-provider) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d01fae0f5..49f21a177 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -48,6 +48,7 @@ + diff --git a/src/Provider/OpenGeocoding/.gitattributes b/src/Provider/OpenGeocoding/.gitattributes new file mode 100644 index 000000000..6643fc2fc --- /dev/null +++ b/src/Provider/OpenGeocoding/.gitattributes @@ -0,0 +1,5 @@ +.gitattributes export-ignore +.github/ export-ignore +.gitignore export-ignore +phpunit.xml.dist export-ignore +Tests/ export-ignore diff --git a/src/Provider/OpenGeocoding/.github/workflows/provider.yml b/src/Provider/OpenGeocoding/.github/workflows/provider.yml new file mode 100644 index 000000000..74ab6830d --- /dev/null +++ b/src/Provider/OpenGeocoding/.github/workflows/provider.yml @@ -0,0 +1,37 @@ +name: Provider + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + test: + name: PHP ${{ matrix.php-version }} (${{ matrix.deps }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + deps: ['high'] + include: + - php-version: '8.0' + deps: 'low' + steps: + - uses: actions/checkout@v7 + - name: Use PHP ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: curl + - name: Validate composer.json + run: composer validate --strict + - name: Install dependencies + if: ${{ matrix.deps == 'high' }} + run: composer update --prefer-stable --prefer-dist --no-progress + - name: Install dependencies (lowest) + if: ${{ matrix.deps == 'low' }} + run: composer update --prefer-lowest --prefer-stable --prefer-dist --no-progress + - name: Run test suite + run: composer run-script test diff --git a/src/Provider/OpenGeocoding/.gitignore b/src/Provider/OpenGeocoding/.gitignore new file mode 100644 index 000000000..c49a5d8df --- /dev/null +++ b/src/Provider/OpenGeocoding/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Provider/OpenGeocoding/CHANGELOG.md b/src/Provider/OpenGeocoding/CHANGELOG.md new file mode 100644 index 000000000..38d81cd6b --- /dev/null +++ b/src/Provider/OpenGeocoding/CHANGELOG.md @@ -0,0 +1,9 @@ +# Change Log + +The change log describes what is "Added", "Removed", "Changed" or "Fixed" between each release. + +## 1.0.0 + +### Added + +- First release: address search and reverse geocoding with the OpenGeocoding API, for PHP 8.0 to 8.5 and Geocoder PHP 4 and 5 diff --git a/src/Provider/OpenGeocoding/LICENSE b/src/Provider/OpenGeocoding/LICENSE new file mode 100644 index 000000000..6d88d944a --- /dev/null +++ b/src/Provider/OpenGeocoding/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Théo Mathieu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Provider/OpenGeocoding/Model/OpenGeocodingAddress.php b/src/Provider/OpenGeocoding/Model/OpenGeocodingAddress.php new file mode 100644 index 000000000..bd1ac5a47 --- /dev/null +++ b/src/Provider/OpenGeocoding/Model/OpenGeocodingAddress.php @@ -0,0 +1,87 @@ +displayName; + } + + public function withDisplayName(?string $displayName): self + { + $new = clone $this; + $new->displayName = $displayName; + + return $new; + } + + /** + * False for a partial match, such as the street when the house number was not found. + */ + public function isGoodMatch(): bool + { + return $this->goodMatch; + } + + public function withGoodMatch(bool $goodMatch): self + { + $new = clone $this; + $new->goodMatch = $goodMatch; + + return $new; + } + + /** + * Distance in metres from the queried point; reverse geocoding only. + */ + public function getDistance(): ?float + { + return $this->distance; + } + + public function withDistance(?float $distance): self + { + $new = clone $this; + $new->distance = $distance; + + return $new; + } + + /** + * Reverse geocoding only: "address", "street" or "locality". + */ + public function getLayer(): ?string + { + return $this->layer; + } + + public function withLayer(?string $layer): self + { + $new = clone $this; + $new->layer = $layer; + + return $new; + } +} diff --git a/src/Provider/OpenGeocoding/OpenGeocoding.php b/src/Provider/OpenGeocoding/OpenGeocoding.php new file mode 100644 index 000000000..a24f5eb48 --- /dev/null +++ b/src/Provider/OpenGeocoding/OpenGeocoding.php @@ -0,0 +1,193 @@ +apiKey = $apiKey; + parent::__construct($client); + } + + public function geocodeQuery(GeocodeQuery $query): Collection + { + $address = $query->getText(); + + if (filter_var($address, FILTER_VALIDATE_IP)) { + throw new UnsupportedOperation('The OpenGeocoding provider does not support IP addresses, only street addresses.'); + } + + $json = $this->executeQuery(self::API_URL.'/v1/search?'.http_build_query(['addressString' => $address])); + + if ([] === $json) { + return new AddressCollection([]); + } + + return new AddressCollection([$this->buildAddress($json)]); + } + + public function reverseQuery(ReverseQuery $query): Collection + { + $coordinates = $query->getCoordinates(); + + $url = self::API_URL.'/v1/reverse?'.http_build_query([ + 'lat' => $coordinates->getLatitude(), + 'lon' => $coordinates->getLongitude(), + 'size' => max(1, min(self::MAX_REVERSE_RESULTS, $query->getLimit())), + 'radiusM' => $query->getData('radiusM'), + ]); + + $results = []; + foreach ($this->executeQuery($url)['results'] ?? [] as $result) { + $results[] = $this->buildAddress( + $result['address'] ?? [], + isset($result['distanceM']) ? (float) $result['distanceM'] : null, + $result['layer'] ?? null + ); + } + + return new AddressCollection($results); + } + + public function getName(): string + { + return 'opengeocoding'; + } + + /** + * @return array + */ + private function executeQuery(string $url): array + { + $request = $this->createRequest('GET', $url, [ + 'Accept' => 'application/json', + 'x-api-key' => $this->apiKey, + ]); + $response = $this->getHttpClient()->sendRequest($request); + + $status = $response->getStatusCode(); + $json = json_decode((string) $response->getBody(), true); + $message = is_array($json) && isset($json['message']) && is_string($json['message']) ? $json['message'] : null; + + if (401 === $status || 403 === $status) { + throw new InvalidCredentials($message ?? 'Invalid or missing API key.'); + } + if (429 === $status) { + throw new QuotaExceeded($message ?? 'Rate limit or request credits exceeded.'); + } + if (400 === $status && null !== $message) { + throw new InvalidArgument($message); + } + if (200 !== $status || !is_array($json)) { + throw InvalidServerResponse::create($url, $status); + } + + return $json; + } + + /** + * @param array $item a FullAddress object + */ + private function buildAddress(array $item, ?float $distance = null, ?string $layer = null): OpenGeocodingAddress + { + $coordinates = $item['geoCoordinates'] ?? []; + $details = $item['addressDetails'] ?? []; + + $builder = new AddressBuilder($this->getName()); + $builder->setCoordinates((float) ($coordinates['lat'] ?? 0), (float) ($coordinates['lon'] ?? 0)); + if (isset($coordinates['boundingBox'])) { + $box = $coordinates['boundingBox']; + $builder->setBounds( + (float) ($box['minLat'] ?? 0), + (float) ($box['minLon'] ?? 0), + (float) ($box['maxLat'] ?? 0), + (float) ($box['maxLon'] ?? 0) + ); + } + + $builder->setStreetNumber($details['houseNumber'] ?? null); + $builder->setStreetName($details['road'] ?? null); + $builder->setPostalCode($details['postcode'] ?? null); + $builder->setLocality($details['city'] ?? $details['town'] ?? $details['village'] ?? $details['hamlet'] ?? $details['municipality'] ?? null); + $builder->setSubLocality($details['suburb'] ?? $details['borough'] ?? $details['cityDistrict'] ?? $details['neighbourhood'] ?? null); + $builder->setCountry($details['country'] ?? null); + $builder->setCountryCode(isset($details['countryCode']) ? strtoupper($details['countryCode']) : null); + $this->addAdminLevels($builder, $details); + + /** @var OpenGeocodingAddress $address */ + $address = $builder->build(OpenGeocodingAddress::class); + + return $address + ->withDisplayName($item['displayName'] ?? null) + ->withGoodMatch((bool) ($item['goodMatch'] ?? false)) + ->withDistance($distance) + ->withLayer($layer); + } + + /** + * @param array $details an AddressDetails object + */ + private function addAdminLevels(AddressBuilder $builder, array $details): void + { + $regions = array_values(array_filter( + $details['regions'] ?? [], + fn ($region) => isset($region['enName']) || isset($region['localName']) + )); + usort($regions, fn ($a, $b) => ($a['adminLevel'] ?? 0) <=> ($b['adminLevel'] ?? 0)); + + // Geocoder's AdminLevelCollection only accepts levels 1 to 5. + foreach (array_slice($regions, 0, 5) as $i => $region) { + $builder->addAdminLevel($i + 1, $region['enName'] ?? $region['localName'], $region['adminCode'] ?? null); + } + + if ([] !== $regions) { + return; + } + + $level = 1; + foreach (['state', 'county'] as $field) { + if (isset($details[$field]) && '' !== $details[$field]) { + $builder->addAdminLevel($level++, $details[$field]); + } + } + } +} diff --git a/src/Provider/OpenGeocoding/README.md b/src/Provider/OpenGeocoding/README.md new file mode 100644 index 000000000..8519545a8 --- /dev/null +++ b/src/Provider/OpenGeocoding/README.md @@ -0,0 +1,110 @@ +# OpenGeocoding provider for Geocoder PHP + +[![CI](https://github.com/geocoder-php/opengeocoding-provider/actions/workflows/provider.yml/badge.svg)](https://github.com/geocoder-php/opengeocoding-provider/actions/workflows/provider.yml) +[![Latest Stable Version](https://poser.pugx.org/geocoder-php/opengeocoding-provider/v/stable)](https://packagist.org/packages/geocoder-php/opengeocoding-provider) +[![Total Downloads](https://poser.pugx.org/geocoder-php/opengeocoding-provider/downloads)](https://packagist.org/packages/geocoder-php/opengeocoding-provider) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) + +A [Geocoder PHP](https://github.com/geocoder-php/Geocoder) provider for +[OpenGeocoding](https://opengeocoding.com), a hosted geocoding API built on open data +(OpenStreetMap, Overture Maps, OpenAddresses, GeoNames). The engine is +[source-available](https://github.com/Mokto/opengeocoding). + +This is a **READ ONLY** repository. Contributions and issues belong in the +[main Geocoder repository](https://github.com/geocoder-php/Geocoder). + +## Install + +You need a [PSR-18 HTTP client](https://packagist.org/providers/psr/http-client-implementation), for example Guzzle: + +```bash +composer require geocoder-php/opengeocoding-provider guzzlehttp/guzzle +``` + +If migrating from `opengeocoding/opengeocoding-provider`, remove that package first. +The PHP namespaces and API are unchanged; do not install both packages together. + +Create an API key in the [OpenGeocoding dashboard](https://opengeocoding.com/dashboard). The +provider sends it in the `x-api-key` header, never in the URL. + +## Usage + +```php +use Geocoder\Provider\OpenGeocoding\OpenGeocoding; +use Geocoder\Query\GeocodeQuery; +use Geocoder\Query\ReverseQuery; +use Http\Discovery\Psr18Client; + +$provider = new OpenGeocoding(new Psr18Client(), getenv('OPENGEOCODING_API_KEY')); + +$address = $provider->geocodeQuery(GeocodeQuery::create('10 Rue de Rivoli, Paris'))->first(); +echo $address->getCoordinates()->getLatitude(); // 48.8555 +echo $address->getPostalCode(); // 75004 +echo $address->getDisplayName(); // 10, Rue de Rivoli, Paris, Ile-de-France, France + +$results = $provider->reverseQuery(ReverseQuery::fromCoordinates(55.676, 12.568)->withLimit(3)); +foreach ($results as $address) { + printf("%s (%.0f m)\n", $address->getDisplayName(), $address->getDistance()); +} +``` + +The provider works with the rest of Geocoder PHP, such as `StatefulGeocoder`, `ProviderAggregator`, +the `Chain` provider and the cache plugin. + +### Behaviour + +- **Search returns at most one result**, the best match, whatever the query's limit. An address + that matches nothing returns an empty collection. +- **Reverse** returns the nearest results first, up to the query's limit (at most 10). Pass a search + radius in metres with `->withData('radiusM', 200)` (the API accepts up to 2000 and defaults to 500). +- **The locale is ignored**: the API has no language parameter. +- IP addresses are not supported (`UnsupportedOperation`). +- Admin levels come from the address's regions, ordered from the largest; their codes are the + ISO 3166-2 subdivision codes (`IDF` for Île-de-France). Country codes are upper case. +- Results are `OpenGeocodingAddress` objects, with `getDisplayName()`, `isGoodMatch()` (false for a + partial match, such as the street when the house number was not found) and, for reverse + results, `getDistance()` in metres and `getLayer()` (`address`, `street` or `locality`). + +### Errors + +| HTTP status | Exception | +|---|---| +| 401, 403 | `Geocoder\Exception\InvalidCredentials` | +| 429 (rate limit or no credits left) | `Geocoder\Exception\QuotaExceeded` | +| 400 | `Geocoder\Exception\InvalidArgument`, with the API's message | +| anything else | `Geocoder\Exception\InvalidServerResponse` | + +## Pricing + +Plans and request limits are on the [pricing page](https://opengeocoding.com/pricing). + +## Data attribution + +Results come from open data under the ODbL and other open-data licences. When you show results, +or data derived from them, to others, credit the sources, for example: + +> Data © OpenStreetMap contributors (ODbL 1.0), Overture Maps Foundation, OpenAddresses contributors, GeoNames (CC BY 4.0, modified). Details: https://opengeocoding.com/legal/attributions + +Some countries' sources need their own credit line; see +[the data attributions page](https://opengeocoding.com/legal/attributions). + +## Development + +From `src/Provider/OpenGeocoding` in the main Geocoder repository: + +```bash +composer update +composer test +``` + +The suite includes unit tests and the shared Geocoder integration tests, using cached +responses. Run `composer analyse` and `composer cs` from the main repository's root. + +The tests replay the responses in `Tests/.cached_responses`. To record a new one, delete the +matching file and run the tests with `OPENGEOCODING_API_KEY` set. The key is sent in a header, +so it never reaches the cached files. Check that a recorded file holds a real result, not an +error, before you commit it. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_2342a693e453d429808f665b5b814d7a37d9b6d5 b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_2342a693e453d429808f665b5b814d7a37d9b6d5 new file mode 100644 index 000000000..6fb4c986a --- /dev/null +++ b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_2342a693e453d429808f665b5b814d7a37d9b6d5 @@ -0,0 +1 @@ +s:3498:"{"results":[{"address":{"displayName":"800, 16th Street Northwest, Washington, District of Columbia, United States","geoCoordinates":{"lat":38.900513,"lon":-77.036995,"boundingBox":{"minLat":38.899513,"maxLat":38.901512,"minLon":-77.03799,"maxLon":-77.03599}},"addressDetails":{"country":"United States","countryCode":"us","state":"District of Columbia","regionCodes":[{"adminLevel":4,"code":"US-DC"}],"city":"Washington","road":"16th Street Northwest","houseNumber":"800","postcode":"20006","regions":[{"countryCode":"us","countryName":"United States","adminCode":"DC","adminLevel":4,"localName":"District of Columbia","enName":"District of Columbia"}]},"goodMatch":true},"distanceM":34.02458,"layer":"address"},{"address":{"displayName":"1615, H Street Northwest, Washington, District of Columbia, United States","geoCoordinates":{"lat":38.90059,"lon":-77.03759,"boundingBox":{"minLat":38.89959,"maxLat":38.90159,"minLon":-77.03859,"maxLon":-77.03659}},"addressDetails":{"country":"United States","countryCode":"us","state":"District of Columbia","regionCodes":[{"adminLevel":4,"code":"US-DC"}],"city":"Washington","road":"H Street Northwest","houseNumber":"1615","postcode":"20062","regions":[{"countryCode":"us","countryName":"United States","adminCode":"DC","adminLevel":4,"localName":"District of Columbia","enName":"District of Columbia"}]},"goodMatch":true},"distanceM":67.31728,"layer":"address"},{"address":{"displayName":"1530, H Street Northwest, Washington, District of Columbia, United States","geoCoordinates":{"lat":38.8997,"lon":-77.03654,"boundingBox":{"minLat":38.8987,"maxLat":38.9007,"minLon":-77.03754,"maxLon":-77.03554}},"addressDetails":{"country":"United States","countryCode":"us","state":"District of Columbia","regionCodes":[{"adminLevel":4,"code":"US-DC"}],"city":"Washington","road":"H Street Northwest","houseNumber":"1530","postcode":"20005","regions":[{"countryCode":"us","countryName":"United States","adminCode":"DC","adminLevel":4,"localName":"District of Columbia","enName":"District of Columbia"}]},"goodMatch":true},"distanceM":68.559105,"layer":"address"},{"address":{"displayName":"850, 16th Street Northwest, Washington, District of Columbia, United States","geoCoordinates":{"lat":38.900833,"lon":-77.03688,"boundingBox":{"minLat":38.899834,"maxLat":38.901833,"minLon":-77.03788,"maxLon":-77.03588}},"addressDetails":{"country":"United States","countryCode":"us","state":"District of Columbia","regionCodes":[{"adminLevel":4,"code":"US-DC"}],"city":"Washington","road":"16th Street Northwest","houseNumber":"850","postcode":"20006","regions":[{"countryCode":"us","countryName":"United States","adminCode":"DC","adminLevel":4,"localName":"District of Columbia","enName":"District of Columbia"}]},"goodMatch":true},"distanceM":70.26911,"layer":"address"},{"address":{"displayName":"888, 16th Street Northwest, Washington, District of Columbia, United States","geoCoordinates":{"lat":38.900906,"lon":-77.03688,"boundingBox":{"minLat":38.899906,"maxLat":38.901905,"minLon":-77.03788,"maxLon":-77.03588}},"addressDetails":{"country":"United States","countryCode":"us","state":"District of Columbia","regionCodes":[{"adminLevel":4,"code":"US-DC"}],"city":"Washington","road":"16th Street Northwest","houseNumber":"888","postcode":"20006","regions":[{"countryCode":"us","countryName":"United States","adminCode":"DC","adminLevel":4,"localName":"District of Columbia","enName":"District of Columbia"}]},"goodMatch":true},"distanceM":78.00364,"layer":"address"}]}"; \ No newline at end of file diff --git a/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_3729604a85cb6dfb1c61f5c2a4d1d01156494050 b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_3729604a85cb6dfb1c61f5c2a4d1d01156494050 new file mode 100644 index 000000000..734208d63 --- /dev/null +++ b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_3729604a85cb6dfb1c61f5c2a4d1d01156494050 @@ -0,0 +1 @@ +s:618:"{"displayName":"10, Downing Street, London, Westminster, England, United Kingdom","geoCoordinates":{"lat":51.503468,"lon":-0.1278019,"boundingBox":{"minLat":51.50247,"maxLat":51.504467,"minLon":-0.1288019,"maxLon":-0.1268019}},"addressDetails":{"country":"United Kingdom","countryCode":"gb","state":"England","county":"Westminster","regionCodes":[{"adminLevel":4,"code":"GB-ENG"}],"city":"London","road":"Downing Street","houseNumber":"10","postcode":"SW1A 2AA","regions":[{"countryCode":"gb","countryName":"United Kingdom","adminCode":"ENG","adminLevel":4,"localName":"England","enName":"England"}]},"goodMatch":true}"; \ No newline at end of file diff --git a/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_3e994a3c06eb8876a27daf5185afe78c2768f177 b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_3e994a3c06eb8876a27daf5185afe78c2768f177 new file mode 100644 index 000000000..56313860d --- /dev/null +++ b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_3e994a3c06eb8876a27daf5185afe78c2768f177 @@ -0,0 +1 @@ +s:782:"{"displayName":"10, Rue de Rivoli, Paris, Ile-de-France, France","geoCoordinates":{"lat":48.8555,"lon":2.3604083,"boundingBox":{"minLat":48.8545,"maxLat":48.8565,"minLon":2.3594084,"maxLon":2.3614082}},"addressDetails":{"country":"France","countryCode":"fr","state":"Ile-de-France","county":"Paris","regionCodes":[{"adminLevel":4,"code":"FR-IDF"},{"adminLevel":6,"code":"FR-75C"}],"municipality":"Paris","city":"Paris","road":"Rue de Rivoli","houseNumber":"10","postcode":"75004","regions":[{"countryCode":"fr","countryName":"France","adminCode":"IDF","adminLevel":4,"localName":"Île-de-France","enName":"Ile-de-France"},{"countryCode":"fr","countryName":"France","adminCode":"75C","adminLevel":6,"localName":"Paris","enName":"Paris","parentAdminCode":"FR-IDF"}]},"goodMatch":true}"; \ No newline at end of file diff --git a/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_59c333369254491759617b6f25f5914eee2954e6 b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_59c333369254491759617b6f25f5914eee2954e6 new file mode 100644 index 000000000..d4e1ff0a8 --- /dev/null +++ b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_59c333369254491759617b6f25f5914eee2954e6 @@ -0,0 +1 @@ +s:2:"{}"; \ No newline at end of file diff --git a/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_5d4075750ece2b3a336bbc42e0eac0e5fd20f5d9 b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_5d4075750ece2b3a336bbc42e0eac0e5fd20f5d9 new file mode 100644 index 000000000..33a3db858 --- /dev/null +++ b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_5d4075750ece2b3a336bbc42e0eac0e5fd20f5d9 @@ -0,0 +1 @@ +s:1478:"{"results":[{"address":{"displayName":"3, Rådhuspladsen, Copenhagen, Copenhagen Municipality, Capital Region of Denmark, Denmark","geoCoordinates":{"lat":55.67611,"lon":12.568231,"boundingBox":{"minLat":55.67511,"maxLat":55.67711,"minLon":12.56723,"maxLon":12.569231}},"addressDetails":{"country":"Denmark","countryCode":"dk","state":"Capital Region of Denmark","county":"Copenhagen Municipality","regionCodes":[{"adminLevel":4,"code":"DK-84"}],"city":"Copenhagen","road":"Rådhuspladsen","houseNumber":"3","postcode":"1550","regions":[{"countryCode":"dk","countryName":"Denmark","adminCode":"84","adminLevel":4,"localName":"Region Hovedstaden","enName":"Capital Region of Denmark"}]},"goodMatch":true},"distanceM":19.04121,"layer":"address"},{"address":{"displayName":"5, Rådhuspladsen, Copenhagen, Copenhagen Municipality, Capital Region of Denmark, Denmark","geoCoordinates":{"lat":55.67619,"lon":12.568038,"boundingBox":{"minLat":55.67519,"maxLat":55.67719,"minLon":12.567037,"maxLon":12.569037}},"addressDetails":{"country":"Denmark","countryCode":"dk","state":"Capital Region of Denmark","county":"Copenhagen Municipality","regionCodes":[{"adminLevel":4,"code":"DK-84"}],"city":"Copenhagen","road":"Rådhuspladsen","houseNumber":"5","postcode":"1550","regions":[{"countryCode":"dk","countryName":"Denmark","adminCode":"84","adminLevel":4,"localName":"Region Hovedstaden","enName":"Capital Region of Denmark"}]},"goodMatch":true},"distanceM":21.32454,"layer":"address"}]}"; \ No newline at end of file diff --git a/src/Provider/OpenGeocoding/Tests/IntegrationTest.php b/src/Provider/OpenGeocoding/Tests/IntegrationTest.php new file mode 100644 index 000000000..7bd4f2107 --- /dev/null +++ b/src/Provider/OpenGeocoding/Tests/IntegrationTest.php @@ -0,0 +1,43 @@ + 'The API returns a placeholder result at 0,0', + ]; + + protected bool $testIpv4 = false; + + protected bool $testIpv6 = false; + + protected function createProvider(ClientInterface $httpClient) + { + return new OpenGeocoding($httpClient, $this->getApiKey()); + } + + protected function getCacheDir(): string + { + return __DIR__.'/.cached_responses'; + } + + protected function getApiKey(): string + { + return (string) getenv('OPENGEOCODING_API_KEY'); + } +} diff --git a/src/Provider/OpenGeocoding/Tests/OpenGeocodingTest.php b/src/Provider/OpenGeocoding/Tests/OpenGeocodingTest.php new file mode 100644 index 000000000..4d6f2fb10 --- /dev/null +++ b/src/Provider/OpenGeocoding/Tests/OpenGeocodingTest.php @@ -0,0 +1,198 @@ +getMockedHttpClient(), 'api_key'); + $this->assertEquals('opengeocoding', $provider->getName()); + } + + public function testEmptyApiKey(): void + { + $this->expectException(InvalidCredentials::class); + new OpenGeocoding($this->getMockedHttpClient(), ''); + } + + public function testEmptySearchResponseIsAnEmptyCollection(): void + { + $provider = new OpenGeocoding($this->getMockedHttpClient('{}'), 'api_key'); + $this->assertCount(0, $provider->geocodeQuery(GeocodeQuery::create('foobar'))); + } + + public function testEmptyReverseResponseIsAnEmptyCollection(): void + { + $provider = new OpenGeocoding($this->getMockedHttpClient('{}'), 'api_key'); + $this->assertCount(0, $provider->reverseQuery(ReverseQuery::fromCoordinates(1, 2))); + } + + public function testSearchRequest(): void + { + $client = $this->getMockedHttpClientCallback(function (RequestInterface $request) { + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('https://api.opengeocoding.com/v1/search?addressString=10+Rue+de+Rivoli%2C+Paris', (string) $request->getUri()); + $this->assertSame('secret_key', $request->getHeaderLine('x-api-key')); + + return '{}'; + }); + + $provider = new OpenGeocoding($client, 'secret_key'); + $provider->geocodeQuery(GeocodeQuery::create('10 Rue de Rivoli, Paris')->withLimit(3)->withLocale('fr')); + } + + public function testReverseRequestCapsSizeAndPassesRadius(): void + { + $client = $this->getMockedHttpClientCallback(function (RequestInterface $request) { + $this->assertSame('https://api.opengeocoding.com/v1/reverse?lat=55.676&lon=12.568&size=10&radiusM=200', (string) $request->getUri()); + $this->assertSame('secret_key', $request->getHeaderLine('x-api-key')); + + return '{"results":[]}'; + }); + + $provider = new OpenGeocoding($client, 'secret_key'); + $provider->reverseQuery(ReverseQuery::fromCoordinates(55.676, 12.568)->withLimit(50)->withData('radiusM', 200)); + } + + public function testAdminLevelsFallBackToStateAndCounty(): void + { + $body = '{"displayName":"Somewhere","geoCoordinates":{"lat":1.5,"lon":2.5},"addressDetails":{"state":"A State","county":"A County","town":"A Town","countryCode":"xx"}}'; + $provider = new OpenGeocoding($this->getMockedHttpClient($body), 'api_key'); + + /** @var OpenGeocodingAddress $result */ + $result = $provider->geocodeQuery(GeocodeQuery::create('somewhere'))->first(); + $this->assertNull($result->getBounds()); + $this->assertEquals('A Town', $result->getLocality()); + $this->assertEquals('XX', $result->getCountry()->getCode()); + $this->assertCount(2, $result->getAdminLevels()); + $this->assertEquals('A State', $result->getAdminLevels()->get(1)->getName()); + $this->assertEquals('A County', $result->getAdminLevels()->get(2)->getName()); + $this->assertFalse($result->isGoodMatch()); + } + + public function testIpAddressesAreUnsupported(): void + { + $this->expectException(UnsupportedOperation::class); + $provider = new OpenGeocoding($this->getMockedHttpClient(), 'api_key'); + $provider->geocodeQuery(GeocodeQuery::create('127.0.0.1')); + } + + /** + * @return array, string|null}> + */ + public function errorProvider(): array + { + return [ + '401' => [401, '{"code":"unauthenticated","message":"The API key is invalid, expired or revoked."}', InvalidCredentials::class, 'The API key is invalid, expired or revoked.'], + '403' => [403, '{"code":"permission_denied","message":"Origin not allowed."}', InvalidCredentials::class, 'Origin not allowed.'], + '429' => [429, '{"code":"resource_exhausted","message":"Too many requests per second for your plan."}', QuotaExceeded::class, 'Too many requests per second for your plan.'], + '400' => [400, '{"code":"invalid_argument","message":"addressString cannot be blank"}', InvalidArgument::class, 'addressString cannot be blank'], + '400 without API error' => [400, '', InvalidServerResponse::class, null], + '404' => [404, '{"code":"not_found","message":"Not found."}', InvalidServerResponse::class, null], + '503' => [503, '{"code":"unavailable","message":"Unavailable."}', InvalidServerResponse::class, null], + '200 not JSON' => [200, '', InvalidServerResponse::class, null], + ]; + } + + /** + * @dataProvider errorProvider + * + * @param class-string<\Throwable> $exception + */ + public function testErrors(int $status, string $body, string $exception, ?string $message): void + { + $client = $this->getMockedHttpClientCallback(fn () => new Response($status, [], $body)); + $provider = new OpenGeocoding($client, 'api_key'); + + $this->expectException($exception); + if (null !== $message) { + $this->expectExceptionMessage($message); + } + $provider->geocodeQuery(GeocodeQuery::create('foo')); + } + + public function testGeocodeWithRealAddress(): void + { + $provider = new OpenGeocoding($this->getHttpClient($this->apiKey()), $this->apiKey()); + $results = $provider->geocodeQuery(GeocodeQuery::create('10 Rue de Rivoli, Paris')->withLimit(5)); + + $this->assertCount(1, $results); + + /** @var OpenGeocodingAddress $result */ + $result = $results->first(); + $this->assertInstanceOf(OpenGeocodingAddress::class, $result); + $this->assertEqualsWithDelta(48.8555, $result->getCoordinates()->getLatitude(), 0.001); + $this->assertEqualsWithDelta(2.3604, $result->getCoordinates()->getLongitude(), 0.001); + $this->assertNotNull($result->getBounds()); + $this->assertLessThan($result->getBounds()->getNorth(), $result->getBounds()->getSouth()); + $this->assertLessThan($result->getBounds()->getEast(), $result->getBounds()->getWest()); + $this->assertEquals('10', $result->getStreetNumber()); + $this->assertEquals('Rue de Rivoli', $result->getStreetName()); + $this->assertEquals('75004', $result->getPostalCode()); + $this->assertEquals('Paris', $result->getLocality()); + $this->assertCount(2, $result->getAdminLevels()); + $this->assertEquals('Ile-de-France', $result->getAdminLevels()->get(1)->getName()); + $this->assertEquals('IDF', $result->getAdminLevels()->get(1)->getCode()); + $this->assertEquals('Paris', $result->getAdminLevels()->get(2)->getName()); + $this->assertEquals('France', $result->getCountry()->getName()); + $this->assertEquals('FR', $result->getCountry()->getCode()); + $this->assertEquals('10, Rue de Rivoli, Paris, Ile-de-France, France', $result->getDisplayName()); + $this->assertTrue($result->isGoodMatch()); + $this->assertNull($result->getDistance()); + } + + public function testReverseWithRealCoordinates(): void + { + $provider = new OpenGeocoding($this->getHttpClient($this->apiKey()), $this->apiKey()); + $results = $provider->reverseQuery(ReverseQuery::fromCoordinates(55.676, 12.568)->withLimit(2)); + + $this->assertCount(2, $results); + + /** @var OpenGeocodingAddress $result */ + $result = $results->first(); + $this->assertEqualsWithDelta(55.676, $result->getCoordinates()->getLatitude(), 0.001); + $this->assertEqualsWithDelta(12.568, $result->getCoordinates()->getLongitude(), 0.001); + $this->assertEquals('Rådhuspladsen', $result->getStreetName()); + $this->assertEquals('1550', $result->getPostalCode()); + $this->assertEquals('Copenhagen', $result->getLocality()); + $this->assertEquals('Capital Region of Denmark', $result->getAdminLevels()->get(1)->getName()); + $this->assertEquals('DK', $result->getCountry()->getCode()); + $this->assertEquals('address', $result->getLayer()); + $this->assertNotNull($result->getDistance()); + $this->assertLessThan(100, $result->getDistance()); + } +} diff --git a/src/Provider/OpenGeocoding/composer.json b/src/Provider/OpenGeocoding/composer.json new file mode 100644 index 000000000..1d23b90ae --- /dev/null +++ b/src/Provider/OpenGeocoding/composer.json @@ -0,0 +1,46 @@ +{ + "name": "geocoder-php/opengeocoding-provider", + "type": "library", + "description": "Geocoder OpenGeocoding adapter", + "keywords": [ + "geocoder", + "geocoding", + "reverse geocoding", + "openstreetmap", + "opengeocoding" + ], + "homepage": "https://opengeocoding.com", + "license": "MIT", + "authors": [ + { + "name": "Théo Mathieu", + "homepage": "https://github.com/Mokto" + } + ], + "require": { + "php": "^8.0", + "geocoder-php/common-http": "^4.6", + "willdurand/geocoder": "^4.0|^5.0" + }, + "provide": { + "geocoder-php/provider-implementation": "1.0" + }, + "require-dev": { + "geocoder-php/provider-integration-tests": "^1.6.3", + "php-http/message": "^1.0", + "phpunit/phpunit": "^9.6.11" + }, + "autoload": { + "psr-4": { + "Geocoder\\Provider\\OpenGeocoding\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "prefer-stable": true, + "scripts": { + "test": "vendor/bin/phpunit" + } +} diff --git a/src/Provider/OpenGeocoding/phpunit.xml.dist b/src/Provider/OpenGeocoding/phpunit.xml.dist new file mode 100644 index 000000000..f400b474d --- /dev/null +++ b/src/Provider/OpenGeocoding/phpunit.xml.dist @@ -0,0 +1,11 @@ + + + + + + + + ./Tests/ + + +