diff options
Diffstat (limited to 'miniflux')
| -rw-r--r-- | miniflux/client.go | 201 | ||||
| -rw-r--r-- | miniflux/client_test.go | 307 |
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") + } +} |
