const std = @import("std"); const httpz = @import("httpz"); const WeatherProvider = @import("../weather/Provider.zig"); const Resolver = @import("../location/resolver.zig").Resolver; const QueryParams = @import("QueryParams.zig"); 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"); const log = std.log.scoped(.handler); pub const HandleWeatherOptions = struct { provider: WeatherProvider, resolver: *Resolver, geoip: *@import("../location/GeoIp.zig"), }; pub fn handleWeather( opts: *HandleWeatherOptions, req: *httpz.Request, res: *httpz.Response, client_ip: []const u8, ) !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 break :blk client_ip; // no location, just use client ip instead }; if (std.mem.eql(u8, "favicon.ico", location)) { res.header("Content-Type", "image/x-icon"); res.body = @embedFile("favicon.ico"); return; } log.debug("location = {s}, client_ip = {s}", .{ location, client_ip }); if (location.len == 0) { res.content_type = .TEXT; res.body = "Sorry, we are unable to determine your location at this time. Try with / or /?location=\n"; return; } // 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, client_ip); } fn handleWeatherInternal( opts: *HandleWeatherOptions, req: *httpz.Request, res: *httpz.Response, location_query: []const u8, client_ip: []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 => { log.debug("Location not found for query {s}", .{location_query}); res.status = 404; res.body = "Location not found (location query resolution failure)\n"; return; }, else => return err, } }; defer location.deinit(); // Fetch weather using coordinates var weather = opts.provider.fetch(req_alloc, location.coords) catch |err| { switch (err) { error.LocationNotFound => { res.status = 404; res.body = "Location not found (provider fetch failure)\n"; return; }, else => return err, } }; defer weather.deinit(); // Set display name for rendering weather.display_name = try req_alloc.dupe(u8, location.name); 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); } var render_options = params.render_options; // Determine if imperial units should be used // Priority: explicit ?u or ?m > lang=us > US IP > default metric if (params.use_imperial == null) { // User did not ask for anything explicitly // Check if lang=us if (params.lang) |lang| { if (std.mem.eql(u8, lang, "us")) render_options.use_imperial = true; } if (!render_options.use_imperial and client_ip.len > 0 and opts.geoip.isUSIp(client_ip)) render_options.use_imperial = true; // this is a US IP } // 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); res.body = blk: { if (params.format) |fmt| { // Anything except the json will be plain text res.content_type = .TEXT; if (std.mem.eql(u8, fmt, "j1")) { res.content_type = .JSON; // reset to json break :blk try Json.render(req_alloc, weather); } if (std.mem.eql(u8, fmt, "v2")) break :blk try V2.render(req_alloc, weather, render_options.use_imperial); if (std.mem.startsWith(u8, fmt, "%")) break :blk try Custom.render(req_alloc, weather, fmt, render_options.use_imperial); // fall back to line if we don't understand the format parameter break :blk try Line.render(req_alloc, weather, fmt, render_options.use_imperial); } else { render_options.format = determineFormat(params, req.headers.get("user-agent")); log.debug( "Format: {}. params.ansi {}, params.text {}, user agent: {?s}", .{ render_options.format, params.ansi, params.text_only, req.headers.get("user-agent") }, ); if (render_options.format != .html) res.content_type = .TEXT else res.content_type = .HTML; break :blk try Formatted.render(req_alloc, weather, render_options); } }; } fn determineFormat(params: QueryParams, user_agent: ?[]const u8) Formatted.Format { if (params.ansi or params.text_only) { // user explicitly requested something. If both are set, text will win if (params.text_only) return .plain_text; return .ansi; } const ua = user_agent orelse ""; // https://github.com/chubin/wttr.in/blob/master/lib/globals.py#L82C1-L97C2 const plain_text_agents = &[_][]const u8{ "curl", "httpie", "lwp-request", "wget", "python-requests", "python-httpx", "openbsd ftp", "powershell", "fetch", "aiohttp", "http_get", "xh", "nushell", "zig", }; for (plain_text_agents) |agent| if (std.mem.indexOf(u8, ua, agent)) |_| return .ansi; return .html; } 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.expect(params_u.use_imperial.?); const params_m = try QueryParams.parse(allocator, "m"); try std.testing.expect(!params_m.use_imperial.?); const params_lang = try QueryParams.parse(allocator, "lang=us"); defer allocator.free(params_lang.lang.?); try std.testing.expectEqualStrings("us", params_lang.lang.?); }