From c678077646f28f0a5558ad93f7c9ac178eac4913 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 30 Oct 2018 14:31:47 -0700 Subject: [PATCH] prepare 5.5.0 release (#96) --- CHANGELOG.md | 20 ++ README.md | 4 + src/LaunchDarkly.Client/FeatureFlagsState.cs | 28 ++- .../Files/FileComponents.cs | 107 ++++++++ .../Files/FileDataSource.cs | 109 +++++++++ .../Files/FileDataSourceFactory.cs | 138 +++++++++++ .../Files/FilePollingReloader.cs | 114 +++++++++ .../Files/FileWatchingReloader.cs | 72 ++++++ src/LaunchDarkly.Client/Files/FlagFactory.cs | 37 +++ src/LaunchDarkly.Client/Files/FlagFileData.cs | 62 +++++ .../Files/FlagFileParser.cs | 44 ++++ src/LaunchDarkly.Client/FlagsStateOption.cs | 7 + .../LaunchDarkly.Client.csproj | 4 +- src/LaunchDarkly.Client/LdClient.cs | 5 +- .../FeatureFlagsStateTest.cs | 20 +- test/LaunchDarkly.Tests/FileDataSourceTest.cs | 228 ++++++++++++++++++ .../LaunchDarkly.Tests.csproj | 21 +- .../LdClientEvaluationTest.cs | 43 +++- .../TestFiles/all-properties.json | 21 ++ .../TestFiles/all-properties.yml | 17 ++ .../LaunchDarkly.Tests/TestFiles/bad-file.txt | 1 + .../TestFiles/flag-only.json | 12 + .../TestFiles/segment-only.json | 8 + test/LaunchDarkly.Tests/TestUtils.cs | 7 +- 24 files changed, 1101 insertions(+), 28 deletions(-) create mode 100644 src/LaunchDarkly.Client/Files/FileComponents.cs create mode 100644 src/LaunchDarkly.Client/Files/FileDataSource.cs create mode 100644 src/LaunchDarkly.Client/Files/FileDataSourceFactory.cs create mode 100644 src/LaunchDarkly.Client/Files/FilePollingReloader.cs create mode 100644 src/LaunchDarkly.Client/Files/FileWatchingReloader.cs create mode 100644 src/LaunchDarkly.Client/Files/FlagFactory.cs create mode 100644 src/LaunchDarkly.Client/Files/FlagFileData.cs create mode 100644 src/LaunchDarkly.Client/Files/FlagFileParser.cs create mode 100644 test/LaunchDarkly.Tests/FileDataSourceTest.cs create mode 100644 test/LaunchDarkly.Tests/TestFiles/all-properties.json create mode 100644 test/LaunchDarkly.Tests/TestFiles/all-properties.yml create mode 100644 test/LaunchDarkly.Tests/TestFiles/bad-file.txt create mode 100644 test/LaunchDarkly.Tests/TestFiles/flag-only.json create mode 100644 test/LaunchDarkly.Tests/TestFiles/segment-only.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d7f2cf2..adecbceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to the LaunchDarkly .NET SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [5.5.0] - 2018-10-30 +### Added: +- It is now possible to inject feature flags into the client from local JSON or YAML files, replacing the normal LaunchDarkly connection. This would typically be for testing purposes. See `LaunchDarkly.Client.Files.FileComponents`. + +- The `AllFlagsState` method now accepts a new option, `FlagsStateOption.DetailsOnlyForTrackedFlags`, which reduces the size of the JSON representation of the flag state by omitting some metadata. Specifically, it omits any data that is normally used for generating detailed evaluation events if a flag does not have event tracking or debugging turned on. + +- The non-strong-named version of this library (`LaunchDarkly.Common`) can now be used with a non-strong-named version of LaunchDarkly.Client, which does not normally exist but could be built as part of a fork of the SDK. + +### Changed: +- Previously, the delay before stream reconnect attempts would increase exponentially only if the previous connection could not be made at all or returned an HTTP error; if it received an HTTP 200 status, the delay would be reset to the minimum even if the connection then immediately failed. Now, if the stream connection fails after it has been up for less than a minute, the reconnect delay will continue to increase. + +### Fixed: +- Fixed an [unobserved exception](https://blogs.msdn.microsoft.com/pfxteam/2011/09/28/task-exception-handling-in-net-4-5/) that could occur following a stream timeout, which could cause a crash in .NET 4.0. + +- Fixed a `NullReferenceException` that could sometimes appear in the log if a stream connection failed. + +- Fixed the documentation for `Configuration.StartWaitTime` to indicate that the default is 10 seconds, not 5 seconds. (Thanks, [KimboTodd](https://github.com/launchdarkly/dotnet-client/pull/95)!) + +- JSON data from `AllFlagsState` is now slightly smaller even if you do not use the new option described above, because it completely omits the flag property for event tracking unless that property is true. + ## [5.4.0] - 2018-08-30 ### Added: - The new `LDClient` methods `BoolVariationDetail`, `IntVariationDetail`, `DoubleVariationDetail`, `StringVariationDetail`, and `JsonVariationDetail` allow you to evaluate a feature flag (using the same parameters as you would for `BoolVariation`, etc.) and receive more information about how the value was calculated. This information is returned in an `EvaluationDetail` object, which contains both the result value and an `EvaluationReason` which will tell you, for instance, if the user was individually targeted for the flag or was matched by one of the flag's rules, or if the flag returned the default value due to an error. diff --git a/README.md b/README.md index 745a5e2d..a2ea7483 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ Redis integration An implementation of feature flag caching using a Redis server is available as a separate package. More information and source code [here](https://github.com/launchdarkly/dotnet-client-redis). +Using flag data from a file +--------------------------- +For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See `LaunchDarkly.Client.Files.FileComponents` for more details. + Learn more ----------- diff --git a/src/LaunchDarkly.Client/FeatureFlagsState.cs b/src/LaunchDarkly.Client/FeatureFlagsState.cs index 19d165fe..fc3b9070 100644 --- a/src/LaunchDarkly.Client/FeatureFlagsState.cs +++ b/src/LaunchDarkly.Client/FeatureFlagsState.cs @@ -41,17 +41,25 @@ internal FeatureFlagsState(bool valid, IDictionary values, _flagMetadata = metadata; } - internal void AddFlag(FeatureFlag flag, JToken value, int? variation, EvaluationReason reason) + internal void AddFlag(FeatureFlag flag, JToken value, int? variation, EvaluationReason reason, + bool detailsOnlyIfTracked) { _flagValues[flag.Key] = value; - _flagMetadata[flag.Key] = new FlagMetadata + var meta = new FlagMetadata { Variation = variation, - Version = flag.Version, - TrackEvents = flag.TrackEvents, - DebugEventsUntilDate = flag.DebugEventsUntilDate, - Reason = reason + DebugEventsUntilDate = flag.DebugEventsUntilDate }; + if (!detailsOnlyIfTracked || flag.TrackEvents || flag.DebugEventsUntilDate != null) + { + meta.Version = flag.Version; + meta.Reason = reason; + } + if (flag.TrackEvents) + { + meta.TrackEvents = true; + } + _flagMetadata[flag.Key] = meta; } /// @@ -123,10 +131,10 @@ internal class FlagMetadata { [JsonProperty(PropertyName = "variation", NullValueHandling = NullValueHandling.Ignore)] internal int? Variation { get; set; } - [JsonProperty(PropertyName = "version")] - internal int Version { get; set; } - [JsonProperty(PropertyName = "trackEvents")] - internal bool TrackEvents { get; set; } + [JsonProperty(PropertyName = "version", NullValueHandling = NullValueHandling.Ignore)] + internal int? Version { get; set; } + [JsonProperty(PropertyName = "trackEvents", NullValueHandling = NullValueHandling.Ignore)] + internal bool? TrackEvents { get; set; } [JsonProperty(PropertyName = "debugEventsUntilDate", NullValueHandling = NullValueHandling.Ignore)] internal long? DebugEventsUntilDate { get; set; } [JsonProperty(PropertyName = "reason", NullValueHandling = NullValueHandling.Ignore)] diff --git a/src/LaunchDarkly.Client/Files/FileComponents.cs b/src/LaunchDarkly.Client/Files/FileComponents.cs new file mode 100644 index 00000000..7417031d --- /dev/null +++ b/src/LaunchDarkly.Client/Files/FileComponents.cs @@ -0,0 +1,107 @@ +namespace LaunchDarkly.Client.Files +{ + /// + /// The entry point for the file data source, which allows you to use local files as a source of + /// feature flag state. + /// + /// + /// + /// This would typically be used in a test environment, to operate using a predetermined feature flag + /// state without an actual LaunchDarkly connection. + /// + /// + /// To use this component, call to obtain a factory object, call one or + /// methods to configure it, and then add it to your LaunchDarkly client configuration. At a + /// minimum, you will want to call to specify + /// your data file(s); you can also use to + /// specify that flags should be reloaded when a file is modified. See + /// for all configuration options. + /// + /// + /// var fileSource = FileComponents.FileDataSource() + /// .WithFilePaths("./testData/flags.json") + /// .WithAutoUpdate(true); + /// var config = Configuration.Default("sdkKey") + /// .WithUpdateProcessorFactory(fileSource) + /// .Build(); + /// + /// + /// This will cause the client not to connect to LaunchDarkly to get feature flags. The + /// client may still make network connections to send analytics events, unless you have disabled + /// this with configuration.WithEventProcessor(Components.NullEventProcessor). + /// + /// + /// Flag data files are JSON by default (although it is possible to specify a parser for another format, + /// such as YAML; see ). They + /// contain an object with three possible properties: + /// + /// + /// flags: Feature flag definitions. + /// flagVersions: Simplified feature flags that contain only a value. + /// segments: User segment definitions. + /// + /// + /// The format of the data in flags and segments is defined by the LaunchDarkly application + /// and is subject to change. Rather than trying to construct these objects yourself, it is simpler + /// to request existing flags directly from the LaunchDarkly server in JSON format, and use this + /// output as the starting point for your file. In Linux you would do this: + /// + /// + /// curl -H "Authorization: {your sdk key}" https://app.launchdarkly.com/sdk/latest-all + /// + /// + /// The output will look something like this (but with many more properties): + /// + /// + /// { + /// "flags": { + /// "flag-key-1": { + /// "key": "flag-key-1", + /// "on": true, + /// "variations": [ "a", "b" ] + /// } + /// }, + /// "segments": { + /// "segment-key-1": { + /// "key": "segment-key-1", + /// "includes": [ "user-key-1" ] + /// } + /// } + /// } + /// + /// + /// Data in this format allows the SDK to exactly duplicate all the kinds of flag behavior supported + /// by LaunchDarkly. However, in many cases you will not need this complexity, but will just want to + /// set specific flag keys to specific values. For that, you can use a much simpler format: + /// + /// + /// { + /// "flagValues": { + /// "my-string-flag-key": "value-1", + /// "my-boolean-flag-key": true, + /// "my-integer-flag-key": 3 + /// } + /// } + /// + /// + /// It is also possible to specify both flags and flagValues, if you want some flags + /// to have simple values and others to have complex behavior. However, it is an error to use the + /// same flag key or segment key more than once, either in a single file or across multiple files. + /// + /// + /// If the data source encounters any error in any file-- malformed content, a missing file, or a + /// duplicate key-- it will not load flags from any of the files. + /// + /// + public static class FileComponents + { + /// + /// Creates a which you can use to configure the file data source. + /// + /// a + public static FileDataSourceFactory FileDataSource() + { + return new FileDataSourceFactory(); + } + } +} diff --git a/src/LaunchDarkly.Client/Files/FileDataSource.cs b/src/LaunchDarkly.Client/Files/FileDataSource.cs new file mode 100644 index 00000000..95d21276 --- /dev/null +++ b/src/LaunchDarkly.Client/Files/FileDataSource.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Common.Logging; + +namespace LaunchDarkly.Client.Files +{ + internal class FileDataSource : IUpdateProcessor + { + private static readonly ILog Log = LogManager.GetLogger(typeof(FileDataSource)); + private readonly IFeatureStore _featureStore; + private readonly List _paths; + private readonly IDisposable _reloader; + private readonly FlagFileParser _parser; + private volatile bool _started; + private volatile bool _loadedValidData; + + public FileDataSource(IFeatureStore featureStore, List paths, bool autoUpdate, TimeSpan pollInterval, + Func alternateParser) + { + _featureStore = featureStore; + _paths = new List(paths); + _parser = new FlagFileParser(alternateParser); + if (autoUpdate) + { + try + { +#if NETSTANDARD1_4 || NETSTANDARD1_6 + _reloader = new FilePollingReloader(_paths, TriggerReload, pollInterval); +#else + _reloader = new FileWatchingReloader(_paths, TriggerReload); +#endif + } + catch (Exception e) + { + Log.ErrorFormat("Unable to watch files for auto-updating: {0}", e); + _reloader = null; + } + } + else + { + _reloader = null; + } + } + + public Task Start() + { + _started = true; + LoadAll(); + + // We always complete the start task regardless of whether we successfully loaded data or not; + // if the data files were bad, they're unlikely to become good within the short interval that + // LdClient waits on this task, even if auto-updating is on. + TaskCompletionSource initTask = new TaskCompletionSource(); + initTask.SetResult(_loadedValidData); + return initTask.Task; + } + + public bool Initialized() + { + return _loadedValidData; + } + + public void Dispose() + { + Dispose(true); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + _reloader?.Dispose(); + } + } + + private void LoadAll() + { + Dictionary> allData = + new Dictionary>(); + foreach (var path in _paths) + { + try + { + var content = File.ReadAllText(path); + var data = _parser.Parse(content); + data.AddToData(allData); + } + catch (Exception e) + { + Log.ErrorFormat("{0}: {1}", path, e); + return; + } + } + _featureStore.Init(allData); + _loadedValidData = true; + } + + private void TriggerReload() + { + if (_started) + { + LoadAll(); + } + } + } +} diff --git a/src/LaunchDarkly.Client/Files/FileDataSourceFactory.cs b/src/LaunchDarkly.Client/Files/FileDataSourceFactory.cs new file mode 100644 index 00000000..ebac2481 --- /dev/null +++ b/src/LaunchDarkly.Client/Files/FileDataSourceFactory.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; + +namespace LaunchDarkly.Client.Files +{ + /// + /// A factory for the file data source described in . + /// + /// + /// To use the file data source, obtain a new instance of this class with + /// , call the builder method + /// , then pass the resulting object to + /// . + /// + public class FileDataSourceFactory : IUpdateProcessorFactory + { + /// + /// The default value for . + /// + public static readonly TimeSpan DefaultPollInterval = TimeSpan.FromSeconds(5); + + private readonly List _paths = new List(); + private bool _autoUpdate = false; + private TimeSpan _pollInterval = DefaultPollInterval; + private Func _parser = null; + + /// + /// Adds any number of source files for loading flag data, specifying each file path as a string. + /// + /// + /// + /// The files will not actually be loaded until the LaunchDarkly client starts up. + /// + /// + /// Files are normally expected to contain JSON; see for alternatives. + /// + /// + /// path(s) to the source file(s); may be absolute or relative to the current working directory + /// the same factory object + public FileDataSourceFactory WithFilePaths(params string[] paths) + { + _paths.AddRange(paths); + return this; + } + + /// + /// Specifies an alternate parsing function to use for non-JSON source files. + /// + /// + /// + /// By default, the file data source attempts to parse files as JSON objects. You may wish to use another format, + /// such as YAML. To avoid bringing in additional dependencies that might conflict with application dependencies, + /// the LaunchDarkly SDK does not import a YAML parser, but you can use WithParser to specify a parser of + /// your choice. + /// + /// + /// The function you provide should take a string and return an object which should contain only the basic + /// types that can be represented in JSON: so, for instance, string values should be string, arrays should + /// be List, and objects with key-value pairs should be Dictionary<string, object>. It should + /// throw an exception if it can't parse the data. + /// + /// + /// The file data source will still try to parse files as JSON if their first non-whitespace character is '{', + /// but if that fails, it will use the custom parser. + /// + /// + /// Here is an example of how you would do this with the YamlDotNet package: + /// + /// + /// var yaml = new DeserializerBuilder().Build(); + /// var source = FileComponents.FileDataSource() + /// .WithFilePaths(myYamlFilePath) + /// .WithParser(s => yaml.Deserialize<object>(s)); + /// + /// + /// the parsing function + /// the same factory object + public FileDataSourceFactory WithParser(Func parseFn) + { + _parser = parseFn; + return this; + } + + /// + /// Specifies whether the data source should watch for changes to the source file(s) and reload flags + /// whenever there is a change. + /// + /// + /// + /// This option is off by default: unless you set this option to true, files will only be loaded once. + /// + /// + /// In .NET Framework, and .NET Standard 2.0, file changes are detected by System.IO.FileSystemWatcher. + /// However, in .NET Standard 1.x, this is not available and so file changes are detected by polling the file + /// modification times (at an interval configurable with . Be aware that + /// the latter mechanism may not detect changes that occur very frequently, since on some operating systems + /// the file modification time does not have a high precision, so if you are running on .NET Standard 1.x you + /// should avoid test scenarios where the data files are modified immediately after startup. + /// + /// + /// Note that auto-updating may not work if any of the files you specified has an invalid directory path. + /// + /// + /// true if flags should be reloaded whenever a source file changes + /// the same factory object + public FileDataSourceFactory WithAutoUpdate(bool autoUpdate) + { + _autoUpdate = autoUpdate; + return this; + } + + /// + /// Specifies how often to poll for file changes if polling is necessary. + /// + /// + /// This setting is only used if is true and if you are using .NET + /// Standard 1.x; otherwise it is ignored. The default value is . + /// + /// the interval for file polling + /// the same factory object + public FileDataSourceFactory WithPollInterval(TimeSpan pollInterval) + { + _pollInterval = pollInterval; + return this; + } + + /// + /// Used internally by the LaunchDarkly client. + /// + /// + /// + /// + public IUpdateProcessor CreateUpdateProcessor(Configuration config, IFeatureStore featureStore) + { + return new FileDataSource(featureStore, _paths, _autoUpdate, _pollInterval, _parser); + } + } +} diff --git a/src/LaunchDarkly.Client/Files/FilePollingReloader.cs b/src/LaunchDarkly.Client/Files/FilePollingReloader.cs new file mode 100644 index 00000000..a648eb2c --- /dev/null +++ b/src/LaunchDarkly.Client/Files/FilePollingReloader.cs @@ -0,0 +1,114 @@ +#if NETSTANDARD1_4 || NETSTANDARD1_6 +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Common.Logging; + +namespace LaunchDarkly.Client.Files +{ + // An unsophisticated implementation of file monitoring that we use when there's no other + // mechanism available, i.e. in .NET Standard 1.x. + // + // WARNING: Even if we're willing to poll very frequently, this logic can still miss file + // changes. This is because the Linux/Mac implementations of File.GetLastWriteTime() have + // a *one-second* resolution for the returned time value, so if a file is modified less + // than one second after its previous modified time, we may not detect it. There doesn't + // seem to be a workaround for this, so there's a warning in the documentation for the + // AutoUpdate setting. + class FilePollingReloader : IDisposable + { + private static readonly ILog Log = LogManager.GetLogger(typeof(FilePollingReloader)); + private readonly List _paths; + private readonly IDictionary _fileTimes; + private readonly Action _reload; + private readonly TimeSpan _pollInterval; + private readonly CancellationTokenSource _canceller; + + public FilePollingReloader(List paths, Action reload, TimeSpan pollInterval) + { + _paths = paths; + _reload = reload; + _pollInterval = pollInterval; + _canceller = new CancellationTokenSource(); + + _fileTimes = new Dictionary(); + foreach (var p in paths) + { + try + { + var time = File.GetLastWriteTime(p); + _fileTimes[p] = time; + } + catch (Exception) + { + _fileTimes[p] = null; + } + } + + Task.Run(() => PollAsync(_canceller.Token)); + } + + private async Task PollAsync(CancellationToken stopToken) + { + while (true) + { + try + { + CheckFileTimes(); + await Task.Delay(_pollInterval, stopToken); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception e) + { + Log.Error("Unexpected exception during file polling: " + e); + } + } + } + + private void CheckFileTimes() + { + bool changed = false; + foreach (var p in _paths) + { + try + { + var time = File.GetLastWriteTime(p); + if (!_fileTimes[p].HasValue || _fileTimes[p].Value != time) + { + _fileTimes[p] = time; + changed = true; + } + } + catch (Exception) + { + // We don't want to treat a missing file as a change. + _fileTimes[p] = null; + } + } + if (changed) + { + _reload(); + } + } + + public void Dispose() + { + Dispose(true); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + _canceller.Cancel(); + } + } + } +} + +#endif \ No newline at end of file diff --git a/src/LaunchDarkly.Client/Files/FileWatchingReloader.cs b/src/LaunchDarkly.Client/Files/FileWatchingReloader.cs new file mode 100644 index 00000000..6c83d247 --- /dev/null +++ b/src/LaunchDarkly.Client/Files/FileWatchingReloader.cs @@ -0,0 +1,72 @@ +#if !NETSTANDARD1_4 && !NETSTANDARD1_6 +using System; +using System.Collections.Generic; +using System.IO; + +namespace LaunchDarkly.Client.Files +{ + /// + /// Implementation of file monitoring using FileSystemWatcher. In .NET Standard 1.x, that class + /// is unavailable so we will use FilePollingReloader instead. + /// + class FileWatchingReloader : IDisposable + { + private readonly ISet _filePaths; + private readonly Action _reload; + private readonly List _watchers; + + public FileWatchingReloader(List paths, Action reload) + { + _reload = reload; + + _filePaths = new HashSet(); + var dirPaths = new HashSet(); + foreach (var p in paths) + { + var absPath = Path.GetFullPath(p); + _filePaths.Add(absPath); + var dirPath = Path.GetDirectoryName(absPath); + dirPaths.Add(dirPath); + } + + _watchers = new List(); + foreach (var dir in dirPaths) + { + var w = new FileSystemWatcher(dir); + + w.Changed += (s, args) => ChangedPath(args.FullPath); + w.Created += (s, args) => ChangedPath(args.FullPath); + w.Renamed += (s, args) => ChangedPath(args.FullPath); + w.EnableRaisingEvents = true; + + _watchers.Add(w); + } + } + + private void ChangedPath(string path) + { + if (_filePaths.Contains(path)) + { + _reload(); + } + } + + public void Dispose() + { + Dispose(true); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + foreach (var w in _watchers) + { + w.Dispose(); + } + } + } + } +} + +#endif \ No newline at end of file diff --git a/src/LaunchDarkly.Client/Files/FlagFactory.cs b/src/LaunchDarkly.Client/Files/FlagFactory.cs new file mode 100644 index 00000000..3722e351 --- /dev/null +++ b/src/LaunchDarkly.Client/Files/FlagFactory.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json.Linq; + +namespace LaunchDarkly.Client.Files +{ + // Creates flag or segment objects from raw JSON. Note that the FeatureFlag and Segment + // classes are internal to LaunchDarkly.Client, so we refer to those types indirectly via + // VersionedDataKind; and if we want to construct a flag from scratch, we can't use the + // constructor but instead must build some JSON and then parse that. + internal static class FlagFactory + { + public static IVersionedData FlagFromJson(JToken json) + { + return json.ToObject(VersionedDataKind.Features.GetItemType()) as IVersionedData; + } + + // Constructs a flag that always returns the same value. This is done by giving it a + // single variation and setting the fallthrough variation to that. + public static IVersionedData FlagWithValue(string key, JToken value) + { + var o = new JObject(); + o.Add("key", key); + o.Add("on", true); + var vs = new JArray(); + vs.Add(value); + o.Add("variations", vs); + var ft = new JObject(); + ft.Add("variation", 0); + o.Add("fallthrough", ft); + return FlagFromJson(o); + } + + public static IVersionedData SegmentFromJson(JToken json) + { + return json.ToObject(VersionedDataKind.Segments.GetItemType()) as IVersionedData; + } + } +} diff --git a/src/LaunchDarkly.Client/Files/FlagFileData.cs b/src/LaunchDarkly.Client/Files/FlagFileData.cs new file mode 100644 index 00000000..8f0671f9 --- /dev/null +++ b/src/LaunchDarkly.Client/Files/FlagFileData.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace LaunchDarkly.Client.Files +{ + // Represents the data structure that we parse files into, and provides the logic for + // transferring its contents into the format used by the feature store. + class FlagFileData + { + [JsonProperty(PropertyName = "flags", NullValueHandling = NullValueHandling.Ignore)] + public Dictionary Flags { get; set; } + + [JsonProperty(PropertyName = "flagValues", NullValueHandling = NullValueHandling.Ignore)] + public Dictionary FlagValues { get; set; } + + [JsonProperty(PropertyName = "segments", NullValueHandling = NullValueHandling.Ignore)] + public Dictionary Segments { get; set; } + + public void AddToData(IDictionary> allData) + { + if (Flags != null) + { + foreach (KeyValuePair e in Flags) + { + AddItem(allData, VersionedDataKind.Features, FlagFactory.FlagFromJson(e.Value)); + } + } + if (FlagValues != null) + { + foreach (KeyValuePair e in FlagValues) + { + AddItem(allData, VersionedDataKind.Features, FlagFactory.FlagWithValue(e.Key, e.Value)); + } + } + if (Segments != null) + { + foreach (KeyValuePair e in Segments) + { + AddItem(allData, VersionedDataKind.Segments, FlagFactory.SegmentFromJson(e.Value)); + } + } + } + + private void AddItem(IDictionary> allData, + IVersionedDataKind kind, IVersionedData item) + { + IDictionary items; + if (!allData.TryGetValue(kind, out items)) + { + items = new Dictionary(); + allData[kind] = items; + } + if (items.ContainsKey(item.Key)) + { + throw new System.Exception("in \"" + kind.GetNamespace() + "\", key \"" + item.Key + + "\" was already defined"); + } + items[item.Key] = item; + } + } +} diff --git a/src/LaunchDarkly.Client/Files/FlagFileParser.cs b/src/LaunchDarkly.Client/Files/FlagFileParser.cs new file mode 100644 index 00000000..f593bc75 --- /dev/null +++ b/src/LaunchDarkly.Client/Files/FlagFileParser.cs @@ -0,0 +1,44 @@ +using System; +using Newtonsoft.Json; + +namespace LaunchDarkly.Client.Files +{ + internal class FlagFileParser + { + private readonly Func _alternateParser; + + public FlagFileParser(Func alternateParser) + { + _alternateParser = alternateParser; + } + + public FlagFileData Parse(string content) + { + if (_alternateParser == null) + { + return JsonConvert.DeserializeObject(content); + } + else + { + if (content.Trim().StartsWith("{")) + { + try + { + return JsonConvert.DeserializeObject(content); + } + catch (Exception e) + { + // we failed to parse it as JSON, so we'll just see if the alternate parser can do it + } + } + // The alternate parser should produce the most basic .NET data structure that can represent + // the file content, using types like Dictionary and String. We then convert this into a + // JSON tree so we can use the JSON deserializer; this is inefficient, but we already know + // that Gson can deserialize our model types correctly. + var o = _alternateParser(content); + var json = JsonConvert.SerializeObject(o); + return JsonConvert.DeserializeObject(json); + } + } + } +} diff --git a/src/LaunchDarkly.Client/FlagsStateOption.cs b/src/LaunchDarkly.Client/FlagsStateOption.cs index da618b38..84f344da 100644 --- a/src/LaunchDarkly.Client/FlagsStateOption.cs +++ b/src/LaunchDarkly.Client/FlagsStateOption.cs @@ -38,6 +38,13 @@ public override string ToString() /// public static readonly FlagsStateOption WithReasons = new FlagsStateOption("WithReasons"); + /// + /// Specifies that any flag metadata that is normally only used for event generation - such as flag versions and + /// evaluation reasons - should be omitted for any flag that does not have event tracking or debugging turned on. + /// This reduces the size of the JSON data if you are passing the flag state to the front end. + /// + public static readonly FlagsStateOption DetailsOnlyForTrackedFlags = new FlagsStateOption("DetailsOnlyForTrackedFlags"); + internal static bool HasOption(FlagsStateOption[] options, FlagsStateOption option) { foreach (var o in options) diff --git a/src/LaunchDarkly.Client/LaunchDarkly.Client.csproj b/src/LaunchDarkly.Client/LaunchDarkly.Client.csproj index d2b33a03..85e35cb2 100644 --- a/src/LaunchDarkly.Client/LaunchDarkly.Client.csproj +++ b/src/LaunchDarkly.Client/LaunchDarkly.Client.csproj @@ -1,6 +1,6 @@  - 5.4.0 + 5.5.0 netstandard1.4;netstandard1.6;netstandard2.0;net45 https://raw.githubusercontent.com/launchdarkly/.net-client/master/LICENSE portable @@ -15,7 +15,7 @@ false - + diff --git a/src/LaunchDarkly.Client/LdClient.cs b/src/LaunchDarkly.Client/LdClient.cs index aeff684c..36c9d532 100644 --- a/src/LaunchDarkly.Client/LdClient.cs +++ b/src/LaunchDarkly.Client/LdClient.cs @@ -234,6 +234,7 @@ public FeatureFlagsState AllFlagsState(User user, params FlagsStateOption[] opti var state = new FeatureFlagsState(true); var clientSideOnly = FlagsStateOption.HasOption(options, FlagsStateOption.ClientSideOnly); var withReasons = FlagsStateOption.HasOption(options, FlagsStateOption.WithReasons); + var detailsOnlyIfTracked = FlagsStateOption.HasOption(options, FlagsStateOption.DetailsOnlyForTrackedFlags); IDictionary flags = _featureStore.All(VersionedDataKind.Features); foreach (KeyValuePair pair in flags) { @@ -246,14 +247,14 @@ public FeatureFlagsState AllFlagsState(User user, params FlagsStateOption[] opti { FeatureFlag.EvalResult result = flag.Evaluate(user, _featureStore, EventFactory.Default); state.AddFlag(flag, result.Result.Value, result.Result.VariationIndex, - withReasons ? result.Result.Reason : null); + withReasons ? result.Result.Reason : null, detailsOnlyIfTracked); } catch (Exception e) { Log.ErrorFormat("Exception caught for feature flag \"{0}\" when evaluating all flags: {1}", flag.Key, Util.ExceptionMessage(e)); Log.Debug(e.ToString(), e); EvaluationReason reason = new EvaluationReason.Error(EvaluationErrorKind.EXCEPTION); - state.AddFlag(flag, null, null, withReasons ? reason : null); + state.AddFlag(flag, null, null, withReasons ? reason : null, detailsOnlyIfTracked); } } return state; diff --git a/test/LaunchDarkly.Tests/FeatureFlagsStateTest.cs b/test/LaunchDarkly.Tests/FeatureFlagsStateTest.cs index b8254077..5becbe27 100644 --- a/test/LaunchDarkly.Tests/FeatureFlagsStateTest.cs +++ b/test/LaunchDarkly.Tests/FeatureFlagsStateTest.cs @@ -15,7 +15,7 @@ public void CanGetFlagValue() { var state = new FeatureFlagsState(true); var flag = new FeatureFlagBuilder("key").Build(); - state.AddFlag(flag, new JValue("value"), 1, null); + state.AddFlag(flag, new JValue("value"), 1, null, false); Assert.Equal(new JValue("value"), state.GetFlagValue("key")); } @@ -33,7 +33,7 @@ public void CanGetFlagReason() { var state = new FeatureFlagsState(true); var flag = new FeatureFlagBuilder("key").Build(); - state.AddFlag(flag, new JValue("value"), 1, EvaluationReason.Fallthrough.Instance); + state.AddFlag(flag, new JValue("value"), 1, EvaluationReason.Fallthrough.Instance, false); Assert.Equal(EvaluationReason.Fallthrough.Instance, state.GetFlagReason("key")); } @@ -51,7 +51,7 @@ public void ReasonIsNullIfReasonsWereNotRecorded() { var state = new FeatureFlagsState(true); var flag = new FeatureFlagBuilder("key").Build(); - state.AddFlag(flag, new JValue("value"), 1, null); + state.AddFlag(flag, new JValue("value"), 1, null, false); Assert.Null(state.GetFlagReason("key")); } @@ -62,8 +62,8 @@ public void CanConvertToValuesMap() var state = new FeatureFlagsState(true); var flag1 = new FeatureFlagBuilder("key1").Build(); var flag2 = new FeatureFlagBuilder("key2").Build(); - state.AddFlag(flag1, new JValue("value1"), 0, null); - state.AddFlag(flag2, new JValue("value2"), 1, null); + state.AddFlag(flag1, new JValue("value1"), 0, null, false); + state.AddFlag(flag2, new JValue("value2"), 1, null, false); var expected = new Dictionary { @@ -80,13 +80,13 @@ public void CanSerializeToJson() var flag1 = new FeatureFlagBuilder("key1").Version(100).Build(); var flag2 = new FeatureFlagBuilder("key2").Version(200) .TrackEvents(true).DebugEventsUntilDate(1000).Build(); - state.AddFlag(flag1, new JValue("value1"), 0, null); - state.AddFlag(flag2, new JValue("value2"), 1, EvaluationReason.Fallthrough.Instance); + state.AddFlag(flag1, new JValue("value1"), 0, null, false); + state.AddFlag(flag2, new JValue("value2"), 1, EvaluationReason.Fallthrough.Instance, false); var expectedString = @"{""key1"":""value1"",""key2"":""value2"", ""$flagsState"":{ ""key1"":{ - ""variation"":0,""version"":100,""trackEvents"":false + ""variation"":0,""version"":100 },""key2"":{ ""variation"":1,""version"":200,""reason"":{""kind"":""FALLTHROUGH""},""trackEvents"":true,""debugEventsUntilDate"":1000 } @@ -106,8 +106,8 @@ public void CanDeserializeFromJson() var flag1 = new FeatureFlagBuilder("key1").Version(100).Build(); var flag2 = new FeatureFlagBuilder("key2").Version(200) .TrackEvents(true).DebugEventsUntilDate(1000).Build(); - state.AddFlag(flag1, new JValue("value1"), 0, null); - state.AddFlag(flag2, new JValue("value2"), 1, EvaluationReason.Fallthrough.Instance); + state.AddFlag(flag1, new JValue("value1"), 0, null, false); + state.AddFlag(flag2, new JValue("value2"), 1, EvaluationReason.Fallthrough.Instance, false); var jsonString = JsonConvert.SerializeObject(state); var state1 = JsonConvert.DeserializeObject(jsonString); diff --git a/test/LaunchDarkly.Tests/FileDataSourceTest.cs b/test/LaunchDarkly.Tests/FileDataSourceTest.cs new file mode 100644 index 00000000..a516acc4 --- /dev/null +++ b/test/LaunchDarkly.Tests/FileDataSourceTest.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using Xunit; +using LaunchDarkly.Client; +using LaunchDarkly.Client.Files; +using YamlDotNet.Serialization; + +namespace LaunchDarkly.Tests +{ + public class FileDataSourceTest + { + private static readonly string ALL_DATA_JSON_FILE = TestUtils.TestFilePath("all-properties.json"); + private static readonly string ALL_DATA_YAML_FILE = TestUtils.TestFilePath("all-properties.yml"); + + private readonly IFeatureStore store = new InMemoryFeatureStore(); + private readonly FileDataSourceFactory factory = FileComponents.FileDataSource(); + private readonly Configuration config = Configuration.Default("sdkKey") + .WithEventProcessorFactory(Components.NullEventProcessor); + private readonly User user = User.WithKey("key"); + + [Fact] + public void FlagsAreNotLoadedUntilStart() + { + factory.WithFilePaths(ALL_DATA_JSON_FILE); + using (var fp = factory.CreateUpdateProcessor(config, store)) + { + Assert.False(store.Initialized()); + Assert.Equal(0, CountFlagsInStore()); + Assert.Equal(0, CountSegmentsInStore()); + } + } + + [Fact] + public void FlagsAreLoadedOnStart() + { + factory.WithFilePaths(ALL_DATA_JSON_FILE); + using (var fp = factory.CreateUpdateProcessor(config, store)) + { + fp.Start(); + Assert.True(store.Initialized()); + Assert.Equal(2, CountFlagsInStore()); + Assert.Equal(1, CountSegmentsInStore()); + } + } + + [Fact] + public void FlagsCanBeLoadedWithExternalYamlParser() + { + var yaml = new DeserializerBuilder().Build(); + factory.WithFilePaths(ALL_DATA_YAML_FILE) + .WithParser(s => yaml.Deserialize(s)); + using (var fp = factory.CreateUpdateProcessor(config, store)) + { + fp.Start(); + Assert.True(store.Initialized()); + Assert.Equal(2, CountFlagsInStore()); + Assert.Equal(1, CountSegmentsInStore()); + } + } + + [Fact] + public void StartTaskIsCompletedAndInitializedIsTrueAfterSuccessfulLoad() + { + using (var fp = factory.CreateUpdateProcessor(config, store)) + { + var task = fp.Start(); + Assert.True(task.IsCompleted); + Assert.True(fp.Initialized()); + } + } + + [Fact] + public void StartTaskIsCompletedAndInitializedIsFalseAfterFailedLoadDueToMissingFile() + { + factory.WithFilePaths("bad-file-path"); + using (var fp = factory.CreateUpdateProcessor(config, store)) + { + var task = fp.Start(); + Assert.True(task.IsCompleted); + Assert.False(fp.Initialized()); + } + } + + [Fact] + public void StartTaskIsCompletedAndInitializedIsFalseAfterFailedLoadDueToMalformedFile() + { + factory.WithFilePaths(TestUtils.TestFilePath("bad-file.txt")); + using (var fp = factory.CreateUpdateProcessor(config, store)) + { + var task = fp.Start(); + Assert.True(task.IsCompleted); + Assert.False(fp.Initialized()); + } + } + + [Fact] + public void ModifiedFileIsNotReloadedIfAutoUpdateIsOff() + { + var filename = Path.GetTempFileName(); + factory.WithFilePaths(filename); + try + { + File.WriteAllText(filename, File.ReadAllText(TestUtils.TestFilePath("flag-only.json"))); + using (var fp = factory.CreateUpdateProcessor(config, store)) + { + fp.Start(); + File.WriteAllText(filename, File.ReadAllText(TestUtils.TestFilePath("segment-only.json"))); + Thread.Sleep(TimeSpan.FromMilliseconds(400)); + Assert.Equal(1, CountFlagsInStore()); + Assert.Equal(0, CountSegmentsInStore()); + } + } + finally + { + File.Delete(filename); + } + } + + [Fact] + public void ModifiedFileIsReloadedIfAutoUpdateIsOn() + { + var filename = Path.GetTempFileName(); + factory.WithFilePaths(filename).WithAutoUpdate(true).WithPollInterval(TimeSpan.FromMilliseconds(200)); + try + { + File.WriteAllText(filename, File.ReadAllText(TestUtils.TestFilePath("flag-only.json"))); + using (var fp = factory.CreateUpdateProcessor(config, store)) + { + fp.Start(); + Assert.True(store.Initialized()); + Assert.Equal(0, CountSegmentsInStore()); + + Thread.Sleep(TimeSpan.FromMilliseconds(1000)); + // See FilePollingReloader for the reason behind this long sleep + + File.WriteAllText(filename, File.ReadAllText(TestUtils.TestFilePath("segment-only.json"))); + + Assert.True( + WaitForCondition(TimeSpan.FromSeconds(5), () => CountSegmentsInStore() == 1), + "Did not detect file modification" + ); + } + } + finally + { + File.Delete(filename); + } + } + + [Fact] + public void IfFlagsAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() + { + var filename = Path.GetTempFileName(); + factory.WithFilePaths(filename).WithAutoUpdate(true).WithPollInterval(TimeSpan.FromMilliseconds(200)); + try + { + File.WriteAllText(filename, "{not correct}"); + using (var fp = factory.CreateUpdateProcessor(config, store)) + { + fp.Start(); + Assert.False(store.Initialized()); + + Thread.Sleep(TimeSpan.FromMilliseconds(1000)); + // See FilePollingReloader for the reason behind this long sleep + + File.WriteAllText(filename, File.ReadAllText(TestUtils.TestFilePath("segment-only.json"))); + + Assert.True( + WaitForCondition(TimeSpan.FromSeconds(5), () => CountSegmentsInStore() == 1), + "Did not detect file modification" + ); + } + } + finally + { + File.Delete(filename); + } + } + + [Fact] + public void FullFlagDefinitionEvaluatesAsExpected() + { + factory.WithFilePaths(ALL_DATA_JSON_FILE); + config.WithUpdateProcessorFactory(factory); + using (var client = new LdClient(config)) + { + Assert.Equal("on", client.StringVariation("flag1", user, "")); + } + } + + [Fact] + public void SimplifiedFlagEvaluatesAsExpected() + { + factory.WithFilePaths(ALL_DATA_JSON_FILE); + config.WithUpdateProcessorFactory(factory); + using (var client = new LdClient(config)) + { + Assert.Equal("value2", client.StringVariation("flag2", user, "")); + } + } + + private int CountFlagsInStore() + { + return store.All(VersionedDataKind.Features).Count; + } + + private int CountSegmentsInStore() + { + return store.All(VersionedDataKind.Segments).Count; + } + + private bool WaitForCondition(TimeSpan maxTime, Func test) + { + DateTime deadline = DateTime.Now.Add(maxTime); + while (DateTime.Now < deadline) + { + if (test()) + { + return true; + } + Thread.Sleep(TimeSpan.FromMilliseconds(100)); + } + return false; + } + } +} diff --git a/test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj b/test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj index 00f0dbdd..36541dd3 100644 --- a/test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj +++ b/test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj @@ -20,12 +20,31 @@ - + + + + + + + Always + + + Always + + + Always + + + Always + + + Always + diff --git a/test/LaunchDarkly.Tests/LdClientEvaluationTest.cs b/test/LaunchDarkly.Tests/LdClientEvaluationTest.cs index d18681cc..299c99a0 100644 --- a/test/LaunchDarkly.Tests/LdClientEvaluationTest.cs +++ b/test/LaunchDarkly.Tests/LdClientEvaluationTest.cs @@ -334,7 +334,7 @@ public void AllFlagsStateReturnsState() var expectedString = @"{""key1"":""value1"",""key2"":""value2"", ""$flagsState"":{ ""key1"":{ - ""variation"":0,""version"":100,""trackEvents"":false + ""variation"":0,""version"":100 },""key2"":{ ""variation"":1,""version"":200,""trackEvents"":true,""debugEventsUntilDate"":1000 } @@ -366,7 +366,7 @@ public void AllFlagsStateReturnsStateWithReasons() var expectedString = @"{""key1"":""value1"",""key2"":""value2"", ""$flagsState"":{ ""key1"":{ - ""variation"":0,""version"":100,""reason"":{""kind"":""OFF""},""trackEvents"":false + ""variation"":0,""version"":100,""reason"":{""kind"":""OFF""} },""key2"":{ ""variation"":1,""version"":200,""reason"":{""kind"":""OFF""},""trackEvents"":true,""debugEventsUntilDate"":1000 } @@ -404,6 +404,45 @@ public void AllFlagsStateCanFilterForOnlyClientSideFlags() Assert.Equal(expectedValues, state.ToValuesMap()); } + [Fact] + public void AllFlagsStateCanOmitDetailsForUntrackedFlags() + { + var flag1 = new FeatureFlagBuilder("key1").Version(100) + .OffVariation(0).Variations(new List { new JValue("value1") }) + .Build(); + var flag2 = new FeatureFlagBuilder("key2").Version(200) + .OffVariation(1).Variations(new List { new JValue("x"), new JValue("value2") }) + .TrackEvents(true) + .Build(); + var flag3 = new FeatureFlagBuilder("key3").Version(300) + .OffVariation(1).Variations(new List { new JValue("x"), new JValue("value3") }) + .DebugEventsUntilDate(1000) + .Build(); + featureStore.Upsert(VersionedDataKind.Features, flag1); + featureStore.Upsert(VersionedDataKind.Features, flag2); + featureStore.Upsert(VersionedDataKind.Features, flag3); + + var state = client.AllFlagsState(user, FlagsStateOption.WithReasons); + Assert.True(state.Valid); + + var expectedString = @"{""key1"":""value1"",""key2"":""value2"",""key3"":""value3"", + ""$flagsState"":{ + ""key1"":{ + ""variation"":0,""version"":100,""reason"":{""kind"":""OFF""} + },""key2"":{ + ""variation"":1,""version"":200,""reason"":{""kind"":""OFF""},""trackEvents"":true + },""key3"":{ + ""variation"":1,""version"":300,""reason"":{""kind"":""OFF""},""debugEventsUntilDate"":1000 + } + }, + ""$valid"":true + }"; + var expectedValue = JsonConvert.DeserializeObject(expectedString); + var actualString = JsonConvert.SerializeObject(state); + var actualValue = JsonConvert.DeserializeObject(actualString); + TestUtils.AssertJsonEqual(expectedValue, actualValue); + } + [Fact] public void AllFlagsStateReturnsEmptyStateForNullUser() { diff --git a/test/LaunchDarkly.Tests/TestFiles/all-properties.json b/test/LaunchDarkly.Tests/TestFiles/all-properties.json new file mode 100644 index 00000000..473f0ecc --- /dev/null +++ b/test/LaunchDarkly.Tests/TestFiles/all-properties.json @@ -0,0 +1,21 @@ +{ + "flags": { + "flag1": { + "key": "flag1", + "on": true, + "fallthrough": { + "variation": 2 + }, + "variations": [ "fall", "off", "on" ] + } + }, + "flagValues": { + "flag2": "value2" + }, + "segments": { + "seg1": { + "key": "seg1", + "include": [ "user1" ] + } + } +} diff --git a/test/LaunchDarkly.Tests/TestFiles/all-properties.yml b/test/LaunchDarkly.Tests/TestFiles/all-properties.yml new file mode 100644 index 00000000..bcb4f620 --- /dev/null +++ b/test/LaunchDarkly.Tests/TestFiles/all-properties.yml @@ -0,0 +1,17 @@ +--- +flags: + flag1: + key: flag1 + "on": true + fallthrough: + variation: 2 + variations: + - fall + - "off" + - "on" +flagValues: + flag2: value2 +segments: + seg1: + key: seg1 + include: ["user1"] diff --git a/test/LaunchDarkly.Tests/TestFiles/bad-file.txt b/test/LaunchDarkly.Tests/TestFiles/bad-file.txt new file mode 100644 index 00000000..2cb719ef --- /dev/null +++ b/test/LaunchDarkly.Tests/TestFiles/bad-file.txt @@ -0,0 +1 @@ +what is this \ No newline at end of file diff --git a/test/LaunchDarkly.Tests/TestFiles/flag-only.json b/test/LaunchDarkly.Tests/TestFiles/flag-only.json new file mode 100644 index 00000000..26ceb508 --- /dev/null +++ b/test/LaunchDarkly.Tests/TestFiles/flag-only.json @@ -0,0 +1,12 @@ +{ + "flags": { + "flag1": { + "key": "flag1", + "on": true, + "fallthrough": { + "variation": 2 + }, + "variations": [ "fall", "off", "on" ] + } + } +} \ No newline at end of file diff --git a/test/LaunchDarkly.Tests/TestFiles/segment-only.json b/test/LaunchDarkly.Tests/TestFiles/segment-only.json new file mode 100644 index 00000000..3ab8c908 --- /dev/null +++ b/test/LaunchDarkly.Tests/TestFiles/segment-only.json @@ -0,0 +1,8 @@ +{ + "segments": { + "seg1": { + "key": "seg1", + "include": [ "user1" ] + } + } +} diff --git a/test/LaunchDarkly.Tests/TestUtils.cs b/test/LaunchDarkly.Tests/TestUtils.cs index 0f42d3fa..f07e0e35 100644 --- a/test/LaunchDarkly.Tests/TestUtils.cs +++ b/test/LaunchDarkly.Tests/TestUtils.cs @@ -8,7 +8,7 @@ namespace LaunchDarkly.Tests { public class TestUtils { - public static void AssertJsonEqual(JToken expected, JToken actual) + public static void AssertJsonEqual(JToken expected, JToken actual) { if (!JToken.DeepEquals(expected, actual)) { @@ -19,6 +19,11 @@ public static void AssertJsonEqual(JToken expected, JToken actual) } } + public static string TestFilePath(string name) + { + return "./TestFiles/" + name; + } + public static IFeatureStoreFactory SpecificFeatureStore(IFeatureStore store) { return new SpecificFeatureStoreFactory(store);