Add /api/reverse/<lat>/<lon> localhost-sourced enrichment bundle

New geocode_bp sibling to the existing /api/reverse?lat=&lon= route (which
is unchanged). Returns a flat 9-field bundle for the Central enrichment
framework: name, city, county, state, country, postal_code (Photon),
timezone (timezones.sqlite via R-tree + shapely), landclass (in-process
lookup_landclass), elevation_m (Valhalla /height).

- Each component lookup is independent and wrapped in try/except: a failure
  logs a warning and yields null, never a 5xx. 400 only on unparseable /
  out-of-range coordinates.
- lat/lon parsed manually rather than via Flask <float:>, which rejects
  negative and integer coordinates and would 404 instead of 400.
- 10k-entry / 24h TTLCache keyed on coords rounded to 4 decimals.
- Tests mock Photon/Valhalla/landclass; one test exercises the real
  timezones.sqlite. cachetools pinned in requirements.txt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-20 05:33:45 +00:00
commit f276b95753
3 changed files with 298 additions and 0 deletions

View file

@ -4,13 +4,19 @@ RECON Netsyms API + Geocode — Flask Blueprints.
GET /api/netsyms/lookup?q=<free text>&country=<optional>
GET /api/netsyms/health
GET /api/geocode?q=<query>&limit=<N> (Photon-first search with ranked results)
GET /api/reverse/<lat>/<lon> (localhost-sourced enrichment bundle for Central)
"""
import sqlite3
import threading
from cachetools import TTLCache
from flask import Blueprint, request, jsonify
from . import netsyms
from . import address_book
from . import nav_tools
from .geocode import PHOTON_URL
from .utils import setup_logging
logger = setup_logging('recon.netsyms_api')
@ -124,3 +130,158 @@ def api_reverse():
results = _parse_photon_features(features, source='photon_reverse')
return jsonify({'query': query_str, 'results': results, 'count': len(results)})
# ─────────────────────────────────────────────────────────────────────────
# /api/reverse/<lat>/<lon> — localhost-sourced enrichment bundle (Central)
#
# Sibling to the query-string /api/reverse above; that route is unchanged.
# Every component is sourced from localhost only (Photon, timezones.sqlite,
# in-process landclass/PostGIS, Valhalla). Each lookup is independent: a
# component failure logs a warning and yields null — never a 5xx.
# ─────────────────────────────────────────────────────────────────────────
_TZ_DB_PATH = "/mnt/nav/sources/timezones.sqlite"
_VALHALLA_HEIGHT_URL = "http://localhost:8002/height"
# Full bundle cache: key=(round(lat,4), round(lon,4)) -> dict. ~10k entries, 24h TTL.
_REVERSE_BUNDLE_CACHE = TTLCache(maxsize=10_000, ttl=86_400)
_REVERSE_BUNDLE_LOCK = threading.Lock()
_BUNDLE_KEYS = ('name', 'city', 'county', 'state', 'country',
'postal_code', 'timezone', 'landclass', 'elevation_m')
def _spatialite_blob_to_wkb(blob):
"""Recover standard WKB from a SpatiaLite geometry BLOB.
Layout: [00][endian][srid:4][mbr:32][7C][WKB body][FE]. The body omits the
leading byte-order marker, so we re-prepend it and drop the trailing 0xFE.
"""
return bytes([blob[1]]) + blob[39:-1]
def _reverse_photon(lat, lon):
"""Nearest-feature admin fields from local Photon. Returns the six address
fields (any value may be None). Mirrors the existing /api/reverse call."""
import requests as http_requests
resp = http_requests.get(
f"{PHOTON_URL}/reverse",
params={"lat": lat, "lon": lon, "limit": 1},
timeout=10,
)
resp.raise_for_status()
features = resp.json().get("features", [])
if not features:
return {}
props = features[0].get("properties", {})
return {
"name": props.get("name"),
"city": props.get("city"),
"county": props.get("county"),
"state": props.get("state"),
"country": props.get("country"),
"postal_code": props.get("postcode"),
}
def _reverse_timezone(lat, lon):
"""IANA tzid for the point from local timezones.sqlite (SpatiaLite tz_world).
Uses the table's R-tree index for an MBR prefilter, then shapely
point-in-polygon on the few candidates. Returns None if unresolved.
"""
from shapely import wkb
from shapely.geometry import Point
con = sqlite3.connect(f"file:{_TZ_DB_PATH}?mode=ro", uri=True)
try:
cur = con.cursor()
cur.execute(
"SELECT pkid FROM idx_tz_world_geom "
"WHERE xmin<=? AND xmax>=? AND ymin<=? AND ymax>=?",
(lon, lon, lat, lat),
)
candidates = [r[0] for r in cur.fetchall()]
if not candidates:
return None
pt = Point(lon, lat)
for pk in candidates:
row = cur.execute(
"SELECT tzid, geom FROM tz_world WHERE pk_uid=?", (pk,)
).fetchone()
if row and wkb.loads(_spatialite_blob_to_wkb(row[1])).contains(pt):
return row[0]
return None
finally:
con.close()
def _reverse_landclass(lat, lon):
"""Most-specific PAD-US land class for the point, looked up in-process.
Returns None when there is no coverage or landclass is unavailable."""
from .landclass import lookup_landclass, format_summary
return format_summary(lookup_landclass(lat, lon))
def _reverse_elevation(lat, lon):
"""Elevation in metres from local Valhalla /height. None on failure."""
import requests as http_requests
resp = http_requests.post(
_VALHALLA_HEIGHT_URL,
json={"shape": [{"lat": lat, "lon": lon}]},
timeout=10,
)
resp.raise_for_status()
heights = resp.json().get("height", [])
return heights[0] if heights else None
@geocode_bp.route('/api/reverse/<lat>/<lon>')
def api_reverse_bundle(lat, lon):
"""Localhost-sourced reverse-geocode enrichment bundle for Central.
GET /api/reverse/<lat>/<lon>
Always returns 200 with EXACTLY these keys (any may be null):
name, city, county, state, country, postal_code, timezone, landclass, elevation_m
lat/lon are parsed manually (not via Flask's <float:> converter, which
rejects negative and integer coordinates) so out-of-range or unparseable
input yields 400 per contract; 503 is reserved for catastrophic failure.
"""
try:
lat = float(lat)
lon = float(lon)
except (ValueError, TypeError):
return jsonify({'error': 'lat and lon must be numbers'}), 400
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
return jsonify({'error': 'lat must be -90..90, lon must be -180..180'}), 400
key = (round(lat, 4), round(lon, 4))
with _REVERSE_BUNDLE_LOCK:
cached = _REVERSE_BUNDLE_CACHE.get(key)
if cached is not None:
return jsonify(cached)
bundle = {k: None for k in _BUNDLE_KEYS}
try:
bundle.update(_reverse_photon(lat, lon))
except Exception:
logger.warning("reverse-bundle: Photon lookup failed for %s,%s", lat, lon)
try:
bundle['timezone'] = _reverse_timezone(lat, lon)
except Exception:
logger.warning("reverse-bundle: timezone lookup failed for %s,%s", lat, lon)
try:
bundle['landclass'] = _reverse_landclass(lat, lon)
except Exception:
logger.warning("reverse-bundle: landclass lookup failed for %s,%s", lat, lon)
try:
bundle['elevation_m'] = _reverse_elevation(lat, lon)
except Exception:
logger.warning("reverse-bundle: elevation lookup failed for %s,%s", lat, lon)
with _REVERSE_BUNDLE_LOCK:
_REVERSE_BUNDLE_CACHE[key] = bundle
return jsonify(bundle)

136
lib/reverse_bundle_test.py Normal file
View file

@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Tests for the /api/reverse/<lat>/<lon> enrichment bundle (lib.netsyms_api).
Photon/Valhalla/landclass are mocked so the suite runs without live services;
one timezone test exercises the real SpatiaLite DB when it is present. Plain
asserts + a __main__ runner, matching the rest of lib/*_test.py.
"""
import os
import sys
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from flask import Flask
from lib import netsyms_api
EXPECTED_KEYS = {'name', 'city', 'county', 'state', 'country',
'postal_code', 'timezone', 'landclass', 'elevation_m'}
def _client():
app = Flask(__name__)
app.register_blueprint(netsyms_api.geocode_bp)
return app.test_client()
def _clear_cache():
netsyms_api._REVERSE_BUNDLE_CACHE.clear()
def test_happy_path():
_clear_cache()
with mock.patch.object(netsyms_api, '_reverse_photon', return_value={
'name': 'Where you are', 'city': 'Boise', 'county': 'Ada',
'state': 'Idaho', 'country': 'United States', 'postal_code': '83701'}), \
mock.patch.object(netsyms_api, '_reverse_timezone', return_value='America/Boise'), \
mock.patch.object(netsyms_api, '_reverse_landclass', return_value='Boise National Forest'), \
mock.patch.object(netsyms_api, '_reverse_elevation', return_value=824):
resp = _client().get('/api/reverse/43.6150/-116.2023')
assert resp.status_code == 200, resp.status_code
data = resp.get_json()
assert set(data.keys()) == EXPECTED_KEYS, data.keys()
assert data['city'] == 'Boise' and data['timezone'] == 'America/Boise'
assert data['landclass'] == 'Boise National Forest' and data['elevation_m'] == 824
print(" PASS: happy path — all 9 fields populated, exact key set")
def test_negative_and_integer_coords_parse():
# Regression: Flask's <float:> converter would 404 these; manual parse must not.
_clear_cache()
with mock.patch.object(netsyms_api, '_reverse_photon', return_value={}), \
mock.patch.object(netsyms_api, '_reverse_timezone', return_value=None), \
mock.patch.object(netsyms_api, '_reverse_landclass', return_value=None), \
mock.patch.object(netsyms_api, '_reverse_elevation', return_value=None):
for path in ('/api/reverse/43.6/-116.2', '/api/reverse/43/-116'):
resp = _client().get(path)
assert resp.status_code == 200, f"{path} -> {resp.status_code}"
assert set(resp.get_json().keys()) == EXPECTED_KEYS
print(" PASS: negative and integer coordinates parse (200, not 404)")
def test_partial_failure_returns_200_with_nulls():
_clear_cache()
with mock.patch.object(netsyms_api, '_reverse_photon',
side_effect=RuntimeError('photon down')), \
mock.patch.object(netsyms_api, '_reverse_timezone', return_value='America/Boise'), \
mock.patch.object(netsyms_api, '_reverse_landclass',
side_effect=RuntimeError('postgis down')), \
mock.patch.object(netsyms_api, '_reverse_elevation', return_value=824):
resp = _client().get('/api/reverse/43.6150/-116.2023')
assert resp.status_code == 200, resp.status_code
data = resp.get_json()
assert set(data.keys()) == EXPECTED_KEYS
assert data['name'] is None and data['city'] is None # photon failed -> nulls
assert data['landclass'] is None # landclass failed -> null
assert data['timezone'] == 'America/Boise' and data['elevation_m'] == 824
print(" PASS: per-component failure -> 200 with nulls, no 5xx")
def test_ocean_point_mostly_null():
_clear_cache()
with mock.patch.object(netsyms_api, '_reverse_photon', return_value={}), \
mock.patch.object(netsyms_api, '_reverse_timezone', return_value='Etc/GMT+2'), \
mock.patch.object(netsyms_api, '_reverse_landclass', return_value=None), \
mock.patch.object(netsyms_api, '_reverse_elevation', return_value=0):
resp = _client().get('/api/reverse/0.0/-30.0')
assert resp.status_code == 200, resp.status_code
data = resp.get_json()
assert set(data.keys()) == EXPECTED_KEYS
assert data['city'] is None and data['country'] is None and data['landclass'] is None
print(" PASS: ocean point -> 200, mostly null")
def test_invalid_input_400():
_clear_cache()
client = _client()
for path in ('/api/reverse/9999/0', '/api/reverse/0/9999', '/api/reverse/abc/0'):
resp = client.get(path)
assert resp.status_code == 400, f"{path} -> {resp.status_code}"
print(" PASS: out-of-range / unparseable input -> 400")
def test_cache_hit_serves_without_recompute():
_clear_cache()
with mock.patch.object(netsyms_api, '_reverse_photon',
return_value={'name': 'X'}) as m_photon, \
mock.patch.object(netsyms_api, '_reverse_timezone', return_value=None), \
mock.patch.object(netsyms_api, '_reverse_landclass', return_value=None), \
mock.patch.object(netsyms_api, '_reverse_elevation', return_value=None):
client = _client()
client.get('/api/reverse/12.3456/-65.4321')
client.get('/api/reverse/12.3456/-65.4321') # same key (rounded) -> cached
assert m_photon.call_count == 1, f"expected 1 compute, got {m_photon.call_count}"
print(" PASS: second identical request served from cache (no recompute)")
def test_real_timezone_db():
if not os.path.exists(netsyms_api._TZ_DB_PATH):
print(" SKIP: real timezone test (timezones.sqlite not present)")
return
assert netsyms_api._reverse_timezone(43.6150, -116.2023) == 'America/Boise'
assert netsyms_api._reverse_timezone(40.7128, -74.0060) == 'America/New_York'
print(" PASS: real timezones.sqlite point-in-polygon")
if __name__ == '__main__':
print("Running reverse-bundle tests...")
test_happy_path()
test_negative_and_integer_coords_parse()
test_partial_failure_returns_200_with_nulls()
test_ocean_point_mostly_null()
test_invalid_input_400()
test_cache_hit_serves_without_recompute()
test_real_timezone_db()
print("All tests passed.")

View file

@ -3,6 +3,7 @@ anyio==4.12.1
babel==2.18.0
beautifulsoup4==4.14.3
blinker==1.9.0
cachetools==7.1.3
certifi==2026.1.4
cffi==2.0.0
charset-normalizer==3.4.4