diff --git a/.circleci/config.yml b/.circleci/config.yml index 087b6fa0..f7bff4a5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -41,6 +41,7 @@ jobs: environment: CIRCLE_TEST_REPORTS: /tmp/circle-reports CIRCLE_ARTIFACTS: /tmp/circle-artifacts + TEST_HARNESS_PARAMS: -junit /tmp/circle-reports/contract-tests-junit.xml steps: - checkout @@ -81,7 +82,13 @@ jobs: name: Process test results command: go-junit-report < $CIRCLE_ARTIFACTS/report.txt > $CIRCLE_TEST_REPORTS/junit.xml when: always - + + - run: make build-contract-tests + - run: + command: make start-contract-test-service + background: true + - run: make run-contract-tests + - store_test_results: path: /tmp/circle-reports diff --git a/.gitignore b/.gitignore index 6c657a20..95ea7aed 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build/ go-server-sdk.test allocations.out .idea +.vscode diff --git a/Makefile b/Makefile index 24e9cf14..f4744fbe 100644 --- a/Makefile +++ b/Makefile @@ -69,3 +69,24 @@ $(LINTER_VERSION_FILE): lint: $(LINTER_VERSION_FILE) $(LINTER) run ./... + + +TEMP_TEST_OUTPUT=/tmp/sse-contract-test-service.log + +build-contract-tests: + @cd testservice && go mod tidy && go build + +start-contract-test-service: build-contract-tests + @./testservice/testservice + +start-contract-test-service-bg: + @echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)" + @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 + +run-contract-tests: + @curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v1.0.0/downloader/run.sh \ + | VERSION=v1 PARAMS="-url http://localhost:8000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh + +contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests + +.PHONY: build-contract-tests start-contract-test-service start-contract-test-service-bg run-contract-tests contract-tests diff --git a/client_context_from_config.go b/client_context_from_config.go index 41681b74..10fcfe3d 100644 --- a/client_context_from_config.go +++ b/client_context_from_config.go @@ -2,12 +2,16 @@ package ldclient import ( "errors" + "regexp" + "gopkg.in/launchdarkly/go-sdk-common.v2/ldlog" "gopkg.in/launchdarkly/go-server-sdk.v5/interfaces" "gopkg.in/launchdarkly/go-server-sdk.v5/internal" "gopkg.in/launchdarkly/go-server-sdk.v5/ldcomponents" ) +var validTagKeyOrValueRegex = regexp.MustCompile(`(?s)^[\w.-]*$`) + func newClientContextFromConfig( sdkKey string, config Config, @@ -27,20 +31,25 @@ func newClientContextFromConfig( ServiceEndpoints: config.ServiceEndpoints, } - httpFactory := config.HTTP - if httpFactory == nil { - httpFactory = ldcomponents.HTTPConfiguration() + loggingFactory := config.Logging + if loggingFactory == nil { + loggingFactory = ldcomponents.Logging() } - http, err := httpFactory.CreateHTTPConfiguration(basicConfig) + logging, err := loggingFactory.CreateLoggingConfiguration(basicConfig) if err != nil { return nil, err } - loggingFactory := config.Logging - if loggingFactory == nil { - loggingFactory = ldcomponents.Logging() + basicConfig.ApplicationInfo.ApplicationID = validateTagValue(config.ApplicationInfo.ApplicationID, + "ApplicationID", logging.GetLoggers()) + basicConfig.ApplicationInfo.ApplicationVersion = validateTagValue(config.ApplicationInfo.ApplicationVersion, + "ApplicationVersion", logging.GetLoggers()) + + httpFactory := config.HTTP + if httpFactory == nil { + httpFactory = ldcomponents.HTTPConfiguration() } - logging, err := loggingFactory.CreateLoggingConfiguration(basicConfig) + http, err := httpFactory.CreateHTTPConfiguration(basicConfig) if err != nil { return nil, err } @@ -60,3 +69,11 @@ func stringIsValidHTTPHeaderValue(s string) bool { } return true } + +func validateTagValue(value, name string, loggers ldlog.Loggers) string { + if value != "" && !validTagKeyOrValueRegex.MatchString(value) { + loggers.Warnf("Value of Config.%s contained invalid characters and was discarded", name) + return "" + } + return value +} diff --git a/config.go b/config.go index 6830ae80..847099eb 100644 --- a/config.go +++ b/config.go @@ -166,4 +166,10 @@ type Config struct { // may set the base URIs to whatever you want, although the SDK will still set the URI paths to // the expected paths for LaunchDarkly services. ServiceEndpoints interfaces.ServiceEndpoints + + // Provides configuration of application metadata. See interfaces.ApplicationInfo. + // + // Application metadata may be used in LaunchDarkly analytics or other product features, but does not + // affect feature flag evaluations. + ApplicationInfo interfaces.ApplicationInfo } diff --git a/interfaces/application_info.go b/interfaces/application_info.go new file mode 100644 index 00000000..6c6e762a --- /dev/null +++ b/interfaces/application_info.go @@ -0,0 +1,26 @@ +package interfaces + +// ApplicationInfo allows configuration of application metadata. +// +// Application metadata may be used in LaunchDarkly analytics or other product features, but does not +// affect feature flag evaluations. +// +// If you want to set non-default values for any of these fields, set the ApplicationInfo field +// in the SDK's Config struct. +type ApplicationInfo struct { + // ApplicationID is a unique identifier representing the application where the LaunchDarkly SDK is + // running. + // + // This can be specified as any string value as long as it only uses the following characters: ASCII + // letters, ASCII digits, period, hyphen, underscore. A string containing any other characters will be + // ignored. + ApplicationID string + + // ApplicationVersion is a unique identifier representing the version of the application where the + // LaunchDarkly SDK is running. + // + // This can be specified as any string value as long as it only uses the following characters: ASCII + // letters, ASCII digits, period, hyphen, underscore. A string containing any other characters will be + // ignored. + ApplicationVersion string +} diff --git a/interfaces/client_context.go b/interfaces/client_context.go index cbf31702..38ea4951 100644 --- a/interfaces/client_context.go +++ b/interfaces/client_context.go @@ -26,4 +26,7 @@ type BasicConfiguration struct { // ServiceEndpoints include any configured custom service URIs. ServiceEndpoints ServiceEndpoints + + // ApplicationInfo includes any configured application metadata. + ApplicationInfo ApplicationInfo } diff --git a/interfaces/flagstate/flags_state.go b/interfaces/flagstate/flags_state.go index 0c9da673..8f344851 100644 --- a/interfaces/flagstate/flags_state.go +++ b/interfaces/flagstate/flags_state.go @@ -61,6 +61,10 @@ type FlagState struct { // DebugEventsUntilDate is non-zero if event debugging is enabled for this flag until the specified time. DebugEventsUntilDate ldtime.UnixMillisecondTime + + // OmitDetails is true if, based on the options passed to AllFlagsState and the flag state, some of the + // metadata can be left out of the JSON representation. + OmitDetails bool } // Option is the interface for optional parameters that can be passed to LDClient.AllFlagsState. @@ -143,8 +147,8 @@ func (a AllFlags) MarshalJSON() ([]byte, error) { for key, flag := range a.flags { flagObj := stateObj.Name(key).Object() flagObj.Maybe("variation", flag.Variation.IsDefined()).Int(flag.Variation.IntValue()) - flagObj.Name("version").Int(flag.Version) - if flag.Reason.IsDefined() { + flagObj.Maybe("version", !flag.OmitDetails).Int(flag.Version) + if flag.Reason.IsDefined() && !flag.OmitDetails { flag.Reason.WriteToJSONWriter(flagObj.Name("reason")) } flagObj.Maybe("trackEvents", flag.TrackEvents).Bool(flag.TrackEvents) @@ -189,14 +193,13 @@ func (b *AllFlagsBuilder) Build() AllFlags { func (b *AllFlagsBuilder) AddFlag(flagKey string, flag FlagState) *AllFlagsBuilder { // To save bandwidth, we include evaluation reasons only if 1. the application explicitly said to // include them or 2. they must be included because of experimentation - wantReason := b.options.withReasons || flag.TrackReason - if wantReason && b.options.detailsOnlyIfTracked { + if b.options.detailsOnlyIfTracked { if !flag.TrackEvents && !flag.TrackReason && !(flag.DebugEventsUntilDate != 0 && flag.DebugEventsUntilDate > ldtime.UnixMillisNow()) { - wantReason = false + flag.OmitDetails = true } } - if !wantReason { + if !b.options.withReasons && !flag.TrackReason { flag.Reason = ldreason.EvaluationReason{} } b.state.flags[flagKey] = flag diff --git a/interfaces/flagstate/flags_state_test.go b/interfaces/flagstate/flags_state_test.go index f9299f11..fde64621 100644 --- a/interfaces/flagstate/flags_state_test.go +++ b/interfaces/flagstate/flags_state_test.go @@ -5,10 +5,9 @@ import ( "gopkg.in/launchdarkly/go-sdk-common.v2/ldreason" "gopkg.in/launchdarkly/go-sdk-common.v2/ldtime" + "gopkg.in/launchdarkly/go-sdk-common.v2/ldvalue" "github.com/stretchr/testify/assert" - - "gopkg.in/launchdarkly/go-sdk-common.v2/ldvalue" ) func TestAllFlags(t *testing.T) { @@ -49,8 +48,8 @@ func TestAllFlags(t *testing.T) { a1 := AllFlags{ flags: map[string]FlagState{ - "flag1": FlagState{Value: ldvalue.String("value1")}, - "flag2": FlagState{Value: ldvalue.String("value2")}, + "flag1": {Value: ldvalue.String("value1")}, + "flag2": {Value: ldvalue.String("value2")}, }, } assert.Equal(t, map[string]ldvalue.Value{ @@ -138,6 +137,31 @@ func TestAllFlagsJSON(t *testing.T) { "$flagsState":{ "flag1": {"variation":1,"version":1000,"reason":{"kind":"FALLTHROUGH"},"trackEvents":true,"trackReason":true} } +}`, string(bytes)) + }) + + t.Run("omitting details", func(t *testing.T) { + a := AllFlags{ + valid: true, + flags: map[string]FlagState{ + "flag1": { + Value: ldvalue.String("value1"), + Variation: ldvalue.NewOptionalInt(1), + Version: 1000, + Reason: ldreason.NewEvalReasonFallthrough(), + OmitDetails: true, + }, + }, + } + bytes, err := a.MarshalJSON() + assert.NoError(t, err) + assert.JSONEq(t, + `{ + "$valid":true, + "flag1": "value1", + "$flagsState":{ + "flag1": {"variation":1} + } }`, string(bytes)) }) } @@ -204,10 +228,10 @@ func TestAllFlagsBuilder(t *testing.T) { }, a.flags) }) - t.Run("add flags with reasons only if tracked", func(t *testing.T) { + t.Run("add flags with details only if tracked", func(t *testing.T) { b := NewAllFlagsBuilder(OptionWithReasons(), OptionDetailsOnlyForTrackedFlags()) - // flag1 should not get a reason + // flag1 should not get full details flag1 := FlagState{ Value: ldvalue.String("value1"), Variation: ldvalue.NewOptionalInt(1), @@ -258,14 +282,14 @@ func TestAllFlagsBuilder(t *testing.T) { b.AddFlag("flag4", flag4) b.AddFlag("flag5", flag5) - flag1WithoutReason, flag2WithoutReason := flag1, flag2 - flag1WithoutReason.Reason = ldreason.EvaluationReason{} - flag2WithoutReason.Reason = ldreason.EvaluationReason{} + flag1WithoutDetails, flag2WithoutDetails := flag1, flag2 + flag1WithoutDetails.OmitDetails = true + flag2WithoutDetails.OmitDetails = true a := b.Build() assert.Equal(t, map[string]FlagState{ - "flag1": flag1WithoutReason, - "flag2": flag2WithoutReason, + "flag1": flag1WithoutDetails, + "flag2": flag2WithoutDetails, "flag3": flag3, "flag4": flag4, "flag5": flag5, diff --git a/ldclient_evaluation_all_flags_test.go b/ldclient_evaluation_all_flags_test.go index ee0c19fc..86a0e027 100644 --- a/ldclient_evaluation_all_flags_test.go +++ b/ldclient_evaluation_all_flags_test.go @@ -125,18 +125,18 @@ func TestAllFlagsStateCanFilterForOnlyClientSideFlags(t *testing.T) { func TestAllFlagsStateCanOmitDetailForUntrackedFlags(t *testing.T) { futureTime := ldtime.UnixMillisNow() + 100000 - // flag1 does not get a reason because neither event tracking nor debugging is on and there's no experiment + // flag1 does not get full detials because neither event tracking nor debugging is on and there's no experiment flag1 := ldbuilders.NewFlagBuilder("key1").Version(100).OffVariation(0).Variations(ldvalue.String("value1")).Build() - // flag2 gets a reason because event tracking is on + // flag2 gets full details because event tracking is on flag2 := ldbuilders.NewFlagBuilder("key2").Version(200).OffVariation(1).Variations(ldvalue.String("x"), ldvalue.String("value2")). TrackEvents(true).Build() - // flag3 gets a reason because debugging is on + // flag3 gets full details because debugging is on flag3 := ldbuilders.NewFlagBuilder("key3").Version(300).OffVariation(1).Variations(ldvalue.String("x"), ldvalue.String("value3")). TrackEvents(false).DebugEventsUntilDate(futureTime).Build() - // flag4 gets a reason because there's an experiment (evaluation is a fallthrough and TrackEventsFallthrough is on) + // flag4 gets full details because there's an experiment (evaluation is a fallthrough and TrackEventsFallthrough is on) flag4 := ldbuilders.NewFlagBuilder("key4").Version(400).On(true).FallthroughVariation(1). Variations(ldvalue.String("x"), ldvalue.String("value4")). TrackEvents(false).TrackEventsFallthrough(true).Build() @@ -153,9 +153,11 @@ func TestAllFlagsStateCanOmitDetailForUntrackedFlags(t *testing.T) { expected := flagstate.NewAllFlagsBuilder(flagstate.OptionWithReasons()). AddFlag("key1", flagstate.FlagState{ - Value: ldvalue.String("value1"), - Variation: ldvalue.NewOptionalInt(0), - Version: 100, + Value: ldvalue.String("value1"), + Variation: ldvalue.NewOptionalInt(0), + Version: 100, + Reason: ldreason.NewEvalReasonOff(), + OmitDetails: true, }). AddFlag("key2", flagstate.FlagState{ Value: ldvalue.String("value2"), diff --git a/ldcomponents/http_configuration_builder.go b/ldcomponents/http_configuration_builder.go index 79bfd5c6..2d62e9f4 100644 --- a/ldcomponents/http_configuration_builder.go +++ b/ldcomponents/http_configuration_builder.go @@ -5,6 +5,7 @@ import ( "net/http" "net/url" "os" + "strings" "time" "gopkg.in/launchdarkly/go-sdk-common.v2/ldvalue" @@ -187,6 +188,9 @@ func (b *HTTPConfigurationBuilder) CreateHTTPConfiguration( if b.wrapperIdentifier != "" { headers.Add("X-LaunchDarkly-Wrapper", b.wrapperIdentifier) } + if tagsHeaderValue := buildTagsHeaderValue(basicConfiguration); tagsHeaderValue != "" { + headers.Add("X-LaunchDarkly-Tags", tagsHeaderValue) + } // For consistency with other SDKs, custom headers are allowed to overwrite headers such as // User-Agent and Authorization. @@ -224,3 +228,14 @@ func (b *HTTPConfigurationBuilder) CreateHTTPConfiguration( HTTPClientFactory: clientFactory, }, nil } + +func buildTagsHeaderValue(basicConfig interfaces.BasicConfiguration) string { + var parts []string + if basicConfig.ApplicationInfo.ApplicationID != "" { + parts = append(parts, fmt.Sprintf("application-id/%s", basicConfig.ApplicationInfo.ApplicationID)) + } + if basicConfig.ApplicationInfo.ApplicationVersion != "" { + parts = append(parts, fmt.Sprintf("application-version/%s", basicConfig.ApplicationInfo.ApplicationVersion)) + } + return strings.Join(parts, " ") +} diff --git a/ldcomponents/http_configuration_builder_test.go b/ldcomponents/http_configuration_builder_test.go index 17a32a7a..b0636c08 100644 --- a/ldcomponents/http_configuration_builder_test.go +++ b/ldcomponents/http_configuration_builder_test.go @@ -239,4 +239,20 @@ func TestHTTPConfigurationBuilder(t *testing.T) { headers2 := c2.GetDefaultHeaders() assert.Equal(t, "FancySDK/2.0", headers2.Get("X-LaunchDarkly-Wrapper")) }) + + t.Run("tags header", func(t *testing.T) { + t.Run("no tags", func(t *testing.T) { + c, err := HTTPConfiguration().CreateHTTPConfiguration(basicConfig) + require.NoError(t, err) + assert.Nil(t, c.GetDefaultHeaders().Values("X-LaunchDarkly-Tags")) + }) + + t.Run("some tags", func(t *testing.T) { + bc := basicConfig + bc.ApplicationInfo = interfaces.ApplicationInfo{ApplicationID: "appid", ApplicationVersion: "appver"} + c, err := HTTPConfiguration().CreateHTTPConfiguration(bc) + require.NoError(t, err) + assert.Equal(t, "application-id/appid application-version/appver", c.GetDefaultHeaders().Get("X-LaunchDarkly-Tags")) + }) + }) } diff --git a/testservice/.gitignore b/testservice/.gitignore new file mode 100644 index 00000000..dcb18e14 --- /dev/null +++ b/testservice/.gitignore @@ -0,0 +1 @@ +testservice diff --git a/testservice/README.md b/testservice/README.md new file mode 100644 index 00000000..aa3942b8 --- /dev/null +++ b/testservice/README.md @@ -0,0 +1,7 @@ +# SDK contract test service + +This directory contains an implementation of the cross-platform SDK testing protocol defined by https://github.com/launchdarkly/sdk-test-harness. See that project's `README` for details of this protocol, and the kinds of SDK capabilities that are relevant to the contract tests. This code should not need to be updated unless the SDK has added or removed such capabilities. + +To run these tests locally, run `make contract-tests` from the SDK project root directory. This downloads the correct version of the test harness tool automatically. + +Or, to test against an in-progress local version of the test harness, run `make start-contract-test-service` from the SDK project root directory; then, in the root directory of the `sdk-test-harness` project, build the test harness and run it from the command line. diff --git a/testservice/big_segment_store_fixture.go b/testservice/big_segment_store_fixture.go new file mode 100644 index 00000000..1472e0a6 --- /dev/null +++ b/testservice/big_segment_store_fixture.go @@ -0,0 +1,45 @@ +package main + +import ( + "gopkg.in/launchdarkly/go-sdk-common.v2/ldvalue" + "gopkg.in/launchdarkly/go-server-sdk.v5/interfaces" + cf "gopkg.in/launchdarkly/go-server-sdk.v5/testservice/servicedef/callbackfixtures" +) + +type BigSegmentStoreFixture struct { + service *callbackService +} + +type bigSegmentMembershipMap map[string]bool + +func (b *BigSegmentStoreFixture) CreateBigSegmentStore(context interfaces.ClientContext) (interfaces.BigSegmentStore, error) { + return b, nil +} + +func (b *BigSegmentStoreFixture) Close() error { + return b.service.close() +} + +func (b *BigSegmentStoreFixture) GetMetadata() (interfaces.BigSegmentStoreMetadata, error) { + var resp cf.BigSegmentStoreGetMetadataResponse + if err := b.service.post(cf.BigSegmentStorePathGetMetadata, nil, &resp); err != nil { + return interfaces.BigSegmentStoreMetadata{}, err + } + return interfaces.BigSegmentStoreMetadata(resp), nil +} + +func (b *BigSegmentStoreFixture) GetUserMembership(userHash string) (interfaces.BigSegmentMembership, error) { + params := cf.BigSegmentStoreGetMembershipParams{UserHash: userHash} + var resp cf.BigSegmentStoreGetMembershipResponse + if err := b.service.post(cf.BigSegmentStorePathGetMembership, params, &resp); err != nil { + return nil, err + } + return bigSegmentMembershipMap(resp.Values), nil +} + +func (m bigSegmentMembershipMap) CheckMembership(segmentRef string) ldvalue.OptionalBool { + if value, ok := m[segmentRef]; ok { + return ldvalue.NewOptionalBool(value) + } + return ldvalue.OptionalBool{} +} diff --git a/testservice/callback_service.go b/testservice/callback_service.go new file mode 100644 index 00000000..f71fe735 --- /dev/null +++ b/testservice/callback_service.go @@ -0,0 +1,59 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" +) + +type callbackService struct { + baseURL string +} + +func (c *callbackService) close() error { + req, _ := http.NewRequest("DELETE", c.baseURL, nil) + _, err := http.DefaultClient.Do(req) + return err +} + +func (c *callbackService) post(path string, params interface{}, responseOut interface{}) error { + url := c.baseURL + path + var postBody io.Reader + if params != nil { + data, _ := json.Marshal(params) + postBody = bytes.NewBuffer(data) + } + resp, err := http.DefaultClient.Post(url, "application/json", postBody) + if err != nil { + //e.logger.Printf("Callback to %s failed: %s", url, err) + return err + } + var body []byte + if resp.Body != nil { + body, err = ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + _ = resp.Body.Close() + } + if resp.StatusCode >= 300 { + message := "" + if body != nil { + message = " (" + string(body) + ")" + } + return fmt.Errorf("callback returned HTTP status %d%s", resp.StatusCode, message) + } + if responseOut != nil { + if body == nil { + return errors.New("expected a response body but got none") + } + if err = json.Unmarshal(body, responseOut); err != nil { + return err + } + } + return nil +} diff --git a/testservice/go.mod b/testservice/go.mod new file mode 100644 index 00000000..f8ac590c --- /dev/null +++ b/testservice/go.mod @@ -0,0 +1,11 @@ +module gopkg.in/launchdarkly/go-server-sdk.v5/testservice + +go 1.14 + +require ( + github.com/gorilla/mux v1.8.0 + gopkg.in/launchdarkly/go-sdk-common.v2 v2.4.0 + gopkg.in/launchdarkly/go-server-sdk.v5 v5.4.0 +) + +replace gopkg.in/launchdarkly/go-server-sdk.v5 => ../ diff --git a/testservice/go.sum b/testservice/go.sum new file mode 100644 index 00000000..2c2e326f --- /dev/null +++ b/testservice/go.sum @@ -0,0 +1,70 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gregjones/httpcache v0.0.0-20171119193500-2bcd89a1743f h1:kOkUP6rcVVqC+KlKKENKtgfFfJyDySYhqL9srXooghY= +github.com/gregjones/httpcache v0.0.0-20171119193500-2bcd89a1743f/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003 h1:vJ0Snvo+SLMY72r5J4sEfkuE7AFbixEP2qRbEcum/wA= +github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003/go.mod h1:zNBxMY8P21owkeogJELCLeHIt+voOSduHYTFUbwRAV8= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/launchdarkly/ccache v1.1.0 h1:voD1M+ZJXR3MREOKtBwgTF9hYHl1jg+vFKS/+VAkR2k= +github.com/launchdarkly/ccache v1.1.0/go.mod h1:TlxzrlnzvYeXiLHmesMuvoZetu4Z97cV1SsdqqBJi1Q= +github.com/launchdarkly/eventsource v1.6.2 h1:5SbcIqzUomn+/zmJDrkb4LYw7ryoKFzH/0TbR0/3Bdg= +github.com/launchdarkly/eventsource v1.6.2/go.mod h1:LHxSeb4OnqznNZxCSXbFghxS/CjIQfzHovNoAqbO/Wk= +github.com/launchdarkly/go-ntlm-proxy-auth v1.0.1/go.mod h1:hKWfH/hga5oslM2mRkDZi+14u2h1dFsmgbvSM9qF8pk= +github.com/launchdarkly/go-ntlmssp v1.0.1/go.mod h1:/cq3t2JyALD7GdVF5BEWcEuGlIGa44FZ4v4CVk7vuCY= +github.com/launchdarkly/go-semver v1.0.2 h1:sYVRnuKyvxlmQCnCUyDkAhtmzSFRoX6rG2Xa21Mhg+w= +github.com/launchdarkly/go-semver v1.0.2/go.mod h1:xFmMwXba5Mb+3h72Z+VeSs9ahCvKo2QFUTHRNHVqR28= +github.com/launchdarkly/go-test-helpers/v2 v2.2.0 h1:L3kGILP/6ewikhzhdNkHy1b5y4zs50LueWenVF0sBbs= +github.com/launchdarkly/go-test-helpers/v2 v2.2.0/go.mod h1:L7+th5govYp5oKU9iN7To5PgznBuIjBPn+ejqKR0avw= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ= +github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ghodss/yaml.v1 v1.0.0/go.mod h1:HDvRMPQLqycKPs9nWLuzZWxsxRzISLCRORiDpBUOMqg= +gopkg.in/launchdarkly/go-jsonstream.v1 v1.0.0/go.mod h1:YefdBjfITIP8D9BJLVbssFctHkJnQXhv+TiRdTV0Jr4= +gopkg.in/launchdarkly/go-jsonstream.v1 v1.0.1 h1:aZHvMDAS+M6/0sRMkDBQ8MyLGsTQrNgN5evu5e8UYpQ= +gopkg.in/launchdarkly/go-jsonstream.v1 v1.0.1/go.mod h1:YefdBjfITIP8D9BJLVbssFctHkJnQXhv+TiRdTV0Jr4= +gopkg.in/launchdarkly/go-sdk-common.v2 v2.2.1/go.mod h1:Fht0iTasUXh2xiDA8IJSmlSGbyQ1GNpmt97lXYz6+p8= +gopkg.in/launchdarkly/go-sdk-common.v2 v2.4.0 h1:uA7it+cSIDIF4AhLoaLvQ5h9TxvSSVmn/CsJiAqrm4E= +gopkg.in/launchdarkly/go-sdk-common.v2 v2.4.0/go.mod h1:P2+C6CHteys+lEDd6298QszCsMhjdYrfzBd6dg//CHA= +gopkg.in/launchdarkly/go-sdk-events.v1 v1.1.1 h1:LfbZsHTPwjzhDbJ/IjYs0oc8rWcbyJM7nN+Ce4ZdUVM= +gopkg.in/launchdarkly/go-sdk-events.v1 v1.1.1/go.mod h1:UETsxDtKpoDGUrwliXl1L7OG68zjOO0aagDI8OnvDRw= +gopkg.in/launchdarkly/go-server-sdk-evaluation.v1 v1.5.0 h1:gA0F8n0sJ0K6LOLuyC28+O4garjdaU2T3m5mk1Fki8g= +gopkg.in/launchdarkly/go-server-sdk-evaluation.v1 v1.5.0/go.mod h1:qzksXz/FZFSgeL5QaJVotUXvZ1wEBFnRvWyPf+DxZqs= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/testservice/sdk_client_entity.go b/testservice/sdk_client_entity.go new file mode 100644 index 00000000..f6163bdf --- /dev/null +++ b/testservice/sdk_client_entity.go @@ -0,0 +1,243 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "os" + "time" + + ld "gopkg.in/launchdarkly/go-server-sdk.v5" + "gopkg.in/launchdarkly/go-server-sdk.v5/interfaces" + "gopkg.in/launchdarkly/go-server-sdk.v5/interfaces/flagstate" + "gopkg.in/launchdarkly/go-server-sdk.v5/ldcomponents" + "gopkg.in/launchdarkly/go-server-sdk.v5/testservice/servicedef" + + "gopkg.in/launchdarkly/go-sdk-common.v2/ldlog" + "gopkg.in/launchdarkly/go-sdk-common.v2/ldreason" + "gopkg.in/launchdarkly/go-sdk-common.v2/ldvalue" +) + +const defaultStartWaitTime = 5 * time.Second + +type SDKClientEntity struct { + sdk *ld.LDClient + logger *log.Logger +} + +func NewSDKClientEntity(params servicedef.CreateInstanceParams) (*SDKClientEntity, error) { + c := &SDKClientEntity{} + c.logger = log.New(os.Stdout, fmt.Sprintf("[%s]: ", params.Tag), + log.Ldate|log.Ltime|log.Lmicroseconds|log.Lmsgprefix) + c.logger.Printf("Starting SDK client with configuration: %s", asJSON(params)) + + sdkLog := ldlog.NewDefaultLoggers() + sdkLog.SetBaseLogger(c.logger) + sdkLog.SetPrefix("[sdklog]") + sdkLog.SetMinLevel(ldlog.Debug) + + ldConfig := makeSDKConfig(params.Configuration, sdkLog) + + startWaitTime := defaultStartWaitTime + if params.Configuration.StartWaitTimeMS > 0 { + startWaitTime = time.Millisecond * time.Duration(params.Configuration.StartWaitTimeMS) + } + sdk, err := ld.MakeCustomClient(params.Configuration.Credential, ldConfig, startWaitTime) + if sdk == nil || (err != nil && !params.Configuration.InitCanFail) { + if sdk != nil { + _ = sdk.Close() + } + return nil, err + } + c.sdk = sdk + + return c, nil +} + +func (c *SDKClientEntity) Close() { + c.sdk.Close() + c.logger.Println("Test ended") + c.logger.SetOutput(ioutil.Discard) +} + +func (c *SDKClientEntity) DoCommand(params servicedef.CommandParams) (interface{}, error) { + c.logger.Printf("Test service sent command: %s", asJSON(params)) + switch params.Command { + case servicedef.CommandEvaluateFlag: + return c.evaluateFlag(*params.Evaluate) + case servicedef.CommandEvaluateAllFlags: + return c.evaluateAllFlags(*params.EvaluateAll) + case servicedef.CommandIdentifyEvent: + err := c.sdk.Identify(params.IdentifyEvent.User) + return nil, err + case servicedef.CommandCustomEvent: + if params.CustomEvent.MetricValue != nil { + return nil, c.sdk.TrackMetric(params.CustomEvent.EventKey, *params.CustomEvent.User, + *params.CustomEvent.MetricValue, params.CustomEvent.Data) + } + if params.CustomEvent.Data.IsDefined() { + return nil, c.sdk.TrackData(params.CustomEvent.EventKey, *params.CustomEvent.User, params.CustomEvent.Data) + } + return nil, c.sdk.TrackEvent(params.CustomEvent.EventKey, *params.CustomEvent.User) + case servicedef.CommandAliasEvent: + err := c.sdk.Alias(params.AliasEvent.User, params.AliasEvent.PreviousUser) + return nil, err + case servicedef.CommandFlushEvents: + c.sdk.Flush() + return nil, nil + case servicedef.CommandGetBigSegmentStoreStatus: + bigSegmentsStatus := c.sdk.GetBigSegmentStoreStatusProvider().GetStatus() + return servicedef.BigSegmentStoreStatusResponse(bigSegmentsStatus), nil + default: + return nil, BadRequestError{Message: fmt.Sprintf("unknown command %q", params.Command)} + } +} + +func (c *SDKClientEntity) evaluateFlag(p servicedef.EvaluateFlagParams) (*servicedef.EvaluateFlagResponse, error) { + if p.User == nil { + return nil, BadRequestError{"user is required for server-side evaluations"} + } + + var result ldreason.EvaluationDetail + if p.Detail { + switch p.ValueType { + case servicedef.ValueTypeBool: + var boolValue bool + boolValue, result, _ = c.sdk.BoolVariationDetail(p.FlagKey, *p.User, p.DefaultValue.BoolValue()) + result.Value = ldvalue.Bool(boolValue) + case servicedef.ValueTypeInt: + var intValue int + intValue, result, _ = c.sdk.IntVariationDetail(p.FlagKey, *p.User, p.DefaultValue.IntValue()) + result.Value = ldvalue.Int(intValue) + case servicedef.ValueTypeDouble: + var floatValue float64 + floatValue, result, _ = c.sdk.Float64VariationDetail(p.FlagKey, *p.User, p.DefaultValue.Float64Value()) + result.Value = ldvalue.Float64(floatValue) + case servicedef.ValueTypeString: + var strValue string + strValue, result, _ = c.sdk.StringVariationDetail(p.FlagKey, *p.User, p.DefaultValue.StringValue()) + result.Value = ldvalue.String(strValue) + default: + var jsonValue ldvalue.Value + jsonValue, result, _ = c.sdk.JSONVariationDetail(p.FlagKey, *p.User, p.DefaultValue) + result.Value = jsonValue + } + } else { + switch p.ValueType { + case servicedef.ValueTypeBool: + var boolValue bool + boolValue, _ = c.sdk.BoolVariation(p.FlagKey, *p.User, p.DefaultValue.BoolValue()) + result.Value = ldvalue.Bool(boolValue) + case servicedef.ValueTypeInt: + var intValue int + intValue, _ = c.sdk.IntVariation(p.FlagKey, *p.User, p.DefaultValue.IntValue()) + result.Value = ldvalue.Int(intValue) + case servicedef.ValueTypeDouble: + var floatValue float64 + floatValue, _ = c.sdk.Float64Variation(p.FlagKey, *p.User, p.DefaultValue.Float64Value()) + result.Value = ldvalue.Float64(floatValue) + case servicedef.ValueTypeString: + var strValue string + strValue, _ = c.sdk.StringVariation(p.FlagKey, *p.User, p.DefaultValue.StringValue()) + result.Value = ldvalue.String(strValue) + default: + result.Value, _ = c.sdk.JSONVariation(p.FlagKey, *p.User, p.DefaultValue) + } + } + rep := &servicedef.EvaluateFlagResponse{ + Value: result.Value, + VariationIndex: result.VariationIndex.AsPointer(), + } + if result.Reason.IsDefined() { + rep.Reason = &result.Reason + } + return rep, nil +} + +func (c *SDKClientEntity) evaluateAllFlags(p servicedef.EvaluateAllFlagsParams) (*servicedef.EvaluateAllFlagsResponse, error) { + if p.User == nil { + return nil, BadRequestError{"user is required for server-side evaluations"} + } + + var options []flagstate.Option + if p.ClientSideOnly { + options = append(options, flagstate.OptionClientSideOnly()) + } + if p.DetailsOnlyForTrackedFlags { + options = append(options, flagstate.OptionDetailsOnlyForTrackedFlags()) + } + if p.WithReasons { + options = append(options, flagstate.OptionWithReasons()) + } + + flagsState := c.sdk.AllFlagsState(*p.User, options...) + flagsJSON, _ := json.Marshal(flagsState) + var mapOut map[string]ldvalue.Value + _ = json.Unmarshal(flagsJSON, &mapOut) + return &servicedef.EvaluateAllFlagsResponse{State: mapOut}, nil +} + +func makeSDKConfig(config servicedef.SDKConfigParams, sdkLog ldlog.Loggers) ld.Config { + ret := ld.Config{} + ret.Logging = ldcomponents.Logging().Loggers(sdkLog) + + if config.Streaming != nil { + ret.ServiceEndpoints.Streaming = config.Streaming.BaseURI + builder := ldcomponents.StreamingDataSource() + if config.Streaming.InitialRetryDelayMs != nil { + builder.InitialReconnectDelay(time.Millisecond * time.Duration(*config.Streaming.InitialRetryDelayMs)) + } + ret.DataSource = builder + } + + if config.Events != nil { + ret.ServiceEndpoints.Events = config.Events.BaseURI + builder := ldcomponents.SendEvents(). + AllAttributesPrivate(config.Events.AllAttributesPrivate). + PrivateAttributeNames(config.Events.GlobalPrivateAttributes...). + InlineUsersInEvents(config.Events.InlineUsers) + if config.Events.Capacity.IsDefined() { + builder.Capacity(config.Events.Capacity.IntValue()) + } + if config.Events.FlushIntervalMS.IsDefined() { + builder.FlushInterval(time.Millisecond * time.Duration(config.Events.FlushIntervalMS)) + } + ret.Events = builder + ret.DiagnosticOptOut = !config.Events.EnableDiagnostics + } else { + ret.Events = ldcomponents.NoEvents() + } + + if config.BigSegments != nil { + fixture := &BigSegmentStoreFixture{service: &callbackService{baseURL: config.BigSegments.CallbackURI}} + builder := ldcomponents.BigSegments(fixture) + if config.BigSegments.UserCacheSize.IsDefined() { + builder.UserCacheSize(config.BigSegments.UserCacheSize.IntValue()) + } + if config.BigSegments.UserCacheTimeMS.IsDefined() { + builder.UserCacheTime(time.Millisecond * time.Duration(config.BigSegments.UserCacheTimeMS)) + } + if config.BigSegments.StaleAfterMS.IsDefined() { + builder.StaleAfter(time.Millisecond * time.Duration(config.BigSegments.StaleAfterMS)) + } + if config.BigSegments.StatusPollIntervalMS.IsDefined() { + builder.StatusPollInterval(time.Millisecond * time.Duration(config.BigSegments.StatusPollIntervalMS)) + } + ret.BigSegments = builder + } + + if config.Tags != nil { + ret.ApplicationInfo = interfaces.ApplicationInfo{ + ApplicationID: config.Tags.ApplicationID.StringValue(), + ApplicationVersion: config.Tags.ApplicationVersion.StringValue(), + } + } + + return ret +} + +func asJSON(value interface{}) string { + ret, _ := json.Marshal(value) + return string(ret) +} diff --git a/testservice/service.go b/testservice/service.go new file mode 100644 index 00000000..25bd5725 --- /dev/null +++ b/testservice/service.go @@ -0,0 +1,244 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "sync" + + "gopkg.in/launchdarkly/go-server-sdk.v5/testservice/servicedef" + + "gopkg.in/launchdarkly/go-sdk-common.v2/ldlog" + ld "gopkg.in/launchdarkly/go-server-sdk.v5" + + "github.com/gorilla/mux" +) + +const clientsBasePath = "/clients/" +const clientPath = clientsBasePath + "{id}" + +var capabilities = []string{ + servicedef.CapabilityServerSide, + servicedef.CapabilityStronglyTyped, + servicedef.CapabilityAllFlagsClientSideOnly, + servicedef.CapabilityAllFlagsDetailsOnlyForTrackedFlags, + servicedef.CapabilityAllFlagsWithReasons, + servicedef.CapabilityBigSegments, + servicedef.CapabilityTags, +} + +// gets the specified environment variable, or the default if not set +func getenv(envVar, defaultVal string) string { + ret := os.Getenv(envVar) + if len(ret) == 0 { + return defaultVal + } + return ret +} + +type TestService struct { + name string + Handler http.Handler + clients map[string]*SDKClientEntity + clientCounter int + loggers ldlog.Loggers + lock sync.Mutex +} + +type HTTPStatusError interface { + HTTPStatus() int +} + +type BadRequestError struct { + Message string +} + +func (e BadRequestError) Error() string { + return e.Message +} + +func (e BadRequestError) HTTPStatus() int { + return http.StatusBadRequest +} + +type NotFoundError struct{} + +func (e NotFoundError) Error() string { + return "not found" +} + +func (e NotFoundError) HTTPStatus() int { + return http.StatusNotFound +} + +func NewTestService(loggers ldlog.Loggers, name string) *TestService { + service := &TestService{ + name: name, + clients: make(map[string]*SDKClientEntity), + loggers: loggers, + } + + router := mux.NewRouter() + + router.HandleFunc("/", service.GetStatus).Methods("GET") + router.HandleFunc("/", service.DeleteStopService).Methods("DELETE") + router.HandleFunc("/", service.PostCreateClient).Methods("POST") + router.HandleFunc(clientPath, service.DeleteClient).Methods("DELETE") + router.HandleFunc(clientPath, service.PostCommand).Methods("POST") + + service.Handler = router + return service +} + +func (s *TestService) GetStatus(w http.ResponseWriter, r *http.Request) { + rep := servicedef.StatusRep{ + Name: s.name, + Capabilities: capabilities, + ClientVersion: ld.Version, + } + writeJSON(w, rep) +} + +func (s *TestService) DeleteStopService(w http.ResponseWriter, r *http.Request) { + fmt.Println("Test service has told us to exit") + os.Exit(0) +} + +func (s *TestService) PostCreateClient(w http.ResponseWriter, r *http.Request) { + var p servicedef.CreateInstanceParams + if err := readJSON(r, &p); err != nil { + writeError(w, err) + return + } + + loggers := s.loggers + loggers.SetPrefix(fmt.Sprintf("[sdklog:%s] ", p.Tag)) + + loggers.Info("Creating ") + c, err := NewSDKClientEntity(p) + if err != nil { + writeError(w, err) + return + } + + s.lock.Lock() + s.clientCounter++ + id := strconv.Itoa(s.clientCounter) + s.clients[id] = c + s.lock.Unlock() + + url := clientsBasePath + id + w.Header().Set("Location", url) + w.WriteHeader(http.StatusCreated) +} + +func (s *TestService) DeleteClient(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] + s.lock.Lock() + c := s.clients[id] + if c != nil { + delete(s.clients, id) + } + s.lock.Unlock() + + if c == nil { + writeError(w, NotFoundError{}) + return + } + + c.Close() + + w.WriteHeader(http.StatusNoContent) +} + +func (s *TestService) PostCommand(w http.ResponseWriter, r *http.Request) { + c, _, err := s.getClient(r) + if err != nil { + writeError(w, err) + return + } + var p servicedef.CommandParams + if err := readJSON(r, &p); err != nil { + writeError(w, err) + return + } + result, err := c.DoCommand(p) + if err != nil { + writeError(w, err) + return + } + if result == nil { + w.WriteHeader(http.StatusAccepted) + } else { + writeJSON(w, result) + } +} + +func (s *TestService) getClient(r *http.Request) (*SDKClientEntity, string, error) { + id := mux.Vars(r)["id"] + s.lock.Lock() + c := s.clients[id] + s.lock.Unlock() + if c != nil { + return c, id, nil + } + return nil, "", NotFoundError{} +} + +func readJSON(r *http.Request, dest interface{}) error { + if r.Body == nil { + return errors.New("request has no body") + } + return json.NewDecoder(r.Body).Decode(dest) +} + +func writeJSON(w http.ResponseWriter, rep interface{}) { + data, _ := json.Marshal(rep) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, _ = w.Write(data) +} + +func writeError(w http.ResponseWriter, err error) { + status := 500 + if hse, ok := err.(HTTPStatusError); ok { + status = hse.HTTPStatus() + } + w.WriteHeader(status) + _, _ = w.Write([]byte(err.Error())) + log.Printf("*** error: %s", err) +} + +func logLevelFromName(name string) ldlog.LogLevel { + switch strings.ToLower(name) { + case "debug": + return ldlog.Debug + case "info": + return ldlog.Info + case "warn": + return ldlog.Warn + case "error": + return ldlog.Error + } + return ldlog.Debug +} + +func main() { + loggers := ldlog.NewDefaultLoggers() + loggers.SetMinLevel(logLevelFromName(os.Getenv("LD_LOG_LEVEL"))) + + port := "8000" + service := NewTestService(loggers, "go-server-sdk") + server := &http.Server{Handler: service.Handler, Addr: ":" + port} + fmt.Printf("Listening on port %s\n", port) + err := server.ListenAndServe() + if err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/testservice/servicedef/callbackfixtures/big_segment_store.go b/testservice/servicedef/callbackfixtures/big_segment_store.go new file mode 100644 index 00000000..04a9f7f4 --- /dev/null +++ b/testservice/servicedef/callbackfixtures/big_segment_store.go @@ -0,0 +1,20 @@ +package callbackfixtures + +import "gopkg.in/launchdarkly/go-sdk-common.v2/ldtime" + +const ( + BigSegmentStorePathGetMetadata = "/getMetadata" + BigSegmentStorePathGetMembership = "/getMembership" +) + +type BigSegmentStoreGetMetadataResponse struct { + LastUpToDate ldtime.UnixMillisecondTime `json:"lastUpToDate"` +} + +type BigSegmentStoreGetMembershipParams struct { + UserHash string `json:"userHash"` +} + +type BigSegmentStoreGetMembershipResponse struct { + Values map[string]bool `json:"values,omitempty"` +} diff --git a/testservice/servicedef/callbackfixtures/persistent_data_store.go b/testservice/servicedef/callbackfixtures/persistent_data_store.go new file mode 100644 index 00000000..fb243d27 --- /dev/null +++ b/testservice/servicedef/callbackfixtures/persistent_data_store.go @@ -0,0 +1,52 @@ +package callbackfixtures + +type DataStoreSerializedCollection struct { + Kind string `json:"kind"` + Items []DataStoreSerializedKeyedItem `json:"items"` +} + +type DataStoreSerializedKeyedItem struct { + Key string `json:"key"` + Item DataStoreSerializedItem `json:"item"` +} + +type DataStoreSerializedItem struct { + Version int `json:"version"` + SerializedItem string `json:"serializedItem,omitempty"` +} + +type DataStoreStatusResponse struct { + Available bool `json:"available"` + Initialized bool `json:"initialized"` +} + +type DataStoreInitParams struct { + AllData []DataStoreSerializedCollection +} + +type DataStoreGetParams struct { + Kind string `json:"kind"` + Key string `json:"key"` +} + +type DataStoreGetResponse struct { + Item *DataStoreSerializedItem `json:"item,omitempty"` +} + +type DataStoreGetAllParams struct { + Kind string `json:"kind"` +} + +type DataStoreGetAllResponse struct { + Items []DataStoreSerializedKeyedItem `json:"items"` +} + +type DataStoreUpsertParams struct { + Kind string `json:"kind"` + Key string `json:"key"` + Item DataStoreSerializedItem `json:"item"` +} + +type DataStoreUpsertResponse struct { + Updated bool `json:"updated"` +} diff --git a/testservice/servicedef/command_params.go b/testservice/servicedef/command_params.go new file mode 100644 index 00000000..eb0ad1bf --- /dev/null +++ b/testservice/servicedef/command_params.go @@ -0,0 +1,83 @@ +package servicedef + +import ( + "gopkg.in/launchdarkly/go-sdk-common.v2/ldreason" + "gopkg.in/launchdarkly/go-sdk-common.v2/lduser" + "gopkg.in/launchdarkly/go-sdk-common.v2/ldvalue" +) + +const ( + CommandEvaluateFlag = "evaluate" + CommandEvaluateAllFlags = "evaluateAll" + CommandIdentifyEvent = "identifyEvent" + CommandCustomEvent = "customEvent" + CommandAliasEvent = "aliasEvent" + CommandFlushEvents = "flushEvents" + CommandGetBigSegmentStoreStatus = "getBigSegmentStoreStatus" +) + +type ValueType string + +const ( + ValueTypeBool = "bool" + ValueTypeInt = "int" + ValueTypeDouble = "double" + ValueTypeString = "string" + ValueTypeAny = "any" +) + +type CommandParams struct { + Command string `json:"command"` + Evaluate *EvaluateFlagParams `json:"evaluate,omitempty"` + EvaluateAll *EvaluateAllFlagsParams `json:"evaluateAll,omitempty"` + CustomEvent *CustomEventParams `json:"customEvent,omitempty"` + IdentifyEvent *IdentifyEventParams `json:"identifyEvent,omitempty"` + AliasEvent *AliasEventParams `json:"aliasEvent,omitempty"` +} + +type EvaluateFlagParams struct { + FlagKey string `json:"flagKey"` + User *lduser.User `json:"user,omitempty"` + ValueType ValueType `json:"valueType"` + DefaultValue ldvalue.Value `json:"defaultValue"` + Detail bool `json:"detail"` +} + +type EvaluateFlagResponse struct { + Value ldvalue.Value `json:"value"` + VariationIndex *int `json:"variationIndex,omitempty"` + Reason *ldreason.EvaluationReason `json:"reason,omitempty"` +} + +type EvaluateAllFlagsParams struct { + User *lduser.User `json:"user,omitempty"` + WithReasons bool `json:"withReasons"` + ClientSideOnly bool `json:"clientSideOnly"` + DetailsOnlyForTrackedFlags bool `json:"detailsOnlyForTrackedFlags"` +} + +type EvaluateAllFlagsResponse struct { + State map[string]ldvalue.Value `json:"state"` +} + +type CustomEventParams struct { + EventKey string `json:"eventKey"` + User *lduser.User `json:"user,omitempty"` + Data ldvalue.Value `json:"data,omitempty"` + OmitNullData bool `json:"omitNullData"` + MetricValue *float64 `json:"metricValue,omitempty"` +} + +type IdentifyEventParams struct { + User lduser.User `json:"user"` +} + +type AliasEventParams struct { + User lduser.User `json:"user"` + PreviousUser lduser.User `json:"previousUser"` +} + +type BigSegmentStoreStatusResponse struct { + Available bool `json:"available"` + Stale bool `json:"stale"` +} diff --git a/testservice/servicedef/sdk_config.go b/testservice/servicedef/sdk_config.go new file mode 100644 index 00000000..a394448c --- /dev/null +++ b/testservice/servicedef/sdk_config.go @@ -0,0 +1,50 @@ +package servicedef + +import ( + "gopkg.in/launchdarkly/go-sdk-common.v2/ldtime" + "gopkg.in/launchdarkly/go-sdk-common.v2/lduser" + "gopkg.in/launchdarkly/go-sdk-common.v2/ldvalue" +) + +type SDKConfigParams struct { + Credential string `json:"credential"` + StartWaitTimeMS ldtime.UnixMillisecondTime `json:"startWaitTimeMs,omitempty"` + InitCanFail bool `json:"initCanFail,omitempty"` + Streaming *SDKConfigStreamingParams `json:"streaming,omitempty"` + Events *SDKConfigEventParams `json:"events,omitempty"` + PersistentDataStore *SDKConfigPersistentDataStoreParams `json:"persistentDataStore,omitempty"` + BigSegments *SDKConfigBigSegmentsParams `json:"bigSegments,omitempty"` + Tags *SDKConfigTagsParams `json:"tags,omitempty"` +} + +type SDKConfigStreamingParams struct { + BaseURI string `json:"baseUri,omitempty"` + InitialRetryDelayMs *ldtime.UnixMillisecondTime `json:"initialRetryDelayMs,omitempty"` +} + +type SDKConfigEventParams struct { + BaseURI string `json:"baseUri,omitempty"` + Capacity ldvalue.OptionalInt `json:"capacity,omitempty"` + EnableDiagnostics bool `json:"enableDiagnostics"` + AllAttributesPrivate bool `json:"allAttributesPrivate,omitempty"` + GlobalPrivateAttributes []lduser.UserAttribute `json:"globalPrivateAttributes,omitempty"` + FlushIntervalMS ldtime.UnixMillisecondTime `json:"flushIntervalMs,omitempty"` + InlineUsers bool `json:"inlineUsers,omitempty"` +} + +type SDKConfigPersistentDataStoreParams struct { + CallbackURI string `json:"callbackURI"` +} + +type SDKConfigBigSegmentsParams struct { + CallbackURI string `json:"callbackUri"` + UserCacheSize ldvalue.OptionalInt `json:"userCacheSize,omitempty"` + UserCacheTimeMS ldtime.UnixMillisecondTime `json:"userCacheTimeMs,omitempty"` + StatusPollIntervalMS ldtime.UnixMillisecondTime `json:"statusPollIntervalMs,omitempty"` + StaleAfterMS ldtime.UnixMillisecondTime `json:"staleAfterMs,omitempty"` +} + +type SDKConfigTagsParams struct { + ApplicationID ldvalue.OptionalString `json:"applicationId,omitempty"` + ApplicationVersion ldvalue.OptionalString `json:"applicationVersion,omitempty"` +} diff --git a/testservice/servicedef/service_params.go b/testservice/servicedef/service_params.go new file mode 100644 index 00000000..ea66b179 --- /dev/null +++ b/testservice/servicedef/service_params.go @@ -0,0 +1,29 @@ +package servicedef + +const ( + CapabilityClientSide = "client-side" + CapabilityServerSide = "server-side" + CapabilityStronglyTyped = "strongly-typed" + + CapabilityAllFlagsWithReasons = "all-flags-with-reasons" + CapabilityAllFlagsClientSideOnly = "all-flags-client-side-only" + CapabilityAllFlagsDetailsOnlyForTrackedFlags = "all-flags-details-only-for-tracked-flags" + + CapabilityBigSegments = "big-segments" + CapabilityTags = "tags" +) + +type StatusRep struct { + // Name is the name of the project that the test service is testing, such as "go-server-sdk". + Name string `json:"name"` + + // Capabilities is a list of strings representing optional features of the test service. + Capabilities []string `json:"capabilities"` + + ClientVersion string `json:"clientVersion"` +} + +type CreateInstanceParams struct { + Configuration SDKConfigParams `json:"configuration"` + Tag string `json:"tag"` +}