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
71 changes: 71 additions & 0 deletions internal/filedata/document.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Package filedata contains the file reading, parsing, and merging logic shared by the
// components that load flag and segment data from local files.
package filedata

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"

"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
"github.com/launchdarkly/go-server-sdk-evaluation/v3/ldbuilders"
"github.com/launchdarkly/go-server-sdk-evaluation/v3/ldmodel"

"gopkg.in/ghodss/yaml.v1"
)

// Document is the parsed form of a single data file. A document may contain full flag
// definitions, simplified flag-key-to-value entries, and segment definitions.
type Document struct {
Flags *map[string]ldmodel.FeatureFlag
FlagValues *map[string]ldvalue.Value
Segments *map[string]ldmodel.Segment
}

// ReadFile reads and parses a single data file, which may be in JSON or YAML format.
func ReadFile(path string) (Document, error) {
var data Document
var rawData []byte
var err error
if rawData, err = os.ReadFile(path); err != nil { //nolint:gosec // G304: ok to read file into variable
return data, fmt.Errorf("unable to read file: %s", err)
}
if detectJSON(rawData) {
err = json.Unmarshal(rawData, &data)
} else {
err = yaml.Unmarshal(rawData, &data)
}
if err != nil {
err = fmt.Errorf("error parsing file: %s", err)
}
return data, err
}

func detectJSON(rawData []byte) bool {
// A valid JSON file for our purposes must be an object, i.e. it must start with '{'
return strings.HasPrefix(strings.TrimLeftFunc(string(rawData), unicode.IsSpace), "{")
}

// AbsFilePaths converts each of the given paths to an absolute path.
func AbsFilePaths(paths []string) ([]string, error) {
absPaths := make([]string, 0)
for _, p := range paths {
absPath, err := filepath.Abs(p)
if err != nil {
// COVERAGE: there's no reliable cross-platform way to simulate an invalid path in unit tests
return nil, fmt.Errorf("unable to determine absolute path for '%s'", p)
}
absPaths = append(absPaths, absPath)
}
return absPaths, nil
}

// MakeFlagWithValue expands a flag-key-to-value entry into a full flag definition that
// returns the given value for every context.
func MakeFlagWithValue(key string, v interface{}) *ldmodel.FeatureFlag {
flag := ldbuilders.NewFlagBuilder(key).SingleVariation(ldvalue.CopyArbitraryValue(v)).Build()
return &flag
}
158 changes: 158 additions & 0 deletions internal/filedata/filedata_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package filedata

import (
"os"
"path/filepath"
"testing"

"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
"github.com/launchdarkly/go-server-sdk-evaluation/v3/ldbuilders"
"github.com/launchdarkly/go-server-sdk-evaluation/v3/ldmodel"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func writeTempFile(t *testing.T, content string) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "filedata-test")
require.NoError(t, err)
_, err = f.WriteString(content)
require.NoError(t, err)
require.NoError(t, f.Close())
return f.Name()
}

func TestReadFileJSON(t *testing.T) {
path := writeTempFile(t, `{"flagValues": {"my-flag": true}, "segments": {"my-segment": {"key": "my-segment", "version": 3}}}`)
doc, err := ReadFile(path)
require.NoError(t, err)
require.NotNil(t, doc.FlagValues)
assert.Equal(t, ldvalue.Bool(true), (*doc.FlagValues)["my-flag"])
require.NotNil(t, doc.Segments)
assert.Equal(t, 3, (*doc.Segments)["my-segment"].Version)
assert.Nil(t, doc.Flags)
}

func TestReadFileYAML(t *testing.T) {
path := writeTempFile(t, "flagValues:\n my-flag: yes\n")
doc, err := ReadFile(path)
require.NoError(t, err)
require.NotNil(t, doc.FlagValues)
assert.Equal(t, ldvalue.Bool(true), (*doc.FlagValues)["my-flag"])
}

func TestReadFileErrors(t *testing.T) {
_, err := ReadFile(filepath.Join(t.TempDir(), "nonexistent"))
assert.ErrorContains(t, err, "unable to read file")

path := writeTempFile(t, `{"flagValues"`)
_, err = ReadFile(path)
assert.ErrorContains(t, err, "error parsing file")

path = writeTempFile(t, "\t: not yaml")
_, err = ReadFile(path)
assert.ErrorContains(t, err, "error parsing file")
}

func TestAbsFilePaths(t *testing.T) {
abs, err := AbsFilePaths([]string{"relative/path", string(filepath.Separator) + "already-absolute"})
require.NoError(t, err)
require.Len(t, abs, 2)
for _, p := range abs {
assert.True(t, filepath.IsAbs(p), "expected absolute path, got %s", p)
}
}

func TestMakeFlagWithValue(t *testing.T) {
flag := MakeFlagWithValue("my-flag", "on")
assert.Equal(t, "my-flag", flag.Key)
require.Len(t, flag.Variations, 1)
assert.Equal(t, ldvalue.String("on"), flag.Variations[0])
require.NotNil(t, flag.OffVariation)
}

func docWithFlag(flag ldmodel.FeatureFlag) Document {
flags := map[string]ldmodel.FeatureFlag{flag.Key: flag}
return Document{Flags: &flags}
}

func docWithFlagValue(key string, value ldvalue.Value) Document {
values := map[string]ldvalue.Value{key: value}
return Document{FlagValues: &values}
}

func docWithSegment(segment ldmodel.Segment) Document {
segments := map[string]ldmodel.Segment{segment.Key: segment}
return Document{Segments: &segments}
}

func TestMergeCombinesDocuments(t *testing.T) {
flag1 := ldbuilders.NewFlagBuilder("flag1").Version(2).Build()
segment1 := ldbuilders.NewSegmentBuilder("segment1").Version(4).Build()

result, err := Merge(DuplicateKeysFail,
docWithFlag(flag1), docWithFlagValue("flag2", ldvalue.Bool(true)), docWithSegment(segment1))
require.NoError(t, err)

require.Len(t, result.Flags, 2)
assert.Equal(t, "flag1", result.Flags[0].Key)
assert.Equal(t, 2, result.Flags[0].Item.Version)
require.IsType(t, &ldmodel.FeatureFlag{}, result.Flags[0].Item.Item)
assert.Equal(t, "flag2", result.Flags[1].Key)
expanded := result.Flags[1].Item.Item.(*ldmodel.FeatureFlag)
require.Len(t, expanded.Variations, 1)
assert.Equal(t, ldvalue.Bool(true), expanded.Variations[0])

require.Len(t, result.Segments, 1)
assert.Equal(t, "segment1", result.Segments[0].Key)
assert.Equal(t, 4, result.Segments[0].Item.Version)
}

func TestMergeDuplicateKeys(t *testing.T) {
flagA := ldbuilders.NewFlagBuilder("flag1").Version(1).Build()
flagB := ldbuilders.NewFlagBuilder("flag1").Version(2).Build()

t.Run("fail", func(t *testing.T) {
_, err := Merge(DuplicateKeysFail, docWithFlag(flagA), docWithFlag(flagB))
assert.ErrorContains(t, err, "flag 'flag1' is specified by multiple files")
})

t.Run("unrecognized handling behaves as fail", func(t *testing.T) {
_, err := Merge(DuplicateKeysHandling("bogus"), docWithFlag(flagA), docWithFlag(flagB))
assert.Error(t, err)
})

t.Run("ignore all but first", func(t *testing.T) {
result, err := Merge(DuplicateKeysIgnoreAllButFirst, docWithFlag(flagA), docWithFlag(flagB))
require.NoError(t, err)
require.Len(t, result.Flags, 1)
assert.Equal(t, 1, result.Flags[0].Item.Version)
})

t.Run("full flag and flag value collide", func(t *testing.T) {
_, err := Merge(DuplicateKeysFail, docWithFlag(flagA), docWithFlagValue("flag1", ldvalue.Bool(true)))
assert.Error(t, err)
})

t.Run("duplicate segments", func(t *testing.T) {
segment := ldbuilders.NewSegmentBuilder("segment1").Build()
_, err := Merge(DuplicateKeysFail, docWithSegment(segment), docWithSegment(segment))
assert.ErrorContains(t, err, "segment 'segment1' is specified by multiple files")
})
}

func TestMergePreservesDocumentOrder(t *testing.T) {
docs := make([]Document, 0, 5)
expectedKeys := []string{"flag-a", "flag-b", "flag-c", "flag-d", "flag-e"}
for _, key := range expectedKeys {
docs = append(docs, docWithFlag(ldbuilders.NewFlagBuilder(key).Build()))
}
result, err := Merge(DuplicateKeysFail, docs...)
require.NoError(t, err)
keys := make([]string, 0, len(result.Flags))
for _, item := range result.Flags {
keys = append(keys, item.Key)
}
assert.Equal(t, expectedKeys, keys)
}
100 changes: 100 additions & 0 deletions internal/filedata/merge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package filedata

import (
"fmt"

"github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes"
)

// DuplicateKeysHandling determines what happens when the same flag or segment key appears
// in more than one document.
//
// The values match the public option types in the packages that expose this behavior, so
// those types can be converted to this one directly.
type DuplicateKeysHandling string

const (
// DuplicateKeysFail means a duplicated key causes the merge to fail.
DuplicateKeysFail DuplicateKeysHandling = "fail"
// DuplicateKeysIgnoreAllButFirst means only the first occurrence of a duplicated key is
// used, in the order the documents were given.
DuplicateKeysIgnoreAllButFirst DuplicateKeysHandling = "ignore"
)

// MergeResult holds the merged items from one or more documents. Item values are
// *ldmodel.FeatureFlag or *ldmodel.Segment.
//
// Ordering is deterministic at document granularity only: all of one document's items
// precede the next document's, matching the order the documents were given, but the
// relative order of items within a single document is unspecified. Consumers key items by
// their Key and must not rely on within-document ordering.
type MergeResult struct {
Flags []ldstoretypes.KeyedItemDescriptor
Segments []ldstoretypes.KeyedItemDescriptor
}

type itemCategory string

const (
flagCategory itemCategory = "flag"
segmentCategory itemCategory = "segment"
)

// Merge combines the items of the given documents, expanding flag-value entries into full
// flag definitions and applying the given duplicate-key handling. An unrecognized
// DuplicateKeysHandling value behaves as DuplicateKeysFail.
func Merge(duplicateKeysHandling DuplicateKeysHandling, docs ...Document) (MergeResult, error) {
var result MergeResult
seenKeys := map[itemCategory]map[string]bool{
flagCategory: {},
segmentCategory: {},
}

insert := func(
items *[]ldstoretypes.KeyedItemDescriptor,
category itemCategory,
key string,
data ldstoretypes.ItemDescriptor,
) error {
if seenKeys[category][key] {
switch duplicateKeysHandling {
case DuplicateKeysIgnoreAllButFirst:
return nil
default:
return fmt.Errorf("%s '%s' is specified by multiple files", category, key)
}
}
*items = append(*items, ldstoretypes.KeyedItemDescriptor{Key: key, Item: data})
seenKeys[category][key] = true
return nil
}

for _, d := range docs {
if d.Flags != nil {
for key, f := range *d.Flags {
data := ldstoretypes.ItemDescriptor{Version: f.Version, Item: &f}
if err := insert(&result.Flags, flagCategory, key, data); err != nil {
return MergeResult{}, err
}
}
}
if d.FlagValues != nil {
for key, value := range *d.FlagValues {
flag := MakeFlagWithValue(key, value)
data := ldstoretypes.ItemDescriptor{Version: flag.Version, Item: flag}
if err := insert(&result.Flags, flagCategory, key, data); err != nil {
return MergeResult{}, err
}
}
}
if d.Segments != nil {
for key, s := range *d.Segments {
data := ldstoretypes.ItemDescriptor{Version: s.Version, Item: &s}
if err := insert(&result.Segments, segmentCategory, key, data); err != nil {
return MergeResult{}, err
}
}
}
}
return result, nil
}
Loading
Loading