Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/dev_server/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ package dev_server

const (
ContextFlag = "context"
OverrideFlag = "override"
SourceEnvironmentFlag = "source"
)
55 changes: 51 additions & 4 deletions cmd/dev_server/start_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dev_server

import (
"context"
"encoding/json"
"errors"
"log"
"os/exec"
Expand All @@ -10,10 +11,12 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
"github.com/launchdarkly/ldcli/cmd/cliflags"
resourcescmd "github.com/launchdarkly/ldcli/cmd/resources"
"github.com/launchdarkly/ldcli/cmd/validators"
"github.com/launchdarkly/ldcli/internal/dev_server"
"github.com/launchdarkly/ldcli/internal/dev_server/model"
)

func NewStartServerCmd(client dev_server.Client) *cobra.Command {
Expand All @@ -28,17 +31,61 @@ func NewStartServerCmd(client dev_server.Client) *cobra.Command {

cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate())

cmd.Flags().String(cliflags.ProjectFlag, "", "The project key")
_ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag))

cmd.Flags().String(SourceEnvironmentFlag, "", "environment to copy flag values from")
_ = viper.BindPFlag(SourceEnvironmentFlag, cmd.Flags().Lookup(SourceEnvironmentFlag))

cmd.Flags().String(ContextFlag, "", `Stringified JSON representation of your context object ex. {"kind": "multi", "user": { "email": "[email protected]", "username": "foo", "key": "bar"}`)
_ = viper.BindPFlag(ContextFlag, cmd.Flags().Lookup(ContextFlag))

cmd.Flags().String(OverrideFlag, "", `Stringified JSON representation of flag overrides ex. {"flagName": true, "stringFlagName": "test" }`)
_ = viper.BindPFlag(OverrideFlag, cmd.Flags().Lookup(OverrideFlag))

return cmd
}

func startServer(client dev_server.Client) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
ctx := context.Background()

var initialSetting model.InitialProjectSettings

if viper.IsSet(cliflags.ProjectFlag) && viper.IsSet(SourceEnvironmentFlag) {

initialSetting = model.InitialProjectSettings{
Enabled: true,
ProjectKey: viper.GetString(cliflags.ProjectFlag),
EnvKey: viper.GetString(SourceEnvironmentFlag),
}
if viper.IsSet(ContextFlag) {
var c ldcontext.Context
contextString := viper.GetString(ContextFlag)
err := c.UnmarshalJSON([]byte(contextString))
if err != nil {
return err
}
initialSetting.Context = &c
}

if viper.IsSet(OverrideFlag) {
var override map[string]model.FlagValue
overrideString := viper.GetString(OverrideFlag)
err := json.Unmarshal([]byte(overrideString), &override)
if err != nil {
return err
}
initialSetting.Overrides = override
}
}

params := dev_server.ServerParams{
AccessToken: viper.GetString(cliflags.AccessTokenFlag),
BaseURI: viper.GetString(cliflags.BaseURIFlag),
DevStreamURI: viper.GetString(cliflags.DevStreamURIFlag),
Port: viper.GetString(cliflags.PortFlag),
AccessToken: viper.GetString(cliflags.AccessTokenFlag),
BaseURI: viper.GetString(cliflags.BaseURIFlag),
DevStreamURI: viper.GetString(cliflags.DevStreamURIFlag),
Port: viper.GetString(cliflags.PortFlag),
InitialProjectSettings: initialSetting,
}

client.RunServer(ctx, params)
Expand Down
12 changes: 12 additions & 0 deletions internal/dev_server/adapters/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package adapters

import (
"context"
ldapi "github.com/launchdarkly/api-client-go/v14"
)

func WithApiAndSdk(ctx context.Context, client ldapi.APIClient, streamingUrl string) context.Context {
ctx = WithSdk(ctx, newSdk(streamingUrl))
ctx = WithApi(ctx, NewApi(client))
return ctx
}
3 changes: 1 addition & 2 deletions internal/dev_server/adapters/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ func Middleware(client ldapi.APIClient, streamingUrl string) func(handler http.H
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
ctx := request.Context()
ctx = WithSdk(ctx, newSdk(streamingUrl))
ctx = WithApi(ctx, NewApi(client))
ctx = WithApiAndSdk(ctx, client, streamingUrl)
request = request.WithContext(ctx)
handler.ServeHTTP(writer, request)
})
Expand Down
21 changes: 16 additions & 5 deletions internal/dev_server/dev_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ type Client interface {
}

type ServerParams struct {
AccessToken string
BaseURI string
DevStreamURI string
Port string
AccessToken string
BaseURI string
DevStreamURI string
Port string
InitialProjectSettings model.InitialProjectSettings
}

type LDClient struct {
Expand All @@ -49,6 +50,7 @@ func (c LDClient) RunServer(ctx context.Context, serverParams ServerParams) {
if err != nil {
log.Fatal(err)
}
observers := model.NewObservers()
ss := api.NewStrictServer()
apiServer := api.NewStrictHandlerWithOptions(ss, nil, api.StrictHTTPServerOptions{
RequestErrorHandlerFunc: api.RequestErrorHandler,
Expand All @@ -57,7 +59,7 @@ func (c LDClient) RunServer(ctx context.Context, serverParams ServerParams) {
r := mux.NewRouter()
r.Use(adapters.Middleware(*ldClient, serverParams.DevStreamURI))
r.Use(model.StoreMiddleware(sqlStore))
r.Use(model.ObserversMiddleware(model.NewObservers()))
r.Use(model.ObserversMiddleware(observers))
r.Handle("/", http.RedirectHandler("/ui/", http.StatusFound))
r.Handle("/ui", http.RedirectHandler("/ui/", http.StatusMovedPermanently))
r.PathPrefix("/ui/").Handler(http.StripPrefix("/ui/", ui.AssetHandler))
Expand All @@ -66,9 +68,18 @@ func (c LDClient) RunServer(ctx context.Context, serverParams ServerParams) {
handler = handlers.CombinedLoggingHandler(os.Stdout, handler)
handler = handlers.RecoveryHandler(handlers.PrintRecoveryStack(true))(handler)

ctx = adapters.WithApiAndSdk(ctx, *ldClient, serverParams.DevStreamURI)
ctx = model.SetObserversOnContext(ctx, observers)
ctx = model.ContextWithStore(ctx, sqlStore)
syncErr := model.CreateOrSyncProject(ctx, serverParams.InitialProjectSettings)
if syncErr != nil {
log.Fatal(syncErr)
}

addr := fmt.Sprintf("0.0.0.0:%s", serverParams.Port)
log.Printf("Server running on %s", addr)
log.Printf("Access the UI for toggling overrides at http://localhost:%s/ui or by running `ldcli dev-server ui`", serverParams.Port)

server := http.Server{
Addr: addr,
Handler: handler,
Expand Down
53 changes: 53 additions & 0 deletions internal/dev_server/model/sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package model

import (
"context"
"log"

"github.com/pkg/errors"

"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
)

type FlagValue = ldvalue.Value

type InitialProjectSettings struct {
Enabled bool
ProjectKey string
EnvKey string
Context *ldcontext.Context `json:"context,omitempty"`
Overrides map[string]FlagValue `json:"overrides,omitempty"`
}

func CreateOrSyncProject(ctx context.Context, settings InitialProjectSettings) error {
if !settings.Enabled {
return nil
}

log.Printf("Initial project [%s] with env [%s]", settings.ProjectKey, settings.EnvKey)
var project Project
project, createError := CreateProject(ctx, settings.ProjectKey, settings.EnvKey, settings.Context)
if createError != nil {
if errors.Is(createError, ErrAlreadyExists) {
log.Printf("Project [%s] exists, refreshing data", settings.ProjectKey)
var updateErr error
project, updateErr = UpdateProject(ctx, settings.ProjectKey, settings.Context, &settings.EnvKey)
if updateErr != nil {
return updateErr
}

} else {
return createError
}
}
for flagKey, val := range settings.Overrides {
_, err := UpsertOverride(ctx, settings.ProjectKey, flagKey, val)
if err != nil {
return err
}
}

log.Printf("Successfully synced Initial project [%s]", project.Key)
return nil
}
173 changes: 173 additions & 0 deletions internal/dev_server/model/sync_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package model_test

import (
"context"
"testing"

"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"

ldapi "github.com/launchdarkly/api-client-go/v14"
"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
"github.com/launchdarkly/go-server-sdk/v7/interfaces/flagstate"
adapters_mocks "github.com/launchdarkly/ldcli/internal/dev_server/adapters/mocks"
"github.com/launchdarkly/ldcli/internal/dev_server/model"
"github.com/launchdarkly/ldcli/internal/dev_server/model/mocks"
)

func TestInitialSync(t *testing.T) {

ctx := context.Background()
mockController := gomock.NewController(t)
observers := model.NewObservers()
ctx, api, sdk := adapters_mocks.WithMockApiAndSdk(ctx, mockController)
store := mocks.NewMockStore(mockController)
ctx = model.ContextWithStore(ctx, store)
ctx = model.SetObserversOnContext(ctx, observers)
projKey := "proj"
sourceEnvKey := "env"
sdkKey := "thing"

allFlagsState := flagstate.NewAllFlagsBuilder().
AddFlag("boolFlag", flagstate.FlagState{Value: ldvalue.Bool(true)}).
Build()

trueVariationId, falseVariationId := "true", "false"
allFlags := []ldapi.FeatureFlag{{
Name: "bool flag",
Kind: "bool",
Key: "boolFlag",
Variations: []ldapi.Variation{
{
Id: &trueVariationId,
Value: true,
},
{
Id: &falseVariationId,
Value: false,
},
},
}}

t.Run("Returns no error if disabled", func(t *testing.T) {
input := model.InitialProjectSettings{
Enabled: false,
ProjectKey: projKey,
EnvKey: sourceEnvKey,
Context: nil,
Overrides: nil,
}
err := model.CreateOrSyncProject(ctx, input)
assert.NoError(t, err)
})

t.Run("Returns error if it cant fetch flag state", func(t *testing.T) {
api.EXPECT().GetSdkKey(gomock.Any(), projKey, sourceEnvKey).Return("", errors.New("fetch flag state fails"))
input := model.InitialProjectSettings{
Enabled: true,
ProjectKey: projKey,
EnvKey: sourceEnvKey,
Context: nil,
Overrides: nil,
}
err := model.CreateOrSyncProject(ctx, input)
assert.NotNil(t, err)
assert.Equal(t, "fetch flag state fails", err.Error())
})

t.Run("Returns error if it can't fetch flags", func(t *testing.T) {
api.EXPECT().GetSdkKey(gomock.Any(), projKey, sourceEnvKey).Return(sdkKey, nil)
sdk.EXPECT().GetAllFlagsState(gomock.Any(), gomock.Any(), sdkKey).Return(allFlagsState, nil)
api.EXPECT().GetAllFlags(gomock.Any(), projKey).Return(nil, errors.New("fetch flags failed"))
input := model.InitialProjectSettings{
Enabled: true,
ProjectKey: projKey,
EnvKey: sourceEnvKey,
Context: nil,
Overrides: nil,
}
err := model.CreateOrSyncProject(ctx, input)
assert.NotNil(t, err)
assert.Equal(t, "fetch flags failed", err.Error())
})

t.Run("Returns error if it fails to insert the project", func(t *testing.T) {
api.EXPECT().GetSdkKey(gomock.Any(), projKey, sourceEnvKey).Return(sdkKey, nil)
sdk.EXPECT().GetAllFlagsState(gomock.Any(), gomock.Any(), sdkKey).Return(allFlagsState, nil)
api.EXPECT().GetAllFlags(gomock.Any(), projKey).Return(allFlags, nil)
store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).Return(errors.New("insert fails"))

input := model.InitialProjectSettings{
Enabled: true,
ProjectKey: projKey,
EnvKey: sourceEnvKey,
Context: nil,
Overrides: nil,
}
err := model.CreateOrSyncProject(ctx, input)
assert.NotNil(t, err)
assert.Equal(t, "insert fails", err.Error())
})

t.Run("Successfully creates project", func(t *testing.T) {
api.EXPECT().GetSdkKey(gomock.Any(), projKey, sourceEnvKey).Return(sdkKey, nil)
sdk.EXPECT().GetAllFlagsState(gomock.Any(), gomock.Any(), sdkKey).Return(allFlagsState, nil)
api.EXPECT().GetAllFlags(gomock.Any(), projKey).Return(allFlags, nil)
store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).Return(nil)

input := model.InitialProjectSettings{
Enabled: true,
ProjectKey: projKey,
EnvKey: sourceEnvKey,
Context: nil,
Overrides: nil,
}
err := model.CreateOrSyncProject(ctx, input)

assert.NoError(t, err)
})
t.Run("Successfully creates project with override", func(t *testing.T) {
override := model.Override{
ProjectKey: projKey,
FlagKey: "boolFlag",
Value: ldvalue.Bool(true),
Active: true,
Version: 1,
}

proj := model.Project{
Key: projKey,
SourceEnvironmentKey: sourceEnvKey,
Context: ldcontext.New(t.Name()),
AllFlagsState: map[string]model.FlagState{
"boolFlag": {
Version: 0,
Value: ldvalue.Bool(false),
},
},
}

api.EXPECT().GetSdkKey(gomock.Any(), projKey, sourceEnvKey).Return(sdkKey, nil)
sdk.EXPECT().GetAllFlagsState(gomock.Any(), gomock.Any(), sdkKey).Return(allFlagsState, nil)
api.EXPECT().GetAllFlags(gomock.Any(), projKey).Return(allFlags, nil)
store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).Return(nil)
store.EXPECT().UpsertOverride(gomock.Any(), override).Return(override, nil)
store.EXPECT().GetDevProject(gomock.Any(), projKey).Return(&proj, nil)

input := model.InitialProjectSettings{
Enabled: true,
ProjectKey: projKey,
EnvKey: sourceEnvKey,
Context: nil,
Overrides: map[string]model.FlagValue{
"boolFlag": ldvalue.Bool(true),
},
}
err := model.CreateOrSyncProject(ctx, input)

assert.NoError(t, err)
})

}
Loading