Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/backend-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,7 @@ jobs:
run: |
black services/backend/api services/backend/tests --check
- name: Test with pytest
env:
GEOCODER_API_KEY: ${{ secrets.GEOCODER_API_KEY }}
Copy link
Copy Markdown
Member Author

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

run: |
pytest services/backend/tests
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ __pycache__/

## Other Stuff
.vscode/
.env

# misc
.DS_Store
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ services:
- 8000:8000
volumes:
- ./services/backend/api:/app/api
env_file:
- .env
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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 key=value format.

frontend:
build: services/frontend
ports:
Expand Down
16 changes: 15 additions & 1 deletion services/backend/api/api.py
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):
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't make this an async function because it ultimately relies on requests which currently doesn't support async. There are async http libs but I don't care enough to learn them.

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))
97 changes: 97 additions & 0 deletions services/backend/api/geocoder.py
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)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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
17 changes: 17 additions & 0 deletions services/backend/api/models.py
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()
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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...

12 changes: 12 additions & 0 deletions services/backend/tests/test_api.py
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}."
39 changes: 39 additions & 0 deletions services/backend/tests/test_geocoder.py
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)