From be7f55bda90e95b60cab8ec46309a3ba49885fdc Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Wed, 27 Dec 2023 14:34:11 -0800 Subject: [PATCH] Add Map Maker API with unit tests --- .github/workflows/unit_tests.yml | 13 ++- neon_api_proxy/controller.py | 3 + neon_api_proxy/services/map_maker_api.py | 107 +++++++++++++++++++++++ tests/test_map_maker_api.py | 90 +++++++++++++++++++ 4 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 neon_api_proxy/services/map_maker_api.py create mode 100644 tests/test_map_maker_api.py diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 2b967ad..ae5ef8d 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -29,8 +29,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements/requirements.txt - pip install -r requirements/test_requirements.txt + pip install . -r requirements/test_requirements.txt - name: Export credentials run: | mkdir -p ~/.local/share/neon @@ -42,6 +41,7 @@ jobs: WOLFRAM_ID: ${{secrets.wolfram_id}} AV_API_KEY: ${{secrets.alpha_vantage_key}} OWM_KEY: ${{secrets.open_weather_map_key}} + MAP_MAKER_KEY: ${{secrets.map_maker_key}} GENERIC_CONTROLLER_CONFIG: ${{secrets.generic_controller_config}} - name: Test Cached API run: | @@ -79,6 +79,15 @@ jobs: name: owm-api-test-results path: tests/owm-api-test-results.xml + - name: Test Map Maker API + run: | + pytest tests/test_map_maker_api.py --doctest-modules --junitxml=tests/map-maker-api-test-results.xml + - name: Upload Map Maker API test results + uses: actions/upload-artifact@v2 + with: + name: map-maker-api-test-results + path: tests/map-maker-api-test-results.xml + - name: Test Generic API run: | pytest tests/test_generic_controller.py --doctest-modules --junitxml=tests/generic-controller-test-results.xml diff --git a/neon_api_proxy/controller.py b/neon_api_proxy/controller.py index 291ec93..aa01eaf 100644 --- a/neon_api_proxy/controller.py +++ b/neon_api_proxy/controller.py @@ -31,6 +31,8 @@ from ovos_config.config import Configuration from neon_utils.configuration_utils import NGIConfig from ovos_config.locations import get_xdg_config_save_path + +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 from neon_api_proxy.services.wolfram_api import WolframAPI @@ -47,6 +49,7 @@ class NeonAPIProxyController: 'wolfram_alpha': WolframAPI, 'alpha_vantage': AlphaVantageAPI, 'open_weather_map': OpenWeatherAPI, + 'map_maker': MapMakerAPI, 'api_test_endpoint': TestAPI } diff --git a/neon_api_proxy/services/map_maker_api.py b/neon_api_proxy/services/map_maker_api.py new file mode 100644 index 0000000..ebfd33a --- /dev/null +++ b/neon_api_proxy/services/map_maker_api.py @@ -0,0 +1,107 @@ +# 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 os import getenv +from time import time, sleep + +from requests import Response + +from neon_api_proxy.cached_api import CachedAPI +from ovos_utils.log import LOG + + +class MapMakerAPI(CachedAPI): + """ + API for querying My Maps API (geocoder.maps.co). + """ + + def __init__(self, api_key: str = None, cache_seconds=604800): # Cache week + super().__init__("map_maker") + self._api_key = api_key or getenv("MAP_MAKER_KEY") + self._rate_limit_seconds = 1 + self._last_query = time() + self.cache_timeout = timedelta(seconds=cache_seconds) + self.geocode_url = "https://geocode.maps.co/search" + self.reverse_url = "https://geocode.maps.co/reverse" + + def handle_query(self, **kwargs) -> dict: + """ + Handles an incoming query and provides a response + :param kwargs: + 'lat' - optional str latitude + 'lon' - optional str longitude + 'address' - optional string address/place to resolve + :return: dict containing `status_code`, `content`, `encoding` + from URL response + """ + lat = kwargs.get("lat") + lon = kwargs.get("lon", kwargs.get("lng")) + address = kwargs.get('address') + + if not (address or (lat and lon)): + # Missing data for lookup + return {"status_code": -1, + "content": f"Incomplete request data: {kwargs}", + "encoding": None} + + if self._rate_limit_seconds: + sleep_time = round(self._rate_limit_seconds - + (time() - self._last_query), 3) + if sleep_time > 0: + LOG.info(f"Waiting {sleep_time}s before next API query") + sleep(sleep_time) + + if lat and lon: + # Lookup address for coordinates + try: + response = self._query_reverse(float(lat), float(lon)) + except ValueError as e: + return {"status_code": -1, + "content": repr(e), + "encoding": None} + else: + # Lookup coordinates for search term/address + response = self._query_geocode(address) + self._last_query = time() + return {"status_code": response.status_code, + "content": response.content, + "encoding": response.encoding} + + def _query_geocode(self, address: str) -> Response: + query_str = urllib.parse.urlencode({"q": address, + "api_key": self._api_key}) + request_url = f"{self.geocode_url}?{query_str}" + return self.get_with_cache_timeout(request_url, self.cache_timeout) + + def _query_reverse(self, lat: float, lon: float): + query_str = urllib.parse.urlencode({"lat": lat, "lon": lon, + "api_key": self._api_key}) + request_url = f"{self.reverse_url}?{query_str}" + return self.get_with_cache_timeout(request_url, self.cache_timeout) diff --git a/tests/test_map_maker_api.py b/tests/test_map_maker_api.py new file mode 100644 index 0000000..ecb8d18 --- /dev/null +++ b/tests/test_map_maker_api.py @@ -0,0 +1,90 @@ +# 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.map_maker_api import MapMakerAPI + +VALID_LAT = "47.4797" +VALID_LON = "-122.2079" + +INVALID_LAT = "a" +INVALID_LON = "b" + +VALID_ADDRESS = "Kirkland" +VALID_ADDRESS_2 = "New York New York" + +INVALID_ADDRESS = "" + + +class TestMapMakerAPI(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.api = MapMakerAPI() + + def test_geocode_lookup(self): + valid_response = self.api.handle_query(address=VALID_ADDRESS) + self.assertEqual(valid_response['status_code'], 200) + self.assertEqual(valid_response["encoding"].lower(), "utf-8") + valid_location = json.loads(valid_response["content"])[0] + self.assertEqual(round(float(valid_location['lat']), 2), 47.69) + self.assertEqual(round(float(valid_location['lon']), 2), -122.19) + + valid_response_2 = self.api.handle_query(address=VALID_ADDRESS_2) + self.assertEqual(valid_response_2['status_code'], 200) + self.assertEqual(valid_response_2["encoding"].lower(), "utf-8") + valid_location = json.loads(valid_response_2["content"])[0] + self.assertEqual(round(float(valid_location['lat']), 2), 36.10) + self.assertEqual(round(float(valid_location['lon']), 2), -115.17) + + invalid_response = self.api.handle_query(address=INVALID_ADDRESS) + self.assertEqual(invalid_response['status_code'], -1) + + def test_reverse_lookup(self): + valid_response = self.api.handle_query(lat=VALID_LAT, lon=VALID_LON) + self.assertEqual(valid_response['status_code'], 200) + self.assertEqual(valid_response["encoding"].lower(), "utf-8") + valid_location = json.loads(valid_response["content"])['address'] + self.assertEqual(valid_location['state'], "Washington", valid_location) + self.assertEqual(valid_location['town'], "Renton", valid_location) + + invalid_response = self.api.handle_query(lat=VALID_LAT, lon=None) + self.assertEqual(invalid_response['status_code'], -1) + + invalid_coords = self.api.handle_query(lat=INVALID_LAT, lon=INVALID_LON) + self.assertNotEqual(invalid_coords['status_code'], 200) + + +if __name__ == '__main__': + unittest.main()