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.
19 kB · 528 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A connection to one Paperless-ngx server, as one user.//!//! ```zig//! var client: paperless.Client = try .init(io, gpa, .{//! .base_url = "https://paperless.example.com",//! .auth = .{ .token = token },//! });//! defer client.deinit();//!//! const inbox = try client.documents().list(.{ .is_in_inbox = true });//! defer inbox.deinit();//! for (inbox.value.results) |document| std.debug.print("{s}\n", .{document.title});//! ```//!//! The HTTP underneath is zig-http's `http_io.Client`, kept as `http` so//! that whatever it can be told -- a certificate bundle, a proxy, a timeout//! for each request -- can be told it here. Like it, this is not safe to//! share between tasks; one each is cheap.//!//! ## What comes back//!//! A JSON response is a `std.json.Parsed(T)`, which owns everything it points//! to and is freed with `deinit`. Fields the server sends and the model does//! not name are ignored, so a newer server does not break an older client.//!//! ## When it goes wrong//!//! A response outside 2xx is an error named for its status --//! `error.NotFound`, `error.BadRequest` -- and the body that came with it is//! kept as `last_error` until the next request, since Paperless explains a//! `400` there field by field and an error code cannot carry it.
const std = @import("std");const Io = std.Io;const Allocator = std.mem.Allocator;
const protocol = @import("http").protocol;const http_io = @import("http_io");const mime = @import("mime");
const json = @import("json.zig");const query = @import("query.zig");const multipart = @import("multipart.zig");const models = @import("models.zig");
pub const Documents = @import("Documents.zig");pub const Tasks = @import("Tasks.zig");pub const resource = @import("resource.zig");
const Method = protocol.Method;const Status = protocol.Status;const Field = protocol.Field;
const Client = @This();
/// Exposed so that it can be configured: `root_ca`, `timeout`, the proxies.http: http_io.Client,gpa: Allocator,/// The server's root, without a trailing slash and without `/api`. Owned.base_url: []const u8,/// The whole `Authorization` value. Owned.authorization: ?[]const u8,accept_buffer: [48]u8 = undefined,accept_len: usize = 0,/// What the last request that failed with a status said, until the next/// request is made.last_error: ?ApiError = null,
pub const Auth = union(enum) { /// For the few endpoints that need none -- `/api/token/` -- and for a /// server behind a proxy that authenticates on its behalf. none, /// An API token, from the profile page in the web interface or from /// `paperless.obtainToken`. What a program should use. token: []const u8, /// A username and password on every request. Works, but means holding /// the password; a token is better. basic: struct { username: []const u8, password: []const u8, },};
pub const Options = struct { /// `https://paperless.example.com`, or with the path prefix a server /// mounted under one is configured with (`PAPERLESS_FORCE_SCRIPT_NAME`). base_url: []const u8, auth: Auth = .none, /// Paperless versions its API through the `Accept` header. 10 is the /// version Paperless-ngx 3 speaks by default, and the models here are /// written against it; 9 is also accepted by 3.x servers, which treat /// a few things differently (the task list is not paginated in 9). api_version: u8 = 10,};
/// The body of a response that was not 2xx.pub const ApiError = struct { status: Status, /// Usually JSON: `{"detail": "..."}`, or for a `400` an object naming /// each field that was refused and why. body: []const u8,};
pub const StatusError = error{ /// 400: Paperless refused what was sent. `last_error` says why. BadRequest, /// 401: no credentials, or ones it does not accept. Unauthorized, /// 403: the credentials are good and do not reach this. Forbidden, /// 404. NotFound, /// 405. MethodNotAllowed, /// 406: the server does not speak the API version asked for. NotAcceptable, /// 409. Conflict, /// 413: an upload larger than the server, or a proxy in front of it, /// will take. PayloadTooLarge, /// 429. TooManyRequests, /// Any 5xx. ServerError, /// Anything else outside 2xx, including a redirect that was not followed. UnexpectedStatus,};
pub const Error = http_io.Client.FetchError || StatusError || Allocator.Error || std.json.ParseError(std.json.Scanner);
pub fn init(io: Io, gpa: Allocator, options: Options) Allocator.Error!Client { const base = std.mem.trimEnd(u8, options.base_url, "/"); const base_url = try gpa.dupe(u8, base); errdefer gpa.free(base_url);
const authorization: ?[]const u8 = switch (options.auth) { .none => null, .token => |token| try std.fmt.allocPrint(gpa, "Token {s}", .{token}), .basic => |basic| basic: { const pair = try std.fmt.allocPrint(gpa, "{s}:{s}", .{ basic.username, basic.password }); defer { std.crypto.secureZero(u8, pair); gpa.free(pair); } const encoder = std.base64.standard.Encoder; const value = try gpa.alloc(u8, "Basic ".len + encoder.calcSize(pair.len)); @memcpy(value[0.."Basic ".len], "Basic "); _ = encoder.encode(value["Basic ".len..], pair); break :basic value; }, };
var client: Client = .{ .http = .{ .io = io, .gpa = gpa }, .gpa = gpa, .base_url = base_url, .authorization = authorization, }; client.accept_len = (std.fmt.bufPrint( &client.accept_buffer, "application/json; version={d}", .{options.api_version}, ) catch unreachable).len; return client;}
pub fn deinit(c: *Client) void { c.clearLastError(); if (c.authorization) |a| { std.crypto.secureZero(u8, @constCast(a)); c.gpa.free(a); } c.gpa.free(c.base_url); c.http.deinit(); c.* = undefined;}
fn clearLastError(c: *Client) void { if (c.last_error) |e| c.gpa.free(e.body); c.last_error = null;}
fn accept(c: *const Client) []const u8 { return c.accept_buffer[0..c.accept_len];}
// The groups of endpoints. Each is a pointer back to this client and// nothing else, so they are made on demand and cost nothing to throw away.
pub fn documents(c: *Client) Documents { return .{ .client = c };}
pub fn tasks(c: *Client) Tasks { return .{ .client = c };}
pub fn tags(c: *Client) resource.Tags { return .{ .client = c };}
pub fn correspondents(c: *Client) resource.Correspondents { return .{ .client = c };}
pub fn documentTypes(c: *Client) resource.DocumentTypes { return .{ .client = c };}
pub fn storagePaths(c: *Client) resource.StoragePaths { return .{ .client = c };}
pub fn customFields(c: *Client) resource.CustomFields { return .{ .client = c };}
pub fn savedViews(c: *Client) resource.SavedViews { return .{ .client = c };}
pub fn shareLinks(c: *Client) resource.ShareLinks { return .{ .client = c };}
pub fn users(c: *Client) resource.Users { return .{ .client = c };}
pub fn groups(c: *Client) resource.Groups { return .{ .client = c };}
/// Counts of everything, as the dashboard shows them.pub fn statistics(c: *Client) Error!std.json.Parsed(models.Statistics) { return c.getJson(models.Statistics, "statistics/", .{});}
/// The server's health: its version, its database, whether Redis and/// Celery answer. Needs an administrator.pub fn status(c: *Client) Error!std.json.Parsed(models.SystemStatus) { return c.getJson(models.SystemStatus, "status/", .{});}
/// The newest release of Paperless-ngx, as the server found it on GitHub --/// or `0.0.0` when the server has been told not to look.pub fn remoteVersion(c: *Client) Error!std.json.Parsed(models.RemoteVersion) { return c.getJson(models.RemoteVersion, "remote_version/", .{});}
/// The search box in the web interface: a few of everything, matched by/// name, and documents matched by title -- or, unless `db_only`, by the/// full-text index too.pub fn search(c: *Client, text: []const u8, options: struct { db_only: ?bool = null }) Error!std.json.Parsed(models.GlobalSearch) { return c.getJson(models.GlobalSearch, "search/", .{ .query = text, .db_only = options.db_only });}
/// Restores documents from the trash.pub fn restoreFromTrash(c: *Client, ids: []const models.Id) Error!void { return c.sendJsonIgnore(.post, "trash/", .{}, .{ .documents = ids, .action = "restore" });}
/// Deletes documents in the trash for good, or, with `null`, empties it.pub fn emptyTrash(c: *Client, ids: ?[]const models.Id) Error!void { if (ids) |list| return c.sendJsonIgnore(.post, "trash/", .{}, .{ .documents = list, .action = "empty" }); return c.sendJsonIgnore(.post, "trash/", .{}, .{ .action = "empty" });}
/// The documents in the trash, a page at a time.pub fn trash(c: *Client, page: struct { page: ?u32 = null, page_size: ?u32 = null }) Error!std.json.Parsed(models.Page(models.Document)) { return c.getJson(models.Page(models.Document), "trash/", page);}
// ---------------------------------------------------------------------------// Requests// ---------------------------------------------------------------------------
/// What a request carries.pub const Body = union(enum) { none, /// Already serialized; sent as `application/json`. json: []const u8, /// A form whose last part is a file read as it is sent. form: struct { content_type: []const u8, body: *multipart.Body, },};
pub const RawOptions = struct { method: Method = .get, /// Below `/api/`, with its trailing slash: `documents/12/notes/`. /// Django redirects a path without the slash, and a redirect drops the /// body of a `POST`. path: []const u8, query: []const query.Param = &.{}, body: Body = .none, /// Where a 2xx body goes. Null discards it. response_writer: ?*Io.Writer = null, /// Null asks for JSON in the configured API version. accept: ?[]const u8 = null,};
/// A request to any endpoint, for the ones this library has no function for/// -- mail accounts, workflows, the application configuration. The status/// is checked exactly as everywhere else; the result is the response's/// headers, which the caller frees with `deinit`.pub fn raw(c: *Client, options: RawOptions) Error!http_io.Client.FetchResult { var url: Io.Writer.Allocating = .init(c.gpa); defer url.deinit(); var first = true; c.writeUrl(&url.writer, options.path) catch return error.OutOfMemory; query.writeParams(&url.writer, &first, options.query) catch return error.OutOfMemory; return c.send(options.method, url.written(), options.body, options.response_writer, options.accept);}
/// The URL of an endpoint, without its query: the base URL, `/api/`, and/// `path`.pub fn writeUrl(c: *const Client, w: *Io.Writer, path: []const u8) Io.Writer.Error!void { try w.writeAll(c.base_url); try w.writeAll("/api/"); try w.writeAll(path);}
/// Formats a path and a query struct into a URL.pub fn formatUrl(c: *const Client, path: []const u8, params: anytype) Allocator.Error![]u8 { var url: Io.Writer.Allocating = .init(c.gpa); errdefer url.deinit(); var first = true; c.writeUrl(&url.writer, path) catch return error.OutOfMemory; query.writeStruct(&url.writer, &first, params) catch return error.OutOfMemory; return url.toOwnedSlice();}
/// One request, start to finish. A status outside 2xx becomes the error/// named for it and leaves the body in `last_error`; the body of a 2xx goes/// to `response_writer` as it arrives, held nowhere on the way.////// Which of the two a body is for is decided by zig-http's/// `response_writer_for`, once the status is known and before any of the/// body is read -- so an error page never reaches the caller's writer, and a/// download of any size costs the connection's buffers and nothing more.pub fn send( c: *Client, method: Method, url: []const u8, body: Body, response_writer: ?*Io.Writer, accept_override: ?[]const u8,) Error!http_io.Client.FetchResult { c.clearLastError();
var privileged: [1]Field = undefined; var privileged_len: usize = 0; if (c.authorization) |a| { privileged[0] = .{ .name = "Authorization", .value = a }; privileged_len = 1; }
var extra: [2]Field = undefined; var extra_len: usize = 0; extra[extra_len] = .{ .name = "Accept", .value = accept_override orelse c.accept() }; extra_len += 1;
var failure: Io.Writer.Allocating = .init(c.gpa); defer failure.deinit(); var sink: Sink = .{ .success = response_writer, .failure = &failure };
var options: http_io.Client.FetchOptions = .{ .location = .{ .url = url }, .method = method, .privileged_headers = privileged[0..privileged_len], // A Paperless API does not redirect anywhere worth following: the // one redirect it makes is Django adding a missing slash, which is // a bug in the path to fix rather than to paper over. .redirects = .unhandled, .response_writer_for = .{ .context = &sink, .choose = Sink.choose }, }; switch (body) { .none => {}, .json => |bytes| { extra[extra_len] = .{ .name = "Content-Type", .value = "application/json" }; extra_len += 1; options.payload = bytes; }, .form => |form| { extra[extra_len] = .{ .name = "Content-Type", .value = form.content_type }; extra_len += 1; options.payload_reader = .{ .reader = form.body.reader(), .length = form.body.length() }; }, } options.extra_headers = extra[0..extra_len];
var result = try c.http.fetch(options); errdefer result.deinit(); try c.check(result.status, failure.written()); return result;}
/// Where a response body goes: the caller's writer for a 2xx, and a buffer/// that becomes `last_error` for anything else.const Sink = struct { success: ?*Io.Writer, failure: *Io.Writer.Allocating,
fn choose(context: *anyopaque, head: http_io.Client.ResponseHead) Io.Writer.Error!?*Io.Writer { const sink: *Sink = @ptrCast(@alignCast(context)); return if (head.status.class() == .success) sink.success else &sink.failure.writer; }};
fn check(c: *Client, response_status: Status, body: []const u8) Error!void { if (response_status.class() == .success) return; c.last_error = .{ .status = response_status, .body = try c.gpa.dupe(u8, body) }; return statusError(response_status);}
/// The error for a status outside 2xx.pub fn statusError(s: Status) StatusError { return switch (s) { .bad_request => error.BadRequest, .unauthorized => error.Unauthorized, .forbidden => error.Forbidden, .not_found => error.NotFound, .method_not_allowed => error.MethodNotAllowed, .not_acceptable => error.NotAcceptable, .conflict => error.Conflict, .payload_too_large => error.PayloadTooLarge, .too_many_requests => error.TooManyRequests, else => if (s.class() == .server_error) error.ServerError else error.UnexpectedStatus, };}
// ---------------------------------------------------------------------------// JSON, which is nearly everything// ---------------------------------------------------------------------------
/// GETs `path` with `params` as its query and parses the response as `T`.pub fn getJson(c: *Client, comptime T: type, path: []const u8, params: anytype) Error!std.json.Parsed(T) { return c.sendJson(T, .get, path, params, null);}
/// Sends `body`, serialized, and parses the response as `T`. A null body/// sends nothing.pub fn sendJson( c: *Client, comptime T: type, method: Method, path: []const u8, params: anytype, body: anytype,) Error!std.json.Parsed(T) { var response: Io.Writer.Allocating = .init(c.gpa); defer response.deinit(); var result = try c.sendJsonWriter(method, path, params, body, &response.writer); result.deinit(); return std.json.parseFromSlice(T, c.gpa, response.written(), json.parse_options);}
/// `sendJson` for an endpoint whose response is of no interest.pub fn sendJsonIgnore(c: *Client, method: Method, path: []const u8, params: anytype, body: anytype) Error!void { var result = try c.sendJsonWriter(method, path, params, body, null); result.deinit();}
fn sendJsonWriter( c: *Client, method: Method, path: []const u8, params: anytype, body: anytype, response_writer: ?*Io.Writer,) Error!http_io.Client.FetchResult { const url = try c.formatUrl(path, params); defer c.gpa.free(url);
if (@TypeOf(body) == @TypeOf(null)) { return c.send(method, url, .none, response_writer, null); } const bytes = try std.json.Stringify.valueAlloc(c.gpa, body, json.stringify_options); defer c.gpa.free(bytes); return c.send(method, url, .{ .json = bytes }, response_writer, null);}
/// The filename in a `Content-Disposition`, decoded to UTF-8: RFC 6266's/// `filename*` where the server sent one, and the plain `filename` where it/// did not. Django sends both for a name that is not ASCII, the plain one/// first with the other characters dropped, and §4.3 says to prefer the/// extended one -- which zig-mime's parameter parser does, along with the/// decoding and the continuations RFC 2231 allows.pub fn dispositionFilename(gpa: Allocator, content_disposition: []const u8) Allocator.Error!?[]u8 { var parameters: mime.parameters.Parameters = try .parse(gpa, content_disposition); defer parameters.deinit(gpa); return parameters.getUtf8Alloc(gpa, "filename");}
test dispositionFilename { const gpa = std.testing.allocator; // What Django sends for a name that is not ASCII: both forms, the // extended one preferred. const name = (try dispositionFilename(gpa, "attachment; filename=\"caf.pdf\"; filename*=utf-8''caf%C3%A9.pdf")).?; defer gpa.free(name); try std.testing.expectEqualStrings("café.pdf", name);
// In either order. const reversed = (try dispositionFilename(gpa, "attachment; filename*=utf-8''caf%C3%A9.pdf; filename=\"caf.pdf\"")).?; defer gpa.free(reversed); try std.testing.expectEqualStrings("café.pdf", reversed);
const plain = (try dispositionFilename(gpa, "inline; filename=\"0000001.txt\"")).?; defer gpa.free(plain); try std.testing.expectEqualStrings("0000001.txt", plain);
try std.testing.expectEqual(null, try dispositionFilename(gpa, "inline"));}
test statusError { try std.testing.expectEqual(error.NotFound, statusError(.not_found)); try std.testing.expectEqual(error.ServerError, statusError(.bad_gateway)); try std.testing.expectEqual(error.UnexpectedStatus, statusError(.found));}