Repository: agrc/masquerade
Issue category: Feature Request

Issue:
Upgrade Python Version
              **thought**: It looks like 3.10 is the next python to EOL. Can this run on python 3.12 or 3.13 which are still getting support and not just security fixes?

https://devguide.python.org/versions/

_Originally posted by @steveoh in https://github.com/agrc/masquerade/pull/219#discussion_r1868240956_
            
Reverse Geocoding
Currently, this is not implemented. Short term, we should probably remove it from this list of capabilities: https://github.com/agrc/masquerade/blob/c9a9bffd73d11834b86c2cfe4856f589eab5c799/src/masquerade/main.py#L132

And then in the long term, we should probably implement it. @mhchou tried to use it today for something.
Translate address grids to actual cities as returned by the web api
Masquerade could be updated to fix incorrect cities returned by the web API service. It would take a secondary request(s) to OpenSGID to get the city/county.

Ref: https://github.com/agrc/api.mapserv.utah.gov/issues/245


Hints:

South Jordan is attempting to use Masquerade within their CityWorks instance and this came up again. Just another data point for motivation to get it done.


Patch:
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index a0f5cf0..98b8ee3 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -12,11 +12,13 @@
         "editorconfig.editorconfig",
         "ms-azuretools.vscode-docker",
         "ms-python.python",
-        "streetsidesoftware.code-spell-checker"
+        "streetsidesoftware.code-spell-checker",
+        "ryanluker.vscode-coverage-gutters"
       ],
       "python": {
         "defaultInterpreterPath": "/usr/local/bin/python"
       }
     }
-  }
+  },
+  "postCreateCommand": "pip install -e .[tests]"
 }
diff --git a/.vscode/settings.json b/.vscode/settings.json
index d0207af..6d5a6c7 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -4,6 +4,7 @@
   },
   "cSpell.words": [
     "ADDNUM",
+    "addopts",
     "ADDSYSTEM",
     "AGRC",
     "atch",
@@ -13,11 +14,13 @@
     "charliermarsh",
     "choco",
     "cloudrun",
+    "Cntry",
     "codecov",
     "dotenv",
     "forcelist",
     "FULLADD",
     "geocoder",
+    "geocodes",
     "geosearch",
     "Geospatial",
     "getenv",
@@ -32,7 +35,10 @@
     "jsonpify",
     "Knative",
     "Lehi",
+    "makepoint",
+    "minversion",
     "mkcert",
+    "norecursedirs",
     "nort",
     "oauth2accesstoken",
     "opensgid",
@@ -41,8 +47,11 @@
     "pytest",
     "PYTHONUNBUFFERED",
     "Referer",
+    "ryanluker",
+    "setsrid",
     "simplejson",
     "STREETNAME",
+    "testpaths",
     "UGRC",
     "UICLSID",
     "usaddress",
@@ -56,5 +65,7 @@
   "editor.rulers": [120],
   "files.insertFinalNewline": true,
   "python.testing.pytestArgs": ["--no-cov"],
-  "python.testing.pytestEnabled": true
+  "python.testing.pytestEnabled": true,
+  "coverage-gutters.showGutterCoverage": false,
+  "coverage-gutters.showLineCoverage": true
 }
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c142f3a..d834405 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,48 @@
 # Changelog
 
+## [1.5.5-4](https://github.com/agrc/masquerade/compare/v1.5.5-3...v1.5.5-4) (2024-12-06)
+
+
+### Bug Fixes
+
+* handle bad json parameters ([7bed818](https://github.com/agrc/masquerade/commit/7bed81881dab1db8f1f30990b626abcf648ef526))
+
+## [1.5.5-3](https://github.com/agrc/masquerade/compare/v1.5.5-2...v1.5.5-3) (2024-12-06)
+
+
+### Features
+
+* replace address grid with city/county name for geocoding results ([f7fb731](https://github.com/agrc/masquerade/commit/f7fb7317cd39cd60b8c8389b761056a47e0c2e90)), closes [#182](https://github.com/agrc/masquerade/issues/182)
+
+
+### Bug Fixes
+
+* fix duplicate "COUNTY" in reverse geocode results ([c78ae46](https://github.com/agrc/masquerade/commit/c78ae46b70e2227a306e024c16a9753722f0ce0e))
+
+## [1.5.5-2](https://github.com/agrc/masquerade/compare/v1.5.5-1...v1.5.5-2) (2024-12-06)
+
+
+### Bug Fixes
+
+* add types and return consistent type ([76ca7f0](https://github.com/agrc/masquerade/commit/76ca7f0e3fb89feeb2642fe21ed9973f5d1c7757))
+* escape returned input parameters ([f329207](https://github.com/agrc/masquerade/commit/f32920764492b37f0c63242acd543ca70b5b9291))
+* escape while preserving numbers ([7bb2170](https://github.com/agrc/masquerade/commit/7bb21705053ec1bbd5724ab6f7860e754d79d535))
+* use flask logger rather than print statements ([440a783](https://github.com/agrc/masquerade/commit/440a783bbaa1721281ab4970e96ae5a0a67aa2c8))
+* use parameterized query for user inputs ([1ace326](https://github.com/agrc/masquerade/commit/1ace326423fd940a9c5149bf96c9271285f30425))
+
+## [1.5.5-1](https://github.com/agrc/masquerade/compare/v1.5.4...v1.5.5-1) (2024-12-06)
+
+
+### Features
+
+* add reverse geocoding endpoint ([e4f4844](https://github.com/agrc/masquerade/commit/e4f484438d02ecb15ee37f7836107c7a69a9428e)), closes [#169](https://github.com/agrc/masquerade/issues/169)
+
+
+### Dependencies
+
+* bump deps ([1ce5264](https://github.com/agrc/masquerade/commit/1ce526411e4f7f2791a813734f1ff621a086cb5b))
+* python 3.10 -&gt; 3.12 and other bumps ([dcbcbb0](https://github.com/agrc/masquerade/commit/dcbcbb03ace591e156ebb453ded235c77f98dacb))
+
 ## [1.5.5-0](https://github.com/agrc/masquerade/compare/v1.5.4...v1.5.5-0) (2024-11-27)
 
 
diff --git a/Dockerfile b/Dockerfile
index ba4ca62..1b0ab29 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,13 +1,11 @@
-FROM python:3.10 AS base
+FROM python:3.12 AS base
 
 
 FROM base AS dev_container
 
 RUN apt install -y git
 
-COPY . .
-
-RUN pip install -e ".[tests]"
+EXPOSE 5000
 
 
 FROM base AS prod
diff --git a/README.md b/README.md
index 336e0a0..159d818 100644
--- a/README.md
+++ b/README.md
@@ -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)
 
diff --git a/pyproject.toml b/pyproject.toml
index 53f2cb5..c2bd2a7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,8 +1,6 @@
 [tool.ruff]
 line-length = 120
 ignore = ["E501"]
-[tool.black]
-line-length = 120
 [tool.pytest.ini_options]
 minversion = "6.0"
 testpaths = ["tests"]
diff --git a/setup.py b/setup.py
index 5d127b5..454c08e 100644
--- a/setup.py
+++ b/setup.py
@@ -4,6 +4,7 @@
 setup.py
 A module that installs masquerade as a module
 """
+
 from glob import glob
 from os.path import basename, splitext
 
@@ -11,7 +12,7 @@
 
 setup(
     name="masquerade",
-    version="1.5.5-0",
+    version="1.5.5-4",
     license="MIT",
     description="A proxy service that creates an Esri locator from UGRC data and web services.",
     author="UGRC",
@@ -33,28 +34,27 @@
     },
     keywords=["gis"],
     install_requires=[
-        "agrc-sweeper==1.4.*",
-        "flask-cors>=4.0,<5.1",
+        "ugrc-sweeper==2.*",
+        "flask-cors==5.*",
         "Flask-JSON==0.4.*",
-        "flask==3.0.*",
-        "psycopg_pool>=3.1,<3.3",
-        "psycopg[binary]>=3.1,<3.3",
-        "python-dotenv==1.0.*",
-        "requests>=2.32.3,<2.33",
-        "tenacity>=8.2,<9.1",
+        "flask==3.*",
+        "psycopg_pool==3.*",
+        "psycopg[binary]==3.*",
+        "pyproj==3.*",
+        "python-dotenv==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.*",
+        "simplejson==3.*",
+        "tenacity==9.*",
     ],
     extras_require={
         "tests": [
-            "black==24.*",
             "callee==0.3.*",
             "pytest-cov==6.*",
             "pytest-instafail==0.5.*",
-            "pytest-watch==4.2.*",
+            "pytest-watch==4.*",
             "pytest==8.*",
-            "requests-mock==1.12.*",
+            "requests-mock==1.*",
             "ruff==0.*",
         ]
     },
diff --git a/src/masquerade/main.py b/src/masquerade/main.py
index 6226252..04b01cf 100644
--- a/src/masquerade/main.py
+++ b/src/masquerade/main.py
@@ -13,10 +13,11 @@
 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
-from .utils import WGS84, cleanse_text, get_out_spatial_reference, get_request_param
+from .utils import WGS84, cleanse_text, escape_while_preserving_numbers, get_out_spatial_reference, get_request_param
 
 load_dotenv()
 
@@ -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": {
@@ -230,7 +231,10 @@ def geocode_addresses():
 
     request_wkid, out_spatial_reference = get_out_spatial_reference(request)
 
-    addresses = json.loads(get_request_param(request, "addresses"))
+    try:
+        addresses = json.loads(get_request_param(request, "addresses"))
+    except json.JSONDecodeError:
+        return {"error": "addresses param is not valid JSON"}, 400
 
     locations = []
 
@@ -292,6 +296,42 @@ def geocode_addresses():
     }
 
 
+@app.route(f"{GEOCODE_SERVER_ROUTE}/reverseGeocode", methods=["GET", "POST"])
+@as_json_p
+def reverse_geocode():
+    """reverse geocode a point"""
+
+    request_wkid, out_spatial_reference = get_out_spatial_reference(request)
+
+    try:
+        location = json.loads(get_request_param(request, "location"))
+    except json.JSONDecodeError:
+        return {"error": "location param is not valid JSON"}, 400
+
+    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, location["x"], location["y"])
+    escaped_result = {key: escape_while_preserving_numbers(value) for key, value in result.items()}
+
+    return {
+        "address": escaped_result,
+        "location": {
+            "x": escape_while_preserving_numbers(x),
+            "y": escape_while_preserving_numbers(y),
+            "spatialReference": {
+                "wkid": escape_while_preserving_numbers(request_wkid),
+                "latestWkid": escape_while_preserving_numbers(out_spatial_reference),
+            },
+        },
+    }
+
+
 @app.route(f"{GEOCODE_SERVER_ROUTE}/<path:path>", methods=["HEAD"])
 def geocode_head(path):
     """handle head requests from Pro"""
diff --git a/src/masquerade/providers/open_sgid.py b/src/masquerade/providers/open_sgid.py
index be1cfa4..83d954c 100644
--- a/src/masquerade/providers/open_sgid.py
+++ b/src/masquerade/providers/open_sgid.py
@@ -3,6 +3,7 @@
 """
 A provider that queries data in Open SGID
 """
+
 import re
 
 from psycopg_pool import ConnectionPool
@@ -47,7 +48,7 @@
     normalize_direction_infos.append((re.compile(rf'^(\d+) ({"|".join(permutations)})( |$)'), direction[0]))
 has_prefix_direction = re.compile(r"^\d+ (n|s|e|w)")
 
-pool = ConnectionPool(f"dbname={DATABASE} user={AGRC} password={AGRC} host={HOST}")
+pool = ConnectionPool(f"dbname={DATABASE} user={AGRC} password={AGRC} host={HOST}", open=True)
 
 
 class DatabaseConnection:
@@ -57,11 +58,11 @@ class DatabaseConnection:
         stop=stop_after_attempt(RETRY_ATTEMPTS),
         wait=wait_exponential(multiplier=0.5, min=RETRY_WAIT_MIN, max=RETRY_WAIT_MAX),
     )
-    def query(self, query):
+    def query(self, query, params=None):
         """get records from the database"""
         with pool.connection() as connection:
             with connection.cursor() as cursor:
-                cursor.execute(query)
+                cursor.execute(query, params)
 
                 return cursor.fetchall()
 
@@ -389,3 +390,32 @@ class NoTableFoundException(Exception):
 
     def __init__(self, table_name):
         super().__init__(f"No table found: {table_name}")
+
+
+def get_boundary_value(x: float, y: float, spatial_reference: int, table_name: str, field_name: str) -> str | None:
+    """return the value of a given field for a point within a specified boundary table"""
+    query = f"""
+        select {field_name} from {table_name}
+        where st_contains(shape, st_transform(st_setsrid(st_makepoint(%s, %s), %s), 26912))
+    """
+    records = database.query(query, (x, y, spatial_reference))
+
+    if len(records) > 0:
+        return records[0][0]
+
+    return None
+
+
+def get_zip(x: float, y: float, spatial_reference: int) -> str | None:
+    """return the zip code for a given point"""
+    return get_boundary_value(x, y, spatial_reference, "opensgid.boundaries.zip_code_areas", "zip5")
+
+
+def get_city(x: float, y: float, spatial_reference: int) -> str | None:
+    """return the city for a given point"""
+    return get_boundary_value(x, y, spatial_reference, "opensgid.boundaries.municipal_boundaries", "name")
+
+
+def get_county(x: float, y: float, spatial_reference: int) -> str | None:
+    """return the county for a given point"""
+    return get_boundary_value(x, y, spatial_reference, "opensgid.boundaries.county_boundaries", "name")
diff --git a/src/masquerade/providers/web_api.py b/src/masquerade/providers/web_api.py
index 18bb635..e3e5e8e 100644
--- a/src/masquerade/providers/web_api.py
+++ b/src/masquerade/providers/web_api.py
@@ -6,16 +6,21 @@
 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
 
 import requests
+from flask import current_app
 from requests.adapters import HTTPAdapter
 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):
@@ -32,7 +37,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]")
@@ -90,7 +95,7 @@ def _get_retry_session():
 session = _get_retry_session()
 
 
-def make_request(address, zone, out_spatial_reference, max_locations):
+def make_geocode_request(address: str, zone: str, out_spatial_reference: int, max_locations: int) -> list:
     """makes a request to the web api geocoding service"""
     parameters = {
         "apiKey": os.getenv("WEB_API_KEY"),
@@ -98,10 +103,9 @@ def make_request(address, zone, out_spatial_reference, max_locations):
         "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 []
@@ -110,15 +114,15 @@ def make_request(address, zone, out_spatial_reference, max_locations):
         try:
             result = response.json()["result"]
         except Exception:
-            print(f"Error parsing result: {response.text}")
+            current_app.warning(f"Error parsing web api geocoding result: {response.text}")
             return []
 
         if "score" in result:
             if result["score"] == 100 or max_locations == 1 and result["score"] >= MIN_SCORE_FOR_BATCH:
-                return [etl_candidate(result)]
+                return [etl_candidate(result, out_spatial_reference)]
 
         if "candidates" in result:
-            return [etl_candidate(candidate) for candidate in result["candidates"]]
+            return [etl_candidate(candidate, out_spatial_reference) for candidate in result["candidates"]]
 
         return []
 
@@ -130,7 +134,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]
@@ -149,7 +153,7 @@ def get_candidate_from_single_line(single_line_address, out_spatial_reference):
     return None
 
 
-def etl_candidate(ugrc_candidate):
+def etl_candidate(ugrc_candidate: dict, spatial_reference: int) -> dict:
     """translates an UGRC Web API candidate to an Esri locator candidate"""
     address = ugrc_candidate["address"] if "address" in ugrc_candidate else ugrc_candidate["matchAddress"]
     try:
@@ -157,6 +161,20 @@ def etl_candidate(ugrc_candidate):
     except KeyError:
         standardized_address = None
 
+    match_city = address.split(",")[-1].strip()
+    city_or_county = open_sgid.get_city(
+        ugrc_candidate["location"]["x"], ugrc_candidate["location"]["y"], spatial_reference
+    )
+    if not city_or_county:
+        city_or_county = open_sgid.get_county(
+            ugrc_candidate["location"]["x"], ugrc_candidate["location"]["y"], spatial_reference
+        )
+        if city_or_county:
+            city_or_county = f"{city_or_county} COUNTY"
+
+    if city_or_county and match_city.upper() != city_or_county.upper():
+        address = address.replace(match_city, city_or_county.upper())
+
     return {
         "address": address,
         "attributes": {
@@ -170,3 +188,70 @@ def etl_candidate(ugrc_candidate):
         "location": ugrc_candidate["location"],
         "score": ugrc_candidate["score"],
     }
+
+
+def reverse_geocode(x: float, y: float, spatial_reference: int, input_x: float, input_y: float) -> dict:
+    """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)
+    if county:
+        county = f"{county} COUNTY"
+    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,
+        "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": input_x,
+        "InputY": input_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:
+            current_app.warning(f"Error parsing web api reverse geocode result: {response.text}")
+            return result
+
+        street = api_result["address"]["street"]
+        address_type = api_result["address"]["addressType"]
+        match_address = f"{street}, {city or 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
diff --git a/src/masquerade/utils.py b/src/masquerade/utils.py
index 10ee1bd..c1d587e 100644
--- a/src/masquerade/utils.py
+++ b/src/masquerade/utils.py
@@ -3,8 +3,11 @@
 """
 Utility functions
 """
+
 import json
 
+from markupsafe import escape
+
 WGS84 = 4326
 WEB_MERCATOR = 3857
 OLD_WEB_MERCATOR = 102100
@@ -48,3 +51,11 @@ def get_out_spatial_reference(incoming_request):
         request_wkid,
         WEB_MERCATOR if request_wkid == OLD_WEB_MERCATOR else request_wkid,
     )
+
+
+def escape_while_preserving_numbers(value: int | float | str) -> int | float | str:
+    """escape a value while preserving numbers"""
+    if isinstance(value, (int, float)):
+        return value
+
+    return escape(value)


Test patch:
diff --git a/tests/test_masquerade.py b/tests/test_masquerade.py
index fb2b05d..4ae875a 100644
--- a/tests/test_masquerade.py
+++ b/tests/test_masquerade.py
@@ -35,6 +35,13 @@ def test_base_geocode_route(test_client):
     assert b"locatorProperties" in response.data
 
 
+def test_geocode_bad_request(test_client):
+    response = test_client.get(f"{GEOCODE_SERVER_ROUTE}/geocodeAddresses", query_string={"addresses": "not a list"})
+
+    assert response.status_code == 400
+    assert b"not valid JSON" in response.data
+
+
 @mock.patch("masquerade.main.open_sgid.get_candidate_from_magic_key")
 def test_find_candidates(get_candidate_mock, test_client):
     get_candidate_mock.return_value = "blah"
@@ -185,3 +192,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
diff --git a/tests/test_open_sgid.py b/tests/test_open_sgid.py
index 6e39ad1..e31b90c 100644
--- a/tests/test_open_sgid.py
+++ b/tests/test_open_sgid.py
@@ -12,6 +12,7 @@
     POINT,
     FullTextTable,
     NoTableFoundException,
+    get_boundary_value,
     get_candidate_from_magic_key,
     get_suggestions,
     get_table_from_table_name,
@@ -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
diff --git a/tests/test_utils.py b/tests/test_utils.py
index f76c6f5..05debef 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,6 +1,8 @@
 from unittest.mock import MagicMock
 
-from masquerade.utils import cleanse_text, get_out_spatial_reference
+from markupsafe import Markup
+
+from masquerade.utils import cleanse_text, escape_while_preserving_numbers, get_out_spatial_reference
 
 
 def test_removes_spaces():
@@ -49,3 +51,19 @@ def test_get_out_spatial_reference_post_request():
     request.form = {"outSR": 3857}
 
     assert get_out_spatial_reference(request) == (3857, 3857)
+
+
+def test_escape_while_preserving_numbers_with_int():
+    assert escape_while_preserving_numbers(123) == 123
+
+
+def test_escape_while_preserving_numbers_with_float():
+    assert escape_while_preserving_numbers(123.45) == 123.45
+
+
+def test_escape_while_preserving_numbers_with_string():
+    assert escape_while_preserving_numbers("<script>") == Markup("&lt;script&gt;")
+
+
+def test_escape_while_preserving_numbers_with_safe_string():
+    assert escape_while_preserving_numbers(Markup("<strong>safe</strong>")) == Markup("<strong>safe</strong>")
diff --git a/tests/test_web_api.py b/tests/test_web_api.py
index cc712e2..4b6dada 100644
--- a/tests/test_web_api.py
+++ b/tests/test_web_api.py
@@ -3,38 +3,68 @@
 """
 A module that contains methods for testing the web_api module
 """
+
 import re
+from unittest import mock
 
 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,
     get_candidates_from_single_line,
+    reverse_geocode,
 )
 
 
-def test_etl_candidate():
+@mock.patch("masquerade.providers.open_sgid.get_city")
+def test_etl_candidate(get_city_mock):
+    get_city_mock.return_value = "WEST VALLEY CITY"
     input = {
-        "address": "3989 SOUTH FRONTAGE RD, Salt Lake City",
+        "address": "3989 SOUTH FRONTAGE RD, SALT LAKE CITY",
         "location": {"x": 416707.62244415266, "y": 4508458.9846219225},
         "score": 69.92,
         "locator": "Centerlines.StatewideRoads",
-        "addressGrid": "Salt Lake City",
+        "addressGrid": "SALT LAKE CITY",
+    }
+
+    result = etl_candidate(input, 26912)
+
+    assert result["address"] == "3989 SOUTH FRONTAGE RD, WEST VALLEY CITY"
+    assert result["attributes"]["score"] == 69.92
+    assert result["attributes"]["locator"] == input["locator"]
+    assert result["attributes"]["addressGrid"] == input["addressGrid"]
+    assert result["location"] == input["location"]
+
+
+@mock.patch("masquerade.providers.open_sgid.get_city")
+@mock.patch("masquerade.providers.open_sgid.get_county")
+def test_etl_candidate_fallback_to_county(get_county_mock, get_city_mock):
+    get_city_mock.return_value = None
+    get_county_mock.return_value = "SALT LAKE"
+    input = {
+        "address": "3989 SOUTH FRONTAGE RD, SALT LAKE CITY",
+        "location": {"x": 416707.62244415266, "y": 4508458.9846219225},
+        "score": 69.92,
+        "locator": "Centerlines.StatewideRoads",
+        "addressGrid": "SALT LAKE CITY",
     }
 
-    result = etl_candidate(input)
+    result = etl_candidate(input, 26912)
 
-    assert result["address"] == input["address"]
+    assert result["address"] == "3989 SOUTH FRONTAGE RD, SALT LAKE COUNTY"
     assert result["attributes"]["score"] == 69.92
     assert result["attributes"]["locator"] == input["locator"]
     assert result["attributes"]["addressGrid"] == input["addressGrid"]
     assert result["location"] == input["location"]
 
 
-def test_etl_candidate_base_result():
+@mock.patch("masquerade.providers.open_sgid.get_city")
+def test_etl_candidate_base_result(get_city_mock):
+    get_city_mock.return_value = "HOLLADAY"
+
     #: this is a test for the base result object as opposed to result.candidate objects
     input = {
         "location": {"x": 429340.24421129236, "y": 4504146.207401402},
@@ -47,9 +77,9 @@ def test_etl_candidate_base_result():
         "candidates": [],
     }
 
-    result = etl_candidate(input)
+    result = etl_candidate(input, 26912)
 
-    assert result["address"] == input["matchAddress"]
+    assert result["address"] == "3987 S 1925 E, HOLLADAY"
     assert result["score"] == 100
     assert result["attributes"]["locator"] == input["locator"]
     assert result["attributes"]["addressGrid"] == input["addressGrid"]
@@ -107,7 +137,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
@@ -164,7 +194,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
@@ -185,7 +215,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
@@ -205,7 +235,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
@@ -225,14 +255,14 @@ 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)
@@ -240,7 +270,7 @@ def test_get_address_candidates_raises(requests_mock):
 
 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.",
@@ -255,3 +285,113 @@ def test_get_address_candidates_bad_address(requests_mock):
     candidates = get_candidates_from_single_line("0101300036", 3857, 5)
 
     assert len(candidates) == 0
+
+
+@mock.patch("masquerade.providers.open_sgid.get_city")
+@mock.patch("masquerade.providers.open_sgid.get_county")
+@mock.patch("masquerade.providers.open_sgid.get_zip")
+def test_reverse_geocode(get_zip_mock, get_county_mock, get_city_mock, requests_mock):
+    get_city_mock.return_value = "SALT LAKE CITY"
+    get_county_mock.return_value = "SALT LAKE"
+    get_zip_mock.return_value = "84101"
+
+    mock_response = {
+        "result": {
+            "address": {
+                "street": "123 S MAIN ST",
+                "addressType": "PointAddress",
+            }
+        },
+        "status": 200,
+    }
+
+    requests_mock.get(re.compile(f"{BASE_URL}/geocode/reverse.*"), json=mock_response)
+
+    result = reverse_geocode(-12455627.277556794, 4977968.997941715, 3857, -12455627.277556794, 4977968.997941715)
+
+    assert result["Match_addr"] == "123 S MAIN ST, SALT LAKE CITY, UTAH, 84101"
+    assert result["LongLabel"] == "123 S MAIN ST, SALT LAKE CITY, UTAH, 84101, USA"
+    assert result["ShortLabel"] == "123 S MAIN ST"
+    assert result["Addr_type"] == "PointAddress"
+    assert result["AddNum"] == "123"
+    assert result["Address"] == "123 S MAIN ST"
+    assert result["City"] == "SALT LAKE CITY"
+    assert result["Subregion"] == "SALT LAKE COUNTY"
+    assert result["Region"] == "UTAH"
+    assert result["Postal"] == "84101"
+    assert result["CntryName"] == "UNITED STATES"
+    assert result["CountryCode"] == "USA"
+    assert result["X"] == -12455627.277556794
+    assert result["Y"] == 4977968.997941715
+    assert result["InputX"] == -12455627.277556794
+    assert result["InputY"] == 4977968.997941715
+
+
+@mock.patch("masquerade.providers.open_sgid.get_city")
+@mock.patch("masquerade.providers.open_sgid.get_county")
+@mock.patch("masquerade.providers.open_sgid.get_zip")
+def test_reverse_geocode_no_city(get_zip_mock, get_county_mock, get_city_mock, requests_mock):
+    get_city_mock.return_value = None
+    get_county_mock.return_value = "SALT LAKE"
+    get_zip_mock.return_value = "84101"
+
+    mock_response = {
+        "result": {
+            "address": {
+                "street": "123 S MAIN ST",
+                "addressType": "PointAddress",
+            }
+        },
+        "status": 200,
+    }
+
+    requests_mock.get(re.compile(f"{BASE_URL}/geocode/reverse.*"), json=mock_response)
+
+    result = reverse_geocode(-12455627.277556794, 4977968.997941715, 3857, -12455627.277556794, 4977968.997941715)
+
+    assert result["Match_addr"] == "123 S MAIN ST, SALT LAKE COUNTY, UTAH, 84101"
+    assert result["LongLabel"] == "123 S MAIN ST, SALT LAKE COUNTY, UTAH, 84101, USA"
+    assert result["ShortLabel"] == "123 S MAIN ST"
+    assert result["Addr_type"] == "PointAddress"
+    assert result["AddNum"] == "123"
+    assert result["Address"] == "123 S MAIN ST"
+    assert result["City"] == ""
+    assert result["Subregion"] == "SALT LAKE COUNTY"
+    assert result["Region"] == "UTAH"
+    assert result["Postal"] == "84101"
+    assert result["CntryName"] == "UNITED STATES"
+    assert result["CountryCode"] == "USA"
+    assert result["X"] == -12455627.277556794
+    assert result["Y"] == 4977968.997941715
+    assert result["InputX"] == -12455627.277556794
+    assert result["InputY"] == 4977968.997941715
+
+
+@mock.patch("masquerade.providers.open_sgid.get_city")
+@mock.patch("masquerade.providers.open_sgid.get_county")
+@mock.patch("masquerade.providers.open_sgid.get_zip")
+def test_reverse_geocode_no_response(get_zip_mock, get_county_mock, get_city_mock, requests_mock):
+    get_city_mock.return_value = "SALT LAKE CITY"
+    get_county_mock.return_value = "SALT LAKE"
+    get_zip_mock.return_value = "84101"
+
+    requests_mock.get(re.compile(f"{BASE_URL}/geocode/reverse.*"), status_code=500)
+
+    result = reverse_geocode(-12455627.277556794, 4977968.997941715, 3857, -12455627.277556794, 4977968.997941715)
+
+    assert result["Match_addr"] == ""
+    assert result["LongLabel"] == ""
+    assert result["ShortLabel"] == ""
+    assert result["Addr_type"] == ""
+    assert result["AddNum"] == ""
+    assert result["Address"] == ""
+    assert result["City"] == "SALT LAKE CITY"
+    assert result["Subregion"] == "SALT LAKE COUNTY"
+    assert result["Region"] == "UTAH"
+    assert result["Postal"] == "84101"
+    assert result["CntryName"] == "UNITED STATES"
+    assert result["CountryCode"] == "USA"
+    assert result["X"] == -12455627.277556794
+    assert result["Y"] == 4977968.997941715
+    assert result["InputX"] == -12455627.277556794
+    assert result["InputY"] == 4977968.997941715