# zig-paperless
A client for the [Paperless-ngx][paperless] REST API, for Zig 0.17: finding
documents, reading and changing what is known about them, downloading their
files, uploading new ones and waiting for them to be consumed, and the tags,
correspondents, document types, storage paths, custom fields, saved views,
share links, users and groups they are filed under.
HTTP is [zig-http][zig-http]'s client, uploads stream from a reader through a
`multipart/form-data` body that is never held in memory, and the media type
table and the RFC 2231 decoding of download filenames come from
[zig-mime][zig-mime].
```zig
const paperless = @import("paperless");
var client: paperless.Client = try .init(io, gpa, .{
.base_url = "https://paperless.example.com",
.auth = .{ .token = token },
});
defer client.deinit();
const task_id = try client.documents().upload(.{
.filename = "invoice.pdf",
.content = .{ .bytes = pdf },
.tags = &.{inbox_tag},
});
const task = try client.tasks().wait(&task_id, .{});
defer task.deinit();
const id = task.value.documentId() orelse return error.UploadFailed;
const doc = try client.documents().update(id, .{
.title = "Invoice 42",
.correspondent = .{ .set = acme },
.document_type = .clear,
});
defer doc.deinit();
```
The API documentation is at . It is
generated from the doc comments, which carry most of the explanation of how
each call behaves.
## Where this lives
The repository lives in three places that carry the same history. The Forgejo
instance at is the web-visible
one, and is where the continuous integration and the published documentation
are:
```console
$ git clone https://git.jcollie.dev/jeff/zig-paperless.git
```
It is mirrored on Tangled at ,
and it is also on the Radicle network, where a repository is found by its
identifier and nothing else:
```
rad:z2Y41dMQk7mnpxNV2D7oPPSzU5wEx
```
`rad clone rad:z2Y41dMQk7mnpxNV2D7oPPSzU5wEx` fetches it from any node that
seeds it. Any of the three is the whole project.
## Adding it to a project
It needs Zig 0.17.0.
```console
$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-paperless.git
```
```zig
const paperless = b.dependency("paperless", .{
.target = target,
.optimize = optimize,
}).module("paperless");
exe.root_module.addImport("paperless", paperless);
```
## What is covered
The models and the calls are written against **API version 10**, which is
what Paperless-ngx 3 speaks by default, and are tested against Paperless-ngx
3.2.1. `Client.Options.api_version` asks for another; version 9 is also
accepted by 3.x servers.
| | |
| --- | --- |
| `client.documents()` | `list` and `pages` with the common filters as fields, `get`, `update`, `delete`, `metadata`, `suggestions`, notes, share links, `nextAsn`, `autocomplete`, `download` (archive, original, preview, thumbnail), `upload`, `updateVersion`, `bulkEdit`, `deleteMany`, `reprocess`, `rotate`, `merge` |
| `client.tasks()` | `list`, `pages`, `get`, `byTaskId`, `acknowledge`, and `wait`, which asks until a task has finished; `Task.outcome` says how an upload ended — a new document, a refused duplicate, stopped by a plugin, or failed — since `status` alone does not |
| `client.tags()`, `correspondents()`, `documentTypes()`, `storagePaths()`, `customFields()`, `savedViews()`, `shareLinks()`, `users()`, `groups()` | `list`, `pages`, `get`, `create`, `update`, `delete`, and `findByName` where the endpoint filters by name |
| `client` | `statistics`, `status`, `remoteVersion`, `search` (the global search box), `trash`, `restoreFromTrash`, `emptyTrash` |
| `client.raw` | any other endpoint, by path — mail accounts and rules, workflows, the application configuration — with the same authentication and the same error handling |
| `paperless.obtainToken` | trades a username and password for the user's API token |
### What comes back
A JSON response is a `std.json.Parsed(T)`: it owns everything it points to and
`deinit` frees it. Fields the server sends and the model does not name are
ignored, so a server newer than the client does not break it, and nearly every
field of a model has a default — what arrives depends on how it was asked for
(`?fields=id,title` trims a document to two fields; `?full_perms=true` swaps
`user_can_change` for `permissions`), and anything absent reads as its default
rather than failing the parse. Enumerations that grow from release to release,
such as a task's type, read a value they do not know as `unknown`.
Dates and times are the ISO 8601 strings Paperless sent. Interpreting them is
left to the caller rather than imposing a calendar library on everybody.
### What is sent
Each object has a `…Fields` struct for creating and updating it. A field left
`null` is not sent, so the same struct is a whole object to `create` and a
partial one to `update`, which is a `PATCH`. A field Paperless allows to be
null is a `?Clearable(T)`, so that leaving it alone and clearing it are
different: `.correspondent = .clear` sends `null`.
List filters are fields named exactly as django-filter names them —
`title__icontains`, `tags__id__all` — so the Paperless documentation, the
browsable API and this library spell a filter the same way. Anything a query
struct does not name goes in its `extra`.
### When it goes wrong
A status outside 2xx is an error named for it — `error.NotFound`,
`error.BadRequest`, `error.Unauthorized` — and the body that came with it is
kept as `client.last_error` until the next request, since a `400` from
Paperless explains itself field by field and an error code cannot carry that.
Lists are paged by number. The `next` link a page
holds is built by the server from the request as it saw it, which behind a
proxy can be a scheme, host or port the client cannot reach, so `pages()`
counts instead of following it.
## The command-line tool
`zig-paperless` is a small client that is also the worked example of the
library. It reads the server from `PAPERLESS_URL` and the credential from
`PAPERLESS_TOKEN`:
```console
$ export PAPERLESS_URL=https://paperless.example.com
$ PAPERLESS_PASSWORD=... zig-paperless token jeff
$ export PAPERLESS_TOKEN=...
$ zig-paperless status
$ zig-paperless upload -t 4 --title "Invoice 42" invoice.pdf
invoice.pdf consuming... document 812
$ zig-paperless list invoice
$ zig-paperless show 812
$ zig-paperless download --original 812
```
`upload` streams each file from disk, and `download` streams into a file of its
own that is renamed into place only once the whole document has arrived.
Downloads stream in the library too: the body goes to the caller's writer as
it arrives, and only when the status is 2xx, since zig-http says what the
status is before any of the body is read. An error page never reaches the
writer; it is kept as `last_error` like any other.
## Where the schema and the server disagree
Paperless-ngx 3.2.1's OpenAPI schema says a tag's `children` are identifiers
and a search hit's `note_highlights` is always a string; the server sends tag
objects, and null. The models follow the server. A Paperless install
generates the schema at `/api/schema/`, and with the package from nixpkgs
`paperless-ngx spectacular --format openapi-json --file schema.json` writes it
without a running server.
## Testing
```console
$ nix develop -c zig build test
```
runs three sets of tests:
- the unit tests in `src/`, for the JSON quirks, the query strings, the
multipart writer and the `Content-Disposition` parsing;
- `tests/client.zig`, which runs zig-http's own server on a loopback socket as
a fake Paperless — its JSON copied from a real one — and checks both what
each request looked like when it arrived and how each response was read;
- and compiles `tests/live.zig`, which needs a real server and runs as
`zig build live-test` when one is named:
```console
$ PAPERLESS_URL=http://127.0.0.1:8000 PAPERLESS_TOKEN=... nix develop -c zig build live-test
```
It creates what it needs with a random suffix and removes it again.
`nix flake check` builds the package and boots Paperless-ngx from the NixOS
module — Redis, Celery, the consumer — in a virtual machine, obtains a token
with the command-line tool, runs the live tests against it, and uploads,
lists and downloads a file with the tool. The live tests are what found the
two places where the schema and the server disagree, and building this found
two things in zig-http and one in zig-mime, each fixed there.
`zig build docs` writes the API documentation to `zig-out/docs`, and
`zig build docs-serve` serves it at .
## License
MIT; the project follows [REUSE](https://reuse.software/), and
`reuse lint` passes.
## References cited
- Paperless-ngx contributors. “REST API.” *Paperless-ngx documentation*.
.
- Freed, N. and N. Borenstein. *Multipurpose Internet Mail Extensions (MIME)
Part Two: Media Types*. RFC 2046, RFC Editor, November 1996.
.
- Freed, N. and K. Moore. *MIME Parameter Value and Encoded Word Extensions:
Character Sets, Languages, and Continuations*. RFC 2231, RFC Editor,
November 1997. .
- Berners-Lee, T., R. Fielding and L. Masinter. *Uniform Resource Identifier
(URI): Generic Syntax*. RFC 3986 (STD 66), RFC Editor, January 2005.
.
- Reschke, J. *Use of the Content-Disposition Header Field in the Hypertext
Transfer Protocol (HTTP)*. RFC 6266, RFC Editor, June 2011.
.
- Masinter, L. *Returning Values from Forms: multipart/form-data*. RFC 7578,
RFC Editor, July 2015. .
- Fielding, R., M. Nottingham and J. Reschke, editors. *HTTP Semantics*.
RFC 9110 (STD 97), RFC Editor, June 2022.
.
[paperless]: https://docs.paperless-ngx.com/
[zig-http]: https://git.jcollie.dev/jeff/zig-http
[zig-mime]: https://git.jcollie.dev/jeff/zig-mime