From d17d26b7e017e09ad3dfe90f6be6dd5b610a645d Mon Sep 17 00:00:00 2001 From: Bernd Warmuth <72415058+warber@users.noreply.github.com> Date: Thu, 27 Jul 2023 15:30:43 +0200 Subject: [PATCH] Update Settings based on unique key properties For Settings schemas with unique key constraints, these key constraints are used in addition to the external ID to identify and update an existing object. This ensures that Settings objects are not attempted to be duplicated but are correctly found and updated. Changes in addition to the main feature: * test(e2e): Add test which tries to deploy settings2.0 object (span-attribute) with the same unique key but different coordinates/externalID in two different projects and checks that a single object was created and updated without error. * refactor(client): Move all settings clients test to same file * refactor(client): Move all config client tests to same file * refactor(client): Move all entity client tests to same file --------- Co-authored-by: Jure Skelin Co-authored-by: UnseenWizzard --- .../v2/settings_integration_test.go | 19 + .../settings-unique-properties/manifest.yaml | 26 + .../builtinmonitoring.slo/config.yaml | 11 + .../d955d9b7-9630-3be5-aca5-3715779a7282.json | 15 + .../1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json | 4 + .../builtinspan-attribute/config.yaml | 11 + .../builtinmonitoring.slo/config.yaml | 11 + .../d955d9b7-9630-3be5-aca5-3715779a7282.json | 15 + .../1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json | 4 + .../builtinspan-attribute/config.yaml | 11 + pkg/client/dtclient/client.go | 56 - pkg/client/dtclient/client_settings_test.go | 106 -- pkg/client/dtclient/client_test.go | 956 ------------ pkg/client/dtclient/config_client_test.go | 85 +- pkg/client/dtclient/entities_client_test.go | 289 +++- ...{client_settings.go => settings_client.go} | 161 +- pkg/client/dtclient/settings_client_test.go | 1300 ++++++++++++++++- 17 files changed, 1932 insertions(+), 1148 deletions(-) create mode 100644 cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/manifest.yaml create mode 100644 cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/config.yaml create mode 100644 cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json create mode 100644 cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json create mode 100644 cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/config.yaml create mode 100644 cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/config.yaml create mode 100644 cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json create mode 100644 cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json create mode 100644 cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/config.yaml delete mode 100644 pkg/client/dtclient/client_settings_test.go rename pkg/client/dtclient/{client_settings.go => settings_client.go} (70%) diff --git a/cmd/monaco/integrationtest/v2/settings_integration_test.go b/cmd/monaco/integrationtest/v2/settings_integration_test.go index d3160176d..3e5a89829 100644 --- a/cmd/monaco/integrationtest/v2/settings_integration_test.go +++ b/cmd/monaco/integrationtest/v2/settings_integration_test.go @@ -138,6 +138,25 @@ func TestOldExternalIDGetsUpdated(t *testing.T) { } +func TestDeploySettingsWithUniqueProperties(t *testing.T) { + fs := testutils.CreateTestFileSystem() + var manifestPath = "test-resources/settings-unique-properties/manifest.yaml" + loadedManifest := integrationtest.LoadManifest(t, fs, manifestPath, "platform_env") + + t.Cleanup(func() { + integrationtest.CleanupIntegrationTest(t, fs, manifestPath, loadedManifest, "") + }) + + cmd := runner.BuildCli(fs) + cmd.SetArgs([]string{"deploy", "-e", "platform_env", "-p", "project1", manifestPath}) + err := cmd.Execute() + assert.NoError(t, err) + + cmd.SetArgs([]string{"deploy", "-e", "platform_env", "-p", "project2", manifestPath}) + err = cmd.Execute() + assert.NoError(t, err) +} + func createSettingsClient(t *testing.T, env manifest.EnvironmentDefinition, opts ...func(dynatraceClient *dtclient.DynatraceClient)) dtclient.SettingsClient { oauthCredentials := auth.OauthCredentials{ ClientID: env.Auth.OAuth.ClientID.Value, diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/manifest.yaml b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/manifest.yaml new file mode 100644 index 000000000..bf5c75ffe --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/manifest.yaml @@ -0,0 +1,26 @@ +manifestVersion: 1.0 +projects: +- name: project1 +- name: project2 +environmentGroups: +- name: default + environments: + - name: classic_env + url: + type: environment + value: URL_ENVIRONMENT_1 + auth: + token: + name: TOKEN_ENVIRONMENT_1 + - name: platform_env + url: + type: environment + value: PLATFORM_URL_ENVIRONMENT_2 + auth: + token: + name: TOKEN_ENVIRONMENT_2 + oAuth: + clientId: + name: OAUTH_CLIENT_ID + clientSecret: + name: OAUTH_CLIENT_SECRET diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/config.yaml b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/config.yaml new file mode 100644 index 000000000..550e4c22c --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/config.yaml @@ -0,0 +1,11 @@ +configs: +- id: d955d9b7-9630-3be5-aca5-3715779a7282 + config: + name: d955d9b7-9630-3be5-aca5-3715779a7282 + template: d955d9b7-9630-3be5-aca5-3715779a7282.json + skip: false + type: + settings: + schema: builtin:monitoring.slo + schemaVersion: 6.0.12 + scope: environment diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json new file mode 100644 index 000000000..5052cf1e8 --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json @@ -0,0 +1,15 @@ +{ + "enabled": true, + "name": "Just a Name", + "metricName": "justaname", + "metricExpression": "(100)*(builtin:service.errors.server.successCount:splitBy())/(builtin:service.requestCount.server:splitBy())", + "evaluationType": "AGGREGATE", + "filter": "type(\"SERVICE\")", + "evaluationWindow": "-1w", + "targetSuccess": 99.98, + "targetWarning": 99.99, + "errorBudgetBurnRate": { + "burnRateVisualizationEnabled": true, + "fastBurnThreshold": 10.0 + } +} diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json new file mode 100644 index 000000000..87aa792ba --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json @@ -0,0 +1,4 @@ +{ + "key": "graphql.operation.name", + "masking": "NOT_MASKED" +} diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/config.yaml b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/config.yaml new file mode 100644 index 000000000..551972d2b --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/config.yaml @@ -0,0 +1,11 @@ +configs: +- id: 1f4b1f5c-11d2-38c8-9324-7d139c6e6452 + config: + name: 1f4b1f5c-11d2-38c8-9324-7d139c6e6452 + template: 1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json + skip: false + type: + settings: + schema: builtin:span-attribute + schemaVersion: 0.0.32 + scope: environment diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/config.yaml b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/config.yaml new file mode 100644 index 000000000..550e4c22c --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/config.yaml @@ -0,0 +1,11 @@ +configs: +- id: d955d9b7-9630-3be5-aca5-3715779a7282 + config: + name: d955d9b7-9630-3be5-aca5-3715779a7282 + template: d955d9b7-9630-3be5-aca5-3715779a7282.json + skip: false + type: + settings: + schema: builtin:monitoring.slo + schemaVersion: 6.0.12 + scope: environment diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json new file mode 100644 index 000000000..4dab5cd6a --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json @@ -0,0 +1,15 @@ +{ + "enabled": true, + "name": "NEW Name", + "metricName": "justaname", + "metricExpression": "(100)*(builtin:service.errors.server.successCount:splitBy())/(builtin:service.requestCount.server:splitBy())", + "evaluationType": "AGGREGATE", + "filter": "type(\"SERVICE\")", + "evaluationWindow": "-1w", + "targetSuccess": 99.98, + "targetWarning": 99.99, + "errorBudgetBurnRate": { + "burnRateVisualizationEnabled": true, + "fastBurnThreshold": 10.0 + } +} diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json new file mode 100644 index 000000000..87aa792ba --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json @@ -0,0 +1,4 @@ +{ + "key": "graphql.operation.name", + "masking": "NOT_MASKED" +} diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/config.yaml b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/config.yaml new file mode 100644 index 000000000..551972d2b --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/config.yaml @@ -0,0 +1,11 @@ +configs: +- id: 1f4b1f5c-11d2-38c8-9324-7d139c6e6452 + config: + name: 1f4b1f5c-11d2-38c8-9324-7d139c6e6452 + template: 1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json + skip: false + type: + settings: + schema: builtin:span-attribute + schemaVersion: 0.0.32 + scope: environment diff --git a/pkg/client/dtclient/client.go b/pkg/client/dtclient/client.go index 89ce12258..e5a4da97c 100644 --- a/pkg/client/dtclient/client.go +++ b/pkg/client/dtclient/client.go @@ -23,7 +23,6 @@ import ( "fmt" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/cache" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/concurrency" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/filter" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/idutils" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/log" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/log/field" @@ -560,61 +559,6 @@ func (d *DynatraceClient) getSettingById(ctx context.Context, objectId string) ( return &result, nil } -func (d *DynatraceClient) ListSettings(ctx context.Context, schemaId string, opts ListSettingsOptions) (res []DownloadSettingsObject, err error) { - d.limiter.ExecuteBlocking(func() { - res, err = d.listSettings(ctx, schemaId, opts) - }) - return -} - -func (d *DynatraceClient) listSettings(ctx context.Context, schemaId string, opts ListSettingsOptions) ([]DownloadSettingsObject, error) { - - if settings, cached := d.settingsCache.Get(schemaId); cached { - log.Debug("Using cached settings for schema %s", schemaId) - return filter.FilterSlice(settings, opts.Filter), nil - } - - log.Debug("Downloading all settings for schema %s", schemaId) - - listSettingsFields := defaultListSettingsFields - if opts.DiscardValue { - listSettingsFields = reducedListSettingsFields - } - params := url.Values{ - "schemaIds": []string{schemaId}, - "pageSize": []string{defaultPageSize}, - "fields": []string{listSettingsFields}, - } - - result := make([]DownloadSettingsObject, 0) - - addToResult := func(body []byte) (int, error) { - var parsed struct { - Items []DownloadSettingsObject `json:"items"` - } - if err := json.Unmarshal(body, &parsed); err != nil { - return 0, fmt.Errorf("failed to unmarshal response: %w", err) - } - - result = append(result, parsed.Items...) - return len(parsed.Items), nil - } - - u, err := buildUrl(d.environmentURL, d.settingsObjectAPIPath, params) - if err != nil { - return nil, fmt.Errorf("failed to list settings: %w", err) - } - - _, err = rest.ListPaginated(ctx, d.platformClient, d.retrySettings, u, schemaId, addToResult) - if err != nil { - return nil, err - } - - d.settingsCache.Set(schemaId, result) - - return filter.FilterSlice(result, opts.Filter), nil -} - type EntitiesTypeListResponse struct { Types []EntitiesType `json:"types"` } diff --git a/pkg/client/dtclient/client_settings_test.go b/pkg/client/dtclient/client_settings_test.go deleted file mode 100644 index db30483de..000000000 --- a/pkg/client/dtclient/client_settings_test.go +++ /dev/null @@ -1,106 +0,0 @@ -/* - * @license - * Copyright 2023 Dynatrace LLC - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package dtclient - -import ( - "context" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/rest" - "github.com/stretchr/testify/assert" - "net/http" - "net/http/httptest" - "testing" -) - -func Test_schemaDetails(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - switch req.URL.Path { - case settingsSchemaAPIPathPlatform + "/builtin:span-attribute": - r := []byte(` -{ - "schemaId": "builtin:span-attribute", - "schemaConstraints": [ - { - "type": "some another type", - "customMessage": "Attribute keys must be unique.", - "something": "example" - }, - { - "type": "UNIQUE", - "customMessage": "Attribute keys must be unique.", - "uniqueProperties": [ - "key0", - "key1" - ] - }, - { - "type": "UNIQUE", - "customMessage": "Attribute keys must be unique.", - "uniqueProperties": [ - "key2", - "key3" - ] - } - ] -}`) - rw.WriteHeader(http.StatusOK) - rw.Write(r) - default: - rw.WriteHeader(http.StatusNotFound) - - } - })) - defer server.Close() - - restCLient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) - - d, _ := NewPlatformClient(server.URL, server.URL, restCLient, restCLient) - - t.Run("unmarshall data", func(t *testing.T) { - expected := SchemaConstraints{SchemaId: "builtin:span-attribute", UniqueProperties: [][]string{{"key0", "key1"}, {"key2", "key3"}}} - - actual, err := d.fetchSchemasConstraints(context.TODO(), "builtin:span-attribute") - - assert.NoError(t, err) - assert.Equal(t, expected, actual) - }) -} - -func Test_FetchSchemaConstraintsUsesCache(t *testing.T) { - apiHits := 0 - server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - apiHits++ - r := []byte(`{"schemaId": "builtin:span-attribute","schemaConstraints": []}`) - rw.WriteHeader(http.StatusOK) - rw.Write(r) - - })) - defer server.Close() - - restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) - d, _ := NewPlatformClient(server.URL, server.URL, restClient, restClient) - - _, err := d.fetchSchemasConstraints(context.TODO(), "builtin:span-attribute") - assert.NoError(t, err) - assert.Equal(t, 1, apiHits) - _, err = d.fetchSchemasConstraints(context.TODO(), "builtin:alerting.profile") - assert.NoError(t, err) - assert.Equal(t, 2, apiHits) - _, err = d.fetchSchemasConstraints(context.TODO(), "builtin:span-attribute") - assert.NoError(t, err) - assert.Equal(t, 2, apiHits) - -} diff --git a/pkg/client/dtclient/client_test.go b/pkg/client/dtclient/client_test.go index 68b1752d0..e31d7527e 100644 --- a/pkg/client/dtclient/client_test.go +++ b/pkg/client/dtclient/client_test.go @@ -19,15 +19,8 @@ package dtclient import ( - "context" - "fmt" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/cache" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/concurrency" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/idutils" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/trafficlogs" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/version" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/api" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config/coordinate" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/rest" "github.com/stretchr/testify/assert" "net/http" @@ -182,955 +175,6 @@ func TestNewPlatformClient(t *testing.T) { }) } -func TestReadByIdReturnsAnErrorUponEncounteringAnError(t *testing.T) { - testServer := httptest.NewTLSServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { - http.Error(res, "", http.StatusForbidden) - })) - defer func() { testServer.Close() }() - client := DynatraceClient{ - environmentURLClassic: testServer.URL, - classicClient: rest.NewRestClient(testServer.Client(), trafficlogs.NewFileBased(), rest.CreateRateLimitStrategy()), - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - } - - _, err := client.ReadConfigById(mockAPI, "test") - assert.ErrorContains(t, err, "Response was") -} - -func TestReadByIdEscapesTheId(t *testing.T) { - unescapedID := "ruxit.perfmon.dotnetV4:%TimeInGC:time_in_gc_alert_high_generic" - - testServer := httptest.NewTLSServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {})) - defer func() { testServer.Close() }() - client := DynatraceClient{ - environmentURLClassic: testServer.URL, - classicClient: rest.NewRestClient(testServer.Client(), nil, rest.CreateRateLimitStrategy()), - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - } - _, err := client.ReadConfigById(mockAPINotSingle, unescapedID) - assert.NoError(t, err) -} - -func TestReadByIdReturnsTheResponseGivenNoError(t *testing.T) { - body := []byte{1, 3, 3, 7} - - testServer := httptest.NewTLSServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { - _, _ = res.Write(body) - })) - defer func() { testServer.Close() }() - - client := DynatraceClient{ - environmentURLClassic: testServer.URL, - classicClient: rest.NewRestClient(testServer.Client(), nil, rest.CreateRateLimitStrategy()), - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - } - - resp, err := client.ReadConfigById(mockAPI, "test") - assert.NoError(t, err, "there should not be an error") - assert.Equal(t, body, resp) -} - -func TestListKnownSettings(t *testing.T) { - - tests := []struct { - name string - givenSchemaID string - givenListSettingsOpts ListSettingsOptions - givenServerResponses []testServerResponse - want []DownloadSettingsObject - wantQueryParamsPerAPICall [][]testQueryParams - wantNumberOfAPICalls int - wantError bool - }{ - { - name: "Lists Settings objects as expected", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ] }`}, - }, - want: []DownloadSettingsObject{ - { - ExternalId: "RG9jdG9yIFdobwo=", - ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", - }, - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: false, - }, - { - name: "Lists Settings objects without value field as expected", - givenSchemaID: "builtin:something", - givenListSettingsOpts: ListSettingsOptions{DiscardValue: true}, - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ] }`}, - }, - want: []DownloadSettingsObject{ - { - ExternalId: "RG9jdG9yIFdobwo=", - ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", - }, - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", reducedListSettingsFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: false, - }, - { - name: "Lists Settings objects with filter as expected", - givenSchemaID: "builtin:something", - givenListSettingsOpts: ListSettingsOptions{Filter: func(o DownloadSettingsObject) bool { - return o.ExternalId == "RG9jdG9yIFdobwo=" - }}, - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ] }`}, - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4641", "externalId": "RG9jdG9yIabcdef="} ] }`}, - }, - want: []DownloadSettingsObject{ - { - ExternalId: "RG9jdG9yIFdobwo=", - ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", - }, - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: false, - }, - { - name: "Handles Pagination when listing settings objects", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ], "nextPageKey": "page42" }`}, - {200, `{ "items": [ {"objectId": "b1d4c623-25e0-4b54-9eb5-6734f1a72041", "externalId": "VGhlIE1hc3Rlcgo="} ] }`}, - }, - want: []DownloadSettingsObject{ - { - ExternalId: "RG9jdG9yIFdobwo=", - ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", - }, - { - ExternalId: "VGhlIE1hc3Rlcgo=", - ObjectId: "b1d4c623-25e0-4b54-9eb5-6734f1a72041", - }, - }, - - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 2, - wantError: false, - }, - { - name: "Returns empty if list if no items exist", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ ] }`}, - }, - want: []DownloadSettingsObject{}, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: false, - }, - { - name: "Returns error if HTTP error is encountered - 400", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {400, `epic fail`}, - }, - want: nil, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: true, - }, - { - name: "Returns error if HTTP error is encountered - 403", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {403, `epic fail`}, - }, - want: nil, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: true, - }, - { - name: "Retries on HTTP error on paginated request and returns eventual success", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ], "nextPageKey": "page42" }`}, - {400, `get next page fail`}, - {400, `retry fail`}, - {200, `{ "items": [ {"objectId": "b1d4c623-25e0-4b54-9eb5-6734f1a72041", "externalId": "VGhlIE1hc3Rlcgo="} ] }`}, - }, - want: []DownloadSettingsObject{ - { - ExternalId: "RG9jdG9yIFdobwo=", - ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", - }, - { - ExternalId: "VGhlIE1hc3Rlcgo=", - ObjectId: "b1d4c623-25e0-4b54-9eb5-6734f1a72041", - }, - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 4, - wantError: false, - }, - { - name: "Returns error if HTTP error is encountered getting further paginated responses", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ], "nextPageKey": "page42" }`}, - {400, `get next page fail`}, - {400, `retry fail 1`}, - {400, `retry fail 2`}, - {400, `retry fail 3`}, - }, - want: nil, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 5, - wantError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - apiCalls := 0 - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if len(tt.wantQueryParamsPerAPICall) > 0 { - params := tt.wantQueryParamsPerAPICall[apiCalls] - for _, param := range params { - addedQueryParameter := req.URL.Query()[param.key] - assert.NotNil(t, addedQueryParameter) - assert.NotEmpty(t, addedQueryParameter) - assert.Equal(t, addedQueryParameter[0], param.value) - } - } else { - assert.Equal(t, "", req.URL.RawQuery, "expected no query params - but '%s' was sent", req.URL.RawQuery) - } - - resp := tt.givenServerResponses[apiCalls] - if resp.statusCode != 200 { - http.Error(rw, resp.body, resp.statusCode) - } else { - _, _ = rw.Write([]byte(resp.body)) - } - - apiCalls++ - assert.LessOrEqualf(t, apiCalls, tt.wantNumberOfAPICalls, "expected at most %d API calls to happen, but encountered call %d", tt.wantNumberOfAPICalls, apiCalls) - })) - defer server.Close() - - restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) - client, _ := NewClassicClient(server.URL, restClient, - WithRetrySettings(testRetrySettings), - WithClientRequestLimiter(concurrency.NewLimiter(5)), - WithExternalIDGenerator(idutils.GenerateExternalID)) - - res, err1 := client.ListSettings(context.TODO(), tt.givenSchemaID, tt.givenListSettingsOpts) - - if tt.wantError { - assert.Error(t, err1) - } else { - assert.NoError(t, err1) - } - - assert.Equal(t, tt.want, res) - - assert.Equal(t, apiCalls, tt.wantNumberOfAPICalls, "expected exactly %d API calls to happen but %d calls where made", tt.wantNumberOfAPICalls, apiCalls) - }) - } -} - -func TestGetSettingById(t *testing.T) { - type fields struct { - environmentURL string - retrySettings rest.RetrySettings - } - type args struct { - objectID string - } - tests := []struct { - name string - fields fields - args args - givenTestServerResp *testServerResponse - wantURLPath string - wantResult *DownloadSettingsObject - wantErr bool - }{ - { - name: "Get Setting by ID - server response != 2xx", - fields: fields{}, - args: args{ - objectID: "12345", - }, - givenTestServerResp: &testServerResponse{ - statusCode: 500, - body: "{}", - }, - wantURLPath: "/api/v2/settings/objects/12345", - wantResult: nil, - wantErr: true, - }, - { - name: "Get Setting by ID - invalid server response", - fields: fields{}, - args: args{ - objectID: "12345", - }, - givenTestServerResp: &testServerResponse{ - statusCode: 200, - body: `{bs}`, - }, - wantURLPath: "/api/v2/settings/objects/12345", - wantResult: nil, - wantErr: true, - }, - { - name: "Get Setting by ID", - fields: fields{}, - args: args{ - objectID: "12345", - }, - givenTestServerResp: &testServerResponse{ - statusCode: 200, - body: `{"objectId":"12345","externalId":"54321", "schemaVersion":"1.0","schemaId":"builtin:bla","scope":"tenant"}`, - }, - wantURLPath: "/api/v2/settings/objects/12345", - wantResult: &DownloadSettingsObject{ - ExternalId: "54321", - SchemaVersion: "1.0", - SchemaId: "builtin:bla", - ObjectId: "12345", - Scope: "tenant", - Value: nil, - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - assert.Equal(t, tt.wantURLPath, req.URL.Path) - if resp := tt.givenTestServerResp; resp != nil { - if resp.statusCode != 200 { - http.Error(rw, resp.body, resp.statusCode) - } else { - _, _ = rw.Write([]byte(resp.body)) - } - } - - })) - defer server.Close() - - var envURL string - if tt.fields.environmentURL != "" { - envURL = tt.fields.environmentURL - } else { - envURL = server.URL - } - - d := DynatraceClient{ - environmentURL: envURL, - platformClient: rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()), - retrySettings: tt.fields.retrySettings, - settingsObjectAPIPath: "/api/v2/settings/objects", - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - } - - settingsObj, err := d.GetSettingById(tt.args.objectID) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tt.wantResult, settingsObj) - - }) - } - -} - -func TestDeleteSettings(t *testing.T) { - type fields struct { - environmentURL string - retrySettings rest.RetrySettings - } - type args struct { - objectID string - } - tests := []struct { - name string - fields fields - args args - givenTestServerResp *testServerResponse - wantURLPath string - wantErr bool - }{ - { - name: "Delete Settings - server response != 2xx", - fields: fields{}, - args: args{ - objectID: "12345", - }, - givenTestServerResp: &testServerResponse{ - statusCode: 500, - body: "{}", - }, - wantURLPath: "/api/v2/settings/objects/12345", - wantErr: true, - }, - { - name: "Delete Settings - server response 404 does not result in an err", - fields: fields{}, - args: args{ - objectID: "12345", - }, - givenTestServerResp: &testServerResponse{ - statusCode: 404, - body: "{}", - }, - wantURLPath: "/api/v2/settings/objects/12345", - wantErr: false, - }, - { - name: "Delete Settings - object ID is passed", - fields: fields{}, - args: args{ - objectID: "12345", - }, - wantURLPath: "/api/v2/settings/objects/12345", - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - assert.Equal(t, tt.wantURLPath, req.URL.Path) - if resp := tt.givenTestServerResp; resp != nil { - if resp.statusCode != 200 { - http.Error(rw, resp.body, resp.statusCode) - } else { - _, _ = rw.Write([]byte(resp.body)) - } - } - - })) - defer server.Close() - - var envURL string - if tt.fields.environmentURL != "" { - envURL = tt.fields.environmentURL - } else { - envURL = server.URL - } - - d := DynatraceClient{ - environmentURL: envURL, - platformClient: rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()), - retrySettings: tt.fields.retrySettings, - settingsObjectAPIPath: settingsObjectAPIPathClassic, - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - } - - if err := d.DeleteSettings(tt.args.objectID); (err != nil) != tt.wantErr { - t.Errorf("DeleteSettings() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestUpsertSettingsRetries(t *testing.T) { - numAPICalls := 0 - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if req.Method == http.MethodGet { - rw.WriteHeader(200) - _, _ = rw.Write([]byte("{}")) - return - } - - numAPICalls++ - if numAPICalls < 3 { - rw.WriteHeader(409) - return - } - rw.WriteHeader(200) - _, _ = rw.Write([]byte(`[{"objectId": "abcdefg"}]`)) - })) - defer server.Close() - - restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) - client, _ := NewPlatformClient(server.URL, server.URL, restClient, restClient, - WithRetrySettings(testRetrySettings), - WithClientRequestLimiter(concurrency.NewLimiter(5)), - WithExternalIDGenerator(idutils.GenerateExternalID)) - - _, err := client.UpsertSettings(context.TODO(), SettingsObject{ - Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, - SchemaId: "some:schema", - Content: []byte("{}"), - }) - - assert.NoError(t, err) - assert.Equal(t, numAPICalls, 3) -} - -func TestUpsertSettingsFromCache(t *testing.T) { - numAPIGetCalls := 0 - numAPIPostCalls := 0 - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if req.Method == http.MethodGet { - numAPIGetCalls++ - rw.WriteHeader(200) - rw.Write([]byte("{}")) - return - } - - numAPIPostCalls++ - rw.WriteHeader(200) - rw.Write([]byte(`[{"objectId": "abcdefg"}]`)) - })) - defer server.Close() - - restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) - client, _ := NewPlatformClient(server.URL, server.URL, restClient, restClient, - WithRetrySettings(testRetrySettings), - WithClientRequestLimiter(concurrency.NewLimiter(5)), - WithExternalIDGenerator(idutils.GenerateExternalID)) - - _, err := client.UpsertSettings(context.TODO(), SettingsObject{ - Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, - SchemaId: "some:schema", - Content: []byte("{}"), - }) - - assert.NoError(t, err) - assert.Equal(t, numAPIGetCalls, 1) - assert.Equal(t, numAPIPostCalls, 1) - - _, err = client.UpsertSettings(context.TODO(), SettingsObject{ - Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, - SchemaId: "some:schema", - Content: []byte("{}"), - }) - - assert.NoError(t, err) - assert.Equal(t, numAPIGetCalls, 1) // still one - assert.Equal(t, numAPIPostCalls, 2) -} - -func TestUpsertSettingsFromCache_CacheInvalidated(t *testing.T) { - numGetAPICalls := 0 - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if req.Method == http.MethodGet { - numGetAPICalls++ - rw.WriteHeader(200) - _, _ = rw.Write([]byte("{}")) - return - } - - rw.WriteHeader(409) - rw.Write([]byte(`{}`)) - })) - defer server.Close() - - client := DynatraceClient{ - environmentURL: server.URL, - platformClient: rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()), - retrySettings: testRetrySettings, - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - settingsCache: &cache.DefaultCache[[]DownloadSettingsObject]{}, - classicConfigsCache: &cache.DefaultCache[[]Value]{}, - schemaConstraintsCache: &cache.DefaultCache[SchemaConstraints]{}, - } - - client.UpsertSettings(context.TODO(), SettingsObject{ - Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, - SchemaId: "some:schema", - Content: []byte("{}"), - }) - assert.Equal(t, 1, numGetAPICalls) - - client.UpsertSettings(context.TODO(), SettingsObject{ - Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, - SchemaId: "some:schema", - Content: []byte("{}"), - }) - assert.Equal(t, 2, numGetAPICalls) - - client.UpsertSettings(context.TODO(), SettingsObject{ - Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, - SchemaId: "some:schema", - Content: []byte("{}"), - }) - assert.Equal(t, 3, numGetAPICalls) - -} - -func TestListEntities(t *testing.T) { - - testType := "SOMETHING" - - tests := []struct { - name string - givenEntitiesType EntitiesType - givenServerResponses []testServerResponse - want []string - wantQueryParamsPerAPICall [][]testQueryParams - wantNumberOfAPICalls int - wantError bool - }{ - { - name: "Lists Entities objects as expected", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ] }`, testType, testType)}, - }, - want: []string{ - fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: false, - }, - { - name: "Handles Pagination when listing entities objects", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, - }, - want: []string{ - fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), - fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), - }, - - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 2, - wantError: false, - }, - { - name: "Returns empty if list if no entities exist", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, `{ "entities": [ ] }`}, - }, - want: []string{}, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: false, - }, - { - name: "Returns error if HTTP error is encountered", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {400, `epic fail`}, - }, - want: nil, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: true, - }, - { - name: "Retries on HTTP error on paginated request and returns eventual success", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, - {400, `get next page fail`}, - {400, `retry fail`}, - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, - }, - want: []string{ - fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), - fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 4, - wantError: false, - }, - { - name: "Returns error if HTTP error is encountered getting further paginated responses", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, - {400, `get next page fail`}, - {400, `retry fail 1`}, - {400, `retry fail 2`}, - {400, `retry fail 3`}, - }, - want: nil, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 5, - wantError: true, - }, - { - name: "Retries on empty paginated response", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, - {200, fmt.Sprintf(`{ "entities": [] }`)}, - {200, fmt.Sprintf(`{ "entities": [] }`)}, - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, - }, - want: []string{ - fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), - fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 4, - wantError: false, - }, - { - name: "Retries on wrong field for entity type", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, - {400, fmt.Sprintf(`{{ - "error":{ - "code":400, - "message":"Constraints violated.", - "constraintViolations":[{ - "path":"fields", - "message":"'ipAddress' is not a valid property for type '%s'", - "parameterLocation":"QUERY", - "location":null - }] - } - } - }`, testType)}, - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, - }, - want: []string{ - fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), - fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 3, - wantError: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - apiCalls := 0 - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if len(tt.wantQueryParamsPerAPICall) > 0 { - params := tt.wantQueryParamsPerAPICall[apiCalls] - for _, param := range params { - addedQueryParameter := req.URL.Query()[param.key] - assert.NotNil(t, addedQueryParameter) - assert.Greater(t, len(addedQueryParameter), 0) - assert.Equal(t, addedQueryParameter[0], param.value) - } - } else { - assert.Equal(t, "", req.URL.RawQuery, "expected no query params - but '%s' was sent", req.URL.RawQuery) - } - - resp := tt.givenServerResponses[apiCalls] - if resp.statusCode != 200 { - http.Error(rw, resp.body, resp.statusCode) - } else { - _, _ = rw.Write([]byte(resp.body)) - } - - apiCalls++ - assert.LessOrEqualf(t, apiCalls, tt.wantNumberOfAPICalls, "expected at most %d API calls to happen, but encountered call %d", tt.wantNumberOfAPICalls, apiCalls) - })) - defer server.Close() - - client := DynatraceClient{ - environmentURL: server.URL, - platformClient: rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()), - retrySettings: testRetrySettings, - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - } - - res, err1 := client.ListEntities(context.TODO(), tt.givenEntitiesType) - - if tt.wantError { - assert.Error(t, err1) - } else { - assert.NoError(t, err1) - } - - assert.Equal(t, tt.want, res) - - assert.Equal(t, apiCalls, tt.wantNumberOfAPICalls, "expected exactly %d API calls to happen but %d calls where made", tt.wantNumberOfAPICalls, apiCalls) - }) - } -} - func TestCreateDynatraceClientWithAutoServerVersion(t *testing.T) { t.Run("Server version is correctly set to determined value", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { diff --git a/pkg/client/dtclient/config_client_test.go b/pkg/client/dtclient/config_client_test.go index 52ddd8126..4efdc9a35 100644 --- a/pkg/client/dtclient/config_client_test.go +++ b/pkg/client/dtclient/config_client_test.go @@ -21,10 +21,12 @@ package dtclient import ( "context" "fmt" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/concurrency" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/idutils" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/trafficlogs" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/api" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/rest" - "gotest.tools/assert" + "github.com/stretchr/testify/assert" "net/http" "net/http/httptest" "testing" @@ -47,8 +49,8 @@ func TestTranslateGenericValuesOnStandardResponse(t *testing.T) { values, err := translateGenericValues(context.TODO(), response, "extensions") - assert.NilError(t, err) - assert.Check(t, len(values) == 1) + assert.NoError(t, err) + assert.Len(t, values, 1) assert.Equal(t, values[0].Id, "foo") assert.Equal(t, values[0].Name, "bar") @@ -77,8 +79,8 @@ func TestTranslateGenericValuesOnNameMissing(t *testing.T) { values, err := translateGenericValues(context.TODO(), response, "extensions") - assert.NilError(t, err) - assert.Check(t, len(values) == 1) + assert.NoError(t, err) + assert.Len(t, values, 1) assert.Equal(t, values[0].Id, "foo") assert.Equal(t, values[0].Name, "foo") @@ -95,8 +97,8 @@ func TestTranslateGenericValuesForReportsEndpoint(t *testing.T) { values, err := translateGenericValues(context.TODO(), response, "reports") - assert.NilError(t, err) - assert.Check(t, len(values) == 1) + assert.NoError(t, err) + assert.Len(t, values, 1) assert.Equal(t, values[0].Id, "foo") assert.Equal(t, values[0].Name, "dashboardId") @@ -551,8 +553,8 @@ func Test_GetObjectIdIfAlreadyExists_WorksCorrectlyForAddedQueryParameters(t *te params := tt.expectedQueryParamsPerApiCall[apiCalls] for _, param := range params { addedQueryParameter := req.URL.Query()[param.key] - assert.Assert(t, addedQueryParameter != nil) - assert.Assert(t, len(addedQueryParameter) > 0) + assert.NotNil(t, addedQueryParameter) + assert.Greater(t, len(addedQueryParameter), 0) assert.Equal(t, addedQueryParameter[0], param.value) } } else { @@ -567,7 +569,7 @@ func Test_GetObjectIdIfAlreadyExists_WorksCorrectlyForAddedQueryParameters(t *te } apiCalls++ - assert.Check(t, apiCalls <= tt.expectedApiCalls, "expected at most %d API calls to happen, but encountered call %d", tt.expectedApiCalls, apiCalls) + assert.LessOrEqual(t, apiCalls, tt.expectedApiCalls, "expected at most %d API calls to happen, but encountered call %d", tt.expectedApiCalls, apiCalls) })) defer server.Close() testApi := api.API{ID: tt.apiKey} @@ -581,9 +583,9 @@ func Test_GetObjectIdIfAlreadyExists_WorksCorrectlyForAddedQueryParameters(t *te _, err := dtclient.getObjectIdIfAlreadyExists(context.TODO(), testApi, server.URL, "") if tt.expectError { - assert.Assert(t, err != nil) + assert.NotNil(t, err) } else { - assert.NilError(t, err) + assert.NoError(t, err) } assert.Equal(t, apiCalls, tt.expectedApiCalls, "expected exactly %d API calls to happen but %d calls where made", tt.expectedApiCalls, apiCalls) @@ -651,8 +653,8 @@ func Test_createDynatraceObject(t *testing.T) { for _, param := range tt.expectedQueryParams { addedQueryParameter := req.URL.Query()[param.key] - assert.Assert(t, addedQueryParameter != nil) - assert.Assert(t, len(addedQueryParameter) > 0) + assert.NotNil(t, addedQueryParameter) + assert.Greater(t, len(addedQueryParameter), 0) assert.Equal(t, addedQueryParameter[0], param.value) } } else { @@ -675,7 +677,7 @@ func Test_createDynatraceObject(t *testing.T) { t.Errorf("createDynatraceObject() error = %v, wantErr %v", err, tt.wantErr) return } - assert.DeepEqual(t, got, tt.want) + assert.Equal(t, got, tt.want) }) } } @@ -725,8 +727,59 @@ func TestDeployConfigsTargetingClassicConfigNonUnique(t *testing.T) { dtclient, _ := NewDynatraceClientForTesting(server.URL, server.Client(), WithRetrySettings(testRetrySettings)) got, err := dtclient.upsertDynatraceEntityByNonUniqueNameAndId(context.TODO(), generatedUuid, theConfigName, testApi, []byte("{}")) - assert.NilError(t, err) + assert.NoError(t, err) assert.Equal(t, got.Id, tt.expectedIdToBeUpserted) }) } } + +func TestReadByIdReturnsAnErrorUponEncounteringAnError(t *testing.T) { + testServer := httptest.NewTLSServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + http.Error(res, "", http.StatusForbidden) + })) + defer func() { testServer.Close() }() + client := DynatraceClient{ + environmentURLClassic: testServer.URL, + classicClient: rest.NewRestClient(testServer.Client(), trafficlogs.NewFileBased(), rest.CreateRateLimitStrategy()), + limiter: concurrency.NewLimiter(5), + generateExternalID: idutils.GenerateExternalID, + } + + _, err := client.ReadConfigById(mockAPI, "test") + assert.ErrorContains(t, err, "Response was") +} + +func TestReadByIdEscapesTheId(t *testing.T) { + unescapedID := "ruxit.perfmon.dotnetV4:%TimeInGC:time_in_gc_alert_high_generic" + + testServer := httptest.NewTLSServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {})) + defer func() { testServer.Close() }() + client := DynatraceClient{ + environmentURLClassic: testServer.URL, + classicClient: rest.NewRestClient(testServer.Client(), nil, rest.CreateRateLimitStrategy()), + limiter: concurrency.NewLimiter(5), + generateExternalID: idutils.GenerateExternalID, + } + _, err := client.ReadConfigById(mockAPINotSingle, unescapedID) + assert.NoError(t, err) +} + +func TestReadByIdReturnsTheResponseGivenNoError(t *testing.T) { + body := []byte{1, 3, 3, 7} + + testServer := httptest.NewTLSServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + _, _ = res.Write(body) + })) + defer func() { testServer.Close() }() + + client := DynatraceClient{ + environmentURLClassic: testServer.URL, + classicClient: rest.NewRestClient(testServer.Client(), nil, rest.CreateRateLimitStrategy()), + limiter: concurrency.NewLimiter(5), + generateExternalID: idutils.GenerateExternalID, + } + + resp, err := client.ReadConfigById(mockAPI, "test") + assert.NoError(t, err, "there should not be an error") + assert.Equal(t, body, resp) +} diff --git a/pkg/client/dtclient/entities_client_test.go b/pkg/client/dtclient/entities_client_test.go index 4c01d2a52..348aa3165 100644 --- a/pkg/client/dtclient/entities_client_test.go +++ b/pkg/client/dtclient/entities_client_test.go @@ -20,7 +20,14 @@ package dtclient import ( "encoding/json" - "gotest.tools/assert" + "fmt" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/concurrency" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/idutils" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/rest" + "github.com/stretchr/testify/assert" + "golang.org/x/net/context" + "net/http" + "net/http/httptest" "testing" ) @@ -77,3 +84,283 @@ func Test_fields(t *testing.T) { }) } } + +func TestListEntities(t *testing.T) { + + testType := "SOMETHING" + + tests := []struct { + name string + givenEntitiesType EntitiesType + givenServerResponses []testServerResponse + want []string + wantQueryParamsPerAPICall [][]testQueryParams + wantNumberOfAPICalls int + wantError bool + }{ + { + name: "Lists Entities objects as expected", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ] }`, testType, testType)}, + }, + want: []string{ + fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: false, + }, + { + name: "Handles Pagination when listing entities objects", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, + }, + want: []string{ + fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), + fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), + }, + + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 2, + wantError: false, + }, + { + name: "Returns empty if list if no entities exist", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, `{ "entities": [ ] }`}, + }, + want: []string{}, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: false, + }, + { + name: "Returns error if HTTP error is encountered", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {400, `epic fail`}, + }, + want: nil, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: true, + }, + { + name: "Retries on HTTP error on paginated request and returns eventual success", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, + {400, `get next page fail`}, + {400, `retry fail`}, + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, + }, + want: []string{ + fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), + fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 4, + wantError: false, + }, + { + name: "Returns error if HTTP error is encountered getting further paginated responses", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, + {400, `get next page fail`}, + {400, `retry fail 1`}, + {400, `retry fail 2`}, + {400, `retry fail 3`}, + }, + want: nil, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 5, + wantError: true, + }, + { + name: "Retries on empty paginated response", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, + {200, fmt.Sprintf(`{ "entities": [] }`)}, + {200, fmt.Sprintf(`{ "entities": [] }`)}, + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, + }, + want: []string{ + fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), + fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 4, + wantError: false, + }, + { + name: "Retries on wrong field for entity type", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, + {400, fmt.Sprintf(`{{ + "error":{ + "code":400, + "message":"Constraints violated.", + "constraintViolations":[{ + "path":"fields", + "message":"'ipAddress' is not a valid property for type '%s'", + "parameterLocation":"QUERY", + "location":null + }] + } + } + }`, testType)}, + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, + }, + want: []string{ + fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), + fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 3, + wantError: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiCalls := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if len(tt.wantQueryParamsPerAPICall) > 0 { + params := tt.wantQueryParamsPerAPICall[apiCalls] + for _, param := range params { + addedQueryParameter := req.URL.Query()[param.key] + assert.NotNil(t, addedQueryParameter) + assert.Greater(t, len(addedQueryParameter), 0) + assert.Equal(t, addedQueryParameter[0], param.value) + } + } else { + assert.Equal(t, "", req.URL.RawQuery, "expected no query params - but '%s' was sent", req.URL.RawQuery) + } + + resp := tt.givenServerResponses[apiCalls] + if resp.statusCode != 200 { + http.Error(rw, resp.body, resp.statusCode) + } else { + _, _ = rw.Write([]byte(resp.body)) + } + + apiCalls++ + assert.LessOrEqualf(t, apiCalls, tt.wantNumberOfAPICalls, "expected at most %d API calls to happen, but encountered call %d", tt.wantNumberOfAPICalls, apiCalls) + })) + defer server.Close() + + client := DynatraceClient{ + environmentURL: server.URL, + platformClient: rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()), + retrySettings: testRetrySettings, + limiter: concurrency.NewLimiter(5), + generateExternalID: idutils.GenerateExternalID, + } + + res, err1 := client.ListEntities(context.TODO(), tt.givenEntitiesType) + + if tt.wantError { + assert.Error(t, err1) + } else { + assert.NoError(t, err1) + } + + assert.Equal(t, tt.want, res) + + assert.Equal(t, apiCalls, tt.wantNumberOfAPICalls, "expected exactly %d API calls to happen but %d calls where made", tt.wantNumberOfAPICalls, apiCalls) + }) + } +} diff --git a/pkg/client/dtclient/client_settings.go b/pkg/client/dtclient/settings_client.go similarity index 70% rename from pkg/client/dtclient/client_settings.go rename to pkg/client/dtclient/settings_client.go index 6f2b98b83..3d715d67a 100644 --- a/pkg/client/dtclient/client_settings.go +++ b/pkg/client/dtclient/settings_client.go @@ -22,12 +22,16 @@ import ( "encoding/json" "errors" "fmt" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/filter" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/log" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/version" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config/coordinate" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/rest" + "github.com/google/go-cmp/cmp" + "golang.org/x/exp/maps" "net/http" "net/url" + "strings" ) type ( @@ -75,13 +79,15 @@ type ( ObjectId string `json:"objectId,omitempty"` } + schemaConstraint struct { + Type string `json:"type"` + UniqueProperties []string `json:"uniqueProperties"` + } + // schemaDetailsResponse is the response type returned by the fetchSchemasConstraints operation schemaDetailsResponse struct { - SchemaId string `json:"schemaId"` - SchemaConstraints []struct { - Type string `json:"type"` - UniqueProperties []string `json:"uniqueProperties"` - } `json:"schemaConstraints"` + SchemaId string `json:"schemaId"` + SchemaConstraints []schemaConstraint `json:"schemaConstraints"` } ) @@ -167,7 +173,6 @@ func (d *DynatraceClient) UpsertSettings(ctx context.Context, obj SettingsObject } func (d *DynatraceClient) upsertSettings(ctx context.Context, obj SettingsObject) (DynatraceEntity, error) { - // special handling for updating settings 2.0 objects on tenants with version pre 1.262.0 // Tenants with versions < 1.262 are not able to handle updates of existing // settings 2.0 objects that are non-deletable. @@ -187,6 +192,13 @@ func (d *DynatraceClient) upsertSettings(ctx context.Context, obj SettingsObject } } + if match, err := d.findObjectWithMatchingConstraints(ctx, obj); err != nil { + return DynatraceEntity{}, err + } else if match != nil { + log.WithCtxFields(ctx).Debug("Updating existing object %q with matching unique keys", match.ObjectId) + obj.OriginObjectId = match.ObjectId + } + // generate legacy external ID without project name. // and check if settings object with that external ID exists // This exists for avoiding breaking changes when we enhanced external id generation with full coordinates (incl. project name) @@ -247,6 +259,88 @@ func (d *DynatraceClient) upsertSettings(ctx context.Context, obj SettingsObject return entity, nil } +func (d *DynatraceClient) findObjectWithMatchingConstraints(ctx context.Context, source SettingsObject) (*DownloadSettingsObject, error) { + constraints, err := d.fetchSchemasConstraints(ctx, source.SchemaId) + if err != nil { + return nil, fmt.Errorf("unable to get details for schema %q: %w", source.SchemaId, err) + } + + if len(constraints.UniqueProperties) == 0 { + return nil, nil + } + + objects, err := d.listSettings(ctx, source.SchemaId, ListSettingsOptions{}) + if err != nil { + return nil, fmt.Errorf("unable to get existing settings objects for %q schema: %w", source.SchemaId, err) + } + + target, err := findObjectWithSameConstraints(constraints, source, objects) + if err != nil { + return nil, err + } + return target, nil +} + +func findObjectWithSameConstraints(schema SchemaConstraints, source SettingsObject, objects []DownloadSettingsObject) (*DownloadSettingsObject, error) { + candidates := make(map[int]struct{}) + + for _, uniqueKeys := range schema.UniqueProperties { + for j, o := range objects { + match, err := doObjectsMatchBasedOnUniqueKeys(uniqueKeys, source, o) + if err != nil { + return nil, err + } + if match { + candidates[j] = struct{}{} // candidate found, store index (same object might match for several constraints, set ensures we only count it once) + } + } + } + + if len(candidates) == 1 { // unique match found + index := maps.Keys(candidates)[0] + return &objects[index], nil + } + + if len(candidates) > 1 { + var objectIds []string + for i := range candidates { + objectIds = append(objectIds, objects[i].ObjectId) + } + + return nil, fmt.Errorf("can't update configuration %q - unable to find exact match, several existing objects with matching unique keys found: %v", source.Coordinate, strings.Join(objectIds, ", ")) + } + + return nil, nil // no matches found +} + +func doObjectsMatchBasedOnUniqueKeys(uniqueKeys []string, source SettingsObject, other DownloadSettingsObject) (bool, error) { + for _, key := range uniqueKeys { + same, err := isSameValueForKey(key, source.Content, other.Value) + if err != nil { + return false, err + } + if !same { + return false, nil + } + } + return true, nil +} + +func isSameValueForKey(key string, c1 []byte, c2 []byte) (bool, error) { + u := make(map[string]any) + if err := json.Unmarshal(c1, &u); err != nil { + return false, fmt.Errorf("failed to unmarshal data for key %q: %w", key, err) + } + v1 := u[key] + + if err := json.Unmarshal(c2, &u); err != nil { + return false, fmt.Errorf("failed to unmarshal data for key %q: %w", key, err) + } + v2 := u[key] + + return cmp.Equal(v1, v2), nil +} + // buildPostRequestPayload builds the json that is required as body in the settings api. // POST Request body: https://www.dynatrace.com/support/help/dynatrace-api/environment-api/settings/objects/post-object#request-body-json-model // @@ -308,3 +402,58 @@ func parsePostResponse(resp rest.Response) (DynatraceEntity, error) { Name: parsed[0].ObjectId, }, nil } + +func (d *DynatraceClient) ListSettings(ctx context.Context, schemaId string, opts ListSettingsOptions) (res []DownloadSettingsObject, err error) { + d.limiter.ExecuteBlocking(func() { + res, err = d.listSettings(ctx, schemaId, opts) + }) + return +} + +func (d *DynatraceClient) listSettings(ctx context.Context, schemaId string, opts ListSettingsOptions) ([]DownloadSettingsObject, error) { + + if settings, cached := d.settingsCache.Get(schemaId); cached { + log.Debug("Using cached settings for schema %s", schemaId) + return filter.FilterSlice(settings, opts.Filter), nil + } + + log.Debug("Downloading all settings for schema %s", schemaId) + + listSettingsFields := defaultListSettingsFields + if opts.DiscardValue { + listSettingsFields = reducedListSettingsFields + } + params := url.Values{ + "schemaIds": []string{schemaId}, + "pageSize": []string{defaultPageSize}, + "fields": []string{listSettingsFields}, + } + + result := make([]DownloadSettingsObject, 0) + + addToResult := func(body []byte) (int, error) { + var parsed struct { + Items []DownloadSettingsObject `json:"items"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return 0, fmt.Errorf("failed to unmarshal response: %w", err) + } + + result = append(result, parsed.Items...) + return len(parsed.Items), nil + } + + u, err := buildUrl(d.environmentURL, d.settingsObjectAPIPath, params) + if err != nil { + return nil, fmt.Errorf("failed to list settings: %w", err) + } + + _, err = rest.ListPaginated(ctx, d.platformClient, d.retrySettings, u, schemaId, addToResult) + if err != nil { + return nil, err + } + + d.settingsCache.Set(schemaId, result) + + return filter.FilterSlice(result, opts.Filter), nil +} diff --git a/pkg/client/dtclient/settings_client_test.go b/pkg/client/dtclient/settings_client_test.go index b823210df..1ec226c56 100644 --- a/pkg/client/dtclient/settings_client_test.go +++ b/pkg/client/dtclient/settings_client_test.go @@ -21,18 +21,401 @@ package dtclient import ( "context" "encoding/json" + "fmt" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/concurrency" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/idutils" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/version" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config/coordinate" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/rest" - "gotest.tools/assert" + "github.com/stretchr/testify/assert" "net/http" "net/http/httptest" "strings" "testing" ) +func Test_schemaDetails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case settingsSchemaAPIPathPlatform + "/builtin:span-attribute": + r := []byte(` +{ + "schemaId": "builtin:span-attribute", + "schemaConstraints": [ + { + "type": "some another type", + "customMessage": "Attribute keys must be unique.", + "something": "example" + }, + { + "type": "UNIQUE", + "customMessage": "Attribute keys must be unique.", + "uniqueProperties": [ + "key0", + "key1" + ] + }, + { + "type": "UNIQUE", + "customMessage": "Attribute keys must be unique.", + "uniqueProperties": [ + "key2", + "key3" + ] + } + ] +}`) + rw.WriteHeader(http.StatusOK) + rw.Write(r) + default: + rw.WriteHeader(http.StatusNotFound) + + } + })) + defer server.Close() + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + + d, _ := NewPlatformClient(server.URL, server.URL, restClient, restClient) + + t.Run("unmarshall data", func(t *testing.T) { + expected := SchemaConstraints{SchemaId: "builtin:span-attribute", UniqueProperties: [][]string{{"key0", "key1"}, {"key2", "key3"}}} + + actual, err := d.fetchSchemasConstraints(context.TODO(), "builtin:span-attribute") + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + }) +} + +func Test_FetchSchemaConstraintsUsesCache(t *testing.T) { + apiHits := 0 + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + apiHits++ + r := []byte(`{"schemaId": "builtin:span-attribute","schemaConstraints": []}`) + rw.WriteHeader(http.StatusOK) + rw.Write(r) + + })) + defer server.Close() + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + d, _ := NewPlatformClient(server.URL, server.URL, restClient, restClient) + + _, err := d.fetchSchemasConstraints(context.TODO(), "builtin:span-attribute") + assert.NoError(t, err) + assert.Equal(t, 1, apiHits) + _, err = d.fetchSchemasConstraints(context.TODO(), "builtin:alerting.profile") + assert.NoError(t, err) + assert.Equal(t, 2, apiHits) + _, err = d.fetchSchemasConstraints(context.TODO(), "builtin:span-attribute") + assert.NoError(t, err) + assert.Equal(t, 2, apiHits) +} + +func Test_findObjectWithSameConstraints(t *testing.T) { + type ( + given struct { + schema SchemaConstraints + source SettingsObject + objects []DownloadSettingsObject + } + ) + + t.Run("normal cases", func(t *testing.T) { + tests := []struct { + name string + given given + expected *DownloadSettingsObject + }{ + { + name: "single constraint with boolean values- match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":true}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":true}`)}, + {Value: []byte(`{"A":false}`)}, + }, + }, + expected: &DownloadSettingsObject{Value: []byte(`{"A":true}`)}, + }, + { + name: "single constraint with int values - no match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":2}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":3}`)}, + {Value: []byte(`{"A":"x2"}`)}, + }, + }, + expected: nil, + }, + { + name: "single constraint - match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x"}`)}, + {Value: []byte(`{"A":"x1"}`)}, + }, + }, + expected: &DownloadSettingsObject{Value: []byte(`{"A":"x"}`)}, + }, + { + name: "single constraint - no match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x1"}`)}, + {Value: []byte(`{"A":"x2"}`)}, + }, + }, + expected: nil, + }, + { + name: "single complex object constraint - match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A": {"key":"x", "val":"y"}}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A": {"key":"x", "val":"y"}}`)}, + {Value: []byte(`{"A": {"key":"x1", "val":"y"}}`)}, + }, + }, + expected: &DownloadSettingsObject{Value: []byte(`{"A": {"key":"x", "val":"y"}}`)}, + }, + { + name: "single complex object constraint - no match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A": {"key":"x", "val":"y"}}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A": {"key":"x1", "val":"y"}}`)}, + {Value: []byte(`{"A": {"key":"x", "val":"y1"}}`)}, + }, + }, + expected: nil, + }, + { + name: "single list value constraint - match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A": [1,2,3]}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A": [1,2,3]}`)}, + {Value: []byte(`{"A": [3,2,1]}`)}, + }, + }, + expected: &DownloadSettingsObject{Value: []byte(`{"A": [1,2,3]}`)}, + }, + { + name: "single list value constraint - no match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A": [1,2,3]}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A": []}`)}, + {Value: []byte(`{"A": [3,2,1]}`)}, + }, + }, + expected: nil, + }, + { + name: "signe composite constraint - match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A", "B"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x", "B":"y"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x", "B":"y"}`)}, + {Value: []byte(`{"A":"x", "B":"y1"}`)}, + }, + }, + expected: &DownloadSettingsObject{Value: []byte(`{"A":"x", "B":"y"}`)}, + }, + { + name: "signe composite constraint - no match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A", "B"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x", "B":"y"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x", "B":"y1"}`)}, + {Value: []byte(`{"A":"x", "B":"y2"}`)}, + }, + }, + expected: nil, + }, + { + name: "multiple simple constraints - one perfect match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + {"A", "B"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x", "B":"y"}`), + }, + objects: []DownloadSettingsObject{ + {ObjectId: "obj_1", Value: []byte(`{"A":"x", "B":"y"}`)}, + {ObjectId: "obj_2", Value: []byte(`{"A":"x2", "B":"y"}`)}, + }, + }, + expected: &DownloadSettingsObject{ObjectId: "obj_1", Value: []byte(`{"A":"x", "B":"y"}`)}, + }, + { + name: "multiple simple constraints - one semi match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + {"B"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x", "B":"y"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x", "B":"y1"}`)}, + {Value: []byte(`{"A":"x2", "B":"y2"}`)}, + }, + }, + expected: &DownloadSettingsObject{Value: []byte(`{"A":"x", "B":"y1"}`)}, + }, + { + name: "multiple simple constraints - no match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + {"B"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x", "B":"y"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x1", "B":"y1"}`)}, + {Value: []byte(`{"A":"x2", "B":"y2"}`)}, + }, + }, + expected: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + actual, err := findObjectWithSameConstraints(tc.given.schema, tc.given.source, tc.given.objects) + + fmt.Println(actual) + assert.NoError(t, err) + if tc.expected != nil { + assert.NotNil(t, actual) + assert.Equal(t, tc.expected, actual) + } else { + assert.Nil(t, actual) + } + }) + } + }) + + t.Run("error cases", func(t *testing.T) { + tests := []struct { + name string + given given + }{ + { + name: "multiple simple constraints - multiple match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + {"B"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x", "B":"y"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x", "B":"y1"}`)}, + {Value: []byte(`{"A":"x2", "B":"y"}`)}, + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := findObjectWithSameConstraints(tc.given.schema, tc.given.source, tc.given.objects) + assert.Error(t, err) + }) + } + + }) +} + func TestUpsertSettings(t *testing.T) { tests := []struct { name string @@ -151,7 +534,11 @@ func TestUpsertSettings(t *testing.T) { t.Run(test.name, func(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, r *http.Request) { - + if r.URL.Path == settingsSchemaAPIPathClassic+"/builtin:alerting.profile" { + writer.WriteHeader(http.StatusOK) + writer.Write([]byte("{}")) + return + } // GET settings requests if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v2/settings/objects") { // GET single settings obj request @@ -169,7 +556,7 @@ func TestUpsertSettings(t *testing.T) { // Build & assert object we expect Dynatrace to receive var expectedSettingsObject any err := json.Unmarshal([]byte(test.expectSettingsRequestValue), &expectedSettingsObject) - assert.NilError(t, err) + assert.NoError(t, err) extId, _ := idutils.GenerateExternalID(coordinate.Coordinate{ Project: "my-project", Type: "builtin:alerting.profile", @@ -186,15 +573,15 @@ func TestUpsertSettings(t *testing.T) { var obj []settingsRequest err = json.NewDecoder(r.Body).Decode(&obj) - assert.NilError(t, err) - assert.DeepEqual(t, obj, expectedRequestPayload) + assert.NoError(t, err) + assert.Equal(t, obj, expectedRequestPayload) // response to client if test.postSettingsResponseCode != 0 { http.Error(writer, test.postSettingsResponseContent, test.postSettingsResponseCode) } else { _, err := writer.Write([]byte(test.postSettingsResponseContent)) - assert.NilError(t, err) + assert.NoError(t, err) } })) @@ -214,7 +601,906 @@ func TestUpsertSettings(t *testing.T) { }) assert.Equal(t, err != nil, test.expectError) - assert.DeepEqual(t, resp, test.expectEntity) + assert.Equal(t, resp, test.expectEntity) + }) + } +} + +func TestUpsertSettingsRetries(t *testing.T) { + numAPICalls := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.Method == http.MethodGet { + rw.WriteHeader(200) + _, _ = rw.Write([]byte("{}")) + return + } + + numAPICalls++ + if numAPICalls < 3 { + rw.WriteHeader(409) + return + } + rw.WriteHeader(200) + _, _ = rw.Write([]byte(`[{"objectId": "abcdefg"}]`)) + })) + defer server.Close() + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + client, _ := NewClassicClient(server.URL, restClient, + WithRetrySettings(testRetrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + _, err := client.UpsertSettings(context.TODO(), SettingsObject{ + Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, + SchemaId: "some:schema", + Content: []byte("{}"), + }) + + assert.NoError(t, err) + assert.Equal(t, numAPICalls, 3) +} + +func TestUpsertSettingsFromCache(t *testing.T) { + numAPIGetCalls := 0 + numAPIPostCalls := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path == settingsSchemaAPIPathClassic+"/some:schema" { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte("{}")) + return + } + if req.Method == http.MethodGet { + numAPIGetCalls++ + rw.WriteHeader(200) + rw.Write([]byte("{}")) + return + } + + numAPIPostCalls++ + rw.WriteHeader(200) + rw.Write([]byte(`[{"objectId": "abcdefg"}]`)) + })) + defer server.Close() + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + client, _ := NewClassicClient(server.URL, restClient, + WithRetrySettings(testRetrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + _, err := client.UpsertSettings(context.TODO(), SettingsObject{ + Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, + SchemaId: "some:schema", + Content: []byte("{}"), + }) + + assert.NoError(t, err) + assert.Equal(t, 1, numAPIGetCalls) + assert.Equal(t, 1, numAPIPostCalls) + + _, err = client.UpsertSettings(context.TODO(), SettingsObject{ + Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, + SchemaId: "some:schema", + Content: []byte("{}"), + }) + + assert.NoError(t, err) + assert.Equal(t, 1, numAPIGetCalls) // still one + assert.Equal(t, 2, numAPIPostCalls) +} + +func TestUpsertSettingsFromCache_CacheInvalidated(t *testing.T) { + numGetAPICalls := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path == settingsSchemaAPIPathClassic+"/some:schema" { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte("{}")) + return + } + if req.Method == http.MethodGet { + numGetAPICalls++ + rw.WriteHeader(200) + _, _ = rw.Write([]byte("{}")) + return + } + + rw.WriteHeader(409) + rw.Write([]byte(`{}`)) + })) + defer server.Close() + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + client, _ := NewClassicClient(server.URL, restClient, + WithRetrySettings(testRetrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + client.UpsertSettings(context.TODO(), SettingsObject{ + Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, + SchemaId: "some:schema", + Content: []byte("{}"), + }) + assert.Equal(t, 1, numGetAPICalls) + + client.UpsertSettings(context.TODO(), SettingsObject{ + Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, + SchemaId: "some:schema", + Content: []byte("{}"), + }) + assert.Equal(t, 2, numGetAPICalls) + + client.UpsertSettings(context.TODO(), SettingsObject{ + Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, + SchemaId: "some:schema", + Content: []byte("{}"), + }) + assert.Equal(t, 3, numGetAPICalls) + +} + +func TestUpsertSettingsConsidersUniqueKeyConstraints(t *testing.T) { + + type given struct { + schemaDetailsResponse schemaDetailsResponse + listSettingsResponse []DownloadSettingsObject + settingsObject SettingsObject + } + type want struct { + error bool + postSettingsRequest settingsRequest + } + tests := []struct { + name string + given given + want want + }{ + { + "Creates new object if none exists", + given{ + schemaDetailsResponse: schemaDetailsResponse{ + SchemaId: "builtin:alerting.profile", + SchemaConstraints: []schemaConstraint{ + { + Type: "UNIQUE", + UniqueProperties: []string{"key_2"}, + }, + }, + }, + listSettingsResponse: []DownloadSettingsObject{}, + settingsObject: SettingsObject{ + Coordinate: coordinate.Coordinate{"p", "builtin:alerting.profile", "id"}, + SchemaId: "builtin:alerting.profile", + Content: []byte(`{ "key_1": "a", "key_2": 42 }`), + }, + }, + want{ + error: false, + postSettingsRequest: settingsRequest{ + SchemaId: "builtin:alerting.profile", + ExternalId: "monaco:cCRidWlsdGluOmFsZXJ0aW5nLnByb2ZpbGUkaWQ=", + Value: map[string]interface{}{ + "key_1": "a", + "key_2": float64(42), + }, + }, + }, + }, + { + "Creates new object if no matching unique key is found", + given{ + schemaDetailsResponse: schemaDetailsResponse{ + SchemaId: "builtin:alerting.profile", + SchemaConstraints: []schemaConstraint{ + { + Type: "UNIQUE", + UniqueProperties: []string{"key_1"}, + }, + }, + }, + listSettingsResponse: []DownloadSettingsObject{ + { + ExternalId: "externalID--1", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--1", + Value: []byte(`{ "key_1": "NOT A MATCH", "key_2": "dont-care" }`), + }, + { + ExternalId: "externalID--2", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--2", + Value: []byte(`{ "key_1": "NOT A MATCH EITHER", "key_2": "dont-care" }`), + }, + }, + settingsObject: SettingsObject{ + Coordinate: coordinate.Coordinate{"p", "builtin:alerting.profile", "id"}, + SchemaId: "builtin:alerting.profile", + Content: []byte(`{ "key_1": "MATCH", "key_2": "dont-care" }`), + }, + }, + want{ + error: false, + postSettingsRequest: settingsRequest{ + SchemaId: "builtin:alerting.profile", + ExternalId: "monaco:cCRidWlsdGluOmFsZXJ0aW5nLnByb2ZpbGUkaWQ=", + Value: map[string]interface{}{ + "key_1": "MATCH", + "key_2": "dont-care", + }, + }, + }, + }, + { + "Updates object if matching unique key is found", + given{ + schemaDetailsResponse: schemaDetailsResponse{ + SchemaId: "builtin:alerting.profile", + SchemaConstraints: []schemaConstraint{ + { + Type: "UNIQUE", + UniqueProperties: []string{"key_1"}, + }, + }, + }, + listSettingsResponse: []DownloadSettingsObject{ + { + ExternalId: "externalID--1", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--1", + Value: []byte(`{ "key_1": "NOT A MATCH", "key_2": "dont-care" }`), + }, + { + ExternalId: "externalID--2", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--2", + Value: []byte(`{ "key_1": "MATCH", "key_2": "dont-care" }`), + }, + }, + settingsObject: SettingsObject{ + Coordinate: coordinate.Coordinate{"p", "builtin:alerting.profile", "id"}, + SchemaId: "builtin:alerting.profile", + Content: []byte(`{ "key_1": "MATCH", "key_2": "dont-care" }`), + }, + }, + want{ + error: false, + postSettingsRequest: settingsRequest{ + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--2", // object ID of matching object + ExternalId: "monaco:cCRidWlsdGluOmFsZXJ0aW5nLnByb2ZpbGUkaWQ=", + Value: map[string]interface{}{ + "key_1": "MATCH", + "key_2": "dont-care", + }, + }, + }, + }, + { + "Updates object if matching unique key is found - complex key object", + given{ + schemaDetailsResponse: schemaDetailsResponse{ + SchemaId: "builtin:alerting.profile", + SchemaConstraints: []schemaConstraint{ + { + Type: "UNIQUE", + UniqueProperties: []string{"key_1"}, + }, + }, + }, + listSettingsResponse: []DownloadSettingsObject{ + { + ExternalId: "externalID--1", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--1", + Value: []byte(`{ "key_1": { "a": [false,true,false], "b": 21.0, "c": { "cK": "cV" } }, "key_2": "dont-care" }`), + }, + { + ExternalId: "externalID--2", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--2", + Value: []byte(`{ "key_1": { "a": [false,true,false], "b": 42.0, "c": { "cK": "cV" } }, "key_2": "dont-care" }`), + }, + }, + settingsObject: SettingsObject{ + Coordinate: coordinate.Coordinate{"p", "builtin:alerting.profile", "id"}, + SchemaId: "builtin:alerting.profile", + Content: []byte(`{ "key_1": { "a": [false,true,false], "b": 42.0, "c": { "cK": "cV" } }, "key_2": "new value" }`), + }, + }, + want{ + error: false, + postSettingsRequest: settingsRequest{ + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--2", // object ID of matching object + ExternalId: "monaco:cCRidWlsdGluOmFsZXJ0aW5nLnByb2ZpbGUkaWQ=", + Value: map[string]interface{}{ + "key_1": map[string]interface{}{ + "a": []interface{}{false, true, false}, + "b": 42.0, + "c": map[string]interface{}{ + "cK": "cV", + }, + }, + "key_2": "new value", + }, + }, + }, + }, + { + "Returns error if several matching objects are found", + given{ + schemaDetailsResponse: schemaDetailsResponse{ + SchemaId: "builtin:alerting.profile", + SchemaConstraints: []schemaConstraint{ + { + Type: "UNIQUE", + UniqueProperties: []string{"key_1"}, + }, + }, + }, + listSettingsResponse: []DownloadSettingsObject{ + { + ExternalId: "externalID--1", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--1", + Value: []byte(`{ "key_1": "MATCH", "key_2": "dont-care" }`), + }, + { + ExternalId: "externalID--2", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--2", + Value: []byte(`{ "key_1": "MATCH", "key_2": "dont-care" }`), + }, + }, + settingsObject: SettingsObject{ + Coordinate: coordinate.Coordinate{"p", "builtin:alerting.profile", "id"}, + SchemaId: "builtin:alerting.profile", + Content: []byte(`{ "key_1": "MATCH", "key_2": "dont-care" }`), + }, + }, + want{ + error: true, + postSettingsRequest: settingsRequest{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, r *http.Request) { + + // GET schema details + if r.URL.Path == settingsSchemaAPIPathClassic+"/builtin:alerting.profile" { + writer.WriteHeader(http.StatusOK) + b, err := json.Marshal(tt.given.schemaDetailsResponse) + assert.NoError(t, err) + _, _ = writer.Write(b) + return + } + + // GET settings objects + if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, settingsObjectAPIPathClassic) { + // response to client + writer.WriteHeader(http.StatusOK) + l := struct { + Items []DownloadSettingsObject `json:"items"` + }{ + tt.given.listSettingsResponse, + } + b, err := json.Marshal(l) + assert.NoError(t, err) + _, _ = writer.Write(b) + return + } + + // ASSERT expected object creation POST request + assert.Equal(t, http.MethodPost, r.Method) + var obj []settingsRequest + err := json.NewDecoder(r.Body).Decode(&obj) + assert.NoError(t, err) + assert.Len(t, obj, 1) + assert.Equal(t, tt.want.postSettingsRequest, obj[0]) + + writer.WriteHeader(200) + writer.Write([]byte(`[ { "objectId": "abcsd423==" } ]`)) + })) + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + c, _ := NewClassicClient(server.URL, restClient, + WithRetrySettings(testRetrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + _, err := c.UpsertSettings(context.TODO(), tt.given.settingsObject) + if tt.want.error { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestListKnownSettings(t *testing.T) { + + tests := []struct { + name string + givenSchemaID string + givenListSettingsOpts ListSettingsOptions + givenServerResponses []testServerResponse + want []DownloadSettingsObject + wantQueryParamsPerAPICall [][]testQueryParams + wantNumberOfAPICalls int + wantError bool + }{ + { + name: "Lists Settings objects as expected", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ] }`}, + }, + want: []DownloadSettingsObject{ + { + ExternalId: "RG9jdG9yIFdobwo=", + ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", + }, + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: false, + }, + { + name: "Lists Settings objects without value field as expected", + givenSchemaID: "builtin:something", + givenListSettingsOpts: ListSettingsOptions{DiscardValue: true}, + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ] }`}, + }, + want: []DownloadSettingsObject{ + { + ExternalId: "RG9jdG9yIFdobwo=", + ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", + }, + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", reducedListSettingsFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: false, + }, + { + name: "Lists Settings objects with filter as expected", + givenSchemaID: "builtin:something", + givenListSettingsOpts: ListSettingsOptions{Filter: func(o DownloadSettingsObject) bool { + return o.ExternalId == "RG9jdG9yIFdobwo=" + }}, + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ] }`}, + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4641", "externalId": "RG9jdG9yIabcdef="} ] }`}, + }, + want: []DownloadSettingsObject{ + { + ExternalId: "RG9jdG9yIFdobwo=", + ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", + }, + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: false, + }, + { + name: "Handles Pagination when listing settings objects", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ], "nextPageKey": "page42" }`}, + {200, `{ "items": [ {"objectId": "b1d4c623-25e0-4b54-9eb5-6734f1a72041", "externalId": "VGhlIE1hc3Rlcgo="} ] }`}, + }, + want: []DownloadSettingsObject{ + { + ExternalId: "RG9jdG9yIFdobwo=", + ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", + }, + { + ExternalId: "VGhlIE1hc3Rlcgo=", + ObjectId: "b1d4c623-25e0-4b54-9eb5-6734f1a72041", + }, + }, + + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 2, + wantError: false, + }, + { + name: "Returns empty if list if no items exist", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ ] }`}, + }, + want: []DownloadSettingsObject{}, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: false, + }, + { + name: "Returns error if HTTP error is encountered - 400", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {400, `epic fail`}, + }, + want: nil, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: true, + }, + { + name: "Returns error if HTTP error is encountered - 403", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {403, `epic fail`}, + }, + want: nil, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: true, + }, + { + name: "Retries on HTTP error on paginated request and returns eventual success", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ], "nextPageKey": "page42" }`}, + {400, `get next page fail`}, + {400, `retry fail`}, + {200, `{ "items": [ {"objectId": "b1d4c623-25e0-4b54-9eb5-6734f1a72041", "externalId": "VGhlIE1hc3Rlcgo="} ] }`}, + }, + want: []DownloadSettingsObject{ + { + ExternalId: "RG9jdG9yIFdobwo=", + ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", + }, + { + ExternalId: "VGhlIE1hc3Rlcgo=", + ObjectId: "b1d4c623-25e0-4b54-9eb5-6734f1a72041", + }, + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 4, + wantError: false, + }, + { + name: "Returns error if HTTP error is encountered getting further paginated responses", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ], "nextPageKey": "page42" }`}, + {400, `get next page fail`}, + {400, `retry fail 1`}, + {400, `retry fail 2`}, + {400, `retry fail 3`}, + }, + want: nil, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 5, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiCalls := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if len(tt.wantQueryParamsPerAPICall) > 0 { + params := tt.wantQueryParamsPerAPICall[apiCalls] + for _, param := range params { + addedQueryParameter := req.URL.Query()[param.key] + assert.NotNil(t, addedQueryParameter) + assert.NotEmpty(t, addedQueryParameter) + assert.Equal(t, addedQueryParameter[0], param.value) + } + } else { + assert.Equal(t, "", req.URL.RawQuery, "expected no query params - but '%s' was sent", req.URL.RawQuery) + } + + resp := tt.givenServerResponses[apiCalls] + if resp.statusCode != 200 { + http.Error(rw, resp.body, resp.statusCode) + } else { + _, _ = rw.Write([]byte(resp.body)) + } + + apiCalls++ + assert.LessOrEqualf(t, apiCalls, tt.wantNumberOfAPICalls, "expected at most %d API calls to happen, but encountered call %d", tt.wantNumberOfAPICalls, apiCalls) + })) + defer server.Close() + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + client, _ := NewClassicClient(server.URL, restClient, + WithRetrySettings(testRetrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + res, err1 := client.ListSettings(context.TODO(), tt.givenSchemaID, tt.givenListSettingsOpts) + + if tt.wantError { + assert.Error(t, err1) + } else { + assert.NoError(t, err1) + } + + assert.Equal(t, tt.want, res) + + assert.Equal(t, apiCalls, tt.wantNumberOfAPICalls, "expected exactly %d API calls to happen but %d calls where made", tt.wantNumberOfAPICalls, apiCalls) + }) + } +} + +func TestGetSettingById(t *testing.T) { + type fields struct { + environmentURL string + retrySettings rest.RetrySettings + } + type args struct { + objectID string + } + tests := []struct { + name string + fields fields + args args + givenTestServerResp *testServerResponse + wantURLPath string + wantResult *DownloadSettingsObject + wantErr bool + }{ + { + name: "Get Setting by ID - server response != 2xx", + fields: fields{}, + args: args{ + objectID: "12345", + }, + givenTestServerResp: &testServerResponse{ + statusCode: 500, + body: "{}", + }, + wantURLPath: "/api/v2/settings/objects/12345", + wantResult: nil, + wantErr: true, + }, + { + name: "Get Setting by ID - invalid server response", + fields: fields{}, + args: args{ + objectID: "12345", + }, + givenTestServerResp: &testServerResponse{ + statusCode: 200, + body: `{bs}`, + }, + wantURLPath: "/api/v2/settings/objects/12345", + wantResult: nil, + wantErr: true, + }, + { + name: "Get Setting by ID", + fields: fields{}, + args: args{ + objectID: "12345", + }, + givenTestServerResp: &testServerResponse{ + statusCode: 200, + body: `{"objectId":"12345","externalId":"54321", "schemaVersion":"1.0","schemaId":"builtin:bla","scope":"tenant"}`, + }, + wantURLPath: "/api/v2/settings/objects/12345", + wantResult: &DownloadSettingsObject{ + ExternalId: "54321", + SchemaVersion: "1.0", + SchemaId: "builtin:bla", + ObjectId: "12345", + Scope: "tenant", + Value: nil, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, tt.wantURLPath, req.URL.Path) + if resp := tt.givenTestServerResp; resp != nil { + if resp.statusCode != 200 { + http.Error(rw, resp.body, resp.statusCode) + } else { + _, _ = rw.Write([]byte(resp.body)) + } + } + + })) + defer server.Close() + + var envURL string + if tt.fields.environmentURL != "" { + envURL = tt.fields.environmentURL + } else { + envURL = server.URL + } + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + client, _ := NewClassicClient(envURL, restClient, + WithRetrySettings(tt.fields.retrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + settingsObj, err := client.GetSettingById(tt.args.objectID) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.wantResult, settingsObj) + + }) + } + +} + +func TestDeleteSettings(t *testing.T) { + type fields struct { + environmentURL string + retrySettings rest.RetrySettings + } + type args struct { + objectID string + } + tests := []struct { + name string + fields fields + args args + givenTestServerResp *testServerResponse + wantURLPath string + wantErr bool + }{ + { + name: "Delete Settings - server response != 2xx", + fields: fields{}, + args: args{ + objectID: "12345", + }, + givenTestServerResp: &testServerResponse{ + statusCode: 500, + body: "{}", + }, + wantURLPath: "/api/v2/settings/objects/12345", + wantErr: true, + }, + { + name: "Delete Settings - server response 404 does not result in an err", + fields: fields{}, + args: args{ + objectID: "12345", + }, + givenTestServerResp: &testServerResponse{ + statusCode: 404, + body: "{}", + }, + wantURLPath: "/api/v2/settings/objects/12345", + wantErr: false, + }, + { + name: "Delete Settings - object ID is passed", + fields: fields{}, + args: args{ + objectID: "12345", + }, + wantURLPath: "/api/v2/settings/objects/12345", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, tt.wantURLPath, req.URL.Path) + if resp := tt.givenTestServerResp; resp != nil { + if resp.statusCode != 200 { + http.Error(rw, resp.body, resp.statusCode) + } else { + _, _ = rw.Write([]byte(resp.body)) + } + } + + })) + defer server.Close() + + var envURL string + if tt.fields.environmentURL != "" { + envURL = tt.fields.environmentURL + } else { + envURL = server.URL + } + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + client, _ := NewClassicClient(envURL, restClient, + WithRetrySettings(tt.fields.retrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + if err := client.DeleteSettings(tt.args.objectID); (err != nil) != tt.wantErr { + t.Errorf("DeleteSettings() error = %v, wantErr %v", err, tt.wantErr) + } }) } }