diff --git a/examples/main.go b/examples/main.go index 2ac8e1e94..bf04724d6 100644 --- a/examples/main.go +++ b/examples/main.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - "github.com/optimizely/go-sdk" + optimizely "github.com/optimizely/go-sdk" "github.com/optimizely/go-sdk/pkg/client" "github.com/optimizely/go-sdk/pkg/config" "github.com/optimizely/go-sdk/pkg/event" diff --git a/pkg/client/client.go b/pkg/client/client.go index a131e1f01..84cc9fe19 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019-2021, Optimizely, Inc. and contributors * + * Copyright 2019-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -53,7 +53,7 @@ type OptimizelyClient struct { // CreateUserContext creates a context of the user for which decision APIs will be called. // A user context will be created successfully even when the SDK is not fully configured yet. func (o *OptimizelyClient) CreateUserContext(userID string, attributes map[string]interface{}) OptimizelyUserContext { - return newOptimizelyUserContext(o, userID, attributes, nil) + return newOptimizelyUserContext(o, userID, attributes, nil, nil) } func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string, options *decide.Options) OptimizelyDecision { @@ -90,8 +90,9 @@ func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string, decisionContext.Feature = &feature usrContext := entities.UserContext{ - ID: userContext.GetUserID(), - Attributes: userContext.GetUserAttributes(), + ID: userContext.GetUserID(), + Attributes: userContext.GetUserAttributes(), + QualifiedSegments: userContext.GetQualifiedSegments(), } var variationKey string var eventSent, flagEnabled bool diff --git a/pkg/client/optimizely_user_context.go b/pkg/client/optimizely_user_context.go index 89e2f5e33..57401d245 100644 --- a/pkg/client/optimizely_user_context.go +++ b/pkg/client/optimizely_user_context.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2020-2021, Optimizely, Inc. and contributors * + * Copyright 2020-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -31,21 +31,24 @@ type OptimizelyUserContext struct { UserID string `json:"userId"` Attributes map[string]interface{} `json:"attributes"` + qualifiedSegments []string optimizely *OptimizelyClient forcedDecisionService *pkgDecision.ForcedDecisionService mutex *sync.RWMutex } // returns an instance of the optimizely user context. -func newOptimizelyUserContext(optimizely *OptimizelyClient, userID string, attributes map[string]interface{}, forcedDecisionService *pkgDecision.ForcedDecisionService) OptimizelyUserContext { +func newOptimizelyUserContext(optimizely *OptimizelyClient, userID string, attributes map[string]interface{}, qualifiedSegments []string, forcedDecisionService *pkgDecision.ForcedDecisionService) OptimizelyUserContext { // store a copy of the provided attributes so it isn't affected by changes made afterwards. if attributes == nil { attributes = map[string]interface{}{} } attributesCopy := copyUserAttributes(attributes) + qualifiedSegmentsCopy := copyQualifiedSegments(qualifiedSegments) return OptimizelyUserContext{ UserID: userID, Attributes: attributesCopy, + qualifiedSegments: qualifiedSegmentsCopy, optimizely: optimizely, forcedDecisionService: forcedDecisionService, mutex: new(sync.RWMutex), @@ -69,6 +72,13 @@ func (o OptimizelyUserContext) GetUserAttributes() map[string]interface{} { return copyUserAttributes(o.Attributes) } +// GetQualifiedSegments returns qualified segments for Optimizely user context +func (o *OptimizelyUserContext) GetQualifiedSegments() []string { + o.mutex.RLock() + defer o.mutex.RUnlock() + return copyQualifiedSegments(o.qualifiedSegments) +} + func (o OptimizelyUserContext) getForcedDecisionService() *pkgDecision.ForcedDecisionService { if o.forcedDecisionService != nil { return o.forcedDecisionService.CreateCopy() @@ -86,25 +96,40 @@ func (o *OptimizelyUserContext) SetAttribute(key string, value interface{}) { o.Attributes[key] = value } +// SetQualifiedSegments clears and adds qualified segments for Optimizely user context +func (o *OptimizelyUserContext) SetQualifiedSegments(qualifiedSegments []string) { + o.mutex.Lock() + defer o.mutex.Unlock() + o.qualifiedSegments = copyQualifiedSegments(qualifiedSegments) +} + +// IsQualifiedFor returns true if the user is qualified for the given segment name +func (o *OptimizelyUserContext) IsQualifiedFor(segment string) bool { + userContext := entities.UserContext{ + QualifiedSegments: o.GetQualifiedSegments(), + } + return userContext.IsQualifiedFor(segment) +} + // Decide returns a decision result for a given flag key and a user context, which contains // all data required to deliver the flag or experiment. func (o *OptimizelyUserContext) Decide(key string, options []decide.OptimizelyDecideOptions) OptimizelyDecision { // use a copy of the user context so that any changes to the original context are not reflected inside the decision - userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService()) + userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.GetQualifiedSegments(), o.getForcedDecisionService()) return o.optimizely.decide(userContextCopy, key, convertDecideOptions(options)) } // DecideAll returns a key-map of decision results for all active flag keys with options. func (o *OptimizelyUserContext) DecideAll(options []decide.OptimizelyDecideOptions) map[string]OptimizelyDecision { // use a copy of the user context so that any changes to the original context are not reflected inside the decision - userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService()) + userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.GetQualifiedSegments(), o.getForcedDecisionService()) return o.optimizely.decideAll(userContextCopy, convertDecideOptions(options)) } // DecideForKeys returns a key-map of decision results for multiple flag keys and options. func (o *OptimizelyUserContext) DecideForKeys(keys []string, options []decide.OptimizelyDecideOptions) map[string]OptimizelyDecision { // use a copy of the user context so that any changes to the original context are not reflected inside the decision - userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService()) + userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.GetQualifiedSegments(), o.getForcedDecisionService()) return o.optimizely.decideForKeys(userContextCopy, keys, convertDecideOptions(options)) } @@ -160,3 +185,12 @@ func copyUserAttributes(attributes map[string]interface{}) (attributesCopy map[s } return attributesCopy } + +func copyQualifiedSegments(qualifiedSegments []string) (qualifiedSegmentsCopy []string) { + if qualifiedSegments == nil { + return nil + } + qualifiedSegmentsCopy = make([]string, len(qualifiedSegments)) + copy(qualifiedSegmentsCopy, qualifiedSegments) + return qualifiedSegmentsCopy +} diff --git a/pkg/client/optimizely_user_context_test.go b/pkg/client/optimizely_user_context_test.go index c5cc5fa92..4844f0df3 100644 --- a/pkg/client/optimizely_user_context_test.go +++ b/pkg/client/optimizely_user_context_test.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2020-2021, Optimizely, Inc. and contributors * + * Copyright 2020-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -55,65 +55,83 @@ func (s *OptimizelyUserContextTestSuite) SetupTest() { s.userID = "tester" } -func (s *OptimizelyUserContextTestSuite) TestOptimizelyUserContextWithAttributes() { +func (s *OptimizelyUserContextTestSuite) TestOptimizelyUserContextWithAttributesAndSegments() { attributes := map[string]interface{}{"key1": 1212, "key2": 1213} - optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, s.userID, attributes, nil) + segments := []string{"123"} + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, s.userID, attributes, segments, nil) s.Equal(s.OptimizelyClient, optimizelyUserContext.GetOptimizely()) s.Equal(s.userID, optimizelyUserContext.GetUserID()) s.Equal(attributes, optimizelyUserContext.GetUserAttributes()) + s.Equal(segments, optimizelyUserContext.GetQualifiedSegments()) s.Nil(optimizelyUserContext.forcedDecisionService) } -func (s *OptimizelyUserContextTestSuite) TestOptimizelyUserContextNoAttributes() { +func (s *OptimizelyUserContextTestSuite) TestOptimizelyUserContextNoAttributesAndNilSegments() { attributes := map[string]interface{}{} - optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, s.userID, attributes, nil) + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, s.userID, attributes, nil, nil) s.Equal(s.OptimizelyClient, optimizelyUserContext.GetOptimizely()) s.Equal(s.userID, optimizelyUserContext.GetUserID()) s.Equal(attributes, optimizelyUserContext.GetUserAttributes()) + s.Nil(optimizelyUserContext.GetQualifiedSegments()) } func (s *OptimizelyUserContextTestSuite) TestUpatingProvidedUserContextHasNoImpactOnOptimizelyUserContext() { attributes := map[string]interface{}{"k1": "v1", "k2": false} - optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, s.userID, attributes, nil) + segments := []string{"123"} + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, s.userID, attributes, segments, nil) s.Equal(s.OptimizelyClient, optimizelyUserContext.GetOptimizely()) s.Equal(s.userID, optimizelyUserContext.GetUserID()) s.Equal(attributes, optimizelyUserContext.GetUserAttributes()) + s.Equal(segments, optimizelyUserContext.GetQualifiedSegments()) attributes["k1"] = "v2" attributes["k2"] = true + segments[0] = "456" s.Equal("v1", optimizelyUserContext.GetUserAttributes()["k1"]) s.Equal(false, optimizelyUserContext.GetUserAttributes()["k2"]) + s.Equal([]string{"123"}, optimizelyUserContext.GetQualifiedSegments()) attributes = optimizelyUserContext.GetUserAttributes() + segments = optimizelyUserContext.GetQualifiedSegments() attributes["k1"] = "v2" attributes["k2"] = true + segments[0] = "456" s.Equal("v1", optimizelyUserContext.GetUserAttributes()["k1"]) s.Equal(false, optimizelyUserContext.GetUserAttributes()["k2"]) + s.Equal([]string{"123"}, optimizelyUserContext.GetQualifiedSegments()) } -func (s *OptimizelyUserContextTestSuite) TestSetAttribute() { +func (s *OptimizelyUserContextTestSuite) TestSetAndGetUserAttributesRaceCondition() { userID := "1212121" var attributes map[string]interface{} - optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes, nil) + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes, nil, nil) s.Equal(s.OptimizelyClient, optimizelyUserContext.GetOptimizely()) var wg sync.WaitGroup - wg.Add(4) + wg.Add(8) addInsideGoRoutine := func(key string, value interface{}, wg *sync.WaitGroup) { optimizelyUserContext.SetAttribute(key, value) wg.Done() } + getInsideGoRoutine := func(wg *sync.WaitGroup) { + optimizelyUserContext.GetUserAttributes() + wg.Done() + } go addInsideGoRoutine("k1", "v1", &wg) go addInsideGoRoutine("k2", true, &wg) go addInsideGoRoutine("k3", 100, &wg) go addInsideGoRoutine("k4", 3.5, &wg) + go getInsideGoRoutine(&wg) + go getInsideGoRoutine(&wg) + go getInsideGoRoutine(&wg) + go getInsideGoRoutine(&wg) wg.Wait() s.Equal(userID, optimizelyUserContext.GetUserID()) @@ -126,7 +144,7 @@ func (s *OptimizelyUserContextTestSuite) TestSetAttribute() { func (s *OptimizelyUserContextTestSuite) TestSetAttributeOverride() { userID := "1212121" attributes := map[string]interface{}{"k1": "v1", "k2": false} - optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes, nil) + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes, nil, nil) s.Equal(s.OptimizelyClient, optimizelyUserContext.GetOptimizely()) s.Equal(userID, optimizelyUserContext.GetUserID()) @@ -142,7 +160,7 @@ func (s *OptimizelyUserContextTestSuite) TestSetAttributeOverride() { func (s *OptimizelyUserContextTestSuite) TestSetAttributeNullValue() { userID := "1212121" attributes := map[string]interface{}{"k1": nil} - optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes, nil) + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes, nil, nil) s.Equal(s.OptimizelyClient, optimizelyUserContext.GetOptimizely()) s.Equal(userID, optimizelyUserContext.GetUserID()) @@ -155,6 +173,87 @@ func (s *OptimizelyUserContextTestSuite) TestSetAttributeNullValue() { s.Equal(nil, optimizelyUserContext.GetUserAttributes()["k1"]) } +func (s *OptimizelyUserContextTestSuite) TestSetAndGetQualifiedSegments() { + userID := "1212121" + var attributes map[string]interface{} + qualifiedSegments := []string{"1", "2", "3"} + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes, []string{}, nil) + s.Len(optimizelyUserContext.GetQualifiedSegments(), 0) + + optimizelyUserContext.SetQualifiedSegments(nil) + s.Nil(optimizelyUserContext.GetQualifiedSegments()) + + optimizelyUserContext.SetQualifiedSegments(qualifiedSegments) + s.Equal(qualifiedSegments, optimizelyUserContext.GetQualifiedSegments()) +} + +func (s *OptimizelyUserContextTestSuite) TestQualifiedSegmentsRaceCondition() { + userID := "1212121" + qualifiedSegments := []string{"1", "2", "3"} + segment := "1" + var attributes map[string]interface{} + + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes, nil, nil) + s.Nil(optimizelyUserContext.GetQualifiedSegments()) + var wg sync.WaitGroup + wg.Add(9) + + setQualifiedSegments := func(value []string, wg *sync.WaitGroup) { + optimizelyUserContext.SetQualifiedSegments(value) + wg.Done() + } + getQualifiedSegments := func(wg *sync.WaitGroup) { + optimizelyUserContext.GetQualifiedSegments() + wg.Done() + } + + IsQualifiedFor := func(segment string, wg *sync.WaitGroup) { + optimizelyUserContext.IsQualifiedFor(segment) + wg.Done() + } + + go setQualifiedSegments(qualifiedSegments, &wg) + go setQualifiedSegments(qualifiedSegments, &wg) + go setQualifiedSegments(qualifiedSegments, &wg) + go getQualifiedSegments(&wg) + go getQualifiedSegments(&wg) + go getQualifiedSegments(&wg) + go IsQualifiedFor(segment, &wg) + go IsQualifiedFor(segment, &wg) + go IsQualifiedFor(segment, &wg) + + wg.Wait() + + s.Equal(qualifiedSegments, optimizelyUserContext.GetQualifiedSegments()) + s.Equal(true, optimizelyUserContext.IsQualifiedFor(segment)) +} + +func (s *OptimizelyUserContextTestSuite) TestIsQualifiedFor() { + userID := "1212121" + qualifiedSegments := []string{"1", "2", "3"} + var attributes map[string]interface{} + + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes, nil, nil) + s.False(optimizelyUserContext.IsQualifiedFor("1")) + optimizelyUserContext.SetQualifiedSegments(qualifiedSegments) + + var wg sync.WaitGroup + wg.Add(6) + testInsideGoRoutine := func(value string, result bool, wg *sync.WaitGroup) { + s.Equal(result, optimizelyUserContext.IsQualifiedFor(value)) + wg.Done() + } + + go testInsideGoRoutine("1", true, &wg) + go testInsideGoRoutine("2", true, &wg) + go testInsideGoRoutine("3", true, &wg) + go testInsideGoRoutine("4", false, &wg) + go testInsideGoRoutine("5", false, &wg) + go testInsideGoRoutine("6", false, &wg) + + wg.Wait() +} + func (s *OptimizelyUserContextTestSuite) TestDecideResponseContainsUserContextCopy() { flagKey := "feature_2" userContext := s.OptimizelyClient.CreateUserContext(s.userID, nil) @@ -163,8 +262,11 @@ func (s *OptimizelyUserContextTestSuite) TestDecideResponseContainsUserContextCo // Change attributes for user context userContext.SetAttribute("test", 123) - // Attributes should not update for the userContext returned inside decision + // Change qualifiedSegments for user context + userContext.SetQualifiedSegments([]string{"123"}) + // Attributes and qualifiedSegments should not update for the userContext returned inside decision s.Nil(decisionUserContext.Attributes["test"]) + s.Len(decisionUserContext.qualifiedSegments, 0) } func (s *OptimizelyUserContextTestSuite) TestDecideFeatureTest() { diff --git a/pkg/config/datafileprojectconfig/config.go b/pkg/config/datafileprojectconfig/config.go index 544b05f96..585a2bfad 100644 --- a/pkg/config/datafileprojectconfig/config.go +++ b/pkg/config/datafileprojectconfig/config.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019-2021, Optimizely, Inc. and contributors * + * Copyright 2019-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -33,6 +33,8 @@ var datafileVersions = map[string]struct{}{ // DatafileProjectConfig is a project config backed by a datafile type DatafileProjectConfig struct { datafile string + hostForODP string + publicKeyForODP string accountID string projectID string revision string @@ -45,6 +47,8 @@ type DatafileProjectConfig struct { featureMap map[string]entities.Feature groupMap map[string]entities.Group rollouts []entities.Rollout + integrations []entities.Integration + segments []string rolloutMap map[string]entities.Rollout anonymizeIP bool botFiltering bool @@ -60,6 +64,16 @@ func (c DatafileProjectConfig) GetDatafile() string { return c.datafile } +// GetHostForODP returns hostForODP +func (c DatafileProjectConfig) GetHostForODP() string { + return c.hostForODP +} + +// GetPublicKeyForODP returns publicKeyForODP +func (c DatafileProjectConfig) GetPublicKeyForODP() string { + return c.publicKeyForODP +} + // GetProjectID returns projectID func (c DatafileProjectConfig) GetProjectID() string { return c.projectID @@ -176,6 +190,16 @@ func (c DatafileProjectConfig) GetExperimentList() (experimentList []entities.Ex return experimentList } +// GetIntegrationList returns an array of all the integrations +func (c DatafileProjectConfig) GetIntegrationList() (integrationList []entities.Integration) { + return c.integrations +} + +// GetSegmentList returns an array of all the segments +func (c DatafileProjectConfig) GetSegmentList() (segmentList []string) { + return c.segments +} + // GetRolloutList returns an array of all the rollouts func (c DatafileProjectConfig) GetRolloutList() (rolloutList []entities.Rollout) { return c.rollouts @@ -247,19 +271,33 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP return nil, err } + var hostForODP, publicKeyForODP string + for _, integration := range datafile.Integrations { + if integration.Key == "odp" { + hostForODP = integration.Host + publicKeyForODP = integration.PublicKey + break + } + } + attributeMap, attributeKeyToIDMap := mappers.MapAttributes(datafile.Attributes) allExperiments := mappers.MergeExperiments(datafile.Experiments, datafile.Groups) groupMap, experimentGroupMap := mappers.MapGroups(datafile.Groups) experimentIDMap, experimentKeyMap := mappers.MapExperiments(allExperiments, experimentGroupMap) rollouts, rolloutMap := mappers.MapRollouts(datafile.Rollouts) + integrations := []entities.Integration{} + for _, integration := range datafile.Integrations { + integrations = append(integrations, entities.Integration{Key: integration.Key, Host: integration.Host, PublicKey: integration.PublicKey}) + } eventMap := mappers.MapEvents(datafile.Events) - mergedAudiences := append(datafile.TypedAudiences, datafile.Audiences...) featureMap := mappers.MapFeatures(datafile.FeatureFlags, rolloutMap, experimentIDMap) - audienceMap := mappers.MapAudiences(mergedAudiences) + audienceMap, audienceSegmentList := mappers.MapAudiences(append(datafile.TypedAudiences, datafile.Audiences...)) flagVariationsMap := mappers.MapFlagVariations(featureMap) config := &DatafileProjectConfig{ + hostForODP: hostForODP, + publicKeyForODP: publicKeyForODP, datafile: string(jsonDatafile), accountID: datafile.AccountID, anonymizeIP: datafile.AnonymizeIP, @@ -277,6 +315,8 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP projectID: datafile.ProjectID, revision: datafile.Revision, rollouts: rollouts, + integrations: integrations, + segments: audienceSegmentList, rolloutMap: rolloutMap, sendFlagDecisions: datafile.SendFlagDecisions, flagVariationsMap: flagVariationsMap, diff --git a/pkg/config/datafileprojectconfig/config_test.go b/pkg/config/datafileprojectconfig/config_test.go index 3880a0921..1bf5de27e 100644 --- a/pkg/config/datafileprojectconfig/config_test.go +++ b/pkg/config/datafileprojectconfig/config_test.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019,2021, Optimizely, Inc. and contributors * + * Copyright 2019,2021-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -41,8 +41,8 @@ func TestNewDatafileProjectConfigNil(t *testing.T) { } func TestNewDatafileProjectConfigNotNil(t *testing.T) { - dpc := DatafileProjectConfig{accountID: "123", revision: "1", projectID: "12345", sdkKey: "a", environmentKey: "production", eventMap: map[string]entities.Event{"event_single_targeted_exp": {Key: "event_single_targeted_exp"}}, attributeMap: map[string]entities.Attribute{"10401066170": {ID: "10401066170"}}} - jsonDatafileStr := `{"accountID":"123","revision":"1","projectId":"12345","version":"4","sdkKey":"a","environmentKey":"production","events":[{"key":"event_single_targeted_exp"}],"attributes":[{"id":"10401066170"}]}` + dpc := DatafileProjectConfig{accountID: "123", revision: "1", projectID: "12345", sdkKey: "a", environmentKey: "production", eventMap: map[string]entities.Event{"event_single_targeted_exp": {Key: "event_single_targeted_exp"}}, attributeMap: map[string]entities.Attribute{"10401066170": {ID: "10401066170"}}, integrations: []entities.Integration{{PublicKey: "123", Host: "www.123.com", Key: "odp"}}} + jsonDatafileStr := `{"accountID":"123","revision":"1","projectId":"12345","version":"4","sdkKey":"a","environmentKey":"production","events":[{"key":"event_single_targeted_exp"}],"attributes":[{"id":"10401066170"}],"integrations": [{"publicKey": "123", "host": "www.123.com", "key": "odp"}]}` jsonDatafile := []byte(jsonDatafileStr) projectConfig, err := NewDatafileProjectConfig(jsonDatafile, logging.GetLogger("", "DatafileProjectConfig")) assert.Nil(t, err) @@ -52,10 +52,49 @@ func TestNewDatafileProjectConfigNotNil(t *testing.T) { assert.Equal(t, dpc.projectID, projectConfig.projectID) assert.Equal(t, dpc.environmentKey, projectConfig.environmentKey) assert.Equal(t, dpc.sdkKey, projectConfig.sdkKey) + assert.Equal(t, dpc.integrations, projectConfig.integrations) +} + +func TestNewDatafileProjectConfigWithODP(t *testing.T) { + dpc := DatafileProjectConfig{integrations: []entities.Integration{{PublicKey: "123", Host: "www.123.com", Key: "odp"}}} + // odp without extra keys + jsonDatafileStr := `{"version":"4","integrations": [{"publicKey": "123", "host": "www.123.com", "key": "odp"}]}` + jsonDatafile := []byte(jsonDatafileStr) + projectConfig, err := NewDatafileProjectConfig(jsonDatafile, logging.GetLogger("", "DatafileProjectConfig")) + assert.Nil(t, err) + assert.NotNil(t, projectConfig) + assert.Equal(t, dpc.integrations, projectConfig.integrations) + + // odp with extra keys + dpc = DatafileProjectConfig{integrations: []entities.Integration{{PublicKey: "123", Host: "www.123.com", Key: "odp"}}} + jsonDatafileStr = `{"version":"4","integrations": [{"publicKey": "123", "host": "www.123.com", "key": "odp", "key1": "odp", "key2": "odp"}]}` + jsonDatafile = []byte(jsonDatafileStr) + projectConfig, err = NewDatafileProjectConfig(jsonDatafile, logging.GetLogger("", "DatafileProjectConfig")) + assert.Nil(t, err) + assert.NotNil(t, projectConfig) + assert.Equal(t, dpc.integrations, projectConfig.integrations) + + // odp with missing host and public key keys + dpc = DatafileProjectConfig{integrations: []entities.Integration{{Key: "odp"}}} + jsonDatafileStr = `{"version":"4","integrations": [{"key": "odp"}]}` + jsonDatafile = []byte(jsonDatafileStr) + projectConfig, err = NewDatafileProjectConfig(jsonDatafile, logging.GetLogger("", "DatafileProjectConfig")) + assert.Nil(t, err) + assert.NotNil(t, projectConfig) + assert.Equal(t, dpc.integrations, projectConfig.integrations) + + // odp with missing key + dpc = DatafileProjectConfig{integrations: []entities.Integration{{PublicKey: "123", Host: "www.123.com"}}} + jsonDatafileStr = `{"version":"4","integrations": [{"publicKey": "123", "host": "www.123.com"}]}` + jsonDatafile = []byte(jsonDatafileStr) + projectConfig, err = NewDatafileProjectConfig(jsonDatafile, logging.GetLogger("", "DatafileProjectConfig")) + assert.Nil(t, err) + assert.NotNil(t, projectConfig) + assert.Equal(t, dpc.integrations, projectConfig.integrations) } func TestGetDatafile(t *testing.T) { - jsonDatafileStr := `{"accountID": "123", "revision": "1", "projectId": "12345", "version": "4", "sdkKey": "a", "environmentKey": "production"}` + jsonDatafileStr := `{"accountID": "123", "revision": "1", "projectId": "12345", "version": "4", "sdkKey": "a", "environmentKey": "production","integrations": [{"publicKey": "123", "host": "www.123.com", "key": "odp"}]}` jsonDatafile := []byte(jsonDatafileStr) config := &DatafileProjectConfig{ datafile: string(jsonDatafile), @@ -64,6 +103,26 @@ func TestGetDatafile(t *testing.T) { assert.Equal(t, string(jsonDatafile), config.GetDatafile()) } +func TestGetHostAndPublicKeyForValidODP(t *testing.T) { + jsonDatafileStr := `{"version": "4","integrations": [{"publicKey": "1234", "host": "www.1234.com", "key": "non-odp"},{},{"randomKey":"123", "publicKey": "123", "host": "www.123.com", "key": "123"},{"publicKey": "123", "host": "www.123.com", "key": "odp"}]}` + jsonDatafile := []byte(jsonDatafileStr) + config, err := NewDatafileProjectConfig(jsonDatafile, logging.GetLogger("", "")) + assert.NoError(t, err) + assert.Equal(t, string(jsonDatafile), config.GetDatafile()) + assert.Equal(t, "www.123.com", config.GetHostForODP()) + assert.Equal(t, "123", config.GetPublicKeyForODP()) +} + +func TestGetHostAndPublicKeyForInvalidODP(t *testing.T) { + jsonDatafileStr := `{"version": "4","integrations": [{"publicKey": "1234", "host": "www.1234.com", "key": "non-odp"},{},{"randomKey":"123", "publicKey": "123", "host": "www.123.com", "key": "123"}]}` + jsonDatafile := []byte(jsonDatafileStr) + config, err := NewDatafileProjectConfig(jsonDatafile, logging.GetLogger("", "")) + assert.NoError(t, err) + assert.Equal(t, string(jsonDatafile), config.GetDatafile()) + assert.Equal(t, "", config.GetHostForODP()) + assert.Equal(t, "", config.GetPublicKeyForODP()) +} + func TestGetProjectID(t *testing.T) { projectID := "projectID" config := &DatafileProjectConfig{ @@ -346,6 +405,25 @@ func TestGetRolloutList(t *testing.T) { assert.Equal(t, config.rollouts, config.GetRolloutList()) } +func TestGetIntegrationListODP(t *testing.T) { + jsonDatafileStr := `{"version": "4","integrations": [{"publicKey": "1234", "host": "www.1234.com", "key": "non-odp"},{"publicKey": "123", "host": "www.123.com", "key": "odp"},{"randomKey":"123", "publicKey": "123", "host": "www.123.com", "key": "123"},{}]}` + jsonDatafile := []byte(jsonDatafileStr) + config, err := NewDatafileProjectConfig(jsonDatafile, logging.GetLogger("", "")) + assert.NoError(t, err) + assert.Equal(t, string(jsonDatafile), config.GetDatafile()) + expectedIntegrations := []entities.Integration{{Host: "www.1234.com", PublicKey: "1234", Key: "non-odp"}, {Host: "www.123.com", PublicKey: "123", Key: "odp"}, {Host: "www.123.com", PublicKey: "123", Key: "123"}, {}} + assert.Equal(t, expectedIntegrations, config.GetIntegrationList()) +} + +func TestGetSegmentList(t *testing.T) { + jsonDatafileStr := `{"version": "4","typedAudiences": [{"id": "22290411365", "conditions": ["and", ["or", ["or", {"value": "order_last_7_days", "type": "third_party_dimension", "name": "odp.audiences", "match": "qualified"}, {"value": "favoritecolorred", "type": "third_party_dimension", "name": "odp.audiences", "match": "qualified"}]]], "name": "sohail"}, {"id": "22282671333", "conditions": ["and", ["or", ["or", {"value": "has_email_or_phone_opted_in", "type": "third_party_dimension", "name": "odp.audiences", "match": "qualified"}]]], "name": "audience_age"}]}` + jsonDatafile := []byte(jsonDatafileStr) + expectedSegmentList := []string{"order_last_7_days", "favoritecolorred", "has_email_or_phone_opted_in"} + config, err := NewDatafileProjectConfig(jsonDatafile, logging.GetLogger("", "")) + assert.NoError(t, err) + assert.Equal(t, expectedSegmentList, config.GetSegmentList()) +} + func TestGetAudienceList(t *testing.T) { config := &DatafileProjectConfig{ audienceMap: map[string]entities.Audience{"5": {ID: "5", Name: "one"}, "6": {ID: "6", Name: "two"}}, diff --git a/pkg/config/datafileprojectconfig/entities/entities.go b/pkg/config/datafileprojectconfig/entities/entities.go index 1f144c9c0..b1a8cd135 100644 --- a/pkg/config/datafileprojectconfig/entities/entities.go +++ b/pkg/config/datafileprojectconfig/entities/entities.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019,2021, Optimizely, Inc. and contributors * + * Copyright 2019,2021-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -104,6 +104,13 @@ type Rollout struct { Experiments []Experiment `json:"experiments"` } +// Integration represents a integration from the Optimizely datafile +type Integration struct { + Key string `json:"key"` + Host string `json:"host"` + PublicKey string `json:"publicKey"` +} + // Datafile represents the datafile we get from Optimizely type Datafile struct { Attributes []Attribute `json:"attributes"` @@ -113,6 +120,7 @@ type Datafile struct { FeatureFlags []FeatureFlag `json:"featureFlags"` Events []Event `json:"events"` Rollouts []Rollout `json:"rollouts"` + Integrations []Integration `json:"integrations"` TypedAudiences []Audience `json:"typedAudiences"` Variables []string `json:"variables"` AccountID string `json:"accountId"` diff --git a/pkg/config/datafileprojectconfig/mappers/audience.go b/pkg/config/datafileprojectconfig/mappers/audience.go index 43bddb4e6..abfb26c5e 100644 --- a/pkg/config/datafileprojectconfig/mappers/audience.go +++ b/pkg/config/datafileprojectconfig/mappers/audience.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019,2021, Optimizely, Inc. and contributors * + * Copyright 2019,2021-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -23,9 +23,12 @@ import ( ) // MapAudiences maps the raw datafile audience entities to SDK Audience entities -func MapAudiences(audiences []datafileEntities.Audience) (audienceMap map[string]entities.Audience) { +func MapAudiences(audiences []datafileEntities.Audience) (audienceMap map[string]entities.Audience, audienceSegmentList []string) { audienceMap = make(map[string]entities.Audience) + // To keep unique segments only + odpSegmentsMap := map[string]bool{} + audienceSegmentList = []string{} // Since typed audiences were added prior to audiences, // they will be given priority in the audienceMap and list for _, audience := range audiences { @@ -36,13 +39,18 @@ func MapAudiences(audiences []datafileEntities.Audience) (audienceMap map[string Name: audience.Name, Conditions: audience.Conditions, } - conditionTree, err := buildConditionTree(audience.Conditions) + conditionTree, fSegments, err := buildConditionTree(audience.Conditions) if err == nil { audience.ConditionTree = conditionTree } - + for _, s := range fSegments { + if !odpSegmentsMap[s] { + odpSegmentsMap[s] = true + audienceSegmentList = append(audienceSegmentList, s) + } + } audienceMap[audience.ID] = audience } } - return audienceMap + return audienceMap, audienceSegmentList } diff --git a/pkg/config/datafileprojectconfig/mappers/audience_test.go b/pkg/config/datafileprojectconfig/mappers/audience_test.go index a6ba1d29b..51e2882da 100644 --- a/pkg/config/datafileprojectconfig/mappers/audience_test.go +++ b/pkg/config/datafileprojectconfig/mappers/audience_test.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019,2021 Optimizely, Inc. and contributors * + * Copyright 2019,2021-2022 Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -28,10 +28,11 @@ import ( func TestMapAudiencesEmptyList(t *testing.T) { - audienceMap := MapAudiences(nil) + audienceMap, audienceSegmentList := MapAudiences(nil) expectedAudienceMap := map[string]entities.Audience{} assert.Equal(t, expectedAudienceMap, audienceMap) + assert.Empty(t, audienceSegmentList) } func TestMapAudiences(t *testing.T) { @@ -39,7 +40,7 @@ func TestMapAudiences(t *testing.T) { expectedConditions := "[\"and\", [\"or\", [\"or\", {\"name\": \"s_foo\", \"type\": \"custom_attribute\", \"value\": \"foo\"}]]]" audienceList := []datafileEntities.Audience{{ID: "1", Name: "one", Conditions: expectedConditions}, {ID: "2", Name: "two"}, {ID: "3", Name: "three"}, {ID: "2", Name: "four"}, {ID: "1", Name: "one"}} - audienceMap := MapAudiences(audienceList) + audienceMap, audienceSegmentList := MapAudiences(audienceList) expectedConditionTree := &entities.TreeNode{ Operator: "and", @@ -68,4 +69,70 @@ func TestMapAudiences(t *testing.T) { "3": {ID: "3", Name: "three"}} assert.Equal(t, expectedAudienceMap, audienceMap) + assert.Empty(t, audienceSegmentList) +} + +func TestMapAudiencesWithDuplicateSegments(t *testing.T) { + + expectedConditions1 := "[\"and\", [\"or\", [\"or\", {\"name\": \"odp.audiences1\", \"type\": \"third_party_dimension\", \"value\": \"favoritecolorred\", \"match\": \"qualified\"}]]]" + expectedConditions2 := "[\"and\", [\"or\", [\"or\", {\"name\": \"odp.audiences2\", \"type\": \"third_party_dimension\", \"value\": \"favoritecolorred\", \"match\": \"qualified\"}]]]" + audienceList := []datafileEntities.Audience{{ID: "1", Name: "one", Conditions: expectedConditions1}, {ID: "2", Name: "two", Conditions: expectedConditions2}, + {ID: "3", Name: "three"}, {ID: "2", Name: "four"}, {ID: "1", Name: "one"}} + audienceMap, audienceSegmentList := MapAudiences(audienceList) + + expectedConditionTree1 := &entities.TreeNode{ + Operator: "and", + Nodes: []*entities.TreeNode{ + { + Operator: "or", + Nodes: []*entities.TreeNode{ + { + Operator: "or", + Nodes: []*entities.TreeNode{ + { + Item: entities.Condition{ + Name: "odp.audiences1", + Type: "third_party_dimension", + Value: "favoritecolorred", + Match: "qualified", + StringRepresentation: `{"match":"qualified","name":"odp.audiences1","type":"third_party_dimension","value":"favoritecolorred"}`, + }, + }, + }, + }, + }, + }, + }, + } + expectedConditionTree2 := &entities.TreeNode{ + Operator: "and", + Nodes: []*entities.TreeNode{ + { + Operator: "or", + Nodes: []*entities.TreeNode{ + { + Operator: "or", + Nodes: []*entities.TreeNode{ + { + Item: entities.Condition{ + Name: "odp.audiences2", + Type: "third_party_dimension", + Value: "favoritecolorred", + Match: "qualified", + StringRepresentation: `{"match":"qualified","name":"odp.audiences2","type":"third_party_dimension","value":"favoritecolorred"}`, + }, + }, + }, + }, + }, + }, + }, + } + + expectedAudienceMap := map[string]entities.Audience{"1": {ID: "1", Name: "one", ConditionTree: expectedConditionTree1, Conditions: expectedConditions1}, "2": {ID: "2", Name: "two", ConditionTree: expectedConditionTree2, Conditions: expectedConditions2}, + "3": {ID: "3", Name: "three"}} + expectedaudienceSegmentList := []string{"favoritecolorred"} + + assert.Equal(t, expectedAudienceMap, audienceMap) + assert.Equal(t, expectedaudienceSegmentList, audienceSegmentList) } diff --git a/pkg/config/datafileprojectconfig/mappers/condition_trees.go b/pkg/config/datafileprojectconfig/mappers/condition_trees.go index c922b13b4..1a7a724a2 100644 --- a/pkg/config/datafileprojectconfig/mappers/condition_trees.go +++ b/pkg/config/datafileprojectconfig/mappers/condition_trees.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019-2021, Optimizely, Inc. and contributors * + * Copyright 2019-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -22,6 +22,7 @@ import ( "reflect" jsoniter "github.com/json-iterator/go" + "github.com/optimizely/go-sdk/pkg/decision/evaluator/matchers" "github.com/optimizely/go-sdk/pkg/entities" ) @@ -39,13 +40,13 @@ const ( ) // Takes the conditions array from the audience in the datafile and turns it into a condition tree -func buildConditionTree(conditions interface{}) (conditionTree *entities.TreeNode, retErr error) { +func buildConditionTree(conditions interface{}) (conditionTree *entities.TreeNode, odpSegments []string, retErr error) { parsedConditions, retErr := parseConditions(conditions) if retErr != nil { return } - + odpSegments = []string{} value := reflect.ValueOf(parsedConditions) visited := make(map[interface{}]bool) @@ -82,6 +83,7 @@ func buildConditionTree(conditions interface{}) (conditionTree *entities.TreeNod retErr = err return } + extractSegment(&odpSegments, n) } root.Nodes = append(root.Nodes, n) @@ -100,6 +102,7 @@ func buildConditionTree(conditions interface{}) (conditionTree *entities.TreeNod retErr = err return } + extractSegment(&odpSegments, n) conditionTree.Operator = "or" conditionTree.Nodes = append(conditionTree.Nodes, n) } @@ -111,7 +114,7 @@ func buildConditionTree(conditions interface{}) (conditionTree *entities.TreeNod retErr = errEmptyTree conditionTree = nil } - return conditionTree, retErr + return conditionTree, odpSegments, retErr } // Parses conditions for audience in the datafile @@ -205,3 +208,15 @@ func isValidOperator(op string) bool { } return false } + +func extractSegment(odpSegments *[]string, node *entities.TreeNode) { + condition, ok := node.Item.(entities.Condition) + if !ok { + return + } + if condition.Match == matchers.QualifiedMatchType { + if segment, ok := condition.Value.(string); ok && segment != "" { + *odpSegments = append(*odpSegments, segment) + } + } +} diff --git a/pkg/config/datafileprojectconfig/mappers/condition_trees_test.go b/pkg/config/datafileprojectconfig/mappers/condition_trees_test.go index b639eb2b2..9bd18ca48 100644 --- a/pkg/config/datafileprojectconfig/mappers/condition_trees_test.go +++ b/pkg/config/datafileprojectconfig/mappers/condition_trees_test.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019-2020, Optimizely, Inc. and contributors * + * Copyright 2019-2020,2022 Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -95,10 +95,11 @@ func TestBuildConditionTreeUsingDatafileAudienceConditions(t *testing.T) { Conditions: "[\"and\", [\"or\", [\"or\", {\"name\": \"s_foo\", \"type\": \"custom_attribute\", \"value\": \"foo\"}]]]", } - conditionTree, err := buildConditionTree(audience.Conditions) + conditionTree, segments, err := buildConditionTree(audience.Conditions) if err != nil { assert.Fail(t, err.Error()) } + assert.Empty(t, segments) expectedConditionTree := &entities.TreeNode{ Operator: "and", @@ -130,10 +131,11 @@ func TestBuildConditionTreeSimpleAudienceCondition(t *testing.T) { conditionString := "[ \"and\", [ \"or\", [ \"or\", { \"type\": \"custom_attribute\", \"name\": \"s_foo\", \"match\": \"exact\", \"value\": \"foo\" } ] ] ]" var conditions interface{} json.Unmarshal([]byte(conditionString), &conditions) - conditionTree, err := buildConditionTree(conditions) + conditionTree, segments, err := buildConditionTree(conditions) if err != nil { assert.Fail(t, err.Error()) } + assert.Empty(t, segments) expectedConditionTree := &entities.TreeNode{ Operator: "and", @@ -162,12 +164,61 @@ func TestBuildConditionTreeSimpleAudienceCondition(t *testing.T) { assert.Equal(t, expectedConditionTree, conditionTree) } +func TestBuildConditionTreeSimpleAudienceConditionWithMultipleSegments(t *testing.T) { + conditionString := "[ \"and\", [ \"or\", [ \"or\", { \"type\": \"third_party_dimension\", \"name\": \"s_foo\", \"match\": \"qualified\", \"value\": \"foo\" }, { \"type\": \"third_party_dimension\", \"name\": \"s_foo1\", \"match\": \"qualified\", \"value\": \"foo1\" } ] ] ]" + + var conditions interface{} + json.Unmarshal([]byte(conditionString), &conditions) + conditionTree, segments, err := buildConditionTree(conditions) + if err != nil { + assert.Fail(t, err.Error()) + } + + expectedSegments := []string{"foo", "foo1"} + expectedConditionTree := &entities.TreeNode{ + Operator: "and", + Nodes: []*entities.TreeNode{ + { + Operator: "or", + Nodes: []*entities.TreeNode{ + { + Operator: "or", + Nodes: []*entities.TreeNode{ + { + Item: entities.Condition{ + Name: "s_foo", + Match: "qualified", + Type: "third_party_dimension", + Value: "foo", + StringRepresentation: `{"match":"qualified","name":"s_foo","type":"third_party_dimension","value":"foo"}`, + }, + }, + { + Item: entities.Condition{ + Name: "s_foo1", + Match: "qualified", + Type: "third_party_dimension", + Value: "foo1", + StringRepresentation: `{"match":"qualified","name":"s_foo1","type":"third_party_dimension","value":"foo1"}`, + }, + }, + }, + }, + }, + }, + }, + } + assert.Equal(t, expectedSegments, segments) + assert.Equal(t, expectedConditionTree, conditionTree) +} + func TestBuildConditionTreeWithLeafNode(t *testing.T) { conditionString := "{ \"type\": \"custom_attribute\", \"name\": \"s_foo\", \"match\": \"exact\", \"value\": \"foo\" }" var conditions interface{} json.Unmarshal([]byte(conditionString), &conditions) - conditionTree, err := buildConditionTree(conditions) + conditionTree, segments, err := buildConditionTree(conditions) assert.NoError(t, err) + assert.Empty(t, segments) expectedConditionTree := &entities.TreeNode{ Operator: "or", @@ -185,3 +236,29 @@ func TestBuildConditionTreeWithLeafNode(t *testing.T) { } assert.Equal(t, expectedConditionTree, conditionTree) } + +func TestBuildConditionTreeLeafNodeWithSegment(t *testing.T) { + conditionString := "{ \"type\": \"third_party_dimension\", \"name\": \"s_foo\", \"match\": \"qualified\", \"value\": \"foo\" }" + var conditions interface{} + json.Unmarshal([]byte(conditionString), &conditions) + conditionTree, segments, err := buildConditionTree(conditions) + assert.NoError(t, err) + + expectedSegments := []string{"foo"} + expectedConditionTree := &entities.TreeNode{ + Operator: "or", + Nodes: []*entities.TreeNode{ + { + Item: entities.Condition{ + Name: "s_foo", + Match: "qualified", + Type: "third_party_dimension", + Value: "foo", + StringRepresentation: `{"match":"qualified","name":"s_foo","type":"third_party_dimension","value":"foo"}`, + }, + }, + }, + } + assert.Equal(t, expectedSegments, segments) + assert.Equal(t, expectedConditionTree, conditionTree) +} diff --git a/pkg/config/interface.go b/pkg/config/interface.go index 8b2ddb54f..3e74078c2 100644 --- a/pkg/config/interface.go +++ b/pkg/config/interface.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019-2021, Optimizely, Inc. and contributors * + * Copyright 2019-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -25,6 +25,8 @@ import ( // ProjectConfig represents the project's experiments and feature flags and contains methods for accessing the them. type ProjectConfig interface { GetDatafile() string + GetHostForODP() string + GetPublicKeyForODP() string GetAccountID() string GetAnonymizeIP() bool GetAttributeID(id string) string // returns "" if there is no id @@ -39,6 +41,8 @@ type ProjectConfig interface { GetFeatureByKey(string) (entities.Feature, error) GetVariableByKey(featureKey string, variableKey string) (entities.Variable, error) GetExperimentList() []entities.Experiment + GetSegmentList() []string + GetIntegrationList() []entities.Integration GetRolloutList() (rolloutList []entities.Rollout) GetFeatureList() []entities.Feature GetGroupByID(string) (entities.Group, error) diff --git a/pkg/decision/evaluator/condition.go b/pkg/decision/evaluator/condition.go index 51ff50a4d..60301d1d3 100644 --- a/pkg/decision/evaluator/condition.go +++ b/pkg/decision/evaluator/condition.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019-2021, Optimizely, Inc. and contributors * + * Copyright 2019-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -45,9 +45,15 @@ func NewCustomAttributeConditionEvaluator(logger logging.OptimizelyLogProducer) // Evaluate returns true if the given user's attributes match the condition func (c CustomAttributeConditionEvaluator) Evaluate(condition entities.Condition, condTreeParams *entities.TreeParameters, options *decide.Options) (bool, decide.DecisionReasons, error) { // We should only be evaluating custom attributes - reasons := decide.NewDecisionReasons(options) - if condition.Type != customAttributeType { + isValid := false + for _, validType := range validTypes { + if validType == condition.Type { + isValid = true + break + } + } + if !isValid { c.logger.Warning(fmt.Sprintf(logging.UnknownConditionType.String(), condition.StringRepresentation)) errorMessage := reasons.AddInfo(`unable to evaluate condition of type "%s"`, condition.Type) return false, reasons, errors.New(errorMessage) diff --git a/pkg/decision/evaluator/condition_test.go b/pkg/decision/evaluator/condition_test.go index 98f3477df..90ea9ce42 100644 --- a/pkg/decision/evaluator/condition_test.go +++ b/pkg/decision/evaluator/condition_test.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019-2021, Optimizely, Inc. and contributors * + * Copyright 2019-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -21,6 +21,7 @@ import ( "testing" "github.com/optimizely/go-sdk/pkg/decide" + "github.com/optimizely/go-sdk/pkg/decision/evaluator/matchers" "github.com/optimizely/go-sdk/pkg/entities" "github.com/optimizely/go-sdk/pkg/logging" "github.com/stretchr/testify/suite" @@ -43,10 +44,10 @@ func (s *ConditionTestSuite) SetupTest() { func (s *ConditionTestSuite) TestCustomAttributeConditionEvaluator() { condition := entities.Condition{ - Match: "exact", + Match: matchers.ExactMatchType, Value: "foo", Name: "string_foo", - Type: "custom_attribute", + Type: customAttributeType, } // Test condition passes @@ -74,7 +75,7 @@ func (s *ConditionTestSuite) TestCustomAttributeConditionEvaluatorWithoutMatchTy condition := entities.Condition{ Value: "foo", Name: "string_foo", - Type: "custom_attribute", + Type: customAttributeType, } // Test condition passes @@ -102,7 +103,7 @@ func (s *ConditionTestSuite) TestCustomAttributeConditionEvaluatorWithInvalidMat condition := entities.Condition{ Value: "foo", Name: "string_foo", - Type: "custom_attribute", + Type: customAttributeType, Match: "invalid", } @@ -149,13 +150,83 @@ func (s *ConditionTestSuite) TestCustomAttributeConditionEvaluatorWithUnknownTyp s.mockLogger.AssertExpectations(s.T()) } +func (s *ConditionTestSuite) TestThirdPartyDimensionConditionEvaluator() { + condition := entities.Condition{ + Match: matchers.QualifiedMatchType, + Value: "1", + Type: thirdPartyDimension, + } + + // Test condition passes + user := entities.UserContext{ + QualifiedSegments: []string{"1", "2", "3"}, + } + + condTreeParams := entities.NewTreeParameters(&user, map[string]entities.Audience{}) + result, _, _ := s.conditionEvaluator.Evaluate(condition, condTreeParams, &s.options) + s.Equal(result, true) + + // Test condition fails + user = entities.UserContext{ + QualifiedSegments: []string{"4", "5", "6"}, + } + result, _, _ = s.conditionEvaluator.Evaluate(condition, condTreeParams, &s.options) + s.Equal(result, false) +} + +func (s *ConditionTestSuite) TestThirdPartyDimensionConditionEvaluatorWithInvalidMatchType() { + condition := entities.Condition{ + Value: "1", + Type: thirdPartyDimension, + Match: "invalid", + } + + // Test condition fails + user := entities.UserContext{ + QualifiedSegments: []string{"1", "2", "3"}, + } + + condTreeParams := entities.NewTreeParameters(&user, map[string]entities.Audience{}) + s.mockLogger.On("Warning", fmt.Sprintf(logging.UnknownMatchType.String(), "")) + s.options.IncludeReasons = true + result, reasons, _ := s.conditionEvaluator.Evaluate(condition, condTreeParams, &s.options) + s.Equal(result, false) + messages := reasons.ToReport() + s.Len(messages, 1) + s.Equal(`invalid Condition matcher "invalid"`, messages[0]) + s.mockLogger.AssertExpectations(s.T()) +} + +func (s *ConditionTestSuite) TestThirdPartyDimensionConditionEvaluatorWithUnknownType() { + condition := entities.Condition{ + Match: matchers.QualifiedMatchType, + Value: "1", + Type: "", + } + + // Test condition fails + user := entities.UserContext{ + QualifiedSegments: []string{"1", "2", "3"}, + } + + condTreeParams := entities.NewTreeParameters(&user, map[string]entities.Audience{}) + s.mockLogger.On("Warning", fmt.Sprintf(logging.UnknownConditionType.String(), "")) + s.options.IncludeReasons = true + result, reasons, _ := s.conditionEvaluator.Evaluate(condition, condTreeParams, &s.options) + s.Equal(result, false) + messages := reasons.ToReport() + s.Len(messages, 1) + s.Equal(`unable to evaluate condition of type ""`, messages[0]) + s.mockLogger.AssertExpectations(s.T()) +} + func (s *ConditionTestSuite) TestCustomAttributeConditionEvaluatorForGeSemver() { conditionEvaluator := CustomAttributeConditionEvaluator{} condition := entities.Condition{ - Match: "semver_ge", + Match: matchers.SemverGeMatchType, Value: "2.9", Name: "string_foo", - Type: "custom_attribute", + Type: customAttributeType, } // Test condition passes @@ -173,10 +244,10 @@ func (s *ConditionTestSuite) TestCustomAttributeConditionEvaluatorForGeSemver() func (s *ConditionTestSuite) TestCustomAttributeConditionEvaluatorForGeSemverBeta() { conditionEvaluator := CustomAttributeConditionEvaluator{} condition := entities.Condition{ - Match: "semver_ge", + Match: matchers.SemverGeMatchType, Value: "3.7.0", Name: "string_foo", - Type: "custom_attribute", + Type: customAttributeType, } // Test condition passes @@ -194,10 +265,10 @@ func (s *ConditionTestSuite) TestCustomAttributeConditionEvaluatorForGeSemverBet func (s *ConditionTestSuite) TestCustomAttributeConditionEvaluatorForGeSemverInvalid() { conditionEvaluator := CustomAttributeConditionEvaluator{} condition := entities.Condition{ - Match: "semver_ge", + Match: matchers.SemverGeMatchType, Value: "3.7.0", Name: "string_foo", - Type: "custom_attribute", + Type: customAttributeType, } // Test condition passes diff --git a/pkg/decision/evaluator/condition_tree.go b/pkg/decision/evaluator/condition_tree.go index 07d239406..3f546ceef 100644 --- a/pkg/decision/evaluator/condition_tree.go +++ b/pkg/decision/evaluator/condition_tree.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019-2021, Optimizely, Inc. and contributors * + * Copyright 2019-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -25,8 +25,15 @@ import ( "github.com/optimizely/go-sdk/pkg/logging" ) +// String constant representing custom attribute condition type. const customAttributeType = "custom_attribute" +// String constant representing a third-party condition type. +const thirdPartyDimension = "third_party_dimension" + +// Valid types allowed for validation +var validTypes = [...]string{customAttributeType, thirdPartyDimension} + const ( // "and" operator returns true if all conditions evaluate to true andOperator = "and" diff --git a/pkg/decision/evaluator/matchers/qualified.go b/pkg/decision/evaluator/matchers/qualified.go new file mode 100644 index 000000000..5f9f1dbe7 --- /dev/null +++ b/pkg/decision/evaluator/matchers/qualified.go @@ -0,0 +1,34 @@ +/**************************************************************************** + * Copyright 2022, Optimizely, Inc. and contributors * + * * + * 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 * + * * + * https://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 matchers // +package matchers + +import ( + "fmt" + + "github.com/optimizely/go-sdk/pkg/entities" + "github.com/optimizely/go-sdk/pkg/logging" +) + +// QualifiedMatcher matches against the "qualified" match type +func QualifiedMatcher(condition entities.Condition, user entities.UserContext, logger logging.OptimizelyLogProducer) (bool, error) { + if stringValue, ok := condition.Value.(string); ok { + return user.IsQualifiedFor(stringValue), nil + } + logger.Warning(fmt.Sprintf(logging.UnsupportedConditionValue.String(), condition.StringRepresentation)) + return false, fmt.Errorf("audience condition %s evaluated to NULL because the condition value type is not supported", condition.Name) +} diff --git a/pkg/decision/evaluator/matchers/qualified_test.go b/pkg/decision/evaluator/matchers/qualified_test.go new file mode 100644 index 000000000..af72bc5b0 --- /dev/null +++ b/pkg/decision/evaluator/matchers/qualified_test.go @@ -0,0 +1,110 @@ +/**************************************************************************** + * Copyright 2022, Optimizely, Inc. and contributors * + * * + * 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 * + * * + * https://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 matchers + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/optimizely/go-sdk/pkg/entities" + "github.com/optimizely/go-sdk/pkg/logging" +) + +type QualifiedTestSuite struct { + suite.Suite + mockLogger *MockLogger + matcher Matcher +} + +func (s *QualifiedTestSuite) SetupTest() { + s.mockLogger = new(MockLogger) + s.matcher, _ = Get(QualifiedMatchType) +} + +func (s *QualifiedTestSuite) TestQualifiedMatcherNonString() { + user := entities.UserContext{ + QualifiedSegments: []string{"a", "b", "c"}, + } + + condition := entities.Condition{ + Value: 42, + } + s.mockLogger.On("Warning", fmt.Sprintf(logging.UnsupportedConditionValue.String(), condition.StringRepresentation)) + result, err := s.matcher(condition, user, s.mockLogger) + s.Error(err) + s.False(result) + + condition = entities.Condition{ + Value: false, + } + s.mockLogger.On("Warning", fmt.Sprintf(logging.UnsupportedConditionValue.String(), condition.StringRepresentation)) + result, err = s.matcher(condition, user, s.mockLogger) + s.Error(err) + s.False(result) + s.mockLogger.AssertExpectations(s.T()) + + condition = entities.Condition{ + Value: []string{}, + } + s.mockLogger.On("Warning", fmt.Sprintf(logging.UnsupportedConditionValue.String(), condition.StringRepresentation)) + result, err = s.matcher(condition, user, s.mockLogger) + s.Error(err) + s.False(result) + + condition = entities.Condition{ + Value: map[string]interface{}{}, + } + s.mockLogger.On("Warning", fmt.Sprintf(logging.UnsupportedConditionValue.String(), condition.StringRepresentation)) + result, err = s.matcher(condition, user, s.mockLogger) + s.Error(err) + s.False(result) + s.mockLogger.AssertExpectations(s.T()) +} + +func (s *QualifiedTestSuite) TestQualifiedMatcherIncorrect() { + user := entities.UserContext{ + QualifiedSegments: []string{"a", "b", "c"}, + } + + condition := entities.Condition{ + Value: "d", + } + result, err := s.matcher(condition, user, s.mockLogger) + s.NoError(err) + s.False(result) + s.mockLogger.AssertNotCalled(s.T(), "Warning", fmt.Sprintf(logging.UnsupportedConditionValue.String(), condition.StringRepresentation)) +} + +func (s *QualifiedTestSuite) TestQualifiedMatcherCorrect() { + user := entities.UserContext{ + QualifiedSegments: []string{"a", "b", "c"}, + } + + condition := entities.Condition{ + Value: "a", + } + result, err := s.matcher(condition, user, s.mockLogger) + s.NoError(err) + s.True(result) + s.mockLogger.AssertNotCalled(s.T(), "Warning", fmt.Sprintf(logging.UnsupportedConditionValue.String(), condition.StringRepresentation)) +} + +func TestQualifiedTestSuite(t *testing.T) { + suite.Run(t, new(QualifiedTestSuite)) +} diff --git a/pkg/decision/evaluator/matchers/registry.go b/pkg/decision/evaluator/matchers/registry.go index fd609bd36..5c71170f4 100644 --- a/pkg/decision/evaluator/matchers/registry.go +++ b/pkg/decision/evaluator/matchers/registry.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2020-2021, Optimizely, Inc. and contributors * + * Copyright 2020-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -28,6 +28,8 @@ import ( type Matcher func(entities.Condition, entities.UserContext, logging.OptimizelyLogProducer) (bool, error) const ( + // QualifiedMatchType name for the "qualified" matcher + QualifiedMatchType = "qualified" // ExactMatchType name for the "exact" matcher ExactMatchType = "exact" // ExistsMatchType name for the "exists" matcher @@ -55,6 +57,7 @@ const ( ) var registry = map[string]Matcher{ + QualifiedMatchType: QualifiedMatcher, ExactMatchType: ExactMatcher, ExistsMatchType: ExistsMatcher, LtMatchType: LtMatcher, diff --git a/pkg/entities/feature.go b/pkg/entities/feature.go index 54585f1b6..226adff49 100644 --- a/pkg/entities/feature.go +++ b/pkg/entities/feature.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019,2021, Optimizely, Inc. and contributors * + * Copyright 2019,2021-2022, Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -33,6 +33,13 @@ type Rollout struct { Experiments []Experiment } +// Integration represents a integration +type Integration struct { + Key string + Host string + PublicKey string +} + // Variable represents a feature variable type Variable struct { DefaultValue string diff --git a/pkg/entities/user_context.go b/pkg/entities/user_context.go index fc44e8526..99e54544a 100644 --- a/pkg/entities/user_context.go +++ b/pkg/entities/user_context.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019-2020, Optimizely, Inc. and contributors * + * Copyright 2019-2020,2022 Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -27,8 +27,20 @@ const bucketingIDAttributeName = "$opt_bucketing_id" // UserContext holds information about a user type UserContext struct { - ID string - Attributes map[string]interface{} + ID string + Attributes map[string]interface{} + QualifiedSegments []string +} + +// IsQualifiedFor returns whether the segment exists in the QualifiedSegments. +func (u *UserContext) IsQualifiedFor(segment string) bool { + for _, q := range u.QualifiedSegments { + if q == segment { + return true + } + } + + return false } // CheckAttributeExists returns whether the specified attribute name exists in the attributes map. diff --git a/pkg/entities/user_context_test.go b/pkg/entities/user_context_test.go index da155c7ec..4cdd5c219 100644 --- a/pkg/entities/user_context_test.go +++ b/pkg/entities/user_context_test.go @@ -1,11 +1,11 @@ /**************************************************************************** - * Copyright 2019, Optimizely, Inc. and contributors * + * Copyright 2019,2022 Optimizely, Inc. and contributors * * * * 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 * + * https://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, * @@ -23,6 +23,20 @@ import ( "github.com/stretchr/testify/assert" ) +func TestIsQualifiedFor(t *testing.T) { + userContext := UserContext{ + QualifiedSegments: []string{"1", "2", "3"}, + } + + // Test happy path + assert.Equal(t, true, userContext.IsQualifiedFor("1")) + assert.Equal(t, true, userContext.IsQualifiedFor("2")) + assert.Equal(t, true, userContext.IsQualifiedFor("3")) + + // Test non-existent segment + assert.Equal(t, false, userContext.IsQualifiedFor("4")) +} + func TestUserAttributeExists(t *testing.T) { userContext := UserContext{ Attributes: map[string]interface{}{ diff --git a/test-data/decide-test-datafile.json b/test-data/decide-test-datafile.json index 7cb096121..3e0af9ebc 100644 --- a/test-data/decide-test-datafile.json +++ b/test-data/decide-test-datafile.json @@ -78,6 +78,13 @@ "botFiltering": true, "projectId": "10431130345", "variables": [], + "integrations": [ + { + "publicKey": "ax6UV2223fD-jpOXID0BMg", + "host": "https://api.zaius.com", + "key": "odp" + } + ], "featureFlags": [ { "experimentIds": ["10390977673"],