refactor(integration): factorize JSON request construction

Every integration talking to a JSON API hand-rolled the same dance:
json.Marshal the payload, build an *http.Request, set the Content-Type and
User-Agent headers, then run it through NewClientWithOptions. This is
near-identical copy-paste, so any change done to how requests are done had to
be repeated in each one.

This commit introduces a small request builder in internal/http/client and
route every integration through it:

```
client.NewRequestBuilder(endpoint).
	WithMethod(http.MethodPost).
	WithJSON(payload).
	WithHeaders(extraHeaders).
	Do()
```

Centralizing the logic also evens out disparities that had crept in between
integrations:

- private networks are now blocked everywhere, honoring
  INTEGRATION_ALLOW_PRIVATE_NETWORKS; notion previously did not block them;
- request errors are wrapped consistently with %w instead of a mix of %v/%w;
- the per-integration defaultClientTimeout constant is replaced by a single
  shared default in the client package;
- the Content-Type and Miniflux User-Agent headers are always set.

Reviewed-By: gudvinr
This commit is contained in:
jvoisin
2026-06-11 14:10:51 +02:00
committed by fguillot
parent 0939195966
commit 706a92e700
15 changed files with 362 additions and 436 deletions
+97
View File
@@ -4,16 +4,23 @@
package client // import "miniflux.app/v2/internal/http/client"
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultRequestTimeout = 10 * time.Second
// ErrPrivateNetwork is returned when a connection to a private network is blocked.
var ErrPrivateNetwork = errors.New("client: connection to private network is blocked")
@@ -68,3 +75,93 @@ func NewClientWithOptions(opts Options) *http.Client {
Transport: transport,
}
}
// requestBuilder builds and executes HTTP requests with the builder pattern.
type requestBuilder struct {
err error
endpoint string
method string
body io.Reader
headers http.Header
}
// NewRequestBuilder creates a new request builder for the given endpoint.
func NewRequestBuilder(endpoint string) *requestBuilder {
return &requestBuilder{
endpoint: endpoint,
method: http.MethodGet,
headers: make(http.Header),
}
}
// WithMethod sets the HTTP method.
func (r *requestBuilder) WithMethod(method string) *requestBuilder {
r.method = method
return r
}
// WithHeader sets a header value.
func (r *requestBuilder) WithHeader(key, value string) *requestBuilder {
r.headers.Set(key, value)
return r
}
// WithJSON marshals payload as JSON, sets the body and Content-Type.
func (r *requestBuilder) WithJSON(payload any) *requestBuilder {
requestBody, err := json.Marshal(payload)
if err != nil {
r.err = fmt.Errorf("unable to encode request body: %w", err)
return r
}
return r.WithJSONBody(requestBody)
}
// WithJSONBody sets an already-marshaled JSON body and the Content-Type.
// It is useful when the caller needs the encoded payload for another
// purpose (e.g. computing a signature) to avoid marshaling it twice.
func (r *requestBuilder) WithJSONBody(body []byte) *requestBuilder {
r.body = bytes.NewReader(body)
r.headers.Set("Content-Type", "application/json")
return r
}
// Do builds and executes the request.
//
// Private networks are blocked unless explicitly allowed through the
// INTEGRATION_ALLOW_PRIVATE_NETWORKS option.
func (r *requestBuilder) Do() (*http.Response, error) {
if r.err != nil {
return nil, r.err
}
// The request is assembled lazily here rather than being stored as a
// prebuilt *http.Request in the builder: http.NewRequest inspects the
// body's concrete type (e.g. *bytes.Reader) to populate ContentLength and
// GetBody. Constructing it only once the body is known yields a correct
// Content-Length header and lets the client replay the body on redirects.
req, err := http.NewRequest(r.method, r.endpoint, r.body)
if err != nil {
return nil, fmt.Errorf("unable to create request: %w", err)
}
for key, values := range r.headers {
for _, value := range values {
req.Header.Add(key, value)
}
}
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
clientOptions := Options{
Timeout: defaultRequestTimeout,
BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks(),
}
response, err := NewClientWithOptions(clientOptions).Do(req)
if err != nil {
return nil, fmt.Errorf("unable to send request: %w", err)
}
return response, nil
}
+74
View File
@@ -5,11 +5,15 @@ package client
import (
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/version"
)
func TestNewClientWithoutBlockingPrivateNetworks(t *testing.T) {
@@ -111,3 +115,73 @@ func TestBlockPrivateNetworksAllowsLoopbackWhenDisabled(t *testing.T) {
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
}
}
func TestRequestBuilderWithJSON(t *testing.T) {
configureIntegrationAllowPrivateNetworksOption(t)
var gotMethod, gotContentType, gotUserAgent, gotAuth, gotBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotContentType = r.Header.Get("Content-Type")
gotUserAgent = r.Header.Get("User-Agent")
gotAuth = r.Header.Get("Authorization")
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
response, err := NewRequestBuilder(server.URL).
WithMethod(http.MethodPost).
WithHeader("Authorization", "Bearer secret").
WithJSON(map[string]string{"hello": "world"}).
Do()
if err != nil {
t.Fatalf("request execution failed: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
t.Errorf("expected status %d, got %d", http.StatusCreated, response.StatusCode)
}
if gotMethod != http.MethodPost {
t.Errorf("expected method POST, got %s", gotMethod)
}
if gotContentType != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", gotContentType)
}
if want := "Miniflux/" + version.Version; gotUserAgent != want {
t.Errorf("expected User-Agent %q, got %q", want, gotUserAgent)
}
if gotAuth != "Bearer secret" {
t.Errorf("expected Authorization %q, got %q", "Bearer secret", gotAuth)
}
if gotBody != `{"hello":"world"}` {
t.Errorf("expected body %q, got %q", `{"hello":"world"}`, gotBody)
}
}
func TestRequestBuilderWithInvalidEndpoint(t *testing.T) {
_, err := NewRequestBuilder("://invalid").WithMethod(http.MethodPost).WithJSON(nil).Do()
if err == nil {
t.Fatal("expected an error for an invalid endpoint, got nil")
}
}
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
t.Helper()
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
configParser := config.NewConfigParser()
parsedOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unable to configure test options: %v", err)
}
previousOptions := config.Opts
config.Opts = parsedOptions
t.Cleanup(func() {
config.Opts = previousOptions
})
}
+8 -29
View File
@@ -6,21 +6,13 @@
package cubox // import "miniflux.app/v2/internal/integration/cubox"
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
apiLink string
}
@@ -34,28 +26,15 @@ func (c *Client) SaveLink(entryURL string) error {
return errors.New("cubox: missing API link")
}
requestBody, err := json.Marshal(&card{
Type: "url",
Content: entryURL,
})
response, err := client.NewRequestBuilder(c.apiLink).
WithMethod(http.MethodPost).
WithJSON(&card{
Type: "url",
Content: entryURL,
}).
Do()
if err != nil {
return fmt.Errorf("cubox: unable to encode request body: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), defaultClientTimeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiLink, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("cubox: unable to create request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
response, err := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}).Do(request)
if err != nil {
return fmt.Errorf("cubox: unable to send request: %w", err)
return fmt.Errorf("cubox: %w", err)
}
defer response.Body.Close()
+32 -49
View File
@@ -6,21 +6,15 @@
package discord // import "miniflux.app/v2/internal/integration/discord"
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
const discordMsgColor = 5793266
type Client struct {
@@ -33,56 +27,45 @@ func NewClient(webhookURL string) *Client {
func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
for _, entry := range entries {
requestBody, err := json.Marshal(&discordMessage{
Embeds: []discordEmbed{
{
Title: "RSS feed update from Miniflux",
Color: discordMsgColor,
Fields: []discordFields{
{
Name: "Updated feed",
Value: feed.Title,
},
{
Name: "Article link",
Value: "[" + entry.Title + "]" + "(" + entry.URL + ")",
},
{
Name: "Author",
Value: entry.Author,
Inline: true,
},
{
Name: "Source website",
Value: urllib.RootURL(feed.SiteURL),
Inline: true,
},
},
},
},
})
if err != nil {
return fmt.Errorf("discord: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("discord: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
slog.Debug("Sending Discord notification",
slog.String("webhookURL", c.webhookURL),
slog.String("title", feed.Title),
slog.String("entry_url", entry.URL),
)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
response, err := client.NewRequestBuilder(c.webhookURL).
WithMethod(http.MethodPost).
WithJSON(&discordMessage{
Embeds: []discordEmbed{
{
Title: "RSS feed update from Miniflux",
Color: discordMsgColor,
Fields: []discordFields{
{
Name: "Updated feed",
Value: feed.Title,
},
{
Name: "Article link",
Value: "[" + entry.Title + "]" + "(" + entry.URL + ")",
},
{
Name: "Author",
Value: entry.Author,
Inline: true,
},
{
Name: "Source website",
Value: urllib.RootURL(feed.SiteURL),
Inline: true,
},
},
},
},
}).
Do()
if err != nil {
return fmt.Errorf("discord: unable to send request: %v", err)
return fmt.Errorf("discord: %w", err)
}
response.Body.Close()
+11 -29
View File
@@ -5,20 +5,14 @@ package espial // import "miniflux.app/v2/internal/integration/espial"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
@@ -38,30 +32,18 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
return fmt.Errorf("espial: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&espialDocument{
Title: entryTitle,
URL: entryURL,
ToRead: true,
Tags: espialTags,
})
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&espialDocument{
Title: entryTitle,
URL: entryURL,
ToRead: true,
Tags: espialTags,
}).
WithHeader("Authorization", "ApiKey "+c.apiKey).
Do()
if err != nil {
return fmt.Errorf("espial: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("espial: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "ApiKey "+c.apiKey)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("espial: unable to send request: %v", err)
return fmt.Errorf("espial: %w", err)
}
defer response.Body.Close()
+13 -31
View File
@@ -4,22 +4,15 @@
package linkace // import "miniflux.app/v2/internal/integration/linkace"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
@@ -45,31 +38,20 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
if err != nil {
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&createItemRequest{
URL: entryURL,
Title: entryTitle,
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
Private: c.private,
CheckDisabled: c.checkDisabled,
})
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&createItemRequest{
URL: entryURL,
Title: entryTitle,
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
Private: c.private,
CheckDisabled: c.checkDisabled,
}).
WithHeader("Accept", "application/json").
WithHeader("Authorization", "Bearer "+c.apiKey).
Do()
if err != nil {
return fmt.Errorf("linkace: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linkace: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkace: unable to send request: %v", err)
return fmt.Errorf("linkace: %w", err)
}
defer response.Body.Close()
+11 -30
View File
@@ -4,22 +4,15 @@
package linkding // import "miniflux.app/v2/internal/integration/linkding"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
@@ -45,30 +38,18 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return fmt.Errorf(`linkding: invalid API endpoint: %v`, err)
}
requestBody, err := json.Marshal(&linkdingBookmark{
URL: entryURL,
Title: entryTitle,
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
Unread: c.unread,
})
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&linkdingBookmark{
URL: entryURL,
Title: entryTitle,
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
Unread: c.unread,
}).
WithHeader("Authorization", "Token "+c.apiKey).
Do()
if err != nil {
return fmt.Errorf("linkding: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linkding: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Token "+c.apiKey)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkding: unable to send request: %v", err)
return fmt.Errorf("linkding: %w", err)
}
defer response.Body.Close()
+6 -25
View File
@@ -4,22 +4,15 @@
package linkwarden // import "miniflux.app/v2/internal/integration/linkwarden"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
@@ -59,25 +52,13 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
payload.Collection = &linkwardenCollection{ID: c.collectionID}
}
requestBody, err := json.Marshal(payload)
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(payload).
WithHeader("Authorization", "Bearer "+c.apiKey).
Do()
if err != nil {
return fmt.Errorf("linkwarden: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linkwarden: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkwarden: unable to send request: %v", err)
return fmt.Errorf("linkwarden: %w", err)
}
defer response.Body.Close()
+16 -33
View File
@@ -4,19 +4,13 @@
package notion
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
apiToken string
pageID string
@@ -32,36 +26,25 @@ func (c *Client) UpdateDocument(entryURL string, entryTitle string) error {
}
apiEndpoint := "https://api.notion.com/v1/blocks/" + c.pageID + "/children"
requestBody, err := json.Marshal(&notionDocument{
Children: []block{
{
Object: "block",
Type: "bookmark",
Bookmark: bookmarkObject{
Caption: []any{},
URL: entryURL,
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPatch).
WithJSON(&notionDocument{
Children: []block{
{
Object: "block",
Type: "bookmark",
Bookmark: bookmarkObject{
Caption: []any{},
URL: entryURL,
},
},
},
},
})
}).
WithHeader("Notion-Version", "2022-06-28").
WithHeader("Authorization", "Bearer "+c.apiToken).
Do()
if err != nil {
return fmt.Errorf("notion: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPatch, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("notion: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Notion-Version", "2022-06-28")
request.Header.Set("Authorization", "Bearer "+c.apiToken)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("notion: unable to send request: %v", err)
return fmt.Errorf("notion: %w", err)
}
defer response.Body.Close()
+11 -30
View File
@@ -4,21 +4,14 @@
package raindrop // import "miniflux.app/v2/internal/integration/raindrop"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
token string
collectionID string
@@ -35,30 +28,18 @@ func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
return errors.New("raindrop: missing token")
}
var request *http.Request
requestBodyJson, err := json.Marshal(&raindrop{
Link: entryURL,
Title: entryTitle,
Collection: collection{Id: c.collectionID},
Tags: c.tags,
})
response, err := client.NewRequestBuilder("https://api.raindrop.io/rest/v1/raindrop").
WithMethod(http.MethodPost).
WithJSON(&raindrop{
Link: entryURL,
Title: entryTitle,
Collection: collection{Id: c.collectionID},
Tags: c.tags,
}).
WithHeader("Authorization", "Bearer "+c.token).
Do()
if err != nil {
return fmt.Errorf("raindrop: unable to encode request body: %v", err)
}
request, err = http.NewRequest(http.MethodPost, "https://api.raindrop.io/rest/v1/raindrop", bytes.NewReader(requestBodyJson))
if err != nil {
return fmt.Errorf("raindrop: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.token)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("raindrop: unable to send request: %v", err)
return fmt.Errorf("raindrop: %w", err)
}
defer response.Body.Close()
+9 -29
View File
@@ -6,22 +6,14 @@
package readwise // import "miniflux.app/v2/internal/integration/readwise"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const (
readwiseApiEndpoint = "https://readwise.io/api/v3/save/"
defaultClientTimeout = 10 * time.Second
)
const readwiseApiEndpoint = "https://readwise.io/api/v3/save/"
type Client struct {
apiKey string
@@ -36,27 +28,15 @@ func (c *Client) CreateDocument(entryURL string) error {
return errors.New("readwise: missing API key")
}
requestBody, err := json.Marshal(&readwiseDocument{
URL: entryURL,
})
response, err := client.NewRequestBuilder(readwiseApiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&readwiseDocument{
URL: entryURL,
}).
WithHeader("Authorization", "Token "+c.apiKey).
Do()
if err != nil {
return fmt.Errorf("readwise: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, readwiseApiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("readwise: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Token "+c.apiKey)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("readwise: unable to send request: %v", err)
return fmt.Errorf("readwise: %w", err)
}
defer response.Body.Close()
+11 -29
View File
@@ -4,24 +4,18 @@
package shaarli // import "miniflux.app/v2/internal/integration/shaarli"
import (
"bytes"
"crypto/hmac"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiSecret string
@@ -41,30 +35,18 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
return fmt.Errorf("shaarli: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&addLinkRequest{
URL: entryURL,
Title: entryTitle,
Private: true,
})
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&addLinkRequest{
URL: entryURL,
Title: entryTitle,
Private: true,
}).
WithHeader("Accept", "application/json").
WithHeader("Authorization", "Bearer "+c.generateBearerToken()).
Do()
if err != nil {
return fmt.Errorf("shaarli: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("shaarli: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.generateBearerToken())
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("shaarli: unable to send request: %v", err)
return fmt.Errorf("shaarli: %w", err)
}
defer response.Body.Close()
+20 -51
View File
@@ -4,21 +4,15 @@
package shiori // import "miniflux.app/v2/internal/integration/shiori"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
username string
@@ -44,34 +38,21 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return fmt.Errorf("shiori: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&addBookmarkRequest{
URL: entryURL,
Title: entryTitle,
Excerpt: "",
CreateArchive: true,
CreateEbook: false,
Public: 0,
Tags: make([]string, 0),
})
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&addBookmarkRequest{
URL: entryURL,
Title: entryTitle,
Excerpt: "",
CreateArchive: true,
CreateEbook: false,
Public: 0,
Tags: make([]string, 0),
}).
WithHeader("Authorization", "Bearer "+token).
Do()
if err != nil {
return fmt.Errorf("shiori: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("shiori: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+token)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("shiori: unable to send request: %v", err)
return fmt.Errorf("shiori: %w", err)
}
defer response.Body.Close()
@@ -88,25 +69,13 @@ func (c *Client) authenticate() (string, error) {
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password, RememberMe: false})
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&authRequest{Username: c.username, Password: c.password, RememberMe: false}).
WithHeader("Accept", "application/json").
Do()
if err != nil {
return "", fmt.Errorf("shiori: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return "", fmt.Errorf("shiori: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return "", fmt.Errorf("shiori: unable to send request: %v", err)
return "", fmt.Errorf("shiori: %w", err)
}
defer response.Body.Close()
+36 -53
View File
@@ -6,21 +6,15 @@
package slack // import "miniflux.app/v2/internal/integration/slack"
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
const slackMsgColor = "#5865F2"
type Client struct {
@@ -33,60 +27,49 @@ func NewClient(webhookURL string) *Client {
func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
for _, entry := range entries {
requestBody, err := json.Marshal(&slackMessage{
Attachments: []slackAttachments{
{
Title: "RSS feed update from Miniflux",
Color: slackMsgColor,
Fields: []slackFields{
{
Title: "Updated feed",
Value: feed.Title,
},
{
Title: "Article title",
Value: entry.Title,
},
{
Title: "Article link",
Value: entry.URL,
},
{
Title: "Author",
Value: entry.Author,
Short: true,
},
{
Title: "Source website",
Value: urllib.RootURL(feed.SiteURL),
Short: true,
},
},
},
},
})
if err != nil {
return fmt.Errorf("slack: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("slack: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
slog.Debug("Sending Slack notification",
slog.String("webhookURL", c.webhookURL),
slog.String("title", feed.Title),
slog.String("entry_url", entry.URL),
)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
response, err := client.NewRequestBuilder(c.webhookURL).
WithMethod(http.MethodPost).
WithJSON(&slackMessage{
Attachments: []slackAttachments{
{
Title: "RSS feed update from Miniflux",
Color: slackMsgColor,
Fields: []slackFields{
{
Title: "Updated feed",
Value: feed.Title,
},
{
Title: "Article title",
Value: entry.Title,
},
{
Title: "Article link",
Value: entry.URL,
},
{
Title: "Author",
Value: entry.Author,
Short: true,
},
{
Title: "Source website",
Value: urllib.RootURL(feed.SiteURL),
Short: true,
},
},
},
},
}).
Do()
if err != nil {
return fmt.Errorf("slack: unable to send request: %v", err)
return fmt.Errorf("slack: %w", err)
}
response.Body.Close()
+7 -18
View File
@@ -4,23 +4,18 @@
package webhook // import "miniflux.app/v2/internal/integration/webhook"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
const (
defaultClientTimeout = 10 * time.Second
NewEntriesEventType = "new_entries"
SaveEntryEventType = "save_entry"
)
@@ -124,20 +119,14 @@ func (c *Client) makeRequest(eventType string, payload any) error {
return fmt.Errorf("webhook: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
response, err := client.NewRequestBuilder(c.webhookURL).
WithMethod(http.MethodPost).
WithJSONBody(requestBody).
WithHeader("X-Miniflux-Signature", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody)).
WithHeader("X-Miniflux-Event-Type", eventType).
Do()
if err != nil {
return fmt.Errorf("webhook: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("X-Miniflux-Signature", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody))
request.Header.Set("X-Miniflux-Event-Type", eventType)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("webhook: unable to send request: %v", err)
return fmt.Errorf("webhook: %w", err)
}
defer response.Body.Close()