Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ func (s *Service) post(ctx context.Context,

res := &httpResponse{
statusCode: resp.StatusCode,
raw: *resp,
}
populateHeaders(res, resp)

Expand All @@ -137,6 +138,7 @@ func (s *Service) post(ctx context.Context,

return nil, errors.Join(errors.New("failed to read POST response"), err)
}
res.raw.Body = io.NopCloser(bytes.NewReader(res.body))

if resp.StatusCode == http.StatusNoContent {
// Nothing returned. This is not considered an error.
Expand Down Expand Up @@ -218,6 +220,7 @@ type httpResponse struct {
headers map[string]string
consensusVersion spec.DataVersion
body []byte
raw http.Response
}

// get sends an HTTP get request and returns the response.
Expand Down Expand Up @@ -291,6 +294,7 @@ func (s *Service) get(ctx context.Context,

res := &httpResponse{
statusCode: resp.StatusCode,
raw: *resp,
}
populateHeaders(res, resp)

Expand All @@ -314,6 +318,7 @@ func (s *Service) get(ctx context.Context,

return nil, errors.Join(errors.New("failed to read GET response"), err)
}
res.raw.Body = io.NopCloser(bytes.NewReader(res.body))

if resp.StatusCode == http.StatusNoContent {
// Nothing returned. This is not considered an error.
Expand Down
218 changes: 218 additions & 0 deletions http/http_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,15 @@
package http

import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/rs/zerolog"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -197,3 +203,215 @@ func TestURLForCall(t *testing.T) {
})
}
}

func TestProxy(t *testing.T) {
tests := []struct {
name string
baseURL string
extraHeaders map[string]string
backendHandler http.HandlerFunc
requestPath string
requestMethod string
requestBody string
requestHeaders map[string]string
expectedStatus int
expectedBody string
expectedHeader string
expectError bool
}{
{
name: "SimpleGET",
baseURL: "", // Will be set to test server URL
backendHandler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"result":"success"}`))
},
requestPath: "/api/v1/test",
requestMethod: "GET",
expectedStatus: http.StatusOK,
expectedBody: `{"result":"success"}`,
},
{
name: "POST",
baseURL: "",
backendHandler: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"received":"` + string(body) + `"}`))
},
requestPath: "/api/v1/create",
requestMethod: "POST",
requestBody: `{"data":"test"}`,
expectedStatus: http.StatusCreated,
expectedBody: `{"received":"{"data":"test"}"}`,
},
{
name: "CustomHeaders",
baseURL: "",
extraHeaders: map[string]string{
"X-Custom-Header": "custom-value",
},
backendHandler: func(w http.ResponseWriter, r *http.Request) {
// Verify custom header was forwarded
if r.Header.Get("X-Custom-Header") != "custom-value" {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("headers-ok"))
},
requestPath: "/api/v1/headers",
requestMethod: "GET",
expectedStatus: http.StatusOK,
expectedBody: "headers-ok",
},
{
name: "ResponseHeaders",
baseURL: "",
backendHandler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Response-Header", "response-value")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
},
requestPath: "/api/v1/response",
requestMethod: "GET",
expectedStatus: http.StatusOK,
expectedBody: "ok",
expectedHeader: "response-value",
},
{
name: "ErrorResponse",
baseURL: "",
backendHandler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"not found"}`))
},
requestPath: "/api/v1/notfound",
requestMethod: "GET",
expectedStatus: http.StatusNotFound,
expectedBody: `{"error":"not found"}`,
},
{
name: "BasicAuth",
baseURL: "http://testuser:testpass@", // Will append server host
backendHandler: func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "testuser" || pass != "testpass" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("auth-ok"))
},
requestPath: "/api/v1/auth",
requestMethod: "GET",
expectedStatus: http.StatusOK,
expectedBody: "auth-ok",
},
{
name: "RequestHeaders",
baseURL: "",
backendHandler: func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Request-Header") != "request-value" {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("request-headers-ok"))
},
requestPath: "/api/v1/reqheaders",
requestMethod: "GET",
requestHeaders: map[string]string{
"X-Request-Header": "request-value",
},
expectedStatus: http.StatusOK,
expectedBody: "request-headers-ok",
},
{
name: "LargeBody",
baseURL: "",
backendHandler: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
},
requestPath: "/api/v1/large",
requestMethod: "POST",
requestBody: string(make([]byte, 10000)), // 10KB of zeros
expectedStatus: http.StatusOK,
expectedBody: string(make([]byte, 10000)),
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Create test backend server
backend := httptest.NewServer(test.backendHandler)
defer backend.Close()

// Setup base URL
baseURL := test.baseURL
if baseURL == "" {
baseURL = backend.URL
} else if baseURL == "http://testuser:testpass@" {
// Extract host from backend URL and add auth
backendURL, _ := url.Parse(backend.URL)
baseURL = "http://testuser:testpass@" + backendURL.Host
}

parsedBase, err := url.Parse(baseURL)
require.NoError(t, err)

// Create service
service := &Service{
log: zerolog.Nop(),
base: parsedBase,
address: baseURL,
client: backend.Client(),
extraHeaders: test.extraHeaders,
}

// Create request
var body io.Reader
if test.requestBody != "" {
body = bytes.NewBufferString(test.requestBody)
}
req, err := http.NewRequest(test.requestMethod, test.requestPath, body)
require.NoError(t, err)

// Add request headers
for k, v := range test.requestHeaders {
req.Header.Set(k, v)
}

// Call proxy
ctx := context.Background()
resp, err := service.proxy(ctx, req)

// Check error expectation
if test.expectError {
require.Error(t, err)
return
}

require.NoError(t, err)
require.NotNil(t, resp)

// Check status code
require.Equal(t, test.expectedStatus, resp.StatusCode)

// Check body
if test.expectedBody != "" {
bodyBytes, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, test.expectedBody, string(bodyBytes))
_ = resp.Body.Close()
}

// Check response header if specified
if test.expectedHeader != "" {
require.Equal(t, test.expectedHeader, resp.Header.Get("X-Response-Header"))
}
})
}
}
54 changes: 54 additions & 0 deletions http/proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright © 2020 - 2025 Attestant Limited.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package http

import (
"context"
"fmt"
"net/http"

"github.com/attestantio/go-eth2-client/api"
)

// Proxy performs an HTTP proxy request and returns the response.
func (s *Service) Proxy(ctx context.Context, req *http.Request) (*http.Response, error) {
return s.proxy(ctx, req)
}

// proxy performs an HTTP proxy request using a reverse proxy and returns the response.
func (s *Service) proxy(ctx context.Context, req *http.Request) (*http.Response, error) {
endpoint := req.URL.Path
query := req.URL.Query().Encode()

var httpResponse *httpResponse
var err error
switch req.Method {
case http.MethodGet:
httpResponse, err = s.get(ctx, endpoint, query, &api.CommonOpts{}, false)
case http.MethodPost:
headers := make(map[string]string)
for k, v := range req.Header {
headers[k] = v[0]
}
httpResponse, err = s.post(ctx, endpoint, query, &api.CommonOpts{}, req.Body, ContentTypeJSON, headers)
default:
err = fmt.Errorf("unsupported method %s for proxy", req.Method)
}

if err != nil {
return nil, err
}

return &httpResponse.raw, nil
}
7 changes: 7 additions & 0 deletions service.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package client

import (
"context"
"net/http"
"time"

"github.com/attestantio/go-eth2-client/api"
Expand Down Expand Up @@ -709,3 +710,9 @@ type NodeClientProvider interface {
// NodeClient provides the client for the node.
NodeClient(ctx context.Context) (*api.Response[string], error)
}

// ProxyProvider provides a proxy for HTTP requests.
type ProxyProvider interface {
// Proxy performs an HTTP proxy request and returns the response.
Proxy(ctx context.Context, req *http.Request) (*http.Response, error)
}
Loading