centralize/reuse coordinates structure

This commit is contained in:
Emil Lerch 2025-12-18 15:41:08 -08:00
parent 448c49ae79
commit 4195f43fa7
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 69 additions and 92 deletions

View file

@ -95,7 +95,7 @@ fn handleWeatherInternal(
// Resolve location // Resolve location
const loc_str = location_query orelse ""; const loc_str = location_query orelse "";
const location = if (loc_str.len == 0) const location = if (loc_str.len == 0)
Location{ .name = "London", .latitude = 51.5074, .longitude = -0.1278 } Location{ .name = "London", .coords = .{ .latitude = 51.5074, .longitude = -0.1278 } }
else else
opts.resolver.resolve(loc_str) catch |err| { opts.resolver.resolve(loc_str) catch |err| {
switch (err) { switch (err) {
@ -113,10 +113,7 @@ fn handleWeatherInternal(
}; };
// Fetch weather using coordinates // Fetch weather using coordinates
const coord_str = try std.fmt.allocPrint(allocator, "{d:.4},{d:.4}", .{ location.latitude, location.longitude }); const weather = opts.provider.fetch(allocator, location.coords) catch |err| {
defer allocator.free(coord_str);
const weather = opts.provider.fetch(allocator, coord_str) catch |err| {
switch (err) { switch (err) {
error.LocationNotFound => { error.LocationNotFound => {
res.status = 404; res.status = 404;

View file

@ -1,10 +1,10 @@
const std = @import("std"); const std = @import("std");
const Coordinates = @import("../Coordinates.zig");
pub const Airport = struct { pub const Airport = struct {
iata: []const u8, iata: []const u8,
name: []const u8, name: []const u8,
latitude: f64, coords: Coordinates,
longitude: f64,
}; };
const Airports = @This(); const Airports = @This();
@ -79,8 +79,10 @@ fn parseAirportLine(allocator: std.mem.Allocator, line: []const u8) !Airport {
return Airport{ return Airport{
.iata = iata, .iata = iata,
.name = name, .name = name,
.latitude = lat, .coords = .{
.longitude = lon, .latitude = lat,
.longitude = lon,
},
}; };
} }
@ -101,8 +103,8 @@ test "parseAirportLine valid" {
try std.testing.expectEqualStrings("GKA", airport.iata); try std.testing.expectEqualStrings("GKA", airport.iata);
try std.testing.expectEqualStrings("Goroka Airport", airport.name); try std.testing.expectEqualStrings("Goroka Airport", airport.name);
try std.testing.expectApproxEqAbs(@as(f64, -6.081689834590001), airport.latitude, 0.0001); try std.testing.expectApproxEqAbs(@as(f64, -6.081689834590001), airport.coords.latitude, 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, 145.391998291), airport.longitude, 0.0001); try std.testing.expectApproxEqAbs(@as(f64, 145.391998291), airport.coords.longitude, 0.0001);
} }
test "parseAirportLine with null IATA" { test "parseAirportLine with null IATA" {
@ -122,5 +124,5 @@ test "AirportDB lookup" {
const result = db.lookup("MUC"); const result = db.lookup("MUC");
try std.testing.expect(result != null); try std.testing.expect(result != null);
try std.testing.expectEqualStrings("Munich Airport", result.?.name); try std.testing.expectEqualStrings("Munich Airport", result.?.name);
try std.testing.expectApproxEqAbs(@as(f64, 48.353802), result.?.latitude, 0.0001); try std.testing.expectApproxEqAbs(@as(f64, 48.353802), result.?.coords.latitude, 0.0001);
} }

View file

@ -1,4 +1,5 @@
const std = @import("std"); const std = @import("std");
const Coordinates = @import("../Coordinates.zig");
const GeoCache = @This(); const GeoCache = @This();
@ -8,8 +9,7 @@ cache_file: ?[]const u8,
pub const CachedLocation = struct { pub const CachedLocation = struct {
name: []const u8, name: []const u8,
latitude: f64, coords: Coordinates,
longitude: f64,
}; };
pub fn init(allocator: std.mem.Allocator, cache_file: ?[]const u8) !GeoCache { pub fn init(allocator: std.mem.Allocator, cache_file: ?[]const u8) !GeoCache {
@ -54,8 +54,7 @@ pub fn put(self: *GeoCache, query: []const u8, location: CachedLocation) !void {
const key = try self.allocator.dupe(u8, query); const key = try self.allocator.dupe(u8, query);
const value = CachedLocation{ const value = CachedLocation{
.name = try self.allocator.dupe(u8, location.name), .name = try self.allocator.dupe(u8, location.name),
.latitude = location.latitude, .coords = location.coords,
.longitude = location.longitude,
}; };
try self.cache.put(key, value); try self.cache.put(key, value);
} }
@ -81,8 +80,10 @@ fn loadFromFile(allocator: std.mem.Allocator, cache: *std.StringHashMap(CachedLo
const key = try allocator.dupe(u8, entry.key_ptr.*); const key = try allocator.dupe(u8, entry.key_ptr.*);
const value = CachedLocation{ const value = CachedLocation{
.name = try allocator.dupe(u8, obj.get("name").?.string), .name = try allocator.dupe(u8, obj.get("name").?.string),
.latitude = obj.get("latitude").?.float, .coords = .{
.longitude = obj.get("longitude").?.float, .latitude = obj.get("latitude").?.float,
.longitude = obj.get("longitude").?.float,
},
}; };
try cache.put(key, value); try cache.put(key, value);
} }
@ -108,8 +109,8 @@ fn saveToFile(self: *GeoCache, file_path: []const u8) !void {
std.json.fmt(entry.key_ptr.*, .{}), std.json.fmt(entry.key_ptr.*, .{}),
std.json.fmt(.{ std.json.fmt(.{
.name = entry.value_ptr.name, .name = entry.value_ptr.name,
.latitude = entry.value_ptr.latitude, .latitude = entry.value_ptr.coords.latitude,
.longitude = entry.value_ptr.longitude, .longitude = entry.value_ptr.coords.longitude,
}, .{}), }, .{}),
}); });
} }
@ -126,14 +127,16 @@ test "GeoCache basic operations" {
// Test put and get // Test put and get
try cache.put("London", .{ try cache.put("London", .{
.name = "London, UK", .name = "London, UK",
.latitude = 51.5074, .coords = .{
.longitude = -0.1278, .latitude = 51.5074,
.longitude = -0.1278,
},
}); });
const result = cache.get("London"); const result = cache.get("London");
try std.testing.expect(result != null); try std.testing.expect(result != null);
try std.testing.expectApproxEqAbs(@as(f64, 51.5074), result.?.latitude, 0.0001); try std.testing.expectApproxEqAbs(@as(f64, 51.5074), result.?.coords.latitude, 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, -0.1278), result.?.longitude, 0.0001); try std.testing.expectApproxEqAbs(@as(f64, -0.1278), result.?.coords.longitude, 0.0001);
} }
test "GeoCache miss returns null" { test "GeoCache miss returns null" {

View file

@ -1,9 +1,5 @@
const std = @import("std"); const std = @import("std");
const Coordinates = @import("../Coordinates.zig");
pub const Coordinates = struct {
latitude: f64,
longitude: f64,
};
pub const MMDB = extern struct { pub const MMDB = extern struct {
filename: [*:0]const u8, filename: [*:0]const u8,

View file

@ -2,11 +2,11 @@ const std = @import("std");
const GeoIp = @import("GeoIp.zig"); const GeoIp = @import("GeoIp.zig");
const GeoCache = @import("GeoCache.zig"); const GeoCache = @import("GeoCache.zig");
const Airports = @import("Airports.zig"); const Airports = @import("Airports.zig");
const Coordinates = @import("../Coordinates.zig");
pub const Location = struct { pub const Location = struct {
name: []const u8, name: []const u8,
latitude: f64, coords: Coordinates,
longitude: f64,
}; };
pub const LocationType = enum { pub const LocationType = enum {
@ -56,10 +56,9 @@ pub const Resolver = struct {
fn resolveIP(self: *Resolver, ip: []const u8) !Location { fn resolveIP(self: *Resolver, ip: []const u8) !Location {
if (self.geoip) |geoip| { if (self.geoip) |geoip| {
if (try geoip.lookup(ip)) |coords| { if (try geoip.lookup(ip)) |coords| {
return Location{ return .{
.name = try self.allocator.dupe(u8, ip), .name = try self.allocator.dupe(u8, ip),
.latitude = coords.latitude, .coords = coords,
.longitude = coords.longitude,
}; };
} }
} }
@ -89,8 +88,7 @@ pub const Resolver = struct {
if (self.geocache.get(name)) |cached| { if (self.geocache.get(name)) |cached| {
return Location{ return Location{
.name = try self.allocator.dupe(u8, cached.name), .name = try self.allocator.dupe(u8, cached.name),
.latitude = cached.latitude, .coords = cached.coords,
.longitude = cached.longitude,
}; };
} }
@ -145,14 +143,18 @@ pub const Resolver = struct {
// Cache the result // Cache the result
try self.geocache.put(name, .{ try self.geocache.put(name, .{
.name = display_name, .name = display_name,
.latitude = lat, .coords = .{
.longitude = lon, .latitude = lat,
.longitude = lon,
},
}); });
return Location{ return Location{
.name = try self.allocator.dupe(u8, display_name), .name = try self.allocator.dupe(u8, display_name),
.latitude = lat, .coords = .{
.longitude = lon, .latitude = lat,
.longitude = lon,
},
}; };
} }
@ -168,8 +170,7 @@ pub const Resolver = struct {
if (airports.lookup(&upper_code)) |airport| { if (airports.lookup(&upper_code)) |airport| {
return Location{ return Location{
.name = try self.allocator.dupe(u8, airport.name), .name = try self.allocator.dupe(u8, airport.name),
.latitude = airport.latitude, .coords = airport.coords,
.longitude = airport.longitude,
}; };
} }
} }

View file

@ -1,5 +1,6 @@
const std = @import("std"); const std = @import("std");
const WeatherProvider = @import("Provider.zig"); const WeatherProvider = @import("Provider.zig");
const Coordinates = @import("../Coordinates.zig");
const types = @import("types.zig"); const types = @import("types.zig");
const MetNo = @This(); const MetNo = @This();
@ -22,16 +23,13 @@ pub fn provider(self: *MetNo) WeatherProvider {
}; };
} }
fn fetch(ptr: *anyopaque, allocator: std.mem.Allocator, location: []const u8) !types.WeatherData { fn fetch(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) !types.WeatherData {
const self: *MetNo = @ptrCast(@alignCast(ptr)); const self: *MetNo = @ptrCast(@alignCast(ptr));
// Parse location as "lat,lon" or use default
const coords = parseLocation(location) catch Coords{ .lat = 51.5074, .lon = -0.1278 };
const url = try std.fmt.allocPrint( const url = try std.fmt.allocPrint(
self.allocator, self.allocator,
"https://api.met.no/weatherapi/locationforecast/2.0/compact?lat={d:.4}&lon={d:.4}", "https://api.met.no/weatherapi/locationforecast/2.0/compact?lat={d:.4}&lon={d:.4}",
.{ coords.lat, coords.lon }, .{ coords.latitude, coords.longitude },
); );
defer self.allocator.free(url); defer self.allocator.free(url);
@ -67,7 +65,7 @@ fn fetch(ptr: *anyopaque, allocator: std.mem.Allocator, location: []const u8) !t
); );
defer parsed.deinit(); defer parsed.deinit();
return try parseMetNoResponse(allocator, location, parsed.value); return try parseMetNoResponse(allocator, coords, parsed.value);
} }
fn deinitProvider(ptr: *anyopaque) void { fn deinitProvider(ptr: *anyopaque) void {
@ -79,24 +77,7 @@ pub fn deinit(self: *MetNo) void {
_ = self; _ = self;
} }
const Coords = struct { fn parseMetNoResponse(allocator: std.mem.Allocator, coords: Coordinates, json: std.json.Value) !types.WeatherData {
lat: f64,
lon: f64,
};
fn parseLocation(location: []const u8) !Coords {
if (std.mem.indexOf(u8, location, ",")) |comma_idx| {
const lat_str = std.mem.trim(u8, location[0..comma_idx], " ");
const lon_str = std.mem.trim(u8, location[comma_idx + 1 ..], " ");
return Coords{
.lat = try std.fmt.parseFloat(f64, lat_str),
.lon = try std.fmt.parseFloat(f64, lon_str),
};
}
return error.InvalidLocationFormat;
}
fn parseMetNoResponse(allocator: std.mem.Allocator, location: []const u8, json: std.json.Value) !types.WeatherData {
const properties = json.object.get("properties") orelse return error.InvalidResponse; const properties = json.object.get("properties") orelse return error.InvalidResponse;
const timeseries = properties.object.get("timeseries") orelse return error.InvalidResponse; const timeseries = properties.object.get("timeseries") orelse return error.InvalidResponse;
@ -128,7 +109,7 @@ fn parseMetNoResponse(allocator: std.mem.Allocator, location: []const u8, json:
"N"; "N";
return types.WeatherData{ return types.WeatherData{
.location = try allocator.dupe(u8, location), .location = try std.fmt.allocPrint(allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude }),
.current = .{ .current = .{
.temp_c = temp_c, .temp_c = temp_c,
.temp_f = temp_c * 9.0 / 5.0 + 32.0, .temp_f = temp_c * 9.0 / 5.0 + 32.0,
@ -178,22 +159,6 @@ fn degreeToDirection(deg: f32) []const u8 {
return directions[@min(idx, 7)]; return directions[@min(idx, 7)];
} }
test "parseLocation with valid coordinates" {
const coords = try parseLocation("51.5074,-0.1278");
try std.testing.expectApproxEqAbs(@as(f64, 51.5074), coords.lat, 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, -0.1278), coords.lon, 0.0001);
}
test "parseLocation with whitespace" {
const coords = try parseLocation(" 40.7128 , -74.0060 ");
try std.testing.expectApproxEqAbs(@as(f64, 40.7128), coords.lat, 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, -74.0060), coords.lon, 0.0001);
}
test "parseLocation with invalid format" {
try std.testing.expectError(error.InvalidLocationFormat, parseLocation("London"));
}
test "degreeToDirection" { test "degreeToDirection" {
try std.testing.expectEqualStrings("N", degreeToDirection(0)); try std.testing.expectEqualStrings("N", degreeToDirection(0));
try std.testing.expectEqualStrings("NE", degreeToDirection(45)); try std.testing.expectEqualStrings("NE", degreeToDirection(45));

View file

@ -1,5 +1,6 @@
const std = @import("std"); const std = @import("std");
const WeatherProvider = @import("Provider.zig"); const WeatherProvider = @import("Provider.zig");
const Coordinates = @import("../Coordinates.zig");
const types = @import("types.zig"); const types = @import("types.zig");
const Mock = @This(); const Mock = @This();
@ -24,14 +25,17 @@ pub fn provider(self: *Mock) WeatherProvider {
}; };
} }
pub fn addResponse(self: *Mock, location: []const u8, data: types.WeatherData) !void { pub fn addResponse(self: *Mock, coords: Coordinates, data: types.WeatherData) !void {
const key = try self.allocator.dupe(u8, location); const key = try std.fmt.allocPrint(self.allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude });
try self.responses.put(key, data); try self.responses.put(key, data);
} }
fn fetch(ptr: *anyopaque, allocator: std.mem.Allocator, location: []const u8) !types.WeatherData { fn fetch(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) !types.WeatherData {
const self: *Mock = @ptrCast(@alignCast(ptr)); const self: *Mock = @ptrCast(@alignCast(ptr));
const data = self.responses.get(location) orelse return error.LocationNotFound; const key = try std.fmt.allocPrint(allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude });
defer allocator.free(key);
const data = self.responses.get(key) orelse return error.LocationNotFound;
return types.WeatherData{ return types.WeatherData{
.location = try allocator.dupe(u8, data.location), .location = try allocator.dupe(u8, data.location),
@ -58,6 +62,8 @@ test "mock weather provider" {
var mock = try Mock.init(std.testing.allocator); var mock = try Mock.init(std.testing.allocator);
defer mock.deinit(); defer mock.deinit();
const coords = Coordinates{ .latitude = 51.5074, .longitude = -0.1278 };
const data = types.WeatherData{ const data = types.WeatherData{
.location = "London", .location = "London",
.current = .{ .current = .{
@ -75,10 +81,10 @@ test "mock weather provider" {
.allocator = std.testing.allocator, .allocator = std.testing.allocator,
}; };
try mock.addResponse("London", data); try mock.addResponse(coords, data);
const p = mock.provider(); const p = mock.provider();
const result = try p.fetch(std.testing.allocator, "London"); const result = try p.fetch(std.testing.allocator, coords);
defer result.deinit(); defer result.deinit();
try std.testing.expectEqual(@as(f32, 15.0), result.current.temp_c); try std.testing.expectEqual(@as(f32, 15.0), result.current.temp_c);

View file

@ -1,5 +1,12 @@
//! Weather provider interface using vtable pattern.
//!
//! This provides a common interface for different weather data sources
//! (e.g., Met.no, mock providers) to implement. Providers must implement
//! the fetch and deinit functions in their vtable.
const std = @import("std"); const std = @import("std");
const types = @import("types.zig"); const types = @import("types.zig");
const Coordinates = @import("../Coordinates.zig");
const WeatherProvider = @This(); const WeatherProvider = @This();
@ -7,12 +14,12 @@ ptr: *anyopaque,
vtable: *const VTable, vtable: *const VTable,
pub const VTable = struct { pub const VTable = struct {
fetch: *const fn (ptr: *anyopaque, allocator: std.mem.Allocator, location: []const u8) anyerror!types.WeatherData, fetch: *const fn (ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) anyerror!types.WeatherData,
deinit: *const fn (ptr: *anyopaque) void, deinit: *const fn (ptr: *anyopaque) void,
}; };
pub fn fetch(self: WeatherProvider, allocator: std.mem.Allocator, location: []const u8) !types.WeatherData { pub fn fetch(self: WeatherProvider, allocator: std.mem.Allocator, coords: Coordinates) !types.WeatherData {
return self.vtable.fetch(self.ptr, allocator, location); return self.vtable.fetch(self.ptr, allocator, coords);
} }
pub fn deinit(self: WeatherProvider) void { pub fn deinit(self: WeatherProvider) void {