From 9c0aa7b97610b08ba2d8ea924fa36f8150b8a85f Mon Sep 17 00:00:00 2001 From: Benjamin Date: Mon, 20 Apr 2026 08:49:12 +0200 Subject: [PATCH] Add ip2geo IP geolocation provider Add a new provider for the ip2geo.dev IP geolocation API. Supports geocoding IP addresses (IPv4 and IPv6) with rich response data including city, country, continent, ASN, currency, flag, timezone, and registered country information. - Provider class with PSR-18 HTTP client support - Custom Ip2GeoAddress model with 20+ extra fields - Unit tests with mocked HTTP responses - Integration test scaffold - CI workflow for PHP 8.0-8.5 API docs: https://ip2geo.dev/docs --- .../Ip2Geo/.github/workflows/provider.yml | 33 ++ src/Provider/Ip2Geo/.gitignore | 1 + src/Provider/Ip2Geo/CHANGELOG.md | 5 + src/Provider/Ip2Geo/Ip2Geo.php | 162 +++++++ src/Provider/Ip2Geo/Ip2GeoAddress.php | 400 ++++++++++++++++++ src/Provider/Ip2Geo/LICENSE | 21 + src/Provider/Ip2Geo/README.md | 113 +++++ .../Ip2Geo/Tests/.cached_responses/.gitkeep | 0 src/Provider/Ip2Geo/Tests/IntegrationTest.php | 47 ++ src/Provider/Ip2Geo/Tests/Ip2GeoTest.php | 258 +++++++++++ src/Provider/Ip2Geo/composer.json | 46 ++ src/Provider/Ip2Geo/phpunit.xml.dist | 14 + 12 files changed, 1100 insertions(+) create mode 100644 src/Provider/Ip2Geo/.github/workflows/provider.yml create mode 100644 src/Provider/Ip2Geo/.gitignore create mode 100644 src/Provider/Ip2Geo/CHANGELOG.md create mode 100644 src/Provider/Ip2Geo/Ip2Geo.php create mode 100644 src/Provider/Ip2Geo/Ip2GeoAddress.php create mode 100644 src/Provider/Ip2Geo/LICENSE create mode 100644 src/Provider/Ip2Geo/README.md create mode 100644 src/Provider/Ip2Geo/Tests/.cached_responses/.gitkeep create mode 100644 src/Provider/Ip2Geo/Tests/IntegrationTest.php create mode 100644 src/Provider/Ip2Geo/Tests/Ip2GeoTest.php create mode 100644 src/Provider/Ip2Geo/composer.json create mode 100644 src/Provider/Ip2Geo/phpunit.xml.dist diff --git a/src/Provider/Ip2Geo/.github/workflows/provider.yml b/src/Provider/Ip2Geo/.github/workflows/provider.yml new file mode 100644 index 000000000..7c6069ea1 --- /dev/null +++ b/src/Provider/Ip2Geo/.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', '8.3', '8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + - 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/Ip2Geo/.gitignore b/src/Provider/Ip2Geo/.gitignore new file mode 100644 index 000000000..48b8bf907 --- /dev/null +++ b/src/Provider/Ip2Geo/.gitignore @@ -0,0 +1 @@ +vendor/ diff --git a/src/Provider/Ip2Geo/CHANGELOG.md b/src/Provider/Ip2Geo/CHANGELOG.md new file mode 100644 index 000000000..70c2a8910 --- /dev/null +++ b/src/Provider/Ip2Geo/CHANGELOG.md @@ -0,0 +1,5 @@ +# Change Log + +## 1.0.0 (2026-04-19) + +- Initial release diff --git a/src/Provider/Ip2Geo/Ip2Geo.php b/src/Provider/Ip2Geo/Ip2Geo.php new file mode 100644 index 000000000..13d3b1eba --- /dev/null +++ b/src/Provider/Ip2Geo/Ip2Geo.php @@ -0,0 +1,162 @@ +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 ip2geo provider does not support street addresses, only IP addresses.'); + } + + if (in_array($address, ['127.0.0.1', '::1', '0.0.0.0'], true)) { + return new AddressCollection([Address::createFromArray([])]); + } + + $url = sprintf('%s/convert?ip=%s', self::BASE_URL, $address); + + $request = $this->getRequest($url); + $request = $request->withHeader('X-Api-Key', $this->apiKey); + + $content = $this->getParsedResponse($request); + + $json = json_decode($content, true); + + if (!is_array($json) || !isset($json['success'])) { + throw new InvalidServerResponse(sprintf('Could not decode response from ip2geo for IP "%s".', $address)); + } + + if (true !== $json['success'] || !isset($json['data'])) { + return new AddressCollection([]); + } + + $data = $json['data']; + + return $this->buildResult($data); + } + + public function reverseQuery(ReverseQuery $query): Collection + { + throw new UnsupportedOperation('The ip2geo provider is not able to do reverse geocoding.'); + } + + public function getName(): string + { + return 'ip2geo'; + } + + private function buildResult(array $data): AddressCollection + { + $builder = new AddressBuilder($this->getName()); + + $continent = $data['continent'] ?? []; + $country = $continent['country'] ?? []; + $subdivision = $country['subdivision'] ?? []; + $city = $country['city'] ?? []; + $timezone = $city['timezone'] ?? []; + $flag = $country['flag'] ?? []; + $currency = $country['currency'] ?? []; + $asn = $data['asn'] ?? []; + $registeredCountry = $data['registered_country'] ?? []; + + // Standard geocoder fields + if (isset($city['latitude'], $city['longitude'])) { + $builder->setCoordinates((float) $city['latitude'], (float) $city['longitude']); + } + + if (isset($city['name'])) { + $builder->setLocality($city['name']); + } + + if (isset($city['postal_code'])) { + $builder->setPostalCode($city['postal_code']); + } + + if (isset($subdivision['name'], $subdivision['code'])) { + $builder->addAdminLevel(1, $subdivision['name'], $subdivision['code']); + } + + if (isset($country['name'], $country['code'])) { + $builder->setCountry($country['name']); + $builder->setCountryCode($country['code']); + } + + if (isset($timezone['name'])) { + $builder->setTimezone($timezone['name']); + } + + // Build custom address with extra ip2geo fields + /** @var Ip2GeoAddress $address */ + $address = $builder->build(Ip2GeoAddress::class); + + $address = $address + ->withIp($data['ip'] ?? null) + ->withIpType($data['type'] ?? null) + ->withIsEu($data['is_eu'] ?? null) + ->withContinentName($continent['name'] ?? null) + ->withContinentCode($continent['code'] ?? null) + ->withPhoneCode($country['phone_code'] ?? null) + ->withCapital($country['capital'] ?? null) + ->withTld($country['tld'] ?? null) + ->withFlagEmoji($flag['emoji'] ?? null) + ->withFlagImg($flag['img'] ?? null) + ->withCurrencyName($currency['name'] ?? null) + ->withCurrencyCode($currency['code'] ?? null) + ->withCurrencySymbol($currency['symbol'] ?? null) + ->withGeonameId(isset($city['geoname_id']) ? (int) $city['geoname_id'] : null) + ->withContinentGeonameId(isset($continent['geoname_id']) ? (int) $continent['geoname_id'] : null) + ->withCountryGeonameId(isset($country['geoname_id']) ? (int) $country['geoname_id'] : null) + ->withMetroCode(isset($city['metro_code']) ? (int) $city['metro_code'] : null) + ->withFlagEmojiUnicode($flag['emoji_unicode'] ?? null) + ->withAccuracyRadius(isset($city['accuracy_radius']) ? (int) $city['accuracy_radius'] : null) + ->withTimeNow($timezone['time_now'] ?? null) + ->withAsnNumber(isset($asn['number']) ? (int) $asn['number'] : null) + ->withAsnName($asn['name'] ?? null) + ->withRegisteredCountryName($registeredCountry['name'] ?? null) + ->withRegisteredCountryCode($registeredCountry['code'] ?? null) + ->withRegisteredCountryGeonameId(isset($registeredCountry['geoname_id']) ? (int) $registeredCountry['geoname_id'] : null); + + return new AddressCollection([$address]); + } +} diff --git a/src/Provider/Ip2Geo/Ip2GeoAddress.php b/src/Provider/Ip2Geo/Ip2GeoAddress.php new file mode 100644 index 000000000..80cc19ef7 --- /dev/null +++ b/src/Provider/Ip2Geo/Ip2GeoAddress.php @@ -0,0 +1,400 @@ +ip; + } + + public function withIp(?string $ip): self + { + $new = clone $this; + $new->ip = $ip; + + return $new; + } + + public function getIpType(): ?string + { + return $this->ipType; + } + + public function withIpType(?string $ipType): self + { + $new = clone $this; + $new->ipType = $ipType; + + return $new; + } + + public function isEu(): ?bool + { + return $this->isEu; + } + + public function withIsEu(?bool $isEu): self + { + $new = clone $this; + $new->isEu = $isEu; + + return $new; + } + + public function getContinentName(): ?string + { + return $this->continentName; + } + + public function withContinentName(?string $continentName): self + { + $new = clone $this; + $new->continentName = $continentName; + + return $new; + } + + public function getContinentCode(): ?string + { + return $this->continentCode; + } + + public function withContinentCode(?string $continentCode): self + { + $new = clone $this; + $new->continentCode = $continentCode; + + return $new; + } + + public function getPhoneCode(): ?string + { + return $this->phoneCode; + } + + public function withPhoneCode(?string $phoneCode): self + { + $new = clone $this; + $new->phoneCode = $phoneCode; + + return $new; + } + + public function getCapital(): ?string + { + return $this->capital; + } + + public function withCapital(?string $capital): self + { + $new = clone $this; + $new->capital = $capital; + + return $new; + } + + public function getTld(): ?string + { + return $this->tld; + } + + public function withTld(?string $tld): self + { + $new = clone $this; + $new->tld = $tld; + + return $new; + } + + public function getFlagEmoji(): ?string + { + return $this->flagEmoji; + } + + public function withFlagEmoji(?string $flagEmoji): self + { + $new = clone $this; + $new->flagEmoji = $flagEmoji; + + return $new; + } + + public function getFlagImg(): ?string + { + return $this->flagImg; + } + + public function withFlagImg(?string $flagImg): self + { + $new = clone $this; + $new->flagImg = $flagImg; + + return $new; + } + + public function getCurrencyName(): ?string + { + return $this->currencyName; + } + + public function withCurrencyName(?string $currencyName): self + { + $new = clone $this; + $new->currencyName = $currencyName; + + return $new; + } + + public function getCurrencyCode(): ?string + { + return $this->currencyCode; + } + + public function withCurrencyCode(?string $currencyCode): self + { + $new = clone $this; + $new->currencyCode = $currencyCode; + + return $new; + } + + public function getCurrencySymbol(): ?string + { + return $this->currencySymbol; + } + + public function withCurrencySymbol(?string $currencySymbol): self + { + $new = clone $this; + $new->currencySymbol = $currencySymbol; + + return $new; + } + + public function getGeonameId(): ?int + { + return $this->geonameId; + } + + public function withGeonameId(?int $geonameId): self + { + $new = clone $this; + $new->geonameId = $geonameId; + + return $new; + } + + public function getContinentGeonameId(): ?int + { + return $this->continentGeonameId; + } + + public function withContinentGeonameId(?int $continentGeonameId): self + { + $new = clone $this; + $new->continentGeonameId = $continentGeonameId; + + return $new; + } + + public function getCountryGeonameId(): ?int + { + return $this->countryGeonameId; + } + + public function withCountryGeonameId(?int $countryGeonameId): self + { + $new = clone $this; + $new->countryGeonameId = $countryGeonameId; + + return $new; + } + + public function getMetroCode(): ?int + { + return $this->metroCode; + } + + public function withMetroCode(?int $metroCode): self + { + $new = clone $this; + $new->metroCode = $metroCode; + + return $new; + } + + public function getFlagEmojiUnicode(): ?string + { + return $this->flagEmojiUnicode; + } + + public function withFlagEmojiUnicode(?string $flagEmojiUnicode): self + { + $new = clone $this; + $new->flagEmojiUnicode = $flagEmojiUnicode; + + return $new; + } + + public function getRegisteredCountryGeonameId(): ?int + { + return $this->registeredCountryGeonameId; + } + + public function withRegisteredCountryGeonameId(?int $registeredCountryGeonameId): self + { + $new = clone $this; + $new->registeredCountryGeonameId = $registeredCountryGeonameId; + + return $new; + } + + public function getAccuracyRadius(): ?int + { + return $this->accuracyRadius; + } + + public function withAccuracyRadius(?int $accuracyRadius): self + { + $new = clone $this; + $new->accuracyRadius = $accuracyRadius; + + return $new; + } + + public function getTimeNow(): ?string + { + return $this->timeNow; + } + + public function withTimeNow(?string $timeNow): self + { + $new = clone $this; + $new->timeNow = $timeNow; + + return $new; + } + + public function getAsnNumber(): ?int + { + return $this->asnNumber; + } + + public function withAsnNumber(?int $asnNumber): self + { + $new = clone $this; + $new->asnNumber = $asnNumber; + + return $new; + } + + public function getAsnName(): ?string + { + return $this->asnName; + } + + public function withAsnName(?string $asnName): self + { + $new = clone $this; + $new->asnName = $asnName; + + return $new; + } + + public function getRegisteredCountryName(): ?string + { + return $this->registeredCountryName; + } + + public function withRegisteredCountryName(?string $registeredCountryName): self + { + $new = clone $this; + $new->registeredCountryName = $registeredCountryName; + + return $new; + } + + public function getRegisteredCountryCode(): ?string + { + return $this->registeredCountryCode; + } + + public function withRegisteredCountryCode(?string $registeredCountryCode): self + { + $new = clone $this; + $new->registeredCountryCode = $registeredCountryCode; + + return $new; + } +} diff --git a/src/Provider/Ip2Geo/LICENSE b/src/Provider/Ip2Geo/LICENSE new file mode 100644 index 000000000..8aa8246ef --- /dev/null +++ b/src/Provider/Ip2Geo/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/Ip2Geo/README.md b/src/Provider/Ip2Geo/README.md new file mode 100644 index 000000000..d5441aafe --- /dev/null +++ b/src/Provider/Ip2Geo/README.md @@ -0,0 +1,113 @@ +# ip2geo Provider for Geocoder PHP + +[![Latest Stable Version](https://poser.pugx.org/geocoder-php/ip2geo-provider/v/stable)](https://packagist.org/packages/geocoder-php/ip2geo-provider) +[![License](https://poser.pugx.org/geocoder-php/ip2geo-provider/license)](https://packagist.org/packages/geocoder-php/ip2geo-provider) + +This is the [ip2geo](https://ip2geo.dev) provider for the [Geocoder PHP](https://github.com/geocoder-php/Geocoder) library. + +## Installation + +```bash +composer require geocoder-php/ip2geo-provider +``` + +## Usage + +An API key is required. You can obtain one at [ip2geo.dev](https://ip2geo.dev). + +```php +use Geocoder\Provider\Ip2Geo\Ip2Geo; +use Geocoder\Provider\Ip2Geo\Ip2GeoAddress; +use Geocoder\Query\GeocodeQuery; +use Http\Discovery\Psr18ClientDiscovery; + +$httpClient = Psr18ClientDiscovery::find(); +$provider = new Ip2Geo($httpClient, 'your-api-key'); + +$results = $provider->geocodeQuery(GeocodeQuery::create('8.8.8.8')); + +/** @var Ip2GeoAddress $address */ +$address = $results->first(); +``` + +### Standard Geocoder Fields + +These fields are available on all Geocoder Address objects: + +```php +echo $address->getLocality(); // "Mountain View" +echo $address->getCountry(); // "United States" +echo $address->getCountryCode(); // "US" +echo $address->getTimezone(); // "America/Los_Angeles" +echo $address->getPostalCode(); // "94035" + +$coordinates = $address->getCoordinates(); +echo $coordinates->getLatitude(); // 37.386 +echo $coordinates->getLongitude(); // -122.0838 + +$adminLevels = $address->getAdminLevels(); +echo $adminLevels->get(1)->getName(); // "California" +echo $adminLevels->get(1)->getCode(); // "CA" +``` + +### Extra ip2geo Fields + +The returned `Ip2GeoAddress` object extends the standard `Address` with additional +getters for all fields provided by the ip2geo API: + +```php +// IP info +echo $address->getIp(); // "8.8.8.8" +echo $address->getIpType(); // "IPv4" +echo $address->isEu(); // false + +// Continent +echo $address->getContinentName(); // "North America" +echo $address->getContinentCode(); // "NA" + +// Country extras +echo $address->getPhoneCode(); // "+1" +echo $address->getCapital(); // "Washington D.C." +echo $address->getTld(); // ".us" + +// Flag +echo $address->getFlagEmoji(); // (flag emoji) +echo $address->getFlagImg(); // "https://flagcdn.com/us.svg" + +// Currency +echo $address->getCurrencyName(); // "United States Dollar" +echo $address->getCurrencyCode(); // "USD" +echo $address->getCurrencySymbol(); // "$" + +// City extras +echo $address->getGeonameId(); // 5375480 +echo $address->getAccuracyRadius(); // 1000 +echo $address->getTimeNow(); // "2026-04-05T10:30:00-07:00" + +// ASN +echo $address->getAsnNumber(); // 15169 +echo $address->getAsnName(); // "Google LLC" + +// Registered country +echo $address->getRegisteredCountryName(); // "United States" +echo $address->getRegisteredCountryCode(); // "US" +``` + +## Supported Operations + +| Operation | Supported | +|------------------|-----------| +| Geocode (IP) | Yes | +| Geocode (Street) | No | +| Reverse | No | + +## Running Tests + +```bash +composer install +vendor/bin/phpunit +``` + +## License + +This package is licensed under the [MIT License](LICENSE). diff --git a/src/Provider/Ip2Geo/Tests/.cached_responses/.gitkeep b/src/Provider/Ip2Geo/Tests/.cached_responses/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/Provider/Ip2Geo/Tests/IntegrationTest.php b/src/Provider/Ip2Geo/Tests/IntegrationTest.php new file mode 100644 index 000000000..f665f50cc --- /dev/null +++ b/src/Provider/Ip2Geo/Tests/IntegrationTest.php @@ -0,0 +1,47 @@ +getApiKey()); + } + + protected function getCacheDir(): string + { + return __DIR__.'/.cached_responses'; + } + + protected function getApiKey(): string + { + if (!isset($_SERVER['IP2GEO_API_KEY'])) { + $this->markTestSkipped('No ip2geo API key'); + } + + return $_SERVER['IP2GEO_API_KEY']; + } +} diff --git a/src/Provider/Ip2Geo/Tests/Ip2GeoTest.php b/src/Provider/Ip2Geo/Tests/Ip2GeoTest.php new file mode 100644 index 000000000..391cd7ec3 --- /dev/null +++ b/src/Provider/Ip2Geo/Tests/Ip2GeoTest.php @@ -0,0 +1,258 @@ +getMockedHttpClient(), 'api-key'); + + $this->assertSame('ip2geo', $provider->getName()); + } + + public function testEmptyApiKeyThrows(): void + { + $this->expectException(InvalidCredentials::class); + $this->expectExceptionMessage('An API key is required.'); + + new Ip2Geo($this->getMockedHttpClient(), ''); + } + + public function testGeocodeWithStreetAddress(): void + { + $this->expectException(UnsupportedOperation::class); + $this->expectExceptionMessage('The ip2geo provider does not support street addresses, only IP addresses.'); + + $provider = new Ip2Geo($this->getMockedHttpClient(), 'api-key'); + $provider->geocodeQuery(GeocodeQuery::create('123 Main Street')); + } + + public function testReverseQuery(): void + { + $this->expectException(UnsupportedOperation::class); + $this->expectExceptionMessage('The ip2geo provider is not able to do reverse geocoding.'); + + $provider = new Ip2Geo($this->getMockedHttpClient(), 'api-key'); + $provider->reverseQuery(ReverseQuery::fromCoordinates(37.386, -122.0838)); + } + + public function testGeocodeWithLocalhostIpv4(): void + { + $provider = new Ip2Geo($this->getMockedHttpClient(), 'api-key'); + $results = $provider->geocodeQuery(GeocodeQuery::create('127.0.0.1')); + + $this->assertInstanceOf('Geocoder\Model\AddressCollection', $results); + $this->assertCount(1, $results); + + $result = $results->first(); + $this->assertNull($result->getLocality()); + $this->assertNull($result->getCoordinates()); + } + + public function testGeocodeWithLocalhostIpv6(): void + { + $provider = new Ip2Geo($this->getMockedHttpClient(), 'api-key'); + $results = $provider->geocodeQuery(GeocodeQuery::create('::1')); + + $this->assertInstanceOf('Geocoder\Model\AddressCollection', $results); + $this->assertCount(1, $results); + } + + public function testGeocodeWithInvalidResponse(): void + { + $this->expectException(InvalidServerResponse::class); + + $provider = new Ip2Geo($this->getMockedHttpClient('not-json'), 'api-key'); + $provider->geocodeQuery(GeocodeQuery::create('8.8.8.8')); + } + + public function testGeocodeWithUnsuccessfulResponse(): void + { + $body = json_encode([ + 'success' => false, + 'message' => 'Invalid API key', + ]); + + $provider = new Ip2Geo($this->getMockedHttpClient($body), 'invalid-key'); + $results = $provider->geocodeQuery(GeocodeQuery::create('8.8.8.8')); + + $this->assertEmpty($results); + } + + public function testGeocodeWithRealIp(): void + { + $body = json_encode($this->getSuccessResponse()); + + $provider = new Ip2Geo($this->getMockedHttpClient($body), 'api-key'); + $results = $provider->geocodeQuery(GeocodeQuery::create('8.8.8.8')); + + $this->assertCount(1, $results); + + /** @var Ip2GeoAddress $result */ + $result = $results->first(); + + $this->assertInstanceOf(Ip2GeoAddress::class, $result); + + // Standard geocoder fields + $this->assertSame('ip2geo', $result->getProvidedBy()); + $this->assertSame('Mountain View', $result->getLocality()); + $this->assertSame('94035', $result->getPostalCode()); + $this->assertEqualsWithDelta(37.386, $result->getCoordinates()->getLatitude(), 0.001); + $this->assertEqualsWithDelta(-122.0838, $result->getCoordinates()->getLongitude(), 0.001); + $this->assertSame('United States', $result->getCountry()->getName()); + $this->assertSame('US', $result->getCountry()->getCode()); + $this->assertSame('California', $result->getAdminLevels()->get(1)->getName()); + $this->assertSame('CA', $result->getAdminLevels()->get(1)->getCode()); + $this->assertSame('America/Los_Angeles', $result->getTimezone()); + + // Extra ip2geo fields + $this->assertSame('8.8.8.8', $result->getIp()); + $this->assertSame('IPv4', $result->getIpType()); + $this->assertFalse($result->isEu()); + $this->assertSame('North America', $result->getContinentName()); + $this->assertSame('NA', $result->getContinentCode()); + $this->assertSame('+1', $result->getPhoneCode()); + $this->assertSame('Washington D.C.', $result->getCapital()); + $this->assertSame('.us', $result->getTld()); + $this->assertSame('https://flagcdn.com/us.svg', $result->getFlagImg()); + $this->assertSame('USD', $result->getCurrencyCode()); + $this->assertSame('United States Dollar', $result->getCurrencyName()); + $this->assertSame('$', $result->getCurrencySymbol()); + $this->assertSame(5375480, $result->getGeonameId()); + $this->assertSame(6255149, $result->getContinentGeonameId()); + $this->assertSame(6252001, $result->getCountryGeonameId()); + $this->assertSame(807, $result->getMetroCode()); + $this->assertSame('U+1F1FA U+1F1F8', $result->getFlagEmojiUnicode()); + $this->assertSame(6252001, $result->getRegisteredCountryGeonameId()); + $this->assertSame(1000, $result->getAccuracyRadius()); + $this->assertSame('2026-04-05T10:30:00-07:00', $result->getTimeNow()); + $this->assertSame(15169, $result->getAsnNumber()); + $this->assertSame('Google LLC', $result->getAsnName()); + $this->assertSame('United States', $result->getRegisteredCountryName()); + $this->assertSame('US', $result->getRegisteredCountryCode()); + } + + public function testGeocodeWithMinimalData(): void + { + $body = json_encode([ + 'success' => true, + 'data' => [ + 'ip' => '1.1.1.1', + 'type' => 'IPv4', + 'continent' => [ + 'name' => 'Oceania', + 'code' => 'OC', + 'country' => [ + 'name' => 'Australia', + 'code' => 'AU', + ], + ], + ], + ]); + + $provider = new Ip2Geo($this->getMockedHttpClient($body), 'api-key'); + $results = $provider->geocodeQuery(GeocodeQuery::create('1.1.1.1')); + + /** @var Ip2GeoAddress $result */ + $result = $results->first(); + + $this->assertInstanceOf(Ip2GeoAddress::class, $result); + $this->assertSame('1.1.1.1', $result->getIp()); + $this->assertSame('Australia', $result->getCountry()->getName()); + $this->assertSame('AU', $result->getCountry()->getCode()); + $this->assertNull($result->getLocality()); + $this->assertNull($result->getCoordinates()); + $this->assertNull($result->getAsnNumber()); + $this->assertNull($result->getCurrencyCode()); + $this->assertNull($result->getFlagEmoji()); + } + + /** + * Returns a full successful API response for testing. + */ + private function getSuccessResponse(): array + { + return [ + 'success' => true, + 'data' => [ + 'ip' => '8.8.8.8', + 'type' => 'IPv4', + 'is_eu' => false, + 'continent' => [ + 'name' => 'North America', + 'code' => 'NA', + 'geoname_id' => 6255149, + 'country' => [ + 'name' => 'United States', + 'code' => 'US', + 'geoname_id' => 6252001, + 'phone_code' => '+1', + 'capital' => 'Washington D.C.', + 'tld' => '.us', + 'flag' => [ + 'emoji' => "\u{1F1FA}\u{1F1F8}", + 'emoji_unicode' => 'U+1F1FA U+1F1F8', + 'img' => 'https://flagcdn.com/us.svg', + ], + 'currency' => [ + 'name' => 'United States Dollar', + 'code' => 'USD', + 'symbol' => '$', + ], + 'subdivision' => [ + 'name' => 'California', + 'code' => 'CA', + ], + 'city' => [ + 'name' => 'Mountain View', + 'latitude' => 37.386, + 'longitude' => -122.0838, + 'postal_code' => '94035', + 'geoname_id' => 5375480, + 'metro_code' => 807, + 'accuracy_radius' => 1000, + 'timezone' => [ + 'name' => 'America/Los_Angeles', + 'time_now' => '2026-04-05T10:30:00-07:00', + ], + ], + ], + ], + 'asn' => [ + 'number' => 15169, + 'name' => 'Google LLC', + ], + 'registered_country' => [ + 'name' => 'United States', + 'code' => 'US', + 'geoname_id' => 6252001, + ], + ], + ]; + } +} diff --git a/src/Provider/Ip2Geo/composer.json b/src/Provider/Ip2Geo/composer.json new file mode 100644 index 000000000..7d82f22d8 --- /dev/null +++ b/src/Provider/Ip2Geo/composer.json @@ -0,0 +1,46 @@ +{ + "name": "geocoder-php/ip2geo-provider", + "type": "library", + "description": "Geocoder ip2geo adapter", + "keywords": ["geocoder", "geocoding", "ip", "geolocation", "ip2geo"], + "homepage": "https://ip2geo.dev", + "license": "MIT", + "authors": [ + { + "name": "ip2geo", + "email": "support@ip2geo.dev" + } + ], + "require": { + "php": "^8.0", + "geocoder-php/common-http": "^4.0", + "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" + }, + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Geocoder\\Provider\\Ip2Geo\\": "" + }, + "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/Ip2Geo/phpunit.xml.dist b/src/Provider/Ip2Geo/phpunit.xml.dist new file mode 100644 index 000000000..f095277ce --- /dev/null +++ b/src/Provider/Ip2Geo/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + + Tests + + +