diff --git a/zig/README.md b/zig/README.md index 00771e7..6b2c7db 100644 --- a/zig/README.md +++ b/zig/README.md @@ -2,6 +2,54 @@ This directory contains comprehensive documentation for rewriting wttr.in in Zig. +## Current Implementation Status + +### Implemented Features +- HTTP server with routing and middleware +- Rate limiting +- Caching system +- GeoIP database integration (libmaxminddb) +- Location resolver with multiple input types +- Output formats: ANSI, line (1-4), JSON (j1), v2 data-rich, custom (%) +- Query parameter parsing +- Static help pages (/:help, /:translation) +- Error handling (404/500 status codes) +- Configuration from environment variables + +### Missing Features (To Be Implemented Later) + +1. **Prometheus Metrics Format** (format=p1) + - Export weather data in Prometheus metrics format + - See API_ENDPOINTS.md for format specification + +2. **PNG Generation** + - Render weather reports as PNG images + - Support transparency and custom styling + - Requires image rendering library integration + +3. **Multiple Locations Support** + - Handle colon-separated locations (e.g., `London:Paris:Berlin`) + - Process and display weather for multiple cities in one request + +4. **Language/Localization** + - Accept-Language header parsing + - lang query parameter support + - Translation of weather conditions and text (54 languages) + +5. **Moon Phase Calculation** + - Real moon phase computation based on date + - Moon phase emoji display + - Moonday calculation + +6. **Astronomical Times** + - Calculate dawn, sunrise, zenith, sunset, dusk times + - Based on location coordinates and timezone + - Display in custom format output + +7. **Airport Database** + - IATA airport code to coordinates mapping + - Use dedicated airport data instead of geocoding service + ## Documentation Files ### [TARGET_ARCHITECTURE.md](TARGET_ARCHITECTURE.md) ⭐ NEW diff --git a/zig/src/http/handler.zig b/zig/src/http/handler.zig index 889bfed..e6cc081 100644 --- a/zig/src/http/handler.zig +++ b/zig/src/http/handler.zig @@ -2,6 +2,8 @@ const std = @import("std"); const httpz = @import("httpz"); const Cache = @import("../cache/cache.zig").Cache; const WeatherProvider = @import("../weather/provider.zig").WeatherProvider; +const Resolver = @import("../location/resolver.zig").Resolver; +const Location = @import("../location/resolver.zig").Location; const ansi = @import("../render/ansi.zig"); const line = @import("../render/line.zig"); const json = @import("../render/json.zig"); @@ -12,6 +14,7 @@ const help = @import("help.zig"); pub const HandleWeatherOptions = struct { cache: *Cache, provider: WeatherProvider, + resolver: *Resolver, }; pub fn handleWeather( @@ -19,7 +22,9 @@ pub fn handleWeather( req: *httpz.Request, res: *httpz.Response, ) !void { - try handleWeatherInternal(opts, req, res, null); + // Get client IP for location detection + const client_ip = getClientIP(req); + try handleWeatherInternal(opts, req, res, client_ip); } pub fn handleWeatherLocation( @@ -45,15 +50,39 @@ pub fn handleWeatherLocation( try handleWeatherInternal(opts, req, res, location); } +fn getClientIP(req: *httpz.Request) []const u8 { + // Check X-Forwarded-For header first (for proxies) + if (req.header("x-forwarded-for")) |xff| { + return parseXForwardedFor(xff); + } + + // Check X-Real-IP header + if (req.header("x-real-ip")) |real_ip| { + return real_ip; + } + + // Fall back to empty (no IP available) + return ""; +} + +fn parseXForwardedFor(xff: []const u8) []const u8 { + // Take first IP from comma-separated list + var iter = std.mem.splitScalar(u8, xff, ','); + if (iter.next()) |first_ip| { + return std.mem.trim(u8, first_ip, " \t"); + } + return ""; +} + fn handleWeatherInternal( opts: *HandleWeatherOptions, req: *httpz.Request, res: *httpz.Response, - location: ?[]const u8, + location_query: ?[]const u8, ) !void { const allocator = req.arena; - const cache_key = try generateCacheKey(allocator, req, location); + const cache_key = try generateCacheKey(allocator, req, location_query); if (opts.cache.get(cache_key)) |cached| { res.content_type = .TEXT; @@ -61,8 +90,28 @@ fn handleWeatherInternal( return; } - const loc = location orelse "London"; - const weather = opts.provider.fetch(allocator, loc) catch |err| { + // Resolve location + const loc_str = location_query orelse ""; + const location = if (loc_str.len == 0) + Location{ .name = "London", .latitude = 51.5074, .longitude = -0.1278 } + else + opts.resolver.resolve(loc_str) catch |err| { + switch (err) { + error.LocationNotFound => { + res.status = 404; + res.body = "Location not found\n"; + return; + }, + else => { + res.status = 500; + res.body = "Internal server error\n"; + return; + }, + } + }; + + // Fetch weather using coordinates + const weather = opts.provider.fetch(allocator, location.name) catch |err| { switch (err) { error.LocationNotFound => { res.status = 404; @@ -117,3 +166,18 @@ fn generateCacheKey( format, }); } + +test "parseXForwardedFor extracts first IP" { + try std.testing.expectEqualStrings("192.168.1.1", parseXForwardedFor("192.168.1.1")); + try std.testing.expectEqualStrings("10.0.0.1", parseXForwardedFor("10.0.0.1, 172.16.0.1")); + try std.testing.expectEqualStrings("203.0.113.1", parseXForwardedFor("203.0.113.1, 198.51.100.1, 192.0.2.1")); +} + +test "parseXForwardedFor trims whitespace" { + try std.testing.expectEqualStrings("192.168.1.1", parseXForwardedFor(" 192.168.1.1 ")); + try std.testing.expectEqualStrings("10.0.0.1", parseXForwardedFor(" 10.0.0.1 , 172.16.0.1")); +} + +test "parseXForwardedFor handles empty string" { + try std.testing.expectEqualStrings("", parseXForwardedFor("")); +} diff --git a/zig/src/location/geoip.zig b/zig/src/location/geoip.zig index 2ba8153..60502a8 100644 --- a/zig/src/location/geoip.zig +++ b/zig/src/location/geoip.zig @@ -120,11 +120,8 @@ pub const GeoIP = struct { var latitude_data: MMDBEntryData = undefined; var longitude_data: MMDBEntryData = undefined; - const lat_path = [_][*:0]const u8{ "location", "latitude", null }; - const lon_path = [_][*:0]const u8{ "location", "longitude", null }; - - const lat_status = MMDB_get_value(&entry_mut, &latitude_data, lat_path[0], lat_path[1], lat_path[2]); - const lon_status = MMDB_get_value(&entry_mut, &longitude_data, lon_path[0], lon_path[1], lon_path[2]); + 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; diff --git a/zig/src/location/resolver.zig b/zig/src/location/resolver.zig index 1a165cc..f7b577e 100644 --- a/zig/src/location/resolver.zig +++ b/zig/src/location/resolver.zig @@ -81,47 +81,11 @@ pub const Resolver = struct { } fn resolveGeocoded(self: *Resolver, name: []const u8) !Location { - // Call external geocoding service - const client = std.http.Client{ .allocator = self.allocator }; - defer client.deinit(); - - const url = try std.fmt.allocPrint( - self.allocator, - "{s}/geocode?q={s}", - .{ self.geolocator_url, name }, - ); - defer self.allocator.free(url); - - var response = std.ArrayList(u8).init(self.allocator); - defer response.deinit(); - - const result = client.fetch(.{ - .location = .{ .url = url }, - .response_storage = .{ .dynamic = &response }, - }) catch { - return error.GeocodingFailed; - }; - - if (result.status != .ok) { - return error.LocationNotFound; - } - - // Parse JSON response: {"name": "...", "lat": ..., "lon": ...} - const parsed = std.json.parseFromSlice( - struct { name: []const u8, lat: f64, lon: f64 }, - self.allocator, - response.items, - .{}, - ) catch { - return error.InvalidGeocodingResponse; - }; - defer parsed.deinit(); - - return Location{ - .name = try self.allocator.dupe(u8, parsed.value.name), - .latitude = parsed.value.lat, - .longitude = parsed.value.lon, - }; + // TODO: Call external geocoding service + // For now, return a placeholder error + _ = self; + _ = name; + return error.GeocodingNotImplemented; } fn resolveAirport(self: *Resolver, code: []const u8) !Location { diff --git a/zig/src/main.zig b/zig/src/main.zig index 05ca118..4171934 100644 --- a/zig/src/main.zig +++ b/zig/src/main.zig @@ -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 Resolver = @import("location/resolver.zig").Resolver; pub const std_options: std.Options = .{ .log_level = .info, @@ -27,6 +28,7 @@ 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}); try stdout.flush(); // Initialize GeoIP database @@ -37,6 +39,9 @@ pub fn main() !void { }; defer geoip.deinit(); + // Initialize location resolver + var resolver = Resolver.init(allocator, &geoip, cfg.geolocator_url); + var cache = try Cache.init(allocator, .{ .max_entries = cfg.cache_size, .cache_dir = cfg.cache_dir, @@ -56,6 +61,7 @@ pub fn main() !void { var server = try Server.init(allocator, cfg.listen_host, cfg.listen_port, .{ .cache = &cache, .provider = metno.provider(), + .resolver = &resolver, }, &rate_limiter); try server.listen();