// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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)); }