go implementation

This commit is contained in:
Илья Глазунов
2025-12-11 16:52:13 +03:00
parent c04ab283a6
commit 8f5b9a5cd1
20 changed files with 4146 additions and 1 deletions
+320
View File
@@ -0,0 +1,320 @@
// Package proxy provides reverse proxy functionality for Konduktor
package proxy
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/konduktor/konduktor/internal/logging"
)
type Config struct {
// Target is the backend server URL
Target string
// Timeout is the request timeout (default: 30s)
Timeout time.Duration
// Headers are additional headers to add to requests
Headers map[string]string
// StripPrefix removes this prefix from the request path
StripPrefix string
// PreserveHost keeps the original Host header
PreserveHost bool
}
type ReverseProxy struct {
config *Config
targetURL *url.URL
httpClient *http.Client
logger *logging.Logger
}
func New(cfg *Config, logger *logging.Logger) (*ReverseProxy, error) {
if cfg.Target == "" {
return nil, fmt.Errorf("proxy target is required")
}
targetURL, err := url.Parse(cfg.Target)
if err != nil {
return nil, fmt.Errorf("invalid proxy target URL: %w", err)
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = 30 * time.Second
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: timeout,
}
return &ReverseProxy{
config: cfg,
targetURL: targetURL,
httpClient: &http.Client{
Transport: transport,
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // Don't follow redirects
},
},
logger: logger,
}, nil
}
func (rp *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rp.ProxyRequest(w, r, nil)
}
func (rp *ReverseProxy) ProxyRequest(w http.ResponseWriter, r *http.Request, params map[string]string) {
ctx := r.Context()
// Build target URL
targetURL := rp.buildTargetURL(r)
// Create proxy request
proxyReq, err := rp.createProxyRequest(ctx, r, targetURL)
if err != nil {
rp.handleError(w, http.StatusInternalServerError, "Failed to create proxy request", err)
return
}
// Add custom headers with parameter substitution
rp.addCustomHeaders(proxyReq, r, params)
// Execute request
resp, err := rp.httpClient.Do(proxyReq)
if err != nil {
rp.handleProxyError(w, err)
return
}
defer resp.Body.Close()
// Copy response
rp.copyResponse(w, resp)
}
func (rp *ReverseProxy) buildTargetURL(r *http.Request) *url.URL {
targetURL := *rp.targetURL
// Strip prefix if configured
path := r.URL.Path
if rp.config.StripPrefix != "" {
path = strings.TrimPrefix(path, rp.config.StripPrefix)
if path == "" || path[0] != '/' {
path = "/" + path
}
}
// If target has a path, append request path to it
if rp.targetURL.Path != "" && rp.targetURL.Path != "/" {
targetURL.Path = singleJoiningSlash(rp.targetURL.Path, path)
} else {
targetURL.Path = path
}
// Preserve query string
targetURL.RawQuery = r.URL.RawQuery
return &targetURL
}
func (rp *ReverseProxy) createProxyRequest(ctx context.Context, r *http.Request, targetURL *url.URL) (*http.Request, error) {
proxyReq, err := http.NewRequestWithContext(ctx, r.Method, targetURL.String(), r.Body)
if err != nil {
return nil, err
}
// Copy ContentLength
proxyReq.ContentLength = r.ContentLength
// Copy headers
for key, values := range r.Header {
for _, value := range values {
proxyReq.Header.Add(key, value)
}
}
// Set/update Host header
if rp.config.PreserveHost {
proxyReq.Host = r.Host
} else {
proxyReq.Host = targetURL.Host
}
// Remove hop-by-hop headers
removeHopByHopHeaders(proxyReq.Header)
return proxyReq, nil
}
func (rp *ReverseProxy) addCustomHeaders(proxyReq *http.Request, originalReq *http.Request, params map[string]string) {
// Add X-Forwarded headers
clientIP := getClientIP(originalReq)
if prior := originalReq.Header.Get("X-Forwarded-For"); prior != "" {
clientIP = prior + ", " + clientIP
}
proxyReq.Header.Set("X-Forwarded-For", clientIP)
proxyReq.Header.Set("X-Forwarded-Proto", getScheme(originalReq))
proxyReq.Header.Set("X-Forwarded-Host", originalReq.Host)
// Add custom headers from config
for key, value := range rp.config.Headers {
// Substitute parameters like {version}
substituted := value
for paramKey, paramValue := range params {
substituted = strings.ReplaceAll(substituted, "{"+paramKey+"}", paramValue)
}
// Substitute $remote_addr
substituted = strings.ReplaceAll(substituted, "$remote_addr", clientIP)
proxyReq.Header.Set(key, substituted)
}
}
func (rp *ReverseProxy) copyResponse(w http.ResponseWriter, resp *http.Response) {
// Copy headers
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
// Remove hop-by-hop headers from response
removeHopByHopHeaders(w.Header())
// Write status code
w.WriteHeader(resp.StatusCode)
// Copy body
io.Copy(w, resp.Body)
}
func (rp *ReverseProxy) handleError(w http.ResponseWriter, status int, message string, err error) {
if rp.logger != nil {
rp.logger.Error(message, "error", err)
}
http.Error(w, message, status)
}
func (rp *ReverseProxy) handleProxyError(w http.ResponseWriter, err error) {
if rp.logger != nil {
rp.logger.Error("Proxy request failed", "error", err)
}
// Check for timeout
if err, ok := err.(net.Error); ok && err.Timeout() {
http.Error(w, "504 Gateway Timeout", http.StatusGatewayTimeout)
return
}
// Check for connection errors
if isConnectionError(err) {
http.Error(w, "502 Bad Gateway", http.StatusBadGateway)
return
}
// Context cancelled (client disconnected)
if err == context.Canceled {
return
}
http.Error(w, "502 Bad Gateway", http.StatusBadGateway)
}
// Helper functions
func singleJoiningSlash(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
switch {
case aslash && bslash:
return a + b[1:]
case !aslash && !bslash:
return a + "/" + b
}
return a + b
}
func removeHopByHopHeaders(h http.Header) {
hopByHopHeaders := []string{
"Connection",
"Proxy-Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"Te",
"Trailer",
"Transfer-Encoding",
"Upgrade",
}
for _, header := range hopByHopHeaders {
h.Del(header)
}
}
func getClientIP(r *http.Request) string {
// Check X-Real-IP first
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
// Get from RemoteAddr
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func getScheme(r *http.Request) string {
if r.TLS != nil {
return "https"
}
if scheme := r.Header.Get("X-Forwarded-Proto"); scheme != "" {
return scheme
}
return "http"
}
func isConnectionError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
connectionErrors := []string{
"connection refused",
"no such host",
"network is unreachable",
"connection reset",
"broken pipe",
}
for _, connErr := range connectionErrors {
if strings.Contains(strings.ToLower(errStr), connErr) {
return true
}
}
return false
}
+747
View File
@@ -0,0 +1,747 @@
package proxy
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// ============== Test Backend Server ==============
type testBackend struct {
server *httptest.Server
requestLog []requestLogEntry
mu sync.Mutex
requestCount int64
}
type requestLogEntry struct {
Method string
Path string
Query string
Headers http.Header
Body string
}
func newTestBackend(handler http.HandlerFunc) *testBackend {
tb := &testBackend{
requestLog: make([]requestLogEntry, 0),
}
if handler == nil {
handler = tb.defaultHandler
}
tb.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tb.logRequest(r)
handler(w, r)
}))
return tb
}
func (tb *testBackend) logRequest(r *http.Request) {
tb.mu.Lock()
defer tb.mu.Unlock()
body, _ := io.ReadAll(r.Body)
// Restore the body for the handler
r.Body = io.NopCloser(bytes.NewReader(body))
tb.requestLog = append(tb.requestLog, requestLogEntry{
Method: r.Method,
Path: r.URL.Path,
Query: r.URL.RawQuery,
Headers: r.Header.Clone(),
Body: string(body),
})
atomic.AddInt64(&tb.requestCount, 1)
}
func (tb *testBackend) defaultHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Backend response",
"path": r.URL.Path,
"method": r.Method,
})
}
func (tb *testBackend) close() {
tb.server.Close()
}
func (tb *testBackend) URL() string {
return tb.server.URL
}
func (tb *testBackend) getRequestCount() int64 {
return atomic.LoadInt64(&tb.requestCount)
}
func (tb *testBackend) getLastRequest() *requestLogEntry {
tb.mu.Lock()
defer tb.mu.Unlock()
if len(tb.requestLog) == 0 {
return nil
}
return &tb.requestLog[len(tb.requestLog)-1]
}
// ============== Proxy Creation Tests ==============
func TestNew_ValidConfig(t *testing.T) {
cfg := &Config{
Target: "http://localhost:8080",
Timeout: 10 * time.Second,
}
proxy, err := New(cfg, nil)
if err != nil {
t.Fatalf("Failed to create proxy: %v", err)
}
if proxy == nil {
t.Fatal("Expected proxy instance")
}
}
func TestNew_EmptyTarget(t *testing.T) {
cfg := &Config{
Target: "",
}
_, err := New(cfg, nil)
if err == nil {
t.Error("Expected error for empty target")
}
}
func TestNew_InvalidTargetURL(t *testing.T) {
cfg := &Config{
Target: "://invalid-url",
}
_, err := New(cfg, nil)
if err == nil {
t.Error("Expected error for invalid URL")
}
}
func TestNew_DefaultTimeout(t *testing.T) {
cfg := &Config{
Target: "http://localhost:8080",
}
proxy, err := New(cfg, nil)
if err != nil {
t.Fatalf("Failed to create proxy: %v", err)
}
if proxy.httpClient.Timeout != 30*time.Second {
t.Errorf("Expected default timeout 30s, got %v", proxy.httpClient.Timeout)
}
}
// ============== Basic Proxy Tests ==============
func TestProxy_BasicGET(t *testing.T) {
backend := newTestBackend(nil)
defer backend.close()
proxy, _ := New(&Config{Target: backend.URL()}, nil)
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", rr.Code)
}
var response map[string]interface{}
json.NewDecoder(rr.Body).Decode(&response)
if response["path"] != "/test" {
t.Errorf("Expected path /test, got %v", response["path"])
}
}
func TestProxy_BasicPOST(t *testing.T) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"received": string(body),
"method": r.Method,
})
})
defer backend.close()
proxy, _ := New(&Config{Target: backend.URL()}, nil)
req := httptest.NewRequest("POST", "/api/data", strings.NewReader(`{"key":"value"}`))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", rr.Code)
}
var response map[string]interface{}
json.NewDecoder(rr.Body).Decode(&response)
if response["method"] != "POST" {
t.Errorf("Expected method POST, got %v", response["method"])
}
}
func TestProxy_PUT(t *testing.T) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"method": r.Method})
})
defer backend.close()
proxy, _ := New(&Config{Target: backend.URL()}, nil)
req := httptest.NewRequest("PUT", "/resource/123", strings.NewReader(`{"name":"updated"}`))
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
var response map[string]string
json.NewDecoder(rr.Body).Decode(&response)
if response["method"] != "PUT" {
t.Errorf("Expected method PUT, got %v", response["method"])
}
}
func TestProxy_DELETE(t *testing.T) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"method": r.Method})
})
defer backend.close()
proxy, _ := New(&Config{Target: backend.URL()}, nil)
req := httptest.NewRequest("DELETE", "/resource/123", nil)
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
var response map[string]string
json.NewDecoder(rr.Body).Decode(&response)
if response["method"] != "DELETE" {
t.Errorf("Expected method DELETE, got %v", response["method"])
}
}
// ============== Header Tests ==============
func TestProxy_HeadersForwarding(t *testing.T) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"custom_header": r.Header.Get("X-Custom-Header"),
"forwarded_for": r.Header.Get("X-Forwarded-For"),
"forwarded_host": r.Header.Get("X-Forwarded-Host"),
})
})
defer backend.close()
proxy, _ := New(&Config{Target: backend.URL()}, nil)
req := httptest.NewRequest("GET", "/headers", nil)
req.Header.Set("X-Custom-Header", "test-value")
req.RemoteAddr = "192.168.1.100:12345"
req.Host = "example.com"
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
var response map[string]interface{}
json.NewDecoder(rr.Body).Decode(&response)
if response["custom_header"] != "test-value" {
t.Errorf("Expected custom header, got %v", response["custom_header"])
}
if response["forwarded_for"] != "192.168.1.100" {
t.Errorf("Expected X-Forwarded-For, got %v", response["forwarded_for"])
}
if response["forwarded_host"] != "example.com" {
t.Errorf("Expected X-Forwarded-Host, got %v", response["forwarded_host"])
}
}
func TestProxy_CustomHeaders(t *testing.T) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{
"api_version": r.Header.Get("X-API-Version"),
})
})
defer backend.close()
proxy, _ := New(&Config{
Target: backend.URL(),
Headers: map[string]string{
"X-API-Version": "{version}",
},
}, nil)
req := httptest.NewRequest("GET", "/api", nil)
rr := httptest.NewRecorder()
// Simulate parameter substitution
proxy.ProxyRequest(rr, req, map[string]string{"version": "2"})
var response map[string]string
json.NewDecoder(rr.Body).Decode(&response)
if response["api_version"] != "2" {
t.Errorf("Expected API version 2, got %v", response["api_version"])
}
}
func TestProxy_RemoteAddrSubstitution(t *testing.T) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{
"client_ip": r.Header.Get("X-Client-IP"),
})
})
defer backend.close()
proxy, _ := New(&Config{
Target: backend.URL(),
Headers: map[string]string{
"X-Client-IP": "$remote_addr",
},
}, nil)
req := httptest.NewRequest("GET", "/api", nil)
req.RemoteAddr = "10.0.0.1:54321"
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
var response map[string]string
json.NewDecoder(rr.Body).Decode(&response)
if response["client_ip"] != "10.0.0.1" {
t.Errorf("Expected client IP 10.0.0.1, got %v", response["client_ip"])
}
}
// ============== Query String Tests ==============
func TestProxy_QueryStringPreservation(t *testing.T) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{
"query": r.URL.RawQuery,
"param": r.URL.Query().Get("key"),
})
})
defer backend.close()
proxy, _ := New(&Config{Target: backend.URL()}, nil)
req := httptest.NewRequest("GET", "/search?key=value&page=2", nil)
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
var response map[string]string
json.NewDecoder(rr.Body).Decode(&response)
if response["param"] != "value" {
t.Errorf("Expected query param 'value', got %v", response["param"])
}
}
// ============== Status Code Tests ==============
func TestProxy_StatusCodePreservation(t *testing.T) {
statusCodes := []int{200, 201, 400, 404, 500}
for _, code := range statusCodes {
code := code // capture range variable
t.Run(fmt.Sprintf("Status_%d", code), func(t *testing.T) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(code)
w.Write([]byte("OK"))
})
defer backend.close()
proxy, _ := New(&Config{Target: backend.URL()}, nil)
req := httptest.NewRequest("GET", "/status", nil)
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
if rr.Code != code {
t.Errorf("Expected status %d, got %d", code, rr.Code)
}
})
}
}
// ============== Error Handling Tests ==============
func TestProxy_BackendUnavailable(t *testing.T) {
// Use a port that's definitely not listening
proxy, _ := New(&Config{
Target: "http://127.0.0.1:59999",
Timeout: 1 * time.Second,
}, nil)
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
if rr.Code != http.StatusBadGateway {
t.Errorf("Expected status 502 Bad Gateway, got %d", rr.Code)
}
}
func TestProxy_Timeout(t *testing.T) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.Write([]byte("OK"))
})
defer backend.close()
proxy, _ := New(&Config{
Target: backend.URL(),
Timeout: 100 * time.Millisecond,
}, nil)
req := httptest.NewRequest("GET", "/slow", nil)
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
if rr.Code != http.StatusGatewayTimeout {
t.Errorf("Expected status 504 Gateway Timeout, got %d", rr.Code)
}
}
// ============== Path Handling Tests ==============
func TestProxy_StripPrefix(t *testing.T) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{
"path": r.URL.Path,
})
})
defer backend.close()
proxy, _ := New(&Config{
Target: backend.URL(),
StripPrefix: "/api/v1",
}, nil)
req := httptest.NewRequest("GET", "/api/v1/users", nil)
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
var response map[string]string
json.NewDecoder(rr.Body).Decode(&response)
if response["path"] != "/users" {
t.Errorf("Expected stripped path /users, got %v", response["path"])
}
}
func TestProxy_TargetWithPath(t *testing.T) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{
"path": r.URL.Path,
})
})
defer backend.close()
proxy, _ := New(&Config{
Target: backend.URL() + "/backend",
}, nil)
req := httptest.NewRequest("GET", "/resource", nil)
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
var response map[string]string
json.NewDecoder(rr.Body).Decode(&response)
if response["path"] != "/backend/resource" {
t.Errorf("Expected path /backend/resource, got %v", response["path"])
}
}
// ============== Large Body Tests ==============
func TestProxy_LargeRequestBody(t *testing.T) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
json.NewEncoder(w).Encode(map[string]int{
"received_bytes": len(body),
})
})
defer backend.close()
proxy, _ := New(&Config{Target: backend.URL()}, nil)
// 100KB body
largeBody := strings.Repeat("x", 100000)
req := httptest.NewRequest("POST", "/upload", strings.NewReader(largeBody))
req.ContentLength = int64(len(largeBody))
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
var response map[string]int
json.NewDecoder(rr.Body).Decode(&response)
if response["received_bytes"] != 100000 {
t.Errorf("Expected 100000 bytes, got %d", response["received_bytes"])
}
}
func TestProxy_LargeResponseBody(t *testing.T) {
largeResponse := strings.Repeat("y", 100000)
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(largeResponse))
})
defer backend.close()
proxy, _ := New(&Config{Target: backend.URL()}, nil)
req := httptest.NewRequest("GET", "/large", nil)
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
if rr.Body.Len() != 100000 {
t.Errorf("Expected 100000 bytes in response, got %d", rr.Body.Len())
}
}
// ============== Concurrent Requests Tests ==============
func TestProxy_ConcurrentRequests(t *testing.T) {
backend := newTestBackend(nil)
defer backend.close()
proxy, _ := New(&Config{Target: backend.URL()}, nil)
const numRequests = 50
var wg sync.WaitGroup
errors := make(chan error, numRequests)
for i := 0; i < numRequests; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
req := httptest.NewRequest("GET", "/concurrent", nil)
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
errors <- &net.OpError{Op: "test", Err: context.DeadlineExceeded}
}
}(i)
}
wg.Wait()
close(errors)
errorCount := 0
for range errors {
errorCount++
}
if errorCount > 0 {
t.Errorf("Got %d errors in concurrent requests", errorCount)
}
if backend.getRequestCount() != numRequests {
t.Errorf("Expected %d requests at backend, got %d", numRequests, backend.getRequestCount())
}
}
// ============== Echo Tests ==============
func TestProxy_Echo(t *testing.T) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.Header().Set("Content-Type", r.Header.Get("Content-Type"))
w.Write(body)
})
defer backend.close()
proxy, _ := New(&Config{Target: backend.URL()}, nil)
testData := "Hello, Proxy!"
req := httptest.NewRequest("POST", "/echo", strings.NewReader(testData))
req.Header.Set("Content-Type", "text/plain")
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
if rr.Body.String() != testData {
t.Errorf("Expected echo of '%s', got '%s'", testData, rr.Body.String())
}
if rr.Header().Get("Content-Type") != "text/plain" {
t.Errorf("Expected Content-Type text/plain, got %s", rr.Header().Get("Content-Type"))
}
}
// ============== Helper Function Tests ==============
func TestSingleJoiningSlash(t *testing.T) {
tests := []struct {
a, b, expected string
}{
{"/api", "/users", "/api/users"},
{"/api/", "/users", "/api/users"},
{"/api", "users", "/api/users"},
{"/api/", "users", "/api/users"},
{"", "/users", "/users"},
{"/api", "", "/api/"},
}
for _, tt := range tests {
result := singleJoiningSlash(tt.a, tt.b)
if result != tt.expected {
t.Errorf("singleJoiningSlash(%q, %q) = %q, want %q", tt.a, tt.b, result, tt.expected)
}
}
}
func TestGetClientIP(t *testing.T) {
tests := []struct {
remoteAddr string
xRealIP string
expected string
}{
{"192.168.1.1:1234", "", "192.168.1.1"},
{"192.168.1.1:1234", "10.0.0.1", "10.0.0.1"},
{"invalid", "", "invalid"},
}
for _, tt := range tests {
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = tt.remoteAddr
if tt.xRealIP != "" {
req.Header.Set("X-Real-IP", tt.xRealIP)
}
result := getClientIP(req)
if result != tt.expected {
t.Errorf("getClientIP() = %q, want %q", result, tt.expected)
}
}
}
func TestGetScheme(t *testing.T) {
tests := []struct {
name string
tls bool
header string
expected string
}{
{"HTTP", false, "", "http"},
{"HTTPS from TLS", true, "", "https"},
{"HTTPS from header", false, "https", "https"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
if tt.header != "" {
req.Header.Set("X-Forwarded-Proto", tt.header)
}
// Note: httptest doesn't set TLS, so we can only test non-TLS cases fully
result := getScheme(req)
if !tt.tls && result != tt.expected {
t.Errorf("getScheme() = %q, want %q", result, tt.expected)
}
})
}
}
func TestIsConnectionError(t *testing.T) {
tests := []struct {
err error
expected bool
}{
{nil, false},
{&net.OpError{Op: "dial", Err: &net.DNSError{Err: "no such host"}}, true},
{context.DeadlineExceeded, false},
}
for _, tt := range tests {
result := isConnectionError(tt.err)
if result != tt.expected {
t.Errorf("isConnectionError(%v) = %v, want %v", tt.err, result, tt.expected)
}
}
}
// ============== Benchmarks ==============
func BenchmarkProxy_SimpleGET(b *testing.B) {
backend := newTestBackend(nil)
defer backend.close()
proxy, _ := New(&Config{Target: backend.URL()}, nil)
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/bench", nil)
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
}
}
func BenchmarkProxy_POSTWithBody(b *testing.B) {
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusOK)
})
defer backend.close()
proxy, _ := New(&Config{Target: backend.URL()}, nil)
body := strings.Repeat("x", 1024) // 1KB body
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("POST", "/bench", strings.NewReader(body))
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
}
}