843 lines
30 KiB
Zig
843 lines
30 KiB
Zig
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);
|
||
}
|