diff --git a/README.md b/README.md index be66cc1..7d1de49 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ public class MyService(ICloudflareApiClient cf) | **Account Security** | IP Access Rules, WAF Rulesets | | **Users** | Profile Management, Memberships, Invitations, Audit Logs, API Tokens, Subscriptions | | **Workers** | Routes (zone-scoped) | +| **Workers KV** | Namespace CRUD, Key-Value CRUD, Metadata, Expiration, Bulk Operations | | **Turnstile** | Widget CRUD, Secret Rotation | | **R2 Client** | Upload, Download, Multipart, Presigned URLs, Batch Delete | | **Analytics** | GraphQL queries for traffic, security, and R2 metrics | diff --git a/docs/articles/accounts/index.md b/docs/articles/accounts/index.md index fbe4d4c..7be6194 100644 --- a/docs/articles/accounts/index.md +++ b/docs/articles/accounts/index.md @@ -28,6 +28,7 @@ public class AccountService(ICloudflareApiClient cf) | [Audit Logs](audit-logs.md) | `cf.AuditLogs` | View account activity logs | | [Turnstile](turnstile.md) | `cf.Turnstile` | Manage Turnstile widgets | | [R2 Buckets](r2/buckets.md) | `cf.Accounts` | Create and manage R2 buckets | +| [Workers KV](workers/kv.md) | `cf.Accounts.Kv` | Global key-value storage | | [Access Rules](security/access-rules.md) | `cf.Accounts.AccessRules` | Account-level IP access control | | [Rulesets](security/rulesets.md) | `cf.Accounts.Rulesets` | Account-level WAF rules | @@ -68,6 +69,9 @@ Console.WriteLine($"2FA Required: {account.Settings?.EnforceTwofactor}"); - [CORS Configuration](r2/cors.md) - Configure cross-origin access - [Lifecycle Policies](r2/lifecycle.md) - Automatic object expiration +### Workers +- [Workers KV](workers/kv.md) - Global low-latency key-value storage + ### Security - [Access Rules](security/access-rules.md) - Account-level IP firewall rules - [WAF Rulesets](security/rulesets.md) - Account-level WAF custom rules @@ -83,6 +87,7 @@ Console.WriteLine($"2FA Required: {account.Settings?.EnforceTwofactor}"); | Audit Logs | Audit Logs | Read | | Turnstile | Turnstile | Read/Write | | R2 Buckets | Workers R2 Storage | Read/Write | +| Workers KV | Workers KV Storage | Read/Write | | Access Rules | Account Firewall Access Rules | Read/Write | | Rulesets | Account Rulesets | Read/Write | diff --git a/docs/articles/accounts/security/access-rules.md b/docs/articles/accounts/security/access-rules.md index c1e8f6a..559ee15 100644 --- a/docs/articles/accounts/security/access-rules.md +++ b/docs/articles/accounts/security/access-rules.md @@ -103,7 +103,7 @@ var page = await cf.Accounts.AccessRules.ListAsync( PerPage = 50 }); -Console.WriteLine($"Total rules: {page.ResultInfo.TotalCount}"); +Console.WriteLine($"Total rules: {page.PageInfo.TotalCount}"); foreach (var rule in page.Items) { diff --git a/docs/articles/accounts/workers/kv.md b/docs/articles/accounts/workers/kv.md new file mode 100644 index 0000000..6fc7305 --- /dev/null +++ b/docs/articles/accounts/workers/kv.md @@ -0,0 +1,601 @@ +# Workers KV + +Workers KV is Cloudflare's global, low-latency key-value data store. This API allows you to manage KV namespaces and their key-value pairs programmatically. + +## Overview + +```csharp +public class KvService(ICloudflareApiClient cf) +{ + public async Task StoreConfigAsync(string namespaceId, string key, string value) + { + await cf.Accounts.Kv.WriteValueAsync(namespaceId, key, value); + } + + public async Task GetConfigAsync(string namespaceId, string key) + { + return await cf.Accounts.Kv.GetValueAsync(namespaceId, key); + } +} +``` + +## API Limits + +| Limit | Value | +|-------|-------| +| Key name max length | 512 bytes | +| Value max size | 25 MiB | +| Metadata max size | 1024 bytes (serialized JSON) | +| Minimum TTL | 60 seconds | +| Write rate limit | 1 per second per key | +| Bulk write max items | 10,000 pairs | +| Bulk write max size | 100 MB total | +| Bulk delete max keys | 10,000 keys | +| Bulk get max keys | 100 keys | + +## Namespace Operations + +### Creating a Namespace + +```csharp +var ns = await cf.Accounts.Kv.CreateAsync("my-config-store"); + +Console.WriteLine($"Created namespace: {ns.Id}"); +Console.WriteLine($"Title: {ns.Title}"); +``` + +> [!NOTE] +> Namespace titles must be unique within your account. Creating a namespace with a duplicate title returns error code 10014. + +### Listing Namespaces + +```csharp +// List all namespaces automatically +await foreach (var ns in cf.Accounts.Kv.ListAllAsync()) +{ + Console.WriteLine($"{ns.Title}: {ns.Id}"); +} + +// With ordering +var filters = new ListKvNamespacesFilters( + Order: KvNamespaceOrderField.Title, + Direction: ListOrderDirection.Ascending +); + +await foreach (var ns in cf.Accounts.Kv.ListAllAsync(filters)) +{ + Console.WriteLine(ns.Title); +} +``` + +#### Manual Pagination + +```csharp +var page = await cf.Accounts.Kv.ListAsync(new ListKvNamespacesFilters( + Page: 1, + PerPage: 50 +)); + +Console.WriteLine($"Page {page.PageInfo.Page} of {page.PageInfo.TotalPages}"); + +foreach (var ns in page.Items) +{ + Console.WriteLine(ns.Title); +} +``` + +### Getting a Namespace + +```csharp +var ns = await cf.Accounts.Kv.GetAsync(namespaceId); + +Console.WriteLine($"Title: {ns.Title}"); +Console.WriteLine($"Supports URL Encoding: {ns.SupportsUrlEncoding}"); +``` + +### Renaming a Namespace + +```csharp +var updated = await cf.Accounts.Kv.RenameAsync(namespaceId, "new-title"); + +Console.WriteLine($"Renamed to: {updated.Title}"); +``` + +### Deleting a Namespace + +```csharp +await cf.Accounts.Kv.DeleteAsync(namespaceId); +``` + +> [!WARNING] +> Deleting a namespace permanently removes all keys and values within it. This action cannot be undone. + +## Key-Value Operations + +### Writing Values + +#### String Values + +```csharp +await cf.Accounts.Kv.WriteValueAsync(namespaceId, "config/app-name", "MyApp"); +``` + +#### Binary Values + +```csharp +byte[] imageData = await File.ReadAllBytesAsync("logo.png"); +await cf.Accounts.Kv.WriteValueAsync(namespaceId, "assets/logo", imageData); +``` + +#### With Expiration + +```csharp +// Expire at a specific Unix timestamp +await cf.Accounts.Kv.WriteValueAsync( + namespaceId, + "session/abc123", + sessionData, + new KvWriteOptions(Expiration: DateTimeOffset.UtcNow.AddHours(24).ToUnixTimeSeconds()) +); + +// Expire after a TTL (in seconds, minimum 60) +await cf.Accounts.Kv.WriteValueAsync( + namespaceId, + "cache/user-profile", + profileJson, + new KvWriteOptions(ExpirationTtl: 3600) // 1 hour +); +``` + +#### With Metadata + +Attach arbitrary JSON metadata to any key: + +```csharp +var metadata = JsonSerializer.SerializeToElement(new +{ + contentType = "application/json", + version = 2, + createdBy = "system" +}); + +await cf.Accounts.Kv.WriteValueAsync( + namespaceId, + "config/settings", + settingsJson, + new KvWriteOptions(Metadata: metadata) +); +``` + +### Reading Values + +#### String Values + +```csharp +string? value = await cf.Accounts.Kv.GetValueAsync(namespaceId, "config/app-name"); + +if (value is null) +{ + Console.WriteLine("Key not found"); +} +else +{ + Console.WriteLine($"Value: {value}"); +} +``` + +#### Binary Values + +```csharp +byte[]? data = await cf.Accounts.Kv.GetValueBytesAsync(namespaceId, "assets/logo"); + +if (data is not null) +{ + await File.WriteAllBytesAsync("downloaded-logo.png", data); +} +``` + +#### With Expiration Info (String) + +```csharp +var result = await cf.Accounts.Kv.GetValueWithExpirationAsync(namespaceId, "session/abc123"); + +if (result is not null) +{ + Console.WriteLine($"Value: {result.Value}"); + + if (result.Expiration is not null) + { + var expiresAt = DateTimeOffset.FromUnixTimeSeconds(result.Expiration.Value); + Console.WriteLine($"Expires: {expiresAt}"); + } +} +``` + +#### With Expiration Info (Binary) + +```csharp +var result = await cf.Accounts.Kv.GetValueBytesWithExpirationAsync(namespaceId, "assets/logo"); + +if (result is not null) +{ + await File.WriteAllBytesAsync("downloaded-logo.png", result.Value); + + if (result.Expiration is not null) + { + var expiresAt = DateTimeOffset.FromUnixTimeSeconds(result.Expiration.Value); + Console.WriteLine($"Expires: {expiresAt}"); + } +} +``` + +#### Metadata Only + +Read metadata without fetching the value (useful for large values): + +```csharp +var metadata = await cf.Accounts.Kv.GetMetadataAsync(namespaceId, "config/settings"); + +if (metadata is not null) +{ + var version = metadata.Value.GetProperty("version").GetInt32(); + Console.WriteLine($"Version: {version}"); +} +``` + +### Deleting Values + +```csharp +await cf.Accounts.Kv.DeleteValueAsync(namespaceId, "config/old-setting"); +``` + +> [!TIP] +> Delete operations are idempotent. Deleting a non-existent key does not throw an error. + +## Listing Keys + +### List All Keys + +```csharp +await foreach (var key in cf.Accounts.Kv.ListAllKeysAsync(namespaceId)) +{ + Console.WriteLine($"Key: {key.Name}"); + + if (key.Expiration is not null) + { + var expiresAt = DateTimeOffset.FromUnixTimeSeconds(key.Expiration.Value); + Console.WriteLine($" Expires: {expiresAt}"); + } + + if (key.Metadata is not null) + { + Console.WriteLine($" Metadata: {key.Metadata}"); + } +} +``` + +### Filter by Prefix + +```csharp +// List only keys under "config/" +await foreach (var key in cf.Accounts.Kv.ListAllKeysAsync(namespaceId, prefix: "config/")) +{ + Console.WriteLine(key.Name); +} +``` + +### Manual Cursor Pagination + +```csharp +var page = await cf.Accounts.Kv.ListKeysAsync(namespaceId, new ListKvKeysFilters( + Prefix: "users/", + Limit: 100 +)); + +foreach (var key in page.Items) +{ + Console.WriteLine(key.Name); +} + +// Get next page using cursor +if (page.CursorInfo?.Cursor is not null) +{ + var nextPage = await cf.Accounts.Kv.ListKeysAsync(namespaceId, new ListKvKeysFilters( + Prefix: "users/", + Limit: 100, + Cursor: page.CursorInfo.Cursor + )); +} +``` + +## Bulk Operations + +### Bulk Write + +Write up to 10,000 key-value pairs in a single request: + +```csharp +var items = new[] +{ + new KvBulkWriteItem("config/setting1", "value1"), + new KvBulkWriteItem("config/setting2", "value2"), + new KvBulkWriteItem("config/setting3", "value3", ExpirationTtl: 3600), +}; + +var result = await cf.Accounts.Kv.BulkWriteAsync(namespaceId, items); + +Console.WriteLine($"Successfully written: {result.SuccessfulKeyCount}"); + +if (result.UnsuccessfulKeys is { Count: > 0 }) +{ + Console.WriteLine($"Failed keys: {string.Join(", ", result.UnsuccessfulKeys)}"); +} +``` + +#### With Metadata + +```csharp +var metadata = JsonSerializer.SerializeToElement(new { source = "import" }); + +var items = new[] +{ + new KvBulkWriteItem( + Key: "data/item1", + Value: "value1", + Metadata: metadata + ), + new KvBulkWriteItem( + Key: "data/item2", + Value: "value2", + Metadata: metadata, + ExpirationTtl: 86400 // 24 hours + ) +}; + +await cf.Accounts.Kv.BulkWriteAsync(namespaceId, items); +``` + +### Bulk Delete + +Delete up to 10,000 keys in a single request: + +```csharp +var keysToDelete = new[] { "old/key1", "old/key2", "old/key3" }; + +var result = await cf.Accounts.Kv.BulkDeleteAsync(namespaceId, keysToDelete); + +Console.WriteLine($"Successfully deleted: {result.SuccessfulKeyCount}"); +``` + +### Bulk Get + +Retrieve up to 100 values in a single request: + +```csharp +var keys = new[] { "config/a", "config/b", "config/c" }; + +var values = await cf.Accounts.Kv.BulkGetAsync(namespaceId, keys); + +foreach (var (key, value) in values) +{ + if (value is not null) + { + Console.WriteLine($"{key}: {value}"); + } + else + { + Console.WriteLine($"{key}: (not found)"); + } +} +``` + +#### With Metadata + +```csharp +var results = await cf.Accounts.Kv.BulkGetWithMetadataAsync(namespaceId, keys); + +foreach (var (key, item) in results) +{ + if (item is not null) + { + Console.WriteLine($"{key}: {item.Value}"); + + if (item.Metadata is not null) + { + Console.WriteLine($" Metadata: {item.Metadata}"); + } + } +} +``` + +## Models Reference + +### KvNamespace + +| Property | Type | Description | +|----------|------|-------------| +| `Id` | `string` | Unique namespace identifier | +| `Title` | `string` | Human-readable namespace name | +| `SupportsUrlEncoding` | `bool?` | Whether the namespace supports URL-encoded keys | + +### KvKey + +| Property | Type | Description | +|----------|------|-------------| +| `Name` | `string` | The key name | +| `Expiration` | `long?` | Unix timestamp when the key expires | +| `Metadata` | `JsonElement?` | Arbitrary JSON metadata attached to the key | + +### KvWriteOptions + +| Property | Type | Description | +|----------|------|-------------| +| `Expiration` | `long?` | Absolute Unix timestamp for expiration | +| `ExpirationTtl` | `int?` | Seconds until expiration (minimum 60) | +| `Metadata` | `JsonElement?` | Arbitrary JSON metadata (max 1024 bytes) | + +### KvBulkWriteItem + +| Property | Type | Description | +|----------|------|-------------| +| `Key` | `string` | The key name (max 512 bytes) | +| `Value` | `string` | The value to store | +| `Expiration` | `long?` | Absolute Unix timestamp for expiration | +| `ExpirationTtl` | `int?` | Seconds until expiration (minimum 60) | +| `Metadata` | `JsonElement?` | Arbitrary JSON metadata | +| `Base64` | `bool?` | Set to true if value is base64-encoded binary | + +### ListKvNamespacesFilters + +| Property | Type | Description | +|----------|------|-------------| +| `Page` | `int?` | Page number (1-based) | +| `PerPage` | `int?` | Items per page | +| `Order` | `KvNamespaceOrderField?` | Field to order by (`Id` or `Title`) | +| `Direction` | `ListOrderDirection?` | Sort direction (`Ascending` or `Descending`) | + +### ListKvKeysFilters + +| Property | Type | Description | +|----------|------|-------------| +| `Prefix` | `string?` | Filter keys by prefix | +| `Limit` | `int?` | Maximum keys to return (default 1000) | +| `Cursor` | `string?` | Cursor for pagination | + +### KvStringValueResult + +Returned by `GetValueWithExpirationAsync`. + +| Property | Type | Description | +|----------|------|-------------| +| `Value` | `string` | The value as a string | +| `Expiration` | `long?` | Unix timestamp when the key expires | + +### KvValueResult + +Returned by `GetValueBytesWithExpirationAsync`. + +| Property | Type | Description | +|----------|------|-------------| +| `Value` | `byte[]` | The raw value bytes | +| `Expiration` | `long?` | Unix timestamp when the key expires | + +### KvBulkWriteResult + +Returned by `BulkWriteAsync`. + +| Property | Type | Description | +|----------|------|-------------| +| `SuccessfulKeyCount` | `int` | Number of keys successfully written | +| `UnsuccessfulKeys` | `IReadOnlyList?` | Keys that failed to write (should be retried) | + +### KvBulkDeleteResult + +Returned by `BulkDeleteAsync`. + +| Property | Type | Description | +|----------|------|-------------| +| `SuccessfulKeyCount` | `int` | Number of keys successfully deleted | +| `UnsuccessfulKeys` | `IReadOnlyList?` | Keys that failed to delete | + +### KvBulkGetItemWithMetadata + +Returned by `BulkGetWithMetadataAsync` as dictionary values. + +| Property | Type | Description | +|----------|------|-------------| +| `Value` | `string?` | The value (null if key not found) | +| `Metadata` | `JsonElement?` | The metadata associated with the key | + +## Common Patterns + +### Configuration Store + +```csharp +public class ConfigurationStore(ICloudflareApiClient cf, string namespaceId) +{ + public async Task GetAsync(string key) + { + var json = await cf.Accounts.Kv.GetValueAsync(namespaceId, key); + return json is null ? default : JsonSerializer.Deserialize(json); + } + + public async Task SetAsync(string key, T value, int? ttlSeconds = null) + { + var json = JsonSerializer.Serialize(value); + var options = ttlSeconds is not null + ? new KvWriteOptions(ExpirationTtl: ttlSeconds) + : null; + + await cf.Accounts.Kv.WriteValueAsync(namespaceId, key, json, options); + } +} +``` + +### Session Store + +```csharp +public class SessionStore(ICloudflareApiClient cf, string namespaceId) +{ + private const int SessionTtlSeconds = 3600; // 1 hour + + public async Task GetSessionAsync(string sessionId) + { + return await cf.Accounts.Kv.GetValueAsync(namespaceId, $"session/{sessionId}"); + } + + public async Task SetSessionAsync(string sessionId, string data) + { + await cf.Accounts.Kv.WriteValueAsync( + namespaceId, + $"session/{sessionId}", + data, + new KvWriteOptions(ExpirationTtl: SessionTtlSeconds) + ); + } + + public async Task DeleteSessionAsync(string sessionId) + { + await cf.Accounts.Kv.DeleteValueAsync(namespaceId, $"session/{sessionId}"); + } +} +``` + +### Bulk Import + +```csharp +public async Task ImportDataAsync( + ICloudflareApiClient cf, + string namespaceId, + IEnumerable> data) +{ + // Process in batches of 10,000 + var batches = data + .Select(kv => new KvBulkWriteItem(kv.Key, kv.Value)) + .Chunk(10_000); + + foreach (var batch in batches) + { + var result = await cf.Accounts.Kv.BulkWriteAsync(namespaceId, batch); + + if (result.UnsuccessfulKeys is { Count: > 0 }) + { + // Handle failures - retry or log + Console.WriteLine($"Failed to write: {string.Join(", ", result.UnsuccessfulKeys)}"); + } + } +} +``` + +## Required Permissions + +| Permission | Scope | Level | +|------------|-------|-------| +| Workers KV Storage | Account | Read (for listing and reading) | +| Workers KV Storage | Account | Write (for create, update, delete) | + +## Related + +- [SDK Conventions](../../conventions.md) - Pagination patterns and common usage +- [API Coverage](../../api-coverage.md) - Full list of supported endpoints +- [Configuration](../../configuration.md) - SDK configuration options diff --git a/docs/articles/api-coverage.md b/docs/articles/api-coverage.md index 39c6b7f..b00f59f 100644 --- a/docs/articles/api-coverage.md +++ b/docs/articles/api-coverage.md @@ -53,6 +53,7 @@ This document provides a comprehensive overview of the Cloudflare API endpoints API Tokens8Create, verify, and manage API tokens DNS Records12Full DNS lifecycle including batch and BIND import/export R2 Buckets22Bucket management, CORS, lifecycle, domains, and Sippy +Workers KV19Namespace and key-value CRUD, metadata, bulk operations Subscriptions6Account, zone, and user subscription details Turnstile5CAPTCHA widget configuration and secret rotation User11Profile, invitations, and membership management @@ -457,7 +458,86 @@ This document provides a comprehensive overview of the Cloudflare API endpoints --- -## Workers +## Workers KV + +### Namespace Operations + + ++++++ + + + + + + + + + +
OperationMethodEndpointStatus
List NamespacesKv.ListAsyncGET /accounts/{id}/storage/kv/namespaces
List All NamespacesKv.ListAllAsyncGET /accounts/{id}/storage/kv/namespaces (auto)
Create NamespaceKv.CreateAsyncPOST /accounts/{id}/storage/kv/namespaces
Get NamespaceKv.GetAsyncGET /accounts/{id}/storage/kv/namespaces/{ns_id}
Rename NamespaceKv.RenameAsyncPUT /accounts/{id}/storage/kv/namespaces/{ns_id}
Delete NamespaceKv.DeleteAsyncDELETE /accounts/{id}/storage/kv/namespaces/{ns_id}
+ +### Key Operations + + ++++++ + + + + + +
OperationMethodEndpointStatus
List KeysKv.ListKeysAsyncGET .../namespaces/{ns_id}/keys
List All KeysKv.ListAllKeysAsyncGET .../namespaces/{ns_id}/keys (auto)
+ +### Value Operations + + ++++++ + + + + + + + + + + +
OperationMethodEndpointStatus
Get ValueKv.GetValueAsyncGET .../namespaces/{ns_id}/values/{key}
Get Value (with exp)Kv.GetValueWithExpirationAsyncGET .../namespaces/{ns_id}/values/{key}
Get Value (bytes)Kv.GetValueBytesAsyncGET .../namespaces/{ns_id}/values/{key}
Get Value (bytes+exp)Kv.GetValueBytesWithExpirationAsyncGET .../namespaces/{ns_id}/values/{key}
Get MetadataKv.GetMetadataAsyncGET .../namespaces/{ns_id}/metadata/{key}
Write ValueKv.WriteValueAsyncPUT .../namespaces/{ns_id}/values/{key}
Delete ValueKv.DeleteValueAsyncDELETE .../namespaces/{ns_id}/values/{key}
+ +### Bulk Operations + + ++++++ + + + + + + + +
OperationMethodEndpointStatus
Bulk WriteKv.BulkWriteAsyncPUT .../namespaces/{ns_id}/bulk
Bulk DeleteKv.BulkDeleteAsyncPOST .../namespaces/{ns_id}/bulk/delete
Bulk GetKv.BulkGetAsyncPOST .../namespaces/{ns_id}/bulk/get
Bulk Get (metadata)Kv.BulkGetWithMetadataAsyncPOST .../namespaces/{ns_id}/bulk/get
+ +--- + +## Workers Route diff --git a/docs/articles/conventions.md b/docs/articles/conventions.md index 87b385e..84ea8b4 100644 --- a/docs/articles/conventions.md +++ b/docs/articles/conventions.md @@ -17,6 +17,7 @@ Page-based pagination uses `page` and `per_page` parameters. This pattern is use - Access Rules (Zone and Account) - Zone Lockdown Rules - User-Agent Rules +- Workers KV Namespaces #### Automatic Pagination @@ -46,11 +47,11 @@ var page = await cf.Zones.ListDnsRecordsAsync(zoneId, new ListDnsRecordsFilters }); // Access pagination info -Console.WriteLine($"Page {page.ResultInfo.Page} of {page.ResultInfo.TotalPages}"); -Console.WriteLine($"Total records: {page.ResultInfo.TotalCount}"); +Console.WriteLine($"Page {page.PageInfo.Page} of {page.PageInfo.TotalPages}"); +Console.WriteLine($"Total records: {page.PageInfo.TotalCount}"); // Iterate manually -while (page.ResultInfo.Page < page.ResultInfo.TotalPages) +while (page.PageInfo.Page < page.PageInfo.TotalPages) { foreach (var record in page.Items) { @@ -60,7 +61,7 @@ while (page.ResultInfo.Page < page.ResultInfo.TotalPages) // Get next page page = await cf.Zones.ListDnsRecordsAsync(zoneId, new ListDnsRecordsFilters { - Page = page.ResultInfo.Page + 1, + Page = page.PageInfo.Page + 1, PerPage = 50 }); } @@ -72,6 +73,7 @@ Cursor-based pagination uses an opaque cursor string for continuation. This patt - R2 Buckets - Rulesets (Zone and Account) +- Workers KV Keys #### Automatic Pagination diff --git a/docs/articles/permissions.md b/docs/articles/permissions.md index 581f242..d1c319e 100644 --- a/docs/articles/permissions.md +++ b/docs/articles/permissions.md @@ -80,6 +80,19 @@ Cloudflare uses a permission-based system for API tokens. To adhere to the princ
+### Workers KV + + + + + + + + + + +
FeaturePermissionLevel
KV Namespaces (read)Workers KV StorageAccount: Read
KV Namespaces (write)Workers KV StorageAccount: Write
KV Keys (read)Workers KV StorageAccount: Read
KV Values (read/write)Workers KV StorageAccount: Write
KV Bulk OperationsWorkers KV StorageAccount: Write
+ ### Account Security diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index af2759a..a3145a4 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -79,6 +79,10 @@ href: accounts/r2/sippy.md - name: Temporary Credentials href: accounts/r2/temp-credentials.md + - name: Workers + items: + - name: Workers KV + href: accounts/workers/kv.md - name: Security items: - name: Access Rules diff --git a/docs/articles/zones/dns-records.md b/docs/articles/zones/dns-records.md index a93e502..4a44aad 100644 --- a/docs/articles/zones/dns-records.md +++ b/docs/articles/zones/dns-records.md @@ -92,7 +92,7 @@ var page = await cf.Dns.ListDnsRecordsAsync(zoneId, new ListDnsRecordsFilters PerPage = 100 }); -Console.WriteLine($"Total records: {page.ResultInfo.TotalCount}"); +Console.WriteLine($"Total records: {page.PageInfo.TotalCount}"); foreach (var record in page.Items) { diff --git a/docs/articles/zones/zone-management.md b/docs/articles/zones/zone-management.md index be29783..a8ffbc1 100644 --- a/docs/articles/zones/zone-management.md +++ b/docs/articles/zones/zone-management.md @@ -81,7 +81,7 @@ var page = await cf.Zones.ListZonesAsync(new ListZonesFilters PerPage = 50 }); -Console.WriteLine($"Found {page.ResultInfo.TotalCount} zones"); +Console.WriteLine($"Found {page.PageInfo.TotalCount} zones"); foreach (var zone in page.Items) { diff --git a/src/Cloudflare.NET/Accounts/AccountsApi.cs b/src/Cloudflare.NET/Accounts/AccountsApi.cs index 6b03758..973753c 100644 --- a/src/Cloudflare.NET/Accounts/AccountsApi.cs +++ b/src/Cloudflare.NET/Accounts/AccountsApi.cs @@ -5,6 +5,7 @@ using Core; using Core.Internal; using Core.Models; +using Kv; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Models; @@ -24,6 +25,9 @@ public class AccountsApi : ApiResource, IAccountsApi /// The lazy-initialized Account Rulesets API resource. private readonly Lazy _rulesets; + /// The lazy-initialized Workers KV API resource. + private readonly Lazy _kv; + #endregion @@ -39,6 +43,7 @@ public AccountsApi(HttpClient httpClient, IOptions options _buckets = new Lazy(() => new R2BucketsApi(httpClient, options, loggerFactory)); _accessRules = new Lazy(() => new AccountAccessRulesApi(httpClient, options, loggerFactory)); _rulesets = new Lazy(() => new AccountRulesetsApi(httpClient, options, loggerFactory)); + _kv = new Lazy(() => new KvApi(httpClient, options, loggerFactory)); } #endregion @@ -55,6 +60,9 @@ public AccountsApi(HttpClient httpClient, IOptions options /// public IAccountRulesetsApi Rulesets => _rulesets.Value; + /// + public IKvApi Kv => _kv.Value; + #endregion #region Methods Impl - Legacy Bucket Operations (Delegating to Buckets API) diff --git a/src/Cloudflare.NET/Accounts/IAccountsApi.cs b/src/Cloudflare.NET/Accounts/IAccountsApi.cs index 608ad67..c5ebc55 100644 --- a/src/Cloudflare.NET/Accounts/IAccountsApi.cs +++ b/src/Cloudflare.NET/Accounts/IAccountsApi.cs @@ -3,6 +3,7 @@ using AccessRules; using Buckets; using Core.Models; +using Kv; using Models; using Rulesets; @@ -30,6 +31,11 @@ public interface IAccountsApi /// Corresponds to the `/accounts/{account_id}/rulesets` endpoint family. IAccountRulesetsApi Rulesets { get; } + /// Gets the API for managing Workers KV namespaces and key-value pairs. + /// Corresponds to the `/accounts/{account_id}/storage/kv/namespaces` endpoint family. + /// + IKvApi Kv { get; } + /// Creates a new R2 bucket within the configured account with optional location and storage settings. /// The desired name for the new bucket. Must be unique. /// diff --git a/src/Cloudflare.NET/Accounts/Kv/IKvApi.cs b/src/Cloudflare.NET/Accounts/Kv/IKvApi.cs new file mode 100644 index 0000000..e2f62cb --- /dev/null +++ b/src/Cloudflare.NET/Accounts/Kv/IKvApi.cs @@ -0,0 +1,273 @@ +namespace Cloudflare.NET.Accounts.Kv; + +using System.Text.Json; +using Core.Exceptions; +using Core.Models; +using Models; + +/// +/// Provides access to Cloudflare Workers KV operations. +/// Workers KV is a global, low-latency key-value data store. +/// +/// +/// +/// All operations are account-scoped. The account ID is configured +/// via . +/// +/// +/// API Limits: +/// +/// Key name max length: 512 bytes +/// Value max size: 25 MiB +/// Metadata max size: 1024 bytes (serialized JSON) +/// Minimum TTL: 60 seconds +/// Write rate limit: 1 per second per key +/// +/// +/// +/// +public interface IKvApi +{ + #region Namespace Operations + + /// Lists KV namespaces in the account. + /// Optional filters for pagination and ordering. + /// Cancellation token. + /// A page of namespaces with pagination info. + /// + Task> ListAsync( + ListKvNamespacesFilters? filters = null, + CancellationToken cancellationToken = default); + + /// Lists all KV namespaces, automatically handling pagination. + /// Optional filters for ordering. + /// Cancellation token. + /// An async enumerable of all namespaces. + IAsyncEnumerable ListAllAsync( + ListKvNamespacesFilters? filters = null, + CancellationToken cancellationToken = default); + + /// Creates a new KV namespace. + /// The title for the new namespace. Must be unique per account. + /// Cancellation token. + /// The created namespace. + /// Thrown if title already exists (code 10014). + /// + Task CreateAsync( + string title, + CancellationToken cancellationToken = default); + + /// Gets a specific KV namespace by ID. + /// The namespace ID. + /// Cancellation token. + /// The namespace details. + /// + Task GetAsync( + string namespaceId, + CancellationToken cancellationToken = default); + + /// Renames a KV namespace. + /// The namespace ID to rename. + /// The new title for the namespace. Must be unique per account. + /// Cancellation token. + /// The updated namespace with id, title, and supports_url_encoding. + /// + Task RenameAsync( + string namespaceId, + string title, + CancellationToken cancellationToken = default); + + /// Deletes a KV namespace and all its keys. + /// The namespace ID to delete. + /// Cancellation token. + /// + Task DeleteAsync( + string namespaceId, + CancellationToken cancellationToken = default); + + #endregion + + + #region Key Operations + + /// Lists keys in a namespace with cursor-based pagination. + /// The namespace ID. + /// Optional filters for prefix, limit, and cursor. + /// Cancellation token. + /// Keys and pagination info (use CursorInfo.Cursor for next page). + /// + Task> ListKeysAsync( + string namespaceId, + ListKvKeysFilters? filters = null, + CancellationToken cancellationToken = default); + + /// Lists all keys in a namespace, automatically handling pagination. + /// The namespace ID. + /// Optional prefix filter. + /// Cancellation token. + /// An async enumerable of all keys. + IAsyncEnumerable ListAllKeysAsync( + string namespaceId, + string? prefix = null, + CancellationToken cancellationToken = default); + + #endregion + + + #region Value Operations + + /// Reads a value by key as a string. + /// The namespace ID. + /// The key name. + /// Cancellation token. + /// The value as a string, or null if the key doesn't exist. + /// Use if you need the expiration timestamp. + /// + Task GetValueAsync( + string namespaceId, + string key, + CancellationToken cancellationToken = default); + + /// Reads a value by key as a string, including expiration info. + /// The namespace ID. + /// The key name. + /// Cancellation token. + /// The value and expiration, or null if the key doesn't exist. + /// + Task GetValueWithExpirationAsync( + string namespaceId, + string key, + CancellationToken cancellationToken = default); + + /// Reads a value by key as bytes. + /// The namespace ID. + /// The key name. + /// Cancellation token. + /// The value as bytes, or null if the key doesn't exist. + /// Use if you need the expiration timestamp. + /// + Task GetValueBytesAsync( + string namespaceId, + string key, + CancellationToken cancellationToken = default); + + /// Reads a value by key as bytes, including expiration info. + /// The namespace ID. + /// The key name. + /// Cancellation token. + /// The value and expiration, or null if the key doesn't exist. + /// + Task GetValueBytesWithExpirationAsync( + string namespaceId, + string key, + CancellationToken cancellationToken = default); + + /// Reads only the metadata for a key (without retrieving the value). + /// The namespace ID. + /// The key name. + /// Cancellation token. + /// The metadata as JSON, or null if no metadata exists. + /// + Task GetMetadataAsync( + string namespaceId, + string key, + CancellationToken cancellationToken = default); + + /// Writes a string value. + /// The namespace ID. + /// The key name (max 512 bytes). + /// The value to store (max 25 MiB). + /// Optional expiration and metadata settings. + /// Cancellation token. + /// + Task WriteValueAsync( + string namespaceId, + string key, + string value, + KvWriteOptions? options = null, + CancellationToken cancellationToken = default); + + /// Writes a binary value. + /// The namespace ID. + /// The key name (max 512 bytes). + /// The value to store (max 25 MiB). + /// Optional expiration and metadata settings. + /// Cancellation token. + /// + Task WriteValueAsync( + string namespaceId, + string key, + byte[] value, + KvWriteOptions? options = null, + CancellationToken cancellationToken = default); + + /// Deletes a key-value pair. + /// The namespace ID. + /// The key name to delete. + /// Cancellation token. + /// + Task DeleteValueAsync( + string namespaceId, + string key, + CancellationToken cancellationToken = default); + + #endregion + + + #region Bulk Operations + + /// Writes multiple key-value pairs in a single request. + /// The namespace ID. + /// Key-value pairs to write (max 10,000 items, 100MB total). + /// Cancellation token. + /// Result indicating success/failure counts. + /// + /// Existing values and expirations will be overwritten. + /// Check and retry if needed. + /// + /// + Task BulkWriteAsync( + string namespaceId, + IEnumerable items, + CancellationToken cancellationToken = default); + + /// Deletes multiple keys in a single request. + /// The namespace ID. + /// Keys to delete (max 10,000 keys). + /// Cancellation token. + /// Result indicating success/failure counts. + /// The request body is a simple JSON array of key names: ["key1", "key2"]. + /// + Task BulkDeleteAsync( + string namespaceId, + IEnumerable keys, + CancellationToken cancellationToken = default); + + /// Retrieves multiple values in a single request. + /// The namespace ID. + /// Keys to retrieve (max 100 keys). + /// Cancellation token. + /// Dictionary of key-value pairs (value is null for non-existent keys). + /// + /// The API returns values as a dictionary: { "key1": "value1", "key2": "value2" }. + /// Use if you need metadata for each key. + /// + /// + Task> BulkGetAsync( + string namespaceId, + IEnumerable keys, + CancellationToken cancellationToken = default); + + /// Retrieves multiple values with metadata in a single request. + /// The namespace ID. + /// Keys to retrieve (max 100 keys). + /// Cancellation token. + /// Dictionary of key-value pairs with metadata. + /// + Task> BulkGetWithMetadataAsync( + string namespaceId, + IEnumerable keys, + CancellationToken cancellationToken = default); + + #endregion +} diff --git a/src/Cloudflare.NET/Accounts/Kv/KvApi.cs b/src/Cloudflare.NET/Accounts/Kv/KvApi.cs new file mode 100644 index 0000000..1267e10 --- /dev/null +++ b/src/Cloudflare.NET/Accounts/Kv/KvApi.cs @@ -0,0 +1,543 @@ +namespace Cloudflare.NET.Accounts.Kv; + +using System.Net; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Core; +using Core.Internal; +using Core.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Models; + +/// Implementation of Workers KV API operations. +public class KvApi : ApiResource, IKvApi +{ + #region Constants + + /// The HTTP header name for KV value expiration. + private const string ExpirationHeaderName = "expiration"; + + #endregion + + + #region Properties & Fields - Non-Public + + /// The Cloudflare Account ID. + private readonly string _accountId; + + /// JSON serializer options for camelCase (used by bulk get endpoint). + private readonly JsonSerializerOptions _camelCaseOptions; + + #endregion + + + #region Constructors + + /// Initializes a new instance of the class. + /// The HttpClient for making requests. + /// The Cloudflare API options containing the account ID. + /// The factory to create loggers. + public KvApi(HttpClient httpClient, IOptions options, ILoggerFactory loggerFactory) + : base(httpClient, loggerFactory.CreateLogger()) + { + _accountId = options.Value.AccountId; + + // The KV Bulk Get endpoint uses camelCase for request body properties. + _camelCaseOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + } + + #endregion + + + #region Helper Methods + + /// URL-encodes a key name for use in API paths. + /// The key name to encode. + /// The URL-encoded key name. + private static string EncodeKeyName(string key) => Uri.EscapeDataString(key); + + /// Builds query string parameters for expiration options. + /// The write options containing expiration settings. + /// Query string (including leading '?') or empty string if no expiration. + private static string BuildExpirationQueryParams(KvWriteOptions? options) + { + if (options == null) + return string.Empty; + + var parts = new List(); + + if (options.Expiration.HasValue) + parts.Add($"expiration={options.Expiration.Value}"); + else if (options.ExpirationTtl.HasValue) + parts.Add($"expiration_ttl={options.ExpirationTtl.Value}"); + + return parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty; + } + + /// Extracts the expiration timestamp from the HTTP response headers. + /// The HTTP response. + /// The expiration timestamp, or null if not present. + private static long? ExtractExpirationHeader(HttpResponseMessage response) + { + if (response.Headers.TryGetValues(ExpirationHeaderName, out var expirationValues)) + { + var expirationStr = expirationValues.FirstOrDefault(); + + if (!string.IsNullOrEmpty(expirationStr) && long.TryParse(expirationStr, out var exp)) + return exp; + } + + return null; + } + + #endregion + + + #region Namespace Operations + + /// + public async Task> ListAsync( + ListKvNamespacesFilters? filters = null, + CancellationToken cancellationToken = default) + { + var queryParams = new List(); + + if (filters?.Page.HasValue == true) + queryParams.Add($"page={filters.Page.Value}"); + + if (filters?.PerPage.HasValue == true) + queryParams.Add($"per_page={filters.PerPage.Value}"); + + if (filters?.Order.HasValue == true) + queryParams.Add($"order={EnumHelper.GetEnumMemberValue(filters.Order.Value)}"); + + if (filters?.Direction.HasValue == true) + queryParams.Add($"direction={EnumHelper.GetEnumMemberValue(filters.Direction.Value)}"); + + var queryString = queryParams.Count > 0 ? $"?{string.Join('&', queryParams)}" : string.Empty; + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces{queryString}"; + + return await GetPagePaginatedResultAsync(endpoint, cancellationToken); + } + + /// + public async IAsyncEnumerable ListAllAsync( + ListKvNamespacesFilters? filters = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var page = 1; + var hasMore = true; + + while (hasMore) + { + cancellationToken.ThrowIfCancellationRequested(); + + var pageFilters = new ListKvNamespacesFilters( + Page: page, + PerPage: filters?.PerPage ?? 100, + Order: filters?.Order, + Direction: filters?.Direction + ); + + var result = await ListAsync(pageFilters, cancellationToken); + + foreach (var ns in result.Items) + { + yield return ns; + } + + // Check if there are more pages. + hasMore = result.PageInfo != null + && result.PageInfo.Page < result.PageInfo.TotalPages; + + page++; + } + } + + /// + public async Task CreateAsync( + string title, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces"; + var request = new CreateKvNamespaceRequest(title); + + return await PostAsync(endpoint, request, cancellationToken); + } + + /// + public async Task GetAsync( + string namespaceId, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}"; + + return await GetAsync(endpoint, cancellationToken); + } + + /// + public async Task RenameAsync( + string namespaceId, + string title, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}"; + var request = new RenameKvNamespaceRequest(title); + + return await PutAsync(endpoint, request, cancellationToken); + } + + /// + public async Task DeleteAsync( + string namespaceId, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}"; + + await DeleteAsync(endpoint, cancellationToken); + } + + #endregion + + + #region Key Operations + + /// + public async Task> ListKeysAsync( + string namespaceId, + ListKvKeysFilters? filters = null, + CancellationToken cancellationToken = default) + { + var queryParams = new List(); + + if (!string.IsNullOrEmpty(filters?.Prefix)) + queryParams.Add($"prefix={Uri.EscapeDataString(filters.Prefix)}"); + + if (filters?.Limit.HasValue == true) + queryParams.Add($"limit={filters.Limit.Value}"); + + if (!string.IsNullOrEmpty(filters?.Cursor)) + queryParams.Add($"cursor={Uri.EscapeDataString(filters.Cursor)}"); + + var queryString = queryParams.Count > 0 ? $"?{string.Join('&', queryParams)}" : string.Empty; + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}/keys{queryString}"; + + return await GetCursorPaginatedResultAsync(endpoint, cancellationToken); + } + + /// + public async IAsyncEnumerable ListAllKeysAsync( + string namespaceId, + string? prefix = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string? cursor = null; + + do + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await ListKeysAsync( + namespaceId, + new ListKvKeysFilters(Prefix: prefix, Limit: 1000, Cursor: cursor), + cancellationToken); + + foreach (var key in result.Items) + { + yield return key; + } + + // Get cursor from CursorInfo (standard envelope pattern). + cursor = result.CursorInfo?.Cursor; + } + while (!string.IsNullOrEmpty(cursor)); + } + + #endregion + + + #region Value Operations + + /// + public async Task GetValueAsync( + string namespaceId, + string key, + CancellationToken cancellationToken = default) + { + var result = await GetValueWithExpirationAsync(namespaceId, key, cancellationToken); + + return result?.Value; + } + + /// + public async Task GetValueWithExpirationAsync( + string namespaceId, + string key, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}/values/{EncodeKeyName(key)}"; + + Logger.SendingRequest("GET", endpoint); + + var response = await HttpClient.GetAsync(endpoint, cancellationToken); + + Logger.ReceivedResponse(response.StatusCode, response.RequestMessage?.RequestUri); + + // Return null if key doesn't exist. + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + // Handle other error responses. + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException( + $"Cloudflare API request failed with status code {(int)response.StatusCode} ({response.ReasonPhrase}). Response Body: {responseBody}", + null, + response.StatusCode); + } + + var value = await response.Content.ReadAsStringAsync(cancellationToken); + var expiration = ExtractExpirationHeader(response); + + return new KvStringValueResult(value, expiration); + } + + /// + public async Task GetValueBytesAsync( + string namespaceId, + string key, + CancellationToken cancellationToken = default) + { + var result = await GetValueBytesWithExpirationAsync(namespaceId, key, cancellationToken); + + return result?.Value; + } + + /// + public async Task GetValueBytesWithExpirationAsync( + string namespaceId, + string key, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}/values/{EncodeKeyName(key)}"; + + Logger.SendingRequest("GET", endpoint); + + var response = await HttpClient.GetAsync(endpoint, cancellationToken); + + Logger.ReceivedResponse(response.StatusCode, response.RequestMessage?.RequestUri); + + // Return null if key doesn't exist. + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + // Handle other error responses. + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException( + $"Cloudflare API request failed with status code {(int)response.StatusCode} ({response.ReasonPhrase}). Response Body: {responseBody}", + null, + response.StatusCode); + } + + var value = await response.Content.ReadAsByteArrayAsync(cancellationToken); + var expiration = ExtractExpirationHeader(response); + + return new KvValueResult(value, expiration); + } + + /// + public async Task GetMetadataAsync( + string namespaceId, + string key, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}/metadata/{EncodeKeyName(key)}"; + + try + { + return await GetAsync(endpoint, cancellationToken); + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + } + + /// + public async Task WriteValueAsync( + string namespaceId, + string key, + string value, + KvWriteOptions? options = null, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}/values/{EncodeKeyName(key)}"; + + // Expiration is always passed via query params (even with multipart). + var queryParams = BuildExpirationQueryParams(options); + var fullEndpoint = endpoint + queryParams; + + Logger.SendingRequest("PUT", fullEndpoint); + + HttpResponseMessage response; + + if (options?.Metadata != null) + { + // Use multipart/form-data when metadata is provided. + using var content = new MultipartFormDataContent(); + content.Add(new StringContent(value), "value"); + content.Add(new StringContent(options.Metadata.Value.GetRawText()), "metadata"); + + response = await HttpClient.PutAsync(fullEndpoint, content, cancellationToken); + } + else + { + // Simple text body for value-only writes. + var content = new StringContent(value, Encoding.UTF8, "text/plain"); + response = await HttpClient.PutAsync(fullEndpoint, content, cancellationToken); + } + + Logger.ReceivedResponse(response.StatusCode, response.RequestMessage?.RequestUri); + + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException( + $"Cloudflare API request failed with status code {(int)response.StatusCode} ({response.ReasonPhrase}). Response Body: {responseBody}", + null, + response.StatusCode); + } + } + + /// + public async Task WriteValueAsync( + string namespaceId, + string key, + byte[] value, + KvWriteOptions? options = null, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}/values/{EncodeKeyName(key)}"; + + // Expiration is always passed via query params (even with multipart). + var queryParams = BuildExpirationQueryParams(options); + var fullEndpoint = endpoint + queryParams; + + Logger.SendingRequest("PUT", fullEndpoint); + + HttpResponseMessage response; + + if (options?.Metadata != null) + { + // Use multipart/form-data when metadata is provided. + using var content = new MultipartFormDataContent(); + content.Add(new ByteArrayContent(value), "value"); + content.Add(new StringContent(options.Metadata.Value.GetRawText()), "metadata"); + + response = await HttpClient.PutAsync(fullEndpoint, content, cancellationToken); + } + else + { + // Binary body for value-only writes. + var content = new ByteArrayContent(value); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); + response = await HttpClient.PutAsync(fullEndpoint, content, cancellationToken); + } + + Logger.ReceivedResponse(response.StatusCode, response.RequestMessage?.RequestUri); + + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException( + $"Cloudflare API request failed with status code {(int)response.StatusCode} ({response.ReasonPhrase}). Response Body: {responseBody}", + null, + response.StatusCode); + } + } + + /// + public async Task DeleteValueAsync( + string namespaceId, + string key, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}/values/{EncodeKeyName(key)}"; + + await DeleteAsync(endpoint, cancellationToken); + } + + #endregion + + + #region Bulk Operations + + /// + public async Task BulkWriteAsync( + string namespaceId, + IEnumerable items, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}/bulk"; + + // The API expects a JSON array of items directly. + return await PutAsync(endpoint, items.ToList(), cancellationToken); + } + + /// + public async Task BulkDeleteAsync( + string namespaceId, + IEnumerable keys, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}/bulk/delete"; + + // The API expects a simple JSON array of key names: ["key1", "key2"]. + // Use POST method (not DELETE) as per API documentation. + return await PostAsync(endpoint, keys.ToList(), cancellationToken); + } + + /// + public async Task> BulkGetAsync( + string namespaceId, + IEnumerable keys, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}/bulk/get"; + var request = new KvBulkGetRequest(keys.ToList()); + + // The bulk get endpoint uses camelCase for request body (withMetadata, not with_metadata). + var jsonContent = JsonSerializer.Serialize(request, _camelCaseOptions); + var result = await PostJsonAsync(endpoint, jsonContent, cancellationToken); + + return result.Values; + } + + /// + public async Task> BulkGetWithMetadataAsync( + string namespaceId, + IEnumerable keys, + CancellationToken cancellationToken = default) + { + var endpoint = $"accounts/{_accountId}/storage/kv/namespaces/{Uri.EscapeDataString(namespaceId)}/bulk/get"; + var request = new KvBulkGetRequest(keys.ToList(), WithMetadata: true); + + // The bulk get endpoint uses camelCase for request body (withMetadata, not with_metadata). + var jsonContent = JsonSerializer.Serialize(request, _camelCaseOptions); + var result = await PostJsonAsync(endpoint, jsonContent, cancellationToken); + + return result.Values; + } + + #endregion +} diff --git a/src/Cloudflare.NET/Accounts/Kv/Models/KvModels.cs b/src/Cloudflare.NET/Accounts/Kv/Models/KvModels.cs new file mode 100644 index 0000000..4ffe69d --- /dev/null +++ b/src/Cloudflare.NET/Accounts/Kv/Models/KvModels.cs @@ -0,0 +1,240 @@ +namespace Cloudflare.NET.Accounts.Kv.Models; + +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; +using Core.Json; +using Security.Firewall.Models; + +#region Namespace Types + +/// Represents a Workers KV namespace. +/// The unique identifier for the namespace. +/// The human-readable name of the namespace. +/// Whether the namespace supports URL-encoded keys. +public record KvNamespace( + [property: JsonPropertyName("id")] + string Id, + + [property: JsonPropertyName("title")] + string Title, + + [property: JsonPropertyName("supports_url_encoding")] + bool? SupportsUrlEncoding = null +); + +/// Filters for listing KV namespaces. +/// Page number of results to return (1-based). +/// Number of results per page (max 100). +/// Field to order results by. +/// Sort direction (asc or desc). +public record ListKvNamespacesFilters( + int? Page = null, + int? PerPage = null, + KvNamespaceOrderField? Order = null, + ListOrderDirection? Direction = null +); + +/// Fields available for ordering KV namespace lists. +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum KvNamespaceOrderField +{ + /// Order by namespace ID. + [EnumMember(Value = "id")] + Id, + + /// Order by namespace title. + [EnumMember(Value = "title")] + Title +} + +#endregion + + +#region Key Types + +/// Represents a key in a KV namespace (from list keys response). +/// The key name (max 512 bytes). +/// Unix timestamp when the key expires (if set). +/// Optional JSON metadata associated with the key. +public record KvKey( + [property: JsonPropertyName("name")] + string Name, + + [property: JsonPropertyName("expiration")] + long? Expiration = null, + + [property: JsonPropertyName("metadata")] + JsonElement? Metadata = null +); + +/// Filters for listing keys in a KV namespace. +/// Filter keys by prefix. +/// Maximum number of keys to return (max 1000). +/// Cursor for pagination (from previous result_info.cursor). +public record ListKvKeysFilters( + string? Prefix = null, + int? Limit = null, + string? Cursor = null +); + +#endregion + + +#region Value Read Types + +/// Result of reading a key-value pair, including expiration from HTTP header. +/// The raw value bytes. +/// Unix timestamp when the key expires (from 'expiration' header), or null if no expiration. +/// +/// Single key reads return raw value directly (not JSON envelope). +/// The expiration is provided in the HTTP 'expiration' response header. +/// +public record KvValueResult( + byte[] Value, + long? Expiration = null +); + +/// Result of reading a key-value pair as string, including expiration. +/// The value as a string. +/// Unix timestamp when the key expires (from 'expiration' header), or null if no expiration. +public record KvStringValueResult( + string Value, + long? Expiration = null +); + +#endregion + + +#region Value Write & Bulk Types + +/// Options for writing a key-value pair. +/// Unix timestamp when the key should expire. +/// Time-to-live in seconds (min 60). Ignored if Expiration is set. +/// Optional JSON metadata to associate with the key (max 1024 bytes serialized). +public record KvWriteOptions( + long? Expiration = null, + int? ExpirationTtl = null, + JsonElement? Metadata = null +); + +/// A key-value pair for bulk write operations. +/// The key name (max 512 bytes). +/// The value to store (base64 encoded for binary). +/// Whether the value is base64 encoded. +/// Unix timestamp when the key should expire. +/// Time-to-live in seconds (min 60). +/// Optional JSON metadata (max 1024 bytes serialized). +public record KvBulkWriteItem( + [property: JsonPropertyName("key")] + string Key, + + [property: JsonPropertyName("value")] + string Value, + + [property: JsonPropertyName("base64")] + bool? Base64 = null, + + [property: JsonPropertyName("expiration")] + long? Expiration = null, + + [property: JsonPropertyName("expiration_ttl")] + int? ExpirationTtl = null, + + [property: JsonPropertyName("metadata")] + JsonElement? Metadata = null +); + +/// Result of a bulk write operation. +/// Number of keys successfully written. +/// Keys that failed to write (should be retried). +public record KvBulkWriteResult( + [property: JsonPropertyName("successful_key_count")] + int SuccessfulKeyCount, + + [property: JsonPropertyName("unsuccessful_keys")] + IReadOnlyList? UnsuccessfulKeys = null +); + +/// Result of a bulk delete operation. +/// Number of keys successfully deleted. +/// Keys that failed to delete (should be retried). +public record KvBulkDeleteResult( + [property: JsonPropertyName("successful_key_count")] + int SuccessfulKeyCount, + + [property: JsonPropertyName("unsuccessful_keys")] + IReadOnlyList? UnsuccessfulKeys = null +); + +/// Request for bulk get operation. +/// Array of key names to retrieve (max 100). +/// Value type: "text" or "json". +/// Whether to include metadata in response. +/// +/// Note: The Cloudflare Bulk Get endpoint uses camelCase for the request body properties. +/// This differs from most other Cloudflare API endpoints which use snake_case. +/// +internal record KvBulkGetRequest( + [property: JsonPropertyName("keys")] + IReadOnlyList Keys, + + [property: JsonPropertyName("type")] + string? Type = null, + + [property: JsonPropertyName("withMetadata")] + bool? WithMetadata = null +); + +/// Response from bulk get operation (without metadata). +/// Dictionary of key-value pairs. Keys not found will have null values. +/// +/// The API returns values as a dictionary: { "key1": "value1", "key2": "value2" }. +/// Keys that were not found will have null values in the dictionary. +/// +internal record KvBulkGetResult( + [property: JsonPropertyName("values")] + IReadOnlyDictionary Values +); + +/// Response from bulk get operation (with metadata). +/// Dictionary of key-value pairs with metadata. +/// +/// When withMetadata=true, each value is an object containing value and metadata. +/// +internal record KvBulkGetResultWithMetadata( + [property: JsonPropertyName("values")] + IReadOnlyDictionary Values +); + +/// A key-value pair with metadata from bulk get. +/// The value (null if key not found). +/// The metadata associated with the key. +public record KvBulkGetItemWithMetadata( + [property: JsonPropertyName("value")] + string? Value, + + [property: JsonPropertyName("metadata")] + JsonElement? Metadata = null +); + +#endregion + + +#region Internal Request Types + +/// Internal request body for creating a KV namespace. +/// The title for the new namespace. +internal record CreateKvNamespaceRequest( + [property: JsonPropertyName("title")] + string Title +); + +/// Internal request body for renaming a KV namespace. +/// The new title for the namespace. +internal record RenameKvNamespaceRequest( + [property: JsonPropertyName("title")] + string Title +); + +#endregion diff --git a/src/Cloudflare.NET/Cloudflare.NET.csproj b/src/Cloudflare.NET/Cloudflare.NET.csproj index 01d507d..808b69f 100644 --- a/src/Cloudflare.NET/Cloudflare.NET.csproj +++ b/src/Cloudflare.NET/Cloudflare.NET.csproj @@ -5,7 +5,7 @@ Cloudflare.NET.Api - 3.1.0 + 3.2.0 Cloudflare.NET - A comprehensive C# client library for the Cloudflare REST API. Manage DNS records, Zones, R2 buckets, Workers, WAF rules, Turnstile, and security features with strongly-typed .NET code. cloudflare;cloudflare-api;cloudflare-sdk;cloudflare-client;dotnet;csharp;dns;r2;waf;firewall;zone;workers;turnstile;api-client;rest-client diff --git a/tests/Cloudflare.NET.Tests/IntegrationTests/KvApiIntegrationTests.cs b/tests/Cloudflare.NET.Tests/IntegrationTests/KvApiIntegrationTests.cs new file mode 100644 index 0000000..9cf4b48 --- /dev/null +++ b/tests/Cloudflare.NET.Tests/IntegrationTests/KvApiIntegrationTests.cs @@ -0,0 +1,781 @@ +namespace Cloudflare.NET.Tests.IntegrationTests; + +using System.Net; +using System.Text.Json; +using Accounts; +using Accounts.Kv; +using Accounts.Kv.Models; +using Cloudflare.NET.Core.Exceptions; +using Fixtures; +using Microsoft.Extensions.DependencyInjection; +using Shared.Fixtures; +using Shared.Helpers; +using Xunit.Abstractions; + +/// +/// Contains integration tests for the Workers KV operations of . These tests interact with the +/// live Cloudflare API and require credentials. +/// +/// +/// This test class covers all Workers KV operations including: +/// +/// Namespace CRUD operations (List, Create, Get, Rename, Delete) +/// Key operations (List keys with filters) +/// Value operations (Read, Write, Delete) +/// Bulk operations (BulkWrite, BulkDelete, BulkGet) +/// Metadata and expiration handling +/// +/// +[Trait("Category", TestConstants.TestCategories.Integration)] +public class KvApiIntegrationTests : IClassFixture, IAsyncLifetime +{ + #region Properties & Fields - Non-Public + + /// The subject under test, resolved from the test fixture. + private readonly IKvApi _sut; + + /// A unique title for the KV namespace used in this test run, to avoid collisions. + private readonly string _namespaceTitle = $"cfnet-test-kv-{Guid.NewGuid():N}"; + + /// The ID of the created namespace, populated during InitializeAsync. + private string _namespaceId = string.Empty; + + /// The xUnit test output helper for writing warnings. + private readonly ITestOutputHelper _output; + + #endregion + + + #region Constructors + + /// Initializes a new instance of the class. + /// The shared test fixture that provides configured API clients. + /// The xUnit test output helper. + public KvApiIntegrationTests(CloudflareApiTestFixture fixture, ITestOutputHelper output) + { + // The SUT is resolved via the fixture's pre-configured DI container. + _sut = fixture.AccountsApi.Kv; + _output = output; + + // Wire up the logger provider to the current test's output. + var loggerProvider = fixture.ServiceProvider.GetRequiredService(); + loggerProvider.Current = output; + } + + #endregion + + + #region Methods Impl + + /// Asynchronously creates the KV namespace required for the tests. This runs once before any tests in this class. + public async Task InitializeAsync() + { + // Create a new KV namespace for the test run. + var ns = await _sut.CreateAsync(_namespaceTitle); + _namespaceId = ns.Id; + + _output.WriteLine($"Created test namespace: {_namespaceId} ({_namespaceTitle})"); + } + + /// Asynchronously deletes the KV namespace after all tests in this class have run. + public async Task DisposeAsync() + { + // Clean up the KV namespace. + if (!string.IsNullOrEmpty(_namespaceId)) + { + try + { + await _sut.DeleteAsync(_namespaceId); + _output.WriteLine($"Deleted test namespace: {_namespaceId}"); + } + catch (Exception ex) + { + _output.WriteLine($"Failed to delete test namespace: {ex.Message}"); + } + } + } + + #endregion + + + #region Namespace Operations + + /// Verifies that KV namespaces can be listed successfully. + [IntegrationTest] + public async Task ListAsync_CanListSuccessfully() + { + // Arrange (namespace is created in InitializeAsync) + + // Act + var result = await _sut.ListAsync(); + + // Assert + result.Items.Should().NotBeEmpty("at least one namespace should exist"); + result.Items.Should().Contain(ns => ns.Id == _namespaceId, "the test namespace should be in the list"); + } + + /// Verifies that ListAllAsync can iterate through all namespaces. + [IntegrationTest] + public async Task ListAllAsync_CanIterateThroughAllNamespaces() + { + // Arrange - Create a second namespace to ensure multiple exist + var secondNamespaceTitle = $"cfnet-test-kv-{Guid.NewGuid():N}"; + var secondNs = await _sut.CreateAsync(secondNamespaceTitle); + + try + { + // Act + var allNamespaces = new List(); + await foreach (var ns in _sut.ListAllAsync()) + allNamespaces.Add(ns); + + // Assert + allNamespaces.Should().NotBeEmpty(); + allNamespaces.Should().Contain(ns => ns.Id == _namespaceId, "the primary test namespace should be found"); + allNamespaces.Should().Contain(ns => ns.Id == secondNs.Id, "the second test namespace should be found"); + } + finally + { + // Cleanup + await _sut.DeleteAsync(secondNs.Id); + } + } + + /// Verifies that pagination works correctly with small page size. + [IntegrationTest] + public async Task ListAllAsync_ShouldPaginateBeyondFirstPage() + { + // Arrange - Create multiple namespaces to force pagination + var testPrefix = $"cfnet-page-{Guid.NewGuid():N}"; + var ns1 = await _sut.CreateAsync($"{testPrefix}-1"); + var ns2 = await _sut.CreateAsync($"{testPrefix}-2"); + var ns3 = await _sut.CreateAsync($"{testPrefix}-3"); + + var createdIds = new[] { ns1.Id, ns2.Id, ns3.Id }; + + try + { + // Act - Use PerPage=2 to force multiple pages for 3+ namespaces + var allNamespaces = new List(); + var filters = new ListKvNamespacesFilters(PerPage: 2); + + await foreach (var ns in _sut.ListAllAsync(filters)) + allNamespaces.Add(ns); + + // Assert - Must find all 3 test namespaces across pages + var testNamespaces = allNamespaces.Where(ns => createdIds.Contains(ns.Id)).ToList(); + testNamespaces.Should().HaveCount(3, "pagination should retrieve all namespaces across multiple pages"); + } + finally + { + // Cleanup + foreach (var id in createdIds) + { + try { await _sut.DeleteAsync(id); } + catch (HttpRequestException) { /* Ignore cleanup errors */ } + } + } + } + + /// Verifies that GetAsync retrieves namespace properties. + [IntegrationTest] + public async Task GetAsync_ReturnsNamespaceProperties() + { + // Arrange (namespace is created in InitializeAsync) + + // Act + var result = await _sut.GetAsync(_namespaceId); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(_namespaceId); + result.Title.Should().Be(_namespaceTitle); + result.SupportsUrlEncoding.Should().BeTrue("new namespaces support URL encoding by default"); + } + + /// Verifies that RenameAsync can rename a namespace. + [IntegrationTest] + public async Task RenameAsync_CanRenameNamespace() + { + // Arrange - Create a temporary namespace to rename + var originalTitle = $"cfnet-rename-test-{Guid.NewGuid():N}"; + var newTitle = $"cfnet-renamed-{Guid.NewGuid():N}"; + var ns = await _sut.CreateAsync(originalTitle); + + try + { + // Act + var result = await _sut.RenameAsync(ns.Id, newTitle); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(ns.Id); + result.Title.Should().Be(newTitle); + + // Verify by getting the namespace again + var verify = await _sut.GetAsync(ns.Id); + verify.Title.Should().Be(newTitle); + } + finally + { + // Cleanup + await _sut.DeleteAsync(ns.Id); + } + } + + /// Verifies that creating a namespace with a duplicate title fails with the expected error. + /// + /// The SDK throws for HTTP 4xx errors (like 400 Bad Request), + /// while is only thrown when HTTP status is 200 OK but + /// the response body contains success: false. + /// + [IntegrationTest] + public async Task CreateAsync_DuplicateTitle_ThrowsHttpRequestException() + { + // Arrange (namespace with _namespaceTitle is created in InitializeAsync) + + // Act + var action = async () => await _sut.CreateAsync(_namespaceTitle); + + // Assert - The SDK throws HttpRequestException for 400 Bad Request responses + var exception = await action.Should().ThrowAsync( + "creating a namespace with a duplicate title should fail"); + exception.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + exception.Which.Message.Should().Contain("10014", + "the error should contain the duplicate namespace error code"); + } + + /// Verifies that a namespace can be created and deleted successfully. + [IntegrationTest] + public async Task CanCreateAndDeleteNamespace() + { + // Arrange + var title = $"cfnet-standalone-{Guid.NewGuid():N}"; + + // Act + var createResult = await _sut.CreateAsync(title); + + // Assert + createResult.Should().NotBeNull(); + createResult.Id.Should().NotBeNullOrEmpty(); + createResult.Title.Should().Be(title); + + // Cleanup & verify deletion works + var deleteAction = async () => await _sut.DeleteAsync(createResult.Id); + await deleteAction.Should().NotThrowAsync("deletion should succeed"); + } + + #endregion + + + #region Value Operations + + /// Verifies that a string value can be written and read back. + [IntegrationTest] + public async Task CanWriteAndReadStringValue() + { + // Arrange + var key = $"test-key-{Guid.NewGuid():N}"; + var value = "Hello, Workers KV!"; + + // Act - Write the value + await _sut.WriteValueAsync(_namespaceId, key, value); + + // Act - Read the value back + var result = await _sut.GetValueAsync(_namespaceId, key); + + // Assert + result.Should().Be(value); + + // Cleanup + await _sut.DeleteValueAsync(_namespaceId, key); + } + + /// Verifies that a binary value can be written and read back. + [IntegrationTest] + public async Task CanWriteAndReadBinaryValue() + { + // Arrange + var key = $"test-binary-{Guid.NewGuid():N}"; + var value = new byte[] { 0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD }; + + // Act - Write the value + await _sut.WriteValueAsync(_namespaceId, key, value); + + // Act - Read the value back + var result = await _sut.GetValueBytesAsync(_namespaceId, key); + + // Assert + result.Should().BeEquivalentTo(value); + + // Cleanup + await _sut.DeleteValueAsync(_namespaceId, key); + } + + /// Verifies that a value can be written with metadata and retrieved. + [IntegrationTest] + public async Task CanWriteValueWithMetadata() + { + // Arrange + var key = $"test-metadata-{Guid.NewGuid():N}"; + var value = "value with metadata"; + var metadata = JsonSerializer.SerializeToElement(new { category = "test", version = 1 }); + var options = new KvWriteOptions(Metadata: metadata); + + // Act - Write the value with metadata + await _sut.WriteValueAsync(_namespaceId, key, value, options); + + // Act - Read the metadata + var metadataResult = await _sut.GetMetadataAsync(_namespaceId, key); + + // Assert + metadataResult.Should().NotBeNull(); + metadataResult!.Value.GetProperty("category").GetString().Should().Be("test"); + metadataResult.Value.GetProperty("version").GetInt32().Should().Be(1); + + // Verify the value is also correct + var valueResult = await _sut.GetValueAsync(_namespaceId, key); + valueResult.Should().Be(value); + + // Cleanup + await _sut.DeleteValueAsync(_namespaceId, key); + } + + /// Verifies that a value can be written with an expiration TTL. + [IntegrationTest] + public async Task CanWriteValueWithExpirationTtl() + { + // Arrange + var key = $"test-expiration-{Guid.NewGuid():N}"; + var value = "expiring value"; + // Use minimum TTL of 60 seconds + var options = new KvWriteOptions(ExpirationTtl: 60); + + // Act - Write the value with TTL + await _sut.WriteValueAsync(_namespaceId, key, value, options); + + // Act - Read the value with expiration + var result = await _sut.GetValueWithExpirationAsync(_namespaceId, key); + + // Assert + result.Should().NotBeNull(); + result!.Value.Should().Be(value); + // Note: The expiration header may not always be present immediately, depending on API behavior. + // If present, it should be in the future. + if (result.Expiration.HasValue) + { + var expirationTime = DateTimeOffset.FromUnixTimeSeconds(result.Expiration.Value); + expirationTime.Should().BeAfter(DateTimeOffset.UtcNow, "expiration should be in the future"); + } + + // Cleanup + await _sut.DeleteValueAsync(_namespaceId, key); + } + + /// Verifies that GetValueAsync returns null for non-existent keys. + [IntegrationTest] + public async Task GetValueAsync_NonExistentKey_ReturnsNull() + { + // Arrange + var key = $"non-existent-{Guid.NewGuid():N}"; + + // Act + var result = await _sut.GetValueAsync(_namespaceId, key); + + // Assert + result.Should().BeNull("non-existent keys should return null"); + } + + /// Verifies that a value can be deleted. + [IntegrationTest] + public async Task DeleteValueAsync_CanDeleteExistingKey() + { + // Arrange + var key = $"test-delete-{Guid.NewGuid():N}"; + var value = "value to delete"; + await _sut.WriteValueAsync(_namespaceId, key, value); + + // Verify it exists first + var beforeDelete = await _sut.GetValueAsync(_namespaceId, key); + beforeDelete.Should().Be(value); + + // Act + await _sut.DeleteValueAsync(_namespaceId, key); + + // Assert - Value should no longer exist + var afterDelete = await _sut.GetValueAsync(_namespaceId, key); + afterDelete.Should().BeNull("the key should be deleted"); + } + + /// Verifies that deleting a non-existent key does not throw an error. + [IntegrationTest] + public async Task DeleteValueAsync_NonExistentKey_DoesNotThrow() + { + // Arrange + var key = $"non-existent-delete-{Guid.NewGuid():N}"; + + // Act + var action = async () => await _sut.DeleteValueAsync(_namespaceId, key); + + // Assert - Should not throw (DELETE is idempotent) + await action.Should().NotThrowAsync("deleting a non-existent key should be idempotent"); + } + + /// Verifies that keys with special characters are handled correctly. + [IntegrationTest] + public async Task CanWriteAndReadKeyWithSpecialCharacters() + { + // Arrange - Key with special characters that need URL encoding + var key = $"path/to/key with spaces+and+plus/value-{Guid.NewGuid():N}"; + var value = "special character key value"; + + // Act - Write the value + await _sut.WriteValueAsync(_namespaceId, key, value); + + // Act - Read the value back + var result = await _sut.GetValueAsync(_namespaceId, key); + + // Assert + result.Should().Be(value); + + // Cleanup + await _sut.DeleteValueAsync(_namespaceId, key); + } + + #endregion + + + #region Key Operations + + /// Verifies that keys can be listed with a prefix filter. + [IntegrationTest] + public async Task ListKeysAsync_WithPrefix_FiltersCorrectly() + { + // Arrange - Create keys with different prefixes + var prefix = $"prefix-{Guid.NewGuid():N}/"; + var key1 = $"{prefix}key1"; + var key2 = $"{prefix}key2"; + var otherKey = $"other-{Guid.NewGuid():N}/key3"; + + await _sut.WriteValueAsync(_namespaceId, key1, "value1"); + await _sut.WriteValueAsync(_namespaceId, key2, "value2"); + await _sut.WriteValueAsync(_namespaceId, otherKey, "value3"); + + try + { + // Act + var result = await _sut.ListKeysAsync(_namespaceId, new ListKvKeysFilters(Prefix: prefix)); + + // Assert + result.Items.Should().HaveCount(2, "only keys with the prefix should be returned"); + result.Items.Select(k => k.Name).Should().Contain(key1); + result.Items.Select(k => k.Name).Should().Contain(key2); + result.Items.Select(k => k.Name).Should().NotContain(otherKey); + } + finally + { + // Cleanup + await _sut.DeleteValueAsync(_namespaceId, key1); + await _sut.DeleteValueAsync(_namespaceId, key2); + await _sut.DeleteValueAsync(_namespaceId, otherKey); + } + } + + /// Verifies that ListAllKeysAsync handles cursor pagination correctly. + [IntegrationTest] + public async Task ListAllKeysAsync_ShouldIterateThroughAllKeys() + { + // Arrange - Create multiple keys + var prefix = $"paginate-{Guid.NewGuid():N}/"; + var keys = Enumerable.Range(1, 5).Select(i => $"{prefix}key{i}").ToList(); + + foreach (var key in keys) + await _sut.WriteValueAsync(_namespaceId, key, $"value for {key}"); + + try + { + // Act + var allKeys = new List(); + await foreach (var key in _sut.ListAllKeysAsync(_namespaceId, prefix)) + allKeys.Add(key); + + // Assert + allKeys.Should().HaveCount(5, "all keys with the prefix should be returned"); + allKeys.Select(k => k.Name).Should().BeEquivalentTo(keys); + } + finally + { + // Cleanup + foreach (var key in keys) + await _sut.DeleteValueAsync(_namespaceId, key); + } + } + + /// Verifies that ListKeysAsync returns keys with their metadata. + [IntegrationTest] + public async Task ListKeysAsync_IncludesKeyMetadata() + { + // Arrange + var key = $"metadata-key-{Guid.NewGuid():N}"; + var metadata = JsonSerializer.SerializeToElement(new { listTest = true }); + await _sut.WriteValueAsync(_namespaceId, key, "value", new KvWriteOptions(Metadata: metadata)); + + try + { + // Act + var result = await _sut.ListKeysAsync(_namespaceId, new ListKvKeysFilters(Prefix: key)); + + // Assert + result.Items.Should().ContainSingle(); + var foundKey = result.Items[0]; + foundKey.Name.Should().Be(key); + foundKey.Metadata.Should().NotBeNull("metadata should be included in list response"); + foundKey.Metadata!.Value.GetProperty("listTest").GetBoolean().Should().BeTrue(); + } + finally + { + // Cleanup + await _sut.DeleteValueAsync(_namespaceId, key); + } + } + + #endregion + + + #region Bulk Operations + + /// Verifies that multiple key-value pairs can be written in a single bulk operation. + [IntegrationTest] + public async Task BulkWriteAsync_CanWriteMultipleKeys() + { + // Arrange + var prefix = $"bulk-write-{Guid.NewGuid():N}/"; + var items = new[] + { + new KvBulkWriteItem($"{prefix}key1", "value1"), + new KvBulkWriteItem($"{prefix}key2", "value2"), + new KvBulkWriteItem($"{prefix}key3", "value3") + }; + + try + { + // Act + var result = await _sut.BulkWriteAsync(_namespaceId, items); + + // Assert + result.SuccessfulKeyCount.Should().Be(3); + result.UnsuccessfulKeys.Should().BeNullOrEmpty(); + + // Verify values were written + var value1 = await _sut.GetValueAsync(_namespaceId, $"{prefix}key1"); + value1.Should().Be("value1"); + + var value2 = await _sut.GetValueAsync(_namespaceId, $"{prefix}key2"); + value2.Should().Be("value2"); + + var value3 = await _sut.GetValueAsync(_namespaceId, $"{prefix}key3"); + value3.Should().Be("value3"); + } + finally + { + // Cleanup + foreach (var item in items) + await _sut.DeleteValueAsync(_namespaceId, item.Key); + } + } + + /// Verifies that bulk write can include metadata and expiration. + [IntegrationTest] + public async Task BulkWriteAsync_CanIncludeMetadataAndExpiration() + { + // Arrange + var prefix = $"bulk-meta-{Guid.NewGuid():N}/"; + var metadata = JsonSerializer.SerializeToElement(new { bulk = true }); + var items = new[] + { + new KvBulkWriteItem( + $"{prefix}key1", + "value with metadata", + Metadata: metadata, + ExpirationTtl: 300) // 5 minutes + }; + + try + { + // Act + var result = await _sut.BulkWriteAsync(_namespaceId, items); + + // Assert + result.SuccessfulKeyCount.Should().Be(1); + + // Verify metadata was written + var readMetadata = await _sut.GetMetadataAsync(_namespaceId, $"{prefix}key1"); + readMetadata.Should().NotBeNull(); + readMetadata!.Value.GetProperty("bulk").GetBoolean().Should().BeTrue(); + } + finally + { + // Cleanup + await _sut.DeleteValueAsync(_namespaceId, $"{prefix}key1"); + } + } + + /// Verifies that multiple keys can be deleted in a single bulk operation. + [IntegrationTest] + public async Task BulkDeleteAsync_CanDeleteMultipleKeys() + { + // Arrange - Write some keys first + var prefix = $"bulk-delete-{Guid.NewGuid():N}/"; + var keys = new[] { $"{prefix}key1", $"{prefix}key2", $"{prefix}key3" }; + + foreach (var key in keys) + await _sut.WriteValueAsync(_namespaceId, key, $"value for {key}"); + + // Verify they exist + foreach (var key in keys) + { + var value = await _sut.GetValueAsync(_namespaceId, key); + value.Should().NotBeNull($"{key} should exist before deletion"); + } + + // Act + var result = await _sut.BulkDeleteAsync(_namespaceId, keys); + + // Assert + result.SuccessfulKeyCount.Should().Be(3); + result.UnsuccessfulKeys.Should().BeNullOrEmpty(); + + // Verify they are deleted + foreach (var key in keys) + { + var value = await _sut.GetValueAsync(_namespaceId, key); + value.Should().BeNull($"{key} should be deleted"); + } + } + + /// Verifies that multiple values can be retrieved in a single bulk get operation. + [IntegrationTest] + public async Task BulkGetAsync_CanRetrieveMultipleValues() + { + // Arrange + var prefix = $"bulk-get-{Guid.NewGuid():N}/"; + var keyValues = new Dictionary + { + { $"{prefix}key1", "value1" }, + { $"{prefix}key2", "value2" }, + { $"{prefix}key3", "value3" } + }; + + foreach (var kv in keyValues) + await _sut.WriteValueAsync(_namespaceId, kv.Key, kv.Value); + + try + { + // Act + var result = await _sut.BulkGetAsync(_namespaceId, keyValues.Keys); + + // Assert + result.Should().HaveCount(3); + foreach (var kv in keyValues) + result[kv.Key].Should().Be(kv.Value); + } + finally + { + // Cleanup + foreach (var key in keyValues.Keys) + await _sut.DeleteValueAsync(_namespaceId, key); + } + } + + /// Verifies that bulk get returns null for non-existent keys. + [IntegrationTest] + public async Task BulkGetAsync_ReturnsNullForNonExistentKeys() + { + // Arrange + var existingKey = $"bulk-exist-{Guid.NewGuid():N}"; + var nonExistentKey = $"bulk-missing-{Guid.NewGuid():N}"; + + await _sut.WriteValueAsync(_namespaceId, existingKey, "exists"); + + try + { + // Act + var result = await _sut.BulkGetAsync(_namespaceId, new[] { existingKey, nonExistentKey }); + + // Assert + result.Should().HaveCount(2); + result[existingKey].Should().Be("exists"); + result[nonExistentKey].Should().BeNull("non-existent keys should have null values"); + } + finally + { + // Cleanup + await _sut.DeleteValueAsync(_namespaceId, existingKey); + } + } + + /// Verifies that bulk get with metadata returns values and their metadata. + [IntegrationTest] + public async Task BulkGetWithMetadataAsync_ReturnsValuesAndMetadata() + { + // Arrange + var prefix = $"bulk-meta-get-{Guid.NewGuid():N}/"; + var key = $"{prefix}key1"; + var metadata = JsonSerializer.SerializeToElement(new { category = "bulk-test" }); + + await _sut.WriteValueAsync(_namespaceId, key, "value with metadata", new KvWriteOptions(Metadata: metadata)); + + try + { + // Act + var result = await _sut.BulkGetWithMetadataAsync(_namespaceId, new[] { key }); + + // Assert + result.Should().HaveCount(1); + result[key].Should().NotBeNull(); + result[key]!.Value.Should().Be("value with metadata"); + result[key]!.Metadata.Should().NotBeNull(); + result[key]!.Metadata!.Value.GetProperty("category").GetString().Should().Be("bulk-test"); + } + finally + { + // Cleanup + await _sut.DeleteValueAsync(_namespaceId, key); + } + } + + #endregion + + + #region Error Handling + + /// Verifies that GetAsync for a non-existent namespace returns appropriate error. + [IntegrationTest] + public async Task GetAsync_NonExistentNamespace_ThrowsError() + { + // Arrange + var nonExistentId = Guid.NewGuid().ToString(); + + // Act + var action = async () => await _sut.GetAsync(nonExistentId); + + // Assert + await action.Should().ThrowAsync("accessing a non-existent namespace should fail"); + } + + /// Verifies that DeleteAsync for a non-existent namespace throws appropriate error. + [IntegrationTest] + public async Task DeleteAsync_NonExistentNamespace_ThrowsError() + { + // Arrange + var nonExistentId = Guid.NewGuid().ToString(); + + // Act + var action = async () => await _sut.DeleteAsync(nonExistentId); + + // Assert + await action.Should().ThrowAsync("deleting a non-existent namespace should fail"); + } + + #endregion +} diff --git a/tests/Cloudflare.NET.Tests/UnitTests/KvApiUnitTests.cs b/tests/Cloudflare.NET.Tests/UnitTests/KvApiUnitTests.cs new file mode 100644 index 0000000..4365d96 --- /dev/null +++ b/tests/Cloudflare.NET.Tests/UnitTests/KvApiUnitTests.cs @@ -0,0 +1,1450 @@ +namespace Cloudflare.NET.Tests.UnitTests; + +using System.Net; +using System.Text.Json; +using Accounts.Kv; +using Accounts.Kv.Models; +using Cloudflare.NET.Core.Exceptions; +using Cloudflare.NET.Core.Models; +using Cloudflare.NET.Security.Firewall.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq.Protected; +using Shared.Fixtures; +using Xunit.Abstractions; + +/// Contains unit tests for the class. +/// +/// This test class covers all Workers KV operations including: +/// +/// Namespace operations (List, Create, Get, Rename, Delete) +/// Key operations (List keys, List all keys) +/// Value operations (Get, Write, Delete) +/// Bulk operations (BulkWrite, BulkDelete, BulkGet) +/// Error handling and edge cases +/// +/// +[Trait("Category", TestConstants.TestCategories.Unit)] +public class KvApiUnitTests +{ + #region Properties & Fields - Non-Public + + /// The logger factory for creating loggers. + private readonly ILoggerFactory _loggerFactory; + + /// JSON serializer options for snake_case property naming. + private readonly JsonSerializerOptions _serializerOptions = + new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + + /// The test account ID used in all tests. + private const string TestAccountId = "test-account-id"; + + #endregion + + + #region Constructors + + /// Initializes a new instance of the class. + /// The xUnit test output helper. + public KvApiUnitTests(ITestOutputHelper output) + { + var loggerProvider = new XunitTestOutputLoggerProvider { Current = output }; + _loggerFactory = new LoggerFactory([loggerProvider]); + } + + #endregion + + + #region Helper Methods + + /// Creates the system under test with a mocked HTTP handler. + /// The JSON content to return. + /// The HTTP status code to return. + /// Optional callback to capture the request. + /// A configured instance. + private KvApi CreateSut( + string responseContent, + HttpStatusCode statusCode = HttpStatusCode.OK, + Action? callback = null) + { + var mockHandler = HttpFixtures.GetMockHttpMessageHandler(responseContent, statusCode, callback); + var httpClient = new HttpClient(mockHandler.Object) { BaseAddress = new Uri("https://api.cloudflare.com/client/v4/") }; + var options = Options.Create(new CloudflareApiOptions { AccountId = TestAccountId }); + + return new KvApi(httpClient, options, _loggerFactory); + } + + /// Creates the system under test with a mocked HTTP handler that returns response headers. + /// The content to return. + /// The HTTP status code to return. + /// Headers to include in the response. + /// Optional callback to capture the request. + /// A configured instance. + private KvApi CreateSutWithHeaders( + string responseContent, + HttpStatusCode statusCode, + IEnumerable> headers, + Action? callback = null) + { + var mockHandler = new Mock(); + var response = new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(responseContent, System.Text.Encoding.UTF8, "application/octet-stream") + }; + + // Add custom headers to the response. + foreach (var header in headers) + response.Headers.TryAddWithoutValidation(header.Key, header.Value); + + var setup = mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ); + + if (callback is not null) + setup.Callback(callback); + + setup.ReturnsAsync(response); + + var httpClient = new HttpClient(mockHandler.Object) { BaseAddress = new Uri("https://api.cloudflare.com/client/v4/") }; + var options = Options.Create(new CloudflareApiOptions { AccountId = TestAccountId }); + + return new KvApi(httpClient, options, _loggerFactory); + } + + /// Creates a paginated response for namespace listings. + /// The namespaces to include. + /// Current page number. + /// Items per page. + /// Total number of items. + /// JSON string representing the paginated response. + private string CreatePaginatedNamespaceResponse(KvNamespace[] namespaces, int page, int perPage, int totalCount) + { + var totalPages = totalCount == 0 ? 0 : (int)Math.Ceiling((double)totalCount / perPage); + var response = new + { + success = true, + errors = Array.Empty(), + messages = Array.Empty(), + result = namespaces, + result_info = new + { + page, + per_page = perPage, + count = namespaces.Length, + total_count = totalCount, + total_pages = totalPages + } + }; + return JsonSerializer.Serialize(response, _serializerOptions); + } + + /// Creates a cursor-paginated response for key listings. + /// The keys to include. + /// Items per page. + /// Optional cursor for next page. + /// JSON string representing the cursor-paginated response. + private string CreateCursorPaginatedKeyResponse(KvKey[] keys, int perPage, string? cursor = null) + { + var response = new + { + success = true, + errors = Array.Empty(), + messages = Array.Empty(), + result = keys, + result_info = new { count = keys.Length, per_page = perPage, cursor = (string?)null }, + cursor_result_info = new { count = keys.Length, per_page = perPage, cursor } + }; + return JsonSerializer.Serialize(response, _serializerOptions); + } + + #endregion + + + #region Namespace Operations - ListAsync + + /// Verifies that ListAsync sends a correctly formatted GET request with no filters. + [Fact] + public async Task ListAsync_WithNoFilters_SendsCorrectRequest() + { + // Arrange + var namespaces = new[] { new KvNamespace("ns-1", "My Namespace", true) }; + var response = CreatePaginatedNamespaceResponse(namespaces, 1, 20, 1); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + var result = await sut.ListAsync(); + + // Assert + result.Items.Should().HaveCount(1); + result.Items[0].Id.Should().Be("ns-1"); + result.Items[0].Title.Should().Be("My Namespace"); + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Get); + capturedRequest.RequestUri!.ToString().Should().Be($"https://api.cloudflare.com/client/v4/accounts/{TestAccountId}/storage/kv/namespaces"); + } + + /// Verifies that ListAsync includes page filter in query string. + [Fact] + public async Task ListAsync_WithPageFilter_SendsCorrectRequest() + { + // Arrange + var response = CreatePaginatedNamespaceResponse(Array.Empty(), 2, 20, 40); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + await sut.ListAsync(new ListKvNamespacesFilters(Page: 2)); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Contain("page=2"); + } + + /// Verifies that ListAsync includes per_page filter in query string. + [Fact] + public async Task ListAsync_WithPerPageFilter_SendsCorrectRequest() + { + // Arrange + var response = CreatePaginatedNamespaceResponse(Array.Empty(), 1, 50, 0); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + await sut.ListAsync(new ListKvNamespacesFilters(PerPage: 50)); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Contain("per_page=50"); + } + + /// Verifies that ListAsync includes order filter in query string. + [Fact] + public async Task ListAsync_WithOrderFilter_SendsCorrectRequest() + { + // Arrange + var response = CreatePaginatedNamespaceResponse(Array.Empty(), 1, 20, 0); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + await sut.ListAsync(new ListKvNamespacesFilters(Order: KvNamespaceOrderField.Title)); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Contain("order=title"); + } + + /// Verifies that ListAsync includes direction filter in query string. + [Fact] + public async Task ListAsync_WithDirectionFilter_SendsCorrectRequest() + { + // Arrange + var response = CreatePaginatedNamespaceResponse(Array.Empty(), 1, 20, 0); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + await sut.ListAsync(new ListKvNamespacesFilters(Direction: ListOrderDirection.Descending)); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Contain("direction=desc"); + } + + /// Verifies that ListAsync includes all filters in query string. + [Fact] + public async Task ListAsync_WithAllFilters_SendsCorrectRequest() + { + // Arrange + var filters = new ListKvNamespacesFilters( + Page: 2, + PerPage: 50, + Order: KvNamespaceOrderField.Id, + Direction: ListOrderDirection.Ascending + ); + var response = CreatePaginatedNamespaceResponse(Array.Empty(), 2, 50, 100); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + await sut.ListAsync(filters); + + // Assert + capturedRequest.Should().NotBeNull(); + var uri = capturedRequest!.RequestUri!.ToString(); + uri.Should().Contain("page=2"); + uri.Should().Contain("per_page=50"); + uri.Should().Contain("order=id"); + uri.Should().Contain("direction=asc"); + } + + #endregion + + + #region Namespace Operations - ListAllAsync + + /// Verifies that ListAllAsync handles pagination correctly. + [Fact] + public async Task ListAllAsync_ShouldHandlePaginationCorrectly() + { + // Arrange + var ns1 = new KvNamespace("ns-1", "Namespace 1", true); + var ns2 = new KvNamespace("ns-2", "Namespace 2", true); + + // First page response. + var responsePage1 = CreatePaginatedNamespaceResponse(new[] { ns1 }, 1, 1, 2); + + // Second page response. + var responsePage2 = CreatePaginatedNamespaceResponse(new[] { ns2 }, 2, 1, 2); + + var capturedRequests = new List(); + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => capturedRequests.Add(req)) + .Returns((HttpRequestMessage req, CancellationToken _) => + { + if (req.RequestUri!.ToString().Contains("page=2")) + return Task.FromResult( + new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(responsePage2) }); + + return Task.FromResult( + new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(responsePage1) }); + }); + + var httpClient = new HttpClient(mockHandler.Object) { BaseAddress = new Uri("https://api.cloudflare.com/client/v4/") }; + var options = Options.Create(new CloudflareApiOptions { AccountId = TestAccountId }); + var sut = new KvApi(httpClient, options, _loggerFactory); + + // Act + var allNamespaces = new List(); + await foreach (var ns in sut.ListAllAsync()) + allNamespaces.Add(ns); + + // Assert + capturedRequests.Should().HaveCount(2); + capturedRequests[0].RequestUri!.Query.Should().Contain("page=1"); + capturedRequests[1].RequestUri!.Query.Should().Contain("page=2"); + allNamespaces.Should().HaveCount(2); + allNamespaces.Select(n => n.Id).Should().ContainInOrder("ns-1", "ns-2"); + } + + /// Verifies that ListAllAsync preserves filters across pagination requests. + [Fact] + public async Task ListAllAsync_WithFilters_ShouldPreserveFiltersAcrossPagination() + { + // Arrange + var ns = new KvNamespace("ns-1", "Namespace 1", true); + var response = CreatePaginatedNamespaceResponse(new[] { ns }, 1, 50, 1); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + var allNamespaces = new List(); + await foreach (var n in sut.ListAllAsync(new ListKvNamespacesFilters( + PerPage: 50, + Order: KvNamespaceOrderField.Title, + Direction: ListOrderDirection.Descending))) + allNamespaces.Add(n); + + // Assert + capturedRequest.Should().NotBeNull(); + var uri = capturedRequest!.RequestUri!.ToString(); + uri.Should().Contain("per_page=50"); + uri.Should().Contain("order=title"); + uri.Should().Contain("direction=desc"); + } + + #endregion + + + #region Namespace Operations - CreateAsync + + /// Verifies that CreateAsync sends a correctly formatted POST request. + [Fact] + public async Task CreateAsync_SendsCorrectRequest() + { + // Arrange + var title = "My New Namespace"; + var expectedResult = new KvNamespace("ns-new-id", title, true); + var successResponse = HttpFixtures.CreateSuccessResponse(expectedResult); + + HttpRequestMessage? capturedRequest = null; + string? capturedJsonBody = null; + var sut = CreateSut(successResponse, callback: (req, _) => + { + capturedRequest = req; + capturedJsonBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }); + + // Act + var result = await sut.CreateAsync(title); + + // Assert + result.Should().BeEquivalentTo(expectedResult); + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Post); + capturedRequest.RequestUri!.ToString().Should().Be($"https://api.cloudflare.com/client/v4/accounts/{TestAccountId}/storage/kv/namespaces"); + + // Verify JSON body contains the title + capturedJsonBody.Should().NotBeNull(); + using var doc = JsonDocument.Parse(capturedJsonBody!); + doc.RootElement.GetProperty("title").GetString().Should().Be(title); + } + + #endregion + + + #region Namespace Operations - GetAsync + + /// Verifies that GetAsync sends a correctly formatted GET request. + [Fact] + public async Task GetAsync_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var expectedResult = new KvNamespace(namespaceId, "My Namespace", true); + var successResponse = HttpFixtures.CreateSuccessResponse(expectedResult); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(successResponse, callback: (req, _) => capturedRequest = req); + + // Act + var result = await sut.GetAsync(namespaceId); + + // Assert + result.Should().BeEquivalentTo(expectedResult); + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Get); + capturedRequest.RequestUri!.ToString().Should().Be($"https://api.cloudflare.com/client/v4/accounts/{TestAccountId}/storage/kv/namespaces/{namespaceId}"); + } + + /// Verifies that GetAsync URL-encodes special characters in namespace IDs. + [Fact] + public async Task GetAsync_UrlEncodesNamespaceId() + { + // Arrange + var namespaceId = "ns id+special"; + var expectedResult = new KvNamespace(namespaceId, "My Namespace", true); + var successResponse = HttpFixtures.CreateSuccessResponse(expectedResult); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(successResponse, callback: (req, _) => capturedRequest = req); + + // Act + var result = await sut.GetAsync(namespaceId); + + // Assert + result.Should().BeEquivalentTo(expectedResult); + capturedRequest.Should().NotBeNull(); + // Verify the namespace ID is URL-encoded using OriginalString to avoid automatic decoding. + capturedRequest!.RequestUri!.OriginalString.Should().Contain("ns%20id%2Bspecial"); + } + + #endregion + + + #region Namespace Operations - RenameAsync + + /// Verifies that RenameAsync sends a correctly formatted PUT request. + [Fact] + public async Task RenameAsync_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var newTitle = "Renamed Namespace"; + var expectedResult = new KvNamespace(namespaceId, newTitle, true); + var successResponse = HttpFixtures.CreateSuccessResponse(expectedResult); + + HttpRequestMessage? capturedRequest = null; + string? capturedJsonBody = null; + var sut = CreateSut(successResponse, callback: (req, _) => + { + capturedRequest = req; + capturedJsonBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }); + + // Act + var result = await sut.RenameAsync(namespaceId, newTitle); + + // Assert + result.Should().BeEquivalentTo(expectedResult); + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Put); + capturedRequest.RequestUri!.ToString().Should().Be($"https://api.cloudflare.com/client/v4/accounts/{TestAccountId}/storage/kv/namespaces/{namespaceId}"); + + // Verify JSON body contains the new title. + capturedJsonBody.Should().NotBeNull(); + using var doc = JsonDocument.Parse(capturedJsonBody!); + doc.RootElement.GetProperty("title").GetString().Should().Be(newTitle); + } + + #endregion + + + #region Namespace Operations - DeleteAsync + + /// Verifies that DeleteAsync sends a correctly formatted DELETE request. + [Fact] + public async Task DeleteAsync_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-to-delete"; + var successResponse = HttpFixtures.CreateSuccessResponse(null); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(successResponse, callback: (req, _) => capturedRequest = req); + + // Act + await sut.DeleteAsync(namespaceId); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Delete); + capturedRequest.RequestUri!.ToString().Should().Be($"https://api.cloudflare.com/client/v4/accounts/{TestAccountId}/storage/kv/namespaces/{namespaceId}"); + } + + #endregion + + + #region Key Operations - ListKeysAsync + + /// Verifies that ListKeysAsync sends a correctly formatted GET request with no filters. + [Fact] + public async Task ListKeysAsync_WithNoFilters_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var keys = new[] { new KvKey("key1", 1735689600, null) }; + var response = CreateCursorPaginatedKeyResponse(keys, 1000); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + var result = await sut.ListKeysAsync(namespaceId); + + // Assert + result.Items.Should().HaveCount(1); + result.Items[0].Name.Should().Be("key1"); + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Get); + capturedRequest.RequestUri!.ToString().Should().Be($"https://api.cloudflare.com/client/v4/accounts/{TestAccountId}/storage/kv/namespaces/{namespaceId}/keys"); + } + + /// Verifies that ListKeysAsync includes prefix filter in query string. + [Fact] + public async Task ListKeysAsync_WithPrefixFilter_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var response = CreateCursorPaginatedKeyResponse(Array.Empty(), 1000); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + await sut.ListKeysAsync(namespaceId, new ListKvKeysFilters(Prefix: "users/")); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Contain("prefix=users%2F"); + } + + /// Verifies that ListKeysAsync includes limit filter in query string. + [Fact] + public async Task ListKeysAsync_WithLimitFilter_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var response = CreateCursorPaginatedKeyResponse(Array.Empty(), 100); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + await sut.ListKeysAsync(namespaceId, new ListKvKeysFilters(Limit: 100)); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Contain("limit=100"); + } + + /// Verifies that ListKeysAsync includes cursor filter in query string. + [Fact] + public async Task ListKeysAsync_WithCursorFilter_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var cursor = "abc123def"; + var response = CreateCursorPaginatedKeyResponse(Array.Empty(), 1000); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + await sut.ListKeysAsync(namespaceId, new ListKvKeysFilters(Cursor: cursor)); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Contain($"cursor={cursor}"); + } + + /// Verifies that ListKeysAsync includes all filters in query string. + [Fact] + public async Task ListKeysAsync_WithAllFilters_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var filters = new ListKvKeysFilters(Prefix: "config/", Limit: 50, Cursor: "cursor123"); + var response = CreateCursorPaginatedKeyResponse(Array.Empty(), 50); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + await sut.ListKeysAsync(namespaceId, filters); + + // Assert + capturedRequest.Should().NotBeNull(); + var uri = capturedRequest!.RequestUri!.ToString(); + uri.Should().Contain("prefix=config%2F"); + uri.Should().Contain("limit=50"); + uri.Should().Contain("cursor=cursor123"); + } + + /// Verifies that ListKeysAsync URL-encodes special characters in prefix. + [Fact] + public async Task ListKeysAsync_WithSpecialCharactersInPrefix_ShouldUrlEncodeValues() + { + // Arrange + var namespaceId = "ns-123"; + var response = CreateCursorPaginatedKeyResponse(Array.Empty(), 1000); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + await sut.ListKeysAsync(namespaceId, new ListKvKeysFilters(Prefix: "data+backup/")); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.OriginalString.Should().Contain("prefix=data%2Bbackup%2F"); + } + + #endregion + + + #region Key Operations - ListAllKeysAsync + + /// Verifies that ListAllKeysAsync handles cursor pagination correctly. + [Fact] + public async Task ListAllKeysAsync_ShouldHandleCursorPaginationCorrectly() + { + // Arrange + var namespaceId = "ns-123"; + var key1 = new KvKey("key1", null, null); + var key2 = new KvKey("key2", null, null); + var cursor = "next_page_cursor"; + + // First page response with a cursor. + var responsePage1 = CreateCursorPaginatedKeyResponse(new[] { key1 }, 1, cursor); + + // Second page response without a cursor (end of pagination). + var responsePage2 = CreateCursorPaginatedKeyResponse(new[] { key2 }, 1, null); + + var capturedRequests = new List(); + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => capturedRequests.Add(req)) + .Returns((HttpRequestMessage req, CancellationToken _) => + { + if (req.RequestUri!.ToString().Contains(cursor)) + return Task.FromResult( + new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(responsePage2) }); + + return Task.FromResult( + new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(responsePage1) }); + }); + + var httpClient = new HttpClient(mockHandler.Object) { BaseAddress = new Uri("https://api.cloudflare.com/client/v4/") }; + var options = Options.Create(new CloudflareApiOptions { AccountId = TestAccountId }); + var sut = new KvApi(httpClient, options, _loggerFactory); + + // Act + var allKeys = new List(); + await foreach (var key in sut.ListAllKeysAsync(namespaceId)) + allKeys.Add(key); + + // Assert + capturedRequests.Should().HaveCount(2); + capturedRequests[0].RequestUri!.Query.Should().NotContain("cursor"); + capturedRequests[1].RequestUri!.Query.Should().Contain($"cursor={cursor}"); + allKeys.Should().HaveCount(2); + allKeys.Select(k => k.Name).Should().ContainInOrder("key1", "key2"); + } + + /// Verifies that ListAllKeysAsync preserves prefix filter across pagination. + [Fact] + public async Task ListAllKeysAsync_WithPrefix_ShouldPreservePrefixAcrossPagination() + { + // Arrange + var namespaceId = "ns-123"; + var key = new KvKey("users/user1", null, null); + var response = CreateCursorPaginatedKeyResponse(new[] { key }, 1000, null); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(response, callback: (req, _) => capturedRequest = req); + + // Act + var allKeys = new List(); + await foreach (var k in sut.ListAllKeysAsync(namespaceId, "users/")) + allKeys.Add(k); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Contain("prefix=users%2F"); + } + + #endregion + + + #region Value Operations - GetValueAsync + + /// Verifies that GetValueAsync sends a correctly formatted GET request. + [Fact] + public async Task GetValueAsync_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var key = "my-key"; + var value = "my-value"; + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSutWithHeaders(value, HttpStatusCode.OK, [], (req, _) => capturedRequest = req); + + // Act + var result = await sut.GetValueAsync(namespaceId, key); + + // Assert + result.Should().Be(value); + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Get); + capturedRequest.RequestUri!.ToString().Should().Be($"https://api.cloudflare.com/client/v4/accounts/{TestAccountId}/storage/kv/namespaces/{namespaceId}/values/{key}"); + } + + /// Verifies that GetValueAsync returns null for non-existent keys. + [Fact] + public async Task GetValueAsync_WhenKeyNotFound_ReturnsNull() + { + // Arrange + var namespaceId = "ns-123"; + var key = "non-existent-key"; + var sut = CreateSutWithHeaders("", HttpStatusCode.NotFound, []); + + // Act + var result = await sut.GetValueAsync(namespaceId, key); + + // Assert + result.Should().BeNull(); + } + + /// Verifies that GetValueAsync URL-encodes special characters in key names. + [Fact] + public async Task GetValueAsync_UrlEncodesKeyName() + { + // Arrange + var namespaceId = "ns-123"; + var key = "my key+special/chars"; + var value = "value"; + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSutWithHeaders(value, HttpStatusCode.OK, [], (req, _) => capturedRequest = req); + + // Act + await sut.GetValueAsync(namespaceId, key); + + // Assert + capturedRequest.Should().NotBeNull(); + // Verify the key is URL-encoded. + capturedRequest!.RequestUri!.OriginalString.Should().Contain("my%20key%2Bspecial%2Fchars"); + } + + #endregion + + + #region Value Operations - GetValueWithExpirationAsync + + /// Verifies that GetValueWithExpirationAsync extracts expiration from response header. + [Fact] + public async Task GetValueWithExpirationAsync_ExtractsExpirationFromHeader() + { + // Arrange + var namespaceId = "ns-123"; + var key = "my-key"; + var value = "my-value"; + var expiration = 1735689600L; + + var sut = CreateSutWithHeaders( + value, + HttpStatusCode.OK, + new[] { new KeyValuePair("expiration", expiration.ToString()) }); + + // Act + var result = await sut.GetValueWithExpirationAsync(namespaceId, key); + + // Assert + result.Should().NotBeNull(); + result!.Value.Should().Be(value); + result.Expiration.Should().Be(expiration); + } + + /// Verifies that GetValueWithExpirationAsync returns null expiration when header is absent. + [Fact] + public async Task GetValueWithExpirationAsync_WhenNoExpirationHeader_ReturnsNullExpiration() + { + // Arrange + var namespaceId = "ns-123"; + var key = "my-key"; + var value = "my-value"; + + var sut = CreateSutWithHeaders(value, HttpStatusCode.OK, []); + + // Act + var result = await sut.GetValueWithExpirationAsync(namespaceId, key); + + // Assert + result.Should().NotBeNull(); + result!.Value.Should().Be(value); + result.Expiration.Should().BeNull(); + } + + /// Verifies that GetValueWithExpirationAsync returns null for non-existent keys. + [Fact] + public async Task GetValueWithExpirationAsync_WhenKeyNotFound_ReturnsNull() + { + // Arrange + var namespaceId = "ns-123"; + var key = "non-existent-key"; + var sut = CreateSutWithHeaders("", HttpStatusCode.NotFound, []); + + // Act + var result = await sut.GetValueWithExpirationAsync(namespaceId, key); + + // Assert + result.Should().BeNull(); + } + + #endregion + + + #region Value Operations - GetValueBytesAsync + + /// Verifies that GetValueBytesAsync returns binary data correctly. + [Fact] + public async Task GetValueBytesAsync_ReturnsBinaryData() + { + // Arrange + var namespaceId = "ns-123"; + var key = "binary-key"; + var valueBytes = new byte[] { 0x01, 0x02, 0x03, 0x04 }; + + var mockHandler = new Mock(); + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new ByteArrayContent(valueBytes) + }; + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(response); + + var httpClient = new HttpClient(mockHandler.Object) { BaseAddress = new Uri("https://api.cloudflare.com/client/v4/") }; + var options = Options.Create(new CloudflareApiOptions { AccountId = TestAccountId }); + var sut = new KvApi(httpClient, options, _loggerFactory); + + // Act + var result = await sut.GetValueBytesAsync(namespaceId, key); + + // Assert + result.Should().BeEquivalentTo(valueBytes); + } + + /// Verifies that GetValueBytesAsync returns null for non-existent keys. + [Fact] + public async Task GetValueBytesAsync_WhenKeyNotFound_ReturnsNull() + { + // Arrange + var namespaceId = "ns-123"; + var key = "non-existent-key"; + var sut = CreateSutWithHeaders("", HttpStatusCode.NotFound, []); + + // Act + var result = await sut.GetValueBytesAsync(namespaceId, key); + + // Assert + result.Should().BeNull(); + } + + #endregion + + + #region Value Operations - GetMetadataAsync + + /// Verifies that GetMetadataAsync sends a correctly formatted GET request. + [Fact] + public async Task GetMetadataAsync_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var key = "my-key"; + var metadata = new { category = "config", version = 1 }; + var successResponse = HttpFixtures.CreateSuccessResponse(metadata); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(successResponse, callback: (req, _) => capturedRequest = req); + + // Act + var result = await sut.GetMetadataAsync(namespaceId, key); + + // Assert + result.Should().NotBeNull(); + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Get); + capturedRequest.RequestUri!.ToString().Should().Be($"https://api.cloudflare.com/client/v4/accounts/{TestAccountId}/storage/kv/namespaces/{namespaceId}/metadata/{key}"); + } + + /// Verifies that GetMetadataAsync URL-encodes special characters in key names. + [Fact] + public async Task GetMetadataAsync_UrlEncodesKeyName() + { + // Arrange + var namespaceId = "ns-123"; + var key = "my key/with+special"; + var successResponse = HttpFixtures.CreateSuccessResponse(new { }); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(successResponse, callback: (req, _) => capturedRequest = req); + + // Act + await sut.GetMetadataAsync(namespaceId, key); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.OriginalString.Should().Contain("my%20key%2Fwith%2Bspecial"); + } + + #endregion + + + #region Value Operations - WriteValueAsync (String) + + /// Verifies that WriteValueAsync sends a correctly formatted PUT request for string values. + [Fact] + public async Task WriteValueAsync_String_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var key = "my-key"; + var value = "my-value"; + + HttpRequestMessage? capturedRequest = null; + string? capturedContent = null; + var sut = CreateSutWithHeaders("", HttpStatusCode.OK, [], (req, _) => + { + capturedRequest = req; + capturedContent = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }); + + // Act + await sut.WriteValueAsync(namespaceId, key, value); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Put); + capturedRequest.RequestUri!.ToString().Should().Be($"https://api.cloudflare.com/client/v4/accounts/{TestAccountId}/storage/kv/namespaces/{namespaceId}/values/{key}"); + capturedContent.Should().Be(value); + } + + /// Verifies that WriteValueAsync includes expiration query parameter. + [Fact] + public async Task WriteValueAsync_WithExpiration_IncludesExpirationInQueryString() + { + // Arrange + var namespaceId = "ns-123"; + var key = "my-key"; + var value = "my-value"; + var expiration = 1735689600L; + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSutWithHeaders("", HttpStatusCode.OK, [], (req, _) => capturedRequest = req); + + // Act + await sut.WriteValueAsync(namespaceId, key, value, new KvWriteOptions(Expiration: expiration)); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Contain($"expiration={expiration}"); + } + + /// Verifies that WriteValueAsync includes expiration_ttl query parameter. + [Fact] + public async Task WriteValueAsync_WithExpirationTtl_IncludesExpirationTtlInQueryString() + { + // Arrange + var namespaceId = "ns-123"; + var key = "my-key"; + var value = "my-value"; + var ttl = 3600; + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSutWithHeaders("", HttpStatusCode.OK, [], (req, _) => capturedRequest = req); + + // Act + await sut.WriteValueAsync(namespaceId, key, value, new KvWriteOptions(ExpirationTtl: ttl)); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Contain($"expiration_ttl={ttl}"); + } + + /// Verifies that WriteValueAsync uses multipart when metadata is provided. + [Fact] + public async Task WriteValueAsync_WithMetadata_UsesMultipartFormData() + { + // Arrange + var namespaceId = "ns-123"; + var key = "my-key"; + var value = "my-value"; + var metadata = JsonSerializer.SerializeToElement(new { category = "config" }); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSutWithHeaders("", HttpStatusCode.OK, [], (req, _) => capturedRequest = req); + + // Act + await sut.WriteValueAsync(namespaceId, key, value, new KvWriteOptions(Metadata: metadata)); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.Content.Should().BeOfType(); + } + + /// Verifies that WriteValueAsync URL-encodes special characters in key names. + [Fact] + public async Task WriteValueAsync_String_UrlEncodesKeyName() + { + // Arrange + var namespaceId = "ns-123"; + var key = "my key/special+chars"; + var value = "my-value"; + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSutWithHeaders("", HttpStatusCode.OK, [], (req, _) => capturedRequest = req); + + // Act + await sut.WriteValueAsync(namespaceId, key, value); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.OriginalString.Should().Contain("my%20key%2Fspecial%2Bchars"); + } + + #endregion + + + #region Value Operations - WriteValueAsync (Bytes) + + /// Verifies that WriteValueAsync sends a correctly formatted PUT request for binary values. + [Fact] + public async Task WriteValueAsync_Bytes_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var key = "binary-key"; + var value = new byte[] { 0x01, 0x02, 0x03, 0x04 }; + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSutWithHeaders("", HttpStatusCode.OK, [], (req, _) => capturedRequest = req); + + // Act + await sut.WriteValueAsync(namespaceId, key, value); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Put); + capturedRequest.Content.Should().BeOfType(); + capturedRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/octet-stream"); + } + + /// Verifies that WriteValueAsync (bytes) uses multipart when metadata is provided. + [Fact] + public async Task WriteValueAsync_Bytes_WithMetadata_UsesMultipartFormData() + { + // Arrange + var namespaceId = "ns-123"; + var key = "binary-key"; + var value = new byte[] { 0x01, 0x02, 0x03 }; + var metadata = JsonSerializer.SerializeToElement(new { type = "binary" }); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSutWithHeaders("", HttpStatusCode.OK, [], (req, _) => capturedRequest = req); + + // Act + await sut.WriteValueAsync(namespaceId, key, value, new KvWriteOptions(Metadata: metadata)); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.Content.Should().BeOfType(); + } + + #endregion + + + #region Value Operations - DeleteValueAsync + + /// Verifies that DeleteValueAsync sends a correctly formatted DELETE request. + [Fact] + public async Task DeleteValueAsync_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var key = "key-to-delete"; + var successResponse = HttpFixtures.CreateSuccessResponse(null); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(successResponse, callback: (req, _) => capturedRequest = req); + + // Act + await sut.DeleteValueAsync(namespaceId, key); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Delete); + capturedRequest.RequestUri!.ToString().Should().Be($"https://api.cloudflare.com/client/v4/accounts/{TestAccountId}/storage/kv/namespaces/{namespaceId}/values/{key}"); + } + + /// Verifies that DeleteValueAsync URL-encodes special characters in key names. + [Fact] + public async Task DeleteValueAsync_UrlEncodesKeyName() + { + // Arrange + var namespaceId = "ns-123"; + var key = "key with/special+chars"; + var successResponse = HttpFixtures.CreateSuccessResponse(null); + + HttpRequestMessage? capturedRequest = null; + var sut = CreateSut(successResponse, callback: (req, _) => capturedRequest = req); + + // Act + await sut.DeleteValueAsync(namespaceId, key); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.OriginalString.Should().Contain("key%20with%2Fspecial%2Bchars"); + } + + #endregion + + + #region Bulk Operations - BulkWriteAsync + + /// Verifies that BulkWriteAsync sends a correctly formatted PUT request. + [Fact] + public async Task BulkWriteAsync_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var items = new[] + { + new KvBulkWriteItem("key1", "value1"), + new KvBulkWriteItem("key2", "value2", Expiration: 1735689600) + }; + var expectedResult = new KvBulkWriteResult(2, null); + var successResponse = HttpFixtures.CreateSuccessResponse(expectedResult); + + HttpRequestMessage? capturedRequest = null; + string? capturedJsonBody = null; + var sut = CreateSut(successResponse, callback: (req, _) => + { + capturedRequest = req; + capturedJsonBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }); + + // Act + var result = await sut.BulkWriteAsync(namespaceId, items); + + // Assert + result.SuccessfulKeyCount.Should().Be(2); + result.UnsuccessfulKeys.Should().BeNull(); + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Put); + capturedRequest.RequestUri!.ToString().Should().Be($"https://api.cloudflare.com/client/v4/accounts/{TestAccountId}/storage/kv/namespaces/{namespaceId}/bulk"); + + // Verify the JSON body is an array of items. + capturedJsonBody.Should().NotBeNull(); + using var doc = JsonDocument.Parse(capturedJsonBody!); + doc.RootElement.GetArrayLength().Should().Be(2); + } + + /// Verifies that BulkWriteAsync handles partial failures correctly. + [Fact] + public async Task BulkWriteAsync_WithPartialFailure_ReturnsUnsuccessfulKeys() + { + // Arrange + var namespaceId = "ns-123"; + var items = new[] + { + new KvBulkWriteItem("key1", "value1"), + new KvBulkWriteItem("key2", "value2") + }; + var expectedResult = new KvBulkWriteResult(1, new[] { "key2" }); + var successResponse = HttpFixtures.CreateSuccessResponse(expectedResult); + + var sut = CreateSut(successResponse); + + // Act + var result = await sut.BulkWriteAsync(namespaceId, items); + + // Assert + result.SuccessfulKeyCount.Should().Be(1); + result.UnsuccessfulKeys.Should().ContainSingle().Which.Should().Be("key2"); + } + + #endregion + + + #region Bulk Operations - BulkDeleteAsync + + /// Verifies that BulkDeleteAsync sends a correctly formatted POST request. + [Fact] + public async Task BulkDeleteAsync_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var keys = new[] { "key1", "key2", "key3" }; + var expectedResult = new KvBulkDeleteResult(3, null); + var successResponse = HttpFixtures.CreateSuccessResponse(expectedResult); + + HttpRequestMessage? capturedRequest = null; + string? capturedJsonBody = null; + var sut = CreateSut(successResponse, callback: (req, _) => + { + capturedRequest = req; + capturedJsonBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }); + + // Act + var result = await sut.BulkDeleteAsync(namespaceId, keys); + + // Assert + result.SuccessfulKeyCount.Should().Be(3); + result.UnsuccessfulKeys.Should().BeNull(); + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Post); + capturedRequest.RequestUri!.ToString().Should().Be($"https://api.cloudflare.com/client/v4/accounts/{TestAccountId}/storage/kv/namespaces/{namespaceId}/bulk/delete"); + + // Verify the JSON body is an array of key names. + capturedJsonBody.Should().NotBeNull(); + using var doc = JsonDocument.Parse(capturedJsonBody!); + doc.RootElement.GetArrayLength().Should().Be(3); + } + + #endregion + + + #region Bulk Operations - BulkGetAsync + + /// Verifies that BulkGetAsync sends a correctly formatted POST request with camelCase body. + [Fact] + public async Task BulkGetAsync_SendsCorrectRequest() + { + // Arrange + var namespaceId = "ns-123"; + var keys = new[] { "key1", "key2" }; + var resultValues = new Dictionary { { "key1", "value1" }, { "key2", "value2" } }; + var successResponse = HttpFixtures.CreateSuccessResponse(new { values = resultValues }); + + HttpRequestMessage? capturedRequest = null; + string? capturedJsonBody = null; + var sut = CreateSut(successResponse, callback: (req, _) => + { + capturedRequest = req; + capturedJsonBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }); + + // Act + var result = await sut.BulkGetAsync(namespaceId, keys); + + // Assert + result.Should().HaveCount(2); + result["key1"].Should().Be("value1"); + result["key2"].Should().Be("value2"); + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Post); + capturedRequest.RequestUri!.ToString().Should().Be($"https://api.cloudflare.com/client/v4/accounts/{TestAccountId}/storage/kv/namespaces/{namespaceId}/bulk/get"); + + // Verify the JSON body uses camelCase. + capturedJsonBody.Should().NotBeNull(); + capturedJsonBody.Should().Contain("\"keys\""); + capturedJsonBody.Should().NotContain("\"withMetadata\""); // null should be omitted + } + + /// Verifies that BulkGetAsync returns null values for non-existent keys. + [Fact] + public async Task BulkGetAsync_WhenKeyNotFound_ReturnsNullValue() + { + // Arrange + var namespaceId = "ns-123"; + var keys = new[] { "key1", "non-existent" }; + var resultValues = new Dictionary { { "key1", "value1" }, { "non-existent", null } }; + var successResponse = HttpFixtures.CreateSuccessResponse(new { values = resultValues }); + + var sut = CreateSut(successResponse); + + // Act + var result = await sut.BulkGetAsync(namespaceId, keys); + + // Assert + result.Should().HaveCount(2); + result["key1"].Should().Be("value1"); + result["non-existent"].Should().BeNull(); + } + + #endregion + + + #region Bulk Operations - BulkGetWithMetadataAsync + + /// Verifies that BulkGetWithMetadataAsync sends withMetadata=true in the request body. + [Fact] + public async Task BulkGetWithMetadataAsync_SendsWithMetadataFlag() + { + // Arrange + var namespaceId = "ns-123"; + var keys = new[] { "key1" }; + var resultValues = new Dictionary + { + { "key1", new KvBulkGetItemWithMetadata("value1", JsonSerializer.SerializeToElement(new { category = "test" })) } + }; + var successResponse = HttpFixtures.CreateSuccessResponse(new { values = resultValues }); + + HttpRequestMessage? capturedRequest = null; + string? capturedJsonBody = null; + var sut = CreateSut(successResponse, callback: (req, _) => + { + capturedRequest = req; + capturedJsonBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }); + + // Act + var result = await sut.BulkGetWithMetadataAsync(namespaceId, keys); + + // Assert + result.Should().HaveCount(1); + result["key1"]!.Value.Should().Be("value1"); + capturedJsonBody.Should().NotBeNull(); + // Verify camelCase withMetadata (not with_metadata). + capturedJsonBody.Should().Contain("\"withMetadata\":true"); + } + + #endregion + + + #region Error Handling + + /// Verifies that API errors are properly propagated as CloudflareApiException. + [Fact] + public async Task CreateAsync_WhenApiReturnsError_ThrowsCloudflareApiException() + { + // Arrange + var errorResponse = HttpFixtures.CreateErrorResponse(10014, "Namespace title already exists"); + var sut = CreateSut(errorResponse); + + // Act + var action = async () => await sut.CreateAsync("Existing Namespace"); + + // Assert + var ex = await action.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("10014"); + ex.Which.Errors.Should().ContainSingle().Which.Code.Should().Be(10014); + } + + /// Verifies that GetValueAsync throws on non-404 errors. + [Fact] + public async Task GetValueAsync_WhenApiReturnsServerError_ThrowsHttpRequestException() + { + // Arrange + var namespaceId = "ns-123"; + var key = "my-key"; + var sut = CreateSutWithHeaders("Internal Server Error", HttpStatusCode.InternalServerError, []); + + // Act + var action = async () => await sut.GetValueAsync(namespaceId, key); + + // Assert + await action.Should().ThrowAsync(); + } + + /// Verifies that WriteValueAsync throws on errors. + [Fact] + public async Task WriteValueAsync_WhenApiReturnsError_ThrowsHttpRequestException() + { + // Arrange + var namespaceId = "ns-123"; + var key = "my-key"; + var sut = CreateSutWithHeaders("Bad Request", HttpStatusCode.BadRequest, []); + + // Act + var action = async () => await sut.WriteValueAsync(namespaceId, key, "value"); + + // Assert + await action.Should().ThrowAsync(); + } + + /// Verifies that DeleteAsync propagates API errors correctly. + [Fact] + public async Task DeleteAsync_WhenApiReturnsError_ThrowsCloudflareApiException() + { + // Arrange + var errorResponse = HttpFixtures.CreateErrorResponse(10007, "Namespace not found"); + var sut = CreateSut(errorResponse); + + // Act + var action = async () => await sut.DeleteAsync("non-existent-namespace"); + + // Assert + var ex = await action.Should().ThrowAsync(); + ex.Which.Errors.Should().ContainSingle().Which.Code.Should().Be(10007); + } + + /// Verifies that BulkWriteAsync propagates API errors correctly. + [Fact] + public async Task BulkWriteAsync_WhenApiReturnsError_ThrowsCloudflareApiException() + { + // Arrange + var errorResponse = HttpFixtures.CreateErrorResponse(10013, "Request body too large"); + var sut = CreateSut(errorResponse); + + // Act + var action = async () => await sut.BulkWriteAsync("ns-123", new[] { new KvBulkWriteItem("key", "value") }); + + // Assert + await action.Should().ThrowAsync(); + } + + #endregion +}