wttr/src/http/handler.zig

843 lines
30 KiB
Zig
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 /<location> or /?location=<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);
}