AI: Nominatim Geocoding

This commit is contained in:
Emil Lerch 2025-12-18 10:27:49 -08:00
parent 20ed4151ff
commit a1815e88f9
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 238 additions and 16 deletions

View file

@ -6,7 +6,7 @@ pub const Config = struct {
cache_size: usize,
cache_dir: []const u8,
geolite_path: []const u8,
geolocator_url: []const u8,
geocache_file: ?[]const u8,
pub fn load(allocator: std.mem.Allocator) !Config {
return Config{
@ -23,7 +23,7 @@ pub const Config = struct {
},
.cache_dir = std.process.getEnvVarOwned(allocator, "WTTR_CACHE_DIR") catch try allocator.dupe(u8, "/tmp/wttr-cache"),
.geolite_path = std.process.getEnvVarOwned(allocator, "WTTR_GEOLITE_PATH") catch try allocator.dupe(u8, "./GeoLite2-City.mmdb"),
.geolocator_url = std.process.getEnvVarOwned(allocator, "WTTR_GEOLOCATOR_URL") catch try allocator.dupe(u8, "http://localhost:8004"),
.geocache_file = std.process.getEnvVarOwned(allocator, "WTTR_GEOCACHE_FILE") catch null,
};
}
@ -31,7 +31,7 @@ pub const Config = struct {
allocator.free(self.listen_host);
allocator.free(self.cache_dir);
allocator.free(self.geolite_path);
allocator.free(self.geolocator_url);
if (self.geocache_file) |f| allocator.free(f);
}
};
@ -44,5 +44,5 @@ test "config loads defaults" {
try std.testing.expectEqual(@as(u16, 8002), cfg.listen_port);
try std.testing.expectEqual(@as(usize, 10_000), cfg.cache_size);
try std.testing.expectEqualStrings("./GeoLite2-City.mmdb", cfg.geolite_path);
try std.testing.expectEqualStrings("http://localhost:8004", cfg.geolocator_url);
try std.testing.expect(cfg.geocache_file == null);
}

View file

@ -0,0 +1,146 @@
const std = @import("std");
pub const GeoCache = struct {
allocator: std.mem.Allocator,
cache: std.StringHashMap(CachedLocation),
cache_file: ?[]const u8,
pub const CachedLocation = struct {
name: []const u8,
latitude: f64,
longitude: f64,
};
pub fn init(allocator: std.mem.Allocator, cache_file: ?[]const u8) !GeoCache {
var cache = std.StringHashMap(CachedLocation).init(allocator);
// Load from file if specified
if (cache_file) |file_path| {
loadFromFile(allocator, &cache, file_path) catch |err| {
std.log.warn("Failed to load geocoding cache from {s}: {}", .{ file_path, err });
};
}
return GeoCache{
.allocator = allocator,
.cache = cache,
.cache_file = if (cache_file) |f| try allocator.dupe(u8, f) else null,
};
}
pub fn deinit(self: *GeoCache) void {
// Save to file if specified
if (self.cache_file) |file_path| {
self.saveToFile(file_path) catch |err| {
std.log.warn("Failed to save geocoding cache to {s}: {}", .{ file_path, err });
};
}
var it = self.cache.iterator();
while (it.next()) |entry| {
self.allocator.free(entry.key_ptr.*);
self.allocator.free(entry.value_ptr.name);
}
self.cache.deinit();
if (self.cache_file) |f| self.allocator.free(f);
}
pub fn get(self: *GeoCache, query: []const u8) ?CachedLocation {
return self.cache.get(query);
}
pub fn put(self: *GeoCache, query: []const u8, location: CachedLocation) !void {
const key = try self.allocator.dupe(u8, query);
const value = CachedLocation{
.name = try self.allocator.dupe(u8, location.name),
.latitude = location.latitude,
.longitude = location.longitude,
};
try self.cache.put(key, value);
}
fn loadFromFile(allocator: std.mem.Allocator, cache: *std.StringHashMap(CachedLocation), file_path: []const u8) !void {
const file = try std.fs.cwd().openFile(file_path, .{});
defer file.close();
const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); // 10MB max
defer allocator.free(content);
const parsed = try std.json.parseFromSlice(
std.json.Value,
allocator,
content,
.{},
);
defer parsed.deinit();
var it = parsed.value.object.iterator();
while (it.next()) |entry| {
const obj = entry.value_ptr.object;
const key = try allocator.dupe(u8, entry.key_ptr.*);
const value = CachedLocation{
.name = try allocator.dupe(u8, obj.get("name").?.string),
.latitude = obj.get("latitude").?.float,
.longitude = obj.get("longitude").?.float,
};
try cache.put(key, value);
}
}
fn saveToFile(self: *GeoCache, file_path: []const u8) !void {
const file = try std.fs.cwd().createFile(file_path, .{});
defer file.close();
var buffer: [4096]u8 = undefined;
var file_writer = file.writer(&buffer);
const writer = &file_writer.interface;
try writer.writeAll("{\n");
var it = self.cache.iterator();
var first = true;
while (it.next()) |entry| {
if (!first) try writer.writeAll(",\n");
first = false;
try writer.print(" {any}: {any}", .{
std.json.fmt(entry.key_ptr.*, .{}),
std.json.fmt(.{
.name = entry.value_ptr.name,
.latitude = entry.value_ptr.latitude,
.longitude = entry.value_ptr.longitude,
}, .{}),
});
}
try writer.writeAll("\n}\n");
try writer.flush();
}
};
test "GeoCache basic operations" {
const allocator = std.testing.allocator;
var cache = try GeoCache.init(allocator, null);
defer cache.deinit();
// Test put and get
try cache.put("London", .{
.name = "London, UK",
.latitude = 51.5074,
.longitude = -0.1278,
});
const result = cache.get("London");
try std.testing.expect(result != null);
try std.testing.expectApproxEqAbs(@as(f64, 51.5074), result.?.latitude, 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, -0.1278), result.?.longitude, 0.0001);
}
test "GeoCache miss returns null" {
const allocator = std.testing.allocator;
var cache = try GeoCache.init(allocator, null);
defer cache.deinit();
const result = cache.get("NonExistent");
try std.testing.expect(result == null);
}

View file

@ -1,5 +1,6 @@
const std = @import("std");
const GeoIP = @import("geoip.zig").GeoIP;
const GeoCache = @import("geocache.zig").GeoCache;
pub const Location = struct {
name: []const u8,
@ -18,13 +19,13 @@ pub const LocationType = enum {
pub const Resolver = struct {
allocator: std.mem.Allocator,
geoip: ?*GeoIP,
geolocator_url: []const u8,
geocache: *GeoCache,
pub fn init(allocator: std.mem.Allocator, geoip: ?*GeoIP, geolocator_url: []const u8) Resolver {
pub fn init(allocator: std.mem.Allocator, geoip: ?*GeoIP, geocache: *GeoCache) Resolver {
return .{
.allocator = allocator,
.geoip = geoip,
.geolocator_url = geolocator_url,
.geocache = geocache,
};
}
@ -81,11 +82,75 @@ pub const Resolver = struct {
}
fn resolveGeocoded(self: *Resolver, name: []const u8) !Location {
// TODO: Call external geocoding service
// For now, return a placeholder error
_ = self;
_ = name;
return error.GeocodingNotImplemented;
// Check cache first
if (self.geocache.get(name)) |cached| {
return Location{
.name = try self.allocator.dupe(u8, cached.name),
.latitude = cached.latitude,
.longitude = cached.longitude,
};
}
// Call Nominatim API
const url = try std.fmt.allocPrint(
self.allocator,
"https://nominatim.openstreetmap.org/search?q={s}&format=json&limit=1",
.{name},
);
defer self.allocator.free(url);
var client = std.http.Client{ .allocator = self.allocator };
defer client.deinit();
const uri = try std.Uri.parse(url);
var response_buf: [1024 * 1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&response_buf);
const result = try client.fetch(.{
.location = .{ .uri = uri },
.method = .GET,
.response_writer = &writer,
.extra_headers = &.{
.{ .name = "User-Agent", .value = "wttr.in-zig/1.0" },
},
});
if (result.status != .ok) {
return error.GeocodingFailed;
}
const response_body = response_buf[0..writer.end];
// Parse JSON response
const parsed = try std.json.parseFromSlice(
std.json.Value,
self.allocator,
response_body,
.{},
);
defer parsed.deinit();
if (parsed.value.array.items.len == 0) {
return error.LocationNotFound;
}
const first = parsed.value.array.items[0];
const display_name = first.object.get("display_name").?.string;
const lat = try std.fmt.parseFloat(f64, first.object.get("lat").?.string);
const lon = try std.fmt.parseFloat(f64, first.object.get("lon").?.string);
// Cache the result
try self.geocache.put(name, .{
.name = display_name,
.latitude = lat,
.longitude = lon,
});
return Location{
.name = try self.allocator.dupe(u8, display_name),
.latitude = lat,
.longitude = lon,
};
}
fn resolveAirport(self: *Resolver, code: []const u8) !Location {
@ -129,7 +194,8 @@ test "detect location type" {
test "resolver init" {
const allocator = std.testing.allocator;
const resolver = Resolver.init(allocator, null, "http://localhost:8004");
var geocache = try GeoCache.init(allocator, null);
defer geocache.deinit();
const resolver = Resolver.init(allocator, null, &geocache);
try std.testing.expect(resolver.geoip == null);
try std.testing.expectEqualStrings("http://localhost:8004", resolver.geolocator_url);
}

View file

@ -6,6 +6,7 @@ const types = @import("weather/types.zig");
const Server = @import("http/server.zig").Server;
const RateLimiter = @import("http/rate_limiter.zig").RateLimiter;
const GeoIP = @import("location/geoip.zig").GeoIP;
const GeoCache = @import("location/geocache.zig").GeoCache;
const Resolver = @import("location/resolver.zig").Resolver;
pub const std_options: std.Options = .{
@ -28,7 +29,11 @@ pub fn main() !void {
try stdout.print("Cache size: {d}\n", .{cfg.cache_size});
try stdout.print("Cache dir: {s}\n", .{cfg.cache_dir});
try stdout.print("GeoLite2 path: {s}\n", .{cfg.geolite_path});
try stdout.print("Geolocator URL: {s}\n", .{cfg.geolocator_url});
if (cfg.geocache_file) |f| {
try stdout.print("Geocache file: {s}\n", .{f});
} else {
try stdout.print("Geocache: in-memory only\n", .{});
}
try stdout.flush();
// Initialize GeoIP database
@ -39,8 +44,12 @@ pub fn main() !void {
};
defer geoip.deinit();
// Initialize geocoding cache
var geocache = try GeoCache.init(allocator, cfg.geocache_file);
defer geocache.deinit();
// Initialize location resolver
var resolver = Resolver.init(allocator, &geoip, cfg.geolocator_url);
var resolver = Resolver.init(allocator, &geoip, &geocache);
var cache = try Cache.init(allocator, .{
.max_entries = cfg.cache_size,
@ -80,5 +89,6 @@ test {
_ = @import("render/v2.zig");
_ = @import("render/custom.zig");
_ = @import("location/geoip.zig");
_ = @import("location/geocache.zig");
_ = @import("location/resolver.zig");
}