Skip to content
Open
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
87 changes: 87 additions & 0 deletions .github/workflows/aot-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: AOT Compatibility

on:
push:
branches: [main]
pull_request:
branches: [main]
merge_group:
workflow_dispatch:

jobs:
aot-compatibility:
name: AOT Test (${{ matrix.os }}, ${{ matrix.arch }})
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
# Linux x64
- os: ubuntu-latest
arch: x64
runtime: linux-x64
# Linux ARM64
- os: ubuntu-24.04-arm
arch: arm64
runtime: linux-arm64
# Windows x64
- os: windows-latest
arch: x64
runtime: win-x64
# Windows ARM64
- os: windows-11-arm
arch: arm64
runtime: win-arm64
# macOS x64
- os: macos-15-intel
arch: x64
runtime: osx-x64
# macOS ARM64 (Apple Silicon)
- os: macos-latest
arch: arm64
runtime: osx-arm64

runs-on: ${{ matrix.os }}

steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
submodules: recursive

- name: Setup .NET SDK
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5
with:
global-json-file: global.json

- name: Restore dependencies
shell: pwsh
run: dotnet restore

- name: Build solution
shell: pwsh
run: dotnet build -c Release --no-restore

- name: Test AOT compatibility project build
shell: pwsh
run: dotnet build test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj -c Release --no-restore

- name: Publish AOT compatibility test (cross-platform)
shell: pwsh
run: |
dotnet publish test/OpenFeature.Providers.Ofrep.AotCompatibility/OpenFeature.Providers.Ofrep.AotCompatibility.csproj `
-f net10.0 `
-r ${{ matrix.runtime }} `
-o ./aot-output

- name: Run AOT compatibility test
shell: pwsh
run: |
if ("${{ runner.os }}" -eq "Windows") {
./aot-output/OpenFeature.Providers.Ofrep.AotCompatibility.exe
} else {
chmod +x ./aot-output/OpenFeature.Providers.Ofrep.AotCompatibility
./aot-output/OpenFeature.Providers.Ofrep.AotCompatibility
}
1 change: 1 addition & 0 deletions DotnetSdkContrib.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<Project Path="test/OpenFeature.Contrib.Providers.Flagsmith.Test/OpenFeature.Contrib.Providers.Flagsmith.Test.csproj" />
<Project Path="test/OpenFeature.Contrib.Providers.Flipt.Test/OpenFeature.Contrib.Providers.Flipt.Test.csproj" />
<Project Path="test/OpenFeature.Contrib.Providers.Statsig.Test/OpenFeature.Contrib.Providers.Statsig.Test.csproj" />
<Project Path="test/OpenFeature.Providers.Ofrep.AotCompatibility/OpenFeature.Providers.Ofrep.AotCompatibility.csproj" />
<Project Path="test/OpenFeature.Providers.Ofrep.Test/OpenFeature.Providers.Ofrep.Test.csproj" />
<Project Path="test/OpenFeature.Providers.GOFeatureFlag.Test/OpenFeature.Providers.GOFeatureFlag.Test.csproj" />
</Folder>
Expand Down
4 changes: 4 additions & 0 deletions src/OpenFeature.Providers.Ofrep/Client/IOfrepClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ internal interface IOfrepClient : IDisposable
/// <param name="context">The evaluation context.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The evaluated flag response.</returns>
/// <remarks>
/// This generic method is provided for backward compatibility.
/// For Native AOT scenarios, use the typed methods (EvaluateBooleanFlag, EvaluateStringFlag, etc.) instead.
/// </remarks>
Comment on lines +20 to +23
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still valid?

Task<OfrepResponse<T>> EvaluateFlag<T>(string flagKey, T defaultValue, EvaluationContext? context,
CancellationToken cancellationToken);
}
115 changes: 96 additions & 19 deletions src/OpenFeature.Providers.Ofrep/Client/OfrepClient.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
using System.Net;
using System.Text;
#if NETFRAMEWORK
using System.Net.Http;
#endif
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using OpenFeature.Constant;
using OpenFeature.Providers.Ofrep.Configuration;
using OpenFeature.Providers.Ofrep.Extensions;
using OpenFeature.Providers.Ofrep.Models;
using OpenFeature.Providers.Ofrep.Serialization;
using OpenFeature.Model;
using OpenFeature.Providers.Ofrep.Client.Constants;

Expand All @@ -27,12 +27,6 @@ internal sealed partial class OfrepClient : IOfrepClient
private readonly TimeProvider _timeProvider;
private bool _disposed;

private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};

/// <summary>
/// Creates a new instance of <see cref="OfrepClient"/>.
/// </summary>
Expand Down Expand Up @@ -105,11 +99,24 @@ internal OfrepClient(OfrepOptions configuration, HttpMessageHandler handler, ILo
this._timeProvider = timeProvider ?? TimeProvider.System;
}

/// <inheritdoc/>
/// <summary>
/// Evaluates a flag value using the OFREP API. This generic method is provided for backward compatibility.
/// For Native AOT scenarios, use the typed methods (EvaluateBooleanFlag, EvaluateStringFlag, etc.) instead.
/// </summary>
/// <typeparam name="T">The type of the flag value.</typeparam>
/// <param name="flagKey">The key of the flag to evaluate.</param>
/// <param name="defaultValue">The default value to return if evaluation fails.</param>
/// <param name="context">The evaluation context.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The evaluated flag response.</returns>
/// <remarks>
/// This method delegates to the AOT-safe typed implementation for OFREP-supported value types.
/// Other generic types use the legacy reflection-based JSON path and are not AOT-safe.
/// </remarks>
Comment on lines +102 to +115
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be now fully native AOT compliant. This needs to be removed.

public async Task<OfrepResponse<T>> EvaluateFlag<T>(string flagKey, T defaultValue,
EvaluationContext? context, CancellationToken cancellationToken = default)
{
#if NET8_0_OR_GREATER
#if NET8_0_OR_GREATER
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#if NET8_0_OR_GREATER
#if NET8_0_OR_GREATER

ArgumentException.ThrowIfNullOrWhiteSpace(flagKey);
#else
if (string.IsNullOrWhiteSpace(flagKey))
Expand Down Expand Up @@ -240,9 +247,8 @@ private async Task<OfrepResponse<T>> ProcessBadRequestResponse<T>(string flagKey
HttpResponseMessage response,
CancellationToken cancellationToken = default)
{
var evaluationResponse = await response.Content
.ReadFromJsonAsync<OfrepResponse<T>>(JsonOptions, cancellationToken).ConfigureAwait(false);
if (evaluationResponse == null)
var rawResponse = await ReadRawResponseAsync(response, cancellationToken).ConfigureAwait(false);
if (rawResponse == null)
{
this.LogNullResponse(flagKey);
return new OfrepResponse<T>(flagKey, defaultValue)
Expand All @@ -252,14 +258,36 @@ private async Task<OfrepResponse<T>> ProcessBadRequestResponse<T>(string flagKey
};
}

return evaluationResponse;
T resolvedValue = defaultValue;
var hasValue = rawResponse.Value.ValueKind != JsonValueKind.Undefined &&
rawResponse.Value.ValueKind != JsonValueKind.Null;

if (hasValue)
{
try
{
resolvedValue = DeserializeResponseValue(rawResponse.Value, defaultValue);
}
catch (JsonException ex)
{
this.LogJsonParseError(flagKey, ex.Message, ex);
}
}

return new OfrepResponse<T>(rawResponse.Key, resolvedValue)
{
ErrorCode = rawResponse.ErrorCode,
ErrorMessage = rawResponse.ErrorMessage,
Reason = rawResponse.Reason,
Variant = rawResponse.Variant,
Metadata = rawResponse.Metadata
};
}

private async Task<OfrepResponse<T>> ProcessOkResponseAsync<T>(string flagKey, T defaultValue,
HttpResponseMessage response, CancellationToken cancellationToken = default)
{
var rawResponse = await response.Content
.ReadFromJsonAsync<OfrepResponse<JsonElement>>(JsonOptions, cancellationToken).ConfigureAwait(false);
var rawResponse = await ReadRawResponseAsync(response, cancellationToken).ConfigureAwait(false);
if (rawResponse == null)
{
this.LogNullResponse(flagKey);
Expand All @@ -278,7 +306,7 @@ private async Task<OfrepResponse<T>> ProcessOkResponseAsync<T>(string flagKey, T
{
try
{
resolvedValue = rawResponse.Value.Deserialize<T>(JsonOptions) ?? defaultValue;
resolvedValue = DeserializeResponseValue(rawResponse.Value, defaultValue);
}
catch (JsonException ex)
{
Expand All @@ -291,7 +319,7 @@ private async Task<OfrepResponse<T>> ProcessOkResponseAsync<T>(string flagKey, T
resolvedValue = defaultValue;
}

var evaluationResponse = new OfrepResponse<T>(rawResponse.Key ?? flagKey, resolvedValue)
var evaluationResponse = new OfrepResponse<T>(rawResponse.Key, resolvedValue)
{
ErrorCode = rawResponse.ErrorCode,
ErrorMessage = rawResponse.ErrorMessage,
Expand All @@ -308,6 +336,15 @@ private async Task<OfrepResponse<T>> ProcessOkResponseAsync<T>(string flagKey, T
return evaluationResponse;
}

private static async Task<OfrepResponse<JsonElement>?> ReadRawResponseAsync(HttpResponseMessage response,
CancellationToken cancellationToken)
{
using var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync(responseStream,
OfrepJsonSerializerContext.Default.OfrepResponseJsonElement, cancellationToken)
.ConfigureAwait(false);
}

public void Dispose()
{
this.Dispose(true);
Expand Down Expand Up @@ -339,16 +376,56 @@ private static HttpRequestMessage CreateEvaluationRequest(string flagKey, Evalua

var evaluationContextDict = (context ?? EvaluationContext.Empty).ToDictionary();

var requestBody = JsonSerializer.Serialize(new OfrepRequest { Context = evaluationContextDict },
OfrepJsonSerializerContext.Default.OfrepRequest);

var request = new HttpRequestMessage()
{
Method = HttpMethod.Post,
RequestUri = new Uri(path, UriKind.Relative),
Content = JsonContent.Create(new OfrepRequest { Context = evaluationContextDict }, options: JsonOptions)
Content = new StringContent(requestBody, Encoding.UTF8, "application/json")
};

return request;
}

private static T DeserializeResponseValue<T>(JsonElement valueElement, T defaultValue)
{
if (typeof(T) == typeof(bool))
{
return (T)(object)valueElement.GetBoolean();
}

if (typeof(T) == typeof(string))
{
var stringValue = valueElement.GetString();
return stringValue is null ? defaultValue : (T)(object)stringValue;
}

if (typeof(T) == typeof(int))
{
return (T)(object)valueElement.GetInt32();
}

if (typeof(T) == typeof(double))
{
return (T)(object)valueElement.GetDouble();
}

if (typeof(T) == typeof(JsonElement))
{
return (T)(object)valueElement;
}

if (typeof(T) == typeof(JsonElement?))
{
JsonElement? nullableElement = valueElement;
return (T)(object)nullableElement;
}

return defaultValue;
}

/// <summary>
/// Helper to handle errors during flag evaluation.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion src/OpenFeature.Providers.Ofrep/Models/OfrepRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ internal class OfrepRequest
/// Gets or sets the evaluation context for the request.
/// </summary>
[JsonPropertyName("context")]
public object? Context { get; set; }
public IDictionary<string, object?>? Context { get; set; }
}
17 changes: 9 additions & 8 deletions src/OpenFeature.Providers.Ofrep/OfrepProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using OpenFeature.Providers.Ofrep.Client.Constants;
using OpenFeature.Providers.Ofrep.Configuration;
using OpenFeature.Providers.Ofrep.Extensions;
using OpenFeature.Providers.Ofrep.Models;

namespace OpenFeature.Providers.Ofrep;

Expand Down Expand Up @@ -124,28 +125,28 @@ public void Dispose()
public override Task<ResolutionDetails<bool>> ResolveBooleanValueAsync(
string flagKey, bool defaultValue, EvaluationContext? context = null,
CancellationToken cancellationToken = default) =>
this.ResolveFlag(flagKey, defaultValue, context,
this.ResolveFlag(flagKey, defaultValue, context, this._client.EvaluateFlag,
cancellationToken);

/// <inheritdoc/>
public override Task<ResolutionDetails<string>> ResolveStringValueAsync(
string flagKey, string defaultValue, EvaluationContext? context = null,
CancellationToken cancellationToken = default) =>
this.ResolveFlag(flagKey, defaultValue, context,
this.ResolveFlag(flagKey, defaultValue, context, this._client.EvaluateFlag,
cancellationToken);

/// <inheritdoc/>
public override Task<ResolutionDetails<int>> ResolveIntegerValueAsync(
string flagKey, int defaultValue, EvaluationContext? context = null,
CancellationToken cancellationToken = default) =>
this.ResolveFlag(flagKey, defaultValue, context,
this.ResolveFlag(flagKey, defaultValue, context, this._client.EvaluateFlag,
cancellationToken);

/// <inheritdoc/>
public override Task<ResolutionDetails<double>> ResolveDoubleValueAsync(
string flagKey, double defaultValue, EvaluationContext? context = null,
CancellationToken cancellationToken = default) =>
this.ResolveFlag(flagKey, defaultValue, context,
this.ResolveFlag(flagKey, defaultValue, context, this._client.EvaluateFlag,
cancellationToken);

/// <inheritdoc/>
Expand All @@ -164,8 +165,7 @@ public override async Task<ResolutionDetails<Value>>
// This avoids type mismatch issues when deserializing object values as System.Text.Json
// returns JsonElement for object types, which is not a valid Value constructor parameter.
var response =
await this._client.EvaluateFlag<JsonElement?>(flagKey, null,
context, cancellationToken).ConfigureAwait(false);
await this._client.EvaluateFlag<JsonElement?>(flagKey, null, context, cancellationToken).ConfigureAwait(false);

var resolvedValue = response.Value.HasValue
? response.Value.Value.ToValue()
Expand All @@ -188,22 +188,23 @@ public override async Task<ResolutionDetails<Value>>
/// <param name="flagKey">The unique identifier for the flag</param>
/// <param name="defaultValue">The default value to return if the flag cannot be resolved</param>
/// <param name="context">Optional evaluation context with targeting information</param>
/// <param name="evaluate">Typed client evaluation function for supported OFREP value types.</param>
/// <param name="cancellationToken">A token to cancel the operation</param>
/// <returns>Resolution details containing the flag value and
/// metadata</returns>
private async Task<ResolutionDetails<T>> ResolveFlag<T>(
string flagKey,
T defaultValue,
EvaluationContext? context,
Func<string, T, EvaluationContext?, CancellationToken, Task<OfrepResponse<T>>> evaluate,
CancellationToken cancellationToken)
{
if (flagKey == null)
{
throw new ArgumentNullException(nameof(flagKey));
}

var response = await this._client.EvaluateFlag(flagKey, defaultValue,
context, cancellationToken).ConfigureAwait(false);
var response = await evaluate(flagKey, defaultValue, context, cancellationToken).ConfigureAwait(false);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you passing a function here instead of calling directly the method?


return new ResolutionDetails<T>(
flagKey,
Expand Down
Loading