diff --git a/CHANGELOG.md b/CHANGELOG.md index 849ca0e..962fb54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## v0.3.0 ### [0.3.0](https://github.com/openfga/dotnet-sdk/compare/v0.2.5...v0.3.0) (2023-12-20) -- feat!: initial support for conditions +- feat!: initial support for [conditions](https://openfga.dev/blog/conditional-tuples-announcement) - feat!: allow overriding storeId per request (#33) - feat: support specifying a port and path for the API (You can now set the `ApiUrl` to something like: `https://api.fga.exampleL8080/some_path`) - feat: validate that store id and auth model id in ulid format (#23) @@ -12,6 +12,7 @@ - fix: `OpenFgaClient.Read` and `OpenFgaClient.ReadChanges` now allow a null body - chore!: use latest API interfaces - chore: dependency updates +- chore: add [example project](./example) BREAKING CHANGES: Note: This release comes with substantial breaking changes, especially to the interfaces due to the protobuf changes in the last release. diff --git a/docs/OpenFgaApi.md b/docs/OpenFgaApi.md index 61487e5..b4d20be 100644 --- a/docs/OpenFgaApi.md +++ b/docs/OpenFgaApi.md @@ -566,7 +566,7 @@ Name | Type | Description | Notes Get tuples from the store that matches a query, without following userset rewrite rules -The Read API will return the tuples for a certain store that match a query filter specified in the body of the request. It is different from the `/stores/{store_id}/expand` API in that it only returns relationship tuples that are stored in the system and satisfy the query. In the body: 1. `tuple_key` is optional. If not specified, it will return all tuples in the store. 2. `tuple_key.object` is mandatory if `tuple_key` is specified. It can be a full object (e.g., `type:object_id`) or type only (e.g., `type:`). 3. `tuple_key.user` is mandatory if tuple_key is specified in the case the `tuple_key.object` is a type only. ## Examples ### Query for all objects in a type definition To query for all objects that `user:bob` has `reader` relationship in the `document` type definition, call read API with body of ```json { \"tuple_key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:\" } } ``` The API will return tuples and a continuation token, something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `user:bob` has a `reader` relationship with 1 document `document:2021-budget`. Note that this API, unlike the List Objects API, does not evaluate the tuples in the store. The continuation token will be empty if there are no more tuples to query. ### Query for all stored relationship tuples that have a particular relation and object To query for all users that have `reader` relationship with `document:2021-budget`, call read API with body of ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" } } ``` The API will return something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `document:2021-budget` has 1 `reader` (`user:bob`). Note that, even if the model said that all `writers` are also `readers`, the API will not return writers such as `user:anne` because it only returns tuples and does not evaluate them. ### Query for all users with all relationships for a particular document To query for all users that have any relationship with `document:2021-budget`, call read API with body of ```json { \"tuple_key\": { \"object\": \"document:2021-budget\" } } ``` The API will return something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-05T13:42:12.356Z\" }, { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `document:2021-budget` has 1 `reader` (`user:bob`) and 1 `writer` (`user:anne`). +The Read API will return the tuples for a certain store that match a query filter specified in the body of the request. The API doesn't guarantee order by any field. It is different from the `/stores/{store_id}/expand` API in that it only returns relationship tuples that are stored in the system and satisfy the query. In the body: 1. `tuple_key` is optional. If not specified, it will return all tuples in the store. 2. `tuple_key.object` is mandatory if `tuple_key` is specified. It can be a full object (e.g., `type:object_id`) or type only (e.g., `type:`). 3. `tuple_key.user` is mandatory if tuple_key is specified in the case the `tuple_key.object` is a type only. ## Examples ### Query for all objects in a type definition To query for all objects that `user:bob` has `reader` relationship in the `document` type definition, call read API with body of ```json { \"tuple_key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:\" } } ``` The API will return tuples and a continuation token, something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `user:bob` has a `reader` relationship with 1 document `document:2021-budget`. Note that this API, unlike the List Objects API, does not evaluate the tuples in the store. The continuation token will be empty if there are no more tuples to query. ### Query for all stored relationship tuples that have a particular relation and object To query for all users that have `reader` relationship with `document:2021-budget`, call read API with body of ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" } } ``` The API will return something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `document:2021-budget` has 1 `reader` (`user:bob`). Note that, even if the model said that all `writers` are also `readers`, the API will not return writers such as `user:anne` because it only returns tuples and does not evaluate them. ### Query for all users with all relationships for a particular document To query for all users that have any relationship with `document:2021-budget`, call read API with body of ```json { \"tuple_key\": { \"object\": \"document:2021-budget\" } } ``` The API will return something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-05T13:42:12.356Z\" }, { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `document:2021-budget` has 1 `reader` (`user:bob`) and 1 `writer` (`user:anne`). ### Example ```csharp diff --git a/example/Makefile b/example/Makefile index 1a906e0..9976d58 100644 --- a/example/Makefile +++ b/example/Makefile @@ -14,4 +14,4 @@ run: run-openfga: docker pull docker.io/openfga/openfga:${openfga_version} && \ - docker run -p 8080:8080 docker.io/openfga/openfga:${openfga_version} + docker run -p 8080:8080 docker.io/openfga/openfga:${openfga_version} run diff --git a/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs b/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs index 395f031..73d60f0 100644 --- a/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs +++ b/src/OpenFga.Sdk.Test/Api/OpenFgaApiTests.cs @@ -304,7 +304,8 @@ public async Task ExchangeCredentialsTest() { "SendAsync", ItExpr.Is(req => req.RequestUri == new Uri($"https://{config.Credentials.Config.ApiTokenIssuer}/oauth/token") && - req.Method == HttpMethod.Post), + req.Method == HttpMethod.Post && + req.Content.Headers.ContentType.ToString().Equals("application/x-www-form-urlencoded")), ItExpr.IsAny() ) .ReturnsAsync(new HttpResponseMessage() { @@ -358,7 +359,8 @@ public async Task ExchangeCredentialsTest() { Times.Exactly(1), ItExpr.Is(req => req.RequestUri == new Uri($"https://{config.Credentials.Config.ApiTokenIssuer}/oauth/token") && - req.Method == HttpMethod.Post), + req.Method == HttpMethod.Post && + req.Content.Headers.ContentType.ToString().Equals("application/x-www-form-urlencoded")), ItExpr.IsAny() ); mockHandler.Protected().Verify( @@ -401,7 +403,8 @@ public async Task ExchangeCredentialsAfterExpiryTest() { "SendAsync", ItExpr.Is(req => req.RequestUri == new Uri($"https://{config.Credentials.Config.ApiTokenIssuer}/oauth/token") && - req.Method == HttpMethod.Post), + req.Method == HttpMethod.Post && + req.Content.Headers.ContentType.ToString().Equals("application/x-www-form-urlencoded")), ItExpr.IsAny() ) .ReturnsAsync(new HttpResponseMessage() { @@ -455,7 +458,8 @@ public async Task ExchangeCredentialsAfterExpiryTest() { Times.Exactly(2), ItExpr.Is(req => req.RequestUri == new Uri($"https://{config.Credentials.Config.ApiTokenIssuer}/oauth/token") && - req.Method == HttpMethod.Post), + req.Method == HttpMethod.Post && + req.Content.Headers.ContentType.ToString().Equals("application/x-www-form-urlencoded")), ItExpr.IsAny() ); mockHandler.Protected().Verify( diff --git a/src/OpenFga.Sdk/Api/OpenFgaApi.cs b/src/OpenFga.Sdk/Api/OpenFgaApi.cs index de5eade..77a4fb3 100644 --- a/src/OpenFga.Sdk/Api/OpenFgaApi.cs +++ b/src/OpenFga.Sdk/Api/OpenFgaApi.cs @@ -248,7 +248,7 @@ public async Task ListObjects(string storeId, ListObjectsRe } /// - /// Get tuples from the store that matches a query, without following userset rewrite rules The Read API will return the tuples for a certain store that match a query filter specified in the body of the request. It is different from the `/stores/{store_id}/expand` API in that it only returns relationship tuples that are stored in the system and satisfy the query. In the body: 1. `tuple_key` is optional. If not specified, it will return all tuples in the store. 2. `tuple_key.object` is mandatory if `tuple_key` is specified. It can be a full object (e.g., `type:object_id`) or type only (e.g., `type:`). 3. `tuple_key.user` is mandatory if tuple_key is specified in the case the `tuple_key.object` is a type only. ## Examples ### Query for all objects in a type definition To query for all objects that `user:bob` has `reader` relationship in the `document` type definition, call read API with body of ```json { \"tuple_key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:\" } } ``` The API will return tuples and a continuation token, something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `user:bob` has a `reader` relationship with 1 document `document:2021-budget`. Note that this API, unlike the List Objects API, does not evaluate the tuples in the store. The continuation token will be empty if there are no more tuples to query. ### Query for all stored relationship tuples that have a particular relation and object To query for all users that have `reader` relationship with `document:2021-budget`, call read API with body of ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" } } ``` The API will return something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `document:2021-budget` has 1 `reader` (`user:bob`). Note that, even if the model said that all `writers` are also `readers`, the API will not return writers such as `user:anne` because it only returns tuples and does not evaluate them. ### Query for all users with all relationships for a particular document To query for all users that have any relationship with `document:2021-budget`, call read API with body of ```json { \"tuple_key\": { \"object\": \"document:2021-budget\" } } ``` The API will return something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-05T13:42:12.356Z\" }, { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `document:2021-budget` has 1 `reader` (`user:bob`) and 1 `writer` (`user:anne`). + /// Get tuples from the store that matches a query, without following userset rewrite rules The Read API will return the tuples for a certain store that match a query filter specified in the body of the request. The API doesn't guarantee order by any field. It is different from the `/stores/{store_id}/expand` API in that it only returns relationship tuples that are stored in the system and satisfy the query. In the body: 1. `tuple_key` is optional. If not specified, it will return all tuples in the store. 2. `tuple_key.object` is mandatory if `tuple_key` is specified. It can be a full object (e.g., `type:object_id`) or type only (e.g., `type:`). 3. `tuple_key.user` is mandatory if tuple_key is specified in the case the `tuple_key.object` is a type only. ## Examples ### Query for all objects in a type definition To query for all objects that `user:bob` has `reader` relationship in the `document` type definition, call read API with body of ```json { \"tuple_key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:\" } } ``` The API will return tuples and a continuation token, something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `user:bob` has a `reader` relationship with 1 document `document:2021-budget`. Note that this API, unlike the List Objects API, does not evaluate the tuples in the store. The continuation token will be empty if there are no more tuples to query. ### Query for all stored relationship tuples that have a particular relation and object To query for all users that have `reader` relationship with `document:2021-budget`, call read API with body of ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" } } ``` The API will return something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `document:2021-budget` has 1 `reader` (`user:bob`). Note that, even if the model said that all `writers` are also `readers`, the API will not return writers such as `user:anne` because it only returns tuples and does not evaluate them. ### Query for all users with all relationships for a particular document To query for all users that have any relationship with `document:2021-budget`, call read API with body of ```json { \"tuple_key\": { \"object\": \"document:2021-budget\" } } ``` The API will return something like ```json { \"tuples\": [ { \"key\": { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-05T13:42:12.356Z\" }, { \"key\": { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"timestamp\": \"2021-10-06T15:32:11.128Z\" } ], \"continuation_token\": \"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==\" } ``` This means that `document:2021-budget` has 1 `reader` (`user:bob`) and 1 `writer` (`user:anne`). /// /// Thrown when fails to make API call /// diff --git a/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs b/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs index 47c7eed..6f2b2f7 100644 --- a/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs +++ b/src/OpenFga.Sdk/ApiClient/OAuth2Client.cs @@ -28,13 +28,6 @@ private const int private static readonly Random _random = new(); - private class AuthRequestBody { - [JsonPropertyName("audience")] public string? Audience { get; set; } - [JsonPropertyName("client_id")] public string? ClientId { get; set; } - [JsonPropertyName("client_secret")] public string? ClientSecret { get; set; } - [JsonPropertyName("grant_type")] public string? GrantType { get; set; } - } - /// /// Credentials Flow Response /// @@ -78,7 +71,7 @@ public bool IsValid() { private readonly BaseClient _httpClient; private AuthToken _authToken = new(); - private AuthRequestBody _authRequest { get; set; } + private IDictionary _authRequest { get; set; } private string _apiTokenIssuer { get; set; } #endregion @@ -102,11 +95,11 @@ public OAuth2Client(Credentials credentialsConfig, BaseClient httpClient) { this._httpClient = httpClient; this._apiTokenIssuer = credentialsConfig.Config.ApiTokenIssuer; - this._authRequest = new AuthRequestBody() { - ClientId = credentialsConfig.Config.ClientId, - ClientSecret = credentialsConfig.Config.ClientSecret, - Audience = credentialsConfig.Config.ApiAudience, - GrantType = "client_credentials" + this._authRequest = new Dictionary() { + { "client_id", credentialsConfig.Config.ClientId }, + { "client_secret", credentialsConfig.Config.ClientSecret }, + { "audience", credentialsConfig.Config.ApiAudience }, + { "grant_type", "client_credentials" } }; } @@ -120,7 +113,7 @@ private async Task ExchangeTokenAsync(CancellationToken cancellationToken = defa Method = HttpMethod.Post, BasePath = $"https://{this._apiTokenIssuer}", PathTemplate = "/oauth/token", - Body = Utils.CreateJsonStringContent(this._authRequest) + Body = Utils.CreateFormEncodedConent(this._authRequest), }; var accessTokenResponse = await _httpClient.SendRequestAsync( diff --git a/src/OpenFga.Sdk/ApiClient/Utils.cs b/src/OpenFga.Sdk/ApiClient/Utils.cs index 2a594da..52df18b 100644 --- a/src/OpenFga.Sdk/ApiClient/Utils.cs +++ b/src/OpenFga.Sdk/ApiClient/Utils.cs @@ -26,6 +26,11 @@ public static HttpContent CreateJsonStringContent(T body) { return new StringContent(json, Encoding.UTF8, "application/json"); } + public static HttpContent CreateFormEncodedConent(IDictionary parameters) { + return new FormUrlEncodedContent(parameters.Select(p => + new KeyValuePair(p.Key, p.Value ?? ""))); + } + public static string BuildQueryParams(IDictionary parameters) { var query = ""; foreach (var parameter in parameters) {