-
Notifications
You must be signed in to change notification settings - Fork 0
Add OSM service policy architecture and remaining compliance fixes #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,13 +5,29 @@ import 'package:http/http.dart' as http; | |
| import 'package:latlong2/latlong.dart'; | ||
|
|
||
| import '../models/search_result.dart'; | ||
| import 'service_policy.dart'; | ||
|
|
||
| /// Cached search result with expiry. | ||
| class _CachedResult { | ||
| final List<SearchResult> results; | ||
| final DateTime cachedAt; | ||
|
|
||
| _CachedResult(this.results) : cachedAt = DateTime.now(); | ||
|
|
||
| bool get isExpired => | ||
| DateTime.now().difference(cachedAt) > const Duration(minutes: 5); | ||
| } | ||
|
|
||
| class SearchService { | ||
| static const String _baseUrl = 'https://nominatim.openstreetmap.org'; | ||
| static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)'; | ||
| static const int _maxResults = 5; | ||
| static const Duration _timeout = Duration(seconds: 10); | ||
|
|
||
|
|
||
| /// Client-side result cache, keyed by normalized query + viewbox. | ||
| /// Required by Nominatim usage policy. | ||
| final Map<String, _CachedResult> _resultCache = {}; | ||
|
|
||
| /// Search for places using Nominatim geocoding service | ||
| Future<List<SearchResult>> search(String query, {LatLngBounds? viewbox}) async { | ||
| if (query.trim().isEmpty) { | ||
|
|
@@ -27,33 +43,47 @@ class SearchService { | |
| // Otherwise, use Nominatim API | ||
| return await _searchNominatim(query.trim(), viewbox: viewbox); | ||
| } | ||
|
|
||
| /// Try to parse various coordinate formats | ||
| SearchResult? _tryParseCoordinates(String query) { | ||
| // Remove common separators and normalize | ||
| final normalized = query.replaceAll(RegExp(r'[,;]'), ' ').trim(); | ||
| final parts = normalized.split(RegExp(r'\s+')); | ||
|
|
||
| if (parts.length != 2) return null; | ||
|
|
||
| final lat = double.tryParse(parts[0]); | ||
| final lon = double.tryParse(parts[1]); | ||
|
|
||
| if (lat == null || lon == null) return null; | ||
|
|
||
| // Basic validation for Earth coordinates | ||
| if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; | ||
|
|
||
| return SearchResult( | ||
| displayName: 'Coordinates: ${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}', | ||
| coordinates: LatLng(lat, lon), | ||
| category: 'coordinates', | ||
| type: 'point', | ||
| ); | ||
| } | ||
|
|
||
| /// Search using Nominatim API | ||
|
|
||
| /// Search using Nominatim API with rate limiting and result caching. | ||
| /// | ||
| /// Nominatim usage policy requires: | ||
| /// - Max 1 request per second | ||
| /// - Client-side result caching | ||
| /// - No auto-complete / typeahead | ||
| Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async { | ||
| final cacheKey = _buildCacheKey(query, viewbox); | ||
|
|
||
| // Check cache first (Nominatim policy requires client-side caching) | ||
| final cached = _resultCache[cacheKey]; | ||
| if (cached != null && !cached.isExpired) { | ||
| debugPrint('[SearchService] Cache hit for "$query"'); | ||
| return cached.results; | ||
| } | ||
|
|
||
| final params = { | ||
| 'q': query, | ||
| 'format': 'json', | ||
|
|
@@ -84,32 +114,65 @@ class SearchService { | |
| } | ||
|
|
||
| final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params); | ||
|
|
||
| debugPrint('[SearchService] Searching Nominatim: $uri'); | ||
|
|
||
| try { | ||
| // Rate limit: max 1 request/sec per Nominatim policy | ||
| await ServiceRateLimiter.acquire(ServiceType.nominatim); | ||
|
|
||
| final response = await http.get( | ||
| uri, | ||
| headers: { | ||
| 'User-Agent': _userAgent, | ||
| }, | ||
| ).timeout(_timeout); | ||
|
|
||
|
|
||
| ServiceRateLimiter.release(ServiceType.nominatim); | ||
|
|
||
| if (response.statusCode != 200) { | ||
| throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}'); | ||
| } | ||
|
|
||
| final List<dynamic> jsonResults = json.decode(response.body); | ||
| final results = jsonResults | ||
| .map((json) => SearchResult.fromNominatim(json as Map<String, dynamic>)) | ||
| .toList(); | ||
|
|
||
|
|
||
| // Cache the results | ||
| _resultCache[cacheKey] = _CachedResult(results); | ||
| _pruneCache(); | ||
|
|
||
| debugPrint('[SearchService] Found ${results.length} results'); | ||
| return results; | ||
|
|
||
| } catch (e) { | ||
| // Release the semaphore on error too | ||
| ServiceRateLimiter.release(ServiceType.nominatim); | ||
| debugPrint('[SearchService] Search failed: $e'); | ||
| throw Exception('Search failed: $e'); | ||
|
Comment on lines
149
to
153
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Build a cache key from the query and viewbox. | ||
| String _buildCacheKey(String query, LatLngBounds? viewbox) { | ||
| final normalizedQuery = query.trim().toLowerCase(); | ||
| if (viewbox == null) return normalizedQuery; | ||
| // Round viewbox to 1 decimal place to group nearby viewboxes | ||
| double round1(double v) => (v * 10).round() / 10; | ||
| return '$normalizedQuery|${round1(viewbox.west)},${round1(viewbox.south)},${round1(viewbox.east)},${round1(viewbox.north)}'; | ||
| } | ||
|
|
||
| /// Remove expired entries and limit cache size. | ||
| void _pruneCache() { | ||
| _resultCache.removeWhere((_, cached) => cached.isExpired); | ||
| // Limit cache to 50 entries to prevent unbounded growth | ||
| if (_resultCache.length > 50) { | ||
| final sortedKeys = _resultCache.keys.toList() | ||
| ..sort((a, b) => _resultCache[a]!.cachedAt.compareTo(_resultCache[b]!.cachedAt)); | ||
| for (final key in sortedKeys.take(_resultCache.length - 50)) { | ||
| _resultCache.remove(key); | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ServiceRateLimiter.release(ServiceType.nominatim)is called immediately after the HTTP request completes, before status-code handling and JSON parsing. If parsing/processing throws, the limiter slot has already been released and another request may start concurrently despitemaxConcurrentRequests: 1. Consider holding the slot until all response processing is complete (e.g., move the release into afinallythat wraps the whole request+parse section after a successfulacquire).