283 lines
8.8 KiB
Python
283 lines
8.8 KiB
Python
"""
|
|
Nominatim Geocoding Service
|
|
|
|
Provides address search and geocoding using OpenStreetMap Nominatim API.
|
|
For development: uses public API (rate-limited)
|
|
For production: should point to self-hosted Nominatim instance
|
|
"""
|
|
|
|
import httpx
|
|
from typing import List, Dict, Any, Optional
|
|
import structlog
|
|
from asyncio import sleep
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
class NominatimService:
|
|
"""
|
|
Nominatim geocoding and address search service.
|
|
|
|
Uses OpenStreetMap Nominatim API for address autocomplete and geocoding.
|
|
Respects rate limits and usage policy.
|
|
"""
|
|
|
|
# For development: public API (rate-limited to 1 req/sec)
|
|
# For production: should be overridden with self-hosted instance
|
|
DEFAULT_BASE_URL = "https://nominatim.openstreetmap.org"
|
|
|
|
def __init__(self, base_url: Optional[str] = None, user_agent: str = "BakeryIA-Forecasting/1.0"):
|
|
"""
|
|
Initialize Nominatim service.
|
|
|
|
Args:
|
|
base_url: Nominatim server URL (defaults to public API)
|
|
user_agent: User agent for API requests (required by Nominatim policy)
|
|
"""
|
|
self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
|
|
self.user_agent = user_agent
|
|
self.headers = {
|
|
"User-Agent": self.user_agent
|
|
}
|
|
|
|
# Rate limiting for public API (1 request per second)
|
|
self.is_public_api = self.base_url == self.DEFAULT_BASE_URL
|
|
self.min_request_interval = 1.0 if self.is_public_api else 0.0
|
|
|
|
logger.info(
|
|
"Nominatim service initialized",
|
|
base_url=self.base_url,
|
|
is_public_api=self.is_public_api,
|
|
rate_limit=f"{self.min_request_interval}s" if self.is_public_api else "none"
|
|
)
|
|
|
|
async def search_address(
|
|
self,
|
|
query: str,
|
|
country_code: str = "es",
|
|
limit: int = 10
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Search for addresses matching query (autocomplete).
|
|
|
|
Args:
|
|
query: Address search query
|
|
country_code: ISO country code to restrict search (default: Spain)
|
|
limit: Maximum number of results
|
|
|
|
Returns:
|
|
List of address suggestions with display_name, lat, lon, osm_id, etc.
|
|
"""
|
|
if not query or len(query.strip()) < 3:
|
|
logger.warning("Search query too short", query=query)
|
|
return []
|
|
|
|
try:
|
|
# Rate limiting for public API
|
|
if self.is_public_api:
|
|
await sleep(self.min_request_interval)
|
|
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
response = await client.get(
|
|
f"{self.base_url}/search",
|
|
params={
|
|
"q": query,
|
|
"format": "json",
|
|
"addressdetails": 1,
|
|
"countrycodes": country_code,
|
|
"limit": limit,
|
|
"accept-language": "es"
|
|
},
|
|
headers=self.headers
|
|
)
|
|
response.raise_for_status()
|
|
results = response.json()
|
|
|
|
# Parse and enrich results
|
|
addresses = []
|
|
for result in results:
|
|
addresses.append({
|
|
"display_name": result.get("display_name"),
|
|
"lat": float(result.get("lat")),
|
|
"lon": float(result.get("lon")),
|
|
"osm_type": result.get("osm_type"),
|
|
"osm_id": result.get("osm_id"),
|
|
"place_id": result.get("place_id"),
|
|
"type": result.get("type"),
|
|
"class": result.get("class"),
|
|
"address": result.get("address", {}),
|
|
"boundingbox": result.get("boundingbox", [])
|
|
})
|
|
|
|
logger.info(
|
|
"Address search completed",
|
|
query=query,
|
|
result_count=len(addresses)
|
|
)
|
|
|
|
return addresses
|
|
|
|
except httpx.HTTPError as e:
|
|
logger.error(
|
|
"Nominatim API request failed",
|
|
query=query,
|
|
error=str(e)
|
|
)
|
|
return []
|
|
except Exception as e:
|
|
logger.error(
|
|
"Unexpected error in address search",
|
|
query=query,
|
|
error=str(e),
|
|
exc_info=True
|
|
)
|
|
return []
|
|
|
|
async def geocode_address(
|
|
self,
|
|
address: str,
|
|
country_code: str = "es"
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Geocode an address to get coordinates.
|
|
|
|
Args:
|
|
address: Full address string
|
|
country_code: ISO country code
|
|
|
|
Returns:
|
|
Dictionary with lat, lon, display_name, address components or None
|
|
"""
|
|
results = await self.search_address(address, country_code, limit=1)
|
|
|
|
if not results:
|
|
logger.warning("No geocoding results found", address=address)
|
|
return None
|
|
|
|
result = results[0]
|
|
|
|
logger.info(
|
|
"Address geocoded successfully",
|
|
address=address,
|
|
lat=result["lat"],
|
|
lon=result["lon"]
|
|
)
|
|
|
|
return result
|
|
|
|
async def reverse_geocode(
|
|
self,
|
|
latitude: float,
|
|
longitude: float
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Reverse geocode coordinates to get address.
|
|
|
|
Args:
|
|
latitude: Latitude coordinate
|
|
longitude: Longitude coordinate
|
|
|
|
Returns:
|
|
Dictionary with address information or None
|
|
"""
|
|
try:
|
|
# Rate limiting for public API
|
|
if self.is_public_api:
|
|
await sleep(self.min_request_interval)
|
|
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
response = await client.get(
|
|
f"{self.base_url}/reverse",
|
|
params={
|
|
"lat": latitude,
|
|
"lon": longitude,
|
|
"format": "json",
|
|
"addressdetails": 1,
|
|
"accept-language": "es"
|
|
},
|
|
headers=self.headers
|
|
)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
|
|
address_info = {
|
|
"display_name": result.get("display_name"),
|
|
"lat": float(result.get("lat")),
|
|
"lon": float(result.get("lon")),
|
|
"osm_type": result.get("osm_type"),
|
|
"osm_id": result.get("osm_id"),
|
|
"place_id": result.get("place_id"),
|
|
"address": result.get("address", {}),
|
|
"boundingbox": result.get("boundingbox", [])
|
|
}
|
|
|
|
logger.info(
|
|
"Reverse geocoding completed",
|
|
lat=latitude,
|
|
lon=longitude,
|
|
address=address_info["display_name"]
|
|
)
|
|
|
|
return address_info
|
|
|
|
except httpx.HTTPError as e:
|
|
logger.error(
|
|
"Nominatim reverse geocoding failed",
|
|
lat=latitude,
|
|
lon=longitude,
|
|
error=str(e)
|
|
)
|
|
return None
|
|
except Exception as e:
|
|
logger.error(
|
|
"Unexpected error in reverse geocoding",
|
|
lat=latitude,
|
|
lon=longitude,
|
|
error=str(e),
|
|
exc_info=True
|
|
)
|
|
return None
|
|
|
|
async def validate_coordinates(
|
|
self,
|
|
latitude: float,
|
|
longitude: float
|
|
) -> bool:
|
|
"""
|
|
Validate that coordinates point to a real location.
|
|
|
|
Args:
|
|
latitude: Latitude to validate
|
|
longitude: Longitude to validate
|
|
|
|
Returns:
|
|
True if coordinates are valid, False otherwise
|
|
"""
|
|
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
|
|
return False
|
|
|
|
result = await self.reverse_geocode(latitude, longitude)
|
|
return result is not None
|
|
|
|
async def health_check(self) -> bool:
|
|
"""
|
|
Check if Nominatim service is accessible.
|
|
|
|
Returns:
|
|
True if service is healthy, False otherwise
|
|
"""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
response = await client.get(
|
|
f"{self.base_url}/status",
|
|
params={"format": "json"},
|
|
headers=self.headers
|
|
)
|
|
return response.status_code == 200
|
|
except Exception as e:
|
|
logger.error(
|
|
"Nominatim health check failed",
|
|
error=str(e)
|
|
)
|
|
return False
|