222 lines
8 KiB
Zig
222 lines
8 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 log = std.log.scoped(.handler);
|
|
|
|
pub const HandleWeatherOptions = struct {
|
|
provider: WeatherProvider,
|
|
resolver: *Resolver,
|
|
geoip: *@import("../location/GeoIp.zig"),
|
|
};
|
|
|
|
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 (std.mem.eql(u8, "favicon.ico", location)) {
|
|
res.header("Content-Type", "image/x-icon");
|
|
res.body = @embedFile("favicon.ico");
|
|
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;
|
|
|
|
// 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 IP > default 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 using response allocator
|
|
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);
|
|
|
|
if (params.format) |fmt| {
|
|
// Anything except the json will be plain text
|
|
res.content_type = .TEXT;
|
|
if (std.mem.eql(u8, fmt, "j1")) {
|
|
res.content_type = .JSON; // reset to json
|
|
try Json.render(res.writer(), weather);
|
|
} else if (std.mem.eql(u8, fmt, "p1")) {
|
|
try Prometheus.render(res.writer(), weather, req_alloc);
|
|
} else if (std.mem.eql(u8, fmt, "v2")) {
|
|
try V2.render(res.writer(), weather, render_options.use_imperial);
|
|
} else if (std.mem.startsWith(u8, fmt, "%")) {
|
|
try Custom.render(res.writer(), weather, fmt, render_options.use_imperial);
|
|
} else {
|
|
// fall back to line if we don't understand the format parameter
|
|
try Line.render(res.writer(), weather, fmt, render_options.use_imperial);
|
|
}
|
|
} else {
|
|
// No specific format selected, we'll provide Formatted output in either
|
|
// text (ansi/plain) or html
|
|
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") },
|
|
);
|
|
if (render_options.format != .html) res.content_type = .TEXT else res.content_type = .HTML;
|
|
try Formatted.render(res.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 "imperial units selection logic" {
|
|
// This test documents the priority order for unit selection:
|
|
// 1. Explicit ?u or ?m parameter (highest priority)
|
|
// 2. lang=us parameter
|
|
// 3. US IP detection
|
|
// 4. Default to metric
|
|
|
|
// The actual logic is tested through integration tests
|
|
// This test just verifies the QueryParams parsing works
|
|
const allocator = std.testing.allocator;
|
|
|
|
const params_u = try QueryParams.parse(allocator, "u");
|
|
try std.testing.expect(params_u.use_imperial.?);
|
|
|
|
const params_m = try QueryParams.parse(allocator, "m");
|
|
try std.testing.expect(!params_m.use_imperial.?);
|
|
|
|
const params_lang = try QueryParams.parse(allocator, "lang=us");
|
|
defer allocator.free(params_lang.lang.?);
|
|
try std.testing.expectEqualStrings("us", params_lang.lang.?);
|
|
}
|