Skip to content

Commit e2a3424

Browse files
authored
feat: HTTP manager for requests/response #10
- feat: add http package with basic structure - feat: add request execution - feat: support headers and query params - feat: add request body support - feat: wire up http manager in main - fix: handle response body close error
2 parents e097e04 + 0a6afb6 commit e2a3424

File tree

4 files changed

+349
-3
lines changed

4 files changed

+349
-3
lines changed

internal/http/manager.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package http
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"net/url"
9+
"strings"
10+
"time"
11+
12+
"github.com/maniac-en/req/internal/log"
13+
)
14+
15+
func NewHTTPManager() *HTTPManager {
16+
client := &http.Client{
17+
Timeout: 30 * time.Second,
18+
}
19+
return &HTTPManager{
20+
Client: client,
21+
}
22+
}
23+
24+
func validateMethod(method string) error {
25+
method = strings.ToUpper(strings.TrimSpace(method))
26+
validMethods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
27+
for _, valid := range validMethods {
28+
if method == valid {
29+
return nil
30+
}
31+
}
32+
return fmt.Errorf("invalid HTTP method: %s", method)
33+
}
34+
35+
func validateURL(url string) error {
36+
url = strings.TrimSpace(url)
37+
if url == "" {
38+
return fmt.Errorf("URL cannot be empty")
39+
}
40+
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
41+
return fmt.Errorf("URL must start with http:// or https://")
42+
}
43+
return nil
44+
}
45+
46+
func (h *HTTPManager) ValidateRequest(req *Request) error {
47+
if err := validateMethod(req.Method); err != nil {
48+
log.Error("invalid method", "method", req.Method, "error", err)
49+
return err
50+
}
51+
if err := validateURL(req.URL); err != nil {
52+
log.Error("invalid URL", "url", req.URL, "error", err)
53+
return err
54+
}
55+
return nil
56+
}
57+
58+
func (h *HTTPManager) ExecuteRequest(req *Request) (*Response, error) {
59+
if err := h.ValidateRequest(req); err != nil {
60+
return nil, err
61+
}
62+
63+
log.Debug("executing HTTP request", "method", req.Method, "url", req.URL)
64+
65+
requestURL, err := h.buildURL(req.URL, req.QueryParams)
66+
if err != nil {
67+
log.Error("failed to build URL", "error", err)
68+
return nil, fmt.Errorf("failed to build URL: %w", err)
69+
}
70+
71+
start := time.Now()
72+
73+
var body io.Reader
74+
if req.Body != "" && (strings.ToUpper(req.Method) == "POST" || strings.ToUpper(req.Method) == "PUT" || strings.ToUpper(req.Method) == "PATCH") {
75+
body = strings.NewReader(req.Body)
76+
}
77+
78+
httpReq, err := http.NewRequest(strings.ToUpper(req.Method), requestURL, body)
79+
if err != nil {
80+
log.Error("failed to create HTTP request", "error", err)
81+
return nil, fmt.Errorf("failed to create request: %w", err)
82+
}
83+
84+
if body != nil {
85+
h.setContentType(httpReq, req.Body)
86+
}
87+
88+
if err := h.setHeaders(httpReq, req.Headers); err != nil {
89+
log.Error("failed to set headers", "error", err)
90+
return nil, fmt.Errorf("failed to set headers: %w", err)
91+
}
92+
93+
resp, err := h.Client.Do(httpReq)
94+
if err != nil {
95+
log.Error("HTTP request failed", "error", err)
96+
return nil, fmt.Errorf("request failed: %w", err)
97+
}
98+
defer func() {
99+
if closeErr := resp.Body.Close(); closeErr != nil {
100+
log.Error("failed to close response body", "error", closeErr)
101+
}
102+
}()
103+
104+
duration := time.Since(start)
105+
106+
response := &Response{
107+
StatusCode: resp.StatusCode,
108+
Status: resp.Status,
109+
Headers: resp.Header,
110+
Duration: duration,
111+
}
112+
113+
log.Info("HTTP request completed", "status", resp.StatusCode, "duration", duration)
114+
return response, nil
115+
}
116+
117+
func (h *HTTPManager) buildURL(baseURL string, queryParams map[string]string) (string, error) {
118+
if len(queryParams) == 0 {
119+
return baseURL, nil
120+
}
121+
122+
parsedURL, err := url.Parse(baseURL)
123+
if err != nil {
124+
return "", err
125+
}
126+
127+
values := parsedURL.Query()
128+
for key, value := range queryParams {
129+
values.Set(key, value)
130+
}
131+
parsedURL.RawQuery = values.Encode()
132+
133+
return parsedURL.String(), nil
134+
}
135+
136+
func (h *HTTPManager) setHeaders(req *http.Request, headers map[string]string) error {
137+
for key, value := range headers {
138+
if strings.TrimSpace(key) == "" {
139+
return fmt.Errorf("header key cannot be empty")
140+
}
141+
req.Header.Set(key, value)
142+
}
143+
return nil
144+
}
145+
146+
func (h *HTTPManager) setContentType(req *http.Request, body string) {
147+
if req.Header.Get("Content-Type") != "" {
148+
return
149+
}
150+
151+
body = strings.TrimSpace(body)
152+
if strings.HasPrefix(body, "{") || strings.HasPrefix(body, "[") {
153+
if json.Valid([]byte(body)) {
154+
req.Header.Set("Content-Type", "application/json")
155+
return
156+
}
157+
}
158+
159+
req.Header.Set("Content-Type", "text/plain")
160+
}

internal/http/manager_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package http
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestNewHTTPManager(t *testing.T) {
10+
manager := NewHTTPManager()
11+
if manager == nil {
12+
t.Fatal("NewHTTPManager returned nil")
13+
}
14+
if manager.Client == nil {
15+
t.Fatal("HTTPManager client is nil")
16+
}
17+
if manager.Client.Timeout != 30*time.Second {
18+
t.Errorf("expected timeout 30s, got %v", manager.Client.Timeout)
19+
}
20+
}
21+
22+
func TestValidateMethod(t *testing.T) {
23+
tests := []struct {
24+
method string
25+
valid bool
26+
}{
27+
{"GET", true},
28+
{"POST", true},
29+
{"put", true},
30+
{"delete", true},
31+
{"INVALID", false},
32+
{"", false},
33+
}
34+
35+
for _, test := range tests {
36+
err := validateMethod(test.method)
37+
if test.valid && err != nil {
38+
t.Errorf("expected %s to be valid, got error: %v", test.method, err)
39+
}
40+
if !test.valid && err == nil {
41+
t.Errorf("expected %s to be invalid, got no error", test.method)
42+
}
43+
}
44+
}
45+
46+
func TestValidateURL(t *testing.T) {
47+
tests := []struct {
48+
url string
49+
valid bool
50+
}{
51+
{"https://example.com", true},
52+
{"http://localhost:8080", true},
53+
{"ftp://invalid.com", false},
54+
{"", false},
55+
{"not-a-url", false},
56+
}
57+
58+
for _, test := range tests {
59+
err := validateURL(test.url)
60+
if test.valid && err != nil {
61+
t.Errorf("expected %s to be valid, got error: %v", test.url, err)
62+
}
63+
if !test.valid && err == nil {
64+
t.Errorf("expected %s to be invalid, got no error", test.url)
65+
}
66+
}
67+
}
68+
69+
func TestValidateRequest(t *testing.T) {
70+
manager := NewHTTPManager()
71+
72+
validReq := &Request{
73+
Method: "GET",
74+
URL: "https://example.com",
75+
}
76+
77+
if err := manager.ValidateRequest(validReq); err != nil {
78+
t.Errorf("expected valid request to pass validation, got: %v", err)
79+
}
80+
81+
invalidReq := &Request{
82+
Method: "INVALID",
83+
URL: "not-a-url",
84+
}
85+
86+
if err := manager.ValidateRequest(invalidReq); err == nil {
87+
t.Error("expected invalid request to fail validation")
88+
}
89+
}
90+
91+
func TestBuildURL(t *testing.T) {
92+
manager := NewHTTPManager()
93+
94+
tests := []struct {
95+
baseURL string
96+
queryParams map[string]string
97+
expected string
98+
}{
99+
{"https://example.com", nil, "https://example.com"},
100+
{"https://example.com", map[string]string{}, "https://example.com"},
101+
{"https://example.com", map[string]string{"foo": "bar"}, "https://example.com?foo=bar"},
102+
}
103+
104+
for _, test := range tests {
105+
result, err := manager.buildURL(test.baseURL, test.queryParams)
106+
if err != nil {
107+
t.Errorf("buildURL failed: %v", err)
108+
}
109+
if result != test.expected {
110+
t.Errorf("expected %s, got %s", test.expected, result)
111+
}
112+
}
113+
}
114+
115+
func TestSetHeaders(t *testing.T) {
116+
manager := NewHTTPManager()
117+
req, _ := http.NewRequest("GET", "https://example.com", nil)
118+
119+
headers := map[string]string{
120+
"Content-Type": "application/json",
121+
"User-Agent": "req-cli",
122+
}
123+
124+
err := manager.setHeaders(req, headers)
125+
if err != nil {
126+
t.Errorf("setHeaders failed: %v", err)
127+
}
128+
129+
if req.Header.Get("Content-Type") != "application/json" {
130+
t.Error("Content-Type header not set correctly")
131+
}
132+
}
133+
134+
func TestSetContentType(t *testing.T) {
135+
manager := NewHTTPManager()
136+
137+
tests := []struct {
138+
body string
139+
expected string
140+
}{
141+
{`{"key": "value"}`, "application/json"},
142+
{`[1, 2, 3]`, "application/json"},
143+
{"plain text", "text/plain"},
144+
}
145+
146+
for _, test := range tests {
147+
req, _ := http.NewRequest("POST", "https://example.com", nil)
148+
manager.setContentType(req, test.body)
149+
150+
if req.Header.Get("Content-Type") != test.expected {
151+
t.Errorf("for body %q, expected %q, got %q",
152+
test.body, test.expected, req.Header.Get("Content-Type"))
153+
}
154+
}
155+
}

internal/http/models.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Package http provides HTTP client functionality for making HTTP requests.
2+
package http
3+
4+
import (
5+
"net/http"
6+
"time"
7+
)
8+
9+
type HTTPManager struct {
10+
Client *http.Client
11+
}
12+
13+
type Request struct {
14+
Method string
15+
URL string
16+
Headers map[string]string
17+
QueryParams map[string]string
18+
Body string
19+
}
20+
21+
type Response struct {
22+
StatusCode int
23+
Status string
24+
Headers map[string][]string
25+
Body string
26+
Duration time.Duration
27+
}

main.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"github.com/maniac-en/req/internal/collections"
1515
"github.com/maniac-en/req/internal/database"
16+
"github.com/maniac-en/req/internal/http"
1617
"github.com/maniac-en/req/internal/log"
1718
_ "github.com/mattn/go-sqlite3"
1819
"github.com/pressly/goose/v3"
@@ -34,6 +35,7 @@ var (
3435
type Config struct {
3536
DB *database.Queries
3637
Collections *collections.CollectionsManager
38+
HTTP *http.HTTPManager
3739
}
3840

3941
func initPaths() error {
@@ -124,16 +126,18 @@ func main() {
124126
log.Fatal("failed to run migrations", "error", err)
125127
}
126128

127-
// create database client and collections manager
129+
// create database client and managers
128130
db := database.New(DB)
129131
collectionsManager := collections.NewCollectionsManager(db)
132+
httpManager := http.NewHTTPManager()
130133

131134
config := &Config{
132135
DB: db,
133136
Collections: collectionsManager,
137+
HTTP: httpManager,
134138
}
135139

136-
log.Info("application initialized", "components", []string{"database", "collections", "logging"})
137-
log.Debug("configuration loaded", "collections_manager", config.Collections != nil, "database", config.DB != nil)
140+
log.Info("application initialized", "components", []string{"database", "collections", "http", "logging"})
141+
log.Debug("configuration loaded", "collections_manager", config.Collections != nil, "database", config.DB != nil, "http_manager", config.HTTP != nil)
138142
log.Info("application started successfully")
139143
}

0 commit comments

Comments
 (0)