diff --git a/.editorconfig b/.editorconfig new file mode 100755 index 0000000..f7bb3b6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*{.yml,.yaml,.json}] +indent_size = 2 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100755 index 0000000..f388950 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: mckenziearts diff --git a/.github/workflows/pint.yml b/.github/workflows/pint.yml new file mode 100755 index 0000000..cccd17f --- /dev/null +++ b/.github/workflows/pint.yml @@ -0,0 +1,25 @@ +name: pint + +on: push + +jobs: + pint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref }} + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + - name: Install Composer + run: composer install --no-interaction + - name: Run Laravel Pint + run: composer pint + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Apply formatting diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..6d7e08a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/vendor +.idea +.DS_Store +composer.lock +.phpunit.result.cache diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..4a69f54 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Arthur Monney + +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/README.md b/README.md new file mode 100644 index 0000000..71a943b --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# [Abstract API](https://www.abstractapi.com) Abstract’s IP Geolocation API Laravel Client Library + +### Getting Started + +Abstract’s IP Geolocation API is a fast, lightweight, modern, and RESTful JSON API for determining the location and other details of IP addresses from over 190 countries. + +The free plan is limited to 1,000 requests per month. To enable all the data fields and additional request volumes see [https://www.abstractapi.com/api/ip-geolocation-api#pricing](https://www.abstractapi.com/api/ip-geolocation-api#pricing). + +### Installation + +The package works with PHP 8 and is available using [Composer](https://getcomposer.org). + +```shell +composer require laravelcm/abstract-ip-geolocation +``` + +### Usage + +Open your application's `\app\Http\Kernel.php` file and add the following to the `Kernel::middleware` property: + +```php +protected $middleware = [ + ... + \Laravelcm\AbstractIpGeolocation\Middleware\AbstractIpGeolocation::class, +]; +``` + +#### Quick Start + +```php +Route::get('/', function () { + $location = "The IP address " . request()->get('abstract-ip-geolocation')['ip_address']; + return view('index', ['location' => $location]); +}); +``` + +Will return the following string to the `index` view: + +```shell +"The IP address 127.0.0.1." +``` + +### Configuration + +wip.. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5177411 --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "laravelcm/abstract-ip-geolocation", + "description": "Abstract’s IP Geolocation API is a fast, lightweight, modern, and RESTful JSON API for determining the location and other details of IP addresses from over 190 countries.", + "license": "MIT", + "keywords": [ + "laravel", + "ip-geolocation", + "abstract-api" + ], + "authors": [ + { + "name": "Arthur Monney", + "email": "monneylobe@gmail.com" + } + ], + "require": { + "php": "^8.2", + "ext-json": "*", + "illuminate/support": "^10.0|^11.0", + "spatie/laravel-package-tools": "^1.15" + }, + "require-dev": { + "orchestra/testbench": "^8.5", + "laravel/pint": "^1.1", + "pestphp/pest": "^2.34", + "mockery/mockery": "^1.6" + }, + "autoload": { + "psr-4": { + "Laravelcm\\AbstractIpGeolocation\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Laravelcm\\AbstractIpGeolocation\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Laravelcm\\AbstractIpGeolocation\\AbstractIpGeolocationServiceProvider" + ] + } + }, + "scripts": { + "test": "vendor/bin/pest", + "pint": "vendor/bin/pint" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/config/abstract-ip-geolocation.php b/config/abstract-ip-geolocation.php new file mode 100644 index 0000000..d005a84 --- /dev/null +++ b/config/abstract-ip-geolocation.php @@ -0,0 +1,92 @@ + env('ABSTRACT_IP_GEOLOCATION_API_KEY'), + + /* + |-------------------------------------------------------------------------- + | User IP Address + |-------------------------------------------------------------------------- + | By default, Abstract IP Geolocation will detect the ip address. But you can + | update this configuration to include the client's ip address every time. + | + | If this value is set to "true", the ip will be detected using the "ip_selector" config. + | + */ + + 'include_ip' => false, + + /* + |-------------------------------------------------------------------------- + | Geolocation Fields + |-------------------------------------------------------------------------- + | You can include a fields value in the query parameters with a comma + | separated list of the top-level keys you want to be returned. For example + | "fields => 'city,region'" will return only the city and region in the response. + | + | see: https://docs.abstractapi.com/ip-geolocation#request-parameters + */ + + 'fields' => null, + + /* + |-------------------------------------------------------------------------- + | Ip Selector Class + |-------------------------------------------------------------------------- + | You can select which class will be used to retrieve the client's ip address + | or you can change it and use your own class to return the ip address. + | + | In case you use a custom IP selector, you may implement the + | \Laravelcm\AbstractIpGeolocation\Contracts\AbstractIpInterface interface + | + | Available class: "IpSelector", "OriginatingIpSelector" + | + */ + + 'ip_selector' => new IpSelector(), + + /* + |-------------------------------------------------------------------------- + | Cache HTTP Request API + |-------------------------------------------------------------------------- + | + | + */ + + 'cache' => [ + /* + | By default, requests are cached to retrieve the user's ip. + | If you use the "Free plan" option, caching can help you save + | on requests to the Abstract IP Geolocation API. + */ + + 'enable' => true, + + /* + | + */ + + 'maxsize' => 5, + + /* + | + | + */ + + 'ttl' => 86400, + ], + +]; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..0c12bb9 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + ./tests + + + + + ./src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..01b216c --- /dev/null +++ b/pint.json @@ -0,0 +1,8 @@ +{ + "presets": "laravel", + "rules": { + "concat_space": { + "spacing": "one" + } + } +} diff --git a/src/AbstractIpGeolocation.php b/src/AbstractIpGeolocation.php new file mode 100644 index 0000000..5cf0c5c --- /dev/null +++ b/src/AbstractIpGeolocation.php @@ -0,0 +1,63 @@ +cache) { + $cacheResponse = $this->cache->get($this->cacheKey($ipAddress ?? '-1')); + + if ($cacheResponse !== null) { + return $cacheResponse; + } + } + + try { + $response = Http::withHeaders([ + 'content-type' => 'application/json', + 'accept' => 'application/json', + ]) + ->get('https://ipgeolocation.abstractapi.com/v1', [ + 'api_key' => $this->token, + 'ip_address' => $ipAddress, + 'fields' => $this->fields, + ]); + } catch (\Exception $e) { + throw new AbstractApiGeolocationException($e->getMessage()); + } + + if ($response->status() === 422) { + throw new AbstractApiGeolocationException('The request was aborted due to insufficient API credits. (Free plan)'); + } elseif ($response->status() >= 400) { + throw new AbstractApiGeolocationException('Exception: ' . json_encode([ + 'status' => $response->status(), + 'message' => $response->body(), + ])); + } + + $result = $response->json(); + + $this->cache?->set($this->cacheKey($ipAddress ?? '-1'), $result); + + return $result; + } + + protected function cacheKey(string $key): string + { + return sprintf('%s_v%s', $key, '-1'); + } +} diff --git a/src/AbstractIpGeolocationServiceProvider.php b/src/AbstractIpGeolocationServiceProvider.php new file mode 100644 index 0000000..39e36ba --- /dev/null +++ b/src/AbstractIpGeolocationServiceProvider.php @@ -0,0 +1,17 @@ +name('abstract-ip-geolocation') + ->hasConfigFile(); + } +} diff --git a/src/Contracts/AbstractIpInterface.php b/src/Contracts/AbstractIpInterface.php new file mode 100644 index 0000000..0eed2d8 --- /dev/null +++ b/src/Contracts/AbstractIpInterface.php @@ -0,0 +1,15 @@ +maxsize = config('abstract-ip-geolocation.cache.maxsize'); + $this->ttl = config('abstract-ip-geolocation.cache.ttl'); + } + + public function has(string $name): bool + { + return Cache::has($name); + } + + public function get(string $name): mixed + { + return Cache::get($name); + } + + public function set(string $name, $value) + { + if (! $this->has($name)) { + $this->elements[] = $name; + } + + Cache::put($name, $value, $this->ttl); + + $this->manageSize(); + } + + /** + * If cache maxsize has been reached, remove oldest elements until limit is reached. + */ + private function manageSize(): void + { + $overflow = count($this->elements) - $this->maxsize; + + if ($overflow > 0) { + foreach (array_slice($this->elements, 0, $overflow) as $name) { + if ($this->has($name)) { + Cache::forget($name); + } + } + $this->elements = array_slice($this->elements, $overflow); + } + } +} diff --git a/src/Exceptions/AbstractApiGeolocationException.php b/src/Exceptions/AbstractApiGeolocationException.php new file mode 100644 index 0000000..8b403c2 --- /dev/null +++ b/src/Exceptions/AbstractApiGeolocationException.php @@ -0,0 +1,9 @@ +ip(); + } +} diff --git a/src/Middleware/AbstractIpGeolocation.php b/src/Middleware/AbstractIpGeolocation.php new file mode 100644 index 0000000..ffea4d7 --- /dev/null +++ b/src/Middleware/AbstractIpGeolocation.php @@ -0,0 +1,57 @@ +getResponse($request); + + $request->merge(['abstract-ip-geolocation' => $response]); + + return $next($request); + } + + /** + * Get client's ip response + * + * @throws AbstractApiGeolocationException + */ + public function getResponse($request): array + { + $ipAddress = null; + $cache = null; + + if (config('abstract-ip-geolocation.include_ip')) { + $ipAddress = (config('abstract-ip-geolocation.ip_selector', new IpSelector()))->getIp($request); + } + + if (config('abstract-ip-geolocation.cache.enable')) { + $cache = new DefaultCache(); + } + + $response = new IpGeolocation( + token: config('abstract-ip-geolocation.api_key'), + fields: config('abstract-ip-geolocation.fields'), + cache: $cache + ); + + return $response->details($ipAddress); + } +} diff --git a/src/OriginatingIpSelector.php b/src/OriginatingIpSelector.php new file mode 100644 index 0000000..7a07b5e --- /dev/null +++ b/src/OriginatingIpSelector.php @@ -0,0 +1,25 @@ +headers->get('x-forwarded-for'); + + if (empty($xForwardedFor)) { + $ip = $request->ip(); + } else { + $ips = explode(',', $xForwardedFor); + // trim as officially the space comes after each comma separator + $ip = trim($ips[0]); + } + + return $ip; + } +} diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..c139c2b --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,7 @@ +in('Unit'); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..ce9e8ed --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,18 @@ +toBeTrue(); +});