-
Notifications
You must be signed in to change notification settings - Fork 0
Add geocoding functionality and endpoint to the backend #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
cd2c7dc
62902ee
fcf4529
4f15336
fecb31c
24547b5
79ff99d
142f10a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ __pycache__/ | |
|
|
||
| ## Other Stuff | ||
| .vscode/ | ||
| .env | ||
|
|
||
| # misc | ||
| .DS_Store | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,8 @@ services: | |
| - 8000:8000 | ||
| volumes: | ||
| - ./services/backend/api:/app/api | ||
| env_file: | ||
| - .env | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a simple way to specify that you want docker to load environment vars from a file with a |
||
| frontend: | ||
| build: services/frontend | ||
| ports: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,22 @@ | ||
| from fastapi import FastAPI | ||
| from fastapi import FastAPI, HTTPException | ||
| from .models import Location | ||
| from . import geocoder | ||
| import logging | ||
|
|
||
| logger = logging.getLogger(__file__) | ||
|
|
||
| app = FastAPI() | ||
|
|
||
|
|
||
| @app.get("/") | ||
| async def get_root(): | ||
| return {"data": "I am a WeatherApp"} | ||
|
|
||
|
|
||
| @app.get("/location/{location_query}", response_model=Location) | ||
| def get_geocoded_location(location_query: str): | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can't make this an |
||
| try: | ||
| location_list = geocoder.get_location_from_query(location_query) | ||
| return location_list[0] | ||
| except geocoder.GeocoderError as e: | ||
| raise HTTPException(status_code=500, detail=str(e)) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| from typing import List | ||
| from requests.api import request | ||
| from .models import Location | ||
| import requests | ||
| import os | ||
| from functools import lru_cache | ||
| import logging | ||
|
|
||
| logger = logging.getLogger(__file__) | ||
|
|
||
|
|
||
| API_KEY = os.environ.get("GEOCODER_API_KEY") | ||
| if not API_KEY: | ||
| logger.warn("Geocoder API key env var not set.") | ||
|
|
||
|
|
||
| class GeocoderError(Exception): | ||
| pass | ||
|
|
||
|
|
||
| def parse_location_string(location_string: str) -> Location: | ||
| """Parses a string formatted like `{lat}+{lon}` into a Location object | ||
|
|
||
| e.g. `34.05513+-118.25703` -> Location(lat: 4.05513, lon: -118.25703) | ||
|
|
||
| Parameters | ||
| ---------- | ||
| location_string: str | ||
| A string formatted as specified above | ||
|
|
||
| Returns | ||
| ------- | ||
| Location | ||
|
|
||
| Raises | ||
| ------ | ||
| ValueError | ||
| If entity can't be parsed | ||
| """ | ||
| # This would arguable be simpler with regex but I don't care to bother with that right now. | ||
| if "+" in location_string: | ||
| split_string = location_string.split("+") | ||
| if len(split_string) == 2: | ||
| lat, lon = split_string | ||
| return Location(lat=float(lat), lon=float(lon)) | ||
|
|
||
| raise ValueError(f"Unable to parse {location_string}. Expected format 'lat+lon'") | ||
|
|
||
|
|
||
| @lru_cache(maxsize=256) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding caching seemed like a handy little trick. Suppose if we really wanted to get fancy we could add a full DB cache that would persist between runs. |
||
| def get_location_from_query(query_string: str, limit: int = 1) -> List[Location]: | ||
| """Geocodes a raw query string into a canonical lat/lon location. | ||
|
|
||
| This funtional calls the `positionstack` geocoder API to transform user location queries | ||
| into canonical lat/lon locations. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| query_string: str | ||
| A raw string, entered by a user, that the geocoder will attempt to locate. | ||
| limit: int (default: 1) | ||
| The max number of records to return. | ||
|
|
||
| Returns | ||
| ------- | ||
| List[Location] | ||
|
|
||
| Raises | ||
| ------ | ||
| GeocoderError | ||
| On non-200 external API return | ||
| TypeError | ||
| If response schema cannot be parsed | ||
| """ | ||
| URI = "http://api.positionstack.com/v1/forward?access_key={api_key}&query={query}&limit={limit}" | ||
| request_url = URI.format(api_key=API_KEY, query=query_string, limit=limit) | ||
| response = requests.get(request_url) | ||
| if not response.ok: | ||
| # TODO: More granular error types based on the error reason | ||
| raise GeocoderError( | ||
| f"Geocoder returned a status code {response.status_code}: {response.reason}" | ||
| ) | ||
| try: | ||
| locations: List[Location] = [] | ||
| location_data = response.json()["data"] | ||
| for data in location_data: | ||
| location = Location( | ||
| lat=data["latitude"], | ||
| lon=data["longitude"], | ||
| query_string=query_string, | ||
| ) | ||
| locations.append(location) | ||
| except TypeError as e: | ||
| logger.error("Attempt to unpack response failed: {}".format(response.json())) | ||
| # TODO: Should this return a GeocoderError as well? | ||
| raise e | ||
| return locations | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| from pydantic import BaseModel | ||
| from typing import Optional | ||
| import hashlib | ||
|
|
||
|
|
||
| class Location(BaseModel): | ||
| lat: float | ||
| lon: float | ||
| query_string: Optional[str] = None | ||
|
|
||
| @property | ||
| def hashable_name(self) -> str: | ||
| return f"{self.lat}+{self.lon}" | ||
|
|
||
| @property | ||
| def hash(self) -> str: | ||
| return hashlib.md5(self.hashable_name).hexdigest() | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Honestly not sure why I wrote this. I think I had some idea about matching values by hash lookup, but now I can't really think of a way that would be useful rn... |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,21 @@ | ||
| from fastapi.testclient import TestClient | ||
| from api.api import app | ||
| from api.models import Location | ||
|
|
||
| test_app = TestClient(app) | ||
|
|
||
|
|
||
| def test_root(): | ||
| response = test_app.get("/") | ||
| assert response.status_code == 200 | ||
|
|
||
|
|
||
| def test_location(): | ||
| test_query = "Los Angeles, CA" | ||
| expected_json_response = Location( | ||
| lat=34.05513, lon=-118.25703, query_string="Los Angeles, CA" | ||
| ).dict() | ||
| response = test_app.get(f"/location/{test_query}") | ||
| assert ( | ||
| response.json() == expected_json_response | ||
| ), f"Expected {expected_json_response}, got {response}." |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| from api.geocoder import GeocoderError, get_location_from_query, parse_location_string | ||
| from api.models import Location | ||
| import pytest | ||
|
|
||
|
|
||
| def test_get_location_from_query(): | ||
| test_query_string = "San Francisco, CA" | ||
| test_limit = 4 | ||
| expected_result = Location( | ||
| lat=37.778008, | ||
| lon=-122.431272, | ||
| query_string="San Francisco, CA", | ||
| ) | ||
|
|
||
| responses = get_location_from_query(test_query_string, test_limit) | ||
| assert ( | ||
| 0 < len(responses) <= test_limit | ||
| ), f"Expected {test_limit} or fewer results, got {len(responses)}." | ||
| top_result = responses[0] | ||
| assert isinstance( | ||
| top_result, Location | ||
| ), f"Expected `Location` type, got {type(top_result)}." | ||
| assert ( | ||
| top_result == expected_result | ||
| ), f"Expected {expected_result}, got {top_result}." | ||
|
|
||
|
|
||
| def test_parse_location_string(): | ||
| valid_test_string = "34.05513+-118.25703" | ||
| expected_location = Location(lat=34.05513, lon=-118.25703) | ||
|
|
||
| parsed_location = parse_location_string(valid_test_string) | ||
| assert ( | ||
| parsed_location == expected_location | ||
| ), f"Expected {expected_location}, got {parsed_location}." | ||
|
|
||
| invalid_test_string = "foo&bar" | ||
| with pytest.raises(ValueError): | ||
| parse_location_string(invalid_test_string) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FYI, this is how you add secrets to Github and then expose them in CI: https://docs.github.com/en/actions/security-guides/encrypted-secrets