wttr/src/http/handler.zig

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