-
Notifications
You must be signed in to change notification settings - Fork 50
feat(ofrep): AOT compatibility #651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7b1a7de
1799c3b
0cb441d
f7f84de
4019d56
1edbd43
8b60b1b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } |
| 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; | ||||||
|
|
||||||
|
|
@@ -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> | ||||||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| ArgumentException.ThrowIfNullOrWhiteSpace(flagKey); | ||||||
| #else | ||||||
| if (string.IsNullOrWhiteSpace(flagKey)) | ||||||
|
|
@@ -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) | ||||||
|
|
@@ -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); | ||||||
|
|
@@ -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) | ||||||
| { | ||||||
|
|
@@ -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, | ||||||
|
|
@@ -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); | ||||||
|
|
@@ -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> | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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/> | ||
|
|
@@ -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() | ||
|
|
@@ -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); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this still valid?