diff --git a/internal/http/manager.go b/internal/http/manager.go new file mode 100644 index 0000000..0e26548 --- /dev/null +++ b/internal/http/manager.go @@ -0,0 +1,160 @@ +package http + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "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 +} + +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) + + 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() + + 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) + } + + 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 func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Error("failed to close response body", "error", closeErr) + } + }() + + 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 +} + +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 +} + +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 new file mode 100644 index 0000000..6a24637 --- /dev/null +++ b/internal/http/manager_test.go @@ -0,0 +1,155 @@ +package http + +import ( + "net/http" + "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") + } +} + +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") + } +} + +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")) + } + } +} 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 +} 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") }