626 lines
22 KiB
Zig
626 lines
22 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;
|
||
}
|
||
}
|
||
|
||
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");
|
||
}
|