Skip to content

Commit ea96b35

Browse files
http: add proxy (#15)
Co-authored-by: kalo <[email protected]>
1 parent 41cf871 commit ea96b35

File tree

4 files changed

+284
-0
lines changed

4 files changed

+284
-0
lines changed

http/http.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ func (s *Service) post(ctx context.Context,
118118

119119
res := &httpResponse{
120120
statusCode: resp.StatusCode,
121+
raw: *resp,
121122
}
122123
populateHeaders(res, resp)
123124

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

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

141143
if resp.StatusCode == http.StatusNoContent {
142144
// Nothing returned. This is not considered an error.
@@ -218,6 +220,7 @@ type httpResponse struct {
218220
headers map[string]string
219221
consensusVersion spec.DataVersion
220222
body []byte
223+
raw http.Response
221224
}
222225

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

292295
res := &httpResponse{
293296
statusCode: resp.StatusCode,
297+
raw: *resp,
294298
}
295299
populateHeaders(res, resp)
296300

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

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

318323
if resp.StatusCode == http.StatusNoContent {
319324
// Nothing returned. This is not considered an error.

http/http_internal_test.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@
1414
package http
1515

1616
import (
17+
"bytes"
18+
"context"
19+
"io"
20+
"net/http"
21+
"net/http/httptest"
1722
"net/url"
1823
"testing"
1924

25+
"github.com/rs/zerolog"
2026
"github.com/stretchr/testify/require"
2127
)
2228

@@ -197,3 +203,215 @@ func TestURLForCall(t *testing.T) {
197203
})
198204
}
199205
}
206+
207+
func TestProxy(t *testing.T) {
208+
tests := []struct {
209+
name string
210+
baseURL string
211+
extraHeaders map[string]string
212+
backendHandler http.HandlerFunc
213+
requestPath string
214+
requestMethod string
215+
requestBody string
216+
requestHeaders map[string]string
217+
expectedStatus int
218+
expectedBody string
219+
expectedHeader string
220+
expectError bool
221+
}{
222+
{
223+
name: "SimpleGET",
224+
baseURL: "", // Will be set to test server URL
225+
backendHandler: func(w http.ResponseWriter, r *http.Request) {
226+
w.WriteHeader(http.StatusOK)
227+
_, _ = w.Write([]byte(`{"result":"success"}`))
228+
},
229+
requestPath: "/api/v1/test",
230+
requestMethod: "GET",
231+
expectedStatus: http.StatusOK,
232+
expectedBody: `{"result":"success"}`,
233+
},
234+
{
235+
name: "POST",
236+
baseURL: "",
237+
backendHandler: func(w http.ResponseWriter, r *http.Request) {
238+
body, _ := io.ReadAll(r.Body)
239+
w.WriteHeader(http.StatusCreated)
240+
_, _ = w.Write([]byte(`{"received":"` + string(body) + `"}`))
241+
},
242+
requestPath: "/api/v1/create",
243+
requestMethod: "POST",
244+
requestBody: `{"data":"test"}`,
245+
expectedStatus: http.StatusCreated,
246+
expectedBody: `{"received":"{"data":"test"}"}`,
247+
},
248+
{
249+
name: "CustomHeaders",
250+
baseURL: "",
251+
extraHeaders: map[string]string{
252+
"X-Custom-Header": "custom-value",
253+
},
254+
backendHandler: func(w http.ResponseWriter, r *http.Request) {
255+
// Verify custom header was forwarded
256+
if r.Header.Get("X-Custom-Header") != "custom-value" {
257+
w.WriteHeader(http.StatusBadRequest)
258+
return
259+
}
260+
w.WriteHeader(http.StatusOK)
261+
_, _ = w.Write([]byte("headers-ok"))
262+
},
263+
requestPath: "/api/v1/headers",
264+
requestMethod: "GET",
265+
expectedStatus: http.StatusOK,
266+
expectedBody: "headers-ok",
267+
},
268+
{
269+
name: "ResponseHeaders",
270+
baseURL: "",
271+
backendHandler: func(w http.ResponseWriter, r *http.Request) {
272+
w.Header().Set("X-Response-Header", "response-value")
273+
w.WriteHeader(http.StatusOK)
274+
_, _ = w.Write([]byte("ok"))
275+
},
276+
requestPath: "/api/v1/response",
277+
requestMethod: "GET",
278+
expectedStatus: http.StatusOK,
279+
expectedBody: "ok",
280+
expectedHeader: "response-value",
281+
},
282+
{
283+
name: "ErrorResponse",
284+
baseURL: "",
285+
backendHandler: func(w http.ResponseWriter, r *http.Request) {
286+
w.WriteHeader(http.StatusNotFound)
287+
_, _ = w.Write([]byte(`{"error":"not found"}`))
288+
},
289+
requestPath: "/api/v1/notfound",
290+
requestMethod: "GET",
291+
expectedStatus: http.StatusNotFound,
292+
expectedBody: `{"error":"not found"}`,
293+
},
294+
{
295+
name: "BasicAuth",
296+
baseURL: "http://testuser:testpass@", // Will append server host
297+
backendHandler: func(w http.ResponseWriter, r *http.Request) {
298+
user, pass, ok := r.BasicAuth()
299+
if !ok || user != "testuser" || pass != "testpass" {
300+
w.WriteHeader(http.StatusUnauthorized)
301+
return
302+
}
303+
w.WriteHeader(http.StatusOK)
304+
_, _ = w.Write([]byte("auth-ok"))
305+
},
306+
requestPath: "/api/v1/auth",
307+
requestMethod: "GET",
308+
expectedStatus: http.StatusOK,
309+
expectedBody: "auth-ok",
310+
},
311+
{
312+
name: "RequestHeaders",
313+
baseURL: "",
314+
backendHandler: func(w http.ResponseWriter, r *http.Request) {
315+
if r.Header.Get("X-Request-Header") != "request-value" {
316+
w.WriteHeader(http.StatusBadRequest)
317+
return
318+
}
319+
w.WriteHeader(http.StatusOK)
320+
_, _ = w.Write([]byte("request-headers-ok"))
321+
},
322+
requestPath: "/api/v1/reqheaders",
323+
requestMethod: "GET",
324+
requestHeaders: map[string]string{
325+
"X-Request-Header": "request-value",
326+
},
327+
expectedStatus: http.StatusOK,
328+
expectedBody: "request-headers-ok",
329+
},
330+
{
331+
name: "LargeBody",
332+
baseURL: "",
333+
backendHandler: func(w http.ResponseWriter, r *http.Request) {
334+
body, _ := io.ReadAll(r.Body)
335+
w.WriteHeader(http.StatusOK)
336+
_, _ = w.Write(body)
337+
},
338+
requestPath: "/api/v1/large",
339+
requestMethod: "POST",
340+
requestBody: string(make([]byte, 10000)), // 10KB of zeros
341+
expectedStatus: http.StatusOK,
342+
expectedBody: string(make([]byte, 10000)),
343+
},
344+
}
345+
346+
for _, test := range tests {
347+
t.Run(test.name, func(t *testing.T) {
348+
// Create test backend server
349+
backend := httptest.NewServer(test.backendHandler)
350+
defer backend.Close()
351+
352+
// Setup base URL
353+
baseURL := test.baseURL
354+
if baseURL == "" {
355+
baseURL = backend.URL
356+
} else if baseURL == "http://testuser:testpass@" {
357+
// Extract host from backend URL and add auth
358+
backendURL, _ := url.Parse(backend.URL)
359+
baseURL = "http://testuser:testpass@" + backendURL.Host
360+
}
361+
362+
parsedBase, err := url.Parse(baseURL)
363+
require.NoError(t, err)
364+
365+
// Create service
366+
service := &Service{
367+
log: zerolog.Nop(),
368+
base: parsedBase,
369+
address: baseURL,
370+
client: backend.Client(),
371+
extraHeaders: test.extraHeaders,
372+
}
373+
374+
// Create request
375+
var body io.Reader
376+
if test.requestBody != "" {
377+
body = bytes.NewBufferString(test.requestBody)
378+
}
379+
req, err := http.NewRequest(test.requestMethod, test.requestPath, body)
380+
require.NoError(t, err)
381+
382+
// Add request headers
383+
for k, v := range test.requestHeaders {
384+
req.Header.Set(k, v)
385+
}
386+
387+
// Call proxy
388+
ctx := context.Background()
389+
resp, err := service.proxy(ctx, req)
390+
391+
// Check error expectation
392+
if test.expectError {
393+
require.Error(t, err)
394+
return
395+
}
396+
397+
require.NoError(t, err)
398+
require.NotNil(t, resp)
399+
400+
// Check status code
401+
require.Equal(t, test.expectedStatus, resp.StatusCode)
402+
403+
// Check body
404+
if test.expectedBody != "" {
405+
bodyBytes, err := io.ReadAll(resp.Body)
406+
require.NoError(t, err)
407+
require.Equal(t, test.expectedBody, string(bodyBytes))
408+
_ = resp.Body.Close()
409+
}
410+
411+
// Check response header if specified
412+
if test.expectedHeader != "" {
413+
require.Equal(t, test.expectedHeader, resp.Header.Get("X-Response-Header"))
414+
}
415+
})
416+
}
417+
}

http/proxy.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright © 2020 - 2025 Attestant Limited.
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package http
15+
16+
import (
17+
"context"
18+
"fmt"
19+
"net/http"
20+
21+
"github.com/attestantio/go-eth2-client/api"
22+
)
23+
24+
// Proxy performs an HTTP proxy request and returns the response.
25+
func (s *Service) Proxy(ctx context.Context, req *http.Request) (*http.Response, error) {
26+
return s.proxy(ctx, req)
27+
}
28+
29+
// proxy performs an HTTP proxy request using a reverse proxy and returns the response.
30+
func (s *Service) proxy(ctx context.Context, req *http.Request) (*http.Response, error) {
31+
endpoint := req.URL.Path
32+
query := req.URL.Query().Encode()
33+
34+
var httpResponse *httpResponse
35+
var err error
36+
switch req.Method {
37+
case http.MethodGet:
38+
httpResponse, err = s.get(ctx, endpoint, query, &api.CommonOpts{}, false)
39+
case http.MethodPost:
40+
headers := make(map[string]string)
41+
for k, v := range req.Header {
42+
headers[k] = v[0]
43+
}
44+
httpResponse, err = s.post(ctx, endpoint, query, &api.CommonOpts{}, req.Body, ContentTypeJSON, headers)
45+
default:
46+
err = fmt.Errorf("unsupported method %s for proxy", req.Method)
47+
}
48+
49+
if err != nil {
50+
return nil, err
51+
}
52+
53+
return &httpResponse.raw, nil
54+
}

service.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ package client
1515

1616
import (
1717
"context"
18+
"net/http"
1819
"time"
1920

2021
"github.com/attestantio/go-eth2-client/api"
@@ -709,3 +710,9 @@ type NodeClientProvider interface {
709710
// NodeClient provides the client for the node.
710711
NodeClient(ctx context.Context) (*api.Response[string], error)
711712
}
713+
714+
// ProxyProvider provides a proxy for HTTP requests.
715+
type ProxyProvider interface {
716+
// Proxy performs an HTTP proxy request and returns the response.
717+
Proxy(ctx context.Context, req *http.Request) (*http.Response, error)
718+
}

0 commit comments

Comments
 (0)