diff --git a/.github/workflows/provider.yml b/.github/workflows/provider.yml
index c7a515b24..89d78e4e3 100644
--- a/.github/workflows/provider.yml
+++ b/.github/workflows/provider.yml
@@ -34,6 +34,7 @@ jobs:
- HostIp
- IP2Location
# - IP2LocationBinary
+ - IpApi
- IpInfo
- IpInfoDb
- Ipstack
diff --git a/.gitignore b/.gitignore
index c5a5e4581..5168ef01b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,5 @@ composer.phar
phpunit.xml
.phpunit.result.cache
.php-cs-fixer.cache
+.php-cs-fixer.php
.puli/
diff --git a/README.md b/README.md
index 31b250196..8de9be50a 100644
--- a/README.md
+++ b/README.md
@@ -174,6 +174,7 @@ Provider | Package | Features | Stats
[IpInfo](https://github.com/geocoder-php/ip-info-provider) | `geocoder-php/ip-info-provider` | IPv4, IPv6
[Website](https://ipinfo.io/) | [![Latest Stable Version](https://poser.pugx.org/geocoder-php/ip-info-provider/v/stable)](https://packagist.org/packages/geocoder-php/ip-info-provider)
[![Total Downloads](https://poser.pugx.org/geocoder-php/ip-info-provider/downloads)](https://packagist.org/packages/geocoder-php/ip-info-provider)
[IpInfoDB](https://github.com/geocoder-php/ip-info-db-provider) | `geocoder-php/ip-info-db-provider` | IPv4
[Website](http://ipinfodb.com/) | [![Latest Stable Version](https://poser.pugx.org/geocoder-php/ip-info-db-provider/v/stable)](https://packagist.org/packages/geocoder-php/ip-info-db-provider)
[![Total Downloads](https://poser.pugx.org/geocoder-php/ip-info-db-provider/downloads)](https://packagist.org/packages/geocoder-php/ip-info-db-provider)
[ipstack](https://github.com/geocoder-php/ipstack-provider) | `geocoder-php/ipstack-provider` | IPv4, IPv6
[Website](https://ipstack.com/) | [![Latest Stable Version](https://poser.pugx.org/geocoder-php/ipstack-provider/v/stable)](https://packagist.org/packages/geocoder-php/ipstack-provider)
[![Total Downloads](https://poser.pugx.org/geocoder-php/ipstack-provider/downloads)](https://packagist.org/packages/geocoder-php/ipstack-provider)
+[ip-api](https://github.com/geocoder-php/ip-api-provider) | `geocoder-php/ip-api-provider` | IPv4, IPv6
[Website](https://ip-api.com/) | [![Latest Stable Version](https://poser.pugx.org/geocoder-php/ip-api-provider/v/stable)](https://packagist.org/packages/geocoder-php/ip-api-provider)
[![Total Downloads](https://poser.pugx.org/geocoder-php/ip-api-provider/downloads)](https://packagist.org/packages/geocoder-php/ip-api-provider)
[MaxMind](https://github.com/geocoder-php/maxmind-provider) | `geocoder-php/maxmind-provider` | IPv4, IPv6
[Website](https://www.maxmind.com/) | [![Latest Stable Version](https://poser.pugx.org/geocoder-php/maxmind-provider/v/stable)](https://packagist.org/packages/geocoder-php/maxmind-provider)
[![Total Downloads](https://poser.pugx.org/geocoder-php/maxmind-provider/downloads)](https://packagist.org/packages/geocoder-php/maxmind-provider)
[MaxMind Binary](https://github.com/geocoder-php/maxmind-binary-provider) | `geocoder-php/maxmind-binary-provider` | IPv4, IPv6
[Website](https://www.maxmind.com/) | [![Latest Stable Version](https://poser.pugx.org/geocoder-php/maxmind-binary-provider/v/stable)](https://packagist.org/packages/geocoder-php/maxmind-binary-provider)
[![Total Downloads](https://poser.pugx.org/geocoder-php/maxmind-binary-provider/downloads)](https://packagist.org/packages/geocoder-php/maxmind-binary-provider)
diff --git a/src/Provider/IpApi/.gitattributes b/src/Provider/IpApi/.gitattributes
new file mode 100644
index 000000000..d04504afd
--- /dev/null
+++ b/src/Provider/IpApi/.gitattributes
@@ -0,0 +1,4 @@
+.gitattributes export-ignore
+.travis.yml export-ignore
+phpunit.xml.dist export-ignore
+Tests/ export-ignore
diff --git a/src/Provider/IpApi/.github/workflows/provider.yml b/src/Provider/IpApi/.github/workflows/provider.yml
new file mode 100644
index 000000000..d7fbb64c2
--- /dev/null
+++ b/src/Provider/IpApi/.github/workflows/provider.yml
@@ -0,0 +1,33 @@
+name: Provider
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ test:
+ name: PHP ${{ matrix.php-version }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version: ['8.0', '8.1', '8.2']
+ steps:
+ - uses: actions/checkout@v3
+ - name: Use PHP ${{ matrix.php-version }}
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ extensions: curl
+ - name: Validate composer.json and composer.lock
+ run: composer validate --strict
+ - name: Install dependencies
+ run: composer update --prefer-stable --prefer-dist --no-progress
+ - name: Run test suite
+ run: composer run-script test-ci
+ - name: Upload Coverage report
+ run: |
+ wget https://scrutinizer-ci.com/ocular.phar
+ php ocular.phar code-coverage:upload --format=php-clover build/coverage.xml
diff --git a/src/Provider/IpApi/.gitignore b/src/Provider/IpApi/.gitignore
new file mode 100644
index 000000000..76367ee5b
--- /dev/null
+++ b/src/Provider/IpApi/.gitignore
@@ -0,0 +1,4 @@
+vendor/
+composer.lock
+phpunit.xml
+.phpunit.result.cache
diff --git a/src/Provider/IpApi/CHANGELOG.md b/src/Provider/IpApi/CHANGELOG.md
new file mode 100644
index 000000000..2a1b887f7
--- /dev/null
+++ b/src/Provider/IpApi/CHANGELOG.md
@@ -0,0 +1,7 @@
+# Change Log
+
+The change log describes what is "Added", "Removed", "Changed" or "Fixed" between each release.
+
+## 0.1.0
+
+First release of this library.
diff --git a/src/Provider/IpApi/IpApi.php b/src/Provider/IpApi/IpApi.php
new file mode 100644
index 000000000..6a824d6ee
--- /dev/null
+++ b/src/Provider/IpApi/IpApi.php
@@ -0,0 +1,155 @@
+apiKey = $apiKey;
+ parent::__construct($client);
+ }
+
+ #[\Override]
+ public function geocodeQuery(GeocodeQuery $query): Collection
+ {
+ $ip = $query->getText();
+
+ if (!filter_var($ip, FILTER_VALIDATE_IP)) {
+ throw new UnsupportedOperation('The ip-api provider does not support street addresses.');
+ }
+
+ if (in_array($ip, ['127.0.0.1', '::1'])) {
+ return new AddressCollection([$this->getLocationForLocalhost()]);
+ }
+
+ $url = $this->buildUrl($ip, $query->getLocale());
+
+ $body = $this->getUrlContents($url);
+
+ $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
+ if ('fail' === $data['status']) {
+ $this->throwError($data['message']);
+ }
+
+ $location = $this->buildLocation($data);
+
+ return new AddressCollection([$location]);
+ }
+
+ #[\Override]
+ public function reverseQuery(ReverseQuery $query): Collection
+ {
+ throw new UnsupportedOperation('The ip-api provider is not able to do reverse geocoding.');
+ }
+
+ #[\Override]
+ public function getName(): string
+ {
+ return 'ip-api';
+ }
+
+ private function buildUrl(string $ip, string|null $locale): string
+ {
+ $baseUrl = strtr(self::URL, [
+ '{host_prefix}' => $this->apiKey ? 'https://pro.' : 'http://',
+ '{ip}' => $ip,
+ ]);
+
+ $query = http_build_query(array_filter([
+ 'key' => $this->apiKey,
+ 'lang' => $locale,
+ 'fields' => self::FIELDS,
+ ]));
+
+ return $baseUrl.'?'.$query;
+ }
+
+ /**
+ * @param array $data
+ */
+ private function buildLocation(array $data): IpApiLocation
+ {
+ $data = array_map(
+ static fn ($value) => '' === $value ? null : $value,
+ $data,
+ );
+
+ $builder = new AddressBuilder($this->getName());
+ $builder->setCoordinates($data['lat'], $data['lon']);
+ $builder->setLocality($data['city']);
+ $builder->setSubLocality($data['district']);
+ $builder->setPostalCode($data['zip']);
+ $builder->setCountry($data['country']);
+ $builder->setCountryCode($data['countryCode']);
+ $builder->setTimezone($data['timezone']);
+
+ if ($data['regionName']) {
+ $builder->addAdminLevel(1, $data['regionName'], $data['region']);
+ }
+
+ /** @var IpApiLocation $location */
+ $location = $builder->build(IpApiLocation::class);
+
+ return $location
+ ->withCurrency($data['currency'] ?? null)
+ ->withCallingCode($data['callingCode'] ?? null)
+ ->withIsProxy($data['proxy'])
+ ->withIsHosting($data['hosting']);
+ }
+
+ /**
+ * @see https://members.ip-api.com/faq#errors
+ *
+ * @return never
+ */
+ private function throwError(string $message)
+ {
+ if (
+ in_array($message, ['private range', 'reserved range', 'invalid query'], true)
+ || str_contains($message, 'Origin restriction')
+ || str_contains($message, 'IP range restriction')
+ || str_contains($message, 'Calling IP restriction')
+ ) {
+ throw new InvalidArgument($message);
+ }
+
+ if (
+ str_contains($message, 'invalid/expired key')
+ || str_contains($message, 'no API key supplied')
+ ) {
+ throw new InvalidCredentials($message);
+ }
+
+ throw new InvalidServerResponse($message);
+ }
+}
diff --git a/src/Provider/IpApi/LICENSE b/src/Provider/IpApi/LICENSE
new file mode 100644
index 000000000..8aa8246ef
--- /dev/null
+++ b/src/Provider/IpApi/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2011 — William Durand
+
+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/IpApi/Model/IpApiLocation.php b/src/Provider/IpApi/Model/IpApiLocation.php
new file mode 100644
index 000000000..d44bc759f
--- /dev/null
+++ b/src/Provider/IpApi/Model/IpApiLocation.php
@@ -0,0 +1,78 @@
+isProxy;
+ }
+
+ public function withCurrency(string|null $currency): self
+ {
+ $new = clone $this;
+ $new->currency = $currency;
+
+ return $new;
+ }
+
+ public function getCurrency(): string|null
+ {
+ return $this->currency;
+ }
+
+ public function withCallingCode(string|null $callingCode): self
+ {
+ $new = clone $this;
+ $new->callingCode = $callingCode;
+
+ return $new;
+ }
+
+ public function getCallingCode(): string|null
+ {
+ return $this->callingCode;
+ }
+
+ public function withIsProxy(bool $isProxy): self
+ {
+ $new = clone $this;
+ $new->isProxy = $isProxy;
+
+ return $new;
+ }
+
+ public function isHosting(): bool
+ {
+ return $this->isHosting;
+ }
+
+ public function withIsHosting(bool $isHosting): self
+ {
+ $new = clone $this;
+ $new->isHosting = $isHosting;
+
+ return $new;
+ }
+}
diff --git a/src/Provider/IpApi/Readme.md b/src/Provider/IpApi/Readme.md
new file mode 100644
index 000000000..bce0886b0
--- /dev/null
+++ b/src/Provider/IpApi/Readme.md
@@ -0,0 +1,26 @@
+# ip-api Geocoder provider
+[![Build Status](https://travis-ci.org/geocoder-php/ip-api-provider.svg?branch=master)](http://travis-ci.org/geocoder-php/ip-api-provider)
+[![Latest Stable Version](https://poser.pugx.org/geocoder-php/ip-api-provider/v/stable)](https://packagist.org/packages/geocoder-php/ip-api-provider)
+[![Total Downloads](https://poser.pugx.org/geocoder-php/ip-api-provider/downloads)](https://packagist.org/packages/geocoder-php/ip-api-provider)
+[![Monthly Downloads](https://poser.pugx.org/geocoder-php/ip-api-provider/d/monthly.png)](https://packagist.org/packages/geocoder-php/ip-api-provider)
+[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/geocoder-php/ip-api-provider.svg?style=flat-square)](https://scrutinizer-ci.com/g/geocoder-php/ip-api-provider)
+[![Quality Score](https://img.shields.io/scrutinizer/g/geocoder-php/ip-api-provider.svg?style=flat-square)](https://scrutinizer-ci.com/g/geocoder-php/ip-api-provider)
+[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE)
+
+This is the IpApi 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.
+
+### Install
+
+```bash
+composer require geocoder-php/ip-api-provider
+```
+
+### Note
+
+The default language-locale is `en`, you can choose between `de`, `es`, `pt-BR`, `fr`, `ja`, `zh-CN`, `ru`.
+
+### Contribute
+
+Contributions are very welcome! Send a pull request to the [main repository](https://github.com/geocoder-php/Geocoder) or
+report any issues you find on the [issue tracker](https://github.com/geocoder-php/Geocoder/issues).
diff --git a/src/Provider/IpApi/Tests/.cached_responses/ip-api.com_21bd4eeca06d4604ac8c5f29082b8be15bd0b50e b/src/Provider/IpApi/Tests/.cached_responses/ip-api.com_21bd4eeca06d4604ac8c5f29082b8be15bd0b50e
new file mode 100644
index 000000000..1916aec3f
--- /dev/null
+++ b/src/Provider/IpApi/Tests/.cached_responses/ip-api.com_21bd4eeca06d4604ac8c5f29082b8be15bd0b50e
@@ -0,0 +1 @@
+s:247:"{"status":"success","country":"United States","countryCode":"US","region":"OK","regionName":"Oklahoma","city":"Tulsa","district":"","zip":"","lat":36.15398,"lon":-95.99277,"timezone":"America/Chicago","currency":"USD","proxy":false,"hosting":true}";
\ No newline at end of file
diff --git a/src/Provider/IpApi/Tests/.cached_responses/ip-api.com_9e899652ed58a5682e5ce3b1adea396c116b5308 b/src/Provider/IpApi/Tests/.cached_responses/ip-api.com_9e899652ed58a5682e5ce3b1adea396c116b5308
new file mode 100644
index 000000000..1916aec3f
--- /dev/null
+++ b/src/Provider/IpApi/Tests/.cached_responses/ip-api.com_9e899652ed58a5682e5ce3b1adea396c116b5308
@@ -0,0 +1 @@
+s:247:"{"status":"success","country":"United States","countryCode":"US","region":"OK","regionName":"Oklahoma","city":"Tulsa","district":"","zip":"","lat":36.15398,"lon":-95.99277,"timezone":"America/Chicago","currency":"USD","proxy":false,"hosting":true}";
\ No newline at end of file
diff --git a/src/Provider/IpApi/Tests/.cached_responses/pro.ip-api.com_6896e1be33bbb84edff7642659256441a17b8201 b/src/Provider/IpApi/Tests/.cached_responses/pro.ip-api.com_6896e1be33bbb84edff7642659256441a17b8201
new file mode 100644
index 000000000..fb99ce902
--- /dev/null
+++ b/src/Provider/IpApi/Tests/.cached_responses/pro.ip-api.com_6896e1be33bbb84edff7642659256441a17b8201
@@ -0,0 +1 @@
+s:265:"{"callingCode":"1","city":"Tulsa","country":"United States","countryCode":"US","currency":"USD","district":"","hosting":true,"lat":36.15398,"lon":-95.99277,"proxy":false,"region":"OK","regionName":"Oklahoma","status":"success","timezone":"America/Chicago","zip":""}";
\ No newline at end of file
diff --git a/src/Provider/IpApi/Tests/.cached_responses/pro.ip-api.com_9f1bf9713a67e48308dff066f06e39a06fb03e28 b/src/Provider/IpApi/Tests/.cached_responses/pro.ip-api.com_9f1bf9713a67e48308dff066f06e39a06fb03e28
new file mode 100644
index 000000000..1ab4c370b
--- /dev/null
+++ b/src/Provider/IpApi/Tests/.cached_responses/pro.ip-api.com_9f1bf9713a67e48308dff066f06e39a06fb03e28
@@ -0,0 +1 @@
+s:275:"{"callingCode":"46","city":"Karlskrona","country":"Sweden","countryCode":"SE","currency":"SEK","district":"","hosting":false,"lat":56.1625,"lon":15.5801,"proxy":false,"region":"K","regionName":"Blekinge County","status":"success","timezone":"Europe/Stockholm","zip":"371 37"}";
\ No newline at end of file
diff --git a/src/Provider/IpApi/Tests/.cached_responses/pro.ip-api.com_a70f7aa075dda93765828a4307d48f156cfceaf8 b/src/Provider/IpApi/Tests/.cached_responses/pro.ip-api.com_a70f7aa075dda93765828a4307d48f156cfceaf8
new file mode 100644
index 000000000..ea3ab73cc
--- /dev/null
+++ b/src/Provider/IpApi/Tests/.cached_responses/pro.ip-api.com_a70f7aa075dda93765828a4307d48f156cfceaf8
@@ -0,0 +1 @@
+s:92:"{"status":"fail","message":"invalid/expired key, renew at https://members.ip-api.com/order"}";
\ No newline at end of file
diff --git a/src/Provider/IpApi/Tests/.cached_responses/pro.ip-api.com_dc2eee9c7e59dc881af84a00451b9fbff8e73ed3 b/src/Provider/IpApi/Tests/.cached_responses/pro.ip-api.com_dc2eee9c7e59dc881af84a00451b9fbff8e73ed3
new file mode 100644
index 000000000..fb99ce902
--- /dev/null
+++ b/src/Provider/IpApi/Tests/.cached_responses/pro.ip-api.com_dc2eee9c7e59dc881af84a00451b9fbff8e73ed3
@@ -0,0 +1 @@
+s:265:"{"callingCode":"1","city":"Tulsa","country":"United States","countryCode":"US","currency":"USD","district":"","hosting":true,"lat":36.15398,"lon":-95.99277,"proxy":false,"region":"OK","regionName":"Oklahoma","status":"success","timezone":"America/Chicago","zip":""}";
\ No newline at end of file
diff --git a/src/Provider/IpApi/Tests/IntegrationTest.php b/src/Provider/IpApi/Tests/IntegrationTest.php
new file mode 100644
index 000000000..ba45b63f1
--- /dev/null
+++ b/src/Provider/IpApi/Tests/IntegrationTest.php
@@ -0,0 +1,45 @@
+getApiKey());
+ }
+
+ protected function getCacheDir(): string
+ {
+ return __DIR__.'/.cached_responses';
+ }
+
+ protected function getApiKey(): string
+ {
+ if (!isset($_SERVER['IP_API_KEY'])) {
+ $this->markTestSkipped('No ip-api API key');
+ }
+
+ return $_SERVER['IP_API_KEY'];
+ }
+}
diff --git a/src/Provider/IpApi/Tests/IpApiTest.php b/src/Provider/IpApi/Tests/IpApiTest.php
new file mode 100644
index 000000000..5e8f0edd2
--- /dev/null
+++ b/src/Provider/IpApi/Tests/IpApiTest.php
@@ -0,0 +1,174 @@
+getMockedHttpClient());
+ $this->assertEquals('ip-api', $provider->getName());
+ }
+
+ public function testInvalidApiKey(): void
+ {
+ $provider = new IpApi($this->getHttpClient('InVaLiDkEy'), 'InVaLiDkEy');
+
+ $this->expectException(InvalidCredentials::class);
+ $provider->geocodeQuery(GeocodeQuery::create('64.233.160.0'));
+ }
+
+ public function testGeocodeWithAddress(): void
+ {
+ $this->expectException(UnsupportedOperation::class);
+ $this->expectExceptionMessage('The ip-api provider does not support street addresses.');
+
+ $provider = new IpApi($this->getMockedHttpClient());
+ $provider->geocodeQuery(GeocodeQuery::create('this is not an IP address'));
+ }
+
+ public function testGeocodeWithLocalhostIPv4(): void
+ {
+ $provider = new IpApi($this->getMockedHttpClient());
+ $results = $provider->geocodeQuery(GeocodeQuery::create('127.0.0.1'));
+
+ $this->assertInstanceOf(AddressCollection::class, $results);
+ $this->assertCount(1, $results);
+
+ /** @var Location $result */
+ $result = $results->first();
+ $this->assertInstanceOf(Address::class, $result);
+ $this->assertNotInstanceOf(IpApiLocation::class, $result);
+ $this->assertEquals('localhost', $result->getLocality());
+ $this->assertEquals('localhost', $result->getCountry()->getName());
+ }
+
+ public function testGeocodeWithLocalhostIPv6(): void
+ {
+ $provider = new IpApi($this->getMockedHttpClient());
+ $results = $provider->geocodeQuery(GeocodeQuery::create('::1'));
+
+ $this->assertInstanceOf(AddressCollection::class, $results);
+ $this->assertCount(1, $results);
+
+ /** @var Location $result */
+ $result = $results->first();
+ $this->assertInstanceOf(Address::class, $result);
+ $this->assertNotInstanceOf(IpApiLocation::class, $result);
+ $this->assertEquals('localhost', $result->getLocality());
+ $this->assertEquals('localhost', $result->getCountry()->getName());
+ }
+
+ /**
+ * @dataProvider apiKeyProvider
+ */
+ public function testGeocodeWithRealIPv4(string|null $apiKey): void
+ {
+ $provider = new IpApi($this->getHttpClient($apiKey), $apiKey);
+ $results = $provider->geocodeQuery(GeocodeQuery::create('74.125.45.100'));
+
+ $this->assertInstanceOf(AddressCollection::class, $results);
+ $this->assertCount(1, $results);
+
+ /** @var Location $result */
+ $result = $results->first();
+ $this->assertInstanceOf(Address::class, $result);
+ $this->assertInstanceOf(IpApiLocation::class, $result);
+ $this->assertEqualsWithDelta(36.154, $result->getCoordinates()->getLatitude(), 0.001);
+ $this->assertEqualsWithDelta(-95.9928, $result->getCoordinates()->getLongitude(), 0.001);
+ $this->assertEquals(null, $result->getPostalCode());
+ $this->assertEquals('Tulsa', $result->getLocality());
+ $this->assertCount(1, $result->getAdminLevels());
+ $this->assertEquals('Oklahoma', $result->getAdminLevels()->get(1)->getName());
+ $this->assertEquals('United States', $result->getCountry()->getName());
+ $this->assertEquals('US', $result->getCountry()->getCode());
+ $this->assertEquals('USD', $result->getCurrency());
+ $this->assertFalse($result->isProxy());
+ $this->assertTrue($result->isHosting());
+
+ // Calling code is available only for authenticated calls
+ if (null !== $apiKey) {
+ $this->assertEquals('1', $result->getCallingCode());
+ }
+ }
+
+ /**
+ * @dataProvider apiKeyProvider
+ */
+ public function testGeocodeWithRealIPv6(string|null $apiKey): void
+ {
+ $provider = new IpApi($this->getHttpClient($apiKey), $apiKey);
+ $results = $provider->geocodeQuery(GeocodeQuery::create('::ffff:74.125.45.100'));
+
+ $this->assertInstanceOf(AddressCollection::class, $results);
+ $this->assertCount(1, $results);
+
+ /** @var Location $result */
+ $result = $results->first();
+ $this->assertInstanceOf(Address::class, $result);
+ $this->assertInstanceOf(IpApiLocation::class, $result);
+ $this->assertEqualsWithDelta(36.154, $result->getCoordinates()->getLatitude(), 0.001);
+ $this->assertEqualsWithDelta(-95.9928, $result->getCoordinates()->getLongitude(), 0.001);
+ $this->assertEquals(null, $result->getPostalCode());
+ $this->assertEquals('Tulsa', $result->getLocality());
+ $this->assertCount(1, $result->getAdminLevels());
+ $this->assertEquals('Oklahoma', $result->getAdminLevels()->get(1)->getName());
+ $this->assertEquals('United States', $result->getCountry()->getName());
+ $this->assertEquals('US', $result->getCountry()->getCode());
+ $this->assertEquals('USD', $result->getCurrency());
+ $this->assertFalse($result->isProxy());
+ $this->assertTrue($result->isHosting());
+
+ // Calling code is available only for authenticated calls
+ if (null !== $apiKey) {
+ $this->assertEquals('1', $result->getCallingCode());
+ }
+ }
+
+ public function testReverse(): void
+ {
+ $this->expectException(UnsupportedOperation::class);
+ $this->expectExceptionMessage('The ip-api provider is not able to do reverse geocoding.');
+
+ $provider = new IpApi($this->getMockedHttpClient());
+ $provider->reverseQuery(ReverseQuery::fromCoordinates(1, 2));
+ }
+
+ /**
+ * @return iterable>
+ */
+ public static function apiKeyProvider(): iterable
+ {
+ yield 'no api key' => [null];
+
+ if (isset($_SERVER['IP_API_KEY'])) {
+ yield 'with api key' => [$_SERVER['IP_API_KEY']];
+ }
+ }
+}
diff --git a/src/Provider/IpApi/composer.json b/src/Provider/IpApi/composer.json
new file mode 100644
index 000000000..82daf86dc
--- /dev/null
+++ b/src/Provider/IpApi/composer.json
@@ -0,0 +1,46 @@
+{
+ "name": "geocoder-php/ip-api-provider",
+ "type": "library",
+ "description": "Geocoder ip-api adapter",
+ "keywords": [],
+ "homepage": "http://geocoder-php.org/Geocoder/",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "William Durand",
+ "email": "william.durand1@gmail.com"
+ }
+ ],
+ "require": {
+ "php": "^8.0",
+ "geocoder-php/common-http": "^4.0",
+ "willdurand/geocoder": "^4.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.5"
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Geocoder\\Provider\\IpApi\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "scripts": {
+ "test": "vendor/bin/phpunit",
+ "test-ci": "vendor/bin/phpunit --coverage-text --coverage-clover=build/coverage.xml"
+ }
+}
diff --git a/src/Provider/IpApi/phpunit.xml.dist b/src/Provider/IpApi/phpunit.xml.dist
new file mode 100644
index 000000000..69ee95036
--- /dev/null
+++ b/src/Provider/IpApi/phpunit.xml.dist
@@ -0,0 +1,21 @@
+
+
+
+
+ ./
+
+
+ ./Tests
+ ./vendor
+
+
+
+
+
+
+
+
+ ./Tests/
+
+
+