diff --git a/axiom/client.go b/axiom/client.go index a70b938..28f1d04 100644 --- a/axiom/client.go +++ b/axiom/client.go @@ -99,6 +99,7 @@ type Client struct { Notifiers *NotifiersService Annotations *AnnotationsService Tokens *TokensService + VirtualFields *VirtualFieldsService } // NewClient returns a new Axiom API client. It automatically takes its @@ -136,6 +137,7 @@ func NewClient(options ...Option) (*Client, error) { client.Notifiers = &NotifiersService{client: client, basePath: "/v2/notifiers"} client.Annotations = &AnnotationsService{client: client, basePath: "/v2/annotations"} client.Tokens = &TokensService{client: client, basePath: "/v2/tokens"} + client.VirtualFields = &VirtualFieldsService{client: client, basePath: "/v2/vfields"} // Apply supplied options. if err := client.Options(options...); err != nil { diff --git a/axiom/vfields.go b/axiom/vfields.go new file mode 100644 index 0000000..88bbf38 --- /dev/null +++ b/axiom/vfields.go @@ -0,0 +1,127 @@ +package axiom + +import ( + "context" + "net/http" + "net/url" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type VirtualField struct { + // Dataset is the dataset to which the virtual field belongs. + Dataset string `json:"dataset"` + // Name is the name of the virtual field. + Name string `json:"name"` + // Expression defines the virtual field's APL. + Expression string `json:"expression"` + // Description is an optional description of the virtual field. + Description string `json:"description,omitempty"` + // Type is the type of the virtual field. E.g. string | number + Type string `json:"type,omitempty"` + // Unit is the unit for the type of data returned by the virtual field. + Unit string `json:"unit,omitempty"` +} + +type VirtualFieldWithId struct { + VirtualField + // ID is the unique identifier of the virtual field. + ID string `json:"id"` +} + +// Axiom API Reference: /v2/vfields +type VirtualFieldsService service + +// List all virtual fields for a given dataset. +func (s *VirtualFieldsService) List(ctx context.Context, dataset string) ([]*VirtualFieldWithId, error) { + ctx, span := s.client.trace(ctx, "VirtualFields.List", trace.WithAttributes( + attribute.String("axiom.param.dataset", dataset), + )) + defer span.End() + + params := url.Values{} + params.Set("dataset", dataset) + + var res []*VirtualFieldWithId + if err := s.client.Call(ctx, http.MethodGet, s.basePath+"?"+params.Encode(), nil, &res); err != nil { + return nil, spanError(span, err) + } + + return res, nil +} + +// Get a virtual field by id. +func (s *VirtualFieldsService) Get(ctx context.Context, id string) (*VirtualFieldWithId, error) { + ctx, span := s.client.trace(ctx, "VirtualFields.Get", trace.WithAttributes( + attribute.String("axiom.virtual_field_id", id), + )) + defer span.End() + + path, err := url.JoinPath(s.basePath, id) + if err != nil { + return nil, spanError(span, err) + } + + var res VirtualFieldWithId + if err := s.client.Call(ctx, http.MethodGet, path, nil, &res); err != nil { + return nil, spanError(span, err) + } + + return &res, nil +} + +// Create a virtual field with the given properties. +func (s *VirtualFieldsService) Create(ctx context.Context, req VirtualField) (*VirtualFieldWithId, error) { + ctx, span := s.client.trace(ctx, "VirtualFields.Create", trace.WithAttributes( + attribute.String("axiom.param.dataset", req.Dataset), + attribute.String("axiom.param.name", req.Name), + )) + defer span.End() + + var res VirtualFieldWithId + if err := s.client.Call(ctx, http.MethodPost, s.basePath, req, &res); err != nil { + return nil, spanError(span, err) + } + + return &res, nil +} + +// Update the virtual field identified by the given id with the given properties. +func (s *VirtualFieldsService) Update(ctx context.Context, id string, req VirtualField) (*VirtualFieldWithId, error) { + ctx, span := s.client.trace(ctx, "VirtualFields.Update", trace.WithAttributes( + attribute.String("axiom.virtual_field_id", id), + )) + defer span.End() + + path, err := url.JoinPath(s.basePath, id) + if err != nil { + return nil, spanError(span, err) + } + + var res VirtualFieldWithId + if err := s.client.Call(ctx, http.MethodPut, path, req, &res); err != nil { + return nil, spanError(span, err) + } + + return &res, nil +} + +// Delete the virtual field identified by the given id. +func (s *VirtualFieldsService) Delete(ctx context.Context, id string) error { + ctx, span := s.client.trace(ctx, "VirtualFields.Delete", trace.WithAttributes( + attribute.String("axiom.virtual_field_id", id), + )) + defer span.End() + + path, err := url.JoinPath(s.basePath, id) + if err != nil { + return spanError(span, err) + } + + if err := s.client.Call(ctx, http.MethodDelete, path, nil, nil); err != nil { + return spanError(span, err) + } + + return nil +} diff --git a/axiom/vfields_integration_test.go b/axiom/vfields_integration_test.go new file mode 100644 index 0000000..0770280 --- /dev/null +++ b/axiom/vfields_integration_test.go @@ -0,0 +1,121 @@ +package axiom_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/axiomhq/axiom-go/axiom" +) + +// VirtualFieldsTestSuite tests all methods of the Axiom Virtual Fields API +// against a live deployment. +type VirtualFieldsTestSuite struct { + IntegrationTestSuite + + // Setup once per test. + vfield *axiom.VirtualFieldWithId +} + +func TestVirtualFieldsTestSuite(t *testing.T) { + suite.Run(t, new(VirtualFieldsTestSuite)) +} + +func (s *VirtualFieldsTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite() +} + +func (s *VirtualFieldsTestSuite) TearDownSuite() { + s.IntegrationTestSuite.TearDownSuite() +} + +func (s *VirtualFieldsTestSuite) SetupTest() { + s.IntegrationTestSuite.SetupTest() + + var err error + s.vfield, err = s.client.VirtualFields.Create(s.ctx, axiom.VirtualField{ + Dataset: "test-dataset", + Name: "Test Field", + Expression: "a + b", + Type: "number", + }) + s.Require().NoError(err) + s.Require().NotNil(s.vfield) +} + +func (s *VirtualFieldsTestSuite) TearDownTest() { + // Teardown routines use their own context to avoid not being run at all + // when the suite gets cancelled or times out. + ctx, cancel := context.WithTimeout(context.WithoutCancel(s.ctx), time.Second*15) + defer cancel() + + err := s.client.VirtualFields.Delete(ctx, s.vfield.ID) + s.NoError(err) + + s.IntegrationTestSuite.TearDownTest() +} + +func (s *VirtualFieldsTestSuite) Test() { + // Update the virtual field. + vfield, err := s.client.VirtualFields.Update(s.ctx, s.vfield.ID, axiom.VirtualField{ + Dataset: "test-dataset", + Name: "Updated Test Field", + Expression: "a - b", + Type: "number", + }) + s.Require().NoError(err) + s.Require().NotNil(vfield) + + s.vfield = vfield + + // Get the virtual field and make sure it matches the updated values. + vfield, err = s.client.VirtualFields.Get(s.ctx, s.vfield.ID) + s.Require().NoError(err) + s.Require().NotNil(vfield) + + s.Equal(s.vfield, vfield) + + // List all virtual fields for the dataset and ensure the created field is part of the list. + vfields, err := s.client.VirtualFields.List(s.ctx, "test-dataset") + s.Require().NoError(err) + s.Require().NotEmpty(vfields) + + s.Contains(vfields, s.vfield) +} + +func (s *VirtualFieldsTestSuite) TestCreateAndDeleteVirtualField() { + // Create a new virtual field. + vfield, err := s.client.VirtualFields.Create(s.ctx, axiom.VirtualField{ + Dataset: "test-dataset", + Name: "New Test Field", + Expression: "x * y", + Type: "number", + }) + s.Require().NoError(err) + s.Require().NotNil(vfield) + + // Get the virtual field and ensure it matches what was created. + fetchedField, err := s.client.VirtualFields.Get(s.ctx, vfield.ID) + s.Require().NoError(err) + s.Require().NotNil(fetchedField) + s.Equal(vfield, fetchedField) + + // Delete the virtual field. + err = s.client.VirtualFields.Delete(s.ctx, vfield.ID) + s.Require().NoError(err) + + // Ensure the virtual field no longer exists. + _, err = s.client.VirtualFields.Get(s.ctx, vfield.ID) + s.Error(err) +} + +func (s *VirtualFieldsTestSuite) TestListVirtualFields() { + // List all virtual fields for the dataset and ensure the created field is part of the list. + vfields, err := s.client.VirtualFields.List(s.ctx, "test-dataset") + s.Require().NoError(err) + s.Require().NotEmpty(vfields) + + s.Contains(vfields, s.vfield) +} diff --git a/axiom/vfields_test.go b/axiom/vfields_test.go new file mode 100644 index 0000000..c5c3600 --- /dev/null +++ b/axiom/vfields_test.go @@ -0,0 +1,165 @@ +package axiom + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVirtualFieldsService_List(t *testing.T) { + exp := []*VirtualFieldWithId{ + { + ID: "vfield1", + VirtualField: VirtualField{ + Dataset: "dataset1", + Name: "field1", + Expression: "a + b", + Type: "number", + }, + }, + } + + hf := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "dataset1", r.URL.Query().Get("dataset")) + + w.Header().Set("Content-Type", mediaTypeJSON) + _, err := fmt.Fprint(w, `[{ + "id": "vfield1", + "dataset": "dataset1", + "name": "field1", + "expression": "a + b", + "type": "number" + }]`) + assert.NoError(t, err) + } + client := setup(t, "GET /v2/vfields", hf) + + res, err := client.VirtualFields.List(context.Background(), "dataset1") + require.NoError(t, err) + + assert.Equal(t, exp, res) +} + +func TestVirtualFieldsService_Get(t *testing.T) { + exp := &VirtualFieldWithId{ + ID: "vfield1", + VirtualField: VirtualField{ + Dataset: "dataset1", + Name: "field1", + Expression: "a + b", + Type: "number", + }, + } + + hf := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + + w.Header().Set("Content-Type", mediaTypeJSON) + _, err := fmt.Fprint(w, `{ + "id": "vfield1", + "dataset": "dataset1", + "name": "field1", + "expression": "a + b", + "type": "number" + }`) + assert.NoError(t, err) + } + client := setup(t, "GET /v2/vfields/vfield1", hf) + + res, err := client.VirtualFields.Get(context.Background(), "vfield1") + require.NoError(t, err) + + assert.Equal(t, exp, res) +} + +func TestVirtualFieldsService_Create(t *testing.T) { + exp := &VirtualFieldWithId{ + ID: "vfield1", + VirtualField: VirtualField{ + Dataset: "dataset1", + Name: "field1", + Expression: "a + b", + Type: "number", + }, + } + hf := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, mediaTypeJSON, r.Header.Get("Content-Type")) + + w.Header().Set("Content-Type", mediaTypeJSON) + _, err := fmt.Fprint(w, `{ + "id": "vfield1", + "dataset": "dataset1", + "name": "field1", + "expression": "a + b", + "type": "number" + }`) + assert.NoError(t, err) + } + client := setup(t, "POST /v2/vfields", hf) + + res, err := client.VirtualFields.Create(context.Background(), VirtualField{ + Dataset: "dataset1", + Name: "field1", + Expression: "a + b", + Type: "number", + }) + require.NoError(t, err) + + assert.Equal(t, exp, res) +} + +func TestVirtualFieldsService_Update(t *testing.T) { + exp := &VirtualFieldWithId{ + ID: "vfield1", + VirtualField: VirtualField{ + Dataset: "dataset1", + Name: "field1_updated", + Expression: "a - b", + Type: "number", + }, + } + hf := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, mediaTypeJSON, r.Header.Get("Content-Type")) + + w.Header().Set("Content-Type", mediaTypeJSON) + _, err := fmt.Fprint(w, `{ + "id": "vfield1", + "dataset": "dataset1", + "name": "field1_updated", + "expression": "a - b", + "type": "number" + }`) + assert.NoError(t, err) + } + client := setup(t, "PUT /v2/vfields/vfield1", hf) + + res, err := client.VirtualFields.Update(context.Background(), "vfield1", VirtualField{ + Dataset: "dataset1", + Name: "field1_updated", + Expression: "a - b", + Type: "number", + }) + require.NoError(t, err) + + assert.Equal(t, exp, res) +} + +func TestVirtualFieldsService_Delete(t *testing.T) { + hf := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + + w.WriteHeader(http.StatusNoContent) + } + + client := setup(t, "DELETE /v2/vfields/vfield1", hf) + + err := client.VirtualFields.Delete(context.Background(), "vfield1") + require.NoError(t, err) +}