From a906a8fec9d0331e7eb073682453fa123a699a29 Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Fri, 25 Oct 2024 15:48:07 -0400 Subject: [PATCH] feat: Csharp client (#470) * chore: start csharp client Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> * chore: moving things for csharp Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> * chore: add tests for csharp client Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> * chore: try to get things wired up Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> * fix: loading libs Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> * chore: fix tests Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> * chore: add readme + license Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> * chore: package Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> * chore: pr updates Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> * chore: package Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> * chore: fix build Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> --------- Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> --- .github/workflows/package-csharp-sdk.yml | 18 +++ .github/workflows/package-ffi-sdks.yml | 3 +- README.md | 2 +- flipt-client-browser/README.md | 3 - flipt-client-csharp/.gitignore | 38 +++++ flipt-client-csharp/LICENSE | 21 +++ flipt-client-csharp/README.md | 87 +++++++++++ flipt-client-csharp/flipt-client.sln | 36 +++++ .../src/FliptClient/EvaluationClient.cs | 105 +++++++++++++ .../src/FliptClient/FliptClient.csproj | 56 +++++++ .../src/FliptClient/Models/ClientOptions.cs | 46 ++++++ .../src/FliptClient/Models/Request.cs | 17 +++ .../src/FliptClient/Models/Response.cs | 107 +++++++++++++ .../src/FliptClient/NativeMethods.cs | 130 ++++++++++++++++ .../EvaluationClientTests.cs | 144 ++++++++++++++++++ .../FliptClient.Tests.csproj | 29 ++++ flipt-client-go/evaluation.go | 31 ++-- flipt-client-go/models.go | 7 - package/ffi/main.go | 33 ++++ test/main.go | 65 +++++--- 20 files changed, 926 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/package-csharp-sdk.yml create mode 100644 flipt-client-csharp/.gitignore create mode 100644 flipt-client-csharp/LICENSE create mode 100644 flipt-client-csharp/README.md create mode 100644 flipt-client-csharp/flipt-client.sln create mode 100644 flipt-client-csharp/src/FliptClient/EvaluationClient.cs create mode 100644 flipt-client-csharp/src/FliptClient/FliptClient.csproj create mode 100644 flipt-client-csharp/src/FliptClient/Models/ClientOptions.cs create mode 100644 flipt-client-csharp/src/FliptClient/Models/Request.cs create mode 100644 flipt-client-csharp/src/FliptClient/Models/Response.cs create mode 100644 flipt-client-csharp/src/FliptClient/NativeMethods.cs create mode 100644 flipt-client-csharp/tests/FliptClient.Tests/EvaluationClientTests.cs create mode 100644 flipt-client-csharp/tests/FliptClient.Tests/FliptClient.Tests.csproj diff --git a/.github/workflows/package-csharp-sdk.yml b/.github/workflows/package-csharp-sdk.yml new file mode 100644 index 00000000..b244b8ed --- /dev/null +++ b/.github/workflows/package-csharp-sdk.yml @@ -0,0 +1,18 @@ +name: Package C# SDK +on: + push: + tags: ["flipt-client-csharp-**"] + +permissions: + contents: write + id-token: write + +env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + +jobs: + build: + uses: ./.github/workflows/package-ffi-sdks.yml + with: + sdks: csharp + secrets: inherit diff --git a/.github/workflows/package-ffi-sdks.yml b/.github/workflows/package-ffi-sdks.yml index 9aca6f42..620081f9 100644 --- a/.github/workflows/package-ffi-sdks.yml +++ b/.github/workflows/package-ffi-sdks.yml @@ -28,7 +28,8 @@ env: MAVEN_PUBLISH_REGISTRY_URL: ${{ secrets.MAVEN_PUBLISH_REGISTRY_URL }} PGP_PRIVATE_KEY: ${{ secrets.PGP_PRIVATE_KEY }} PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} - GO_VERSION: "1.22" + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + GO_VERSION: "1.23" DAGGER_VERSION: "0.12.3" jobs: diff --git a/README.md b/README.md index 83d0ee43..bb5cc083 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Currently, we support the following languages/platforms: | [JavaScript (Browser)](./flipt-client-browser) | WASM | N/A | N/A | | [React Web (Browser)](./flipt-client-react) | WASM | N/A | N/A | | [Flutter/Dart](./flipt-client-dart) | FFI | ✅ | ❌ | +| [C#](./flipt-client-csharp) | FFI | ✅ | ❌ | Documentation for each client can be found in the README of that client's directory. @@ -71,7 +72,6 @@ Documentation for each client can be found in the README of that client's direct Languages we are planning to support: 1. [Rust](https://github.com/flipt-io/flipt-client-sdks/issues/83) -1. [C#](https://github.com/flipt-io/flipt-client-sdks/issues/310) ### Help Wanted diff --git a/flipt-client-browser/README.md b/flipt-client-browser/README.md index 388a940e..cecb5136 100644 --- a/flipt-client-browser/README.md +++ b/flipt-client-browser/README.md @@ -10,9 +10,6 @@ The `flipt-client-browser` library contains the JavaScript/TypeScript source cod npm install @flipt-io/flipt-client-browser ``` -> [!IMPORTANT] -> The latest version of the Flipt Browser SDK does not currently work with Next.js App Router because Next.js App Router does not support WASM dependencies. See [this issue](https://github.com/vercel/next.js/issues/55537) for more information. - ## Usage In your JavaScript/Typescript code you can import this client and use it as so: diff --git a/flipt-client-csharp/.gitignore b/flipt-client-csharp/.gitignore new file mode 100644 index 00000000..308fcf88 --- /dev/null +++ b/flipt-client-csharp/.gitignore @@ -0,0 +1,38 @@ +*.swp +*.*~ +project.lock.json +.DS_Store +*.pyc +nupkg/ + +# Visual Studio Code +.vscode + +# Rider +.idea + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +msbuild.log +msbuild.err +msbuild.wrn + +# Visual Studio 2015 +.vs/ +ext/ diff --git a/flipt-client-csharp/LICENSE b/flipt-client-csharp/LICENSE new file mode 100644 index 00000000..66092fda --- /dev/null +++ b/flipt-client-csharp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Flipt Software Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/flipt-client-csharp/README.md b/flipt-client-csharp/README.md new file mode 100644 index 00000000..4234e838 --- /dev/null +++ b/flipt-client-csharp/README.md @@ -0,0 +1,87 @@ +# Flipt Client C# + +The `flipt-client-csharp` library contains the C# source code for the Flipt [client-side evaluation](https://www.flipt.io/docs/integration/client) client. + +## Installation + +```bash +dotnet add package Flipt.Client +``` + +## How Does It Work? + +The `flipt-client-csharp` library is a wrapper around the [flipt-engine-ffi](https://github.com/flipt-io/flipt-client-sdks/tree/main/flipt-engine-ffi) library. + +All evaluation happens within the SDK, using the shared library built from the [flipt-engine-ffi](https://github.com/flipt-io/flipt-client-sdks/tree/main/flipt-engine-ffi) library. + +Because the evaluation happens within the SDK, the SDKs can be used in environments where the Flipt server is not available or reachable after the initial data is fetched. + +## Data Fetching + +Upon instantiation, the `flipt-client-csharp` library will fetch the flag state from the Flipt server and store it in memory. This means that the first time you use the SDK, it will make a request to the Flipt server. + +### Polling (Default) + +By default, the SDK will poll the Flipt server for new flag state at a regular interval. This interval can be configured using the `FetchMode` option when constructing a client. The default interval is 120 seconds. + +### Streaming (Flipt Cloud Only) + +[Flipt Cloud](https://flipt.io/cloud) users can use the `streaming` fetch method to stream flag state changes from the Flipt server to the SDK. + +When in streaming mode, the SDK will connect to the Flipt server and open a persistent connection that will remain open until the client is closed. The SDK will then receive flag state changes in real-time. + +## Supported Architectures + +This SDK currently supports the following OSes/architectures: + +- Linux x86_64 +- Linux arm64 +- MacOS x86_64 +- MacOS arm64 +- Windows x86_64 + +## Usage + +In your C# code you can import this client and use it as so: + +```csharp +using Flipt.Client; + +var options = new ClientOptions + { + Url = "http://localhost:8080", + Authentication = new Authentication { ClientToken = "secret" } + }; + +var client = new EvaluationClient("default", options); + +var context = new Dictionary { { "fizz", "buzz" } }; +var response = client.EvaluateVariant("flag1", "someentity", context); +``` + +### Initialization Arguments + +The `EvaluationClient` constructor accepts two optional arguments: + +- `Namespace`: The namespace to fetch flag state from. If not provided, the client will default to the `default` namespace. +- `Options`: An instance of the `ClientOptions` type that supports several options for the client. The structure is: + - `Url`: The URL of the upstream Flipt instance. If not provided, the client will default to `http://localhost:8080`. + - `Authentication`: The authentication strategy to use when communicating with the upstream Flipt instance. If not provided, the client will default to no authentication. See the [Authentication](#authentication) section for more information. + - `Reference`: The [reference](https://docs.flipt.io/guides/user/using-references) to use when fetching flag state. If not provided, reference will not be used. + - `FetchMode`: The fetch mode to use when fetching flag state. If not provided, the client will default to polling. + +### Authentication + +The `EvaluationClient` supports the following authentication strategies: + +- No Authentication (default) +- [Client Token Authentication](https://docs.flipt.io/authentication/using-tokens) +- [JWT Authentication](https://docs.flipt.io/authentication/using-jwts) + +## Contributing + +Contributions are welcome! Please feel free to open an issue or submit a Pull Request. + +## License + +This project is licensed under the [MIT License](LICENSE). diff --git a/flipt-client-csharp/flipt-client.sln b/flipt-client-csharp/flipt-client.sln new file mode 100644 index 00000000..46ee4717 --- /dev/null +++ b/flipt-client-csharp/flipt-client.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7739B816-EA8F-4C26-9B5A-6A2AB45EEC07}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FliptClient", "src\FliptClient\FliptClient.csproj", "{0FF0E57A-2DBA-4E29-BC0E-D86B04E7092E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4E8D7E46-E419-4235-95C2-9E5C4037B731}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FliptClient.Tests", "tests\FliptClient.Tests\FliptClient.Tests.csproj", "{42663DCD-E7BF-4CAF-B817-82FDD9BEDCD1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0FF0E57A-2DBA-4E29-BC0E-D86B04E7092E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0FF0E57A-2DBA-4E29-BC0E-D86B04E7092E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0FF0E57A-2DBA-4E29-BC0E-D86B04E7092E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0FF0E57A-2DBA-4E29-BC0E-D86B04E7092E}.Release|Any CPU.Build.0 = Release|Any CPU + {42663DCD-E7BF-4CAF-B817-82FDD9BEDCD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {42663DCD-E7BF-4CAF-B817-82FDD9BEDCD1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42663DCD-E7BF-4CAF-B817-82FDD9BEDCD1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {42663DCD-E7BF-4CAF-B817-82FDD9BEDCD1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {0FF0E57A-2DBA-4E29-BC0E-D86B04E7092E} = {7739B816-EA8F-4C26-9B5A-6A2AB45EEC07} + {42663DCD-E7BF-4CAF-B817-82FDD9BEDCD1} = {4E8D7E46-E419-4235-95C2-9E5C4037B731} + EndGlobalSection +EndGlobal diff --git a/flipt-client-csharp/src/FliptClient/EvaluationClient.cs b/flipt-client-csharp/src/FliptClient/EvaluationClient.cs new file mode 100644 index 00000000..64eea9d6 --- /dev/null +++ b/flipt-client-csharp/src/FliptClient/EvaluationClient.cs @@ -0,0 +1,105 @@ +using System; +using System.Runtime.InteropServices; +using System.Text.Json; +using FliptClient.Models; + +namespace FliptClient +{ + public class EvaluationClient : IDisposable + { + private IntPtr _engine; + + public EvaluationClient(string @namespace = "default", ClientOptions? options = null) + { + options ??= new ClientOptions(); + string optsJson = JsonSerializer.Serialize(options); + _engine = NativeMethods.InitializeEngine(@namespace, optsJson); + } + + public VariantEvaluationResponse EvaluateVariant(string flagKey, string entityId, Dictionary context) + { + var request = new EvaluationRequest + { + FlagKey = flagKey, + EntityId = entityId, + Context = context + }; + + string requestJson = JsonSerializer.Serialize(request); + IntPtr resultPtr = NativeMethods.EvaluateVariant(_engine, requestJson); + string resultJson = Marshal.PtrToStringAnsi(resultPtr); + NativeMethods.DestroyString(resultPtr); + + var result = JsonSerializer.Deserialize(resultJson); + if (result.Status != "success") + { + throw new Exception(result.ErrorMessage); + } + + return result.Response; + } + + public BooleanEvaluationResponse EvaluateBoolean(string flagKey, string entityId, Dictionary context) + { + var request = new EvaluationRequest + { + FlagKey = flagKey, + EntityId = entityId, + Context = context + }; + + string requestJson = JsonSerializer.Serialize(request); + IntPtr resultPtr = NativeMethods.EvaluateBoolean(_engine, requestJson); + string resultJson = Marshal.PtrToStringAnsi(resultPtr); + NativeMethods.DestroyString(resultPtr); + + var result = JsonSerializer.Deserialize(resultJson); + if (result.Status != "success") + { + throw new Exception(result.ErrorMessage); + } + + return result.Response; + } + + public BatchEvaluationResponse EvaluateBatch(List requests) + { + string requestJson = JsonSerializer.Serialize(requests); + IntPtr resultPtr = NativeMethods.EvaluateBatch(_engine, requestJson); + string resultJson = Marshal.PtrToStringAnsi(resultPtr); + NativeMethods.DestroyString(resultPtr); + + var result = JsonSerializer.Deserialize(resultJson); + if (result.Status != "success") + { + throw new Exception(result.ErrorMessage); + } + + return result.Response; + } + + public Flag[] ListFlags() + { + IntPtr resultPtr = NativeMethods.ListFlags(_engine); + string resultJson = Marshal.PtrToStringAnsi(resultPtr); + NativeMethods.DestroyString(resultPtr); + + var result = JsonSerializer.Deserialize(resultJson); + if (result.Status != "success") + { + throw new Exception(result.ErrorMessage); + } + + return result.Response; + } + + public void Dispose() + { + if (_engine != IntPtr.Zero) + { + NativeMethods.DestroyEngine(_engine); + _engine = IntPtr.Zero; + } + } + } +} diff --git a/flipt-client-csharp/src/FliptClient/FliptClient.csproj b/flipt-client-csharp/src/FliptClient/FliptClient.csproj new file mode 100644 index 00000000..ea33143f --- /dev/null +++ b/flipt-client-csharp/src/FliptClient/FliptClient.csproj @@ -0,0 +1,56 @@ + + + + net8.0 + enable + enable + Flipt.Client + 0.0.1-rc.1 + Flipt Devs (dev@flipt.io) + Flipt.io + Flipt Client SDK + README.md + MIT + Flipt, Feature Flags + https://github.com/flipt-io/flipt-client-sdks/tree/main/flipt-client-csharp + win-x64;linux-x64;linux-arm64;osx-x64;osx-arm64 + + + + + + + + + PreserveNewest + true + runtimes/win-x64/native/fliptengine.dll + runtimes/win-x64/native/fliptengine.dll + + + PreserveNewest + true + runtimes/linux-x64/native/libfliptengine.so + runtimes/linux-x64/native/libfliptengine.so + + + PreserveNewest + true + runtimes/linux-arm64/native/libfliptengine.so + runtimes/linux-arm64/native/libfliptengine.so + + + PreserveNewest + true + runtimes/osx-x64/native/libfliptengine.dylib + runtimes/osx-x64/native/libfliptengine.dylib + + + PreserveNewest + true + runtimes/osx-arm64/native/libfliptengine.dylib + runtimes/osx-arm64/native/libfliptengine.dylib + + + + diff --git a/flipt-client-csharp/src/FliptClient/Models/ClientOptions.cs b/flipt-client-csharp/src/FliptClient/Models/ClientOptions.cs new file mode 100644 index 00000000..89728ee1 --- /dev/null +++ b/flipt-client-csharp/src/FliptClient/Models/ClientOptions.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FliptClient.Models +{ + public class ClientOptions + { + [JsonPropertyName("url")] + public string Url { get; set; } = "http://localhost:8080"; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("update_interval")] + public int UpdateInterval { get; set; } = 120; + + [JsonPropertyName("authentication")] + public Authentication Authentication { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("reference")] + public string Reference { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("fetch_mode")] + public FetchMode FetchMode { get; set; } = FetchMode.Polling; + } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum FetchMode + { + [JsonPropertyName("polling")] + Polling, + [JsonPropertyName("streaming")] + Streaming, + } + + public class Authentication + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("client_token")] + public string ClientToken { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("jwt_token")] + public string JwtToken { get; set; } + } +} \ No newline at end of file diff --git a/flipt-client-csharp/src/FliptClient/Models/Request.cs b/flipt-client-csharp/src/FliptClient/Models/Request.cs new file mode 100644 index 00000000..44b40bd5 --- /dev/null +++ b/flipt-client-csharp/src/FliptClient/Models/Request.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace FliptClient.Models +{ + public class EvaluationRequest + { + [JsonPropertyName("flag_key")] + public string FlagKey { get; set; } + + [JsonPropertyName("entity_id")] + public string EntityId { get; set; } + + [JsonPropertyName("context")] + public Dictionary Context { get; set; } + } +} + diff --git a/flipt-client-csharp/src/FliptClient/Models/Response.cs b/flipt-client-csharp/src/FliptClient/Models/Response.cs new file mode 100644 index 00000000..8d0e0740 --- /dev/null +++ b/flipt-client-csharp/src/FliptClient/Models/Response.cs @@ -0,0 +1,107 @@ +using System.Text.Json.Serialization; + +namespace FliptClient.Models +{ + public class Flag + { + [JsonPropertyName("key")] + public string Key { get; set; } + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + } + + public class VariantEvaluationResponse + { + [JsonPropertyName("flag_key")] + public string FlagKey { get; set; } + + [JsonPropertyName("match")] + public bool Match { get; set; } + + [JsonPropertyName("segment_keys")] + public string[] SegmentKeys { get; set; } + + [JsonPropertyName("reason")] + public string Reason { get; set; } + + [JsonPropertyName("variant_key")] + public string VariantKey { get; set; } + + [JsonPropertyName("variant_attachment")] + public string? VariantAttachment { get; set; } + } + + public class BooleanEvaluationResponse + { + [JsonPropertyName("flag_key")] + public string FlagKey { get; set; } + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("reason")] + public string Reason { get; set; } + } + + public class BatchEvaluationResponse + { + [JsonPropertyName("request_id")] + public string RequestId { get; set; } + + [JsonPropertyName("responses")] + public Response[] Responses { get; set; } + + [JsonPropertyName("request_duration_millis")] + public float RequestDurationMillis { get; set; } + } + + + public class Response + { + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("boolean_evaluation_response")] + public BooleanEvaluationResponse? BooleanEvaluationResponse { get; set; } + + [JsonPropertyName("variant_evaluation_response")] + public VariantEvaluationResponse? VariantEvaluationResponse { get; set; } + + [JsonPropertyName("error_evaluation_response")] + public ErrorEvaluationResponse? ErrorEvaluationResponse { get; set; } + } + + + public class ErrorEvaluationResponse + { + [JsonPropertyName("flag_key")] + public string? FlagKey { get; set; } + + [JsonPropertyName("namespace_key")] + public string? NamespaceKey { get; set; } + + [JsonPropertyName("reason")] + public string? Reason { get; set; } + } + + public class Result + { + [JsonPropertyName("status")] + public string Status { get; set; } + + [JsonPropertyName("result")] + public T? Response { get; set; } + + [JsonPropertyName("error_message")] + public string? ErrorMessage { get; set; } + } + + public class VariantResult : Result { } + public class BooleanResult : Result { } + public class BatchResult : Result { } + public class ListFlagsResult : Result { } +} diff --git a/flipt-client-csharp/src/FliptClient/NativeMethods.cs b/flipt-client-csharp/src/FliptClient/NativeMethods.cs new file mode 100644 index 00000000..74f18409 --- /dev/null +++ b/flipt-client-csharp/src/FliptClient/NativeMethods.cs @@ -0,0 +1,130 @@ +using System; +using System.Runtime.InteropServices; +using System.IO; + +namespace FliptClient +{ + internal static class NativeMethods + { + private static IntPtr _nativeLibraryHandle; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate IntPtr InitializeEngineDelegate(string ns, string opts); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate IntPtr EvaluateVariantDelegate(IntPtr engine, string request); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate IntPtr EvaluateBooleanDelegate(IntPtr engine, string request); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate IntPtr EvaluateBatchDelegate(IntPtr engine, string request); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate IntPtr ListFlagsDelegate(IntPtr engine); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void DestroyEngineDelegate(IntPtr engine); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void DestroyStringDelegate(IntPtr str); + + public static InitializeEngineDelegate InitializeEngine; + public static EvaluateVariantDelegate EvaluateVariant; + public static EvaluateBooleanDelegate EvaluateBoolean; + public static EvaluateBatchDelegate EvaluateBatch; + public static ListFlagsDelegate ListFlags; + public static DestroyEngineDelegate DestroyEngine; + public static DestroyStringDelegate DestroyString; + + static NativeMethods() + { + string libraryPath = GetLibraryName(); + + if (!File.Exists(libraryPath)) + { + throw new FileNotFoundException($"Native library not found at path: {libraryPath}"); + } + + _nativeLibraryHandle = NativeLibrary.Load(libraryPath); + + if (_nativeLibraryHandle == IntPtr.Zero) + { + throw new InvalidOperationException("Failed to load native library, handle is null."); + } + + // Initialize the delegates + InitializeEngine = GetDelegate("initialize_engine"); + EvaluateVariant = GetDelegate("evaluate_variant"); + EvaluateBoolean = GetDelegate("evaluate_boolean"); + EvaluateBatch = GetDelegate("evaluate_batch"); + ListFlags = GetDelegate("list_flags"); + DestroyEngine = GetDelegate("destroy_engine"); + DestroyString = GetDelegate("destroy_string"); + } + + private static string GetLibraryName() + { + string libraryName = GetPlatformLibraryPath(); + string libraryPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, libraryName); + + if (!File.Exists(libraryPath)) + { + // Try to find the library in the NuGet package structure + libraryPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "runtimes", libraryName); + } + + return libraryPath; + } + + private static string GetPlatformLibraryPath() + { + string libraryPath = ""; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + libraryPath = "runtimes/win-x64/native/fliptengine.dll"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + if (RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + libraryPath = "runtimes/linux-x64/native/libfliptengine.so"; + } + else if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64) + { + libraryPath = "runtimes/linux-arm64/native/libfliptengine.so"; + } + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + if (RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + libraryPath = "runtimes/osx-x64/native/libfliptengine.dylib"; + } + else if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64) + { + libraryPath = "runtimes/osx-arm64/native/libfliptengine.dylib"; + } + } + + return libraryPath; + } + + private static T GetDelegate(string functionName) where T : Delegate + { + if (_nativeLibraryHandle == IntPtr.Zero) + { + throw new InvalidOperationException("Native library not loaded."); + } + + IntPtr procAddress = NativeLibrary.GetExport(_nativeLibraryHandle, functionName); + if (procAddress == IntPtr.Zero) + { + throw new InvalidOperationException($"Function {functionName} not found."); + } + + return Marshal.GetDelegateForFunctionPointer(procAddress); + } + } +} diff --git a/flipt-client-csharp/tests/FliptClient.Tests/EvaluationClientTests.cs b/flipt-client-csharp/tests/FliptClient.Tests/EvaluationClientTests.cs new file mode 100644 index 00000000..4ae01a95 --- /dev/null +++ b/flipt-client-csharp/tests/FliptClient.Tests/EvaluationClientTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using Xunit; +using Xunit.Abstractions; +using FliptClient; +using FliptClient.Models; + +namespace FliptClient.Tests +{ + public class EvaluationClientTests : IDisposable + { + private readonly EvaluationClient _client; + + public EvaluationClientTests(ITestOutputHelper output) + { + Console.SetOut(new ConsoleWriter(output)); + string fliptUrl = Environment.GetEnvironmentVariable("FLIPT_URL"); + string authToken = Environment.GetEnvironmentVariable("FLIPT_AUTH_TOKEN"); + + if (string.IsNullOrEmpty(fliptUrl)) + { + throw new Exception("FLIPT_URL environment variable is not set"); + } + + if (string.IsNullOrEmpty(authToken)) + { + throw new Exception("FLIPT_AUTH_TOKEN environment variable is not set"); + } + + var options = new ClientOptions + { + Url = fliptUrl, + Authentication = new Authentication { ClientToken = authToken } + }; + + _client = new EvaluationClient("default", options); + } + + public class ConsoleWriter : StringWriter + { + private ITestOutputHelper _output; + public ConsoleWriter(ITestOutputHelper output) + { + _output = output; + } + + public override void WriteLine(string m) + { + _output.WriteLine(m); + } + } + + public void Dispose() + { + _client.Dispose(); + } + + [Fact] + public void TestEvaluateVariant() + { + var context = new Dictionary { { "fizz", "buzz" } }; + var response = _client.EvaluateVariant("flag1", "someentity", context); + + Assert.Equal("flag1", response.FlagKey); + Assert.True(response.Match); + Assert.Equal("MATCH_EVALUATION_REASON", response.Reason); + Assert.Equal("variant1", response.VariantKey); + Assert.Contains("segment1", response.SegmentKeys); + } + + [Fact] + public void TestEvaluateBoolean() + { + var context = new Dictionary { { "fizz", "buzz" } }; + var response = _client.EvaluateBoolean("flag_boolean", "someentity", context); + + Assert.Equal("flag_boolean", response.FlagKey); + Assert.True(response.Enabled); + Assert.Equal("MATCH_EVALUATION_REASON", response.Reason); + } + + [Fact] + public void TestEvaluateBatch() + { + var requests = new List + { + new EvaluationRequest { FlagKey = "flag1", EntityId = "someentity", Context = new Dictionary { { "fizz", "buzz" } } }, + new EvaluationRequest { FlagKey = "flag_boolean", EntityId = "someentity", Context = new Dictionary { { "fizz", "buzz" } } }, + new EvaluationRequest { FlagKey = "notfound", EntityId = "someentity", Context = new Dictionary { { "fizz", "buzz" } } } + }; + + var response = _client.EvaluateBatch(requests); + + Assert.Equal(3, response.Responses.Length); + + var variantResponse = response.Responses[0]; + Assert.Equal("VARIANT_EVALUATION_RESPONSE_TYPE", variantResponse.Type); + Assert.NotNull(variantResponse.VariantEvaluationResponse); + Assert.Equal("flag1", variantResponse.VariantEvaluationResponse.FlagKey); + Assert.True(variantResponse.VariantEvaluationResponse.Match); + Assert.Equal("MATCH_EVALUATION_REASON", variantResponse.VariantEvaluationResponse.Reason); + Assert.Equal("variant1", variantResponse.VariantEvaluationResponse.VariantKey); + Assert.Contains("segment1", variantResponse.VariantEvaluationResponse.SegmentKeys); + + var booleanResponse = response.Responses[1]; + Assert.Equal("BOOLEAN_EVALUATION_RESPONSE_TYPE", booleanResponse.Type); + Assert.NotNull(booleanResponse.BooleanEvaluationResponse); + Assert.Equal("flag_boolean", booleanResponse.BooleanEvaluationResponse.FlagKey); + Assert.True(booleanResponse.BooleanEvaluationResponse.Enabled); + Assert.Equal("MATCH_EVALUATION_REASON", booleanResponse.BooleanEvaluationResponse.Reason); + + var errorResponse = response.Responses[2]; + Assert.Equal("ERROR_EVALUATION_RESPONSE_TYPE", errorResponse.Type); + Assert.NotNull(errorResponse.ErrorEvaluationResponse); + Assert.Equal("notfound", errorResponse.ErrorEvaluationResponse.FlagKey); + Assert.Equal("default", errorResponse.ErrorEvaluationResponse.NamespaceKey); + Assert.Equal("NOT_FOUND_ERROR_EVALUATION_REASON", errorResponse.ErrorEvaluationResponse.Reason); + } + + [Fact] + public void TestEvaluateVariantFailure() + { + var context = new Dictionary { { "fizz", "buzz" } }; + var exception = Assert.Throws(() => _client.EvaluateVariant("nonexistent", "someentity", context)); + Assert.Equal("invalid request: failed to get flag information default/nonexistent", exception.Message); + } + + [Fact] + public void TestEvaluateBooleanFailure() + { + var context = new Dictionary { { "fizz", "buzz" } }; + var exception = Assert.Throws(() => _client.EvaluateBoolean("nonexistent", "someentity", context)); + Assert.Equal("invalid request: failed to get flag information default/nonexistent", exception.Message); + } + + [Fact] + public void TestListFlags() + { + var flags = _client.ListFlags(); + Assert.NotEmpty(flags); + Assert.Equal(2, flags.Length); + } + } +} diff --git a/flipt-client-csharp/tests/FliptClient.Tests/FliptClient.Tests.csproj b/flipt-client-csharp/tests/FliptClient.Tests/FliptClient.Tests.csproj new file mode 100644 index 00000000..48bc477c --- /dev/null +++ b/flipt-client-csharp/tests/FliptClient.Tests/FliptClient.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/flipt-client-go/evaluation.go b/flipt-client-go/evaluation.go index f9bc2711..76545069 100644 --- a/flipt-client-go/evaluation.go +++ b/flipt-client-go/evaluation.go @@ -118,11 +118,10 @@ func WithFetchMode(fetchMode FetchMode) clientOption { // EvaluateVariant performs evaluation for a variant flag. func (e *EvaluationClient) EvaluateVariant(_ context.Context, flagKey, entityID string, evalContext map[string]string) (*VariantEvaluationResponse, error) { - ereq, err := json.Marshal(evaluationRequest{ - NamespaceKey: e.namespace, - FlagKey: flagKey, - EntityId: entityID, - Context: evalContext, + ereq, err := json.Marshal(EvaluationRequest{ + FlagKey: flagKey, + EntityId: entityID, + Context: evalContext, }) if err != nil { return nil, err @@ -151,11 +150,10 @@ func (e *EvaluationClient) EvaluateVariant(_ context.Context, flagKey, entityID // EvaluateBoolean performs evaluation for a boolean flag. func (e *EvaluationClient) EvaluateBoolean(_ context.Context, flagKey, entityID string, evalContext map[string]string) (*BooleanEvaluationResponse, error) { - ereq, err := json.Marshal(evaluationRequest{ - NamespaceKey: e.namespace, - FlagKey: flagKey, - EntityId: entityID, - Context: evalContext, + ereq, err := json.Marshal(EvaluationRequest{ + FlagKey: flagKey, + EntityId: entityID, + Context: evalContext, }) if err != nil { return nil, err @@ -184,18 +182,7 @@ func (e *EvaluationClient) EvaluateBoolean(_ context.Context, flagKey, entityID // EvaluateBatch performs evaluation for a batch of flags. func (e *EvaluationClient) EvaluateBatch(_ context.Context, requests []*EvaluationRequest) (*BatchEvaluationResponse, error) { - evaluationRequests := make([]*evaluationRequest, 0, len(requests)) - - for _, ir := range requests { - evaluationRequests = append(evaluationRequests, &evaluationRequest{ - NamespaceKey: e.namespace, - FlagKey: ir.FlagKey, - EntityId: ir.EntityId, - Context: ir.Context, - }) - } - - requestsBytes, err := json.Marshal(evaluationRequests) + requestsBytes, err := json.Marshal(requests) if err != nil { return nil, err } diff --git a/flipt-client-go/models.go b/flipt-client-go/models.go index e47622aa..59aaf8a4 100644 --- a/flipt-client-go/models.go +++ b/flipt-client-go/models.go @@ -1,12 +1,5 @@ package evaluation -type evaluationRequest struct { - NamespaceKey string `json:"namespace_key"` - FlagKey string `json:"flag_key"` - EntityId string `json:"entity_id"` - Context map[string]string `json:"context"` -} - type EvaluationRequest struct { FlagKey string `json:"flag_key"` EntityId string `json:"entity_id"` diff --git a/package/ffi/main.go b/package/ffi/main.go index 7d921dfb..91aa28d3 100644 --- a/package/ffi/main.go +++ b/package/ffi/main.go @@ -30,6 +30,7 @@ var ( "java": javaBuild, "java-musl": javaMuslBuild, "dart": dartBuild, + "csharp": csharpBuild, } sema = make(chan struct{}, 5) // defaultInclude is the default include for all builds to copy over the @@ -555,6 +556,38 @@ func dartBuild(ctx context.Context, client *dagger.Client, hostDirectory *dagger return err } +func csharpBuild(ctx context.Context, client *dagger.Client, hostDirectory *dagger.Directory, opts ...buildOptionsFn) error { + container := client.Container().From("mcr.microsoft.com/dotnet/sdk:8.0"). + WithWorkdir("/src"). + WithDirectory("/src", hostDirectory.Directory("flipt-client-csharp"), dagger.ContainerWithDirectoryOpts{ + Exclude: []string{".gitignore", "obj/", "bin/"}, + }). + WithDirectory("/src/src/FliptClient/ext/ffi", hostDirectory.Directory("tmp/glibc"), dagger.ContainerWithDirectoryOpts{ + Include: defaultInclude, + }). + WithExec([]string{"dotnet", "build", "-c", "Release"}). + WithExec([]string{"dotnet", "pack", "-c", "Release", "-o", "bin/Release"}) + + var err error + + if !push { + _, err = container.Sync(ctx) + return err + } + + if os.Getenv("NUGET_API_KEY") == "" { + return fmt.Errorf("NUGET_API_KEY is not set") + } + + nugetAPIKeySecret := client.SetSecret("nuget-api-key", os.Getenv("NUGET_API_KEY")) + + _, err = container.WithSecretVariable("NUGET_API_KEY", nugetAPIKeySecret). + WithExec([]string{"sh", "-c", "dotnet nuget push bin/Release/*.nupkg --api-key $NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate"}). + Sync(ctx) + + return err +} + func isDirEmptyOrNotExist(path string) (bool, error) { f, err := os.Open(path) if err != nil { diff --git a/test/main.go b/test/main.go index 312151d7..e62b9bf1 100644 --- a/test/main.go +++ b/test/main.go @@ -26,11 +26,12 @@ var ( "browser": browserTests, "dart": dartTests, "react": reactTests, + "csharp": csharpTests, } sema = make(chan struct{}, 5) ) -type integrationTestFn func(context.Context, *dagger.Client, *testCase) error +type integrationTestFn func(context.Context, *dagger.Container, *testCase) error func init() { flag.StringVar(&sdks, "sdks", "", "comma separated list of which language(s) to run integration tests for") @@ -95,6 +96,15 @@ func run() error { var g errgroup.Group + platform := "linux/amd64" + if architecture == "arm64" { + platform = "linux/arm64" + } + + container := client.Container(dagger.ContainerOpts{ + Platform: dagger.Platform(platform), + }) + for lang, fn := range tests { lang, fn := lang, fn g.Go(take(func() error { @@ -113,7 +123,7 @@ func run() error { testCase.test = getFFITestContainer(ctx, client, dir) } - return fn(ctx, client, testCase) + return fn(ctx, container, testCase) })) } @@ -182,8 +192,8 @@ func getWasmTestContainer(_ context.Context, client *dagger.Client, hostDirector } // pythonTests runs the python integration test suite against a container running Flipt. -func pythonTests(ctx context.Context, client *dagger.Client, t *testCase) error { - _, err := client.Pipeline("python").Container().From("python:3.11-bookworm"). +func pythonTests(ctx context.Context, root *dagger.Container, t *testCase) error { + _, err := root.From("python:3.11-bookworm"). WithExec([]string{"pip", "install", "poetry==1.7.0"}). WithWorkdir("/src"). WithDirectory("/src", t.dir.Directory("flipt-client-python")). @@ -199,8 +209,8 @@ func pythonTests(ctx context.Context, client *dagger.Client, t *testCase) error } // goTests runs the golang integration test suite against a container running Flipt. -func goTests(ctx context.Context, client *dagger.Client, t *testCase) error { - _, err := client.Pipeline("go").Container().From("golang:1.21.3-bookworm"). +func goTests(ctx context.Context, root *dagger.Container, t *testCase) error { + _, err := root.From("golang:1.21.3-bookworm"). WithExec([]string{"apt-get", "update"}). WithExec([]string{"apt-get", "-y", "install", "build-essential"}). WithWorkdir("/src"). @@ -222,8 +232,8 @@ func goTests(ctx context.Context, client *dagger.Client, t *testCase) error { } // nodeTests runs the node integration test suite against a container running Flipt. -func nodeTests(ctx context.Context, client *dagger.Client, t *testCase) error { - _, err := client.Pipeline("node").Container().From("node:21.2-bookworm"). +func nodeTests(ctx context.Context, root *dagger.Container, t *testCase) error { + _, err := root.From("node:21.2-bookworm"). WithWorkdir("/src"). WithDirectory("/src", t.dir.Directory("flipt-client-node"), dagger.ContainerWithDirectoryOpts{ Exclude: []string{".node_modules/", ".gitignore", "dist/"}, @@ -243,8 +253,8 @@ func nodeTests(ctx context.Context, client *dagger.Client, t *testCase) error { } // rubyTests runs the ruby integration test suite against a container running Flipt. -func rubyTests(ctx context.Context, client *dagger.Client, t *testCase) error { - _, err := client.Pipeline("ruby").Container().From("ruby:3.1-bookworm"). +func rubyTests(ctx context.Context, root *dagger.Container, t *testCase) error { + _, err := root.From("ruby:3.1-bookworm"). WithWorkdir("/src"). WithDirectory("/src", t.dir.Directory("flipt-client-ruby")). WithFile(fmt.Sprintf("/src/lib/ext/linux_%s/libfliptengine.so", architecture), t.test.File(libFile)). @@ -259,14 +269,14 @@ func rubyTests(ctx context.Context, client *dagger.Client, t *testCase) error { } // javaTests run the java integration tests suite against a container running Flipt. -func javaTests(ctx context.Context, client *dagger.Client, t *testCase) error { +func javaTests(ctx context.Context, root *dagger.Container, t *testCase) error { path := "x86-64" if architecture == "arm64" { path = "aarch64" } - _, err := client.Pipeline("java").Container().From("gradle:8.5.0-jdk11"). + _, err := root.From("gradle:8.5.0-jdk11"). WithWorkdir("/src"). WithDirectory("/src", t.dir.Directory("flipt-client-java"), dagger.ContainerWithDirectoryOpts{ Exclude: []string{"./.idea/", ".gradle/", "build/"}, @@ -282,8 +292,8 @@ func javaTests(ctx context.Context, client *dagger.Client, t *testCase) error { } // browserTests runs the browser integration test suite against a container running Flipt. -func browserTests(ctx context.Context, client *dagger.Client, t *testCase) error { - _, err := client.Pipeline("browser").Container().From("node:21.2-bookworm"). +func browserTests(ctx context.Context, root *dagger.Container, t *testCase) error { + _, err := root.From("node:21.2-bookworm"). WithWorkdir("/src"). WithDirectory("/src", t.dir.Directory("flipt-client-browser"), dagger.ContainerWithDirectoryOpts{ Exclude: []string{".node_modules/", ".gitignore", "dist/"}, @@ -304,8 +314,8 @@ func browserTests(ctx context.Context, client *dagger.Client, t *testCase) error // reactTests runs the react unit test suite against a mocked Flipt client. // this is because the react client simply uses the browser client under the hood -func reactTests(ctx context.Context, client *dagger.Client, t *testCase) error { - _, err := client.Pipeline("react").Container().From("node:21.2-bookworm"). +func reactTests(ctx context.Context, root *dagger.Container, t *testCase) error { + _, err := root.From("node:21.2-bookworm"). WithWorkdir("/src"). WithDirectory("/src", t.dir.Directory("flipt-client-react"), dagger.ContainerWithDirectoryOpts{ Exclude: []string{".node_modules/", ".gitignore", "dist/"}, @@ -319,8 +329,8 @@ func reactTests(ctx context.Context, client *dagger.Client, t *testCase) error { } // dartTests runs the dart integration test suite against a container running Flipt. -func dartTests(ctx context.Context, client *dagger.Client, t *testCase) error { - _, err := client.Pipeline("dart").Container().From("dart:stable"). +func dartTests(ctx context.Context, root *dagger.Container, t *testCase) error { + _, err := root.From("dart:stable"). WithWorkdir("/src"). WithDirectory("/src", t.dir.Directory("flipt-client-dart"), dagger.ContainerWithDirectoryOpts{ Exclude: []string{".gitignore", ".dart_tool/"}, @@ -335,3 +345,22 @@ func dartTests(ctx context.Context, client *dagger.Client, t *testCase) error { return err } + +// csharpTests runs the csharp integration test suite against a container running Flipt. +func csharpTests(ctx context.Context, root *dagger.Container, t *testCase) error { + _, err := root.From("mcr.microsoft.com/dotnet/sdk:8.0"). + WithWorkdir("/src"). + WithDirectory("/src", t.dir.Directory("flipt-client-csharp"), dagger.ContainerWithDirectoryOpts{ + Exclude: []string{".gitignore", "obj/", "bin/"}, + }). + WithFile(fmt.Sprintf("src/FliptClient/ext/ffi/linux_%s/libfliptengine.so", architecture), t.test.File(libFile)). + WithServiceBinding("flipt", t.flipt.WithExec(nil).AsService()). + WithEnvVariable("FLIPT_URL", "http://flipt:8080"). + WithEnvVariable("FLIPT_AUTH_TOKEN", "secret"). + WithExec([]string{"dotnet", "restore"}). + WithExec([]string{"dotnet", "build"}). + WithExec([]string{"dotnet", "test"}). + Sync(ctx) + + return err +}