From 78bda2643ac7a3608183273de308e2885af32fbb Mon Sep 17 00:00:00 2001 From: Shivam Mehta Date: Sat, 26 Jul 2025 10:37:44 +0530 Subject: [PATCH 1/6] feat: add http package with basic structure Create request/response models and validation --- internal/http/manager.go | 53 +++++++++++++++++++++ internal/http/manager_test.go | 88 +++++++++++++++++++++++++++++++++++ internal/http/models.go | 27 +++++++++++ 3 files changed, 168 insertions(+) create mode 100644 internal/http/manager.go create mode 100644 internal/http/manager_test.go create mode 100644 internal/http/models.go diff --git a/internal/http/manager.go b/internal/http/manager.go new file mode 100644 index 0000000..5ed01b7 --- /dev/null +++ b/internal/http/manager.go @@ -0,0 +1,53 @@ +package http + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/maniac-en/req/internal/log" +) + +func NewHTTPManager() *HTTPManager { + client := &http.Client{ + Timeout: 30 * time.Second, + } + return &HTTPManager{ + Client: client, + } +} + +func validateMethod(method string) error { + method = strings.ToUpper(strings.TrimSpace(method)) + validMethods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} + for _, valid := range validMethods { + if method == valid { + return nil + } + } + return fmt.Errorf("invalid HTTP method: %s", method) +} + +func validateURL(url string) error { + url = strings.TrimSpace(url) + if url == "" { + return fmt.Errorf("URL cannot be empty") + } + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return fmt.Errorf("URL must start with http:// or https://") + } + return nil +} + +func (h *HTTPManager) ValidateRequest(req *Request) error { + if err := validateMethod(req.Method); err != nil { + log.Error("invalid method", "method", req.Method, "error", err) + return err + } + if err := validateURL(req.URL); err != nil { + log.Error("invalid URL", "url", req.URL, "error", err) + return err + } + return nil +} diff --git a/internal/http/manager_test.go b/internal/http/manager_test.go new file mode 100644 index 0000000..69b699c --- /dev/null +++ b/internal/http/manager_test.go @@ -0,0 +1,88 @@ +package http + +import ( + "testing" + "time" +) + +func TestNewHTTPManager(t *testing.T) { + manager := NewHTTPManager() + if manager == nil { + t.Fatal("NewHTTPManager returned nil") + } + if manager.Client == nil { + t.Fatal("HTTPManager client is nil") + } + if manager.Client.Timeout != 30*time.Second { + t.Errorf("expected timeout 30s, got %v", manager.Client.Timeout) + } +} + +func TestValidateMethod(t *testing.T) { + tests := []struct { + method string + valid bool + }{ + {"GET", true}, + {"POST", true}, + {"put", true}, + {"delete", true}, + {"INVALID", false}, + {"", false}, + } + + for _, test := range tests { + err := validateMethod(test.method) + if test.valid && err != nil { + t.Errorf("expected %s to be valid, got error: %v", test.method, err) + } + if !test.valid && err == nil { + t.Errorf("expected %s to be invalid, got no error", test.method) + } + } +} + +func TestValidateURL(t *testing.T) { + tests := []struct { + url string + valid bool + }{ + {"https://example.com", true}, + {"http://localhost:8080", true}, + {"ftp://invalid.com", false}, + {"", false}, + {"not-a-url", false}, + } + + for _, test := range tests { + err := validateURL(test.url) + if test.valid && err != nil { + t.Errorf("expected %s to be valid, got error: %v", test.url, err) + } + if !test.valid && err == nil { + t.Errorf("expected %s to be invalid, got no error", test.url) + } + } +} + +func TestValidateRequest(t *testing.T) { + manager := NewHTTPManager() + + validReq := &Request{ + Method: "GET", + URL: "https://example.com", + } + + if err := manager.ValidateRequest(validReq); err != nil { + t.Errorf("expected valid request to pass validation, got: %v", err) + } + + invalidReq := &Request{ + Method: "INVALID", + URL: "not-a-url", + } + + if err := manager.ValidateRequest(invalidReq); err == nil { + t.Error("expected invalid request to fail validation") + } +} diff --git a/internal/http/models.go b/internal/http/models.go new file mode 100644 index 0000000..20ad82c --- /dev/null +++ b/internal/http/models.go @@ -0,0 +1,27 @@ +// Package http provides HTTP client functionality for making HTTP requests. +package http + +import ( + "net/http" + "time" +) + +type HTTPManager struct { + Client *http.Client +} + +type Request struct { + Method string + URL string + Headers map[string]string + QueryParams map[string]string + Body string +} + +type Response struct { + StatusCode int + Status string + Headers map[string][]string + Body string + Duration time.Duration +} From 9b613b47ec08f437e33b434b0c54ddcf51c4c11f Mon Sep 17 00:00:00 2001 From: Shivam Mehta Date: Sat, 26 Jul 2025 12:15:45 +0530 Subject: [PATCH 2/6] feat: add request execution Execute HTTP requests and measure timing --- internal/http/manager.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/http/manager.go b/internal/http/manager.go index 5ed01b7..03fb933 100644 --- a/internal/http/manager.go +++ b/internal/http/manager.go @@ -51,3 +51,37 @@ func (h *HTTPManager) ValidateRequest(req *Request) error { } return nil } + +func (h *HTTPManager) ExecuteRequest(req *Request) (*Response, error) { + if err := h.ValidateRequest(req); err != nil { + return nil, err + } + + log.Debug("executing HTTP request", "method", req.Method, "url", req.URL) + + start := time.Now() + httpReq, err := http.NewRequest(strings.ToUpper(req.Method), req.URL, nil) + if err != nil { + log.Error("failed to create HTTP request", "error", err) + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := h.Client.Do(httpReq) + if err != nil { + log.Error("HTTP request failed", "error", err) + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + duration := time.Since(start) + + response := &Response{ + StatusCode: resp.StatusCode, + Status: resp.Status, + Headers: resp.Header, + Duration: duration, + } + + log.Info("HTTP request completed", "status", resp.StatusCode, "duration", duration) + return response, nil +} From 4e85afbb2b6c2f62ab636c6471a6bb0ff5e65bd2 Mon Sep 17 00:00:00 2001 From: Shivam Mehta Date: Sat, 26 Jul 2025 14:21:37 +0530 Subject: [PATCH 3/6] feat: support headers and query params Handle URL building and header setting --- internal/http/manager.go | 43 +++++++++++++++++++++++++++++++++- internal/http/manager_test.go | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/internal/http/manager.go b/internal/http/manager.go index 03fb933..fb33f57 100644 --- a/internal/http/manager.go +++ b/internal/http/manager.go @@ -3,6 +3,7 @@ package http import ( "fmt" "net/http" + "net/url" "strings" "time" @@ -59,13 +60,24 @@ func (h *HTTPManager) ExecuteRequest(req *Request) (*Response, error) { log.Debug("executing HTTP request", "method", req.Method, "url", req.URL) + requestURL, err := h.buildURL(req.URL, req.QueryParams) + if err != nil { + log.Error("failed to build URL", "error", err) + return nil, fmt.Errorf("failed to build URL: %w", err) + } + start := time.Now() - httpReq, err := http.NewRequest(strings.ToUpper(req.Method), req.URL, nil) + httpReq, err := http.NewRequest(strings.ToUpper(req.Method), requestURL, nil) if err != nil { log.Error("failed to create HTTP request", "error", err) return nil, fmt.Errorf("failed to create request: %w", err) } + if err := h.setHeaders(httpReq, req.Headers); err != nil { + log.Error("failed to set headers", "error", err) + return nil, fmt.Errorf("failed to set headers: %w", err) + } + resp, err := h.Client.Do(httpReq) if err != nil { log.Error("HTTP request failed", "error", err) @@ -85,3 +97,32 @@ func (h *HTTPManager) ExecuteRequest(req *Request) (*Response, error) { log.Info("HTTP request completed", "status", resp.StatusCode, "duration", duration) return response, nil } + +func (h *HTTPManager) buildURL(baseURL string, queryParams map[string]string) (string, error) { + if len(queryParams) == 0 { + return baseURL, nil + } + + parsedURL, err := url.Parse(baseURL) + if err != nil { + return "", err + } + + values := parsedURL.Query() + for key, value := range queryParams { + values.Set(key, value) + } + parsedURL.RawQuery = values.Encode() + + return parsedURL.String(), nil +} + +func (h *HTTPManager) setHeaders(req *http.Request, headers map[string]string) error { + for key, value := range headers { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("header key cannot be empty") + } + req.Header.Set(key, value) + } + return nil +} diff --git a/internal/http/manager_test.go b/internal/http/manager_test.go index 69b699c..35c9bf9 100644 --- a/internal/http/manager_test.go +++ b/internal/http/manager_test.go @@ -1,6 +1,7 @@ package http import ( + "net/http" "testing" "time" ) @@ -86,3 +87,46 @@ func TestValidateRequest(t *testing.T) { t.Error("expected invalid request to fail validation") } } + +func TestBuildURL(t *testing.T) { + manager := NewHTTPManager() + + tests := []struct { + baseURL string + queryParams map[string]string + expected string + }{ + {"https://example.com", nil, "https://example.com"}, + {"https://example.com", map[string]string{}, "https://example.com"}, + {"https://example.com", map[string]string{"foo": "bar"}, "https://example.com?foo=bar"}, + } + + for _, test := range tests { + result, err := manager.buildURL(test.baseURL, test.queryParams) + if err != nil { + t.Errorf("buildURL failed: %v", err) + } + if result != test.expected { + t.Errorf("expected %s, got %s", test.expected, result) + } + } +} + +func TestSetHeaders(t *testing.T) { + manager := NewHTTPManager() + req, _ := http.NewRequest("GET", "https://example.com", nil) + + headers := map[string]string{ + "Content-Type": "application/json", + "User-Agent": "req-cli", + } + + err := manager.setHeaders(req, headers) + if err != nil { + t.Errorf("setHeaders failed: %v", err) + } + + if req.Header.Get("Content-Type") != "application/json" { + t.Error("Content-Type header not set correctly") + } +} From aadb25400b9a887e84857655a0fa5f6c1a0fc3d7 Mon Sep 17 00:00:00 2001 From: Shivam Mehta Date: Sat, 26 Jul 2025 15:56:01 +0530 Subject: [PATCH 4/6] feat: add request body support Handle POST/PUT bodies with auto content-type --- internal/http/manager.go | 30 +++++++++++++++++++++++++++++- internal/http/manager_test.go | 23 +++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/internal/http/manager.go b/internal/http/manager.go index fb33f57..03a51a9 100644 --- a/internal/http/manager.go +++ b/internal/http/manager.go @@ -1,7 +1,9 @@ package http import ( + "encoding/json" "fmt" + "io" "net/http" "net/url" "strings" @@ -67,12 +69,22 @@ func (h *HTTPManager) ExecuteRequest(req *Request) (*Response, error) { } start := time.Now() - httpReq, err := http.NewRequest(strings.ToUpper(req.Method), requestURL, nil) + + var body io.Reader + if req.Body != "" && (strings.ToUpper(req.Method) == "POST" || strings.ToUpper(req.Method) == "PUT" || strings.ToUpper(req.Method) == "PATCH") { + body = strings.NewReader(req.Body) + } + + httpReq, err := http.NewRequest(strings.ToUpper(req.Method), requestURL, body) if err != nil { log.Error("failed to create HTTP request", "error", err) return nil, fmt.Errorf("failed to create request: %w", err) } + if body != nil { + h.setContentType(httpReq, req.Body) + } + if err := h.setHeaders(httpReq, req.Headers); err != nil { log.Error("failed to set headers", "error", err) return nil, fmt.Errorf("failed to set headers: %w", err) @@ -126,3 +138,19 @@ func (h *HTTPManager) setHeaders(req *http.Request, headers map[string]string) e } return nil } + +func (h *HTTPManager) setContentType(req *http.Request, body string) { + if req.Header.Get("Content-Type") != "" { + return + } + + body = strings.TrimSpace(body) + if strings.HasPrefix(body, "{") || strings.HasPrefix(body, "[") { + if json.Valid([]byte(body)) { + req.Header.Set("Content-Type", "application/json") + return + } + } + + req.Header.Set("Content-Type", "text/plain") +} diff --git a/internal/http/manager_test.go b/internal/http/manager_test.go index 35c9bf9..6a24637 100644 --- a/internal/http/manager_test.go +++ b/internal/http/manager_test.go @@ -130,3 +130,26 @@ func TestSetHeaders(t *testing.T) { t.Error("Content-Type header not set correctly") } } + +func TestSetContentType(t *testing.T) { + manager := NewHTTPManager() + + tests := []struct { + body string + expected string + }{ + {`{"key": "value"}`, "application/json"}, + {`[1, 2, 3]`, "application/json"}, + {"plain text", "text/plain"}, + } + + for _, test := range tests { + req, _ := http.NewRequest("POST", "https://example.com", nil) + manager.setContentType(req, test.body) + + if req.Header.Get("Content-Type") != test.expected { + t.Errorf("for body %q, expected %q, got %q", + test.body, test.expected, req.Header.Get("Content-Type")) + } + } +} From 1910cd0da424778a918526de72b19fc2030cbb08 Mon Sep 17 00:00:00 2001 From: Shivam Mehta Date: Sat, 26 Jul 2025 18:46:39 +0530 Subject: [PATCH 5/6] feat: wire up http manager in main Add to config and logging --- main.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 82e3734..d4a0719 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "github.com/maniac-en/req/internal/collections" "github.com/maniac-en/req/internal/database" + "github.com/maniac-en/req/internal/http" "github.com/maniac-en/req/internal/log" _ "github.com/mattn/go-sqlite3" "github.com/pressly/goose/v3" @@ -34,6 +35,7 @@ var ( type Config struct { DB *database.Queries Collections *collections.CollectionsManager + HTTP *http.HTTPManager } func initPaths() error { @@ -124,16 +126,18 @@ func main() { log.Fatal("failed to run migrations", "error", err) } - // create database client and collections manager + // create database client and managers db := database.New(DB) collectionsManager := collections.NewCollectionsManager(db) + httpManager := http.NewHTTPManager() config := &Config{ DB: db, Collections: collectionsManager, + HTTP: httpManager, } - log.Info("application initialized", "components", []string{"database", "collections", "logging"}) - log.Debug("configuration loaded", "collections_manager", config.Collections != nil, "database", config.DB != nil) + log.Info("application initialized", "components", []string{"database", "collections", "http", "logging"}) + log.Debug("configuration loaded", "collections_manager", config.Collections != nil, "database", config.DB != nil, "http_manager", config.HTTP != nil) log.Info("application started successfully") } From 0a6afb6cce5dade35ff229ff6fd76826f10b51a7 Mon Sep 17 00:00:00 2001 From: Shivam Mehta Date: Sat, 26 Jul 2025 18:54:11 +0530 Subject: [PATCH 6/6] fix: handle response body close error Properly check and log close errors --- internal/http/manager.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/http/manager.go b/internal/http/manager.go index 03a51a9..0e26548 100644 --- a/internal/http/manager.go +++ b/internal/http/manager.go @@ -95,7 +95,11 @@ func (h *HTTPManager) ExecuteRequest(req *Request) (*Response, error) { log.Error("HTTP request failed", "error", err) return nil, fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Error("failed to close response body", "error", closeErr) + } + }() duration := time.Since(start)