From c2e60e7210f81faf9960ffe2bc561384a0582072 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Tue, 14 May 2024 14:15:39 -0700 Subject: [PATCH] Initial support for ip-api geolocation Needs paid API support before use with Neon services --- neon_api_proxy/client/__init__.py | 1 + neon_api_proxy/client/ip_geolocation.py | 48 ++++++++++++ neon_api_proxy/controller.py | 5 +- neon_api_proxy/services/ip_geolocation_api.py | 70 +++++++++++++++++ tests/test_client.py | 23 ++++++ tests/test_ip_geolocation_api.py | 77 +++++++++++++++++++ 6 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 neon_api_proxy/client/ip_geolocation.py create mode 100644 neon_api_proxy/services/ip_geolocation_api.py create mode 100644 tests/test_ip_geolocation_api.py diff --git a/neon_api_proxy/client/__init__.py b/neon_api_proxy/client/__init__.py index 306763b..0533922 100644 --- a/neon_api_proxy/client/__init__.py +++ b/neon_api_proxy/client/__init__.py @@ -43,6 +43,7 @@ def __str__(self): WOLFRAM_ALPHA = "wolfram_alpha" MAP_MAKER = "map_maker" FINANCIAL_MODELING_PREP = "financial_modeling_prep" + IP_API = "ip_api" NOT_IMPLEMENTED = "not_implemented" TEST_API = "api_test_endpoint" diff --git a/neon_api_proxy/client/ip_geolocation.py b/neon_api_proxy/client/ip_geolocation.py new file mode 100644 index 0000000..e1979c0 --- /dev/null +++ b/neon_api_proxy/client/ip_geolocation.py @@ -0,0 +1,48 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2022 Neongecko.com Inc. +# Contributors: Daniel McKnight, Guy Daniels, Elon Gasper, Richard Leeds, +# Regina Bloomstine, Casimiro Ferreira, Andrii Pernatii, Kirill Hrymailo +# BSD-3 License +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import json + +from ovos_utils.log import LOG +from neon_api_proxy.client import NeonAPI, request_api + + +def get_location(ip_addr: str) -> dict: + """ + Get a dict location for the requested public IP Address + @param ip_addr: Public IP address to geolocate + @returns: dict location + """ + resp = request_api(NeonAPI.IP_API, {"ip": ip_addr}) + if resp['status_code'] == 200: + resp['content'] = json.loads(resp['content']) + if resp['content']['status'] != "success": + resp['status_code'] = 500 + LOG.warning(f"Got failure response: {resp}") + LOG.info(f"Got location: {resp['content']}") + return resp['content'] diff --git a/neon_api_proxy/controller.py b/neon_api_proxy/controller.py index 172e4cd..d2468ca 100644 --- a/neon_api_proxy/controller.py +++ b/neon_api_proxy/controller.py @@ -32,6 +32,7 @@ from neon_utils.configuration_utils import NGIConfig from ovos_config.locations import get_xdg_config_save_path +from neon_api_proxy.services.ip_geolocation_api import IpGeolocationAPI from neon_api_proxy.services.map_maker_api import MapMakerAPI from neon_api_proxy.services.owm_api import OpenWeatherAPI from neon_api_proxy.services.alpha_vantage_api import AlphaVantageAPI @@ -50,6 +51,7 @@ class NeonAPIProxyController: 'alpha_vantage': AlphaVantageAPI, 'open_weather_map': OpenWeatherAPI, 'map_maker': MapMakerAPI, + "ip_api": IpGeolocationAPI, 'api_test_endpoint': TestAPI } @@ -89,7 +91,8 @@ def init_service_instances(self, service_class_mapping: dict) -> dict: api_key = self.config.get(item, {}).get("api_key") if self.config \ else None try: - if api_key is None and item != 'api_test_endpoint': + if api_key is None and item not in ('api_test_endpoint', + "ip_api"): LOG.warning(f"No API key for {item} in " f"{list(self.config.keys())}") service_mapping[item] = \ diff --git a/neon_api_proxy/services/ip_geolocation_api.py b/neon_api_proxy/services/ip_geolocation_api.py new file mode 100644 index 0000000..8d65178 --- /dev/null +++ b/neon_api_proxy/services/ip_geolocation_api.py @@ -0,0 +1,70 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2022 Neongecko.com Inc. +# Contributors: Daniel McKnight, Guy Daniels, Elon Gasper, Richard Leeds, +# Regina Bloomstine, Casimiro Ferreira, Andrii Pernatii, Kirill Hrymailo +# BSD-3 License +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import urllib.parse + +from datetime import timedelta +from time import time, sleep +from requests import Response +from ovos_utils.log import LOG + +from neon_api_proxy.cached_api import CachedAPI + + +class IpGeolocationAPI(CachedAPI): + """ + API for querying IP Geolocation (ip-api.com). + """ + + def __init__(self, api_key=None, cache_seconds=604800): # Cache week + super().__init__("ip_api") + self.cache_timeout = timedelta(seconds=cache_seconds) + if api_key: + self.geolocate_api = "https://members.ip-api.com/json/" + else: + self.geolocate_api = "http://ip-api.com/json/" + + def handle_query(self, **kwargs) -> dict: + """ + Handles an incoming query and provides a response + :param kwargs: + 'ip' - IP Address to location + :return: dict containing `status_code`, `content`, `encoding` + from URL response + """ + ip = kwargs.get('ip') + try: + # TODO: Build URL with API key + response = self.get_with_cache_timeout(f"{self.geolocate_api}{ip}") + return {"status_code": response.status_code, + "content": response.content, + "encoding": response.encoding} + except Exception as e: + return {"status_code": 500, + "content": repr(e), + "encoding": None} diff --git a/tests/test_client.py b/tests/test_client.py index ca3fd62..b459f57 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -331,3 +331,26 @@ def test_get_address(self): # Invalid Request with self.assertRaises(RuntimeError): get_address('', '') + + +class IpGeolocationTests(unittest.TestCase): + def test_geolocate(self): + from neon_api_proxy.client.ip_geolocation import get_location + WA_IP = '50.47.222.4' + DO_IP = '146.190.53.128' + INVALID_IP = '10.0.0.1' + + wa_loc = get_location(WA_IP) + ca_loc = get_location(DO_IP) + invalid = get_location(INVALID_IP) + + self.assertEqual(wa_loc['status'], 'success') + self.assertAlmostEqual(wa_loc['lat'], 48.0, 0) + self.assertAlmostEqual(wa_loc['lon'], -122.0, 0) + + self.assertEqual(ca_loc['status'], 'success') + self.assertAlmostEqual(ca_loc['lat'], 37.0, 0) + self.assertAlmostEqual(ca_loc['lon'], -122.0, 0) + + self.assertEqual(invalid['status'], 'fail') + self.assertIsInstance(invalid['message'], str, invalid) diff --git a/tests/test_ip_geolocation_api.py b/tests/test_ip_geolocation_api.py new file mode 100644 index 0000000..ed9f0fa --- /dev/null +++ b/tests/test_ip_geolocation_api.py @@ -0,0 +1,77 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2022 Neongecko.com Inc. +# Contributors: Daniel McKnight, Guy Daniels, Elon Gasper, Richard Leeds, +# Regina Bloomstine, Casimiro Ferreira, Andrii Pernatii, Kirill Hrymailo +# BSD-3 License +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import json +import os +import sys +import unittest + +from requests import Response + +sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) +from neon_api_proxy.services.ip_geolocation_api import IpGeolocationAPI + +WA_IP = '50.47.222.4' +DO_IP = '146.190.53.128' +INVALID_IP = '10.0.0.1' + + +class TestIpGeolocationAPI(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.api = IpGeolocationAPI() + + def test_lookup(self): + resp = self.api.handle_query(ip=WA_IP) + self.assertEqual(resp['status_code'], 200, resp) + location = json.loads(resp['content'].decode(resp['encoding'])) + self.assertEqual(location['query'], WA_IP) + self.assertEqual(location['country'], 'United States') + self.assertEqual(location['regionName'], 'Washington') + self.assertEqual(location['timezone'], 'America/Los_Angeles') + self.assertAlmostEqual(location['lat'], 48.0, 0) + self.assertAlmostEqual(location['lon'], -122.0, 0) + + resp = self.api.handle_query(ip=DO_IP) + self.assertEqual(resp['status_code'], 200, resp) + location = json.loads(resp['content'].decode(resp['encoding'])) + self.assertEqual(location['query'], DO_IP) + self.assertEqual(location['country'], 'United States') + self.assertEqual(location['regionName'], 'California') + self.assertEqual(location['timezone'], 'America/Los_Angeles') + self.assertAlmostEqual(location['lat'], 37.0, 0) + self.assertAlmostEqual(location['lon'], -122.0, 0) + + resp = self.api.handle_query(ip=INVALID_IP) + self.assertEqual(resp['status_code'], 200, resp) + location = json.loads(resp['content'].decode(resp['encoding'])) + self.assertEqual(location['status'], 'fail') + + +if __name__ == '__main__': + unittest.main()