diff --git a/config/clients/dotnet/CHANGELOG.md.mustache b/config/clients/dotnet/CHANGELOG.md.mustache index 876cc966..3429707d 100644 --- a/config/clients/dotnet/CHANGELOG.md.mustache +++ b/config/clients/dotnet/CHANGELOG.md.mustache @@ -1,5 +1,10 @@ # Changelog +## v0.4.1 + +### [0.4.1](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.4.0...v0.4.1) (2024-07-30) +- feat: export OpenTelemetry metrics. Refer to the [https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/blob/main/OpenTelemetry.md](documentation) for more. + ## v0.4.0 ### [0.4.0](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.3.2...v0.4.0) (2024-06-14) diff --git a/config/clients/dotnet/config.overrides.json b/config/clients/dotnet/config.overrides.json index 90c2c16b..81de92d1 100644 --- a/config/clients/dotnet/config.overrides.json +++ b/config/clients/dotnet/config.overrides.json @@ -10,7 +10,7 @@ "packageGuid": "b8d9e3e9-0156-4948-9de7-5e0d3f9c4d9e", "testPackageGuid": "d119dfae-509a-4eba-a973-645b739356fc", "packageName": "OpenFga.Sdk", - "packageVersion": "0.4.0", + "packageVersion": "0.4.1", "licenseUrl": "https://github.com/openfga/dotnet-sdk/blob/main/LICENSE", "fossaComplianceNoticeId": "f8ac2ec4-84fc-44f4-a617-5800cd3d180e", "termsOfService": "", @@ -33,11 +33,8 @@ "enablePostProcessFile": true, "hashCodeBasePrimeNumber": 9661, "hashCodeMultiplierPrimeNumber": 9923, + "supportsOpenTelemetry": true, "files": { - ".github/workflows/main.yaml.mustache": { - "destinationFilename": ".github/workflows/main.yaml", - "templateType": "SupportingFiles" - }, "Client_OAuth2Client.mustache": { "destinationFilename": "src/OpenFga.Sdk/ApiClient/OAuth2Client.cs", "templateType": "SupportingFiles" @@ -214,6 +211,22 @@ "destinationFilename": "src/OpenFga.Sdk/Client/Model/StoreIdOptions.cs", "templateType": "SupportingFiles" }, + "Telemetry/Attributes.cs.mustache": { + "destinationFilename": "src/OpenFga.Sdk/Telemetry/Attributes.cs", + "templateType": "SupportingFiles" + }, + "Telemetry/Counters.cs.mustache": { + "destinationFilename": "src/OpenFga.Sdk/Telemetry/Counters.cs", + "templateType": "SupportingFiles" + }, + "Telemetry/Histograms.cs.mustache": { + "destinationFilename": "src/OpenFga.Sdk/Telemetry/Histograms.cs", + "templateType": "SupportingFiles" + }, + "Telemetry/Metrics.cs.mustache": { + "destinationFilename": "src/OpenFga.Sdk/Telemetry/Metrics.cs", + "templateType": "SupportingFiles" + }, "Configuration_Configuration.mustache": { "destinationFilename": "src/OpenFga.Sdk/Configuration/Configuration.cs", "templateType": "SupportingFiles" @@ -282,10 +295,17 @@ "destinationFilename": ".fossa.yml", "templateType": "SupportingFiles" }, + "OpenTelemetry.md.mustache": { + "destinationFilename": "OpenTelemetry.md", + "templateType": "SupportingFiles" + }, "example/Makefile": {}, "example/README.md": {}, "example/Example1/Example1.cs": {}, "example/Example1/Example1.csproj": {}, + "example/OpenTelemetryExample/.env.example": {}, + "example/OpenTelemetryExample/OpenTelemetryExample.cs": {}, + "example/OpenTelemetryExample/OpenTelemetryExample.csproj": {}, "assets/FGAIcon.png": {}, ".editorconfig": {} } diff --git a/config/clients/dotnet/template/Client_ApiClient.mustache b/config/clients/dotnet/template/Client_ApiClient.mustache index 97e5bb84..97e8753f 100644 --- a/config/clients/dotnet/template/Client_ApiClient.mustache +++ b/config/clients/dotnet/template/Client_ApiClient.mustache @@ -3,19 +3,22 @@ using {{packageName}}.Client.Model; using {{packageName}}.Configuration; using {{packageName}}.Exceptions; +using {{packageName}}.Telemetry; +using System.Diagnostics; namespace {{packageName}}.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 = new(); /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Client Configuration /// User Http Client - Allows Http Client reuse @@ -28,14 +31,15 @@ public class ApiClient : IDisposable { return; } - switch (_configuration.Credentials.Method) - { + 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 }); break; case CredentialsMethod.None: default: @@ -44,8 +48,9 @@ public class ApiClient : IDisposable { } /// - /// 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 /// /// /// @@ -53,10 +58,11 @@ public class ApiClient : IDisposable { /// 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(); @@ -64,25 +70,36 @@ public class ApiClient : IDisposable { if (!string.IsNullOrEmpty(token)) { additionalHeaders["Authorization"] = $"Bearer {token}"; } - } catch (ApiException e) { + } + catch (ApiException e) { throw new FgaApiAuthenticationError("Invalid Client Credentials", apiName, e); } } - 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, _configuration.Credentials, 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(); @@ -90,73 +107,55 @@ public class ApiClient : IDisposable { if (!string.IsNullOrEmpty(token)) { additionalHeaders["Authorization"] = $"Bearer {token}"; } - } catch (ApiException e) { + } + catch (ApiException e) { throw new FgaApiAuthenticationError("Invalid Client Credentials", apiName, e); } } - 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, _configuration.Credentials, 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++; - 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); + var response = await retryable(); - await Task.Delay(waitInMs); - } - catch (FgaApiError err) { - if (!err.ShouldRetry || numRetries > _configuration.MaxRetry) { - throw; - } - var waitInMs = (int)(_configuration.MinWaitInMs); + response.retryCount = requestCount - 1; // OTEL spec specifies that the original request is not included in the count - await Task.Delay(waitInMs); + return response; } - } - } - - private async Task Retry(Func retryable) { - var numRetries = 0; - while (true) { - try { - numRetries++; - - await retryable(); - - return; - } catch (FgaApiRateLimitExceededError err) { - if (numRetries > _configuration.MaxRetry) { + catch (FgaApiRateLimitExceededError err) { + 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 = (int)(_configuration.MinWaitInMs); + + var waitInMs = _configuration.MinWaitInMs; await Task.Delay(waitInMs); } } } - public void Dispose() { - _baseClient.Dispose(); - } -} + public void Dispose() => _baseClient.Dispose(); +} \ No newline at end of file diff --git a/config/clients/dotnet/template/Client_BaseClient.mustache b/config/clients/dotnet/template/Client_BaseClient.mustache index 27d718bc..bc1c92f4 100644 --- a/config/clients/dotnet/template/Client_BaseClient.mustache +++ b/config/clients/dotnet/template/Client_BaseClient.mustache @@ -1,79 +1,87 @@ {{>partial_header}} +using {{packageName}}.Exceptions; +using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; -using {{packageName}}.Exceptions; - namespace {{packageName}}.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 /// /// /// @@ -83,7 +91,7 @@ public class BaseClient : IDisposable { /// /// /// - public async Task SendRequestAsync(HttpRequestMessage request, + public async Task> SendRequestAsync(HttpRequestMessage request, IDictionary? additionalHeaders = null, string? apiName = null, CancellationToken cancellationToken = default) { if (additionalHeaders != null) { @@ -96,48 +104,28 @@ public class BaseClient : IDisposable { 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(); @@ -146,9 +134,7 @@ public class BaseClient : IDisposable { } /// - /// 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); } diff --git a/config/clients/dotnet/template/Client_OAuth2Client.mustache b/config/clients/dotnet/template/Client_OAuth2Client.mustache index 17b0edd2..b51ef15c 100644 --- a/config/clients/dotnet/template/Client_OAuth2Client.mustache +++ b/config/clients/dotnet/template/Client_OAuth2Client.mustache @@ -1,44 +1,45 @@ {{>partial_header}} -using System.Text.Json.Serialization; - using {{packageName}}.Client.Model; using {{packageName}}.Configuration; using {{packageName}}.Exceptions; +using {{packageName}}.Telemetry; +using System.Diagnostics; +using System.Text.Json.Serialization; namespace {{packageName}}.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 = {{tokenExpiryThresholdBufferInSec}}; + private const int TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC = 300; private const int - TOKEN_EXPIRY_JITTER_IN_SEC = {{tokenExpiryJitterInSec}}; // We add some jitter so that token refreshes are less likely to collide + 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 = new(); /// - /// 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; } @@ -49,29 +50,29 @@ public class OAuth2Client { 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; + private readonly Credentials _credentialsConfig; #endregion #region Methods /// - /// Initializes a new instance of the class + /// Initializes a new instance of the class /// /// /// @@ -85,41 +86,50 @@ public class OAuth2Client { throw new FgaRequiredParamError("OAuth2Client", "config.ClientSecret"); } - this._httpClient = httpClient; - this._apiTokenIssuer = credentialsConfig.Config.ApiTokenIssuer; - this._authRequest = new Dictionary() { + _credentialsConfig = credentialsConfig; + _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; + _retryParams = retryParams; } /// - /// 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, _credentialsConfig, + 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) { @@ -134,7 +144,8 @@ public class OAuth2Client { 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); @@ -144,6 +155,7 @@ public class OAuth2Client { if (!err.ShouldRetry || numRetries > _retryParams.MaxRetry) { throw; } + var waitInMs = _retryParams.MinWaitInMs; await Task.Delay(waitInMs); @@ -152,7 +164,7 @@ public class OAuth2Client { } /// - /// 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/config/clients/dotnet/template/Client_RequestBuilder.mustache b/config/clients/dotnet/template/Client_RequestBuilder.mustache index 67223e4f..8e7ee1e0 100644 --- a/config/clients/dotnet/template/Client_RequestBuilder.mustache +++ b/config/clients/dotnet/template/Client_RequestBuilder.mustache @@ -1,12 +1,21 @@ {{>partial_header}} -using System.Web; - using {{packageName}}.Exceptions; +using System.Text; +using System.Text.Json; +using System.Web; namespace {{packageName}}.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; } @@ -15,13 +24,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)); @@ -56,6 +86,7 @@ public class RequestBuilder { if (BasePath == null) { throw new FgaRequiredParamError("RequestBuilder.BuildUri", nameof(BasePath)); } + var uriString = $"{BasePath}"; uriString += BuildPathString(); @@ -68,6 +99,7 @@ public class RequestBuilder { 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 }; } } diff --git a/config/clients/dotnet/template/OpenTelemetry.md.mustache b/config/clients/dotnet/template/OpenTelemetry.md.mustache new file mode 100644 index 00000000..8607aad1 --- /dev/null +++ b/config/clients/dotnet/template/OpenTelemetry.md.mustache @@ -0,0 +1,37 @@ +# 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 | Description | +|---------------------------------|-----------|--------------------------------------------------------------------------------------| +| `fga-client.request.duration` | Histogram | The total request time for FGA requests | +| `fga-client.query.duration` | Histogram | The amount of time the FGA server took to internally process nd evaluate the request | +|` fga-client.credentials.request`| Counter | The total number of times a new token was requested when using ClientCredentials | + +### Supported attributes + +| Attribute Name | Type | Description | +|--------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `fga-client.response.model_id` | `string` | The authorization model ID that the FGA server used | +| `fga-client.request.method` | `string` | The FGA method/action that was performed (e.g. `Check`, `ListObjects`, ...) in TitleCase | +| `fga-client.request.store_id` | `string` | The store ID that was sent as part of the request | +| `fga-client.request.model_id` | `string` | The authorization model ID that was sent as part of the request, if any | +| `fga-client.request.client_id` | `string` | The client ID associated with the request, if any | +| `fga-client.user` | `string` | The user that is associated with the action of the request for `Check` and `ListObjects` | +| `http.request.resend_count` | `int` | 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` | The status code of the response | +| `http.request.method` | `string` | The HTTP method for the request | +| `http.host` | `string` | Host identifier of the origin the request was sent to | +| `url.scheme` | `string` | HTTP Scheme of the request (`http`/`https`) | +| `url.full` | `string` | Full URL of the request | +| `user_agent.original` | `string` | User Agent used in the query | + +{{>OpenTelemetryDocs_custom}} \ No newline at end of file diff --git a/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache b/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache new file mode 100644 index 00000000..ba1442e5 --- /dev/null +++ b/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache @@ -0,0 +1,55 @@ +## 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 {{packageName}}.Client; +using {{packageName}}.Client.Model; +using {{packageName}}.Model; +using {{packageName}}.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("{{packageName}}") 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 + var configuration = new ClientConfiguration() { + ApiUrl = Environment.GetEnvironmentVariable("FGA_API_URL") ?? "http://localhost:8080", // required, e.g. https://api.fga.example + StoreId = Environment.GetEnvironmentVariable("FGA_STORE_ID"), // not needed when calling `CreateStore` or `ListStores` + AuthorizationModelId = Environment.GetEnvironmentVariable("FGA_MODEL_ID"), // Optional, can be overridden per request + // Credentials = ... // If needed + }; + var fgaClient = new OpenFgaClient(configuration); + + // Call the SDK normally + var response = await fgaClient.ReadAuthorizationModels(); + } catch (ApiException e) { + Debug.Print("Error: "+ e); + } + } + } +} +``` + +### 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/config/clients/dotnet/template/README.mustache b/config/clients/dotnet/template/README.mustache new file mode 100644 index 00000000..0399d8ad --- /dev/null +++ b/config/clients/dotnet/template/README.mustache @@ -0,0 +1,139 @@ +# {{packageDescription}} + +{{>README_custom_badges}}[![Release](https://img.shields.io/github/v/release/{{gitUserId}}/{{gitRepoId}}?sort=semver&color=green)](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/releases) +{{#licenseBadgeId}}[![License](https://img.shields.io/badge/License-{{licenseBadgeId}}-blue.svg)](./LICENSE){{/licenseBadgeId}} +[![FOSSA Status](https://app.fossa.com/api/projects/git%2B{{gitHost}}%2F{{gitUserId}}%2F{{gitRepoId}}.svg?type=shield)](https://app.fossa.com/projects/git%2B{{gitHost}}%2F{{gitUserId}}%2F{{gitRepoId}}?ref=badge_shield) +[![Join our community](https://img.shields.io/badge/slack-cncf_%23openfga-40abb8.svg?logo=slack)](https://openfga.dev/community) +{{#twitterUserName}}[![Twitter](https://img.shields.io/twitter/follow/{{.}}?color=%23179CF0&logo=twitter&style=flat-square "@{{.}} on Twitter")](https://twitter.com/{{.}}){{/twitterUserName}} + +{{#packageDetailedDescription}} +{{{.}}} +{{/packageDetailedDescription}} + +## Table of Contents + +- [About {{appLongName}}](#about) +- [Resources](#resources) +- [Installation](#installation) +- [Getting Started](#getting-started) + - [Initializing the API Client](#initializing-the-api-client) + - [Get your Store ID](#get-your-store-id) + - [Calling the API](#calling-the-api) + - [Stores](#stores) + - [List All Stores](#list-stores) + - [Create a Store](#create-store) + - [Get a Store](#get-store) + - [Delete a Store](#delete-store) + - [Authorization Models](#authorization-models) + - [Read Authorization Models](#read-authorization-models) + - [Write Authorization Model](#write-authorization-model) + - [Read a Single Authorization Model](#read-a-single-authorization-model) + - [Read the Latest Authorization Model](#read-the-latest-authorization-model) + - [Relationship Tuples](#relationship-tuples) + - [Read Relationship Tuple Changes (Watch)](#read-relationship-tuple-changes-watch) + - [Read Relationship Tuples](#read-relationship-tuples) + - [Write (Create and Delete) Relationship Tuples](#write-create-and-delete-relationship-tuples) + - [Relationship Queries](#relationship-queries) + - [Check](#check) + - [Batch Check](#batch-check) + - [Expand](#expand) + - [List Objects](#list-objects) + - [List Relations](#list-relations) + - [List Users](#list-users) + - [Assertions](#assertions) + - [Read Assertions](#read-assertions) + - [Write Assertions](#write-assertions) + - [Retries](#retries) + - [API Endpoints](#api-endpoints) + - [Models](#models) +{{#supportsOpenTelemetry}} + - [OpenTelemetry](#opentelemetry) +{{/supportsOpenTelemetry}} +- [Contributing](#contributing) + - [Issues](#issues) + - [Pull Requests](#pull-requests) +- [License](#license) + +## About + +{{>README_project_introduction}} + +## Resources + +{{#docsUrl}} +- [{{appName}} Documentation]({{docsUrl}}) +{{/docsUrl}} +{{#apiDocsUrl}} +- [{{appName}} API Documentation]({{apiDocsUrl}}) +{{/apiDocsUrl}} +{{#twitterUserName}} +- [Twitter](https://twitter.com/{{.}}) +{{/twitterUserName}} +{{#redditUrl}} +- [{{appName}} Subreddit]({{redditUrl}}) +{{/redditUrl}} +{{#supportInfo}} +- [{{appName}} Community]({{supportInfo}}) +{{/supportInfo}} +- [Zanzibar Academy](https://zanzibar.academy) +- [Google's Zanzibar Paper (2019)](https://research.google/pubs/pub48190/) + +## Installation + +{{>README_installation}} + +## Getting Started + +### Initializing the API Client + +[Learn how to initialize your SDK]({{docsUrl}}/getting-started/setup-sdk-client) + +{{>README_initializing}} + +### Get your Store ID + +You need your store id to call the {{appName}} API (unless it is to call the [CreateStore](#create-store) or [ListStores](#list-stores) methods). + +If your server is configured with [authentication enabled]({{docsUrl}}/getting-started/setup-openfga#configuring-authentication), you also need to have your credentials ready. + +### Calling the API + +{{>README_calling_api}} + +### Retries + +{{>README_retries}} + +### API Endpoints + +{{>README_api_endpoints}} + +### Models + +{{>README_models}} + +{{#supportsOpenTelemetry}} +### 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://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/blob/main/OpenTelemetry.md) + +{{/supportsOpenTelemetry}} +## Contributing + +### Issues + +If you have found a bug or if you have a feature request, please report them on the [sdk-generator repo](https://{{gitHost}}/{{gitUserId}}/sdk-generator/issues) issues section. Please do not report security vulnerabilities on the public GitHub issue tracker. + +### Pull Requests + +All changes made to this repo will be overwritten on the next generation, so we kindly ask that you send all pull requests related to the SDKs to the [sdk-generator repo](https://{{gitHost}}/{{gitUserId}}/sdk-generator) instead. + +## Author + +[{{author}}](https://{{gitHost}}/{{gitUserId}}) + +## License + +This project is licensed under the {{licenseId}} license. See the [LICENSE](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/blob/main/LICENSE) file for more info. + +{{>README_license_disclaimer}} diff --git a/config/clients/dotnet/template/README_retries.mustache b/config/clients/dotnet/template/README_retries.mustache index af18dd41..4d0153b9 100644 --- a/config/clients/dotnet/template/README_retries.mustache +++ b/config/clients/dotnet/template/README_retries.mustache @@ -5,9 +5,9 @@ To customize this behavior, create a `RetryParams` instance and assign values to Apply your custom retry values by passing the object to the `ClientConfiguration` constructor's `RetryParams` parameter. ```csharp -using OpenFga.Sdk.Client; -using OpenFga.Sdk.Client.Model; -using OpenFga.Sdk.Model; +using {{packageName}}.Client; +using {{packageName}}.Client.Model; +using {{packageName}}.Model; namespace Example { public class Example { diff --git a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache new file mode 100644 index 00000000..99f1e5a0 --- /dev/null +++ b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache @@ -0,0 +1,202 @@ +{{>partial_header}} + +using {{packageName}}.ApiClient; +using {{packageName}}.Configuration; +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace {{packageName}}.Telemetry; + +public class Attributes { + /** + * Common attribute (tag) names + */ + + // Attributes (tags) associated with the request made // + + // The FGA method/action that was performed (e.g. `check`, `listObjects`, ...) in camelCase + private const string AttributeRequestMethod = "fga-client.request.method"; + + // The store ID that was sent as part of the request + private const string AttributeRequestStoreId = "fga-client.request.store_id"; + + // The authorization model ID that was sent as part of the request, if any + private const string AttributeRequestModelId = "fga-client.request.model_id"; + + // The client ID associated with the request, if any + private const string AttributeRequestClientId = "fga-client.request.client_id"; + + // Attributes (tags) associated with the response // + + // The authorization model ID that the FGA server used + private const string AttributeResponseModelId = "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 + private const string AttributeFgaRequestUser = "fga-client.user"; + + // OTEL Semantic Attributes (tags) // + + // The total request time for FGA requests + private const string AttributeHttpClientRequestDuration = "http.client.request.duration"; + + // The amount of time the FGA server took to internally process nd evaluate the request + private const string AttributeHttpServerRequestDuration = "http.server.request.duration"; + + // The HTTP method for the request + private const string AttributeHttpMethod = "http.request.method"; + + // The status code of the response + private const string AttributeHttpStatus = "http.response.status_code"; + + // Host identifier of the origin the request was sent to + private const string AttributeHttpHost = "http.host"; + + // HTTP Scheme of the request (`http`/`https`) + private const string AttributeHttpScheme = "url.scheme"; + + // Full URL of the request + private const string AttributeHttpUrl = "url.full"; + + // User Agent used in the query + private const string AttributeHttpUserAgent = "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) + private const string AttributeRequestRetryCount = "http.request.resend_count"; + + private static string? GetHeaderValueIfValid(HttpResponseHeaders headers, string headerName) { + if (headers.Contains(headerName) && headers.GetValues(headerName).Any()) { + return headers.GetValues(headerName).First(); + } + + return null; + } + + /** + * Builds an object of attributes that can be used to report alongside an OpenTelemetry metric event. + * + * @param response - The Axios response object, used to add data like HTTP status, host, method, and headers. + * @param credentials - The credentials object, used to add data like the ClientID when using ClientCredentials. + * @param methodAttributes - Extra attributes that the method (i.e. check, listObjects) wishes to have included. Any custom attributes should use the common names. + * @returns {Attributes} + */ + public static TagList buildAttributesForResponse(string apiName, + HttpResponseMessage response, RequestBuilder requestBuilder, Credentials? credentials, + Stopwatch requestDuration, int retryCount) { + var attributes = new TagList { new(AttributeRequestMethod, apiName) }; + + if (requestBuilder.PathParameters.ContainsKey("store_id")) { + var storeId = requestBuilder.PathParameters.GetValueOrDefault("store_id"); + if (!string.IsNullOrEmpty(storeId)) { + attributes.Add(new KeyValuePair(AttributeRequestStoreId, storeId)); + } + } + + string? modelId = null; + // if the model id is in the path params try to get it from there + if (requestBuilder.PathParameters.ContainsKey("authorization_model_id")) { + modelId = requestBuilder.PathParameters.GetValueOrDefault("authorization_model_id"); + if (!string.IsNullOrEmpty(modelId)) { + attributes.Add(new KeyValuePair(AttributeRequestModelId, modelId)); + } + } + // In the case of ReadAuthorizationModel, the path param is called 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(AttributeRequestModelId, modelId)); + } + } + // In many endpoints authorization_model_id is sent as a field in the body + // if the apiName is Check or ListObjects, we always want to parse the request body to get the model ID and the user (subject) + // if the apiName is Write, Expand or ListUsers we want to parse it to get the model ID + else if (apiName is "Check" or "ListObjects" or "Write" or "Expand" or "ListUsers") { + 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)) { + if (!string.IsNullOrEmpty(authModelId.GetString())) { + attributes.Add(new KeyValuePair(AttributeRequestModelId, + authModelId.GetString())); + } + + switch (apiName) { + case "Check": { + if (root.TryGetProperty("tuple_key", out var tupleKey) && + tupleKey.TryGetProperty("user", out var fgaUser) && + !string.IsNullOrEmpty(fgaUser.GetString())) { + attributes.Add(new KeyValuePair(AttributeFgaRequestUser, + fgaUser.GetString())); + } + + break; + } + case "ListObjects": { + if (root.TryGetProperty("user", out var fgaUser) && + !string.IsNullOrEmpty(fgaUser.GetString())) { + attributes.Add(new KeyValuePair(AttributeFgaRequestUser, + fgaUser.GetString())); + } + + break; + } + } + } + } + } + } + catch { } + } + + if (response.StatusCode != null) { + attributes.Add(new KeyValuePair(AttributeHttpStatus, (int)response.StatusCode)); + } + + if (response.RequestMessage != null) { + if (response.RequestMessage.Method != null) { + attributes.Add(new KeyValuePair(AttributeHttpMethod, response.RequestMessage.Method)); + } + + if (response.RequestMessage.RequestUri != null) { + attributes.Add(new KeyValuePair(AttributeHttpScheme, + response.RequestMessage.RequestUri.Scheme)); + attributes.Add(new KeyValuePair(AttributeHttpHost, + response.RequestMessage.RequestUri.Host)); + attributes.Add(new KeyValuePair(AttributeHttpUrl, + response.RequestMessage.RequestUri.AbsoluteUri)); + } + + if (response.RequestMessage.Headers.UserAgent != null && + !string.IsNullOrEmpty(response.RequestMessage.Headers.UserAgent.ToString())) { + attributes.Add(new KeyValuePair(AttributeHttpUserAgent, + response.RequestMessage.Headers.UserAgent.ToString())); + } + } + + var responseModelId = GetHeaderValueIfValid(response.Headers, "openfga-authorization-model-id"); + if (!string.IsNullOrEmpty(responseModelId)) { + attributes.Add(new KeyValuePair(AttributeResponseModelId, responseModelId)); + } + else { + responseModelId = GetHeaderValueIfValid(response.Headers, "fga-authorization-model-id"); + if (!string.IsNullOrEmpty(responseModelId)) { + attributes.Add(new KeyValuePair(AttributeResponseModelId, responseModelId)); + } + } + + if (credentials is { Method: CredentialsMethod.ClientCredentials, Config.ClientId: not null }) { + attributes.Add(new KeyValuePair(AttributeRequestClientId, credentials.Config.ClientId)); + } + + // OTEL specifies that this value should be conditionally sent if a retry occurred + if (retryCount > 0) { + attributes.Add(new KeyValuePair(AttributeRequestRetryCount, retryCount)); + } + + return attributes; + } +} diff --git a/config/clients/dotnet/template/Telemetry/Counters.cs.mustache b/config/clients/dotnet/template/Telemetry/Counters.cs.mustache new file mode 100644 index 00000000..e4776f5a --- /dev/null +++ b/config/clients/dotnet/template/Telemetry/Counters.cs.mustache @@ -0,0 +1,23 @@ +{{>partial_header}} + +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace {{packageName}}.Telemetry; + +public class TelemetryCounters { + // Meters + // The total number of times a new token was requested when using ClientCredentials + private const string TokenExchangeCountKey = "fga-client.credentials.request"; + + protected Meter meter; + public Counter tokenExchangeCounter; + + public TelemetryCounters(Meter meter) { + this.meter = meter; + tokenExchangeCounter = this.meter.CreateCounter(TokenExchangeCountKey, + description: "The count of token exchange requests"); + } + + public void buildForResponse(TagList attributes) => tokenExchangeCounter.Add(1, attributes!); +} \ No newline at end of file diff --git a/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache b/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache new file mode 100644 index 00000000..9c218c7e --- /dev/null +++ b/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache @@ -0,0 +1,44 @@ +{{>partial_header}} + +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace {{packageName}}.Telemetry; + +public class TelemetryHistograms { + // Meters + + // The total request time for FGA requests + private const string RequestDurationKey = "fga-client.request.duration"; + + // The amount of time the FGA server took to internally process nd evaluate the request + private const string QueryDurationKey = "fga-client.query.duration"; + + protected Meter meter; + public Histogram queryDuration; + public Histogram requestDurationHistogram; + + public TelemetryHistograms(Meter meter) { + this.meter = meter; + requestDurationHistogram = this.meter.CreateHistogram(RequestDurationKey, + description: "The duration of requests", unit: "milliseconds"); + queryDuration = this.meter.CreateHistogram(QueryDurationKey, + description: "The duration of queries on the FGA server", unit: "milliseconds"); + } + + public void buildForResponse(HttpResponseMessage response, TagList attributes, + Stopwatch requestDuration) { + 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)) { + var success = float.TryParse(durationHeader, out var durationFloat); + if (success) { + queryDuration?.Record(durationFloat, attributes); + } + } + } + + requestDurationHistogram.Record(requestDuration.ElapsedMilliseconds, attributes); + } +} \ No newline at end of file diff --git a/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache b/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache new file mode 100644 index 00000000..8753a324 --- /dev/null +++ b/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache @@ -0,0 +1,31 @@ +{{>partial_header}} + +using {{packageName}}.ApiClient; +using {{packageName}}.Configuration; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace {{packageName}}.Telemetry; + +public class Metrics { + public const string name = "{{packageName}}"; + public TelemetryCounters counters; + public TelemetryHistograms histograms; + public Meter meter = new(name, Configuration.Configuration.Version); + + public Metrics() { + histograms = new TelemetryHistograms(meter); + counters = new TelemetryCounters(meter); + } + + public void buildForResponse(string apiName, HttpResponseMessage response, RequestBuilder requestBuilder, + Credentials? credentials, Stopwatch requestDuration, int retryCount) => histograms.buildForResponse(response, + Attributes.buildAttributesForResponse(apiName, response, requestBuilder, credentials, requestDuration, + retryCount), + requestDuration); + + public void buildForClientCredentialsResponse(HttpResponseMessage response, RequestBuilder requestBuilder, + Credentials? credentials, Stopwatch requestDuration, int retryCount) => counters.buildForResponse( + Attributes.buildAttributesForResponse("ClientCredentialsExchange", response, requestBuilder, credentials, + requestDuration, retryCount)); +} \ No newline at end of file diff --git a/config/clients/dotnet/template/api.mustache b/config/clients/dotnet/template/api.mustache index e3b631bc..8e62088e 100644 --- a/config/clients/dotnet/template/api.mustache +++ b/config/clients/dotnet/template/api.mustache @@ -7,11 +7,11 @@ using {{packageName}}.{{modelPackage}}; namespace {{packageName}}.{{apiPackage}}; public class {{classname}} : 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. /// /// /// @@ -20,8 +20,8 @@ public class {{classname}} : IDisposable { HttpClient? httpClient = null ) { configuration.IsValid(); - this._configuration = configuration; - this._apiClient = new ApiClient.ApiClient(_configuration, httpClient); + _configuration = configuration; + _apiClient = new ApiClient.ApiClient(_configuration, httpClient); } {{#operations}} @@ -58,18 +58,18 @@ public class {{classname}} : IDisposable { } {{/queryParams}} - var requestBuilder = new RequestBuilder { + var requestBuilder = new RequestBuilder<{{#bodyParam}}{{{dataType}}}{{/bodyParam}}{{^bodyParam}}Any{{/bodyParam}}> { Method = new HttpMethod("{{httpMethod}}"), BasePath = _configuration.BasePath, PathTemplate = "{{path}}", PathParameters = pathParams, {{#bodyParam}} - Body = Utils.CreateJsonStringContent({{paramName}}), + Body = {{paramName}}, {{/bodyParam}} QueryParameters = queryParams, }; - {{#returnType}}return {{/returnType}}await this._apiClient.SendRequestAsync{{#returnType}}<{{{.}}}>{{/returnType}}(requestBuilder, + {{#returnType}}return {{/returnType}}await _apiClient.SendRequestAsync{{#returnType}}<{{#bodyParam}}{{{dataType}}}{{/bodyParam}}{{^bodyParam}}Any{{/bodyParam}}, {{{.}}}>{{/returnType}}(requestBuilder, "{{operationId}}", cancellationToken); } diff --git a/config/clients/dotnet/template/example/Example1/Example1.cs b/config/clients/dotnet/template/example/Example1/Example1.cs index f4e003d0..26bfc546 100644 --- a/config/clients/dotnet/template/example/Example1/Example1.cs +++ b/config/clients/dotnet/template/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/config/clients/dotnet/template/example/OpenTelemetryExample/.env.example b/config/clients/dotnet/template/example/OpenTelemetryExample/.env.example new file mode 100644 index 00000000..db7ea750 --- /dev/null +++ b/config/clients/dotnet/template/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/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs b/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs new file mode 100644 index 00000000..16eeb238 --- /dev/null +++ b/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs @@ -0,0 +1,276 @@ +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") + }; + } + + var configuration = new ClientConfiguration { + ApiUrl = + Environment.GetEnvironmentVariable("FGA_API_URL") ?? + "http://localhost:8080", // required, e.g. https://api.fga.example + StoreId = + Environment.GetEnvironmentVariable( + "FGA_STORE_ID"), // not needed when calling `CreateStore` or `ListStores` + AuthorizationModelId = + Environment.GetEnvironmentVariable("FGA_MODEL_ID"), // Optional, can be overridden per request + Credentials = credentials + }; + 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 + })); + 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/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.csproj b/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.csproj new file mode 100644 index 00000000..00bcfd0a --- /dev/null +++ b/config/clients/dotnet/template/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/config/clients/dotnet/template/netcore_project.mustache b/config/clients/dotnet/template/netcore_project.mustache index 894167db..506beb40 100644 --- a/config/clients/dotnet/template/netcore_project.mustache +++ b/config/clients/dotnet/template/netcore_project.mustache @@ -30,9 +30,13 @@ - - - + + + + + + + diff --git a/config/clients/dotnet/template/netcore_testproject.mustache b/config/clients/dotnet/template/netcore_testproject.mustache index 6816229c..65c9d1ed 100644 --- a/config/clients/dotnet/template/netcore_testproject.mustache +++ b/config/clients/dotnet/template/netcore_testproject.mustache @@ -11,8 +11,8 @@ all all - all - + all + all runtime; build; native; contentfiles; analyzers; buildtransitive