// REST API handlers for /events endpoints // separate from events.zig which handles websocket connections const std = @import("std"); const mem = std.mem; const zap = @import("zap"); const log = @import("../logging.zig"); const db_events = @import("../db/events.zig"); const json = @import("../utilities/json.zig"); const encoding = @import("../utilities/encoding.zig"); const PAGE_SIZE: usize = 50; // POST /events/filter - query events with filters // GET /events/filter/next?page-token=... - get next page // GET /events/count - count total events pub fn handle(r: zap.Request) !void { const target = r.path orelse "/"; const method = r.method orelse "GET"; // GET /events/filter/next?page-token=... if (mem.eql(u8, method, "GET") and mem.indexOf(u8, target, "/filter/next") != null) { try filterNext(r); return; } // POST /events/filter if (mem.eql(u8, method, "POST") and mem.endsWith(u8, target, "/filter")) { try filter(r); return; } // GET /events/count if (mem.eql(u8, method, "GET") and mem.endsWith(u8, target, "/count")) { try count(r); return; } json.sendStatus(r, "{\"detail\":\"not found\"}", .not_found); } fn filter(r: zap.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const alloc = arena.allocator(); // parse request body const body = r.body orelse "{}"; const parsed = std.json.parseFromSlice(std.json.Value, alloc, body, .{}) catch { json.sendStatus(r, "{\"detail\":\"invalid json\"}", .bad_request); return; }; // parse limit (default PAGE_SIZE, max PAGE_SIZE for pagination) const limit: usize = blk: { if (json.getInt(parsed.value, "limit")) |l| { if (l > 0 and l <= PAGE_SIZE) break :blk @intCast(l); } break :blk PAGE_SIZE; }; // parse filter object const filter_opts = parseFilterOptions(alloc, parsed.value); // get total count first const total = db_events.countWithFilter(alloc, filter_opts) catch 0; // query first page (offset 0) const events = db_events.queryWithFilter(alloc, filter_opts, limit, 0) catch |err| { log.err("events-api", "query error: {}", .{err}); json.sendStatus(r, "{\"detail\":\"database error\"}", .internal_server_error); return; }; // generate next_page URL if there are more results const next_page = generateNextPageUrl(alloc, r, filter_opts, total, limit, 0); // send response sendEventPage(r, alloc, events, total, next_page); } fn filterNext(r: zap.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const alloc = arena.allocator(); // extract page-token from query string const query = r.query orelse ""; const token_start = mem.indexOf(u8, query, "page-token=") orelse { json.sendStatus(r, "{\"detail\":\"missing page token\"}", .forbidden); return; }; const token_value_start = token_start + "page-token=".len; var token_end = query.len; if (mem.indexOf(u8, query[token_value_start..], "&")) |amp| { token_end = token_value_start + amp; } const url_encoded_token = query[token_value_start..token_end]; // URL decode (handle %2B -> +, %2F -> /, %3D -> =) const decoded_token = encoding.urlDecode(alloc, url_encoded_token) catch { json.sendStatus(r, "{\"detail\":\"invalid page token\"}", .forbidden); return; }; // first base64 decode (URL layer) const inner_token = encoding.base64Decode(alloc, decoded_token) catch { json.sendStatus(r, "{\"detail\":\"invalid page token\"}", .forbidden); return; }; // second base64 decode (token layer) const token_json = encoding.base64Decode(alloc, inner_token) catch { json.sendStatus(r, "{\"detail\":\"invalid page token\"}", .forbidden); return; }; // parse token JSON const token_parsed = std.json.parseFromSlice(std.json.Value, alloc, token_json, .{}) catch { json.sendStatus(r, "{\"detail\":\"invalid page token\"}", .forbidden); return; }; if (token_parsed.value != .object) { json.sendStatus(r, "{\"detail\":\"invalid page token\"}", .forbidden); return; } // extract token fields using path helpers const total: usize = if (json.getInt(token_parsed.value, "count")) |c| if (c >= 0) @intCast(c) else 0 else 0; const page_size: usize = if (json.getInt(token_parsed.value, "page_size")) |ps| if (ps > 0) @intCast(ps) else PAGE_SIZE else PAGE_SIZE; const offset: usize = if (json.getInt(token_parsed.value, "offset")) |o| if (o >= 0) @intCast(o) else 0 else 0; // parse filter from token var filter_opts = db_events.FilterOptions{}; filter_opts.occurred_since = json.getString(token_parsed.value, "filter.occurred_since"); filter_opts.occurred_until = json.getString(token_parsed.value, "filter.occurred_until"); filter_opts.order_asc = json.getBool(token_parsed.value, "filter.order_asc") orelse false; if (json.getPath(token_parsed.value, "filter.event_prefixes")) |v| { filter_opts.event_prefixes = db_events.parseJsonStringArray(alloc, v); } if (json.getPath(token_parsed.value, "filter.event_names")) |v| { filter_opts.event_names = db_events.parseJsonStringArray(alloc, v); } if (json.getPath(token_parsed.value, "filter.resource_ids")) |v| { filter_opts.resource_ids = db_events.parseJsonStringArray(alloc, v); } if (json.getPath(token_parsed.value, "filter.resource_id_prefixes")) |v| { filter_opts.resource_id_prefixes = db_events.parseJsonStringArray(alloc, v); } // query this page const events = db_events.queryWithFilter(alloc, filter_opts, page_size, offset) catch |err| { log.err("events-api", "query next page error: {}", .{err}); json.sendStatus(r, "{\"detail\":\"database error\"}", .internal_server_error); return; }; // generate next_page URL if there are more results const next_page = generateNextPageUrl(alloc, r, filter_opts, total, page_size, offset); // send response sendEventPage(r, alloc, events, total, next_page); } fn parseFilterOptions(alloc: std.mem.Allocator, value: std.json.Value) db_events.FilterOptions { var opts = db_events.FilterOptions{}; // occurred filter opts.occurred_since = json.getString(value, "filter.occurred.since"); opts.occurred_until = json.getString(value, "filter.occurred.until"); // event filter if (json.getPath(value, "filter.event.prefix")) |v| { opts.event_prefixes = db_events.parseJsonStringArray(alloc, v); } if (json.getPath(value, "filter.event.name")) |v| { opts.event_names = db_events.parseJsonStringArray(alloc, v); } // resource filter if (json.getPath(value, "filter.resource.id")) |v| { opts.resource_ids = db_events.parseJsonStringArray(alloc, v); } if (json.getPath(value, "filter.resource.id_prefix")) |v| { opts.resource_id_prefixes = db_events.parseJsonStringArray(alloc, v); } // order if (json.getString(value, "filter.order")) |order| { opts.order_asc = mem.eql(u8, order, "ASC"); } return opts; } fn generateNextPageUrl( alloc: std.mem.Allocator, r: zap.Request, filter_opts: db_events.FilterOptions, total: usize, page_size: usize, current_offset: usize, ) ?[]const u8 { const next_offset = current_offset + page_size; if (next_offset >= total) return null; // build token JSON: {"filter": {...}, "count": N, "page_size": N, "offset": N} const filter_json = filter_opts.toJson(alloc) catch return null; const token_json = std.fmt.allocPrint(alloc, "{{\"filter\":{s},\"count\":{d},\"page_size\":{d},\"offset\":{d}}}", .{ filter_json, total, page_size, next_offset, }) catch return null; // base64 encode the token const inner_token = encoding.base64Encode(alloc, token_json) catch return null; // base64 encode again for URL const url_token = encoding.base64Encode(alloc, inner_token) catch return null; // build URL const host = r.getHeader("host") orelse "localhost:4200"; return std.fmt.allocPrint(alloc, "http://{s}/api/events/filter/next?page-token={s}", .{ host, url_token }) catch null; } fn sendEventPage(r: zap.Request, alloc: std.mem.Allocator, events: []const []const u8, total: usize, next_page: ?[]const u8) void { var output: std.Io.Writer.Allocating = .init(alloc); var jw: std.json.Stringify = .{ .writer = &output.writer }; jw.beginObject() catch {}; // "events": [...] jw.objectField("events") catch {}; jw.beginArray() catch {}; for (events) |event_json| { jw.print("{s}", .{event_json}) catch {}; } jw.endArray() catch {}; // "total": N jw.objectField("total") catch {}; jw.write(total) catch {}; // "next_page": "..." or null jw.objectField("next_page") catch {}; if (next_page) |url| { jw.write(url) catch {}; } else { jw.write(null) catch {}; } jw.endObject() catch {}; json.send(r, output.toOwnedSlice() catch "{\"events\":[],\"total\":0,\"next_page\":null}"); } fn count(r: zap.Request) !void { const total = db_events.count(); var buf: [64]u8 = undefined; const resp = std.fmt.bufPrint(&buf, "{{\"count\":{d}}}", .{total}) catch "{\"count\":0}"; json.send(r, resp); }