diff --git a/api/datasets/datasets.go b/api/datasets/datasets.go index 9e1019c..4b812c8 100644 --- a/api/datasets/datasets.go +++ b/api/datasets/datasets.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" "strconv" "github.com/braintrustdata/braintrust-sdk-go/internal/https" @@ -104,31 +105,31 @@ func (a *API) Fetch(ctx context.Context, datasetID string, cursor string, limit // Query searches for datasets by name, version, or other criteria. func (a *API) Query(ctx context.Context, params QueryParams) (*QueryResponse, error) { // Build query parameters - queryParams := make(map[string]string) + queryParams := url.Values{} if params.ID != "" { - queryParams["id"] = params.ID + queryParams.Set("id", params.ID) } if params.Name != "" { - queryParams["dataset_name"] = params.Name + queryParams.Set("dataset_name", params.Name) } if params.Version != "" { - queryParams["version"] = params.Version + queryParams.Set("version", params.Version) } if params.ProjectID != "" { - queryParams["project_id"] = params.ProjectID + queryParams.Set("project_id", params.ProjectID) } if params.ProjectName != "" { - queryParams["project_name"] = params.ProjectName + queryParams.Set("project_name", params.ProjectName) } if params.Limit > 0 { - queryParams["limit"] = strconv.Itoa(params.Limit) + queryParams.Set("limit", strconv.Itoa(params.Limit)) } if params.StartingAfter != "" { - queryParams["starting_after"] = params.StartingAfter + queryParams.Set("starting_after", params.StartingAfter) } if params.EndingBefore != "" { - queryParams["ending_before"] = params.EndingBefore + queryParams.Set("ending_before", params.EndingBefore) } resp, err := a.client.GET(ctx, "/v1/dataset", queryParams) diff --git a/api/experiments/experiments.go b/api/experiments/experiments.go index 39b2734..e1d6585 100644 --- a/api/experiments/experiments.go +++ b/api/experiments/experiments.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" "strconv" "github.com/braintrustdata/braintrust-sdk-go/internal/https" @@ -61,19 +62,34 @@ func (a *API) Register(ctx context.Context, name, projectID string, opts Registe // List returns a list of experiments filtered by the given parameters. func (a *API) List(ctx context.Context, params ListParams) (*ListResponse, error) { - queryParams := make(map[string]string) + queryParams := url.Values{} if params.ProjectID != "" { - queryParams["project_id"] = params.ProjectID + queryParams.Set("project_id", params.ProjectID) + } + if params.ProjectName != "" { + queryParams.Set("project_name", params.ProjectName) } if params.ExperimentName != "" { - queryParams["experiment_name"] = params.ExperimentName + queryParams.Set("experiment_name", params.ExperimentName) } if params.OrgName != "" { - queryParams["org_name"] = params.OrgName + queryParams.Set("org_name", params.OrgName) } if params.Limit > 0 { - queryParams["limit"] = strconv.Itoa(params.Limit) + queryParams.Set("limit", strconv.Itoa(params.Limit)) + } + if params.StartingAfter != "" { + queryParams.Set("starting_after", params.StartingAfter) + } + if params.EndingBefore != "" { + queryParams.Set("ending_before", params.EndingBefore) + } + if len(params.IDs) > 0 { + // Add multiple values for the ids parameter + for _, id := range params.IDs { + queryParams.Add("ids", id) + } } resp, err := a.client.GET(ctx, "/v1/experiment", queryParams) @@ -110,6 +126,100 @@ func (a *API) Get(ctx context.Context, experimentID string) (*Experiment, error) return &result, nil } +// Update partially updates an experiment by its ID. +// Only the fields provided in params will be updated. +func (a *API) Update(ctx context.Context, experimentID string, params UpdateParams) (*Experiment, error) { + if experimentID == "" { + return nil, fmt.Errorf("experiment ID is required") + } + + resp, err := a.client.PATCH(ctx, "/v1/experiment/"+experimentID, params) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + var result Experiment + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &result, nil +} + +// InsertEvents inserts events into an experiment. +func (a *API) InsertEvents(ctx context.Context, experimentID string, events []ExperimentEvent) (*InsertEventsResponse, error) { + if experimentID == "" { + return nil, fmt.Errorf("experiment ID is required") + } + if len(events) == 0 { + return nil, fmt.Errorf("at least one event is required") + } + + reqBody := InsertEventsRequest{Events: events} + resp, err := a.client.POST(ctx, "/v1/experiment/"+experimentID+"/insert", reqBody) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + var result InsertEventsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &result, nil +} + +// FetchEvents retrieves events from an experiment with optional pagination. +// This uses the POST variant of the fetch endpoint, which accepts filter parameters in the request body. +func (a *API) FetchEvents(ctx context.Context, experimentID string, params FetchEventsParams) (*FetchEventsResponse, error) { + if experimentID == "" { + return nil, fmt.Errorf("experiment ID is required") + } + + resp, err := a.client.POST(ctx, "/v1/experiment/"+experimentID+"/fetch", params) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + var result FetchEventsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &result, nil +} + +// Summarize returns summary statistics for an experiment, including score averages and comparisons. +func (a *API) Summarize(ctx context.Context, experimentID string, params SummarizeParams) (*SummarizeResponse, error) { + if experimentID == "" { + return nil, fmt.Errorf("experiment ID is required") + } + + queryParams := url.Values{} + if params.SummarizeScores { + queryParams.Set("summarize_scores", "true") + } + if params.ComparisonExperimentID != "" { + queryParams.Set("comparison_experiment_id", params.ComparisonExperimentID) + } + + resp, err := a.client.GET(ctx, "/v1/experiment/"+experimentID+"/summarize", queryParams) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + var result SummarizeResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &result, nil +} + // Delete deletes an experiment by its ID. func (a *API) Delete(ctx context.Context, experimentID string) error { if experimentID == "" { diff --git a/api/experiments/experiments_test.go b/api/experiments/experiments_test.go index a8037e1..e45c945 100644 --- a/api/experiments/experiments_test.go +++ b/api/experiments/experiments_test.go @@ -356,3 +356,331 @@ func TestExperiments_EnsureNew(t *testing.T) { // Names should start with the same prefix (API may append suffix to avoid conflicts) assert.Contains(t, second.Name, expName, "EnsureNew experiment name should contain original name") } + +// TestExperiments_List_Pagination tests list pagination with cursors +func TestExperiments_List_Pagination(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Create API client + client := tests.GetTestHTTPSClient(t) + api := New(client) + + // Create a project + project := createTestProject(t) + + // Create multiple experiments + for i := 0; i < 5; i++ { + _, err := api.Create(ctx, CreateParams{ + ProjectID: project.ID, + Name: tests.RandomName(t, "pagination-test"), + }) + require.NoError(t, err) + } + + // List with limit of 2 + firstPage, err := api.List(ctx, ListParams{ + ProjectID: project.ID, + Limit: 2, + }) + require.NoError(t, err) + require.Len(t, firstPage.Objects, 2) + + // Get second page using starting_after cursor + if firstPage.Cursor != "" { + secondPage, err := api.List(ctx, ListParams{ + ProjectID: project.ID, + Limit: 2, + StartingAfter: firstPage.Cursor, + }) + require.NoError(t, err) + require.NotEmpty(t, secondPage.Objects) + + // Ensure no overlap between pages + firstIDs := make(map[string]bool) + for _, exp := range firstPage.Objects { + firstIDs[exp.ID] = true + } + for _, exp := range secondPage.Objects { + assert.False(t, firstIDs[exp.ID], "Second page should not contain IDs from first page") + } + } +} + +// TestExperiments_List_FilterByIDs tests filtering by specific IDs +func TestExperiments_List_FilterByIDs(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Create API client + client := tests.GetTestHTTPSClient(t) + api := New(client) + + // Create a project + project := createTestProject(t) + + // Create several experiments + exp1, err := api.Create(ctx, CreateParams{ + ProjectID: project.ID, + Name: tests.RandomName(t, "filter-test-1"), + }) + require.NoError(t, err) + + exp2, err := api.Create(ctx, CreateParams{ + ProjectID: project.ID, + Name: tests.RandomName(t, "filter-test-2"), + }) + require.NoError(t, err) + + // Create a third one we won't filter for + _, err = api.Create(ctx, CreateParams{ + ProjectID: project.ID, + Name: tests.RandomName(t, "filter-test-3"), + }) + require.NoError(t, err) + + // List filtering by specific IDs + response, err := api.List(ctx, ListParams{ + ProjectID: project.ID, + IDs: []string{exp1.ID, exp2.ID}, + }) + require.NoError(t, err) + require.Len(t, response.Objects, 2) + + // Verify we got the right experiments + ids := make(map[string]bool) + for _, exp := range response.Objects { + ids[exp.ID] = true + } + assert.True(t, ids[exp1.ID], "Should include exp1") + assert.True(t, ids[exp2.ID], "Should include exp2") +} + +// TestExperiments_Update_Integration tests updating an experiment +func TestExperiments_Update_Integration(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Create API client + client := tests.GetTestHTTPSClient(t) + api := New(client) + + // Create a project and experiment + project := createTestProject(t) + experiment, err := api.Create(ctx, CreateParams{ + ProjectID: project.ID, + Name: tests.RandomName(t, "update-test"), + Description: "Original description", + Tags: []string{"original"}, + Metadata: map[string]interface{}{ + "version": "1.0", + }, + }) + require.NoError(t, err) + + // Update the experiment + newDescription := "Updated description" + newName := tests.RandomName(t, "updated-name") + updated, err := api.Update(ctx, experiment.ID, UpdateParams{ + Name: newName, + Description: &newDescription, + Tags: []string{"updated", "test"}, + Metadata: map[string]interface{}{ + "version": "2.0", + "updated": true, + }, + }) + require.NoError(t, err) + require.NotNil(t, updated) + + // Verify the update + assert.Equal(t, experiment.ID, updated.ID) + assert.Equal(t, newName, updated.Name) + assert.Equal(t, "Updated description", updated.Description) + assert.Contains(t, updated.Tags, "updated") + assert.Contains(t, updated.Tags, "test") + assert.Equal(t, "2.0", updated.Metadata["version"]) + assert.Equal(t, true, updated.Metadata["updated"]) +} + +// TestExperiments_InsertEvents_Integration tests inserting events into an experiment +func TestExperiments_InsertEvents_Integration(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Create API client + client := tests.GetTestHTTPSClient(t) + api := New(client) + + // Create a project and experiment + project := createTestProject(t) + experiment, err := api.Create(ctx, CreateParams{ + ProjectID: project.ID, + Name: tests.RandomName(t, "insert-events-test"), + }) + require.NoError(t, err) + + // Insert events + events := []ExperimentEvent{ + { + Input: map[string]interface{}{ + "prompt": "What is 2+2?", + }, + Output: map[string]interface{}{ + "answer": "4", + }, + Expected: map[string]interface{}{ + "answer": "4", + }, + Scores: map[string]float64{ + "accuracy": 1.0, + }, + Metadata: map[string]interface{}{ + "model": "gpt-4", + }, + }, + { + Input: map[string]interface{}{ + "prompt": "What is the capital of France?", + }, + Output: map[string]interface{}{ + "answer": "Paris", + }, + Expected: map[string]interface{}{ + "answer": "Paris", + }, + Scores: map[string]float64{ + "accuracy": 1.0, + }, + }, + } + + result, err := api.InsertEvents(ctx, experiment.ID, events) + require.NoError(t, err) + require.NotNil(t, result) + assert.Len(t, result.RowIDs, 2, "Should return row IDs for inserted events") +} + +// TestExperiments_FetchEvents_Integration tests fetching events from an experiment +func TestExperiments_FetchEvents_Integration(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Create API client + client := tests.GetTestHTTPSClient(t) + api := New(client) + + // Create a project and experiment + project := createTestProject(t) + experiment, err := api.Create(ctx, CreateParams{ + ProjectID: project.ID, + Name: tests.RandomName(t, "fetch-events-test"), + }) + require.NoError(t, err) + + // Insert some events first + events := []ExperimentEvent{ + { + Input: map[string]interface{}{ + "prompt": "Test prompt 1", + }, + Output: map[string]interface{}{ + "answer": "Test answer 1", + }, + Scores: map[string]float64{ + "accuracy": 1.0, + }, + }, + { + Input: map[string]interface{}{ + "prompt": "Test prompt 2", + }, + Output: map[string]interface{}{ + "answer": "Test answer 2", + }, + Scores: map[string]float64{ + "accuracy": 0.8, + }, + }, + } + _, err = api.InsertEvents(ctx, experiment.ID, events) + require.NoError(t, err) + + // Fetch the events + fetchResult, err := api.FetchEvents(ctx, experiment.ID, FetchEventsParams{ + Limit: 10, + }) + require.NoError(t, err) + require.NotNil(t, fetchResult) + assert.GreaterOrEqual(t, len(fetchResult.Events), 2, "Should fetch at least the inserted events") + + // Verify event structure + for _, event := range fetchResult.Events { + assert.NotNil(t, event.Input, "Event should have input") + assert.NotNil(t, event.Output, "Event should have output") + assert.NotNil(t, event.Scores, "Event should have scores") + } +} + +// TestExperiments_Summarize_Integration tests getting experiment summary +func TestExperiments_Summarize_Integration(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Create API client + client := tests.GetTestHTTPSClient(t) + api := New(client) + + // Create a project and experiment + project := createTestProject(t) + experiment, err := api.Create(ctx, CreateParams{ + ProjectID: project.ID, + Name: tests.RandomName(t, "summarize-test"), + }) + require.NoError(t, err) + + // Insert some events with scores + events := []ExperimentEvent{ + { + Input: map[string]interface{}{ + "prompt": "Test 1", + }, + Output: map[string]interface{}{ + "answer": "Answer 1", + }, + Scores: map[string]float64{ + "accuracy": 1.0, + "quality": 0.9, + }, + }, + { + Input: map[string]interface{}{ + "prompt": "Test 2", + }, + Output: map[string]interface{}{ + "answer": "Answer 2", + }, + Scores: map[string]float64{ + "accuracy": 0.8, + "quality": 0.85, + }, + }, + } + _, err = api.InsertEvents(ctx, experiment.ID, events) + require.NoError(t, err) + + // Get summary + summary, err := api.Summarize(ctx, experiment.ID, SummarizeParams{ + SummarizeScores: true, + }) + require.NoError(t, err) + require.NotNil(t, summary) + assert.Equal(t, experiment.Name, summary.ExperimentName) + assert.NotEmpty(t, summary.ProjectName) +} diff --git a/api/experiments/types.go b/api/experiments/types.go index fe30678..558c88b 100644 --- a/api/experiments/types.go +++ b/api/experiments/types.go @@ -46,19 +46,150 @@ type RegisterOpts struct { DatasetVersion string // Optional dataset version } +// UpdateParams represents parameters for updating an experiment. +// All fields are optional - only provided fields will be updated. +type UpdateParams struct { + Name string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` // Pointer to distinguish between empty string and not set + BaseExpID string `json:"base_exp_id,omitempty"` + DatasetID string `json:"dataset_id,omitempty"` + DatasetVersion string `json:"dataset_version,omitempty"` + Public *bool `json:"public,omitempty"` // Pointer to distinguish between false and not set + Tags []string `json:"tags,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + // ListParams represents parameters for listing experiments type ListParams struct { // ProjectID filters experiments by project ProjectID string + // ProjectName filters by project name + ProjectName string // ExperimentName filters by specific experiment name ExperimentName string // OrgName filters by organization name OrgName string + // IDs filters search results to a particular set of object IDs + IDs []string // Limit maximum number of objects to return (default 25, max 1000) Limit int + // StartingAfter is a cursor for pagination (fetches records after this ID) + StartingAfter string + // EndingBefore is a cursor for pagination (fetches records before this ID) + EndingBefore string } // ListResponse represents a paginated list of experiments type ListResponse struct { Objects []Experiment `json:"objects"` + // Cursor for pagination to fetch the next page of results + Cursor string `json:"cursor,omitempty"` +} + +// ExperimentEvent represents an event to be inserted into an experiment. +// All fields are optional. +type ExperimentEvent struct { + // Input is the arguments that uniquely define a test case (JSON serializable). + Input interface{} `json:"input,omitempty"` + // Output is the output of your application (JSON serializable). + Output interface{} `json:"output,omitempty"` + // Expected is the ground truth value (JSON serializable). + Expected interface{} `json:"expected,omitempty"` + // Error is the error that occurred, if any. + Error interface{} `json:"error,omitempty"` + // Scores is a dictionary of numeric values (between 0 and 1) to log. + Scores map[string]float64 `json:"scores,omitempty"` + // Metadata is additional data about the test example. + Metadata map[string]interface{} `json:"metadata,omitempty"` + // Tags is a list of tags to log. + Tags []string `json:"tags,omitempty"` + // Metrics are numerical measurements tracking execution. + Metrics map[string]interface{} `json:"metrics,omitempty"` + // Context is additional context for the event. + Context map[string]interface{} `json:"context,omitempty"` + // SpanAttributes are attributes for the span. + SpanAttributes map[string]interface{} `json:"span_attributes,omitempty"` + // ID is a unique identifier for the event. If you don't provide one, BT will generate one. + ID string `json:"id,omitempty"` + // SpanID is the ID of this span. + SpanID string `json:"span_id,omitempty"` + // RootSpanID is the ID of the root span. + RootSpanID string `json:"root_span_id,omitempty"` + // SpanParents are the parent span IDs. + SpanParents []string `json:"span_parents,omitempty"` +} + +// InsertEventsRequest is the request body for inserting events. +type InsertEventsRequest struct { + Events []ExperimentEvent `json:"events"` +} + +// InsertEventsResponse is the response from inserting events. +type InsertEventsResponse struct { + // RowIDs are the IDs of the inserted rows. + RowIDs []string `json:"row_ids"` +} + +// FetchEventsParams represents parameters for fetching experiment events. +type FetchEventsParams struct { + // Limit controls the number of traces returned (pagination-aware). + Limit int `json:"limit,omitempty"` + // Cursor is an opaque pagination cursor for retrieving subsequent result pages. + Cursor string `json:"cursor,omitempty"` + // MaxXactID is deprecated; used for manual pagination cursor construction. + MaxXactID string `json:"max_xact_id,omitempty"` + // MaxRootSpanID is deprecated; used with max_xact_id for manual pagination. + MaxRootSpanID string `json:"max_root_span_id,omitempty"` + // Version retrieves a snapshot from a past point in time. + Version string `json:"version,omitempty"` +} + +// FetchEventsResponse is the response from fetching experiment events. +type FetchEventsResponse struct { + // Events is the list of fetched events. + Events []ExperimentEvent `json:"events"` + // Cursor for pagination to fetch the next page of results. + Cursor string `json:"cursor,omitempty"` +} + +// SummarizeParams represents parameters for summarizing an experiment. +type SummarizeParams struct { + // SummarizeScores determines whether to summarize the scores and metrics. + // If false (or omitted), only metadata will be returned. + SummarizeScores bool + // ComparisonExperimentID specifies a baseline experiment for comparison. + // Falls back to base_exp_id metadata or the most recent project experiment if omitted. + ComparisonExperimentID string +} + +// SummarizeResponse is the response from summarizing an experiment. +type SummarizeResponse struct { + ProjectName string `json:"project_name"` + ProjectID string `json:"project_id,omitempty"` + ExperimentName string `json:"experiment_name"` + ExperimentID string `json:"experiment_id,omitempty"` + ExperimentURL string `json:"experiment_url,omitempty"` + ProjectURL string `json:"project_url,omitempty"` + ComparisonExperimentName string `json:"comparison_experiment_name,omitempty"` + Scores map[string]ScoreSummary `json:"scores,omitempty"` + Metrics map[string]MetricSummary `json:"metrics,omitempty"` +} + +// ScoreSummary contains summary statistics for a score. +type ScoreSummary struct { + Name string `json:"name"` + Score float64 `json:"score"` // Average score (0-1) + Diff float64 `json:"diff,omitempty"` // Difference from comparison + Improvements int `json:"improvements,omitempty"` + Regressions int `json:"regressions,omitempty"` +} + +// MetricSummary contains summary statistics for a metric. +type MetricSummary struct { + Name string `json:"name"` + Metric float64 `json:"metric"` + Unit string `json:"unit,omitempty"` + Diff float64 `json:"diff,omitempty"` // Difference from comparison + Improvements int `json:"improvements,omitempty"` + Regressions int `json:"regressions,omitempty"` } diff --git a/api/functions/functions.go b/api/functions/functions.go index 573c115..60fa328 100644 --- a/api/functions/functions.go +++ b/api/functions/functions.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "net/url" "github.com/braintrustdata/braintrust-sdk-go/internal/https" ) @@ -18,28 +19,28 @@ func New(client *https.Client) *API { // Returns a list of functions that match the criteria. func (a *API) Query(ctx context.Context, params QueryParams) ([]Function, error) { // Build query parameters - queryParams := make(map[string]string) + queryParams := url.Values{} if params.ProjectName != "" { - queryParams["project_name"] = params.ProjectName + queryParams.Set("project_name", params.ProjectName) } if params.ProjectID != "" { - queryParams["project_id"] = params.ProjectID + queryParams.Set("project_id", params.ProjectID) } if params.Slug != "" { - queryParams["slug"] = params.Slug + queryParams.Set("slug", params.Slug) } if params.FunctionName != "" { - queryParams["function_name"] = params.FunctionName + queryParams.Set("function_name", params.FunctionName) } if params.Version != "" { - queryParams["version"] = params.Version + queryParams.Set("version", params.Version) } if params.Environment != "" { - queryParams["environment"] = params.Environment + queryParams.Set("environment", params.Environment) } if params.Limit > 0 { - queryParams["limit"] = fmt.Sprintf("%d", params.Limit) + queryParams.Set("limit", fmt.Sprintf("%d", params.Limit)) } resp, err := a.client.GET(ctx, "/v1/function", queryParams) diff --git a/api/projects/projects.go b/api/projects/projects.go index 0288608..eb886af 100644 --- a/api/projects/projects.go +++ b/api/projects/projects.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" "strconv" "github.com/braintrustdata/braintrust-sdk-go/internal/https" @@ -79,13 +80,13 @@ func (a *API) Get(ctx context.Context, id string) (*Project, error) { // Limit: 10, // }) func (a *API) List(ctx context.Context, params ListParams) (*ListResponse, error) { - queryParams := make(map[string]string) + queryParams := url.Values{} if params.OrgID != "" { - queryParams["org_id"] = params.OrgID + queryParams.Set("org_id", params.OrgID) } if params.Limit > 0 { - queryParams["limit"] = strconv.Itoa(params.Limit) + queryParams.Set("limit", strconv.Itoa(params.Limit)) } resp, err := a.client.GET(ctx, "/v1/project", queryParams) diff --git a/eval/eval_integration_test.go b/eval/eval_integration_test.go index 0d042d5..4fb0003 100644 --- a/eval/eval_integration_test.go +++ b/eval/eval_integration_test.go @@ -652,8 +652,9 @@ func TestEval_DifferentProject(t *testing.T) { apiClient, err := api.NewClient(endpoints.APIKey, api.WithAPIURL(endpoints.APIURL)) require.NoError(t, err) - // Create a different project (use fixed suffix instead of random) - differentProjectName := integrationTestProject + "-other" + // Create a different project with a unique name to avoid conflicts with parallel tests + // The project will be deleted in the defer block below + differentProjectName := tests.RandomName(t, "project-other") project, err := apiClient.Projects().Create(ctx, projects.CreateParams{Name: differentProjectName}) require.NoError(t, err) require.NotNil(t, project) diff --git a/internal/https/client.go b/internal/https/client.go index fad1d37..ed110b1 100644 --- a/internal/https/client.go +++ b/internal/https/client.go @@ -47,16 +47,12 @@ func NewClient(apiKey, apiURL string, log logger.Logger) (*Client, error) { } // GET makes a GET request with query parameters. -func (c *Client) GET(ctx context.Context, path string, params map[string]string) (*http.Response, error) { +func (c *Client) GET(ctx context.Context, path string, params url.Values) (*http.Response, error) { fullURL := c.apiURL + path // Add query parameters if provided if len(params) > 0 { - urlValues := url.Values{} - for k, v := range params { - urlValues.Add(k, v) - } - fullURL = fullURL + "?" + urlValues.Encode() + fullURL = fullURL + "?" + params.Encode() } req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil) @@ -92,6 +88,31 @@ func (c *Client) POST(ctx context.Context, path string, body interface{}) (*http return c.doRequest(req) } +// PATCH makes a PATCH request with a JSON body. +func (c *Client) PATCH(ctx context.Context, path string, body interface{}) (*http.Response, error) { + var reqBody io.Reader + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %w", err) + } + reqBody = bytes.NewBuffer(jsonData) + + c.logger.Debug("http request body", "body", string(jsonData)) + } + + req, err := http.NewRequestWithContext(ctx, "PATCH", c.apiURL+path, reqBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return c.doRequest(req) +} + // DELETE makes a DELETE request. func (c *Client) DELETE(ctx context.Context, path string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, "DELETE", c.apiURL+path, nil)