148 lines
6 KiB
Zig
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");
|
|
}
|