Skip to content
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

client ID #3

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 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
15 changes: 15 additions & 0 deletions .coderabbit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
language: "en-US"
early_access: false
reviews:
profile: chill
request_changes_workflow: true
high_level_summary: true
poem: true
review_status: true
collapse_walkthrough: false
auto_review:
enabled: true
drafts: true
chat:
auto_reply: true
30 changes: 17 additions & 13 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ var (
nextPart = func(mpr *multipart.Reader) (*multipart.Part, error) { return mpr.NextPart() }
)

var (
// seq is used to generate unique client ids; it is incremented each time a client is created
seq int
)

// RequestOption is a function that applies an option to a request
type RequestOption = func(*http.Request) error

Expand Down Expand Up @@ -55,8 +60,8 @@ type ClientOption func(*client) error
// This type is not exported; functionality is accessed through the implmented
// HttpClient interface.
type client struct {
// name is used to identify the client in error messages
name string
// id is used to identify the client in error messages
id string

// url is prepended to the url of any request made with the client
url string
Expand All @@ -80,9 +85,10 @@ type client struct {
// The url typically includes the protocol, hostname and port for the client
// but may include any additional url components consistently required for
// requests performed using the client.
func NewClient(name string, opts ...ClientOption) (HttpClient, error) {
func NewClient(opts ...ClientOption) (HttpClient, error) {
seq++
w := client{
name: name,
id: "http-" + strconv.Itoa(seq),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bito Code Review Agent Run #3867f3 - 07/06/2024, 12:12 am

🔴 High importance
Issue: The current ID generation logic using a global sequence counter (seq) may lead to race conditions in a concurrent environment, causing duplicate IDs or other inconsistencies.
Fix: Use a thread-safe mechanism to generate unique client IDs, such as using a UUID generator or a sync/atomic counter.
Code suggestion
 @@ -88,7 +88,7 @@
  func NewClient(opts ...ClientOption) (HttpClient, error) {
      seq++
      w := client{
 -        id:      "http-" + strconv.Itoa(seq),
 +        id:      "http-" + uuid.New().String(),
          wrapped: http.DefaultClient,
      }
      errs := make([]error, 0, len(opts))
      for _, opt := range opts {
          if err := opt(&w); err != nil {
              errs = append(errs, err)
          }
      }
      if len(errs) > 0 {
          return nil, fmt.Errorf("%w: %w", ErrInitialisingClient, errors.Join(errs...))
      }
      return w, nil
  }

wrapped: http.DefaultClient,
}
errs := make([]error, 0, len(opts))
Expand Down Expand Up @@ -273,7 +279,7 @@ func (c client) execute(
) (*http.Response, error) {
rq, err := c.NewRequest(ctx, method, url, opts...)
if err != nil {
return nil, errorcontext.Errorf(ctx, "%s: %s: %w", c.name, method, err)
return nil, errorcontext.Errorf(ctx, "%s: %s: %w", c.id, method, err)
}
return c.Do(rq)
}
Expand All @@ -283,7 +289,7 @@ func (c client) execute(
func (c client) Do(rq *http.Request) (*http.Response, error) {
ctx := rq.Context()
handle := func(r *http.Response, err error) (*http.Response, error) {
return r, errorcontext.Errorf(ctx, "%s: %s %s: %w", c.name, rq.Method, rq.URL, err)
return r, errorcontext.Errorf(ctx, "%s: %s %s: %w", c.id, rq.Method, rq.URL, err)
}

retries, statusCodes, bodyRequired, stream, err := c.parseRequestHeaders(rq)
Expand Down Expand Up @@ -417,11 +423,9 @@ func MapFromMultipartFormData[K comparable, V any](
//
// The function returns an error if the body cannot be read or if the body does not
// contain valid JSON and the result will be the zero value of the generic type.
func UnmarshalJSON[T any](ctx context.Context, r *http.Response) (T, error) {
result := *new(T)

handle := func(sen, err error) (T, error) {
return result, errorcontext.Errorf(ctx, "http.UnmarshalJSON: %w: %w", sen, err)
func UnmarshalJSON[T any](r *http.Response) (*T, error) {
handle := func(sen, err error) (*T, error) {
return nil, fmt.Errorf("http.UnmarshalJSON: %w: %w", sen, err)
}

body, err := ioReadAll(r.Body)
Expand All @@ -430,9 +434,9 @@ func UnmarshalJSON[T any](ctx context.Context, r *http.Response) (T, error) {
return handle(ErrReadingResponseBody, err)
}

if err := json.Unmarshal(body, &result); err != nil {
result := new(T)
if err := json.Unmarshal(body, result); err != nil {
return handle(ErrInvalidJSON, err)
}

return result, nil
}
25 changes: 21 additions & 4 deletions clientOptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ import (
"net/url"
)

// ClientID sets the client ID for requests made using the client. The client ID is
// typically used to differentate between clients in log entries. If no client ID is
// provided a default value will be applied consisting of the string "http-<seq>"
// where <seq> is a number that increments for each client created.
func ClientID(id string) ClientOption {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bito Code Review Agent Run #3867f3 - 07/06/2024, 12:12 am

🔴 High importance
Issue: The 'ClientID' function does not handle the case where the provided 'id' is an empty string. This could lead to clients being created with an empty ID, which might cause issues in logging or client differentiation.
Fix: Add a check to ensure that the 'id' is not an empty string. If it is, assign a default value.
Code suggestion
 @@ -13,7 +13,11 @@ func ClientID(id string) ClientOption {
 	return func(c *client) error {
 		if id == "" {
 			id = fmt.Sprintf("http-%d", nextClientID())
 		}
 		c.id = id
 		return nil
 	}
 }

return func(c *client) error {
c.id = id
return nil
}
}

// MaxRetries sets the maximum number of retries for requests made using the client.
// Individual requests may be configured to override this value on a case-by-case basis.
func MaxRetries(n uint) ClientOption {
Expand All @@ -16,11 +27,11 @@ func MaxRetries(n uint) ClientOption {
}

// URL sets the base URL for requests made using the client. The URL may be specified
// as a string or a *url.URL.
//
// If a string is provided, it will be parsed to ensure it is a valid, absolute URL.
// as any of:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bito Code Review Agent Run #3867f3 - 07/06/2024, 12:12 am

🔴 High importance
Issue: The removal of detailed comments explaining the expected input types and parsing behavior for a URL reduces the clarity and documentation quality of the code. This can lead to confusion for future developers who may not understand the expected input types and how they are handled.
Fix: Restore the detailed comments explaining the expected input types and parsing behavior for a URL. This will ensure that future developers have a clear understanding of how the input should be provided and how it will be processed.
Code suggestion
 @@ -19,3 +30,5 @@
 -// as a string or a *url.URL.
 -//
 -// If a string is provided, it will be parsed to ensure it is a valid, absolute URL.
 +// as any of:
 +// - a string, which will be parsed to ensure it is a valid, absolute URL.
 +// - a *url.URL, which will be used directly.

//
// If a URL is provided is must be absolute.
// string // a which parses to a valid, absolute URL
// url.URL // a valid, absolute URL
// *url.URL // a valid, absolute URL
Comment on lines +32 to +34
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bito Code Review Agent Run #3867f3 - 07/06/2024, 12:12 am

🔴 High importance
Issue: The new comments added are not clear and do not provide sufficient context or explanation. They seem to be incomplete and may confuse future developers.
Fix: Provide a more detailed and clear explanation of the different URL types that can be used, including examples if possible.
Code suggestion
 @@ -32,3 +32,6 @@
 +// The following types are supported for URLs:
 +// - string: a string that parses to a valid, absolute URL
 +// - url.URL: a valid, absolute URL
 +// - *url.URL: a pointer to a valid, absolute URL

func URL(u any) ClientOption {
return func(c *client) error {
switch u := u.(type) {
Expand All @@ -31,6 +42,12 @@ func URL(u any) ClientOption {
}
return URL(url)(c)

case url.URL:
if !u.IsAbs() {
return fmt.Errorf("http: URL option: %w: URL must be absolute", ErrInvalidURL)
}
c.url = u.String()

case *url.URL:
if !u.IsAbs() {
return fmt.Errorf("http: URL option: %w: URL must be absolute", ErrInvalidURL)
Expand Down
40 changes: 40 additions & 0 deletions clientOptions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ func TestClientOptions(t *testing.T) {
scenario string
exec func(t *testing.T)
}{
{scenario: "ClientID",
exec: func(t *testing.T) {
// ARRANGE
sut := &client{}

// ACT
err := ClientID("foo")(sut)

// ASSERT
test.That(t, err).IsNil()
test.That(t, sut).Equals(&client{id: "foo"})
},
},
Comment on lines +28 to +40
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bito Code Review Agent Run #3867f3 - 07/06/2024, 12:12 am

🔴 High importance
Issue: The test case for 'ClientID' does not cover edge cases such as passing an empty string or a very long string as the client ID. This could lead to potential issues if the 'ClientID' function does not handle these cases properly.
Fix: Add additional test cases to cover edge cases for the 'ClientID' function, such as passing an empty string and a very long string.
Code suggestion
 @@ -40,0 +41,20 @@
 +		{scenario: "ClientID/empty",
 +			exec: func(t *testing.T) {
 +				// ARRANGE
 +				sut := &client{}
 +
 +				// ACT
 +				err := ClientID("")(sut)
 +
 +				// ASSERT
 +				test.That(t, err).IsNil()
 +				test.That(t, sut).Equals(&client{id: ""})
 +			},
 +		},
 +		{scenario: "ClientID/long",
 +			exec: func(t *testing.T) {
 +				// ARRANGE
 +				sut := &client{}
 +				longID := strings.Repeat("a", 1000)
 +
 +				// ACT
 +				err := ClientID(longID)(sut)
 +
 +				// ASSERT
 +				test.That(t, err).IsNil()
 +				test.That(t, sut).Equals(&client{id: longID})
 +			},
 +		},

{scenario: "URL/int",
exec: func(t *testing.T) {
// ARRANGE
Expand Down Expand Up @@ -96,6 +109,33 @@ func TestClientOptions(t *testing.T) {
// ACT
err := URL(url)(client)

// ASSERT
test.Error(t, err).IsNil()
test.That(t, client.url).Equals("http://example.com")
},
},
{scenario: "URL/*URL/relative",
exec: func(t *testing.T) {
// ARRANGE
client := &client{}
url, _ := url.Parse("example.com")

// ACT
err := URL(*url)(client)

// ASSERT
test.Error(t, err).Is(ErrInvalidURL)
},
},
Comment on lines +117 to +129
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bito Code Review Agent Run #3867f3 - 07/06/2024, 12:12 am

🔴 High importance
Issue: The test case for 'URL/*URL/relative' does not handle the potential panic that could occur if 'url.Parse' returns an error. This could lead to a test failure due to an unhandled error.
Fix: Add error handling for the 'url.Parse' function to ensure that the test does not panic if an error occurs.
Code suggestion
 @@ -121,0 +122,2 @@
 +				if err != nil {
 +					t.Fatalf("failed to parse URL: %v", err)
 +				}

{scenario: "URL/*URL/successful",
exec: func(t *testing.T) {
// ARRANGE
client := &client{}
url, _ := url.Parse("http://example.com")

// ACT
err := URL(*url)(client)

// ASSERT
test.Error(t, err).IsNil()
test.That(t, client.url).Equals("http://example.com")
Expand Down
25 changes: 16 additions & 9 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ func TestNewClient(t *testing.T) {
{scenario: "no errors",
exec: func(t *testing.T) {
// ACT
result, err := NewClient("name", func(c *client) error { return nil })
result, err := NewClient(func(c *client) error { return nil })

// ASSERT
test.That(t, err).IsNil()
test.That(t, result).Equals(client{
name: "name",
id: "http-1",
wrapped: http.DefaultClient,
})
},
Expand All @@ -40,7 +40,7 @@ func TestNewClient(t *testing.T) {
opts := []ClientOption{func(c *client) error { return opterr }}

// ACT
result, err := NewClient("name", opts...)
result, err := NewClient(opts...)

// ASSERT
test.Error(t, err).Is(ErrInitialisingClient)
Expand Down Expand Up @@ -140,6 +140,10 @@ func TestNewRequest(t *testing.T) {
}
for _, tc := range testcases {
t.Run(tc.scenario, func(t *testing.T) {
// ARRANGE
seq = 0
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bito Code Review Agent Run #3867f3 - 07/06/2024, 12:12 am

🔴 High importance
Issue: The variable 'seq' is being set to 0 without any context or explanation. This could lead to confusion or errors if 'seq' is used elsewhere in the test cases or if its value is expected to be different.
Fix: Provide a clear explanation or comment on why 'seq' is being reset to 0. Ensure that this reset does not interfere with other test cases or the overall test logic.
Code suggestion
 @@ -143,4 +143,6 @@
 			// ARRANGE
 +			// Resetting seq to 0 to ensure a consistent starting point for the sequence in each test case
 			seq = 0
			
 			// ACT


// ACT
tc.exec(t)
})
}
Expand Down Expand Up @@ -705,7 +709,7 @@ func TestConvenienceMethods(t *testing.T) {
ioReadAll = func(r io.Reader) ([]byte, error) { return nil, readerr }

// ACT
result, err := UnmarshalJSON[map[string]string](ctx, response)
result, err := UnmarshalJSON[map[string]string](response)

// ASSERT
test.Error(t, err).Is(readerr)
Expand All @@ -718,7 +722,7 @@ func TestConvenienceMethods(t *testing.T) {
response := &http.Response{Body: io.NopCloser(bytes.NewReader([]byte("not valid JSON")))}

// ACT
result, err := UnmarshalJSON[map[string]string](ctx, response)
result, err := UnmarshalJSON[map[string]string](response)

// ASSERT
test.Error(t, err).Is(ErrInvalidJSON)
Expand All @@ -731,24 +735,27 @@ func TestConvenienceMethods(t *testing.T) {
response := &http.Response{Body: io.NopCloser(bytes.NewReader([]byte(`{"key":"value"}`)))}

// ACT
result, err := UnmarshalJSON[int](ctx, response)
result, err := UnmarshalJSON[int](response)

// ASSERT
test.Error(t, err).Is(ErrInvalidJSON)
test.That(t, result).Equals(0)
test.That(t, result).IsNil()
},
},
{scenario: "UnmarshalJSON/ok",
exec: func(t *testing.T) {
// ARRANGE
type body struct {
Key string `json:"key"`
}
response := &http.Response{Body: io.NopCloser(bytes.NewReader([]byte(`{"key":"value"}`)))}

// ACT
result, err := UnmarshalJSON[map[string]string](ctx, response)
result, err := UnmarshalJSON[body](response)

// ASSERT
test.Error(t, err).Is(nil)
test.That(t, result).Equals(map[string]string{"key": "value"})
test.That(t, result).Equals(&body{Key: "value"})
},
},
}
Expand Down
13 changes: 7 additions & 6 deletions mockClient.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ type MockClient interface {
// methods for configuring request and response expectations and
// verifying that those expectations have been met.
type mockClient struct {
name string
id string
hostname string
expectations []*MockRequest
unexpected []*http.Request
Expand All @@ -46,7 +46,7 @@ type mockClient struct {
//
// # params
//
// name // used to identify the mock client in test failure reports and errors
// id // used to identify the mock client in test failure reports and errors
// wrap // optional function(s) to wrap the client with some other client
// // implementation, if required; nil functions are ignored
//
Expand All @@ -65,7 +65,7 @@ func NewMockClient(name string, wrap ...func(c interface {
Do(*http.Request) (*http.Response, error)
}) (HttpClient, MockClient) {
def := &mockClient{
name: name,
id: name,
hostname: "mock://hostname",
next: noExpectedRequests,
}
Expand All @@ -80,7 +80,8 @@ func NewMockClient(name string, wrap ...func(c interface {
mock = wrap(mock)
}

c, _ := NewClient(def.name,
c, _ := NewClient(
ClientID(def.id),
URL(def.hostname),
Using(mock),
)
Expand Down Expand Up @@ -189,7 +190,7 @@ func (mock mockClient) ExpectationsWereMet() error {
}

if len(errs) > 0 {
return MockExpectationsError{mock.name, errs}
return MockExpectationsError{mock.id, errs}
}

return nil
Expand All @@ -207,7 +208,7 @@ func (mock mockClient) ExpectationsWereMet() error {
func (mock *mockClient) Expect(method string, path string) *MockRequest {
if mock.next > 0 {
msg := "requests have already been made"
panic(fmt.Errorf("%s: %w: %s", mock.name, ErrCannotChangeExpectations, msg))
panic(fmt.Errorf("%s: %w: %s", mock.id, ErrCannotChangeExpectations, msg))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bito Code Review Agent Run #3867f3 - 07/06/2024, 12:12 am

🔴 High importance
Issue: The panic message uses 'mock.id' which is a change from 'mock.name'. If 'mock.id' is not properly initialized or if it is an empty string, the error message might not be informative enough to debug the issue.
Fix: Ensure that 'mock.id' is properly initialized and contains a meaningful value before it is used in the panic message. If 'mock.id' can be empty or uninitialized, consider adding a check or a default value to make the error message more informative.
Code suggestion
 @@ -209,6 +209,10 @@
  func (mock *mockClient) Expect(method string, path string) *MockRequest {
      if mock.next > 0 {
          msg := "requests have already been made"
 +        if mock.id == "" {
 +            mock.id = "unknown-client"
 +        }
          panic(fmt.Errorf("%s: %w: %s", mock.id, ErrCannotChangeExpectations, msg))
      }

}

fqu, err := url.JoinPath(mock.hostname, path)
Expand Down
12 changes: 6 additions & 6 deletions mockClient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ func TestNewMockClient(t *testing.T) {

// ASSERT
if c, ok := test.IsType[client](t, c); ok {
test.That(t, c.name).Equals("foo")
test.That(t, c.id).Equals("foo")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bito Code Review Agent Run #3867f3 - 07/06/2024, 12:12 am

🔴 High importance
Issue: The test assertion 'test.That(t, c.id).Equals("foo")' does not verify if the 'id' field is correctly set in the 'client' struct. If the 'id' field is not set correctly, the test will fail, but it won't provide any information about the actual value of 'id'.
Fix: Add a message to the assertion to provide more context if the test fails. This will help in debugging by showing the actual value of 'id'.
Code suggestion
 @@ -39,7 +39,7 @@
  if c, ok := test.IsType[client](t, c); ok {
 -    test.That(t, c.id).Equals("foo")
 +    test.That(t, c.id).Equals("foo", "Expected client id to be 'foo', but got: %v", c.id)
      test.That(t, c.url).Equals("mock://hostname")
  }

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The blugnu/test package used for this test uses a fluent API to assert expected vs actual values. If the test fails, the value of c.id and the value in the argument passed to the Equals() fluent method are both included in the test failure report.

test.That(t, c.url).Equals("mock://hostname")
}
if m, ok := test.IsType[*mockClient](t, m); ok {
test.That(t, m.name).Equals("foo")
test.That(t, m.id).Equals("foo")
test.That(t, m.hostname).Equals("mock://hostname")
}
test.IsTrue(t, wrappersAreApplied)
Expand Down Expand Up @@ -198,7 +198,7 @@ func TestMockClient(t *testing.T) {
exec: func(t *testing.T) {
// ARRANGE
client := &mockClient{
name: "foo",
id: "foo",
next: noExpectedRequests,
unexpected: []*http.Request{{Method: http.MethodGet, URL: &url.URL{Scheme: "http", Host: "hostname", Path: "path"}}},
}
Expand All @@ -219,7 +219,7 @@ func TestMockClient(t *testing.T) {
exec: func(t *testing.T) {
// ARRANGE
client := &mockClient{
name: "foo",
id: "foo",
next: 0,
expectations: []*MockRequest{{}},
unexpected: []*http.Request{{Method: http.MethodGet, URL: &url.URL{Scheme: "http", Host: "hostname", Path: "path"}}},
Expand Down Expand Up @@ -265,7 +265,7 @@ func TestMockClient(t *testing.T) {
// ARRANGE
m := http.MethodPost
client := &mockClient{
name: "foo",
id: "foo",
expectations: []*MockRequest{
{
isExpected: true,
Expand Down Expand Up @@ -297,7 +297,7 @@ func TestMockClient(t *testing.T) {
exec: func(t *testing.T) {
// ARRANGE
client := &mockClient{
name: "foo",
id: "foo",
expectations: []*MockRequest{
{
isExpected: true,
Expand Down