diff --git a/QsNet.Tests/DecodeOptionsTests.cs b/QsNet.Tests/DecodeOptionsTests.cs index 9a42276..5e3fe2b 100644 --- a/QsNet.Tests/DecodeOptionsTests.cs +++ b/QsNet.Tests/DecodeOptionsTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Text; using FluentAssertions; using QsNet.Enums; @@ -108,4 +110,111 @@ public void CopyWith_WithModifications_ShouldReturnModifiedOptions() newOptions.ParseLists.Should().BeTrue(); newOptions.StrictNullHandling.Should().BeFalse(); } + + [Fact] + public void DecodeKey_ShouldThrow_When_DecodeDotInKeysTrue_And_AllowDotsFalse() + { + var options = new DecodeOptions + { + AllowDots = false, + DecodeDotInKeys = true + }; + + Action act = () => options.DecodeKey("a%2Eb", Encoding.UTF8); + act.Should().Throw() + .Where(e => e.Message.Contains("decodeDotInKeys", StringComparison.OrdinalIgnoreCase) + && e.Message.Contains("allowDots", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void DecodeKey_DecodesPercentSequences_LikeValues() + { + var options = new DecodeOptions + { + AllowDots = true, + DecodeDotInKeys = false + }; + + options.DecodeKey("a%2Eb", Encoding.UTF8).Should().Be("a.b"); + options.DecodeKey("a%2eb", Encoding.UTF8).Should().Be("a.b"); + } + + [Fact] + public void DecodeValue_DecodesPercentSequences_Normally() + { + var options = new DecodeOptions(); + options.DecodeValue("%2E", Encoding.UTF8).Should().Be("."); + } + + [Fact] + public void DecoderWithKind_IsUsed_For_Key_And_Value() + { + var calls = new List<(string? s, DecodeKind kind)>(); + var options = new DecodeOptions + { + DecoderWithKind = (s, enc, kind) => + { + calls.Add((s, kind)); + return s; + } + }; + + options.DecodeKey("x", Encoding.UTF8).Should().Be("x"); + options.DecodeValue("y", Encoding.UTF8).Should().Be("y"); + + calls.Should().HaveCount(2); + calls[0].kind.Should().Be(DecodeKind.Key); + calls[0].s.Should().Be("x"); + calls[1].kind.Should().Be(DecodeKind.Value); + calls[1].s.Should().Be("y"); + } + + [Fact] + public void DecoderWithKind_NullReturn_IsHonored_NoFallback() + { + var options = new DecodeOptions + { + DecoderWithKind = (s, enc, kind) => null + }; + + options.DecodeValue("foo", Encoding.UTF8).Should().BeNull(); + options.DecodeKey("bar", Encoding.UTF8).Should().BeNull(); + } + + [Fact] + public void LegacyDecoder_IsUsed_When_NoKindAwareDecoder_IsProvided() + { + var options = new DecodeOptions + { + Decoder = (s, enc) => s is null ? null : s.ToUpperInvariant() + }; + + options.DecodeValue("abc", Encoding.UTF8).Should().Be("ABC"); + // For keys, legacy decoder is also used when no kind-aware decoder is set + options.DecodeKey("a%2Eb", Encoding.UTF8).Should().Be("A%2EB"); + } + + [Fact] + public void CopyWith_PreservesAndOverrides_Decoders() + { + var original = new DecodeOptions + { + Decoder = (s, enc) => s == null ? null : $"L:{s}", + DecoderWithKind = (s, enc, k) => s == null ? null : $"K:{k}:{s}" + }; + + // Copy without overrides preserves both decoders + var copy = original.CopyWith(); + copy.DecodeValue("v", Encoding.UTF8).Should().Be("K:Value:v"); + copy.DecodeKey("k", Encoding.UTF8).Should().Be("K:Key:k"); + + // Override only the legacy decoder; kind-aware remains + var copy2 = original.CopyWith(decoder: (s, enc) => s == null ? null : $"L2:{s}"); + copy2.DecodeValue("v", Encoding.UTF8).Should().Be("K:Value:v"); // still kind-aware takes precedence + + // Override kind-aware decoder + var copy3 = original.CopyWith(decoderWithKind: (s, enc, k) => s == null ? null : $"K2:{k}:{s}"); + copy3.DecodeValue("v", Encoding.UTF8).Should().Be("K2:Value:v"); + copy3.DecodeKey("k", Encoding.UTF8).Should().Be("K2:Key:k"); + } } \ No newline at end of file diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index 8406fae..8710400 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -4123,4 +4123,416 @@ public void Decode_NonGeneric_Hashtable_Is_Normalised() // Assert – keys are strings and values preserved decoded.Should().Equal(new Dictionary { ["x"] = 1, ["2"] = "y" }); } + + #region Encoded dot behavior in keys (%2E / %2e) + + [Fact] + public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysTrue_PlainDotSplits_EncodedDotDoesNotSplit() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + + Qs.Decode("a.b=c", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); + + Qs.Decode("a%2Eb=c", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); + + Qs.Decode("a%2eb=c", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); + } + + [Fact] + public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysFalse_EncodedDotRemainsPercentSequence() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; + + Qs.Decode("a%2Eb=c", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); + + Qs.Decode("a%2eb=c", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); + } + + [Fact] + public void EncodedDot_AllowDotsFalse_DecodeDotInKeysTrue_IsInvalid() + { + var opt = new DecodeOptions { AllowDots = false, DecodeDotInKeys = true }; + Action act = () => Qs.Decode("a%2Eb=c", opt); + act.Should().Throw(); + } + + [Fact] + public void EncodedDot_BracketSegment_MapsToDot_WhenDecodeDotInKeysTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + + Qs.Decode("a[%2E]=x", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["."] = "x" } + }); + + Qs.Decode("a[%2e]=x", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["."] = "x" } + }); + } + + [Fact] + public void EncodedDot_BracketSegment_RemainsPercentSequence_WhenDecodeDotInKeysFalse() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; + + Qs.Decode("a[%2E]=x", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["."] = "x" } + }); + + Qs.Decode("a[%2e]=x", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["."] = "x" } + }); + } + + [Fact] + public void EncodedDot_ValueAlwaysDecodesToDot() + { + Qs.Decode("x=%2E") + .Should() + .BeEquivalentTo(new Dictionary { ["x"] = "." }); + } + + [Fact] + public void EncodedDot_TopLevel_Latin1_AllowDotsTrue_DecodeDotInKeysTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true, Charset = Encoding.Latin1 }; + + Qs.Decode("a%2Eb=c", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); + + Qs.Decode("a[%2E]=x", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["."] = "x" } + }); + } + + [Fact] + public void EncodedDot_TopLevel_Latin1_AllowDotsTrue_DecodeDotInKeysFalse() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false, Charset = Encoding.Latin1 }; + + Qs.Decode("a%2Eb=c", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); + + Qs.Decode("a[%2E]=x", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["."] = "x" } + }); + } + + [Fact] + public void DecodeKey_PercentDecoding_InsideBrackets() + { + var o1 = new DecodeOptions { AllowDots = false, DecodeDotInKeys = false }; + var o2 = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; + + // Inside brackets: percent-decoding still applies to keys + o1.DecodeKey("a[%2Eb]", Encoding.UTF8).Should().Be("a[.b]"); + o1.DecodeKey("a[b%2Ec]", Encoding.UTF8).Should().Be("a[b.c]"); + + o2.DecodeKey("a[%2Eb]", Encoding.UTF8).Should().Be("a[.b]"); + o2.DecodeKey("a[b%2Ec]", Encoding.UTF8).Should().Be("a[b.c]"); + } + + [Fact] + public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsTrue_DecodeDotInKeysTrue_Upper() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + var result = Qs.Decode("a%5Bb%5D%5Bc%5D%2Ed=x", opt); + result.Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = new Dictionary + { + ["c"] = new Dictionary + { + ["d"] = "x" + } + } + } + }); + } + + [Fact] + public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsTrue_DecodeDotInKeysTrue_Lower() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + var result = Qs.Decode("a%5bb%5d%5bc%5d%2ed=x", opt); + result.Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = new Dictionary + { + ["c"] = new Dictionary + { + ["d"] = "x" + } + } + } + }); + } + + [Fact] + public void NestedBracketsInsideSegment_AllowDotsTrue_DecodeDotInKeysTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + var result = Qs.Decode("a[b%5Bc%5D].e=x", opt); + + result.Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b[c]"] = new Dictionary + { + ["e"] = "x" + } + } + }); + } + + [Fact] + public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsFalse_DecodeDotInKeysTrue_Throws() + { + var opt = new DecodeOptions { AllowDots = false, DecodeDotInKeys = true }; + Action act = () => Qs.Decode("a%5Bb%5D%5Bc%5D%2Ed=x", opt); + act.Should().Throw() + .WithMessage("*decodeDotInKeys*allowDots*"); + } + + [Fact] + public void TopLevel_EncodedDot_AllowDotsTrue_DecodeDotInKeysTrue_Splits() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + Qs.Decode("a%2Eb=c", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = "c" + } + }); + } + + [Fact] + public void TopLevel_EncodedDot_AllowDotsTrue_DecodeDotInKeysFalse_AlsoSplits() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; + + Qs.Decode("a%2Eb=c", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); + } + + [Fact] + public void TopLevel_EncodedDot_AllowDotsFalse_DecodeDotInKeysFalse_DoesNotSplit() + { + var opt = new DecodeOptions { AllowDots = false, DecodeDotInKeys = false }; + Qs.Decode("a%2Eb=c", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a.b"] = "c" + }); + } + + [Fact] + public void BracketThenEncodedDot_ToNextSegment_AllowDotsTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + // a[b]%2Ec = x => a[b].c = x => { a: { b: { c: x }}} + Qs.Decode("a[b]%2Ec=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = new Dictionary + { + ["c"] = "x" + } + } + }); + + // lowercase %2e + Qs.Decode("a[b]%2ec=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = new Dictionary + { + ["c"] = "x" + } + } + }); + } + + [Fact] + public void MixedCase_EncodedBrackets_TopLevelDotThenBracket_AllowDotsTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + // a%2E[b] = x => a.[b] then dot-split at top level → { a: { b: x }} + Qs.Decode("a%2E[b]=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = "x" + } + }); + } + + [Fact] + public void TopLevel_EncodedDot_Lowercase_AllowDotsTrue_Splits() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; + Qs.Decode("a%2eb=c", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); + } + + [Fact] + public void DotBeforeIndex_AllowDotsTrue_IndexRemainsIndex() + { + var opt = new DecodeOptions { AllowDots = true }; + Qs.Decode("foo[0].baz[0]=15&foo[0].bar=2", opt) + .Should().BeEquivalentTo( + new Dictionary + { + ["foo"] = new List + { + new Dictionary + { + ["baz"] = new List { "15" }, + ["bar"] = "2" + } + } + }); + } + + [Fact] + public void TrailingDot_AllowDotsTrue_Ignored() + { + var opt = new DecodeOptions { AllowDots = true }; + Qs.Decode("user.email.=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["user"] = new Dictionary { ["email"] = "x" } + }); + } + + [Fact] + public void BracketSegment_EncodedDot_MappedToDot() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + Qs.Decode("a[%2E]=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["."] = "x" } + }); + + Qs.Decode("a[%2e]=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["."] = "x" } + }); + } + + [Fact] + public void TopLevel_EncodedDot_BeforeBracket_Lowercase() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + Qs.Decode("a%2e[b]=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "x" } + }); + } + + [Fact] + public void PlainDot_BeforeBracket_AllowDotsTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + Qs.Decode("a.[b]=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "x" } + }); + } + + [Fact] + public void DecoderWithKind_ReceivesKey_ForTopLevelAndBracketedKeys() + { + var calls = new List<(string? s, DecodeKind kind)>(); + var opt = new DecodeOptions + { + DecoderWithKind = (s, enc, kind) => + { + calls.Add((s, kind)); + return s; + }, + AllowDots = true, + DecodeDotInKeys = true + }; + + Qs.Decode("a%2Eb=c&a[b]=d", opt); + + calls.Should().Contain(x => x.kind == DecodeKind.Key && (x.s == "a%2Eb" || x.s == "a[b]")); + calls.Should().Contain(x => x.kind == DecodeKind.Value && (x.s == "c" || x.s == "d")); + } + + #endregion } \ No newline at end of file diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index b29d84d..0fc92d5 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -3,7 +3,6 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Collections.Specialized; using System.Linq; using System.Reflection; using System.Text; diff --git a/QsNet/Enums/DecodeKind.cs b/QsNet/Enums/DecodeKind.cs new file mode 100644 index 0000000..0ea4d22 --- /dev/null +++ b/QsNet/Enums/DecodeKind.cs @@ -0,0 +1,31 @@ +namespace QsNet.Enums; + +/// +/// Indicates the decoding context for a scalar token. +/// +/// +/// Use when decoding a key or key segment so the decoder can apply +/// key-specific rules (for example, preserving percent-encoded dots %2E/%2e +/// until after key splitting). Use for normal value decoding. +/// +public enum DecodeKind +{ + /// + /// The token is a key (or a key segment). + /// + /// + /// Implementations typically avoid turning %2E/%2e into a literal dot + /// before key splitting when this kind is used, to match the semantics of the + /// reference qs library. + /// + Key, + + /// + /// The token is a value. + /// + /// + /// Values are decoded normally (e.g., percent-decoding and charset handling) + /// without any key-specific protections. + /// + Value +} \ No newline at end of file diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index ee59ddc..3fb750d 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -49,6 +49,7 @@ private static bool IsLatin1(Encoding e) => Equals(e, Encoding.Latin1); #endif + /// /// Parses a list value from a string or any other type, applying the options provided. /// @@ -163,15 +164,17 @@ int currentListLength if (pos == -1) { - key = options.GetDecoder(part, charset)?.ToString() ?? string.Empty; + key = options.DecodeKey(part, charset) ?? string.Empty; value = options.StrictNullHandling ? null : ""; } else { #if NETSTANDARD2_0 - key = options.GetDecoder(part.Substring(0, pos), charset)?.ToString() ?? string.Empty; + var rawKey = part.Substring(0, pos); + key = options.DecodeKey(rawKey, charset) ?? string.Empty; #else - key = options.GetDecoder(part[..pos], charset)?.ToString() ?? string.Empty; + var rawKey = part[..pos]; + key = options.DecodeKey(rawKey, charset) ?? string.Empty; #endif var currentLength = obj.TryGetValue(key, out var val) && val is IList list ? list.Count : 0; @@ -179,12 +182,12 @@ int currentListLength #if NETSTANDARD2_0 value = Utils.Apply( ParseListValue(part.Substring(pos + 1), options, currentLength), - v => options.GetDecoder(v?.ToString(), charset) + v => options.DecodeValue(v?.ToString(), charset) ); #else value = Utils.Apply( ParseListValue(part[(pos + 1)..], options, currentLength), - v => options.GetDecoder(v?.ToString(), charset) + v => options.DecodeValue(v?.ToString(), charset) ); #endif } @@ -394,6 +397,59 @@ bool valuesParsed return ParseObject(segments, value, options, valuesParsed); } + /// + /// Convert top-level dot segments into bracket groups, preserving dots inside brackets + /// and ignoring degenerate segments (leading/trailing/double dots). + /// Examples: + /// "user.email.name" -> "user[email][name]" + /// "a[b].c" -> "a[b][c]" (dot outside brackets) + /// "a[.].c" -> remains "a[.][c]" (dot inside brackets is preserved) + /// "user.email." -> "user[email]" (trailing dot ignored) + /// + private static string DotToBracketTopLevel(string key) + { + if (string.IsNullOrEmpty(key) || key.IndexOf('.') < 0) + return key; + + var sb = new StringBuilder(key.Length + 4); + var depth = 0; + + for (var i = 0; i < key.Length; i++) + { + var ch = key[i]; + switch (ch) + { + case '[': + depth++; + sb.Append(ch); + break; + case ']': + { + if (depth > 0) depth--; + sb.Append(ch); + break; + } + case '.' when depth == 0: + { + // Convert the immediate token after the dot into a bracketed segment. + // The token ends at the next '.' or '[' or end of string. + var j = i + 1; + while (j < key.Length && key[j] != '.' && key[j] != '[') j++; + var len = j - (i + 1); + if (len > 0) sb.Append('[').Append(key, i + 1, len).Append(']'); + // Degenerate cases (leading/double/trailing dot): do nothing. + i = j - 1; // continue from the delimiter we stopped at + break; + } + default: + sb.Append(ch); + break; + } + } + + return sb.ToString(); + } + /// /// Splits a key into segments based on brackets and dots, handling depth and strictness. /// @@ -411,9 +467,7 @@ bool strictDepth ) { // Apply dot→bracket *before* splitting, but when depth == 0, we do NOT split at all and do NOT throw. - var key = allowDots - ? DotToBracket.Replace(originalKey, match => $"[{match.Groups[1].Value}]") - : originalKey; + var key = allowDots ? DotToBracketTopLevel(originalKey) : originalKey; // Depth 0 semantics: use the original key as a single segment; never throw. if (maxDepth <= 0) @@ -432,31 +486,63 @@ bool strictDepth var open = first; var depth = 0; + var lastClose = -1; while (open >= 0 && depth < maxDepth) { - var close = key.IndexOf(']', open + 1); + var level = 1; + var i = open + 1; + var close = -1; + while (i < key.Length) + { + var ch = key[i]; + if (ch == '[') + { + level++; + } + else if (ch == ']') + { + level--; + if (level == 0) + { + close = i; + break; + } + } + + i++; + } + if (close < 0) - break; + // Unterminated group: treat the entire key as a single literal segment (qs semantics). + // This ensures inputs like "[", "[[", or "[hello[" are preserved as-is and do not get dropped. + return [key]; #if NETSTANDARD2_0 - segments.Add(key.Substring(open, close + 1 - open)); // e.g. "[p]" or "[]" + segments.Add(key.Substring(open, close + 1 - open)); // balanced group, e.g. "[b[c]]" #else - segments.Add(key[open..(close + 1)]); // e.g. "[p]" or "[]" + segments.Add(key[open..(close + 1)]); // balanced group, e.g. "[b[c]]" #endif + lastClose = close; depth++; open = key.IndexOf('[', close + 1); } - if (open < 0) return segments; - // When depth > 0, strictDepth can apply to the remainder. + // If there's any trailing text after the last closing bracket, treat it as a single final segment. + // Ignore a lone trailing '.' (degenerate top-level dot). + if (lastClose < 0 || lastClose + 1 >= key.Length) return segments; +#if NETSTANDARD2_0 + var remainder = key.Substring(lastClose + 1); +#else + var remainder = key[(lastClose + 1)..]; +#endif + if (remainder == ".") return segments; + if (strictDepth) throw new IndexOutOfRangeException( $"Input depth exceeded depth option of {maxDepth} and strictDepth is true" ); -#if NETSTANDARD2_0 - segments.Add("[" + key.Substring(open) + "]"); -#else - segments.Add("[" + key[open..] + "]"); -#endif + + // Wrap the remainder as one final bracket segment. + segments.Add("[" + remainder + "]"); return segments; } diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index 35bf183..797f26d 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -14,6 +14,20 @@ namespace QsNet.Models; /// The decoded value, or null if the value is not present. public delegate object? Decoder(string? value, Encoding? encoding); +/// +/// A function that decodes a value from a query string or form data with key/value context. +/// The indicates whether the token is a key (or key segment) or a value. +/// +/// The encoded value to decode. +/// The character encoding to use for decoding, if any. +/// Whether this token is a or . +/// The decoded value, or null if the value is not present. +/// +/// When is , the decoder is expected to return a string or +/// null. +/// +public delegate object? KindAwareDecoder(string? value, Encoding? encoding, DecodeKind kind); + /// /// Options that configure the output of Qs.Decode. /// @@ -40,9 +54,6 @@ public DecodeOptions() if (ParameterLimit <= 0) throw new ArgumentException("Parameter limit must be positive"); - - if (DecodeDotInKeys && !AllowDots) - throw new ArgumentException("decodeDotInKeys requires allowDots to be true"); } /// @@ -147,12 +158,14 @@ public DecodeOptions() public bool ThrowOnLimitExceeded { get; init; } /// - /// Set to true to use dot dictionary notation in the encoded output. + /// Set to true to parse dot dictionary notation in the encoded input. + /// Note: when not explicitly set, this property implicitly evaluates to true + /// if is true, to keep option combinations coherent. /// public bool AllowDots { init => _allowDots = value; - get => _allowDots ?? _decodeDotInKeys == true; + get => _allowDots ?? _decodeDotInKeys == true; // implied true when DecodeDotInKeys is true } /// @@ -160,6 +173,12 @@ public bool AllowDots /// public Decoder? Decoder { private get; init; } + /// + /// Optional decoder that receives key/value context. + /// When provided, this takes precedence over . + /// + public KindAwareDecoder? DecoderWithKind { private get; init; } + /// /// Gets whether to decode dots in keys. /// @@ -170,21 +189,65 @@ public bool DecodeDotInKeys } /// - /// Decode the input using the specified decoder. + /// Decode a single scalar token using the most specific decoder available. + /// If is provided, it is always used (even when it returns null). + /// Otherwise the legacy two-argument is used; if neither is set, + /// a library default is used. /// - /// The value to decode - /// The encoding to use - /// The decoded value - public object? GetDecoder(string? value, Encoding? encoding = null) + public object? Decode(string? value, Encoding? encoding = null, DecodeKind kind = DecodeKind.Value) { - return Decoder != null ? Decoder?.Invoke(value, encoding) : Utils.Decode(value, encoding); + if (kind == DecodeKind.Key && _decodeDotInKeys == true && _allowDots == false) + throw new ArgumentException( + "DecodeDotInKeys=true requires AllowDots=true when decoding keys.", + nameof(DecodeDotInKeys) + ); + var d3 = DecoderWithKind; + if (d3 is not null) return d3.Invoke(value, encoding, kind); + + var d = Decoder; + return d is not null ? d.Invoke(value, encoding) : DefaultDecode(value, encoding); } + /// + /// Decode a key (or key segment). Returns a string or null. + /// + public string? DecodeKey(string? value, Encoding? encoding = null) + { + var decoded = Decode(value, encoding, DecodeKind.Key); + return decoded switch + { + null => null, + string s => s, + _ => throw new InvalidOperationException( + $"Key decoder must return a string or null; got {decoded.GetType().FullName}. " + + "If using a custom decoder, ensure it returns string for keys.") + }; + } + + /// + /// Decode a value token. Returns any scalar (string/number/etc.) or null. + /// + public object? DecodeValue(string? value, Encoding? encoding = null) + { + return Decode(value, encoding); + } + + /// + /// Default decoder when no custom decoder is supplied. Keys are decoded identically + /// to values using with the provided encoding. + /// + private static object? DefaultDecode(string? value, Encoding? encoding) + { + return value is null ? null : Utils.Decode(value, encoding); + } + + /// /// Creates a new instance of DecodeOptions with the specified properties changed. /// /// Set to override AllowDots /// Set to override the decoder function + /// Set to override the kind-aware decoder function /// Set to override DecodeDotInKeys /// Set to override AllowEmptyLists /// Set to override AllowSparseLists @@ -206,6 +269,7 @@ public bool DecodeDotInKeys public DecodeOptions CopyWith( bool? allowDots = null, Decoder? decoder = null, + KindAwareDecoder? decoderWithKind = null, bool? decodeDotInKeys = null, bool? allowEmptyLists = null, bool? allowSparseLists = null, @@ -235,7 +299,8 @@ public DecodeOptions CopyWith( CharsetSentinel = charsetSentinel ?? CharsetSentinel, Comma = comma ?? Comma, DecodeDotInKeys = decodeDotInKeys ?? DecodeDotInKeys, - Decoder = decoder ?? GetDecoder, + Decoder = decoder ?? Decoder, + DecoderWithKind = decoderWithKind ?? DecoderWithKind, Delimiter = delimiter ?? Delimiter, Depth = depth ?? Depth, ParameterLimit = parameterLimit ?? ParameterLimit,