From 8b864595fdffe250ea9cd8f2261b548c6c95fefe Mon Sep 17 00:00:00 2001 From: hamzaalqurneh Date: Thu, 2 Apr 2026 11:52:22 +0300 Subject: [PATCH 1/2] Add new JSON mapping functionality and remove deprecated classes --- SW.Bitween.Api/Resources/Mappers/Preview.cs | 44 +++++++ .../JsonFieldMapper/JsonFieldMapperInput.cs | 9 -- .../JsonFieldMapper/NativeJsonFieldMapper.cs | 119 ------------------ .../JsonMapper/JsonMapperInput.cs | 9 ++ .../JsonMapper/NativeJSONMapper.cs | 26 ++++ .../JsonMapper/ScribanJsonHelper.cs | 116 +++++++++++++++++ .../SW.Bitween.NativeAdapters.csproj | 1 + .../ServiceCollectionExtensions.cs | 4 +- 8 files changed, 198 insertions(+), 130 deletions(-) create mode 100644 SW.Bitween.Api/Resources/Mappers/Preview.cs delete mode 100644 SW.Bitween.NativeAdapters/JsonFieldMapper/JsonFieldMapperInput.cs delete mode 100644 SW.Bitween.NativeAdapters/JsonFieldMapper/NativeJsonFieldMapper.cs create mode 100644 SW.Bitween.NativeAdapters/JsonMapper/JsonMapperInput.cs create mode 100644 SW.Bitween.NativeAdapters/JsonMapper/NativeJSONMapper.cs create mode 100644 SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs diff --git a/SW.Bitween.Api/Resources/Mappers/Preview.cs b/SW.Bitween.Api/Resources/Mappers/Preview.cs new file mode 100644 index 00000000..9f258ecc --- /dev/null +++ b/SW.Bitween.Api/Resources/Mappers/Preview.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading.Tasks; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.NativeAdapters.JsonMapper; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Mappers; + +public class MapperPreviewRequest +{ + public string ScribanTemplate { get; set; } = "{}"; + public string InputJson { get; set; } = "{}"; +} + +public class MapperPreviewResponse +{ + public string? OutputJson { get; set; } + public string? Error { get; set; } +} + +public class Preview : ICommandHandler +{ + private readonly RequestContext _requestContext; + + public Preview(RequestContext requestContext) + { + _requestContext = requestContext; + } + + public Task Handle(MapperPreviewRequest request) + { + _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + + try + { + var output = ScribanJsonHelper.Render(request.ScribanTemplate, request.InputJson); + return Task.FromResult(new MapperPreviewResponse { OutputJson = output }); + } + catch (Exception ex) + { + return Task.FromResult(new MapperPreviewResponse { Error = ex.Message }); + } + } +} diff --git a/SW.Bitween.NativeAdapters/JsonFieldMapper/JsonFieldMapperInput.cs b/SW.Bitween.NativeAdapters/JsonFieldMapper/JsonFieldMapperInput.cs deleted file mode 100644 index e1caa5f1..00000000 --- a/SW.Bitween.NativeAdapters/JsonFieldMapper/JsonFieldMapperInput.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace SW.Bitween.NativeAdapters; - -public class JsonFieldMapperInput -{ - [Required] - public string Rules { get; set; } = "[]"; -} diff --git a/SW.Bitween.NativeAdapters/JsonFieldMapper/NativeJsonFieldMapper.cs b/SW.Bitween.NativeAdapters/JsonFieldMapper/NativeJsonFieldMapper.cs deleted file mode 100644 index d3a577c3..00000000 --- a/SW.Bitween.NativeAdapters/JsonFieldMapper/NativeJsonFieldMapper.cs +++ /dev/null @@ -1,119 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using SW.PrimitiveTypes; - -namespace SW.Bitween.NativeAdapters; - -public class NativeJsonFieldMapper : INativeInfolinkHandler -{ - public string Name => "NativeJsonFieldMapper"; - public Type StartupValuesType => typeof(JsonFieldMapperInput); - - private JsonFieldMapperInput _options = new(); - - public void InitializeStartupValues(IDictionary settings) - { - _options = new JsonFieldMapperInput - { - Rules = settings.TryGetValue("Rules", out var r) ? r : "[]" - }; - } - - public Task Handle(XchangeFile xchangeFile) - { - var source = JObject.Parse(xchangeFile.Data); - var rules = JsonConvert.DeserializeObject>(_options.Rules) ?? new List(); - var result = new JObject(); - - var validRules = rules.Where(r => !string.IsNullOrWhiteSpace(r.OutputField)).ToList(); - - // Split into scalar (no [*]) and array (contain [*]) - var scalarRules = validRules - .Where(r => !r.OutputField.Contains("[*]") && (string.IsNullOrWhiteSpace(r.SourcePath) || !r.SourcePath.Contains("[*]"))) - .ToList(); - var arrayRules = validRules - .Where(r => r.OutputField.Contains("[*]") || (!string.IsNullOrWhiteSpace(r.SourcePath) && r.SourcePath.Contains("[*]"))) - .ToList(); - - // Process scalars in declaration order — preserves field position and handles both fixed and source-mapped - foreach (var rule in scalarRules) - { - if (rule.FixedValue != null && string.IsNullOrWhiteSpace(rule.SourcePath)) - SetByPath(result, rule.OutputField, new JValue(rule.FixedValue)); - else if (!string.IsNullOrWhiteSpace(rule.SourcePath)) - SetByPath(result, rule.OutputField, GetByPath(source, rule.SourcePath)); - } - - var sourceMappedArrayRules = arrayRules.Where(r => !string.IsNullOrWhiteSpace(r.SourcePath)).ToList(); - var fixedArrayRules = arrayRules.Where(r => string.IsNullOrWhiteSpace(r.SourcePath) && r.FixedValue != null).ToList(); - - var arrayGroups = sourceMappedArrayRules.GroupBy(r => ( - SourcePrefix: r.SourcePath.Split(new[] { "[*]" }, StringSplitOptions.None)[0].TrimEnd('.'), - OutputPrefix: r.OutputField.Split(new[] { "[*]" }, StringSplitOptions.None)[0].TrimEnd('.') - )); - - foreach (var group in arrayGroups) - { - if (GetByPath(source, group.Key.SourcePrefix) is not JArray sourceArray) continue; - - var groupFixed = fixedArrayRules - .Where(r => r.OutputField.Split(new[] { "[*]" }, StringSplitOptions.None)[0].TrimEnd('.') == group.Key.OutputPrefix) - .ToList(); - - var outputArray = new JArray(); - foreach (var item in sourceArray) - { - var outputItem = new JObject(); - foreach (var fixedRule in groupFixed) - { - var outParts = fixedRule.OutputField.Split(new[] { "[*]." }, StringSplitOptions.None); - if (outParts.Length >= 2) - SetByPath(outputItem, outParts[1], new JValue(fixedRule.FixedValue)); - } - foreach (var rule in group) - { - var srcParts = rule.SourcePath.Split(new[] { "[*]." }, StringSplitOptions.None); - var outParts = rule.OutputField.Split(new[] { "[*]." }, StringSplitOptions.None); - if (srcParts.Length < 2 || outParts.Length < 2) continue; - SetByPath(outputItem, outParts[1], GetByPath(item, srcParts[1])); - } - outputArray.Add(outputItem); - } - SetByPath(result, group.Key.OutputPrefix, outputArray); - } - - return Task.FromResult(new XchangeFile(result.ToString(Formatting.None), xchangeFile.Filename)); - } - - private static JToken? GetByPath(JToken token, string path) - { - var parts = path.Split('.'); - JToken? current = token; - foreach (var part in parts) - { - if (current is JObject jObj) - current = jObj[part]; - else - return null; - } - return current; - } - - private static void SetByPath(JObject obj, string path, JToken? value) - { - var parts = path.Split('.'); - var current = obj; - for (var i = 0; i < parts.Length - 1; i++) - { - if (current[parts[i]] is not JObject nested) - { - nested = new JObject(); - current[parts[i]] = nested; - } - current = nested; - } - current[parts[^1]] = value ?? JValue.CreateNull(); - } -} - -public record MappingRule(string OutputField, string SourcePath, string? FixedValue = null); diff --git a/SW.Bitween.NativeAdapters/JsonMapper/JsonMapperInput.cs b/SW.Bitween.NativeAdapters/JsonMapper/JsonMapperInput.cs new file mode 100644 index 00000000..db9f1644 --- /dev/null +++ b/SW.Bitween.NativeAdapters/JsonMapper/JsonMapperInput.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace SW.Bitween.NativeAdapters.JsonMapper; + +public class JsonMapperInput +{ + [Required] + public string ScribanTemplate { get; set; } = "{}"; +} diff --git a/SW.Bitween.NativeAdapters/JsonMapper/NativeJSONMapper.cs b/SW.Bitween.NativeAdapters/JsonMapper/NativeJSONMapper.cs new file mode 100644 index 00000000..f87b073d --- /dev/null +++ b/SW.Bitween.NativeAdapters/JsonMapper/NativeJSONMapper.cs @@ -0,0 +1,26 @@ +using SW.Bitween.NativeAdapters.JsonMapper; +using SW.PrimitiveTypes; + +namespace SW.Bitween.NativeAdapters; + +public class NativeJSONMapper : INativeInfolinkHandler +{ + public string Name => "NativeJSONMapper"; + public Type StartupValuesType => typeof(JsonMapperInput); + + private JsonMapperInput _options = new(); + + public void InitializeStartupValues(IDictionary settings) + { + _options = new JsonMapperInput + { + ScribanTemplate = settings.TryGetValue("ScribanTemplate", out var t) ? t : "{}" + }; + } + + public Task Handle(XchangeFile xchangeFile) + { + var outputJson = ScribanJsonHelper.Render(_options.ScribanTemplate, xchangeFile.Data); + return Task.FromResult(new XchangeFile(outputJson)); + } +} diff --git a/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs b/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs new file mode 100644 index 00000000..01cdfc98 --- /dev/null +++ b/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs @@ -0,0 +1,116 @@ +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Scriban; +using Scriban.Runtime; + +namespace SW.Bitween.NativeAdapters.JsonMapper; + +public static class ScribanJsonHelper +{ + /// + /// Renders a Scriban template against the provided input JSON and returns the mapped output JSON. + /// + public static string Render(string scribanTemplate, string inputJson) + { + // 1. Parse input JSON + var inputObj = JObject.Parse(inputJson); + + // 2. Build top-level ScriptObject from input (recursive) + var scriptObj = BuildScriptObject(inputObj); + + // 3. Register custom functions (| json pipe) + var functions = new ScriptObject(); + functions.Import("json", new Func(JsonFilter)); + + // 4. Create template context + var context = new TemplateContext { StrictVariables = false }; + context.PushGlobal(scriptObj); + context.PushGlobal(functions); + + // 5. Parse and render Scriban template + var template = Template.Parse(scribanTemplate); + if (template.HasErrors) + { + var errors = string.Join("; ", template.Messages.Select(m => m.Message)); + throw new InvalidOperationException($"Template parse error: {errors}"); + } + + var rendered = template.Render(context); + + // 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; + try + { + flat = JObject.Parse(rendered); + } + catch (JsonException ex) + { + throw new InvalidOperationException($"Template produced invalid JSON: {ex.Message}\n\nRendered:\n{rendered}"); + } + + // 8. Expand dotted keys into nested objects (e.g. "buyer.email" → {buyer:{email:...}}) + var result = new JObject(); + foreach (var prop in flat.Properties()) + { + SetByPath(result, prop.Name, prop.Value); + } + + return result.ToString(Formatting.Indented); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + /// Custom | json Scriban pipe — serializes any value to its JSON representation. + private static string JsonFilter(object? value) + { + return value switch + { + null => "null", + bool b => b ? "true" : "false", + int or long or float or double or decimal => Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? "null", + string s => JsonConvert.SerializeObject(s), + _ => JsonConvert.SerializeObject(value) + }; + } + + /// Recursively converts a JObject into a Scriban ScriptObject. + private static ScriptObject BuildScriptObject(JObject obj) + { + var so = new ScriptObject(); + foreach (var prop in obj.Properties()) + { + so[prop.Name] = ToScribanValue(prop.Value); + } + return so; + } + + private static object? ToScribanValue(JToken token) => token switch + { + JObject o => BuildScriptObject(o), + JArray a => a.Select(ToScribanValue).ToList(), + JValue v => v.Value, + _ => null + }; + + /// 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) + { + var parts = path.Split('.'); + JObject current = root; + for (int i = 0; i < parts.Length - 1; i++) + { + var part = parts[i]; + if (current[part] is not JObject child) + { + child = new JObject(); + current[part] = child; + } + current = child; + } + current[parts[^1]] = value; + } +} diff --git a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj index f3186c66..575dd240 100644 --- a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj +++ b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj @@ -12,6 +12,7 @@ + diff --git a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs index a342023b..a4250185 100644 --- a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs +++ b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs @@ -25,8 +25,8 @@ public static void AddNativeAdapters(this IServiceCollection serviceCollection) serviceCollection.AddScoped(); serviceCollection.AddScoped(); - serviceCollection.AddScoped(); - serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); From 589e1e8910d610e0b55b141ffefc8a561f6ba61b Mon Sep 17 00:00:00 2001 From: hamzaalqurneh Date: Sun, 5 Apr 2026 14:17:13 +0300 Subject: [PATCH 2/2] Add SaveMapper class for handling subscription save operations --- .../Resources/Subscriptions/SaveMapper.cs | 82 +++++++++++++++++++ SW.Bitween.Sdk/Model/Subscription.cs | 12 ++- 2 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 SW.Bitween.Api/Resources/Subscriptions/SaveMapper.cs diff --git a/SW.Bitween.Api/Resources/Subscriptions/SaveMapper.cs b/SW.Bitween.Api/Resources/Subscriptions/SaveMapper.cs new file mode 100644 index 00000000..9b043a41 --- /dev/null +++ b/SW.Bitween.Api/Resources/Subscriptions/SaveMapper.cs @@ -0,0 +1,82 @@ +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using SW.EfCoreExtensions; +using SW.Bitween.Domain; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using System; +using System.Linq; +using System.Threading.Tasks; +using SW.Bitween.Domain.Accounts; + +namespace SW.Bitween.Resources.Subscriptions +{ + [HandlerName("savemapper")] + public class SaveMapper : ICommandHandler + { + private readonly BitweenDbContext _dbContext; + private readonly IInfolinkCache _BitweenCache; + private readonly RequestContext _requestContext; + + public SaveMapper(BitweenDbContext dbContext, IInfolinkCache BitweenCache, RequestContext requestContext) + { + _dbContext = dbContext; + _BitweenCache = BitweenCache; + _requestContext = requestContext; + } + + public async Task Handle(int key, SubscriptionSaveMapper model) + { + _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + var entity = await _dbContext.FindAsync(key); + + entity.MapperId = model.MapperId; + entity.SetDictionaries( + entity.HandlerProperties, + model.MapperProperties.ToDictionary(), + entity.ReceiverProperties, + entity.DocumentFilter, + entity.ValidatorProperties + ); + + await _dbContext.SaveChangesAsync(); + _BitweenCache.BroadcastRevoke(); + return null; + } + + private class Validate : AbstractValidator + { + public Validate(NativeAdapterDiscoveryService nativeAdapterDiscovery, IServiceProvider serviceProvider) + { + RuleFor(i => i.MapperId).NotEmpty(); + + When(i => i.MapperId != null, () => + { + RuleFor(i => i.MapperProperties).CustomAsync(async (i, context, _) => + { + var mapperId = ((SubscriptionSaveMapper)context.InstanceToValidate).MapperId; + var mustProps = Enumerable.Empty(); + + if (mapperId.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase)) + { + var properties = nativeAdapterDiscovery.GetExpectedStartupValues(mapperId); + mustProps = properties.Where(p => p.Value.EndsWith(" *")).Select(p => p.Key); + } + else + { + var serverless = serviceProvider.GetRequiredService(); + await serverless.StartAsync(mapperId, null); + mustProps = (await serverless.GetExpectedStartupValues()) + .Where(p => p.Value.Optional == false).Select(p => p.Key); + } + + var missing = mustProps.ToHashSet(StringComparer.OrdinalIgnoreCase) + .Except(i.Where(p => !string.IsNullOrEmpty(p.Value)).Select(p => p.Key)); + if (missing.Any()) + context.AddFailure($"Missing: {string.Join(",", missing)}"); + }); + }); + } + } + } +} diff --git a/SW.Bitween.Sdk/Model/Subscription.cs b/SW.Bitween.Sdk/Model/Subscription.cs index fa9ca9e5..e7e5bf98 100644 --- a/SW.Bitween.Sdk/Model/Subscription.cs +++ b/SW.Bitween.Sdk/Model/Subscription.cs @@ -26,6 +26,12 @@ public class SubscriptionAggregateNow { } + public class SubscriptionSaveMapper + { + public string MapperId { get; set; } + public ICollection MapperProperties { get; set; } + } + public class SubscriptionTrailModel : TrailBaseModel { public int SubscriptionId { get; set; } @@ -45,8 +51,8 @@ public abstract class SubscriptionCreateUpdateBase : IName public int? PartnerId { get; set; } public int? AggregationForId { get; set; } } - - public class SubscriptionCreate :SubscriptionCreateUpdateBase + + public class SubscriptionCreate : SubscriptionCreateUpdateBase { public SubscriptionType Type { get; set; } } @@ -91,7 +97,7 @@ public class SubscriptionUpdate : SubscriptionCreateUpdateBase public string CategoryCode { get; set; } public string CategoryDescription { get; set; } } - + public class SubscriptionGet : SubscriptionUpdate { public SubscriptionType Type { get; set; }