aboutsummaryrefslogtreecommitdiff
path: root/miniflux/client.go
diff options
context:
space:
mode:
Diffstat (limited to 'miniflux/client.go')
-rw-r--r--miniflux/client.go201
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
+}