From 1deecddabcf879b9f48717bed9d4cdbeb0a40e7b Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Tue, 22 Jul 2025 18:41:39 +0100 Subject: [PATCH 1/5] Add `Synadia.Orbit.KeyValueStore.Extensions` project with basic setup Introduced a new project for Key-Value store extensions with initial files, including a class definition (`NatsKVEncodedStore`), project configuration targeting .NET 8.0, and versioning details (`1.0.0-preview.1`). Updated the solution file to include the new project. --- orbit.net.sln | 7 +++++++ .../NatsKVEncodedStore.cs | 5 +++++ src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md | 3 +++ .../Synadia.Orbit.KeyValueStore.Extensions.csproj | 9 +++++++++ src/Synadia.Orbit.KeyValueStore.Extensions/version.txt | 1 + 5 files changed, 25 insertions(+) create mode 100644 src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVEncodedStore.cs create mode 100644 src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md create mode 100644 src/Synadia.Orbit.KeyValueStore.Extensions/Synadia.Orbit.KeyValueStore.Extensions.csproj create mode 100644 src/Synadia.Orbit.KeyValueStore.Extensions/version.txt diff --git a/orbit.net.sln b/orbit.net.sln index d61aed8..dd6fe4d 100644 --- a/orbit.net.sln +++ b/orbit.net.sln @@ -46,6 +46,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrbitPublish", "tools\Orbit EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocsExamples", "tools\DocsExamples\DocsExamples.csproj", "{E54DD844-5486-48D2-8D8D-EC8F5D244041}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Synadia.Orbit.KeyValueStore.Extensions", "src\Synadia.Orbit.KeyValueStore.Extensions\Synadia.Orbit.KeyValueStore.Extensions.csproj", "{E5CCE6B0-496A-431B-BF99-D9F3E0EC535F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -83,6 +85,10 @@ Global {E54DD844-5486-48D2-8D8D-EC8F5D244041}.Debug|Any CPU.Build.0 = Debug|Any CPU {E54DD844-5486-48D2-8D8D-EC8F5D244041}.Release|Any CPU.ActiveCfg = Release|Any CPU {E54DD844-5486-48D2-8D8D-EC8F5D244041}.Release|Any CPU.Build.0 = Release|Any CPU + {E5CCE6B0-496A-431B-BF99-D9F3E0EC535F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5CCE6B0-496A-431B-BF99-D9F3E0EC535F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5CCE6B0-496A-431B-BF99-D9F3E0EC535F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5CCE6B0-496A-431B-BF99-D9F3E0EC535F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {4166181E-1CA1-4378-8CBC-BD46E672623E} = {B8294392-93F3-4406-B96F-A687327CBA15} @@ -93,5 +99,6 @@ Global {6D406238-250E-4E52-B8D4-7D694A81DA9E} = {7B81447B-C919-4AD9-B2B9-3F89FFD87D8B} {8166F364-159D-448B-9720-1A5A216297D3} = {2D30E3B9-67BC-4453-B530-3AF500B594D5} {E54DD844-5486-48D2-8D8D-EC8F5D244041} = {2D30E3B9-67BC-4453-B530-3AF500B594D5} + {E5CCE6B0-496A-431B-BF99-D9F3E0EC535F} = {B8294392-93F3-4406-B96F-A687327CBA15} EndGlobalSection EndGlobal diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVEncodedStore.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVEncodedStore.cs new file mode 100644 index 0000000..242f3b0 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVEncodedStore.cs @@ -0,0 +1,5 @@ +namespace Synadia.Orbit.KeyValueStore.Extensions; + +public class NatsKVEncodedStore +{ +} diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md b/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md new file mode 100644 index 0000000..0721d15 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md @@ -0,0 +1,3 @@ +# KV Extensions + +## KV Encoded Store 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..3a63532 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Synadia.Orbit.KeyValueStore.Extensions.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + 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 From 953698aaec6f70c40e0df15f1362c352107fa0e4 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Thu, 15 Jan 2026 11:45:13 +0000 Subject: [PATCH 2/5] Add key encoding/decoding support to `Synadia.Orbit.KeyValueStore.Extensions` Introduced key codec implementations (`PathKeyCodec`, `Base64KeyCodec`, `NoOpKeyCodec`) and their integration into the Key-Value store. Added extension methods for applying codecs (`WithBase64Keys`, `WithPathKeys`) and extensive unit tests to validate codec behaviors with encoded keys, filters, and operations. Removed unused `NatsKVEncodedStore` class. --- Directory.Packages.props | 3 +- orbit.net.slnx | 2 + .../Base64KeyCodec.cs | 96 +++++ .../IKeyCodec.cs | 40 ++ .../KeyChainCodec.cs | 96 +++++ .../KeyCodecException.cs | 29 ++ .../NatsKVCodecStore.cs | 258 ++++++++++++ .../NatsKVEncodedStore.cs | 5 - .../NatsKVStoreExtensions.cs | 48 +++ .../NoOpKeyCodec.cs | 28 ++ .../PathKeyCodec.cs | 78 ++++ ...adia.Orbit.KeyValueStore.Extensions.csproj | 10 +- .../CodecTests.cs | 372 ++++++++++++++++++ .../NatsServerCollection.cs | 11 + ...Orbit.KeyValueStore.Extensions.Test.csproj | 37 ++ 15 files changed, 1104 insertions(+), 9 deletions(-) create mode 100644 src/Synadia.Orbit.KeyValueStore.Extensions/Base64KeyCodec.cs create mode 100644 src/Synadia.Orbit.KeyValueStore.Extensions/IKeyCodec.cs create mode 100644 src/Synadia.Orbit.KeyValueStore.Extensions/KeyChainCodec.cs create mode 100644 src/Synadia.Orbit.KeyValueStore.Extensions/KeyCodecException.cs create mode 100644 src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVCodecStore.cs delete mode 100644 src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVEncodedStore.cs create mode 100644 src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVStoreExtensions.cs create mode 100644 src/Synadia.Orbit.KeyValueStore.Extensions/NoOpKeyCodec.cs create mode 100644 src/Synadia.Orbit.KeyValueStore.Extensions/PathKeyCodec.cs create mode 100644 tests/Synadia.Orbit.KeyValueStore.Extensions.Test/CodecTests.cs create mode 100644 tests/Synadia.Orbit.KeyValueStore.Extensions.Test/NatsServerCollection.cs create mode 100644 tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Synadia.Orbit.KeyValueStore.Extensions.Test.csproj 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/Base64KeyCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Base64KeyCodec.cs new file mode 100644 index 0000000..3b9bd36 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Base64KeyCodec.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; + +/// +/// 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 Base64KeyCodec : IFilterableKeyCodec +{ + private Base64KeyCodec() + { + } + + /// + /// Gets the singleton instance of the . + /// + public static Base64KeyCodec 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/IKeyCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/IKeyCodec.cs new file mode 100644 index 0000000..eaec5d9 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/IKeyCodec.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; + +/// +/// Defines the interface for encoding and decoding keys in a KV bucket. +/// +public interface IKeyCodec +{ + /// + /// 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 IFilterableKeyCodec : IKeyCodec +{ + /// + /// 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/KeyChainCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/KeyChainCodec.cs new file mode 100644 index 0000000..279e613 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/KeyChainCodec.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; + +/// +/// Applies multiple key codecs in sequence. +/// Encoding is applied in order (first to last), decoding in reverse order (last to first). +/// +public sealed class KeyChainCodec : IFilterableKeyCodec +{ + private readonly IKeyCodec[] _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 KeyChainCodec(params IKeyCodec[] 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 KeyCodecException) + { + throw new KeyCodecException($"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 KeyCodecException) + { + throw new KeyCodecException($"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 IFilterableKeyCodec) + { + throw new KeyCodecException($"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 = ((IFilterableKeyCodec)_codecs[i]).EncodeFilter(result); + } + catch (Exception ex) when (ex is not KeyCodecException) + { + throw new KeyCodecException($"Failed to encode filter at codec {i}.", ex); + } + } + + return result; + } +} diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/KeyCodecException.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/KeyCodecException.cs new file mode 100644 index 0000000..a5f4160 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/KeyCodecException.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; + +/// +/// Exception thrown when a key codec operation fails. +/// +public class KeyCodecException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message. + public KeyCodecException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The inner exception. + public KeyCodecException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVCodecStore.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVCodecStore.cs new file mode 100644 index 0000000..3d1e604 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/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; + +/// +/// A wrapper around that applies key encoding/decoding using a codec. +/// +public sealed class NatsKVCodecStore : INatsKVStore +{ + private readonly INatsKVStore _store; + private readonly IKeyCodec _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, IKeyCodec 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 IFilterableKeyCodec filterableCodec) + { + return filterableCodec.EncodeFilter(filter); + } + + // Check if filter contains wildcards + if (filter.Contains("*") || filter.Contains(">")) + { + throw new KeyCodecException($"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/NatsKVEncodedStore.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVEncodedStore.cs deleted file mode 100644 index 242f3b0..0000000 --- a/src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVEncodedStore.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Synadia.Orbit.KeyValueStore.Extensions; - -public class NatsKVEncodedStore -{ -} diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVStoreExtensions.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVStoreExtensions.cs new file mode 100644 index 0000000..6a687de --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/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; + +/// +/// 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, IKeyCodec 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, Base64KeyCodec.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, PathKeyCodec.Instance); + } +} diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/NoOpKeyCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/NoOpKeyCodec.cs new file mode 100644 index 0000000..e3394c7 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/NoOpKeyCodec.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; + +/// +/// A no-op codec that passes keys through unchanged. +/// +public sealed class NoOpKeyCodec : IFilterableKeyCodec +{ + private NoOpKeyCodec() + { + } + + /// + /// Gets the singleton instance of the . + /// + public static NoOpKeyCodec 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/PathKeyCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/PathKeyCodec.cs new file mode 100644 index 0000000..9213675 --- /dev/null +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/PathKeyCodec.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; + +/// +/// 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 PathKeyCodec : IFilterableKeyCodec +{ + /// + /// 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 PathKeyCodec() + { + } + + /// + /// Gets the singleton instance of the . + /// + public static PathKeyCodec 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/Synadia.Orbit.KeyValueStore.Extensions.csproj b/src/Synadia.Orbit.KeyValueStore.Extensions/Synadia.Orbit.KeyValueStore.Extensions.csproj index 3a63532..5c1629b 100644 --- a/src/Synadia.Orbit.KeyValueStore.Extensions/Synadia.Orbit.KeyValueStore.Extensions.csproj +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Synadia.Orbit.KeyValueStore.Extensions.csproj @@ -1,9 +1,13 @@  - net8.0 - enable - enable + enable + enable + true + + + + diff --git a/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/CodecTests.cs b/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/CodecTests.cs new file mode 100644 index 0000000..f0b30dc --- /dev/null +++ b/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/CodecTests.cs @@ -0,0 +1,372 @@ +// 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.TestUtils; + +namespace Synadia.Orbit.KeyValueStore.Extensions.Test; + +[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 NoOpKeyCodec_passes_through_unchanged() + { + var codec = NoOpKeyCodec.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 Base64KeyCodec_encodes_each_token_separately() + { + var codec = Base64KeyCodec.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 Base64KeyCodec_handles_special_characters() + { + var codec = Base64KeyCodec.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 Base64KeyCodec_preserves_wildcards_in_filter() + { + var codec = Base64KeyCodec.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 Base64KeyCodec_preserves_gt_wildcard_in_filter() + { + var codec = Base64KeyCodec.Instance; + + var filter = "users.>"; + var encoded = codec.EncodeFilter(filter); + + Assert.EndsWith(".>", encoded); + Assert.Equal("dXNlcnM.>", encoded); + } + + [Fact] + public void PathKeyCodec_converts_slashes_to_dots() + { + var codec = PathKeyCodec.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 PathKeyCodec_handles_leading_slash() + { + var codec = PathKeyCodec.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 PathKeyCodec_handles_root_only() + { + var codec = PathKeyCodec.Instance; + + Assert.Equal("_root_", codec.EncodeKey("/")); + Assert.Equal("/", codec.DecodeKey("_root_")); + } + + [Fact] + public void PathKeyCodec_trims_trailing_slash() + { + var codec = PathKeyCodec.Instance; + + Assert.Equal("users.123", codec.EncodeKey("users/123/")); + } + + [Fact] + public void KeyChainCodec_applies_codecs_in_order() + { + // Chain: Path -> Base64 + // Input: "/users/123" -> "_root_.users.123" -> "X3Jvb3Rf.dXNlcnM.MTIz" + var chain = new KeyChainCodec(PathKeyCodec.Instance, Base64KeyCodec.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 KeyChainCodec_requires_at_least_one_codec() + { + Assert.Throws(() => new KeyChainCodec()); + Assert.Throws(() => new KeyChainCodec(Array.Empty())); + } + + [Fact] + public void KeyChainCodec_filter_requires_all_filterable() + { + // NoOp and Base64 are filterable, so this should work + var chain = new KeyChainCodec(NoOpKeyCodec.Instance, Base64KeyCodec.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 + + + + + + + + + + + + + From de07b8969e9c5161c573d29deb239dcd108f223a Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Thu, 15 Jan 2026 12:00:30 +0000 Subject: [PATCH 3/5] Prefix types with `Nats` --- .../{IKeyCodec.cs => Codecs/INatsKeyCodec.cs} | 10 ++-- .../NatsBase64KeyCodec.cs} | 10 ++-- .../{ => Codecs}/NatsKVCodecStore.cs | 10 ++-- .../{ => Codecs}/NatsKVStoreExtensions.cs | 8 +-- .../NatsKeyChainCodec.cs} | 30 +++++------ .../NatsKeyCodecException.cs} | 12 ++--- .../NatsNoOpKeyCodec.cs} | 10 ++-- .../NatsPathKeyCodec.cs} | 10 ++-- .../{ => Codecs}/CodecTests.cs | 53 ++++++++++--------- 9 files changed, 77 insertions(+), 76 deletions(-) rename src/Synadia.Orbit.KeyValueStore.Extensions/{IKeyCodec.cs => Codecs/INatsKeyCodec.cs} (78%) rename src/Synadia.Orbit.KeyValueStore.Extensions/{Base64KeyCodec.cs => Codecs/NatsBase64KeyCodec.cs} (88%) rename src/Synadia.Orbit.KeyValueStore.Extensions/{ => Codecs}/NatsKVCodecStore.cs (96%) rename src/Synadia.Orbit.KeyValueStore.Extensions/{ => Codecs}/NatsKVStoreExtensions.cs (88%) rename src/Synadia.Orbit.KeyValueStore.Extensions/{KeyChainCodec.cs => Codecs/NatsKeyChainCodec.cs} (61%) rename src/Synadia.Orbit.KeyValueStore.Extensions/{KeyCodecException.cs => Codecs/NatsKeyCodecException.cs} (58%) rename src/Synadia.Orbit.KeyValueStore.Extensions/{NoOpKeyCodec.cs => Codecs/NatsNoOpKeyCodec.cs} (62%) rename src/Synadia.Orbit.KeyValueStore.Extensions/{PathKeyCodec.cs => Codecs/NatsPathKeyCodec.cs} (87%) rename tests/Synadia.Orbit.KeyValueStore.Extensions.Test/{ => Codecs}/CodecTests.cs (86%) diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/IKeyCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/INatsKeyCodec.cs similarity index 78% rename from src/Synadia.Orbit.KeyValueStore.Extensions/IKeyCodec.cs rename to src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/INatsKeyCodec.cs index eaec5d9..721bcbc 100644 --- a/src/Synadia.Orbit.KeyValueStore.Extensions/IKeyCodec.cs +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/INatsKeyCodec.cs @@ -1,12 +1,12 @@ // Copyright (c) Synadia Communications, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. -namespace Synadia.Orbit.KeyValueStore.Extensions; +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; /// /// Defines the interface for encoding and decoding keys in a KV bucket. /// -public interface IKeyCodec +public interface INatsKeyCodec { /// /// Encodes a key for storage. @@ -26,13 +26,13 @@ public interface IKeyCodec /// /// 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 . +/// wildcards (* or >) will throw . /// -public interface IFilterableKeyCodec : IKeyCodec +public interface INatsFilterableKeyCodec : INatsKeyCodec { /// /// Encodes a pattern that may contain wildcards (* or >). - /// Unlike , this must preserve wildcards in the result. + /// Unlike , this must preserve wildcards in the result. /// /// The filter pattern to encode. /// The encoded filter pattern with wildcards preserved. diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/Base64KeyCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsBase64KeyCodec.cs similarity index 88% rename from src/Synadia.Orbit.KeyValueStore.Extensions/Base64KeyCodec.cs rename to src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsBase64KeyCodec.cs index 3b9bd36..370382e 100644 --- a/src/Synadia.Orbit.KeyValueStore.Extensions/Base64KeyCodec.cs +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsBase64KeyCodec.cs @@ -3,22 +3,22 @@ using System.Text; -namespace Synadia.Orbit.KeyValueStore.Extensions; +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 Base64KeyCodec : IFilterableKeyCodec +public sealed class NatsBase64KeyCodec : INatsFilterableKeyCodec { - private Base64KeyCodec() + private NatsBase64KeyCodec() { } /// - /// Gets the singleton instance of the . + /// Gets the singleton instance of the . /// - public static Base64KeyCodec Instance { get; } = new(); + public static NatsBase64KeyCodec Instance { get; } = new(); /// public string EncodeKey(string key) diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVCodecStore.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKVCodecStore.cs similarity index 96% rename from src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVCodecStore.cs rename to src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKVCodecStore.cs index 3d1e604..45f11cc 100644 --- a/src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVCodecStore.cs +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKVCodecStore.cs @@ -6,7 +6,7 @@ using NATS.Client.JetStream; using NATS.Client.KeyValueStore; -namespace Synadia.Orbit.KeyValueStore.Extensions; +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; /// /// A wrapper around that applies key encoding/decoding using a codec. @@ -14,14 +14,14 @@ namespace Synadia.Orbit.KeyValueStore.Extensions; public sealed class NatsKVCodecStore : INatsKVStore { private readonly INatsKVStore _store; - private readonly IKeyCodec _keyCodec; + 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, IKeyCodec keyCodec) + public NatsKVCodecStore(INatsKVStore store, INatsKeyCodec keyCodec) { _store = store ?? throw new ArgumentNullException(nameof(store)); _keyCodec = keyCodec ?? throw new ArgumentNullException(nameof(keyCodec)); @@ -207,7 +207,7 @@ public IAsyncEnumerable GetKeysAsync(IEnumerable filters, NatsKV private string EncodeFilter(string filter) { - if (_keyCodec is IFilterableKeyCodec filterableCodec) + if (_keyCodec is INatsFilterableKeyCodec filterableCodec) { return filterableCodec.EncodeFilter(filter); } @@ -215,7 +215,7 @@ private string EncodeFilter(string filter) // Check if filter contains wildcards if (filter.Contains("*") || filter.Contains(">")) { - throw new KeyCodecException($"Codec does not support wildcard filtering. Key: '{filter}'"); + throw new NatsKeyCodecException($"Codec does not support wildcard filtering. Key: '{filter}'"); } return _keyCodec.EncodeKey(filter); diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVStoreExtensions.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKVStoreExtensions.cs similarity index 88% rename from src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVStoreExtensions.cs rename to src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKVStoreExtensions.cs index 6a687de..3f3f0e2 100644 --- a/src/Synadia.Orbit.KeyValueStore.Extensions/NatsKVStoreExtensions.cs +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKVStoreExtensions.cs @@ -3,7 +3,7 @@ using NATS.Client.KeyValueStore; -namespace Synadia.Orbit.KeyValueStore.Extensions; +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; /// /// Extension methods for . @@ -16,7 +16,7 @@ public static class NatsKVStoreExtensions /// 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, IKeyCodec keyCodec) + public static INatsKVStore WithKeyCodec(this INatsKVStore store, INatsKeyCodec keyCodec) { return new NatsKVCodecStore(store, keyCodec); } @@ -29,7 +29,7 @@ public static INatsKVStore WithKeyCodec(this INatsKVStore store, IKeyCodec keyCo /// A new that Base64 encodes all keys. public static INatsKVStore WithBase64Keys(this INatsKVStore store) { - return new NatsKVCodecStore(store, Base64KeyCodec.Instance); + return new NatsKVCodecStore(store, NatsBase64KeyCodec.Instance); } /// @@ -43,6 +43,6 @@ public static INatsKVStore WithBase64Keys(this INatsKVStore store) /// public static INatsKVStore WithPathKeys(this INatsKVStore store) { - return new NatsKVCodecStore(store, PathKeyCodec.Instance); + return new NatsKVCodecStore(store, NatsPathKeyCodec.Instance); } } diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/KeyChainCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKeyChainCodec.cs similarity index 61% rename from src/Synadia.Orbit.KeyValueStore.Extensions/KeyChainCodec.cs rename to src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKeyChainCodec.cs index 279e613..32d1bed 100644 --- a/src/Synadia.Orbit.KeyValueStore.Extensions/KeyChainCodec.cs +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKeyChainCodec.cs @@ -1,22 +1,22 @@ // Copyright (c) Synadia Communications, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. -namespace Synadia.Orbit.KeyValueStore.Extensions; +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 KeyChainCodec : IFilterableKeyCodec +public sealed class NatsKeyChainCodec : INatsFilterableKeyCodec { - private readonly IKeyCodec[] _codecs; + private readonly INatsKeyCodec[] _codecs; /// - /// Initializes a new instance of the class. + /// 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 KeyChainCodec(params IKeyCodec[] codecs) + public NatsKeyChainCodec(params INatsKeyCodec[] codecs) { if (codecs == null || codecs.Length == 0) { @@ -36,9 +36,9 @@ public string EncodeKey(string key) { result = _codecs[i].EncodeKey(result); } - catch (Exception ex) when (ex is not KeyCodecException) + catch (Exception ex) when (ex is not NatsKeyCodecException) { - throw new KeyCodecException($"Failed to encode key at codec {i}.", ex); + throw new NatsKeyCodecException($"Failed to encode key at codec {i}.", ex); } } @@ -55,9 +55,9 @@ public string DecodeKey(string key) { result = _codecs[i].DecodeKey(result); } - catch (Exception ex) when (ex is not KeyCodecException) + catch (Exception ex) when (ex is not NatsKeyCodecException) { - throw new KeyCodecException($"Failed to decode key at codec {i}.", ex); + throw new NatsKeyCodecException($"Failed to decode key at codec {i}.", ex); } } @@ -65,15 +65,15 @@ public string DecodeKey(string key) } /// - /// Thrown when any codec in the chain does not support filtering. + /// 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 IFilterableKeyCodec) + if (_codecs[i] is not INatsFilterableKeyCodec) { - throw new KeyCodecException($"Codec at index {i} does not support wildcard filtering."); + throw new NatsKeyCodecException($"Codec at index {i} does not support wildcard filtering."); } } @@ -83,11 +83,11 @@ public string EncodeFilter(string filter) { try { - result = ((IFilterableKeyCodec)_codecs[i]).EncodeFilter(result); + result = ((INatsFilterableKeyCodec)_codecs[i]).EncodeFilter(result); } - catch (Exception ex) when (ex is not KeyCodecException) + catch (Exception ex) when (ex is not NatsKeyCodecException) { - throw new KeyCodecException($"Failed to encode filter at codec {i}.", ex); + throw new NatsKeyCodecException($"Failed to encode filter at codec {i}.", ex); } } diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/KeyCodecException.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKeyCodecException.cs similarity index 58% rename from src/Synadia.Orbit.KeyValueStore.Extensions/KeyCodecException.cs rename to src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKeyCodecException.cs index a5f4160..332b0af 100644 --- a/src/Synadia.Orbit.KeyValueStore.Extensions/KeyCodecException.cs +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsKeyCodecException.cs @@ -1,28 +1,28 @@ // Copyright (c) Synadia Communications, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. -namespace Synadia.Orbit.KeyValueStore.Extensions; +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; /// /// Exception thrown when a key codec operation fails. /// -public class KeyCodecException : Exception +public class NatsKeyCodecException : Exception { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The error message. - public KeyCodecException(string message) + public NatsKeyCodecException(string message) : base(message) { } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The error message. /// The inner exception. - public KeyCodecException(string message, Exception innerException) + public NatsKeyCodecException(string message, Exception innerException) : base(message, innerException) { } diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/NoOpKeyCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsNoOpKeyCodec.cs similarity index 62% rename from src/Synadia.Orbit.KeyValueStore.Extensions/NoOpKeyCodec.cs rename to src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsNoOpKeyCodec.cs index e3394c7..6c99696 100644 --- a/src/Synadia.Orbit.KeyValueStore.Extensions/NoOpKeyCodec.cs +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsNoOpKeyCodec.cs @@ -1,21 +1,21 @@ // Copyright (c) Synadia Communications, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. -namespace Synadia.Orbit.KeyValueStore.Extensions; +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; /// /// A no-op codec that passes keys through unchanged. /// -public sealed class NoOpKeyCodec : IFilterableKeyCodec +public sealed class NatsNoOpKeyCodec : INatsFilterableKeyCodec { - private NoOpKeyCodec() + private NatsNoOpKeyCodec() { } /// - /// Gets the singleton instance of the . + /// Gets the singleton instance of the . /// - public static NoOpKeyCodec Instance { get; } = new(); + public static NatsNoOpKeyCodec Instance { get; } = new(); /// public string EncodeKey(string key) => key; diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/PathKeyCodec.cs b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsPathKeyCodec.cs similarity index 87% rename from src/Synadia.Orbit.KeyValueStore.Extensions/PathKeyCodec.cs rename to src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsPathKeyCodec.cs index 9213675..e78bae0 100644 --- a/src/Synadia.Orbit.KeyValueStore.Extensions/PathKeyCodec.cs +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/Codecs/NatsPathKeyCodec.cs @@ -1,7 +1,7 @@ // Copyright (c) Synadia Communications, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. -namespace Synadia.Orbit.KeyValueStore.Extensions; +namespace Synadia.Orbit.KeyValueStore.Extensions.Codecs; /// /// A codec that translates between path-style keys (using '/') and NATS subject notation (using '.'). @@ -10,7 +10,7 @@ namespace Synadia.Orbit.KeyValueStore.Extensions; /// 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 PathKeyCodec : IFilterableKeyCodec +public sealed class NatsPathKeyCodec : INatsFilterableKeyCodec { /// /// The prefix used to encode keys that start with a leading slash. @@ -19,14 +19,14 @@ public sealed class PathKeyCodec : IFilterableKeyCodec /// internal const string RootPrefix = "_root_"; - private PathKeyCodec() + private NatsPathKeyCodec() { } /// - /// Gets the singleton instance of the . + /// Gets the singleton instance of the . /// - public static PathKeyCodec Instance { get; } = new(); + public static NatsPathKeyCodec Instance { get; } = new(); /// public string EncodeKey(string key) diff --git a/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/CodecTests.cs b/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Codecs/CodecTests.cs similarity index 86% rename from tests/Synadia.Orbit.KeyValueStore.Extensions.Test/CodecTests.cs rename to tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Codecs/CodecTests.cs index f0b30dc..e2a87d1 100644 --- a/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/CodecTests.cs +++ b/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Codecs/CodecTests.cs @@ -4,9 +4,10 @@ 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; +namespace Synadia.Orbit.KeyValueStore.Extensions.Test.Codecs; [Collection("nats-server")] public class CodecTests @@ -21,9 +22,9 @@ public CodecTests(ITestOutputHelper output, NatsServerFixture server) } [Fact] - public void NoOpKeyCodec_passes_through_unchanged() + public void NatsNoOpKeyCodec_passes_through_unchanged() { - var codec = NoOpKeyCodec.Instance; + var codec = NatsNoOpKeyCodec.Instance; Assert.Equal("test.key", codec.EncodeKey("test.key")); Assert.Equal("test.key", codec.DecodeKey("test.key")); @@ -32,9 +33,9 @@ public void NoOpKeyCodec_passes_through_unchanged() } [Fact] - public void Base64KeyCodec_encodes_each_token_separately() + public void NatsBase64KeyCodec_encodes_each_token_separately() { - var codec = Base64KeyCodec.Instance; + var codec = NatsBase64KeyCodec.Instance; // "hello" in base64url is "aGVsbG8" // "world" in base64url is "d29ybGQ" @@ -46,9 +47,9 @@ public void Base64KeyCodec_encodes_each_token_separately() } [Fact] - public void Base64KeyCodec_handles_special_characters() + public void NatsBase64KeyCodec_handles_special_characters() { - var codec = Base64KeyCodec.Instance; + var codec = NatsBase64KeyCodec.Instance; // Test with characters that would be invalid in NATS subjects var key = "user/123.profile@test"; @@ -61,9 +62,9 @@ public void Base64KeyCodec_handles_special_characters() } [Fact] - public void Base64KeyCodec_preserves_wildcards_in_filter() + public void NatsBase64KeyCodec_preserves_wildcards_in_filter() { - var codec = Base64KeyCodec.Instance; + var codec = NatsBase64KeyCodec.Instance; var filter = "users.*.profile"; var encoded = codec.EncodeFilter(filter); @@ -74,9 +75,9 @@ public void Base64KeyCodec_preserves_wildcards_in_filter() } [Fact] - public void Base64KeyCodec_preserves_gt_wildcard_in_filter() + public void NatsBase64KeyCodec_preserves_gt_wildcard_in_filter() { - var codec = Base64KeyCodec.Instance; + var codec = NatsBase64KeyCodec.Instance; var filter = "users.>"; var encoded = codec.EncodeFilter(filter); @@ -86,9 +87,9 @@ public void Base64KeyCodec_preserves_gt_wildcard_in_filter() } [Fact] - public void PathKeyCodec_converts_slashes_to_dots() + public void NatsPathKeyCodec_converts_slashes_to_dots() { - var codec = PathKeyCodec.Instance; + var codec = NatsPathKeyCodec.Instance; // Without leading slash Assert.Equal("users.123.profile", codec.EncodeKey("users/123/profile")); @@ -96,9 +97,9 @@ public void PathKeyCodec_converts_slashes_to_dots() } [Fact] - public void PathKeyCodec_handles_leading_slash() + public void NatsPathKeyCodec_handles_leading_slash() { - var codec = PathKeyCodec.Instance; + var codec = NatsPathKeyCodec.Instance; // With leading slash - should use _root_ prefix var encoded = codec.EncodeKey("/users/123/profile"); @@ -109,28 +110,28 @@ public void PathKeyCodec_handles_leading_slash() } [Fact] - public void PathKeyCodec_handles_root_only() + public void NatsPathKeyCodec_handles_root_only() { - var codec = PathKeyCodec.Instance; + var codec = NatsPathKeyCodec.Instance; Assert.Equal("_root_", codec.EncodeKey("/")); Assert.Equal("/", codec.DecodeKey("_root_")); } [Fact] - public void PathKeyCodec_trims_trailing_slash() + public void NatsPathKeyCodec_trims_trailing_slash() { - var codec = PathKeyCodec.Instance; + var codec = NatsPathKeyCodec.Instance; Assert.Equal("users.123", codec.EncodeKey("users/123/")); } [Fact] - public void KeyChainCodec_applies_codecs_in_order() + public void NatsKeyChainCodec_applies_codecs_in_order() { // Chain: Path -> Base64 // Input: "/users/123" -> "_root_.users.123" -> "X3Jvb3Rf.dXNlcnM.MTIz" - var chain = new KeyChainCodec(PathKeyCodec.Instance, Base64KeyCodec.Instance); + var chain = new NatsKeyChainCodec(NatsPathKeyCodec.Instance, NatsBase64KeyCodec.Instance); var encoded = chain.EncodeKey("/users/123"); _output.WriteLine($"Encoded: {encoded}"); @@ -141,17 +142,17 @@ public void KeyChainCodec_applies_codecs_in_order() } [Fact] - public void KeyChainCodec_requires_at_least_one_codec() + public void NatsKeyChainCodec_requires_at_least_one_codec() { - Assert.Throws(() => new KeyChainCodec()); - Assert.Throws(() => new KeyChainCodec(Array.Empty())); + Assert.Throws(() => new NatsKeyChainCodec()); + Assert.Throws(() => new NatsKeyChainCodec(Array.Empty())); } [Fact] - public void KeyChainCodec_filter_requires_all_filterable() + public void NatsKeyChainCodec_filter_requires_all_filterable() { // NoOp and Base64 are filterable, so this should work - var chain = new KeyChainCodec(NoOpKeyCodec.Instance, Base64KeyCodec.Instance); + var chain = new NatsKeyChainCodec(NatsNoOpKeyCodec.Instance, NatsBase64KeyCodec.Instance); var result = chain.EncodeFilter("test.*"); Assert.Contains("*", result); } From cc46ae43b729157854dac0f24e39cdbd486fa863 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Thu, 15 Jan 2026 12:21:13 +0000 Subject: [PATCH 4/5] Add detailed documentation and unit tests for KeyValueStore key codecs Expanded `PACKAGE.md` with examples and descriptions of key codecs (`Base64`, `Path`, `Custom`) and their usage. Added unit tests for `Base64KeyCodec` and `PathKeyCodec`, ensuring proper encoding/decoding behaviors and compatibility with complex key scenarios. --- .../PACKAGE.md | 107 +++++++++++++++++- .../Codecs/CodecTests.cs | 52 +++++++++ 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md b/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md index 0721d15..cd785bb 100644 --- a/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md @@ -1,3 +1,106 @@ -# KV Extensions +# KeyValueStore Extensions -## KV Encoded Store +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()); +``` + +## 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/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Codecs/CodecTests.cs b/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Codecs/CodecTests.cs index e2a87d1..9370927 100644 --- a/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Codecs/CodecTests.cs +++ b/tests/Synadia.Orbit.KeyValueStore.Extensions.Test/Codecs/CodecTests.cs @@ -61,6 +61,42 @@ public void NatsBase64KeyCodec_handles_special_characters() _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() { @@ -126,6 +162,22 @@ public void NatsPathKeyCodec_trims_trailing_slash() 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() { From 2e4f05a5a8416f3ae372da1c59745452e971c71e Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 16 Jan 2026 12:48:35 +0000 Subject: [PATCH 5/5] Add examples for custom and combined key codecs in KeyValueStore documentation Expanded `PACKAGE.md` and included new examples demonstrating `ROT13KeyCodec`, chaining of `Path` and `Base64` codecs, and other advanced key codecs. Added related project references and a comprehensive `ExampleKV.cs` file for practical usage scenarios. --- .../PACKAGE.md | 70 +++++++ tools/DocsExamples/DocsExamples.csproj | 1 + tools/DocsExamples/ExampleKV.cs | 181 ++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 tools/DocsExamples/ExampleKV.cs diff --git a/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md b/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md index cd785bb..f2af722 100644 --- a/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md +++ b/src/Synadia.Orbit.KeyValueStore.Extensions/PACKAGE.md @@ -96,6 +96,76 @@ public class MyCustomCodec : INatsFilterableKeyCodec 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 | 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); + } +}