diff --git a/CHANGELOG.md b/CHANGELOG.md index ac49439b..844d07e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,25 @@ ## [Unreleased](https://github.com/openfga/dotnet-sdk/compare/v0.9.1...HEAD) ### Added +- feat: add ApiExecutor for raw requests (#176) - feat: add `FromJson()` methods to `ClientWriteAuthorizationModelRequest` and `ClientCreateStoreRequest` to enable loading from JSON string (#180) - feat: report a per call HTTP metric (#173) +### Breaking Changes + +> [!WARNING] +> - **`ApiClient.SendRequestAsync` removed**: If you were calling `SendRequestAsync` directly on `ApiClient`, switch to `ApiExecutor.ExecuteAsync`: +> +> Before: +> ```csharp +> var result = await apiClient.SendRequestAsync(requestBuilder, "ApiName"); +> ``` +> +> After: +> ```csharp +> var result = (await apiClient.ApiExecutor.ExecuteAsync(requestBuilder, "ApiName")).Data; +> ``` + ## v0.9.1 ### [0.9.1](https://github.com/openfga/dotnet-sdk/compare/v0.9.0...v0.9.1) (2026-01-26) diff --git a/README.md b/README.md index 948bbaa1..6805d83e 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ This is an autogenerated SDK for OpenFGA. It provides a wrapper around the [Open - [Assertions](#assertions) - [Read Assertions](#read-assertions) - [Write Assertions](#write-assertions) + - [Calling Other Endpoints](#calling-other-endpoints) - [Retries](#retries) - [API Endpoints](#api-endpoints) - [Models](#models) @@ -191,6 +192,10 @@ namespace Example { Credentials = new Credentials() { Method = CredentialsMethod.ClientCredentials, Config = new CredentialsConfig() { + // API Token Issuer can contain: + // - a scheme, defaults to https + // - a path, defaults to /oauth/token + // - a port ApiTokenIssuer = Environment.GetEnvironmentVariable("FGA_API_TOKEN_ISSUER"), ApiAudience = Environment.GetEnvironmentVariable("FGA_API_AUDIENCE"), ClientId = Environment.GetEnvironmentVariable("FGA_CLIENT_ID"), @@ -965,6 +970,110 @@ var body = new List() {new ClientAssertion() { await fgaClient.WriteAssertions(body, options); ``` +### Calling Other Endpoints + +For advanced use cases where you need to call API endpoints not yet available in the SDK's typed methods, or when you need access to full response details (status code, headers, raw response), you can use `ApiClient.ExecuteAsync()`. + +#### Basic Usage + +```csharp +using OpenFga.Sdk.ApiClient; + +var client = new OpenFgaClient(configuration); +var executor = client.ApiExecutor; + +// Build a request using RequestBuilder +var request = new RequestBuilder { + Method = HttpMethod.Get, + BasePath = configuration.ApiUrl, + PathTemplate = "/stores/{store_id}", + PathParameters = new Dictionary { { "store_id", storeId } }, + QueryParameters = new Dictionary() +}; + +// Execute and get full response details +var response = await executor.ExecuteAsync(request, "GetStore"); + +// Always check if the request was successful +if (!response.IsSuccessful) { + Console.WriteLine($"Request failed: {response.StatusCode}"); + Console.WriteLine($"Error: {response.RawResponse}"); + return; +} + +// Access response details +Console.WriteLine($"Status: {response.StatusCode}"); +Console.WriteLine($"Headers: {response.Headers.Count}"); +Console.WriteLine($"Raw JSON: {response.RawResponse}"); +Console.WriteLine($"Data: {response.Data.Name}"); +``` + +#### Fluent API Style + +You can also use a fluent API for cleaner request building: + +```csharp +var request = RequestBuilder + .Create(HttpMethod.Post, configuration.ApiUrl, "/stores/{store_id}/check") + .WithPathParameter("store_id", storeId) + .WithQueryParameter("consistency", "HIGHER_CONSISTENCY") + .WithBody(checkRequest); + +var response = await executor.ExecuteAsync(request, "Check"); +``` + +#### Custom Headers + +```csharp +var options = new ClientRequestOptions { + Headers = new Dictionary { + { "X-Custom-Header", "value" }, + { "X-Trace-Id", traceId } + } +}; + +var response = await executor.ExecuteAsync( + request, + "CustomEndpoint", + options +); +``` + +#### Error Handling + +Always check `response.IsSuccessful` and handle different status codes appropriately: + +```csharp +var response = await executor.ExecuteAsync(request, "GetStore"); + +if (!response.IsSuccessful) +{ + switch ((int)response.StatusCode) + { + case 404: + Console.WriteLine("Store not found"); + break; + case 401: + Console.WriteLine("Unauthorized - check your credentials"); + break; + case 429: + Console.WriteLine("Rate limited - retry after delay"); + break; + case >= 500: + Console.WriteLine($"Server error: {response.RawResponse}"); + break; + default: + Console.WriteLine($"Request failed: {response.StatusCode}"); + break; + } + return; +} + +// Safe to use response.Data here +Console.WriteLine($"Store Name: {response.Data.Name}"); +``` + +For a complete example with all features, see the [ApiExecutor Example](./example/ApiExecutorExample/). ### Retries diff --git a/example/ApiExecutorExample/.gitignore b/example/ApiExecutorExample/.gitignore new file mode 100644 index 00000000..2789d716 --- /dev/null +++ b/example/ApiExecutorExample/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +*.user +*.suo +.vs/ diff --git a/example/ApiExecutorExample/ApiExecutorExample.csproj b/example/ApiExecutorExample/ApiExecutorExample.csproj new file mode 100644 index 00000000..8cc38eca --- /dev/null +++ b/example/ApiExecutorExample/ApiExecutorExample.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + enable + enable + Linux + + + + + + + + diff --git a/example/ApiExecutorExample/Makefile b/example/ApiExecutorExample/Makefile new file mode 100644 index 00000000..624eb94f --- /dev/null +++ b/example/ApiExecutorExample/Makefile @@ -0,0 +1,46 @@ +.PHONY: help start-openfga stop-openfga run run-all clean + +help: + @echo "ApiExecutor Example - Makefile Commands" + @echo "" + @echo " make start-openfga - Start OpenFGA server in Docker on port 8080" + @echo " make stop-openfga - Stop the OpenFGA Docker container" + @echo " make run - Run the ApiExecutor example (requires OpenFGA running)" + @echo " make run-all - Start OpenFGA, run example, then stop OpenFGA" + @echo " make clean - Clean build artifacts" + @echo "" + +# Start OpenFGA server in Docker +start-openfga: + @echo "Starting OpenFGA server on localhost:8080..." + @docker run -d --name openfga-example -p 8080:8080 openfga/openfga:latest run + @echo "Waiting for OpenFGA to be ready..." + @sleep 3 + @curl -s http://localhost:8080/healthz || (echo "OpenFGA failed to start" && exit 1) + @echo "āœ… OpenFGA is ready!" + +# Stop OpenFGA server +stop-openfga: + @echo "Stopping OpenFGA server..." + @docker stop openfga-example 2>/dev/null || true + @docker rm openfga-example 2>/dev/null || true + @echo "āœ… OpenFGA stopped" + +# Run the example +run: + @echo "Running ApiExecutor example..." + @dotnet run --project ApiExecutorExample.csproj + +# Run everything: start server, run example, stop server +run-all: start-openfga + @echo "" + @$(MAKE) run + @echo "" + @$(MAKE) stop-openfga + +# Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + @dotnet clean + @rm -rf bin obj + @echo "āœ… Clean complete" diff --git a/example/ApiExecutorExample/Program.cs b/example/ApiExecutorExample/Program.cs new file mode 100644 index 00000000..109b5e79 --- /dev/null +++ b/example/ApiExecutorExample/Program.cs @@ -0,0 +1,409 @@ +using OpenFga.Sdk.ApiClient; +using OpenFga.Sdk.Client; +using OpenFga.Sdk.Client.Model; +using OpenFga.Sdk.Configuration; +using OpenFga.Sdk.Model; +using System.Net.Http; + +namespace ApiExecutorExample; + +/// +/// This example demonstrates how to use ApiExecutor to make custom HTTP requests +/// to the OpenFGA API with full response details (status code, headers, raw response, typed data). +/// +/// Prerequisites: Run an OpenFGA server on localhost:8080 +/// docker run -p 8080:8080 openfga/openfga:latest run +/// +class Program { + static async Task Main(string[] args) { + Console.WriteLine("=== OpenFGA Custom API Requests Example ===\n"); + + // Configure client to connect to local OpenFGA instance + var config = new ClientConfiguration { + ApiUrl = "http://localhost:8080" + }; + + using var client = new OpenFgaClient(config); + var executor = client.ApiExecutor; // Get the ApiExecutor for custom requests + + try { + // Example 1: List stores using raw GET request + await ListStoresExample(executor, config.ApiUrl); + + // Example 2: Create a store using raw POST request with typed response + var storeId = await CreateStoreExample(executor, config.ApiUrl); + + // Example 3: Get store details using path parameters + await GetStoreExample(executor, config.ApiUrl, storeId); + + // Example 4: Create an authorization model + var modelId = await CreateAuthorizationModelExample(executor, config.ApiUrl, storeId); + + // Example 5: Write relationship tuples + await WriteTuplesExample(executor, config.ApiUrl, storeId); + + // Example 6: Read relationship tuples + await ReadTuplesExample(executor, config.ApiUrl, storeId); + + // Example 7: Check permissions + await CheckPermissionExample(executor, config.ApiUrl, storeId, modelId); + + // Example 8: Use raw JSON response instead of typed + await RawJsonResponseExample(executor, config.ApiUrl); + + // Example 9: Custom headers + await CustomHeadersExample(executor, config.ApiUrl); + + // Example 10: Fluent API for building requests + await FluentApiExample(executor, config.ApiUrl); + + // Cleanup: Delete the store we created + await DeleteStoreExample(executor, config.ApiUrl, storeId); + + Console.WriteLine("\n=== All examples completed successfully! ==="); + } catch (Exception ex) { + Console.WriteLine($"\nāŒ Error: {ex.Message}"); + Console.WriteLine("\nMake sure OpenFGA is running on localhost:8080"); + Console.WriteLine("Run: docker run -p 8080:8080 openfga/openfga:latest run"); + Environment.Exit(1); + } + } + + static async Task ListStoresExample(ApiExecutor executor, string basePath) { + Console.WriteLine("šŸ“‹ Example 1: List Stores"); + Console.WriteLine("Making GET request to /stores"); + + var request = new RequestBuilder { + Method = HttpMethod.Get, + BasePath = basePath, + PathTemplate = "/stores", + PathParameters = new Dictionary(), + QueryParameters = new Dictionary() + }; + + var response = await executor.ExecuteAsync(request, "ListStores"); + + Console.WriteLine($"āœ… Status: {response.StatusCode}"); + Console.WriteLine($" Is Successful: {response.IsSuccessful}"); + Console.WriteLine($" Found {response.Data.Stores?.Count ?? 0} store(s)"); + Console.WriteLine(); + } + + static async Task CreateStoreExample(ApiExecutor executor, string basePath) { + Console.WriteLine("šŸŖ Example 2: Create Store"); + Console.WriteLine("Making POST request to /stores"); + + var storeName = "ApiExecutor-Example-" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var requestBody = new Dictionary { + { "name", storeName } + }; + + var request = new RequestBuilder { + Method = HttpMethod.Post, + BasePath = basePath, + PathTemplate = "/stores", + PathParameters = new Dictionary(), + QueryParameters = new Dictionary(), + Body = requestBody + }; + + var response = await executor.ExecuteAsync(request, "CreateStore"); + + Console.WriteLine($"āœ… Status: {response.StatusCode}"); + Console.WriteLine($" Store ID: {response.Data.Id}"); + Console.WriteLine($" Store Name: {response.Data.Name}"); + Console.WriteLine($" Raw Response Length: {response.RawResponse.Length} chars"); + Console.WriteLine(); + + return response.Data.Id!; + } + + static async Task GetStoreExample(ApiExecutor executor, string basePath, string storeId) { + Console.WriteLine("šŸ” Example 3: Get Store Details"); + Console.WriteLine($"Making GET request to /stores/{{store_id}}"); + + var request = new RequestBuilder { + Method = HttpMethod.Get, + BasePath = basePath, + PathTemplate = "/stores/{store_id}", + PathParameters = new Dictionary { { "store_id", storeId } }, + QueryParameters = new Dictionary() + }; + + var response = await executor.ExecuteAsync(request, "GetStore"); + + Console.WriteLine($"āœ… Status: {response.StatusCode}"); + Console.WriteLine($" Store Name: {response.Data.Name}"); + Console.WriteLine($" Created At: {response.Data.CreatedAt}"); + Console.WriteLine($" Response Headers: {response.Headers.Count}"); + Console.WriteLine(); + } + + static async Task CreateAuthorizationModelExample(ApiExecutor executor, string basePath, string storeId) { + Console.WriteLine("šŸ“ Example 4: Create Authorization Model"); + Console.WriteLine("Making POST request to /stores/{store_id}/authorization-models"); + + var requestBody = new Dictionary { + { "schema_version", "1.1" }, + { + "type_definitions", new List> { + new() { + { "type", "user" }, + { "relations", new Dictionary() } + }, + new() { + { "type", "document" }, + { + "relations", new Dictionary { + { + "reader", new Dictionary { + { "this", new Dictionary() } + } + }, + { + "writer", new Dictionary { + { "this", new Dictionary() } + } + } + } + }, + { + "metadata", new Dictionary { + { + "relations", new Dictionary { + { + "reader", new Dictionary { + { + "directly_related_user_types", new List> { + new() { { "type", "user" } } + } + } + } + }, + { + "writer", new Dictionary { + { + "directly_related_user_types", new List> { + new() { { "type", "user" } } + } + } + } + } + } + } + } + } + } + } + } + }; + + var request = new RequestBuilder { + Method = HttpMethod.Post, + BasePath = basePath, + PathTemplate = "/stores/{store_id}/authorization-models", + PathParameters = new Dictionary { { "store_id", storeId } }, + QueryParameters = new Dictionary(), + Body = requestBody + }; + + var response = await executor.ExecuteAsync(request, "WriteAuthorizationModel"); + + Console.WriteLine($"āœ… Status: {response.StatusCode}"); + Console.WriteLine($" Model ID: {response.Data.AuthorizationModelId}"); + Console.WriteLine(); + + return response.Data.AuthorizationModelId!; + } + + static async Task WriteTuplesExample(ApiExecutor executor, string basePath, string storeId) { + Console.WriteLine("āœļø Example 5: Write Relationship Tuples"); + Console.WriteLine("Making POST request to /stores/{store_id}/write"); + + var requestBody = new Dictionary { + { + "writes", new Dictionary { + { + "tuple_keys", new List> { + new() { + { "user", "user:alice" }, + { "relation", "writer" }, + { "object", "document:roadmap" } + }, + new() { + { "user", "user:bob" }, + { "relation", "reader" }, + { "object", "document:roadmap" } + } + } + } + } + } + }; + + var request = new RequestBuilder { + Method = HttpMethod.Post, + BasePath = basePath, + PathTemplate = "/stores/{store_id}/write", + PathParameters = new Dictionary { { "store_id", storeId } }, + QueryParameters = new Dictionary(), + Body = requestBody + }; + + var response = await executor.ExecuteAsync(request, "Write"); + + Console.WriteLine($"āœ… Status: {response.StatusCode}"); + Console.WriteLine(" Tuples written successfully"); + Console.WriteLine(); + } + + static async Task ReadTuplesExample(ApiExecutor executor, string basePath, string storeId) { + Console.WriteLine("šŸ“– Example 6: Read Relationship Tuples"); + Console.WriteLine("Making POST request to /stores/{store_id}/read"); + + var requestBody = new Dictionary { + { + "tuple_key", new Dictionary { + { "object", "document:roadmap" } + } + } + }; + + var request = new RequestBuilder { + Method = HttpMethod.Post, + BasePath = basePath, + PathTemplate = "/stores/{store_id}/read", + PathParameters = new Dictionary { { "store_id", storeId } }, + QueryParameters = new Dictionary(), + Body = requestBody + }; + + var response = await executor.ExecuteAsync(request, "Read"); + + Console.WriteLine($"āœ… Status: {response.StatusCode}"); + Console.WriteLine($" Found {response.Data.Tuples?.Count ?? 0} tuple(s):"); + if (response.Data.Tuples != null) { + foreach (var tuple in response.Data.Tuples) { + Console.WriteLine($" - {tuple.Key.User} is {tuple.Key.Relation} of {tuple.Key.Object}"); + } + } + Console.WriteLine(); + } + + static async Task CheckPermissionExample(ApiExecutor executor, string basePath, string storeId, string modelId) { + Console.WriteLine("šŸ” Example 7: Check Permission"); + Console.WriteLine("Making POST request to /stores/{store_id}/check"); + + var requestBody = new Dictionary { + { "authorization_model_id", modelId }, + { + "tuple_key", new Dictionary { + { "user", "user:alice" }, + { "relation", "writer" }, + { "object", "document:roadmap" } + } + } + }; + + var request = new RequestBuilder { + Method = HttpMethod.Post, + BasePath = basePath, + PathTemplate = "/stores/{store_id}/check", + PathParameters = new Dictionary { { "store_id", storeId } }, + QueryParameters = new Dictionary(), + Body = requestBody + }; + + var response = await executor.ExecuteAsync(request, "Check"); + + Console.WriteLine($"āœ… Status: {response.StatusCode}"); + Console.WriteLine($" Allowed: {response.Data.Allowed}"); + Console.WriteLine(); + } + + static async Task RawJsonResponseExample(ApiExecutor executor, string basePath) { + Console.WriteLine("šŸ“„ Example 8: Raw JSON Response"); + Console.WriteLine("Getting response as raw JSON string instead of typed object"); + + var request = new RequestBuilder { + Method = HttpMethod.Get, + BasePath = basePath, + PathTemplate = "/stores", + PathParameters = new Dictionary(), + QueryParameters = new Dictionary { { "page_size", "5" } } + }; + + // Use ExecuteAsync without second type parameter to get raw JSON string + var response = await executor.ExecuteAsync(request, "ListStores"); + + Console.WriteLine($"āœ… Status: {response.StatusCode}"); + Console.WriteLine($" Raw JSON (first 100 chars): {response.Data?.Substring(0, Math.Min(100, response.Data.Length))}..."); + Console.WriteLine($" RawResponse and Data are the same: {response.RawResponse == response.Data}"); + Console.WriteLine(); + } + + static async Task CustomHeadersExample(ApiExecutor executor, string basePath) { + Console.WriteLine("šŸ“Ø Example 9: Custom Headers"); + Console.WriteLine("Making request with custom headers"); + + var request = new RequestBuilder { + Method = HttpMethod.Get, + BasePath = basePath, + PathTemplate = "/stores", + PathParameters = new Dictionary(), + QueryParameters = new Dictionary() + }; + + // Pass custom headers via ClientRequestOptions + var options = new ClientRequestOptions { + Headers = new Dictionary { + { "X-Custom-Header", "example-value" }, + { "X-Request-ID", Guid.NewGuid().ToString() } + } + }; + + var response = await executor.ExecuteAsync(request, "ListStores", options); + + Console.WriteLine($"āœ… Status: {response.StatusCode}"); + Console.WriteLine(" Custom headers sent successfully"); + Console.WriteLine($" Response has {response.Headers.Count} headers"); + Console.WriteLine(); + } + + static async Task FluentApiExample(ApiExecutor executor, string basePath) { + Console.WriteLine("šŸŽÆ Example 10: Fluent API for Request Building"); + Console.WriteLine("Using the enhanced RequestBuilder with fluent methods"); + + // Use the new fluent API - much cleaner! + var request = RequestBuilder + .Create(HttpMethod.Get, basePath, "/stores") + .WithQueryParameter("page_size", "10") + .WithQueryParameter("continuation_token", ""); + + var response = await executor.ExecuteAsync(request, "ListStores"); + + Console.WriteLine($"āœ… Status: {response.StatusCode}"); + Console.WriteLine($" Found {response.Data.Stores?.Count ?? 0} store(s) using fluent API"); + Console.WriteLine(" Note: Fluent API provides better validation and cleaner syntax!"); + Console.WriteLine(); + } + + static async Task DeleteStoreExample(ApiExecutor executor, string basePath, string storeId) { + Console.WriteLine("šŸ—‘ļø Cleanup: Delete Store"); + Console.WriteLine($"Making DELETE request to /stores/{{store_id}}"); + + var request = new RequestBuilder { + Method = HttpMethod.Delete, + BasePath = basePath, + PathTemplate = "/stores/{store_id}", + PathParameters = new Dictionary { { "store_id", storeId } }, + QueryParameters = new Dictionary() + }; + + var response = await executor.ExecuteAsync(request, "DeleteStore"); + + Console.WriteLine($"āœ… Status: {response.StatusCode}"); + Console.WriteLine(" Store deleted successfully"); + Console.WriteLine(); + } +} diff --git a/example/ApiExecutorExample/README.md b/example/ApiExecutorExample/README.md new file mode 100644 index 00000000..20ff0264 --- /dev/null +++ b/example/ApiExecutorExample/README.md @@ -0,0 +1,324 @@ +# Custom API Requests Example + +This example demonstrates how to use `ApiExecutor` to make custom HTTP requests to the OpenFGA API with full response details (status code, headers, raw response, typed data). This is useful when you need more control over HTTP requests or want to call endpoints that aren't yet supported by the SDK's typed API. + +## What This Example Demonstrates + +1. **List Stores** - Making a GET request to list all stores +2. **Create Store** - Making a POST request with a typed response +3. **Get Store** - Using path parameters to get store details +4. **Create Authorization Model** - Creating an authorization model with custom request body +5. **Write Tuples** - Writing relationship tuples +6. **Read Tuples** - Reading relationship tuples with filters +7. **Check Permission** - Checking if a user has permission +8. **Raw JSON Response** - Getting responses as raw JSON strings +9. **Custom Headers** - Adding custom headers to requests via ClientRequestOptions +10. **Fluent API** - Using the enhanced RequestBuilder with fluent methods + +## Key Concepts + +### ApiClient.ExecuteAsync vs Standard SDK Methods + +The SDK provides two ways to interact with the OpenFGA API: + +1. **Standard SDK Methods** (recommended for most use cases): + ```csharp + var response = await client.Check(checkRequest); + ``` + +2. **Custom API Requests** (for advanced scenarios): + ```csharp + var executor = client.ApiExecutor; + var request = RequestBuilder.Create(...); + var response = await executor.ExecuteAsync(request, "Check"); + // Now you have: response.StatusCode, response.Headers, response.RawResponse, response.Data + ``` + +### RequestBuilder: Two Styles + +You can build requests using either style: + +**Object Initializer** (compatible with existing patterns): +```csharp +var request = new RequestBuilder { + Method = HttpMethod.Post, + BasePath = config.ApiUrl, + PathTemplate = "/stores/{store_id}/check", + PathParameters = new Dictionary { { "store_id", storeId } }, + QueryParameters = new Dictionary(), + Body = requestBody +}; +``` + +**Fluent API** (enhanced developer experience): +```csharp +var request = RequestBuilder + .Create(HttpMethod.Post, config.ApiUrl, "/stores/{store_id}/check") + .WithPathParameter("store_id", storeId) + .WithQueryParameter("timeout", "30s") + .WithBody(requestBody); +``` + +## Prerequisites + +- .NET 8.0 SDK or later +- Docker (for running OpenFGA server) + +## Quick Start + +### Option 1: Using Make (Recommended) + +The easiest way to run this example: + +```bash +# Show available commands +make help + +# Run everything (start OpenFGA, run example, stop OpenFGA) +make run-all + +# Or run step by step: +make start-openfga # Start OpenFGA in Docker +make run # Run the example +make stop-openfga # Stop OpenFGA when done +``` + +### Option 2: Manual Setup + +1. **Start OpenFGA Server:** + ```bash + docker run -d --name openfga-example -p 8080:8080 openfga/openfga:latest run + ``` + +2. **Verify OpenFGA is running:** + ```bash + curl http://localhost:8080/healthz + # Should return: {"status":"SERVING"} + ``` + +3. **Run the example:** + ```bash + dotnet run + ``` + +4. **Stop OpenFGA when done:** + ```bash + docker stop openfga-example && docker rm openfga-example + ``` + +## Example Output + +```text +=== OpenFGA Custom API Requests Example === + +šŸ“‹ Example 1: List Stores +Making GET request to /stores +āœ… Status: OK + Is Successful: True + Found 0 store(s) + +šŸŖ Example 2: Create Store +Making POST request to /stores +āœ… Status: Created + Store ID: 01JQWXYZ123ABC456DEF789GHJ + Store Name: ApiExecutor-Example-1738713600000 + Raw Response Length: 245 chars + +šŸ” Example 3: Get Store Details +Making GET request to /stores/{store_id} +āœ… Status: OK + Store Name: ApiExecutor-Example-1738713600000 + Created At: 2025-02-04T10:00:00Z + Response Headers: 8 + +šŸ“ Example 4: Create Authorization Model +Making POST request to /stores/{store_id}/authorization-models +āœ… Status: Created + Model ID: 01JQWXYZ789DEF123ABC456GHJ + +āœļø Example 5: Write Relationship Tuples +Making POST request to /stores/{store_id}/write +āœ… Status: OK + Tuples written successfully + +šŸ“– Example 6: Read Relationship Tuples +Making POST request to /stores/{store_id}/read +āœ… Status: OK + Found 2 tuple(s): + - user:alice is writer of document:roadmap + - user:bob is reader of document:roadmap + +šŸ” Example 7: Check Permission +Making POST request to /stores/{store_id}/check +āœ… Status: OK + Allowed: True + +šŸ“„ Example 8: Raw JSON Response +Getting response as raw JSON string instead of typed object +āœ… Status: OK + Raw JSON (first 100 chars): {"stores":[],"continuation_token":""}... + RawResponse and Data are the same: True + +šŸ“Ø Example 9: Custom Headers +Making request with custom headers +āœ… Status: OK + Custom headers sent successfully + Response has 8 headers + +šŸŽÆ Example 10: Fluent API for Request Building +Using the enhanced RequestBuilder with fluent methods +āœ… Status: OK + Found 0 store(s) using fluent API + Note: Fluent API provides better validation and cleaner syntax! + +šŸ—‘ļø Cleanup: Delete Store +Making DELETE request to /stores/{store_id} +āœ… Status: NoContent + Store deleted successfully + +=== All examples completed successfully! === +``` + +## Architecture + +### How It Works + +``` +OpenFgaClient + └── GetApiClient() → ApiClient (core building block) + └── ExecuteAsync() → Full response details + ā”œā”€ā”€ Authentication (OAuth/ApiToken) + ā”œā”€ā”€ Retry logic with exponential backoff + ā”œā”€ā”€ Error handling + └── Metrics & telemetry + +RequestBuilder + ā”œā”€ā”€ Object initializer (existing style) + └── Fluent API (enhanced style) +``` + +### Benefits of ExecuteAsync + +1. **Full Response Access** + - `response.StatusCode` - HTTP status code + - `response.Headers` - All response headers + - `response.RawResponse` - Raw JSON string + - `response.Data` - Strongly-typed response object + - `response.IsSuccessful` - Quick success check + +2. **Shared Infrastructure** + - Same authentication as standard SDK methods + - Same retry logic with exponential backoff + - Same error handling and exceptions + - Same metrics and telemetry + +3. **Flexibility** + - Call any OpenFGA endpoint + - Add custom headers via `ClientRequestOptions` + - Get raw JSON or strongly-typed responses + - Use path and query parameters easily + +## When to Use Custom API Requests + +### Use Standard SDK Methods When: +- The operation is available in the SDK (Check, Write, Read, etc.) +- You don't need access to response headers +- You want the simplest API + +### Use ApiClient.ExecuteAsync When: +- Calling endpoints not yet in the SDK +- You need response headers or status codes +- Building custom integrations +- Need fine-grained control over requests +- Working with experimental API features + +## Code Examples + +### Basic Request +```csharp +using OpenFga.Sdk.ApiClient; +using OpenFga.Sdk.Client; +using OpenFga.Sdk.Configuration; +using OpenFga.Sdk.Model; +// Optional: Use an alias to avoid namespace/class name conflicts +using FgaApiClient = OpenFga.Sdk.ApiClient.ApiClient; + +var client = new OpenFgaClient(config); +var executor = client.ApiExecutor; + +var request = new RequestBuilder { + Method = HttpMethod.Get, + BasePath = config.ApiUrl, + PathTemplate = "/stores", + PathParameters = new Dictionary(), + QueryParameters = new Dictionary() +}; + +var response = await executor.ExecuteAsync( + request, + "ListStores" +); + +Console.WriteLine($"Status: {response.StatusCode}"); +Console.WriteLine($"Stores: {response.Data.Stores.Count}"); +``` + +> **Note:** If you get namespace conflicts with `ApiClient`, you can use a type alias: +> ```csharp +> using FgaApiClient = OpenFga.Sdk.ApiClient.ApiClient; +> ``` +> Then use `FgaApiClient` as the type in your method signatures. + +### Fluent API Style +```csharp +var request = RequestBuilder + .Create(HttpMethod.Post, config.ApiUrl, "/stores/{store_id}/check") + .WithPathParameter("store_id", storeId) + .WithQueryParameter("timeout", "30s") + .WithBody(new { + tuple_key = new { user = "user:anne", relation = "reader", @object = "doc:1" } + }); + +var response = await executor.ExecuteAsync( + request, + "Check", + new ClientRequestOptions { + Headers = new Dictionary { + { "X-Trace-Id", traceId } + } + } +); +``` + +### Raw JSON Response +```csharp +// When you want the raw JSON without deserialization +var response = await executor.ExecuteAsync(request, "CustomEndpoint"); +string json = response.Data; // Raw JSON string +``` + +## Resources + +- [OpenFGA API Documentation](https://openfga.dev/api) + +## Common Issues + +### OpenFGA Connection Failed + +If you see connection errors: +1. Verify OpenFGA is running: `curl http://localhost:8080/healthz` +2. Check Docker logs: `docker logs openfga-example` +3. Ensure port 8080 is not in use by another application + +### Build Errors + +Make sure you have .NET 8.0 SDK installed: +```bash +dotnet --version +``` + +## Learn More + +- [OpenFGA Documentation](https://openfga.dev/docs) +- [OpenFGA API Reference](https://openfga.dev/api/service) +- [OpenFGA SDK Documentation](https://github.com/openfga/dotnet-sdk) diff --git a/example/README.md b/example/README.md index 5f7c7a2b..301151ac 100644 --- a/example/README.md +++ b/example/README.md @@ -3,9 +3,19 @@ A set of Examples on how to call the OpenFGA .NET SDK ### Examples -Example 1: + +**Example 1:** A bare bones example. It creates a store, and runs a set of calls against it including creating a model, writing tuples and checking for access. +**StreamedListObjectsExample:** +Demonstrates how to use the StreamedListObjects API to efficiently list objects with streaming responses. + +**OpenTelemetryExample:** +Shows how to integrate OpenTelemetry for observability and tracing of OpenFGA SDK operations. + +**ApiExecutor Example:** +Demonstrates how to use the `ApiExecutor` to make custom HTTP requests to the OpenFGA API with full response details. This is useful when you need more control over requests or want to call endpoints not yet available in the SDK's typed API. Includes a Makefile for easy execution with a local OpenFGA server. + ### Running the Examples diff --git a/src/OpenFga.Sdk.Test/ApiClient/ApiClientTests.cs b/src/OpenFga.Sdk.Test/ApiClient/ApiClientTests.cs index 9ed1e3f7..9813111a 100644 --- a/src/OpenFga.Sdk.Test/ApiClient/ApiClientTests.cs +++ b/src/OpenFga.Sdk.Test/ApiClient/ApiClientTests.cs @@ -156,7 +156,7 @@ public async Task ApiClient_WithApiToken_SendsAuthorizationHeader() { Body = null }; - await apiClient.SendRequestAsync( + await apiClient.ApiExecutor.ExecuteAsync( requestBuilder, "ReadAuthorizationModels" ); @@ -237,7 +237,7 @@ public async Task ApiClient_WithOAuth_SendsAuthorizationHeader() { Body = null }; - await apiClient.SendRequestAsync( + await apiClient.ApiExecutor.ExecuteAsync( requestBuilder, "ReadAuthorizationModels" ); @@ -285,7 +285,7 @@ public async Task ApiClient_WithNoCredentials_SendsNoAuthorizationHeader() { Body = null }; - await apiClient.SendRequestAsync( + await apiClient.ApiExecutor.ExecuteAsync( requestBuilder, "ReadAuthorizationModels" ); @@ -365,7 +365,7 @@ public async Task ApiClient_WithApiToken_CustomHeadersInOptions() { } }; - await apiClient.SendRequestAsync( + await apiClient.ApiExecutor.ExecuteAsync( requestBuilder, "ReadAuthorizationModels", options diff --git a/src/OpenFga.Sdk.Test/Client/ApiExecutor/ApiExecutorTests.cs b/src/OpenFga.Sdk.Test/Client/ApiExecutor/ApiExecutorTests.cs new file mode 100644 index 00000000..0bcad9e1 --- /dev/null +++ b/src/OpenFga.Sdk.Test/Client/ApiExecutor/ApiExecutorTests.cs @@ -0,0 +1,524 @@ +using Moq; +using Moq.Protected; +using OpenFga.Sdk.ApiClient; +using OpenFga.Sdk.Client; +using OpenFga.Sdk.Client.Model; +using OpenFga.Sdk.Configuration; +using OpenFga.Sdk.Exceptions; +using OpenFga.Sdk.Model; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace OpenFga.Sdk.Test.Client.ApiExecutor; + +public class ApiExecutorTests : IDisposable { + private readonly string _storeId = "01H0H015178Y2V4CX10C2KGHF4"; + private readonly string _apiUrl = "https://api.fga.example"; + + public void Dispose() { + // Cleanup when everything is done. + } + + private (OpenFgaClient client, Mock handler) CreateMockClient( + HttpResponseMessage response, + Func? requestValidator = null) { + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + requestValidator != null + ? ItExpr.Is(req => requestValidator(req)) + : ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(response); + + var config = new ClientConfiguration { + ApiUrl = _apiUrl, + StoreId = _storeId + }; + var client = new OpenFgaClient(config, new HttpClient(mockHandler.Object)); + return (client, mockHandler); + } + + [Fact] + public async Task ExecuteAsync_ValidGetRequest_ReturnsSuccessResponse() { + // Arrange + var expectedResponse = new { id = _storeId, name = "test-store" }; + using var responseMessage = new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + JsonSerializer.Serialize(expectedResponse), + Encoding.UTF8, + "application/json") + }; + responseMessage.Headers.Add("X-Request-Id", "req-123"); + + var (client, _) = CreateMockClient(responseMessage, req => + req.Method == HttpMethod.Get && + req.RequestUri.ToString().Contains("/stores/" + _storeId)); + + var requestBuilder = new RequestBuilder { + Method = HttpMethod.Get, + BasePath = _apiUrl, + PathTemplate = "/stores/{store_id}", + PathParameters = new Dictionary { { "store_id", _storeId } }, + QueryParameters = new Dictionary() + }; + + // Act + var response = await client.ApiExecutor.ExecuteAsync(requestBuilder, "GetStore"); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.IsSuccessful); + Assert.NotNull(response.Data); + Assert.Contains("test-store", response.RawResponse); + Assert.True(response.Headers.ContainsKey("X-Request-Id")); + } + + [Fact] + public async Task ExecuteAsync_ValidPostRequest_ReturnsSuccessResponse() { + // Arrange + var requestBody = new { user = "user:anne", relation = "reader", @object = "document:2021-budget" }; + var expectedResponse = new { allowed = true }; + using var responseMessage = new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + JsonSerializer.Serialize(expectedResponse), + Encoding.UTF8, + "application/json") + }; + + var (client, _) = CreateMockClient(responseMessage, req => + req.Method == HttpMethod.Post && + req.RequestUri.ToString().Contains("/stores/" + _storeId + "/check")); + + var requestBuilder = new RequestBuilder { + Method = HttpMethod.Post, + BasePath = _apiUrl, + PathTemplate = "/stores/{store_id}/check", + PathParameters = new Dictionary { { "store_id", _storeId } }, + QueryParameters = new Dictionary(), + Body = requestBody + }; + + // Act + var response = await client.ApiExecutor.ExecuteAsync(requestBuilder, "Check"); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.IsSuccessful); + Assert.NotNull(response.Data); + } + + [Fact] + public async Task ExecuteAsync_WithPathParams_ReplacesInUrl() { + // Arrange + using var responseMessage = new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + + var (client, mockHandler) = CreateMockClient(responseMessage, req => + req.RequestUri.ToString().Contains("/stores/" + _storeId + "/check")); + + var requestBuilder = new RequestBuilder { + Method = HttpMethod.Post, + BasePath = _apiUrl, + PathTemplate = "/stores/{store_id}/check", + PathParameters = new Dictionary { { "store_id", _storeId } }, + QueryParameters = new Dictionary() + }; + + // Act + await client.ApiExecutor.ExecuteAsync(requestBuilder, "Check"); + + // Assert + mockHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.RequestUri.ToString().Contains("/stores/" + _storeId + "/check")), + ItExpr.IsAny() + ); + } + + [Fact] + public async Task ExecuteAsync_WithQueryParams_AppendsToUrl() { + // Arrange + using var responseMessage = new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("[]", Encoding.UTF8, "application/json") + }; + + var (client, mockHandler) = CreateMockClient(responseMessage, req => + req.RequestUri.ToString().Contains("page_size=20") && + req.RequestUri.ToString().Contains("continuation_token=abc123")); + + var requestBuilder = new RequestBuilder { + Method = HttpMethod.Get, + BasePath = _apiUrl, + PathTemplate = "/stores", + PathParameters = new Dictionary(), + QueryParameters = new Dictionary { + { "page_size", "20" }, + { "continuation_token", "abc123" } + } + }; + + // Act + await client.ApiExecutor.ExecuteAsync(requestBuilder, "ListStores"); + + // Assert + mockHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.RequestUri.ToString().Contains("page_size=20") && + req.RequestUri.ToString().Contains("continuation_token=abc123")), + ItExpr.IsAny() + ); + } + + [Fact] + public async Task ExecuteAsync_WithCustomHeaders_IncludesInRequest() { + // Arrange + using var responseMessage = new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + + var (client, mockHandler) = CreateMockClient(responseMessage); + + var requestBuilder = new RequestBuilder { + Method = HttpMethod.Post, + BasePath = _apiUrl, + PathTemplate = "/stores", + PathParameters = new Dictionary(), + QueryParameters = new Dictionary() + }; + + var options = new ClientRequestOptions { + Headers = new Dictionary { + { "X-Custom-Header", "custom-value" }, + { "X-Another-Header", "another-value" } + } + }; + + // Act + await client.ApiExecutor.ExecuteAsync(requestBuilder, "CreateStore", options); + + // Assert + mockHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Headers.Contains("X-Custom-Header") && + req.Headers.GetValues("X-Custom-Header").First() == "custom-value" && + req.Headers.Contains("X-Another-Header") && + req.Headers.GetValues("X-Another-Header").First() == "another-value"), + ItExpr.IsAny() + ); + } + + [Fact] + public async Task ExecuteAsync_WithBody_SerializesToJson() { + // Arrange + var requestBody = new { name = "test-store" }; + using var responseMessage = new HttpResponseMessage { + StatusCode = HttpStatusCode.Created, + Content = new StringContent( + JsonSerializer.Serialize(new { id = "new-store-123" }), + Encoding.UTF8, + "application/json") + }; + + var (client, mockHandler) = CreateMockClient(responseMessage); + + var requestBuilder = new RequestBuilder { + Method = HttpMethod.Post, + BasePath = _apiUrl, + PathTemplate = "/stores", + PathParameters = new Dictionary(), + QueryParameters = new Dictionary(), + Body = requestBody + }; + + // Act + await client.ApiExecutor.ExecuteAsync(requestBuilder, "CreateStore"); + + // Assert + mockHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Content != null && + req.Content.Headers.ContentType.MediaType == "application/json"), + ItExpr.IsAny() + ); + } + + [Fact] + public async Task ExecuteAsync_RawResponse_ReturnsJsonString() { + // Arrange + var expectedJson = "{\"id\":\"123\",\"name\":\"test\"}"; + using var responseMessage = new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(expectedJson, Encoding.UTF8, "application/json") + }; + + var (client, _) = CreateMockClient(responseMessage); + + var requestBuilder = new RequestBuilder { + Method = HttpMethod.Get, + BasePath = _apiUrl, + PathTemplate = "/stores/{store_id}", + PathParameters = new Dictionary { { "store_id", _storeId } }, + QueryParameters = new Dictionary() + }; + + // Act + var response = await client.ApiExecutor.ExecuteAsync(requestBuilder, "GetStore"); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expectedJson, response.Data); + Assert.Equal(expectedJson, response.RawResponse); + } + + [Fact] + public async Task ExecuteAsync_ApiError_ThrowsFgaApiError() { + // Arrange + var errorResponse = new { + code = "invalid_request", + message = "Invalid request parameters" + }; + using var responseMessage = new HttpResponseMessage { + StatusCode = HttpStatusCode.BadRequest, + Content = new StringContent( + JsonSerializer.Serialize(errorResponse), + Encoding.UTF8, + "application/json") + }; + + var (client, _) = CreateMockClient(responseMessage); + + var requestBuilder = new RequestBuilder { + Method = HttpMethod.Post, + BasePath = _apiUrl, + PathTemplate = "/stores/{store_id}/check", + PathParameters = new Dictionary { { "store_id", _storeId } }, + QueryParameters = new Dictionary() + }; + + // Act & Assert + await Assert.ThrowsAsync(async () => + await client.ApiExecutor.ExecuteAsync(requestBuilder, "Check")); + } + + [Fact] + public void GetApiClient_CalledMultipleTimes_ReturnsSameInstance() { + // Arrange + var config = new ClientConfiguration { + ApiUrl = _apiUrl, + StoreId = _storeId + }; + var client = new OpenFgaClient(config); + + // Act + var apiClient1 = client.ApiExecutor; + var apiClient2 = client.ApiExecutor; + + // Assert + Assert.Same(apiClient1, apiClient2); + } + + [Fact] + public async Task ExecuteAsync_TypedResponse_DeserializesCorrectly() { + // Arrange + var expectedResponse = new CheckResponse { + Allowed = true + }; + using var responseMessage = new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + JsonSerializer.Serialize(expectedResponse), + Encoding.UTF8, + "application/json") + }; + + var (client, _) = CreateMockClient(responseMessage); + + var requestBuilder = new RequestBuilder { + Method = HttpMethod.Post, + BasePath = _apiUrl, + PathTemplate = "/stores/{store_id}/check", + PathParameters = new Dictionary { { "store_id", _storeId } }, + QueryParameters = new Dictionary(), + Body = new { user = "user:anne", relation = "reader", @object = "document:test" } + }; + + // Act + var response = await client.ApiExecutor.ExecuteAsync(requestBuilder, "Check"); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Data); + Assert.True(response.Data.Allowed); + } + + [Fact] + public async Task ExecuteAsync_CancellationToken_CancelsRequest() { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); // Cancel immediately + + var config = new ClientConfiguration { + ApiUrl = _apiUrl, + StoreId = _storeId + }; + var client = new OpenFgaClient(config); + + var requestBuilder = new RequestBuilder { + Method = HttpMethod.Get, + BasePath = _apiUrl, + PathTemplate = "/stores", + PathParameters = new Dictionary(), + QueryParameters = new Dictionary() + }; + + // Act & Assert + await Assert.ThrowsAnyAsync(async () => + await client.ApiExecutor.ExecuteAsync(requestBuilder, "ListStores", null, cts.Token)); + } + + [Fact] + public async Task ExecuteAsync_WithCredentials_IncludesAuthorizationHeader() { + // Arrange + using var responseMessage = new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(responseMessage); + + var config = new ClientConfiguration { + ApiUrl = _apiUrl, + StoreId = _storeId, + Credentials = new Credentials { + Method = CredentialsMethod.ApiToken, + Config = new CredentialsConfig { + ApiToken = "test-token-123" + } + } + }; + var client = new OpenFgaClient(config, new HttpClient(mockHandler.Object)); + + var requestBuilder = new RequestBuilder { + Method = HttpMethod.Get, + BasePath = _apiUrl, + PathTemplate = "/stores", + PathParameters = new Dictionary(), + QueryParameters = new Dictionary() + }; + + // Act + await client.ApiExecutor.ExecuteAsync(requestBuilder, "ListStores"); + + // Assert + mockHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Headers.Authorization != null && + req.Headers.Authorization.Scheme == "Bearer" && + req.Headers.Authorization.Parameter == "test-token-123"), + ItExpr.IsAny() + ); + } + + [Fact] + public async Task ExecuteAsync_UsingFluentApi_WorksCorrectly() { + // Arrange + var expectedResponse = new { id = _storeId, name = "test-store" }; + using var responseMessage = new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + JsonSerializer.Serialize(expectedResponse), + Encoding.UTF8, + "application/json") + }; + + var (client, _) = CreateMockClient(responseMessage, req => + req.Method == HttpMethod.Get && + req.RequestUri.ToString().Contains("/stores/" + _storeId)); + + // Demonstrate the new fluent API inspired by ApiExecutorRequestBuilder + var requestBuilder = RequestBuilder + .Create(HttpMethod.Get, _apiUrl, "/stores/{store_id}") + .WithPathParameter("store_id", _storeId); + + // Act + var response = await client.ApiExecutor.ExecuteAsync(requestBuilder, "GetStore"); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.IsSuccessful); + Assert.NotNull(response.Data); + Assert.Contains("test-store", response.RawResponse); + } + + [Fact] + public async Task ExecuteAsync_FluentApiWithMultipleParams_WorksCorrectly() { + // Arrange + var requestBody = new { user = "user:anne", relation = "reader", @object = "document:2021-budget" }; + var expectedResponse = new { allowed = true }; + using var responseMessage = new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + JsonSerializer.Serialize(expectedResponse), + Encoding.UTF8, + "application/json") + }; + + var (client, _) = CreateMockClient(responseMessage, req => + req.Method == HttpMethod.Post && + req.RequestUri.ToString().Contains("/stores/" + _storeId + "/check") && + req.RequestUri.ToString().Contains("consistency=")); + + // Fluent API with method chaining + var requestBuilder = RequestBuilder + .Create(HttpMethod.Post, _apiUrl, "/stores/{store_id}/check") + .WithPathParameter("store_id", _storeId) + .WithQueryParameter("consistency", "HIGHER_CONSISTENCY") + .WithBody(requestBody); + + // Act + var response = await client.ApiExecutor.ExecuteAsync(requestBuilder, "Check"); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.IsSuccessful); + } +} \ No newline at end of file diff --git a/src/OpenFga.Sdk.Test/Client/ApiExecutor/ApiResponseTests.cs b/src/OpenFga.Sdk.Test/Client/ApiExecutor/ApiResponseTests.cs new file mode 100644 index 00000000..5731fa6e --- /dev/null +++ b/src/OpenFga.Sdk.Test/Client/ApiExecutor/ApiResponseTests.cs @@ -0,0 +1,169 @@ +using OpenFga.Sdk.ApiClient; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using Xunit; + +namespace OpenFga.Sdk.Test.Client.ApiExecutor; + +public class ApiResponseTests { + [Fact] + public void Constructor_ValidData_SetsAllProperties() { + // Arrange + var statusCode = HttpStatusCode.OK; + var headers = new Dictionary> { + { "X-Request-Id", new[] { "req-123" } }, + { "Content-Type", new[] { "application/json" } } + }; + var rawResponse = "{\"id\":\"123\"}"; + var data = new { id = "123" }; + + // Act + var response = new ApiResponse(statusCode, headers, rawResponse, data); + + // Assert + Assert.Equal(statusCode, response.StatusCode); + Assert.Equal(headers, response.Headers); + Assert.Equal(rawResponse, response.RawResponse); + Assert.Equal(data, response.Data); + } + + [Fact] + public void IsSuccessful_2xxStatusCode_ReturnsTrue() { + // Arrange + var headers = new Dictionary>(); + var testCases = new[] { + HttpStatusCode.OK, // 200 + HttpStatusCode.Created, // 201 + HttpStatusCode.Accepted, // 202 + HttpStatusCode.NoContent // 204 + }; + + foreach (var statusCode in testCases) { + // Act + var response = new ApiResponse(statusCode, headers, "", null); + + // Assert + Assert.True(response.IsSuccessful, $"Status code {statusCode} should be successful"); + } + } + + [Fact] + public void IsSuccessful_NonSuccessStatusCode_ReturnsFalse() { + // Arrange + var headers = new Dictionary>(); + var testCases = new[] { + HttpStatusCode.BadRequest, // 400 + HttpStatusCode.Unauthorized, // 401 + HttpStatusCode.Forbidden, // 403 + HttpStatusCode.NotFound, // 404 + HttpStatusCode.InternalServerError, // 500 + HttpStatusCode.ServiceUnavailable // 503 + }; + + foreach (var statusCode in testCases) { + // Act + var response = new ApiResponse(statusCode, headers, "", null); + + // Assert + Assert.False(response.IsSuccessful, $"Status code {statusCode} should not be successful"); + } + } + + [Fact] + public void FromHttpResponse_ContainsAllResponseHeaders() { + // Arrange + using var httpResponse = new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") + }; + httpResponse.Headers.Add("X-Request-Id", "req-123"); + httpResponse.Headers.Add("X-Trace-Id", "trace-456"); + + var rawResponse = "{\"id\":\"123\"}"; + var data = new { id = "123" }; + + // Act + var response = ApiResponse.FromHttpResponse(httpResponse, rawResponse, data); + + // Assert + Assert.True(response.Headers.TryGetValue("X-Request-Id", out var requestId)); + Assert.Equal("req-123", requestId.First()); + Assert.True(response.Headers.TryGetValue("X-Trace-Id", out var traceId)); + Assert.Equal("trace-456", traceId.First()); + } + + [Fact] + public void FromHttpResponse_ContainsContentHeaders() { + // Arrange + using var httpResponse = new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"123\"}", Encoding.UTF8, "application/json") + }; + + var rawResponse = "{\"id\":\"123\"}"; + var data = new { id = "123" }; + + // Act + var response = ApiResponse.FromHttpResponse(httpResponse, rawResponse, data); + + // Assert + Assert.True(response.Headers.TryGetValue("Content-Type", out var contentType)); + Assert.Contains("application/json", contentType.First()); + } + + [Fact] + public void Headers_AreCaseInsensitive() { + // Arrange + using var httpResponse = new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("", Encoding.UTF8, "application/json") + }; + httpResponse.Headers.Add("X-Custom-Header", "value"); + + var rawResponse = ""; + var data = (string)null; + + // Act + var response = ApiResponse.FromHttpResponse(httpResponse, rawResponse, data); + + // Assert + Assert.True(response.Headers.ContainsKey("x-custom-header")); + Assert.True(response.Headers.ContainsKey("X-CUSTOM-HEADER")); + Assert.True(response.Headers.ContainsKey("X-Custom-Header")); + } + + [Fact] + public void FromHttpResponse_WithNullData_StoresNull() { + // Arrange + using var httpResponse = new HttpResponseMessage { + StatusCode = HttpStatusCode.NoContent + }; + + var rawResponse = ""; + + // Act + var response = ApiResponse.FromHttpResponse(httpResponse, rawResponse, null); + + // Assert + Assert.Null(response.Data); + Assert.Equal("", response.RawResponse); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public void RawResponse_PreservesOriginalContent() { + // Arrange + var rawJson = "{\"id\":\"123\",\"name\":\"test\",\"nested\":{\"value\":42}}"; + var headers = new Dictionary>(); + var data = new { id = "123" }; + + // Act + var response = new ApiResponse(HttpStatusCode.OK, headers, rawJson, data); + + // Assert + Assert.Equal(rawJson, response.RawResponse); + } +} \ No newline at end of file diff --git a/src/OpenFga.Sdk/Api/OpenFgaApi.cs b/src/OpenFga.Sdk/Api/OpenFgaApi.cs index 91e9d64a..470f4cb2 100644 --- a/src/OpenFga.Sdk/Api/OpenFgaApi.cs +++ b/src/OpenFga.Sdk/Api/OpenFgaApi.cs @@ -41,6 +41,11 @@ public OpenFgaApi( _apiClient = new ApiClient.ApiClient(_configuration, httpClient); } + /// + /// Gets the internal ApiClient instance for making custom API requests. + /// + internal ApiClient.ApiClient ApiClientInternal => _apiClient; + /// /// Send a list of `check` operations in a single request The `BatchCheck` API functions nearly identically to `Check`, but instead of checking a single user-object relationship BatchCheck accepts a list of relationships to check and returns a map containing `BatchCheckItem` response for each check it received. An associated `correlation_id` is required for each check in the batch. This ID is used to correlate a check to the appropriate response. It is a string consisting of only alphanumeric characters or hyphens with a maximum length of 36 characters. This `correlation_id` is used to map the result of each check to the item which was checked, so it must be unique for each item in the batch. We recommend using a UUID or ULID as the `correlation_id`, but you can use whatever unique identifier you need as long as it matches this regex pattern: `^[\\w\\d-]{1,36}$` NOTE: The maximum number of checks that can be passed in the `BatchCheck` API is configurable via the [OPENFGA_MAX_CHECKS_PER_BATCH_CHECK](https://openfga.dev/docs/getting-started/setup-openfga/configuration#OPENFGA_MAX_CHECKS_PER_BATCH_CHECK) environment variable. If `BatchCheck` is called using the SDK, the SDK can split the batch check requests for you. For more details on how `Check` functions, see the docs for `/check`. ### Examples #### A BatchCheckRequest ```json { \"checks\": [ { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:anne\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PM3QM7VBPGB8KMPK8SBD5\" }, { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:bob\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PMM6A90NV5ET0F28CYSZQ\" } ] } ``` Below is a possible response to the above request. Note that the result map's keys are the `correlation_id` values from the checked items in the request: ```json { \"result\": { \"01JA8PMM6A90NV5ET0F28CYSZQ\": { \"allowed\": false, \"error\": {\"message\": \"\"} }, \"01JA8PM3QM7VBPGB8KMPK8SBD5\": { \"allowed\": true, \"error\": {\"message\": \"\"} } } ``` /// @@ -70,8 +75,8 @@ public async Task BatchCheck(string storeId, BatchCheckReque QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "BatchCheck", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "BatchCheck", options, cancellationToken)).Data!; } /// @@ -103,8 +108,8 @@ public async Task Check(string storeId, CheckRequest body, IReque QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "Check", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "Check", options, cancellationToken)).Data!; } /// @@ -129,8 +134,8 @@ public async Task CreateStore(CreateStoreRequest body, IReq QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "CreateStore", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "CreateStore", options, cancellationToken)).Data!; } /// @@ -160,7 +165,7 @@ public async Task DeleteStore(string storeId, IRequestOptions? options = null, C QueryParameters = queryParams, }; - await _apiClient.SendRequestAsync(requestBuilder, + await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, "DeleteStore", options, cancellationToken); } @@ -193,8 +198,8 @@ public async Task Expand(string storeId, ExpandRequest body, IRe QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "Expand", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "Expand", options, cancellationToken)).Data!; } /// @@ -224,8 +229,8 @@ public async Task GetStore(string storeId, IRequestOptions? op QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "GetStore", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "GetStore", options, cancellationToken)).Data!; } /// @@ -257,8 +262,8 @@ public async Task ListObjects(string storeId, ListObjectsRe QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "ListObjects", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "ListObjects", options, cancellationToken)).Data!; } /// @@ -293,8 +298,8 @@ public async Task ListObjects(string storeId, ListObjectsRe QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "ListStores", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "ListStores", options, cancellationToken)).Data!; } /// @@ -326,8 +331,8 @@ public async Task ListUsers(string storeId, ListUsersRequest QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "ListUsers", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "ListUsers", options, cancellationToken)).Data!; } /// @@ -359,8 +364,8 @@ public async Task Read(string storeId, ReadRequest body, IRequestO QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "Read", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "Read", options, cancellationToken)).Data!; } /// @@ -394,8 +399,8 @@ public async Task ReadAssertions(string storeId, string QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "ReadAssertions", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "ReadAssertions", options, cancellationToken)).Data!; } /// @@ -429,8 +434,8 @@ public async Task ReadAuthorizationModel(string QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "ReadAuthorizationModel", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "ReadAuthorizationModel", options, cancellationToken)).Data!; } /// @@ -468,8 +473,8 @@ public async Task ReadAuthorizationModel(string QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "ReadAuthorizationModels", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "ReadAuthorizationModels", options, cancellationToken)).Data!; } /// @@ -516,8 +521,8 @@ public async Task ReadAuthorizationModel(string QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "ReadChanges", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "ReadChanges", options, cancellationToken)).Data!; } /// @@ -584,8 +589,8 @@ public async Task Write(string storeId, WriteRequest body, IRequestOptio QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "Write", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "Write", options, cancellationToken)).Data!; } /// @@ -621,7 +626,7 @@ public async Task WriteAssertions(string storeId, string authorizationModelId, W QueryParameters = queryParams, }; - await _apiClient.SendRequestAsync(requestBuilder, + await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, "WriteAssertions", options, cancellationToken); } @@ -654,8 +659,8 @@ public async Task WriteAuthorizationModel(strin QueryParameters = queryParams, }; - return await _apiClient.SendRequestAsync(requestBuilder, - "WriteAuthorizationModel", options, cancellationToken); + return (await _apiClient.ApiExecutor.ExecuteAsync(requestBuilder, + "WriteAuthorizationModel", options, cancellationToken)).Data!; } diff --git a/src/OpenFga.Sdk/ApiClient/ApiClient.cs b/src/OpenFga.Sdk/ApiClient/ApiClient.cs index af9e9dc6..65cc7d77 100644 --- a/src/OpenFga.Sdk/ApiClient/ApiClient.cs +++ b/src/OpenFga.Sdk/ApiClient/ApiClient.cs @@ -22,6 +22,7 @@ public class ApiClient : IDisposable { private readonly OAuth2Client? _oauth2Client; private readonly Metrics metrics; private readonly RetryHandler _retryHandler; + private readonly Lazy _apiExecutor; /// /// Initializes a new instance of the class. @@ -34,6 +35,7 @@ public ApiClient(Configuration.Configuration configuration, HttpClient? userHttp metrics = new Metrics(_configuration); _baseClient = new BaseClient(configuration, userHttpClient, metrics); _retryHandler = new RetryHandler(new RetryParams { MaxRetry = _configuration.MaxRetry, MinWaitInMs = _configuration.MinWaitInMs }); + _apiExecutor = new Lazy(() => new ApiExecutor(this)); if (_configuration.Credentials == null) { return; @@ -55,6 +57,22 @@ public ApiClient(Configuration.Configuration configuration, HttpClient? userHttp } } + /// + /// Gets the ApiExecutor for making custom API requests. + /// Use this when you need to call OpenFGA API endpoints not yet available in the SDK's typed methods, + /// or when you need access to full response details (status code, headers, raw response). + /// + /// + /// + /// var executor = apiClient.ApiExecutor; + /// var request = RequestBuilder<object> + /// .Create(HttpMethod.Get, config.ApiUrl, "/stores/{store_id}") + /// .WithPathParameter("store_id", storeId); + /// var response = await executor.ExecuteAsync<object, GetStoreResponse>(request, "GetStore"); + /// + /// + public ApiExecutor ApiExecutor => _apiExecutor.Value; + /// /// Gets the authentication token based on the configured credentials method. /// For OAuth (ClientCredentials), fetches token from OAuth2Client. @@ -80,62 +98,6 @@ public ApiClient(Configuration.Configuration configuration, HttpClient? userHttp return null; } - /// - /// 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 - /// - /// - /// - /// Request options. - /// - /// Response Type - /// - /// - public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName, - IRequestOptions? options = null, - CancellationToken cancellationToken = default) { - var sw = Stopwatch.StartNew(); - - var authToken = await GetAuthenticationTokenAsync(apiName); - var additionalHeaders = BuildHeaders(_configuration, authToken, options); - - var response = await Retry(async (attemptCount) => - await _baseClient.SendRequestAsync(requestBuilder, additionalHeaders, apiName, - attemptCount, 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) - /// - /// - /// - /// Request options. - /// - /// - public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName, - IRequestOptions? options = null, - CancellationToken cancellationToken = default) { - var sw = Stopwatch.StartNew(); - - var authToken = await GetAuthenticationTokenAsync(apiName); - var additionalHeaders = BuildHeaders(_configuration, authToken, options); - - var response = await Retry(async (attemptCount) => - await _baseClient.SendRequestAsync(requestBuilder, additionalHeaders, apiName, - attemptCount, cancellationToken)); - - sw.Stop(); - metrics.BuildForResponse(apiName, response.rawResponse, requestBuilder, sw, - response.retryCount); - } /// /// Handles streaming requests that return IAsyncEnumerable. @@ -266,5 +228,94 @@ private void PopulateRetryMetadata(FgaApiError error, int attemptCount) { } } + /// + /// Executes an API request using RequestBuilder and returns an ApiResponse with full response details. + /// + /// The type of the request body + /// The type of the response body + /// The request builder containing request details + /// The API name for telemetry and error reporting + /// Request options including custom headers + /// Cancellation token + /// An ApiResponse containing status code, headers, raw response, and typed data + /// Thrown when authentication fails + /// Thrown when the API returns an error response + public async Task> ExecuteAsync( + RequestBuilder requestBuilder, + string apiName, + IRequestOptions? options = null, + CancellationToken cancellationToken = default) { + + var responseWrapper = await SendRequestInternalAsync( + requestBuilder, apiName, options, cancellationToken); + + var rawResponse = responseWrapper.rawResponse.Content != null + ? await responseWrapper.rawResponse.Content.ReadAsStringAsync().ConfigureAwait(false) + : string.Empty; + + return ApiResponse.FromHttpResponse( + responseWrapper.rawResponse, + rawResponse, + responseWrapper.responseContent); + } + + /// + /// Executes an API request using RequestBuilder and returns an ApiResponse with raw JSON string. + /// This variant is useful when you want to process the JSON response manually. + /// + /// The type of the request body + /// The request builder containing request details + /// The API name for telemetry and error reporting + /// Request options including custom headers + /// Cancellation token + /// An ApiResponse with the raw JSON response as the Data property + /// Thrown when authentication fails + /// Thrown when the API returns an error response + public async Task> ExecuteAsync( + RequestBuilder requestBuilder, + string apiName, + IRequestOptions? options = null, + CancellationToken cancellationToken = default) { + + // Use object as intermediate type to avoid strong typing + var responseWrapper = await SendRequestInternalAsync( + requestBuilder, apiName, options, cancellationToken); + + var rawResponse = responseWrapper.rawResponse.Content != null + ? await responseWrapper.rawResponse.Content.ReadAsStringAsync().ConfigureAwait(false) + : string.Empty; + + return ApiResponse.FromHttpResponse( + responseWrapper.rawResponse, + rawResponse, + rawResponse); + } + + /// + /// Core private method that handles authentication, retry logic, and metrics. + /// ExecuteAsync builds on top of this shared implementation. + /// + private async Task> SendRequestInternalAsync( + RequestBuilder requestBuilder, + string apiName, + IRequestOptions? options, + CancellationToken cancellationToken) { + + var sw = Stopwatch.StartNew(); + + var authToken = await GetAuthenticationTokenAsync(apiName); + var additionalHeaders = BuildHeaders(_configuration, authToken, options); + + var response = await Retry(async (attemptCount) => + await _baseClient.SendRequestAsync(requestBuilder, additionalHeaders, apiName, + attemptCount, cancellationToken)); + + sw.Stop(); + metrics.BuildForResponse(apiName, response.rawResponse, requestBuilder, sw, + response.retryCount); + + return response; + } + public void Dispose() => _baseClient.Dispose(); } \ No newline at end of file diff --git a/src/OpenFga.Sdk/ApiClient/ApiExecutor.cs b/src/OpenFga.Sdk/ApiClient/ApiExecutor.cs new file mode 100644 index 00000000..73f286ae --- /dev/null +++ b/src/OpenFga.Sdk/ApiClient/ApiExecutor.cs @@ -0,0 +1,88 @@ +using OpenFga.Sdk.Client.Model; +using OpenFga.Sdk.Model; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenFga.Sdk.ApiClient; + +/// +/// Provides methods to execute custom API requests against the OpenFGA API. +/// Use this when you need to call endpoints not yet available in the SDK's typed methods, +/// or when you need access to full response details (status code, headers, raw response). +/// +public class ApiExecutor { + private readonly ApiClient _apiClient; + + internal ApiExecutor(ApiClient apiClient) { + _apiClient = apiClient; + } + + /// + /// Executes an API request using RequestBuilder and returns an ApiResponse with full response details. + /// This provides a lower-level API for custom requests while leveraging authentication, retry logic, and error handling. + /// + /// The type of the request body + /// The type of the response body. Must be a type that can be deserialized from JSON. + /// The request builder containing request details (path, parameters, body, etc.) + /// The API name for telemetry and error reporting + /// Optional request options including custom headers + /// Cancellation token + /// An ApiResponse containing status code, headers, raw response, and typed data + /// Thrown when authentication fails + /// Thrown when the API returns an error response + /// + /// + /// var executor = client.ApiExecutor; + /// var request = RequestBuilder<object> + /// .Create(HttpMethod.Get, config.ApiUrl, "/stores/{store_id}") + /// .WithPathParameter("store_id", storeId); + /// var response = await executor.ExecuteAsync<object, GetStoreResponse>(request, "GetStore"); + /// if (response.IsSuccessful) { + /// Console.WriteLine($"Store: {response.Data.Name}"); + /// } + /// + /// + public async Task> ExecuteAsync( + RequestBuilder requestBuilder, + string apiName, + IRequestOptions? options = null, + CancellationToken cancellationToken = default) { + return await _apiClient.ExecuteAsync( + requestBuilder, + apiName, + options, + cancellationToken); + } + + /// + /// Executes an API request using RequestBuilder and returns an ApiResponse with raw JSON string. + /// This variant is useful when you want to process the JSON response manually without deserialization. + /// + /// The type of the request body + /// The request builder containing request details + /// The API name for telemetry and error reporting + /// Optional request options including custom headers + /// Cancellation token + /// An ApiResponse with the raw JSON response as the Data property + /// Thrown when authentication fails + /// Thrown when the API returns an error response + /// + /// + /// var executor = client.ApiExecutor; + /// var request = new RequestBuilder<object> { /* ... */ }; + /// var response = await executor.ExecuteAsync(request, "CustomEndpoint"); + /// string rawJson = response.Data; // Raw JSON string + /// + /// + public async Task> ExecuteAsync( + RequestBuilder requestBuilder, + string apiName, + IRequestOptions? options = null, + CancellationToken cancellationToken = default) { + return await _apiClient.ExecuteAsync( + requestBuilder, + apiName, + options, + cancellationToken); + } +} \ No newline at end of file diff --git a/src/OpenFga.Sdk/ApiClient/ApiResponse.cs b/src/OpenFga.Sdk/ApiClient/ApiResponse.cs new file mode 100644 index 00000000..175f23ac --- /dev/null +++ b/src/OpenFga.Sdk/ApiClient/ApiResponse.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; + +namespace OpenFga.Sdk.ApiClient; + +/// +/// Represents the response from an API request executed via ApiClient.ExecuteAsync, +/// containing both raw and typed response data. +/// +/// The type of the deserialized response data +public class ApiResponse { + /// + /// Gets the HTTP status code of the response. + /// + public HttpStatusCode StatusCode { get; } + + /// + /// Gets the response headers as a read-only dictionary. + /// Keys are case-insensitive. Includes both response and content headers. + /// + public IReadOnlyDictionary> Headers { get; } + + /// + /// Gets the raw response body as a string. + /// + public string RawResponse { get; } + + /// + /// Gets the deserialized response data. + /// Will be null if deserialization fails or if the response has no content. + /// + public T? Data { get; } + + /// + /// Gets whether the response indicates success (2xx status code). + /// + public bool IsSuccessful => (int)StatusCode >= 200 && (int)StatusCode < 300; + + /// + /// Initializes a new instance of the ApiResponse class. + /// + /// The HTTP status code + /// The response headers + /// The raw response body + /// The deserialized response data + internal ApiResponse( + HttpStatusCode statusCode, + IReadOnlyDictionary> headers, + string rawResponse, + T? data) { + StatusCode = statusCode; + Headers = headers; + RawResponse = rawResponse; + Data = data; + } + + /// + /// Creates an ApiResponse from an HttpResponseMessage. + /// + /// The HTTP response message + /// The raw response body as a string + /// The deserialized response data + /// A new ApiResponse instance + internal static ApiResponse FromHttpResponse( + HttpResponseMessage response, + string rawResponse, + T? data) { + var headers = ConvertHeaders(response); + return new ApiResponse( + response.StatusCode, + headers, + rawResponse, + data); + } + + /// + /// Converts HttpResponseMessage headers to a case-insensitive dictionary. + /// Includes both response headers and content headers. + /// + /// The HTTP response message + /// A read-only dictionary of headers + private static IReadOnlyDictionary> ConvertHeaders( + HttpResponseMessage response) { + var headers = new Dictionary>( + StringComparer.OrdinalIgnoreCase); + + // Add response headers + foreach (var header in response.Headers) { + headers[header.Key] = header.Value; + } + + // Add content headers if present + if (response.Content?.Headers != null) { + foreach (var header in response.Content.Headers) { + headers[header.Key] = header.Value; + } + } + + return headers; + } +} \ No newline at end of file diff --git a/src/OpenFga.Sdk/ApiClient/RequestBuilder.cs b/src/OpenFga.Sdk/ApiClient/RequestBuilder.cs index 02d70374..2cd55954 100644 --- a/src/OpenFga.Sdk/ApiClient/RequestBuilder.cs +++ b/src/OpenFga.Sdk/ApiClient/RequestBuilder.cs @@ -10,6 +10,8 @@ namespace OpenFga.Sdk.ApiClient; /// +/// Builder for constructing HTTP requests with support for path parameters, query parameters, and request bodies. +/// Can be used with object initializer syntax or fluent builder methods. /// /// Type of the Request Body public class RequestBuilder { @@ -28,6 +30,96 @@ public RequestBuilder() { public TReq? Body { get; set; } + /// + /// Creates a new RequestBuilder with the specified HTTP method, base path, and path template. + /// This is an alternative to object initializer syntax that provides better validation. + /// + /// The HTTP method (e.g., GET, POST, PUT, DELETE) + /// The base URL for the API (e.g., "https://api.fga.example") + /// The path template (e.g., "/stores/{store_id}/check") + /// A new RequestBuilder instance + /// Thrown when method, basePath, or pathTemplate is null + /// Thrown when basePath or pathTemplate is empty or whitespace + public static RequestBuilder Create(HttpMethod method, string basePath, string pathTemplate) { + if (method == null) { + throw new ArgumentNullException(nameof(method), "HTTP method cannot be null"); + } + + if (basePath == null) { + throw new ArgumentNullException(nameof(basePath), "Base path cannot be null"); + } + + if (string.IsNullOrWhiteSpace(basePath)) { + throw new ArgumentException("Base path cannot be empty or whitespace", nameof(basePath)); + } + + if (pathTemplate == null) { + throw new ArgumentNullException(nameof(pathTemplate), "Path template cannot be null"); + } + + if (string.IsNullOrWhiteSpace(pathTemplate)) { + throw new ArgumentException("Path template cannot be empty or whitespace", nameof(pathTemplate)); + } + + return new RequestBuilder { + Method = method, + BasePath = basePath, + PathTemplate = pathTemplate + }; + } + + /// + /// Adds a path parameter to the request. The parameter will replace {key} in the path template. + /// + /// The parameter name (without braces) + /// The parameter value + /// This builder instance for method chaining + /// Thrown when key is null, empty, or whitespace + /// Thrown when value is null + public RequestBuilder WithPathParameter(string key, string value) { + if (string.IsNullOrWhiteSpace(key)) { + throw new ArgumentException("Path parameter key cannot be null, empty, or whitespace", nameof(key)); + } + + if (value == null) { + throw new ArgumentNullException(nameof(value), $"Path parameter value for key '{key}' cannot be null"); + } + + PathParameters[key] = value; + return this; + } + + /// + /// Adds a query parameter to the request. The parameter will be appended to the URL as ?key=value. + /// + /// The query parameter name + /// The query parameter value + /// This builder instance for method chaining + /// Thrown when key is null, empty, or whitespace + /// Thrown when value is null + public RequestBuilder WithQueryParameter(string key, string value) { + if (string.IsNullOrWhiteSpace(key)) { + throw new ArgumentException("Query parameter key cannot be null, empty, or whitespace", nameof(key)); + } + + if (value == null) { + throw new ArgumentNullException(nameof(value), $"Query parameter value for key '{key}' cannot be null"); + } + + QueryParameters[key] = value; + return this; + } + + /// + /// Sets the request body. The body will be JSON-serialized when sent. + /// + /// The request body object (will be serialized to JSON) + /// This builder instance for method chaining + public RequestBuilder WithBody(TReq? body) { + Body = body; + return this; + } + public string? JsonBody => Body == null ? null : JsonSerializer.Serialize(Body); public HttpContent? FormEncodedBody { diff --git a/src/OpenFga.Sdk/Client/Client.cs b/src/OpenFga.Sdk/Client/Client.cs index b9b59166..731c0b5b 100644 --- a/src/OpenFga.Sdk/Client/Client.cs +++ b/src/OpenFga.Sdk/Client/Client.cs @@ -44,7 +44,33 @@ public string? AuthorizationModelId { set => _configuration.AuthorizationModelId = value; } - public void Dispose() => api.Dispose(); + /// + /// Gets the ApiExecutor for making custom API requests. + /// Use this when you need to call OpenFGA API endpoints not yet available in the SDK's typed methods, + /// or when you need access to full response details (status code, headers, raw response). + /// + /// + /// + /// var executor = client.ApiExecutor; + /// var request = RequestBuilder<object> + /// .Create(HttpMethod.Get, config.ApiUrl, "/stores/{store_id}") + /// .WithPathParameter("store_id", storeId); + /// var response = await executor.ExecuteAsync<object, GetStoreResponse>(request, "GetStore"); + /// + /// + public ApiExecutor ApiExecutor => api.ApiClientInternal.ApiExecutor; + + /// + /// Gets the underlying ApiClient (internal use). + /// + /// The ApiClient instance used by this client + internal ApiClient.ApiClient GetApiClient() { + return api.ApiClientInternal; + } + + public void Dispose() { + api.Dispose(); + } #if NET6_0_OR_GREATER private async Task ProcessWriteChunksAsync( diff --git a/src/OpenFga.Sdk/Model/TupleChange.cs b/src/OpenFga.Sdk/Model/TupleChange.cs index 8a96ef4c..c99fb4cc 100644 --- a/src/OpenFga.Sdk/Model/TupleChange.cs +++ b/src/OpenFga.Sdk/Model/TupleChange.cs @@ -127,9 +127,7 @@ public bool Equals(TupleChange input) { this.Operation.Equals(input.Operation) ) && ( - this.Timestamp == input.Timestamp || - (this.Timestamp != null && - this.Timestamp.Equals(input.Timestamp)) + this.Timestamp.Equals(input.Timestamp) ) && (this.AdditionalProperties.Count == input.AdditionalProperties.Count && this.AdditionalProperties.All(kv => input.AdditionalProperties.TryGetValue(kv.Key, out var inputValue) && Equals(kv.Value, inputValue))); } @@ -146,9 +144,7 @@ public override int GetHashCode() { hashCode = (hashCode * FgaConstants.HashCodeMultiplierPrimeNumber) + this.TupleKey.GetHashCode(); } hashCode = (hashCode * FgaConstants.HashCodeMultiplierPrimeNumber) + this.Operation.GetHashCode(); - if (this.Timestamp != null) { - hashCode = (hashCode * FgaConstants.HashCodeMultiplierPrimeNumber) + this.Timestamp.GetHashCode(); - } + hashCode = (hashCode * FgaConstants.HashCodeMultiplierPrimeNumber) + this.Timestamp.GetHashCode(); if (this.AdditionalProperties != null) { hashCode = (hashCode * FgaConstants.HashCodeMultiplierPrimeNumber) + this.AdditionalProperties.GetHashCode(); }