const std = @import("std"); const httpz = @import("httpz"); const WeatherProvider = @import("../weather/Provider.zig"); const Resolver = @import("../location/resolver.zig").Resolver; const QueryParams = @import("query.zig").QueryParams; const formatted = @import("../render/formatted.zig"); const line = @import("../render/line.zig"); const json = @import("../render/json.zig"); const v2 = @import("../render/v2.zig"); const custom = @import("../render/custom.zig"); const help = @import("help.zig"); pub const HandleWeatherOptions = struct { provider: WeatherProvider, resolver: *Resolver, geoip: *@import("../location/GeoIp.zig"), }; pub fn handleWeather( opts: *HandleWeatherOptions, req: *httpz.Request, res: *httpz.Response, ) !void { // Get location from path parameter or query string const location = req.param("location") orelse blk: { // Check query string for location parameter const query_string = req.url.query; const params = try QueryParams.parse(req.arena, query_string); defer { if (params.format) |f| req.arena.free(f); if (params.lang) |l| req.arena.free(l); } if (params.location) |loc| { break :blk loc; } else { // Fall back to IP-based detection const client_ip = getClientIP(req); break :blk client_ip; } }; // Handle special endpoints if (location[0] == ':') { 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); } 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_query: []const u8, ) !void { const req_alloc = req.arena; // Resolve location. By the time we get here, we really // should have a location from the path, query string, or // client IP lookup. So if we have an empty location parameter, it // is better to 404 than to fake it with a London response if (location_query.len == 0) { res.status = 404; res.body = "Location not found\n"; return; } const location = opts.resolver.resolve(location_query) 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(req_alloc, location.coords) 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_string = req.url.query; const params = try QueryParams.parse(req_alloc, query_string); defer { if (params.format) |f| req_alloc.free(f); if (params.lang) |l| req_alloc.free(l); } // Determine if imperial units should be used // Priority: explicit ?u or ?m > lang=us > US IP > default metric const use_imperial = blk: { if (params.units) |u| { break :blk u == .uscs; } if (params.lang) |lang| { if (std.mem.eql(u8, lang, "us")) { break :blk true; } } const client_ip = getClientIP(req); if (client_ip.len > 0) { if (opts.geoip.isUSIP(client_ip)) { break :blk true; } } break :blk false; }; const output = if (params.format) |fmt| blk: { if (std.mem.eql(u8, fmt, "j1")) { res.content_type = .JSON; break :blk try json.render(req_alloc, weather); } else if (std.mem.eql(u8, fmt, "v2")) { break :blk try v2.render(req_alloc, weather, use_imperial); } else if (std.mem.startsWith(u8, fmt, "%")) { break :blk try custom.render(req_alloc, weather, fmt, use_imperial); } else { break :blk try line.render(req_alloc, weather, fmt, use_imperial); } } else try formatted.render(req_alloc, weather, .{ .use_imperial = use_imperial }); // Add coordinates header using response allocator const coords_header = try std.fmt.allocPrint(res.arena, "{d:.4},{d:.4}", .{ location.coords.latitude, location.coords.longitude }); res.headers.add("X-Location-Coordinates", coords_header); if (res.content_type != .JSON) res.content_type = .TEXT; res.body = output; } 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("")); } test "imperial units selection logic" { // This test documents the priority order for unit selection: // 1. Explicit ?u or ?m parameter (highest priority) // 2. lang=us parameter // 3. US IP detection // 4. Default to metric // The actual logic is tested through integration tests // This test just verifies the QueryParams parsing works const allocator = std.testing.allocator; const params_u = try QueryParams.parse(allocator, "u"); try std.testing.expectEqual(QueryParams.Units.uscs, params_u.units.?); const params_m = try QueryParams.parse(allocator, "m"); try std.testing.expectEqual(QueryParams.Units.metric, params_m.units.?); const params_lang = try QueryParams.parse(allocator, "lang=us"); defer allocator.free(params_lang.lang.?); try std.testing.expectEqualStrings("us", params_lang.lang.?); }