Compare commits

..

10 commits

10 changed files with 384 additions and 88 deletions

View file

@ -0,0 +1,79 @@
name: Generic zig build
on:
workflow_dispatch:
push:
branches:
- '*'
env:
BUILD_TARGET: x86_64-linux-musl # Needs to be musl since we're using dlopen
BUILD_OPTIMIZATION: ReleaseSafe # Safety is usually a good thing
BINARY_NAME: wttr
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v6
- name: Setup Zig
uses: https://codeberg.org/mlugg/setup-zig@v2.1.0
- name: Build project
run: zig build --summary all
- name: Run tests
run: zig build test -Ddownload-geoip --summary all
- name: Package
run: zig build -Dtarget="$BUILD_TARGET" -Doptimize="$BUILD_OPTIMIZATION"
- name: Upload
uses: actions/upload-artifact@v3
with:
name: $BINARY_NAME
path: zig-out/bin/$BINARY_NAME
- name: Notify
uses: https://git.lerch.org/lobo/action-notify-ntfy@v2
if: always() && env.GITEA_ACTIONS == 'true'
with:
host: ${{ secrets.NTFY_HOST }}
topic: ${{ secrets.NTFY_TOPIC }}
status: ${{ job.status }}
user: ${{ secrets.NTFY_USER }}
password: ${{ secrets.NTFY_PASSWORD }}
deploy:
runs-on: ubuntu-latest
container:
image: ghcr.io/catthehacker/ubuntu:act-22.04
needs: build
steps:
- name: Check out repository code
uses: actions/checkout@v6
- name: Download Artifact
uses: actions/download-artifact@v3
with:
name: $BINARY_NAME
- name: "Make executable actually executable"
run: chmod 755 $BINARY_NAME && mv $BINARY_NAME docker
- name: Get short ref
id: vars
run: echo "shortsha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
-
name: Login to Gitea
uses: docker/login-action@v2
with:
registry: git.lerch.org
username: ${{ github.actor }}
password: ${{ secrets.PACKAGE_PUSH }}
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: docker
push: true
tags: |
git.lerch.org/${{ github.repository }}:${{ steps.vars.outputs.shortsha }}
git.lerch.org/${{ github.repository }}:latest
- name: Notify
uses: https://git.lerch.org/lobo/action-notify-ntfy@v2
if: always()
with:
host: ${{ secrets.NTFY_HOST }}
topic: ${{ secrets.NTFY_TOPIC }}
user: ${{ secrets.NTFY_USER }}
password: ${{ secrets.NTFY_PASSWORD }}

1
.gitignore vendored
View file

@ -1,4 +1,3 @@
*.swp *.swp
GeoLite2-City.mmdb
.zig-cache/ .zig-cache/
zig-out/ zig-out/

View file

@ -1,4 +1,5 @@
const std = @import("std"); const std = @import("std");
const GitVersion = @import("build/GitVersion.zig");
pub fn build(b: *std.Build) void { pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{}); const target = b.standardTargetOptions(.{});
@ -53,6 +54,10 @@ pub fn build(b: *std.Build) void {
maxminddb.installHeadersDirectory(maxminddb_upstream.path("include"), "", .{}); maxminddb.installHeadersDirectory(maxminddb_upstream.path("include"), "", .{});
const version = GitVersion.getVersion(b, .{});
const build_options = b.addOptions();
build_options.addOption([]const u8, "version", version);
const exe = b.addExecutable(.{ const exe = b.addExecutable(.{
.name = "wttr", .name = "wttr",
.root_module = b.createModule(.{ .root_module = b.createModule(.{
@ -67,6 +72,7 @@ pub fn build(b: *std.Build) void {
exe.root_module.addAnonymousImport("airports.dat", .{ exe.root_module.addAnonymousImport("airports.dat", .{
.root_source_file = openflights.path("data/airports.dat"), .root_source_file = openflights.path("data/airports.dat"),
}); });
exe.root_module.addOptions("build_options", build_options);
exe.root_module.addIncludePath(maxminddb_upstream.path("include")); exe.root_module.addIncludePath(maxminddb_upstream.path("include"));
exe.root_module.addConfigHeader(maxminddb_config); exe.root_module.addConfigHeader(maxminddb_config);
exe.linkLibrary(maxminddb); exe.linkLibrary(maxminddb);
@ -87,6 +93,7 @@ pub fn build(b: *std.Build) void {
const test_options = b.addOptions(); const test_options = b.addOptions();
test_options.addOption(bool, "download_geoip", download_geoip); test_options.addOption(bool, "download_geoip", download_geoip);
test_options.addOption([]const u8, "version", version);
const tests = b.addTest(.{ const tests = b.addTest(.{
.root_module = b.createModule(.{ .root_module = b.createModule(.{

161
build/GitVersion.zig Normal file
View file

@ -0,0 +1,161 @@
const std = @import("std");
const Build = std.Build;
pub const Options = struct {
/// Length of the short hash (default: 7, git's default)
hash_length: u6 = 7,
/// String to append when working tree is dirty
dirty_flag: []const u8 = "*",
};
/// Get git version information by reading .git files directly
pub fn getVersion(b: *Build, options: Options) []const u8 {
const allocator = b.allocator;
// Find build root by looking for build.zig
const build_root = findBuildRoot(allocator) catch return "unknown";
defer allocator.free(build_root);
// Read .git/HEAD relative to build root
const head_path = std.fmt.allocPrint(allocator, "{s}/.git/HEAD", .{build_root}) catch return "unknown";
defer allocator.free(head_path);
const head_data = std.fs.cwd().readFileAlloc(allocator, head_path, 1024) catch {
return "not under version control";
};
defer allocator.free(head_data);
const head_trimmed = std.mem.trim(u8, head_data, &std.ascii.whitespace);
// Parse HEAD - either "ref: refs/heads/branch" or direct hash
const hash_owned = if (std.mem.startsWith(u8, head_trimmed, "ref: ")) blk: {
const ref_path_rel = std.mem.trimLeft(u8, head_trimmed[5..], &std.ascii.whitespace);
const ref_file = std.fmt.allocPrint(allocator, "{s}/.git/{s}", .{ build_root, ref_path_rel }) catch return "unknown";
defer allocator.free(ref_file);
const ref_fd = std.fs.openFileAbsolute(ref_file, .{}) catch return "unknown";
defer ref_fd.close();
var ref_buf: [1024]u8 = undefined;
const bytes_read = ref_fd.readAll(&ref_buf) catch return "unknown";
const ref_data = ref_buf[0..bytes_read];
const ref_trimmed = std.mem.trim(u8, ref_data, &std.ascii.whitespace);
break :blk allocator.dupe(u8, ref_trimmed) catch return "unknown";
} else allocator.dupe(u8, head_trimmed) catch return "unknown";
defer allocator.free(hash_owned);
// Truncate to short hash
const short_hash = if (hash_owned.len > options.hash_length)
hash_owned[0..options.hash_length]
else
hash_owned;
// Check if dirty using simple heuristic:
// If any .zig files are newer than .git/index, mark as dirty
const is_dirty = isDirty(allocator, build_root) catch return "unknown";
if (is_dirty) {
return std.fmt.allocPrint(allocator, "{s}{s}", .{ short_hash, options.dirty_flag }) catch return "unknown";
}
return allocator.dupe(u8, short_hash) catch return "unknown";
}
fn findBuildRoot(allocator: std.mem.Allocator) ![]const u8 {
var buf: [std.fs.max_path_bytes]u8 = undefined;
const start_cwd = try std.fs.cwd().realpath(".", &buf);
var cwd: []const u8 = start_cwd;
while (true) {
// Check if build.zig exists in current directory
var dir = std.fs.openDirAbsolute(cwd, .{}) catch break;
defer dir.close();
dir.access("build.zig", .{}) catch {
// build.zig not found, try parent
const parent = std.fs.path.dirname(cwd) orelse break;
if (std.mem.eql(u8, parent, cwd)) break; // Reached root
cwd = parent;
continue;
};
return allocator.dupe(u8, cwd);
}
return error.BuildRootNotFound;
}
fn isDirty(allocator: std.mem.Allocator, build_root: []const u8) !bool {
// Get .git/index mtime
const index_path = try std.fs.path.join(allocator, &[_][]const u8{ build_root, ".git", "index" });
defer allocator.free(index_path);
const index_stat = std.fs.cwd().statFile(index_path) catch return error.CannotDetermineDirty;
const index_mtime = index_stat.mtime;
// Read .gitignore
const ignore_path = try std.fs.path.join(allocator, &[_][]const u8{ build_root, ".gitignore" });
defer allocator.free(ignore_path);
const ignore_data = std.fs.cwd().readFileAlloc(allocator, ignore_path, 1024 * 1024) catch
try allocator.dupe(u8, "");
defer allocator.free(ignore_data);
// Walk source files in build root and check if any are newer
var dir = std.fs.openDirAbsolute(build_root, .{ .iterate = true }) catch return error.CannotDetermineDirty;
defer dir.close();
var walker = dir.walk(allocator) catch return error.CannotDetermineDirty;
defer walker.deinit();
while (walker.next() catch return error.CannotDetermineDirty) |entry| {
if (entry.kind != .file) continue;
// Always ignore .git/
if (std.mem.startsWith(u8, entry.path, ".git/")) continue;
// Check if path matches any ignore pattern
var lines = std.mem.splitScalar(u8, ignore_data, '\n');
var ignored = false;
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0) continue;
if (trimmed[0] == '#') continue;
if (isIgnored(entry.path, trimmed)) {
ignored = true;
break;
}
}
if (ignored) continue;
const stat = entry.dir.statFile(entry.basename) catch continue;
if (stat.mtime > index_mtime) {
return true;
}
}
return false;
}
/// Handles checking a gitignore style pattern to determine if a path should
/// be ignored. Mostly, globs are not handled, but an extension-like pattern
/// is common and recognized by this function, so things like '*.swp' will work
/// as expected
fn isIgnored(path: []const u8, pattern: []const u8) bool {
// TODO: Handle other glob patterns (?, [], *prefix, suffix*)
// Handle *.extension pattern
if (std.mem.startsWith(u8, pattern, "*."))
return std.mem.endsWith(u8, path, pattern[1..]);
// Pattern ending with / matches directory prefix
if (std.mem.endsWith(u8, pattern, "/"))
return std.mem.startsWith(u8, path, pattern);
// Pattern starting with / matches from root
if (std.mem.startsWith(u8, pattern, "/"))
return std.mem.eql(u8, path, pattern[1..]);
// Otherwise match as substring (file/dir name anywhere in path)
return std.mem.indexOf(u8, path, pattern) != null;
}

7
docker/Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM scratch
COPY ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY wttr /wttr
ENV HOME=/home/wttr
USER 1000:1000
EXPOSE 8002
ENTRYPOINT ["/wttr"]

84
src/Config.zig Normal file
View file

@ -0,0 +1,84 @@
const std = @import("std");
const Config = @This();
listen_host: []const u8,
listen_port: u16,
cache_size: usize,
cache_dir: []const u8,
/// GeoLite2 is used for GeoIP (IP -> geographic location)
/// IP2Location is a fallback if IP is not found in this db
geolite_path: []const u8,
/// Geocache file stores location lookups
/// (e.g. "Portland -> 45.52345°N, -122.67621° W). When not found in cache,
/// a web service from Nominatum (https://nominatim.org/) is used
geocache_file: ?[]const u8,
/// If provided, when GeoLite2 is missing data, https://www.ip2location.com/
/// can be used. This will also be cached in the cached file
ip2location_api_key: ?[]const u8,
ip2location_cache_file: []const u8,
pub fn load(allocator: std.mem.Allocator) !Config {
var env = try std.process.getEnvMap(allocator);
defer env.deinit();
// Get XDG_CACHE_HOME or default to ~/.cache
const home = env.get("HOME") orelse "/tmp";
const xdg_cache = env.get("XDG_CACHE_HOME") orelse
try std.fs.path.join(allocator, &[_][]const u8{ home, ".cache" });
defer if (env.get("XDG_CACHE_HOME") == null) allocator.free(xdg_cache);
const default_cache_dir = try std.fs.path.join(allocator, &[_][]const u8{ xdg_cache, "wttr" });
defer allocator.free(default_cache_dir);
return .{
.listen_host = env.get("WTTR_LISTEN_HOST") orelse try allocator.dupe(u8, "0.0.0.0"),
.listen_port = if (env.get("WTTR_LISTEN_PORT")) |p|
try std.fmt.parseInt(u16, p, 10)
else
8002,
.cache_size = if (env.get("WTTR_CACHE_SIZE")) |s|
try std.fmt.parseInt(usize, s, 10)
else
10_000,
.cache_dir = try allocator.dupe(u8, env.get("WTTR_CACHE_DIR") orelse default_cache_dir),
.geolite_path = blk: {
if (env.get("WTTR_GEOLITE_PATH")) |v| {
break :blk try allocator.dupe(u8, v);
}
break :blk try std.fmt.allocPrint(allocator, "{s}/GeoLite2-City.mmdb", .{
env.get("WTTR_CACHE_DIR") orelse default_cache_dir,
});
},
.geocache_file = if (env.get("WTTR_GEOCACHE_FILE")) |v| try allocator.dupe(u8, v) else try std.fs.path.join(allocator, &[_][]const u8{ default_cache_dir, "geocache.json" }),
.ip2location_api_key = if (env.get("IP2LOCATION_API_KEY")) |v| try allocator.dupe(u8, v) else null,
.ip2location_cache_file = blk: {
if (env.get("IP2LOCATION_CACHE_FILE")) |v| {
break :blk try allocator.dupe(u8, v);
}
break :blk try std.fmt.allocPrint(allocator, "{s}/ip2location.cache", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir});
},
};
}
pub fn deinit(self: Config, allocator: std.mem.Allocator) void {
allocator.free(self.listen_host);
allocator.free(self.cache_dir);
allocator.free(self.geolite_path);
if (self.geocache_file) |f| allocator.free(f);
if (self.ip2location_api_key) |k| allocator.free(k);
allocator.free(self.ip2location_cache_file);
}
test "config loads defaults" {
const allocator = std.testing.allocator;
const cfg = try Config.load(allocator);
defer cfg.deinit(allocator);
try std.testing.expectEqualStrings("0.0.0.0", cfg.listen_host);
try std.testing.expectEqual(@as(u16, 8002), cfg.listen_port);
try std.testing.expectEqual(@as(usize, 10_000), cfg.cache_size);
}

View file

@ -1,75 +0,0 @@
const std = @import("std");
pub const Config = struct {
listen_host: []const u8,
listen_port: u16,
cache_size: usize,
cache_dir: []const u8,
geolite_path: []const u8,
geocache_file: ?[]const u8,
ip2location_api_key: ?[]const u8,
ip2location_cache_file: []const u8,
pub fn load(allocator: std.mem.Allocator) !Config {
var env = try std.process.getEnvMap(allocator);
defer env.deinit();
// Get XDG_CACHE_HOME or default to ~/.cache
const home = env.get("HOME") orelse "/tmp";
const xdg_cache = env.get("XDG_CACHE_HOME") orelse
try std.fmt.allocPrint(allocator, "{s}/.cache", .{home});
defer if (env.get("XDG_CACHE_HOME") == null) allocator.free(xdg_cache);
const default_cache_dir = try std.fmt.allocPrint(allocator, "{s}/wttr", .{xdg_cache});
defer allocator.free(default_cache_dir);
return Config{
.listen_host = env.get("WTTR_LISTEN_HOST") orelse try allocator.dupe(u8, "0.0.0.0"),
.listen_port = if (env.get("WTTR_LISTEN_PORT")) |p|
try std.fmt.parseInt(u16, p, 10)
else
8002,
.cache_size = if (env.get("WTTR_CACHE_SIZE")) |s|
try std.fmt.parseInt(usize, s, 10)
else
10_000,
.cache_dir = try allocator.dupe(u8, env.get("WTTR_CACHE_DIR") orelse default_cache_dir),
.geolite_path = blk: {
if (env.get("WTTR_GEOLITE_PATH")) |v| {
break :blk try allocator.dupe(u8, v);
}
break :blk try std.fmt.allocPrint(allocator, "{s}/GeoLite2-City.mmdb", .{
env.get("WTTR_CACHE_DIR") orelse default_cache_dir,
});
},
.geocache_file = if (env.get("WTTR_GEOCACHE_FILE")) |v| try allocator.dupe(u8, v) else null,
.ip2location_api_key = if (env.get("IP2LOCATION_API_KEY")) |v| try allocator.dupe(u8, v) else null,
.ip2location_cache_file = blk: {
if (env.get("IP2LOCATION_CACHE_FILE")) |v| {
break :blk try allocator.dupe(u8, v);
}
break :blk try std.fmt.allocPrint(allocator, "{s}/ip2location.cache", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir});
},
};
}
pub fn deinit(self: Config, allocator: std.mem.Allocator) void {
allocator.free(self.listen_host);
allocator.free(self.cache_dir);
allocator.free(self.geolite_path);
if (self.geocache_file) |f| allocator.free(f);
if (self.ip2location_api_key) |k| allocator.free(k);
allocator.free(self.ip2location_cache_file);
}
};
test "config loads defaults" {
const allocator = std.testing.allocator;
const cfg = try Config.load(allocator);
defer cfg.deinit(allocator);
try std.testing.expectEqualStrings("0.0.0.0", cfg.listen_host);
try std.testing.expectEqual(@as(u16, 8002), cfg.listen_port);
try std.testing.expectEqual(@as(usize, 10_000), cfg.cache_size);
try std.testing.expect(cfg.geocache_file == null);
}

View file

@ -138,8 +138,12 @@ test "GeoIP init with invalid path fails" {
} }
test "isUSIP detects US IPs" { test "isUSIP detects US IPs" {
const allocator = std.testing.allocator;
const Config = @import("../Config.zig");
const config = try Config.load(allocator);
defer config.deinit(allocator);
const build_options = @import("build_options"); const build_options = @import("build_options");
const db_path = "./GeoLite2-City.mmdb"; const db_path = config.geolite_path;
if (build_options.download_geoip) { if (build_options.download_geoip) {
const GeoLite2 = @import("GeoLite2.zig"); const GeoLite2 = @import("GeoLite2.zig");

View file

@ -1,5 +1,5 @@
const std = @import("std"); const std = @import("std");
const config = @import("config.zig"); const Config = @import("Config.zig");
const Cache = @import("cache/Cache.zig"); const Cache = @import("cache/Cache.zig");
const MetNo = @import("weather/MetNo.zig"); const MetNo = @import("weather/MetNo.zig");
const Server = @import("http/Server.zig"); const Server = @import("http/Server.zig");
@ -9,16 +9,17 @@ const GeoCache = @import("location/GeoCache.zig");
const Airports = @import("location/Airports.zig"); const Airports = @import("location/Airports.zig");
const Resolver = @import("location/resolver.zig").Resolver; const Resolver = @import("location/resolver.zig").Resolver;
const GeoLite2 = @import("location/GeoLite2.zig"); const GeoLite2 = @import("location/GeoLite2.zig");
const version = @import("build_options").version;
pub fn main() !void { pub fn main() !u8 {
var gpa = std.heap.GeneralPurposeAllocator(.{}){}; var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit(); defer _ = gpa.deinit();
const allocator = gpa.allocator(); const allocator = gpa.allocator();
const cfg = try config.Config.load(allocator); const cfg = try Config.load(allocator);
defer cfg.deinit(allocator); defer cfg.deinit(allocator);
std.log.info("wttr starting on {s}:{d}", .{ cfg.listen_host, cfg.listen_port }); std.log.info("wttr version {s} starting on {s}:{d}", .{ version, cfg.listen_host, cfg.listen_port });
std.log.info("Cache size: {d}", .{cfg.cache_size}); std.log.info("Cache size: {d}", .{cfg.cache_size});
std.log.info("Cache dir: {s}", .{cfg.cache_dir}); std.log.info("Cache dir: {s}", .{cfg.cache_dir});
std.log.info("GeoLite2 path: {s}", .{cfg.geolite_path}); std.log.info("GeoLite2 path: {s}", .{cfg.geolite_path});
@ -28,6 +29,12 @@ pub fn main() !void {
std.log.info("Geocache: in-memory only", .{}); std.log.info("Geocache: in-memory only", .{});
} }
var metno = MetNo.init(allocator, null) catch |err| {
if (err == MetNo.MissingIdentificationError) return 1;
return err;
};
defer metno.deinit();
// Ensure GeoLite2 database exists // Ensure GeoLite2 database exists
try GeoLite2.ensureDatabase(allocator, cfg.geolite_path); try GeoLite2.ensureDatabase(allocator, cfg.geolite_path);
@ -67,9 +74,6 @@ pub fn main() !void {
}); });
defer rate_limiter.deinit(); defer rate_limiter.deinit();
var metno = try MetNo.init(allocator);
defer metno.deinit();
var server = try Server.init(allocator, cfg.listen_host, cfg.listen_port, .{ var server = try Server.init(allocator, cfg.listen_host, cfg.listen_port, .{
.provider = metno.provider(cache), .provider = metno.provider(cache),
.resolver = &resolver, .resolver = &resolver,
@ -77,11 +81,12 @@ pub fn main() !void {
}, &rate_limiter); }, &rate_limiter);
try server.listen(); try server.listen();
return 0;
} }
test { test {
std.testing.refAllDecls(@This()); std.testing.refAllDecls(@This());
_ = @import("config.zig"); _ = @import("Config.zig");
_ = @import("cache/Lru.zig"); _ = @import("cache/Lru.zig");
_ = @import("weather/Mock.zig"); _ = @import("weather/Mock.zig");
_ = @import("http/RateLimiter.zig"); _ = @import("http/RateLimiter.zig");

View file

@ -1,4 +1,5 @@
const std = @import("std"); const std = @import("std");
const build_options = @import("build_options");
const WeatherProvider = @import("Provider.zig"); const WeatherProvider = @import("Provider.zig");
const Coordinates = @import("../Coordinates.zig"); const Coordinates = @import("../Coordinates.zig");
const types = @import("types.zig"); const types = @import("types.zig");
@ -7,6 +8,8 @@ const zeit = @import("zeit");
const MetNo = @This(); const MetNo = @This();
pub const MissingIdentificationError = error.MetNoIdentificationRequired;
const MetNoOpenWeatherEntry = struct { []const u8, types.WeatherCode }; const MetNoOpenWeatherEntry = struct { []const u8, types.WeatherCode };
// symbol codes: https://github.com/metno/weathericons/tree/main/weather // symbol codes: https://github.com/metno/weathericons/tree/main/weather
// they also have _day, _night and _polartwilight variants // they also have _day, _night and _polartwilight variants
@ -63,10 +66,24 @@ const WeatherCodeMap = std.StaticStringMap(types.WeatherCode);
const weather_code_map = WeatherCodeMap.initComptime(weather_code_entries); const weather_code_map = WeatherCodeMap.initComptime(weather_code_entries);
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
identifying_email: []const u8,
pub fn init(allocator: std.mem.Allocator, identifying_email: ?[]const u8) !MetNo {
const email = identifying_email orelse blk: {
const env_email = std.process.getEnvVarOwned(allocator, "METNO_TOS_IDENTIFYING_EMAIL") catch |err| {
if (err == error.EnvironmentVariableNotFound) {
std.log.err("Met.no Terms of Service require identification. Set METNO_TOS_IDENTIFYING_EMAIL environment variable", .{});
std.log.err("See \x1b]8;;https://api.met.no/doc/TermsOfService\x1b\\https://api.met.no/doc/TermsOfService\x1b]8;;\x1b\\ for more information", .{});
return MissingIdentificationError;
}
return err;
};
break :blk env_email;
};
pub fn init(allocator: std.mem.Allocator) !MetNo {
return MetNo{ return MetNo{
.allocator = allocator, .allocator = allocator,
.identifying_email = email,
}; };
} }
@ -100,12 +117,20 @@ fn fetchRaw(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates)
var response_buf: [1024 * 1024]u8 = undefined; var response_buf: [1024 * 1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&response_buf); var writer = std.Io.Writer.fixed(&response_buf);
const user_agent = try std.fmt.allocPrint(
self.allocator,
"wttr/{s} git.lerch.org/lobo/wttr {s}",
.{ build_options.version, self.identifying_email },
);
defer self.allocator.free(user_agent);
const result = try client.fetch(.{ const result = try client.fetch(.{
.location = .{ .uri = uri }, .location = .{ .uri = uri },
.method = .GET, .method = .GET,
.response_writer = &writer, .response_writer = &writer,
.extra_headers = &.{ .extra_headers = &.{
.{ .name = "User-Agent", .value = "wttr.in-zig/1.0 github.com/chubin/wttr.in" }, .{ .name = "User-Agent", .value = user_agent },
}, },
}); });
@ -145,7 +170,7 @@ fn deinitProvider(ptr: *anyopaque) void {
} }
pub fn deinit(self: *MetNo) void { pub fn deinit(self: *MetNo) void {
_ = self; self.allocator.free(self.identifying_email);
} }
fn parseMetNoResponse(allocator: std.mem.Allocator, coords: Coordinates, json: std.json.Value) !types.WeatherData { fn parseMetNoResponse(allocator: std.mem.Allocator, coords: Coordinates, json: std.json.Value) !types.WeatherData {