diff --git a/composer.json b/composer.json index 3774d9b..5f9f412 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "type": "project", "require": { "php": ">=8.3.0", - "utopia-php/fetch": "0.5.*", + "utopia-php/fetch": "^1.1", "utopia-php/http": "^2.0@RC", "utopia-php/platform": "^1.0@RC", "utopia-php/di": "0.3.*", diff --git a/composer.lock b/composer.lock index dc04db8..20035c3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f46abbcdacdcd7c9a220cf6cc2f2ecfb", + "content-hash": "9414093a4661751fd60181b446d38c23", "packages": [ { "name": "brick/math", @@ -2115,16 +2115,16 @@ }, { "name": "utopia-php/fetch", - "version": "0.5.1", + "version": "1.1.2", "source": { "type": "git", "url": "https://github.com/utopia-php/fetch.git", - "reference": "a96a010e1c273f3888765449687baf58cbc61fcd" + "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/fetch/zipball/a96a010e1c273f3888765449687baf58cbc61fcd", - "reference": "a96a010e1c273f3888765449687baf58cbc61fcd", + "url": "https://api.github.com/repos/utopia-php/fetch/zipball/64f2b3a789480f1deb102ce684dac4217d8e98d5", + "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5", "shasum": "" }, "require": { @@ -2133,7 +2133,8 @@ "require-dev": { "laravel/pint": "^1.5.0", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^9.5", + "swoole/ide-helper": "^6.0" }, "type": "library", "autoload": { @@ -2148,9 +2149,9 @@ "description": "A simple library that provides an interface for making HTTP Requests.", "support": { "issues": "https://github.com/utopia-php/fetch/issues", - "source": "https://github.com/utopia-php/fetch/tree/0.5.1" + "source": "https://github.com/utopia-php/fetch/tree/1.1.2" }, - "time": "2025-12-18T16:25:10+00:00" + "time": "2026-04-29T11:19:19+00:00" }, { "name": "utopia-php/http", diff --git a/phpunit.xml b/phpunit.xml index eccb13e..b975dbf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,6 +7,9 @@ testdox="true" > + + tests/Unit + tests/E2E diff --git a/src/Geo/GeoIp.php b/src/Geo/GeoIp.php new file mode 100644 index 0000000..58e3157 --- /dev/null +++ b/src/Geo/GeoIp.php @@ -0,0 +1,55 @@ +client = $client ?? new Client(); + $this->client + ->addHeader('Accept', Client::CONTENT_TYPE_APPLICATION_JSON) + ->setTimeout(1000); + } + + public function getCountryCode(string $ip): ?string + { + $geo = $this->get($ip); + + return $geo?->getCountryCode(); + } + + public function get(string $ip): ?GeoRecord + { + if ($this->endpoint === '' || $this->secret === '') { + return null; + } + + try { + $response = $this->client + ->addHeader('Authorization', 'Bearer ' . $this->secret) + ->fetch( + \rtrim($this->endpoint, '/') . '/v1/ips/' . \rawurlencode($ip), + Client::METHOD_GET, + ); + + if ($response->getStatusCode() >= 400) { + return null; + } + + $body = \json_decode((string) $response->getBody(), true); + } catch (Throwable) { + return null; + } + + return \is_array($body) ? GeoRecord::fromArray($body) : null; + } +} diff --git a/src/Geo/GeoRecord.php b/src/Geo/GeoRecord.php new file mode 100644 index 0000000..069561a --- /dev/null +++ b/src/Geo/GeoRecord.php @@ -0,0 +1,44 @@ + $data + */ + public function __construct( + private array $data, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self($data); + } + + public function getCountryCode(): ?string + { + $countryCode = $this->data['countryCode'] ?? null; + + return \is_string($countryCode) && $countryCode !== '' + ? $countryCode + : null; + } + + public function get(string $key): mixed + { + return $this->data[$key] ?? null; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->data; + } +} diff --git a/tests/Unit/GeoIpTest.php b/tests/Unit/GeoIpTest.php new file mode 100644 index 0000000..95bec6a --- /dev/null +++ b/tests/Unit/GeoIpTest.php @@ -0,0 +1,134 @@ +createMock(FetchClient::class); + $client + ->expects($this->exactly(2)) + ->method('addHeader') + ->willReturnCallback(function (string $key, string $value) use ($client): FetchClient { + if ($key === 'Authorization') { + $this->assertSame('Bearer secret', $value); + return $client; + } + + $this->assertSame('Accept', $key); + $this->assertSame(FetchClient::CONTENT_TYPE_APPLICATION_JSON, $value); + + return $client; + }); + $client + ->expects($this->once()) + ->method('setTimeout') + ->with(1000) + ->willReturn($client); + $client + ->expects($this->once()) + ->method('fetch') + ->with('http://geo/v1/ips/8.8.8.8', FetchClient::METHOD_GET) + ->willReturn(new Response(200, '{"countryCode":"US","city":{"en":"Mountain View"}}', [])); + + $geo = new GeoIp('http://geo/', 'secret', $client); + $record = $geo->get('8.8.8.8'); + + $this->assertInstanceOf(GeoRecord::class, $record); + $this->assertSame('US', $record->getCountryCode()); + $this->assertSame(['en' => 'Mountain View'], $record->get('city')); + $this->assertSame([ + 'countryCode' => 'US', + 'city' => [ + 'en' => 'Mountain View', + ], + ], $record->toArray()); + } + + public function testGetCountryCodeReturnsCountryCode(): void + { + $geo = new GeoIp('http://geo', 'secret', $this->clientReturning(new Response(200, '{"countryCode":"US"}', []))); + + $this->assertSame('US', $geo->getCountryCode('8.8.8.8')); + } + + public function testGetCountryCodeReturnsNullWhenCountryCodeIsMissing(): void + { + $geo = new GeoIp('http://geo', 'secret', $this->clientReturning(new Response(200, '{}', []))); + + $this->assertNull($geo->getCountryCode('8.8.8.8')); + } + + public function testGetReturnsNullOnHttpErrors(): void + { + $geo = new GeoIp('http://geo', 'secret', $this->clientReturning(new Response(500, '{}', []))); + + $this->assertNull($geo->get('8.8.8.8')); + } + + public function testGetReturnsNullOnInvalidJson(): void + { + $geo = new GeoIp('http://geo', 'secret', $this->clientReturning(new Response(200, 'not-json', []))); + + $this->assertNull($geo->get('8.8.8.8')); + } + + public function testGetReturnsNullOnFetchExceptions(): void + { + $client = $this->createStub(FetchClient::class); + $client + ->method('addHeader') + ->willReturn($client); + $client + ->method('setTimeout') + ->willReturn($client); + $client + ->method('fetch') + ->willThrowException(new RuntimeException('timeout')); + + $geo = new GeoIp('http://geo', 'secret', $client); + + $this->assertNull($geo->get('8.8.8.8')); + } + + public function testGetDoesNotCallClientWithoutEndpointOrSecret(): void + { + $client = $this->createMock(FetchClient::class); + $client + ->method('addHeader') + ->willReturn($client); + $client + ->method('setTimeout') + ->willReturn($client); + $client + ->expects($this->never()) + ->method('fetch'); + + $this->assertNull(new GeoIp('', 'secret', $client)->get('8.8.8.8')); + $this->assertNull(new GeoIp('http://geo', '', $client)->get('8.8.8.8')); + } + + private function clientReturning(Response $response): FetchClient + { + $client = $this->createStub(FetchClient::class); + $client + ->method('addHeader') + ->willReturn($client); + $client + ->method('setTimeout') + ->willReturn($client); + $client + ->method('fetch') + ->willReturn($response); + + return $client; + } +}