diff --git a/Directory.Packages.props b/Directory.Packages.props index 58457fa..e270f83 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,7 +1,7 @@ true - 2.7.1 + 2.7.2-preview.1 @@ -10,6 +10,7 @@ + diff --git a/orbit.net.slnx b/orbit.net.slnx index 417bc88..f200845 100644 --- a/orbit.net.slnx +++ b/orbit.net.slnx @@ -16,11 +16,13 @@ + + diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/INatsKeyCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/INatsKeyCodec.cs new file mode 100644 index 0000000..721bcbc --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/INatsKeyCodec.cs @@ -0,0 +1,40 @@ +// Copyright (c) Synadia Communications, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. + +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +/// +/// Defines the interface for encoding and decoding keys in a KV bucket. +/// +public interface INatsKeyCodec +{ + /// + /// Encodes a key for storage. + /// + /// The key to encode. + /// The encoded key. + string EncodeKey(string key); + + /// + /// Decodes a key retrieved from storage. + /// + /// The encoded key to decode. + /// The decoded key. + string DecodeKey(string key); +} + +/// +/// An optional interface that key codecs can implement to support wildcard filtering operations. +/// If a key codec doesn't implement this interface, filter operations where the pattern contains +/// wildcards (* or >) will throw . +/// +public interface INatsFilterableKeyCodec : INatsKeyCodec +{ + /// + /// Encodes a pattern that may contain wildcards (* or >). + /// Unlike , this must preserve wildcards in the result. + /// + /// The filter pattern to encode. + /// The encoded filter pattern with wildcards preserved. + string EncodeFilter(string filter); +} diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsBase64KeyCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsBase64KeyCodec.cs new file mode 100644 index 0000000..370382e --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsBase64KeyCodec.cs @@ -0,0 +1,96 @@ +// Copyright (c) Synadia Communications, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. + +using System.Text; + +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +/// +/// A codec that encodes keys using URL-safe Base64 encoding. +/// Each token (separated by '.') is encoded separately, preserving the NATS subject structure. +/// +public sealed class NatsBase64KeyCodec : INatsFilterableKeyCodec +{ + private NatsBase64KeyCodec() + { + } + + /// + /// Gets the singleton instance of the . + /// + public static NatsBase64KeyCodec Instance { get; } = new(); + + /// + public string EncodeKey(string key) + { + var tokens = key.Split('.'); + for (var i = 0; i < tokens.Length; i++) + { + tokens[i] = Base64UrlEncode(tokens[i]); + } + + return string.Join(".", tokens); + } + + /// + public string DecodeKey(string key) + { + var tokens = key.Split('.'); + for (var i = 0; i < tokens.Length; i++) + { + tokens[i] = Base64UrlDecode(tokens[i]); + } + + return string.Join(".", tokens); + } + + /// + public string EncodeFilter(string filter) + { + var tokens = filter.Split('.'); + for (var i = 0; i < tokens.Length; i++) + { + var token = tokens[i]; + if (token != "*" && token != ">") + { + tokens[i] = Base64UrlEncode(token); + } + } + + return string.Join(".", tokens); + } + + private static string Base64UrlEncode(string input) + { + var bytes = Encoding.UTF8.GetBytes(input); + var base64 = Convert.ToBase64String(bytes); + + // Convert to URL-safe Base64 (no padding) + return base64 + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private static string Base64UrlDecode(string input) + { + // Convert from URL-safe Base64 + var base64 = input + .Replace('-', '+') + .Replace('_', '/'); + + // Add padding if needed + switch (base64.Length % 4) + { + case 2: + base64 += "=="; + break; + case 3: + base64 += "="; + break; + } + + var bytes = Convert.FromBase64String(base64); + return Encoding.UTF8.GetString(bytes); + } +} diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKVCodecStore.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKVCodecStore.cs new file mode 100644 index 0000000..45f11cc --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKVCodecStore.cs @@ -0,0 +1,258 @@ +// Copyright (c) Synadia Communications, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. + +using System.Runtime.CompilerServices; +using NATS.Client.Core; +using NATS.Client.JetStream; +using NATS.Client.KeyValueStore; + +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +/// +/// A wrapper around that applies key encoding/decoding using a codec. +/// +public sealed class NatsKVCodecStore : INatsKVStore +{ + private readonly INatsKVStore _store; + private readonly INatsKeyCodec _keyCodec; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying KV store to wrap. + /// The codec to use for key encoding/decoding. + public NatsKVCodecStore(INatsKVStore store, INatsKeyCodec keyCodec) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _keyCodec = keyCodec ?? throw new ArgumentNullException(nameof(keyCodec)); + } + + /// + public INatsJSContext JetStreamContext => _store.JetStreamContext; + + /// + public string Bucket => _store.Bucket; + + /// + public ValueTask PutAsync(string key, T value, INatsSerialize? serializer = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.PutAsync(encodedKey, value, serializer, cancellationToken); + } + + /// + public ValueTask> TryPutAsync(string key, T value, INatsSerialize? serializer = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.TryPutAsync(encodedKey, value, serializer, cancellationToken); + } + + /// + public ValueTask CreateAsync(string key, T value, INatsSerialize? serializer = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.CreateAsync(encodedKey, value, serializer, cancellationToken); + } + + /// + public ValueTask CreateAsync(string key, T value, TimeSpan ttl, INatsSerialize? serializer = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.CreateAsync(encodedKey, value, ttl, serializer, cancellationToken); + } + + /// + public ValueTask> TryCreateAsync(string key, T value, INatsSerialize? serializer = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.TryCreateAsync(encodedKey, value, serializer, cancellationToken); + } + + /// + public ValueTask> TryCreateAsync(string key, T value, TimeSpan ttl, INatsSerialize? serializer = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.TryCreateAsync(encodedKey, value, ttl, serializer, cancellationToken); + } + + /// + public ValueTask UpdateAsync(string key, T value, ulong revision, INatsSerialize? serializer = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.UpdateAsync(encodedKey, value, revision, serializer, cancellationToken); + } + + /// + public ValueTask> TryUpdateAsync(string key, T value, ulong revision, INatsSerialize? serializer = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.TryUpdateAsync(encodedKey, value, revision, serializer, cancellationToken); + } + + /// + public ValueTask DeleteAsync(string key, NatsKVDeleteOpts? opts = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.DeleteAsync(encodedKey, opts, cancellationToken); + } + + /// + public ValueTask TryDeleteAsync(string key, NatsKVDeleteOpts? opts = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.TryDeleteAsync(encodedKey, opts, cancellationToken); + } + + /// + public ValueTask PurgeAsync(string key, NatsKVDeleteOpts? opts = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.PurgeAsync(encodedKey, opts, cancellationToken); + } + + /// + public ValueTask PurgeAsync(string key, TimeSpan ttl, NatsKVDeleteOpts? opts = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.PurgeAsync(encodedKey, ttl, opts, cancellationToken); + } + + /// + public ValueTask TryPurgeAsync(string key, NatsKVDeleteOpts? opts = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.TryPurgeAsync(encodedKey, opts, cancellationToken); + } + + /// + public ValueTask TryPurgeAsync(string key, TimeSpan ttl, NatsKVDeleteOpts? opts = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return _store.TryPurgeAsync(encodedKey, ttl, opts, cancellationToken); + } + + /// + public async ValueTask> GetEntryAsync(string key, ulong revision = default, INatsDeserialize? serializer = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + var entry = await _store.GetEntryAsync(encodedKey, revision, serializer, cancellationToken).ConfigureAwait(false); + return DecodeEntry(entry, key); + } + + /// + public async ValueTask>> TryGetEntryAsync(string key, ulong revision = default, INatsDeserialize? serializer = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + var result = await _store.TryGetEntryAsync(encodedKey, revision, serializer, cancellationToken).ConfigureAwait(false); + if (result.Success) + { + return new NatsResult>(DecodeEntry(result.Value, key)); + } + + return result; + } + + /// + public IAsyncEnumerable> WatchAsync(string key, INatsDeserialize? serializer = default, NatsKVWatchOpts? opts = default, CancellationToken cancellationToken = default) + { + var encodedKey = EncodeFilter(key); + return DecodeEntriesAsync(_store.WatchAsync(encodedKey, serializer, opts, cancellationToken), cancellationToken); + } + + /// + public IAsyncEnumerable> WatchAsync(IEnumerable keys, INatsDeserialize? serializer = default, NatsKVWatchOpts? opts = default, CancellationToken cancellationToken = default) + { + var encodedKeys = keys.Select(EncodeFilter); + return DecodeEntriesAsync(_store.WatchAsync(encodedKeys, serializer, opts, cancellationToken), cancellationToken); + } + + /// + public IAsyncEnumerable> WatchAsync(INatsDeserialize? serializer = default, NatsKVWatchOpts? opts = default, CancellationToken cancellationToken = default) + { + // Watch all - no filter encoding needed, but we still need to decode keys in results + return DecodeEntriesAsync(_store.WatchAsync(serializer, opts, cancellationToken), cancellationToken); + } + + /// + public IAsyncEnumerable> HistoryAsync(string key, INatsDeserialize? serializer = default, NatsKVWatchOpts? opts = default, CancellationToken cancellationToken = default) + { + var encodedKey = _keyCodec.EncodeKey(key); + return DecodeEntriesAsync(_store.HistoryAsync(encodedKey, serializer, opts, cancellationToken), cancellationToken, key); + } + + /// + public ValueTask GetStatusAsync(CancellationToken cancellationToken = default) + { + return _store.GetStatusAsync(cancellationToken); + } + + /// + public ValueTask PurgeDeletesAsync(NatsKVPurgeOpts? opts = default, CancellationToken cancellationToken = default) + { + return _store.PurgeDeletesAsync(opts, cancellationToken); + } + + /// + public IAsyncEnumerable GetKeysAsync(NatsKVWatchOpts? opts = default, CancellationToken cancellationToken = default) + { + return DecodeKeysAsync(_store.GetKeysAsync(opts, cancellationToken), cancellationToken); + } + + /// + public IAsyncEnumerable GetKeysAsync(IEnumerable filters, NatsKVWatchOpts? opts = default, CancellationToken cancellationToken = default) + { + var encodedFilters = filters.Select(EncodeFilter); + return DecodeKeysAsync(_store.GetKeysAsync(encodedFilters, opts, cancellationToken), cancellationToken); + } + + private string EncodeFilter(string filter) + { + if (_keyCodec is INatsFilterableKeyCodec filterableCodec) + { + return filterableCodec.EncodeFilter(filter); + } + + // Check if filter contains wildcards + if (filter.Contains("*") || filter.Contains(">")) + { + throw new NatsKeyCodecException($"Codec does not support wildcard filtering. Key: '{filter}'"); + } + + return _keyCodec.EncodeKey(filter); + } + + private NatsKVEntry DecodeEntry(NatsKVEntry entry, string? originalKey = null) + { + var decodedKey = originalKey ?? _keyCodec.DecodeKey(entry.Key); + return new NatsKVEntry(entry.Bucket, decodedKey) + { + Value = entry.Value, + Revision = entry.Revision, + Delta = entry.Delta, + Created = entry.Created, + Operation = entry.Operation, + Error = entry.Error, + }; + } + + private async IAsyncEnumerable> DecodeEntriesAsync( + IAsyncEnumerable> entries, + [EnumeratorCancellation] CancellationToken cancellationToken, + string? originalKey = null) + { + await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + yield return DecodeEntry(entry, originalKey); + } + } + + private async IAsyncEnumerable DecodeKeysAsync( + IAsyncEnumerable keys, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var key in keys.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + yield return _keyCodec.DecodeKey(key); + } + } +} diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKVStoreExtensions.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKVStoreExtensions.cs new file mode 100644 index 0000000..3f3f0e2 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKVStoreExtensions.cs @@ -0,0 +1,48 @@ +// Copyright (c) Synadia Communications, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. + +using NATS.Client.KeyValueStore; + +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +/// +/// Extension methods for . +/// +public static class NatsKVStoreExtensions +{ + /// + /// Wraps the KV store with a key codec for transparent key encoding/decoding. + /// + /// The KV store to wrap. + /// The codec to use for key encoding/decoding. + /// A new that applies the codec to all key operations. + public static INatsKVStore WithKeyCodec(this INatsKVStore store, INatsKeyCodec keyCodec) + { + return new NatsKVCodecStore(store, keyCodec); + } + + /// + /// Wraps the KV store with Base64 key encoding. + /// Each key token (separated by '.') is encoded separately using URL-safe Base64. + /// + /// The KV store to wrap. + /// A new that Base64 encodes all keys. + public static INatsKVStore WithBase64Keys(this INatsKVStore store) + { + return new NatsKVCodecStore(store, NatsBase64KeyCodec.Instance); + } + + /// + /// Wraps the KV store with path-style key encoding. + /// Keys using '/' separators are translated to NATS subject notation using '.'. + /// + /// The KV store to wrap. + /// A new that translates path-style keys. + /// + /// Example: "/users/123/profile" becomes "_root_.users.123.profile". + /// + public static INatsKVStore WithPathKeys(this INatsKVStore store) + { + return new NatsKVCodecStore(store, NatsPathKeyCodec.Instance); + } +} diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKeyChainCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKeyChainCodec.cs new file mode 100644 index 0000000..32d1bed --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKeyChainCodec.cs @@ -0,0 +1,96 @@ +// Copyright (c) Synadia Communications, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. + +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +/// +/// Applies multiple key codecs in sequence. +/// Encoding is applied in order (first to last), decoding in reverse order (last to first). +/// +public sealed class NatsKeyChainCodec : INatsFilterableKeyCodec +{ + private readonly INatsKeyCodec[] _codecs; + + /// + /// Initializes a new instance of the class. + /// + /// The codecs to chain together. At least one codec must be provided. + /// Thrown when no codecs are provided. + public NatsKeyChainCodec(params INatsKeyCodec[] codecs) + { + if (codecs == null || codecs.Length == 0) + { + throw new ArgumentException("At least one codec must be provided.", nameof(codecs)); + } + + _codecs = codecs; + } + + /// + public string EncodeKey(string key) + { + var result = key; + for (var i = 0; i < _codecs.Length; i++) + { + try + { + result = _codecs[i].EncodeKey(result); + } + catch (Exception ex) when (ex is not NatsKeyCodecException) + { + throw new NatsKeyCodecException($"Failed to encode key at codec {i}.", ex); + } + } + + return result; + } + + /// + public string DecodeKey(string key) + { + var result = key; + for (var i = _codecs.Length - 1; i >= 0; i--) + { + try + { + result = _codecs[i].DecodeKey(result); + } + catch (Exception ex) when (ex is not NatsKeyCodecException) + { + throw new NatsKeyCodecException($"Failed to decode key at codec {i}.", ex); + } + } + + return result; + } + + /// + /// Thrown when any codec in the chain does not support filtering. + public string EncodeFilter(string filter) + { + // First, verify all codecs support filtering + for (var i = 0; i < _codecs.Length; i++) + { + if (_codecs[i] is not INatsFilterableKeyCodec) + { + throw new NatsKeyCodecException($"Codec at index {i} does not support wildcard filtering."); + } + } + + // All codecs support filtering, apply them in sequence + var result = filter; + for (var i = 0; i < _codecs.Length; i++) + { + try + { + result = ((INatsFilterableKeyCodec)_codecs[i]).EncodeFilter(result); + } + catch (Exception ex) when (ex is not NatsKeyCodecException) + { + throw new NatsKeyCodecException($"Failed to encode filter at codec {i}.", ex); + } + } + + return result; + } +} diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKeyCodecException.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKeyCodecException.cs new file mode 100644 index 0000000..332b0af --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKeyCodecException.cs @@ -0,0 +1,29 @@ +// Copyright (c) Synadia Communications, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. + +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +/// +/// Exception thrown when a key codec operation fails. +/// +public class NatsKeyCodecException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message. + public NatsKeyCodecException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The inner exception. + public NatsKeyCodecException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsNoOpKeyCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsNoOpKeyCodec.cs new file mode 100644 index 0000000..6c99696 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsNoOpKeyCodec.cs @@ -0,0 +1,28 @@ +// Copyright (c) Synadia Communications, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. + +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +/// +/// A no-op codec that passes keys through unchanged. +/// +public sealed class NatsNoOpKeyCodec : INatsFilterableKeyCodec +{ + private NatsNoOpKeyCodec() + { + } + + /// + /// Gets the singleton instance of the . + /// + public static NatsNoOpKeyCodec Instance { get; } = new(); + + /// + public string EncodeKey(string key) => key; + + /// + public string DecodeKey(string key) => key; + + /// + public string EncodeFilter(string filter) => filter; +} diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsPathKeyCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsPathKeyCodec.cs new file mode 100644 index 0000000..e78bae0 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsPathKeyCodec.cs @@ -0,0 +1,78 @@ +// Copyright (c) Synadia Communications, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. + +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +/// +/// A codec that translates between path-style keys (using '/') and NATS subject notation (using '.'). +/// +/// +/// This codec is useful when you want to use familiar path-style keys like "/users/123/profile" +/// which get translated to NATS-compatible keys like "_root_.users.123.profile". +/// +public sealed class NatsPathKeyCodec : INatsFilterableKeyCodec +{ + /// + /// The prefix used to encode keys that start with a leading slash. + /// Since NATS subjects cannot start with a dot, we replace the leading slash + /// with this prefix to maintain round-trip compatibility. + /// + internal const string RootPrefix = "_root_"; + + private NatsPathKeyCodec() + { + } + + /// + /// Gets the singleton instance of the . + /// + public static NatsPathKeyCodec Instance { get; } = new(); + + /// + public string EncodeKey(string key) + { + // Handle leading / by replacing with _root_ + if (key.StartsWith("/")) + { + if (key == "/") + { + return RootPrefix; + } + + key = RootPrefix + "." + key.Substring(1); + } + + // Trim trailing / as subjects do not allow trailing . + key = key.TrimEnd('/'); + + return key.Replace('/', '.'); + } + + /// + public string DecodeKey(string key) + { + // Handle _root_ prefix + if (key == RootPrefix) + { + return "/"; + } + + var prefixWithDot = RootPrefix + "."; + if (key.StartsWith(prefixWithDot)) + { + // Remove _root_ prefix and replace . with / + var result = key.Substring(prefixWithDot.Length).Replace('.', '/'); + return "/" + result; + } + + return key.Replace('.', '/'); + } + + /// + public string EncodeFilter(string filter) + { + // For path codec, filter encoding is the same as key encoding + // since wildcards (* and >) don't conflict with path characters + return EncodeKey(filter); + } +} diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md b/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md new file mode 100644 index 0000000..f2af722 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md @@ -0,0 +1,176 @@ +# KeyValueStore Extensions + +Utilities that extend NATS KeyValueStore client functionality. + +## Key Codecs + +Key codecs provide transparent key encoding/decoding for KV stores. This is useful when you need to store keys that contain characters not allowed in NATS subjects, or when you prefer a different key format. + +### Base64 Key Encoding + +Encodes each key token using URL-safe Base64. Useful for keys containing special characters like `/`, `@`, or spaces. + +```csharp +// dotnet add package nats.net +// dotnet add package Synadia.Orbit.KeyValueStore.Extensions --prerelease +using Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +await using var client = new NatsClient(); +var kv = client.CreateKeyValueStoreContext(); + +var rawStore = await kv.CreateStoreAsync("my-bucket"); +var store = rawStore.WithBase64Keys(); + +// Keys with special characters work transparently +await store.PutAsync("user/123@example.com", "user data"); +// Stored in KV as: "dXNlci8xMjNAZXhhbXBsZQ.Y29t" +// ^^^^^^^^^^^^^^^^^^^^^^ ^^^^ +// "user/123@example" "com" +// (dots are preserved as token separators, each token is Base64 encoded) + +var entry = await store.GetEntryAsync("user/123@example.com"); +Console.WriteLine($"Key: {entry.Key}, Value: {entry.Value}"); +// Output: Key: user/123@example.com, Value: user data +``` + +### Path-Style Keys + +Translates familiar path-style keys (using `/`) to NATS subject notation (using `.`). + +```csharp +using Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +await using var client = new NatsClient(); +var kv = client.CreateKeyValueStoreContext(); + +var rawStore = await kv.CreateStoreAsync("config-bucket"); +var store = rawStore.WithPathKeys(); + +// Use familiar path-style keys +await store.PutAsync("/config/database/connection-string", "Server=localhost;..."); +// Stored in KV as: "_root_.config.database.connection-string" + +await store.PutAsync("/config/database/timeout", "30"); +// Stored in KV as: "_root_.config.database.timeout" + +await store.PutAsync("/config/logging/level", "Information"); +// Stored in KV as: "_root_.config.logging.level" + +// Keys are returned in path format +await foreach (var key in store.GetKeysAsync()) +{ + Console.WriteLine(key); +} +// Output: +// /config/database/connection-string +// /config/database/timeout +// /config/logging/level +``` + +### Chaining Codecs + +Multiple codecs can be chained together for complex encoding scenarios. + +```csharp +using Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +// Chain: Path codec first, then Base64 +var chain = new NatsKeyChainCodec(NatsPathKeyCodec.Instance, NatsBase64KeyCodec.Instance); +var store = rawStore.WithKeyCodec(chain); +``` + +### Custom Codecs + +Implement `INatsKeyCodec` or `INatsFilterableKeyCodec` to create custom encoding logic. + +```csharp +using Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +public class MyCustomCodec : INatsFilterableKeyCodec +{ + public string EncodeKey(string key) => /* your encoding logic */; + public string DecodeKey(string key) => /* your decoding logic */; + public string EncodeFilter(string filter) => /* preserve wildcards */; +} + +var store = rawStore.WithKeyCodec(new MyCustomCodec()); +``` + +Example: Custom Codec (ROT13 'encryption') + +```csharp +using Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +await using var client = new NatsClient(); +var kv = client.CreateKeyValueStoreContext(); + +var rawStore = await kv.CreateStoreAsync("secret-bucket"); + +// Use custom ROT13 codec for "encrypted" keys +var store = rawStore.WithKeyCodec(new Rot13KeyCodec()); + +// Store with readable keys - they get ROT13 encoded in storage +await store.PutAsync("secret.password", "hunter2"); +await store.PutAsync("secret.api-key", "abc123"); + +// Keys are returned decoded +var entry = await store.GetEntryAsync("secret.password"); +Console.WriteLine($"Key: {entry.Key}, Value: {entry.Value}"); +// Output: Key: secret.password, Value: hunter2 + +// But in raw storage, keys are ROT13 encoded +await foreach (string key in rawStore.GetKeysAsync()) +{ + Console.WriteLine($"Raw Key: {key}"); +} +// Output: +// Raw Key: frperg.cnffjbeq +// Raw Key: frperg.ncv-xrl +``` + +```csharp +/// +/// A custom codec that "encrypts" keys using ROT13 substitution cipher. +/// This is for demonstration purposes only - ROT13 is not secure encryption! +/// +public class Rot13KeyCodec : INatsFilterableKeyCodec +{ + public string EncodeKey(string key) => Rot13(key); + + public string DecodeKey(string key) => Rot13(key); // ROT13 is its own inverse + + public string EncodeFilter(string filter) => Rot13(filter); + + private static string Rot13(string input) + { + var result = new char[input.Length]; + for (int i = 0; i < input.Length; i++) + { + char c = input[i]; + if (c >= 'a' && c <= 'z') + { + result[i] = (char)('a' + (c - 'a' + 13) % 26); + } + else if (c >= 'A' && c <= 'Z') + { + result[i] = (char)('A' + (c - 'A' + 13) % 26); + } + else + { + result[i] = c; // Non-letters pass through unchanged (including '.' and '*') + } + } + + return new string(result); + } +} +``` + +## Available Codecs + +| Codec | Description | +|-------|-------------| +| `NatsNoOpKeyCodec` | Pass-through, no encoding | +| `NatsBase64KeyCodec` | URL-safe Base64 encoding per token | +| `NatsPathKeyCodec` | Converts `/path/style` to `.subject.style` | +| `NatsKeyChainCodec` | Chains multiple codecs together | diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/Synadia.Orbit.KeyValueStore.Extensions.csproj b/src/Synadia.Orbit.KeyValueStore.Extensions/Synadia.Orbit.KeyValueStore.Extensions.csproj new file mode 100644 index 0000000..5c1629b --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Synadia.Orbit.KeyValueStore.Extensions.csproj @@ -0,0 +1,13 @@ + + + + enable + enable + true + + + + + + + diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/version.txt b/src/Synadia.Orbit.KeyValueStore.Extensions/version.txt new file mode 100644 index 0000000..5fd7619 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/version.txt @@ -0,0 +1 @@ +1.0.0-preview.1 diff --git a/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Codecs/CodecTests.cs b/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Codecs/CodecTests.cs new file mode 100644 index 0000000..9370927 --- /dev/null +++ b/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Codecs/CodecTests.cs @@ -0,0 +1,425 @@ +// Copyright (c) Synadia Communications, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. + +using NATS.Client.Core; +using NATS.Client.KeyValueStore; +using NATS.Net; +using Synadia.Orbit.KeyValueStore.Extensions.Codecs; +using Synadia.Orbit.TestUtils; + +namespace Synadia.Orbit.KeyValueStore.Extensions.Test.Codecs; + +[Collection("nats-server")] +public class CodecTests +{ + private readonly ITestOutputHelper _output; + private readonly NatsServerFixture _server; + + public CodecTests(ITestOutputHelper output, NatsServerFixture server) + { + _output = output; + _server = server; + } + + [Fact] + public void NatsNoOpKeyCodec_passes_through_unchanged() + { + var codec = NatsNoOpKeyCodec.Instance; + + Assert.Equal("test.key", codec.EncodeKey("test.key")); + Assert.Equal("test.key", codec.DecodeKey("test.key")); + Assert.Equal("test.*", codec.EncodeFilter("test.*")); + Assert.Equal("test.>", codec.EncodeFilter("test.>")); + } + + [Fact] + public void NatsBase64KeyCodec_encodes_each_token_separately() + { + var codec = NatsBase64KeyCodec.Instance; + + // "hello" in base64url is "aGVsbG8" + // "world" in base64url is "d29ybGQ" + var encoded = codec.EncodeKey("hello.world"); + Assert.Equal("aGVsbG8.d29ybGQ", encoded); + + var decoded = codec.DecodeKey(encoded); + Assert.Equal("hello.world", decoded); + } + + [Fact] + public void NatsBase64KeyCodec_handles_special_characters() + { + var codec = NatsBase64KeyCodec.Instance; + + // Test with characters that would be invalid in NATS subjects + var key = "user/123.profile@test"; + var encoded = codec.EncodeKey(key); + var decoded = codec.DecodeKey(encoded); + + Assert.Equal(key, decoded); + _output.WriteLine($"Original: {key}"); + _output.WriteLine($"Encoded: {encoded}"); + } + + [Fact] + public void NatsBase64KeyCodec_preserves_dots_as_token_separators() + { + var codec = NatsBase64KeyCodec.Instance; + + // "user/123@example.com" has a dot, so it splits into two tokens: + // "user/123@example" and "com" + var key = "user/123@example.com"; + var encoded = codec.EncodeKey(key); + var decoded = codec.DecodeKey(encoded); + + Assert.Equal(key, decoded); + Assert.Equal("dXNlci8xMjNAZXhhbXBsZQ.Y29t", encoded); + + // "user/123@example" -> "dXNlci8xMjNAZXhhbXBsZQ" + // "com" -> "Y29t" + _output.WriteLine($"Original: {key}"); + _output.WriteLine($"Encoded: {encoded}"); + } + + [Fact] + public void NatsBase64KeyCodec_encodes_key_without_dots_as_single_token() + { + var codec = NatsBase64KeyCodec.Instance; + + // Key without any dots is encoded as a single base64 token + var key = "user/123@test"; + var encoded = codec.EncodeKey(key); + var decoded = codec.DecodeKey(encoded); + + Assert.Equal(key, decoded); + Assert.DoesNotContain(".", encoded); // No dots since input has no dots + _output.WriteLine($"Original: {key}"); + _output.WriteLine($"Encoded: {encoded}"); + } + + [Fact] + public void NatsBase64KeyCodec_preserves_wildcards_in_filter() + { + var codec = NatsBase64KeyCodec.Instance; + + var filter = "users.*.profile"; + var encoded = codec.EncodeFilter(filter); + + // "users" and "profile" should be encoded, but "*" should be preserved + Assert.Contains("*", encoded); + Assert.Equal("dXNlcnM.*.cHJvZmlsZQ", encoded); + } + + [Fact] + public void NatsBase64KeyCodec_preserves_gt_wildcard_in_filter() + { + var codec = NatsBase64KeyCodec.Instance; + + var filter = "users.>"; + var encoded = codec.EncodeFilter(filter); + + Assert.EndsWith(".>", encoded); + Assert.Equal("dXNlcnM.>", encoded); + } + + [Fact] + public void NatsPathKeyCodec_converts_slashes_to_dots() + { + var codec = NatsPathKeyCodec.Instance; + + // Without leading slash + Assert.Equal("users.123.profile", codec.EncodeKey("users/123/profile")); + Assert.Equal("users/123/profile", codec.DecodeKey("users.123.profile")); + } + + [Fact] + public void NatsPathKeyCodec_handles_leading_slash() + { + var codec = NatsPathKeyCodec.Instance; + + // With leading slash - should use _root_ prefix + var encoded = codec.EncodeKey("/users/123/profile"); + Assert.Equal("_root_.users.123.profile", encoded); + + var decoded = codec.DecodeKey(encoded); + Assert.Equal("/users/123/profile", decoded); + } + + [Fact] + public void NatsPathKeyCodec_handles_root_only() + { + var codec = NatsPathKeyCodec.Instance; + + Assert.Equal("_root_", codec.EncodeKey("/")); + Assert.Equal("/", codec.DecodeKey("_root_")); + } + + [Fact] + public void NatsPathKeyCodec_trims_trailing_slash() + { + var codec = NatsPathKeyCodec.Instance; + + Assert.Equal("users.123", codec.EncodeKey("users/123/")); + } + + [Fact] + public void NatsPathKeyCodec_encodes_config_style_paths() + { + var codec = NatsPathKeyCodec.Instance; + + // Examples from PACKAGE.md documentation + Assert.Equal("_root_.config.database.connection-string", codec.EncodeKey("/config/database/connection-string")); + Assert.Equal("_root_.config.database.timeout", codec.EncodeKey("/config/database/timeout")); + Assert.Equal("_root_.config.logging.level", codec.EncodeKey("/config/logging/level")); + + // Verify roundtrip + Assert.Equal("/config/database/connection-string", codec.DecodeKey("_root_.config.database.connection-string")); + Assert.Equal("/config/database/timeout", codec.DecodeKey("_root_.config.database.timeout")); + Assert.Equal("/config/logging/level", codec.DecodeKey("_root_.config.logging.level")); + } + + [Fact] + public void NatsKeyChainCodec_applies_codecs_in_order() + { + // Chain: Path -> Base64 + // Input: "/users/123" -> "_root_.users.123" -> "X3Jvb3Rf.dXNlcnM.MTIz" + var chain = new NatsKeyChainCodec(NatsPathKeyCodec.Instance, NatsBase64KeyCodec.Instance); + + var encoded = chain.EncodeKey("/users/123"); + _output.WriteLine($"Encoded: {encoded}"); + + // Verify it roundtrips + var decoded = chain.DecodeKey(encoded); + Assert.Equal("/users/123", decoded); + } + + [Fact] + public void NatsKeyChainCodec_requires_at_least_one_codec() + { + Assert.Throws(() => new NatsKeyChainCodec()); + Assert.Throws(() => new NatsKeyChainCodec(Array.Empty())); + } + + [Fact] + public void NatsKeyChainCodec_filter_requires_all_filterable() + { + // NoOp and Base64 are filterable, so this should work + var chain = new NatsKeyChainCodec(NatsNoOpKeyCodec.Instance, NatsBase64KeyCodec.Instance); + var result = chain.EncodeFilter("test.*"); + Assert.Contains("*", result); + } + + [Fact] + public async Task NatsKVCodecStore_put_and_get_with_base64() + { + await using var connection = new NatsConnection(new NatsOpts { Url = _server.Url }); + await connection.ConnectAsync(); + + var kv = connection.CreateKeyValueStoreContext(); + string prefix = _server.GetNextId(); + string bucketName = $"{prefix}_codec_test"; + + CancellationToken ct = TestContext.Current.CancellationToken; + + var rawStore = await kv.CreateStoreAsync(bucketName, ct); + var store = rawStore.WithBase64Keys(); + + // Put with a key that has special characters + var key = "user/123"; + await store.PutAsync(key, "test-value", cancellationToken: ct); + + // Get should return the original key + var entry = await store.GetEntryAsync(key, cancellationToken: ct); + Assert.Equal(key, entry.Key); + Assert.Equal("test-value", entry.Value); + + _output.WriteLine($"Key: {entry.Key}, Value: {entry.Value}"); + } + + [Fact] + public async Task NatsKVCodecStore_put_and_get_with_path() + { + await using var connection = new NatsConnection(new NatsOpts { Url = _server.Url }); + await connection.ConnectAsync(); + + var kv = connection.CreateKeyValueStoreContext(); + string prefix = _server.GetNextId(); + string bucketName = $"{prefix}_path_test"; + + CancellationToken ct = TestContext.Current.CancellationToken; + + var rawStore = await kv.CreateStoreAsync(bucketName, ct); + var store = rawStore.WithPathKeys(); + + // Put with path-style key + var key = "/users/123/profile"; + await store.PutAsync(key, "profile-data", cancellationToken: ct); + + // Get should return the original path-style key + var entry = await store.GetEntryAsync(key, cancellationToken: ct); + Assert.Equal(key, entry.Key); + Assert.Equal("profile-data", entry.Value); + + _output.WriteLine($"Key: {entry.Key}, Value: {entry.Value}"); + } + + [Fact] + public async Task NatsKVCodecStore_get_keys_decodes_keys() + { + await using var connection = new NatsConnection(new NatsOpts { Url = _server.Url }); + await connection.ConnectAsync(); + + var kv = connection.CreateKeyValueStoreContext(); + string prefix = _server.GetNextId(); + string bucketName = $"{prefix}_keys_test"; + + CancellationToken ct = TestContext.Current.CancellationToken; + + var rawStore = await kv.CreateStoreAsync(bucketName, ct); + var store = rawStore.WithPathKeys(); + + // Put multiple path-style keys + await store.PutAsync("/users/1", "user1", cancellationToken: ct); + await store.PutAsync("/users/2", "user2", cancellationToken: ct); + await store.PutAsync("/users/3", "user3", cancellationToken: ct); + + // Get keys should return decoded path-style keys + var keys = new List(); + await foreach (var key in store.GetKeysAsync(cancellationToken: ct)) + { + keys.Add(key); + _output.WriteLine($"Key: {key}"); + } + + Assert.Contains("/users/1", keys); + Assert.Contains("/users/2", keys); + Assert.Contains("/users/3", keys); + } + + [Fact] + public async Task NatsKVCodecStore_watch_decodes_keys() + { + await using var connection = new NatsConnection(new NatsOpts { Url = _server.Url }); + await connection.ConnectAsync(); + + var kv = connection.CreateKeyValueStoreContext(); + string prefix = _server.GetNextId(); + string bucketName = $"{prefix}_watch_test"; + + CancellationToken ct = TestContext.Current.CancellationToken; + + var rawStore = await kv.CreateStoreAsync(bucketName, ct); + var store = rawStore.WithPathKeys(); + + // Put a value first + await store.PutAsync("/config/setting1", "value1", cancellationToken: ct); + + // Watch should return decoded keys + var entries = new List>(); + await foreach (var entry in store.WatchAsync(cancellationToken: ct)) + { + entries.Add(entry); + _output.WriteLine($"Watch entry: Key={entry.Key}, Value={entry.Value}"); + + // Stop after getting our entry (watch includes initial values then waits) + if (entries.Count >= 1) + { + break; + } + } + + Assert.Single(entries); + Assert.Equal("/config/setting1", entries[0].Key); + } + + [Fact] + public async Task NatsKVCodecStore_history_decodes_keys() + { + await using var connection = new NatsConnection(new NatsOpts { Url = _server.Url }); + await connection.ConnectAsync(); + + var kv = connection.CreateKeyValueStoreContext(); + string prefix = _server.GetNextId(); + string bucketName = $"{prefix}_history_test"; + + CancellationToken ct = TestContext.Current.CancellationToken; + + var rawStore = await kv.CreateStoreAsync(new NatsKVConfig(bucketName) { History = 5 }, ct); + var store = rawStore.WithPathKeys(); + + var key = "/data/item"; + + // Put multiple revisions + await store.PutAsync(key, "v1", cancellationToken: ct); + await store.PutAsync(key, "v2", cancellationToken: ct); + await store.PutAsync(key, "v3", cancellationToken: ct); + + // History should return decoded keys + var history = new List>(); + await foreach (var entry in store.HistoryAsync(key, cancellationToken: ct)) + { + history.Add(entry); + _output.WriteLine($"History: Key={entry.Key}, Value={entry.Value}, Revision={entry.Revision}"); + } + + Assert.Equal(3, history.Count); + Assert.All(history, e => Assert.Equal(key, e.Key)); + } + + [Fact] + public async Task NatsKVCodecStore_delete_with_encoded_key() + { + await using var connection = new NatsConnection(new NatsOpts { Url = _server.Url }); + await connection.ConnectAsync(); + + var kv = connection.CreateKeyValueStoreContext(); + string prefix = _server.GetNextId(); + string bucketName = $"{prefix}_delete_test"; + + CancellationToken ct = TestContext.Current.CancellationToken; + + var rawStore = await kv.CreateStoreAsync(bucketName, ct); + var store = rawStore.WithBase64Keys(); + + var key = "test/key"; + await store.PutAsync(key, "value", cancellationToken: ct); + + // Delete using the original key + await store.DeleteAsync(key, cancellationToken: ct); + + // Verify deletion + var result = await store.TryGetEntryAsync(key, cancellationToken: ct); + Assert.False(result.Success); + } + + [Fact] + public async Task NatsKVCodecStore_create_and_update() + { + await using var connection = new NatsConnection(new NatsOpts { Url = _server.Url }); + await connection.ConnectAsync(); + + var kv = connection.CreateKeyValueStoreContext(); + string prefix = _server.GetNextId(); + string bucketName = $"{prefix}_create_update_test"; + + CancellationToken ct = TestContext.Current.CancellationToken; + + var rawStore = await kv.CreateStoreAsync(bucketName, ct); + var store = rawStore.WithPathKeys(); + + var key = "/items/new"; + + // Create + var revision = await store.CreateAsync(key, "initial", cancellationToken: ct); + Assert.True(revision > 0); + + // Update with revision + var newRevision = await store.UpdateAsync(key, "updated", revision, cancellationToken: ct); + Assert.True(newRevision > revision); + + // Verify + var entry = await store.GetEntryAsync(key, cancellationToken: ct); + Assert.Equal("updated", entry.Value); + Assert.Equal(key, entry.Key); + } +} diff --git a/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/NatsServerCollection.cs b/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/NatsServerCollection.cs new file mode 100644 index 0000000..11025d8 --- /dev/null +++ b/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/NatsServerCollection.cs @@ -0,0 +1,11 @@ +// Copyright (c) Synadia Communications, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. + +using Synadia.Orbit.TestUtils; + +namespace Synadia.Orbit.KeyValueStore.Extensions.Test; + +[CollectionDefinition("nats-server")] +public class NatsServerCollection : ICollectionFixture +{ +} diff --git a/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Synadia.Orbit.KeyValueStore.Extensions.Test.csproj b/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Synadia.Orbit.KeyValueStore.Extensions.Test.csproj new file mode 100644 index 0000000..b563625 --- /dev/null +++ b/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Synadia.Orbit.KeyValueStore.Extensions.Test.csproj @@ -0,0 +1,37 @@ + + + + net8.0;net9.0;net10.0 + $(TargetFrameworks);net481 + any;win-x86 + enable + enable + false + true + 1.0.0 + Exe + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/tools/DocsExamples/DocsExamples.csproj b/tools/DocsExamples/DocsExamples.csproj index a087932..ae17e40 100644 --- a/tools/DocsExamples/DocsExamples.csproj +++ b/tools/DocsExamples/DocsExamples.csproj @@ -10,6 +10,7 @@ + diff --git a/tools/DocsExamples/ExampleKV.cs b/tools/DocsExamples/ExampleKV.cs new file mode 100644 index 0000000..2240986 --- /dev/null +++ b/tools/DocsExamples/ExampleKV.cs @@ -0,0 +1,181 @@ +// Copyright (c) Synadia Communications, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. + +#pragma warning disable + +// dotnet add package nats.net +// dotnet add package Synadia.Orbit.KeyValueStore.Extensions --prerelease +using NATS.Net; +using Synadia.Orbit.KeyValueStore.Extensions.Codecs; + +namespace DocsExamples; + +public class ExampleKV +{ + public static async Task Run() + { + string hr = new('-', 50); + + Console.WriteLine(hr); + Console.WriteLine("Example: Using Base64 Key Encoding in NATS Key-Value Store"); + { + await using var client = new NatsClient(); + var kv = client.CreateKeyValueStoreContext(); + + var rawStore = await kv.CreateStoreAsync("my-bucket"); + var store = rawStore.WithBase64Keys(); + + // Keys with special characters work transparently + await store.PutAsync("user/123@example.com", "user data"); + // Stored in KV as: "dXNlci8xMjNAZXhhbXBsZQ.Y29t" + // ^^^^^^^^^^^^^^^^^^^^^^ ^^^^ + // "user/123@example" "com" + // (dots are preserved as token separators, each token is Base64 encoded) + + var entry = await store.GetEntryAsync("user/123@example.com"); + Console.WriteLine($"Key: {entry.Key}, Value: {entry.Value}"); + // Output: Key: user/123@example.com, Value: user data + + await foreach (string key in rawStore.GetKeysAsync()) + { + Console.WriteLine($"Raw Key: {key}"); + // Output: Raw Key: dXNlci8xMjNAZXhhbXBsZQ.Y29t + } + } + + Console.WriteLine(hr); + Console.WriteLine("Example: Using Path-Style Keys in NATS Key-Value Store"); + { + await using var client = new NatsClient(); + var kv = client.CreateKeyValueStoreContext(); + + var rawStore = await kv.CreateStoreAsync("config-bucket"); + var store = rawStore.WithPathKeys(); + + // Use familiar path-style keys + await store.PutAsync("/config/database/connection-string", "Server=localhost;..."); + // Stored in KV as: "_root_.config.database.connection-string" + + await store.PutAsync("/config/database/timeout", "30"); + // Stored in KV as: "_root_.config.database.timeout" + + await store.PutAsync("/config/logging/level", "Information"); + // Stored in KV as: "_root_.config.logging.level" + + // Keys are returned in path format + await foreach (string key in store.GetKeysAsync()) + { + Console.WriteLine(key); + } + // Output: + // /config/database/connection-string + // /config/database/timeout + // /config/logging/level + + await foreach (string key in rawStore.GetKeysAsync()) + { + Console.WriteLine($"Raw Key: {key}"); + } + // Output: + // Raw Key: _root_.config.database.connection-string + // Raw Key: _root_.config.database.timeout + // Raw Key: _root_.config.logging.level + } + + Console.WriteLine(hr); + Console.WriteLine("Example: Combining Path and Base64 Key Codecs in NATS Key-Value Store"); + { + await using var client = new NatsClient(); + var kv = client.CreateKeyValueStoreContext(); + + var rawStore = await kv.CreateStoreAsync("chain-bucket"); + + // Chain: Path codec first, then Base64 + var chain = new NatsKeyChainCodec(NatsPathKeyCodec.Instance, NatsBase64KeyCodec.Instance); + var store = rawStore.WithKeyCodec(chain); + + // Use path-style key with special characters + await store.PutAsync("/@dmin/user+1/profile", "admin profile data"); + // Stored in KV as: "_root_.QGRtaW4.YXVzZXIrMQ.cHJvZmlsZQ" + // ^^^^^^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^^ + // "/@dmin" "user+1" "profile" + // (each token is Base64 encoded after path translation) + + var entry = await store.GetEntryAsync("/@dmin/user+1/profile"); + Console.WriteLine($"Key: {entry.Key}, Value: {entry.Value}"); + // Output: Key: /@dmin/user+1/profile, Value: admin profile data + + await foreach (string key in rawStore.GetKeysAsync()) + { + Console.WriteLine($"Raw Key: {key}"); + // Output: Raw Key: X3Jvb3Rf.QGRtaW4.dXNlcisx.cHJvZmlsZQ + } + } + + Console.WriteLine(hr); + Console.WriteLine("Example: Custom Codec (ROT13 'encryption')"); + { + await using var client = new NatsClient(); + var kv = client.CreateKeyValueStoreContext(); + + var rawStore = await kv.CreateStoreAsync("secret-bucket"); + + // Use custom ROT13 codec for "encrypted" keys + var store = rawStore.WithKeyCodec(new Rot13KeyCodec()); + + // Store with readable keys - they get ROT13 encoded in storage + await store.PutAsync("secret.password", "hunter2"); + await store.PutAsync("secret.api-key", "abc123"); + + // Keys are returned decoded + var entry = await store.GetEntryAsync("secret.password"); + Console.WriteLine($"Key: {entry.Key}, Value: {entry.Value}"); + // Output: Key: secret.password, Value: hunter2 + + // But in raw storage, keys are ROT13 encoded + await foreach (string key in rawStore.GetKeysAsync()) + { + Console.WriteLine($"Raw Key: {key}"); + } + // Output: + // Raw Key: frperg.cnffjbeq + // Raw Key: frperg.ncv-xrl + } + } +} + +/// +/// A custom codec that "encrypts" keys using ROT13 substitution cipher. +/// This is for demonstration purposes only - ROT13 is not secure encryption! +/// +public class Rot13KeyCodec : INatsFilterableKeyCodec +{ + public string EncodeKey(string key) => Rot13(key); + + public string DecodeKey(string key) => Rot13(key); // ROT13 is its own inverse + + public string EncodeFilter(string filter) => Rot13(filter); + + private static string Rot13(string input) + { + var result = new char[input.Length]; + for (int i = 0; i < input.Length; i++) + { + char c = input[i]; + if (c >= 'a' && c <= 'z') + { + result[i] = (char)('a' + (c - 'a' + 13) % 26); + } + else if (c >= 'A' && c <= 'Z') + { + result[i] = (char)('A' + (c - 'A' + 13) % 26); + } + else + { + result[i] = c; // Non-letters pass through unchanged (including '.' and '*') + } + } + + return new string(result); + } +}