Преглед изворни кода

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
jvoisin пре 2 месеци
родитељ
комит
706a92e700

+ 97 - 0
internal/http/client/client.go

@@ -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 - 0
internal/http/client/client_test.go

@@ -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
internal/integration/cubox/cubox.go

@@ -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,
-	})
-	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)
+	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 send request: %w", err)
+		return fmt.Errorf("cubox: %w", err)
 	}
 	defer response.Body.Close()
 

+ 32 - 49
internal/integration/discord/discord.go

@@ -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
internal/integration/espial/espial.go

@@ -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,
-	})
-
-	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)
+	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 send request: %v", err)
+		return fmt.Errorf("espial: %w", err)
 	}
 	defer response.Body.Close()
 

+ 13 - 31
internal/integration/linkace/linkace.go

@@ -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,
-	})
-	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)
+	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 send request: %v", err)
+		return fmt.Errorf("linkace: %w", err)
 	}
 	defer response.Body.Close()
 

+ 11 - 30
internal/integration/linkding/linkding.go

@@ -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,
-	})
-
-	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)
+	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 send request: %v", err)
+		return fmt.Errorf("linkding: %w", err)
 	}
 	defer response.Body.Close()
 

+ 6 - 25
internal/integration/linkwarden/linkwarden.go

@@ -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)
-
-	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)
+	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 send request: %v", err)
+		return fmt.Errorf("linkwarden: %w", err)
 	}
 	defer response.Body.Close()
 

+ 16 - 33
internal/integration/notion/notion.go

@@ -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,
+					},
 				},
 			},
-		},
-	})
-	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)
+		}).
+		WithHeader("Notion-Version", "2022-06-28").
+		WithHeader("Authorization", "Bearer "+c.apiToken).
+		Do()
 	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
internal/integration/raindrop/raindrop.go

@@ -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,
-	})
-	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)
+	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 send request: %v", err)
+		return fmt.Errorf("raindrop: %w", err)
 	}
 	defer response.Body.Close()
 

+ 9 - 29
internal/integration/readwise/readwise.go

@@ -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,
-	})
-
-	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)
+	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 send request: %v", err)
+		return fmt.Errorf("readwise: %w", err)
 	}
 	defer response.Body.Close()
 

+ 11 - 29
internal/integration/shaarli/shaarli.go

@@ -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,
-	})
-
-	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)
+	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 send request: %v", err)
+		return fmt.Errorf("shaarli: %w", err)
 	}
 	defer response.Body.Close()
 

+ 20 - 51
internal/integration/shiori/shiori.go

@@ -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),
-	})
-
-	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)
+	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 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})
-	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)
+	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 send request: %v", err)
+		return "", fmt.Errorf("shiori: %w", err)
 	}
 	defer response.Body.Close()
 

+ 36 - 53
internal/integration/slack/slack.go

@@ -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
internal/integration/webhook/webhook.go

@@ -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))
-	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)
+	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 send request: %v", err)
+		return fmt.Errorf("webhook: %w", err)
 	}
 	defer response.Body.Close()