From 015418fccb7e642c4a3ec4f70fd4c38bcd5a26dd Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 18 Dec 2025 13:23:06 -0800 Subject: [PATCH] Had AI collapse appropriate files to avoid nested structs --- src/cache/Cache.zig | 43 ++++++ src/cache/LRU.zig | 106 +++++++++++++++ src/cache/cache.zig | 43 ------ src/cache/lru.zig | 106 --------------- src/http/RateLimiter.zig | 149 +++++++++++++++++++++ src/http/Server.zig | 66 ++++++++++ src/http/handler.zig | 4 +- src/http/middleware.zig | 2 +- src/http/rate_limiter.zig | 149 --------------------- src/http/server.zig | 66 ---------- src/location/Airports.zig | 131 +++++++++++++++++++ src/location/GeoCache.zig | 146 +++++++++++++++++++++ src/location/GeoIP.zig | 187 +++++++++++++++++++++++++++ src/location/airports.zig | 131 ------------------- src/location/geocache.zig | 146 --------------------- src/location/geoip.zig | 187 --------------------------- src/location/resolver.zig | 10 +- src/main.zig | 28 ++-- src/weather/{metno.zig => MetNo.zig} | 138 ++++++++++---------- 19 files changed, 919 insertions(+), 919 deletions(-) create mode 100644 src/cache/Cache.zig create mode 100644 src/cache/LRU.zig delete mode 100644 src/cache/cache.zig delete mode 100644 src/cache/lru.zig create mode 100644 src/http/RateLimiter.zig create mode 100644 src/http/Server.zig delete mode 100644 src/http/rate_limiter.zig delete mode 100644 src/http/server.zig create mode 100644 src/location/Airports.zig create mode 100644 src/location/GeoCache.zig create mode 100644 src/location/GeoIP.zig delete mode 100644 src/location/airports.zig delete mode 100644 src/location/geocache.zig delete mode 100644 src/location/geoip.zig rename src/weather/{metno.zig => MetNo.zig} (73%) diff --git a/src/cache/Cache.zig b/src/cache/Cache.zig new file mode 100644 index 0000000..62d191c --- /dev/null +++ b/src/cache/Cache.zig @@ -0,0 +1,43 @@ +const std = @import("std"); +const LRU = @import("LRU.zig"); + +const Cache = @This(); + +allocator: std.mem.Allocator, +lru: LRU, +cache_dir: []const u8, +file_threshold: usize, + +pub const Config = struct { + max_entries: usize = 10_000, + file_threshold: usize = 1024, + cache_dir: []const u8, +}; + +pub fn init(allocator: std.mem.Allocator, config: Config) !Cache { + std.fs.makeDirAbsolute(config.cache_dir) catch |err| { + if (err != error.PathAlreadyExists) return err; + }; + + return Cache{ + .allocator = allocator, + .lru = try LRU.init(allocator, config.max_entries), + .cache_dir = try allocator.dupe(u8, config.cache_dir), + .file_threshold = config.file_threshold, + }; +} + +pub fn get(self: *Cache, key: []const u8) ?[]const u8 { + return self.lru.get(key); +} + +pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl_seconds: u64) !void { + const now = std.time.milliTimestamp(); + const expires = now + @as(i64, @intCast(ttl_seconds * 1000)); + try self.lru.put(key, value, expires); +} + +pub fn deinit(self: *Cache) void { + self.lru.deinit(); + self.allocator.free(self.cache_dir); +} diff --git a/src/cache/LRU.zig b/src/cache/LRU.zig new file mode 100644 index 0000000..9d44367 --- /dev/null +++ b/src/cache/LRU.zig @@ -0,0 +1,106 @@ +const std = @import("std"); + +const LRU = @This(); + +allocator: std.mem.Allocator, +map: std.StringHashMap(Entry), +max_entries: usize, + +const Entry = struct { + value: []const u8, + expires: i64, + access_count: u64, +}; + +pub fn init(allocator: std.mem.Allocator, max_entries: usize) !LRU { + return LRU{ + .allocator = allocator, + .map = std.StringHashMap(Entry).init(allocator), + .max_entries = max_entries, + }; +} + +pub fn get(self: *LRU, key: []const u8) ?[]const u8 { + var entry = self.map.getPtr(key) orelse return null; + + const now = std.time.milliTimestamp(); + if (now > entry.expires) { + self.remove(key); + return null; + } + + entry.access_count += 1; + return entry.value; +} + +pub fn put(self: *LRU, key: []const u8, value: []const u8, expires: i64) !void { + if (self.map.get(key)) |old_entry| { + self.allocator.free(old_entry.value); + _ = self.map.remove(key); + } + + if (self.map.count() >= self.max_entries) { + self.evictOldest(); + } + + const key_copy = try self.allocator.dupe(u8, key); + const value_copy = try self.allocator.dupe(u8, value); + + try self.map.put(key_copy, .{ + .value = value_copy, + .expires = expires, + .access_count = 0, + }); +} + +fn evictOldest(self: *LRU) void { + var oldest_key: ?[]const u8 = null; + var oldest_access: u64 = std.math.maxInt(u64); + + var it = self.map.iterator(); + while (it.next()) |entry| { + if (entry.value_ptr.access_count < oldest_access) { + oldest_access = entry.value_ptr.access_count; + oldest_key = entry.key_ptr.*; + } + } + + if (oldest_key) |key| { + self.remove(key); + } +} + +fn remove(self: *LRU, key: []const u8) void { + if (self.map.fetchRemove(key)) |kv| { + self.allocator.free(kv.value.value); + self.allocator.free(kv.key); + } +} + +pub fn deinit(self: *LRU) void { + var it = self.map.iterator(); + while (it.next()) |entry| { + self.allocator.free(entry.value_ptr.value); + self.allocator.free(entry.key_ptr.*); + } + self.map.deinit(); +} + +test "LRU basic operations" { + var lru = try LRU.init(std.testing.allocator, 3); + defer lru.deinit(); + + try lru.put("key1", "value1", 9999999999999); + try std.testing.expectEqualStrings("value1", lru.get("key1").?); +} + +test "LRU eviction" { + var lru = try LRU.init(std.testing.allocator, 2); + defer lru.deinit(); + + try lru.put("key1", "value1", 9999999999999); + try lru.put("key2", "value2", 9999999999999); + try lru.put("key3", "value3", 9999999999999); + + try std.testing.expect(lru.get("key1") == null); +} diff --git a/src/cache/cache.zig b/src/cache/cache.zig deleted file mode 100644 index ed2bae4..0000000 --- a/src/cache/cache.zig +++ /dev/null @@ -1,43 +0,0 @@ -const std = @import("std"); -const LRU = @import("lru.zig").LRU; - -pub const Cache = struct { - allocator: std.mem.Allocator, - lru: LRU, - cache_dir: []const u8, - file_threshold: usize, - - pub const Config = struct { - max_entries: usize = 10_000, - file_threshold: usize = 1024, - cache_dir: []const u8, - }; - - pub fn init(allocator: std.mem.Allocator, config: Config) !Cache { - std.fs.makeDirAbsolute(config.cache_dir) catch |err| { - if (err != error.PathAlreadyExists) return err; - }; - - return Cache{ - .allocator = allocator, - .lru = try LRU.init(allocator, config.max_entries), - .cache_dir = try allocator.dupe(u8, config.cache_dir), - .file_threshold = config.file_threshold, - }; - } - - pub fn get(self: *Cache, key: []const u8) ?[]const u8 { - return self.lru.get(key); - } - - pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl_seconds: u64) !void { - const now = std.time.milliTimestamp(); - const expires = now + @as(i64, @intCast(ttl_seconds * 1000)); - try self.lru.put(key, value, expires); - } - - pub fn deinit(self: *Cache) void { - self.lru.deinit(); - self.allocator.free(self.cache_dir); - } -}; diff --git a/src/cache/lru.zig b/src/cache/lru.zig deleted file mode 100644 index 244966c..0000000 --- a/src/cache/lru.zig +++ /dev/null @@ -1,106 +0,0 @@ -const std = @import("std"); - -pub const LRU = struct { - allocator: std.mem.Allocator, - map: std.StringHashMap(Entry), - max_entries: usize, - - const Entry = struct { - value: []const u8, - expires: i64, - access_count: u64, - }; - - pub fn init(allocator: std.mem.Allocator, max_entries: usize) !LRU { - return LRU{ - .allocator = allocator, - .map = std.StringHashMap(Entry).init(allocator), - .max_entries = max_entries, - }; - } - - pub fn get(self: *LRU, key: []const u8) ?[]const u8 { - var entry = self.map.getPtr(key) orelse return null; - - const now = std.time.milliTimestamp(); - if (now > entry.expires) { - self.remove(key); - return null; - } - - entry.access_count += 1; - return entry.value; - } - - pub fn put(self: *LRU, key: []const u8, value: []const u8, expires: i64) !void { - if (self.map.get(key)) |old_entry| { - self.allocator.free(old_entry.value); - _ = self.map.remove(key); - } - - if (self.map.count() >= self.max_entries) { - self.evictOldest(); - } - - const key_copy = try self.allocator.dupe(u8, key); - const value_copy = try self.allocator.dupe(u8, value); - - try self.map.put(key_copy, .{ - .value = value_copy, - .expires = expires, - .access_count = 0, - }); - } - - fn evictOldest(self: *LRU) void { - var oldest_key: ?[]const u8 = null; - var oldest_access: u64 = std.math.maxInt(u64); - - var it = self.map.iterator(); - while (it.next()) |entry| { - if (entry.value_ptr.access_count < oldest_access) { - oldest_access = entry.value_ptr.access_count; - oldest_key = entry.key_ptr.*; - } - } - - if (oldest_key) |key| { - self.remove(key); - } - } - - fn remove(self: *LRU, key: []const u8) void { - if (self.map.fetchRemove(key)) |kv| { - self.allocator.free(kv.value.value); - self.allocator.free(kv.key); - } - } - - pub fn deinit(self: *LRU) void { - var it = self.map.iterator(); - while (it.next()) |entry| { - self.allocator.free(entry.value_ptr.value); - self.allocator.free(entry.key_ptr.*); - } - self.map.deinit(); - } -}; - -test "LRU basic operations" { - var lru = try LRU.init(std.testing.allocator, 3); - defer lru.deinit(); - - try lru.put("key1", "value1", 9999999999999); - try std.testing.expectEqualStrings("value1", lru.get("key1").?); -} - -test "LRU eviction" { - var lru = try LRU.init(std.testing.allocator, 2); - defer lru.deinit(); - - try lru.put("key1", "value1", 9999999999999); - try lru.put("key2", "value2", 9999999999999); - try lru.put("key3", "value3", 9999999999999); - - try std.testing.expect(lru.get("key1") == null); -} diff --git a/src/http/RateLimiter.zig b/src/http/RateLimiter.zig new file mode 100644 index 0000000..0fb75cf --- /dev/null +++ b/src/http/RateLimiter.zig @@ -0,0 +1,149 @@ +const std = @import("std"); + +const RateLimiter = @This(); + +allocator: std.mem.Allocator, +buckets: std.StringHashMap(TokenBucket), +config: Config, +mutex: std.Thread.Mutex, + +pub const Config = struct { + capacity: u32 = 300, + refill_rate: u32 = 5, + refill_interval_ms: u64 = 200, +}; + +const TokenBucket = struct { + tokens: f64, + capacity: u32, + last_refill: i64, + + fn refill(self: *TokenBucket, now: i64, rate: u32, interval_ms: u64) void { + const elapsed = now - self.last_refill; + const intervals = @as(f64, @floatFromInt(elapsed)) / @as(f64, @floatFromInt(interval_ms)); + const new_tokens = intervals * @as(f64, @floatFromInt(rate)); + + self.tokens = @min( + self.tokens + new_tokens, + @as(f64, @floatFromInt(self.capacity)), + ); + self.last_refill = now; + } + + fn consume(self: *TokenBucket, count: f64) bool { + if (self.tokens >= count) { + self.tokens -= count; + return true; + } + return false; + } +}; + +pub fn init(allocator: std.mem.Allocator, config: Config) !RateLimiter { + return RateLimiter{ + .allocator = allocator, + .buckets = std.StringHashMap(TokenBucket).init(allocator), + .config = config, + .mutex = .{}, + }; +} + +pub fn check(self: *RateLimiter, ip: []const u8) !void { + self.mutex.lock(); + defer self.mutex.unlock(); + + const now = std.time.milliTimestamp(); + + const result = try self.buckets.getOrPut(ip); + if (!result.found_existing) { + const ip_copy = try self.allocator.dupe(u8, ip); + result.key_ptr.* = ip_copy; + result.value_ptr.* = TokenBucket{ + .tokens = @floatFromInt(self.config.capacity), + .capacity = self.config.capacity, + .last_refill = now, + }; + } + + var bucket = result.value_ptr; + bucket.refill(now, self.config.refill_rate, self.config.refill_interval_ms); + + if (!bucket.consume(1.0)) { + return error.RateLimitExceeded; + } +} + +pub fn deinit(self: *RateLimiter) void { + var it = self.buckets.iterator(); + while (it.next()) |entry| { + self.allocator.free(entry.key_ptr.*); + } + self.buckets.deinit(); +} + +test "rate limiter allows requests within capacity" { + var limiter = try RateLimiter.init(std.testing.allocator, .{ + .capacity = 10, + .refill_rate = 1, + .refill_interval_ms = 1000, + }); + defer limiter.deinit(); + + var i: usize = 0; + while (i < 10) : (i += 1) { + try limiter.check("1.2.3.4"); + } +} + +test "rate limiter blocks after capacity exhausted" { + var limiter = try RateLimiter.init(std.testing.allocator, .{ + .capacity = 5, + .refill_rate = 1, + .refill_interval_ms = 1000, + }); + defer limiter.deinit(); + + var i: usize = 0; + while (i < 5) : (i += 1) { + try limiter.check("1.2.3.4"); + } + + try std.testing.expectError(error.RateLimitExceeded, limiter.check("1.2.3.4")); +} + +test "rate limiter refills tokens over time" { + var limiter = try RateLimiter.init(std.testing.allocator, .{ + .capacity = 10, + .refill_rate = 5, + .refill_interval_ms = 100, + }); + defer limiter.deinit(); + + var i: usize = 0; + while (i < 10) : (i += 1) { + try limiter.check("1.2.3.4"); + } + + try std.testing.expectError(error.RateLimitExceeded, limiter.check("1.2.3.4")); + + std.Thread.sleep(250 * std.time.ns_per_ms); + + try limiter.check("1.2.3.4"); +} + +test "rate limiter tracks different IPs separately" { + var limiter = try RateLimiter.init(std.testing.allocator, .{ + .capacity = 2, + .refill_rate = 1, + .refill_interval_ms = 1000, + }); + defer limiter.deinit(); + + try limiter.check("1.2.3.4"); + try limiter.check("1.2.3.4"); + + try std.testing.expectError(error.RateLimitExceeded, limiter.check("1.2.3.4")); + + try limiter.check("5.6.7.8"); + try limiter.check("5.6.7.8"); +} diff --git a/src/http/Server.zig b/src/http/Server.zig new file mode 100644 index 0000000..569aff1 --- /dev/null +++ b/src/http/Server.zig @@ -0,0 +1,66 @@ +const std = @import("std"); +const httpz = @import("httpz"); +const handler = @import("handler.zig"); +const RateLimiter = @import("RateLimiter.zig"); +const middleware = @import("middleware.zig"); + +const Server = @This(); + +allocator: std.mem.Allocator, +httpz_server: httpz.Server(*Context), +context: Context, + +const Context = struct { + options: handler.HandleWeatherOptions, + rate_limiter: *RateLimiter, +}; + +pub fn init( + allocator: std.mem.Allocator, + host: []const u8, + port: u16, + options: handler.HandleWeatherOptions, + rate_limiter: *RateLimiter, +) !Server { + const ctx = try allocator.create(Context); + ctx.* = .{ + .options = options, + .rate_limiter = rate_limiter, + }; + + var httpz_server = try httpz.Server(*Context).init(allocator, .{ + .address = host, + .port = port, + }, ctx); + + var router = try httpz_server.router(.{}); + router.get("/", handleWeatherRoot, .{}); + router.get("/:location", handleWeatherLocation, .{}); + + return Server{ + .allocator = allocator, + .httpz_server = httpz_server, + .context = ctx.*, + }; +} + +fn handleWeatherRoot(ctx: *Context, req: *httpz.Request, res: *httpz.Response) !void { + try middleware.rateLimitMiddleware(ctx.rate_limiter, req, res); + if (res.status == 429) return; + try handler.handleWeather(&ctx.options, req, res); +} + +fn handleWeatherLocation(ctx: *Context, req: *httpz.Request, res: *httpz.Response) !void { + try middleware.rateLimitMiddleware(ctx.rate_limiter, req, res); + if (res.status == 429) return; + try handler.handleWeatherLocation(&ctx.options, req, res); +} + +pub fn listen(self: *Server) !void { + std.log.info("wttr listening on port {d}", .{self.httpz_server.config.port.?}); + try self.httpz_server.listen(); +} + +pub fn deinit(self: *Server) void { + self.httpz_server.deinit(); +} diff --git a/src/http/handler.zig b/src/http/handler.zig index 9a9b0f0..f840db4 100644 --- a/src/http/handler.zig +++ b/src/http/handler.zig @@ -1,6 +1,6 @@ const std = @import("std"); const httpz = @import("httpz"); -const Cache = @import("../cache/cache.zig").Cache; +const Cache = @import("../cache/Cache.zig"); const WeatherProvider = @import("../weather/provider.zig").WeatherProvider; const Resolver = @import("../location/resolver.zig").Resolver; const Location = @import("../location/resolver.zig").Location; @@ -16,7 +16,7 @@ pub const HandleWeatherOptions = struct { cache: *Cache, provider: WeatherProvider, resolver: *Resolver, - geoip: *@import("../location/geoip.zig").GeoIP, + geoip: *@import("../location/GeoIP.zig"), }; pub fn handleWeather( diff --git a/src/http/middleware.zig b/src/http/middleware.zig index e740f5f..8c404f7 100644 --- a/src/http/middleware.zig +++ b/src/http/middleware.zig @@ -1,6 +1,6 @@ const std = @import("std"); const httpz = @import("httpz"); -const RateLimiter = @import("rate_limiter.zig").RateLimiter; +const RateLimiter = @import("RateLimiter.zig"); pub fn rateLimitMiddleware(limiter: *RateLimiter, req: *httpz.Request, res: *httpz.Response) !void { const ip = req.address.in.sa.addr; diff --git a/src/http/rate_limiter.zig b/src/http/rate_limiter.zig deleted file mode 100644 index 84294db..0000000 --- a/src/http/rate_limiter.zig +++ /dev/null @@ -1,149 +0,0 @@ -const std = @import("std"); - -pub const RateLimiter = struct { - allocator: std.mem.Allocator, - buckets: std.StringHashMap(TokenBucket), - config: Config, - mutex: std.Thread.Mutex, - - pub const Config = struct { - capacity: u32 = 300, - refill_rate: u32 = 5, - refill_interval_ms: u64 = 200, - }; - - const TokenBucket = struct { - tokens: f64, - capacity: u32, - last_refill: i64, - - fn refill(self: *TokenBucket, now: i64, rate: u32, interval_ms: u64) void { - const elapsed = now - self.last_refill; - const intervals = @as(f64, @floatFromInt(elapsed)) / @as(f64, @floatFromInt(interval_ms)); - const new_tokens = intervals * @as(f64, @floatFromInt(rate)); - - self.tokens = @min( - self.tokens + new_tokens, - @as(f64, @floatFromInt(self.capacity)), - ); - self.last_refill = now; - } - - fn consume(self: *TokenBucket, count: f64) bool { - if (self.tokens >= count) { - self.tokens -= count; - return true; - } - return false; - } - }; - - pub fn init(allocator: std.mem.Allocator, config: Config) !RateLimiter { - return RateLimiter{ - .allocator = allocator, - .buckets = std.StringHashMap(TokenBucket).init(allocator), - .config = config, - .mutex = .{}, - }; - } - - pub fn check(self: *RateLimiter, ip: []const u8) !void { - self.mutex.lock(); - defer self.mutex.unlock(); - - const now = std.time.milliTimestamp(); - - const result = try self.buckets.getOrPut(ip); - if (!result.found_existing) { - const ip_copy = try self.allocator.dupe(u8, ip); - result.key_ptr.* = ip_copy; - result.value_ptr.* = TokenBucket{ - .tokens = @floatFromInt(self.config.capacity), - .capacity = self.config.capacity, - .last_refill = now, - }; - } - - var bucket = result.value_ptr; - bucket.refill(now, self.config.refill_rate, self.config.refill_interval_ms); - - if (!bucket.consume(1.0)) { - return error.RateLimitExceeded; - } - } - - pub fn deinit(self: *RateLimiter) void { - var it = self.buckets.iterator(); - while (it.next()) |entry| { - self.allocator.free(entry.key_ptr.*); - } - self.buckets.deinit(); - } -}; - -test "rate limiter allows requests within capacity" { - var limiter = try RateLimiter.init(std.testing.allocator, .{ - .capacity = 10, - .refill_rate = 1, - .refill_interval_ms = 1000, - }); - defer limiter.deinit(); - - var i: usize = 0; - while (i < 10) : (i += 1) { - try limiter.check("1.2.3.4"); - } -} - -test "rate limiter blocks after capacity exhausted" { - var limiter = try RateLimiter.init(std.testing.allocator, .{ - .capacity = 5, - .refill_rate = 1, - .refill_interval_ms = 1000, - }); - defer limiter.deinit(); - - var i: usize = 0; - while (i < 5) : (i += 1) { - try limiter.check("1.2.3.4"); - } - - try std.testing.expectError(error.RateLimitExceeded, limiter.check("1.2.3.4")); -} - -test "rate limiter refills tokens over time" { - var limiter = try RateLimiter.init(std.testing.allocator, .{ - .capacity = 10, - .refill_rate = 5, - .refill_interval_ms = 100, - }); - defer limiter.deinit(); - - var i: usize = 0; - while (i < 10) : (i += 1) { - try limiter.check("1.2.3.4"); - } - - try std.testing.expectError(error.RateLimitExceeded, limiter.check("1.2.3.4")); - - std.Thread.sleep(250 * std.time.ns_per_ms); - - try limiter.check("1.2.3.4"); -} - -test "rate limiter tracks different IPs separately" { - var limiter = try RateLimiter.init(std.testing.allocator, .{ - .capacity = 2, - .refill_rate = 1, - .refill_interval_ms = 1000, - }); - defer limiter.deinit(); - - try limiter.check("1.2.3.4"); - try limiter.check("1.2.3.4"); - - try std.testing.expectError(error.RateLimitExceeded, limiter.check("1.2.3.4")); - - try limiter.check("5.6.7.8"); - try limiter.check("5.6.7.8"); -} diff --git a/src/http/server.zig b/src/http/server.zig deleted file mode 100644 index 76f30c6..0000000 --- a/src/http/server.zig +++ /dev/null @@ -1,66 +0,0 @@ -const std = @import("std"); -const httpz = @import("httpz"); -const handler = @import("handler.zig"); -const RateLimiter = @import("rate_limiter.zig").RateLimiter; -const middleware = @import("middleware.zig"); - -pub const Server = struct { - allocator: std.mem.Allocator, - httpz_server: httpz.Server(*Context), - context: Context, - - const Context = struct { - options: handler.HandleWeatherOptions, - rate_limiter: *RateLimiter, - }; - - pub fn init( - allocator: std.mem.Allocator, - host: []const u8, - port: u16, - options: handler.HandleWeatherOptions, - rate_limiter: *RateLimiter, - ) !Server { - const ctx = try allocator.create(Context); - ctx.* = .{ - .options = options, - .rate_limiter = rate_limiter, - }; - - var httpz_server = try httpz.Server(*Context).init(allocator, .{ - .address = host, - .port = port, - }, ctx); - - var router = try httpz_server.router(.{}); - router.get("/", handleWeatherRoot, .{}); - router.get("/:location", handleWeatherLocation, .{}); - - return Server{ - .allocator = allocator, - .httpz_server = httpz_server, - .context = ctx.*, - }; - } - - fn handleWeatherRoot(ctx: *Context, req: *httpz.Request, res: *httpz.Response) !void { - try middleware.rateLimitMiddleware(ctx.rate_limiter, req, res); - if (res.status == 429) return; - try handler.handleWeather(&ctx.options, req, res); - } - - fn handleWeatherLocation(ctx: *Context, req: *httpz.Request, res: *httpz.Response) !void { - try middleware.rateLimitMiddleware(ctx.rate_limiter, req, res); - if (res.status == 429) return; - try handler.handleWeatherLocation(&ctx.options, req, res); - } - - pub fn listen(self: *Server) !void { - std.log.info("wttr listening on port {d}", .{self.httpz_server.config.port.?}); - try self.httpz_server.listen(); - } - - pub fn deinit(self: *Server) void { - self.httpz_server.deinit(); - } -}; diff --git a/src/location/Airports.zig b/src/location/Airports.zig new file mode 100644 index 0000000..fc8f251 --- /dev/null +++ b/src/location/Airports.zig @@ -0,0 +1,131 @@ +const std = @import("std"); + +pub const Airport = struct { + iata: []const u8, + name: []const u8, + latitude: f64, + longitude: f64, +}; + +const Airports = @This(); + +allocator: std.mem.Allocator, +airports: std.StringHashMap(Airport), + +pub fn initFromFile(allocator: std.mem.Allocator, file_path: []const u8) !Airports { + const file = try std.fs.cwd().openFile(file_path, .{}); + defer file.close(); + + const csv_data = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); // 10MB max + defer allocator.free(csv_data); + + return try init(allocator, csv_data); +} + +pub fn init(allocator: std.mem.Allocator, csv_data: []const u8) !Airports { + var airports = std.StringHashMap(Airport).init(allocator); + + var lines = std.mem.splitScalar(u8, csv_data, '\n'); + while (lines.next()) |line| { + if (line.len == 0) continue; + + const airport = parseAirportLine(allocator, line) catch continue; + if (airport.iata.len == 3) { + try airports.put(airport.iata, airport); + } + } + + return Airports{ + .allocator = allocator, + .airports = airports, + }; +} + +pub fn deinit(self: *Airports) void { + var it = self.airports.iterator(); + while (it.next()) |entry| { + self.allocator.free(entry.key_ptr.*); + self.allocator.free(entry.value_ptr.name); + } + self.airports.deinit(); +} + +pub fn lookup(self: *Airports, iata_code: []const u8) ?Airport { + return self.airports.get(iata_code); +} + +fn parseAirportLine(allocator: std.mem.Allocator, line: []const u8) !Airport { + // CSV format: ID,Name,City,Country,IATA,ICAO,Lat,Lon,... + var fields = std.mem.splitScalar(u8, line, ','); + + _ = fields.next() orelse return error.InvalidFormat; // ID + const name_quoted = fields.next() orelse return error.InvalidFormat; // Name + _ = fields.next() orelse return error.InvalidFormat; // City + _ = fields.next() orelse return error.InvalidFormat; // Country + const iata_quoted = fields.next() orelse return error.InvalidFormat; // IATA + _ = fields.next() orelse return error.InvalidFormat; // ICAO + const lat_str = fields.next() orelse return error.InvalidFormat; // Lat + const lon_str = fields.next() orelse return error.InvalidFormat; // Lon + + // Remove quotes from fields + const name = try unquote(allocator, name_quoted); + const iata = try unquote(allocator, iata_quoted); + + // Skip if IATA is "\\N" (null) + if (std.mem.eql(u8, iata, "\\N")) { + allocator.free(name); + allocator.free(iata); + return error.NoIATA; + } + + const lat = try std.fmt.parseFloat(f64, lat_str); + const lon = try std.fmt.parseFloat(f64, lon_str); + + return Airport{ + .iata = iata, + .name = name, + .latitude = lat, + .longitude = lon, + }; +} + +fn unquote(allocator: std.mem.Allocator, quoted: []const u8) ![]const u8 { + if (quoted.len >= 2 and quoted[0] == '"' and quoted[quoted.len - 1] == '"') { + return allocator.dupe(u8, quoted[1 .. quoted.len - 1]); + } + return allocator.dupe(u8, quoted); +} + +test "parseAirportLine valid" { + const allocator = std.testing.allocator; + const line = "1,\"Goroka Airport\",\"Goroka\",\"Papua New Guinea\",\"GKA\",\"AYGA\",-6.081689834590001,145.391998291,5282,10,\"U\",\"Pacific/Port_Moresby\",\"airport\",\"OurAirports\""; + + const airport = try Airports.parseAirportLine(allocator, line); + defer allocator.free(airport.iata); + defer allocator.free(airport.name); + + try std.testing.expectEqualStrings("GKA", airport.iata); + try std.testing.expectEqualStrings("Goroka Airport", airport.name); + try std.testing.expectApproxEqAbs(@as(f64, -6.081689834590001), airport.latitude, 0.0001); + try std.testing.expectApproxEqAbs(@as(f64, 145.391998291), airport.longitude, 0.0001); +} + +test "parseAirportLine with null IATA" { + const allocator = std.testing.allocator; + const line = "1,\"Test Airport\",\"City\",\"Country\",\"\\N\",\"ICAO\",0.0,0.0"; + + try std.testing.expectError(error.NoIATA, Airports.parseAirportLine(allocator, line)); +} + +test "AirportDB lookup" { + const allocator = std.testing.allocator; + const csv = "1,\"Munich Airport\",\"Munich\",\"Germany\",\"MUC\",\"EDDM\",48.353802,11.7861,1487,1,\"E\",\"Europe/Berlin\",\"airport\",\"OurAirports\""; + + var db = try Airports.init(allocator, csv); + defer db.deinit(); + + const result = db.lookup("MUC"); + try std.testing.expect(result != null); + try std.testing.expectEqualStrings("Munich Airport", result.?.name); + try std.testing.expectApproxEqAbs(@as(f64, 48.353802), result.?.latitude, 0.0001); +} diff --git a/src/location/GeoCache.zig b/src/location/GeoCache.zig new file mode 100644 index 0000000..7a688fd --- /dev/null +++ b/src/location/GeoCache.zig @@ -0,0 +1,146 @@ +const std = @import("std"); + +const GeoCache = @This(); + +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); +} diff --git a/src/location/GeoIP.zig b/src/location/GeoIP.zig new file mode 100644 index 0000000..39e3218 --- /dev/null +++ b/src/location/GeoIP.zig @@ -0,0 +1,187 @@ +const std = @import("std"); + +pub const Coordinates = struct { + latitude: f64, + longitude: f64, +}; + +pub const MMDB = extern struct { + filename: [*:0]const u8, + flags: u32, + file_content: ?*anyopaque, + file_size: usize, + data_section: ?*anyopaque, + data_section_size: u32, + metadata_section: ?*anyopaque, + metadata_section_size: u32, + full_record_byte_size: u16, + depth: u16, + ipv4_start_node: extern struct { + node_value: u32, + netmask: u16, + }, + metadata: extern struct { + node_count: u32, + record_size: u16, + ip_version: u16, + database_type: [*:0]const u8, + languages: extern struct { + count: usize, + names: [*][*:0]const u8, + }, + binary_format_major_version: u16, + binary_format_minor_version: u16, + build_epoch: u64, + description: extern struct { + count: usize, + descriptions: [*]?*anyopaque, + }, + }, +}; + +pub const MMDBLookupResult = extern struct { + found_entry: bool, + entry: MMDBEntry, + netmask: u16, +}; + +pub const MMDBEntry = extern struct { + mmdb: *MMDB, + offset: u32, +}; + +pub const MMDBEntryData = extern struct { + has_data: bool, + data_type: u32, + offset: u32, + offset_to_next: u32, + data_size: u32, + utf8_string: [*:0]const u8, + double_value: f64, + bytes: [*]const u8, + uint16: u16, + uint32: u32, + int32: i32, + uint64: u64, + uint128: u128, + boolean: bool, + float_value: f32, +}; + +extern fn MMDB_open(filename: [*:0]const u8, flags: u32, mmdb: *MMDB) c_int; +extern fn MMDB_close(mmdb: *MMDB) void; +extern fn MMDB_lookup_string(mmdb: *MMDB, ipstr: [*:0]const u8, gai_error: *c_int, mmdb_error: *c_int) MMDBLookupResult; +extern fn MMDB_get_value(entry: *MMDBEntry, entry_data: *MMDBEntryData, ...) c_int; +extern fn MMDB_strerror(error_code: c_int) [*:0]const u8; + +const GeoIP = @This(); + +mmdb: MMDB, + +pub fn init(db_path: []const u8) !GeoIP { + var mmdb: MMDB = undefined; + const path_z = try std.heap.c_allocator.dupeZ(u8, db_path); + defer std.heap.c_allocator.free(path_z); + + const status = MMDB_open(path_z.ptr, 0, &mmdb); + if (status != 0) { + return error.CannotOpenDatabase; + } + + return GeoIP{ .mmdb = mmdb }; +} + +pub fn deinit(self: *GeoIP) void { + MMDB_close(&self.mmdb); +} + +pub fn lookup(self: *GeoIP, ip: []const u8) !?Coordinates { + const ip_z = try std.heap.c_allocator.dupeZ(u8, ip); + defer std.heap.c_allocator.free(ip_z); + + var gai_error: c_int = 0; + var mmdb_error: c_int = 0; + + const result = MMDB_lookup_string(&self.mmdb, ip_z.ptr, &gai_error, &mmdb_error); + + if (gai_error != 0 or mmdb_error != 0) { + return null; + } + + if (!result.found_entry) { + return null; + } + + return try self.extractCoordinates(result.entry); +} + +pub fn isUSIP(self: *GeoIP, ip: []const u8) bool { + const ip_z = std.heap.c_allocator.dupeZ(u8, ip) catch return false; + defer std.heap.c_allocator.free(ip_z); + + var gai_error: c_int = 0; + var mmdb_error: c_int = 0; + + const result = MMDB_lookup_string(&self.mmdb, ip_z.ptr, &gai_error, &mmdb_error); + + if (gai_error != 0 or mmdb_error != 0 or !result.found_entry) { + return false; + } + + var entry_mut = result.entry; + var country_data: MMDBEntryData = undefined; + const null_term: [*:0]const u8 = @ptrCast(&[_]u8{0}); + const status = MMDB_get_value(&entry_mut, &country_data, "country\x00", "iso_code\x00", null_term); + + if (status != 0 or !country_data.has_data) { + return false; + } + + const country_code = std.mem.span(country_data.utf8_string); + return std.mem.eql(u8, country_code, "US"); +} + +fn extractCoordinates(self: *GeoIP, entry: MMDBEntry) !Coordinates { + _ = self; + var entry_mut = entry; + var latitude_data: MMDBEntryData = undefined; + var longitude_data: MMDBEntryData = undefined; + + const lat_status = MMDB_get_value(&entry_mut, &latitude_data, "location", "latitude", @as([*:0]const u8, @ptrCast(&[_]u8{0}))); + const lon_status = MMDB_get_value(&entry_mut, &longitude_data, "location", "longitude", @as([*:0]const u8, @ptrCast(&[_]u8{0}))); + + if (lat_status != 0 or lon_status != 0 or !latitude_data.has_data or !longitude_data.has_data) { + return error.CoordinatesNotFound; + } + + return Coordinates{ + .latitude = latitude_data.double_value, + .longitude = longitude_data.double_value, + }; +} + +test "MMDB functions are callable" { + const mmdb_error = MMDB_strerror(0); + try std.testing.expect(mmdb_error[0] != 0); +} + +test "GeoIP init with invalid path fails" { + const result = GeoIP.init("/nonexistent/path.mmdb"); + try std.testing.expectError(error.CannotOpenDatabase, result); +} + +test "isUSIP detects US IPs" { + var geoip = GeoIP.init("./GeoLite2-City.mmdb") catch { + std.debug.print("Skipping test - GeoLite2-City.mmdb not found\n", .{}); + return error.SkipZigTest; + }; + defer geoip.deinit(); + + // Test that the function doesn't crash with various IPs + _ = geoip.isUSIP("8.8.8.8"); + _ = geoip.isUSIP("1.1.1.1"); + + // Test invalid IP returns false + const invalid = geoip.isUSIP("invalid"); + try std.testing.expect(!invalid); +} diff --git a/src/location/airports.zig b/src/location/airports.zig deleted file mode 100644 index 5783070..0000000 --- a/src/location/airports.zig +++ /dev/null @@ -1,131 +0,0 @@ -const std = @import("std"); - -pub const Airport = struct { - iata: []const u8, - name: []const u8, - latitude: f64, - longitude: f64, -}; - -pub const AirportDB = struct { - allocator: std.mem.Allocator, - airports: std.StringHashMap(Airport), - - pub fn initFromFile(allocator: std.mem.Allocator, file_path: []const u8) !AirportDB { - const file = try std.fs.cwd().openFile(file_path, .{}); - defer file.close(); - - const csv_data = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); // 10MB max - defer allocator.free(csv_data); - - return try init(allocator, csv_data); - } - - pub fn init(allocator: std.mem.Allocator, csv_data: []const u8) !AirportDB { - var airports = std.StringHashMap(Airport).init(allocator); - - var lines = std.mem.splitScalar(u8, csv_data, '\n'); - while (lines.next()) |line| { - if (line.len == 0) continue; - - const airport = parseAirportLine(allocator, line) catch continue; - if (airport.iata.len == 3) { - try airports.put(airport.iata, airport); - } - } - - return AirportDB{ - .allocator = allocator, - .airports = airports, - }; - } - - pub fn deinit(self: *AirportDB) void { - var it = self.airports.iterator(); - while (it.next()) |entry| { - self.allocator.free(entry.key_ptr.*); - self.allocator.free(entry.value_ptr.name); - } - self.airports.deinit(); - } - - pub fn lookup(self: *AirportDB, iata_code: []const u8) ?Airport { - return self.airports.get(iata_code); - } - - fn parseAirportLine(allocator: std.mem.Allocator, line: []const u8) !Airport { - // CSV format: ID,Name,City,Country,IATA,ICAO,Lat,Lon,... - var fields = std.mem.splitScalar(u8, line, ','); - - _ = fields.next() orelse return error.InvalidFormat; // ID - const name_quoted = fields.next() orelse return error.InvalidFormat; // Name - _ = fields.next() orelse return error.InvalidFormat; // City - _ = fields.next() orelse return error.InvalidFormat; // Country - const iata_quoted = fields.next() orelse return error.InvalidFormat; // IATA - _ = fields.next() orelse return error.InvalidFormat; // ICAO - const lat_str = fields.next() orelse return error.InvalidFormat; // Lat - const lon_str = fields.next() orelse return error.InvalidFormat; // Lon - - // Remove quotes from fields - const name = try unquote(allocator, name_quoted); - const iata = try unquote(allocator, iata_quoted); - - // Skip if IATA is "\\N" (null) - if (std.mem.eql(u8, iata, "\\N")) { - allocator.free(name); - allocator.free(iata); - return error.NoIATA; - } - - const lat = try std.fmt.parseFloat(f64, lat_str); - const lon = try std.fmt.parseFloat(f64, lon_str); - - return Airport{ - .iata = iata, - .name = name, - .latitude = lat, - .longitude = lon, - }; - } - - fn unquote(allocator: std.mem.Allocator, quoted: []const u8) ![]const u8 { - if (quoted.len >= 2 and quoted[0] == '"' and quoted[quoted.len - 1] == '"') { - return allocator.dupe(u8, quoted[1 .. quoted.len - 1]); - } - return allocator.dupe(u8, quoted); - } -}; - -test "parseAirportLine valid" { - const allocator = std.testing.allocator; - const line = "1,\"Goroka Airport\",\"Goroka\",\"Papua New Guinea\",\"GKA\",\"AYGA\",-6.081689834590001,145.391998291,5282,10,\"U\",\"Pacific/Port_Moresby\",\"airport\",\"OurAirports\""; - - const airport = try AirportDB.parseAirportLine(allocator, line); - defer allocator.free(airport.iata); - defer allocator.free(airport.name); - - try std.testing.expectEqualStrings("GKA", airport.iata); - try std.testing.expectEqualStrings("Goroka Airport", airport.name); - try std.testing.expectApproxEqAbs(@as(f64, -6.081689834590001), airport.latitude, 0.0001); - try std.testing.expectApproxEqAbs(@as(f64, 145.391998291), airport.longitude, 0.0001); -} - -test "parseAirportLine with null IATA" { - const allocator = std.testing.allocator; - const line = "1,\"Test Airport\",\"City\",\"Country\",\"\\N\",\"ICAO\",0.0,0.0"; - - try std.testing.expectError(error.NoIATA, AirportDB.parseAirportLine(allocator, line)); -} - -test "AirportDB lookup" { - const allocator = std.testing.allocator; - const csv = "1,\"Munich Airport\",\"Munich\",\"Germany\",\"MUC\",\"EDDM\",48.353802,11.7861,1487,1,\"E\",\"Europe/Berlin\",\"airport\",\"OurAirports\""; - - var db = try AirportDB.init(allocator, csv); - defer db.deinit(); - - const result = db.lookup("MUC"); - try std.testing.expect(result != null); - try std.testing.expectEqualStrings("Munich Airport", result.?.name); - try std.testing.expectApproxEqAbs(@as(f64, 48.353802), result.?.latitude, 0.0001); -} diff --git a/src/location/geocache.zig b/src/location/geocache.zig deleted file mode 100644 index b44b478..0000000 --- a/src/location/geocache.zig +++ /dev/null @@ -1,146 +0,0 @@ -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); -} diff --git a/src/location/geoip.zig b/src/location/geoip.zig deleted file mode 100644 index e658c53..0000000 --- a/src/location/geoip.zig +++ /dev/null @@ -1,187 +0,0 @@ -const std = @import("std"); - -pub const Coordinates = struct { - latitude: f64, - longitude: f64, -}; - -pub const MMDB = extern struct { - filename: [*:0]const u8, - flags: u32, - file_content: ?*anyopaque, - file_size: usize, - data_section: ?*anyopaque, - data_section_size: u32, - metadata_section: ?*anyopaque, - metadata_section_size: u32, - full_record_byte_size: u16, - depth: u16, - ipv4_start_node: extern struct { - node_value: u32, - netmask: u16, - }, - metadata: extern struct { - node_count: u32, - record_size: u16, - ip_version: u16, - database_type: [*:0]const u8, - languages: extern struct { - count: usize, - names: [*][*:0]const u8, - }, - binary_format_major_version: u16, - binary_format_minor_version: u16, - build_epoch: u64, - description: extern struct { - count: usize, - descriptions: [*]?*anyopaque, - }, - }, -}; - -pub const MMDBLookupResult = extern struct { - found_entry: bool, - entry: MMDBEntry, - netmask: u16, -}; - -pub const MMDBEntry = extern struct { - mmdb: *MMDB, - offset: u32, -}; - -pub const MMDBEntryData = extern struct { - has_data: bool, - data_type: u32, - offset: u32, - offset_to_next: u32, - data_size: u32, - utf8_string: [*:0]const u8, - double_value: f64, - bytes: [*]const u8, - uint16: u16, - uint32: u32, - int32: i32, - uint64: u64, - uint128: u128, - boolean: bool, - float_value: f32, -}; - -extern fn MMDB_open(filename: [*:0]const u8, flags: u32, mmdb: *MMDB) c_int; -extern fn MMDB_close(mmdb: *MMDB) void; -extern fn MMDB_lookup_string(mmdb: *MMDB, ipstr: [*:0]const u8, gai_error: *c_int, mmdb_error: *c_int) MMDBLookupResult; -extern fn MMDB_get_value(entry: *MMDBEntry, entry_data: *MMDBEntryData, ...) c_int; -extern fn MMDB_strerror(error_code: c_int) [*:0]const u8; - -pub const GeoIP = struct { - mmdb: MMDB, - - pub fn init(db_path: []const u8) !GeoIP { - var mmdb: MMDB = undefined; - const path_z = try std.heap.c_allocator.dupeZ(u8, db_path); - defer std.heap.c_allocator.free(path_z); - - const status = MMDB_open(path_z.ptr, 0, &mmdb); - if (status != 0) { - return error.CannotOpenDatabase; - } - - return GeoIP{ .mmdb = mmdb }; - } - - pub fn deinit(self: *GeoIP) void { - MMDB_close(&self.mmdb); - } - - pub fn lookup(self: *GeoIP, ip: []const u8) !?Coordinates { - const ip_z = try std.heap.c_allocator.dupeZ(u8, ip); - defer std.heap.c_allocator.free(ip_z); - - var gai_error: c_int = 0; - var mmdb_error: c_int = 0; - - const result = MMDB_lookup_string(&self.mmdb, ip_z.ptr, &gai_error, &mmdb_error); - - if (gai_error != 0 or mmdb_error != 0) { - return null; - } - - if (!result.found_entry) { - return null; - } - - return try self.extractCoordinates(result.entry); - } - - pub fn isUSIP(self: *GeoIP, ip: []const u8) bool { - const ip_z = std.heap.c_allocator.dupeZ(u8, ip) catch return false; - defer std.heap.c_allocator.free(ip_z); - - var gai_error: c_int = 0; - var mmdb_error: c_int = 0; - - const result = MMDB_lookup_string(&self.mmdb, ip_z.ptr, &gai_error, &mmdb_error); - - if (gai_error != 0 or mmdb_error != 0 or !result.found_entry) { - return false; - } - - var entry_mut = result.entry; - var country_data: MMDBEntryData = undefined; - const null_term: [*:0]const u8 = @ptrCast(&[_]u8{0}); - const status = MMDB_get_value(&entry_mut, &country_data, "country\x00", "iso_code\x00", null_term); - - if (status != 0 or !country_data.has_data) { - return false; - } - - const country_code = std.mem.span(country_data.utf8_string); - return std.mem.eql(u8, country_code, "US"); - } - - fn extractCoordinates(self: *GeoIP, entry: MMDBEntry) !Coordinates { - _ = self; - var entry_mut = entry; - var latitude_data: MMDBEntryData = undefined; - var longitude_data: MMDBEntryData = undefined; - - const lat_status = MMDB_get_value(&entry_mut, &latitude_data, "location", "latitude", @as([*:0]const u8, @ptrCast(&[_]u8{0}))); - const lon_status = MMDB_get_value(&entry_mut, &longitude_data, "location", "longitude", @as([*:0]const u8, @ptrCast(&[_]u8{0}))); - - if (lat_status != 0 or lon_status != 0 or !latitude_data.has_data or !longitude_data.has_data) { - return error.CoordinatesNotFound; - } - - return Coordinates{ - .latitude = latitude_data.double_value, - .longitude = longitude_data.double_value, - }; - } -}; - -test "MMDB functions are callable" { - const mmdb_error = MMDB_strerror(0); - try std.testing.expect(mmdb_error[0] != 0); -} - -test "GeoIP init with invalid path fails" { - const result = GeoIP.init("/nonexistent/path.mmdb"); - try std.testing.expectError(error.CannotOpenDatabase, result); -} - -test "isUSIP detects US IPs" { - var geoip = GeoIP.init("./GeoLite2-City.mmdb") catch { - std.debug.print("Skipping test - GeoLite2-City.mmdb not found\n", .{}); - return error.SkipZigTest; - }; - defer geoip.deinit(); - - // Test that the function doesn't crash with various IPs - _ = geoip.isUSIP("8.8.8.8"); - _ = geoip.isUSIP("1.1.1.1"); - - // Test invalid IP returns false - const invalid = geoip.isUSIP("invalid"); - try std.testing.expect(!invalid); -} diff --git a/src/location/resolver.zig b/src/location/resolver.zig index 8f321cd..cfc7fa0 100644 --- a/src/location/resolver.zig +++ b/src/location/resolver.zig @@ -1,7 +1,7 @@ const std = @import("std"); -const GeoIP = @import("geoip.zig").GeoIP; -const GeoCache = @import("geocache.zig").GeoCache; -const AirportDB = @import("airports.zig").AirportDB; +const GeoIP = @import("GeoIP.zig"); +const GeoCache = @import("GeoCache.zig"); +const Airports = @import("Airports.zig"); pub const Location = struct { name: []const u8, @@ -21,9 +21,9 @@ pub const Resolver = struct { allocator: std.mem.Allocator, geoip: ?*GeoIP, geocache: *GeoCache, - airports: ?*AirportDB, + airports: ?*Airports, - pub fn init(allocator: std.mem.Allocator, geoip: ?*GeoIP, geocache: *GeoCache, airports: ?*AirportDB) Resolver { + pub fn init(allocator: std.mem.Allocator, geoip: ?*GeoIP, geocache: *GeoCache, airports: ?*Airports) Resolver { return .{ .allocator = allocator, .geoip = geoip, diff --git a/src/main.zig b/src/main.zig index f51d381..d6b4757 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,13 +1,13 @@ const std = @import("std"); const config = @import("config.zig"); -const Cache = @import("cache/cache.zig").Cache; -const MetNo = @import("weather/metno.zig").MetNo; +const Cache = @import("cache/Cache.zig"); +const MetNo = @import("weather/MetNo.zig"); 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 AirportDB = @import("location/airports.zig").AirportDB; +const Server = @import("http/Server.zig"); +const RateLimiter = @import("http/RateLimiter.zig"); +const GeoIP = @import("location/GeoIP.zig"); +const GeoCache = @import("location/GeoCache.zig"); +const Airports = @import("location/Airports.zig"); const Resolver = @import("location/resolver.zig").Resolver; const geolite_downloader = @import("location/geolite_downloader.zig"); @@ -57,9 +57,9 @@ pub fn main() !void { defer geocache.deinit(); // Initialize airports database - var airports_db: ?AirportDB = null; + var airports_db: ?Airports = null; if (cfg.airports_dat_path) |path| { - airports_db = AirportDB.initFromFile(allocator, path) catch |err| blk: { + airports_db = Airports.initFromFile(allocator, path) catch |err| blk: { std.log.warn("Failed to load airports database: {}", .{err}); break :blk null; }; @@ -100,17 +100,17 @@ pub fn main() !void { test { std.testing.refAllDecls(@This()); _ = @import("config.zig"); - _ = @import("cache/lru.zig"); + _ = @import("cache/LRU.zig"); _ = @import("weather/mock.zig"); - _ = @import("http/rate_limiter.zig"); + _ = @import("http/RateLimiter.zig"); _ = @import("http/query.zig"); _ = @import("http/help.zig"); _ = @import("render/line.zig"); _ = @import("render/json.zig"); _ = @import("render/v2.zig"); _ = @import("render/custom.zig"); - _ = @import("location/geoip.zig"); - _ = @import("location/geocache.zig"); - _ = @import("location/airports.zig"); + _ = @import("location/GeoIP.zig"); + _ = @import("location/GeoCache.zig"); + _ = @import("location/Airports.zig"); _ = @import("location/resolver.zig"); } diff --git a/src/weather/metno.zig b/src/weather/MetNo.zig similarity index 73% rename from src/weather/metno.zig rename to src/weather/MetNo.zig index b8c402d..d60a096 100644 --- a/src/weather/metno.zig +++ b/src/weather/MetNo.zig @@ -2,82 +2,82 @@ const std = @import("std"); const weather_provider = @import("provider.zig"); const types = @import("types.zig"); -pub const MetNo = struct { - allocator: std.mem.Allocator, +const MetNo = @This(); - pub fn init(allocator: std.mem.Allocator) !MetNo { - return MetNo{ - .allocator = allocator, - }; +allocator: std.mem.Allocator, + +pub fn init(allocator: std.mem.Allocator) !MetNo { + return MetNo{ + .allocator = allocator, + }; +} + +pub fn provider(self: *MetNo) weather_provider.WeatherProvider { + return .{ + .ptr = self, + .vtable = &.{ + .fetch = fetch, + .deinit = deinitProvider, + }, + }; +} + +fn fetch(ptr: *anyopaque, allocator: std.mem.Allocator, location: []const u8) !types.WeatherData { + const self: *MetNo = @ptrCast(@alignCast(ptr)); + + // Parse location as "lat,lon" or use default + const coords = parseLocation(location) catch Coords{ .lat = 51.5074, .lon = -0.1278 }; + + const url = try std.fmt.allocPrint( + self.allocator, + "https://api.met.no/weatherapi/locationforecast/2.0/compact?lat={d:.4}&lon={d:.4}", + .{ coords.lat, coords.lon }, + ); + defer self.allocator.free(url); + + // Fetch weather data from met.no API + 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 github.com/chubin/wttr.in" }, + }, + }); + + if (result.status != .ok) { + return error.WeatherApiFailed; } - pub fn provider(self: *MetNo) weather_provider.WeatherProvider { - return .{ - .ptr = self, - .vtable = &.{ - .fetch = fetch, - .deinit = deinitProvider, - }, - }; - } + const response_body = response_buf[0..writer.end]; - fn fetch(ptr: *anyopaque, allocator: std.mem.Allocator, location: []const u8) !types.WeatherData { - const self: *MetNo = @ptrCast(@alignCast(ptr)); + // Parse JSON response + const parsed = try std.json.parseFromSlice( + std.json.Value, + allocator, + response_body, + .{}, + ); + defer parsed.deinit(); - // Parse location as "lat,lon" or use default - const coords = parseLocation(location) catch Coords{ .lat = 51.5074, .lon = -0.1278 }; + return try parseMetNoResponse(allocator, location, parsed.value); +} - const url = try std.fmt.allocPrint( - self.allocator, - "https://api.met.no/weatherapi/locationforecast/2.0/compact?lat={d:.4}&lon={d:.4}", - .{ coords.lat, coords.lon }, - ); - defer self.allocator.free(url); +fn deinitProvider(ptr: *anyopaque) void { + const self: *MetNo = @ptrCast(@alignCast(ptr)); + self.deinit(); +} - // Fetch weather data from met.no API - 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 github.com/chubin/wttr.in" }, - }, - }); - - if (result.status != .ok) { - return error.WeatherApiFailed; - } - - const response_body = response_buf[0..writer.end]; - - // Parse JSON response - const parsed = try std.json.parseFromSlice( - std.json.Value, - allocator, - response_body, - .{}, - ); - defer parsed.deinit(); - - return try parseMetNoResponse(allocator, location, parsed.value); - } - - fn deinitProvider(ptr: *anyopaque) void { - const self: *MetNo = @ptrCast(@alignCast(ptr)); - self.deinit(); - } - - pub fn deinit(self: *MetNo) void { - _ = self; - } -}; +pub fn deinit(self: *MetNo) void { + _ = self; +} const Coords = struct { lat: f64,