1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
|
# fiken
A Go client for [Fiken](https://fiken.no)'s API v2, and the OAuth2 flow that
gets a token for it.
```sh
go get simenandre.no/fiken
```
Three packages, and the split is the point:
- **`fiken`** knows how to put a bearer token in a header and how to read
Fiken's answers. Where the token came from is not its business.
- **`fikenauth`** gets and keeps a token: the authorization-code flow over a
loopback redirect, refreshing an expired grant, and a `0600` file to store it
in. Refreshing is a side effect of asking for a token rather than a step the
caller has to remember, which is the only way it gets done everywhere.
- **`fikenconfig`** is the login every program on the machine shares, so
logging in once, from any of them, logs in all of them.
```go
store := fikenauth.NewStore(filepath.Join(dir, "fiken-token.json"))
source := fikenauth.NewSource(cfg, store)
source.LoginCommand = "myapp fiken login" // named in the error if the grant dies
client, err := fiken.NewWithTokens(source)
```
## One login for every program
`fikenconfig` keeps one OAuth app and a grant per Fiken account in
`~/.config/fiken` (`$XDG_CONFIG_HOME/fiken`, or `$FIKEN_CONFIG_DIR`):
```
config.json the app, and each account with the companies it reaches
accounts/<email>.json each account's grant
```
```json
{
"app": {
"client_id": "...",
"client_secret": "...",
"redirect_url": "http://localhost:8137/callback"
},
"accounts": {
"simen@sokkel.io": {
"name": "Simen",
"companies": [{ "name": "Sokkel AS", "slug": "sokkel-as" }]
},
"post@bjerk.io": { "companies": [...], "token": "a personal API token" }
}
}
```
A program names the company it works for and gets the account that reaches
it. Which company that is stays the program's business:
```go
cfg, err := fikenconfig.Open("")
cfg.LoginCommand = "myapp login"
client, err := cfg.Client(ctx, "sokkel-as")
```
Logging in records the grant under the email of the Fiken user who gave it:
```go
grant, err := cfg.Auth().Login(ctx, announce)
email, err := cfg.AddGrant(ctx, grant)
```
A company added in Fiken after the login is found without logging in again:
when no account has it, `Client` asks each account once more.
Sharing the grant file is the only safe way to share a grant, because Fiken
rotates the refresh token on every renewal and two copies means the first to
renew silently logs out the other. Renewing takes a lock beside the file and
reads it again under the lock, so two programs renewing at once take turns.
`$FIKEN_CLIENT_ID` and `$FIKEN_CLIENT_SECRET` override the app in the file.
## Without the shared login
A personal API token works too, with no OAuth at all:
```go
client, err := fiken.New(os.Getenv("FIKEN_TOKEN"))
```
## What it is careful about
**Amounts are integers.** `DraftLine.UnitPrice` is the invoice currency's minor
unit and `Quantity` is normally 1, so the amount is carried entirely by the
price and no float ever enters the payload. Fiken's minor unit has two decimals
whatever the currency, so a price carried at more precision than that has to be
rounded by the caller — where the rounding rule can be stated and tested,
rather than here where it would be invisible.
**A token never reaches a log line.** `Token.String` hides its own value, so
`%v` on one prints nothing useful.
**404 is its own error.** `ErrNoSuchResource` wraps it, because deleting a draft
that is already gone is usually the state the caller wanted, not a failure.
`ErrUnauthorized` is likewise separate from a transport error: one means log in
again, the other means try again.
**Retries are bounded and only for what is retryable.** A 5xx or 429 is retried;
a 4xx is not, because sending the same bad request again cannot help. Nothing
that creates something is retried at all: a 5xx does not say whether Fiken
acted before failing, and a draft or contact made twice is worse than an
error.
## Status
Extracted from a working invoicing tool and used by three of them, so the
surface is what those needed rather than all of Fiken: companies, contacts,
invoices, drafts, credit notes. Pull requests that widen it are welcome; the
constraints above are not up for negotiation.
Tests run against `httptest` servers — including retries, 4xx, and a declined
consent — and reach nothing real.
|