Skip to content

Commit

Permalink
Add Map Maker API with unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
NeonDaniel committed Dec 27, 2023
1 parent cbba80c commit be7f55b
Show file tree
Hide file tree
Showing 4 changed files with 211 additions and 2 deletions.
13 changes: 11 additions & 2 deletions .github/workflows/unit_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions neon_api_proxy/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -47,6 +49,7 @@ class NeonAPIProxyController:
'wolfram_alpha': WolframAPI,
'alpha_vantage': AlphaVantageAPI,
'open_weather_map': OpenWeatherAPI,
'map_maker': MapMakerAPI,
'api_test_endpoint': TestAPI
}

Expand Down
107 changes: 107 additions & 0 deletions neon_api_proxy/services/map_maker_api.py
Original file line number Diff line number Diff line change
@@ -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)
90 changes: 90 additions & 0 deletions tests/test_map_maker_api.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit be7f55b

Please sign in to comment.