├── .github ├── dependabot.yml └── workflows │ └── ci.yaml ├── .gitignore ├── LICENSE ├── README.md ├── contrib ├── integration-env │ ├── .env │ ├── docker-compose.yml │ └── paperless.env └── run-integration ├── example ├── list-tags │ └── main.go └── postconsume-dump │ ├── README.md │ └── main.go ├── go.mod ├── go.sum ├── integration ├── destructive.go ├── main.go ├── randimage.go ├── randimage_test.go └── readonly.go ├── internal ├── httptransport │ └── concurrency.go ├── kpflagvalue │ ├── header.go │ ├── strings.go │ ├── strings_test.go │ ├── time.go │ └── time_test.go └── testutil │ ├── cmp.go │ ├── env.go │ ├── setenv.go │ └── writefile.go └── pkg ├── client ├── auth.go ├── auth_example_test.go ├── cert_test.go ├── client.go ├── client_test.go ├── color.go ├── color_test.go ├── correspondent.go ├── crud.go ├── customfield.go ├── doc.go ├── document.go ├── document_test.go ├── documenttype.go ├── download.go ├── download_test.go ├── error.go ├── fields.go ├── filter.go ├── filter_example_test.go ├── filter_test.go ├── flags.go ├── flags_test.go ├── gcpauth.go ├── gcpauth_test.go ├── generate.go ├── generate_models.go ├── group.go ├── log.go ├── log_test.go ├── logger.go ├── logger_test.go ├── matching.go ├── matching_string.go ├── models_generated.go ├── pagination.go ├── pagination_example_test.go ├── pagination_test.go ├── permissions.go ├── ping.go ├── ping_test.go ├── pointer.go ├── remote_version.go ├── remote_version_test.go ├── statistics.go ├── statistics_test.go ├── status.go ├── status_test.go ├── storagepath.go ├── tag.go ├── tag_test.go ├── task.go ├── task_string.go ├── task_test.go ├── taskwait.go ├── taskwait_test.go ├── tools.go ├── user.go └── user_test.go ├── hook └── hook.go ├── kpflag ├── builder.go ├── client.go ├── client_test.go ├── flags_test.go ├── interface.go ├── postconsume.go ├── postconsume_test.go ├── preconsume.go └── preconsume_test.go ├── postconsume └── flags.go └── preconsume └── flags.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: gomod 5 | directory: / 6 | schedule: 7 | interval: monthly 8 | 9 | - package-ecosystem: github-actions 10 | directory: / 11 | schedule: 12 | interval: monthly 13 | 14 | # vim: set sw=2 sts=2 et : 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | test: 13 | uses: hansmi/ghactions-go-test-workflow/.github/workflows/test.yaml@v0.2 14 | with: 15 | runs-on: ubuntu-latest 16 | 17 | # vim: set sw=2 sts=2 et : 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /example/list-tags/list-tags 2 | /example/postconsume-dump/postconsume-dump 3 | /integration/integration 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Michael Hanselmann. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of the copyright holder nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Client and hook toolkit for Paperless-ngx 2 | 3 | [![Latest release](https://img.shields.io/github/v/release/hansmi/paperhooks)][releases] 4 | [![CI workflow](https://github.com/hansmi/paperhooks/actions/workflows/ci.yaml/badge.svg)](https://github.com/hansmi/paperhooks/actions/workflows/ci.yaml) 5 | [![Go reference](https://pkg.go.dev/badge/github.com/hansmi/paperhooks.svg)](https://pkg.go.dev/github.com/hansmi/paperhooks) 6 | 7 | Paperhooks is a toolkit for [writing consumption hooks][paperless-hooks] for 8 | Paperless-ngx written using the Go programming language. A 9 | [REST API][paperless-api] client is part of the toolkit 10 | ([`pkg/client`](./pkg/client/)). 11 | 12 | [Paperless-ngx][paperless] is a document management system transforming 13 | physical documents into a searchable online archive. 14 | 15 | ## Run integration tests 16 | 17 | [Integration tests](https://en.wikipedia.org/wiki/Integration_testing) execute 18 | operations against a real Paperless-ngx server running in a Docker container. 19 | The wrapper script enables _destructive_ tests and should not be run against 20 | a production instance. 21 | 22 | Commands: 23 | 24 | ```shell 25 | env --chdir contrib/integration-env docker-compose up 26 | 27 | contrib/run-integration 28 | ``` 29 | 30 | [paperless-api]: https://docs.paperless-ngx.com/api/ 31 | [paperless-hooks]: https://docs.paperless-ngx.com/advanced_usage/#consume-hooks 32 | [paperless]: https://docs.paperless-ngx.com/ 33 | [releases]: https://github.com/hansmi/paperhooks/releases/latest 34 | 35 | 36 | -------------------------------------------------------------------------------- /contrib/integration-env/.env: -------------------------------------------------------------------------------- 1 | COMPOSE_PROJECT_NAME=paperhooks-integration 2 | INTEGRATION_HOST_PORT=8124 3 | -------------------------------------------------------------------------------- /contrib/integration-env/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # An ephemeral environment for running paperhooks integration tests. 2 | 3 | version: "3.4" 4 | services: 5 | broker: 6 | image: docker.io/library/redis:7-alpine 7 | restart: on-failure 8 | 9 | webserver: 10 | image: ghcr.io/paperless-ngx/paperless-ngx:latest 11 | restart: on-failure 12 | depends_on: 13 | - broker 14 | ports: 15 | - "${INTEGRATION_HOST_PORT:?}:8000" 16 | env_file: paperless.env 17 | environment: 18 | PAPERLESS_REDIS: redis://broker:6379 19 | PAPERLESS_URL: "http://localhost:${INTEGRATION_HOST_PORT:?}" 20 | 21 | # vim: set sw=2 sts=2 et : 22 | -------------------------------------------------------------------------------- /contrib/integration-env/paperless.env: -------------------------------------------------------------------------------- 1 | PAPERLESS_ADMIN_USER=admin 2 | PAPERLESS_ADMIN_PASSWORD=insecurepassword 3 | 4 | # vim: set sw=2 sts=2 et : 5 | -------------------------------------------------------------------------------- /contrib/run-integration: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e -u -o pipefail 4 | 5 | run_integration() { 6 | PAPERLESS_URL=${PAPERLESS_URL:-http://localhost:8124/} \ 7 | PAPERLESS_AUTH_USERNAME=${PAPERLESS_AUTH_USERNAME:-admin} \ 8 | PAPERLESS_AUTH_PASSWORD=${PAPERLESS_AUTH_PASSWORD:-insecurepassword} \ 9 | go run github.com/hansmi/paperhooks/integration "$@" 10 | } 11 | 12 | echo 'Run integration test in non-destructive mode' >&2 13 | run_integration 14 | 15 | echo 'Run integration test in destructive mode' >&2 16 | run_integration --destructive 17 | 18 | # vim: set sw=2 sts=2 et : 19 | -------------------------------------------------------------------------------- /example/list-tags/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "text/tabwriter" 9 | 10 | "github.com/alecthomas/kingpin/v2" 11 | "github.com/hansmi/paperhooks/pkg/client" 12 | "github.com/hansmi/paperhooks/pkg/kpflag" 13 | ) 14 | 15 | func main() { 16 | var flags client.Flags 17 | 18 | kpflag.RegisterClient(kingpin.CommandLine, &flags) 19 | 20 | kingpin.Parse() 21 | 22 | ctx := context.Background() 23 | 24 | cl, err := flags.Build() 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | tw := tabwriter.NewWriter(os.Stdout, 0, 4, 1, ' ', 0) 30 | 31 | defer tw.Flush() 32 | 33 | fmt.Fprintf(tw, "ID\tName\tCount\t\n") 34 | 35 | if err := cl.ListAllTags(ctx, client.ListTagsOptions{}, func(_ context.Context, tag client.Tag) error { 36 | fmt.Fprintf(tw, "%d\t%s\t%d\t\n", tag.ID, tag.Name, tag.DocumentCount) 37 | return nil 38 | }); err != nil { 39 | log.Fatal(err) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /example/postconsume-dump/README.md: -------------------------------------------------------------------------------- 1 | # Post-consumption hook logging debug information 2 | 3 | This example is a [post-consumption hook][paperless-hooks] writing debug 4 | information to its output. 5 | 6 | Paperless always appends a few arguments while all relevant information comes 7 | from environment variables. A wrapper script is the easiest way to ignore the 8 | additional arguments: 9 | 10 | ```shell 11 | #!/bin/bash 12 | 13 | set -e -u -o pipefail 14 | 15 | exec /usr/local/bin/postconsume-dump 16 | ``` 17 | 18 | Then configure Paperless to use the post-consumption hook: 19 | 20 | ```shell 21 | PAPERLESS_POST_CONSUME_SCRIPT=/usr/local/hooks/postconsume 22 | ``` 23 | 24 | [paperless-hooks]: https://docs.paperless-ngx.com/advanced_usage/#consume-hooks 25 | 26 | 27 | -------------------------------------------------------------------------------- /example/postconsume-dump/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/alecthomas/kingpin/v2" 5 | "github.com/hansmi/paperhooks/pkg/kpflag" 6 | "github.com/hansmi/paperhooks/pkg/postconsume" 7 | "github.com/kr/pretty" 8 | ) 9 | 10 | func main() { 11 | var postconsumeFlags postconsume.Flags 12 | 13 | kpflag.RegisterPostConsume(kingpin.CommandLine, &postconsumeFlags) 14 | 15 | kingpin.Parse() 16 | 17 | pretty.Logf("%# v", postconsumeFlags) 18 | } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hansmi/paperhooks 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/alecthomas/kingpin/v2 v2.4.0 9 | github.com/cenkalti/backoff/v4 v4.3.0 10 | github.com/go-resty/resty/v2 v2.16.5 11 | github.com/google/go-cmp v0.7.0 12 | github.com/google/go-querystring v1.1.0 13 | github.com/iancoleman/strcase v0.3.0 14 | github.com/jarcoal/httpmock v1.4.0 15 | github.com/kr/pretty v0.3.1 16 | go.uber.org/multierr v1.11.0 17 | golang.org/x/exp v0.0.0-20231214170342-aacd6d4b4611 18 | golang.org/x/oauth2 v0.30.0 19 | golang.org/x/sync v0.14.0 20 | golang.org/x/tools v0.33.0 21 | ) 22 | 23 | require ( 24 | cloud.google.com/go/compute/metadata v0.3.0 // indirect 25 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect 26 | github.com/kr/text v0.2.0 // indirect 27 | github.com/rogpeppe/go-internal v1.9.0 // indirect 28 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 29 | golang.org/x/mod v0.24.0 // indirect 30 | golang.org/x/net v0.40.0 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= 2 | cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 3 | github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= 4 | github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= 5 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= 6 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 7 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 8 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 9 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= 14 | github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= 15 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 16 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 17 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 18 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 19 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 20 | github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= 21 | github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= 22 | github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k= 23 | github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= 24 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 25 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 26 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 27 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 28 | github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI= 29 | github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= 30 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 31 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 32 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 33 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 34 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 35 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 36 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 37 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 38 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 39 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 40 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 41 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 42 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 43 | golang.org/x/exp v0.0.0-20231214170342-aacd6d4b4611 h1:qCEDpW1G+vcj3Y7Fy52pEM1AWm3abj8WimGYejI3SC4= 44 | golang.org/x/exp v0.0.0-20231214170342-aacd6d4b4611/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= 45 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 46 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 47 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 48 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 49 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 50 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 51 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 52 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 53 | golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 54 | golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 55 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 56 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 57 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 58 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 59 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 60 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 61 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 62 | -------------------------------------------------------------------------------- /integration/destructive.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | 10 | "github.com/hansmi/paperhooks/pkg/client" 11 | ) 12 | 13 | type destructiveTests struct { 14 | logger *log.Logger 15 | client *client.Client 16 | mark string 17 | } 18 | 19 | func (t *destructiveTests) tags(ctx context.Context) error { 20 | name := fmt.Sprintf("%s test tag", t.mark) 21 | 22 | t.logger.Printf("Create tag %q", name) 23 | 24 | tag, _, err := t.client.CreateTag(ctx, client.NewTagFields(). 25 | SetName(name). 26 | SetMatchingAlgorithm(client.MatchAny)) 27 | if err != nil { 28 | return fmt.Errorf("creating tag %s failed: %w", name, err) 29 | } 30 | 31 | if !(tag.Name == name && tag.MatchingAlgorithm == client.MatchAny) { 32 | return fmt.Errorf("tag settings differ from configuration: %#v", *tag) 33 | } 34 | 35 | t.logger.Printf("Update tag %q without making changes: %#v", name, *tag) 36 | 37 | tag, _, err = t.client.UpdateTag(ctx, tag.ID, tag) 38 | if err != nil { 39 | return fmt.Errorf("updating tag %s without changes failed: %w", name, err) 40 | } 41 | 42 | tag.Name = name + " modified" 43 | tag.Color = client.Color{R: 0xFF} 44 | tag.IsInboxTag = true 45 | tag.IsInsensitive = true 46 | tag.MatchingAlgorithm = client.MatchFuzzy 47 | tag.Match = name 48 | 49 | t.logger.Printf("Update tag %q with changes: %#v", name, *tag) 50 | 51 | tag, _, err = t.client.UpdateTag(ctx, tag.ID, tag) 52 | if err != nil { 53 | return fmt.Errorf("updating tag %s with changes failed: %w", name, err) 54 | } 55 | 56 | name += " modified" 57 | 58 | t.logger.Printf("List tags with name %q", name) 59 | 60 | if matches, _, err := t.client.ListTags(ctx, client.ListTagsOptions{ 61 | Ordering: client.OrderingSpec{ 62 | Field: "name", 63 | }, 64 | Name: client.CharFilterSpec{ 65 | EqualsIgnoringCase: client.String(name), 66 | }, 67 | }); err != nil { 68 | return fmt.Errorf("listing tags failed: %v", err) 69 | } else if len(matches) != 1 { 70 | return fmt.Errorf("listing tags did not return exactly one match for %q: %+v", name, matches) 71 | } 72 | 73 | t.logger.Printf("Delete tag %q", name) 74 | 75 | if _, err := t.client.DeleteTag(ctx, tag.ID); err != nil { 76 | return fmt.Errorf("deleting tag %s failed: %w", name, err) 77 | } 78 | 79 | _, _, err = t.client.GetTag(ctx, tag.ID) 80 | if detail, ok := err.(*client.RequestError); !(ok && detail.StatusCode == http.StatusNotFound) { 81 | return fmt.Errorf("getting tag %s did not return HTTP 404: %w", name, err) 82 | } 83 | 84 | return nil 85 | } 86 | 87 | func (t *destructiveTests) uploadDocument(ctx context.Context) error { 88 | imgBytes, err := makeRandomImage(100, 100) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | t.logger.Printf("Upload document with a generated image") 94 | 95 | result, _, err := t.client.UploadDocument(ctx, 96 | bytes.NewReader(imgBytes), 97 | client.DocumentUploadOptions{ 98 | Filename: t.mark + ".png", 99 | Title: t.mark, 100 | }) 101 | if err != nil { 102 | return fmt.Errorf("uploading document failed: %w", err) 103 | } 104 | 105 | t.logger.Printf("Task ID from document upload: %s", result.TaskID) 106 | 107 | if task, err := t.client.WaitForTask(ctx, result.TaskID, client.WaitForTaskOptions{}); err != nil { 108 | return fmt.Errorf("document upload: %w", err) 109 | } else { 110 | t.logger.Printf("Task finished: %+v", task) 111 | } 112 | 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /integration/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "math/rand" 8 | "time" 9 | 10 | "github.com/alecthomas/kingpin/v2" 11 | "github.com/hansmi/paperhooks/pkg/client" 12 | "github.com/hansmi/paperhooks/pkg/kpflag" 13 | ) 14 | 15 | func main() { 16 | var clientFlags client.Flags 17 | 18 | rand.Seed(time.Now().UnixNano()) 19 | 20 | destructive := kingpin.Flag("destructive", 21 | "Execute potentially destructive tests. Do not use with production instances."). 22 | Bool() 23 | 24 | kpflag.RegisterClient(kingpin.CommandLine, &clientFlags) 25 | 26 | kingpin.CommandLine.Help = "Integration tests for the paperhooks library." 27 | kingpin.Parse() 28 | 29 | ctx := context.Background() 30 | 31 | client, err := clientFlags.Build() 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | 36 | if err := client.Ping(ctx); err != nil { 37 | log.Fatalf("Connection test failed: %v", err) 38 | } 39 | 40 | ro := readOnlyTests{ 41 | logger: log.Default(), 42 | client: client, 43 | } 44 | 45 | tests := []func(context.Context) error{ 46 | ro.tags, 47 | ro.correspondents, 48 | ro.documentTypes, 49 | ro.storagePaths, 50 | ro.customFields, 51 | ro.documents, 52 | ro.tasks, 53 | ro.logs, 54 | ro.currentUser, 55 | ro.users, 56 | ro.groups, 57 | } 58 | 59 | if *destructive { 60 | dt := &destructiveTests{ 61 | logger: ro.logger, 62 | client: ro.client, 63 | mark: fmt.Sprintf("test%x", rand.Int63()), 64 | } 65 | 66 | // Destructive tests (create, update, delete, etc.) 67 | tests = append(tests, 68 | dt.uploadDocument, 69 | dt.tags, 70 | ) 71 | } 72 | 73 | for _, fn := range tests { 74 | if err := fn(ctx); err != nil { 75 | log.Fatal(err) 76 | } 77 | } 78 | 79 | log.Print("All tests completed successfully.") 80 | } 81 | -------------------------------------------------------------------------------- /integration/randimage.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "image/png" 8 | "math/rand" 9 | ) 10 | 11 | func makeRandomImage(width, height int) ([]byte, error) { 12 | img := image.NewRGBA(image.Rect(0, 0, width, height)) 13 | 14 | if _, err := rand.Read(img.Pix); err != nil { 15 | return nil, fmt.Errorf("generating random image: %w", err) 16 | } 17 | 18 | var buf bytes.Buffer 19 | 20 | if err := png.Encode(&buf, img); err != nil { 21 | return nil, fmt.Errorf("encoding image: %w", err) 22 | } 23 | 24 | return buf.Bytes(), nil 25 | } 26 | -------------------------------------------------------------------------------- /integration/randimage_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "image/png" 6 | "testing" 7 | ) 8 | 9 | func TestMakeRandomImage(t *testing.T) { 10 | b, err := makeRandomImage(1, 1) 11 | if err != nil { 12 | t.Fatalf("makeRandomImage() failed: %v", err) 13 | } 14 | 15 | if _, err := png.Decode(bytes.NewReader(b)); err != nil { 16 | t.Errorf("Decoding image failed: %v", err) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /integration/readonly.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/hansmi/paperhooks/pkg/client" 12 | ) 13 | 14 | type readOnlyTests struct { 15 | logger *log.Logger 16 | client *client.Client 17 | } 18 | 19 | func (t *readOnlyTests) tags(ctx context.Context) error { 20 | opt := client.ListTagsOptions{} 21 | all := []client.Tag{} 22 | 23 | for { 24 | tags, resp, err := t.client.ListTags(ctx, opt) 25 | if err != nil { 26 | return fmt.Errorf("listing tags failed: %w", err) 27 | } 28 | 29 | all = append(all, tags...) 30 | 31 | if resp.NextPage == nil { 32 | break 33 | } 34 | 35 | opt.Page = resp.NextPage 36 | } 37 | 38 | t.logger.Printf("Received %d tags. Fetching all of them.", len(all)) 39 | 40 | for _, i := range all { 41 | if _, _, err := t.client.GetTag(ctx, i.ID); err != nil { 42 | return fmt.Errorf("getting tag %d failed: %w", i.ID, err) 43 | } 44 | } 45 | 46 | return nil 47 | } 48 | 49 | func (t *readOnlyTests) correspondents(ctx context.Context) error { 50 | opt := client.ListCorrespondentsOptions{} 51 | all := []client.Correspondent{} 52 | 53 | for { 54 | correspondents, resp, err := t.client.ListCorrespondents(ctx, opt) 55 | if err != nil { 56 | return fmt.Errorf("listing correspondents failed: %w", err) 57 | } 58 | 59 | all = append(all, correspondents...) 60 | 61 | if resp.NextPage == nil { 62 | break 63 | } 64 | 65 | opt.Page = resp.NextPage 66 | } 67 | 68 | t.logger.Printf("Received %d correspondents.", len(all)) 69 | 70 | for _, i := range all { 71 | if _, _, err := t.client.GetCorrespondent(ctx, i.ID); err != nil { 72 | return fmt.Errorf("getting correspondent %d failed: %w", i.ID, err) 73 | } 74 | } 75 | 76 | return nil 77 | } 78 | 79 | func (t *readOnlyTests) documentTypes(ctx context.Context) error { 80 | opt := client.ListDocumentTypesOptions{} 81 | all := []client.DocumentType{} 82 | 83 | for { 84 | documentTypes, resp, err := t.client.ListDocumentTypes(ctx, opt) 85 | if err != nil { 86 | return fmt.Errorf("listing document types failed: %w", err) 87 | } 88 | 89 | all = append(all, documentTypes...) 90 | 91 | if resp.NextPage == nil { 92 | break 93 | } 94 | 95 | opt.Page = resp.NextPage 96 | } 97 | 98 | t.logger.Printf("Received %d document types.", len(all)) 99 | 100 | for _, i := range all { 101 | if _, _, err := t.client.GetDocumentType(ctx, i.ID); err != nil { 102 | return fmt.Errorf("getting document type %d failed: %w", i.ID, err) 103 | } 104 | } 105 | 106 | return nil 107 | } 108 | 109 | func (t *readOnlyTests) storagePaths(ctx context.Context) error { 110 | opt := client.ListStoragePathsOptions{} 111 | all := []client.StoragePath{} 112 | 113 | for { 114 | storagePaths, resp, err := t.client.ListStoragePaths(ctx, opt) 115 | if err != nil { 116 | return fmt.Errorf("listing storage paths failed: %w", err) 117 | } 118 | 119 | all = append(all, storagePaths...) 120 | 121 | if resp.NextPage == nil { 122 | break 123 | } 124 | 125 | opt.Page = resp.NextPage 126 | } 127 | 128 | t.logger.Printf("Received %d storage paths.", len(all)) 129 | 130 | for _, i := range all { 131 | if _, _, err := t.client.GetStoragePath(ctx, i.ID); err != nil { 132 | return fmt.Errorf("getting storage path %d failed: %w", i.ID, err) 133 | } 134 | } 135 | 136 | return nil 137 | } 138 | 139 | func (t *readOnlyTests) customFields(ctx context.Context) error { 140 | var opt client.ListCustomFieldsOptions 141 | 142 | return t.client.ListAllCustomFields(ctx, opt, func(ctx context.Context, field client.CustomField) error { 143 | t.logger.Printf("Received custom field: %#v", field) 144 | return nil 145 | }) 146 | } 147 | 148 | func (t *readOnlyTests) documents(ctx context.Context) error { 149 | const examineCount = 10 150 | 151 | opt := client.ListDocumentsOptions{} 152 | all := []client.Document{} 153 | 154 | for { 155 | documents, resp, err := t.client.ListDocuments(ctx, opt) 156 | if err != nil { 157 | return fmt.Errorf("listing documents failed: %w", err) 158 | } 159 | 160 | all = append(all, documents...) 161 | 162 | if resp.NextPage == nil { 163 | break 164 | } 165 | 166 | opt.Page = resp.NextPage 167 | } 168 | 169 | t.logger.Printf("Received %d documents.", len(all)) 170 | 171 | for idx, i := range all { 172 | if _, _, err := t.client.GetDocument(ctx, i.ID); err != nil { 173 | return fmt.Errorf("getting document %d failed: %w", i.ID, err) 174 | } 175 | 176 | if md, _, err := t.client.GetDocumentMetadata(ctx, i.ID); err != nil { 177 | return fmt.Errorf("getting document %d metadata failed: %w", i.ID, err) 178 | } else { 179 | t.logger.Printf("Document %d metadata: %+v", i.ID, *md) 180 | } 181 | 182 | for _, x := range []struct { 183 | name string 184 | fn func(context.Context, io.Writer, int64) (*client.DownloadResult, *client.Response, error) 185 | }{ 186 | { 187 | name: "original", 188 | fn: t.client.DownloadDocumentOriginal, 189 | }, 190 | { 191 | name: "archived", 192 | fn: t.client.DownloadDocumentArchived, 193 | }, 194 | { 195 | name: "thumbnail", 196 | fn: t.client.DownloadDocumentThumbnail, 197 | }, 198 | } { 199 | t.logger.Printf("Download %s version of document %d.", x.name, i.ID) 200 | 201 | if r, _, err := x.fn(ctx, io.Discard, i.ID); err != nil { 202 | return fmt.Errorf("download of document %d failed: %w", i.ID, err) 203 | } else { 204 | t.logger.Printf("Received %d bytes for filename %q with content type %q.", 205 | r.Length, r.Filename, r.ContentType) 206 | } 207 | } 208 | 209 | if idx >= examineCount { 210 | break 211 | } 212 | } 213 | 214 | return nil 215 | } 216 | 217 | func (t *readOnlyTests) tasks(ctx context.Context) error { 218 | tasks, _, err := t.client.ListTasks(ctx) 219 | if err != nil { 220 | return fmt.Errorf("listing tasks failed: %w", err) 221 | } 222 | 223 | t.logger.Printf("Received %d tasks.", len(tasks)) 224 | 225 | return nil 226 | } 227 | 228 | func (t *readOnlyTests) logs(ctx context.Context) error { 229 | logs, _, err := t.client.ListLogs(ctx) 230 | if err != nil { 231 | return fmt.Errorf("listing logs failed: %w", err) 232 | } 233 | 234 | t.logger.Printf("Received log names: %v", logs) 235 | 236 | for _, name := range logs { 237 | entries, _, err := t.client.GetLog(ctx, name) 238 | if err != nil { 239 | var re *client.RequestError 240 | 241 | if !(errors.As(err, &re) && re.StatusCode == http.StatusNotFound) { 242 | return fmt.Errorf("fetching entries for log %q failed: %w", name, err) 243 | } 244 | 245 | entries = nil 246 | } 247 | 248 | t.logger.Printf("Received %d entries for log %q.", len(entries), name) 249 | } 250 | 251 | return nil 252 | } 253 | 254 | func (t *readOnlyTests) currentUser(ctx context.Context) error { 255 | got, _, err := t.client.GetCurrentUser(ctx) 256 | if err != nil { 257 | return fmt.Errorf("gettng current user: %w", err) 258 | } 259 | 260 | t.logger.Printf("Current user: %#v", got) 261 | 262 | return nil 263 | } 264 | 265 | func (t *readOnlyTests) users(ctx context.Context) error { 266 | var opts client.ListUsersOptions 267 | 268 | count := 0 269 | 270 | if err := t.client.ListAllUsers(ctx, opts, func(ctx context.Context, user client.User) error { 271 | count++ 272 | 273 | t.logger.Printf("Received user: %#v", user) 274 | 275 | return nil 276 | }); err != nil { 277 | return fmt.Errorf("listing users: %w", err) 278 | } 279 | 280 | if count < 1 { 281 | return errors.New("no users found") 282 | } 283 | 284 | return nil 285 | } 286 | 287 | func (t *readOnlyTests) groups(ctx context.Context) error { 288 | var opts client.ListGroupsOptions 289 | 290 | if err := t.client.ListAllGroups(ctx, opts, func(ctx context.Context, group client.Group) error { 291 | t.logger.Printf("Received group: %#v", group) 292 | 293 | return nil 294 | }); err != nil { 295 | return fmt.Errorf("listing groups: %w", err) 296 | } 297 | 298 | return nil 299 | } 300 | -------------------------------------------------------------------------------- /internal/httptransport/concurrency.go: -------------------------------------------------------------------------------- 1 | package httptransport 2 | 3 | import ( 4 | "net/http" 5 | 6 | "golang.org/x/sync/semaphore" 7 | ) 8 | 9 | type limitConcurrent struct { 10 | base http.RoundTripper 11 | sem *semaphore.Weighted 12 | } 13 | 14 | var _ http.RoundTripper = (*limitConcurrent)(nil) 15 | 16 | // LimitConcurrent returns an HTTP round-tripper permitting up to max 17 | // concurrent requests. All other requests need to wait. A max of zero or less 18 | // disables the limitation. 19 | func LimitConcurrent(base http.RoundTripper, max int) http.RoundTripper { 20 | if max < 1 { 21 | return base 22 | } 23 | 24 | return &limitConcurrent{ 25 | base: base, 26 | sem: semaphore.NewWeighted(int64(max)), 27 | } 28 | } 29 | 30 | func (c *limitConcurrent) RoundTrip(r *http.Request) (*http.Response, error) { 31 | const weight = 1 32 | 33 | if err := c.sem.Acquire(r.Context(), weight); err != nil { 34 | return nil, err 35 | } 36 | 37 | defer c.sem.Release(weight) 38 | 39 | return c.base.RoundTrip(r) 40 | } 41 | -------------------------------------------------------------------------------- /internal/kpflagvalue/header.go: -------------------------------------------------------------------------------- 1 | package kpflagvalue 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/alecthomas/kingpin/v2" 9 | ) 10 | 11 | type httpHeader http.Header 12 | 13 | var _ kingpin.Value = (*httpHeader)(nil) 14 | 15 | func (h *httpHeader) String() string { 16 | return fmt.Sprintf("%q", *(*http.Header)(h)) 17 | } 18 | 19 | func (h *httpHeader) IsCumulative() bool { 20 | return true 21 | } 22 | 23 | func (h *httpHeader) Set(value string) error { 24 | parts := strings.SplitN(value, ":", 2) 25 | if len(parts) != 2 { 26 | return fmt.Errorf("expected header:value, got %q", value) 27 | } 28 | 29 | if *h == nil { 30 | *h = httpHeader{} 31 | } 32 | 33 | p := (*http.Header)(h) 34 | p.Add(parts[0], parts[1]) 35 | 36 | return nil 37 | } 38 | 39 | func HTTPHeaderVar(t kingpin.Settings, target *http.Header) { 40 | t.SetValue((*httpHeader)(target)) 41 | } 42 | -------------------------------------------------------------------------------- /internal/kpflagvalue/strings.go: -------------------------------------------------------------------------------- 1 | package kpflagvalue 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/alecthomas/kingpin/v2" 8 | ) 9 | 10 | type commaSeparatedStrings []string 11 | 12 | var _ kingpin.Value = (*commaSeparatedStrings)(nil) 13 | 14 | func (s *commaSeparatedStrings) String() string { 15 | return fmt.Sprint(*s) 16 | } 17 | 18 | func (s *commaSeparatedStrings) IsCumulative() bool { 19 | return true 20 | } 21 | 22 | func (s *commaSeparatedStrings) Set(value string) error { 23 | for _, part := range strings.Split(value, ",") { 24 | if part = strings.TrimSpace(part); part != "" { 25 | *s = append(*s, part) 26 | } 27 | } 28 | 29 | return nil 30 | } 31 | 32 | func CommaSeparatedStringsVar(t kingpin.Settings, target *[]string) { 33 | t.SetValue((*commaSeparatedStrings)(target)) 34 | } 35 | -------------------------------------------------------------------------------- /internal/kpflagvalue/strings_test.go: -------------------------------------------------------------------------------- 1 | package kpflagvalue 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/google/go-cmp/cmp/cmpopts" 9 | ) 10 | 11 | func TestHTTPHeader(t *testing.T) { 12 | for _, tc := range []struct { 13 | name string 14 | values []string 15 | want http.Header 16 | wantErr error 17 | }{ 18 | { 19 | name: "nothing", 20 | }, 21 | { 22 | name: "empty", 23 | values: []string{""}, 24 | wantErr: cmpopts.AnyError, 25 | }, 26 | { 27 | name: "one", 28 | values: []string{"a:b"}, 29 | want: http.Header{ 30 | "A": []string{"b"}, 31 | }, 32 | }, 33 | { 34 | name: "multiple", 35 | values: []string{ 36 | "A:b", 37 | "host:localhost", 38 | "a:aaa", 39 | }, 40 | want: http.Header{ 41 | "A": []string{"b", "aaa"}, 42 | "Host": []string{"localhost"}, 43 | }, 44 | }, 45 | } { 46 | t.Run(tc.name, func(t *testing.T) { 47 | var fv httpHeader 48 | var err error 49 | 50 | for _, i := range tc.values { 51 | if err = fv.Set(i); err != nil { 52 | break 53 | } 54 | } 55 | 56 | if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { 57 | t.Errorf("Set() error diff (-want +got):\n%s", diff) 58 | } 59 | 60 | if err == nil { 61 | if diff := cmp.Diff(tc.want, (http.Header)(fv), cmpopts.EquateEmpty()); diff != "" { 62 | t.Errorf("Parsed values diff (-want +got):\n%s", diff) 63 | } 64 | } 65 | }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/kpflagvalue/time.go: -------------------------------------------------------------------------------- 1 | package kpflagvalue 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/alecthomas/kingpin/v2" 8 | ) 9 | 10 | var timeLayouts = []string{ 11 | // Formats used by Paperless 12 | "2006-01-02 15:04:05.000000Z07:00", 13 | "2006-01-02 15:04:05Z07:00", 14 | 15 | // Other formats 16 | time.RFC3339, 17 | "2006-01-02T15:04:05", 18 | "2006-01-02 15:04:05", 19 | "2006-01-02T15:04", 20 | "2006-01-02 15:04", 21 | "2006-01-02", 22 | } 23 | 24 | type timeValue time.Time 25 | 26 | var _ kingpin.Value = (*timeValue)(nil) 27 | 28 | func (t *timeValue) String() string { 29 | return (*time.Time)(t).String() 30 | } 31 | 32 | func (t *timeValue) Set(value string) error { 33 | var firstErr error 34 | 35 | for _, layout := range timeLayouts { 36 | ts, err := time.ParseInLocation(layout, value, time.Local) 37 | if err == nil { 38 | *(*time.Time)(t) = ts 39 | return nil 40 | } 41 | 42 | if firstErr == nil { 43 | firstErr = err 44 | } 45 | } 46 | 47 | return fmt.Errorf("parsing %q as a time value failed (supported layouts: %q): %w", value, timeLayouts, firstErr) 48 | } 49 | 50 | func TimeVar(t kingpin.Settings, target *time.Time) { 51 | t.SetValue((*timeValue)(target)) 52 | } 53 | -------------------------------------------------------------------------------- /internal/kpflagvalue/time_test.go: -------------------------------------------------------------------------------- 1 | package kpflagvalue 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/google/go-cmp/cmp/cmpopts" 9 | ) 10 | 11 | func TestTime(t *testing.T) { 12 | for _, tc := range []struct { 13 | name string 14 | value string 15 | wantErr error 16 | want time.Time 17 | }{ 18 | { 19 | name: "empty", 20 | wantErr: cmpopts.AnyError, 21 | }, 22 | { 23 | name: "success", 24 | value: "2020-12-31T13:07:14-05:00", 25 | want: time.Date(2020, time.December, 31, 13+5, 07, 14, 0, time.UTC), 26 | }, 27 | { 28 | name: "day only", 29 | value: "2004-03-02", 30 | want: time.Date(2004, time.March, 02, 0, 0, 0, 0, time.Local), 31 | }, 32 | { 33 | name: "paperless format", 34 | value: "2023-02-27 23:03:50.127675+00:00", 35 | want: time.Date(2023, time.February, 27, 23, 03, 50, 127675, time.UTC), 36 | }, 37 | { 38 | name: "paperless format short", 39 | value: "2023-02-24 23:00:00+00:00", 40 | want: time.Date(2023, time.February, 24, 23, 00, 00, 0, time.UTC), 41 | }, 42 | } { 43 | t.Run(tc.name, func(t *testing.T) { 44 | var fv timeValue 45 | 46 | err := fv.Set(tc.value) 47 | 48 | if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { 49 | t.Errorf("Set() error diff (-want +got):\n%s", diff) 50 | } 51 | 52 | if err == nil { 53 | if diff := cmp.Diff(tc.want, (time.Time)(fv), cmpopts.EquateApproxTime(time.Second)); diff != "" { 54 | t.Errorf("Parsed time diff (-want +got):\n%s", diff) 55 | } 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/testutil/cmp.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func EquateTimeLocation() cmp.Option { 10 | return cmp.Comparer(func(a, b time.Location) bool { 11 | return a.String() == b.String() 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /internal/testutil/env.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | // RestoreEnv restores all environment variables at the end of a test. 10 | func RestoreEnv(t *testing.T) { 11 | t.Helper() 12 | 13 | previous := os.Environ() 14 | 15 | t.Cleanup(func() { 16 | os.Clearenv() 17 | 18 | for _, i := range previous { 19 | parts := strings.SplitN(i, "=", 2) 20 | 21 | if err := os.Setenv(parts[0], parts[1]); err != nil { 22 | t.Errorf("Setenv(%q, %q) failed: %v", parts[0], parts[1], err) 23 | } 24 | } 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /internal/testutil/setenv.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func Setenv(t *testing.T, env map[string]string) { 9 | t.Helper() 10 | 11 | for key, value := range env { 12 | if err := os.Setenv(key, value); err != nil { 13 | t.Errorf("Setenv(%q, %q) failed: %v", key, value, err) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/testutil/writefile.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func MustWriteFile(t *testing.T, path string, content string) string { 9 | t.Helper() 10 | 11 | if err := os.WriteFile(path, []byte(content), 0o600); err != nil { 12 | t.Errorf("WriteFile(%q) failed: %v", path, err) 13 | } 14 | 15 | return path 16 | } 17 | -------------------------------------------------------------------------------- /pkg/client/auth.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "github.com/go-resty/resty/v2" 4 | 5 | type AuthMechanism interface { 6 | authenticate(Options, *resty.Client) 7 | } 8 | 9 | // Paperless authentication token. 10 | type TokenAuth struct { 11 | Token string 12 | } 13 | 14 | var _ AuthMechanism = (*TokenAuth)(nil) 15 | 16 | func (t *TokenAuth) authenticate(_ Options, c *resty.Client) { 17 | c.SetAuthScheme("Token") 18 | c.SetAuthToken(t.Token) 19 | } 20 | 21 | // HTTP basic authentication with a username and password. 22 | type UsernamePasswordAuth struct { 23 | Username string 24 | Password string 25 | } 26 | 27 | var _ AuthMechanism = (*UsernamePasswordAuth)(nil) 28 | 29 | func (a *UsernamePasswordAuth) authenticate(_ Options, c *resty.Client) { 30 | c.SetBasicAuth(a.Username, a.Password) 31 | } 32 | -------------------------------------------------------------------------------- /pkg/client/auth_example_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | ) 9 | 10 | func ExampleTokenAuth() { 11 | ts := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) 12 | defer ts.Close() 13 | 14 | cl := New(Options{ 15 | BaseURL: ts.URL, 16 | Auth: &TokenAuth{"mytoken1234"}, 17 | }) 18 | 19 | if err := cl.Ping(context.Background()); err != nil { 20 | fmt.Printf("Pinging server failed: %v\n", err) 21 | } else { 22 | fmt.Println("Success!") 23 | } 24 | 25 | // Output: Success! 26 | } 27 | -------------------------------------------------------------------------------- /pkg/client/cert_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/pem" 6 | "testing" 7 | ) 8 | 9 | // Fake X.509 certificate for testing. Generated using the following commands: 10 | // 11 | // openssl genrsa -out key 512 12 | // 13 | // faketime '2000-01-01 00:00 UTC' \ 14 | // openssl req -x509 -new -nodes -key key -days 1 -out cert \ 15 | // -outform PEM -batch 16 | const fakeCertPEM = ` 17 | -----BEGIN CERTIFICATE----- 18 | MIIB4TCCAYugAwIBAgIUDUm2YVOrpISBpfQO5H8o3Kxu8S0wDQYJKoZIhvcNAQEL 19 | BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM 20 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0wMDAxMDEwMDAwMDBaFw0wMDAx 21 | MDIwMDAwMDBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw 22 | HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwXDANBgkqhkiG9w0BAQEF 23 | AANLADBIAkEAxMlvlAar74MFUhb9LrqeclDmKWsjWbuiCVdAoj8+Gq+XG3B4H3bL 24 | auNZ+dhyr3eZuHsbw+D3KToeiMJRxAsRZQIDAQABo1MwUTAdBgNVHQ4EFgQURv88 25 | YquEePhNH7s0kP5Gu8rDX0QwHwYDVR0jBBgwFoAURv88YquEePhNH7s0kP5Gu8rD 26 | X0QwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAANBACPTaHtvgMR9yzrb 27 | YhfkbRMvlye1i/xliJihG6kUSd9oPEqtTN6L/6qId2FWENfxijFIceavp6VLYsun 28 | cr9Jj64= 29 | -----END CERTIFICATE----- 30 | ` 31 | 32 | func newFakeCertPool(t *testing.T) *x509.CertPool { 33 | t.Helper() 34 | 35 | block, _ := pem.Decode([]byte(fakeCertPEM)) 36 | 37 | cert, err := x509.ParseCertificate(block.Bytes) 38 | if err != nil { 39 | t.Fatalf("Parsing test certificate: %v", err) 40 | } 41 | 42 | pool := x509.NewCertPool() 43 | pool.AddCert(cert) 44 | 45 | return pool 46 | } 47 | -------------------------------------------------------------------------------- /pkg/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "log" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/go-resty/resty/v2" 12 | "github.com/hansmi/paperhooks/internal/httptransport" 13 | ) 14 | 15 | const ItemCountUnknown = -1 16 | 17 | // Options for constructing a Paperless client. 18 | type Options struct { 19 | // Paperless URL. May include a path. 20 | BaseURL string 21 | 22 | // API authentication. 23 | Auth AuthMechanism 24 | 25 | // Enable debug mode with many details logged. 26 | DebugMode bool 27 | 28 | // Logger for writing log messages. If debug mode is enabled and no logger 29 | // is configured all messages are written to standard library's default 30 | // logger (log.Default()). 31 | Logger Logger 32 | 33 | // HTTP headers to set on all requests. 34 | Header http.Header 35 | 36 | // Server's timezone for parsing timestamps without explicit offset. 37 | // Defaults to [time.Local]. 38 | ServerLocation *time.Location 39 | 40 | // Number of concurrent requests allowed to be in flight. Defaults to zero 41 | // (no limitation). 42 | MaxConcurrentRequests int 43 | 44 | // TrustedRootCAs defines the set of certificate authorities the client 45 | // uses when verifying server certificates. If nil the system's default 46 | // certificate pool is used. 47 | TrustedRootCAs *x509.CertPool 48 | 49 | // Override the default HTTP transport. 50 | transport http.RoundTripper 51 | } 52 | 53 | type Client struct { 54 | logger Logger 55 | loc *time.Location 56 | r *resty.Client 57 | } 58 | 59 | // New creates a new client instance. 60 | func New(opts Options) *Client { 61 | if opts.Logger == nil { 62 | if opts.DebugMode { 63 | opts.Logger = &wrappedStdLogger{log.Default()} 64 | } else { 65 | opts.Logger = &discardLogger{} 66 | } 67 | } 68 | 69 | if opts.ServerLocation == nil { 70 | opts.ServerLocation = time.Local 71 | } 72 | 73 | r := resty.New(). 74 | SetDebug(opts.DebugMode). 75 | SetLogger(&prefixLogger{ 76 | wrapped: opts.Logger, 77 | prefix: "Resty: ", 78 | }). 79 | SetDisableWarn(true). 80 | SetBaseURL(opts.BaseURL). 81 | SetHeader("Accept", "application/json; version=2"). 82 | SetRedirectPolicy(resty.NoRedirectPolicy()) 83 | 84 | if opts.transport != nil { 85 | r.SetTransport(opts.transport) 86 | } 87 | 88 | if opts.Auth != nil { 89 | // Authentication may use or modify the transport (e.g. OAuth), so it 90 | // must be set up before applying limitations specific to the Paperless 91 | // API. 92 | opts.Auth.authenticate(opts, r) 93 | } 94 | 95 | if opts.TrustedRootCAs != nil { 96 | // TODO: Resty v3 has Client.TLSClientConfig and 97 | // Client.SetTLSClientConfig functions. 98 | transport, err := r.Transport() 99 | if err != nil { 100 | // Happens when the transport is not an *http.Transport instance. 101 | panic(err) 102 | } 103 | 104 | tlsConfig := transport.TLSClientConfig 105 | 106 | if tlsConfig == nil { 107 | tlsConfig = &tls.Config{} 108 | transport.TLSClientConfig = tlsConfig 109 | } 110 | 111 | tlsConfig.RootCAs = opts.TrustedRootCAs 112 | 113 | r.SetTransport(transport) 114 | } 115 | 116 | r.SetTransport(httptransport.LimitConcurrent(r.GetClient().Transport, opts.MaxConcurrentRequests)) 117 | 118 | if len(opts.Header) > 0 { 119 | r.SetPreRequestHook(func(_ *resty.Client, req *http.Request) error { 120 | for name, values := range opts.Header { 121 | req.Header[http.CanonicalHeaderKey(name)] = values 122 | } 123 | 124 | return nil 125 | }) 126 | } 127 | 128 | return &Client{ 129 | logger: opts.Logger, 130 | loc: opts.ServerLocation, 131 | r: r, 132 | } 133 | } 134 | 135 | func (c *Client) newRequest(ctx context.Context) *resty.Request { 136 | return c.r.R(). 137 | SetContext(ctx). 138 | SetError(requestError{}). 139 | ExpectContentType("application/json") 140 | } 141 | 142 | type Response struct { 143 | *http.Response 144 | 145 | // Expected number of items after filtering and across all pages (if 146 | // paginated). Set to [ItemCountUnknown] if the value isn't available. 147 | ItemCount int64 148 | 149 | // Token for fetching next page in paginated result sets. 150 | NextPage *PageToken 151 | 152 | // Token for fetching previous page in paginated result sets. 153 | PrevPage *PageToken 154 | } 155 | 156 | func wrapResponse(r *resty.Response) *Response { 157 | if r == nil { 158 | return nil 159 | } 160 | 161 | return &Response{ 162 | Response: r.RawResponse, 163 | ItemCount: ItemCountUnknown, 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /pkg/client/client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "crypto/x509" 6 | "errors" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/http/httptest" 11 | "testing" 12 | 13 | "github.com/go-resty/resty/v2" 14 | "github.com/google/go-cmp/cmp" 15 | "github.com/google/go-cmp/cmp/cmpopts" 16 | "github.com/jarcoal/httpmock" 17 | ) 18 | 19 | func newMockTransport(t *testing.T) *httpmock.MockTransport { 20 | t.Helper() 21 | 22 | transport := httpmock.NewMockTransport() 23 | transport.RegisterNoResponder(httpmock.NewNotFoundResponder(t.Fatal)) 24 | 25 | return transport 26 | } 27 | 28 | func TestClient(t *testing.T) { 29 | for _, tc := range []struct { 30 | name string 31 | opts Options 32 | setup func(*testing.T, *httpmock.MockTransport) 33 | wantErr error 34 | }{ 35 | { 36 | name: "defaults", 37 | setup: func(t *testing.T, transport *httpmock.MockTransport) { 38 | transport.RegisterMatcherResponder(http.MethodGet, "/api/", 39 | httpmock.HeaderIs("Accept", "application/json; version=2"), 40 | httpmock.NewJsonResponderOrPanic(http.StatusOK, nil)) 41 | }, 42 | }, 43 | { 44 | name: "customized", 45 | opts: Options{ 46 | BaseURL: "http://localhost:1234/path/", 47 | Auth: &TokenAuth{"foo26175bar"}, 48 | Header: http.Header{ 49 | "X-Custom": []string{"aaa"}, 50 | }, 51 | }, 52 | setup: func(t *testing.T, transport *httpmock.MockTransport) { 53 | transport.RegisterMatcherResponder(http.MethodGet, "http://localhost:1234/path/api/", 54 | httpmock.HeaderIs("Authorization", "Token foo26175bar").And( 55 | httpmock.HeaderIs("X-Custom", "aaa"), 56 | ), 57 | httpmock.NewJsonResponderOrPanic(http.StatusOK, nil)) 58 | }, 59 | }, 60 | { 61 | name: "basic auth", 62 | opts: Options{ 63 | BaseURL: "http://localhost:1234////", 64 | Auth: &UsernamePasswordAuth{ 65 | Username: "user", 66 | Password: "password", 67 | }, 68 | }, 69 | setup: func(t *testing.T, transport *httpmock.MockTransport) { 70 | transport.RegisterMatcherResponder(http.MethodGet, "http://localhost:1234/api/", 71 | httpmock.HeaderIs("Authorization", "Basic dXNlcjpwYXNzd29yZA=="), 72 | httpmock.NewJsonResponderOrPanic(http.StatusOK, nil)) 73 | }, 74 | }, 75 | { 76 | name: "redirect", 77 | setup: func(t *testing.T, transport *httpmock.MockTransport) { 78 | transport.RegisterResponder(http.MethodGet, "/api/", 79 | httpmock.NewJsonResponderOrPanic(http.StatusSeeOther, nil)) 80 | }, 81 | wantErr: &RequestError{ 82 | StatusCode: http.StatusSeeOther, 83 | Message: "303 See Other", 84 | }, 85 | }, 86 | { 87 | name: "internal server error", 88 | setup: func(t *testing.T, transport *httpmock.MockTransport) { 89 | transport.RegisterResponder(http.MethodGet, "/api/", 90 | httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, nil)) 91 | }, 92 | wantErr: &RequestError{ 93 | StatusCode: http.StatusInternalServerError, 94 | Message: "null", 95 | }, 96 | }, 97 | } { 98 | t.Run(tc.name, func(t *testing.T) { 99 | transport := newMockTransport(t) 100 | 101 | tc.setup(t, transport) 102 | 103 | tc.opts.transport = transport 104 | 105 | err := New(tc.opts).Ping(context.Background()) 106 | 107 | if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { 108 | t.Errorf("Ping() error diff (-want +got):\n%s", diff) 109 | } 110 | }) 111 | } 112 | } 113 | 114 | func TestClientWithTLS(t *testing.T) { 115 | for _, tc := range []struct { 116 | name string 117 | trust bool 118 | opts Options 119 | }{ 120 | { 121 | name: "trusted", 122 | trust: true, 123 | }, 124 | { 125 | name: "untrusted", 126 | }, 127 | { 128 | name: "max concurrent", 129 | trust: true, 130 | opts: Options{ 131 | MaxConcurrentRequests: 100, 132 | }, 133 | }, 134 | } { 135 | t.Run(tc.name, func(t *testing.T) { 136 | srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 137 | })) 138 | t.Cleanup(srv.Close) 139 | 140 | srv.Config.ErrorLog = log.New(io.Discard, "", 0) 141 | srv.StartTLS() 142 | 143 | opts := tc.opts 144 | opts.BaseURL = srv.URL 145 | opts.TrustedRootCAs = x509.NewCertPool() 146 | 147 | if tc.trust { 148 | opts.TrustedRootCAs.AddCert(srv.Certificate()) 149 | } 150 | 151 | err := New(opts).Ping(t.Context()) 152 | 153 | if !tc.trust { 154 | var caErr x509.UnknownAuthorityError 155 | 156 | if err == nil || !errors.As(err, &caErr) { 157 | t.Errorf("Ping() should report bad X.509 CA, got: %v", err) 158 | } 159 | } else if err != nil { 160 | t.Errorf("Ping() failed: %v", err) 161 | } 162 | }) 163 | } 164 | } 165 | 166 | func TestWrapResponse(t *testing.T) { 167 | for _, tc := range []struct { 168 | name string 169 | resp *resty.Response 170 | }{ 171 | { 172 | name: "nil", 173 | }, 174 | { 175 | name: "response", 176 | resp: &resty.Response{}, 177 | }, 178 | } { 179 | t.Run(tc.name, func(t *testing.T) { 180 | wrapResponse(tc.resp) 181 | }) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /pkg/client/color.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "image/color" 7 | ) 8 | 9 | type Color struct { 10 | R, G, B uint8 11 | } 12 | 13 | var _ json.Marshaler = (*Color)(nil) 14 | var _ json.Unmarshaler = (*Color)(nil) 15 | var _ color.Color = (*Color)(nil) 16 | 17 | // NewColor converts any color implementing the [color.Color] interface. 18 | func NewColor(src color.Color) Color { 19 | nrgb := color.NRGBAModel.Convert(src).(color.NRGBA) 20 | 21 | return Color{ 22 | R: nrgb.R, 23 | G: nrgb.G, 24 | B: nrgb.B, 25 | } 26 | } 27 | 28 | func (c Color) MarshalJSON() ([]byte, error) { 29 | return json.Marshal(fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B)) 30 | } 31 | 32 | func (c *Color) UnmarshalJSON(data []byte) error { 33 | var str *string 34 | 35 | if err := json.Unmarshal(data, &str); err != nil { 36 | return err 37 | } 38 | 39 | if str == nil { 40 | *c = Color{} 41 | return nil 42 | } 43 | 44 | if len(*str) == 7 && (*str)[0] == '#' { 45 | var r, g, b uint8 46 | 47 | if n, err := fmt.Sscanf(*str, "#%02x%02x%02x", &r, &g, &b); err == nil && n == 3 { 48 | c.R = r 49 | c.G = g 50 | c.B = b 51 | return nil 52 | } 53 | } 54 | 55 | return fmt.Errorf("unrecognized color format: %s", *str) 56 | } 57 | 58 | // RGBA implements [color.Color.RGBA]. 59 | func (c Color) RGBA() (r, g, b, a uint32) { 60 | r = uint32(c.R) 61 | r |= r << 8 62 | g = uint32(c.G) 63 | g |= g << 8 64 | b = uint32(c.B) 65 | b |= b << 8 66 | a = 0xFFFF 67 | return 68 | } 69 | -------------------------------------------------------------------------------- /pkg/client/color_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "image/color" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func TestNewColor(t *testing.T) { 12 | for _, tc := range []struct { 13 | name string 14 | input color.Color 15 | want Color 16 | }{ 17 | { 18 | name: "black", 19 | input: color.Black, 20 | }, 21 | { 22 | name: "white", 23 | input: color.White, 24 | want: Color{0xff, 0xff, 0xff}, 25 | }, 26 | { 27 | name: "transparent", 28 | input: color.Transparent, 29 | }, 30 | { 31 | name: "opaque", 32 | input: color.Opaque, 33 | want: Color{0xff, 0xff, 0xff}, 34 | }, 35 | { 36 | name: "red from rgba", 37 | input: color.RGBA{R: 0xff, A: 0xff}, 38 | want: Color{0xff, 0, 0}, 39 | }, 40 | { 41 | name: "green from nrgba", 42 | input: color.RGBA{G: 0xff, A: 0xff}, 43 | want: Color{0, 0xff, 0}, 44 | }, 45 | { 46 | name: "semi-opaque blue from rgba", 47 | input: color.RGBA{B: 0x22, A: 0x7f}, 48 | want: Color{0, 0, 0x44}, 49 | }, 50 | } { 51 | t.Run(tc.name, func(t *testing.T) { 52 | got := NewColor(tc.input) 53 | 54 | if diff := cmp.Diff(tc.want, got); diff != "" { 55 | t.Errorf("Value diff (-want +got):\n%s", diff) 56 | } 57 | 58 | roundtrip := NewColor(got) 59 | 60 | if diff := cmp.Diff(tc.want, roundtrip); diff != "" { 61 | t.Errorf("Roundtrip diff (-want +got):\n%s", diff) 62 | } 63 | }) 64 | } 65 | } 66 | 67 | func TestColorJSON(t *testing.T) { 68 | for _, tc := range []struct { 69 | name string 70 | value Color 71 | want string 72 | }{ 73 | { 74 | name: "zero", 75 | want: `"#000000"`, 76 | }, 77 | { 78 | name: "white", 79 | value: Color{0xFF, 0xFF, 0xFF}, 80 | want: `"#ffffff"`, 81 | }, 82 | { 83 | name: "red", 84 | value: Color{R: 0xFF}, 85 | want: `"#ff0000"`, 86 | }, 87 | { 88 | name: "blue", 89 | value: Color{B: 0xFF}, 90 | want: `"#0000ff"`, 91 | }, 92 | } { 93 | t.Run(tc.name, func(t *testing.T) { 94 | got, err := json.Marshal(tc.value) 95 | if err != nil { 96 | t.Errorf("Marshalling failed: %v", err) 97 | } 98 | 99 | if diff := cmp.Diff(tc.want, string(got)); diff != "" { 100 | t.Errorf("Marshalled value diff (-want +got):\n%s", diff) 101 | } 102 | 103 | var restored Color 104 | 105 | if err := json.Unmarshal(got, &restored); err != nil { 106 | t.Errorf("Unmarshalling failed: %v", err) 107 | } 108 | 109 | if diff := cmp.Diff(tc.value, restored); diff != "" { 110 | t.Errorf("Value diff (-want +got):\n%s", diff) 111 | } 112 | }) 113 | } 114 | } 115 | 116 | func TestColorRGBA(t *testing.T) { 117 | for _, tc := range []struct { 118 | name string 119 | input Color 120 | want color.Color 121 | }{ 122 | {name: "black", want: color.Black}, 123 | {name: "white", input: Color{0xff, 0xff, 0xff}, want: color.White}, 124 | } { 125 | t.Run(tc.name, func(t *testing.T) { 126 | model := color.NRGBAModel 127 | 128 | if diff := cmp.Diff(model.Convert(tc.want), model.Convert(tc.input)); diff != "" { 129 | t.Errorf("Value diff (-want +got):\n%s", diff) 130 | } 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /pkg/client/correspondent.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func (c *Client) correspondentCrudOpts() crudOptions { 8 | return crudOptions{ 9 | base: "api/correspondents/", 10 | newRequest: c.newRequest, 11 | getID: func(v any) int64 { 12 | return v.(Correspondent).ID 13 | }, 14 | setPage: func(opts any, page *PageToken) { 15 | opts.(*ListCorrespondentsOptions).Page = page 16 | }, 17 | } 18 | } 19 | 20 | type ListCorrespondentsOptions struct { 21 | ListOptions 22 | 23 | Ordering OrderingSpec `url:"ordering"` 24 | Owner IntFilterSpec `url:"owner"` 25 | Name CharFilterSpec `url:"name"` 26 | } 27 | 28 | func (c *Client) ListCorrespondents(ctx context.Context, opts ListCorrespondentsOptions) ([]Correspondent, *Response, error) { 29 | return crudList[Correspondent](ctx, c.correspondentCrudOpts(), opts) 30 | } 31 | 32 | // ListAllCorrespondents iterates over all correspondents matching the filters 33 | // specified in opts, invoking handler for each. 34 | func (c *Client) ListAllCorrespondents(ctx context.Context, opts ListCorrespondentsOptions, handler func(context.Context, Correspondent) error) error { 35 | return crudListAll[Correspondent](ctx, c.correspondentCrudOpts(), opts, handler) 36 | } 37 | 38 | func (c *Client) GetCorrespondent(ctx context.Context, id int64) (*Correspondent, *Response, error) { 39 | return crudGet[Correspondent](ctx, c.correspondentCrudOpts(), id) 40 | } 41 | 42 | func (c *Client) CreateCorrespondent(ctx context.Context, data *CorrespondentFields) (*Correspondent, *Response, error) { 43 | return crudCreate[Correspondent](ctx, c.correspondentCrudOpts(), data) 44 | } 45 | 46 | func (c *Client) UpdateCorrespondent(ctx context.Context, id int64, data *Correspondent) (*Correspondent, *Response, error) { 47 | return crudUpdate[Correspondent](ctx, c.correspondentCrudOpts(), id, data) 48 | } 49 | 50 | func (c *Client) PatchCorrespondent(ctx context.Context, id int64, data *CorrespondentFields) (*Correspondent, *Response, error) { 51 | return crudPatch[Correspondent](ctx, c.correspondentCrudOpts(), id, data) 52 | } 53 | 54 | func (c *Client) DeleteCorrespondent(ctx context.Context, id int64) (*Response, error) { 55 | return crudDelete[Correspondent](ctx, c.correspondentCrudOpts(), id) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/client/crud.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/go-resty/resty/v2" 11 | "github.com/google/go-querystring/query" 12 | "golang.org/x/sync/errgroup" 13 | ) 14 | 15 | type listResult[T any] struct { 16 | // Total item count. 17 | Count *json.Number `json:"count"` 18 | 19 | // URL for next page (if any). 20 | Next string `json:"next"` 21 | 22 | // URL for previous page (if any). 23 | Previous string `json:"previous"` 24 | 25 | Items []T `json:"results"` 26 | } 27 | 28 | type crudOptions struct { 29 | newRequest func(context.Context) *resty.Request 30 | base string 31 | getID func(any) int64 32 | setPage func(any, *PageToken) 33 | } 34 | 35 | func crudList[T, O any](ctx context.Context, opts crudOptions, listOpts O) ([]T, *Response, error) { 36 | req := opts.newRequest(ctx).SetResult(new(listResult[T])) 37 | 38 | var pageNumber int 39 | 40 | if values, err := query.Values(listOpts); err != nil { 41 | return nil, nil, err 42 | } else { 43 | req.SetQueryParamsFromValues(values) 44 | 45 | // listOpts has a property with the page number. Getting its value 46 | // directly would require reflection. 47 | if page := values.Get("page"); page != "" { 48 | if pageNumber, err = strconv.Atoi(page); err != nil { 49 | return nil, nil, err 50 | } 51 | } 52 | } 53 | 54 | resp, err := req.Get(opts.base) 55 | 56 | // Items modified or deleted during iteration can cause a received page 57 | // number to become unavailable. Treat the situation as an empty result 58 | // set. 59 | if err == nil && resp.StatusCode() == http.StatusNotFound && pageNumber > 1 { 60 | return nil, wrapResponse(resp), nil 61 | } 62 | 63 | if err := convertError(err, resp); err != nil { 64 | return nil, wrapResponse(resp), err 65 | } 66 | 67 | results := resp.Result().(*listResult[T]) 68 | 69 | w := wrapResponse(resp) 70 | 71 | if results.Count != nil { 72 | if w.ItemCount, err = results.Count.Int64(); err != nil { 73 | return nil, nil, fmt.Errorf("parsing item count: %w", err) 74 | } 75 | } 76 | 77 | if w.NextPage, err = pageTokenFromURL(results.Next); err != nil { 78 | return nil, nil, err 79 | } 80 | 81 | if w.PrevPage, err = pageTokenFromURL(results.Previous); err != nil { 82 | return nil, nil, err 83 | } 84 | 85 | return results.Items, w, nil 86 | } 87 | 88 | func crudListAll[T, O any](ctx context.Context, opts crudOptions, listOpts O, handler func(context.Context, T) error) error { 89 | queue := make(chan []T, 2) 90 | 91 | g, ctx := errgroup.WithContext(ctx) 92 | g.Go(func() error { 93 | defer close(queue) 94 | 95 | for { 96 | items, resp, err := crudList[T](ctx, opts, listOpts) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | select { 102 | case queue <- items: 103 | case <-ctx.Done(): 104 | return ctx.Err() 105 | } 106 | 107 | if resp == nil || resp.NextPage == nil { 108 | break 109 | } 110 | 111 | opts.setPage(&listOpts, resp.NextPage) 112 | } 113 | 114 | return nil 115 | }) 116 | 117 | g.Go(func() error { 118 | seen := map[int64]struct{}{} 119 | 120 | for items := range queue { 121 | for _, i := range items { 122 | key := opts.getID(i) 123 | 124 | if _, ok := seen[key]; ok { 125 | // Duplicates may be returned when items are added, 126 | // modified or deleted during iteration. 127 | continue 128 | } 129 | 130 | seen[key] = struct{}{} 131 | 132 | if err := handler(ctx, i); err != nil { 133 | return err 134 | } 135 | } 136 | } 137 | 138 | return nil 139 | }) 140 | 141 | return g.Wait() 142 | } 143 | 144 | func crudGet[T any](ctx context.Context, opts crudOptions, id int64) (*T, *Response, error) { 145 | resp, err := opts.newRequest(ctx). 146 | SetResult(new(T)). 147 | Get(fmt.Sprintf("%s%d/", opts.base, id)) 148 | 149 | if err := convertError(err, resp); err != nil { 150 | return nil, wrapResponse(resp), err 151 | } 152 | 153 | return resp.Result().(*T), wrapResponse(resp), nil 154 | } 155 | 156 | func crudCreate[T any](ctx context.Context, opts crudOptions, data any) (*T, *Response, error) { 157 | resp, err := opts.newRequest(ctx). 158 | SetResult(new(T)). 159 | SetBody(data). 160 | Post(opts.base) 161 | 162 | err = convertError(err, resp) 163 | 164 | if detail, ok := err.(*RequestError); ok && detail.StatusCode == http.StatusCreated { 165 | return resp.Result().(*T), wrapResponse(resp), nil 166 | } 167 | 168 | if err == nil { 169 | err = &RequestError{ 170 | StatusCode: resp.StatusCode(), 171 | Message: fmt.Sprintf("unexpected status %s", resp.Status()), 172 | } 173 | } 174 | 175 | return nil, wrapResponse(resp), err 176 | } 177 | 178 | func crudUpdate[T any](ctx context.Context, opts crudOptions, id int64, data *T) (*T, *Response, error) { 179 | resp, err := opts.newRequest(ctx). 180 | SetResult(new(T)). 181 | SetBody(*data). 182 | Put(fmt.Sprintf("%s%d/", opts.base, id)) 183 | 184 | if err := convertError(err, resp); err != nil { 185 | return nil, wrapResponse(resp), err 186 | } 187 | 188 | return resp.Result().(*T), wrapResponse(resp), nil 189 | } 190 | 191 | func crudPatch[T any](ctx context.Context, opts crudOptions, id int64, data any) (*T, *Response, error) { 192 | resp, err := opts.newRequest(ctx). 193 | SetResult(new(T)). 194 | SetBody(data). 195 | Patch(fmt.Sprintf("%s%d/", opts.base, id)) 196 | 197 | if err := convertError(err, resp); err != nil { 198 | return nil, wrapResponse(resp), err 199 | } 200 | 201 | return resp.Result().(*T), wrapResponse(resp), nil 202 | } 203 | 204 | func crudDelete[T any](ctx context.Context, opts crudOptions, id int64) (*Response, error) { 205 | resp, err := opts.newRequest(ctx). 206 | Delete(fmt.Sprintf("%s%d/", opts.base, id)) 207 | 208 | return wrapResponse(resp), convertError(err, resp) 209 | } 210 | -------------------------------------------------------------------------------- /pkg/client/customfield.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func (c *Client) customFieldCrudOpts() crudOptions { 8 | return crudOptions{ 9 | base: "api/custom_fields/", 10 | newRequest: c.newRequest, 11 | getID: func(v any) int64 { 12 | return v.(CustomField).ID 13 | }, 14 | setPage: func(opts any, page *PageToken) { 15 | opts.(*ListCustomFieldsOptions).Page = page 16 | }, 17 | } 18 | } 19 | 20 | type ListCustomFieldsOptions struct { 21 | ListOptions 22 | } 23 | 24 | func (c *Client) ListCustomFields(ctx context.Context, opts ListCustomFieldsOptions) ([]CustomField, *Response, error) { 25 | return crudList[CustomField](ctx, c.customFieldCrudOpts(), opts) 26 | } 27 | 28 | // ListAllCustomFields iterates over all custom fields matching the filters 29 | // specified in opts, invoking handler for each. 30 | func (c *Client) ListAllCustomFields(ctx context.Context, opts ListCustomFieldsOptions, handler func(context.Context, CustomField) error) error { 31 | return crudListAll[CustomField](ctx, c.customFieldCrudOpts(), opts, handler) 32 | } 33 | 34 | func (c *Client) GetCustomField(ctx context.Context, id int64) (*CustomField, *Response, error) { 35 | return crudGet[CustomField](ctx, c.customFieldCrudOpts(), id) 36 | } 37 | 38 | func (c *Client) CreateCustomField(ctx context.Context, data *CustomFieldFields) (*CustomField, *Response, error) { 39 | return crudCreate[CustomField](ctx, c.customFieldCrudOpts(), data) 40 | } 41 | 42 | func (c *Client) UpdateCustomField(ctx context.Context, id int64, data *CustomField) (*CustomField, *Response, error) { 43 | return crudUpdate[CustomField](ctx, c.customFieldCrudOpts(), id, data) 44 | } 45 | 46 | func (c *Client) PatchCustomField(ctx context.Context, id int64, data *CustomFieldFields) (*CustomField, *Response, error) { 47 | return crudPatch[CustomField](ctx, c.customFieldCrudOpts(), id, data) 48 | } 49 | 50 | func (c *Client) DeleteCustomField(ctx context.Context, id int64) (*Response, error) { 51 | return crudDelete[CustomField](ctx, c.customFieldCrudOpts(), id) 52 | } 53 | 54 | type CustomFieldInstance struct { 55 | Field int64 `json:"field"` 56 | Value any `json:"value"` 57 | } 58 | -------------------------------------------------------------------------------- /pkg/client/doc.go: -------------------------------------------------------------------------------- 1 | // Package client implements the [REST API] exposed by [Paperless-ngx]. 2 | // Paperless-ngx is a document management system transforming physical 3 | // documents into a searchable online archive. 4 | // 5 | // # Authentication 6 | // 7 | // Multiple [authentication schemes] are supported: 8 | // 9 | // - [UsernamePasswordAuth]: HTTP basic authentication. 10 | // - [TokenAuth]: Paperless-ngx API authentication tokens. 11 | // - [GCPServiceAccountKeyAuth]: OpenID Connect (OIDC) using a Google Cloud 12 | // Platform service account. 13 | // 14 | // # Pagination 15 | // 16 | // APIs returning lists of items support pagination (e.g. 17 | // [Client.ListDocuments]). The [ListOptions] struct embedded in the 18 | // API-specific options supports specifying the page to request. Pagination 19 | // tokens are available via the [Response] struct. 20 | // 21 | // [REST API]: https://docs.paperless-ngx.com/api/ 22 | // [Paperless-ngx]: https://docs.paperless-ngx.com/ 23 | // [authentication schemes]: https://docs.paperless-ngx.com/api/#authorization 24 | package client 25 | -------------------------------------------------------------------------------- /pkg/client/document.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/google/go-querystring/query" 11 | ) 12 | 13 | type DocumentVersionMetadata struct { 14 | Namespace string `json:"namespace"` 15 | Prefix string `json:"prefix"` 16 | Key string `json:"key"` 17 | Value string `json:"value"` 18 | } 19 | 20 | type DocumentMetadata struct { 21 | OriginalFilename string `json:"original_filename"` 22 | OriginalMediaFilename string `json:"media_filename"` 23 | OriginalChecksum string `json:"original_checksum"` 24 | OriginalSize int64 `json:"original_size"` 25 | OriginalMimeType string `json:"original_mime_type"` 26 | OriginalMetadata []DocumentVersionMetadata `json:"original_metadata"` 27 | 28 | HasArchiveVersion bool `json:"has_archive_version"` 29 | ArchiveMediaFilename string `json:"archive_media_filename"` 30 | ArchiveChecksum string `json:"archive_checksum"` 31 | ArchiveSize int64 `json:"archive_size"` 32 | ArchiveMetadata []DocumentVersionMetadata `json:"archive_metadata"` 33 | 34 | Language string `json:"lang"` 35 | } 36 | 37 | func (c *Client) documentCrudOpts() crudOptions { 38 | return crudOptions{ 39 | base: "api/documents/", 40 | newRequest: c.newRequest, 41 | getID: func(v any) int64 { 42 | return v.(Document).ID 43 | }, 44 | setPage: func(opts any, page *PageToken) { 45 | opts.(*ListDocumentsOptions).Page = page 46 | }, 47 | } 48 | } 49 | 50 | type ListDocumentsOptions struct { 51 | ListOptions 52 | 53 | Ordering OrderingSpec `url:"ordering"` 54 | Owner IntFilterSpec `url:"owner"` 55 | Title CharFilterSpec `url:"title"` 56 | Content CharFilterSpec `url:"content"` 57 | ArchiveSerialNumber IntFilterSpec `url:"archive_serial_number"` 58 | Created DateTimeFilterSpec `url:"created"` 59 | Added DateTimeFilterSpec `url:"added"` 60 | Modified DateTimeFilterSpec `url:"modified"` 61 | Correspondent ForeignKeyFilterSpec `url:"correspondent"` 62 | Tags ForeignKeyFilterSpec `url:"tags"` 63 | DocumentType ForeignKeyFilterSpec `url:"document_type"` 64 | StoragePath ForeignKeyFilterSpec `url:"storage_path"` 65 | } 66 | 67 | func (c *Client) ListDocuments(ctx context.Context, opts ListDocumentsOptions) ([]Document, *Response, error) { 68 | return crudList[Document](ctx, c.documentCrudOpts(), opts) 69 | } 70 | 71 | // ListAllDocuments iterates over all documents matching the filters specified 72 | // in opts, invoking handler for each. 73 | func (c *Client) ListAllDocuments(ctx context.Context, opts ListDocumentsOptions, handler func(context.Context, Document) error) error { 74 | return crudListAll[Document](ctx, c.documentCrudOpts(), opts, handler) 75 | } 76 | 77 | func (c *Client) GetDocument(ctx context.Context, id int64) (*Document, *Response, error) { 78 | return crudGet[Document](ctx, c.documentCrudOpts(), id) 79 | } 80 | 81 | func (c *Client) UpdateDocument(ctx context.Context, id int64, data *Document) (*Document, *Response, error) { 82 | return crudUpdate[Document](ctx, c.documentCrudOpts(), id, data) 83 | } 84 | 85 | func (c *Client) PatchDocument(ctx context.Context, id int64, data *DocumentFields) (*Document, *Response, error) { 86 | return crudPatch[Document](ctx, c.documentCrudOpts(), id, data) 87 | } 88 | 89 | func (c *Client) DeleteDocument(ctx context.Context, id int64) (*Response, error) { 90 | return crudDelete[Document](ctx, c.documentCrudOpts(), id) 91 | } 92 | 93 | func (c *Client) GetDocumentMetadata(ctx context.Context, id int64) (*DocumentMetadata, *Response, error) { 94 | resp, err := c.newRequest(ctx). 95 | SetResult(DocumentMetadata{}). 96 | Get(fmt.Sprintf("api/documents/%d/metadata/", id)) 97 | 98 | if err := convertError(err, resp); err != nil { 99 | return nil, wrapResponse(resp), err 100 | } 101 | 102 | return resp.Result().(*DocumentMetadata), wrapResponse(resp), nil 103 | } 104 | 105 | type DocumentUploadOptions struct { 106 | Filename string `url:"-"` 107 | 108 | // Title for the document. 109 | Title string `url:"title,omitempty"` 110 | 111 | // Datetime at which the document was created. 112 | Created time.Time `url:"created,omitempty"` 113 | 114 | // ID of a correspondent for the document. 115 | Correspondent *int64 `url:"correspondent,omitempty"` 116 | 117 | // ID of a document type for the document. 118 | DocumentType *int64 `url:"document_type,omitempty"` 119 | 120 | // ID of a storage path for the document. 121 | StoragePath *int64 `url:"storage_path,omitempty"` 122 | 123 | // Tag IDs for the document. 124 | Tags []int64 `url:"tags,omitempty"` 125 | 126 | // Archive serial number to set on the document. 127 | ArchiveSerialNumber *int64 `url:"archive_serial_number,omitempty"` 128 | } 129 | 130 | type DocumentUpload struct { 131 | TaskID string 132 | } 133 | 134 | // Upload a file. Returns immediately and without error if the document 135 | // consumption process was started successfully. No additional status 136 | // information about the consumption process is available immediately. Poll the 137 | // returned task ID to wait for the consumption. 138 | func (c *Client) UploadDocument(ctx context.Context, r io.Reader, opts DocumentUploadOptions) (*DocumentUpload, *Response, error) { 139 | result := &DocumentUpload{} 140 | 141 | req := c.newRequest(ctx). 142 | SetResult(&result.TaskID). 143 | SetFileReader("document", filepath.Base(opts.Filename), r) 144 | 145 | if values, err := query.Values(opts); err != nil { 146 | return nil, nil, err 147 | } else { 148 | req.SetFormDataFromValues(values) 149 | } 150 | 151 | resp, err := req.Post("api/documents/post_document/") 152 | 153 | if err := convertError(err, resp); err != nil { 154 | return nil, wrapResponse(resp), err 155 | } 156 | 157 | return result, wrapResponse(resp), nil 158 | } 159 | -------------------------------------------------------------------------------- /pkg/client/document_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/google/go-cmp/cmp" 13 | "github.com/google/go-cmp/cmp/cmpopts" 14 | "github.com/jarcoal/httpmock" 15 | ) 16 | 17 | func TestGetDocumentMetadata(t *testing.T) { 18 | for _, tc := range []struct { 19 | name string 20 | setup func(*testing.T, *httpmock.MockTransport) 21 | id int64 22 | want *DocumentMetadata 23 | wantErr error 24 | }{ 25 | { 26 | name: "success", 27 | setup: func(t *testing.T, transport *httpmock.MockTransport) { 28 | transport.RegisterResponder(http.MethodGet, "/api/documents/7124/metadata/", 29 | httpmock.NewStringResponder(http.StatusOK, `{ 30 | "original_mime_type": "text/plain" 31 | }`)) 32 | }, 33 | id: 7124, 34 | want: &DocumentMetadata{ 35 | OriginalMimeType: "text/plain", 36 | }, 37 | }, 38 | { 39 | name: "error", 40 | setup: func(t *testing.T, transport *httpmock.MockTransport) { 41 | transport.RegisterResponder(http.MethodGet, "/api/documents/25650/metadata/", 42 | httpmock.NewStringResponder(http.StatusTeapot, `{ "detail": "wrong" }`)) 43 | }, 44 | id: 25650, 45 | wantErr: &RequestError{ 46 | StatusCode: http.StatusTeapot, 47 | Message: `{"detail":"wrong"}`, 48 | }, 49 | }, 50 | } { 51 | t.Run(tc.name, func(t *testing.T) { 52 | transport := newMockTransport(t) 53 | 54 | tc.setup(t, transport) 55 | 56 | c := New(Options{ 57 | transport: transport, 58 | }) 59 | 60 | got, _, err := c.GetDocumentMetadata(context.Background(), tc.id) 61 | 62 | if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { 63 | t.Errorf("GetDocumentMetadata() error diff (-want +got):\n%s", diff) 64 | } 65 | 66 | if err == nil { 67 | if diff := cmp.Diff(tc.want, got, cmpopts.EquateEmpty()); diff != "" { 68 | t.Errorf("GetDocumentMetadata() result diff (-want +got):\n%s", diff) 69 | } 70 | } 71 | }) 72 | } 73 | } 74 | 75 | func TestUploadDocument(t *testing.T) { 76 | for _, tc := range []struct { 77 | name string 78 | setup func(*testing.T, *httpmock.MockTransport) 79 | r io.Reader 80 | opts DocumentUploadOptions 81 | want *DocumentUpload 82 | wantErr error 83 | }{ 84 | { 85 | name: "success", 86 | setup: func(t *testing.T, transport *httpmock.MockTransport) { 87 | transport.RegisterResponder(http.MethodPost, "/api/documents/post_document/", 88 | httpmock.NewStringResponder(http.StatusOK, `"e068eb08-cf70-4755-8087-3cf0644f3c7b"`)) 89 | }, 90 | r: strings.NewReader("test content"), 91 | want: &DocumentUpload{ 92 | TaskID: "e068eb08-cf70-4755-8087-3cf0644f3c7b", 93 | }, 94 | }, 95 | { 96 | name: "options", 97 | setup: func(t *testing.T, transport *httpmock.MockTransport) { 98 | transport.RegisterMatcherResponder(http.MethodPost, "/api/documents/post_document/", 99 | httpmock.BodyContainsString("\ndoctitle"), 100 | httpmock.NewStringResponder(http.StatusOK, `"0dbf0a2b-3a09-4d7b-96bf-51544dda8427"`)) 101 | }, 102 | r: strings.NewReader("more content"), 103 | opts: DocumentUploadOptions{ 104 | Filename: filepath.Join(t.TempDir(), "myfile.txt"), 105 | Title: "doctitle", 106 | Created: time.Date(2020, time.December, 31, 1, 2, 3, 0, time.UTC), 107 | Correspondent: Int64(100), 108 | DocumentType: Int64(200), 109 | StoragePath: Int64(500), 110 | Tags: []int64{300, 301, 302}, 111 | ArchiveSerialNumber: Int64(400), 112 | }, 113 | want: &DocumentUpload{ 114 | TaskID: "0dbf0a2b-3a09-4d7b-96bf-51544dda8427", 115 | }, 116 | }, 117 | { 118 | name: "error", 119 | setup: func(t *testing.T, transport *httpmock.MockTransport) { 120 | transport.RegisterResponder(http.MethodPost, "/api/documents/post_document/", 121 | httpmock.NewStringResponder(http.StatusTeapot, `{}`)) 122 | }, 123 | r: strings.NewReader(""), 124 | wantErr: &RequestError{ 125 | StatusCode: http.StatusTeapot, 126 | Message: `{}`, 127 | }, 128 | }, 129 | } { 130 | t.Run(tc.name, func(t *testing.T) { 131 | transport := newMockTransport(t) 132 | 133 | tc.setup(t, transport) 134 | 135 | c := New(Options{ 136 | transport: transport, 137 | }) 138 | 139 | got, _, err := c.UploadDocument(context.Background(), tc.r, tc.opts) 140 | 141 | if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { 142 | t.Errorf("UploadDocument() error diff (-want +got):\n%s", diff) 143 | } 144 | 145 | if err == nil { 146 | if diff := cmp.Diff(tc.want, got, cmpopts.EquateEmpty()); diff != "" { 147 | t.Errorf("UploadDocument() result diff (-want +got):\n%s", diff) 148 | } 149 | } 150 | }) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /pkg/client/documenttype.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func (c *Client) documentTypeCrudOpts() crudOptions { 8 | return crudOptions{ 9 | base: "api/document_types/", 10 | newRequest: c.newRequest, 11 | getID: func(v any) int64 { 12 | return v.(DocumentType).ID 13 | }, 14 | setPage: func(opts any, page *PageToken) { 15 | opts.(*ListDocumentTypesOptions).Page = page 16 | }, 17 | } 18 | } 19 | 20 | type ListDocumentTypesOptions struct { 21 | ListOptions 22 | 23 | Ordering OrderingSpec `url:"ordering"` 24 | Owner IntFilterSpec `url:"owner"` 25 | Name CharFilterSpec `url:"name"` 26 | } 27 | 28 | func (c *Client) ListDocumentTypes(ctx context.Context, opts ListDocumentTypesOptions) ([]DocumentType, *Response, error) { 29 | return crudList[DocumentType](ctx, c.documentTypeCrudOpts(), opts) 30 | } 31 | 32 | // ListAllDocumentTypes iterates over all document types matching the filters 33 | // specified in opts, invoking handler for each. 34 | func (c *Client) ListAllDocumentTypes(ctx context.Context, opts ListDocumentTypesOptions, handler func(context.Context, DocumentType) error) error { 35 | return crudListAll[DocumentType](ctx, c.documentTypeCrudOpts(), opts, handler) 36 | } 37 | 38 | func (c *Client) GetDocumentType(ctx context.Context, id int64) (*DocumentType, *Response, error) { 39 | return crudGet[DocumentType](ctx, c.documentTypeCrudOpts(), id) 40 | } 41 | 42 | func (c *Client) CreateDocumentType(ctx context.Context, data *DocumentTypeFields) (*DocumentType, *Response, error) { 43 | return crudCreate[DocumentType](ctx, c.documentTypeCrudOpts(), data) 44 | } 45 | 46 | func (c *Client) UpdateDocumentType(ctx context.Context, id int64, data *DocumentType) (*DocumentType, *Response, error) { 47 | return crudUpdate[DocumentType](ctx, c.documentTypeCrudOpts(), id, data) 48 | } 49 | 50 | func (c *Client) PatchDocumentType(ctx context.Context, id int64, data *DocumentTypeFields) (*DocumentType, *Response, error) { 51 | return crudPatch[DocumentType](ctx, c.documentTypeCrudOpts(), id, data) 52 | } 53 | 54 | func (c *Client) DeleteDocumentType(ctx context.Context, id int64) (*Response, error) { 55 | return crudDelete[DocumentType](ctx, c.documentTypeCrudOpts(), id) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/client/download.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "mime" 8 | "path/filepath" 9 | 10 | "go.uber.org/multierr" 11 | ) 12 | 13 | type DownloadResult struct { 14 | // MIME content type (e.g. "application/pdf"). 15 | ContentType string 16 | 17 | // Parameters for body content type (e.g. "charset"). 18 | ContentTypeParams map[string]string 19 | 20 | // The preferred filename as reported by the server (if any). 21 | Filename string 22 | 23 | // Length of the downloaded body in bytes. 24 | Length int64 25 | } 26 | 27 | func (c *Client) download(ctx context.Context, w io.Writer, url string, expectDisposition bool) (_ *DownloadResult, _ *Response, err error) { 28 | req := c.newRequest(ctx). 29 | SetDoNotParseResponse(true) 30 | 31 | resp, err := req.Get(url) 32 | 33 | if !(resp == nil || resp.RawBody() == nil) { 34 | defer multierr.AppendFunc(&err, resp.RawBody().Close) 35 | } 36 | 37 | if err := convertError(err, resp); err != nil { 38 | return nil, wrapResponse(resp), err 39 | } 40 | 41 | result := &DownloadResult{} 42 | 43 | if result.ContentType, result.ContentTypeParams, err = mime.ParseMediaType(resp.Header().Get("Content-Type")); err != nil { 44 | return nil, wrapResponse(resp), fmt.Errorf("invalid content-type header: %w", err) 45 | } 46 | 47 | if contentDisposition := resp.Header().Get("Content-Disposition"); contentDisposition == "" { 48 | if expectDisposition { 49 | c.logger.Warnf("%s: missing Content-Disposition header", req.URL) 50 | } 51 | } else if _, params, err := mime.ParseMediaType(contentDisposition); err != nil { 52 | c.logger.Warnf("%s: parsing content-disposition header failed: %w", req.URL, err) 53 | } else if filename, ok := params["filename"]; ok && filename != "" { 54 | result.Filename = filepath.Base(filepath.Clean(params["filename"])) 55 | } 56 | 57 | result.Length, err = io.Copy(w, resp.RawBody()) 58 | if err != nil { 59 | return nil, wrapResponse(resp), err 60 | } 61 | 62 | return result, wrapResponse(resp), nil 63 | } 64 | 65 | // DownloadDocumentOriginal retrieves the document in the format originally 66 | // consumed by Paperless. The file format can be determined using 67 | // [DownloadResult.ContentType]. 68 | // 69 | // The content of the document is written to the given writer. To verify that 70 | // the document is complete (the HTTP request may have been terminated early) 71 | // the size and/or checksum can be verified with [GetDocumentMetadata]. 72 | func (c *Client) DownloadDocumentOriginal(ctx context.Context, w io.Writer, id int64) (*DownloadResult, *Response, error) { 73 | return c.download(ctx, w, fmt.Sprintf("api/documents/%d/download/?original=true", id), true) 74 | } 75 | 76 | // DownloadDocumentArchived retrieves an archived PDF/A file generated from the 77 | // originally consumed file. The archived version may not be available and the 78 | // API may return the original. [DownloadDocumentOriginal] for additional 79 | // details. 80 | func (c *Client) DownloadDocumentArchived(ctx context.Context, w io.Writer, id int64) (*DownloadResult, *Response, error) { 81 | return c.download(ctx, w, fmt.Sprintf("api/documents/%d/download/", id), true) 82 | } 83 | 84 | // DownloadDocumentThumbnail retrieves a preview image of the document. See 85 | // [DownloadDocumentOriginal] for additional details. 86 | func (c *Client) DownloadDocumentThumbnail(ctx context.Context, w io.Writer, id int64) (*DownloadResult, *Response, error) { 87 | return c.download(ctx, w, fmt.Sprintf("api/documents/%d/thumb/", id), false) 88 | } 89 | -------------------------------------------------------------------------------- /pkg/client/download_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/google/go-cmp/cmp/cmpopts" 11 | "github.com/jarcoal/httpmock" 12 | ) 13 | 14 | func TestDownload(t *testing.T) { 15 | for _, tc := range []struct { 16 | name string 17 | setup func(*testing.T, *httpmock.MockTransport) 18 | url string 19 | want *DownloadResult 20 | wantErr error 21 | }{ 22 | { 23 | name: "header", 24 | setup: func(t *testing.T, transport *httpmock.MockTransport) { 25 | transport.RegisterResponder(http.MethodGet, "/test/download?foo=bar", 26 | httpmock.NewStringResponder(http.StatusOK, `content`). 27 | HeaderSet(http.Header{ 28 | "Content-Type": []string{"foo/bar; charset=utf-8"}, 29 | "Content-Disposition": []string{`inline; filename="test.txt"`}, 30 | })) 31 | }, 32 | url: "/test/download?foo=bar", 33 | want: &DownloadResult{ 34 | ContentType: "foo/bar", 35 | ContentTypeParams: map[string]string{ 36 | "charset": "utf-8", 37 | }, 38 | Filename: "test.txt", 39 | Length: 7, 40 | }, 41 | }, 42 | { 43 | name: "error", 44 | setup: func(t *testing.T, transport *httpmock.MockTransport) { 45 | transport.RegisterResponder(http.MethodGet, "/report/error", 46 | httpmock.NewStringResponder(http.StatusTeapot, ``)) 47 | }, 48 | url: "/report/error", 49 | wantErr: &RequestError{ 50 | StatusCode: http.StatusTeapot, 51 | Message: `418 I'm a teapot`, 52 | }, 53 | }, 54 | { 55 | name: "connection error", 56 | setup: func(t *testing.T, transport *httpmock.MockTransport) { 57 | transport.RegisterResponder(http.MethodGet, "/conn/error", 58 | httpmock.ConnectionFailure) 59 | }, 60 | url: "/conn/error", 61 | wantErr: cmpopts.AnyError, 62 | }, 63 | } { 64 | t.Run(tc.name, func(t *testing.T) { 65 | transport := newMockTransport(t) 66 | 67 | tc.setup(t, transport) 68 | 69 | c := New(Options{ 70 | transport: transport, 71 | }) 72 | 73 | var buf bytes.Buffer 74 | 75 | got, _, err := c.download(context.Background(), &buf, tc.url, true) 76 | 77 | if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { 78 | t.Errorf("DownloadDocument() error diff (-want +got):\n%s", diff) 79 | } 80 | 81 | if err == nil { 82 | if diff := cmp.Diff(tc.want, got, cmpopts.EquateEmpty()); diff != "" { 83 | t.Errorf("DownloadDocument() result diff (-want +got):\n%s", diff) 84 | } 85 | } 86 | }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pkg/client/error.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/go-resty/resty/v2" 10 | ) 11 | 12 | type RequestError struct { 13 | StatusCode int 14 | Message string 15 | } 16 | 17 | func (e *RequestError) Error() string { 18 | return fmt.Sprintf("HTTP status %d (%s): %s", e.StatusCode, http.StatusText(e.StatusCode), e.Message) 19 | } 20 | 21 | func (e *RequestError) Is(other error) bool { 22 | err, ok := other.(*RequestError) 23 | 24 | return ok && e.StatusCode == err.StatusCode && e.Message == err.Message 25 | } 26 | 27 | type requestError struct { 28 | json.RawMessage 29 | } 30 | 31 | func convertError(requestErr error, resp *resty.Response) error { 32 | if requestErr != nil { 33 | return requestErr 34 | } 35 | 36 | if resp.IsSuccess() { 37 | switch resp.StatusCode() { 38 | case http.StatusOK, http.StatusNoContent: 39 | return nil 40 | } 41 | } 42 | 43 | err := &RequestError{ 44 | StatusCode: resp.StatusCode(), 45 | } 46 | 47 | switch respErr := resp.Error().(type) { 48 | case *requestError: 49 | var buf bytes.Buffer 50 | 51 | if compactErr := json.Compact(&buf, respErr.RawMessage); compactErr != nil { 52 | err.Message = string(respErr.RawMessage) 53 | } else { 54 | err.Message = buf.String() 55 | } 56 | } 57 | 58 | if err.Message == "" { 59 | err.Message = resp.Status() 60 | } 61 | 62 | if err.Message == "" { 63 | err.Message = "unknown error" 64 | } 65 | 66 | return err 67 | } 68 | -------------------------------------------------------------------------------- /pkg/client/fields.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "golang.org/x/exp/maps" 7 | ) 8 | 9 | type objectFields map[string]any 10 | 11 | func (f objectFields) MarshalJSON() ([]byte, error) { 12 | return json.Marshal(map[string]any(f)) 13 | } 14 | 15 | // AsMap returns a map from object field name to value. 16 | func (f objectFields) AsMap() map[string]any { 17 | return maps.Clone(f) 18 | } 19 | 20 | func (f objectFields) set(name string, value any) { 21 | f[name] = value 22 | } 23 | 24 | func (f objectFields) build() map[string]any { 25 | return f 26 | } 27 | -------------------------------------------------------------------------------- /pkg/client/filter.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "net/url" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/google/go-querystring/query" 9 | ) 10 | 11 | // OrderingSpec controls the sorting order for lists. 12 | type OrderingSpec struct { 13 | // Field name, e.g. "created". 14 | Field string 15 | 16 | // Set to true for descending order. Ascending is the default. 17 | Desc bool 18 | } 19 | 20 | var _ query.Encoder = (*OrderingSpec)(nil) 21 | 22 | func (o OrderingSpec) EncodeValues(key string, v *url.Values) error { 23 | if o.Field != "" { 24 | v.Set(key, map[bool]string{ 25 | false: "", 26 | true: "-", 27 | }[o.Desc]+o.Field) 28 | } 29 | 30 | return nil 31 | } 32 | 33 | // CharFilterSpec contains filters available on character/string fields. All 34 | // comparison are case-insensitive. 35 | type CharFilterSpec struct { 36 | EqualsIgnoringCase *string 37 | StartsWithIgnoringCase *string 38 | EndsWithIgnoringCase *string 39 | ContainsIgnoringCase *string 40 | } 41 | 42 | var _ query.Encoder = (*CharFilterSpec)(nil) 43 | 44 | func (s CharFilterSpec) EncodeValues(key string, v *url.Values) error { 45 | for suffix, value := range map[string]*string{ 46 | "iexact": s.EqualsIgnoringCase, 47 | "istartswith": s.StartsWithIgnoringCase, 48 | "iendswith": s.EndsWithIgnoringCase, 49 | "icontains": s.ContainsIgnoringCase, 50 | } { 51 | if !(value == nil || *value == "") { 52 | v.Set(key+"__"+suffix, *value) 53 | } 54 | } 55 | 56 | return nil 57 | } 58 | 59 | // IntFilterSpec contains filters available on numeric fields. 60 | type IntFilterSpec struct { 61 | Equals *int64 62 | Gt *int64 63 | Gte *int64 64 | Lt *int64 65 | Lte *int64 66 | IsNull *bool 67 | } 68 | 69 | var _ query.Encoder = (*IntFilterSpec)(nil) 70 | 71 | func (s IntFilterSpec) EncodeValues(key string, v *url.Values) error { 72 | for suffix, value := range map[string]*int64{ 73 | "exact": s.Equals, 74 | "gt": s.Gt, 75 | "gte": s.Gte, 76 | "lt": s.Lt, 77 | "lte": s.Lte, 78 | } { 79 | if value != nil { 80 | v.Set(key+"__"+suffix, strconv.FormatInt(*value, 10)) 81 | } 82 | } 83 | 84 | if s.IsNull != nil { 85 | v.Set(key+"__isnull", strconv.FormatBool(*s.IsNull)) 86 | } 87 | 88 | return nil 89 | } 90 | 91 | type ForeignKeyFilterSpec struct { 92 | IsNull *bool 93 | ID *int64 94 | Name CharFilterSpec 95 | } 96 | 97 | var _ query.Encoder = (*ForeignKeyFilterSpec)(nil) 98 | 99 | func (s ForeignKeyFilterSpec) EncodeValues(key string, v *url.Values) error { 100 | if s.IsNull != nil { 101 | v.Set(key+"__isnull", strconv.FormatBool(*s.IsNull)) 102 | } 103 | 104 | if s.ID != nil { 105 | v.Set(key+"__id", strconv.FormatInt(*s.ID, 10)) 106 | } 107 | 108 | return s.Name.EncodeValues(key+"__name", v) 109 | } 110 | 111 | type DateTimeFilterSpec struct { 112 | // Set to a non-nil value to only include newer items. 113 | Gt *time.Time 114 | 115 | // Set to a non-nil value to only include older items. 116 | Lt *time.Time 117 | } 118 | 119 | var _ query.Encoder = (*DateTimeFilterSpec)(nil) 120 | 121 | func (s DateTimeFilterSpec) EncodeValues(key string, v *url.Values) error { 122 | for suffix, value := range map[string]*time.Time{ 123 | "gt": s.Gt, 124 | "lt": s.Lt, 125 | } { 126 | if value != nil { 127 | v.Set(key+"__"+suffix, value.Format(time.RFC3339)) 128 | } 129 | } 130 | 131 | return nil 132 | } 133 | -------------------------------------------------------------------------------- /pkg/client/filter_example_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "log" 6 | ) 7 | 8 | func Example_filter() { 9 | cl := New(Options{ /* … */ }) 10 | 11 | var opt ListStoragePathsOptions 12 | 13 | opt.Ordering.Field = "name" 14 | opt.Name.ContainsIgnoringCase = String("sales") 15 | opt.Path.StartsWithIgnoringCase = String("2019/") 16 | 17 | for { 18 | got, resp, err := cl.ListStoragePaths(context.Background(), opt) 19 | if err != nil { 20 | log.Fatalf("Listing storage paths failed: %v", err) 21 | } 22 | 23 | for _, i := range got { 24 | log.Printf("%s (%d documents)", i.Name, i.DocumentCount) 25 | } 26 | 27 | if resp.NextPage == nil { 28 | break 29 | } 30 | 31 | opt.Page = resp.NextPage 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/client/filter_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | "time" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/google/go-cmp/cmp/cmpopts" 10 | "github.com/google/go-querystring/query" 11 | ) 12 | 13 | func TestSpecs(t *testing.T) { 14 | type FakeOrderBy struct { 15 | Order OrderingSpec `url:"order_by"` 16 | } 17 | 18 | type FakeChar struct { 19 | Title CharFilterSpec `url:"title"` 20 | } 21 | 22 | type FakeInt struct { 23 | Number IntFilterSpec `url:"number"` 24 | } 25 | 26 | type FakeForeignKey struct { 27 | Kind ForeignKeyFilterSpec `url:"kind"` 28 | } 29 | 30 | type FakeDateTime struct { 31 | Created DateTimeFilterSpec `url:"created"` 32 | } 33 | 34 | for _, tc := range []struct { 35 | name string 36 | value any 37 | wantErr error 38 | want url.Values 39 | }{ 40 | { 41 | name: "empty", 42 | }, 43 | { 44 | name: "ordering asc", 45 | value: FakeOrderBy{ 46 | OrderingSpec{ 47 | Field: "title", 48 | }, 49 | }, 50 | want: url.Values{ 51 | "order_by": []string{"title"}, 52 | }, 53 | }, 54 | { 55 | name: "ordering desc", 56 | value: FakeOrderBy{ 57 | OrderingSpec{ 58 | Field: "title", 59 | Desc: true, 60 | }, 61 | }, 62 | want: url.Values{ 63 | "order_by": []string{"-title"}, 64 | }, 65 | }, 66 | { 67 | name: "char iexact", 68 | value: FakeChar{ 69 | Title: CharFilterSpec{ 70 | EqualsIgnoringCase: String("xyz"), 71 | }, 72 | }, 73 | want: url.Values{ 74 | "title__iexact": []string{"xyz"}, 75 | }, 76 | }, 77 | { 78 | name: "char all", 79 | value: FakeChar{ 80 | Title: CharFilterSpec{ 81 | EqualsIgnoringCase: String("equals"), 82 | StartsWithIgnoringCase: String("startswith"), 83 | EndsWithIgnoringCase: String("endswith"), 84 | ContainsIgnoringCase: String("contains"), 85 | }, 86 | }, 87 | want: url.Values{ 88 | "title__iexact": []string{"equals"}, 89 | "title__istartswith": []string{"startswith"}, 90 | "title__iendswith": []string{"endswith"}, 91 | "title__icontains": []string{"contains"}, 92 | }, 93 | }, 94 | { 95 | name: "int", 96 | value: FakeInt{ 97 | Number: IntFilterSpec{ 98 | Equals: Int64(300), 99 | Gt: Int64(400), 100 | Gte: Int64(401), 101 | Lt: Int64(500), 102 | Lte: Int64(501), 103 | IsNull: Bool(false), 104 | }, 105 | }, 106 | want: url.Values{ 107 | "number__exact": []string{"300"}, 108 | "number__gt": []string{"400"}, 109 | "number__gte": []string{"401"}, 110 | "number__lt": []string{"500"}, 111 | "number__lte": []string{"501"}, 112 | "number__isnull": []string{"false"}, 113 | }, 114 | }, 115 | { 116 | name: "foreign key", 117 | value: FakeForeignKey{ 118 | Kind: ForeignKeyFilterSpec{ 119 | ID: Int64(123), 120 | IsNull: Bool(true), 121 | Name: CharFilterSpec{ 122 | EqualsIgnoringCase: String("equals"), 123 | StartsWithIgnoringCase: String("startswith"), 124 | EndsWithIgnoringCase: String("endswith"), 125 | ContainsIgnoringCase: String("contains"), 126 | }, 127 | }, 128 | }, 129 | want: url.Values{ 130 | "kind__id": []string{"123"}, 131 | "kind__isnull": []string{"true"}, 132 | "kind__name__iexact": []string{"equals"}, 133 | "kind__name__istartswith": []string{"startswith"}, 134 | "kind__name__iendswith": []string{"endswith"}, 135 | "kind__name__icontains": []string{"contains"}, 136 | }, 137 | }, 138 | { 139 | name: "datetime", 140 | value: FakeDateTime{ 141 | Created: DateTimeFilterSpec{ 142 | Lt: Time(time.Date(2015, time.March, 7, 1, 2, 3, 0, time.UTC)), 143 | Gt: Time(time.Date(2018, time.July, 9, 4, 5, 6, 0, time.UTC)), 144 | }, 145 | }, 146 | want: url.Values{ 147 | "created__lt": []string{"2015-03-07T01:02:03Z"}, 148 | "created__gt": []string{"2018-07-09T04:05:06Z"}, 149 | }, 150 | }, 151 | } { 152 | t.Run(tc.name, func(t *testing.T) { 153 | got, err := query.Values(tc.value) 154 | 155 | if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { 156 | t.Errorf("query.Values() error diff (-want +got):\n%s", diff) 157 | } 158 | 159 | if err == nil { 160 | if diff := cmp.Diff(tc.want, got, cmpopts.EquateEmpty()); diff != "" { 161 | t.Errorf("Encoded query diff (-want +got):\n%s", diff) 162 | } 163 | } 164 | }) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /pkg/client/flags.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "crypto/x509" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "os" 10 | "time" 11 | "unicode" 12 | ) 13 | 14 | func readFile(name string) (string, error) { 15 | content, err := os.ReadFile(name) 16 | if err != nil { 17 | return "", err 18 | } 19 | 20 | return string(bytes.TrimRightFunc(content, unicode.IsSpace)), nil 21 | } 22 | 23 | // Flags contains attributes to construct a Paperless client instance. The 24 | // separate "kpflag" package implements bindings for 25 | // [github.com/alecthomas/kingpin/v2]. 26 | type Flags struct { 27 | // Whether to enable verbose log messages. 28 | DebugMode bool 29 | 30 | // HTTP(S) URL for Paperless. 31 | BaseURL string 32 | 33 | // Read the set of PEM-formatted X.509 certificate authorities the client 34 | // uses when verifying server certificates from files. If empty the 35 | // system's default trust store is used. 36 | TrustedRootCAFiles []string 37 | 38 | // Number of concurrent requests allowed to be in flight. 39 | MaxConcurrentRequests int 40 | 41 | // Authenticate via token. 42 | AuthToken string 43 | 44 | // Read the authentication token from a file. 45 | AuthTokenFile string 46 | 47 | // Authenticate via HTTP basic authentication (username and password). 48 | AuthUsername string 49 | AuthPassword string 50 | 51 | // Read the password from a file. 52 | AuthPasswordFile string 53 | 54 | // Authenticate using OpenID Connect (OIDC) ID tokens derived from a Google 55 | // Cloud Platform service account key file. 56 | AuthGCPServiceAccountKeyFile string 57 | 58 | // Target audience for OpenID Connect (OIDC) ID tokens. May be left empty, 59 | // in which case the Paperless URL is used verbatim. 60 | AuthOIDCIDTokenAudience string 61 | 62 | // HTTP headers to set on all requests. 63 | Header http.Header 64 | 65 | // Timezone for parsing timestamps without offset. 66 | ServerTimezone string 67 | } 68 | 69 | func (f *Flags) buildTrustedRootCAPool() (*x509.CertPool, error) { 70 | if len(f.TrustedRootCAFiles) == 0 { 71 | return nil, nil 72 | } 73 | 74 | pool := x509.NewCertPool() 75 | 76 | for _, path := range f.TrustedRootCAFiles { 77 | content, err := os.ReadFile(path) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | pool.AppendCertsFromPEM(content) 83 | } 84 | 85 | return pool, nil 86 | } 87 | 88 | // This function makes no attempt to deconflict different authentication 89 | // options. Tokens from a files are preferred. 90 | func (f *Flags) buildAuth() (AuthMechanism, error) { 91 | var err error 92 | 93 | token := f.AuthToken 94 | 95 | if f.AuthTokenFile != "" { 96 | token, err = readFile(f.AuthTokenFile) 97 | if err != nil { 98 | return nil, fmt.Errorf("reading authentication token failed: %w", err) 99 | } 100 | } 101 | 102 | if token != "" { 103 | return &TokenAuth{token}, nil 104 | } 105 | 106 | if f.AuthUsername != "" { 107 | password := f.AuthPassword 108 | 109 | if f.AuthPasswordFile != "" { 110 | password, err = readFile(f.AuthPasswordFile) 111 | if err != nil { 112 | return nil, fmt.Errorf("reading password failed: %w", err) 113 | } 114 | } 115 | 116 | return &UsernamePasswordAuth{ 117 | Username: f.AuthUsername, 118 | Password: password, 119 | }, nil 120 | } 121 | 122 | if f.AuthGCPServiceAccountKeyFile != "" { 123 | a, err := GCPServiceAccountKeyAuth{ 124 | KeyFile: f.AuthGCPServiceAccountKeyFile, 125 | Audience: f.AuthOIDCIDTokenAudience, 126 | }.Build() 127 | if err != nil { 128 | return nil, fmt.Errorf("GCP service account key authentication: %w", err) 129 | } 130 | 131 | return a, nil 132 | } 133 | 134 | return nil, nil 135 | } 136 | 137 | // BuildOptions returns the client options derived from flags. 138 | func (f *Flags) BuildOptions() (*Options, error) { 139 | if f.BaseURL == "" { 140 | return nil, errors.New("Paperless URL is not specified") 141 | } 142 | 143 | opts := &Options{ 144 | BaseURL: f.BaseURL, 145 | MaxConcurrentRequests: f.MaxConcurrentRequests, 146 | DebugMode: f.DebugMode, 147 | Header: http.Header{}, 148 | ServerLocation: time.Local, 149 | } 150 | 151 | if pool, err := f.buildTrustedRootCAPool(); err != nil { 152 | return nil, fmt.Errorf("trusted root CAs: %w", err) 153 | } else { 154 | opts.TrustedRootCAs = pool 155 | } 156 | 157 | for name, values := range f.Header { 158 | name = http.CanonicalHeaderKey(name) 159 | for _, value := range values { 160 | opts.Header.Add(name, value) 161 | } 162 | } 163 | 164 | if auth, err := f.buildAuth(); err != nil { 165 | return nil, err 166 | } else { 167 | opts.Auth = auth 168 | } 169 | 170 | if f.ServerTimezone != "" { 171 | if loc, err := time.LoadLocation(f.ServerTimezone); err != nil { 172 | return nil, err 173 | } else { 174 | opts.ServerLocation = loc 175 | } 176 | } 177 | 178 | return opts, nil 179 | } 180 | 181 | // Build returns a fully configured Paperless client derived from flags. 182 | func (f *Flags) Build() (*Client, error) { 183 | opts, err := f.BuildOptions() 184 | if err != nil { 185 | return nil, err 186 | } 187 | 188 | return New(*opts), nil 189 | } 190 | -------------------------------------------------------------------------------- /pkg/client/flags_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "crypto/x509" 5 | "net/http" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/google/go-cmp/cmp/cmpopts" 13 | "github.com/hansmi/paperhooks/internal/testutil" 14 | ) 15 | 16 | func TestFlagsBuild(t *testing.T) { 17 | for _, tc := range []struct { 18 | name string 19 | flags Flags 20 | want Options 21 | wantErr error 22 | }{ 23 | { 24 | name: "defaults", 25 | wantErr: cmpopts.AnyError, 26 | }, 27 | { 28 | name: "token auth", 29 | flags: Flags{ 30 | BaseURL: "http://localhost:1234", 31 | AuthToken: "abcdef22379", 32 | Header: http.Header{ 33 | "x-header": []string{"value"}, 34 | "another": []string{"value2"}, 35 | }, 36 | }, 37 | want: Options{ 38 | BaseURL: "http://localhost:1234", 39 | Header: http.Header{ 40 | "X-Header": []string{"value"}, 41 | "Another": []string{"value2"}, 42 | }, 43 | Auth: &TokenAuth{"abcdef22379"}, 44 | ServerLocation: time.Local, 45 | }, 46 | }, 47 | { 48 | name: "token file", 49 | flags: Flags{ 50 | BaseURL: "http://localhost:1234/tokenfile", 51 | AuthToken: "mytoken", 52 | AuthTokenFile: testutil.MustWriteFile(t, filepath.Join(t.TempDir(), "file.txt"), "content\n"), 53 | Header: http.Header{ 54 | "x-header": []string{"foobar"}, 55 | }, 56 | }, 57 | want: Options{ 58 | BaseURL: "http://localhost:1234/tokenfile", 59 | Header: http.Header{ 60 | "X-Header": []string{"foobar"}, 61 | }, 62 | Auth: &TokenAuth{"content"}, 63 | ServerLocation: time.Local, 64 | }, 65 | }, 66 | { 67 | name: "password auth", 68 | flags: Flags{ 69 | DebugMode: true, 70 | BaseURL: "http://localhost:9999/pw", 71 | AuthUsername: "admin", 72 | AuthPassword: "password", 73 | }, 74 | want: Options{ 75 | BaseURL: "http://localhost:9999/pw", 76 | Auth: &UsernamePasswordAuth{ 77 | Username: "admin", 78 | Password: "password", 79 | }, 80 | DebugMode: true, 81 | ServerLocation: time.Local, 82 | }, 83 | }, 84 | { 85 | name: "token file not found", 86 | flags: Flags{ 87 | BaseURL: "http://localhost/notfound", 88 | AuthTokenFile: filepath.Join(t.TempDir(), "missing"), 89 | }, 90 | wantErr: os.ErrNotExist, 91 | }, 92 | { 93 | name: "password file not found", 94 | flags: Flags{ 95 | BaseURL: "http://localhost/notfound", 96 | AuthUsername: "user", 97 | AuthPasswordFile: filepath.Join(t.TempDir(), "missing"), 98 | }, 99 | wantErr: os.ErrNotExist, 100 | }, 101 | { 102 | name: "explicit timezone", 103 | flags: Flags{ 104 | BaseURL: "http://localhost/timezone", 105 | ServerTimezone: "UTC", 106 | }, 107 | want: Options{ 108 | BaseURL: "http://localhost/timezone", 109 | ServerLocation: time.UTC, 110 | }, 111 | }, 112 | { 113 | name: "trusted CA empty", 114 | flags: Flags{ 115 | BaseURL: "http://localhost/rootca/empty", 116 | TrustedRootCAFiles: []string{ 117 | testutil.MustWriteFile(t, filepath.Join(t.TempDir(), "file.txt"), ""), 118 | }, 119 | }, 120 | want: Options{ 121 | BaseURL: "http://localhost/rootca/empty", 122 | ServerLocation: time.Local, 123 | TrustedRootCAs: x509.NewCertPool(), 124 | }, 125 | }, 126 | { 127 | name: "trusted CA", 128 | flags: Flags{ 129 | BaseURL: "http://localhost/rootca", 130 | TrustedRootCAFiles: []string{ 131 | testutil.MustWriteFile(t, filepath.Join(t.TempDir(), "file.txt"), fakeCertPEM), 132 | }, 133 | }, 134 | want: Options{ 135 | BaseURL: "http://localhost/rootca", 136 | ServerLocation: time.Local, 137 | TrustedRootCAs: newFakeCertPool(t), 138 | }, 139 | }, 140 | { 141 | name: "trusted CA file not found", 142 | flags: Flags{ 143 | BaseURL: "http://localhost/rootca/notfound", 144 | TrustedRootCAFiles: []string{ 145 | filepath.Join(t.TempDir(), "missing"), 146 | }, 147 | }, 148 | wantErr: os.ErrNotExist, 149 | }, 150 | } { 151 | t.Run(tc.name, func(t *testing.T) { 152 | got, err := tc.flags.BuildOptions() 153 | 154 | if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { 155 | t.Errorf("BuildOptions() error diff (-want +got):\n%s", diff) 156 | } 157 | 158 | if err == nil { 159 | if diff := cmp.Diff(tc.want, *got, cmpopts.EquateEmpty(), cmp.AllowUnexported(Options{}), testutil.EquateTimeLocation()); diff != "" { 160 | t.Errorf("Options diff (-want +got):\n%s", diff) 161 | } 162 | } 163 | }) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /pkg/client/gcpauth.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "sync" 9 | 10 | "github.com/go-resty/resty/v2" 11 | "golang.org/x/oauth2" 12 | "golang.org/x/oauth2/google" 13 | "golang.org/x/oauth2/jwt" 14 | ) 15 | 16 | // GCPServiceAccountKeyAuth uses a Google Cloud Platform service account key 17 | // file to authenticate against an OAuth 2.0-protected Paperless instance using 18 | // the two-legged JWT flow. 19 | // 20 | // The service account key is used to request OpenID Connect (OIDC) ID tokens 21 | // from the Google OAuth 2.0 API. The ID tokens are in turn used for all 22 | // Paperless API requests. 23 | // 24 | // References: 25 | // 26 | // - https://cloud.google.com/iam/docs/service-account-creds 27 | // - https://openid.net/specs/openid-connect-core-1_0.html 28 | type GCPServiceAccountKeyAuth struct { 29 | // Path to a file containing the service account key. 30 | KeyFile string 31 | 32 | // Service account key in JSON format. 33 | Key []byte 34 | 35 | // Audience to request for the ID token (case-sensitive). If empty the 36 | // Paperless URL is used verbatim. 37 | Audience string 38 | 39 | // Custom HTTP client for requesting tokens. 40 | HTTPClient *http.Client 41 | } 42 | 43 | func (a GCPServiceAccountKeyAuth) Build() (AuthMechanism, error) { 44 | key := a.Key 45 | 46 | if len(key) == 0 { 47 | if a.KeyFile == "" { 48 | return nil, fmt.Errorf("%w: missing key or key path", os.ErrInvalid) 49 | } 50 | 51 | if content, err := os.ReadFile(a.KeyFile); err != nil { 52 | return nil, fmt.Errorf("reading service account key: %w", err) 53 | } else { 54 | key = content 55 | } 56 | } 57 | 58 | config, err := google.JWTConfigFromJSON(key) 59 | if err != nil { 60 | return nil, fmt.Errorf("building JWT config from service account key: %w", err) 61 | } 62 | 63 | if config.PrivateClaims == nil { 64 | config.PrivateClaims = map[string]any{} 65 | } 66 | 67 | config.UseIDToken = true 68 | 69 | return &gcpServiceAccountKeyAuthImpl{ 70 | audience: a.Audience, 71 | httpClient: a.HTTPClient, 72 | config: config, 73 | }, nil 74 | } 75 | 76 | type gcpServiceAccountKeyAuthImpl struct { 77 | mu sync.Mutex 78 | audience string 79 | httpClient *http.Client 80 | config *jwt.Config 81 | } 82 | 83 | var _ AuthMechanism = (*gcpServiceAccountKeyAuthImpl)(nil) 84 | 85 | func (o *gcpServiceAccountKeyAuthImpl) authenticate(clientOpts Options, c *resty.Client) { 86 | ctx := context.Background() 87 | 88 | o.mu.Lock() 89 | defer o.mu.Unlock() 90 | 91 | audience := o.audience 92 | 93 | if audience == "" { 94 | audience = clientOpts.BaseURL 95 | } 96 | 97 | o.config.PrivateClaims["target_audience"] = audience 98 | 99 | if o.httpClient != nil { 100 | ctx = context.WithValue(ctx, oauth2.HTTPClient, o.httpClient) 101 | } 102 | 103 | c.SetTransport(&oauth2.Transport{ 104 | Base: c.GetClient().Transport, 105 | Source: oauth2.ReuseTokenSource(nil, o.config.TokenSource(ctx)), 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /pkg/client/gcpauth_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "path/filepath" 7 | "strconv" 8 | "testing" 9 | 10 | "github.com/go-resty/resty/v2" 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/google/go-cmp/cmp/cmpopts" 13 | "github.com/hansmi/paperhooks/internal/testutil" 14 | "github.com/jarcoal/httpmock" 15 | "golang.org/x/oauth2/jws" 16 | ) 17 | 18 | func TestGCPServiceAccountKeyAuth(t *testing.T) { 19 | const fakeRsaPrivateKey = ` 20 | -----BEGIN RSA PRIVATE KEY----- 21 | MIIBOwIBAAJBAK0uWi2bu8rMLcv+NCs4J4dW0SHFQ6wax6YYQX9SO3YkJtyhNnB+ 22 | 9r7G0Ei4EVnViXH/WbgoCdgIIfKIP6yJYYsCAwEAAQJAAhsnM5jKPtweznVH8yKa 23 | sHWo021Ptl8ZAHcZDNBWMsiWpS0T1AduvKqWm03eVznRXkReTSLO2y/68H71kSkI 24 | iQIhANYHvzIjUW1SgJ5CcXKASICmlaic/t7hSmvYiWE+mvtdAiEAzyP8/yDEk/2s 25 | 5KYVROHZ7r/vIV2dckXPVjrKfgeYagcCIC4yqeBmozLXtg9zBA3VBtFOI8urZ5Aw 26 | TOIOcUjePJG5AiEAqBZfDbTMb/7xFpYDOmM/krLjXKL3yawGhMWuXbjSIG8CIQDU 27 | ZeJO3dIEZiy84+1LTckzPluFpUGzGmsCbYXRBBYAig== 28 | -----END RSA PRIVATE KEY----- 29 | ` 30 | const fakeIDToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" 31 | 32 | for _, tc := range []struct { 33 | name string 34 | a GCPServiceAccountKeyAuth 35 | wantErr error 36 | }{ 37 | { 38 | name: "empty", 39 | wantErr: os.ErrInvalid, 40 | }, 41 | { 42 | name: "file not found", 43 | a: GCPServiceAccountKeyAuth{ 44 | KeyFile: filepath.Join(t.TempDir(), "missing"), 45 | }, 46 | wantErr: os.ErrNotExist, 47 | }, 48 | { 49 | name: "direct", 50 | a: GCPServiceAccountKeyAuth{ 51 | Key: []byte(`{ 52 | "type": "service_account", 53 | "project_id": "myproject", 54 | "private_key_id": "keyid1234", 55 | "private_key": ` + strconv.QuoteToASCII(fakeRsaPrivateKey) + `, 56 | "client_email": "user@example.com", 57 | "client_id": "clientid1234", 58 | "auth_uri": "https://example.com/o/oauth2/auth", 59 | "token_uri": "https://example.com/token", 60 | "auth_provider_x509_cert_url": "https://example.com/oauth2/v1/certs" 61 | }`), 62 | }, 63 | }, 64 | { 65 | name: "file with audience", 66 | a: GCPServiceAccountKeyAuth{ 67 | Audience: "testaudience", 68 | KeyFile: testutil.MustWriteFile(t, filepath.Join(t.TempDir(), "key"), `{ 69 | "type": "service_account", 70 | "project_id": "keyfromfile", 71 | "private_key_id": "keyid10250", 72 | "private_key": `+strconv.QuoteToASCII(fakeRsaPrivateKey)+`, 73 | "client_email": "file@example.com", 74 | "client_id": "clientid18686", 75 | "auth_uri": "https://example.com/o/oauth2/auth", 76 | "token_uri": "https://example.com/token", 77 | "auth_provider_x509_cert_url": "https://example.com/oauth2/v1/certs" 78 | }`), 79 | }, 80 | }, 81 | } { 82 | t.Run(tc.name, func(t *testing.T) { 83 | transport := newMockTransport(t) 84 | transport.RegisterMatcherResponder(http.MethodPost, "https://example.com/token", 85 | httpmock.NewMatcher("", func(req *http.Request) bool { 86 | if err := req.ParseForm(); err != nil { 87 | t.Errorf("ParseForm() failed: %v", err) 88 | } 89 | 90 | if _, err := jws.Decode(req.Form.Get("assertion")); err != nil { 91 | t.Errorf("Decode() failed: %v", err) 92 | } 93 | 94 | return true 95 | }), 96 | httpmock.NewStringResponder(http.StatusOK, `{ "id_token": "`+fakeIDToken+`" }`)) 97 | transport.RegisterMatcherResponder(http.MethodGet, "http://localhost/", 98 | httpmock.HeaderIs("Authorization", "Bearer "+fakeIDToken), 99 | httpmock.NewStringResponder(http.StatusOK, "success:"+t.Name())) 100 | 101 | tc.a.HTTPClient = &http.Client{ 102 | Transport: transport, 103 | } 104 | 105 | got, err := tc.a.Build() 106 | 107 | if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { 108 | t.Errorf("Build() error diff (-want +got):\n%s", diff) 109 | } 110 | 111 | if err == nil { 112 | r := resty.New(). 113 | SetBaseURL("http://localhost"). 114 | SetTransport(transport) 115 | 116 | got.authenticate(Options{}, r) 117 | 118 | for range [3]struct{}{} { 119 | if resp, err := r.R().Get("/"); err != nil { 120 | t.Errorf("Get() failed: %v", err) 121 | } else if diff := cmp.Diff("success:"+t.Name(), string(resp.Body())); diff != "" { 122 | t.Errorf("Response body diff (-want +got):\n%s", diff) 123 | } 124 | } 125 | } 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /pkg/client/generate.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | //go:generate go run ./generate_models.go --output models_generated.go 4 | -------------------------------------------------------------------------------- /pkg/client/generate_models.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "flag" 8 | "fmt" 9 | "go/format" 10 | "io" 11 | "log" 12 | "os" 13 | "path/filepath" 14 | "sort" 15 | "strings" 16 | 17 | "github.com/iancoleman/strcase" 18 | ) 19 | 20 | type modelField struct { 21 | name string 22 | typ string 23 | comment string 24 | readOnly bool 25 | writeOnly bool 26 | } 27 | 28 | type model struct { 29 | name string 30 | owned bool 31 | fields []modelField 32 | } 33 | 34 | func (o model) write(w io.Writer) { 35 | fmt.Fprintf(w, "type %s struct {\n", strcase.ToCamel(o.name)) 36 | 37 | fields := append([]modelField(nil), o.fields...) 38 | 39 | if o.owned { 40 | fields = append(fields, ownedModelFields...) 41 | } 42 | 43 | for idx, f := range fields { 44 | if f.writeOnly { 45 | continue 46 | } 47 | 48 | name := strcase.ToCamel(f.name) 49 | 50 | switch f.name { 51 | case "id": 52 | name = "ID" 53 | } 54 | 55 | if f.comment != "" { 56 | if idx > 0 { 57 | fmt.Fprintf(w, "\n") 58 | } 59 | fmt.Fprintf(w, " // %s\n", strings.TrimSpace(f.comment)) 60 | } 61 | 62 | fmt.Fprintf(w, " %s %s `json:%q`\n", name, f.typ, f.name) 63 | } 64 | 65 | fmt.Fprintf(w, "}\n") 66 | 67 | fieldsStruct := strcase.ToCamel(o.name + "_fields") 68 | 69 | fmt.Fprintf(w, "\n") 70 | fmt.Fprintf(w, "type %s struct {\n", fieldsStruct) 71 | fmt.Fprintf(w, " objectFields\n") 72 | fmt.Fprintf(w, "}\n") 73 | 74 | fmt.Fprintf(w, "var _ json.Marshaler = (*%s)(nil)\n", fieldsStruct) 75 | 76 | fmt.Fprintf(w, "func New%[1]s() *%[1]s {", fieldsStruct) 77 | fmt.Fprintf(w, " return &%s{ objectFields{} }\n", fieldsStruct) 78 | fmt.Fprintf(w, "}\n") 79 | 80 | for _, f := range fields { 81 | if f.readOnly { 82 | continue 83 | } 84 | 85 | argName := strcase.ToLowerCamel(f.name) 86 | 87 | funcName := fmt.Sprintf("Set%s", strcase.ToCamel(f.name)) 88 | 89 | fmt.Fprintf(w, "\n") 90 | fmt.Fprintf(w, "// %s sets the %q field.\n", funcName, f.name) 91 | if f.comment != "" { 92 | fmt.Fprintf(w, "//\n") 93 | fmt.Fprintf(w, "// %s\n", strings.TrimSpace(f.comment)) 94 | } 95 | fmt.Fprintf(w, "func (f *%s) %s(%s %s) *%[1]s {\n", fieldsStruct, funcName, argName, f.typ) 96 | fmt.Fprintf(w, " f.set(%q, %s)\n", f.name, argName) 97 | fmt.Fprintf(w, " return f\n") 98 | fmt.Fprintf(w, "}\n") 99 | } 100 | } 101 | 102 | var ownedModelFields = []modelField{ 103 | { 104 | name: "owner", 105 | typ: "*int64", 106 | comment: "Object owner; objects without owner can be viewed and edited by all users.", 107 | }, 108 | { 109 | name: "set_permissions", 110 | typ: "*ObjectPermissions", 111 | comment: "Change object-level permissions.", 112 | writeOnly: true, 113 | }, 114 | } 115 | 116 | var correspondentModel = model{ 117 | name: "correspondent", 118 | owned: true, 119 | fields: []modelField{ 120 | {name: "id", typ: "int64", readOnly: true}, 121 | {name: "slug", typ: "string", readOnly: true}, 122 | {name: "name", typ: "string"}, 123 | {name: "match", typ: "string"}, 124 | {name: "matching_algorithm", typ: "MatchingAlgorithm"}, 125 | {name: "is_insensitive", typ: "bool"}, 126 | {name: "document_count", typ: "int64", readOnly: true}, 127 | {name: "last_correspondence", typ: "*time.Time", readOnly: true}, 128 | }, 129 | } 130 | 131 | var customFieldModel = model{ 132 | name: "customField", 133 | owned: true, 134 | fields: []modelField{ 135 | {name: "id", typ: "int64", readOnly: true}, 136 | {name: "name", typ: "string"}, 137 | {name: "data_type", typ: "string"}, 138 | }, 139 | } 140 | 141 | var documentModel = model{ 142 | name: "document", 143 | owned: true, 144 | fields: []modelField{ 145 | {name: "id", typ: "int64", comment: "ID of the document.", readOnly: true}, 146 | {name: "title", typ: "string", comment: "Title of the document."}, 147 | {name: "content", typ: "string", comment: "Plain-text content of the document."}, 148 | {name: "tags", typ: "[]int64", comment: "List of tag IDs assigned to this document, or empty list."}, 149 | {name: "document_type", typ: "*int64", comment: "Document type of this document or nil."}, 150 | {name: "correspondent", typ: "*int64", comment: "Correspondent of this document or nil."}, 151 | {name: "storage_path", typ: "*int64", comment: "Storage path of this document or nil."}, 152 | {name: "created", typ: "time.Time", comment: "The date time at which this document was created."}, 153 | {name: "modified", typ: "time.Time", comment: "The date at which this document was last edited in paperless.", readOnly: true}, 154 | {name: "added", typ: "time.Time", comment: "The date at which this document was added to paperless.", readOnly: true}, 155 | {name: "archive_serial_number", typ: "*int64", comment: "The identifier of this document in a physical document archive."}, 156 | {name: "original_file_name", typ: "string", comment: "Verbose filename of the original document.", readOnly: true}, 157 | {name: "archived_file_name", typ: "*string", comment: "Verbose filename of the archived document. Nil if no archived document is available.", readOnly: true}, 158 | {name: "custom_fields", typ: "[]CustomFieldInstance", comment: "Custom fields on the document."}, 159 | }, 160 | } 161 | 162 | var storagePathModel = model{ 163 | name: "storagePath", 164 | owned: true, 165 | fields: []modelField{ 166 | {name: "id", typ: "int64", readOnly: true}, 167 | {name: "slug", typ: "string", readOnly: true}, 168 | {name: "name", typ: "string"}, 169 | {name: "match", typ: "string"}, 170 | {name: "matching_algorithm", typ: "MatchingAlgorithm"}, 171 | {name: "is_insensitive", typ: "bool"}, 172 | {name: "document_count", typ: "int64", readOnly: true}, 173 | }, 174 | } 175 | 176 | var tagModel = model{ 177 | name: "tag", 178 | owned: true, 179 | fields: []modelField{ 180 | {name: "id", typ: "int64", readOnly: true}, 181 | {name: "slug", typ: "string", readOnly: true}, 182 | {name: "name", typ: "string"}, 183 | {name: "color", typ: "Color"}, 184 | {name: "text_color", typ: "Color"}, 185 | {name: "match", typ: "string"}, 186 | {name: "matching_algorithm", typ: "MatchingAlgorithm"}, 187 | {name: "is_insensitive", typ: "bool"}, 188 | {name: "is_inbox_tag", typ: "bool"}, 189 | {name: "document_count", typ: "int64", readOnly: true}, 190 | }, 191 | } 192 | 193 | var documentTypeModel = model{ 194 | name: "documentType", 195 | owned: true, 196 | fields: []modelField{ 197 | {name: "id", typ: "int64", readOnly: true}, 198 | {name: "slug", typ: "string", readOnly: true}, 199 | {name: "name", typ: "string"}, 200 | {name: "match", typ: "string"}, 201 | {name: "matching_algorithm", typ: "MatchingAlgorithm"}, 202 | {name: "is_insensitive", typ: "bool"}, 203 | {name: "document_count", typ: "int64", readOnly: true}, 204 | }, 205 | } 206 | 207 | var userModel = model{ 208 | name: "user", 209 | fields: []modelField{ 210 | {name: "id", typ: "int64", readOnly: true}, 211 | {name: "username", typ: "string"}, 212 | {name: "email", typ: "string"}, 213 | {name: "first_name", typ: "string"}, 214 | {name: "last_name", typ: "string"}, 215 | {name: "is_active", typ: "bool"}, 216 | {name: "is_staff", typ: "bool"}, 217 | {name: "is_superuser", typ: "bool"}, 218 | }, 219 | } 220 | 221 | var groupModel = model{ 222 | name: "group", 223 | fields: []modelField{ 224 | {name: "id", typ: "int64", readOnly: true}, 225 | {name: "name", typ: "string"}, 226 | }, 227 | } 228 | 229 | func main() { 230 | outputFile := flag.String("output", "", "Destination file") 231 | 232 | flag.Parse() 233 | 234 | var buf bytes.Buffer 235 | 236 | exe, err := os.Executable() 237 | if err != nil { 238 | log.Fatal(err) 239 | } 240 | 241 | fmt.Fprintf(&buf, "// Code generated by %q; DO NOT EDIT.\n", 242 | strings.Join(append([]string{filepath.Base(exe)}, os.Args[1:]...), " ")) 243 | buf.WriteString("\n") 244 | buf.WriteString("package client\n") 245 | 246 | imports := []string{ 247 | "encoding/json", 248 | "time", 249 | } 250 | 251 | sort.Strings(imports) 252 | 253 | for _, i := range imports { 254 | fmt.Fprintf(&buf, "import %q\n", i) 255 | } 256 | 257 | models := []model{ 258 | correspondentModel, 259 | customFieldModel, 260 | documentModel, 261 | documentTypeModel, 262 | storagePathModel, 263 | tagModel, 264 | userModel, 265 | groupModel, 266 | } 267 | 268 | sort.Slice(models, func(a, b int) bool { 269 | return models[a].name < models[b].name 270 | }) 271 | 272 | for _, i := range models { 273 | i.write(&buf) 274 | } 275 | 276 | formatted, err := format.Source(buf.Bytes()) 277 | if err != nil { 278 | log.Fatalf("Formatting code failed: %v\n%s", err, buf.String()) 279 | } 280 | 281 | if *outputFile == "" || *outputFile == "-" { 282 | os.Stdout.Write(formatted) 283 | } else if err := os.WriteFile(*outputFile, formatted, 0o644); err != nil { 284 | log.Fatal("Writing output failed: %v", err) 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /pkg/client/group.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func (c *Client) groupCrudOpts() crudOptions { 8 | return crudOptions{ 9 | base: "api/groups/", 10 | newRequest: c.newRequest, 11 | getID: func(v any) int64 { 12 | return v.(Group).ID 13 | }, 14 | setPage: func(opts any, page *PageToken) { 15 | opts.(*ListGroupsOptions).Page = page 16 | }, 17 | } 18 | } 19 | 20 | type ListGroupsOptions struct { 21 | ListOptions 22 | 23 | Ordering OrderingSpec `url:"ordering"` 24 | Name CharFilterSpec `url:"name"` 25 | } 26 | 27 | func (c *Client) ListGroups(ctx context.Context, opts ListGroupsOptions) ([]Group, *Response, error) { 28 | return crudList[Group](ctx, c.groupCrudOpts(), opts) 29 | } 30 | 31 | // ListAllGroups iterates over all groups matching the filters specified in opts, 32 | // invoking handler for each. 33 | func (c *Client) ListAllGroups(ctx context.Context, opts ListGroupsOptions, handler func(context.Context, Group) error) error { 34 | return crudListAll[Group](ctx, c.groupCrudOpts(), opts, handler) 35 | } 36 | 37 | func (c *Client) GetGroup(ctx context.Context, id int64) (*Group, *Response, error) { 38 | return crudGet[Group](ctx, c.groupCrudOpts(), id) 39 | } 40 | -------------------------------------------------------------------------------- /pkg/client/log.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "regexp" 8 | "strings" 9 | "time" 10 | "unicode" 11 | ) 12 | 13 | // ListLogs retrieves the names of available log files. 14 | func (c *Client) ListLogs(ctx context.Context) ([]string, *Response, error) { 15 | req := c.newRequest(ctx).SetResult([]string(nil)) 16 | 17 | resp, err := req.Get("api/logs/") 18 | 19 | if err := convertError(err, resp); err != nil { 20 | return nil, wrapResponse(resp), err 21 | } 22 | 23 | return *resp.Result().(*[]string), wrapResponse(resp), nil 24 | } 25 | 26 | type LogEntry struct { 27 | Time time.Time 28 | Level string 29 | Module string 30 | Message string 31 | } 32 | 33 | func (e *LogEntry) appendLine(line string) { 34 | e.Message += "\n" + line 35 | } 36 | 37 | // Regular expression matching a log message. Example: 38 | // [2023-02-28 00:28:37,604] [INFO] [paperless.consumer] Consuming xyz.pdf" 39 | var logEntryRe = regexp.MustCompile(`^` + 40 | `\[(?P