From 5b82b8af8487b849f0e44ead1076a92b6194d530 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 9 Jun 2026 15:52:10 +0300 Subject: [PATCH 1/2] feat: enhance ScribanJsonHelper to support root array input and output with comprehensive unit tests --- .../JsonMapper/ScribanJsonHelper.cs | 40 +- .../ScribanJsonHelperRootArrayTests.cs | 364 ++++++++++++++++++ SW.Bitween.UnitTests/ScribanJsonTestHelper.cs | 3 + 3 files changed, 400 insertions(+), 7 deletions(-) create mode 100644 SW.Bitween.UnitTests/ScribanJsonHelperRootArrayTests.cs diff --git a/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs b/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs index 59dff730..eb369695 100644 --- a/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs +++ b/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs @@ -14,11 +14,37 @@ public static class ScribanJsonHelper /// ` public static string Render(string scribanTemplate, string inputJson) { - // 1. Parse input JSON - var inputObj = JObject.Parse(inputJson); + // 1. Parse input JSON — handle both root object and root array + var rootToken = JToken.Parse(inputJson); // 2. Build top-level ScriptObject from input (recursive) - var scriptObj = BuildScriptObject(inputObj); + ScriptObject scriptObj; + if (rootToken is JArray rootArray) + { + // Expose the array under "items" and also forward member access to the + // first element so templates can write either: + // {{ items[0].OrderId }} or {{ for item in items }} ... {{ end }} + scriptObj = new ScriptObject(); + var smartArray = new SmartArray(rootArray.Select(ToScribanValue)); + scriptObj["items"] = smartArray; + + // If the first element is an object, also hoist its properties to the + // top level so templates that reference fields directly still work. + if (rootArray.Count > 0 && rootArray[0] is JObject firstObj) + { + var firstSo = BuildScriptObject(firstObj); + foreach (var key in firstSo.Keys.ToList()) + scriptObj.TrySetValue(null!, default, key, firstSo[key], false); + } + } + else if (rootToken is JObject rootObj) + { + scriptObj = BuildScriptObject(rootObj); + } + else + { + throw new InvalidOperationException("Input JSON must be a root object or root array."); + } // 3. Register custom functions (| json pipe, | to_float cast) var functions = new ScriptObject(); @@ -50,11 +76,11 @@ public static string Render(string scribanTemplate, string inputJson) // 6. Strip trailing commas that may appear after the last field/element rendered = Regex.Replace(rendered, @",(\s*[}\]])", "$1"); - // 7. Parse rendered output as JToken - JObject flat; + // 7. Parse rendered output — root may be an object OR an array + JToken renderedToken; try { - flat = JObject.Parse(rendered); + renderedToken = JToken.Parse(rendered); } catch (JsonException ex) { @@ -62,7 +88,7 @@ public static string Render(string scribanTemplate, string inputJson) } // 8. Expand dotted keys into nested objects recursively at all depths - return ExpandDottedKeys(flat).ToString(Formatting.Indented); + return ExpandDottedKeys(renderedToken).ToString(Formatting.Indented); } // ─── Helpers ────────────────────────────────────────────────────────────── diff --git a/SW.Bitween.UnitTests/ScribanJsonHelperRootArrayTests.cs b/SW.Bitween.UnitTests/ScribanJsonHelperRootArrayTests.cs new file mode 100644 index 00000000..44c19a4a --- /dev/null +++ b/SW.Bitween.UnitTests/ScribanJsonHelperRootArrayTests.cs @@ -0,0 +1,364 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; +using static SW.Bitween.UnitTests.ScribanJsonTestHelper; + +namespace SW.Bitween.UnitTests; + +/// +/// Tests for root-level array support: +/// A) Root array INPUT → object output +/// B) Root object input → root array OUTPUT +/// C) Root array INPUT → root array OUTPUT +/// +[TestClass] +public class ScribanJsonHelperRootArrayTests +{ + // ─── A. Root array input → object output ───────────────────────────────── + + [TestMethod] + public void RootArrayInput_AccessFirstItemField_DirectReference() + { + // Hoisted top-level keys — direct field access without index + var input = "[{\"OrderId\":1,\"OrderNumber\":\"ORD-001\"}]"; + var template = "{ \"id\": {{ OrderId | json }}, \"ref\": {{ OrderNumber | json }} }"; + + var output = RenderObject(template, input); + + Assert.AreEqual(1L, output["id"]?.Value()); + Assert.AreEqual("ORD-001", output["ref"]?.Value()); + } + + [TestMethod] + public void RootArrayInput_AccessFirstItemField_ViaItemsIndex() + { + var input = "[{\"OrderId\":1}]"; + var template = "{ \"id\": {{ items[0].OrderId | json }} }"; + + var output = RenderObject(template, input); + + Assert.AreEqual(1L, output["id"]?.Value()); + } + + [TestMethod] + public void RootArrayInput_IterateAllItems_ViaItemsLoop() + { + var input = "[{\"OrderId\":1},{\"OrderId\":2},{\"OrderId\":3}]"; + var template = + "{ \"ids\": [" + + "{{ for item in items }}" + + "{{ item.OrderId | json }}," + + "{{ end }}" + + "] }"; + + var output = RenderObject(template, input); + var ids = output["ids"] as JArray; + + Assert.IsNotNull(ids); + Assert.AreEqual(3, ids!.Count); + Assert.AreEqual(1L, ids[0].Value()); + Assert.AreEqual(2L, ids[1].Value()); + Assert.AreEqual(3L, ids[2].Value()); + } + + [TestMethod] + public void RootArrayInput_EmptyArray_ProducesEmptyItems() + { + var input = "[]"; + var template = "{ \"count\": {{ items.size | json }} }"; + + // Should not throw; items should be an empty array + var output = Render(template, input); + + Assert.IsNotNull(output); + } + + [TestMethod] + public void RootArrayInput_PascalCaseAndCamelCaseAliasBothWork() + { + var input = "[{\"OrderId\":99}]"; + var templatePascal = "{ \"a\": {{ OrderId | json }} }"; + var templateCamel = "{ \"a\": {{ orderId | json }} }"; + + var a = RenderObject(templatePascal, input)["a"]?.Value(); + var b = RenderObject(templateCamel, input)["a"]?.Value(); + + Assert.AreEqual(99L, a); + Assert.AreEqual(99L, b); + } + + [TestMethod] + public void RootArrayInput_AllFourModes_SourceFixedPartnerGlobal() + { + var input = + "[{\"OrderId\":7,\"Status\":\"A\"}," + + "{\"OrderId\":8,\"Status\":\"B\"}]"; + + // Embed __partner__ / __globals__ via hoisted fields — simulate injection + var inputWithExtras = + "[{\"OrderId\":7,\"Status\":\"A\",\"__partner__\":{\"key\":\"PK\"},\"__globals__\":{\"S\":{\"K\":\"GV\"}}}]"; + + var template = + "{ " + + "\"fromSource\": {{ OrderId | json }}, " + + "\"fromFixed\": \"FX\", " + + "\"fromPartner\": {{ __partner__?.key | json }}, " + + "\"fromGlobal\": {{ __globals__?.S[\"K\"] | json }} " + + "}"; + + var output = RenderObject(template, inputWithExtras); + + Assert.AreEqual(7L, output["fromSource"]?.Value()); + Assert.AreEqual("FX", output["fromFixed"]?.Value()); + Assert.AreEqual("PK", output["fromPartner"]?.Value()); + Assert.AreEqual("GV", output["fromGlobal"]?.Value()); + } + + [TestMethod] + public void RootArrayInput_LookupOnHoistedField() + { + var input = "[{\"Status\":\"A\"}]"; + var template = "{ \"state\": {{ $__e = { \"A\": \"Active\", \"B\": \"Blocked\" }; $__e[Status] | json }} }"; + + var output = RenderObject(template, input); + + Assert.AreEqual("Active", output["state"]?.Value()); + } + + // ─── B. Root object input → root array output ───────────────────────────── + + [TestMethod] + public void RootObjectInput_TemplateProducesRootArray_IsValidJson() + { + var input = "{\"items\":[{\"id\":1},{\"id\":2}]}"; + var template = + "[" + + "{{ for item in items }}" + + "{ \"id\": {{ item.id | json }} }," + + "{{ end }}" + + "]"; + + var output = RenderArray(template, input); + + Assert.AreEqual(2, output.Count); + Assert.AreEqual(1L, output[0]["id"]?.Value()); + Assert.AreEqual(2L, output[1]["id"]?.Value()); + } + + [TestMethod] + public void RootObjectInput_RootArrayOutput_AllFourModes() + { + var input = + "{\"items\":[{\"sku\":\"A\"},{\"sku\":\"B\"}]," + + "\"__partner__\":{\"env\":\"prod\"}," + + "\"__globals__\":{\"S\":{\"K\":\"gval\"}}}"; + + var template = + "[" + + "{{ for item in items }}" + + "{ " + + "\"sku\": {{ item.sku | json }}, " + + "\"fixed\": \"FX\", " + + "\"partner\": {{ __partner__?.env | json }}, " + + "\"global\": {{ __globals__?.S[\"K\"] | json }} " + + "}," + + "{{ end }}" + + "]"; + + var output = RenderArray(template, input); + + Assert.AreEqual(2, output.Count); + Assert.AreEqual("A", output[0]["sku"]?.Value()); + Assert.AreEqual("FX", output[0]["fixed"]?.Value()); + Assert.AreEqual("prod", output[0]["partner"]?.Value()); + Assert.AreEqual("gval", output[0]["global"]?.Value()); + Assert.AreEqual("B", output[1]["sku"]?.Value()); + } + + [TestMethod] + public void RootObjectInput_RootArrayOutput_LookupOnItemField() + { + var input = "{\"items\":[{\"code\":\"A\"},{\"code\":\"B\"},{\"code\":\"Z\"}]}"; + var template = + "[" + + "{{ for item in items }}" + + "{ \"label\": {{ $__e = { \"A\": \"Alpha\", \"B\": \"Beta\" }; ($__e[item.code] ?? \"Unknown\") | json }} }," + + "{{ end }}" + + "]"; + + var output = RenderArray(template, input); + + Assert.AreEqual("Alpha", output[0]["label"]?.Value()); + Assert.AreEqual("Beta", output[1]["label"]?.Value()); + Assert.AreEqual("Unknown", output[2]["label"]?.Value()); + } + + [TestMethod] + public void RootObjectInput_RootPrimitiveArray_FromSourceField() + { + var input = "{\"tags\":[\"x\",\"y\",\"z\"]}"; + var template = + "[" + + "{{ for t in tags }}" + + "{{ t | json }}," + + "{{ end }}" + + "]"; + + var output = RenderArray(template, input); + + Assert.AreEqual(3, output.Count); + Assert.AreEqual("x", output[0].Value()); + Assert.AreEqual("y", output[1].Value()); + Assert.AreEqual("z", output[2].Value()); + } + + [TestMethod] + public void RootObjectInput_RootArrayOutput_EmptySourceArray_ProducesEmptyArray() + { + var input = "{\"items\":[]}"; + var template = + "[" + + "{{ for item in items }}" + + "{ \"id\": {{ item.id | json }} }," + + "{{ end }}" + + "]"; + + var output = RenderArray(template, input); + + Assert.AreEqual(0, output.Count); + } + + [TestMethod] + public void RootObjectInput_RootArrayOutput_WithFilter() + { + var input = "{\"items\":[{\"id\":1,\"active\":true},{\"id\":2,\"active\":false}]}"; + var template = + "[" + + "{{ for item in items }}" + + "{{ if item.active == true }}" + + "{ \"id\": {{ item.id | json }} }," + + "{{ end }}" + + "{{ end }}" + + "]"; + + var output = RenderArray(template, input); + + Assert.AreEqual(1, output.Count); + Assert.AreEqual(1L, output[0]["id"]?.Value()); + } + + // ─── C. Root array input → root array output ────────────────────────────── + + [TestMethod] + public void RootArrayInput_RootArrayOutput_IteratesItemsVariable() + { + var input = "[{\"OrderId\":1,\"Status\":\"A\"},{\"OrderId\":2,\"Status\":\"B\"}]"; + var template = + "[" + + "{{ for item in items }}" + + "{ \"id\": {{ item.OrderId | json }}, \"status\": {{ item.Status | json }} }," + + "{{ end }}" + + "]"; + + var output = RenderArray(template, input); + + Assert.AreEqual(2, output.Count); + Assert.AreEqual(1L, output[0]["id"]?.Value()); + Assert.AreEqual("A", output[0]["status"]?.Value()); + Assert.AreEqual(2L, output[1]["id"]?.Value()); + Assert.AreEqual("B", output[1]["status"]?.Value()); + } + + [TestMethod] + public void RootArrayInput_RootArrayOutput_AllFourModes() + { + var input = + "[{" + + "\"OrderId\":5,\"Status\":\"A\"," + + "\"__partner__\":{\"env\":\"prod\"}," + + "\"__globals__\":{\"S\":{\"K\":\"gval\"}}" + + "}]"; + + var template = + "[" + + "{{ for item in items }}" + + "{ " + + "\"id\": {{ item.OrderId | json }}, " + + "\"fixed\": \"FX\", " + + "\"partner\": {{ __partner__?.env | json }}, " + + "\"global\": {{ __globals__?.S[\"K\"] | json }} " + + "}," + + "{{ end }}" + + "]"; + + var output = RenderArray(template, input); + + Assert.AreEqual(1, output.Count); + Assert.AreEqual(5L, output[0]["id"]?.Value()); + Assert.AreEqual("FX", output[0]["fixed"]?.Value()); + Assert.AreEqual("prod", output[0]["partner"]?.Value()); + Assert.AreEqual("gval", output[0]["global"]?.Value()); + } + + [TestMethod] + public void RootArrayInput_RootArrayOutput_LookupOnItemField() + { + var input = "[{\"Status\":\"A\"},{\"Status\":\"B\"},{\"Status\":\"Z\"}]"; + var template = + "[" + + "{{ for item in items }}" + + "{ \"label\": {{ $__e = { \"A\": \"Active\", \"B\": \"Blocked\" }; ($__e[item.Status] ?? \"Unknown\") | json }} }," + + "{{ end }}" + + "]"; + + var output = RenderArray(template, input); + + Assert.AreEqual(3, output.Count); + Assert.AreEqual("Active", output[0]["label"]?.Value()); + Assert.AreEqual("Blocked", output[1]["label"]?.Value()); + Assert.AreEqual("Unknown", output[2]["label"]?.Value()); + } + + [TestMethod] + public void RootArrayInput_RootPrimitiveArray_ExtractsSingleFieldFromEachItem() + { + var input = "[{\"OrderId\":10},{\"OrderId\":20},{\"OrderId\":30}]"; + var template = + "[" + + "{{ for item in items }}" + + "{{ item.OrderId | json }}," + + "{{ end }}" + + "]"; + + var output = RenderArray(template, input); + + Assert.AreEqual(3, output.Count); + Assert.AreEqual(10L, output[0].Value()); + Assert.AreEqual(20L, output[1].Value()); + Assert.AreEqual(30L, output[2].Value()); + } + + [TestMethod] + public void RootArrayInput_RootArrayOutput_NestedArrayInsideItems() + { + var input = "[{\"OrderId\":1,\"OrderItems\":[{\"SKU\":\"A\"},{\"SKU\":\"B\"}]}]"; + var template = + "[" + + "{{ for order in items }}" + + "{ \"orderId\": {{ order.OrderId | json }}, \"skus\": [" + + "{{ for li in order.OrderItems }}" + + "{{ li.SKU | json }}," + + "{{ end }}" + + "] }," + + "{{ end }}" + + "]"; + + var output = RenderArray(template, input); + + Assert.AreEqual(1, output.Count); + Assert.AreEqual(1L, output[0]["orderId"]?.Value()); + var skus = output[0]["skus"] as JArray; + Assert.IsNotNull(skus); + Assert.AreEqual("A", skus![0].Value()); + Assert.AreEqual("B", skus![1].Value()); + } +} diff --git a/SW.Bitween.UnitTests/ScribanJsonTestHelper.cs b/SW.Bitween.UnitTests/ScribanJsonTestHelper.cs index d67dff5f..bab7c43b 100644 --- a/SW.Bitween.UnitTests/ScribanJsonTestHelper.cs +++ b/SW.Bitween.UnitTests/ScribanJsonTestHelper.cs @@ -13,6 +13,9 @@ public static string Render(string template, string inputJson) => public static JObject RenderObject(string template, string inputJson) => JObject.Parse(Render(template, inputJson)); + public static JArray RenderArray(string template, string inputJson) => + JArray.Parse(Render(template, inputJson)); + public static JToken RenderValue(string valueExpression, string inputJson) { var template = "{ \"value\": " + valueExpression + " }"; From 7e5bcbb8b5b16740839a02baf54dd1ac22a7d3fd Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 9 Jun 2026 16:41:06 +0300 Subject: [PATCH 2/2] feat: enhance Preview class to support JSON array input and output with partner and global properties --- SW.Bitween.Api/Resources/Mappers/Preview.cs | 50 ++++++++++++--------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/SW.Bitween.Api/Resources/Mappers/Preview.cs b/SW.Bitween.Api/Resources/Mappers/Preview.cs index e228d8cf..84dce1ae 100644 --- a/SW.Bitween.Api/Resources/Mappers/Preview.cs +++ b/SW.Bitween.Api/Resources/Mappers/Preview.cs @@ -47,32 +47,40 @@ public async Task Handle(MapperPreviewRequest request) { var inputJson = request.InputJson; - JObject? jObj = null; - if (JToken.Parse(inputJson) is JObject parsedObj) - jObj = parsedObj; + var parsed = JToken.Parse(inputJson); + var jObj = parsed as JObject; + var jArr = parsed as JArray; - if (jObj != null) - { - var enriched = false; + JObject? partnerObj = partner?.AdapterProperties?.Count > 0 + ? JObject.FromObject(partner.AdapterProperties) + : null; - if (partner?.AdapterProperties?.Count > 0) - { - jObj["__partner__"] = JObject.FromObject(partner.AdapterProperties); - enriched = true; - } + JObject? globalsObj = null; + var nonEmptySets = globalSets.Where(s => s.Values?.Count > 0).ToList(); + if (nonEmptySets.Count > 0) + { + globalsObj = new JObject(); + foreach (var set in nonEmptySets) + globalsObj[set.Id] = JObject.FromObject(set.Values); + } - var nonEmptySets = globalSets.Where(s => s.Values?.Count > 0).ToList(); - if (nonEmptySets.Count > 0) + if (jObj != null && (partnerObj != null || globalsObj != null)) + { + if (partnerObj != null) jObj["__partner__"] = partnerObj; + if (globalsObj != null) jObj["__globals__"] = globalsObj; + inputJson = jObj.ToString(Formatting.None); + } + else if (jArr != null && (partnerObj != null || globalsObj != null)) + { + foreach (var token in jArr) { - var globalsObj = new JObject(); - foreach (var set in nonEmptySets) - globalsObj[set.Id] = JObject.FromObject(set.Values); - jObj["__globals__"] = globalsObj; - enriched = true; + if (token is JObject elem) + { + if (partnerObj != null) elem["__partner__"] = partnerObj; + if (globalsObj != null) elem["__globals__"] = globalsObj; + } } - - if (enriched) - inputJson = jObj.ToString(Formatting.None); + inputJson = jArr.ToString(Formatting.None); } var output = ScribanJsonHelper.Render(request.ScribanTemplate, inputJson);