wttr/src/http/handler.zig

217 lines
7.2 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("query.zig").QueryParams;
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 help = @import("help.zig");
pub const HandleWeatherOptions = struct {
provider: WeatherProvider,
resolver: *Resolver,
geoip: *@import("../location/GeoIp.zig"),
};
pub fn handleWeather(
opts: *HandleWeatherOptions,
req: *httpz.Request,
res: *httpz.Response,
) !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 {
// Fall back to IP-based detection
const client_ip = getClientIP(req);
break :blk client_ip;
}
};
// 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);
}
fn getClientIP(req: *httpz.Request) []const u8 {
// Check X-Forwarded-For header first (for proxies)
if (req.header("x-forwarded-for")) |xff| {
return parseXForwardedFor(xff);
}
// Check X-Real-IP header
if (req.header("x-real-ip")) |real_ip| {
return real_ip;
}
// Fall back to empty (no IP available)
return "";
}
fn parseXForwardedFor(xff: []const u8) []const u8 {
// Take first IP from comma-separated list
var iter = std.mem.splitScalar(u8, xff, ',');
if (iter.next()) |first_ip| {
return std.mem.trim(u8, first_ip, " \t");
}
return "";
}
fn handleWeatherInternal(
opts: *HandleWeatherOptions,
req: *httpz.Request,
res: *httpz.Response,
location_query: []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 => {
res.status = 404;
res.body = "Location not found\n";
return;
},
else => {
res.status = 500;
res.body = "Internal server error\n";
return;
},
}
};
// Fetch weather using coordinates
const weather = opts.provider.fetch(req_alloc, location.coords) catch |err| {
switch (err) {
error.LocationNotFound => {
res.status = 404;
res.body = "Location not found\n";
return;
},
else => {
res.status = 500;
res.body = "Internal server error\n";
return;
},
}
};
defer weather.deinit();
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);
}
// Determine if imperial units should be used
// Priority: explicit ?u or ?m > lang=us > US IP > default metric
const use_imperial = blk: {
if (params.units) |u| {
break :blk u == .uscs;
}
if (params.lang) |lang| {
if (std.mem.eql(u8, lang, "us")) {
break :blk true;
}
}
const client_ip = getClientIP(req);
if (client_ip.len > 0) {
if (opts.geoip.isUSIP(client_ip)) {
break :blk true;
}
}
break :blk false;
};
const output = if (params.format) |fmt| blk: {
if (std.mem.eql(u8, fmt, "j1")) {
res.content_type = .JSON;
break :blk try json.render(req_alloc, weather);
} else if (std.mem.eql(u8, fmt, "v2")) {
break :blk try v2.render(req_alloc, weather, use_imperial);
} else if (std.mem.startsWith(u8, fmt, "%")) {
break :blk try custom.render(req_alloc, weather, fmt, use_imperial);
} else {
break :blk try line.render(req_alloc, weather, fmt, use_imperial);
}
} else try formatted.render(req_alloc, weather, .{ .use_imperial = use_imperial });
// 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 (res.content_type != .JSON)
res.content_type = .TEXT;
res.body = output;
}
test "parseXForwardedFor extracts first IP" {
try std.testing.expectEqualStrings("192.168.1.1", parseXForwardedFor("192.168.1.1"));
try std.testing.expectEqualStrings("10.0.0.1", parseXForwardedFor("10.0.0.1, 172.16.0.1"));
try std.testing.expectEqualStrings("203.0.113.1", parseXForwardedFor("203.0.113.1, 198.51.100.1, 192.0.2.1"));
}
test "parseXForwardedFor trims whitespace" {
try std.testing.expectEqualStrings("192.168.1.1", parseXForwardedFor(" 192.168.1.1 "));
try std.testing.expectEqualStrings("10.0.0.1", parseXForwardedFor(" 10.0.0.1 , 172.16.0.1"));
}
test "parseXForwardedFor handles empty string" {
try std.testing.expectEqualStrings("", parseXForwardedFor(""));
}
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.expectEqual(QueryParams.Units.uscs, params_u.units.?);
const params_m = try QueryParams.parse(allocator, "m");
try std.testing.expectEqual(QueryParams.Units.metric, params_m.units.?);
const params_lang = try QueryParams.parse(allocator, "lang=us");
defer allocator.free(params_lang.lang.?);
try std.testing.expectEqualStrings("us", params_lang.lang.?);
}