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; } // Any other `:` name is an endpoint we do not have, not a place res.status = 404; res.content_type = .TEXT; res.body = "Unknown endpoint\n"; return; } if (isJunkLocation(location)) { log.debug("rejecting junk location {s}", .{location}); res.status = 404; res.content_type = .TEXT; res.body = "Not found\n"; 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; }, error.GeocodingFailed => { log.warn("geocoding unavailable for query {s}", .{location_query}); upstreamUnavailable(res, "Location lookup is temporarily unavailable"); 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; }, error.OutOfMemory => return err, // Anything else is the upstream failing (status, network, TLS, or // a malformed response) with no usable cached data to fall back on else => { log.warn("weather unavailable for {d:.4},{d:.4}: {}", .{ location.coords.latitude, location.coords.longitude, err }); upstreamUnavailable(res, "Weather data is temporarily unavailable"); return; }, } }; defer weather.deinit(); // The upstream is down and this is old data. Say so, with how long past its // expiry it is, so scripts and status bars can decide whether to trust it. if (weather.stale_for_s) |stale_for_s| res.headers.add("X-Weather-Stale", try std.fmt.allocPrint(res.arena, "{d}", .{stale_for_s})); // 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); } /// A dependency we cannot work without is down. That is a 503 the client can /// retry, not a 500 implying we are broken. fn upstreamUnavailable(res: *httpz.Response, comptime message: []const u8) void { res.status = 503; res.content_type = .TEXT; res.header("Retry-After", "60"); res.body = message ++ ", please try again shortly\n"; } /// File extensions that only ever show up in requests from scanners probing /// for files. No place name ends in one, and letting them through costs a /// Nominatim lookup each time (failures are not cached) plus possibly a /// weather fetch. const junk_extensions = [_][]const u8{ "7z", "asp", "aspx", "bak", "bz2", "cfg", "cgi", "conf", "config", "css", "env", "gif", "gz", "htm", "html", "ico", "ini", "jpeg", "jpg", "js", "json", "jsp", "log", "php", "png", "rar", "sql", "svg", "tar", "tgz", "txt", "webp", "xml", "yaml", "yml", "zip", }; /// Whether a location is a probe for something other than weather. Runs after /// the endpoints we do serve (favicon.ico, robots.txt, ...) are handled. fn isJunkLocation(location: []const u8) bool { // `@domain` lookups legitimately end in what looks like an extension if (location.len == 0 or location[0] == '@') return false; const name = if (location[0] == '~') location[1..] else location; if (name.len == 0) return true; // Dotfiles: .env, .git, .aws, ... if (name[0] == '.') return true; // WordPress probes: wp-login.php is caught below, wp-admin is not if (std.ascii.startsWithIgnoreCase(name, "wp-")) return true; const dot = std.mem.lastIndexOfScalar(u8, name, '.') orelse return false; const extension = name[dot + 1 ..]; for (junk_extensions) |junk| if (std.ascii.eqlIgnoreCase(extension, junk)) return true; return false; } 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"); } test "isJunkLocation: rejects scanner probes" { const junk = [_][]const u8{ "sitemap.xml.gz", "wp-login.php", "wp-admin", "WP-Admin", ".env", ".git", "config.json", "backup.sql", "index.HTML", "apple-touch-icon.png", "ads.txt", "~", "~shell.php", }; for (junk) |location| { std.testing.expect(isJunkLocation(location)) catch |err| { std.debug.print("expected junk: {s}\n", .{location}); return err; }; } } test "isJunkLocation: lets real locations through" { const real = [_][]const u8{ "London", "St. Louis", "St.Louis", "Washington.DC", "48.85,2.35", "50.39.107.54", "2001:db8::1", "@github.com", "@wttr.in", "~Eiffel Tower", "Mt. Hood", "Москва", "PDX", "", }; for (real) |location| { std.testing.expect(!isJunkLocation(location)) catch |err| { std.debug.print("expected real: {s}\n", .{location}); return err; }; } } test "handler: junk paths and unknown endpoints are 404 without a lookup" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); // Any lookup reaching the provider would fail loudly with a 503 instead harness.mock.fail_with = error.WeatherApiFailed; const cases = [_][]const u8{ "sitemap.xml.gz", ":", ":bogus", ".env", "wp-admin" }; for (cases) |location| { var ht = httpz.testing.init(.{}); defer ht.deinit(); const url = try std.fmt.allocPrint(allocator, "/{s}", .{location}); defer allocator.free(url); ht.url(url); ht.param("location", location); try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1"); try ht.expectStatus(404); } } test "handler: upstream weather failure with nothing cached is a retryable 503" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; var harness = try MockHarness.init(allocator); defer harness.deinit(); harness.mock.fail_with = error.WeatherApiFailed; // The harness cache directory is shared across tests and runs, so other // tests' responses for these coordinates would be served from it. An empty // in-memory cache guarantees there is nothing to fall back on. const Cache = @import("../cache/Cache.zig"); const empty_cache = try Cache.init(allocator, std.testing.io, .{ .cache_dir = null, .stale_retention_s = WeatherProvider.stale_retention_s, }); defer empty_cache.deinit(); harness.opts.provider = harness.mock.provider(empty_cache); var ht = httpz.testing.init(.{}); defer ht.deinit(); ht.url("/73.158.64.1?format=1"); ht.param("location", "73.158.64.1"); try handleWeather(&harness.opts, ht.req, ht.res, "73.158.64.1"); try ht.expectStatus(503); try ht.expectHeader("Retry-After", "60"); } test "handler: stale data is served with an X-Weather-Stale header" { const allocator = std.testing.allocator; const MockHarness = @import("Server.zig").MockHarness; const Pins = @import("../location/Pins.zig"); const Location = @import("../location/resolver.zig").Location; const Cache = @import("../cache/Cache.zig"); var harness = try MockHarness.init(allocator); defer harness.deinit(); harness.mock.fail_with = error.WeatherApiFailed; // Pinned so the cache key does not depend on the GeoLite2 database 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"), ); // Own cache, for the same reason as the 503 test above, holding an entry // for the pinned coordinates that expired five minutes ago const cache = try Cache.init(allocator, std.testing.io, .{ .cache_dir = null, .stale_retention_s = WeatherProvider.stale_retention_s, }); defer cache.deinit(); const now = std.Io.Timestamp.now(std.testing.io, .real).toMilliseconds(); try cache.lru.put("47.6000,-122.3000", "{}", now - 5 * 60 * 1000); harness.opts.provider = harness.mock.provider(cache); 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"); const pr = try ht.parseResponse(); const stale_for_s = try std.fmt.parseInt(i64, pr.headers.get("X-Weather-Stale").?, 10); try std.testing.expect(stale_for_s >= 300 and stale_for_s < 360); } test "handler: fresh data has no X-Weather-Stale header" { 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"); try handleWeather(&harness.opts, ht.req, ht.res, "73.158.64.1"); try ht.expectStatus(200); const pr = try ht.parseResponse(); try std.testing.expect(pr.headers.get("X-Weather-Stale") == null); }