-
Notifications
You must be signed in to change notification settings - Fork 0
feat: HTTP manager for requests/response #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
78bda26
feat: add http package with basic structure
maniac-en 9b613b4
feat: add request execution
maniac-en 4e85afb
feat: support headers and query params
maniac-en aadb254
feat: add request body support
maniac-en 1910cd0
feat: wire up http manager in main
maniac-en 0a6afb6
fix: handle response body close error
maniac-en File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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")) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.