Skip to content

Commit 61f76ab

Browse files
authored
chore: refactor net/rest to use Ferret HTTP client abstraction (#31)
* chore: refactor `net/rest` to use Ferret HTTP client abstraction, add tests for network context handling, and improve request encoding * chore: update `go.sum` for `golang.org/x` dependencies
1 parent 25b2c5b commit 61f76ab

8 files changed

Lines changed: 238 additions & 47 deletions

File tree

modules/db/postgres/go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,11 @@ github.com/wI2L/jettison v0.7.4/go.mod h1:O+F+T7X7ZN6kTsd167Qk4aZMC8jNrH48SMedNm
6464
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
6565
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
6666
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
67+
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
6768
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
69+
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
6870
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
71+
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
6972
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
7073
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
7174
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

modules/net/rest/core/client.go

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,22 @@ package core
22

33
import (
44
"context"
5-
"net/http"
65

76
commonresource "github.com/MontFerret/contrib/pkg/common/resource"
87
"github.com/MontFerret/ferret/v2/pkg/runtime"
98
)
109

1110
// Client is an opaque HTTP API client exposed to Ferret.
1211
type Client struct {
13-
httpClient *http.Client
14-
config Config
15-
id uint64
12+
config Config
13+
id uint64
1614
}
1715

1816
// NewClient creates a configured HTTP API client handle.
1917
func NewClient(config Config) *Client {
2018
return &Client{
21-
config: config,
22-
httpClient: http.DefaultClient,
23-
id: newResourceID(),
19+
config: config,
20+
id: newResourceID(),
2421
}
2522
}
2623

modules/net/rest/core/client_test.go

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"testing"
1313
"time"
1414

15+
ferretnet "github.com/MontFerret/ferret/v2/pkg/net"
16+
ferrethttp "github.com/MontFerret/ferret/v2/pkg/net/http"
1517
"github.com/MontFerret/ferret/v2/pkg/runtime"
1618
)
1719

@@ -43,7 +45,7 @@ func TestClientQueryJSONList(t *testing.T) {
4345
}))
4446
defer server.Close()
4547

46-
ctx := context.Background()
48+
ctx := networkContext()
4749
cfg := DefaultConfig()
4850
cfg.BaseURL = server.URL
4951
cfg.Headers.Set("Authorization", "Bearer token")
@@ -107,7 +109,7 @@ func TestClientInfersPostAndReturnsFullResponse(t *testing.T) {
107109
}))
108110
defer server.Close()
109111

110-
ctx := context.Background()
112+
ctx := networkContext()
111113
cfg := DefaultConfig()
112114
cfg.BaseURL = server.URL
113115
client := NewClient(cfg)
@@ -152,7 +154,7 @@ func TestClientErrorModes(t *testing.T) {
152154
}))
153155
defer server.Close()
154156

155-
ctx := context.Background()
157+
ctx := networkContext()
156158
cfg := DefaultConfig()
157159
cfg.BaseURL = server.URL
158160

@@ -195,7 +197,7 @@ func TestClientResponseEncodings(t *testing.T) {
195197
}))
196198
defer server.Close()
197199

198-
ctx := context.Background()
200+
ctx := networkContext()
199201
cfg := DefaultConfig()
200202
cfg.BaseURL = server.URL
201203
cfg.ResponseEncoding = EncodingText
@@ -253,7 +255,7 @@ func TestClientFormRequestEncoding(t *testing.T) {
253255
}))
254256
defer server.Close()
255257

256-
ctx := context.Background()
258+
ctx := networkContext()
257259
cfg := DefaultConfig()
258260
cfg.BaseURL = server.URL
259261
client := NewClient(cfg)
@@ -297,15 +299,16 @@ func TestClientAcceptsHTTPDialect(t *testing.T) {
297299
cfg.BaseURL = server.URL
298300
client := NewClient(cfg)
299301

300-
out, err := client.Query(context.Background(), runtime.Query{
302+
ctx := networkContext()
303+
out, err := client.Query(ctx, runtime.Query{
301304
Kind: runtime.NewString("http"),
302305
Expression: runtime.NewString("/users"),
303306
})
304307
if err != nil {
305308
t.Fatalf("unexpected query error: %v", err)
306309
}
307310

308-
length, err := out.Length(context.Background())
311+
length, err := out.Length(ctx)
309312
if err != nil {
310313
t.Fatalf("unexpected length error: %v", err)
311314
}
@@ -332,7 +335,7 @@ func TestClientRequestTimeout(t *testing.T) {
332335
cfg.Timeout = int64(10 * time.Millisecond)
333336
client := NewClient(cfg)
334337

335-
_, err := client.QueryOne(context.Background(), runtime.Query{Expression: runtime.NewString("/slow")})
338+
_, err := client.QueryOne(networkContext(), runtime.Query{Expression: runtime.NewString("/slow")})
336339
if err == nil {
337340
t.Fatal("expected timeout error")
338341
}
@@ -370,6 +373,94 @@ func TestClientRejectsUnsupportedDialect(t *testing.T) {
370373
}
371374
}
372375

376+
func TestClientUsesFerretHTTPClientFromContext(t *testing.T) {
377+
t.Parallel()
378+
379+
httpClient := &recordingHTTPClient{
380+
response: &ferrethttp.Response{
381+
StatusCode: http.StatusOK,
382+
Status: "200 OK",
383+
Headers: ferrethttp.Headers{
384+
"X-Result": []string{"ok"},
385+
},
386+
Body: []byte(`{"ok":true}`),
387+
},
388+
}
389+
ctx := ferretnet.WithNetwork(
390+
context.Background(),
391+
ferretnet.New(ferretnet.WithHTTPClient(httpClient)),
392+
)
393+
394+
cfg := DefaultConfig()
395+
cfg.BaseURL = "https://api.example.test/v1/"
396+
cfg.Headers.Set("Authorization", "Bearer token")
397+
client := NewClient(cfg)
398+
399+
out, err := client.QueryOne(ctx, runtime.Query{
400+
Expression: runtime.NewString("/users"),
401+
Params: object(t, map[string]runtime.Value{
402+
"method": runtime.NewString("POST"),
403+
"query": object(t, map[string]runtime.Value{
404+
"active": runtime.True,
405+
}),
406+
"headers": object(t, map[string]runtime.Value{
407+
"X-Request-ID": runtime.NewString("req-1"),
408+
}),
409+
"body": object(t, map[string]runtime.Value{
410+
"name": runtime.NewString("Ada"),
411+
}),
412+
}),
413+
Options: object(t, map[string]runtime.Value{
414+
"response": runtime.NewString("full"),
415+
}),
416+
})
417+
if err != nil {
418+
t.Fatalf("unexpected query error: %v", err)
419+
}
420+
if got := field(t, out, "status"); got != runtime.NewInt(http.StatusOK) {
421+
t.Fatalf("expected status 200, got %s", got.String())
422+
}
423+
424+
req := httpClient.request()
425+
if req == nil {
426+
t.Fatal("expected HTTP client to be called")
427+
}
428+
if req.Method != http.MethodPost {
429+
t.Fatalf("expected POST, got %s", req.Method)
430+
}
431+
if req.URL != "https://api.example.test/users?active=true" {
432+
t.Fatalf("unexpected URL: %s", req.URL)
433+
}
434+
if got := req.Headers["Authorization"]; len(got) != 1 || got[0] != "Bearer token" {
435+
t.Fatalf("unexpected authorization header: %v", got)
436+
}
437+
if got := req.Headers["X-Request-Id"]; len(got) != 1 || got[0] != "req-1" {
438+
t.Fatalf("unexpected request id header: %v", got)
439+
}
440+
if got := req.Headers["Content-Type"]; len(got) != 1 || got[0] != "application/json" {
441+
t.Fatalf("unexpected content type: %v", got)
442+
}
443+
if string(req.Body) != `{"name":"Ada"}` {
444+
t.Fatalf("unexpected body: %s", req.Body)
445+
}
446+
}
447+
448+
func TestClientRequiresNetworkContext(t *testing.T) {
449+
t.Parallel()
450+
451+
cfg := DefaultConfig()
452+
cfg.BaseURL = "https://api.example.test"
453+
client := NewClient(cfg)
454+
455+
_, err := client.QueryOne(context.Background(), runtime.Query{Expression: runtime.NewString("/users")})
456+
if err == nil {
457+
t.Fatal("expected missing network error")
458+
}
459+
if !strings.Contains(err.Error(), "network not found in context") {
460+
t.Fatalf("unexpected error: %v", err)
461+
}
462+
}
463+
373464
func object(t *testing.T, props map[string]runtime.Value) *runtime.Object {
374465
t.Helper()
375466

@@ -413,3 +504,7 @@ func readBody(t *testing.T, r *http.Request) string {
413504

414505
return string(data)
415506
}
507+
508+
func networkContext() context.Context {
509+
return ferretnet.WithNetwork(context.Background(), ferretnet.New())
510+
}

modules/net/rest/core/codec.go

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,12 @@ import (
55
"context"
66
"encoding/json"
77
"fmt"
8-
"io"
98
"net/url"
10-
"strings"
119

1210
"github.com/MontFerret/ferret/v2/pkg/runtime"
1311
)
1412

15-
func encodeRequestBody(ctx context.Context, value runtime.Value, encoding Encoding) (io.Reader, string, error) {
13+
func encodeRequestBody(ctx context.Context, value runtime.Value, encoding Encoding) ([]byte, string, error) {
1614
if runtime.TypeNone.Is(value) {
1715
return nil, "", nil
1816
}
@@ -29,22 +27,22 @@ func encodeRequestBody(ctx context.Context, value runtime.Value, encoding Encodi
2927
return nil, "", err
3028
}
3129

32-
return bytes.NewReader(data), "application/json", nil
30+
return data, "application/json", nil
3331
case EncodingText:
34-
return strings.NewReader(value.String()), "text/plain; charset=utf-8", nil
32+
return []byte(value.String()), "text/plain; charset=utf-8", nil
3533
case EncodingBytes:
3634
if binary, ok := value.(runtime.Binary); ok {
37-
return bytes.NewReader(binary), "application/octet-stream", nil
35+
return []byte(binary), "application/octet-stream", nil
3836
}
3937

40-
return strings.NewReader(value.String()), "application/octet-stream", nil
38+
return []byte(value.String()), "application/octet-stream", nil
4139
case EncodingForm:
4240
values := make(url.Values)
4341
if err := appendURLValues(ctx, values, "HTTP request body", value); err != nil {
4442
return nil, "", err
4543
}
4644

47-
return strings.NewReader(values.Encode()), "application/x-www-form-urlencoded", nil
45+
return []byte(values.Encode()), "application/x-www-form-urlencoded", nil
4846
default:
4947
return nil, "", fmt.Errorf("unsupported request encoding %q", encoding)
5048
}

modules/net/rest/core/execute.go

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ package core
22

33
import (
44
"context"
5-
"io"
6-
"net/http"
75

6+
ferretnet "github.com/MontFerret/ferret/v2/pkg/net"
7+
ferrethttp "github.com/MontFerret/ferret/v2/pkg/net/http"
88
"github.com/MontFerret/ferret/v2/pkg/runtime"
99
)
1010

@@ -33,6 +33,11 @@ func executeQuery(ctx context.Context, client *Client, q runtime.Query) (runtime
3333
return runtime.None, false, OperationError("QUERY", err)
3434
}
3535

36+
httpClient, err := ferretnet.HTTPClientFrom(ctx)
37+
if err != nil {
38+
return runtime.None, false, OperationError("QUERY", err)
39+
}
40+
3641
requestCtx := ctx
3742
cancel := func() {}
3843

@@ -42,28 +47,22 @@ func executeQuery(ctx context.Context, client *Client, q runtime.Query) (runtime
4247

4348
defer cancel()
4449

45-
req, err := http.NewRequestWithContext(requestCtx, requestData.Method, requestURL, body)
46-
if err != nil {
47-
return runtime.None, false, OperationError("QUERY", err)
48-
}
49-
50-
req.Header = mergeHeaders(client.config.Headers, requestData.Headers)
51-
if contentType != "" && !hasHeader(req.Header, "Content-Type") {
52-
req.Header.Set("Content-Type", contentType)
53-
}
54-
55-
resp, err := client.httpClient.Do(req)
56-
if err != nil {
57-
return runtime.None, false, OperationError("QUERY", err)
50+
headers := mergeHeaders(client.config.Headers, requestData.Headers)
51+
if contentType != "" && !hasHeader(headers, "Content-Type") {
52+
headers.Set("Content-Type", contentType)
5853
}
59-
defer resp.Body.Close()
6054

61-
responseBody, err := io.ReadAll(resp.Body)
55+
resp, err := httpClient.Do(requestCtx, &ferrethttp.Request{
56+
Method: requestData.Method,
57+
URL: requestURL,
58+
Headers: ferrethttp.Headers(headers),
59+
Body: body,
60+
})
6261
if err != nil {
6362
return runtime.None, false, OperationError("QUERY", err)
6463
}
6564

66-
value, flatten, err := decodeHTTPResponse(ctx, resp, responseBody, options)
65+
value, flatten, err := decodeHTTPResponse(ctx, requestURL, resp, options)
6766
if err != nil {
6867
return runtime.None, false, OperationError("QUERY", err)
6968
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package core
2+
3+
import (
4+
"context"
5+
"sync"
6+
7+
ferrethttp "github.com/MontFerret/ferret/v2/pkg/net/http"
8+
)
9+
10+
type recordingHTTPClient struct {
11+
err error
12+
lastRequest *ferrethttp.Request
13+
response *ferrethttp.Response
14+
mu sync.Mutex
15+
}
16+
17+
func (c *recordingHTTPClient) Do(_ context.Context, req *ferrethttp.Request) (*ferrethttp.Response, error) {
18+
c.mu.Lock()
19+
defer c.mu.Unlock()
20+
21+
c.lastRequest = c.cloneRequest(req)
22+
if c.err != nil {
23+
return nil, c.err
24+
}
25+
26+
return c.response, nil
27+
}
28+
29+
func (c *recordingHTTPClient) request() *ferrethttp.Request {
30+
c.mu.Lock()
31+
defer c.mu.Unlock()
32+
33+
return c.cloneRequest(c.lastRequest)
34+
}
35+
36+
func (c *recordingHTTPClient) cloneRequest(req *ferrethttp.Request) *ferrethttp.Request {
37+
if req == nil {
38+
return nil
39+
}
40+
41+
clone := *req
42+
if req.Headers != nil {
43+
clone.Headers = make(ferrethttp.Headers, len(req.Headers))
44+
for key, values := range req.Headers {
45+
clone.Headers[key] = append([]string(nil), values...)
46+
}
47+
}
48+
clone.Body = append([]byte(nil), req.Body...)
49+
50+
return &clone
51+
}

0 commit comments

Comments
 (0)