Skip to content

Commit e15cbae

Browse files
committed
Add IP2Location.io service support
Adds a new geolocation service backed by the IP2Location.io API, including configuration and tests.
1 parent 33ebe46 commit e15cbae

3 files changed

Lines changed: 197 additions & 1 deletion

File tree

config/geoip.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
| Here you may specify the default storage driver that should be used
3939
| by the framework using the services listed below.
4040
|
41-
| Supported: "maxmind_database", "maxmind_api", "ipapi", "ipgeolocation", "ipdata", "ipfinder"
41+
| Supported: "maxmind_database", "maxmind_api", "ipapi", "ipgeolocation", "ipdata", "ipfinder", "ip2location"
4242
|
4343
*/
4444

@@ -98,6 +98,11 @@
9898
'locales' => ['en'],
9999
],
100100

101+
'ip2location' => [
102+
'class' => \InteractionDesignFoundation\GeoIP\Services\IP2Location::class,
103+
'key' => env('IP2LOCATION_API_KEY'),
104+
],
105+
101106
],
102107

103108
/*

src/Services/IP2Location.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace InteractionDesignFoundation\GeoIP\Services;
6+
7+
use InteractionDesignFoundation\GeoIP\Support\HttpClient;
8+
9+
/**
10+
* @internal
11+
*/
12+
class IP2Location extends AbstractService
13+
{
14+
protected HttpClient $client;
15+
16+
#[\Override]
17+
public function boot(): void
18+
{
19+
$this->ensureConfigurationParameterDefined('key');
20+
21+
$this->client = new HttpClient([
22+
'base_uri' => 'https://api.ip2location.io/',
23+
'query' => [
24+
'key' => $this->config('key'),
25+
],
26+
]);
27+
}
28+
29+
/**
30+
* {@inheritDoc}
31+
* @throws \RuntimeException
32+
*/
33+
#[\Override]
34+
public function locate($ip): \InteractionDesignFoundation\GeoIP\Location
35+
{
36+
$data = $this->client->get('', [
37+
'ip' => $ip,
38+
]);
39+
40+
if ($this->client->getErrors() !== null) {
41+
throw new \RuntimeException('Request failed (' . $this->client->getErrors() . ')');
42+
}
43+
44+
$json = json_decode((string) $data[0]);
45+
46+
if (! is_object($json)) {
47+
throw new \RuntimeException('Unexpected ip2location.io response');
48+
}
49+
50+
return $this->hydrate([
51+
'ip' => $ip,
52+
'iso_code' => $json->country_code ?? null,
53+
'country' => $json->country_name ?? null,
54+
'city' => $json->city_name ?? null,
55+
'state' => null,
56+
'state_name' => $json->region_name ?? null,
57+
'postal_code' => $json->zip_code ?? null,
58+
'lat' => $json->latitude ?? null,
59+
'lon' => $json->longitude ?? null,
60+
'timezone' => $json->time_zone ?? null,
61+
'continent' => null,
62+
]);
63+
}
64+
}

tests/Services/IP2LocationTest.php

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace InteractionDesignFoundation\GeoIP\Tests\Services;
6+
7+
use InteractionDesignFoundation\GeoIP\Exceptions\MissingConfigurationException;
8+
use InteractionDesignFoundation\GeoIP\Location;
9+
use InteractionDesignFoundation\GeoIP\Services\IP2Location;
10+
use InteractionDesignFoundation\GeoIP\Support\HttpClient;
11+
use PHPUnit\Framework\Attributes\CoversClass;
12+
use PHPUnit\Framework\Attributes\Test;
13+
use PHPUnit\Framework\TestCase;
14+
15+
#[CoversClass(IP2Location::class)]
16+
class IP2LocationTest extends TestCase
17+
{
18+
#[Test]
19+
public function it_throws_when_api_key_is_missing(): void
20+
{
21+
$this->expectException(MissingConfigurationException::class);
22+
23+
new IP2Location([]);
24+
}
25+
26+
#[Test]
27+
public function it_boots_with_valid_config(): void
28+
{
29+
$service = new IP2Location(['key' => 'test-key']);
30+
31+
$this->assertSame('test-key', $service->config('key'));
32+
}
33+
34+
#[Test]
35+
public function it_returns_a_location_instance(): void
36+
{
37+
$service = $this->createServiceWithMockedClient(json_encode($this->sampleApiResponse()));
38+
39+
$location = $service->locate('8.8.8.8');
40+
41+
$this->assertInstanceOf(Location::class, $location);
42+
$this->assertSame('8.8.8.8', $location->ip);
43+
$this->assertSame('US', $location->iso_code);
44+
$this->assertSame('United States of America', $location->country);
45+
$this->assertSame('Mountain View', $location->city);
46+
$this->assertSame('California', $location->state_name);
47+
$this->assertSame('94043', $location->postal_code);
48+
$this->assertSame(37.38605, $location->lat);
49+
$this->assertSame(-122.08385, $location->lon);
50+
$this->assertSame('-07:00', $location->timezone);
51+
$this->assertFalse($location->default);
52+
}
53+
54+
#[Test]
55+
public function it_throws_on_curl_error(): void
56+
{
57+
$this->expectException(\RuntimeException::class);
58+
$this->expectExceptionMessage('Request failed');
59+
60+
$service = $this->createServiceWithMockedClient('', 'Connection timed out');
61+
62+
$service->locate('8.8.8.8');
63+
}
64+
65+
#[Test]
66+
public function it_throws_on_invalid_json_response(): void
67+
{
68+
$this->expectException(\RuntimeException::class);
69+
$this->expectExceptionMessage('Unexpected ip2location.io response');
70+
71+
$service = $this->createServiceWithMockedClient('not-json');
72+
73+
$service->locate('8.8.8.8');
74+
}
75+
76+
#[Test]
77+
public function it_handles_partial_response_gracefully(): void
78+
{
79+
$partialResponse = (object) [
80+
'country_code' => 'DE',
81+
'country_name' => 'Germany',
82+
];
83+
84+
$service = $this->createServiceWithMockedClient(json_encode($partialResponse));
85+
86+
$location = $service->locate('1.2.3.4');
87+
88+
$this->assertInstanceOf(Location::class, $location);
89+
$this->assertSame('1.2.3.4', $location->ip);
90+
$this->assertSame('DE', $location->iso_code);
91+
$this->assertSame('Germany', $location->country);
92+
$this->assertNull($location->city);
93+
}
94+
95+
private function createServiceWithMockedClient(string $responseBody, ?string $error = null): IP2Location
96+
{
97+
$client = $this->createMock(HttpClient::class);
98+
$client->method('get')->willReturn([$responseBody, []]);
99+
$client->method('getErrors')->willReturn($error);
100+
101+
$service = new IP2Location(['key' => 'test-key']);
102+
103+
$reflection = new \ReflectionClass($service);
104+
$property = $reflection->getProperty('client');
105+
$property->setValue($service, $client);
106+
107+
return $service;
108+
}
109+
110+
private function sampleApiResponse(): object
111+
{
112+
return (object) [
113+
'ip' => '8.8.8.8',
114+
'country_code' => 'US',
115+
'country_name' => 'United States of America',
116+
'region_name' => 'California',
117+
'city_name' => 'Mountain View',
118+
'latitude' => 37.38605,
119+
'longitude' => -122.08385,
120+
'zip_code' => '94043',
121+
'time_zone' => '-07:00',
122+
'isp' => 'Google LLC',
123+
'domain' => 'google.com',
124+
'is_proxy' => false,
125+
];
126+
}
127+
}

0 commit comments

Comments
 (0)