diff --git a/directory.go b/directory.go new file mode 100644 index 0000000..0d773a3 --- /dev/null +++ b/directory.go @@ -0,0 +1,218 @@ +package signalfx + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "unicode" + "unicode/utf16" + "unicode/utf8" + + "github.com/signalfx/signalfx-go/directory" +) + +// DirectoryAPIURL is the base URL for interacting with Directory entries. +const DirectoryAPIURL = "/v2/directory" + +const ( + directoryMaxPathSegments = 10 + directoryMaxPathLength = 250 +) + +// GetDirectoryEntry returns the Directory entry for path. Path is a decoded +// logical path such as "~users/user@example.com/My Dashboards". An empty path +// reads the Directory root. +func (c *Client) GetDirectoryEntry(ctx context.Context, path string) (*directory.Result, error) { + requestPath, err := directoryRequestPath(path, true) + if err != nil { + return nil, err + } + + resp, err := c.doDirectoryRequest(ctx, http.MethodGet, requestPath, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err := newResponseError(resp, http.StatusOK); err != nil { + return nil, err + } + + return decodeDirectoryResult(resp.Body) +} + +// PatchDirectoryEntry creates or updates the Directory entry for path. +// Templates, when present, replaces the entry's complete membership list. +// The Directory API provides no atomic add/remove operation or stale-write +// precondition, so callers must account for concurrent updates. Setting Pinned +// to false on an entry with no Templates can make the entry unoccupied. +func (c *Client) PatchDirectoryEntry(ctx context.Context, path string, patch *directory.PatchDirectoryEntryRequest) (*directory.Result, error) { + if patch == nil { + return nil, errors.New("directory patch must not be nil") + } + + requestPath, err := directoryRequestPath(path, false) + if err != nil { + return nil, err + } + + payload, err := json.Marshal(patch) + if err != nil { + return nil, fmt.Errorf("marshal directory patch: %w", err) + } + + resp, err := c.doDirectoryRequest(ctx, http.MethodPatch, requestPath, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err := newResponseError(resp, http.StatusOK, http.StatusCreated); err != nil { + return nil, err + } + + return decodeDirectoryResult(resp.Body) +} + +// DeleteDirectoryEntry deletes the exact Directory entry at path. +// +// This low-level operation does not verify ownership. Callers should first +// read the entry, verify that the path is owned by them, and confirm that its +// Templates and Children are empty. Deleting an entry does not delete its +// Template resources, but it can remove the entry's membership metadata. +func (c *Client) DeleteDirectoryEntry(ctx context.Context, path string) error { + requestPath, err := directoryRequestPath(path, false) + if err != nil { + return err + } + + resp, err := c.doDirectoryRequest(ctx, http.MethodDelete, requestPath, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if err := newResponseError(resp, http.StatusNoContent); err != nil { + return err + } + + _, err = io.Copy(io.Discard, resp.Body) + return err +} + +func decodeDirectoryResult(body io.Reader) (*directory.Result, error) { + payload, err := io.ReadAll(body) + if err != nil { + return nil, fmt.Errorf("read directory response: %w", err) + } + + result := &directory.Result{} + if err := json.Unmarshal(payload, result); err != nil { + return nil, fmt.Errorf("decode directory response: %w", err) + } + return result, nil +} + +// directoryRequestPath validates a decoded logical path and returns the exact +// escaped route expected by the Directory service. It intentionally uses +// form-style escaping per segment because the service treats '+' as a space +// and '%2B' as a literal plus. +func directoryRequestPath(path string, allowRoot bool) (string, error) { + if path == "" { + if allowRoot { + return DirectoryAPIURL, nil + } + return "", errors.New("directory root cannot be modified") + } + + if !utf8.ValidString(path) { + return "", errors.New("directory path must be valid UTF-8") + } + if strings.HasPrefix(path, "/") || strings.HasSuffix(path, "/") { + return "", fmt.Errorf("invalid directory path %q: leading and trailing slashes are not allowed", path) + } + if len(utf16.Encode([]rune("/"+path))) > directoryMaxPathLength { + return "", fmt.Errorf("invalid directory path %q: path exceeds %d characters", path, directoryMaxPathLength) + } + + segments := strings.Split(path, "/") + if len(segments) > directoryMaxPathSegments { + return "", fmt.Errorf("invalid directory path %q: path exceeds %d segments", path, directoryMaxPathSegments) + } + + escapedSegments := make([]string, len(segments)) + for i, segment := range segments { + if err := validateDirectoryPathSegment(segment); err != nil { + return "", fmt.Errorf("invalid directory path %q: segment %d %w", path, i+1, err) + } + + escaped := url.QueryEscape(segment) + escapedSegments[i] = strings.ReplaceAll(escaped, "%2A", "*") + } + + return DirectoryAPIURL + "/" + strings.Join(escapedSegments, "/"), nil +} + +func validateDirectoryPathSegment(segment string) error { + if segment == "" { + return errors.New("must not be empty") + } + if segment == "." || segment == ".." { + return fmt.Errorf("%q is reserved", segment) + } + + runes := []rune(segment) + if unicode.IsSpace(runes[0]) || unicode.IsSpace(runes[len(runes)-1]) { + return errors.New("must not start or end with whitespace") + } + if strings.Contains(segment[1:], "~") { + return errors.New("must not contain '~' except as its first character") + } + + for _, character := range runes { + if unicode.IsControl(character) { + return errors.New("must not contain control characters") + } + if strings.ContainsRune("%<>:\"\\|?", character) { + return fmt.Errorf("must not contain %q", character) + } + } + + return nil +} + +// doDirectoryRequest preserves the Directory service's nonstandard use of +// '+' for spaces in path segments. url.URL.Path cannot represent that spelling +// because '+' is normally a literal character in a URL path. +func (c *Client) doDirectoryRequest(ctx context.Context, method string, requestPath string, body io.Reader) (*http.Response, error) { + baseURL, err := url.Parse(c.baseURL) + if err != nil { + return nil, err + } + + baseURL.RawQuery = "" + baseURL.Fragment = "" + baseURL.Path = strings.TrimSuffix(baseURL.Path, "/") + if baseURL.RawPath != "" { + baseURL.RawPath = strings.TrimSuffix(baseURL.RawPath, "/") + } + + destination := strings.TrimSuffix(baseURL.String(), "/") + requestPath + req, err := http.NewRequestWithContext(ctx, method, destination, body) + if err != nil { + return nil, err + } + if c.authToken != "" { + req.Header.Set(AuthHeaderKey, c.authToken) + } + req.Header.Set("User-Agent", c.userAgent) + req.Header.Set("Content-Type", "application/json") + + return c.httpClient.Do(req) +} diff --git a/directory/doc.go b/directory/doc.go new file mode 100644 index 0000000..cb42adc --- /dev/null +++ b/directory/doc.go @@ -0,0 +1,2 @@ +// Package directory contains models for the SignalFx Directory API. +package directory diff --git a/directory/model.go b/directory/model.go new file mode 100644 index 0000000..2453999 --- /dev/null +++ b/directory/model.go @@ -0,0 +1,42 @@ +package directory + +import "github.com/signalfx/signalfx-go/util" + +// EntryType is the schema type returned for Directory entries. +const EntryType = "#/dashify/v1/directory/Entry" + +// Entry describes one logical Directory path and its Template memberships. +type Entry struct { + Self string `json:"self"` + Type string `json:"type"` + Path string `json:"path"` + Label string `json:"label"` + Templates []string `json:"templates"` + Ancestors []string `json:"ancestors"` + Children []string `json:"children"` + // Pinned marks the entry as explicitly kept even when it would otherwise + // be unoccupied (no Templates and no Children). + Pinned bool `json:"pinned"` + Identity bool `json:"identity"` + Canonical bool `json:"canonical"` +} + +// PatchDirectoryEntryRequest contains the writable fields for a Directory entry. +// +// Pinned is a pointer so callers can distinguish an omitted field from an +// explicit false value. Templates replaces the complete membership list. A +// nil Templates slice is omitted, while a non-nil empty slice clears the list. +type PatchDirectoryEntryRequest struct { + Templates []string `json:"templates,omitzero"` + Pinned *bool `json:"pinned,omitempty"` +} + +// APIError describes an error reported inside a Directory response envelope. +type APIError = util.APIError + +// Result is the response envelope returned by Directory reads and patches. +type Result struct { + Data *Entry `json:"data"` + Errors []APIError `json:"errors"` + Includes []Entry `json:"includes"` +} diff --git a/directory_test.go b/directory_test.go new file mode 100644 index 0000000..f68b2e1 --- /dev/null +++ b/directory_test.go @@ -0,0 +1,384 @@ +package signalfx + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/signalfx/signalfx-go/directory" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDirectoryRequestPath(t *testing.T) { + valid := []struct { + name string + path string + allowRoot bool + want string + }{ + { + name: "root", + allowRoot: true, + want: "/v2/directory", + }, + { + name: "nested path", + path: "team/dashboards", + want: "/v2/directory/team/dashboards", + }, + { + name: "spaces and at sign", + path: "~users/user@example.com/My Dashboards", + want: "/v2/directory/~users/user%40example.com/My+Dashboards", + }, + { + name: "literal plus differs from space", + path: "A+B/A B", + want: "/v2/directory/A%2BB/A+B", + }, + { + name: "unicode", + path: "zażółć/東京", + want: "/v2/directory/za%C5%BC%C3%B3%C5%82%C4%87/%E6%9D%B1%E4%BA%AC", + }, + { + name: "server supported punctuation", + path: "test'path/test(path)/test,path/test!path/test*path/a#b&c=d", + want: "/v2/directory/test%27path/test%28path%29/test%2Cpath/test%21path/test*path/a%23b%26c%3Dd", + }, + } + + for _, tc := range valid { + t.Run(tc.name, func(t *testing.T) { + got, err := directoryRequestPath(tc.path, tc.allowRoot) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } + + t.Run("server limits", func(t *testing.T) { + _, err := directoryRequestPath(strings.Repeat("a", 249), false) + require.NoError(t, err) + + _, err = directoryRequestPath("a/b/c/d/e/f/g/h/i/j", false) + require.NoError(t, err) + }) + + invalid := []struct { + name string + path string + }{ + {name: "root mutation", path: ""}, + {name: "leading slash", path: "/team"}, + {name: "trailing slash", path: "team/"}, + {name: "empty segment", path: "team//dashboards"}, + {name: "leading whitespace", path: "team/ dashboards"}, + {name: "trailing whitespace", path: "team/dashboards "}, + {name: "dot segment", path: "team/./dashboards"}, + {name: "dot dot segment", path: "team/../dashboards"}, + {name: "percent", path: "team/100%"}, + {name: "filesystem punctuation", path: "team/dashboards?"}, + {name: "control character", path: "team/dash\tboards"}, + {name: "misplaced tilde", path: "team/dash~boards"}, + {name: "extra tilde", path: "team/~dash~boards"}, + {name: "too many segments", path: "a/b/c/d/e/f/g/h/i/j/k"}, + {name: "too long", path: strings.Repeat("a", 250)}, + {name: "invalid UTF-8", path: string([]byte{0xff})}, + } + + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + _, err := directoryRequestPath(tc.path, false) + assert.Error(t, err) + }) + } +} + +func TestGetDirectoryEntry(t *testing.T) { + t.Run("root", func(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc("/v2/directory", verifyRequest(t, http.MethodGet, true, http.StatusOK, nil, "directory/root_success.json")) + + result, err := client.GetDirectoryEntry(context.Background(), "") + require.NoError(t, err) + require.NotNil(t, result.Data) + assert.Equal(t, directory.EntryType, result.Data.Type) + assert.Equal(t, "", result.Data.Path) + assert.Equal(t, []string{"/v2/directory/~organization", "/v2/directory/~users"}, result.Data.Children) + }) + + t.Run("nested path with base URL prefix", func(t *testing.T) { + mux = http.NewServeMux() + server = httptest.NewServer(mux) + defer server.Close() + + client, _ = NewClient(TestToken, APIUrl(server.URL+"/extra/path/")) + mux.HandleFunc("/", createResponse(t, http.StatusOK, "directory/nested_success.json", func(t *testing.T, request *http.Request) { + verifyHeaders(t, request, true) + assert.Equal(t, http.MethodGet, request.Method) + assert.Equal(t, "/extra/path/v2/directory/~users/user%40example.com/My+Dashboards", request.RequestURI) + })) + + result, err := client.GetDirectoryEntry(context.Background(), "~users/user@example.com/My Dashboards") + require.NoError(t, err) + require.NotNil(t, result.Data) + assert.Equal(t, "~users/user@example.com/My Dashboards", result.Data.Path) + assert.Equal(t, []string{"/v2/template/test-dashboard", "/v2/template/test-chart"}, result.Data.Templates) + require.Len(t, result.Includes, 1) + assert.True(t, result.Includes[0].Identity) + }) + + t.Run("unoccupied", func(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc("/v2/directory/test", verifyRequest(t, http.MethodGet, true, http.StatusOK, nil, "directory/unoccupied_success.json")) + + result, err := client.GetDirectoryEntry(context.Background(), "test") + require.NoError(t, err) + require.NotNil(t, result.Data) + assert.Equal(t, "test", result.Data.Path) + assert.False(t, result.Data.Pinned) + assert.Empty(t, result.Data.Templates) + assert.Empty(t, result.Data.Children) + assert.Empty(t, result.Errors) + }) + + t.Run("unexpected status preserves response details", func(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc("/v2/directory/test", verifyRequest(t, http.MethodGet, true, http.StatusForbidden, nil, "directory/error.json")) + + result, err := client.GetDirectoryEntry(context.Background(), "test") + assert.Nil(t, result) + require.Error(t, err) + + responseError, ok := AsResponseError(err) + require.True(t, ok) + assert.Equal(t, http.StatusForbidden, responseError.Code()) + assert.Equal(t, "/v2/directory/test", responseError.Route()) + assert.JSONEq(t, fixture("directory/error.json"), responseError.Details()) + }) + + t.Run("malformed response", func(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc("/v2/directory/test", func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(response, "{") + }) + + result, err := client.GetDirectoryEntry(context.Background(), "test") + assert.Nil(t, result) + assert.ErrorContains(t, err, "decode directory response") + }) + + t.Run("response body is closed", func(t *testing.T) { + body := &trackingDirectoryBody{Reader: strings.NewReader(fixture("directory/root_success.json"))} + testClient, err := NewClient(TestToken, + APIUrl("https://example.test"), + HTTPClient(&http.Client{Transport: directoryRoundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: body, + Header: make(http.Header), + Request: request, + }, nil + })}), + ) + require.NoError(t, err) + + result, err := testClient.GetDirectoryEntry(context.Background(), "") + require.NoError(t, err) + require.NotNil(t, result.Data) + assert.True(t, body.closed) + }) + + t.Run("transport error", func(t *testing.T) { + transportErr := errors.New("test transport failure") + testClient, err := NewClient(TestToken, + APIUrl("https://example.test"), + HTTPClient(&http.Client{Transport: directoryRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, transportErr + })}), + ) + require.NoError(t, err) + + result, err := testClient.GetDirectoryEntry(context.Background(), "") + assert.Nil(t, result) + assert.ErrorIs(t, err, transportErr) + }) +} + +func TestPatchDirectoryEntry(t *testing.T) { + tests := []struct { + name string + patch *directory.PatchDirectoryEntryRequest + wantBody string + statusCode int + }{ + { + name: "complete ordered membership and pinned", + patch: &directory.PatchDirectoryEntryRequest{ + Templates: []string{"/v2/template/dashboard", "/v2/template/chart"}, + Pinned: new(true), + }, + wantBody: `{"templates":["/v2/template/dashboard","/v2/template/chart"],"pinned":true}`, + statusCode: http.StatusOK, + }, + { + name: "explicit empty membership", + patch: &directory.PatchDirectoryEntryRequest{Templates: []string{}}, + wantBody: `{"templates":[]}`, + statusCode: http.StatusOK, + }, + { + name: "false is not omitted", + patch: &directory.PatchDirectoryEntryRequest{Pinned: new(false)}, + wantBody: `{"pinned":false}`, + statusCode: http.StatusOK, + }, + { + name: "omitted fields", + patch: &directory.PatchDirectoryEntryRequest{}, + wantBody: `{}`, + statusCode: http.StatusOK, + }, + { + name: "legacy created status", + patch: &directory.PatchDirectoryEntryRequest{Pinned: new(true)}, + wantBody: `{"pinned":true}`, + statusCode: http.StatusCreated, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc("/v2/directory/test", verifyRequestWithJsonBody(t, http.MethodPatch, true, tc.statusCode, nil, tc.wantBody, "directory/nested_success.json")) + + result, err := client.PatchDirectoryEntry(context.Background(), "test", tc.patch) + require.NoError(t, err) + require.NotNil(t, result.Data) + assert.Equal(t, "~users/user@example.com/My Dashboards", result.Data.Path) + }) + } + + t.Run("nil patch is rejected without a request", func(t *testing.T) { + teardown := setup() + defer teardown() + + requested := false + mux.HandleFunc("/", func(http.ResponseWriter, *http.Request) { + requested = true + }) + + result, err := client.PatchDirectoryEntry(context.Background(), "test", nil) + assert.Nil(t, result) + assert.ErrorContains(t, err, "must not be nil") + assert.False(t, requested) + }) + + t.Run("invalid path is rejected without a request", func(t *testing.T) { + teardown := setup() + defer teardown() + + requested := false + mux.HandleFunc("/", func(http.ResponseWriter, *http.Request) { + requested = true + }) + + result, err := client.PatchDirectoryEntry(context.Background(), "other//user", &directory.PatchDirectoryEntryRequest{}) + assert.Nil(t, result) + assert.ErrorContains(t, err, "must not be empty") + assert.False(t, requested) + }) + + t.Run("unexpected status", func(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc("/v2/directory/test", verifyRequestWithJsonBody(t, http.MethodPatch, true, http.StatusBadRequest, nil, `{}`, "directory/error.json")) + + result, err := client.PatchDirectoryEntry(context.Background(), "test", &directory.PatchDirectoryEntryRequest{}) + assert.Nil(t, result) + require.Error(t, err) + + responseError, ok := AsResponseError(err) + require.True(t, ok) + assert.Equal(t, http.StatusBadRequest, responseError.Code()) + }) +} + +func TestDeleteDirectoryEntry(t *testing.T) { + t.Run("exact path", func(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc("/", createResponse(t, http.StatusNoContent, "", func(t *testing.T, request *http.Request) { + verifyHeaders(t, request, true) + assert.Equal(t, http.MethodDelete, request.Method) + assert.Equal(t, "/v2/directory/~users/user%40example.com/DASHM-1966+test%2Bfolder", request.RequestURI) + })) + + err := client.DeleteDirectoryEntry(context.Background(), "~users/user@example.com/DASHM-1966 test+folder") + assert.NoError(t, err) + }) + + t.Run("root is rejected without a request", func(t *testing.T) { + teardown := setup() + defer teardown() + + requested := false + mux.HandleFunc("/", func(http.ResponseWriter, *http.Request) { + requested = true + }) + + err := client.DeleteDirectoryEntry(context.Background(), "") + assert.ErrorContains(t, err, "root cannot be modified") + assert.False(t, requested) + }) + + t.Run("unexpected status", func(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc("/v2/directory/test", verifyRequest(t, http.MethodDelete, true, http.StatusForbidden, nil, "directory/error.json")) + + err := client.DeleteDirectoryEntry(context.Background(), "test") + require.Error(t, err) + + responseError, ok := AsResponseError(err) + require.True(t, ok) + assert.Equal(t, http.StatusForbidden, responseError.Code()) + assert.JSONEq(t, fixture("directory/error.json"), responseError.Details()) + }) +} + +type directoryRoundTripFunc func(*http.Request) (*http.Response, error) + +func (function directoryRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return function(request) +} + +type trackingDirectoryBody struct { + io.Reader + closed bool +} + +func (body *trackingDirectoryBody) Close() error { + body.closed = true + return nil +} diff --git a/go.mod b/go.mod index c1f8105..7f84146 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/signalfx/signalfx-go -go 1.25.0 +go 1.26.0 require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc diff --git a/testdata/fixtures/directory/error.json b/testdata/fixtures/directory/error.json new file mode 100644 index 0000000..c84a6fe --- /dev/null +++ b/testdata/fixtures/directory/error.json @@ -0,0 +1,10 @@ +{ + "data": null, + "errors": [ + { + "code": "403", + "message": "Directory path is not writable" + } + ], + "includes": [] +} diff --git a/testdata/fixtures/directory/nested_success.json b/testdata/fixtures/directory/nested_success.json new file mode 100644 index 0000000..6bb578f --- /dev/null +++ b/testdata/fixtures/directory/nested_success.json @@ -0,0 +1,33 @@ +{ + "data": { + "self": "/v2/directory/~users/user%40example.com/My+Dashboards", + "type": "#/dashify/v1/directory/Entry", + "path": "~users/user@example.com/My Dashboards", + "label": "My Dashboards", + "templates": ["/v2/template/test-dashboard", "/v2/template/test-chart"], + "ancestors": [ + "/v2/directory", + "/v2/directory/~users", + "/v2/directory/~users/user%40example.com" + ], + "children": [], + "pinned": true, + "identity": false, + "canonical": false + }, + "errors": [], + "includes": [ + { + "self": "/v2/directory/~users/user%40example.com", + "type": "#/dashify/v1/directory/Entry", + "path": "~users/user@example.com", + "label": "Test User", + "templates": [], + "ancestors": ["/v2/directory", "/v2/directory/~users"], + "children": ["/v2/directory/~users/user%40example.com/My+Dashboards"], + "pinned": true, + "identity": true, + "canonical": true + } + ] +} diff --git a/testdata/fixtures/directory/root_success.json b/testdata/fixtures/directory/root_success.json new file mode 100644 index 0000000..7f43aae --- /dev/null +++ b/testdata/fixtures/directory/root_success.json @@ -0,0 +1,17 @@ +{ + "data": { + "self": "/v2/directory", + "type": "#/dashify/v1/directory/Entry", + "path": "", + "label": "All dashboards", + "templates": [], + "ancestors": [], + "children": ["/v2/directory/~organization", "/v2/directory/~users"], + "pinned": true, + "identity": false, + "canonical": false, + "futureField": "ignored" + }, + "errors": [], + "includes": [] +} diff --git a/testdata/fixtures/directory/unoccupied_success.json b/testdata/fixtures/directory/unoccupied_success.json new file mode 100644 index 0000000..9137c46 --- /dev/null +++ b/testdata/fixtures/directory/unoccupied_success.json @@ -0,0 +1,16 @@ +{ + "data": { + "self": "/v2/directory/test", + "type": "#/dashify/v1/directory/Entry", + "path": "test", + "label": "test", + "templates": [], + "ancestors": [], + "children": [], + "pinned": false, + "identity": false, + "canonical": false + }, + "errors": null, + "includes": null +}