From d9acacc2e0a02cac0c82bd23affdb6de23fc61b6 Mon Sep 17 00:00:00 2001 From: lior1997k Date: Sat, 2 May 2026 16:38:02 +0300 Subject: [PATCH 1/3] fix: sanitize JSON Schema $ref type IDs for array and generic type names GetSchemaTypeId() now percent-encodes [ ] < > characters in type IDs to produce valid JSON Schema $ref URI-reference keys. This fixes invalid $defs keys like System.String[] and IEnumerable which violate RFC 3986. Also updates TestSchemaTypeId assertions to match the new sanitized output. Ultraworked with Sisyphus Co-authored-by: Sisyphus --- .../src/SchemaTests/TestSchemaTypeId.cs | 18 +++++++++--------- ReflectorNet/src/Utils/TypeUtils.Name.cs | 16 ++++++++++++++-- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/ReflectorNet.Tests/src/SchemaTests/TestSchemaTypeId.cs b/ReflectorNet.Tests/src/SchemaTests/TestSchemaTypeId.cs index 06372e53..4564f799 100644 --- a/ReflectorNet.Tests/src/SchemaTests/TestSchemaTypeId.cs +++ b/ReflectorNet.Tests/src/SchemaTests/TestSchemaTypeId.cs @@ -18,7 +18,7 @@ public void GetSchemaTypeId_SimpleArray_ShouldAppendArray() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal($"System.Int32{TypeUtils.ArraySuffix}", result); + Assert.Equal("System.Int32%5B%5D", result); _output.WriteLine($"int[] -> {result}"); } @@ -32,7 +32,7 @@ public void GetSchemaTypeId_NestedArray_ShouldAppendMultipleArrays() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal($"System.Int32{TypeUtils.ArraySuffix}{TypeUtils.ArraySuffix}", result); + Assert.Equal("System.Int32%5B%5D%5B%5D", result); _output.WriteLine($"int[][] -> {result}"); } @@ -46,7 +46,7 @@ public void GetSchemaTypeId_TripleNestedArray_ShouldAppendThreeArrays() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal($"System.Int32{TypeUtils.ArraySuffix}{TypeUtils.ArraySuffix}{TypeUtils.ArraySuffix}", result); + Assert.Equal("System.Int32%5B%5D%5B%5D%5B%5D", result); _output.WriteLine($"int[][][] -> {result}"); } @@ -60,7 +60,7 @@ public void GetSchemaTypeId_StringArray_ShouldWorkForAnyType() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal($"System.String{TypeUtils.ArraySuffix}", result); + Assert.Equal("System.String%5B%5D", result); _output.WriteLine($"string[] -> {result}"); } @@ -102,7 +102,7 @@ public void GetSchemaTypeId_NullableArrayType_ShouldHandleUnderlyingArrayType() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal($"System.Int32{TypeUtils.ArraySuffix}", result); + Assert.Equal("System.Int32%5B%5D", result); _output.WriteLine($"int?[] -> {result}"); } @@ -116,7 +116,7 @@ public void GetSchemaTypeId_IEnumerableOfInt_ShouldReturnGenericFormat() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("System.Collections.Generic.IEnumerable", result); + Assert.Equal("System.Collections.Generic.IEnumerable%3CSystem.Int32%3E", result); _output.WriteLine($"IEnumerable -> {result}"); } @@ -130,7 +130,7 @@ public void GetSchemaTypeId_ICollectionOfString_ShouldReturnGenericFormat() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("System.Collections.Generic.ICollection", result); + Assert.Equal("System.Collections.Generic.ICollection%3CSystem.String%3E", result); _output.WriteLine($"ICollection -> {result}"); } @@ -144,7 +144,7 @@ public void GetSchemaTypeId_IListOfInt_ShouldReturnGenericFormat() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("System.Collections.Generic.IList", result); + Assert.Equal("System.Collections.Generic.IList%3CSystem.Int32%3E", result); _output.WriteLine($"IList -> {result}"); } @@ -172,7 +172,7 @@ public void GetSchemaTypeId_ArrayOfCustomClass_ShouldAppendArray() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal($"com.IvanMurzak.ReflectorNet.Tests.SchemaTests.TestType{TypeUtils.ArraySuffix}", result); + Assert.Equal("com.IvanMurzak.ReflectorNet.Tests.SchemaTests.TestType%5B%5D", result); _output.WriteLine($"TestType[] -> {result}"); } } diff --git a/ReflectorNet/src/Utils/TypeUtils.Name.cs b/ReflectorNet/src/Utils/TypeUtils.Name.cs index feeaf503..79268f82 100644 --- a/ReflectorNet/src/Utils/TypeUtils.Name.cs +++ b/ReflectorNet/src/Utils/TypeUtils.Name.cs @@ -115,8 +115,20 @@ public static string GetTypeId(Type type) return Sanitize(type); } - public static string GetSchemaTypeId() => GetTypeId(typeof(T)); - public static string GetSchemaTypeId(Type type) => GetTypeId(type); + public static string GetSchemaTypeId() => GetSchemaTypeId(typeof(T)); + public static string GetSchemaTypeId(Type type) + { + var typeId = GetTypeId(type); + return SanitizeForJsonSchemaRef(typeId); + } + + private static string SanitizeForJsonSchemaRef(string typeId) + { + if (string.IsNullOrEmpty(typeId)) + return typeId; + return typeId.Replace("[", "%5B").Replace("]", "%5D") + .Replace("<", "%3C").Replace(">", "%3E"); + } public static bool IsNameMatch(Type? type, string? typeName) { From edbeae97eea3ca78a997cab49b42b3ef60f2901d Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Thu, 21 May 2026 04:19:40 -0700 Subject: [PATCH 2/3] fix(schema): percent-encode + for nested-class type ids Extend SanitizeForJsonSchemaRef() to percent-encode the `+` separator that C# emits between an outer type and its nested type (`Outer+Nested`). `+` is reserved in URI query components and produces invalid JSON Schema $ref fragments otherwise. Encoded as `%2B`. Adds four tests in TestSchemaTypeId covering: - ParentClass.NestedClass (instance nested in instance parent) - ParentClass.NestedStaticClass (static nested in instance parent) - StaticParentClass.NestedClass (instance nested in static parent) - ParentClass.NestedClass[] (combination with array %5B%5D) Addresses maintainer review comment on PR #77. inherited-failure (downstream MCP-Plugin-dotnet, reproduces on base): - McpBuilderTests_ListTool::ListTool_GenericMethodWithComplexType_ShouldBeListed - McpBuilderTests_ListTool::ListTool_NoArgsListOfIntReturnMethod_ShouldBeListed - McpBuilderTests_ListTool::ListTool_NoArgsListOfGenericReturnMethod_ShouldBeListed Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/SchemaTests/TestSchemaTypeId.cs | 61 +++++++++++++++++++ ReflectorNet/src/Utils/TypeUtils.Name.cs | 3 +- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/ReflectorNet.Tests/src/SchemaTests/TestSchemaTypeId.cs b/ReflectorNet.Tests/src/SchemaTests/TestSchemaTypeId.cs index 4564f799..87626b5f 100644 --- a/ReflectorNet.Tests/src/SchemaTests/TestSchemaTypeId.cs +++ b/ReflectorNet.Tests/src/SchemaTests/TestSchemaTypeId.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using com.IvanMurzak.ReflectorNet.OuterAssembly.Model; using com.IvanMurzak.ReflectorNet.Utils; using Xunit.Abstractions; @@ -175,5 +176,65 @@ public void GetSchemaTypeId_ArrayOfCustomClass_ShouldAppendArray() Assert.Equal("com.IvanMurzak.ReflectorNet.Tests.SchemaTests.TestType%5B%5D", result); _output.WriteLine($"TestType[] -> {result}"); } + + [Fact] + public void GetSchemaTypeId_NestedClass_ShouldPercentEncodePlus() + { + // Arrange + var type = typeof(ParentClass.NestedClass); + + // Act + var result = type.GetSchemaTypeId(); + + // Assert + Assert.Equal("com.IvanMurzak.ReflectorNet.OuterAssembly.Model.ParentClass%2BNestedClass", result); + Assert.DoesNotContain("+", result); + _output.WriteLine($"ParentClass.NestedClass -> {result}"); + } + + [Fact] + public void GetSchemaTypeId_NestedStaticClass_ShouldPercentEncodePlus() + { + // Arrange + var type = typeof(ParentClass.NestedStaticClass); + + // Act + var result = type.GetSchemaTypeId(); + + // Assert + Assert.Equal("com.IvanMurzak.ReflectorNet.OuterAssembly.Model.ParentClass%2BNestedStaticClass", result); + Assert.DoesNotContain("+", result); + _output.WriteLine($"ParentClass.NestedStaticClass -> {result}"); + } + + [Fact] + public void GetSchemaTypeId_NestedClassInStaticParent_ShouldPercentEncodePlus() + { + // Arrange + var type = typeof(StaticParentClass.NestedClass); + + // Act + var result = type.GetSchemaTypeId(); + + // Assert + Assert.Equal("com.IvanMurzak.ReflectorNet.OuterAssembly.Model.StaticParentClass%2BNestedClass", result); + Assert.DoesNotContain("+", result); + _output.WriteLine($"StaticParentClass.NestedClass -> {result}"); + } + + [Fact] + public void GetSchemaTypeId_ArrayOfNestedClass_ShouldPercentEncodePlusAndBrackets() + { + // Arrange + var type = typeof(ParentClass.NestedClass[]); + + // Act + var result = type.GetSchemaTypeId(); + + // Assert + Assert.Equal("com.IvanMurzak.ReflectorNet.OuterAssembly.Model.ParentClass%2BNestedClass%5B%5D", result); + Assert.DoesNotContain("+", result); + _output.WriteLine($"ParentClass.NestedClass[] -> {result}"); + } } } diff --git a/ReflectorNet/src/Utils/TypeUtils.Name.cs b/ReflectorNet/src/Utils/TypeUtils.Name.cs index 79268f82..4c15e3fd 100644 --- a/ReflectorNet/src/Utils/TypeUtils.Name.cs +++ b/ReflectorNet/src/Utils/TypeUtils.Name.cs @@ -127,7 +127,8 @@ private static string SanitizeForJsonSchemaRef(string typeId) if (string.IsNullOrEmpty(typeId)) return typeId; return typeId.Replace("[", "%5B").Replace("]", "%5D") - .Replace("<", "%3C").Replace(">", "%3E"); + .Replace("<", "%3C").Replace(">", "%3E") + .Replace("+", "%2B"); } public static bool IsNameMatch(Type? type, string? typeName) From 7dfe6d368ee2336b6fce90d1d49833beb2d5c6b4 Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Thu, 21 May 2026 12:30:26 -0700 Subject: [PATCH 3/3] fix(schema): encode at $ref site only; decode at type-name resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review: percent-encoding the $defs key itself broke RFC 6901 JSON Pointer resolution. URI fragment decoding happens before JSON Pointer parsing, so a `$ref` of `#/$defs/System.Int32%5B%5D` decodes to the token `System.Int32[]` and would never match a stored key of `System.Int32%5B%5D`. Production changes: - TypeUtils.GetSchemaTypeId returns raw type-ids; SanitizeForJsonSchemaRef removed from TypeUtils.Name (the encoding now lives at exactly one site). - JsonSchema.cs: EncodeForJsonSchemaRef applied only when constructing the `$ref` URI value. $defs keys remain raw — consumers URI-decode the fragment and look up the raw key directly. - TypeUtils.GetType (all three overloads): DecodeSchemaRefChars normalizes the input typeName, so reflector modify / field-set / property-set callers may submit either raw type ids or $ref-style encoded forms (`%2B %3C %3E %5B %5D`). Other percent sequences pass through unchanged. Test changes: - TestSchemaTypeId.cs assertions revert to raw output (the contract for $defs keys); nested-class tests retain `+` literal not `%2B`. - New TestSchemaRefEncoding asserts the $ref-side encoding contract. - New TestGetTypeDecodeSchemaRef asserts input tolerance for both forms. - SchemaTestBase helpers URI-decode `$ref` fragments before comparing to raw type-ids, fixing the test-side mirror of the production contract. 1,439 tests pass on net8.0 and net9.0. --- .../src/SchemaTests/SchemaTestBase.cs | 33 ++++--- .../SchemaTests/TestGetTypeDecodeSchemaRef.cs | 75 ++++++++++++++++ .../src/SchemaTests/TestSchemaRefEncoding.cs | 86 +++++++++++++++++++ .../src/SchemaTests/TestSchemaTypeId.cs | 40 ++++----- ReflectorNet/src/Utils/Json/JsonSchema.cs | 21 ++++- ReflectorNet/src/Utils/TypeUtils.GetType.cs | 25 +++++- ReflectorNet/src/Utils/TypeUtils.Name.cs | 15 +--- 7 files changed, 239 insertions(+), 56 deletions(-) create mode 100644 ReflectorNet.Tests/src/SchemaTests/TestGetTypeDecodeSchemaRef.cs create mode 100644 ReflectorNet.Tests/src/SchemaTests/TestSchemaRefEncoding.cs diff --git a/ReflectorNet.Tests/src/SchemaTests/SchemaTestBase.cs b/ReflectorNet.Tests/src/SchemaTests/SchemaTestBase.cs index a70dd22e..39a20169 100644 --- a/ReflectorNet.Tests/src/SchemaTests/SchemaTestBase.cs +++ b/ReflectorNet.Tests/src/SchemaTests/SchemaTestBase.cs @@ -65,14 +65,15 @@ protected void TestMethodInputs_PropertyRefs(Reflector? reflector, MethodInfo me Assert.NotNull(methodParameter); var typeId = methodParameter.ParameterType.GetSchemaTypeId(); - var refString = $"{JsonSchema.RefValue}{typeId}"; var targetDefine = defines[typeId]; Assert.NotNull(targetDefine); + // $ref values are URI-encoded; decode each before comparing against the raw typeId. var refStringValue = properties.FirstOrDefault(kvp => kvp.Value!.AsObject().TryGetPropertyValue(JsonSchema.Ref, out var refValue) - && refString == refValue?.ToString()) + && refValue is not null + && Uri.UnescapeDataString(refValue.ToString().Replace(JsonSchema.RefValue, string.Empty)) == typeId) .Value ?.ToString(); @@ -205,16 +206,15 @@ protected void AssertResultDefines(JsonNode schema, params Type[] expectedTypes) var expectedTypeId = expectedType.GetSchemaTypeId(); // Check if the type is either: - // 1. Directly defined in $defs (exact match) + // 1. Directly defined in $defs (raw typeId match — $defs keys are raw) var isDirectlyDefined = defines.ContainsKey(expectedTypeId); - // 2. Referenced somewhere in the schema (exact match in $ref) - var expectedRef = $"{JsonSchema.RefValue}{expectedTypeId}"; - var isReferenced = allReferences.Contains(expectedRef); + // 2. Referenced somewhere in the schema ($ref values are URI-encoded — decode each) + var isReferenced = allReferences.Any(reference => + Uri.UnescapeDataString(reference.Replace(JsonSchema.RefValue, string.Empty)) == expectedTypeId); Assert.True(isDirectlyDefined && isReferenced, $"Expected type '{expectedType.GetTypeShortName()}' with ID '{expectedTypeId}' should be both defined in $defs and referenced in schema. " + - $"Expected $ref: '{expectedRef}'. " + $"Defined types: {string.Join(", ", defines.Select(d => d.Key))}. " + $"Referenced types: {string.Join(", ", allReferences)}"); } @@ -278,13 +278,6 @@ protected void AssertAllRefsDefined(JsonNode schema) return; } - // References don't include restricted types - foreach (var reference in allReferences) - { - Assert.False(RestrictedDefineTypes.Any(x => JsonSchema.RefValue + x.GetSchemaTypeId() == reference), - $"Reference '{reference}' is for a restricted type that should not appear as a $ref."); - } - // Schema must have $defs if there are references Assert.True(schema.AsObject().ContainsKey(JsonSchema.Defs), $"Schema contains {allReferences.Count} $ref reference(s) but no $defs section. References: {string.Join(", ", allReferences)}"); @@ -292,14 +285,18 @@ protected void AssertAllRefsDefined(JsonNode schema) var defines = schema[JsonSchema.Defs]!.AsObject(); Assert.NotNull(defines); - // Check each reference to ensure it's defined + // $defs keys are raw type-ids; $ref values are URI references with percent-encoded + // fragments. URI-decode the fragment to recover the raw type-id before lookup. foreach (var reference in allReferences) { - // Extract the type ID from the reference (e.g., "#/$defs/TypeId" -> "TypeId") - var typeId = reference.Replace(JsonSchema.RefValue, string.Empty); + var encodedTypeId = reference.Replace(JsonSchema.RefValue, string.Empty); + var typeId = Uri.UnescapeDataString(encodedTypeId); + + Assert.False(RestrictedDefineTypes.Any(x => x.GetSchemaTypeId() == typeId), + $"Reference '{reference}' is for a restricted type that should not appear as a $ref."); Assert.True(defines.ContainsKey(typeId), - $"Reference '{reference}' (type ID: '{typeId}') is not defined in $defs. " + + $"Reference '{reference}' (decoded type ID: '{typeId}') is not defined in $defs. " + $"Available definitions: {string.Join(", ", defines.Select(d => d.Key))}"); } diff --git a/ReflectorNet.Tests/src/SchemaTests/TestGetTypeDecodeSchemaRef.cs b/ReflectorNet.Tests/src/SchemaTests/TestGetTypeDecodeSchemaRef.cs new file mode 100644 index 00000000..4c2be8aa --- /dev/null +++ b/ReflectorNet.Tests/src/SchemaTests/TestGetTypeDecodeSchemaRef.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using com.IvanMurzak.ReflectorNet.OuterAssembly.Model; +using com.IvanMurzak.ReflectorNet.Utils; +using Xunit.Abstractions; + +namespace com.IvanMurzak.ReflectorNet.Tests.SchemaTests +{ + /// + /// TypeUtils.GetType(...) is the funnel for every type-name → Type resolution invoked by + /// reflector modify / field-set / property-set actions. Callers may submit a raw type id + /// (e.g. System.Int32[]) or a $ref-style percent-encoded form (e.g. System.Int32%5B%5D). + /// Both must resolve to the same Type. Only these five chars are decoded: + /// %2B → +, %3C → <, %3E → >, %5B → [, %5D → ]. + /// + public class TestGetTypeDecodeSchemaRef : BaseTest + { + public TestGetTypeDecodeSchemaRef(ITestOutputHelper output) : base(output) { } + + [Theory] + [InlineData("System.Int32[]", "System.Int32%5B%5D")] + [InlineData("System.String[]", "System.String%5B%5D")] + [InlineData("System.Int32[][]", "System.Int32%5B%5D%5B%5D")] + public void GetType_Array_AcceptsBothRawAndEncodedBrackets(string raw, string encoded) + { + var rawType = TypeUtils.GetType(raw); + var encodedType = TypeUtils.GetType(encoded); + + Assert.NotNull(rawType); + Assert.Same(rawType, encodedType); + } + + [Theory] + [InlineData( + "System.Collections.Generic.IList", + "System.Collections.Generic.IList%3CSystem.Int32%3E")] + [InlineData( + "System.Collections.Generic.IEnumerable", + "System.Collections.Generic.IEnumerable%3CSystem.String%3E")] + public void GetType_Generic_AcceptsBothRawAndEncodedAngleBrackets(string raw, string encoded) + { + var rawType = TypeUtils.GetType(raw); + var encodedType = TypeUtils.GetType(encoded); + + Assert.NotNull(rawType); + Assert.Same(rawType, encodedType); + } + + [Fact] + public void GetType_NestedClass_AcceptsBothRawAndEncodedPlus() + { + var raw = "com.IvanMurzak.ReflectorNet.OuterAssembly.Model.ParentClass+NestedClass"; + var encoded = "com.IvanMurzak.ReflectorNet.OuterAssembly.Model.ParentClass%2BNestedClass"; + + var rawType = TypeUtils.GetType(raw); + var encodedType = TypeUtils.GetType(encoded); + + Assert.NotNull(rawType); + Assert.Equal(typeof(ParentClass.NestedClass), rawType); + Assert.Same(rawType, encodedType); + } + + [Fact] + public void GetType_GenericOfArrayOfNestedClass_AcceptsFullyEncodedForm() + { + // The $ref-style encoded form a JSON Schema consumer would send through. + var encoded = "System.Collections.Generic.IList%3Ccom.IvanMurzak.ReflectorNet.OuterAssembly.Model.ParentClass%2BNestedClass%5B%5D%3E"; + + var encodedType = TypeUtils.GetType(encoded); + var directType = typeof(IList); + + Assert.NotNull(encodedType); + Assert.Equal(directType, encodedType); + } + } +} diff --git a/ReflectorNet.Tests/src/SchemaTests/TestSchemaRefEncoding.cs b/ReflectorNet.Tests/src/SchemaTests/TestSchemaRefEncoding.cs new file mode 100644 index 00000000..c69420a9 --- /dev/null +++ b/ReflectorNet.Tests/src/SchemaTests/TestSchemaRefEncoding.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Linq; +using com.IvanMurzak.ReflectorNet.OuterAssembly.Model; +using com.IvanMurzak.ReflectorNet.Utils; +using Xunit.Abstractions; + +namespace com.IvanMurzak.ReflectorNet.Tests.SchemaTests +{ + /// + /// $defs keys are stored as raw type-ids (arbitrary JSON object keys). + /// $ref values are URI references and must percent-encode chars not allowed in URI fragments. + /// A consumer that URI-decodes the $ref fragment recovers the raw type-id and looks it up in $defs. + /// + public class TestSchemaRefEncoding : BaseTest + { + public TestSchemaRefEncoding(ITestOutputHelper output) : base(output) { } + + [Fact] + public void GetSchemaRef_GenericType_EncodesAngleBrackets() + { + var reflector = new Reflector(); + var schema = reflector.GetSchemaRef>(); + + var refValue = schema?[JsonSchema.Ref]?.ToString(); + Assert.NotNull(refValue); + Assert.StartsWith(JsonSchema.RefValue, refValue); + Assert.Contains("%3C", refValue); + Assert.Contains("%3E", refValue); + Assert.DoesNotContain("<", refValue); + Assert.DoesNotContain(">", refValue); + } + + [Fact] + public void GetSchemaRef_ArrayType_EncodesBrackets() + { + var reflector = new Reflector(); + var schema = reflector.GetSchemaRef(); + + var refValue = schema?[JsonSchema.Ref]?.ToString(); + Assert.NotNull(refValue); + Assert.Contains("%5B", refValue); + Assert.Contains("%5D", refValue); + Assert.DoesNotContain("[", refValue!.Substring(JsonSchema.RefValue.Length)); + Assert.DoesNotContain("]", refValue.Substring(JsonSchema.RefValue.Length)); + } + + [Fact] + public void GetSchemaRef_NestedClass_EncodesPlus() + { + var reflector = new Reflector(); + var schema = reflector.GetSchemaRef(); + + var refValue = schema?[JsonSchema.Ref]?.ToString(); + Assert.NotNull(refValue); + Assert.Contains("%2B", refValue); + Assert.DoesNotContain("+", refValue); + } + + [Fact] + public void GetSchema_GenericType_DefsKeyIsRaw() + { + // $defs keys must remain raw — URI-decoding the $ref fragment must recover them verbatim. + var reflector = new Reflector(); + var schema = reflector.GetSchema>(); + var defs = schema?[JsonSchema.Defs] as System.Text.Json.Nodes.JsonObject; + + Assert.NotNull(defs); + Assert.True(defs!.ContainsKey(typeof(IList).GetSchemaTypeId()), + $"$defs must contain raw key '{typeof(IList).GetSchemaTypeId()}'. Actual keys: {string.Join(", ", defs.Select(kvp => kvp.Key))}"); + } + + [Fact] + public void GetSchema_NestedClass_DefsKeyIsRawWithPlus() + { + var reflector = new Reflector(); + var schema = reflector.GetSchema(); + var defs = schema?[JsonSchema.Defs] as System.Text.Json.Nodes.JsonObject; + + Assert.NotNull(defs); + var expectedKey = typeof(ParentClass.NestedClass).GetSchemaTypeId(); + Assert.Contains("+", expectedKey); + Assert.True(defs!.ContainsKey(expectedKey), + $"$defs must contain raw key '{expectedKey}'. Actual keys: {string.Join(", ", defs.Select(kvp => kvp.Key))}"); + } + } +} diff --git a/ReflectorNet.Tests/src/SchemaTests/TestSchemaTypeId.cs b/ReflectorNet.Tests/src/SchemaTests/TestSchemaTypeId.cs index 87626b5f..8662a9f2 100644 --- a/ReflectorNet.Tests/src/SchemaTests/TestSchemaTypeId.cs +++ b/ReflectorNet.Tests/src/SchemaTests/TestSchemaTypeId.cs @@ -19,7 +19,7 @@ public void GetSchemaTypeId_SimpleArray_ShouldAppendArray() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("System.Int32%5B%5D", result); + Assert.Equal("System.Int32[]", result); _output.WriteLine($"int[] -> {result}"); } @@ -33,7 +33,7 @@ public void GetSchemaTypeId_NestedArray_ShouldAppendMultipleArrays() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("System.Int32%5B%5D%5B%5D", result); + Assert.Equal("System.Int32[][]", result); _output.WriteLine($"int[][] -> {result}"); } @@ -47,7 +47,7 @@ public void GetSchemaTypeId_TripleNestedArray_ShouldAppendThreeArrays() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("System.Int32%5B%5D%5B%5D%5B%5D", result); + Assert.Equal("System.Int32[][][]", result); _output.WriteLine($"int[][][] -> {result}"); } @@ -61,7 +61,7 @@ public void GetSchemaTypeId_StringArray_ShouldWorkForAnyType() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("System.String%5B%5D", result); + Assert.Equal("System.String[]", result); _output.WriteLine($"string[] -> {result}"); } @@ -103,7 +103,7 @@ public void GetSchemaTypeId_NullableArrayType_ShouldHandleUnderlyingArrayType() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("System.Int32%5B%5D", result); + Assert.Equal("System.Int32[]", result); _output.WriteLine($"int?[] -> {result}"); } @@ -117,7 +117,7 @@ public void GetSchemaTypeId_IEnumerableOfInt_ShouldReturnGenericFormat() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("System.Collections.Generic.IEnumerable%3CSystem.Int32%3E", result); + Assert.Equal("System.Collections.Generic.IEnumerable", result); _output.WriteLine($"IEnumerable -> {result}"); } @@ -131,7 +131,7 @@ public void GetSchemaTypeId_ICollectionOfString_ShouldReturnGenericFormat() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("System.Collections.Generic.ICollection%3CSystem.String%3E", result); + Assert.Equal("System.Collections.Generic.ICollection", result); _output.WriteLine($"ICollection -> {result}"); } @@ -145,7 +145,7 @@ public void GetSchemaTypeId_IListOfInt_ShouldReturnGenericFormat() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("System.Collections.Generic.IList%3CSystem.Int32%3E", result); + Assert.Equal("System.Collections.Generic.IList", result); _output.WriteLine($"IList -> {result}"); } @@ -173,12 +173,12 @@ public void GetSchemaTypeId_ArrayOfCustomClass_ShouldAppendArray() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("com.IvanMurzak.ReflectorNet.Tests.SchemaTests.TestType%5B%5D", result); + Assert.Equal("com.IvanMurzak.ReflectorNet.Tests.SchemaTests.TestType[]", result); _output.WriteLine($"TestType[] -> {result}"); } [Fact] - public void GetSchemaTypeId_NestedClass_ShouldPercentEncodePlus() + public void GetSchemaTypeId_NestedClass_ShouldUsePlusSeparator() { // Arrange var type = typeof(ParentClass.NestedClass); @@ -187,13 +187,14 @@ public void GetSchemaTypeId_NestedClass_ShouldPercentEncodePlus() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("com.IvanMurzak.ReflectorNet.OuterAssembly.Model.ParentClass%2BNestedClass", result); - Assert.DoesNotContain("+", result); + // $defs keys are raw JSON object keys — the '+' nested-class separator is stored + // verbatim. URI encoding happens at the $ref site only (see TestSchemaRefEncoding). + Assert.Equal("com.IvanMurzak.ReflectorNet.OuterAssembly.Model.ParentClass+NestedClass", result); _output.WriteLine($"ParentClass.NestedClass -> {result}"); } [Fact] - public void GetSchemaTypeId_NestedStaticClass_ShouldPercentEncodePlus() + public void GetSchemaTypeId_NestedStaticClass_ShouldUsePlusSeparator() { // Arrange var type = typeof(ParentClass.NestedStaticClass); @@ -202,13 +203,12 @@ public void GetSchemaTypeId_NestedStaticClass_ShouldPercentEncodePlus() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("com.IvanMurzak.ReflectorNet.OuterAssembly.Model.ParentClass%2BNestedStaticClass", result); - Assert.DoesNotContain("+", result); + Assert.Equal("com.IvanMurzak.ReflectorNet.OuterAssembly.Model.ParentClass+NestedStaticClass", result); _output.WriteLine($"ParentClass.NestedStaticClass -> {result}"); } [Fact] - public void GetSchemaTypeId_NestedClassInStaticParent_ShouldPercentEncodePlus() + public void GetSchemaTypeId_NestedClassInStaticParent_ShouldUsePlusSeparator() { // Arrange var type = typeof(StaticParentClass.NestedClass); @@ -217,13 +217,12 @@ public void GetSchemaTypeId_NestedClassInStaticParent_ShouldPercentEncodePlus() var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("com.IvanMurzak.ReflectorNet.OuterAssembly.Model.StaticParentClass%2BNestedClass", result); - Assert.DoesNotContain("+", result); + Assert.Equal("com.IvanMurzak.ReflectorNet.OuterAssembly.Model.StaticParentClass+NestedClass", result); _output.WriteLine($"StaticParentClass.NestedClass -> {result}"); } [Fact] - public void GetSchemaTypeId_ArrayOfNestedClass_ShouldPercentEncodePlusAndBrackets() + public void GetSchemaTypeId_ArrayOfNestedClass_ShouldUsePlusAndBrackets() { // Arrange var type = typeof(ParentClass.NestedClass[]); @@ -232,8 +231,7 @@ public void GetSchemaTypeId_ArrayOfNestedClass_ShouldPercentEncodePlusAndBracket var result = type.GetSchemaTypeId(); // Assert - Assert.Equal("com.IvanMurzak.ReflectorNet.OuterAssembly.Model.ParentClass%2BNestedClass%5B%5D", result); - Assert.DoesNotContain("+", result); + Assert.Equal("com.IvanMurzak.ReflectorNet.OuterAssembly.Model.ParentClass+NestedClass[]", result); _output.WriteLine($"ParentClass.NestedClass[] -> {result}"); } } diff --git a/ReflectorNet/src/Utils/Json/JsonSchema.cs b/ReflectorNet/src/Utils/Json/JsonSchema.cs index 57b6d7b1..fea46f0e 100644 --- a/ReflectorNet/src/Utils/Json/JsonSchema.cs +++ b/ReflectorNet/src/Utils/Json/JsonSchema.cs @@ -282,10 +282,13 @@ public JsonNode GetSchemaRef(Reflector reflector, Type type) else { var typeId = type.GetSchemaTypeId(); - // If justRef is true and the type is not primitive, we return a reference schema + // The $defs key is stored raw (it's an arbitrary JSON object key). The $ref + // value is a URI reference per JSON Schema, so the type-id fragment must be + // percent-encoded for chars not allowed in URI fragments. A consumer decoding + // the $ref recovers the raw type-id and looks it up in $defs directly. schema = new JsonObject { - [Ref] = RefValue + typeId + [Ref] = RefValue + EncodeForJsonSchemaRef(typeId) }; } @@ -318,6 +321,20 @@ public JsonNode GetSchemaRef(Reflector reflector, Type type) return schema; } + /// + /// Percent-encodes characters that are not allowed in a JSON Schema $ref URI fragment. + /// $defs keys are raw JSON object keys (e.g. List<int>, Outer+Nested); + /// the $ref value is a URI reference per JSON Schema and must escape [ ] < > +. + /// A consumer that URI-decodes the fragment recovers the raw type-id stored in $defs. + /// + static string EncodeForJsonSchemaRef(string typeId) + { + if (string.IsNullOrEmpty(typeId)) + return typeId; + return typeId.Replace("[", "%5B").Replace("]", "%5D") + .Replace("<", "%3C").Replace(">", "%3E") + .Replace("+", "%2B"); + } /// /// Recursively collects all nested non-primitive types from a given type and adds them to the definitions. diff --git a/ReflectorNet/src/Utils/TypeUtils.GetType.cs b/ReflectorNet/src/Utils/TypeUtils.GetType.cs index 84602c44..bfb31c87 100644 --- a/ReflectorNet/src/Utils/TypeUtils.GetType.cs +++ b/ReflectorNet/src/Utils/TypeUtils.GetType.cs @@ -6,16 +6,35 @@ namespace com.IvanMurzak.ReflectorNet.Utils { public static partial class TypeUtils { + /// + /// Decodes percent-encoded type-id chars produced by JSON Schema $ref values + /// (%2B %3C %3E %5B %5D) back to their literal forms (+ < > [ ]). + /// Callers may submit type names in either raw or $ref-encoded form; normalizing here + /// lets every resolution path (cache lookup, Type.GetType, generic/array parsers) see a + /// single canonical form. Other percent sequences are left untouched (C# identifiers + /// never legitimately contain %, but we preserve the input rather than guess). + /// + static string DecodeSchemaRefChars(string typeName) + { + if (typeName.IndexOf('%') < 0) + return typeName; + return typeName.Replace("%5B", "[").Replace("%5D", "]") + .Replace("%3C", "<").Replace("%3E", ">") + .Replace("%2B", "+"); + } + /// /// Retrieves a by its name. /// - /// The name of the type to retrieve. Can be a full name, assembly qualified name, or a custom identifier. + /// The name of the type to retrieve. Can be a full name, assembly qualified name, or a custom identifier. Percent-encoded chars %2B %3C %3E %5B %5D are decoded before resolution so $ref-style input is accepted alongside raw type ids. /// The corresponding to the specified name, or if the type cannot be found. public static Type? GetType(string? typeName) { if (string.IsNullOrWhiteSpace(typeName)) return null; + typeName = DecodeSchemaRefChars(typeName!); + if (_typeCache.TryGetValue(typeName, out var cachedType)) return cachedType; @@ -87,6 +106,8 @@ public static partial class TypeUtils if (string.IsNullOrWhiteSpace(typeName)) return null; + typeName = DecodeSchemaRefChars(typeName!); + if (string.IsNullOrEmpty(assemblyName)) return GetType(typeName); @@ -154,6 +175,8 @@ public static partial class TypeUtils if (string.IsNullOrWhiteSpace(typeName) || assembly == null) return null; + typeName = DecodeSchemaRefChars(typeName!); + var cacheKey = $"{assembly.GetName().Name}|{typeName}"; if (_exactAssemblyTypeCache.TryGetValue(cacheKey, out var cachedType)) return cachedType; diff --git a/ReflectorNet/src/Utils/TypeUtils.Name.cs b/ReflectorNet/src/Utils/TypeUtils.Name.cs index 4c15e3fd..2a6711ab 100644 --- a/ReflectorNet/src/Utils/TypeUtils.Name.cs +++ b/ReflectorNet/src/Utils/TypeUtils.Name.cs @@ -116,20 +116,7 @@ public static string GetTypeId(Type type) } public static string GetSchemaTypeId() => GetSchemaTypeId(typeof(T)); - public static string GetSchemaTypeId(Type type) - { - var typeId = GetTypeId(type); - return SanitizeForJsonSchemaRef(typeId); - } - - private static string SanitizeForJsonSchemaRef(string typeId) - { - if (string.IsNullOrEmpty(typeId)) - return typeId; - return typeId.Replace("[", "%5B").Replace("]", "%5D") - .Replace("<", "%3C").Replace(">", "%3E") - .Replace("+", "%2B"); - } + public static string GetSchemaTypeId(Type type) => GetTypeId(type); public static bool IsNameMatch(Type? type, string? typeName) {