From ab7a63484d8f717823b0080b836631c4a8a95f56 Mon Sep 17 00:00:00 2001 From: hamzaalqurneh Date: Sun, 10 May 2026 15:21:14 +0300 Subject: [PATCH 1/2] feat: validate promoted properties and enhance Scriban array access --- SW.Bitween.Api/Resources/Documents/Update.cs | 39 +++++++++++++++++++ .../JsonMapper/ScribanJsonHelper.cs | 25 +++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/SW.Bitween.Api/Resources/Documents/Update.cs b/SW.Bitween.Api/Resources/Documents/Update.cs index e201b46b..c4099f44 100644 --- a/SW.Bitween.Api/Resources/Documents/Update.cs +++ b/SW.Bitween.Api/Resources/Documents/Update.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain.Accounts; +using System.Text.RegularExpressions; namespace SW.Bitween.Resources.Documents { @@ -43,6 +44,44 @@ public async Task Handle(int key, DocumentUpdate model) throw new SWValidationException("DUPLICATED_BUS_TYPE_NAME", "Cant use duplicated bus Message type name"); + if (model.PromotedProperties != null) + { + foreach (var pp in model.PromotedProperties) + { + if (string.IsNullOrWhiteSpace(pp.Key)) + throw new SWValidationException("INVALID_PROMOTED_PROPERTY_KEY", + "Promoted property key cannot be null or empty."); + + if (string.IsNullOrWhiteSpace(pp.Value)) + throw new SWValidationException("INVALID_PROMOTED_PROPERTY_VALUE", + $"Promoted property '{pp.Key}' must have a non-empty path value."); + + if (model.DocumentFormat == DocumentFormat.Json) + { + // Must be a JSONPath: starts with '$' or a simple dot-separated identifier path + var trimmed = pp.Value.Trim(); + if (!trimmed.StartsWith("$") && !Regex.IsMatch(trimmed, @"^[a-zA-Z_][a-zA-Z0-9_.]*$")) + throw new SWValidationException("INVALID_PROMOTED_PROPERTY_PATH", + $"Promoted property '{pp.Key}' has an invalid JSON path: '{pp.Value}'. Expected a JSONPath expression (e.g. '$.field.subField') or dot-notation path."); + } + else if (model.DocumentFormat == DocumentFormat.Xml) + { + // Basic XPath sanity: must start with '/' or '//' or be a valid element path + var trimmed = pp.Value.Trim(); + if (!trimmed.StartsWith("/") && !Regex.IsMatch(trimmed, @"^[a-zA-Z_][a-zA-Z0-9_/\[\]@.:*-]*$")) + throw new SWValidationException("INVALID_PROMOTED_PROPERTY_PATH", + $"Promoted property '{pp.Key}' has an invalid XML path: '{pp.Value}'. Expected an XPath expression (e.g. '/root/element')."); + } + } + + var duplicateKey = model.PromotedProperties + .GroupBy(pp => pp.Key, System.StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(g => g.Count() > 1)?.Key; + + if (duplicateKey != null) + throw new SWValidationException("DUPLICATE_PROMOTED_PROPERTY_KEY", + $"Promoted property key '{duplicateKey}' appears more than once."); + } var trail = new DocumentTrail(DocumentTrailCode.Updated, entity); entity.SetDictionaries(model.PromotedProperties.ToDictionary()); diff --git a/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs b/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs index 17543a18..ccc47e35 100644 --- a/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs +++ b/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs @@ -2,6 +2,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Scriban; +using Scriban.Parsing; using Scriban.Runtime; namespace SW.Bitween.NativeAdapters.JsonMapper; @@ -102,11 +103,33 @@ private static ScriptObject BuildScriptObject(JObject obj) private static object? ToScribanValue(JToken token) => token switch { JObject o => BuildScriptObject(o), - JArray a => a.Select(ToScribanValue).ToList(), + JArray a => new SmartArray(a.Select(ToScribanValue)), JValue v => v.Value, _ => null }; + /// + /// A Scriban array that also delegates member access to its first element, + /// so templates can write either data[0].field or data.field + /// when the source JSON value is a single-element (or first-item) array. + /// + private sealed class SmartArray : ScriptArray + { + public SmartArray(IEnumerable items) : base(items) { } + + public override bool TryGetValue(TemplateContext context, SourceSpan span, string member, out object? value) + { + if (base.TryGetValue(context, span, member, out value)) + return true; + + if (Count > 0 && this[0] is ScriptObject first) + return first.TryGetValue(context, span, member, out value); + + value = null; + return false; + } + } + /// Sets a value at a dot-separated path inside a JObject, creating intermediate objects as needed. private static void SetByPath(JObject root, string path, JToken value) { From 5f7d18bbea7d8aea8db8f64239742b46e7db56cf Mon Sep 17 00:00:00 2001 From: hamzaalqurneh Date: Sun, 10 May 2026 15:36:27 +0300 Subject: [PATCH 2/2] fix: correct JSON path validation for promoted properties --- SW.Bitween.Api/Resources/Documents/Update.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SW.Bitween.Api/Resources/Documents/Update.cs b/SW.Bitween.Api/Resources/Documents/Update.cs index c4099f44..c899d93c 100644 --- a/SW.Bitween.Api/Resources/Documents/Update.cs +++ b/SW.Bitween.Api/Resources/Documents/Update.cs @@ -10,7 +10,7 @@ namespace SW.Bitween.Resources.Documents { - public class Update : ICommandHandler + public class Update : ICommandHandler { private readonly BitweenDbContext _dbContext; private readonly IInfolinkCache _BitweenCache; @@ -60,7 +60,7 @@ public async Task Handle(int key, DocumentUpdate model) { // Must be a JSONPath: starts with '$' or a simple dot-separated identifier path var trimmed = pp.Value.Trim(); - if (!trimmed.StartsWith("$") && !Regex.IsMatch(trimmed, @"^[a-zA-Z_][a-zA-Z0-9_.]*$")) + if (!trimmed.StartsWith("$") && !Regex.IsMatch(trimmed, @"^[a-zA-Z_][a-zA-Z0-9_]*(?:(\.[a-zA-Z_][a-zA-Z0-9_]*)|(\[[0-9]+\]))*$")) throw new SWValidationException("INVALID_PROMOTED_PROPERTY_PATH", $"Promoted property '{pp.Key}' has an invalid JSON path: '{pp.Value}'. Expected a JSONPath expression (e.g. '$.field.subField') or dot-notation path."); }