add initial backend web routes

This commit is contained in:
Emil Lerch 2025-10-15 14:50:37 -07:00
parent 080ea81ef5
commit d2be071265
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 213 additions and 52 deletions

View file

@ -36,11 +36,17 @@ pub fn build(b: *std.Build) !void {
// running `zig build`).
b.installArtifact(lib);
const httpz = b.dependency("httpz", .{
.target = target,
.optimize = optimize,
});
const exe_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
exe_module.addImport("httpz", httpz.module("httpz"));
const exe = b.addExecutable(.{
.name = "zetviel",
@ -96,6 +102,7 @@ pub fn build(b: *std.Build) !void {
.target = target,
.optimize = optimize,
});
exe_test_module.addImport("httpz", httpz.module("httpz"));
const exe_unit_tests = b.addTest(.{
.root_module = exe_test_module,

View file

@ -17,42 +17,11 @@
// Once all dependencies are fetched, `zig build` no longer requires
// internet connectivity.
.dependencies = .{
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
//.example = .{
// // When updating this field to a new URL, be sure to delete the corresponding
// // `hash`, otherwise you are communicating that you expect to find the old hash at
// // the new URL.
// .url = "https://example.com/foo.tar.gz",
//
// // This is computed from the file contents of the directory of files that is
// // obtained after fetching `url` and applying the inclusion rules given by
// // `paths`.
// //
// // This field is the source of truth; packages do not come from a `url`; they
// // come from a `hash`. `url` is just one of many possible mirrors for how to
// // obtain a package matching this `hash`.
// //
// // Uses the [multihash](https://multiformats.io/multihash/) format.
// .hash = "...",
//
// // When this is provided, the package is found in a directory relative to the
// // build root. In this case the package's hash is irrelevant and therefore not
// // computed. This field and `url` are mutually exclusive.
// .path = "foo",
// // When this is set to `true`, a package is declared to be lazily
// // fetched. This makes the dependency only get fetched if it is
// // actually used.
// .lazy = false,
//},
.httpz = .{
.url = "git+https://github.com/karlseguin/http.zig?ref=master#5e5ab5f82477252fd85943bcb33db483bde6de86",
.hash = "httpz-0.0.0-PNVzrLndBgBwKYPO0v3OFD-6741_9uKdWtU27sil2-df",
},
},
// Specifies the set of files and directories that are included in this package.
// Only files and directories listed here are included in the `hash` that
// is computed for this package.
// Paths are relative to the build root. Use the empty string (`""`) to refer to
// the build root itself.
// A directory listed here means that all files within, recursively, are included.
.paths = .{
// This makes *all* files, recursively, included in this package. It is generally
// better to explicitly list the files and directories instead, to insure that

View file

@ -1,23 +1,199 @@
const std = @import("std");
const httpz = @import("httpz");
const root = @import("root.zig");
pub fn main() !void {
// Prints to stderr (it's a shortcut based on `std.io.getStdErr()`)
std.debug.print("All your {s} are belong to us. \n", .{"codebase"});
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// stdout is for the actual output of your application, for example if you
// are implementing gzip, then only the compressed bytes should be sent to
// stdout, not any debugging messages.
const stdout_file = std.fs.File{ .handle = std.posix.STDOUT_FILENO };
try stdout_file.writeAll("Run `zig build test` to run the tests.\n");
// Get notmuch database path from environment or use default
const db_path = std.posix.getenv("NOTMUCH_PATH") orelse "mail";
// Example of using the root.zig functionality
const allocator = std.heap.page_allocator;
var db_result = root.openNotmuchDb(allocator, "mail", null) catch |err| {
std.debug.print("Failed to open notmuch database: {}\n", .{err});
// Open notmuch database
var db = try root.openNotmuchDb(allocator, db_path, null);
defer db.close();
std.debug.print("Zetviel starting on http://localhost:5000\n", .{});
std.debug.print("Notmuch database: {s}\n", .{db.path});
// Create HTTP server
var server = try httpz.Server(*root.NotmuchDb).init(allocator, .{
.port = 5000,
.address = "127.0.0.1",
}, &db);
defer server.deinit();
// API routes
var router = try server.router(.{});
router.get("/api/query/*", queryHandler, .{});
router.get("/api/thread/:thread_id", threadHandler, .{});
router.get("/api/message/:message_id", messageHandler, .{});
router.get("/api/attachment/:message_id/:num", attachmentHandler, .{});
// TODO: Static file serving for frontend
try server.listen();
}
fn queryHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Response) !void {
const query = req.url.path[11..]; // Skip "/api/query/"
if (query.len == 0) {
res.status = 400;
try res.json(.{ .@"error" = "Query parameter required" }, .{});
return;
}
var threads = db.search(query) catch |err| {
res.status = 500;
try res.json(.{ .@"error" = @errorName(err) }, .{});
return;
};
defer db_result.close();
defer threads.deinit();
std.debug.print("Successfully opened notmuch database at: {s}\n", .{db_result.path});
try res.json(threads, .{});
}
fn threadHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Response) !void {
const thread_id = req.param("thread_id") orelse {
res.status = 400;
try res.json(.{ .@"error" = "Thread ID required" }, .{});
return;
};
var thread = db.getThread(thread_id) catch |err| {
res.status = 404;
try res.json(.{ .@"error" = @errorName(err) }, .{});
return;
};
defer thread.deinit();
try res.json(thread, .{});
}
fn messageHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Response) !void {
const message_id = req.param("message_id") orelse {
res.status = 400;
try res.json(.{ .@"error" = "Message ID required" }, .{});
return;
};
const msg = db.getMessage(message_id) catch |err| {
res.status = 404;
try res.json(.{ .@"error" = @errorName(err) }, .{});
return;
};
defer msg.deinit(db.allocator);
try res.json(msg, .{});
}
fn attachmentHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Response) !void {
const message_id = req.param("message_id") orelse {
res.status = 400;
try res.json(.{ .@"error" = "Message ID required" }, .{});
return;
};
const num_str = req.param("num") orelse {
res.status = 400;
try res.json(.{ .@"error" = "Attachment number required" }, .{});
return;
};
const num = std.fmt.parseInt(usize, num_str, 10) catch {
res.status = 400;
try res.json(.{ .@"error" = "Invalid attachment number" }, .{});
return;
};
const msg = db.getMessage(message_id) catch |err| {
res.status = 404;
try res.json(.{ .@"error" = @errorName(err) }, .{});
return;
};
defer msg.deinit(db.allocator);
if (num >= msg.attachments.len) {
res.status = 404;
try res.json(.{ .@"error" = "Attachment not found" }, .{});
return;
}
const att = msg.attachments[num];
res.header("Content-Type", att.content_type);
res.header("Content-Disposition", try std.fmt.allocPrint(db.allocator, "attachment; filename=\"{s}\"", .{att.filename}));
// TODO: Actually retrieve and send attachment content
// For now, just send metadata
try res.json(.{ .filename = att.filename, .content_type = att.content_type }, .{});
}
test "queryHandler with valid query" {
const allocator = std.testing.allocator;
var db = try root.openNotmuchDb(allocator, "mail", null);
defer db.close();
var t = httpz.testing.init(.{});
defer t.deinit();
t.url("/api/query/tag:inbox");
try queryHandler(&db, t.req, t.res);
try std.testing.expect(t.res.status != 400);
}
test "queryHandler with empty query" {
const allocator = std.testing.allocator;
var db = try root.openNotmuchDb(allocator, "mail", null);
defer db.close();
var t = httpz.testing.init(.{});
defer t.deinit();
t.url("/api/query/");
try queryHandler(&db, t.req, t.res);
try std.testing.expectEqual(@as(u16, 400), t.res.status);
}
test "messageHandler with valid message" {
const allocator = std.testing.allocator;
var db = try root.openNotmuchDb(allocator, "mail", null);
defer db.close();
var threads = try db.search("*");
defer threads.deinit();
var maybe_thread = (try threads.next()).?;
defer maybe_thread.deinit();
var mi = try maybe_thread.thread.getMessages();
const msg_id = mi.next().?.getMessageId();
var t = httpz.testing.init(.{});
defer t.deinit();
t.param("message_id", msg_id);
try messageHandler(&db, t.req, t.res);
try std.testing.expect(t.res.status != 404);
}
test "threadHandler with valid thread" {
const allocator = std.testing.allocator;
var db = try root.openNotmuchDb(allocator, "mail", null);
defer db.close();
var threads = try db.search("*");
defer threads.deinit();
var maybe_thread = (try threads.next()).?;
defer maybe_thread.deinit();
const thread_id = maybe_thread.thread.getThreadId();
var t = httpz.testing.init(.{});
defer t.deinit();
t.param("thread_id", thread_id);
try threadHandler(&db, t.req, t.res);
try std.testing.expect(t.res.status != 404);
}

View file

@ -5,11 +5,16 @@ const Email = @import("Email.zig");
pub const Thread = struct {
allocator: std.mem.Allocator,
thread: *notmuch.Db.Thread,
iterator: ?*notmuch.Db.ThreadIterator = null,
pub fn init(allocator: std.mem.Allocator, t: *notmuch.Db.Thread) Thread {
return .{ .allocator = allocator, .thread = t };
}
pub fn deinit(self: Thread) void {
if (self.iterator) |iter| {
iter.deinit();
self.allocator.destroy(iter);
}
self.allocator.destroy(self.thread);
}
@ -178,14 +183,18 @@ pub const NotmuchDb = struct {
pub fn getThread(self: *NotmuchDb, thread_id: []const u8) !Thread {
var query_buf: [1024:0]u8 = undefined;
const query_z = try std.fmt.bufPrintZ(&query_buf, "thread:{s}", .{thread_id});
var thread_iter = try self.db.searchThreads(query_z);
defer thread_iter.deinit();
const iter_ptr = try self.allocator.create(notmuch.Db.ThreadIterator);
errdefer self.allocator.destroy(iter_ptr);
iter_ptr.* = try self.db.searchThreads(query_z);
errdefer iter_ptr.deinit();
const thread = thread_iter.next();
const thread = iter_ptr.next();
if (thread) |t| {
const tptr = try self.allocator.create(notmuch.Db.Thread);
tptr.* = t;
return Thread.init(self.allocator, tptr);
var result = Thread.init(self.allocator, tptr);
result.iterator = iter_ptr;
return result;
}
return error.ThreadNotFound;
}