From ecb09146f0ecf47b2bfc7bae1c0304611d807bda Mon Sep 17 00:00:00 2001 From: Raghd Hamzeh Date: Tue, 16 Jul 2024 10:08:12 -0400 Subject: [PATCH 1/4] feat: add support for exporting metrics --- .openapi-generator/FILES | 9 +- OpenTelemetry.md | 157 +++++++++ README.md | 5 + example/Example1/Example1.cs | 2 +- example/OpenTelemetryExample/.env.example | 11 + .../OpenTelemetryExample.cs | 310 +++++++++++++++++ .../OpenTelemetryExample.csproj | 30 ++ src/OpenFga.Sdk/Api/OpenFgaApi.cs | 92 ++--- src/OpenFga.Sdk/ApiClient/ApiClient.cs | 102 +++--- src/OpenFga.Sdk/ApiClient/BaseClient.cs | 123 +++---- src/OpenFga.Sdk/ApiClient/OAuth2Client.cs | 103 +++--- src/OpenFga.Sdk/ApiClient/RequestBuilder.cs | 45 ++- src/OpenFga.Sdk/Client/ClientConfiguration.cs | 44 ++- .../Configuration/Configuration.cs | 49 +-- .../Configuration/TelemetryConfig.cs | 99 ++++++ src/OpenFga.Sdk/Model/TypeName.cs | 126 ++++--- src/OpenFga.Sdk/OpenFga.Sdk.csproj | 10 +- src/OpenFga.Sdk/Telemetry/Attributes.cs | 321 ++++++++++++++++++ src/OpenFga.Sdk/Telemetry/Counters.cs | 48 +++ src/OpenFga.Sdk/Telemetry/Histograms.cs | 66 ++++ src/OpenFga.Sdk/Telemetry/Meters.cs | 43 +++ src/OpenFga.Sdk/Telemetry/Metrics.cs | 113 ++++++ 22 files changed, 1595 insertions(+), 313 deletions(-) create mode 100644 OpenTelemetry.md create mode 100644 example/OpenTelemetryExample/.env.example create mode 100644 example/OpenTelemetryExample/OpenTelemetryExample.cs create mode 100644 example/OpenTelemetryExample/OpenTelemetryExample.csproj create mode 100644 src/OpenFga.Sdk/Configuration/TelemetryConfig.cs create mode 100644 src/OpenFga.Sdk/Telemetry/Attributes.cs create mode 100644 src/OpenFga.Sdk/Telemetry/Counters.cs create mode 100644 src/OpenFga.Sdk/Telemetry/Histograms.cs create mode 100644 src/OpenFga.Sdk/Telemetry/Meters.cs create mode 100644 src/OpenFga.Sdk/Telemetry/Metrics.cs diff --git a/.openapi-generator/FILES b/.openapi-generator/FILES index 221405f..c009443 100644 --- a/.openapi-generator/FILES +++ b/.openapi-generator/FILES @@ -6,7 +6,6 @@ .github/ISSUE_TEMPLATE/config.yaml .github/ISSUE_TEMPLATE/feature_request.yaml .github/dependabot.yaml -.github/workflows/main.yaml .github/workflows/semgrep.yaml .gitignore CHANGELOG.md @@ -14,6 +13,7 @@ CONTRIBUTING.md LICENSE NOTICE.txt OpenFga.Sdk.sln +OpenTelemetry.md README.md VERSION.txt assets/FGAIcon.png @@ -100,6 +100,9 @@ docs/WriteRequestWrites.md example/Example1/Example1.cs example/Example1/Example1.csproj example/Makefile +example/OpenTelemetryExample/.env.example +example/OpenTelemetryExample/OpenTelemetryExample.cs +example/OpenTelemetryExample/OpenTelemetryExample.csproj example/README.md src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs src/OpenFga.Sdk.Test/Client/OpenFgaClientTests.cs @@ -250,3 +253,7 @@ src/OpenFga.Sdk/Model/WriteRequest.cs src/OpenFga.Sdk/Model/WriteRequestDeletes.cs src/OpenFga.Sdk/Model/WriteRequestWrites.cs src/OpenFga.Sdk/OpenFga.Sdk.csproj +src/OpenFga.Sdk/Telemetry/Attributes.cs +src/OpenFga.Sdk/Telemetry/Counters.cs +src/OpenFga.Sdk/Telemetry/Histograms.cs +src/OpenFga.Sdk/Telemetry/Metrics.cs diff --git a/OpenTelemetry.md b/OpenTelemetry.md new file mode 100644 index 0000000..94fda6c --- /dev/null +++ b/OpenTelemetry.md @@ -0,0 +1,157 @@ +# OpenTelemetry + +This SDK produces [metrics](https://opentelemetry.io/docs/concepts/signals/metrics/) using [OpenTelemetry](https://opentelemetry.io/) that allow you to view data such as request timings. These metrics also include attributes for the model and store ID, as well as the API called to allow you to build reporting. + +When an OpenTelemetry SDK instance is configured, the metrics will be exported and sent to the collector configured as part of your applications configuration. If you are not using OpenTelemetry, the metric functionality is a no-op and the events are never sent. + +In cases when metrics events are sent, they will not be viewable outside of infrastructure configured in your application, and are never available to the OpenFGA team or contributors. + +## Metrics + +### Supported Metrics + +| Metric Name | Type | Enabled by Default | Description | +|---------------------------------|-----------|--------------------|--------------------------------------------------------------------------------------| +| `fga-client.request.duration` | Histogram | Yes | The total request time for FGA requests | +| `fga-client.query.duration` | Histogram | Yes | The amount of time the FGA server took to internally process nd evaluate the request | +|` fga-client.credentials.request`| Counter | Yes | The total number of times a new token was requested when using ClientCredentials | +| `fga-client.request.count` | Counter | No | The total number of requests made to the FGA server | + +### Supported attributes + +| Attribute Name | Type | Enabled by Default | Description | +|--------------------------------|----------|--------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `fga-client.response.model_id` | `string` | Yes | The authorization model ID that the FGA server used | +| `fga-client.request.method` | `string` | Yes | The FGA method/action that was performed (e.g. `Check`, `ListObjects`, ...) in TitleCase | +| `fga-client.request.store_id` | `string` | Yes | The store ID that was sent as part of the request | +| `fga-client.request.model_id` | `string` | Yes | The authorization model ID that was sent as part of the request, if any | +| `fga-client.request.client_id` | `string` | Yes | The client ID associated with the request, if any | +| `fga-client.user` | `string` | No | The user that is associated with the action of the request for check and list objects | +| `http.request.resend_count` | `int` | Yes | The number of retries attempted (Only sent if the request was retried. Count of `1` means the request was retried once in addition to the original request) | +| `http.response.status_code` | `int` | Yes | The status code of the response | +| `http.request.method` | `string` | Yes | The HTTP method for the request | +| `http.host` | `string` | Yes | Host identifier of the origin the request was sent to | +| `url.scheme` | `string` | Yes | HTTP Scheme of the request (`http`/`https`) | +| `url.full` | `string` | No | Full URL of the request | +| `user_agent.original` | `string` | Yes | User Agent used in the query | + +### Default Metrics + +Not all metrics and attributes are enabled by default. + +Some attributes, like `fga-client.user` have been disabled by default due to their high cardinality, which may result for very high costs when using some SaaS metric collectors. +If you expect to have a high cardinality for a specific attribute, you can disable it by updating the `TelemetryConfig` accordingly. + +```csharp + +## Configuration + +See the OpenTelemetry docs on [Customizing the SDK](https://github.com/open-telemetry/opentelemetry-dotnet/blob/main/docs/metrics/customizing-the-sdk/README.md). + +```csharp +using OpenFga.Sdk.Client; +using OpenFga.Sdk.Client.Model; +using OpenFga.Sdk.Model; +using OpenFga.Sdk.Telemetry; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using System.Diagnostics; + +namespace Example { + public class Example { + public static async Task Main() { + try { + // Setup OpenTelemetry Metrics + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddHttpClientInstrumentation() // To instrument the default http client + .AddMeter(Metrics.Name) // .AddMeter("OpenFga.Sdk") also works + .ConfigureResource(resourceBuilder => resourceBuilder.AddService("openfga-dotnet-example")) + .AddOtlpExporter() // Required to export to an OTLP compatible endpoint + .AddConsoleExporter() // Only needed to export the metrics to the console (e.g. when debugging) + .Build(); + + // Configure the OpenFGA SDK with default configuration (default metrics and attributes will be enabled) + var configuration = new ClientConfiguration() { + ApiUrl = Environment.GetEnvironmentVariable("FGA_API_URL"), + StoreId = Environment.GetEnvironmentVariable("FGA_STORE_ID"), + AuthorizationModelId = Environment.GetEnvironmentVariable("FGA_MODEL_ID"), + // Credentials = ... // If needed + }; + var fgaClient = new OpenFgaClient(configuration); + + // Call the SDK normally + var response = await fgaClient.ReadAuthorizationModels(); + } catch (ApiException e) { + Debug.Print("Error: "+ e); + } + } + } +} +``` + +#### Customize metrics +You can can customize the metrics that are enabled and the attributes that are included in the metrics by setting the `TelemetryConfig` property on the `ClientConfiguration` object. +If you do set the `Telemetry` property to anything other than `null`, the default configuration will be overridden. + +```csharp +TelemetryConfig telemetryConfig = new () { + Metrics = new Dictionary { + [TelemetryMeters.TokenExchangeCount] = new () { + Attributes = new HashSet { + TelemetryAttribute.HttpScheme, + TelemetryAttribute.HttpMethod, + TelemetryAttribute.HttpHost, + TelemetryAttribute.HttpStatus, + TelemetryAttribute.HttpUserAgent, + TelemetryAttribute.RequestMethod, + TelemetryAttribute.RequestClientId, + TelemetryAttribute.RequestStoreId, + TelemetryAttribute.RequestModelId, + TelemetryAttribute.RequestRetryCount, + TelemetryAttribute.ResponseModelId + } + }, + [TelemetryMeters.QueryDuration] = new () { + Attributes = new HashSet { + TelemetryAttribute.HttpStatus, + TelemetryAttribute.HttpUserAgent, + TelemetryAttribute.RequestMethod, + TelemetryAttribute.RequestClientId, + TelemetryAttribute.RequestStoreId, + TelemetryAttribute.RequestModelId, + TelemetryAttribute.RequestRetryCount, + } + }, + [TelemetryMeters.QueryDuration] = new () { + Attributes = new HashSet { + TelemetryAttribute.HttpStatus, + TelemetryAttribute.HttpUserAgent, + TelemetryAttribute.RequestMethod, + TelemetryAttribute.RequestClientId, + TelemetryAttribute.RequestStoreId, + TelemetryAttribute.RequestModelId, + TelemetryAttribute.RequestRetryCount, + } + }, + } +}; + +var configuration = new ClientConfiguration() { + ApiUrl = Environment.GetEnvironmentVariable("FGA_API_URL"), + StoreId = Environment.GetEnvironmentVariable("FGA_STORE_ID"), + AuthorizationModelId = Environment.GetEnvironmentVariable("FGA_MODEL_ID"), + // Credentials = ... // If needed + Telemetry = telemetryConfig +}; +``` + +### More Resources +* [OpenTelemetry.Instrumentation.Http](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/blob/main/src/OpenTelemetry.Instrumentation.Http/README.md) for instrumenting the HttpClient. +* If you are using .NET 8+, checkout the built-in metrics. + +A number of these metrics are baked into .NET 8+ as well: + +## Example + +There is an [example project](https://github.com/openfga/dotnet-sdk/blob/main/example/OpenTelemetryExample) that provides some guidance on how to configure OpenTelemetry available in the examples directory. diff --git a/README.md b/README.md index c809f3a..1dbc291 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ This is an autogenerated SDK for OpenFGA. It provides a wrapper around the [Open - [Retries](#retries) - [API Endpoints](#api-endpoints) - [Models](#models) + - [OpenTelemetry](#opentelemetry) - [Contributing](#contributing) - [Issues](#issues) - [Pull Requests](#pull-requests) @@ -928,6 +929,10 @@ namespace Example { +### OpenTelemetry + +This SDK supports producing metrics that can be consumed as part of an [OpenTelemetry](https://opentelemetry.io/) setup. For more information, please see [the documentation](https://github.com/openfga/dotnet-sdk/blob/main/OpenTelemetry.md) + ## Contributing ### Issues diff --git a/example/Example1/Example1.cs b/example/Example1/Example1.cs index f4e003d..26bfc54 100644 --- a/example/Example1/Example1.cs +++ b/example/Example1/Example1.cs @@ -15,7 +15,7 @@ public static async Task Main() { credentials.Method = CredentialsMethod.ClientCredentials; credentials.Config = new CredentialsConfig() { ApiAudience = Environment.GetEnvironmentVariable("FGA_API_AUDIENCE"), - ApiTokenIssuer = Environment.GetEnvironmentVariable("FGA_TOKEN_ISSUER"), + ApiTokenIssuer = Environment.GetEnvironmentVariable("FGA_API_TOKEN_ISSUER"), ClientId = Environment.GetEnvironmentVariable("FGA_CLIENT_ID"), ClientSecret = Environment.GetEnvironmentVariable("FGA_CLIENT_SECRET") }; diff --git a/example/OpenTelemetryExample/.env.example b/example/OpenTelemetryExample/.env.example new file mode 100644 index 0000000..db7ea75 --- /dev/null +++ b/example/OpenTelemetryExample/.env.example @@ -0,0 +1,11 @@ +# Configuration for OpenFGA +FGA_CLIENT_ID= +FGA_API_TOKEN_ISSUER= +FGA_API_AUDIENCE= +FGA_CLIENT_SECRET= +FGA_STORE_ID= +FGA_AUTHORIZATION_MODEL_ID= +FGA_API_URL="http://localhost:8080" + +# Configuration for OpenTelemetry +OTEL_SERVICE_NAME="openfga-otel-dotnet-example" diff --git a/example/OpenTelemetryExample/OpenTelemetryExample.cs b/example/OpenTelemetryExample/OpenTelemetryExample.cs new file mode 100644 index 0000000..c383e41 --- /dev/null +++ b/example/OpenTelemetryExample/OpenTelemetryExample.cs @@ -0,0 +1,310 @@ +using OpenFga.Sdk.Client; +using OpenFga.Sdk.Client.Model; +using OpenFga.Sdk.Configuration; +using OpenFga.Sdk.Exceptions; +using OpenFga.Sdk.Model; +using OpenFga.Sdk.Telemetry; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using System.Diagnostics; + +namespace OpenTelemetryExample; + +public class OpenTelemetryExample { + public static async Task Main() { + try { + var credentials = new Credentials(); + if (Environment.GetEnvironmentVariable("FGA_CLIENT_ID") != null) { + credentials.Method = CredentialsMethod.ClientCredentials; + credentials.Config = new CredentialsConfig { + ApiAudience = Environment.GetEnvironmentVariable("FGA_API_AUDIENCE"), + ApiTokenIssuer = Environment.GetEnvironmentVariable("FGA_API_TOKEN_ISSUER"), + ClientId = Environment.GetEnvironmentVariable("FGA_CLIENT_ID"), + ClientSecret = Environment.GetEnvironmentVariable("FGA_CLIENT_SECRET") + }; + } + else if (Environment.GetEnvironmentVariable("FGA_API_TOKEN") != null) { + credentials.Method = CredentialsMethod.ApiToken; + credentials.Config = new CredentialsConfig { + ApiToken = Environment.GetEnvironmentVariable("FGA_API_TOKEN") + }; + } + + // Customize the metrics and the attributes on each metric + TelemetryConfig telemetryConfig = new TelemetryConfig() { + Metrics = new Dictionary { + [TelemetryMeter.TokenExchangeCount] = new() { + Attributes = new HashSet { + TelemetryAttribute.HttpHost, + TelemetryAttribute.HttpStatus, + TelemetryAttribute.HttpUserAgent, + TelemetryAttribute.RequestClientId, + } + }, + [TelemetryMeter.QueryDuration] = new () { + Attributes = new HashSet { + TelemetryAttribute.HttpStatus, + TelemetryAttribute.HttpUserAgent, + TelemetryAttribute.RequestMethod, + TelemetryAttribute.RequestClientId, + TelemetryAttribute.RequestStoreId, + TelemetryAttribute.RequestModelId, + TelemetryAttribute.RequestRetryCount, + } + }, + [TelemetryMeter.RequestDuration] = new () { + Attributes = new HashSet { + TelemetryAttribute.HttpStatus, + TelemetryAttribute.HttpUserAgent, + TelemetryAttribute.RequestMethod, + TelemetryAttribute.RequestClientId, + TelemetryAttribute.RequestStoreId, + TelemetryAttribute.RequestModelId, + TelemetryAttribute.RequestRetryCount, + } + }, + } + }; + + var configuration = new ClientConfiguration { + ApiUrl = Environment.GetEnvironmentVariable("FGA_API_URL") ?? "http://localhost:8080", + StoreId = Environment.GetEnvironmentVariable("FGA_STORE_ID"), + AuthorizationModelId = Environment.GetEnvironmentVariable("FGA_MODEL_ID"), + Credentials = credentials, + Telemetry = telemetryConfig + }; + var fgaClient = new OpenFgaClient(configuration); + + // Setup OpenTelemetry + // See: https://github.com/open-telemetry/opentelemetry-dotnet/blob/main/docs/metrics/customizing-the-sdk/README.md + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddHttpClientInstrumentation() + .AddMeter(Metrics.Name) + .ConfigureResource(resourceBuilder => + resourceBuilder.AddService(Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME") ?? + "openfga-otel-dotnet-example")) + .AddOtlpExporter() // Required to export to an OTLP compatible endpoint + .AddConsoleExporter() // Only needed to export the metrics to the console (e.g. when debugging) + .Build(); + + var performStoreActions = configuration.StoreId == null; + GetStoreResponse? currentStore = null; + if (performStoreActions) { + // ListStores + Console.WriteLine("Listing Stores"); + var stores1 = await fgaClient.ListStores(); + Console.WriteLine("Stores Count: " + stores1.Stores?.Count()); + + // CreateStore + Console.WriteLine("Creating Test Store"); + var store = await fgaClient.CreateStore(new ClientCreateStoreRequest { Name = "Test Store" }); + Console.WriteLine("Test Store ID: " + store.Id); + + // Set the store id + fgaClient.StoreId = store.Id; + + // ListStores after Create + Console.WriteLine("Listing Stores"); + var stores = await fgaClient.ListStores(); + Console.WriteLine("Stores Count: " + stores.Stores?.Count()); + + // GetStore + Console.WriteLine("Getting Current Store"); + currentStore = await fgaClient.GetStore(); + Console.WriteLine("Current Store Name: " + currentStore.Name); + } + + // ReadAuthorizationModels + Console.WriteLine("Reading Authorization Models"); + var models = await fgaClient.ReadAuthorizationModels(); + Console.WriteLine("Models Count: " + models.AuthorizationModels?.Count()); + + // ReadLatestAuthorizationModel + var latestAuthorizationModel = await fgaClient.ReadLatestAuthorizationModel(); + if (latestAuthorizationModel != null) { + Console.WriteLine("Latest Authorization Model ID " + latestAuthorizationModel.AuthorizationModel?.Id); + } + else { + Console.WriteLine("Latest Authorization Model not found"); + } + + // WriteAuthorizationModel + Console.WriteLine("Writing an Authorization Model"); + var body = new ClientWriteAuthorizationModelRequest { + SchemaVersion = "1.1", + TypeDefinitions = + new List { + new() { Type = "user", Relations = new Dictionary() }, + new() { + Type = "document", + Relations = + new Dictionary { + { "writer", new Userset { This = new object() } }, { + "viewer", + new Userset { + Union = new Usersets { + Child = new List { + new() { This = new object() }, + new() { + ComputedUserset = new ObjectRelation { Relation = "writer" } + } + } + } + } + } + }, + Metadata = new Metadata { + Relations = new Dictionary { + { + "writer", + new RelationMetadata { + DirectlyRelatedUserTypes = new List { + new() { Type = "user" }, + new() { Type = "user", Condition = "ViewCountLessThan200" } + } + } + }, { + "viewer", + new RelationMetadata { + DirectlyRelatedUserTypes = new List { + new() { Type = "user" } + } + } + } + } + } + } + }, + Conditions = new Dictionary { + ["ViewCountLessThan200"] = new() { + Name = "ViewCountLessThan200", + Expression = "ViewCount < 200", + Parameters = new Dictionary { + ["ViewCount"] = new() { TypeName = TypeName.INT }, + ["Type"] = new() { TypeName = TypeName.STRING }, + ["Name"] = new() { TypeName = TypeName.STRING } + } + } + } + }; + var authorizationModel = await fgaClient.WriteAuthorizationModel(body); + Thread.Sleep(1000); + Console.WriteLine("Authorization Model ID " + authorizationModel.AuthorizationModelId); + + // ReadAuthorizationModels - after Write + Console.WriteLine("Reading Authorization Models"); + models = await fgaClient.ReadAuthorizationModels(); + Console.WriteLine("Models Count: " + models.AuthorizationModels?.Count()); + + // ReadLatestAuthorizationModel - after Write + latestAuthorizationModel = await fgaClient.ReadLatestAuthorizationModel(); + Console.WriteLine("Latest Authorization Model ID " + latestAuthorizationModel?.AuthorizationModel?.Id); + + // Set the model ID + fgaClient.AuthorizationModelId = latestAuthorizationModel?.AuthorizationModel?.Id; + + var contToken = ""; + do { + // Read All Tuples + Console.WriteLine("Reading All Tuples (paginated)"); + var existingTuples = + await fgaClient.Read(null, new ClientReadOptions { ContinuationToken = contToken }); + contToken = existingTuples.ContinuationToken; + + // Deleting All Tuples + Console.WriteLine("Deleting All Tuples (paginated)"); + var tuplesToDelete = new List(); + existingTuples.Tuples.ForEach(tuple => tuplesToDelete.Add(new ClientTupleKeyWithoutCondition { + User = tuple.Key.User, Relation = tuple.Key.Relation, Object = tuple.Key.Object + })); + if (tuplesToDelete.Count > 0) { + await fgaClient.DeleteTuples(tuplesToDelete); + } + } while (contToken != ""); + + // Write + Console.WriteLine("Writing Tuples"); + await fgaClient.Write( + new ClientWriteRequest { + Writes = new List { + new() { + User = "user:anne", + Relation = "writer", + Object = "document:roadmap", + Condition = new RelationshipCondition { + Name = "ViewCountLessThan200", Context = new { Name = "Roadmap", Type = "document" } + } + } + } + }, new ClientWriteOptions { AuthorizationModelId = authorizationModel.AuthorizationModelId }); + Console.WriteLine("Done Writing Tuples"); + + // Read + Console.WriteLine("Reading Tuples"); + var readTuples = await fgaClient.Read(); + Console.WriteLine("Read Tuples" + readTuples.ToJson()); + + // ReadChanges + Console.WriteLine("Reading Tuple Changess"); + var readChangesTuples = await fgaClient.ReadChanges(); + Console.WriteLine("Read Changes Tuples" + readChangesTuples.ToJson()); + + // Check + Console.WriteLine("Checking for access"); + try { + var failingCheckResponse = await fgaClient.Check(new ClientCheckRequest { + User = "user:anne", Relation = "viewer", Object = "document:roadmap" + }); + Console.WriteLine("Allowed: " + failingCheckResponse.Allowed); + } + catch (Exception e) { + Console.WriteLine("Failed due to: " + e.Message); + } + + // Checking for access with context + Console.WriteLine("Checking for access with context"); + var checkResponse = await fgaClient.Check(new ClientCheckRequest { + User = "user:anne", Relation = "viewer", Object = "document:roadmap", Context = new { ViewCount = 100 } + }); + Console.WriteLine("Allowed: " + checkResponse.Allowed); + + // WriteAssertions + await fgaClient.WriteAssertions(new List { + new() { User = "user:carl", Relation = "writer", Object = "document:budget", Expectation = true }, + new() { User = "user:anne", Relation = "viewer", Object = "document:roadmap", Expectation = false } + }); + Console.WriteLine("Assertions updated"); + + // ReadAssertions + Console.WriteLine("Reading Assertions"); + var assertions = await fgaClient.ReadAssertions(); + Console.WriteLine("Assertions " + assertions.ToJson()); + + // Checking for access w/ context in a loop + var rnd = new Random(); + var randomNumber = rnd.Next(1, 1000); + Console.WriteLine($"Checking for access with context in a loop ({randomNumber} times)"); + for (var index = 0; index < randomNumber; index++) { + checkResponse = await fgaClient.Check(new ClientCheckRequest { + User = "user:anne", + Relation = "viewer", + Object = "document:roadmap", + Context = new { ViewCount = 100 } + }); + Console.WriteLine("Allowed: " + checkResponse.Allowed); + } + + if (performStoreActions) { + // DeleteStore + Console.WriteLine("Deleting Current Store"); + await fgaClient.DeleteStore(); + Console.WriteLine("Deleted Store: " + currentStore?.Name); + } + } + catch (ApiException e) { + Console.WriteLine("Error: " + e); + Debug.Print("Error: " + e); + } + } +} \ No newline at end of file diff --git a/example/OpenTelemetryExample/OpenTelemetryExample.csproj b/example/OpenTelemetryExample/OpenTelemetryExample.csproj new file mode 100644 index 0000000..00bcfd0 --- /dev/null +++ b/example/OpenTelemetryExample/OpenTelemetryExample.csproj @@ -0,0 +1,30 @@ + + + + Exe + net6.0 + enable + enable + Linux + OpenTelemetryExample + + + + + + + + + + + ..\..\src\OpenFga.Sdk\bin\Debug\net6.0\OpenFga.Sdk.dll + + + + + + + + + + diff --git a/src/OpenFga.Sdk/Api/OpenFgaApi.cs b/src/OpenFga.Sdk/Api/OpenFgaApi.cs index 6e50963..67b346b 100644 --- a/src/OpenFga.Sdk/Api/OpenFgaApi.cs +++ b/src/OpenFga.Sdk/Api/OpenFgaApi.cs @@ -18,11 +18,11 @@ namespace OpenFga.Sdk.Api; public class OpenFgaApi : IDisposable { - private Configuration.Configuration _configuration; - private ApiClient.ApiClient _apiClient; + private readonly ApiClient.ApiClient _apiClient; + private readonly Configuration.Configuration _configuration; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// /// @@ -31,8 +31,8 @@ public OpenFgaApi( HttpClient? httpClient = null ) { configuration.IsValid(); - this._configuration = configuration; - this._apiClient = new ApiClient.ApiClient(_configuration, httpClient); + _configuration = configuration; + _apiClient = new ApiClient.ApiClient(_configuration, httpClient); } /// @@ -54,16 +54,16 @@ public async Task Check(string storeId, CheckRequest body, Cancel } var queryParams = new Dictionary(); - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("POST"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}/check", PathParameters = pathParams, - Body = Utils.CreateJsonStringContent(body), + Body = body, QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "Check", cancellationToken); } @@ -79,16 +79,16 @@ public async Task CreateStore(CreateStoreRequest body, Canc var queryParams = new Dictionary(); - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("POST"), BasePath = _configuration.BasePath, PathTemplate = "/stores", PathParameters = pathParams, - Body = Utils.CreateJsonStringContent(body), + Body = body, QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "CreateStore", cancellationToken); } @@ -110,7 +110,7 @@ public async Task DeleteStore(string storeId, CancellationToken cancellationToke } var queryParams = new Dictionary(); - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("DELETE"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}", @@ -118,7 +118,7 @@ public async Task DeleteStore(string storeId, CancellationToken cancellationToke QueryParameters = queryParams, }; - await this._apiClient.SendRequestAsync(requestBuilder, + await _apiClient.SendRequestAsync(requestBuilder, "DeleteStore", cancellationToken); } @@ -141,16 +141,16 @@ public async Task Expand(string storeId, ExpandRequest body, Can } var queryParams = new Dictionary(); - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("POST"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}/expand", PathParameters = pathParams, - Body = Utils.CreateJsonStringContent(body), + Body = body, QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "Expand", cancellationToken); } @@ -172,7 +172,7 @@ public async Task GetStore(string storeId, CancellationToken c } var queryParams = new Dictionary(); - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("GET"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}", @@ -180,7 +180,7 @@ public async Task GetStore(string storeId, CancellationToken c QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "GetStore", cancellationToken); } @@ -203,16 +203,16 @@ public async Task ListObjects(string storeId, ListObjectsRe } var queryParams = new Dictionary(); - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("POST"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}/list-objects", PathParameters = pathParams, - Body = Utils.CreateJsonStringContent(body), + Body = body, QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "ListObjects", cancellationToken); } @@ -235,7 +235,7 @@ public async Task ListObjects(string storeId, ListObjectsRe queryParams.Add("continuation_token", continuationToken.ToString()); } - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("GET"), BasePath = _configuration.BasePath, PathTemplate = "/stores", @@ -243,7 +243,7 @@ public async Task ListObjects(string storeId, ListObjectsRe QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "ListStores", cancellationToken); } @@ -266,16 +266,16 @@ public async Task ListUsers(string storeId, ListUsersRequest } var queryParams = new Dictionary(); - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("POST"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}/list-users", PathParameters = pathParams, - Body = Utils.CreateJsonStringContent(body), + Body = body, QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "ListUsers", cancellationToken); } @@ -298,16 +298,16 @@ public async Task Read(string storeId, ReadRequest body, Cancellat } var queryParams = new Dictionary(); - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("POST"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}/read", PathParameters = pathParams, - Body = Utils.CreateJsonStringContent(body), + Body = body, QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "Read", cancellationToken); } @@ -333,7 +333,7 @@ public async Task ReadAssertions(string storeId, string } var queryParams = new Dictionary(); - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("GET"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}/assertions/{authorization_model_id}", @@ -341,7 +341,7 @@ public async Task ReadAssertions(string storeId, string QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "ReadAssertions", cancellationToken); } @@ -367,7 +367,7 @@ public async Task ReadAuthorizationModel(string } var queryParams = new Dictionary(); - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("GET"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}/authorization-models/{id}", @@ -375,7 +375,7 @@ public async Task ReadAuthorizationModel(string QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "ReadAuthorizationModel", cancellationToken); } @@ -405,7 +405,7 @@ public async Task ReadAuthorizationModel(string queryParams.Add("continuation_token", continuationToken.ToString()); } - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("GET"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}/authorization-models", @@ -413,7 +413,7 @@ public async Task ReadAuthorizationModel(string QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "ReadAuthorizationModels", cancellationToken); } @@ -447,7 +447,7 @@ public async Task ReadAuthorizationModel(string queryParams.Add("continuation_token", continuationToken.ToString()); } - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("GET"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}/changes", @@ -455,7 +455,7 @@ public async Task ReadAuthorizationModel(string QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "ReadChanges", cancellationToken); } @@ -478,16 +478,16 @@ public async Task Write(string storeId, WriteRequest body, CancellationT } var queryParams = new Dictionary(); - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("POST"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}/write", PathParameters = pathParams, - Body = Utils.CreateJsonStringContent(body), + Body = body, QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "Write", cancellationToken); } @@ -514,16 +514,16 @@ public async Task WriteAssertions(string storeId, string authorizationModelId, W } var queryParams = new Dictionary(); - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("PUT"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}/assertions/{authorization_model_id}", PathParameters = pathParams, - Body = Utils.CreateJsonStringContent(body), + Body = body, QueryParameters = queryParams, }; - await this._apiClient.SendRequestAsync(requestBuilder, + await _apiClient.SendRequestAsync(requestBuilder, "WriteAssertions", cancellationToken); } @@ -546,16 +546,16 @@ public async Task WriteAuthorizationModel(strin } var queryParams = new Dictionary(); - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder { Method = new HttpMethod("POST"), BasePath = _configuration.BasePath, PathTemplate = "/stores/{store_id}/authorization-models", PathParameters = pathParams, - Body = Utils.CreateJsonStringContent(body), + Body = body, QueryParameters = queryParams, }; - return await this._apiClient.SendRequestAsync(requestBuilder, + return await _apiClient.SendRequestAsync(requestBuilder, "WriteAuthorizationModel", cancellationToken); } diff --git a/src/OpenFga.Sdk/ApiClient/ApiClient.cs b/src/OpenFga.Sdk/ApiClient/ApiClient.cs index be36277..395e574 100644 --- a/src/OpenFga.Sdk/ApiClient/ApiClient.cs +++ b/src/OpenFga.Sdk/ApiClient/ApiClient.cs @@ -14,19 +14,22 @@ using OpenFga.Sdk.Client.Model; using OpenFga.Sdk.Configuration; using OpenFga.Sdk.Exceptions; +using OpenFga.Sdk.Telemetry; +using System.Diagnostics; namespace OpenFga.Sdk.ApiClient; /// -/// API Client - used by all the API related methods to call the API. Handles token exchange and retries. +/// API Client - used by all the API related methods to call the API. Handles token exchange and retries. /// public class ApiClient : IDisposable { private readonly BaseClient _baseClient; - private readonly OAuth2Client? _oauth2Client; private readonly Configuration.Configuration _configuration; + private readonly OAuth2Client? _oauth2Client; + private readonly Metrics metrics; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Client Configuration /// User Http Client - Allows Http Client reuse @@ -35,17 +38,22 @@ public ApiClient(Configuration.Configuration configuration, HttpClient? userHttp _configuration = configuration; _baseClient = new BaseClient(configuration, userHttpClient); + metrics = new Metrics(_configuration); + if (_configuration.Credentials == null) { return; } switch (_configuration.Credentials.Method) { case CredentialsMethod.ApiToken: - _configuration.DefaultHeaders["Authorization"] = $"Bearer {_configuration.Credentials.Config!.ApiToken}"; + _configuration.DefaultHeaders["Authorization"] = + $"Bearer {_configuration.Credentials.Config!.ApiToken}"; _baseClient = new BaseClient(_configuration, userHttpClient); break; case CredentialsMethod.ClientCredentials: - _oauth2Client = new OAuth2Client(_configuration.Credentials, _baseClient, new RetryParams { MaxRetry = _configuration.MaxRetry, MinWaitInMs = _configuration.MinWaitInMs }); + _oauth2Client = new OAuth2Client(_configuration.Credentials, _baseClient, + new RetryParams { MaxRetry = _configuration.MaxRetry, MinWaitInMs = _configuration.MinWaitInMs }, + metrics); break; case CredentialsMethod.None: default: @@ -54,8 +62,9 @@ public ApiClient(Configuration.Configuration configuration, HttpClient? userHttp } /// - /// Handles getting the access token, calling the API and potentially retrying - /// Based on: https://github.com/auth0/auth0.net/blob/595ae80ccad8aa7764b80d26d2ef12f8b35bbeff/src/Auth0.ManagementApi/HttpClientManagementConnection.cs#L67 + /// Handles getting the access token, calling the API and potentially retrying + /// Based on: + /// https://github.com/auth0/auth0.net/blob/595ae80ccad8aa7764b80d26d2ef12f8b35bbeff/src/Auth0.ManagementApi/HttpClientManagementConnection.cs#L67 /// /// /// @@ -63,10 +72,11 @@ public ApiClient(Configuration.Configuration configuration, HttpClient? userHttp /// Response Type /// /// - public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName, + public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName, CancellationToken cancellationToken = default) { IDictionary additionalHeaders = new Dictionary(); + var sw = Stopwatch.StartNew(); if (_oauth2Client != null) { try { var token = await _oauth2Client.GetAccessTokenAsync(); @@ -80,20 +90,30 @@ public async Task SendRequestAsync(RequestBuilder requestBuilder, string a } } - return await Retry(async () => await _baseClient.SendRequestAsync(requestBuilder, additionalHeaders, apiName, cancellationToken)); + var response = await Retry(async () => + await _baseClient.SendRequestAsync(requestBuilder, additionalHeaders, apiName, + cancellationToken)); + + sw.Stop(); + metrics.BuildForResponse(apiName, response.rawResponse, requestBuilder, sw, + response.retryCount); + + return response.responseContent; } /// - /// Handles getting the access token, calling the API and potentially retrying (use for requests that return no content) + /// Handles getting the access token, calling the API and potentially retrying (use for requests that return no + /// content) /// /// /// /// /// - public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName, + public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName, CancellationToken cancellationToken = default) { IDictionary additionalHeaders = new Dictionary(); + var sw = Stopwatch.StartNew(); if (_oauth2Client != null) { try { var token = await _oauth2Client.GetAccessTokenAsync(); @@ -107,62 +127,44 @@ public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName } } - await Retry(async () => await _baseClient.SendRequestAsync(requestBuilder, additionalHeaders, apiName, cancellationToken)); - } - - private async Task Retry(Func> retryable) { - var numRetries = 0; - while (true) { - try { - numRetries++; - - return await retryable(); - } - catch (FgaApiRateLimitExceededError err) { - if (numRetries > _configuration.MaxRetry) { - throw; - } - var waitInMs = (int)((err.ResetInMs == null || err.ResetInMs < _configuration.MinWaitInMs) - ? _configuration.MinWaitInMs - : err.ResetInMs); - - await Task.Delay(waitInMs); - } - catch (FgaApiError err) { - if (!err.ShouldRetry || numRetries > _configuration.MaxRetry) { - throw; - } - var waitInMs = _configuration.MinWaitInMs; + var response = await Retry(async () => + await _baseClient.SendRequestAsync(requestBuilder, additionalHeaders, apiName, + cancellationToken)); - await Task.Delay(waitInMs); - } - } + sw.Stop(); + metrics.BuildForResponse(apiName, response.rawResponse, requestBuilder, sw, + response.retryCount); } - private async Task Retry(Func retryable) { - var numRetries = 0; + private async Task> Retry(Func>> retryable) { + var requestCount = 0; while (true) { try { - numRetries++; + requestCount++; + + var response = await retryable(); - await retryable(); + response.retryCount = + requestCount - 1; // OTEL spec specifies that the original request is not included in the count - return; + return response; } catch (FgaApiRateLimitExceededError err) { - if (numRetries > _configuration.MaxRetry) { + if (requestCount > _configuration.MaxRetry) { throw; } - var waitInMs = (int)((err.ResetInMs == null || err.ResetInMs < _configuration.MinWaitInMs) + + var waitInMs = (int)(err.ResetInMs == null || err.ResetInMs < _configuration.MinWaitInMs ? _configuration.MinWaitInMs : err.ResetInMs); await Task.Delay(waitInMs); } catch (FgaApiError err) { - if (!err.ShouldRetry || numRetries > _configuration.MaxRetry) { + if (!err.ShouldRetry || requestCount > _configuration.MaxRetry) { throw; } + var waitInMs = _configuration.MinWaitInMs; await Task.Delay(waitInMs); @@ -170,7 +172,5 @@ private async Task Retry(Func retryable) { } } - public void Dispose() { - _baseClient.Dispose(); - } + public void Dispose() => _baseClient.Dispose(); } \ No newline at end of file diff --git a/src/OpenFga.Sdk/ApiClient/BaseClient.cs b/src/OpenFga.Sdk/ApiClient/BaseClient.cs index 935cfff..84fc944 100644 --- a/src/OpenFga.Sdk/ApiClient/BaseClient.cs +++ b/src/OpenFga.Sdk/ApiClient/BaseClient.cs @@ -12,78 +12,87 @@ using OpenFga.Sdk.Exceptions; +using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; namespace OpenFga.Sdk.ApiClient; +public class ResponseWrapper { + public HttpResponseMessage rawResponse; + public T? responseContent; + + public int retryCount; +} + /// -/// Base Client, used by the API and OAuth Clients +/// Base Client, used by the API and OAuth Clients /// public class BaseClient : IDisposable { private readonly HttpClient _httpClient; private bool _shouldDisposeWhenDone; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// - /// Optional to use when sending requests. + /// Optional to use when sending requests. /// - /// If you supply a it is your responsibility to manage its lifecycle and - /// dispose it when appropriate. - /// If you do not supply a one will be created automatically and disposed - /// of when this object is disposed. + /// If you supply a it is your responsibility to manage its lifecycle and + /// dispose it when appropriate. + /// If you do not supply a one will be created automatically and disposed + /// of when this object is disposed. /// public BaseClient(Configuration.Configuration configuration, HttpClient? httpClient = null) { _shouldDisposeWhenDone = httpClient == null; - this._httpClient = httpClient ?? new HttpClient(); - this._httpClient.DefaultRequestHeaders.Accept.Clear(); - this._httpClient.DefaultRequestHeaders.Accept.Add( + _httpClient = httpClient ?? new HttpClient(); + _httpClient.DefaultRequestHeaders.Accept.Clear(); + _httpClient.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/json")); foreach (var header in configuration.DefaultHeaders) { if (header.Value != null) { - this._httpClient.DefaultRequestHeaders.Add(header.Key, header.Value); + _httpClient.DefaultRequestHeaders.Add(header.Key, header.Value); } } } /// - /// Handles calling the API + /// Handles calling the API /// /// /// /// /// - /// + /// + /// /// - public async Task SendRequestAsync(RequestBuilder requestBuilder, + public async Task> SendRequestAsync(RequestBuilder requestBuilder, IDictionary? additionalHeaders = null, string? apiName = null, CancellationToken cancellationToken = default) { var request = requestBuilder.BuildRequest(); - return await SendRequestAsync(request, additionalHeaders, apiName, cancellationToken); + return await SendRequestAsync(request, additionalHeaders, apiName, cancellationToken); } - /// - /// Handles calling the API for requests that are expected to return no content - /// - /// - /// - /// - /// - /// - public async Task SendRequestAsync(RequestBuilder requestBuilder, - IDictionary? additionalHeaders = null, - string? apiName = null, CancellationToken cancellationToken = default) { - var request = requestBuilder.BuildRequest(); - - await this.SendRequestAsync(request, additionalHeaders, apiName, cancellationToken); - } + // /// + // /// Handles calling the API for requests that are expected to return no content + // /// + // /// + // /// + // /// + // /// + // /// + // public async Task SendRequestAsync(RequestBuilder requestBuilder, + // IDictionary? additionalHeaders = null, + // string? apiName = null, CancellationToken cancellationToken = default) { + // var request = requestBuilder.BuildRequest(); + // + // await this.SendRequestAsync(request, additionalHeaders, apiName, cancellationToken); + // } /// - /// Handles calling the API + /// Handles calling the API /// /// /// @@ -93,7 +102,7 @@ public async Task SendRequestAsync(RequestBuilder requestBuilder, /// /// /// - public async Task SendRequestAsync(HttpRequestMessage request, + public async Task> SendRequestAsync(HttpRequestMessage request, IDictionary? additionalHeaders = null, string? apiName = null, CancellationToken cancellationToken = default) { if (additionalHeaders != null) { @@ -106,48 +115,28 @@ public async Task SendRequestAsync(HttpRequestMessage request, var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); { - if (response == null || (response.StatusCode != null && !response.IsSuccessStatusCode)) { + try { + response.EnsureSuccessStatusCode(); + } + catch { throw await ApiException.CreateSpecificExceptionAsync(response, request, apiName).ConfigureAwait(false); } - return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken).ConfigureAwait(false) ?? - throw new FgaError(); - } - } - - /// - /// Handles calling the API for requests that are expected to return no content - /// - /// - /// - /// - /// - /// - /// - /// - public async Task SendRequestAsync(HttpRequestMessage request, - IDictionary? additionalHeaders = null, - string? apiName = null, CancellationToken cancellationToken = default) { - if (additionalHeaders != null) { - foreach (var header in additionalHeaders) { - if (header.Value != null) { - request.Headers.Add(header.Key, header.Value); - } + T responseContent = default; + if (response.Content != null && response.StatusCode != HttpStatusCode.NoContent) { + responseContent = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false) ?? + throw new FgaError(); } - } - var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - { - if (response == null || (response.StatusCode != null && !response.IsSuccessStatusCode)) { - throw await ApiException.CreateSpecificExceptionAsync(response, request, apiName).ConfigureAwait(false); - } + return new ResponseWrapper { rawResponse = response, responseContent = responseContent }; } } /// - /// Disposes of any owned disposable resources such as the underlying if owned. + /// Disposes of any owned disposable resources such as the underlying if owned. /// - /// Whether we are actually disposing () or not (). + /// Whether we are actually disposing () or not (). protected virtual void Dispose(bool disposing) { if (disposing && _shouldDisposeWhenDone) { _httpClient.Dispose(); @@ -156,9 +145,7 @@ protected virtual void Dispose(bool disposing) { } /// - /// Disposes of any owned disposable resources such as the underlying if owned. + /// Disposes of any owned disposable resources such as the underlying if owned. /// - public void Dispose() { - Dispose(true); - } + public void Dispose() => Dispose(true); } \ No newline at end of file diff --git a/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs b/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs index 755c347..32fbabc 100644 --- a/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs +++ b/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs @@ -14,12 +14,14 @@ using OpenFga.Sdk.Client.Model; using OpenFga.Sdk.Configuration; using OpenFga.Sdk.Exceptions; +using OpenFga.Sdk.Telemetry; +using System.Diagnostics; using System.Text.Json.Serialization; namespace OpenFga.Sdk.ApiClient; /// -/// OAuth2 Client to exchange the credentials for an access token using the client credentials flow +/// OAuth2 Client to exchange the credentials for an access token using the client credentials flow /// public class OAuth2Client { private const int TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC = 300; @@ -28,27 +30,27 @@ private const int TOKEN_EXPIRY_JITTER_IN_SEC = 300; // We add some jitter so that token refreshes are less likely to collide private static readonly Random _random = new(); + private readonly Metrics metrics; /// - /// Credentials Flow Response - /// - /// https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-credentials-flow + /// Credentials Flow Response + /// https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-credentials-flow /// public class AccessTokenResponse { /// - /// Time period after which the token will expire (in ms) + /// Time period after which the token will expire (in ms) /// [JsonPropertyName("expires_in")] public long ExpiresIn { get; set; } /// - /// Token Type + /// Token Type /// [JsonPropertyName("token_type")] public string? TokenType { get; set; } /// - /// Access token to use + /// Access token to use /// [JsonPropertyName("access_token")] public string? AccessToken { get; set; } @@ -59,34 +61,38 @@ private class AuthToken { public string? AccessToken { get; set; } - public bool IsValid() { - return !string.IsNullOrWhiteSpace(AccessToken) && (ExpiresAt == null || - ExpiresAt - DateTime.Now > - TimeSpan.FromSeconds( - TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC + - (_random.Next(0, TOKEN_EXPIRY_JITTER_IN_SEC)))); - } + public bool IsValid() => + !string.IsNullOrWhiteSpace(AccessToken) && (ExpiresAt == null || + ExpiresAt - DateTime.Now > + TimeSpan.FromSeconds( + TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC + + _random.Next(0, TOKEN_EXPIRY_JITTER_IN_SEC))); } #region Fields private readonly BaseClient _httpClient; private AuthToken _authToken = new(); - private IDictionary _authRequest { get; set; } - private string _apiTokenIssuer { get; set; } - private RetryParams _retryParams; + private IDictionary _authRequest { get; } + private string _apiTokenIssuer { get; } + private readonly RetryParams _retryParams; #endregion #region Methods /// - /// Initializes a new instance of the class + /// Initializes a new instance of the class /// /// /// /// - public OAuth2Client(Credentials credentialsConfig, BaseClient httpClient, RetryParams retryParams) { + public OAuth2Client(Credentials credentialsConfig, BaseClient httpClient, RetryParams retryParams, + Metrics metrics) { + if (credentialsConfig == null) { + throw new Exception("Credentials are required for OAuth2Client"); + } + if (string.IsNullOrWhiteSpace(credentialsConfig.Config!.ClientId)) { throw new FgaRequiredParamError("OAuth2Client", "config.ClientId"); } @@ -95,41 +101,58 @@ public OAuth2Client(Credentials credentialsConfig, BaseClient httpClient, RetryP throw new FgaRequiredParamError("OAuth2Client", "config.ClientSecret"); } - this._httpClient = httpClient; - this._apiTokenIssuer = credentialsConfig.Config.ApiTokenIssuer; - this._authRequest = new Dictionary() { + if (string.IsNullOrWhiteSpace(credentialsConfig.Config.ApiTokenIssuer)) { + throw new FgaRequiredParamError("OAuth2Client", "config.ApiTokenIssuer"); + } + + _httpClient = httpClient; + _apiTokenIssuer = credentialsConfig.Config.ApiTokenIssuer; + _authRequest = new Dictionary { { "client_id", credentialsConfig.Config.ClientId }, { "client_secret", credentialsConfig.Config.ClientSecret }, - { "audience", credentialsConfig.Config.ApiAudience }, { "grant_type", "client_credentials" } }; - this._retryParams = retryParams; + if (credentialsConfig.Config.ApiAudience != null) { + _authRequest["audience"] = credentialsConfig.Config.ApiAudience; + } + + _retryParams = retryParams; + this.metrics = metrics; } /// - /// Exchange client id and client secret for an access token, and handles token refresh + /// Exchange client id and client secret for an access token, and handles token refresh /// /// /// private async Task ExchangeTokenAsync(CancellationToken cancellationToken = default) { - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder> { Method = HttpMethod.Post, - BasePath = $"https://{this._apiTokenIssuer}", + BasePath = $"https://{_apiTokenIssuer}", PathTemplate = "/oauth/token", - Body = Utils.CreateFormEncodedConent(this._authRequest), + Body = _authRequest, + ContentType = "application/x-www-form-urlencode" }; - var accessTokenResponse = await Retry(async () => await _httpClient.SendRequestAsync( - requestBuilder, - null, - "ExchangeTokenAsync", - cancellationToken)); + var sw = Stopwatch.StartNew(); + var accessTokenResponse = await Retry(async () => + await _httpClient.SendRequestAsync, AccessTokenResponse>( + requestBuilder, + null, + "ExchangeTokenAsync", + cancellationToken)); - _authToken = new AuthToken() { - AccessToken = accessTokenResponse.AccessToken, - ExpiresAt = DateTime.Now + TimeSpan.FromSeconds(accessTokenResponse.ExpiresIn) - }; + sw.Stop(); + + metrics.BuildForClientCredentialsResponse(accessTokenResponse.rawResponse, requestBuilder, + sw, accessTokenResponse.retryCount); + + _authToken = new AuthToken { AccessToken = accessTokenResponse.responseContent?.AccessToken }; + + if (accessTokenResponse.responseContent?.ExpiresIn != null) { + _authToken.ExpiresAt = DateTime.Now + TimeSpan.FromSeconds(accessTokenResponse.responseContent.ExpiresIn); + } } private async Task Retry(Func> retryable) { @@ -144,7 +167,8 @@ private async Task Retry(Func> retryable) { if (numRetries > _retryParams.MaxRetry) { throw; } - var waitInMs = (int)((err.ResetInMs == null || err.ResetInMs < _retryParams.MinWaitInMs) + + var waitInMs = (int)(err.ResetInMs == null || err.ResetInMs < _retryParams.MinWaitInMs ? _retryParams.MinWaitInMs : err.ResetInMs); @@ -154,6 +178,7 @@ private async Task Retry(Func> retryable) { if (!err.ShouldRetry || numRetries > _retryParams.MaxRetry) { throw; } + var waitInMs = _retryParams.MinWaitInMs; await Task.Delay(waitInMs); @@ -162,7 +187,7 @@ private async Task Retry(Func> retryable) { } /// - /// Gets the access token, and handles exchanging, rudimentary in memory caching and refreshing it when expired + /// Gets the access token, and handles exchanging, rudimentary in memory caching and refreshing it when expired /// /// /// diff --git a/src/OpenFga.Sdk/ApiClient/RequestBuilder.cs b/src/OpenFga.Sdk/ApiClient/RequestBuilder.cs index 229d766..355d5ae 100644 --- a/src/OpenFga.Sdk/ApiClient/RequestBuilder.cs +++ b/src/OpenFga.Sdk/ApiClient/RequestBuilder.cs @@ -12,11 +12,21 @@ using OpenFga.Sdk.Exceptions; +using System.Text; +using System.Text.Json; using System.Web; namespace OpenFga.Sdk.ApiClient; -public class RequestBuilder { +/// +/// +/// Type of the Request Body +public class RequestBuilder { + public RequestBuilder() { + PathParameters = new Dictionary(); + QueryParameters = new Dictionary(); + } + public HttpMethod Method { get; set; } public string BasePath { get; set; } public string PathTemplate { get; set; } @@ -25,13 +35,34 @@ public class RequestBuilder { public Dictionary QueryParameters { get; set; } - public HttpContent? Body { get; set; } + public TReq? Body { get; set; } - public RequestBuilder() { - PathParameters = new Dictionary(); - QueryParameters = new Dictionary(); + public string? JsonBody => Body == null ? null : JsonSerializer.Serialize(Body); + + public HttpContent? FormEncodedBody { + get { + if (Body == null) { + return null; + } + + if (ContentType != "application/x-www-form-urlencode") { + throw new Exception( + "Content type must be \"application/x-www-form-urlencode\" in order to get the FormEncoded representation"); + } + + var body = (IDictionary)Body; + + return new FormUrlEncodedContent(body.Select(p => + new KeyValuePair(p.Key, p.Value ?? ""))); + } } + private HttpContent? HttpContentBody => + Body == null ? null : + ContentType == "application/json" ? new StringContent(JsonBody, Encoding.UTF8, ContentType) : FormEncodedBody; + + public string ContentType { get; set; } = "application/json"; + public string BuildPathString() { if (PathTemplate == null) { throw new FgaRequiredParamError("RequestBuilder.BuildUri", nameof(PathTemplate)); @@ -66,6 +97,7 @@ public Uri BuildUri() { if (BasePath == null) { throw new FgaRequiredParamError("RequestBuilder.BuildUri", nameof(BasePath)); } + var uriString = $"{BasePath}"; uriString += BuildPathString(); @@ -78,6 +110,7 @@ public HttpRequestMessage BuildRequest() { if (Method == null) { throw new FgaRequiredParamError("RequestBuilder.BuildRequest", nameof(Method)); } - return new HttpRequestMessage() { RequestUri = BuildUri(), Method = Method, Content = Body }; + + return new HttpRequestMessage { RequestUri = BuildUri(), Method = Method, Content = HttpContentBody }; } } \ No newline at end of file diff --git a/src/OpenFga.Sdk/Client/ClientConfiguration.cs b/src/OpenFga.Sdk/Client/ClientConfiguration.cs index 036d539..12cc166 100644 --- a/src/OpenFga.Sdk/Client/ClientConfiguration.cs +++ b/src/OpenFga.Sdk/Client/ClientConfiguration.cs @@ -10,14 +10,27 @@ // NOTE: This file was auto generated. DO NOT EDIT. // - using OpenFga.Sdk.Client.Model; +using OpenFga.Sdk.Configuration; using OpenFga.Sdk.Exceptions; using System.Text.RegularExpressions; namespace OpenFga.Sdk.Client; +/// +/// Class for managing telemetry settings. +/// +public class Telemetry { +} + +/// +/// Configuration class for the OpenFGA client. +/// public class ClientConfiguration : Configuration.Configuration { + /// + /// Initializes a new instance of the class with the specified configuration. + /// + /// The base configuration to copy settings from. public ClientConfiguration(Configuration.Configuration config) { ApiScheme = config.ApiScheme; ApiHost = config.ApiHost; @@ -25,25 +38,37 @@ public ClientConfiguration(Configuration.Configuration config) { UserAgent = config.UserAgent; Credentials = config.Credentials; DefaultHeaders = config.DefaultHeaders; + Telemetry = config.Telemetry; RetryParams = new RetryParams { MaxRetry = config.MaxRetry, MinWaitInMs = config.MinWaitInMs }; } + /// + /// Initializes a new instance of the class. + /// public ClientConfiguration() { } /// - /// Gets or sets the Store ID. + /// Gets or sets the Store ID. /// - /// Store ID. + /// The Store ID. public string? StoreId { get; set; } /// - /// Gets or sets the Authorization Model ID. + /// Gets or sets the Authorization Model ID. /// - /// Authorization Model ID. + /// The Authorization Model ID. public string? AuthorizationModelId { get; set; } + /// + /// Gets or sets the retry parameters. + /// + /// The retry parameters. public RetryParams? RetryParams { get; set; } = new(); + /// + /// Validates the configuration settings. + /// + /// Thrown when the Store ID or Authorization Model ID is not in a valid ULID format. public new void IsValid() { base.IsValid(); @@ -51,16 +76,17 @@ public ClientConfiguration() { } throw new FgaValidationError("StoreId is not in a valid ulid format"); } - if (AuthorizationModelId != null && AuthorizationModelId != "" && !IsWellFormedUlidString(AuthorizationModelId)) { + if (!string.IsNullOrEmpty(AuthorizationModelId) && + !IsWellFormedUlidString(AuthorizationModelId)) { throw new FgaValidationError("AuthorizationModelId is not in a valid ulid format"); } } /// - /// Ensures that a string is in valid [ULID](https://github.com/ulid/spec) format + /// Ensures that a string is in valid [ULID](https://github.com/ulid/spec) format. /// - /// - /// + /// The string to validate as a ULID. + /// True if the string is a valid ULID, otherwise false. public static bool IsWellFormedUlidString(string ulid) { var regex = new Regex("^[0-7][0-9A-HJKMNP-TV-Z]{25}$"); return regex.IsMatch(ulid); diff --git a/src/OpenFga.Sdk/Configuration/Configuration.cs b/src/OpenFga.Sdk/Configuration/Configuration.cs index 6446e3b..0df4ce0 100644 --- a/src/OpenFga.Sdk/Configuration/Configuration.cs +++ b/src/OpenFga.Sdk/Configuration/Configuration.cs @@ -16,17 +16,32 @@ namespace OpenFga.Sdk.Configuration; /// -/// Setup OpenFGA Configuration +/// Setup OpenFGA Configuration /// public class Configuration { - #region Methods + #region Constructors + + /// + /// Initializes a new instance of the class + /// + /// + public Configuration() { + DefaultHeaders ??= new Dictionary(); - private static bool IsWellFormedUriString(string uri) { - return Uri.TryCreate(uri, UriKind.Absolute, out var uriResult) && - ((uriResult.ToString().Equals(uri) || uriResult.ToString().Equals($"{uri}/")) && - (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps)); + if (!DefaultHeaders.ContainsKey("User-Agent")) { + DefaultHeaders.Add("User-Agent", DefaultUserAgent); + } } + #endregion Constructors + + #region Methods + + private static bool IsWellFormedUriString(string uri) => + Uri.TryCreate(uri, UriKind.Absolute, out var uriResult) && + (uriResult.ToString().Equals(uri) || uriResult.ToString().Equals($"{uri}/")) && + (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); + /// /// Checks if the configuration is valid /// @@ -46,6 +61,7 @@ public void IsValid() { } Credentials?.IsValid(); + Telemetry?.IsValid(); } #endregion @@ -62,22 +78,6 @@ public void IsValid() { #endregion Constants - #region Constructors - - /// - /// Initializes a new instance of the class - /// - /// - public Configuration() { - DefaultHeaders ??= new Dictionary(); - - if (!DefaultHeaders.ContainsKey("User-Agent")) { - DefaultHeaders.Add("User-Agent", DefaultUserAgent); - } - } - - #endregion Constructors - #region Properties @@ -155,5 +155,10 @@ public string BasePath { /// MinWaitInMs public int MinWaitInMs { get; set; } = 100; + /// + /// Gets or sets the telemetry configuration. + /// + public TelemetryConfig? Telemetry { get; set; } + #endregion Properties } \ No newline at end of file diff --git a/src/OpenFga.Sdk/Configuration/TelemetryConfig.cs b/src/OpenFga.Sdk/Configuration/TelemetryConfig.cs new file mode 100644 index 0000000..70534b3 --- /dev/null +++ b/src/OpenFga.Sdk/Configuration/TelemetryConfig.cs @@ -0,0 +1,99 @@ +using OpenFga.Sdk.Exceptions; +using OpenFga.Sdk.Telemetry; + +namespace OpenFga.Sdk.Configuration; + +/// +/// Configuration for a specific metric, including its enabled attributes. +/// +public class MetricConfig { + /// + /// List of enabled attributes associated with the metric. + /// + public HashSet Attributes { get; set; } = new(); +} + +/// +/// Configuration for telemetry, including metrics. +/// +public class TelemetryConfig { + /// + /// Dictionary of metric configurations, keyed by metric name. + /// + public IDictionary? Metrics { get; set; } = new Dictionary(); + + /// + /// Initializes a new instance of the class. + /// + /// + public TelemetryConfig(IDictionary? metrics) { + Metrics = metrics; + } + + /// + /// Initializes a new instance of the class. + /// + public TelemetryConfig() { + } + + /// + /// Sets the configuration to use the default metrics. + /// + public TelemetryConfig UseDefaultConfig() { + Metrics = GetDefaultMetricsConfiguration(); + return this; + } + + /// + /// Returns the default metrics configuration. + /// + /// + private static IDictionary GetDefaultMetricsConfiguration() { + var defaultAttributes = new HashSet { + TelemetryAttribute.HttpHost, + TelemetryAttribute.HttpStatus, + TelemetryAttribute.HttpUserAgent, + TelemetryAttribute.RequestMethod, + TelemetryAttribute.RequestClientId, + TelemetryAttribute.RequestStoreId, + TelemetryAttribute.RequestModelId, + TelemetryAttribute.RequestRetryCount, + TelemetryAttribute.ResponseModelId + + // These metrics are not included by default because they are usually less useful + // TelemetryAttribute.HttpScheme, + // TelemetryAttribute.HttpMethod, + // TelemetryAttribute.HttpUrl, + + // This not included by default as it has a very high cardinality which could increase costs for users + // TelemetryAttribute.FgaRequestUser + }; + + return new Dictionary { + { TelemetryMeter.TokenExchangeCount, new MetricConfig { Attributes = defaultAttributes } }, + { TelemetryMeter.RequestDuration, new MetricConfig { Attributes = defaultAttributes } }, + { TelemetryMeter.QueryDuration, new MetricConfig { Attributes = defaultAttributes } }, + // { TelemetryMeters.RequestCount, new MetricConfig { Attributes = defaultAttributes } } + }; + } + + public void IsValid() { + if (Metrics == null) { + return; + } + + var supportedMeters = TelemetryMeter.GetAllMeters(); + var supportedAttributes = TelemetryAttribute.GetAllAttributes(); + foreach (var metricName in Metrics.Keys) { + if (!supportedMeters.Contains(metricName)) { + throw new FgaValidationError($"Telemetry.Metrics[{metricName}] is not a supported metric"); + } + + foreach (var attribute in Metrics[metricName].Attributes) { + if (!supportedAttributes.Contains(attribute)) { + throw new FgaValidationError($"Telemetry.Metrics[{metricName}].Attributes[{attribute}] is not a supported attribute"); + } + } + } + } +} \ No newline at end of file diff --git a/src/OpenFga.Sdk/Model/TypeName.cs b/src/OpenFga.Sdk/Model/TypeName.cs index c18f2a2..b86937c 100644 --- a/src/OpenFga.Sdk/Model/TypeName.cs +++ b/src/OpenFga.Sdk/Model/TypeName.cs @@ -14,84 +14,76 @@ using System.Runtime.Serialization; using System.Text.Json.Serialization; -namespace OpenFga.Sdk.Model { +namespace OpenFga.Sdk.Model; + +/// +/// Defines TypeName +/// +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum TypeName { /// - /// Defines TypeName + /// Enum UNSPECIFIED for value: TYPE_NAME_UNSPECIFIED /// - [JsonConverter(typeof(JsonStringEnumMemberConverter))] - public enum TypeName { - /// - /// Enum UNSPECIFIED for value: TYPE_NAME_UNSPECIFIED - /// - [EnumMember(Value = "TYPE_NAME_UNSPECIFIED")] - UNSPECIFIED = 1, - - /// - /// Enum ANY for value: TYPE_NAME_ANY - /// - [EnumMember(Value = "TYPE_NAME_ANY")] - ANY = 2, + [EnumMember(Value = "TYPE_NAME_UNSPECIFIED")] + TypeName_UNSPECIFIED = 1, - /// - /// Enum BOOL for value: TYPE_NAME_BOOL - /// - [EnumMember(Value = "TYPE_NAME_BOOL")] - BOOL = 3, - - /// - /// Enum STRING for value: TYPE_NAME_STRING - /// - [EnumMember(Value = "TYPE_NAME_STRING")] - STRING = 4, + /// + /// Enum ANY for value: TYPE_NAME_ANY + /// + [EnumMember(Value = "TYPE_NAME_ANY")] TypeName_ANY = 2, - /// - /// Enum INT for value: TYPE_NAME_INT - /// - [EnumMember(Value = "TYPE_NAME_INT")] - INT = 5, + /// + /// Enum BOOL for value: TYPE_NAME_BOOL + /// + [EnumMember(Value = "TYPE_NAME_BOOL")] TypeName_BOOL = 3, - /// - /// Enum UINT for value: TYPE_NAME_UINT - /// - [EnumMember(Value = "TYPE_NAME_UINT")] - UINT = 6, + /// + /// Enum STRING for value: TYPE_NAME_STRING + /// + [EnumMember(Value = "TYPE_NAME_STRING")] + STRING = 4, - /// - /// Enum DOUBLE for value: TYPE_NAME_DOUBLE - /// - [EnumMember(Value = "TYPE_NAME_DOUBLE")] - DOUBLE = 7, + /// + /// Enum INT for value: TYPE_NAME_INT + /// + [EnumMember(Value = "TYPE_NAME_INT")] INT = 5, - /// - /// Enum DURATION for value: TYPE_NAME_DURATION - /// - [EnumMember(Value = "TYPE_NAME_DURATION")] - DURATION = 8, + /// + /// Enum UINT for value: TYPE_NAME_UINT + /// + [EnumMember(Value = "TYPE_NAME_UINT")] UINT = 6, - /// - /// Enum TIMESTAMP for value: TYPE_NAME_TIMESTAMP - /// - [EnumMember(Value = "TYPE_NAME_TIMESTAMP")] - TIMESTAMP = 9, + /// + /// Enum DOUBLE for value: TYPE_NAME_DOUBLE + /// + [EnumMember(Value = "TYPE_NAME_DOUBLE")] + DOUBLE = 7, - /// - /// Enum MAP for value: TYPE_NAME_MAP - /// - [EnumMember(Value = "TYPE_NAME_MAP")] - MAP = 10, + /// + /// Enum DURATION for value: TYPE_NAME_DURATION + /// + [EnumMember(Value = "TYPE_NAME_DURATION")] + DURATION = 8, - /// - /// Enum LIST for value: TYPE_NAME_LIST - /// - [EnumMember(Value = "TYPE_NAME_LIST")] - LIST = 11, + /// + /// Enum TIMESTAMP for value: TYPE_NAME_TIMESTAMP + /// + [EnumMember(Value = "TYPE_NAME_TIMESTAMP")] + TIMESTAMP = 9, - /// - /// Enum IPADDRESS for value: TYPE_NAME_IPADDRESS - /// - [EnumMember(Value = "TYPE_NAME_IPADDRESS")] - IPADDRESS = 12 + /// + /// Enum MAP for value: TYPE_NAME_MAP + /// + [EnumMember(Value = "TYPE_NAME_MAP")] MAP = 10, - } + /// + /// Enum LIST for value: TYPE_NAME_LIST + /// + [EnumMember(Value = "TYPE_NAME_LIST")] LIST = 11, + /// + /// Enum IPADDRESS for value: TYPE_NAME_IPADDRESS + /// + [EnumMember(Value = "TYPE_NAME_IPADDRESS")] + IPADDRESS = 12 } \ No newline at end of file diff --git a/src/OpenFga.Sdk/OpenFga.Sdk.csproj b/src/OpenFga.Sdk/OpenFga.Sdk.csproj index 3971549..6f779af 100644 --- a/src/OpenFga.Sdk/OpenFga.Sdk.csproj +++ b/src/OpenFga.Sdk/OpenFga.Sdk.csproj @@ -28,9 +28,13 @@ - - - + + + + + + + diff --git a/src/OpenFga.Sdk/Telemetry/Attributes.cs b/src/OpenFga.Sdk/Telemetry/Attributes.cs new file mode 100644 index 0000000..3a85a36 --- /dev/null +++ b/src/OpenFga.Sdk/Telemetry/Attributes.cs @@ -0,0 +1,321 @@ +// +// OpenFGA/.NET SDK for OpenFGA +// +// API version: 1.x +// Website: https://openfga.dev +// Documentation: https://openfga.dev/docs +// Support: https://openfga.dev/community +// License: [Apache-2.0](https://github.com/openfga/dotnet-sdk/blob/main/LICENSE) +// +// NOTE: This file was auto generated. DO NOT EDIT. +// + +using OpenFga.Sdk.ApiClient; +using OpenFga.Sdk.Configuration; +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace OpenFga.Sdk.Telemetry; + +/// +/// Common attribute (tag) names. +/// For why `static readonly` over `const`, see https://github.com/dotnet/aspnetcore/pull/12441/files +/// +public static class TelemetryAttribute { + // Attributes (tags) associated with the request made // + + /// + /// The FGA method/action that was performed (e.g. `Check`, `ListObjects`, ...) in TitleCase. + /// + public static readonly string RequestMethod = "fga-client.request.method"; + + /// + /// The store ID that was sent as part of the request. + /// + public static readonly string RequestStoreId = "fga-client.request.store_id"; + + /// + /// The authorization model ID that was sent as part of the request, if any. + /// + public static readonly string RequestModelId = "fga-client.request.model_id"; + + /// + /// The client ID associated with the request, if any. + /// + public static readonly string RequestClientId = "fga-client.request.client_id"; + + // Attributes (tags) associated with the response // + + /// + /// The authorization model ID that the FGA server used. + /// + public static readonly string ResponseModelId = "fga-client.response.model_id"; + + // Attributes (tags) associated with specific actions // + + /// + /// The user that is associated with the action of the request for check and list objects. + /// + public static readonly string FgaRequestUser = "fga-client.user"; + + // OTEL Semantic Attributes (tags) // + + /// + /// The HTTP method for the request. + /// + public static readonly string HttpMethod = "http.request.method"; + + /// + /// The status code of the response. + /// + public static readonly string HttpStatus = "http.response.status_code"; + + /// + /// Host identifier of the origin the request was sent to. + /// + public static readonly string HttpHost = "http.host"; + + /// + /// HTTP Scheme of the request (`http`/`https`). + /// + public static readonly string HttpScheme = "url.scheme"; + + /// + /// Full URL of the request. + /// + public static readonly string HttpUrl = "url.full"; + + /// + /// User Agent used in the query. + /// + public static readonly string HttpUserAgent = "user_agent.original"; + + /// + /// The number of retries attempted (Only sent if the request was retried. Count of `1` means the request was retried + /// once in addition to the original request). + /// + public static readonly string RequestRetryCount = "http.request.resend_count"; + + /// + /// Return all supported attributes + /// + public static HashSet GetAllAttributes() { + return new() { + RequestMethod, + RequestStoreId, + RequestModelId, + RequestClientId, + ResponseModelId, + FgaRequestUser, + HttpMethod, + HttpStatus, + HttpHost, + HttpScheme, + HttpUrl, + HttpUserAgent, + RequestRetryCount + }; + } +} + +/// +/// Class for building attributes for telemetry. +/// +public class Attributes { + /// + /// Gets the header value if valid. + /// + /// The HTTP response headers. + /// The name of the header. + /// The header value if valid, otherwise null. + private static string? GetHeaderValueIfValid(HttpResponseHeaders headers, string headerName) { + if (headers.Contains(headerName) && headers.GetValues(headerName).Any()) { + return headers.GetValues(headerName).First(); + } + + return null; + } + + /// + /// Filters the attributes based on the enabled attributes. + /// + /// The list of attributes to filter. + /// The dictionary of enabled attributes. + /// A filtered list of attributes. + public static TagList FilterAttributes(TagList attributes, HashSet? enabledAttributes) { + var filteredAttributes = new TagList(); + + if (enabledAttributes != null && enabledAttributes.Count != 0) { + foreach (var attribute in attributes) { + if (enabledAttributes.Contains(attribute.Key)) { + filteredAttributes.Add(attribute); + } + } + } + + return filteredAttributes; + } + + /// + /// Builds an object of attributes that can be used to report alongside an OpenTelemetry metric event. + /// + /// The type of the request builder. + /// The list of enabled attributes. + /// The credentials object. + /// The GRPC method name. + /// The HTTP response message. + /// The request builder. + /// The stopwatch measuring the request duration. + /// The number of retries attempted. + /// A TagList of attributes. + public static TagList BuildAttributesForResponse( + HashSet enabledAttributes, Credentials? credentials, + string apiName, HttpResponseMessage response, RequestBuilder requestBuilder, + Stopwatch requestDuration, int retryCount) { + var attributes = new TagList(); + + attributes = AddRequestAttributes(enabledAttributes, apiName, requestBuilder, attributes); + attributes = AddResponseAttributes(enabledAttributes, response, attributes); + attributes = AddCommonAttributes(enabledAttributes, response, requestBuilder, credentials, retryCount, attributes); + + return attributes; + } + + private static TagList AddRequestAttributes( + HashSet enabledAttributes, string apiName, RequestBuilder requestBuilder, TagList attributes) { + // var attributes = new TagList(); + if (enabledAttributes.Contains(TelemetryAttribute.RequestMethod)) { + attributes.Add(new KeyValuePair(TelemetryAttribute.RequestMethod, apiName)); + } + + if (enabledAttributes.Contains(TelemetryAttribute.RequestStoreId) && + requestBuilder.PathParameters.ContainsKey("store_id")) { + var storeId = requestBuilder.PathParameters.GetValueOrDefault("store_id"); + if (!string.IsNullOrEmpty(storeId)) { + attributes.Add(new KeyValuePair(TelemetryAttribute.RequestStoreId, storeId)); + } + } + + if (enabledAttributes.Contains(TelemetryAttribute.RequestModelId)) { + attributes = AddRequestModelIdAttributes(requestBuilder, apiName, attributes); + } + + return attributes; + } + + private static TagList AddRequestModelIdAttributes( + RequestBuilder requestBuilder, string apiName, TagList attributes) { + string? modelId = null; + + if (requestBuilder.PathParameters.ContainsKey("authorization_model_id")) { + modelId = requestBuilder.PathParameters.GetValueOrDefault("authorization_model_id"); + } + else if (requestBuilder.PathTemplate == "/stores/{store_id}/authorization-models/{id}" && + requestBuilder.PathParameters.ContainsKey("id")) { + modelId = requestBuilder.PathParameters.GetValueOrDefault("id"); + } + + if (!string.IsNullOrEmpty(modelId)) { + attributes.Add(new KeyValuePair(TelemetryAttribute.RequestModelId, modelId)); + } + + if (apiName is "Check" or "ListObjects" or "Write" or "Expand" or "ListUsers") { + AddRequestBodyAttributes(requestBuilder, apiName, attributes); + } + + return attributes; + } + + private static TagList AddRequestBodyAttributes( + RequestBuilder requestBuilder, string apiName, TagList attributes) { + try { + if (requestBuilder.JsonBody != null) { + using (var document = JsonDocument.Parse(requestBuilder.JsonBody)) { + var root = document.RootElement; + + if (root.TryGetProperty("authorization_model_id", out var authModelId) && + !string.IsNullOrEmpty(authModelId.GetString())) { + attributes.Add(new KeyValuePair(TelemetryAttribute.RequestModelId, + authModelId.GetString())); + } + + if (apiName is "Check" or "ListObjects" && root.TryGetProperty("user", out var fgaUser) && + !string.IsNullOrEmpty(fgaUser.GetString())) { + attributes.Add(new KeyValuePair(TelemetryAttribute.FgaRequestUser, + fgaUser.GetString())); + } + } + } + } + catch { + // Handle parsing errors if necessary + } + + return attributes; + } + + private static TagList AddResponseAttributes( + HashSet enabledAttributes, HttpResponseMessage response, TagList attributes) { + if (enabledAttributes.Contains(TelemetryAttribute.HttpStatus) && response.StatusCode != null) { + attributes.Add(new KeyValuePair(TelemetryAttribute.HttpStatus, (int)response.StatusCode)); + } + + if (enabledAttributes.Contains(TelemetryAttribute.ResponseModelId)) { + var responseModelId = GetHeaderValueIfValid(response.Headers, "openfga-authorization-model-id") ?? + GetHeaderValueIfValid(response.Headers, "fga-authorization-model-id"); + if (!string.IsNullOrEmpty(responseModelId)) { + attributes.Add(new KeyValuePair(TelemetryAttribute.ResponseModelId, responseModelId)); + } + } + + return attributes; + } + + private static TagList AddCommonAttributes( + HashSet enabledAttributes, HttpResponseMessage response, RequestBuilder requestBuilder, + Credentials? credentials, int retryCount, TagList attributes) { + if (response.RequestMessage != null) { + if (enabledAttributes.Contains(TelemetryAttribute.HttpMethod) && response.RequestMessage.Method != null) { + attributes.Add(new KeyValuePair(TelemetryAttribute.HttpMethod, + response.RequestMessage.Method)); + } + + if (response.RequestMessage.RequestUri != null) { + if (enabledAttributes.Contains(TelemetryAttribute.HttpScheme)) { + attributes.Add(new KeyValuePair(TelemetryAttribute.HttpScheme, + response.RequestMessage.RequestUri.Scheme)); + } + + if (enabledAttributes.Contains(TelemetryAttribute.HttpHost)) { + attributes.Add(new KeyValuePair(TelemetryAttribute.HttpHost, + response.RequestMessage.RequestUri.Host)); + } + + if (enabledAttributes.Contains(TelemetryAttribute.HttpUrl)) { + attributes.Add(new KeyValuePair(TelemetryAttribute.HttpUrl, + response.RequestMessage.RequestUri.AbsoluteUri)); + } + } + + if (enabledAttributes.Contains(TelemetryAttribute.HttpUserAgent) && + response.RequestMessage.Headers.UserAgent != null && + !string.IsNullOrEmpty(response.RequestMessage.Headers.UserAgent.ToString())) { + attributes.Add(new KeyValuePair(TelemetryAttribute.HttpUserAgent, + response.RequestMessage.Headers.UserAgent.ToString())); + } + } + + if (enabledAttributes.Contains(TelemetryAttribute.RequestClientId) && credentials is + { Method: CredentialsMethod.ClientCredentials, Config.ClientId: not null }) { + attributes.Add(new KeyValuePair(TelemetryAttribute.RequestClientId, + credentials.Config.ClientId)); + } + + if (enabledAttributes.Contains(TelemetryAttribute.RequestRetryCount) && retryCount > 0) { + attributes.Add(new KeyValuePair(TelemetryAttribute.RequestRetryCount, retryCount)); + } + + return attributes; + } +} \ No newline at end of file diff --git a/src/OpenFga.Sdk/Telemetry/Counters.cs b/src/OpenFga.Sdk/Telemetry/Counters.cs new file mode 100644 index 0000000..799e2a5 --- /dev/null +++ b/src/OpenFga.Sdk/Telemetry/Counters.cs @@ -0,0 +1,48 @@ +// +// OpenFGA/.NET SDK for OpenFGA +// +// API version: 1.x +// Website: https://openfga.dev +// Documentation: https://openfga.dev/docs +// Support: https://openfga.dev/community +// License: [Apache-2.0](https://github.com/openfga/dotnet-sdk/blob/main/LICENSE) +// +// NOTE: This file was auto generated. DO NOT EDIT. +// + +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace OpenFga.Sdk.Telemetry; + +/// +/// Class for managing telemetry counters. +/// +public class TelemetryCounters { + public Counter TokenExchangeCounter { get; } + + public Counter RequestCounter { get; private set; } + + /// + /// Initializes a new instance of the class. + /// + /// The meter used to create counters. + public TelemetryCounters(Meter meter) { + TokenExchangeCounter = meter.CreateCounter(TelemetryMeter.TokenExchangeCount, + description: "The count of token exchange requests"); + RequestCounter = + meter.CreateCounter(TelemetryMeter.RequestCount, description: "The count of requests made"); + } + + /// + /// Increments the counter for a token exchange. + /// + /// The attributes associated with the telemetry data. + public void IncrementTokenExchangeCounter(TagList attributes) => TokenExchangeCounter.Add(1, attributes); + + /// + /// Increments the counter for an API request. + /// + /// The attributes associated with the telemetry data. + public void IncrementRequestCounter(TagList attributes) => TokenExchangeCounter.Add(1, attributes); +} \ No newline at end of file diff --git a/src/OpenFga.Sdk/Telemetry/Histograms.cs b/src/OpenFga.Sdk/Telemetry/Histograms.cs new file mode 100644 index 0000000..73d983b --- /dev/null +++ b/src/OpenFga.Sdk/Telemetry/Histograms.cs @@ -0,0 +1,66 @@ +// +// OpenFGA/.NET SDK for OpenFGA +// +// API version: 1.x +// Website: https://openfga.dev +// Documentation: https://openfga.dev/docs +// Support: https://openfga.dev/community +// License: [Apache-2.0](https://github.com/openfga/dotnet-sdk/blob/main/LICENSE) +// +// NOTE: This file was auto generated. DO NOT EDIT. +// + +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace OpenFga.Sdk.Telemetry; + +/// +/// Class for managing telemetry histograms. +/// +public class TelemetryHistograms { + /// + /// Histogram for query duration. + /// + public Histogram QueryDurationHistogram { get; } + + /// + /// Histogram for request duration. + /// + public Histogram RequestDurationHistogram { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The meter used to create histograms. + public TelemetryHistograms(Meter meter) { + RequestDurationHistogram = + meter.CreateHistogram(TelemetryMeter.RequestDuration, "The duration of requests", "milliseconds"); + QueryDurationHistogram = meter.CreateHistogram(TelemetryMeter.QueryDuration, + "The duration of queries on the FGA server", "milliseconds"); + } + + /// + /// Records the server query duration if the header is present. + /// + /// The HTTP response message. + /// The attributes associated with the telemetry data. + public void RecordQueryDuration(HttpResponseMessage response, TagList attributes) { + if (response.Headers.Contains("fga-query-duration-ms") && + response.Headers.GetValues("fga-query-duration-ms").Any()) { + var durationHeader = response.Headers.GetValues("fga-query-duration-ms").First(); + if (!string.IsNullOrEmpty(durationHeader) && float.TryParse(durationHeader, out var durationFloat)) { + QueryDurationHistogram?.Record(durationFloat, attributes); + } + } + } + + /// + /// Records the total request duration (includes all requests needed till success such as credential exchange and + /// retries). + /// + /// The stopwatch measuring the request duration. + /// The attributes associated with the telemetry data. + public void RecordRequestDuration(Stopwatch requestDuration, TagList attributes) => + RequestDurationHistogram.Record(requestDuration.ElapsedMilliseconds, attributes); +} \ No newline at end of file diff --git a/src/OpenFga.Sdk/Telemetry/Meters.cs b/src/OpenFga.Sdk/Telemetry/Meters.cs new file mode 100644 index 0000000..fb8ba2f --- /dev/null +++ b/src/OpenFga.Sdk/Telemetry/Meters.cs @@ -0,0 +1,43 @@ +namespace OpenFga.Sdk.Telemetry; + +/// +/// Meter names for telemetry. +/// For why `static readonly` over `const`, see https://github.com/dotnet/aspnetcore/pull/12441/files +/// +public static class TelemetryMeter { + // Histograms // + + /// + /// The total request time for FGA requests + /// + public static readonly string RequestDuration = "fga-client.request.duration"; + + /// + /// The amount of time the FGA server took to internally process and evaluate the request + /// + public static readonly string QueryDuration = "fga-client.query.duration"; + + // Counters // + + /// + /// The total number of times a new token was requested when using ClientCredentials. + /// + public static readonly string TokenExchangeCount = "fga-client.credentials.request"; + + /// + /// The total number of times a new token was requested when using ClientCredentials. + /// + public static readonly string RequestCount = "fga-client.request.count"; + + /// + /// Return all supported meter names + /// + public static HashSet GetAllMeters() { + return new() { + RequestDuration, + QueryDuration, + TokenExchangeCount, + RequestCount + }; + } +} \ No newline at end of file diff --git a/src/OpenFga.Sdk/Telemetry/Metrics.cs b/src/OpenFga.Sdk/Telemetry/Metrics.cs new file mode 100644 index 0000000..73a0ed6 --- /dev/null +++ b/src/OpenFga.Sdk/Telemetry/Metrics.cs @@ -0,0 +1,113 @@ +// +// OpenFGA/.NET SDK for OpenFGA +// +// API version: 1.x +// Website: https://openfga.dev +// Documentation: https://openfga.dev/docs +// Support: https://openfga.dev/community +// License: [Apache-2.0](https://github.com/openfga/dotnet-sdk/blob/main/LICENSE) +// +// NOTE: This file was auto generated. DO NOT EDIT. +// + +using OpenFga.Sdk.ApiClient; +using OpenFga.Sdk.Configuration; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace OpenFga.Sdk.Telemetry; + +/// +/// Class for managing metrics. +/// +public class Metrics { + public const string Name = "OpenFga.Sdk"; + + // This is to store all the enabled attributes across all metrics so that they can be computed only once + private readonly HashSet _allEnabledAttributes = new(); + private readonly Credentials? _credentialsConfig; + + private readonly TelemetryConfig _telemetryConfig; + + private TelemetryCounters Counters { get; } + private TelemetryHistograms Histograms { get; } + public Meter Meter { get; } = new(Name, Configuration.Configuration.Version); + + /// + /// Initializes a new instance of the class. + /// + public Metrics(Configuration.Configuration config) { + Histograms = new TelemetryHistograms(Meter); + Counters = new TelemetryCounters(Meter); + _telemetryConfig = config.Telemetry ?? new TelemetryConfig().UseDefaultConfig(); + _credentialsConfig = config.Credentials; + + if (_telemetryConfig.Metrics?.Keys == null) { + return; + } + + foreach (var metricName in _telemetryConfig.Metrics.Keys) { + var attributesHashSet = _telemetryConfig.Metrics[metricName]?.Attributes ?? new HashSet(); + foreach (var attribute in attributesHashSet) { + _allEnabledAttributes.Add(attribute); + } + } + } + + /// + /// Builds metrics for a given HTTP response. + /// + /// The type of the request builder response. + /// The API name. + /// The HTTP response message. + /// The request builder. + /// The stopwatch measuring the request duration. + /// The number of retries attempted. + public void BuildForResponse(string apiName, + HttpResponseMessage response, RequestBuilder requestBuilder, Stopwatch requestDuration, int retryCount) { + if (_telemetryConfig?.Metrics == null || _telemetryConfig?.Metrics.Count == 0) { + // No point processing if all metrics are disabled + return; + } + + // Compute all enabled attributes once and then attached them to the metrics configured after + var attributes = Attributes.BuildAttributesForResponse( + _allEnabledAttributes, _credentialsConfig, apiName, response, requestBuilder, requestDuration, retryCount); + + if (apiName == "ClientCredentialsExchange" && + _telemetryConfig!.Metrics.TryGetValue(TelemetryMeter.TokenExchangeCount, out var exchangeCountConfig)) { + Counters.IncrementTokenExchangeCounter(Attributes.FilterAttributes(attributes, + exchangeCountConfig?.Attributes)); + } + else { + // We only want to increment the request counter for non-token exchange requests + if (_telemetryConfig!.Metrics.TryGetValue(TelemetryMeter.RequestCount, out var requestCountConfig)) { + Counters.IncrementRequestCounter(Attributes.FilterAttributes(attributes, + requestCountConfig?.Attributes)); + } + + // The query duration is only provided by OpenFGA servers + if (_telemetryConfig.Metrics.TryGetValue(TelemetryMeter.QueryDuration, out var queryDurationConfig)) { + Histograms.RecordQueryDuration(response, + Attributes.FilterAttributes(attributes, queryDurationConfig?.Attributes)); + } + } + + if (_telemetryConfig.Metrics.TryGetValue(TelemetryMeter.RequestDuration, out var requestDurationConfig)) { + Histograms.RecordRequestDuration(requestDuration, + Attributes.FilterAttributes(attributes, requestDurationConfig?.Attributes)); + } + } + + /// + /// Builds metrics for a client credentials response. + /// + /// The type of the request builder. + /// The HTTP response message. + /// The request builder. + /// The stopwatch measuring the request duration. + /// The number of retries attempted. + public void BuildForClientCredentialsResponse( + HttpResponseMessage response, RequestBuilder requestBuilder, Stopwatch requestDuration, int retryCount) => + BuildForResponse("ClientCredentialsExchange", response, requestBuilder, requestDuration, retryCount); +} \ No newline at end of file From 93f635ad51be0134a5c41a03cd18502916796e2d Mon Sep 17 00:00:00 2001 From: Raghd Hamzeh Date: Sat, 7 Sep 2024 08:41:00 -0400 Subject: [PATCH 2/4] chore: rename the IsValid methods to EnsureValid In the cases where IsValid throws an error instead of returning false, it has been renamed to EnsureValid to make it clearer --- src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs | 18 +++++++++--------- .../Client/OpenFgaClientTests.cs | 2 +- src/OpenFga.Sdk/Api/OpenFgaApi.cs | 2 +- src/OpenFga.Sdk/ApiClient/ApiClient.cs | 2 +- src/OpenFga.Sdk/Client/Client.cs | 2 +- src/OpenFga.Sdk/Client/ClientConfiguration.cs | 6 +++--- src/OpenFga.Sdk/Configuration/Configuration.cs | 9 +++++---- src/OpenFga.Sdk/Configuration/Credentials.cs | 6 +++--- .../Configuration/TelemetryConfig.cs | 6 +++++- 9 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs b/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs index a1ca9ff..85892f6 100644 --- a/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs +++ b/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs @@ -69,7 +69,7 @@ public void Dispose() { [Fact] public void StoreIdNotRequired() { var storeIdRequiredConfig = new Configuration.Configuration() { ApiHost = _host }; - storeIdRequiredConfig.IsValid(); + storeIdRequiredConfig.EnsureValid(); } /// @@ -91,7 +91,7 @@ public async Task StoreIdRequiredWhenNeeded() { [Fact] public void ValidHostRequired() { var config = new Configuration.Configuration() { }; - void ActionMissingHost() => config.IsValid(); + void ActionMissingHost() => config.EnsureValid(); var exception = Assert.Throws(ActionMissingHost); Assert.Equal("Required parameter ApiUrl was not defined when calling Configuration.", exception.Message); } @@ -102,7 +102,7 @@ public void ValidHostRequired() { [Fact] public void ValidHostWellFormed() { var config = new Configuration.Configuration() { ApiHost = "https://api.fga.example" }; - void ActionMalformedHost() => config.IsValid(); + void ActionMalformedHost() => config.EnsureValid(); var exception = Assert.Throws(ActionMalformedHost); Assert.Equal("Configuration.ApiUrl (https://https://api.fga.example) does not form a valid URI (https://https://api.fga.example)", exception.Message); } @@ -119,7 +119,7 @@ public void ApiTokenRequired() { } }; void ActionMissingApiToken() => - missingApiTokenConfig.IsValid(); + missingApiTokenConfig.EnsureValid(); var exceptionMissingApiToken = Assert.Throws(ActionMissingApiToken); Assert.Equal("Required parameter ApiToken was not defined when calling Configuration.", @@ -143,7 +143,7 @@ public void ValidApiTokenIssuerWellFormed() { } } }; - void ActionMalformedApiTokenIssuer() => config.IsValid(); + void ActionMalformedApiTokenIssuer() => config.EnsureValid(); var exception = Assert.Throws(ActionMalformedApiTokenIssuer); Assert.Equal("Configuration.ApiTokenIssuer does not form a valid URI (https://https://tokenissuer.fga.example)", exception.Message); } @@ -214,7 +214,7 @@ public void ClientIdClientSecretRequired() { } }; void ActionMissingClientId() => - missingClientIdConfig.IsValid(); + missingClientIdConfig.EnsureValid(); var exceptionMissingClientId = Assert.Throws(ActionMissingClientId); Assert.Equal("Required parameter ClientId was not defined when calling Configuration.", @@ -233,7 +233,7 @@ void ActionMissingClientId() => } }; void ActionMissingClientSecret() => - missingClientSecretConfig.IsValid(); + missingClientSecretConfig.EnsureValid(); var exceptionMissingClientSecret = Assert.Throws(ActionMissingClientSecret); Assert.Equal("Required parameter ClientSecret was not defined when calling Configuration.", @@ -252,7 +252,7 @@ void ActionMissingClientSecret() => } }; void ActionMissingApiTokenIssuer() => - missingApiTokenIssuerConfig.IsValid(); + missingApiTokenIssuerConfig.EnsureValid(); var exceptionMissingApiTokenIssuer = Assert.Throws(ActionMissingApiTokenIssuer); Assert.Equal("Required parameter ApiTokenIssuer was not defined when calling Configuration.", @@ -272,7 +272,7 @@ void ActionMissingApiTokenIssuer() => }; void ActionMissingApiAudience() => - missingApiAudienceConfig.IsValid(); + missingApiAudienceConfig.EnsureValid(); var exceptionMissingApiAudience = Assert.Throws(ActionMissingApiAudience); Assert.Equal("Required parameter ApiAudience was not defined when calling Configuration.", diff --git a/src/OpenFga.Sdk.Test/Client/OpenFgaClientTests.cs b/src/OpenFga.Sdk.Test/Client/OpenFgaClientTests.cs index d7326ff..584d6cc 100644 --- a/src/OpenFga.Sdk.Test/Client/OpenFgaClientTests.cs +++ b/src/OpenFga.Sdk.Test/Client/OpenFgaClientTests.cs @@ -71,7 +71,7 @@ public async Task ConfigurationInValidStoreIdTest() { ApiUrl = _apiUrl, StoreId = "invalid-format" }; - void ActionInvalidId() => config.IsValid(); + void ActionInvalidId() => config.EnsureValid(); var exception = Assert.Throws(ActionInvalidId); Assert.Equal("StoreId is not in a valid ulid format", exception.Message); } diff --git a/src/OpenFga.Sdk/Api/OpenFgaApi.cs b/src/OpenFga.Sdk/Api/OpenFgaApi.cs index 67b346b..f4fed49 100644 --- a/src/OpenFga.Sdk/Api/OpenFgaApi.cs +++ b/src/OpenFga.Sdk/Api/OpenFgaApi.cs @@ -30,7 +30,7 @@ public OpenFgaApi( Configuration.Configuration configuration, HttpClient? httpClient = null ) { - configuration.IsValid(); + configuration.EnsureValid(); _configuration = configuration; _apiClient = new ApiClient.ApiClient(_configuration, httpClient); } diff --git a/src/OpenFga.Sdk/ApiClient/ApiClient.cs b/src/OpenFga.Sdk/ApiClient/ApiClient.cs index 395e574..923da0d 100644 --- a/src/OpenFga.Sdk/ApiClient/ApiClient.cs +++ b/src/OpenFga.Sdk/ApiClient/ApiClient.cs @@ -34,7 +34,7 @@ public class ApiClient : IDisposable { /// Client Configuration /// User Http Client - Allows Http Client reuse public ApiClient(Configuration.Configuration configuration, HttpClient? userHttpClient = null) { - configuration.IsValid(); + configuration.EnsureValid(); _configuration = configuration; _baseClient = new BaseClient(configuration, userHttpClient); diff --git a/src/OpenFga.Sdk/Client/Client.cs b/src/OpenFga.Sdk/Client/Client.cs index 05a06aa..3e71470 100644 --- a/src/OpenFga.Sdk/Client/Client.cs +++ b/src/OpenFga.Sdk/Client/Client.cs @@ -31,7 +31,7 @@ public OpenFgaClient( ClientConfiguration configuration, HttpClient? httpClient = null ) { - configuration.IsValid(); + configuration.EnsureValid(); _configuration = configuration; api = new OpenFgaApi(_configuration, httpClient); } diff --git a/src/OpenFga.Sdk/Client/ClientConfiguration.cs b/src/OpenFga.Sdk/Client/ClientConfiguration.cs index 12cc166..589beea 100644 --- a/src/OpenFga.Sdk/Client/ClientConfiguration.cs +++ b/src/OpenFga.Sdk/Client/ClientConfiguration.cs @@ -66,11 +66,11 @@ public ClientConfiguration() { } public RetryParams? RetryParams { get; set; } = new(); /// - /// Validates the configuration settings. + /// Ensures the configuration is valid, otherwise throws an error. /// /// Thrown when the Store ID or Authorization Model ID is not in a valid ULID format. - public new void IsValid() { - base.IsValid(); + public new void EnsureValid() { + base.EnsureValid(); if (StoreId != null && !IsWellFormedUlidString(StoreId)) { throw new FgaValidationError("StoreId is not in a valid ulid format"); diff --git a/src/OpenFga.Sdk/Configuration/Configuration.cs b/src/OpenFga.Sdk/Configuration/Configuration.cs index 0df4ce0..b083152 100644 --- a/src/OpenFga.Sdk/Configuration/Configuration.cs +++ b/src/OpenFga.Sdk/Configuration/Configuration.cs @@ -43,10 +43,11 @@ private static bool IsWellFormedUriString(string uri) => (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); /// - /// Checks if the configuration is valid + /// Ensures that the configuration is valid otherwise throws an error /// /// - public void IsValid() { + /// + public void EnsureValid() { if (BasePath == null || BasePath == "") { throw new FgaRequiredParamError("Configuration", "ApiUrl"); } @@ -60,8 +61,8 @@ public void IsValid() { throw new FgaValidationError("Configuration.MaxRetry exceeds maximum allowed limit of 15"); } - Credentials?.IsValid(); - Telemetry?.IsValid(); + Credentials?.EnsureValid(); + Telemetry?.EnsureValid(); } #endregion diff --git a/src/OpenFga.Sdk/Configuration/Credentials.cs b/src/OpenFga.Sdk/Configuration/Credentials.cs index ae520d9..4c4f903 100644 --- a/src/OpenFga.Sdk/Configuration/Credentials.cs +++ b/src/OpenFga.Sdk/Configuration/Credentials.cs @@ -96,10 +96,10 @@ private static bool IsWellFormedUriString(string uri) { } /// - /// Checks if the credentials configuration is valid + /// Ensures the credentials configuration is valid otherwise throws an error /// /// - public void IsValid() { + public void EnsureValid() { switch (Method) { case CredentialsMethod.ApiToken: if (string.IsNullOrWhiteSpace(Config?.ApiToken)) { @@ -140,7 +140,7 @@ public void IsValid() { /// /// public Credentials() { - this.IsValid(); + this.EnsureValid(); } static Credentials Init(IAuthCredentialsConfig config) { diff --git a/src/OpenFga.Sdk/Configuration/TelemetryConfig.cs b/src/OpenFga.Sdk/Configuration/TelemetryConfig.cs index 70534b3..d4b80ec 100644 --- a/src/OpenFga.Sdk/Configuration/TelemetryConfig.cs +++ b/src/OpenFga.Sdk/Configuration/TelemetryConfig.cs @@ -77,7 +77,11 @@ private static IDictionary GetDefaultMetricsConfiguration( }; } - public void IsValid() { + /// + /// Validates the telemetry configuration. + /// + /// + public void EnsureValid() { if (Metrics == null) { return; } From 4b983a4da3e7c2d1a9c41e6c10f76dcf6fc2ad85 Mon Sep 17 00:00:00 2001 From: Raghd Hamzeh Date: Fri, 6 Sep 2024 19:31:55 -0400 Subject: [PATCH 3/4] release: v0.5.1 --- CHANGELOG.md | 9 +++++++-- src/OpenFga.Sdk/Configuration/Configuration.cs | 4 ++-- src/OpenFga.Sdk/OpenFga.Sdk.csproj | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38277f9..ec75194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,17 @@ # Changelog +## v0.5.1 + +### [0.5.1](https://github.com/openfga/dotnet-sdk/compare/v0.5.0...v0.5.1) (2024-09-09) +- feat: export OpenTelemetry metrics. Refer to the [https://github.com/openfga/dotnet-sdk/blob/main/OpenTelemetry.md](documentation) for more. + ## v0.5.0 ### [0.5.0](https://github.com/openfga/dotnet-sdk/compare/v0.4.0...v0.5.0) (2024-08-28) - feat: support consistency parameter (#70) -Note: To use this feature, you need to be running OpenFGA v1.5.7+ with the experimental flag `enable-consistency-params` enabled. -See the [v1.5.7 release notes](https://github.com/openfga/openfga/releases/tag/v1.5.7) for details. + Note: To use this feature, you need to be running OpenFGA v1.5.7+ with the experimental flag `enable-consistency-params` enabled. + See the [v1.5.7 release notes](https://github.com/openfga/openfga/releases/tag/v1.5.7) for details. ## v0.4.0 diff --git a/src/OpenFga.Sdk/Configuration/Configuration.cs b/src/OpenFga.Sdk/Configuration/Configuration.cs index b083152..2ab08e6 100644 --- a/src/OpenFga.Sdk/Configuration/Configuration.cs +++ b/src/OpenFga.Sdk/Configuration/Configuration.cs @@ -73,9 +73,9 @@ public void EnsureValid() { /// Version of the package. /// /// Version of the package. - public const string Version = "0.5.0"; + public const string Version = "0.5.1"; - private const string DefaultUserAgent = "openfga-sdk dotnet/0.5.0"; + private const string DefaultUserAgent = "openfga-sdk dotnet/0.5.1"; #endregion Constants diff --git a/src/OpenFga.Sdk/OpenFga.Sdk.csproj b/src/OpenFga.Sdk/OpenFga.Sdk.csproj index 6f779af..2a7db59 100644 --- a/src/OpenFga.Sdk/OpenFga.Sdk.csproj +++ b/src/OpenFga.Sdk/OpenFga.Sdk.csproj @@ -12,7 +12,7 @@ .NET SDK for OpenFGA OpenFGA OpenFga.Sdk - 0.5.0 + 0.5.1 bin\$(Configuration)\$(TargetFramework)\OpenFga.Sdk.xml Apache-2.0 README.md From a3489ed0e5d98b1adccdb318f4f2742b108dc55d Mon Sep 17 00:00:00 2001 From: Raghd Hamzeh Date: Sat, 7 Sep 2024 08:43:57 -0400 Subject: [PATCH 4/4] chore(docs): update default attributes in OpenTelemetry docs --- OpenTelemetry.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OpenTelemetry.md b/OpenTelemetry.md index 94fda6c..abe0efc 100644 --- a/OpenTelemetry.md +++ b/OpenTelemetry.md @@ -29,9 +29,9 @@ In cases when metrics events are sent, they will not be viewable outside of infr | `fga-client.user` | `string` | No | The user that is associated with the action of the request for check and list objects | | `http.request.resend_count` | `int` | Yes | The number of retries attempted (Only sent if the request was retried. Count of `1` means the request was retried once in addition to the original request) | | `http.response.status_code` | `int` | Yes | The status code of the response | -| `http.request.method` | `string` | Yes | The HTTP method for the request | +| `http.request.method` | `string` | No | The HTTP method for the request | | `http.host` | `string` | Yes | Host identifier of the origin the request was sent to | -| `url.scheme` | `string` | Yes | HTTP Scheme of the request (`http`/`https`) | +| `url.scheme` | `string` | No | HTTP Scheme of the request (`http`/`https`) | | `url.full` | `string` | No | Full URL of the request | | `user_agent.original` | `string` | Yes | User Agent used in the query |