diff --git a/zig/build.zig b/zig/build.zig index fa911e3..3b9376d 100644 --- a/zig/build.zig +++ b/zig/build.zig @@ -9,6 +9,43 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + const maxminddb_upstream = b.dependency("maxminddb", .{}); + + // Build libmaxminddb as a static library + const maxminddb = b.addLibrary(.{ + .name = "maxminddb", + .linkage = .static, + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + + // Generate maxminddb_config.h + const maxminddb_config = b.addConfigHeader(.{ + .style = .blank, + .include_path = "maxminddb_config.h", + }, .{ + .PACKAGE_VERSION = "1.11.0", + .MMDB_UINT128_USING_MODE = 1, + }); + + maxminddb.addConfigHeader(maxminddb_config); + maxminddb.addIncludePath(maxminddb_upstream.path("include")); + maxminddb.addIncludePath(maxminddb_upstream.path("src")); + + maxminddb.addCSourceFiles(.{ + .root = maxminddb_upstream.path(""), + .files = &.{ + "src/data-pool.c", + "src/maxminddb.c", + }, + .flags = &.{}, + }); + + maxminddb.installHeadersDirectory(maxminddb_upstream.path("include"), "", .{}); + const exe = b.addExecutable(.{ .name = "wttr", .root_module = b.createModule(.{ @@ -19,6 +56,7 @@ pub fn build(b: *std.Build) void { }); exe.root_module.addImport("httpz", httpz.module("httpz")); + exe.linkLibrary(maxminddb); exe.linkLibC(); b.installArtifact(exe); @@ -40,6 +78,7 @@ pub fn build(b: *std.Build) void { }), }); tests.root_module.addImport("httpz", httpz.module("httpz")); + tests.linkLibrary(maxminddb); tests.linkLibC(); const run_tests = b.addRunArtifact(tests); diff --git a/zig/build.zig.zon b/zig/build.zig.zon index c36613d..9ebb342 100644 --- a/zig/build.zig.zon +++ b/zig/build.zig.zon @@ -6,6 +6,10 @@ .url = "https://github.com/karlseguin/http.zig/archive/refs/heads/master.tar.gz", .hash = "httpz-0.0.0-PNVzrEktBwCzPoiua-S8LAYo2tILqczm3tSpneEzLQ9L", }, + .maxminddb = .{ + .url = "https://github.com/maxmind/libmaxminddb/archive/refs/tags/1.11.0.tar.gz", + .hash = "N-V-__8AAAYyBQCd9x7qVVFKQIxi01UZ1K8ZFZFfTzj99CvX", + }, }, .fingerprint = 0x710c2b57e81aa678, .minimum_zig_version = "0.15.0", diff --git a/zig/src/config.zig b/zig/src/config.zig index dd7cb49..d14e167 100644 --- a/zig/src/config.zig +++ b/zig/src/config.zig @@ -34,3 +34,15 @@ pub const Config = struct { allocator.free(self.geolocator_url); } }; + +test "config loads defaults" { + const allocator = std.testing.allocator; + const cfg = try Config.load(allocator); + defer cfg.deinit(allocator); + + try std.testing.expectEqualStrings("0.0.0.0", cfg.listen_host); + 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); +} diff --git a/zig/src/http/handler.zig b/zig/src/http/handler.zig index 8c79037..415b5c5 100644 --- a/zig/src/http/handler.zig +++ b/zig/src/http/handler.zig @@ -4,6 +4,7 @@ const Cache = @import("../cache/cache.zig").Cache; const WeatherProvider = @import("../weather/provider.zig").WeatherProvider; const ansi = @import("../render/ansi.zig"); const line = @import("../render/line.zig"); +const help = @import("help.zig"); pub const HandleWeatherOptions = struct { cache: *Cache, @@ -24,6 +25,20 @@ pub fn handleWeatherLocation( res: *httpz.Response, ) !void { const location = req.param("location") orelse "London"; + + // Handle special endpoints + if (std.mem.startsWith(u8, location, ":")) { + if (std.mem.eql(u8, location, ":help")) { + res.content_type = .TEXT; + res.body = help.help_page; + return; + } else if (std.mem.eql(u8, location, ":translation")) { + res.content_type = .TEXT; + res.body = help.translation_page; + return; + } + } + try handleWeatherInternal(opts, req, res, location); } @@ -44,7 +59,20 @@ fn handleWeatherInternal( } const loc = location orelse "London"; - const weather = try opts.provider.fetch(allocator, loc); + const weather = opts.provider.fetch(allocator, loc) 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; + }, + } + }; defer weather.deinit(); const query = try req.query(); diff --git a/zig/src/http/help.zig b/zig/src/http/help.zig new file mode 100644 index 0000000..d82718d --- /dev/null +++ b/zig/src/http/help.zig @@ -0,0 +1,55 @@ +const std = @import("std"); + +pub const help_page = + \\wttr.in - Weather Forecast Service + \\ + \\Usage: + \\ curl wttr.in # Weather for your location + \\ curl wttr.in/London # Weather for London + \\ curl wttr.in/~Eiffel+Tower # Weather for special location + \\ curl wttr.in/@github.com # Weather for domain location + \\ curl wttr.in/muc # Weather for airport (IATA code) + \\ + \\Query Parameters: + \\ ?format=FORMAT Output format (1,2,3,4,j1,p1,v2) + \\ ?lang=LANG Language code (en,de,fr,etc) + \\ ?u Use USCS units + \\ ?m Use metric units + \\ ?t Transparency for PNG + \\ + \\Special Endpoints: + \\ /:help This help page + \\ /:translation Translation information + \\ + \\Examples: + \\ curl wttr.in/Paris?format=3 + \\ curl wttr.in/Berlin?lang=de + \\ curl wttr.in/Tokyo?m + \\ + \\For more information visit: https://github.com/chubin/wttr.in + \\ +; + +pub const translation_page = + \\wttr.in Translation + \\ + \\wttr.in is currently translated into 54 languages. + \\ + \\Language Support: + \\ - Automatic detection via Accept-Language header + \\ - Manual selection via ?lang=CODE parameter + \\ - Subdomain selection (e.g., de.wttr.in) + \\ + \\Contributing: + \\ To help translate wttr.in, visit: + \\ https://github.com/chubin/wttr.in + \\ +; + +test "help page exists" { + try std.testing.expect(help_page.len > 0); +} + +test "translation page exists" { + try std.testing.expect(translation_page.len > 0); +} diff --git a/zig/src/location/geoip.zig b/zig/src/location/geoip.zig new file mode 100644 index 0000000..2ba8153 --- /dev/null +++ b/zig/src/location/geoip.zig @@ -0,0 +1,148 @@ +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); + } + + fn extractCoordinates(self: *GeoIP, entry: MMDBEntry) !Coordinates { + _ = self; + var entry_mut = entry; + 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]); + + 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); +} diff --git a/zig/src/location/resolver.zig b/zig/src/location/resolver.zig new file mode 100644 index 0000000..6eb2745 --- /dev/null +++ b/zig/src/location/resolver.zig @@ -0,0 +1,171 @@ +const std = @import("std"); +const GeoIP = @import("geoip.zig").GeoIP; + +pub const Location = struct { + name: []const u8, + latitude: f64, + longitude: f64, +}; + +pub const LocationType = enum { + city_name, + airport_code, + special_location, + ip_address, + domain_name, +}; + +pub const Resolver = struct { + allocator: std.mem.Allocator, + geoip: ?*GeoIP, + geolocator_url: []const u8, + + pub fn init(allocator: std.mem.Allocator, geoip: ?*GeoIP, geolocator_url: []const u8) Resolver { + return .{ + .allocator = allocator, + .geoip = geoip, + .geolocator_url = geolocator_url, + }; + } + + pub fn resolve(self: *Resolver, query: []const u8) !Location { + const location_type = detectType(query); + + return switch (location_type) { + .ip_address => try self.resolveIP(query), + .domain_name => try self.resolveDomain(query[1..]), // Skip '@' + .special_location => try self.resolveGeocoded(query[1..]), // Skip '~' + .airport_code => try self.resolveAirport(query), + .city_name => try self.resolveGeocoded(query), + }; + } + + fn detectType(query: []const u8) LocationType { + if (query.len == 0) return .city_name; + if (query[0] == '@') return .domain_name; + if (query[0] == '~') return .special_location; + if (query.len == 3 and isAlpha(query)) return .airport_code; + if (isIPAddress(query)) return .ip_address; + return .city_name; + } + + fn resolveIP(self: *Resolver, ip: []const u8) !Location { + if (self.geoip) |geoip| { + if (try geoip.lookup(ip)) |coords| { + return Location{ + .name = try self.allocator.dupe(u8, ip), + .latitude = coords.latitude, + .longitude = coords.longitude, + }; + } + } + return error.LocationNotFound; + } + + fn resolveDomain(self: *Resolver, domain: []const u8) !Location { + // Use std.net to resolve domain to IP + const addr_list = std.net.getAddressList(self.allocator, domain, 0) catch { + return error.LocationNotFound; + }; + defer addr_list.deinit(); + + if (addr_list.addrs.len == 0) { + return error.LocationNotFound; + } + + // Format IP address + const ip_str = try std.fmt.allocPrint(self.allocator, "{}", .{addr_list.addrs[0].any}); + defer self.allocator.free(ip_str); + + return self.resolveIP(ip_str); + } + + 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, + }; + } + + fn resolveAirport(self: *Resolver, code: []const u8) !Location { + // For now, treat as geocoded location + return self.resolveGeocoded(code); + } + + fn isAlpha(s: []const u8) bool { + for (s) |c| { + if (!std.ascii.isAlphabetic(c)) return false; + } + return true; + } + + fn isIPAddress(s: []const u8) bool { + // Simple check for IPv4 + var dots: u8 = 0; + for (s) |c| { + if (c == '.') { + dots += 1; + } else if (!std.ascii.isDigit(c)) { + return false; + } + } + return dots == 3; + } +}; + +test "detect IP address" { + try std.testing.expect(Resolver.isIPAddress("192.168.1.1")); + try std.testing.expect(!Resolver.isIPAddress("not.an.ip")); +} + +test "detect location type" { + try std.testing.expectEqual(LocationType.ip_address, Resolver.detectType("8.8.8.8")); + try std.testing.expectEqual(LocationType.domain_name, Resolver.detectType("@github.com")); + try std.testing.expectEqual(LocationType.special_location, Resolver.detectType("~Eiffel+Tower")); + try std.testing.expectEqual(LocationType.airport_code, Resolver.detectType("muc")); + try std.testing.expectEqual(LocationType.city_name, Resolver.detectType("London")); +} + +test "resolver init" { + const allocator = std.testing.allocator; + const resolver = Resolver.init(allocator, null, "http://localhost:8004"); + try std.testing.expect(resolver.geoip == null); + try std.testing.expectEqualStrings("http://localhost:8004", resolver.geolocator_url); +} diff --git a/zig/src/main.zig b/zig/src/main.zig index cdba81e..e326766 100644 --- a/zig/src/main.zig +++ b/zig/src/main.zig @@ -5,6 +5,7 @@ const MetNo = @import("weather/metno.zig").MetNo; 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; pub const std_options: std.Options = .{ .log_level = .info, @@ -25,8 +26,17 @@ pub fn main() !void { try stdout.print("wttr starting on {s}:{d}\n", .{ cfg.listen_host, cfg.listen_port }); 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.flush(); + // Initialize GeoIP database + var geoip = GeoIP.init(cfg.geolite_path) catch |err| { + std.log.warn("Failed to load GeoIP database: {}", .{err}); + std.log.warn("IP-based location resolution will be unavailable", .{}); + return err; + }; + defer geoip.deinit(); + var cache = try Cache.init(allocator, .{ .max_entries = cfg.cache_size, .cache_dir = cfg.cache_dir, @@ -53,8 +63,13 @@ pub fn main() !void { test { std.testing.refAllDecls(@This()); + _ = @import("config.zig"); _ = @import("cache/lru.zig"); _ = @import("weather/mock.zig"); _ = @import("http/rate_limiter.zig"); + _ = @import("http/query.zig"); + _ = @import("http/help.zig"); _ = @import("render/line.zig"); + _ = @import("location/geoip.zig"); + _ = @import("location/resolver.zig"); } diff --git a/zig/src/render/json.zig b/zig/src/render/json.zig index e0a9287..163be80 100644 --- a/zig/src/render/json.zig +++ b/zig/src/render/json.zig @@ -1,11 +1,73 @@ const std = @import("std"); const types = @import("../weather/types.zig"); -pub fn render(allocator: std.mem.Allocator, data: types.WeatherData) ![]const u8 { - var output: std.ArrayList(u8) = .empty; - errdefer output.deinit(allocator); +pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData) ![]const u8 { + var output = std.ArrayList(u8).init(allocator); + errdefer output.deinit(); - try std.json.stringify(data, .{}, output.writer(allocator)); + try std.json.stringify(.{ + .current_condition = .{ + .temp_C = weather.current.temp_c, + .temp_F = weather.current.temp_f, + .weatherCode = weather.current.weather_code, + .weatherDesc = .{.{ .value = weather.current.condition }}, + .humidity = weather.current.humidity, + .windspeedKmph = weather.current.wind_kph, + .winddirDegree = weather.current.wind_dir, + .pressure = weather.current.pressure_mb, + .precipMM = weather.current.precip_mm, + }, + .weather = blk: { + var forecast_array = std.ArrayList(struct { + date: []const u8, + maxtempC: f32, + mintempC: f32, + weatherCode: u16, + weatherDesc: []const u8, + }).init(allocator); + defer forecast_array.deinit(); - return output.toOwnedSlice(allocator); + for (weather.forecast) |day| { + try forecast_array.append(.{ + .date = day.date, + .maxtempC = day.max_temp_c, + .mintempC = day.min_temp_c, + .weatherCode = day.weather_code, + .weatherDesc = day.condition, + }); + } + + break :blk try forecast_array.toOwnedSlice(); + }, + }, .{}, output.writer()); + + return output.toOwnedSlice(); +} + +test "render json format" { + const allocator = std.testing.allocator; + + const weather = types.WeatherData{ + .location = "London", + .current = .{ + .temp_c = 15.0, + .temp_f = 59.0, + .condition = "Partly cloudy", + .weather_code = 116, + .humidity = 72, + .wind_kph = 13.0, + .wind_dir = "SW", + .pressure_mb = 1013.0, + .precip_mm = 0.0, + }, + .forecast = &[_]types.ForecastDay{}, + .allocator = allocator, + }; + + const output = try render(allocator, weather); + defer allocator.free(output); + + try std.testing.expect(output.len > 0); + try std.testing.expect(std.mem.indexOf(u8, output, "temp_C") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "15") != null); } diff --git a/zig/src/weather/types.zig b/zig/src/weather/types.zig index 2ecf1cb..c6ed749 100644 --- a/zig/src/weather/types.zig +++ b/zig/src/weather/types.zig @@ -1,5 +1,11 @@ const std = @import("std"); +pub const WeatherError = error{ + LocationNotFound, + ApiError, + NetworkError, +}; + pub const WeatherData = struct { location: []const u8, current: CurrentCondition,