diff options
| author | Sam Scholten | 2026-08-30 12:50:24 +1000 |
|---|---|---|
| committer | Sam Scholten | 2026-08-30 12:52:39 +1000 |
| commit | 96ab2fcb2ff442698465389d75390afa91629165 (patch) | |
| tree | b36ef45dccaa791b0fce4dcad99d564635663c10 /miniflux/client.go | |
| download | fluxrec-96ab2fcb2ff442698465389d75390afa91629165.tar.gz fluxrec-96ab2fcb2ff442698465389d75390afa91629165.zip | |
fluxrec: personal Miniflux article recommender
Diffstat (limited to 'miniflux/client.go')
| -rw-r--r-- | miniflux/client.go | 201 |
1 files changed, 201 insertions, 0 deletions
diff --git a/miniflux/client.go b/miniflux/client.go new file mode 100644 index 0000000..ae23fe3 --- /dev/null +++ b/miniflux/client.go @@ -0,0 +1,201 @@ +// Miniflux API client for fluxrec. +// +// Typed over exactly what fluxrec needs: GET /v1/entries with the filter +// subset used by export (starred/status/changed_after) and serve +// (after_entry_id). Auth is the X-Auth-Token header; credentials come from +// the environment (MINIFLUX_* with MFLUX_* fallback, so existing mflux +// exports work). Read-only by construction: there are no mutation methods. +package miniflux + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "time" +) + +// Env var names, primary then fallback (matches mflux's exports). +var ( + urlVars = [2]string{"MINIFLUX_URL", "MFLUX_URL"} + tokenVars = [2]string{"MINIFLUX_TOKEN", "MFLUX_TOKEN"} +) + +// Config is resolved client configuration. +type Config struct { + BaseURL string + Token string +} + +// ConfigFromEnv resolves MINIFLUX_URL/MINIFLUX_TOKEN, falling back to +// MFLUX_URL/MFLUX_TOKEN. Errors name both variables so a missing export is +// obvious from the message alone. +func ConfigFromEnv() (Config, error) { + baseURL := firstEnv(urlVars) + if baseURL == "" { + return Config{}, fmt.Errorf("miniflux URL not set: export %s (or %s)", urlVars[0], urlVars[1]) + } + token := firstEnv(tokenVars) + if token == "" { + return Config{}, fmt.Errorf("miniflux token not set: export %s (or %s)", tokenVars[0], tokenVars[1]) + } + return Config{BaseURL: baseURL, Token: token}, nil +} + +func firstEnv(names [2]string) string { + for _, n := range names { + if v := os.Getenv(n); v != "" { + return v + } + } + return "" +} + +// Client is a read-only Miniflux API client. +type Client struct { + baseURL string + token string + httpClient *http.Client +} + +// NewClient builds a Client with a sane default HTTP timeout. +func NewClient(cfg Config) *Client { + return &Client{ + baseURL: cfg.BaseURL, + token: cfg.Token, + httpClient: &http.Client{Timeout: 60 * time.Second}, + } +} + +// NewClientWithHTTP is NewClient with a caller-provided http.Client +// (tests point it at httptest servers). +func NewClientWithHTTP(cfg Config, hc *http.Client) *Client { + return &Client{baseURL: cfg.BaseURL, token: cfg.Token, httpClient: hc} +} + +// Feed is the subset of a Miniflux feed fluxrec needs. +type Feed struct { + ID int64 `json:"id"` + Title string `json:"title"` +} + +// Entry is the subset of a Miniflux entry fluxrec needs. JSON field names +// match the Miniflux API verbatim so decoding is a straight mirror. +type Entry struct { + ID int64 `json:"id"` + URL string `json:"url"` + Title string `json:"title"` + Content string `json:"content"` + Status string `json:"status"` // "read" | "unread" | "removed" + Starred bool `json:"starred"` + PublishedAt time.Time `json:"published_at"` + ChangedAt time.Time `json:"changed_at"` + Feed Feed `json:"feed"` +} + +// Filters selects which entries GET /v1/entries returns. Zero values mean +// "no filter" for every field; Starred is a *bool so unset vs. false stays +// distinguishable (we only ever filter starred=true, but keep the type +// honest). +type Filters struct { + Starred *bool // only starred entries when true + Status string // "read" | "unread"; "" = any + ChangedAfter int64 // unix timestamp; incremental export cursor + PublishedAfter int64 // unix timestamp; only entries published after this + AfterEntryID int64 // serve-lane poll cursor + Direction string // "asc" | "desc"; "" = server default + Limit int // page size; 0 = server default + Offset int // paging offset +} + +// EntrySet is one page of GET /v1/entries results. Total is the full +// matching count across pages, which offsets paging loops can stop against. +type EntrySet struct { + Total int `json:"total"` + Entries []Entry `json:"entries"` +} + +// Entries calls GET /v1/entries with the given filters. +func (c *Client) Entries(f Filters) (*EntrySet, error) { + body, err := c.fetchEntries(f) + if err != nil { + return nil, err + } + var set EntrySet + if err := json.Unmarshal(body, &set); err != nil { + return nil, fmt.Errorf("decoding /v1/entries response: %w", err) + } + return &set, nil +} + +// RawEntries returns the /v1/entries response body verbatim. Exists for +// fixture capture (`export --dump-raw`); the typed path stays Entries. +func (c *Client) RawEntries(f Filters) ([]byte, error) { + return c.fetchEntries(f) +} + +// fetchEntries issues the GET and returns the body on HTTP 200. +func (c *Client) fetchEntries(f Filters) ([]byte, error) { + u := entriesURL(c.baseURL, f) + + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + req.Header.Set("X-Auth-Token", c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", u, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return nil, fmt.Errorf("GET %s: status %d: %s", u, resp.StatusCode, body) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response from %s: %w", u, err) + } + return body, nil +} + +// entriesURL builds /v1/entries?... honoring only the non-zero filters. +func entriesURL(base string, f Filters) string { + q := url.Values{} + if f.Starred != nil { + q.Set("starred", strconv.FormatBool(*f.Starred)) + } + if f.Status != "" { + q.Set("status", f.Status) + } + if f.ChangedAfter > 0 { + q.Set("changed_after", strconv.FormatInt(f.ChangedAfter, 10)) + } + if f.PublishedAfter > 0 { + q.Set("published_after", strconv.FormatInt(f.PublishedAfter, 10)) + } + if f.AfterEntryID > 0 { + q.Set("after_entry_id", strconv.FormatInt(f.AfterEntryID, 10)) + } + if f.Direction != "" { + q.Set("direction", f.Direction) + } + if f.Limit > 0 { + q.Set("limit", strconv.Itoa(f.Limit)) + } + if f.Offset > 0 { + q.Set("offset", strconv.Itoa(f.Offset)) + } + + u := base + "/v1/entries" + if len(q) > 0 { + u += "?" + q.Encode() + } + return u +} |
