Skip to content

Commit c30446e

Browse files
authoredFeb 10, 2025··
feat(gofeatureflag): Provider refactor (#313)
Signed-off-by: Thomas Poignant <[email protected]>

28 files changed

+1347
-764
lines changed
 

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/GOFeatureFlagRequest.cs

-19
This file was deleted.

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProvider.cs

+58-35
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Collections.Immutable;
34
using System.Globalization;
45
using System.Net;
56
using System.Net.Http;
@@ -9,7 +10,11 @@
910
using System.Threading;
1011
using System.Threading.Tasks;
1112
using OpenFeature.Constant;
13+
using OpenFeature.Contrib.Providers.GOFeatureFlag.converters;
1214
using OpenFeature.Contrib.Providers.GOFeatureFlag.exception;
15+
using OpenFeature.Contrib.Providers.GOFeatureFlag.extensions;
16+
using OpenFeature.Contrib.Providers.GOFeatureFlag.hooks;
17+
using OpenFeature.Contrib.Providers.GOFeatureFlag.models;
1318
using OpenFeature.Model;
1419

1520
namespace OpenFeature.Contrib.Providers.GOFeatureFlag
@@ -20,8 +25,8 @@ namespace OpenFeature.Contrib.Providers.GOFeatureFlag
2025
public class GoFeatureFlagProvider : FeatureProvider
2126
{
2227
private const string ApplicationJson = "application/json";
28+
private ExporterMetadata _exporterMetadata;
2329
private HttpClient _httpClient;
24-
private JsonSerializerOptions _serializerOptions;
2530

2631
/// <summary>
2732
/// Constructor of the provider.
@@ -34,6 +39,17 @@ public GoFeatureFlagProvider(GoFeatureFlagProviderOptions options)
3439
InitializeProvider(options);
3540
}
3641

42+
/// <summary>
43+
/// List of hooks to use for this provider
44+
/// </summary>
45+
/// <returns></returns>
46+
public override IImmutableList<Hook> GetProviderHooks()
47+
{
48+
var hooks = ImmutableArray.CreateBuilder<Hook>();
49+
hooks.Add(new EnrichEvaluationContextHook(_exporterMetadata));
50+
return hooks.ToImmutable();
51+
}
52+
3753
/// <summary>
3854
/// validateInputOptions is validating the different options provided when creating the provider.
3955
/// </summary>
@@ -53,6 +69,10 @@ private void ValidateInputOptions(GoFeatureFlagProviderOptions options)
5369
/// <param name="options">Options used while creating the provider</param>
5470
private void InitializeProvider(GoFeatureFlagProviderOptions options)
5571
{
72+
_exporterMetadata = options.ExporterMetadata ?? new ExporterMetadata();
73+
_exporterMetadata.Add("provider", ".NET");
74+
_exporterMetadata.Add("openfeature", true);
75+
5676
_httpClient = options.HttpMessageHandler != null
5777
? new HttpClient(options.HttpMessageHandler)
5878
: new HttpClient
@@ -63,7 +83,6 @@ private void InitializeProvider(GoFeatureFlagProviderOptions options)
6383
};
6484
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(ApplicationJson));
6585
_httpClient.BaseAddress = new Uri(options.Endpoint);
66-
_serializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
6786

6887
if (options.ApiKey != null)
6988
_httpClient.DefaultRequestHeaders.Authorization =
@@ -96,8 +115,8 @@ public override async Task<ResolutionDetails<bool>> ResolveBooleanValueAsync(str
96115
try
97116
{
98117
var resp = await CallApi(flagKey, defaultValue, context);
99-
return new ResolutionDetails<bool>(flagKey, bool.Parse(resp.value.ToString()), ErrorType.None,
100-
resp.reason, resp.variationType);
118+
return new ResolutionDetails<bool>(flagKey, bool.Parse(resp.Value.ToString()), ErrorType.None,
119+
resp.Reason, resp.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata());
101120
}
102121
catch (FormatException e)
103122
{
@@ -121,16 +140,17 @@ public override async Task<ResolutionDetails<bool>> ResolveBooleanValueAsync(str
121140
/// <exception cref="FlagNotFoundError">If the flag does not exists</exception>
122141
/// <exception cref="GeneralError">If an unknown error happen</exception>
123142
/// <exception cref="FlagDisabled">If the flag is disabled</exception>
124-
public override async Task<ResolutionDetails<string>> ResolveStringValueAsync(string flagKey, string defaultValue,
143+
public override async Task<ResolutionDetails<string>> ResolveStringValueAsync(string flagKey,
144+
string defaultValue,
125145
EvaluationContext context = null, CancellationToken cancellationToken = default)
126146
{
127147
try
128148
{
129149
var resp = await CallApi(flagKey, defaultValue, context);
130-
if (!(resp.value is JsonElement element && element.ValueKind == JsonValueKind.String))
150+
if (!(resp.Value is JsonElement element && element.ValueKind == JsonValueKind.String))
131151
throw new TypeMismatchError($"flag value {flagKey} had unexpected type");
132-
return new ResolutionDetails<string>(flagKey, resp.value.ToString(), ErrorType.None, resp.reason,
133-
resp.variationType);
152+
return new ResolutionDetails<string>(flagKey, resp.Value.ToString(), ErrorType.None, resp.Reason,
153+
resp.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata());
134154
}
135155
catch (FormatException e)
136156
{
@@ -160,8 +180,8 @@ public override async Task<ResolutionDetails<int>> ResolveIntegerValueAsync(stri
160180
try
161181
{
162182
var resp = await CallApi(flagKey, defaultValue, context);
163-
return new ResolutionDetails<int>(flagKey, int.Parse(resp.value.ToString()), ErrorType.None,
164-
resp.reason, resp.variationType);
183+
return new ResolutionDetails<int>(flagKey, int.Parse(resp.Value.ToString()), ErrorType.None,
184+
resp.Reason, resp.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata());
165185
}
166186
catch (FormatException e)
167187
{
@@ -185,15 +205,16 @@ public override async Task<ResolutionDetails<int>> ResolveIntegerValueAsync(stri
185205
/// <exception cref="FlagNotFoundError">If the flag does not exists</exception>
186206
/// <exception cref="GeneralError">If an unknown error happen</exception>
187207
/// <exception cref="FlagDisabled">If the flag is disabled</exception>
188-
public override async Task<ResolutionDetails<double>> ResolveDoubleValueAsync(string flagKey, double defaultValue,
208+
public override async Task<ResolutionDetails<double>> ResolveDoubleValueAsync(string flagKey,
209+
double defaultValue,
189210
EvaluationContext context = null, CancellationToken cancellationToken = default)
190211
{
191212
try
192213
{
193214
var resp = await CallApi(flagKey, defaultValue, context);
194215
return new ResolutionDetails<double>(flagKey,
195-
double.Parse(resp.value.ToString(), CultureInfo.InvariantCulture), ErrorType.None,
196-
resp.reason, resp.variationType);
216+
double.Parse(resp.Value.ToString(), CultureInfo.InvariantCulture), ErrorType.None,
217+
resp.Reason, resp.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata());
197218
}
198219
catch (FormatException e)
199220
{
@@ -217,17 +238,18 @@ public override async Task<ResolutionDetails<double>> ResolveDoubleValueAsync(st
217238
/// <exception cref="FlagNotFoundError">If the flag does not exists</exception>
218239
/// <exception cref="GeneralError">If an unknown error happen</exception>
219240
/// <exception cref="FlagDisabled">If the flag is disabled</exception>
220-
public override async Task<ResolutionDetails<Value>> ResolveStructureValueAsync(string flagKey, Value defaultValue,
241+
public override async Task<ResolutionDetails<Value>> ResolveStructureValueAsync(string flagKey,
242+
Value defaultValue,
221243
EvaluationContext context = null, CancellationToken cancellationToken = default)
222244
{
223245
try
224246
{
225247
var resp = await CallApi(flagKey, defaultValue, context);
226-
if (resp.value is JsonElement)
248+
if (resp.Value is JsonElement)
227249
{
228-
var value = ConvertValue((JsonElement)resp.value);
229-
return new ResolutionDetails<Value>(flagKey, value, ErrorType.None, resp.reason,
230-
resp.variationType);
250+
var value = ConvertValue((JsonElement)resp.Value);
251+
return new ResolutionDetails<Value>(flagKey, value, ErrorType.None, resp.Reason,
252+
resp.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata());
231253
}
232254

233255
throw new TypeMismatchError($"flag value {flagKey} had unexpected type");
@@ -253,39 +275,40 @@ public override async Task<ResolutionDetails<Value>> ResolveStructureValueAsync(
253275
/// <exception cref="FlagNotFoundError">If the flag does not exists</exception>
254276
/// <exception cref="GeneralError">If an unknown error happen</exception>
255277
/// <exception cref="FlagDisabled">If the flag is disabled</exception>
256-
private async Task<GoFeatureFlagResponse> CallApi<T>(string flagKey, T defaultValue,
278+
private async Task<OfrepResponse> CallApi<T>(string flagKey, T defaultValue,
257279
EvaluationContext context = null)
258280
{
259-
var request = new GOFeatureFlagRequest<T>
260-
{
261-
User = context,
262-
DefaultValue = defaultValue
263-
};
264-
var goffRequest = JsonSerializer.Serialize(request, _serializerOptions);
265-
266-
var response = await _httpClient.PostAsync($"v1/feature/{flagKey}/eval",
267-
new StringContent(goffRequest, Encoding.UTF8, ApplicationJson));
281+
var request = new OfrepRequest(context);
282+
var response = await _httpClient.PostAsync($"ofrep/v1/evaluate/flags/{flagKey}",
283+
new StringContent(request.AsJsonString(), Encoding.UTF8, ApplicationJson));
268284

269285
if (response.StatusCode == HttpStatusCode.NotFound)
270286
throw new FlagNotFoundError($"flag {flagKey} was not found in your configuration");
271287

272-
if (response.StatusCode == HttpStatusCode.Unauthorized)
288+
if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden)
273289
throw new UnauthorizedError("invalid token used to contact GO Feature Flag relay proxy instance");
274290

275291
if (response.StatusCode >= HttpStatusCode.BadRequest)
276292
throw new GeneralError("impossible to contact GO Feature Flag relay proxy instance");
277293

278294
var responseBody = await response.Content.ReadAsStringAsync();
279-
var goffResp =
280-
JsonSerializer.Deserialize<GoFeatureFlagResponse>(responseBody);
295+
var options = new JsonSerializerOptions
296+
{
297+
PropertyNameCaseInsensitive = true
298+
};
299+
var ofrepResp =
300+
JsonSerializer.Deserialize<OfrepResponse>(responseBody, options);
281301

282-
if (goffResp != null && Reason.Disabled.Equals(goffResp.reason))
302+
if (Reason.Disabled.Equals(ofrepResp?.Reason))
283303
throw new FlagDisabled();
284304

285-
if ("FLAG_NOT_FOUND".Equals(goffResp.errorCode))
305+
if ("FLAG_NOT_FOUND".Equals(ofrepResp?.ErrorCode))
286306
throw new FlagNotFoundError($"flag {flagKey} was not found in your configuration");
287307

288-
return goffResp;
308+
if (ofrepResp?.Metadata != null)
309+
ofrepResp.Metadata = DictionaryConverter.ConvertDictionary(ofrepResp.Metadata);
310+
311+
return ofrepResp;
289312
}
290313

291314
/// <summary>
@@ -337,4 +360,4 @@ private Value ConvertValue(JsonElement value)
337360
throw new ImpossibleToConvertTypeError($"impossible to convert the object {value}");
338361
}
339362
}
340-
}
363+
}

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProviderOptions.cs

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Net.Http;
3+
using OpenFeature.Contrib.Providers.GOFeatureFlag.models;
34

45
namespace OpenFeature.Contrib.Providers.GOFeatureFlag
56
{
@@ -34,5 +35,11 @@ public class GoFeatureFlagProviderOptions
3435
/// Default: null
3536
/// </Summary>
3637
public string ApiKey { get; set; }
38+
39+
/// <summary>
40+
/// (optional) ExporterMetadata are static information you can set that will be available in the
41+
/// evaluation data sent to the exporter.
42+
/// </summary>
43+
public ExporterMetadata ExporterMetadata { get; set; }
3744
}
38-
}
45+
}

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagResponse.cs

-43
This file was deleted.

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagUser.cs

-64
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using System.Text.Json;
4+
5+
namespace OpenFeature.Contrib.Providers.GOFeatureFlag.converters
6+
{
7+
/// <summary>
8+
/// DictionaryConverter is converting a json Dictionary to a Dictionary with real object.
9+
/// </summary>
10+
public static class DictionaryConverter
11+
{
12+
/// <summary>
13+
/// Function that convert the dictionary to a Dictionary with real object.
14+
/// </summary>
15+
/// <param name="inputDictionary"></param>
16+
/// <returns>A dictionary with real types.</returns>
17+
public static Dictionary<string, object> ConvertDictionary(Dictionary<string, object> inputDictionary)
18+
{
19+
return inputDictionary.ToDictionary(
20+
kvp => kvp.Key,
21+
kvp => ConvertValue(kvp.Value)
22+
);
23+
}
24+
25+
/// <summary>
26+
/// Function that convert a value to a object.
27+
/// </summary>
28+
/// <param name="value"></param>
29+
/// <returns>A value with real types.</returns>
30+
public static object ConvertValue(object value)
31+
{
32+
if (value is JsonElement jsonElement)
33+
switch (jsonElement.ValueKind)
34+
{
35+
case JsonValueKind.String:
36+
return jsonElement.GetString();
37+
case JsonValueKind.Number:
38+
if (jsonElement.TryGetInt32(out var intValue)) return intValue;
39+
40+
if (jsonElement.TryGetDouble(out var doubleValue)) return doubleValue;
41+
return jsonElement.GetRawText(); // Fallback to string if not int or double
42+
case JsonValueKind.True:
43+
return true;
44+
case JsonValueKind.False:
45+
return false;
46+
case JsonValueKind.Null:
47+
return null;
48+
case JsonValueKind.Object:
49+
return ConvertDictionary(
50+
JsonSerializer
51+
.Deserialize<Dictionary<string, object>>(jsonElement
52+
.GetRawText())); //Recursive for nested objects
53+
case JsonValueKind.Array:
54+
var array = new List<object>();
55+
foreach (var element in jsonElement.EnumerateArray()) array.Add(ConvertValue(element));
56+
57+
return array;
58+
default:
59+
return jsonElement.GetRawText(); // Handle other types as needed
60+
}
61+
62+
return value; // Return original value if not a JsonElement
63+
}
64+
}
65+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System.Text.Json;
2+
3+
namespace OpenFeature.Contrib.Providers.GOFeatureFlag.converters
4+
{
5+
/// <summary>
6+
/// Extensions for default JsonConverter behavior
7+
/// </summary>
8+
public static class JsonConverterExtensions
9+
{
10+
/// <summary>
11+
/// JsonConverter serializer settings for GO Feature Flag to OpenFeature model deserialization
12+
/// </summary>
13+
public static readonly JsonSerializerOptions DefaultSerializerSettings = new JsonSerializerOptions
14+
{
15+
WriteIndented = true,
16+
AllowTrailingCommas = true,
17+
Converters =
18+
{
19+
new OpenFeatureStructureConverter(),
20+
new OpenFeatureValueConverter()
21+
}
22+
};
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text.Json;
4+
using System.Text.Json.Serialization;
5+
using OpenFeature.Model;
6+
7+
namespace OpenFeature.Contrib.Providers.GOFeatureFlag.converters
8+
{
9+
/// <summary>
10+
/// OpenFeatureStructureConverter
11+
/// </summary>
12+
public class OpenFeatureStructureConverter : JsonConverter<Structure>
13+
{
14+
/// <inheritdoc />
15+
public override void Write(Utf8JsonWriter writer, Structure value, JsonSerializerOptions options)
16+
{
17+
var jsonDoc = JsonDocument.Parse(JsonSerializer.Serialize(value.AsDictionary(),
18+
JsonConverterExtensions.DefaultSerializerSettings));
19+
jsonDoc.WriteTo(writer);
20+
}
21+
22+
/// <inheritdoc />
23+
public override Structure Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
24+
{
25+
using var jsonDocument = JsonDocument.ParseValue(ref reader);
26+
var jsonText = jsonDocument.RootElement.GetRawText();
27+
return new Structure(JsonSerializer.Deserialize<Dictionary<string, Value>>(jsonText,
28+
JsonConverterExtensions.DefaultSerializerSettings));
29+
}
30+
}
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text.Json;
4+
using System.Text.Json.Serialization;
5+
using OpenFeature.Model;
6+
7+
namespace OpenFeature.Contrib.Providers.GOFeatureFlag.converters
8+
{
9+
/// <summary>
10+
/// OpenFeature Value type converter
11+
/// </summary>
12+
public class OpenFeatureValueConverter : JsonConverter<Value>
13+
{
14+
/// <inheritdoc />
15+
public override Value Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
16+
{
17+
var value = new Value();
18+
switch (reader.TokenType)
19+
{
20+
case JsonTokenType.String:
21+
return reader.TryGetDateTime(out var dateTimeValue)
22+
? new Value(dateTimeValue)
23+
: new Value(reader.GetString() ?? string.Empty);
24+
case JsonTokenType.True:
25+
case JsonTokenType.False:
26+
return new Value(reader.GetBoolean());
27+
case JsonTokenType.Number:
28+
if (reader.TryGetInt32(out var intValue)) return new Value(intValue);
29+
if (reader.TryGetDouble(out var dblValue)) return new Value(dblValue);
30+
break;
31+
case JsonTokenType.StartArray:
32+
return new Value(GenerateValueArray(ref reader, typeToConvert, options));
33+
case JsonTokenType.StartObject:
34+
return new Value(GetStructure(ref reader, typeToConvert, options));
35+
}
36+
37+
return value;
38+
}
39+
40+
private Structure GetStructure(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
41+
{
42+
var startDepth = reader.CurrentDepth;
43+
var structureDictionary = new Dictionary<string, Value>();
44+
while (reader.Read())
45+
{
46+
if (reader.TokenType == JsonTokenType.PropertyName)
47+
{
48+
var key = reader.GetString();
49+
reader.Read();
50+
var val = Read(ref reader, typeToConvert, options);
51+
structureDictionary[key ?? string.Empty] = val;
52+
}
53+
54+
if (reader.TokenType == JsonTokenType.EndObject && reader.CurrentDepth == startDepth) break;
55+
}
56+
57+
return new Structure(structureDictionary);
58+
}
59+
60+
61+
private IList<Value> GenerateValueArray(ref Utf8JsonReader reader, Type typeToConvert,
62+
JsonSerializerOptions options)
63+
{
64+
var valuesArray = new List<Value>();
65+
var startDepth = reader.CurrentDepth;
66+
67+
while (reader.Read())
68+
switch (reader.TokenType)
69+
{
70+
case JsonTokenType.EndArray when reader.CurrentDepth == startDepth:
71+
return valuesArray;
72+
default:
73+
valuesArray.Add(Read(ref reader, typeToConvert, options));
74+
break;
75+
}
76+
77+
return valuesArray;
78+
}
79+
80+
/// <inheritdoc />
81+
public override void Write(Utf8JsonWriter writer, Value value, JsonSerializerOptions options)
82+
{
83+
if (value.IsList)
84+
{
85+
writer.WriteStartArray();
86+
foreach (var val in value.AsList!)
87+
{
88+
var jsonDoc = JsonDocument.Parse(JsonSerializer.Serialize(val.AsObject,
89+
JsonConverterExtensions.DefaultSerializerSettings));
90+
jsonDoc.WriteTo(writer);
91+
}
92+
93+
writer.WriteEndArray();
94+
}
95+
else
96+
{
97+
var jsonDoc = JsonDocument.Parse(JsonSerializer.Serialize(value.AsObject,
98+
JsonConverterExtensions.DefaultSerializerSettings));
99+
jsonDoc.WriteTo(writer);
100+
}
101+
}
102+
}
103+
}

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/FlagDisabled.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ namespace OpenFeature.Contrib.Providers.GOFeatureFlag.exception
66
public class FlagDisabled : GoFeatureFlagException
77
{
88
}
9-
}
9+
}

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/FlagNotFoundError.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ public FlagNotFoundError(string message, Exception innerException = null) : base
1919
{
2020
}
2121
}
22-
}
22+
}

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/GeneralError.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ public GeneralError(string message, Exception innerException = null) : base(Erro
1919
{
2020
}
2121
}
22-
}
22+
}

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/GoFeatureFlagException.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,4 @@ public GoFeatureFlagException(string message, Exception inner)
3333
{
3434
}
3535
}
36-
}
36+
}

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/ImpossibleToConvertTypeError.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ public ImpossibleToConvertTypeError(string message, Exception innerException = n
2020
{
2121
}
2222
}
23-
}
23+
}

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidEvaluationContext.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ public InvalidEvaluationContext(string message, Exception innerException = null)
1919
{
2020
}
2121
}
22-
}
22+
}

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidOption.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ public InvalidOption(string message) : base(message)
1313
{
1414
}
1515
}
16-
}
16+
}

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidTargetingKey.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ public InvalidTargetingKey(string message, Exception innerException = null) : ba
1919
{
2020
}
2121
}
22-
}
22+
}

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/TypeMismatchError.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ public TypeMismatchError(string message, Exception innerException = null) : base
1919
{
2020
}
2121
}
22-
}
22+
}

‎src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/UnauthorizedError.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ public UnauthorizedError(string message, Exception innerException = null) : base
1919
{
2020
}
2121
}
22-
}
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using System.Collections.Generic;
2+
using OpenFeature.Model;
3+
4+
namespace OpenFeature.Contrib.Providers.GOFeatureFlag.extensions
5+
{
6+
/// <summary>
7+
/// Extensions for GO Feature Flag provider.
8+
/// </summary>
9+
public static class GoFeatureFlagExtensions
10+
{
11+
/// <summary>
12+
/// Convert a Dictionary to an ImmutableMetadata.
13+
/// </summary>
14+
/// <param name="metadataDictionary"></param>
15+
/// <returns></returns>
16+
public static ImmutableMetadata
17+
ToImmutableMetadata(this Dictionary<string, object> metadataDictionary) // 'this' keyword is crucial
18+
{
19+
return metadataDictionary != null ? new ImmutableMetadata(metadataDictionary) : null;
20+
}
21+
}
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.Collections.Generic;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using OpenFeature.Contrib.Providers.GOFeatureFlag.models;
5+
using OpenFeature.Model;
6+
7+
namespace OpenFeature.Contrib.Providers.GOFeatureFlag.hooks
8+
{
9+
/// <summary>
10+
/// Enrich the evaluation context with additional information
11+
/// </summary>
12+
public class EnrichEvaluationContextHook : Hook
13+
{
14+
private readonly Structure _metadata;
15+
16+
/// <summary>
17+
/// Constructor of the Hook
18+
/// </summary>
19+
/// <param name="metadata">metadata to use in order to enrich the evaluation context</param>
20+
public EnrichEvaluationContextHook(ExporterMetadata metadata)
21+
{
22+
_metadata = metadata.AsStructure();
23+
}
24+
25+
/// <summary>
26+
/// Enrich the evaluation context with additional information before the evaluation of the flag
27+
/// </summary>
28+
/// <param name="context"></param>
29+
/// <param name="hints"></param>
30+
/// <param name="cancellationToken"></param>
31+
/// <typeparam name="T"></typeparam>
32+
/// <returns></returns>
33+
public override ValueTask<EvaluationContext> BeforeAsync<T>(HookContext<T> context,
34+
IReadOnlyDictionary<string, object> hints = null, CancellationToken cancellationToken = default)
35+
{
36+
var builder = EvaluationContext.Builder();
37+
builder.Merge(context.EvaluationContext);
38+
builder.Set("gofeatureflag", _metadata);
39+
return new ValueTask<EvaluationContext>(builder.Build());
40+
}
41+
}
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using OpenFeature.Model;
2+
3+
namespace OpenFeature.Contrib.Providers.GOFeatureFlag.models
4+
{
5+
/// <summary>
6+
/// This class represent the exporter metadata that will be sent in your evaluation data collectore
7+
/// </summary>
8+
public class ExporterMetadata
9+
{
10+
private readonly StructureBuilder _exporterMetadataBuilder = Structure.Builder();
11+
12+
/// <summary>
13+
/// Add metadata to the exporter
14+
/// </summary>
15+
/// <param name="key"></param>
16+
/// <param name="value"></param>
17+
public void Add(string key, string value)
18+
{
19+
_exporterMetadataBuilder.Set(key, value);
20+
}
21+
22+
/// <summary>
23+
/// Add metadata to the exporter
24+
/// </summary>
25+
/// <param name="key"></param>
26+
/// <param name="value"></param>
27+
public void Add(string key, bool value)
28+
{
29+
_exporterMetadataBuilder.Set(key, value);
30+
}
31+
32+
/// <summary>
33+
/// Add metadata to the exporter
34+
/// </summary>
35+
/// <param name="key"></param>
36+
/// <param name="value"></param>
37+
public void Add(string key, double value)
38+
{
39+
_exporterMetadataBuilder.Set(key, value);
40+
}
41+
42+
/// <summary>
43+
/// Add metadata to the exporter
44+
/// </summary>
45+
/// <param name="key"></param>
46+
/// <param name="value"></param>
47+
public void Add(string key, int value)
48+
{
49+
_exporterMetadataBuilder.Set(key, value);
50+
}
51+
52+
/// <summary>
53+
/// Return the metadata as a structure
54+
/// </summary>
55+
/// <returns></returns>
56+
public Structure AsStructure()
57+
{
58+
return _exporterMetadataBuilder.Build();
59+
}
60+
}
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using System.Collections.Generic;
2+
using System.Text.Json;
3+
using OpenFeature.Contrib.Providers.GOFeatureFlag.converters;
4+
using OpenFeature.Contrib.Providers.GOFeatureFlag.exception;
5+
using OpenFeature.Model;
6+
7+
namespace OpenFeature.Contrib.Providers.GOFeatureFlag
8+
{
9+
/// <summary>
10+
/// GO Feature Flag request to be sent to the evaluation API
11+
/// </summary>
12+
public class OfrepRequest
13+
{
14+
private const string KeyField = "targetingKey";
15+
private readonly EvaluationContext _ctx;
16+
17+
/// <summary>
18+
/// Create a new GO Feature Flag request to be sent to the evaluation API
19+
/// </summary>
20+
/// <param name="ctx"></param>
21+
/// <exception cref="InvalidEvaluationContext"></exception>
22+
/// <exception cref="InvalidTargetingKey"></exception>
23+
public OfrepRequest(EvaluationContext ctx)
24+
{
25+
try
26+
{
27+
if (ctx is null)
28+
throw new InvalidEvaluationContext("GO Feature Flag need an Evaluation context to work.");
29+
if (!ctx.GetValue(KeyField).IsString)
30+
throw new InvalidTargetingKey("targetingKey field MUST be a string.");
31+
}
32+
catch (KeyNotFoundException e)
33+
{
34+
throw new InvalidTargetingKey("targetingKey field is mandatory.", e);
35+
}
36+
37+
_ctx = ctx;
38+
}
39+
40+
/// <summary>
41+
/// Returns the JSON request as string to be sent to the API
42+
/// </summary>
43+
/// <returns>JSON request as string to be sent to the API</returns>
44+
public string AsJsonString()
45+
{
46+
var request = new Dictionary<string, object> { { "context", _ctx.AsDictionary() } };
47+
return JsonSerializer.Serialize(request, JsonConverterExtensions.DefaultSerializerSettings);
48+
}
49+
}
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using System.Collections.Generic;
2+
3+
namespace OpenFeature.Contrib.Providers.GOFeatureFlag
4+
{
5+
/// <summary>
6+
/// OfrepResponse is the response returned by the OFREP API.
7+
/// </summary>
8+
public class OfrepResponse
9+
{
10+
/// <summary>
11+
/// value contains the result of the flag.
12+
/// </summary>
13+
public object Value { get; set; }
14+
15+
/// <summary>
16+
/// key contains the name of the feature flag.
17+
/// </summary>
18+
public string Key { get; set; }
19+
20+
/// <summary>
21+
/// reason used to choose this variation.
22+
/// </summary>
23+
public string Reason { get; set; }
24+
25+
/// <summary>
26+
/// variationType contains the name of the variation used for this flag.
27+
/// </summary>
28+
public string Variant { get; set; }
29+
30+
/// <summary>
31+
/// cacheable is true if the flag is cacheable.
32+
/// </summary>
33+
public bool Cacheable { get; set; }
34+
35+
/// <summary>
36+
/// errorCode is empty if everything went ok.
37+
/// </summary>
38+
public string ErrorCode { get; set; }
39+
40+
/// <summary>
41+
/// errorDetails is set only if errorCode is not empty.
42+
/// </summary>
43+
public string ErrorDetails { get; set; }
44+
45+
/// <summary>
46+
/// metadata contains the metadata of the flag.
47+
/// </summary>
48+
public Dictionary<string, object> Metadata { get; set; }
49+
}
50+
}

‎test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/GoFeatureFlagProviderTest.cs

+665-563
Large diffs are not rendered by default.

‎test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/GoFeatureFlagUserTest.cs

-29
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text.Json;
4+
using Newtonsoft.Json.Linq;
5+
using OpenFeature.Contrib.Providers.GOFeatureFlag.converters;
6+
using OpenFeature.Model;
7+
using Xunit;
8+
9+
namespace OpenFeature.Contrib.Providers.GOFeatureFlag.Test;
10+
11+
public class OfrepSerializationTest
12+
{
13+
[Fact]
14+
public void OfrepSerializesCorrectly()
15+
{
16+
var ctx = EvaluationContext.Builder()
17+
.Set("targetingKey", "1d1b9238-2591-4a47-94cf-d2bc080892f1")
18+
.Set("firstname", "john")
19+
.Set("lastname", "doe")
20+
.Set("email", "john.doe@gofeatureflag.org")
21+
.Set("admin", true)
22+
.Set("anonymous", false)
23+
.Build();
24+
25+
var ofrepReq = new OfrepRequest(ctx);
26+
27+
var want = JObject.Parse("{\"context\":{\"firstname\":\"john\",\"email\":\"john.doe@gofeatureflag.org\",\"lastname\":\"doe\",\"targetingKey\":\"1d1b9238-2591-4a47-94cf-d2bc080892f1\",\"admin\":true,\"anonymous\":false}}");
28+
var got = JObject.Parse(ofrepReq.AsJsonString());
29+
Assert.True(JToken.DeepEquals(want, got), "unexpected json");
30+
}
31+
32+
[Fact]
33+
public void ToStringDictionary_WithEmptyContext_ShouldReturnEmptyDictionary()
34+
{
35+
var evaluationContext = EvaluationContext.Builder().Build();
36+
var want = JObject.Parse("{\"context\":{}}");
37+
var request = new Dictionary<string, object> { { "context", evaluationContext.AsDictionary() } };
38+
var got = JObject.Parse(JsonSerializer.Serialize(request, JsonConverterExtensions.DefaultSerializerSettings));
39+
Assert.True(JToken.DeepEquals(want, got), "unexpected json");
40+
}
41+
42+
[Fact]
43+
public void ToStringDictionary_WithContext_ShouldReturnADictionaryWithValues()
44+
{
45+
var evaluationContext = EvaluationContext.Builder()
46+
.SetTargetingKey("828c9b62-94c4-4ef3-bddc-e024bfa51a67")
47+
.Set("location", "somewhere")
48+
.Build();
49+
50+
var request = new Dictionary<string, object> { { "context", evaluationContext.AsDictionary() } };
51+
var got = JObject.Parse(JsonSerializer.Serialize(request, JsonConverterExtensions.DefaultSerializerSettings));
52+
var want = JObject.Parse("{\"context\":{\"location\":\"somewhere\",\"targetingKey\":\"828c9b62-94c4-4ef3-bddc-e024bfa51a67\"}}");
53+
Assert.True(JToken.DeepEquals(want, got), "unexpected json");
54+
}
55+
56+
[Fact]
57+
public void ToStringDictionary_WithContextAndIntegerValue_ShouldReturnADictionaryWithStringValues()
58+
{
59+
var evaluationContext = EvaluationContext.Builder()
60+
.SetTargetingKey("828c9b62-94c4-4ef3-bddc-e024bfa51a67")
61+
.Set("age", 23)
62+
.Build();
63+
var request = new Dictionary<string, object> { { "context", evaluationContext.AsDictionary() } };
64+
var got = JObject.Parse(JsonSerializer.Serialize(request, JsonConverterExtensions.DefaultSerializerSettings));
65+
var want = JObject.Parse("{\"context\":{\"age\":23,\"targetingKey\":\"828c9b62-94c4-4ef3-bddc-e024bfa51a67\"}}");
66+
Assert.True(JToken.DeepEquals(want, got), "unexpected json");
67+
}
68+
69+
[Fact]
70+
public void ToStringDictionary_WithContextAndValuesOfStrings_ShouldReturnADictionaryWithSerializedStringValues()
71+
{
72+
var testStructure = new Structure(new Dictionary<string, Value>
73+
{
74+
{ "config1", new Value("value1") },
75+
{ "config2", new Value("value2") }
76+
});
77+
78+
var evaluationContext = EvaluationContext.Builder()
79+
.SetTargetingKey("828c9b62-94c4-4ef3-bddc-e024bfa51a67")
80+
.Set("config", testStructure)
81+
.Build();
82+
var request = new Dictionary<string, object> { { "context", evaluationContext.AsDictionary() } };
83+
var got = JObject.Parse(JsonSerializer.Serialize(request, JsonConverterExtensions.DefaultSerializerSettings));
84+
var want = JObject.Parse("{\"context\":{\"config\":{\"config1\":\"value1\", \"config2\":\"value2\"},\"targetingKey\":\"828c9b62-94c4-4ef3-bddc-e024bfa51a67\"}}");
85+
Assert.True(JToken.DeepEquals(want, got), "unexpected json");
86+
}
87+
88+
[Fact]
89+
public void ToStringDictionary_WithContextAndMixedValueTypes_ShouldReturnADictionaryWithSerializedValues()
90+
{
91+
var dateTime = new DateTime(2025, 9, 1);
92+
var testStructure = new Structure(new Dictionary<string, Value>
93+
{
94+
{ "config1", new Value(1) },
95+
{ "config2", new Value("value2") },
96+
{ "config3", new Value(dateTime) }
97+
});
98+
99+
var evaluationContext = EvaluationContext.Builder()
100+
.SetTargetingKey("828c9b62-94c4-4ef3-bddc-e024bfa51a67")
101+
.Set("config", testStructure)
102+
.Build();
103+
104+
var request = new Dictionary<string, object> { { "context", evaluationContext.AsDictionary() } };
105+
var got = JObject.Parse(JsonSerializer.Serialize(request, JsonConverterExtensions.DefaultSerializerSettings));
106+
var want = JObject.Parse("{\"context\":{\"config\":{\"config3\":\"2025-09-01T00:00:00\",\"config2\":\"value2\",\"config1\":1},\"targetingKey\":\"828c9b62-94c4-4ef3-bddc-e024bfa51a67\"}}");
107+
Assert.True(JToken.DeepEquals(want, got), "unexpected json");
108+
}
109+
110+
[Fact]
111+
public void ToStringDictionary_WithContextWithListAndNestedList_ShouldReturnADictionaryWithSerializedValues()
112+
{
113+
var sampleDictionary = new Dictionary<string, Value>();
114+
sampleDictionary["config2"] = new Value([
115+
new Value([new Value("element1-1"), new Value("element1-2")]),
116+
new Value("element2"),
117+
new Value("element3")
118+
]);
119+
sampleDictionary["config3"] = new Value(new DateTime(2025, 9, 1));
120+
121+
var testStructure = new Structure(sampleDictionary);
122+
123+
var evaluationContext = EvaluationContext.Builder()
124+
.SetTargetingKey("828c9b62-94c4-4ef3-bddc-e024bfa51a67")
125+
.Set("config", testStructure)
126+
.Build();
127+
128+
var request = new Dictionary<string, object> { { "context", evaluationContext.AsDictionary() } };
129+
var got = JObject.Parse(JsonSerializer.Serialize(request, JsonConverterExtensions.DefaultSerializerSettings));
130+
var want = JObject.Parse("{\"context\":{\"config\":{\"config2\":[[\"element1-1\",\"element1-2\"],\"element2\",\"element3\"],\"config3\":\"2025-09-01T00:00:00\"},\"targetingKey\":\"828c9b62-94c4-4ef3-bddc-e024bfa51a67\"}}");
131+
Assert.True(JToken.DeepEquals(want, got), "unexpected json");
132+
}
133+
134+
[Fact]
135+
public void ToStringDictionary_WithContextWithNestedStructure_ShouldReturnADictionaryWithSerializedValues()
136+
{
137+
var testStructure = new Structure(new Dictionary<string, Value>
138+
{
139+
{
140+
"config-value-struct", new Value(new Structure(new Dictionary<string, Value>
141+
{
142+
{ "nested1", new Value(1) }
143+
}))
144+
},
145+
{ "config-value-value", new Value(new Value(new DateTime(2025, 9, 1))) }
146+
});
147+
148+
var evaluationContext = EvaluationContext.Builder()
149+
.SetTargetingKey("828c9b62-94c4-4ef3-bddc-e024bfa51a67")
150+
.Set("config", testStructure)
151+
.Build();
152+
var request = new Dictionary<string, object> { { "context", evaluationContext.AsDictionary() } };
153+
var got = JObject.Parse(JsonSerializer.Serialize(request, JsonConverterExtensions.DefaultSerializerSettings));
154+
var want = JObject.Parse("{\"context\":{\"config\":{\"config-value-struct\":{\"nested1\":1},\"config-value-value\":\"2025-09-01T00:00:00\"},\"targetingKey\":\"828c9b62-94c4-4ef3-bddc-e024bfa51a67\"}}");
155+
Assert.True(JToken.DeepEquals(want, got), "unexpected json");
156+
}
157+
}

‎test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
</ItemGroup>
88
<ItemGroup>
99
<PackageReference Include="RichardSzalay.MockHttp" Version="6.0.0" />
10+
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
1011
</ItemGroup>
1112
</Project>

0 commit comments

Comments
 (0)
Please sign in to comment.