Add POI feature and imporve the overall backend implementation
This commit is contained in:
282
services/external/app/services/nominatim_service.py
vendored
Normal file
282
services/external/app/services/nominatim_service.py
vendored
Normal file
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user