aboutsummaryrefslogtreecommitdiff
path: root/miniflux
diff options
context:
space:
mode:
Diffstat (limited to 'miniflux')
-rw-r--r--miniflux/client.go201
-rw-r--r--miniflux/client_test.go307
2 files changed, 508 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
+}
diff --git a/miniflux/client_test.go b/miniflux/client_test.go
new file mode 100644
index 0000000..8f8c6ad
--- /dev/null
+++ b/miniflux/client_test.go
@@ -0,0 +1,307 @@
+// Tests for the Miniflux client against an httptest fake. Inline fixture
+// JSON mirrors a real GET /v1/entries response shape (extra API fields
+// included to prove they're harmlessly ignored).
+package miniflux
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+const entriesFixture = `{
+ "total": 2,
+ "entries": [
+ {
+ "id": 41001,
+ "user_id": 3,
+ "feed_id": 77,
+ "status": "read",
+ "hash": "abc123",
+ "title": "Why headline-only models are fine actually",
+ "url": "https://example.com/posts/headline-models",
+ "comments_url": "",
+ "published_at": "2024-03-02T10:15:00Z",
+ "created_at": "2024-03-02T11:00:00Z",
+ "changed_at": "2024-03-05T08:30:00Z",
+ "content": "<p>Article body here.</p>",
+ "author": "Jane Doe",
+ "share_code": "",
+ "starred": true,
+ "reading_time": 4,
+ "enclosures": null,
+ "feed": {
+ "id": 77,
+ "user_id": 3,
+ "feed_url": "https://example.com/feed.xml",
+ "site_url": "https://example.com",
+ "title": "Example Blog",
+ "checked_at": "2024-03-05T09:00:00Z",
+ "etag_header": "",
+ "last_modified_header": "",
+ "parsing_error_message": "",
+ "parsing_error_count": 0
+ }
+ },
+ {
+ "id": 41002,
+ "user_id": 3,
+ "feed_id": 102,
+ "status": "unread",
+ "hash": "def456",
+ "title": "Sixty days of RSS triage: a field report",
+ "url": "https://blog.other.example/triage-report",
+ "comments_url": "",
+ "published_at": "2024-03-04T14:05:00Z",
+ "created_at": "2024-03-04T15:20:00Z",
+ "changed_at": "2024-03-04T15:20:00Z",
+ "content": "<p>Another body.</p>",
+ "author": "",
+ "share_code": "",
+ "starred": false,
+ "reading_time": 9,
+ "enclosures": null,
+ "feed": {
+ "id": 102,
+ "user_id": 3,
+ "feed_url": "https://blog.other.example/rss",
+ "site_url": "https://blog.other.example",
+ "title": "Other Blog",
+ "checked_at": "2024-03-05T09:00:00Z",
+ "etag_header": "",
+ "last_modified_header": "",
+ "parsing_error_message": "",
+ "parsing_error_count": 0
+ }
+ }
+ ]
+}`
+
+// fakeServer records the last request's auth header and query, then replies
+// with the fixture (or an error status).
+type fakeServer struct {
+ t *testing.T
+ status int
+ body string
+ gotHeader string
+ gotQuery map[string]string
+ sawRequest bool
+}
+
+func (f *fakeServer) handler() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ f.sawRequest = true
+ f.gotHeader = r.Header.Get("X-Auth-Token")
+ f.gotQuery = map[string]string{}
+ for k, v := range r.URL.Query() {
+ f.gotQuery[k] = v[0]
+ }
+ if r.URL.Path != "/v1/entries" {
+ f.t.Errorf("unexpected path: %s", r.URL.Path)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(f.status)
+ _, _ = w.Write([]byte(f.body))
+ }
+}
+
+func newFakeClient(t *testing.T, f *fakeServer) *Client {
+ t.Helper()
+ srv := httptest.NewServer(f.handler())
+ t.Cleanup(srv.Close)
+ return NewClientWithHTTP(Config{BaseURL: srv.URL, Token: "test-token-123"}, srv.Client())
+}
+
+func TestEntriesDecodesFixture(t *testing.T) {
+ f := &fakeServer{t: t, status: http.StatusOK, body: entriesFixture}
+ client := newFakeClient(t, f)
+
+ set, err := client.Entries(Filters{})
+ if err != nil {
+ t.Fatalf("Entries: %v", err)
+ }
+
+ if !f.sawRequest {
+ t.Fatal("fake server saw no request")
+ }
+ if f.gotHeader != "test-token-123" {
+ t.Errorf("X-Auth-Token = %q, want test-token-123", f.gotHeader)
+ }
+ if set.Total != 2 {
+ t.Errorf("Total = %d, want 2", set.Total)
+ }
+ if len(set.Entries) != 2 {
+ t.Fatalf("len(Entries) = %d, want 2", len(set.Entries))
+ }
+
+ e := set.Entries[0]
+ if e.ID != 41001 {
+ t.Errorf("entry[0].ID = %d, want 41001", e.ID)
+ }
+ if e.Title != "Why headline-only models are fine actually" {
+ t.Errorf("entry[0].Title = %q", e.Title)
+ }
+ if !e.Starred {
+ t.Error("entry[0].Starred = false, want true")
+ }
+ if e.Status != "read" {
+ t.Errorf("entry[0].Status = %q, want read", e.Status)
+ }
+ if e.Feed.ID != 77 || e.Feed.Title != "Example Blog" {
+ t.Errorf("entry[0].Feed = %+v", e.Feed)
+ }
+ if e.PublishedAt.IsZero() || e.ChangedAt.IsZero() {
+ t.Errorf("entry[0] times not parsed: %v / %v", e.PublishedAt, e.ChangedAt)
+ }
+}
+
+func TestEntriesSendsFilters(t *testing.T) {
+ f := &fakeServer{t: t, status: http.StatusOK, body: `{"total":0,"entries":[]}`}
+ client := newFakeClient(t, f)
+
+ starred := true
+ _, err := client.Entries(Filters{
+ Starred: &starred,
+ Status: "read",
+ ChangedAfter: 1700000000,
+ PublishedAfter: 1700000000,
+ AfterEntryID: 40999,
+ Limit: 200,
+ Offset: 400,
+ })
+ if err != nil {
+ t.Fatalf("Entries: %v", err)
+ }
+
+ want := map[string]string{
+ "starred": "true",
+ "status": "read",
+ "changed_after": "1700000000",
+ "published_after": "1700000000",
+ "after_entry_id": "40999",
+ "limit": "200",
+ "offset": "400",
+ }
+ for k, v := range want {
+ if f.gotQuery[k] != v {
+ t.Errorf("query %s = %q, want %q", k, f.gotQuery[k], v)
+ }
+ }
+}
+
+func TestEntriesZeroFiltersSendNoParams(t *testing.T) {
+ f := &fakeServer{t: t, status: http.StatusOK, body: `{"total":0,"entries":[]}`}
+ client := newFakeClient(t, f)
+
+ if _, err := client.Entries(Filters{}); err != nil {
+ t.Fatalf("Entries: %v", err)
+ }
+ if len(f.gotQuery) != 0 {
+ t.Errorf("zero Filters sent params: %v", f.gotQuery)
+ }
+}
+
+func TestEntriesHTTPError(t *testing.T) {
+ f := &fakeServer{t: t, status: http.StatusUnauthorized, body: `{"error_message":"bad token"}`}
+ client := newFakeClient(t, f)
+
+ _, err := client.Entries(Filters{})
+ if err == nil {
+ t.Fatal("expected error on 401, got nil")
+ }
+ if !strings.Contains(err.Error(), "401") {
+ t.Errorf("error should name the status: %v", err)
+ }
+}
+
+func TestEntriesBadJSON(t *testing.T) {
+ f := &fakeServer{t: t, status: http.StatusOK, body: `not json`}
+ client := newFakeClient(t, f)
+
+ if _, err := client.Entries(Filters{}); err == nil {
+ t.Fatal("expected decode error, got nil")
+ }
+}
+
+func TestConfigFromEnv(t *testing.T) {
+ t.Run("primary vars", func(t *testing.T) {
+ t.Setenv("MINIFLUX_URL", "https://flux.example")
+ t.Setenv("MINIFLUX_TOKEN", "tok-primary")
+ cfg, err := ConfigFromEnv()
+ if err != nil {
+ t.Fatalf("ConfigFromEnv: %v", err)
+ }
+ if cfg.BaseURL != "https://flux.example" || cfg.Token != "tok-primary" {
+ t.Errorf("cfg = %+v", cfg)
+ }
+ })
+
+ t.Run("fallback vars", func(t *testing.T) {
+ t.Setenv("MFLUX_URL", "https://flux.example")
+ t.Setenv("MFLUX_TOKEN", "tok-fallback")
+ cfg, err := ConfigFromEnv()
+ if err != nil {
+ t.Fatalf("ConfigFromEnv: %v", err)
+ }
+ if cfg.Token != "tok-fallback" {
+ t.Errorf("Token = %q, want tok-fallback", cfg.Token)
+ }
+ })
+
+ t.Run("primary beats fallback", func(t *testing.T) {
+ t.Setenv("MINIFLUX_TOKEN", "tok-primary")
+ t.Setenv("MFLUX_TOKEN", "tok-fallback")
+ t.Setenv("MINIFLUX_URL", "https://flux.example")
+ cfg, err := ConfigFromEnv()
+ if err != nil {
+ t.Fatalf("ConfigFromEnv: %v", err)
+ }
+ if cfg.Token != "tok-primary" {
+ t.Errorf("Token = %q, want tok-primary", cfg.Token)
+ }
+ })
+
+ t.Run("missing URL errors naming both vars", func(t *testing.T) {
+ t.Setenv("MINIFLUX_TOKEN", "tok")
+ _, err := ConfigFromEnv()
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "MINIFLUX_URL") || !strings.Contains(err.Error(), "MFLUX_URL") {
+ t.Errorf("error should name both URL vars: %v", err)
+ }
+ })
+
+ t.Run("missing token errors naming both vars", func(t *testing.T) {
+ t.Setenv("MINIFLUX_URL", "https://flux.example")
+ _, err := ConfigFromEnv()
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "MINIFLUX_TOKEN") || !strings.Contains(err.Error(), "MFLUX_TOKEN") {
+ t.Errorf("error should name both token vars: %v", err)
+ }
+ })
+}
+
+// Compile-time assertion: EntrySet round-trips through encoding/json (a
+// guard against tag typos drifting from the API's field names).
+func TestEntrySetJSONRoundTrip(t *testing.T) {
+ var set EntrySet
+ if err := json.Unmarshal([]byte(entriesFixture), &set); err != nil {
+ t.Fatalf("fixture does not decode into EntrySet: %v", err)
+ }
+ b, err := json.Marshal(set)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var back EntrySet
+ if err := json.Unmarshal(b, &back); err != nil {
+ t.Fatalf("re-decode: %v", err)
+ }
+ if back.Total != set.Total || len(back.Entries) != len(set.Entries) {
+ t.Error("round trip mismatch")
+ }
+}