Skip to content

Commit

Permalink
feat: add reverse geocoding endpoint
Browse files Browse the repository at this point in the history
Closes #169
  • Loading branch information
stdavis committed Dec 9, 2024
1 parent e1b21cd commit b138b80
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 19 deletions.
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@
"charliermarsh",
"choco",
"cloudrun",
"Cntry",
"codecov",
"dotenv",
"forcelist",
"FULLADD",
"geocoder",
"geocodes",
"geosearch",
"Geospatial",
"getenv",
Expand All @@ -32,6 +34,7 @@
"jsonpify",
"Knative",
"Lehi",
"makepoint",
"mkcert",
"nort",
"oauth2accesstoken",
Expand All @@ -41,6 +44,8 @@
"pytest",
"PYTHONUNBUFFERED",
"Referer",
"ryanluker",
"setsrid",
"simplejson",
"STREETNAME",
"UGRC",
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ _Give Masquerade's awesome searching capabilities a try in this [simple web app]

✅ Geocode Addresses

Suggest
Reverse Geocode

Reverse Geocode has not been implemented. [Let us know](https://github.com/agrc/masquerade/issues/new) if you are interested in this feature!
✅ Suggest

[Esri REST API Reference](https://developers.arcgis.com/rest/services-reference/enterprise/geocode-service.htm)

Expand Down
4 changes: 3 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
setup.py
A module that installs masquerade as a module
"""

from glob import glob
from os.path import basename, splitext

Expand Down Expand Up @@ -39,12 +40,13 @@
"flask==3.0.*",
"psycopg_pool>=3.1,<3.3",
"psycopg[binary]>=3.1,<3.3",
"pyproj>=3.7,<4",
"python-dotenv==1.0.*",
"requests>=2.32.3,<2.33",
"tenacity>=8.2,<9.1",
#: flask uses this by default if installed
#: this handles decimals as returned from open sgid data better than the default json library
"simplejson==3.19.*",
"tenacity>=8.2,<9.1",
],
extras_require={
"tests": [
Expand Down
34 changes: 33 additions & 1 deletion src/masquerade/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from flask.logging import create_logger
from flask_cors import CORS
from flask_json import FlaskJSON, as_json_p
from pyproj import CRS, Transformer
from requests.models import HTTPError

from .providers import open_sgid, web_api
Expand Down Expand Up @@ -126,7 +127,7 @@ def geocode_base():
"required": True,
},
],
"capabilities": ",".join(["Geocode", "Suggest"]),
"capabilities": ",".join(["Geocode", "ReverseGeocode", "Suggest"]),
"countries": ["US"],
"currentVersion": f"{SERVER_VERSION_MAJOR}.{SERVER_VERSION_MINOR}{SERVER_VERSION_PATCH}",
"locatorProperties": {
Expand Down Expand Up @@ -292,6 +293,37 @@ def geocode_addresses():
}


@app.route(f"{GEOCODE_SERVER_ROUTE}/reverseGeocode", methods=["GET", "POST"])
def reverse_geocode():
"""reverse geocode a point"""

request_wkid, out_spatial_reference = get_out_spatial_reference(request)

location = json.loads(get_request_param(request, "location"))

if location["spatialReference"]["wkid"] != out_spatial_reference:
from_crs = CRS.from_epsg(location["spatialReference"]["wkid"])
to_crs = CRS.from_epsg(out_spatial_reference)
transformer = Transformer.from_crs(from_crs, to_crs, always_xy=True)
x, y = transformer.transform(location["x"], location["y"])
else:
x, y = location["x"], location["y"]

result = web_api.reverse_geocode(x, y, out_spatial_reference)

return {
"address": result,
"location": {
"x": location["x"],
"y": location["y"],
"spatialReference": {
"wkid": request_wkid,
"latestWkid": out_spatial_reference,
},
},
}


@app.route(f"{GEOCODE_SERVER_ROUTE}/<path:path>", methods=["HEAD"])
def geocode_head(path):
"""handle head requests from Pro"""
Expand Down
82 changes: 75 additions & 7 deletions src/masquerade/providers/web_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
This module shares a fair amount of code with this one:
https://github.com/agrc/geocoding-toolbox/blob/master/src/agrcgeocoding/geocode.py
"""

import os
import re

Expand All @@ -14,8 +15,11 @@
from sweeper.address_parser import Address
from urllib3.util.retry import Retry

WEB_API_URL = "https://api.mapserv.utah.gov/api/v1/geocode"
from . import open_sgid

BASE_URL = "https://api.mapserv.utah.gov/api/v1"
MIN_SCORE_FOR_BATCH = 70
HEADERS = {"Referer": "https://masquerade.ugrc.utah.gov"}


def get_candidates_from_single_line(single_line_address, out_spatial_reference, max_locations):
Expand All @@ -32,7 +36,7 @@ def get_candidates_from_single_line(single_line_address, out_spatial_reference,
if not zone or not parsed_address.normalized:
return []

return make_request(parsed_address.normalized, zone, out_spatial_reference, max_locations)
return make_geocode_request(parsed_address.normalized, zone, out_spatial_reference, max_locations)


ALLOWABLE_CHARS = re.compile("[^a-zA-Z0-9]")
Expand Down Expand Up @@ -90,18 +94,17 @@ def _get_retry_session():
session = _get_retry_session()


def make_request(address, zone, out_spatial_reference, max_locations):
def make_geocode_request(address, zone, out_spatial_reference, max_locations):
"""makes a request to the web api geocoding service"""
parameters = {
"apiKey": os.getenv("WEB_API_KEY"),
"spatialReference": out_spatial_reference,
"suggest": max_locations,
}

headers = {"Referer": "https://masquerade.ugrc.utah.gov"}
url = f"{WEB_API_URL}/{_cleanse_street(address)}/{_cleanse_zone(zone)}"
url = f"{BASE_URL}/geocode/{_cleanse_street(address)}/{_cleanse_zone(zone)}"

response = session.get(url, params=parameters, headers=headers, timeout=10)
response = session.get(url, params=parameters, headers=HEADERS, timeout=10)

if response.status_code == 404 and "no address candidates found" in response.text.lower():
return []
Expand Down Expand Up @@ -130,7 +133,7 @@ def make_request(address, zone, out_spatial_reference, max_locations):
def get_candidate_from_parts(address, zone, out_spatial_reference):
"""gets a single candidate from address & zone input"""

candidates = make_request(address, zone, out_spatial_reference, 1)
candidates = make_geocode_request(address, zone, out_spatial_reference, 1)

if len(candidates) > 0:
return candidates[0]
Expand Down Expand Up @@ -170,3 +173,68 @@ def etl_candidate(ugrc_candidate):
"location": ugrc_candidate["location"],
"score": ugrc_candidate["score"],
}


def reverse_geocode(x, y, spatial_reference):
"""reverse geocodes a point using web api supplemented by open sgid queries"""

city = open_sgid.get_city(x, y, spatial_reference)
city = city.upper() if city else None
county = open_sgid.get_county(x, y, spatial_reference)
zip_code = open_sgid.get_zip(x, y, spatial_reference)

#: example esri result: https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/reverseGeocode?location=%7B%22spatialReference%22%3A%7B%22wkid%22%3A102100%7D%2C%22x%22%3A-12452539.51148021%2C%22y%22%3A4947846.054923615%7D&f=json
result = {
"Match_addr": "",
"LongLabel": "",
"ShortLabel": "",
"Addr_type": "",
"Type": "", # unused by masquerade
"AddNum": "",
"Address": "",
"Block": "", # unused by masquerade
"Sector": "", # unused by masquerade
"Neighborhood": "", # unused by masquerade
"District": "", # unused by masquerade
"City": city or "",
"MetroArea": "", # unused by masquerade
"Subregion": county or "",
"Region": "UTAH",
"RegionAbbr": "UT",
"Territory": "", # unused by masquerade
"Postal": zip_code or "",
"PostalExt": "", # unused by masquerade
"CntryName": "UNITED STATES",
"CountryCode": "USA",
"X": x,
"Y": y,
"InputX": x,
"InputY": y,
}

parameters = {
"apiKey": os.getenv("WEB_API_KEY"),
"spatialReference": spatial_reference,
}
url = f"{BASE_URL}/geocode/reverse/{x}/{y}"
response = session.get(url, params=parameters, headers=HEADERS, timeout=10)

if response.status_code == 200 and response.ok:
try:
api_result = response.json()["result"]
except Exception:
print(f"Error parsing result: {response.text}")
return None

street = api_result["address"]["street"]
address_type = api_result["address"]["addressType"]
match_address = f"{street}, {city or f'{county} COUNTY'}, UTAH, {zip_code}"

result["Match_addr"] = match_address
result["LongLabel"] = f"{match_address}, USA"
result["ShortLabel"] = street
result["Addr_type"] = address_type
result["AddNum"] = street.split(" ")[0]
result["Address"] = street

return result
41 changes: 41 additions & 0 deletions tests/test_masquerade.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,44 @@ def test_can_handle_output_sr_in_numeric_form(test_client):
response_json = json.loads(response.data)

assert response_json["spatialReference"]["latestWkid"] == 4326


@mock.patch("masquerade.main.web_api.reverse_geocode")
def test_reverse_geocode(reverse_geocode_mock, test_client):
reverse_geocode_mock.return_value = {
"address": "123 Main St",
}
response = test_client.get(
f"{GEOCODE_SERVER_ROUTE}/reverseGeocode",
query_string={
"location": dumps({"spatialReference": {"wkid": 102100}, "x": -12448301.645792466, "y": 4947055.905820554})
},
)

assert response.status_code == 200

response_json = json.loads(response.data)

assert response_json["address"]["address"] == "123 Main St"
assert response_json["location"]["spatialReference"]["wkid"] == 4326


@mock.patch("masquerade.main.web_api.reverse_geocode")
def test_reverse_geocode_output_spatial_reference(reverse_geocode_mock, test_client):
reverse_geocode_mock.return_value = {
"address": "123 Main St",
}
response = test_client.get(
f"{GEOCODE_SERVER_ROUTE}/reverseGeocode",
query_string={
"location": dumps({"spatialReference": {"wkid": 102100}, "x": -12448301.645792466, "y": 4947055.905820554}),
"outSR": 26912,
},
)

assert response.status_code == 200

response_json = json.loads(response.data)

assert response_json["address"]["address"] == "123 Main St"
assert response_json["location"]["spatialReference"]["wkid"] == 26912
16 changes: 16 additions & 0 deletions tests/test_open_sgid.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
POINT,
FullTextTable,
NoTableFoundException,
get_boundary_value,
get_candidate_from_magic_key,
get_suggestions,
get_table_from_table_name,
Expand Down Expand Up @@ -56,3 +57,18 @@ def test_full_text_table():
table = FullTextTable("table_name", "search_field", POINT)

assert "%hello%" in table.get_suggest_query("hello", 10)


def test_get_boundary_value():
mock_db = mock.Mock()
mock_db.query.return_value = [("boundary_value",)]

with mock.patch("masquerade.providers.open_sgid.database", new=mock_db):
value = get_boundary_value(1, 2, 4326, "table_name", "field_name")
assert value == "boundary_value"

mock_db.query.return_value = []

with mock.patch("masquerade.providers.open_sgid.database", new=mock_db):
value = get_boundary_value(1, 2, 4326, "table_name", "field_name")
assert value is None
17 changes: 9 additions & 8 deletions tests/test_web_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
"""
A module that contains methods for testing the web_api module
"""

import re

from pytest import raises

from masquerade.providers.web_api import (
WEB_API_URL,
BASE_URL,
etl_candidate,
get_candidate_from_parts,
get_candidate_from_single_line,
Expand Down Expand Up @@ -107,7 +108,7 @@ def test_get_address_candidate(requests_mock):
"status": 200,
}

requests_mock.get(re.compile(f"{WEB_API_URL}.*"), json=mock_response)
requests_mock.get(re.compile(f"{BASE_URL}/geocode.*"), json=mock_response)
candidates = get_candidates_from_single_line("123 s main, 84115", 3857, 5)

assert len(candidates) == 5
Expand Down Expand Up @@ -164,7 +165,7 @@ def test_get_address_candidate_perfect_match(requests_mock):
"status": 200,
}

requests_mock.get(re.compile(f"{WEB_API_URL}.*"), json=mock_response)
requests_mock.get(re.compile(f"{BASE_URL}/geocode.*"), json=mock_response)
candidates = get_candidates_from_single_line("123 s main st, 84115", 3857, 5)

assert len(candidates) == 1
Expand All @@ -185,7 +186,7 @@ def test_get_address_candidate_single_result_for_batch(requests_mock):
"status": 200,
}

requests_mock.get(re.compile(f"{WEB_API_URL}.*"), json=mock_response)
requests_mock.get(re.compile(f"{BASE_URL}/geocode.*"), json=mock_response)
candidate = get_candidate_from_parts("123 s main st", "84115", 3857)

assert candidate["score"] == 80
Expand All @@ -205,7 +206,7 @@ def test_get_address_candidate_no_candidates(requests_mock):
"status": 200,
}

requests_mock.get(re.compile(f"{WEB_API_URL}.*"), json=mock_response)
requests_mock.get(re.compile(f"{BASE_URL}/geocode.*"), json=mock_response)
candidate = get_candidate_from_parts("123 s main st", "84115", 3857)

assert candidate is None
Expand All @@ -225,22 +226,22 @@ def test_get_candidate_from_single_line(requests_mock):
"status": 200,
}

requests_mock.get(re.compile(f"{WEB_API_URL}.*"), json=mock_response)
requests_mock.get(re.compile(f"{BASE_URL}/geocode.*"), json=mock_response)
candidate = get_candidate_from_single_line("123 s main st, 84115", 3857)

assert candidate["score"] == 100


def test_get_address_candidates_raises(requests_mock):
requests_mock.get(re.compile(f"{WEB_API_URL}.*"), json={}, status_code=500)
requests_mock.get(re.compile(f"{BASE_URL}/geocode.*"), json={}, status_code=500)

with raises(Exception):
get_candidates_from_single_line("123 s main street, 84114", 3857, 5)


def test_get_address_candidates_bad_address(requests_mock):
requests_mock.get(
re.compile(f"{WEB_API_URL}.*"),
re.compile(f"{BASE_URL}/geocode.*"),
json={
"status": 404,
"message": "No address candidates found with a score of 70 or better.",
Expand Down

0 comments on commit b138b80

Please sign in to comment.