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 Prometheus = @import("../render/Prometheus.zig"); const help = @import("help.zig"); const types = @import("../weather/types.zig"); const log = std.log.scoped(.handler); pub const HandleWeatherOptions = struct { provider: WeatherProvider, resolver: *Resolver, geoip: *@import("../location/GeoIp.zig"), /// Zig 0.16 reads the clock (and everything else) through `Io`. io: std.Io, }; /// Only used for shutdown route (/stop) in debug mode pub var server_instance: ?*httpz.Server(*@import("Server.zig").Context) = null; 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 (server_instance) |s| if (std.mem.eql(u8, location, "stop")) { s.stop(); return; }; if (std.mem.eql(u8, "favicon.ico", location)) { res.header("Content-Type", "image/x-icon"); res.body = @embedFile("favicon.ico"); return; } if (std.mem.eql(u8, "robots.txt", location)) { res.content_type = .TEXT; res.body = help.robots_txt; return; } if (std.mem.eql(u8, "sitemap.xml", location)) { res.header("Content-Type", "application/xml"); res.body = help.sitemap_xml; 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; // Read the clock once per request and pass it down, so the render layer // stays free of I/O. const now_unix_s = std.Io.Timestamp.now(opts.io, .real).toSeconds(); // 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 client address > default metric // // The address is resolved through the full pin -> database -> fallback-cache // chain, so an operator's pin decides units as well as location. It used to // read the database directly, which rendered a pinned US client in 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 to response 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); // Render weather data // Set content type based on format if (params.format) |fmt| { res.content_type = if (std.mem.eql(u8, fmt, "j1")) .JSON else .TEXT; } 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") }, ); res.content_type = if (render_options.format == .html) .HTML else .TEXT; } try renderWeatherData(res.writer(), weather, params, render_options, now_unix_s); } fn renderWeatherData( writer: *std.Io.Writer, weather: types.WeatherData, params: QueryParams, render_options: Formatted.RenderOptions, /// Current unix time, read once by the caller. Keeps the render path free of /// I/O now that Zig 0.16 requires an `Io` to read the clock. now_unix_s: i64, ) !void { if (params.format) |fmt| { if (std.mem.eql(u8, fmt, "1")) { try Line.render(writer, weather, .@"1", render_options.use_imperial); } else if (std.mem.eql(u8, fmt, "2")) { try Line.render(writer, weather, .@"2", render_options.use_imperial); } else if (std.mem.eql(u8, fmt, "3")) { try Line.render(writer, weather, .@"3", render_options.use_imperial); } else if (std.mem.eql(u8, fmt, "4")) { try Line.render(writer, weather, .@"4", render_options.use_imperial); } else if (std.mem.eql(u8, fmt, "j1")) { try Json.render(writer, weather); } else if (std.mem.eql(u8, fmt, "p1")) { try Prometheus.render(writer, weather, now_unix_s); } else if (std.mem.eql(u8, fmt, "v2")) { try V2.render(writer, weather, render_options.use_imperial); } else { try Custom.render(writer, weather, fmt, render_options.use_imperial, now_unix_s); } } else { try Formatted.render(writer, 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 "handler: help page" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/:help"); ht.param("location", ":help"); try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1"); try ht.expectStatus(200); } test "handler: translation page" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/:translation"); ht.param("location", ":translation"); try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1"); try ht.expectStatus(200); } test "handler: favicon" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/favicon.ico"); ht.param("location", "favicon.ico"); try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1"); try ht.expectStatus(200); } test "handler: robots.txt" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/robots.txt"); ht.param("location", "robots.txt"); try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1"); try ht.expectStatus(200); try ht.expectBody(help.robots_txt); } test "handler: sitemap.xml" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/sitemap.xml"); ht.param("location", "sitemap.xml"); try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1"); try ht.expectStatus(200); try ht.expectBody(help.sitemap_xml); } test "handler: format j1 (json)" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/73.158.64.1?format=j1"); ht.param("location", "73.158.64.1"); var client_ip_buf: [47]u8 = undefined; const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf); try handleWeather(&harness.opts, ht.req, ht.res, client_ip); try ht.expectStatus(200); try ht.expectHeader("Content-Type", "application/json; charset=UTF-8"); try ht.expectBody( \\{"current_condition":{"temp_C":20,"weatherCode":"clear","weatherDesc":[{"value":"Clear"}],"humidity":50,"windspeedKmph":5,"winddirDegree":0,"pressure":1013,"precipMM":0},"weather":[]} ); } test "handler: format p1 (prometheus)" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/73.158.64.1?format=p1"); ht.param("location", "73.158.64.1"); var client_ip_buf: [47]u8 = undefined; const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf); try handleWeather(&harness.opts, ht.req, ht.res, client_ip); try ht.expectStatus(200); try ht.expectBody( \\# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius \\temperature_feels_like_celsius{forecast="current"} 20 \\# HELP temperature_feels_like_fahrenheit Feels Like Temperature in Fahrenheit \\temperature_feels_like_fahrenheit{forecast="current"} 68 \\# HELP cloudcover_percentage Cloud Coverage in Percent \\cloudcover_percentage{forecast="current"} 0 \\# HELP humidity_percentage Humidity in Percent \\humidity_percentage{forecast="current"} 50 \\# HELP precipitation_mm Precipitation (Rainfall) in mm \\precipitation_mm{forecast="current"} 0.0 \\# HELP pressure_hpa Air pressure in hPa \\pressure_hpa{forecast="current"} 1013 \\# HELP temperature_celsius Temperature in Celsius \\temperature_celsius{forecast="current"} 20 \\# HELP temperature_fahrenheit Temperature in Fahrenheit \\temperature_fahrenheit{forecast="current"} 68 \\# HELP uv_index Ultraviolet Radiation Index \\uv_index{forecast="current"} 0 \\# HELP visibility Visible Distance in Kilometres \\visibility{forecast="current"} 10 \\# HELP weather_code Code to describe Weather Condition \\weather_code{forecast="current"} 800 \\# HELP winddir_degree Wind Direction in Degree \\winddir_degree{forecast="current"} 0 \\# HELP windspeed_kmph Wind Speed in Kilometres per Hour \\windspeed_kmph{forecast="current"} 5 \\# HELP windspeed_mph Wind Speed in Miles per Hour \\windspeed_mph{forecast="current"} 3.106856 \\# HELP observation_time Minutes since start of the day the observation happened \\observation_time{forecast="current"} 0 \\# HELP weather_desc Weather Description \\weather_desc{forecast="current", description="Clear"} 1 \\# HELP winddir_16_point Wind Direction on a 16-wind compass rose \\winddir_16_point{forecast="current", description="N"} 1 \\ ); } test "handler: format v2" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/73.158.64.1?format=v2"); ht.param("location", "73.158.64.1"); var client_ip_buf: [47]u8 = undefined; const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf); try handleWeather(&harness.opts, ht.req, ht.res, client_ip); try ht.expectStatus(200); // Don't assert exact location name since the GeoLite2 database updates // upstream and city mappings change over time. Verify structural properties. const pr = try ht.parseResponse(); try std.testing.expect(std.mem.indexOf(u8, pr.body, "Weather report:") != null); try std.testing.expect(std.mem.indexOf(u8, pr.body, "California, United States") != null); try std.testing.expect(std.mem.indexOf(u8, pr.body, "Current conditions") != null); } test "handler: format custom (%c)" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/73.158.64.1?format=%c"); ht.param("location", "73.158.64.1"); var client_ip_buf: [47]u8 = undefined; const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf); try handleWeather(&harness.opts, ht.req, ht.res, client_ip); try ht.expectStatus(200); try ht.expectBody("☀️"); } test "handler: format line 1" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/73.158.64.1?format=1"); ht.param("location", "73.158.64.1"); var client_ip_buf: [47]u8 = undefined; const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf); try handleWeather(&harness.opts, ht.req, ht.res, client_ip); try ht.expectStatus(200); try ht.expectBody("☀️ +20°C\n"); } test "handler: format line 2" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/73.158.64.1?format=2"); ht.param("location", "73.158.64.1"); var client_ip_buf: [47]u8 = undefined; const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf); try handleWeather(&harness.opts, ht.req, ht.res, client_ip); try ht.expectStatus(200); try ht.expectBody("☀️ 🌡️+20°C 🌬️↓5km/h\n"); } test "handler: format line 3" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/73.158.64.1?format=3"); ht.param("location", "73.158.64.1"); var client_ip_buf: [47]u8 = undefined; const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf); try handleWeather(&harness.opts, ht.req, ht.res, client_ip); try ht.expectStatus(200); // Don't assert exact location name since the GeoLite2 database updates // upstream and city mappings change over time. Verify structural properties. const pr = try ht.parseResponse(); try std.testing.expect(std.mem.indexOf(u8, pr.body, "California, United States:") != null); try std.testing.expect(std.mem.indexOf(u8, pr.body, "°C") != null); } test "handler: a pin's country selects units, not the database" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; const Pins = @import("../location/Pins.zig"); const Location = @import("../location/resolver.zig").Location; var harness = try MockHarness.init(allocator); defer harness.deinit(); // GeoLite2 places this AT&T address in Texas, so the database says US. The // operator says London. Units have to follow the operator. try harness.geoip.pins.put( try Pins.parseCidr("12.94.132.0/24"), "London, United Kingdom", .{ .latitude = 51.5074, .longitude = -0.1278 }, Location.isoFrom("GB"), ); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/12.94.132.170?format=1"); ht.param("location", "12.94.132.170"); try handleWeather(&harness.opts, ht.req, ht.res, "12.94.132.170"); try ht.expectStatus(200); // Metric. Before unit selection consulted pins this rendered in Fahrenheit, // because it read the database directly and the database still says US. try ht.expectBody("☀️ +20°C\n"); } test "handler: a US pin selects imperial where the database has no entry" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; const Pins = @import("../location/Pins.zig"); const Location = @import("../location/resolver.zig").Location; var harness = try MockHarness.init(allocator); defer harness.deinit(); // TEST-NET-3 is absent from GeoLite2, so without the pin there is no country // and the client would fall back to metric despite being pinned to the US. try harness.geoip.pins.put( try Pins.parseCidr("203.0.113.0/24"), "Seattle, Washington, United States", .{ .latitude = 47.6, .longitude = -122.3 }, Location.isoFrom("US"), ); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/203.0.113.7?format=1"); ht.param("location", "203.0.113.7"); try handleWeather(&harness.opts, ht.req, ht.res, "203.0.113.7"); try ht.expectStatus(200); try ht.expectBody("☀️ +68°F\n"); } test "handler: an explicit unit request still overrides the pin's country" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; const Pins = @import("../location/Pins.zig"); const Location = @import("../location/resolver.zig").Location; var harness = try MockHarness.init(allocator); defer harness.deinit(); try harness.geoip.pins.put( try Pins.parseCidr("203.0.113.0/24"), "Seattle, Washington, United States", .{ .latitude = 47.6, .longitude = -122.3 }, Location.isoFrom("US"), ); var ht = httpz.testing.init(.{}); defer ht.deinit(); // `?m` has to keep winning: the country is only consulted when the caller // expressed no preference. ht.url("/203.0.113.7?format=1&m"); ht.param("location", "203.0.113.7"); try handleWeather(&harness.opts, ht.req, ht.res, "203.0.113.7"); try ht.expectStatus(200); try ht.expectBody("☀️ +20°C\n"); }