zfin-server/src/main.zig
Emil Lerch eabe91099a
All checks were successful
Generic zig build / build (push) Successful in 23s
Generic zig build / deploy (push) Successful in 14s
add refresh POST operation to allow forced symbol refreshes
2026-08-13 14:08:25 -07:00

148 lines
6 KiB
Zig

//! zfin-server — HTTP data service backed by zfin's provider infrastructure.
//!
//! Two modes:
//! zfin-server serve [--port=8080] Start the HTTP server
//! zfin-server refresh Refresh cache for all tracked symbols (for cron)
//!
//! See GET /help for endpoint documentation.
const std = @import("std");
const httpz = @import("httpz");
const build_options = @import("build_options");
const version = build_options.version;
const log = std.log.scoped(.@"zfin-server");
const App = @import("App.zig").App;
const handlers = @import("handlers.zig");
const refresh_cmd = @import("refresh.zig");
// ── Main ─────────────────────────────────────────────────────
pub fn main(init: std.process.Init) !u8 {
const allocator = init.gpa;
const io = init.io;
const environ = init.environ_map;
const args = try init.minimal.args.toSlice(allocator);
defer allocator.free(args);
if (args.len < 2) {
try printUsage(io);
return 1;
}
const command = args[1];
if (std.mem.eql(u8, command, "serve")) {
var port: u16 = 8080;
for (args[2..]) |arg| {
if (std.mem.startsWith(u8, arg, "--port=")) {
port = std.fmt.parseInt(u16, arg["--port=".len..], 10) catch 8080;
}
}
var app = App.init(io, allocator, environ);
defer app.deinit();
var server = try httpz.Server(*App).init(io, allocator, .{
.address = .all(port),
}, &app);
defer {
server.stop();
server.deinit();
}
var router = try server.router(.{});
// Static routes
router.get("/", handlers.handleIndex, .{});
router.get("/help", handlers.handleHelp, .{});
router.get("/symbols", handlers.handleSymbols, .{});
// POST, not GET: this spends provider quota and writes the cache, so it is
// not `safe` in the HTTP sense and must not sit on a verb that proxies,
// prefetchers and link checkers feel free to replay. (`/:symbol/watch`
// stays GET only because LibreOffice's WEBSERVICE cannot issue anything
// else - a constraint, not a precedent.)
router.post("/refresh", handlers.handleRefresh, .{});
// Symbol routes
router.get("/:symbol/returns", handlers.handleReturns, .{});
// Authenticated explicit watchlist add (API key required - not on
// the public allowlist). Distinct from the UA-gated
// /:symbol/returns?watch=true path used by LibreOffice.
router.get("/:symbol/watch", handlers.handleWatch, .{});
router.get("/:symbol/quote", handlers.handleQuote, .{});
router.get("/:symbol/candles", handlers.handleCandles, .{});
router.get("/:symbol/candles_meta", handlers.handleCandlesMeta, .{});
router.get("/:symbol/diagnostics", handlers.handleDiagnostics, .{});
router.get("/:symbol/dividends", handlers.handleDividends, .{});
router.get("/:symbol/splits", handlers.handleSplits, .{});
router.get("/:symbol/earnings", handlers.handleEarnings, .{});
router.get("/:symbol/options", handlers.handleOptions, .{});
// Wikidata + EDGAR derived data — populated by `refresh`.
router.get("/:symbol/classification", handlers.handleClassification, .{});
router.get("/:symbol/etf_metrics", handlers.handleEtfMetrics, .{});
router.get("/:cik/entity_facts", handlers.handleEntityFacts, .{});
// EDGAR shared ticker maps (~3-5 MB each, refreshed
// every 30 days). Static-key routes — single file
// shared across every symbol lookup.
router.get("/_edgar/tickers_funds", handlers.handleTickersFunds, .{});
router.get("/_edgar/tickers_companies", handlers.handleTickersCompanies, .{});
log.info("zfin-server {s}", .{version});
log.info("listening on port {d}", .{port});
try server.listen();
} else if (std.mem.eql(u8, command, "refresh")) {
return try refresh_cmd.refresh(io, init.arena.allocator(), environ);
} else {
try printUsage(io);
}
return 0;
}
fn printUsage(io: std.Io) !void {
var buf: [2048]u8 = undefined;
var fw = std.Io.File.stderr().writer(io, &buf);
const w = &fw.interface;
try w.print("zfin-server {s}\n", .{version});
try w.writeAll(
\\Usage: zfin-server <command>
\\
\\Commands:
\\ serve [--port=8080] Start the HTTP server
\\ refresh Refresh cache for all tracked symbols
\\
\\Environment:
\\ ZFIN_PORTFOLIO Path to portfolio SRF file (default: portfolio.srf)
\\ ZFIN_SERVER_API_KEY If set, require this key on every endpoint
\\ except /, /help, and /:symbol/returns. Supply it
\\ as an X-API-Key header or ?api_key= query param.
\\ TWELVEDATA_API_KEY TwelveData API key
\\ POLYGON_API_KEY Polygon API key
\\ FINNHUB_API_KEY Finnhub API key
\\ ALPHAVANTAGE_API_KEY Alpha Vantage API key
\\
\\refresh exit codes:
\\ 0 all tracked symbols current
\\ 75 provider data lag (a just-closed bar not yet posted) - retry soon
\\ 1 one or more hard failures
\\
);
try w.flush();
}
// ── Tests ────────────────────────────────────────────────────
test {
// Pulls each module's tests into the build. NOT optional bookkeeping: a test
// build never analyses `main`, so nothing else references these files, and
// without this the runner reports "test success" having executed ZERO tests.
// That is the worst possible failure - a green tick read as coverage - and it
// is exactly what the first attempt at this split produced.
_ = @import("App.zig");
_ = @import("handlers.zig");
_ = @import("refresh.zig");
}