From 01689cb4e036bfe62a0f5a9089c3e624db0dd3b6 Mon Sep 17 00:00:00 2001 From: Jonathan Dillman Date: Tue, 8 Sep 2026 18:03:52 -0700 Subject: [PATCH 1/4] DASHM-1965 Add Template API support --- template.go | 184 +++++++ template/doc.go | 2 + template/model_options.go | 19 + template/model_template.go | 119 +++++ template_test.go | 448 ++++++++++++++++++ testdata/fixtures/template/chart_success.json | 47 ++ .../fixtures/template/dashboard_success.json | 38 ++ .../template/missing_envelope_fields.json | 9 + testdata/fixtures/template/not_found.json | 10 + .../fixtures/template/search_success.json | 34 ++ 10 files changed, 910 insertions(+) create mode 100644 template.go create mode 100644 template/doc.go create mode 100644 template/model_options.go create mode 100644 template/model_template.go create mode 100644 template_test.go create mode 100644 testdata/fixtures/template/chart_success.json create mode 100644 testdata/fixtures/template/dashboard_success.json create mode 100644 testdata/fixtures/template/missing_envelope_fields.json create mode 100644 testdata/fixtures/template/not_found.json create mode 100644 testdata/fixtures/template/search_success.json diff --git a/template.go b/template.go new file mode 100644 index 0000000..a73662a --- /dev/null +++ b/template.go @@ -0,0 +1,184 @@ +package signalfx + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/signalfx/signalfx-go/template" +) + +// TemplateAPIURL is the base URL for interacting with Template API records. +const TemplateAPIURL = "/v2/template" + +var ( + errEmptyTemplateID = errors.New("template ID must not be empty") + errInvalidTemplateID = errors.New("template ID must be a single path segment") + errNilTemplateWrite = errors.New("template write must not be nil") +) + +// CreateTemplate creates a Template record. +func (c *Client) CreateTemplate(ctx context.Context, write *template.Write) (*template.Result, error) { + if write == nil { + return nil, errNilTemplateWrite + } + + result := &template.Result{} + if err := c.executeTemplateRequest(ctx, http.MethodPost, TemplateAPIURL, http.StatusCreated, write, nil, result); err != nil { + return nil, err + } + + return result, nil +} + +// GetTemplate gets a Template record by ID. +func (c *Client) GetTemplate(ctx context.Context, id string, options *template.GetOptions) (*template.Result, error) { + path, err := templateAPIPath(id) + if err != nil { + return nil, err + } + + result := &template.Result{} + if err := c.executeTemplateRequest(ctx, http.MethodGet, path, http.StatusOK, nil, templateGetParams(options), result); err != nil { + return nil, err + } + + return result, nil +} + +// UpdateTemplate fully replaces a Template record. +func (c *Client) UpdateTemplate(ctx context.Context, id string, write *template.Write) (*template.Result, error) { + path, err := templateAPIPath(id) + if err != nil { + return nil, err + } + if write == nil { + return nil, errNilTemplateWrite + } + + result := &template.Result{} + if err := c.executeTemplateRequest(ctx, http.MethodPut, path, http.StatusOK, write, nil, result); err != nil { + return nil, err + } + + return result, nil +} + +// DeleteTemplate deletes a Template record. +func (c *Client) DeleteTemplate(ctx context.Context, id string) error { + path, err := templateAPIPath(id) + if err != nil { + return err + } + + return c.executeTemplateRequest(ctx, http.MethodDelete, path, http.StatusNoContent, nil, nil, nil) +} + +// SearchTemplates searches Template records. +func (c *Client) SearchTemplates(ctx context.Context, options *template.SearchOptions) (*template.SearchResult, error) { + result := &template.SearchResult{} + if err := c.executeTemplateRequest(ctx, http.MethodGet, TemplateAPIURL, http.StatusOK, nil, templateSearchParams(options), result); err != nil { + return nil, err + } + + return result, nil +} + +func (c *Client) executeTemplateRequest(ctx context.Context, method string, path string, expectedStatus int, write *template.Write, params url.Values, result any) error { + var body io.Reader + if write != nil { + payload, err := json.Marshal(write) + if err != nil { + return fmt.Errorf("marshal template request: %w", err) + } + body = bytes.NewReader(payload) + } + + resp, err := c.doRequest(ctx, method, path, params, body) + if err != nil { + return err + } + defer resp.Body.Close() + + if err := newResponseError(resp, expectedStatus); err != nil { + return err + } + + if result == nil { + _, _ = io.Copy(io.Discard, resp.Body) + return nil + } + + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + _, _ = io.Copy(io.Discard, resp.Body) + return fmt.Errorf("decode template response: %w", err) + } + _, _ = io.Copy(io.Discard, resp.Body) + + return nil +} + +func templateAPIPath(id string) (string, error) { + if id == "" { + return "", errEmptyTemplateID + } + if id == "." || id == ".." || strings.Contains(id, "/") { + return "", errInvalidTemplateID + } + return TemplateAPIURL + "/" + id, nil +} + +func templateGetParams(options *template.GetOptions) url.Values { + if options == nil { + return nil + } + + params := url.Values{} + addTemplateImports(params, options.Imports) + return params +} + +func templateSearchParams(options *template.SearchOptions) url.Values { + if options == nil { + return nil + } + + params := url.Values{} + if options.Search != "" { + params.Set("search", options.Search) + } + for _, rootElement := range options.RootElements { + params.Add("rootElement", string(rootElement)) + } + for _, id := range options.IDs { + params.Add("id", id) + } + for _, title := range options.Titles { + params.Add("title", title) + } + for _, orderBy := range options.OrderBy { + params.Add("orderBy", orderBy) + } + if options.Offset != 0 { + params.Set("offset", strconv.Itoa(options.Offset)) + } + if options.Size != 0 { + params.Set("size", strconv.Itoa(options.Size)) + } + addTemplateImports(params, options.Imports) + + return params +} + +func addTemplateImports(params url.Values, imports []template.Import) { + for _, include := range imports { + params.Add("import", string(include)) + } +} diff --git a/template/doc.go b/template/doc.go new file mode 100644 index 0000000..d503a78 --- /dev/null +++ b/template/doc.go @@ -0,0 +1,2 @@ +// Package template contains request and response models for the Template API. +package template diff --git a/template/model_options.go b/template/model_options.go new file mode 100644 index 0000000..bd9673b --- /dev/null +++ b/template/model_options.go @@ -0,0 +1,19 @@ +package template + +// GetOptions controls related records included with a Template read. +type GetOptions struct { + Imports []Import +} + +// SearchOptions contains optional Template search filters and pagination. +// Slice fields are encoded as repeated query parameters in their given order. +type SearchOptions struct { + Search string + RootElements []RootElement + IDs []string + Titles []string + OrderBy []string + Offset int + Size int + Imports []Import +} diff --git a/template/model_template.go b/template/model_template.go new file mode 100644 index 0000000..a0bdc95 --- /dev/null +++ b/template/model_template.go @@ -0,0 +1,119 @@ +package template + +import "encoding/json" + +// RecordType is the schema type used by Template API records. +const RecordType = "https://schema.splunkdev.com/dashify/v1/templates/Record" + +// RootElement identifies the kind of document stored in a Template record. +type RootElement string + +const ( + // RootElementDashboard identifies a Dashboard template document. + RootElementDashboard RootElement = "Dashboard" + // RootElementChart identifies a reusable Chart template document. + RootElementChart RootElement = "Chart" +) + +// Import controls which related Template records the API includes in a +// response. +type Import string + +const ( + // ImportAllChildren requests all transitively imported Template records. + ImportAllChildren Import = "ALL_CHILDREN" +) + +// DatasourceType identifies the kind of datasource associated with a Template +// write request. +type DatasourceType string + +const ( + // DatasourceTypeSplunkObservability identifies a SignalFlow datasource. + DatasourceTypeSplunkObservability DatasourceType = "SPLUNK_O11Y" + // DatasourceTypeSplunkObservabilitySLO identifies a Splunk Observability + // service-level objective datasource. + DatasourceTypeSplunkObservabilitySLO DatasourceType = "SPLUNK_O11Y_SLO" +) + +// Datasource contains optional datasource metadata accepted when creating or +// replacing a Template. The API does not return this object in Template read +// metadata. +type Datasource struct { + Type DatasourceType `json:"type,omitempty"` + ProgramText string `json:"programText,omitempty"` + SLOID string `json:"sloId,omitempty"` +} + +// WriteMetadata describes a Template document and the metadata extracted from +// it for a create or replace request. +type WriteMetadata struct { + RootElement *RootElement `json:"rootElement"` + Imports []string `json:"imports,omitempty"` + Datasource *Datasource `json:"datasource,omitempty"` +} + +// Metadata describes the read-only metadata returned for a Template document. +type Metadata struct { + RootElement *RootElement `json:"rootElement"` + Imports []string `json:"imports"` +} + +// Write is the body used to create or replace a Template record. +// +// Spec contains the polymorphic Dashify document. SignalView is omitted for a +// nil value and may contain the association update object accepted by the API. +type Write struct { + Type string `json:"type"` + Spec json.RawMessage `json:"spec"` + Title string `json:"title"` + Metadata WriteMetadata `json:"metadata"` + SignalView json.RawMessage `json:"signalview,omitempty"` +} + +// Template is a Template record returned by the API. +type Template struct { + ID string `json:"id"` + Self string `json:"self"` + Type string `json:"type"` + Title string `json:"title"` + Spec json.RawMessage `json:"spec"` + Metadata *Metadata `json:"metadata"` + SignalView json.RawMessage `json:"signalview"` + DirectoryEntries []string `json:"directoryEntries"` + CreatedAt string `json:"createdAt"` + CreatedBy *string `json:"createdBy"` + UpdatedAt string `json:"updatedAt"` + UpdatedBy *string `json:"updatedBy"` +} + +// APIError is an error entry in a Template API response envelope: a status +// code as a string and a human-readable message. +type APIError struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// Result is the response envelope for a single Template operation. +type Result struct { + Data *Template `json:"data"` + Errors []APIError `json:"errors"` + Includes []Template `json:"includes"` +} + +// Collection describes a page of Template URI references. +type Collection struct { + Type string `json:"type"` + Self string `json:"self"` + Next *string `json:"next"` + Prev *string `json:"prev"` + Count int64 `json:"count"` + Items []string `json:"items"` +} + +// SearchResult is the response envelope for a Template search. +type SearchResult struct { + Data *Collection `json:"data"` + Errors []APIError `json:"errors"` + Includes []Template `json:"includes"` +} diff --git a/template_test.go b/template_test.go new file mode 100644 index 0000000..7ddfa47 --- /dev/null +++ b/template_test.go @@ -0,0 +1,448 @@ +package signalfx + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/signalfx/signalfx-go/template" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const templateRequestBody = `{ + "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "spec": { + "": [], + "$import:chart0": "/v2/template/HNPr-tr_AAc" + }, + "title": "Service overview", + "metadata": { + "rootElement": "Dashboard", + "imports": ["/v2/template/HNPr-tr_AAc"] + } +}` + +const templateUpdateRequestBody = `{ + "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "spec": { + "": [], + "$import:chart0": "/v2/template/HNPr-tr_AAc" + }, + "title": "Service overview", + "metadata": { + "rootElement": "Dashboard", + "imports": ["/v2/template/HNPr-tr_AAc"] + }, + "signalview": { + "lastConvertedAt": null + } +}` + +const templateDatasourceRequestBody = `{ + "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "spec": { + "": [] + }, + "title": "Request rate", + "metadata": { + "rootElement": "Chart", + "datasource": { + "type": "SPLUNK_O11Y", + "programText": "data('http.requests').publish()" + } + } +}` + +func testTemplateWrite() *template.Write { + rootElement := template.RootElementDashboard + return &template.Write{ + Type: template.RecordType, + Spec: json.RawMessage(`{"":[],"$import:chart0":"/v2/template/HNPr-tr_AAc"}`), + Title: "Service overview", + Metadata: template.WriteMetadata{ + RootElement: &rootElement, + Imports: []string{"/v2/template/HNPr-tr_AAc"}, + }, + } +} + +func TestCreateTemplateWithDatasource(t *testing.T) { + teardown := setup() + defer teardown() + + rootElement := template.RootElementChart + write := &template.Write{ + Type: template.RecordType, + Spec: json.RawMessage(`{"":[]}`), + Title: "Request rate", + Metadata: template.WriteMetadata{ + RootElement: &rootElement, + Datasource: &template.Datasource{ + Type: template.DatasourceTypeSplunkObservability, + ProgramText: "data('http.requests').publish()", + }, + }, + } + mux.HandleFunc(TemplateAPIURL, verifyRequestWithJsonBody( + t, + http.MethodPost, + true, + http.StatusCreated, + nil, + templateDatasourceRequestBody, + "template/chart_success.json", + )) + + result, err := client.CreateTemplate(context.Background(), write) + require.NoError(t, err) + require.NotNil(t, result.Data) +} + +func TestTemplateSLODatasourceJSON(t *testing.T) { + datasource := template.Datasource{ + Type: template.DatasourceTypeSplunkObservabilitySLO, + ProgramText: "data('service.level').publish()", + SLOID: "example-slo-id", + } + + payload, err := json.Marshal(datasource) + require.NoError(t, err) + assert.JSONEq(t, `{ + "type": "SPLUNK_O11Y_SLO", + "programText": "data('service.level').publish()", + "sloId": "example-slo-id" + }`, string(payload)) +} + +func TestCreateTemplate(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc(TemplateAPIURL, verifyRequestWithJsonBody( + t, + http.MethodPost, + true, + http.StatusCreated, + nil, + templateRequestBody, + "template/dashboard_success.json", + )) + + result, err := client.CreateTemplate(context.Background(), testTemplateWrite()) + require.NoError(t, err) + require.NotNil(t, result.Data) + assert.Equal(t, "HNPz_pNAIAE", result.Data.ID) + assert.Equal(t, "Service overview", result.Data.Title) + assert.Equal(t, "2026-09-05T00:44:31.408Z[UTC]", result.Data.CreatedAt) + assert.Equal(t, []string{"/v2/directory/~templates/HNPz_pNAIAE"}, result.Data.DirectoryEntries) + require.NotNil(t, result.Data.Metadata) + assert.Empty(t, result.Data.Metadata.Imports) + assert.JSONEq(t, "null", string(result.Data.SignalView)) + assert.Contains(t, string(result.Data.Spec), "futureLayoutOption") + assert.Contains(t, string(result.Data.Spec), "/v2/template/HNPr-tr_AAc") +} + +func TestGetTemplate(t *testing.T) { + teardown := setup() + defer teardown() + + const id = "HNPr-tr_AAc" + mux.HandleFunc(TemplateAPIURL+"/"+id, createResponse(t, http.StatusOK, "template/chart_success.json", func(t *testing.T, r *http.Request) { + verifyHeaders(t, r, true) + assert.Equal(t, http.MethodGet, r.Method) + assert.Empty(t, r.URL.RawQuery) + })) + + result, err := client.GetTemplate(context.Background(), id, nil) + require.NoError(t, err) + require.NotNil(t, result.Data) + assert.Equal(t, id, result.Data.ID) + require.NotNil(t, result.Data.Metadata) + require.NotNil(t, result.Data.Metadata.RootElement) + assert.Equal(t, template.RootElementChart, *result.Data.Metadata.RootElement) + assert.Contains(t, string(result.Data.SignalView), "futureAssociationField") + assert.Nil(t, result.Errors) + assert.Nil(t, result.Includes) + assert.Nil(t, result.Data.CreatedBy) + require.NotNil(t, result.Data.UpdatedBy) + assert.Equal(t, "/v2/user/example", *result.Data.UpdatedBy) +} + +func TestGetTemplateWithOptions(t *testing.T) { + teardown := setup() + defer teardown() + + params := url.Values{"import": []string{string(template.ImportAllChildren)}} + mux.HandleFunc( + TemplateAPIURL+"/HNPz_pNAIAE", + verifyRequest(t, http.MethodGet, true, http.StatusOK, params, "template/dashboard_success.json"), + ) + + result, err := client.GetTemplate(context.Background(), "HNPz_pNAIAE", &template.GetOptions{ + Imports: []template.Import{template.ImportAllChildren}, + }) + require.NoError(t, err) + require.NotNil(t, result.Data) + assert.Equal(t, "HNPz_pNAIAE", result.Data.ID) +} + +func TestUpdateTemplate(t *testing.T) { + teardown := setup() + defer teardown() + + write := testTemplateWrite() + write.SignalView = json.RawMessage(`{"lastConvertedAt":null}`) + mux.HandleFunc(TemplateAPIURL+"/HNPz_pNAIAE", verifyRequestWithJsonBody( + t, + http.MethodPut, + true, + http.StatusOK, + nil, + templateUpdateRequestBody, + "template/dashboard_success.json", + )) + + result, err := client.UpdateTemplate(context.Background(), "HNPz_pNAIAE", write) + require.NoError(t, err) + require.NotNil(t, result.Data) + assert.Equal(t, "HNPz_pNAIAE", result.Data.ID) +} + +func TestDeleteTemplate(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc( + TemplateAPIURL+"/HNPz_pNAIAE", + verifyRequest(t, http.MethodDelete, true, http.StatusNoContent, nil, ""), + ) + + assert.NoError(t, client.DeleteTemplate(context.Background(), "HNPz_pNAIAE")) +} + +func TestSearchTemplates(t *testing.T) { + teardown := setup() + defer teardown() + + expectedParams := url.Values{ + "search": []string{"service"}, + "rootElement": []string{"Dashboard", "Chart"}, + "id": []string{"template-a", "template-b"}, + "title": []string{"Service overview", "Request rate"}, + "orderBy": []string{"title", "-updatedAt"}, + "offset": []string{"3"}, + "size": []string{"200"}, + "import": []string{"ALL_CHILDREN"}, + } + mux.HandleFunc(TemplateAPIURL, createResponse(t, http.StatusOK, "template/search_success.json", func(t *testing.T, r *http.Request) { + verifyHeaders(t, r, true) + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, expectedParams, r.URL.Query()) + })) + + result, err := client.SearchTemplates(context.Background(), &template.SearchOptions{ + Search: "service", + RootElements: []template.RootElement{template.RootElementDashboard, template.RootElementChart}, + IDs: []string{"template-a", "template-b"}, + Titles: []string{"Service overview", "Request rate"}, + OrderBy: []string{"title", "-updatedAt"}, + Offset: 3, + Size: 200, + Imports: []template.Import{template.ImportAllChildren}, + }) + require.NoError(t, err) + require.NotNil(t, result.Data) + assert.Equal(t, int64(1), result.Data.Count) + assert.Nil(t, result.Data.Next) + assert.Nil(t, result.Data.Prev) + assert.Equal(t, []string{"/v2/template/HNPz_pNAIAE"}, result.Data.Items) + require.Len(t, result.Includes, 1) + assert.Equal(t, "HNPz_pNAIAE", result.Includes[0].ID) +} + +func TestSearchTemplatesWithNilOptions(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc(TemplateAPIURL, createResponse(t, http.StatusOK, "template/search_success.json", func(t *testing.T, r *http.Request) { + verifyHeaders(t, r, true) + assert.Equal(t, http.MethodGet, r.Method) + assert.Empty(t, r.URL.RawQuery) + })) + + result, err := client.SearchTemplates(context.Background(), nil) + require.NoError(t, err) + require.NotNil(t, result.Data) +} + +func TestTemplateEnvelopeAllowsMissingLists(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc( + TemplateAPIURL+"/minimal-id", + verifyRequest(t, http.MethodGet, true, http.StatusOK, nil, "template/missing_envelope_fields.json"), + ) + + result, err := client.GetTemplate(context.Background(), "minimal-id", nil) + require.NoError(t, err) + require.NotNil(t, result.Data) + assert.Nil(t, result.Errors) + assert.Nil(t, result.Includes) + assert.Nil(t, result.Data.Metadata) +} + +func TestGetTemplateNotFound(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc( + TemplateAPIURL+"/missing-id", + verifyRequest(t, http.MethodGet, true, http.StatusNotFound, nil, "template/not_found.json"), + ) + + result, err := client.GetTemplate(context.Background(), "missing-id", nil) + assert.Nil(t, result) + require.Error(t, err) + responseErr, ok := AsResponseError(err) + require.True(t, ok) + assert.Equal(t, http.StatusNotFound, responseErr.Code()) + assert.Equal(t, TemplateAPIURL+"/missing-id", responseErr.Route()) + assert.JSONEq(t, fixture("template/not_found.json"), responseErr.Details()) +} + +func TestUpdateTemplateResponseErrorDoesNotExposeRequest(t *testing.T) { + teardown := setup() + defer teardown() + + write := testTemplateWrite() + write.Title = "private template title" + mux.HandleFunc( + TemplateAPIURL+"/HNPz_pNAIAE", + verifyRequest(t, http.MethodPut, true, http.StatusForbidden, nil, "template/not_found.json"), + ) + + result, err := client.UpdateTemplate(context.Background(), "HNPz_pNAIAE", write) + assert.Nil(t, result) + require.Error(t, err) + responseErr, ok := AsResponseError(err) + require.True(t, ok) + assert.Equal(t, http.StatusForbidden, responseErr.Code()) + assert.Equal(t, TemplateAPIURL+"/HNPz_pNAIAE", responseErr.Route()) + assert.JSONEq(t, fixture("template/not_found.json"), responseErr.Details()) + assert.NotContains(t, err.Error(), write.Title) +} + +func TestTemplateRejectsInvalidArguments(t *testing.T) { + c := &Client{} + + result, err := c.CreateTemplate(context.Background(), nil) + assert.Nil(t, result) + assert.EqualError(t, err, "template write must not be nil") + + result, err = c.GetTemplate(context.Background(), "", nil) + assert.Nil(t, result) + assert.EqualError(t, err, "template ID must not be empty") + + result, err = c.UpdateTemplate(context.Background(), "", testTemplateWrite()) + assert.Nil(t, result) + assert.EqualError(t, err, "template ID must not be empty") + + result, err = c.UpdateTemplate(context.Background(), "valid-id", nil) + assert.Nil(t, result) + assert.EqualError(t, err, "template write must not be nil") + + assert.EqualError(t, c.DeleteTemplate(context.Background(), ""), "template ID must not be empty") + + for _, id := range []string{".", "..", "parent/child"} { + result, err = c.GetTemplate(context.Background(), id, nil) + assert.Nil(t, result) + assert.EqualError(t, err, "template ID must be a single path segment") + } +} + +func TestTemplateOptionParamsOmitZeroValues(t *testing.T) { + assert.Empty(t, templateGetParams(&template.GetOptions{})) + assert.Empty(t, templateSearchParams(&template.SearchOptions{})) +} + +func TestCreateTemplateRejectsInvalidRawJSON(t *testing.T) { + c := &Client{} + write := testTemplateWrite() + write.Spec = json.RawMessage("{") + + result, err := c.CreateTemplate(context.Background(), write) + assert.Nil(t, result) + require.Error(t, err) + assert.Contains(t, err.Error(), "marshal template request") +} + +func TestTemplateMalformedResponse(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc(TemplateAPIURL+"/malformed", func(w http.ResponseWriter, r *http.Request) { + verifyHeaders(t, r, true) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "{") + }) + + result, err := client.GetTemplate(context.Background(), "malformed", nil) + assert.Nil(t, result) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode template response") +} + +func TestTemplateTransportError(t *testing.T) { + expectedErr := errors.New("template transport failed") + httpClient := &http.Client{Transport: templateRoundTripperFunc(func(*http.Request) (*http.Response, error) { + return nil, expectedErr + })} + c, err := NewClient(TestToken, HTTPClient(httpClient)) + require.NoError(t, err) + + result, err := c.GetTemplate(context.Background(), "valid-id", nil) + assert.Nil(t, result) + assert.ErrorIs(t, err, expectedErr) +} + +func TestTemplateClosesResponseBody(t *testing.T) { + body := &templateCloseTrackingBody{Reader: strings.NewReader(fixture("template/dashboard_success.json"))} + httpClient := &http.Client{Transport: templateRoundTripperFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: body, + Request: request, + }, nil + })} + c, err := NewClient(TestToken, HTTPClient(httpClient)) + require.NoError(t, err) + + result, err := c.GetTemplate(context.Background(), "valid-id", nil) + require.NoError(t, err) + require.NotNil(t, result.Data) + assert.True(t, body.closed) +} + +type templateRoundTripperFunc func(*http.Request) (*http.Response, error) + +func (f templateRoundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +type templateCloseTrackingBody struct { + io.Reader + closed bool +} + +func (b *templateCloseTrackingBody) Close() error { + b.closed = true + return nil +} diff --git a/testdata/fixtures/template/chart_success.json b/testdata/fixtures/template/chart_success.json new file mode 100644 index 0000000..13a1cd3 --- /dev/null +++ b/testdata/fixtures/template/chart_success.json @@ -0,0 +1,47 @@ +{ + "data": { + "id": "HNPr-tr_AAc", + "self": "/v2/template/HNPr-tr_AAc", + "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "title": "Request rate", + "spec": { + "": [ + { + "": [], + "chart": { + "colorScale": [ + { + "gt": 0.5, + "color": "green" + } + ] + }, + "datasource": { + "program": "A = data('requests'); publish(A)" + } + } + ] + }, + "metadata": { + "rootElement": "Chart", + "imports": [] + }, + "signalview": { + "type": "CHART", + "associationCreatedAt": "2026-09-05T00:40:00Z[UTC]", + "lastConvertedAt": null, + "lastConvertedBy": null, + "object": { + "name": "Classic chart", + "futureAssociationField": 42 + } + }, + "directoryEntries": [], + "createdAt": "2026-09-05T00:40:00Z[UTC]", + "createdBy": null, + "updatedAt": "2026-09-05T00:40:00Z[UTC]", + "updatedBy": "/v2/user/example" + }, + "errors": null, + "includes": null +} diff --git a/testdata/fixtures/template/dashboard_success.json b/testdata/fixtures/template/dashboard_success.json new file mode 100644 index 0000000..a30d476 --- /dev/null +++ b/testdata/fixtures/template/dashboard_success.json @@ -0,0 +1,38 @@ +{ + "data": { + "id": "HNPz_pNAIAE", + "self": "/v2/template/HNPz_pNAIAE", + "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "title": "Service overview", + "spec": { + "": [ + { + "": [ + { + "<$import.chart0>": [] + } + ], + "futureLayoutOption": { + "preserve": true + } + } + ], + "$import:chart0": "/v2/template/HNPr-tr_AAc" + }, + "metadata": { + "rootElement": "Dashboard", + "imports": [] + }, + "signalview": null, + "directoryEntries": [ + "/v2/directory/~templates/HNPz_pNAIAE" + ], + "createdAt": "2026-09-05T00:44:31.408Z[UTC]", + "createdBy": "/v2/user/example", + "updatedAt": "2026-09-05T00:45:12.002Z[UTC]", + "updatedBy": null, + "futureRecordField": "ignored" + }, + "errors": [], + "includes": [] +} diff --git a/testdata/fixtures/template/missing_envelope_fields.json b/testdata/fixtures/template/missing_envelope_fields.json new file mode 100644 index 0000000..9b83778 --- /dev/null +++ b/testdata/fixtures/template/missing_envelope_fields.json @@ -0,0 +1,9 @@ +{ + "data": { + "id": "minimal-id", + "self": "/v2/template/minimal-id", + "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "title": "Minimal template", + "spec": {} + } +} diff --git a/testdata/fixtures/template/not_found.json b/testdata/fixtures/template/not_found.json new file mode 100644 index 0000000..8f1d769 --- /dev/null +++ b/testdata/fixtures/template/not_found.json @@ -0,0 +1,10 @@ +{ + "data": null, + "errors": [ + { + "code": "404", + "message": "Template with id 'missing-id' not found" + } + ], + "includes": [] +} diff --git a/testdata/fixtures/template/search_success.json b/testdata/fixtures/template/search_success.json new file mode 100644 index 0000000..98e48e7 --- /dev/null +++ b/testdata/fixtures/template/search_success.json @@ -0,0 +1,34 @@ +{ + "data": { + "type": "https://schema.splunkdev.com/dashify/v1/common/Collection", + "self": "/v2/template?offset=0&rootElement=Dashboard&size=200", + "next": null, + "prev": null, + "count": 1, + "items": [ + "/v2/template/HNPz_pNAIAE" + ] + }, + "errors": [], + "includes": [ + { + "id": "HNPz_pNAIAE", + "self": "/v2/template/HNPz_pNAIAE", + "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "title": "Service overview", + "spec": { + "": [] + }, + "metadata": { + "rootElement": "Dashboard", + "imports": [] + }, + "signalview": null, + "directoryEntries": [], + "createdAt": "2026-09-05T00:44:31.408Z[UTC]", + "createdBy": null, + "updatedAt": "2026-09-05T00:44:31.408Z[UTC]", + "updatedBy": null + } + ] +} From b04fbf1f253dea47ed173da2dc4e6b7897aa5839 Mon Sep 17 00:00:00 2001 From: Jonathan Dillman Date: Thu, 17 Sep 2026 11:58:09 -0700 Subject: [PATCH 2/4] address mr feedback --- template.go | 10 +++++----- template/model_template.go | 4 ++-- template_test.go | 32 ++++++++++---------------------- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/template.go b/template.go index a73662a..a413578 100644 --- a/template.go +++ b/template.go @@ -25,7 +25,7 @@ var ( ) // CreateTemplate creates a Template record. -func (c *Client) CreateTemplate(ctx context.Context, write *template.Write) (*template.Result, error) { +func (c *Client) CreateTemplate(ctx context.Context, write *template.Content) (*template.Result, error) { if write == nil { return nil, errNilTemplateWrite } @@ -54,7 +54,7 @@ func (c *Client) GetTemplate(ctx context.Context, id string, options *template.G } // UpdateTemplate fully replaces a Template record. -func (c *Client) UpdateTemplate(ctx context.Context, id string, write *template.Write) (*template.Result, error) { +func (c *Client) UpdateTemplate(ctx context.Context, id string, write *template.Content) (*template.Result, error) { path, err := templateAPIPath(id) if err != nil { return nil, err @@ -91,7 +91,7 @@ func (c *Client) SearchTemplates(ctx context.Context, options *template.SearchOp return result, nil } -func (c *Client) executeTemplateRequest(ctx context.Context, method string, path string, expectedStatus int, write *template.Write, params url.Values, result any) error { +func (c *Client) executeTemplateRequest(ctx context.Context, method string, path string, expectedStatus int, write *template.Content, params url.Values, result any) error { var body io.Reader if write != nil { payload, err := json.Marshal(write) @@ -137,7 +137,7 @@ func templateAPIPath(id string) (string, error) { func templateGetParams(options *template.GetOptions) url.Values { if options == nil { - return nil + return url.Values{} } params := url.Values{} @@ -147,7 +147,7 @@ func templateGetParams(options *template.GetOptions) url.Values { func templateSearchParams(options *template.SearchOptions) url.Values { if options == nil { - return nil + return url.Values{} } params := url.Values{} diff --git a/template/model_template.go b/template/model_template.go index a0bdc95..1164013 100644 --- a/template/model_template.go +++ b/template/model_template.go @@ -59,11 +59,11 @@ type Metadata struct { Imports []string `json:"imports"` } -// Write is the body used to create or replace a Template record. +// Content is the body used to create or replace a Template record. // // Spec contains the polymorphic Dashify document. SignalView is omitted for a // nil value and may contain the association update object accepted by the API. -type Write struct { +type Content struct { Type string `json:"type"` Spec json.RawMessage `json:"spec"` Title string `json:"title"` diff --git a/template_test.go b/template_test.go index 7ddfa47..26df9f7 100644 --- a/template_test.go +++ b/template_test.go @@ -3,7 +3,6 @@ package signalfx import ( "context" "encoding/json" - "errors" "io" "net/http" "net/url" @@ -59,9 +58,9 @@ const templateDatasourceRequestBody = `{ } }` -func testTemplateWrite() *template.Write { +func testContent() *template.Content { rootElement := template.RootElementDashboard - return &template.Write{ + return &template.Content{ Type: template.RecordType, Spec: json.RawMessage(`{"":[],"$import:chart0":"/v2/template/HNPr-tr_AAc"}`), Title: "Service overview", @@ -77,7 +76,7 @@ func TestCreateTemplateWithDatasource(t *testing.T) { defer teardown() rootElement := template.RootElementChart - write := &template.Write{ + write := &template.Content{ Type: template.RecordType, Spec: json.RawMessage(`{"":[]}`), Title: "Request rate", @@ -134,7 +133,7 @@ func TestCreateTemplate(t *testing.T) { "template/dashboard_success.json", )) - result, err := client.CreateTemplate(context.Background(), testTemplateWrite()) + result, err := client.CreateTemplate(context.Background(), testContent()) require.NoError(t, err) require.NotNil(t, result.Data) assert.Equal(t, "HNPz_pNAIAE", result.Data.ID) @@ -196,7 +195,7 @@ func TestUpdateTemplate(t *testing.T) { teardown := setup() defer teardown() - write := testTemplateWrite() + write := testContent() write.SignalView = json.RawMessage(`{"lastConvertedAt":null}`) mux.HandleFunc(TemplateAPIURL+"/HNPz_pNAIAE", verifyRequestWithJsonBody( t, @@ -321,7 +320,7 @@ func TestUpdateTemplateResponseErrorDoesNotExposeRequest(t *testing.T) { teardown := setup() defer teardown() - write := testTemplateWrite() + write := testContent() write.Title = "private template title" mux.HandleFunc( TemplateAPIURL+"/HNPz_pNAIAE", @@ -350,7 +349,7 @@ func TestTemplateRejectsInvalidArguments(t *testing.T) { assert.Nil(t, result) assert.EqualError(t, err, "template ID must not be empty") - result, err = c.UpdateTemplate(context.Background(), "", testTemplateWrite()) + result, err = c.UpdateTemplate(context.Background(), "", testContent()) assert.Nil(t, result) assert.EqualError(t, err, "template ID must not be empty") @@ -370,11 +369,13 @@ func TestTemplateRejectsInvalidArguments(t *testing.T) { func TestTemplateOptionParamsOmitZeroValues(t *testing.T) { assert.Empty(t, templateGetParams(&template.GetOptions{})) assert.Empty(t, templateSearchParams(&template.SearchOptions{})) + assert.NotNil(t, templateGetParams(nil)) + assert.NotNil(t, templateSearchParams(nil)) } func TestCreateTemplateRejectsInvalidRawJSON(t *testing.T) { c := &Client{} - write := testTemplateWrite() + write := testContent() write.Spec = json.RawMessage("{") result, err := c.CreateTemplate(context.Background(), write) @@ -400,19 +401,6 @@ func TestTemplateMalformedResponse(t *testing.T) { assert.Contains(t, err.Error(), "decode template response") } -func TestTemplateTransportError(t *testing.T) { - expectedErr := errors.New("template transport failed") - httpClient := &http.Client{Transport: templateRoundTripperFunc(func(*http.Request) (*http.Response, error) { - return nil, expectedErr - })} - c, err := NewClient(TestToken, HTTPClient(httpClient)) - require.NoError(t, err) - - result, err := c.GetTemplate(context.Background(), "valid-id", nil) - assert.Nil(t, result) - assert.ErrorIs(t, err, expectedErr) -} - func TestTemplateClosesResponseBody(t *testing.T) { body := &templateCloseTrackingBody{Reader: strings.NewReader(fixture("template/dashboard_success.json"))} httpClient := &http.Client{Transport: templateRoundTripperFunc(func(request *http.Request) (*http.Response, error) { From f660271516e72d7e03f4d2dff352081412d6bb2b Mon Sep 17 00:00:00 2001 From: Jonathan Dillman Date: Thu, 17 Sep 2026 13:51:33 -0700 Subject: [PATCH 3/4] move api_error to a shared util --- template/model_template.go | 13 ++++++------- util/api_error.go | 8 ++++++++ 2 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 util/api_error.go diff --git a/template/model_template.go b/template/model_template.go index 1164013..8a182a2 100644 --- a/template/model_template.go +++ b/template/model_template.go @@ -1,6 +1,10 @@ package template -import "encoding/json" +import ( + "encoding/json" + + "github.com/signalfx/signalfx-go/util" +) // RecordType is the schema type used by Template API records. const RecordType = "https://schema.splunkdev.com/dashify/v1/templates/Record" @@ -87,12 +91,7 @@ type Template struct { UpdatedBy *string `json:"updatedBy"` } -// APIError is an error entry in a Template API response envelope: a status -// code as a string and a human-readable message. -type APIError struct { - Code string `json:"code,omitempty"` - Message string `json:"message,omitempty"` -} +type APIError = util.APIError // Result is the response envelope for a single Template operation. type Result struct { diff --git a/util/api_error.go b/util/api_error.go new file mode 100644 index 0000000..4200965 --- /dev/null +++ b/util/api_error.go @@ -0,0 +1,8 @@ +package util + +// APIError is an error entry in an API response envelope. APIs in this +// repository return the status code as either a string or an integer. +type APIError struct { + Code StringOrInteger `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} From 73e6b912bb4914e852fd12ddb4cfaa5440b72d9a Mon Sep 17 00:00:00 2001 From: Jonathan Dillman Date: Thu, 24 Sep 2026 12:26:41 -0700 Subject: [PATCH 4/4] Address MR feedback on schema URIs and naming Drop the schema.splunkdev.com domain from record/collection type URIs in favor of local #/... fragments, avoiding a URI that looks dereferenceable and reads as an internal-domain reference. Rename Content -> CreateUpdateTemplateRequest and Collection -> SearchPage for clarity per review feedback. --- template.go | 6 ++-- template/model_template.go | 15 +++++----- template_test.go | 28 ++++++++++--------- testdata/fixtures/template/chart_success.json | 2 +- .../fixtures/template/dashboard_success.json | 2 +- .../template/missing_envelope_fields.json | 2 +- .../fixtures/template/search_success.json | 4 +-- 7 files changed, 30 insertions(+), 29 deletions(-) diff --git a/template.go b/template.go index a413578..2fe47b4 100644 --- a/template.go +++ b/template.go @@ -25,7 +25,7 @@ var ( ) // CreateTemplate creates a Template record. -func (c *Client) CreateTemplate(ctx context.Context, write *template.Content) (*template.Result, error) { +func (c *Client) CreateTemplate(ctx context.Context, write *template.CreateUpdateTemplateRequest) (*template.Result, error) { if write == nil { return nil, errNilTemplateWrite } @@ -54,7 +54,7 @@ func (c *Client) GetTemplate(ctx context.Context, id string, options *template.G } // UpdateTemplate fully replaces a Template record. -func (c *Client) UpdateTemplate(ctx context.Context, id string, write *template.Content) (*template.Result, error) { +func (c *Client) UpdateTemplate(ctx context.Context, id string, write *template.CreateUpdateTemplateRequest) (*template.Result, error) { path, err := templateAPIPath(id) if err != nil { return nil, err @@ -91,7 +91,7 @@ func (c *Client) SearchTemplates(ctx context.Context, options *template.SearchOp return result, nil } -func (c *Client) executeTemplateRequest(ctx context.Context, method string, path string, expectedStatus int, write *template.Content, params url.Values, result any) error { +func (c *Client) executeTemplateRequest(ctx context.Context, method string, path string, expectedStatus int, write *template.CreateUpdateTemplateRequest, params url.Values, result any) error { var body io.Reader if write != nil { payload, err := json.Marshal(write) diff --git a/template/model_template.go b/template/model_template.go index 8a182a2..7601362 100644 --- a/template/model_template.go +++ b/template/model_template.go @@ -6,9 +6,6 @@ import ( "github.com/signalfx/signalfx-go/util" ) -// RecordType is the schema type used by Template API records. -const RecordType = "https://schema.splunkdev.com/dashify/v1/templates/Record" - // RootElement identifies the kind of document stored in a Template record. type RootElement string @@ -63,11 +60,12 @@ type Metadata struct { Imports []string `json:"imports"` } -// Content is the body used to create or replace a Template record. +// CreateUpdateTemplateRequest is the body used to create or replace a Template record. // // Spec contains the polymorphic Dashify document. SignalView is omitted for a // nil value and may contain the association update object accepted by the API. -type Content struct { +type CreateUpdateTemplateRequest struct { + // Type is the record type identifier expected by the Template API. Type string `json:"type"` Spec json.RawMessage `json:"spec"` Title string `json:"title"` @@ -100,8 +98,9 @@ type Result struct { Includes []Template `json:"includes"` } -// Collection describes a page of Template URI references. -type Collection struct { +// SearchPage contains the pagination links, total count, and Template URI +// references returned by a Template search. +type SearchPage struct { Type string `json:"type"` Self string `json:"self"` Next *string `json:"next"` @@ -112,7 +111,7 @@ type Collection struct { // SearchResult is the response envelope for a Template search. type SearchResult struct { - Data *Collection `json:"data"` + Data *SearchPage `json:"data"` Errors []APIError `json:"errors"` Includes []Template `json:"includes"` } diff --git a/template_test.go b/template_test.go index 26df9f7..b9c64e8 100644 --- a/template_test.go +++ b/template_test.go @@ -14,8 +14,10 @@ import ( "github.com/stretchr/testify/require" ) +const templateRecordType = "#/dashify/v1/templates/Record" + const templateRequestBody = `{ - "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "type": "#/dashify/v1/templates/Record", "spec": { "": [], "$import:chart0": "/v2/template/HNPr-tr_AAc" @@ -28,7 +30,7 @@ const templateRequestBody = `{ }` const templateUpdateRequestBody = `{ - "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "type": "#/dashify/v1/templates/Record", "spec": { "": [], "$import:chart0": "/v2/template/HNPr-tr_AAc" @@ -44,7 +46,7 @@ const templateUpdateRequestBody = `{ }` const templateDatasourceRequestBody = `{ - "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "type": "#/dashify/v1/templates/Record", "spec": { "": [] }, @@ -58,10 +60,10 @@ const templateDatasourceRequestBody = `{ } }` -func testContent() *template.Content { +func testTemplateWriteRequest() *template.CreateUpdateTemplateRequest { rootElement := template.RootElementDashboard - return &template.Content{ - Type: template.RecordType, + return &template.CreateUpdateTemplateRequest{ + Type: templateRecordType, Spec: json.RawMessage(`{"":[],"$import:chart0":"/v2/template/HNPr-tr_AAc"}`), Title: "Service overview", Metadata: template.WriteMetadata{ @@ -76,8 +78,8 @@ func TestCreateTemplateWithDatasource(t *testing.T) { defer teardown() rootElement := template.RootElementChart - write := &template.Content{ - Type: template.RecordType, + write := &template.CreateUpdateTemplateRequest{ + Type: templateRecordType, Spec: json.RawMessage(`{"":[]}`), Title: "Request rate", Metadata: template.WriteMetadata{ @@ -133,7 +135,7 @@ func TestCreateTemplate(t *testing.T) { "template/dashboard_success.json", )) - result, err := client.CreateTemplate(context.Background(), testContent()) + result, err := client.CreateTemplate(context.Background(), testTemplateWriteRequest()) require.NoError(t, err) require.NotNil(t, result.Data) assert.Equal(t, "HNPz_pNAIAE", result.Data.ID) @@ -195,7 +197,7 @@ func TestUpdateTemplate(t *testing.T) { teardown := setup() defer teardown() - write := testContent() + write := testTemplateWriteRequest() write.SignalView = json.RawMessage(`{"lastConvertedAt":null}`) mux.HandleFunc(TemplateAPIURL+"/HNPz_pNAIAE", verifyRequestWithJsonBody( t, @@ -320,7 +322,7 @@ func TestUpdateTemplateResponseErrorDoesNotExposeRequest(t *testing.T) { teardown := setup() defer teardown() - write := testContent() + write := testTemplateWriteRequest() write.Title = "private template title" mux.HandleFunc( TemplateAPIURL+"/HNPz_pNAIAE", @@ -349,7 +351,7 @@ func TestTemplateRejectsInvalidArguments(t *testing.T) { assert.Nil(t, result) assert.EqualError(t, err, "template ID must not be empty") - result, err = c.UpdateTemplate(context.Background(), "", testContent()) + result, err = c.UpdateTemplate(context.Background(), "", testTemplateWriteRequest()) assert.Nil(t, result) assert.EqualError(t, err, "template ID must not be empty") @@ -375,7 +377,7 @@ func TestTemplateOptionParamsOmitZeroValues(t *testing.T) { func TestCreateTemplateRejectsInvalidRawJSON(t *testing.T) { c := &Client{} - write := testContent() + write := testTemplateWriteRequest() write.Spec = json.RawMessage("{") result, err := c.CreateTemplate(context.Background(), write) diff --git a/testdata/fixtures/template/chart_success.json b/testdata/fixtures/template/chart_success.json index 13a1cd3..967980f 100644 --- a/testdata/fixtures/template/chart_success.json +++ b/testdata/fixtures/template/chart_success.json @@ -2,7 +2,7 @@ "data": { "id": "HNPr-tr_AAc", "self": "/v2/template/HNPr-tr_AAc", - "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "type": "#/dashify/v1/templates/Record", "title": "Request rate", "spec": { "": [ diff --git a/testdata/fixtures/template/dashboard_success.json b/testdata/fixtures/template/dashboard_success.json index a30d476..6259b88 100644 --- a/testdata/fixtures/template/dashboard_success.json +++ b/testdata/fixtures/template/dashboard_success.json @@ -2,7 +2,7 @@ "data": { "id": "HNPz_pNAIAE", "self": "/v2/template/HNPz_pNAIAE", - "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "type": "#/dashify/v1/templates/Record", "title": "Service overview", "spec": { "": [ diff --git a/testdata/fixtures/template/missing_envelope_fields.json b/testdata/fixtures/template/missing_envelope_fields.json index 9b83778..d53fd8d 100644 --- a/testdata/fixtures/template/missing_envelope_fields.json +++ b/testdata/fixtures/template/missing_envelope_fields.json @@ -2,7 +2,7 @@ "data": { "id": "minimal-id", "self": "/v2/template/minimal-id", - "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "type": "#/dashify/v1/templates/Record", "title": "Minimal template", "spec": {} } diff --git a/testdata/fixtures/template/search_success.json b/testdata/fixtures/template/search_success.json index 98e48e7..7d385ed 100644 --- a/testdata/fixtures/template/search_success.json +++ b/testdata/fixtures/template/search_success.json @@ -1,6 +1,6 @@ { "data": { - "type": "https://schema.splunkdev.com/dashify/v1/common/Collection", + "type": "#/dashify/v1/common/Collection", "self": "/v2/template?offset=0&rootElement=Dashboard&size=200", "next": null, "prev": null, @@ -14,7 +14,7 @@ { "id": "HNPz_pNAIAE", "self": "/v2/template/HNPz_pNAIAE", - "type": "https://schema.splunkdev.com/dashify/v1/templates/Record", + "type": "#/dashify/v1/templates/Record", "title": "Service overview", "spec": { "": []