Something went wrong. Try again.
A Zig client for the Paperless-ngx REST API, with streaming uploads, task waiting and a command-line tool.
Something went wrong. Try again.
21 kB · 503 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The client against a fake Paperless on a loopback socket.//!//! The fake is zig-http's own server with a handler that answers the way//! Paperless-ngx 3.2 does -- the JSON in these responses was copied from a//! real one -- and that writes down what each request looked like when it//! arrived. So these check both halves: that a request goes out with the//! path, query, headers and body Paperless expects, and that what Paperless//! sends back is read correctly. Whether the fake is faithful is what//! `tests/live.zig` checks, against the real thing.
const std = @import("std");const Io = std.Io;const testing = std.testing;
const http = @import("http");const http_io = @import("http_io");const paperless = @import("paperless");
const Request = http.server.Request;const Response = http.server.Response;
const gpa = testing.allocator;
/// What the last request looked like, as the server saw it. Tests run one at/// a time and each reads this only after its request has been answered.const Seen = struct { method: [8]u8 = undefined, method_len: usize = 0, target: [512]u8 = undefined, target_len: usize = 0, authorization: [128]u8 = undefined, authorization_len: usize = 0, accept: [64]u8 = undefined, accept_len: usize = 0, content_type: [128]u8 = undefined, content_type_len: usize = 0, body: [8192]u8 = undefined, body_len: usize = 0,
fn set(buffer: []u8, len: *usize, value: []const u8) void { const n = @min(buffer.len, value.len); @memcpy(buffer[0..n], value[0..n]); len.* = n; }
fn methodName(s: *const Seen) []const u8 { return s.method[0..s.method_len]; } fn targetText(s: *const Seen) []const u8 { return s.target[0..s.target_len]; } fn bodyText(s: *const Seen) []const u8 { return s.body[0..s.body_len]; }};
var seen: Seen = .{};/// How many times the task has been asked after, so that it can finish on/// the third.var task_polls: u32 = 0;
const document_json = \\{"id":1,"correspondent":1,"document_type":null,"storage_path":null,"title":"Hello", \\"content":"Invoice 42 from ACME\nTotal due: 100 dollars\n","tags":[1],"created":"2026-10-05", \\"created_date":"2026-10-05","modified":"2026-10-05T20:14:48.386910Z","added":"2026-10-05T20:14:48.347739Z", \\"deleted_at":null,"archive_serial_number":null,"original_file_name":"invoice.txt","archived_file_name":null, \\"duplicate_documents":[],"owner":1,"user_can_change":true,"is_shared_by_requester":false, \\"notes":[{"id":1,"note":"first note","created":"2026-10-05T20:17:39.292056Z","user":{"id":1,"username":"admin","first_name":"","last_name":""}}], \\"custom_fields":[{"field":2,"value":"EUR12.50"}],"page_count":null,"mime_type":"text/plain","root_document":null, \\"versions":[{"id":1,"added":"2026-10-05T20:14:48.347739Z","version_label":null,"checksum":"f5b1","is_root":true}]};
fn routes(req: *Request, res: *Response) anyerror!void { Seen.set(&seen.method, &seen.method_len, req.method.name); var target: Io.Writer = .fixed(&seen.target); target.writeAll(req.target.path) catch {}; if (req.target.query) |q| { target.writeByte('?') catch {}; target.writeAll(q) catch {}; } seen.target_len = target.end; Seen.set(&seen.authorization, &seen.authorization_len, req.header("authorization") orelse ""); Seen.set(&seen.accept, &seen.accept_len, req.header("accept") orelse ""); Seen.set(&seen.content_type, &seen.content_type_len, req.header("content-type") orelse ""); const body = try req.body.readAlloc(req.arena, 1 << 20); Seen.set(&seen.body, &seen.body_len, body);
const path = req.target.path; const method = req.method.name; try res.setHeader("Content-Type", "application/json");
if (std.mem.eql(u8, path, "/api/documents/") and std.mem.eql(u8, method, "GET")) { // Three pages of one document each, by `page`. const q = req.target.query orelse ""; const page: u8 = if (std.mem.find(u8, q, "page=2") != null) 2 else if (std.mem.find(u8, q, "page=3") != null) 3 else 1; return res.sendPrint( \\{{"count":3,"next":{s},"previous":null,"all":[1,2,3],"results":[{{"id":{d},"title":"page {d}"}}]}} , .{ if (page < 3) "\"http://elsewhere.invalid/api/documents/?page=2\"" else "null", page, page }); } if (std.mem.eql(u8, path, "/api/documents/1/")) { if (std.mem.eql(u8, method, "DELETE")) return res.sendStatus(.no_content); return res.send(document_json); } if (std.mem.eql(u8, path, "/api/documents/404/")) { res.status = .not_found; return res.send("{\"detail\":\"No Document matches the given query.\"}"); } if (std.mem.eql(u8, path, "/api/documents/1/download/")) { try res.setHeader("Content-Type", "application/pdf"); try res.setHeader("Content-Disposition", "attachment; filename=\"caf.pdf\"; filename*=utf-8''caf%C3%A9.pdf"); return res.send("%PDF-1.7 pretend"); } if (std.mem.eql(u8, path, "/api/documents/post_document/")) { return res.send("\"737bdbcd-bd4b-42a2-9951-f0fcef1774c9\""); } if (std.mem.eql(u8, path, "/api/documents/bulk_edit/")) { return res.send("{\"result\":\"OK\"}"); } if (std.mem.eql(u8, path, "/api/tasks/")) { task_polls += 1; const status = if (task_polls < 3) "started" else "success"; return res.sendPrint( \\{{"count":1,"next":null,"previous":null,"results":[{{"id":2,"task_id":"737bdbcd-bd4b-42a2-9951-f0fcef1774c9", \\"task_type":"consume_file","trigger_source":"api_upload","status":"{s}","result_data":{s}, \\"related_document_ids":[],"acknowledged":false,"owner":1}}]}} , .{ status, if (task_polls < 3) "null" else "{\"document_id\":1}" }); } if (std.mem.eql(u8, path, "/api/tags/") and std.mem.eql(u8, method, "POST")) { res.status = .created; return res.send( \\{"id":5,"slug":"invoices","name":"invoices","color":"#ff0000","text_color":"#000000","match":"", \\"matching_algorithm":4,"is_insensitive":true,"is_inbox_tag":false,"owner":1,"user_can_change":true, \\"parent":null,"children":[]} ); } if (std.mem.eql(u8, path, "/api/tags/") and std.mem.eql(u8, method, "GET")) { return res.send( \\{"count":1,"next":null,"previous":null,"results":[{"id":5,"name":"invoices","document_count":3}],"display_count":1} ); } if (std.mem.eql(u8, path, "/api/correspondents/")) { res.status = .bad_request; return res.send("{\"name\":[\"This field is required.\"]}"); } if (std.mem.eql(u8, path, "/api/token/")) { return res.send("{\"token\":\"492ddbf785d06855752ae8bfa6ba67b40339a83d\"}"); } res.status = .not_found; return res.send("{\"detail\":\"Not found.\"}");}
const Fixture = struct { listener: http_io.tcp.Listener = undefined, server: http.server.Server = undefined, tasks: Io.Group = .init, url_buffer: [64]u8 = undefined,
fn start(f: *Fixture) !void { const io = testing.io; f.* = .{}; seen = .{}; task_polls = 0; f.listener = try .listen(io, .{ .ip4 = .loopback(0) }, .{}); errdefer f.listener.deinit(io); f.server = .init(io, gpa, f.listener.listener(), .initStateless(routes), .{ .http1 = .{ .send_date = false, .server_name = null }, }); // Without a second task the accept and the connect deadlock. f.tasks.concurrent(io, run, .{&f.server}) catch return error.SkipZigTest; }
fn run(server: *http.server.Server) void { server.serve() catch {}; }
fn stop(f: *Fixture) void { f.server.shutdown(); f.tasks.await(testing.io) catch {}; f.listener.deinit(testing.io); }
/// With a trailing slash, which `Client.init` takes off. fn baseUrl(f: *Fixture) []const u8 { return std.fmt.bufPrint(&f.url_buffer, "http://127.0.0.1:{d}/", .{f.listener.localAddress().getPort()}) catch unreachable; }
fn client(f: *Fixture) !paperless.Client { return .init(testing.io, gpa, .{ .base_url = f.baseUrl(), .auth = .{ .token = "492ddbf785d06855752ae8bfa6ba67b40339a83d" }, }); }};
test "a list goes out with its filters, its token and its API version" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
const page = try client.documents().list(.{ .query = "invoice tag:acme", .tags__id__all = &.{ 1, 4 }, .is_in_inbox = false, .ordering = "-created", .page_size = 1, }); defer page.deinit();
try testing.expectEqualStrings("GET", seen.methodName()); try testing.expectEqualStrings( "/api/documents/?page_size=1&ordering=-created&query=invoice%20tag%3Aacme&tags__id__all=1%2C4&is_in_inbox=false", seen.targetText(), ); try testing.expectEqualStrings("Token 492ddbf785d06855752ae8bfa6ba67b40339a83d", seen.authorization[0..seen.authorization_len]); try testing.expectEqualStrings("application/json; version=10", seen.accept[0..seen.accept_len]);
try testing.expectEqual(3, page.value.count); try testing.expectEqualSlices(paperless.Id, &.{ 1, 2, 3 }, page.value.all); try testing.expectEqualStrings("page 1", page.value.results[0].title);}
// Django reads a query string as form data, where `+` is a space, so a// literal plus has to arrive as `%2B` -- which zig-http, since 0.3.0, sends// as it was written rather than re-encoding the query through zig-uri.test "a plus sign reaches the server as a plus" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
const page = try client.documents().list(.{ .title__icontains = "a+b" }); defer page.deinit(); try testing.expectEqualStrings("/api/documents/?title__icontains=a%2Bb", seen.targetText());}
test "the pager counts pages rather than following a next link it cannot reach" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
var pager = client.documents().pages(.{ .page_size = 1 }); var titles: [3][]const u8 = undefined; var n: usize = 0; while (try pager.next()) |page| { defer page.deinit(); try testing.expect(n < titles.len); titles[n] = if (std.mem.eql(u8, page.value.results[0].title, "page 1")) "1" else if (std.mem.eql(u8, page.value.results[0].title, "page 2")) "2" else "3"; n += 1; } try testing.expectEqual(3, n); try testing.expectEqualStrings("123", &.{ titles[0][0], titles[1][0], titles[2][0] });}
test "a document reads in full, custom fields and notes included" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
const doc = try client.documents().get(1, .{}); defer doc.deinit(); try testing.expectEqualStrings("/api/documents/1/", seen.targetText()); try testing.expectEqualStrings("Hello", doc.value.title); try testing.expectEqual(1, doc.value.correspondent.?); try testing.expectEqual(null, doc.value.document_type); try testing.expectEqualStrings("first note", doc.value.notes[0].note); try testing.expectEqualStrings("admin", doc.value.notes[0].user.?.username); try testing.expectEqual(2, doc.value.custom_fields[0].field); try testing.expectEqualStrings("EUR12.50", doc.value.custom_fields[0].value.string); try testing.expect(doc.value.versions[0].is_root);}
test "an update sends only what it changes, and null for what it clears" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
const doc = try client.documents().update(1, .{ .title = "Renamed", .correspondent = .clear, .tags = &.{ 1, 2 }, }); defer doc.deinit(); try testing.expectEqualStrings("PATCH", seen.methodName()); try testing.expectEqualStrings("application/json", seen.content_type[0..seen.content_type_len]); try testing.expectEqualStrings( \\{"title":"Renamed","correspondent":null,"tags":[1,2]} , seen.bodyText());}
test "a missing document is NotFound, with what the server said kept" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
try testing.expectError(error.NotFound, client.documents().get(404, .{})); try testing.expectEqual(.not_found, client.last_error.?.status); try testing.expectEqualStrings("{\"detail\":\"No Document matches the given query.\"}", client.last_error.?.body);
// And a 400 explains itself field by field. try testing.expectError(error.BadRequest, client.correspondents().create(.{})); try testing.expectEqualStrings("{\"name\":[\"This field is required.\"]}", client.last_error.?.body);
// The next request that succeeds forgets it. const page = try client.tags().list(.{}); defer page.deinit(); try testing.expectEqual(null, client.last_error);}
test "delete is a DELETE and a 204 is success" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
try client.documents().delete(1); try testing.expectEqualStrings("DELETE", seen.methodName());}
test "an upload is a multipart form with the file last, and hands back a task" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
const task_id = try client.documents().upload(.{ .filename = "invoice.pdf", .content = .{ .bytes = "%PDF-1.7 pretend" }, .title = "Invoice 42", .tags = &.{ 1, 3 }, .correspondent = 2, .custom_fields = &.{.{ .field = 2, .value = .{ .string = "EUR12.50" } }}, }); try testing.expectEqualStrings("737bdbcd-bd4b-42a2-9951-f0fcef1774c9", &task_id); try testing.expectEqualStrings("POST", seen.methodName()); try testing.expectEqualStrings("/api/documents/post_document/", seen.targetText());
const content_type = seen.content_type[0..seen.content_type_len]; try testing.expect(std.mem.startsWith(u8, content_type, "multipart/form-data; boundary=zig-paperless-")); const boundary = content_type["multipart/form-data; boundary=".len..];
const body = seen.bodyText(); try testing.expect(std.mem.find(u8, body, "name=\"title\"\r\n\r\nInvoice 42\r\n") != null); try testing.expect(std.mem.find(u8, body, "name=\"tags\"\r\n\r\n1\r\n") != null); try testing.expect(std.mem.find(u8, body, "name=\"tags\"\r\n\r\n3\r\n") != null); try testing.expect(std.mem.find(u8, body, "name=\"correspondent\"\r\n\r\n2\r\n") != null); try testing.expect(std.mem.find(u8, body, "name=\"custom_fields\"\r\n\r\n{\"2\":\"EUR12.50\"}\r\n") != null); try testing.expect(std.mem.find(u8, body, "name=\"document\"; filename=\"invoice.pdf\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.7 pretend\r\n") != null); // The closing delimiter, and nothing after it. var closing: [128]u8 = undefined; try testing.expect(std.mem.endsWith(u8, body, try std.fmt.bufPrint(&closing, "\r\n--{s}--\r\n", .{boundary})));}
test "an upload read from a reader sends exactly its length" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
var digits: [3000]u8 = undefined; for (&digits, 0..) |*d, i| d.* = '0' + @as(u8, @intCast(i % 10)); var content: Io.Reader = .fixed(&digits); _ = try client.documents().upload(.{ .filename = "digits.txt", .content = .{ .reader = .{ .reader = &content, .length = 3000 } }, }); const body = seen.bodyText(); const at = std.mem.find(u8, body, "Content-Type: text/plain\r\n\r\n").? + "Content-Type: text/plain\r\n\r\n".len; try testing.expectEqualStrings(&digits, body[at..][0..digits.len]); try testing.expect(std.mem.startsWith(u8, body[at + digits.len ..], "\r\n--"));}
test "waiting on a task asks until it is done and names the document" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
const task = try client.tasks().wait("737bdbcd-bd4b-42a2-9951-f0fcef1774c9", .{ .interval = .fromMilliseconds(1) }); defer task.deinit(); try testing.expectEqual(3, task_polls); try testing.expectEqualStrings("/api/tasks/?task_id=737bdbcd-bd4b-42a2-9951-f0fcef1774c9", seen.targetText()); try testing.expectEqual(paperless.models.TaskStatus.success, task.value.status); try testing.expectEqual(1, task.value.documentId().?);}
test "a download names its file from Content-Disposition, preferring filename*" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
var out: Io.Writer.Allocating = .init(gpa); defer out.deinit(); var info = try client.documents().download(1, &out.writer, .{ .file = .original }); defer info.deinit(); try testing.expectEqualStrings("/api/documents/1/download/?original=true", seen.targetText()); try testing.expectEqualStrings("*/*", seen.accept[0..seen.accept_len]); try testing.expectEqualStrings("%PDF-1.7 pretend", out.written()); try testing.expectEqualStrings("application/pdf", info.content_type.?); try testing.expectEqualStrings("café.pdf", info.filename.?);}
test "a failed download leaves the caller's writer untouched" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
var out: Io.Writer.Allocating = .init(gpa); defer out.deinit(); try testing.expectError(error.NotFound, client.documents().download(77, &out.writer, .{})); try testing.expectEqualStrings("", out.written());}
test "bulk edit sends the method and its parameters" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
try client.documents().bulkEdit(&.{ 1, 2 }, .{ .add_tag = 9 }); try testing.expectEqualStrings( \\{"documents":[1,2],"method":"add_tag","parameters":{"tag":9}} , seen.bodyText());}
test "create sends a matching algorithm as a number, and findByName reads the first match" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit();
const tag = try client.tags().create(.{ .name = "invoices", .matching_algorithm = .regex, .match = "^INV" }); defer tag.deinit(); try testing.expectEqualStrings( \\{"name":"invoices","match":"^INV","matching_algorithm":4} , seen.bodyText()); try testing.expectEqual(paperless.models.MatchingAlgorithm.regex, tag.value.matching_algorithm);
const found = (try client.tags().findByName("Invoices")).?; defer found.deinit(); try testing.expectEqualStrings("/api/tags/?page_size=1&name__iexact=Invoices", seen.targetText()); try testing.expectEqual(5, found.value.id); try testing.expectEqual(3, found.value.document_count);}
test "basic credentials are sent as Basic" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client: paperless.Client = try .init(testing.io, gpa, .{ .base_url = f.baseUrl(), .auth = .{ .basic = .{ .username = "admin", .password = "admin" } }, .api_version = 9, }); defer client.deinit();
const page = try client.tags().list(.{}); defer page.deinit(); try testing.expectEqualStrings("Basic YWRtaW46YWRtaW4=", seen.authorization[0..seen.authorization_len]); try testing.expectEqualStrings("application/json; version=9", seen.accept[0..seen.accept_len]);}
test "obtainToken trades a password for the token" { var f: Fixture = undefined; try f.start(); defer f.stop();
const token = try paperless.obtainToken(testing.io, gpa, f.baseUrl(), "admin", "pass word"); defer gpa.free(token); try testing.expectEqualStrings("492ddbf785d06855752ae8bfa6ba67b40339a83d", token); try testing.expectEqualStrings("", seen.authorization[0..seen.authorization_len]); try testing.expectEqualStrings( \\{"username":"admin","password":"pass word"} , seen.bodyText());}