diff --git a/SW.Bitween.Api/Resources/Adapters/GetProperties.cs b/SW.Bitween.Api/Resources/Adapters/GetProperties.cs index 4974470e..67bb6312 100644 --- a/SW.Bitween.Api/Resources/Adapters/GetProperties.cs +++ b/SW.Bitween.Api/Resources/Adapters/GetProperties.cs @@ -12,15 +12,26 @@ namespace SW.Bitween.Resources.Adapters public class GetProperties : IGetHandler { private readonly IServerlessService serverless; + private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; - public GetProperties(IServerlessService serverless) + public GetProperties(IServerlessService serverless, NativeAdapterDiscoveryService nativeAdapterDiscovery) { this.serverless = serverless; + _nativeAdapterDiscovery = nativeAdapterDiscovery; } async public Task Handle(string key) { - await serverless.StartAsync( Uri.UnescapeDataString(key), null); + var decodedKey = Uri.UnescapeDataString(key); + + // Check if it's a native adapter + if (decodedKey.StartsWith("native.", StringComparison.OrdinalIgnoreCase)) + { + return _nativeAdapterDiscovery.GetNativeAdapterProperties(decodedKey); + } + + // Handle serverless adapters + await serverless.StartAsync(decodedKey, null); var expected = await serverless.GetExpectedStartupValues(); return expected.ToList().ToDictionary(k => k.Key, v => $"{v.Key} {(v.Value.Optional ? $" ({v.Value.Default ?? "null"})" : " *")}"); } diff --git a/SW.Bitween.Api/Resources/Adapters/Search.cs b/SW.Bitween.Api/Resources/Adapters/Search.cs index 108afbe9..ee84d76a 100644 --- a/SW.Bitween.Api/Resources/Adapters/Search.cs +++ b/SW.Bitween.Api/Resources/Adapters/Search.cs @@ -10,17 +10,23 @@ public class Search : IQueryHandler { private readonly ServerlessOptions _serverlessOptions; private readonly ICloudFilesService _cloudFilesService; + private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; - public Search(ServerlessOptions serverlessOptions, ICloudFilesService cloudFilesService) + public Search(ServerlessOptions serverlessOptions, ICloudFilesService cloudFilesService, + NativeAdapterDiscoveryService nativeAdapterDiscovery) { _serverlessOptions = serverlessOptions; _cloudFilesService = cloudFilesService; + _nativeAdapterDiscovery = nativeAdapterDiscovery; } public async Task Handle(AdapterSearchRequest request) { + // Get native adapters first + var nativeAdapters = _nativeAdapterDiscovery.GetNativeAdapters(request.Prefix).ToList(); + // Get external adapters from storage var cloudFilesList = (await _cloudFilesService.ListAsync( $"{_serverlessOptions.AdapterRemotePath}/infolink6.{request.Prefix}")) @@ -33,10 +39,13 @@ public async Task Handle(AdapterSearchRequest request) return key; }) + .Distinct() .ToList(); + // Combine native (first) and external adapters + var allAdapters = nativeAdapters.Concat(cloudFilesList); - return cloudFilesList.Distinct().ToDictionary(k => k, v => v); + return allAdapters.ToDictionary(k => k, v => v); } } } \ No newline at end of file diff --git a/SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs b/SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs index 3bb3d313..1f4528d4 100644 --- a/SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs +++ b/SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs @@ -12,11 +12,14 @@ public class SearchVersioned : IQueryHandler { private readonly ServerlessOptions _serverlessOptions; private readonly ICloudFilesService _cloudFilesService; + private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; - public SearchVersioned(ServerlessOptions serverlessOptions, ICloudFilesService cloudFilesService) + public SearchVersioned(ServerlessOptions serverlessOptions, ICloudFilesService cloudFilesService, + NativeAdapterDiscoveryService nativeAdapterDiscovery) { _serverlessOptions = serverlessOptions; _cloudFilesService = cloudFilesService; + _nativeAdapterDiscovery = nativeAdapterDiscovery; } @@ -24,6 +27,16 @@ public async Task Handle(AdapterSearchRequest request) { var index = _serverlessOptions.AdapterRemotePath.Length + 1; + // Get native adapters first (they don't have versions) + var nativeAdapters = _nativeAdapterDiscovery.GetNativeAdapters(request.Prefix) + .Select(key => new + { + Key = key, + Versions = new List() // Native adapters have no versions + }) + .ToList(); + + // Get external adapters from storage var cloudFilesList = (await _cloudFilesService.ListAsync( $"{_serverlessOptions.AdapterRemotePath}/infolink6.{request.Prefix}")) @@ -40,7 +53,7 @@ public async Task Handle(AdapterSearchRequest request) return key; }); - return grouped.Select(i => new + var externalAdapters = grouped.Select(i => new { i.Key, Versions = i.Where(v => v.Key != i.Key && Semver.IsVersionNumber(v.Key.Split("/").Last())) @@ -49,6 +62,9 @@ public async Task Handle(AdapterSearchRequest request) Key = v.Key[index..] }).ToList() }); + + // Return native adapters first, then external + return nativeAdapters.Concat(externalAdapters); } } } \ No newline at end of file diff --git a/SW.Bitween.Api/Resources/Subscriptions/Update.cs b/SW.Bitween.Api/Resources/Subscriptions/Update.cs index f918b028..ad0b3f83 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Update.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Update.cs @@ -102,10 +102,24 @@ public Validate(IServiceProvider serviceProvider) { RuleFor(i => i.MapperProperties).CustomAsync(async (i, context, ct) => { - var serverless = serviceProvider.GetService(); - await serverless.StartAsync(((SubscriptionUpdate)context.InstanceToValidate).MapperId, null); - var mustProps = (await serverless.GetExpectedStartupValues()) - .Where(p => p.Value.Optional == false).Select(p => p.Key); + var mapperId = ((SubscriptionUpdate)context.InstanceToValidate).MapperId; + var mustProps = Enumerable.Empty(); + + // Check if it's a native adapter + if (mapperId.StartsWith("native.", StringComparison.OrdinalIgnoreCase)) + { + var nativeAdapterDiscovery = serviceProvider.GetService(); + var properties = nativeAdapterDiscovery.GetNativeAdapterProperties(mapperId); + mustProps = properties.Where(p => p.Value.EndsWith(" *")).Select(p => p.Key); + } + else + { + var serverless = serviceProvider.GetService(); + 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()) @@ -117,10 +131,24 @@ public Validate(IServiceProvider serviceProvider) { RuleFor(i => i.HandlerProperties).CustomAsync(async (i, context, ct) => { - var serverless = serviceProvider.GetService(); - await serverless.StartAsync(((SubscriptionUpdate)context.InstanceToValidate).HandlerId, null); - var mustProps = (await serverless.GetExpectedStartupValues()) - .Where(p => p.Value.Optional == false).Select(p => p.Key); + var handlerId = ((SubscriptionUpdate)context.InstanceToValidate).HandlerId; + var mustProps = Enumerable.Empty(); + + // Check if it's a native adapter + if (handlerId.StartsWith("native.", StringComparison.OrdinalIgnoreCase)) + { + var nativeAdapterDiscovery = serviceProvider.GetService(); + var properties = nativeAdapterDiscovery.GetNativeAdapterProperties(handlerId); + mustProps = properties.Where(p => p.Value.EndsWith(" *")).Select(p => p.Key); + } + else + { + var serverless = serviceProvider.GetService(); + await serverless.StartAsync(handlerId, 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()) @@ -137,11 +165,24 @@ public Validate(IServiceProvider serviceProvider) { RuleFor(i => i.ReceiverProperties).CustomAsync(async (i, context, ct) => { - var serverless = serviceProvider.GetService(); - await serverless.StartAsync(((SubscriptionUpdate)context.InstanceToValidate).ReceiverId, - null); - var mustProps = (await serverless.GetExpectedStartupValues()) - .Where(p => p.Value.Optional == false).Select(p => p.Key); + var receiverId = ((SubscriptionUpdate)context.InstanceToValidate).ReceiverId; + var mustProps = Enumerable.Empty(); + + // Check if it's a native adapter + if (receiverId.StartsWith("native.", StringComparison.OrdinalIgnoreCase)) + { + var nativeAdapterDiscovery = serviceProvider.GetService(); + var properties = nativeAdapterDiscovery.GetNativeAdapterProperties(receiverId); + mustProps = properties.Where(p => p.Value.EndsWith(" *")).Select(p => p.Key); + } + else + { + var serverless = serviceProvider.GetService(); + await serverless.StartAsync(receiverId, 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()) diff --git a/SW.Bitween.Api/SW.Bitween.Api.csproj b/SW.Bitween.Api/SW.Bitween.Api.csproj index 5694d925..98448c29 100644 --- a/SW.Bitween.Api/SW.Bitween.Api.csproj +++ b/SW.Bitween.Api/SW.Bitween.Api.csproj @@ -27,6 +27,7 @@ + diff --git a/SW.Bitween.Api/Services/NativeAdapterDiscoveryService.cs b/SW.Bitween.Api/Services/NativeAdapterDiscoveryService.cs new file mode 100644 index 00000000..6bf15119 --- /dev/null +++ b/SW.Bitween.Api/Services/NativeAdapterDiscoveryService.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using SW.Bitween.NativeAdapters; +using SW.PrimitiveTypes; + +namespace SW.Bitween +{ + public class NativeAdapterDiscoveryService + { + private readonly Dictionary> _adaptersCache; + + public NativeAdapterDiscoveryService() + { + _adaptersCache = new Dictionary>(); + DiscoverNativeAdapters(); + } + + private void DiscoverNativeAdapters() + { + var assemblies = new List() { typeof(DictionaryConverter).Assembly }; + + + + foreach (var assembly in assemblies) + { + try + { + var types = assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract); + + foreach (var type in types) + { + if (typeof(IInfolinkHandler).IsAssignableFrom(type)) + { + AddAdapter("handlers", type); + } + else if (typeof(IInfolinkValidator).IsAssignableFrom(type)) + { + AddAdapter("validators", type); + } + else if (typeof(IInfolinkReceiver).IsAssignableFrom(type)) + { + AddAdapter("receivers", type); + } + } + } + catch + { + // Skip assemblies that can't be loaded or scanned + } + } + } + + private void AddAdapter(string category, Type type) + { + if (!_adaptersCache.ContainsKey(category)) + { + _adaptersCache[category] = new List(); + } + + var adapterName = type.Name;//.Replace("Handler", "").Replace("Mapper", "") + //.Replace("Validator", "").Replace("Receiver", "").ToLower(); + + _adaptersCache[category].Add(new NativeAdapterInfo + { + Key = $"native.{adapterName}", + Name = type.Name, + Type = type, + Category = category + }); + } + + public IEnumerable GetNativeAdapters(string prefix) + { + if (string.IsNullOrEmpty(prefix)) + { + return _adaptersCache.Values.SelectMany(v => v).Select(a => a.Key); + } + + var category = prefix.ToLower().TrimStart('.'); + + if (_adaptersCache.TryGetValue(category, out var adapters)) + { + return adapters.Select(a => a.Key); + } + + return Enumerable.Empty(); + } + + public NativeAdapterInfo GetNativeAdapterInfo(string adapterId) + { + return _adaptersCache.Values + .SelectMany(v => v) + .FirstOrDefault(a => a.Key.Equals(adapterId, StringComparison.OrdinalIgnoreCase)); + } + + public Dictionary GetNativeAdapterProperties(string adapterId) + { + var adapterInfo = GetNativeAdapterInfo(adapterId); + if (adapterInfo == null) + return new Dictionary(); + + var result = new Dictionary(); + + // Get constructor parameters + var constructor = adapterInfo.Type.GetConstructors() + .FirstOrDefault(c => c.GetParameters().Length > 0); + + if (constructor == null) + return result; + + // Get the first parameter type (input model) + var inputParameter = constructor.GetParameters().FirstOrDefault(); + if (inputParameter == null) + return result; + + var inputType = inputParameter.ParameterType; + + // Get all properties from the input model + var properties = inputType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + var defaultValue = GetDefaultValue(prop); + var hasRequiredAttribute = prop.GetCustomAttribute() != null; + var isRequired = hasRequiredAttribute || (!IsNullableType(prop.PropertyType) && defaultValue == null); + + if (isRequired) + { + result[prop.Name] = $"{prop.Name} *"; + } + else + { + result[prop.Name] = $"{prop.Name} ({defaultValue ?? "null"})"; + } + } + + return result; + } + + private string? GetDefaultValue(PropertyInfo property) + { + // Try to get default value from DefaultValueAttribute if it exists + var defaultAttr = property.GetCustomAttribute(); + if (defaultAttr != null) + return defaultAttr.Value?.ToString(); + + // For value types, return their default + if (property.PropertyType.IsValueType) + return Activator.CreateInstance(property.PropertyType)?.ToString(); + + return null; + } + + private bool IsNullableType(Type type) + { + return !type.IsValueType || + Nullable.GetUnderlyingType(type) != null || + (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)); + } + } + + public class NativeAdapterInfo + { + public string Key { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public Type Type { get; set; } = null!; + public string Category { get; set; } = string.Empty; + } +} diff --git a/SW.Bitween.Api/Services/ReceivingService.cs b/SW.Bitween.Api/Services/ReceivingService.cs index ab0bb40b..684001aa 100644 --- a/SW.Bitween.Api/Services/ReceivingService.cs +++ b/SW.Bitween.Api/Services/ReceivingService.cs @@ -78,26 +78,106 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) async Task RunReceiver(IServiceProvider serviceProvider, string serverlessId, IDictionary startupParameters, int subId) { - var serverless = serviceProvider.GetRequiredService(); - await serverless.StartAsync(serverlessId, null, startupParameters); - await serverless.InvokeAsync(nameof(IInfolinkReceiver.Initialize), null); - var fileList = - (await serverless.InvokeAsync>(nameof(IInfolinkReceiver.ListFiles), null)).ToList(); + // Check if it's a native adapter + if (serverlessId.StartsWith("native.", StringComparison.OrdinalIgnoreCase)) + { + var nativeAdapterDiscovery = serviceProvider.GetRequiredService(); + var receiver = InstantiateNativeReceiver(nativeAdapterDiscovery, serverlessId, startupParameters); + + await receiver.Initialize(); + var fileList = (await receiver.ListFiles()).ToList(); + + logger.LogInformation($"Subscription:'{subId}' found {fileList.Count()} items for retrieval."); + + foreach (var file in fileList) + { + var xchangeFile = await receiver.GetFile(file); - logger.LogInformation($"Subscription:'{subId}' found {fileList.Count()} items for retrieval."); + logger.LogInformation($"Submitting received file for subscriber: '{subId}'."); - foreach (var file in fileList) + var xchangeService = serviceProvider.GetService(); + await xchangeService.SubmitSubscriptionXchange(subId, xchangeFile); + await receiver.DeleteFile(file); + } + + await receiver.Finalize(); + } + else { - var xchangeFile = await serverless.InvokeAsync(nameof(IInfolinkReceiver.GetFile), file); + // Use serverless for external adapters + var serverless = serviceProvider.GetRequiredService(); + await serverless.StartAsync(serverlessId, null, startupParameters); + await serverless.InvokeAsync(nameof(IInfolinkReceiver.Initialize), null); + var fileList = + (await serverless.InvokeAsync>(nameof(IInfolinkReceiver.ListFiles), null)).ToList(); + + logger.LogInformation($"Subscription:'{subId}' found {fileList.Count()} items for retrieval."); + + foreach (var file in fileList) + { + var xchangeFile = await serverless.InvokeAsync(nameof(IInfolinkReceiver.GetFile), file); + + logger.LogInformation($"Submitting received file for subscriber: '{subId}'."); + + var xchangeService = serviceProvider.GetService(); + await xchangeService.SubmitSubscriptionXchange(subId, xchangeFile); + await serverless.InvokeAsync(nameof(IInfolinkReceiver.DeleteFile), file); + } + + await serverless.InvokeAsync(nameof(IInfolinkReceiver.Finalize), null); + } + } + + private IInfolinkReceiver InstantiateNativeReceiver(NativeAdapterDiscoveryService nativeAdapterDiscovery, + string adapterId, IDictionary properties) + { + var adapterInfo = nativeAdapterDiscovery.GetNativeAdapterInfo(adapterId); + if (adapterInfo == null) + throw new BitweenException($"Native adapter not found: {adapterId}"); + + // Get the constructor that takes a parameter + var constructor = adapterInfo.Type.GetConstructors() + .FirstOrDefault(c => c.GetParameters().Length > 0); + + if (constructor == null) + throw new BitweenException($"Native adapter {adapterId} must have a constructor that accepts an input model"); + + // Get the input parameter type + var inputParameter = constructor.GetParameters().First(); + var inputType = inputParameter.ParameterType; - logger.LogInformation($"Submitting received file for subscriber: '{subId}'."); + // Create an instance of the input model by mapping properties + var inputInstance = Activator.CreateInstance(inputType); - var xchangeService = serviceProvider.GetService(); - await xchangeService.SubmitSubscriptionXchange(subId, xchangeFile); - await serverless.InvokeAsync(nameof(IInfolinkReceiver.DeleteFile), file); + // Map dictionary properties to the input model + foreach (var prop in inputType.GetProperties()) + { + // Case-insensitive property lookup + var propEntry = properties.FirstOrDefault(p => + string.Equals(p.Key, prop.Name, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrEmpty(propEntry.Key)) + { + var value = propEntry.Value; + try + { + var convertedValue = Convert.ChangeType(value, + Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType); + prop.SetValue(inputInstance, convertedValue); + } + catch + { + // If conversion fails, set string value directly + if (prop.PropertyType == typeof(string)) + prop.SetValue(inputInstance, value); + } + } } - await serverless.InvokeAsync(nameof(IInfolinkReceiver.Finalize), null); + // Instantiate the adapter with the input model + var adapter = Activator.CreateInstance(adapterInfo.Type, inputInstance); + + return (IInfolinkReceiver)adapter; } diff --git a/SW.Bitween.Api/Services/XchangeService.cs b/SW.Bitween.Api/Services/XchangeService.cs index ecf5fc39..b0c058b5 100644 --- a/SW.Bitween.Api/Services/XchangeService.cs +++ b/SW.Bitween.Api/Services/XchangeService.cs @@ -33,16 +33,19 @@ public class XchangeService : private readonly IPublish _publish; private readonly ILogger _logger; private readonly IInfolinkCache _BitweenCache; + private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; public XchangeService(BitweenOptions BitweenSettings, BitweenDbContext dbContext, FilterService filterService, ICloudFilesService cloudFiles, IServiceProvider serviceProvider, - IPublish publish, ILogger logger, IInfolinkCache BitweenCache) + IPublish publish, ILogger logger, IInfolinkCache BitweenCache, + NativeAdapterDiscoveryService nativeAdapterDiscovery) { _BitweenSettings = BitweenSettings; _dbContext = dbContext; _filterService = filterService; _cloudFiles = cloudFiles; + _nativeAdapterDiscovery = nativeAdapterDiscovery; _serviceProvider = serviceProvider; _publish = publish; _logger = logger; @@ -125,13 +128,23 @@ private async Task RunMapper(Xchange xchange, XchangeFile xchangeFi { if (xchange.MapperId == null) return xchangeFile; - var serverless = _serviceProvider.GetRequiredService(); - var mapperProperties = xchange.MapperProperties.ToDictionary(); mapperProperties["xchangeid"] = xchange.Id; - await serverless.StartAsync(xchange.MapperId, xchange.CorrelationId ?? xchange.Id, mapperProperties); - xchangeFile = await serverless.InvokeAsync(nameof(IInfolinkHandler.Handle), xchangeFile); + // Check if it's a native adapter + if (xchange.MapperId.StartsWith("native.", StringComparison.OrdinalIgnoreCase)) + { + var handler = InstantiateNativeAdapter(xchange.MapperId, mapperProperties); + xchangeFile = await handler.Handle(xchangeFile); + } + else + { + // Use serverless for external adapters + var serverless = _serviceProvider.GetRequiredService(); + await serverless.StartAsync(xchange.MapperId, xchange.CorrelationId ?? xchange.Id, mapperProperties); + xchangeFile = await serverless.InvokeAsync(nameof(IInfolinkHandler.Handle), xchangeFile); + } + if (xchangeFile is null) throw new BitweenException( $"Unexpected null return value after running mapping for exchange id: {xchange.Id}, adapter id: {xchange.MapperId}"); @@ -145,10 +158,22 @@ public async Task RunValidator(string validatorId, IDictionary p { if (validatorId == null) return; - var serverless = _serviceProvider.GetRequiredService(); - await serverless.StartAsync(validatorId, null, properties); - var result = - await serverless.InvokeAsync(nameof(IInfolinkValidator.Validate), xchangeFile); + InfolinkValidatorResult result; + + // Check if it's a native adapter + if (validatorId.StartsWith("native.", StringComparison.OrdinalIgnoreCase)) + { + var validator = InstantiateNativeAdapter(validatorId, properties); + result = await validator.Validate(xchangeFile); + } + else + { + // Use serverless for external adapters + var serverless = _serviceProvider.GetRequiredService(); + await serverless.StartAsync(validatorId, null, properties); + result = await serverless.InvokeAsync(nameof(IInfolinkValidator.Validate), xchangeFile); + } + if (!result.Success) throw new SWValidationException(result.Validations); } @@ -157,18 +182,79 @@ private async Task RunHandler(Xchange xchange, XchangeFile xchangeF { if (xchange.HandlerId == null) return null; - var serverless = _serviceProvider.GetRequiredService(); - var handlerProperties = xchange.HandlerProperties.ToDictionary(); handlerProperties["xchangeid"] = xchange.Id; - await serverless.StartAsync(xchange.HandlerId, xchange.CorrelationId ?? xchange.Id, handlerProperties); - xchangeFile = await serverless.InvokeAsync(nameof(IInfolinkHandler.Handle), xchangeFile); + // Check if it's a native adapter + if (xchange.HandlerId.StartsWith("native.", StringComparison.OrdinalIgnoreCase)) + { + var handler = InstantiateNativeAdapter(xchange.HandlerId, handlerProperties); + xchangeFile = await handler.Handle(xchangeFile); + } + else + { + // Use serverless for external adapters + var serverless = _serviceProvider.GetRequiredService(); + await serverless.StartAsync(xchange.HandlerId, xchange.CorrelationId ?? xchange.Id, handlerProperties); + xchangeFile = await serverless.InvokeAsync(nameof(IInfolinkHandler.Handle), xchangeFile); + } + if (xchangeFile != null) await AddFile(xchange.Id, XchangeFileType.Response, xchangeFile); return xchangeFile; } + private T InstantiateNativeAdapter(string adapterId, IDictionary properties) + { + var adapterInfo = _nativeAdapterDiscovery.GetNativeAdapterInfo(adapterId); + if (adapterInfo == null) + throw new BitweenException($"Native adapter not found: {adapterId}"); + + // Get the constructor that takes a parameter + var constructor = adapterInfo.Type.GetConstructors() + .FirstOrDefault(c => c.GetParameters().Length > 0); + + if (constructor == null) + throw new BitweenException($"Native adapter {adapterId} must have a constructor that accepts an input model"); + + // Get the input parameter type + var inputParameter = constructor.GetParameters().First(); + var inputType = inputParameter.ParameterType; + + // Create an instance of the input model by mapping properties + var inputInstance = Activator.CreateInstance(inputType); + + // Map dictionary properties to the input model + foreach (var prop in inputType.GetProperties()) + { + // Case-insensitive property lookup + var propEntry = properties.FirstOrDefault(p => + string.Equals(p.Key, prop.Name, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrEmpty(propEntry.Key)) + { + var value = propEntry.Value; + try + { + var convertedValue = Convert.ChangeType(value, + Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType); + prop.SetValue(inputInstance, convertedValue); + } + catch + { + // If conversion fails, set string value directly + if (prop.PropertyType == typeof(string)) + prop.SetValue(inputInstance, value); + } + } + } + + // Instantiate the adapter with the input model + var adapter = Activator.CreateInstance(adapterInfo.Type, inputInstance); + + return (T)adapter; + } + private async Task AddFile(string xchangeId, XchangeFileType type, XchangeFile file) { await _cloudFiles.WriteTextAsync(file.Data, new WriteFileSettings @@ -354,16 +440,28 @@ private async Task NotifyResult(Notifier notifier, XchangeResult xchangeResult, CorrelationId = xchange.CorrelationId }; - var serverless = _serviceProvider.GetRequiredService(); + var handlerProperties = notifier.HandlerProperties.ToDictionary(); handlerProperties["xchangeid"] = xchangeResult.Id; try { - await serverless.StartAsync(notifier.HandlerId, correlationId, handlerProperties); - await serverless.InvokeAsync(nameof(IInfolinkHandler.Handle), - new XchangeFile(JsonConvert.SerializeObject(notificationData), xchangeResult.Id)); + // Check if it's a native adapter + if (notifier.HandlerId.StartsWith("native.", StringComparison.OrdinalIgnoreCase)) + { + var handler = InstantiateNativeAdapter(notifier.HandlerId, handlerProperties); + await handler.Handle(new XchangeFile(JsonConvert.SerializeObject(notificationData), xchangeResult.Id)); + } + else + { + + // Use serverless for external adapters + var serverless = _serviceProvider.GetRequiredService(); + await serverless.StartAsync(notifier.HandlerId, correlationId, handlerProperties); + await serverless.InvokeAsync(nameof(IInfolinkHandler.Handle), + new XchangeFile(JsonConvert.SerializeObject(notificationData), xchangeResult.Id)); + } _dbContext.Add(new XchangeNotification(xchangeResult.Id, notifier.Id, notifier.Name)); } diff --git a/SW.Bitween.NativeAdapters/HttpHandler/HttpHandler.cs b/SW.Bitween.NativeAdapters/HttpHandler/HttpHandler.cs new file mode 100644 index 00000000..8f6ae161 --- /dev/null +++ b/SW.Bitween.NativeAdapters/HttpHandler/HttpHandler.cs @@ -0,0 +1,159 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using DotLiquid; +using Newtonsoft.Json; +using SW.PrimitiveTypes; + +namespace SW.Bitween.NativeAdapters; + +public class HttpHandler : IInfolinkHandler +{ + private HttpMethod HttpMethodFromString(string method) + { + switch (method.ToLower()) + { + case "get": + return HttpMethod.Get; + case "delete": + return HttpMethod.Delete; + case "put": + return HttpMethod.Put; + default: + return HttpMethod.Post; + } + } + + private readonly HttpHandlerInput _options; + + public HttpHandler(HttpHandlerInput options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public async Task Handle(XchangeFile xchangeFile) + { + HttpClient client = new HttpClient(); + if (_options.AuthType == "ApiKey") + client.DefaultRequestHeaders.Add("ApiKey", _options.ApiKey); + else if (_options.AuthType == "Bearer") + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", _options.LoginPassword); + else if (_options.AuthType == "Basic") + { + string credentials = + Convert.ToBase64String( + Encoding.ASCII.GetBytes(_options.LoginUsername + ":" + _options.LoginPassword)); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + else if (_options.AuthType == "Login") + { + string loginJson = JsonConvert.SerializeObject(new UserLoginModel() + { + Email = _options.LoginUsername, + Password = _options.LoginPassword + }); + HttpResponseMessage loginResponse = await client.PostAsync(new Uri(_options.LoginUrl!), + new StringContent(loginJson, Encoding.UTF8, "application/json")); + loginResponse.EnsureSuccessStatusCode(); + if (loginResponse.StatusCode != HttpStatusCode.OK) + throw new Exception(loginResponse.StatusCode.ToString()); + string rs = await loginResponse.Content.ReadAsStringAsync(); + LoginResponse? rsDeserialized = JsonConvert.DeserializeObject(rs); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", rsDeserialized?.Jwt); + } + else if (_options.AuthType == "OAuth2") + { + var oathRequest = new HttpRequestMessage(HttpMethod.Post, _options.LoginUrl); + var oauthContentDictionary = new List>(); + oauthContentDictionary.Add(new("client_id", _options.ClientId!)); + oauthContentDictionary.Add(new("client_secret", _options.ClientSecret!)); + oauthContentDictionary.Add(new("grant_type", "client_credentials")); + var oauthContent = new FormUrlEncodedContent(oauthContentDictionary); + oathRequest.Content = oauthContent; + var oauthResponse = await client.SendAsync(oathRequest); + var res = await oauthResponse.Content.ReadAsStringAsync(); + var resDeserialized = JsonConvert.DeserializeObject(res); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", resDeserialized?.access_token); + } + + string requestBody = xchangeFile.Data; + if (string.IsNullOrEmpty(requestBody)) + requestBody = _options.DefaultRequest ?? string.Empty; + string str = _options.ContentType.ToLower(); + HttpContent content; + MultipartFormDataContent multipartTmp; + byte[] fileContent; + switch (str) + { + case "application/x-www-form-urlencoded": + content = new FormUrlEncodedContent( + JsonConvert.DeserializeObject>(requestBody) + ?? new Dictionary()); + break; + case "multipart/form-data": + multipartTmp = new MultipartFormDataContent(); + fileContent = Encoding.UTF8.GetBytes(requestBody); + multipartTmp.Add(new ByteArrayContent(fileContent), "file", xchangeFile.Filename ?? "file"); + content = multipartTmp; + break; + case "application/json": + content = new StringContent(requestBody, Encoding.UTF8, "application/json"); + break; + default: + content = new StringContent(requestBody, Encoding.UTF8, _options.ContentType); + break; + } + + Uri uri; + if (!string.IsNullOrEmpty(xchangeFile.Data) && _options.Url.Contains("{{")) + { + Template parsedTemplate = Template.Parse(_options.Url); + IDictionary obj = + JsonConvert.DeserializeObject>(xchangeFile.Data, + new DictionaryConverter()) ?? new Dictionary(); + Hash jsonHash = Hash.FromDictionary(obj); + uri = new Uri(parsedTemplate.Render(jsonHash)); + } + else + uri = new Uri(_options.Url); + + HttpRequestMessage request = new HttpRequestMessage() + { + RequestUri = uri, + Method = HttpMethodFromString(_options.Verb), + Content = content + }; + string? headers1 = _options.Headers; + IEnumerable>? headers = headers1 != null + ? (headers1.Split(',')).Select((Func>)(h => + { + string[] strArray = h.Split(':'); + return new KeyValuePair(strArray[0], strArray[1]); + })) + : null; + if (headers != null) + { + foreach (KeyValuePair keyValuePair1 in headers) + { + KeyValuePair keyValuePair = keyValuePair1; + request.Headers.Add(keyValuePair.Key, keyValuePair.Value); + } + } + + if (!string.IsNullOrEmpty(_options.CorrelationId)) + request.Headers.Add("request-context-correlation-id", _options.CorrelationId); + HttpResponseMessage response = await client.SendAsync(request); + if (response.StatusCode < HttpStatusCode.OK || response.StatusCode >= HttpStatusCode.InternalServerError) + throw new Exception(response.StatusCode.ToString()); + string resp = await response.Content.ReadAsStringAsync(); + XchangeFile xchangeFile1 = response.StatusCode < HttpStatusCode.BadRequest + ? new XchangeFile(resp) + : new XchangeFile(resp, badData: true); + return xchangeFile1; + } + + +} \ No newline at end of file diff --git a/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerInput.cs b/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerInput.cs new file mode 100644 index 00000000..c69fc53b --- /dev/null +++ b/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerInput.cs @@ -0,0 +1,29 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace SW.Bitween.NativeAdapters; + +public class HttpHandlerInput +{ + public string? AuthType { get; set; } + public string? ApiKey { get; set; } + public string? LoginUrl { get; set; } + + [DefaultValue("post")] + public string Verb { get; set; } = "post"; + + public string? LoginUsername { get; set; } + public string? LoginPassword { get; set; } + + [Required] + public string Url { get; set; } = string.Empty; + + [DefaultValue("application/json")] + public string ContentType { get; set; } = "application/json"; + + public string? Headers { get; set; } + public string? CorrelationId { get; set; } + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } + public string? DefaultRequest { get; set; } +} \ No newline at end of file diff --git a/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerModels.cs b/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerModels.cs new file mode 100644 index 00000000..623b5059 --- /dev/null +++ b/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerModels.cs @@ -0,0 +1,18 @@ +namespace SW.Bitween.NativeAdapters; + +public class UserLoginModel +{ + public string? Email { get; set; } + public string? Password { get; set; } +} + +public class LoginResponse +{ + public string? Jwt { get; set; } + public string? Refresh { get; set; } +} + +public class OAuth2Response +{ + public string? access_token { get; set; } +} diff --git a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj new file mode 100644 index 00000000..f0d60c4b --- /dev/null +++ b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + + + + + + + + + diff --git a/SW.Bitween.NativeAdapters/Services/DictionaryConverter.cs b/SW.Bitween.NativeAdapters/Services/DictionaryConverter.cs new file mode 100644 index 00000000..28edd394 --- /dev/null +++ b/SW.Bitween.NativeAdapters/Services/DictionaryConverter.cs @@ -0,0 +1,157 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace SW.Bitween.NativeAdapters; + +public class DictionaryConverter : JsonConverter +{ + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + this.WriteValue(writer, value); + } + + private void WriteValue(JsonWriter writer, object? value) + { + if (value == null) + { + writer.WriteNull(); + return; + } + + var t = JToken.FromObject(value); + switch (t.Type) + { + case JTokenType.Object: + this.WriteObject(writer, value); + break; + case JTokenType.Array: + this.WriteArray(writer, value); + break; + default: + writer.WriteValue(value); + break; + } + } + + private void WriteObject(JsonWriter writer, object value) + { + writer.WriteStartObject(); + var obj = value as IDictionary; + if (obj != null) + { + foreach (var kvp in obj) + { + writer.WritePropertyName(kvp.Key); + this.WriteValue(writer, kvp.Value); + } + } + writer.WriteEndObject(); + } + + private void WriteArray(JsonWriter writer, object value) + { + writer.WriteStartArray(); + var array = value as IEnumerable; + if (array != null) + { + foreach (var o in array) + { + this.WriteValue(writer, o); + } + } + writer.WriteEndArray(); + } + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + return ReadValue(reader); + } + + private object? ReadValue(JsonReader reader) + { + while (reader.TokenType == JsonToken.Comment) + { + if (!reader.Read()) + throw new JsonSerializationException("Unexpected Token when converting IDictionary"); + } + + switch (reader.TokenType) + { + case JsonToken.StartObject: + return ReadObject(reader); + case JsonToken.StartArray: + return this.ReadArray(reader); + case JsonToken.Integer: + case JsonToken.Float: + case JsonToken.String: + case JsonToken.Boolean: + case JsonToken.Undefined: + case JsonToken.Null: + case JsonToken.Date: + case JsonToken.Bytes: + return reader.Value; + default: + throw new JsonSerializationException( + $"Unexpected token when converting IDictionary: {reader.TokenType}"); + } + } + + private object ReadArray(JsonReader reader) + { + IList list = new List(); + + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonToken.Comment: + break; + case JsonToken.EndArray: + return list; + default: + var v = ReadValue(reader); + if (v != null) + list.Add(v); + break; + } + } + + throw new JsonSerializationException("Unexpected end when reading IDictionary"); + } + + private object ReadObject(JsonReader reader) + { + var obj = new Dictionary(); + + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonToken.PropertyName: + var propertyName = reader.Value?.ToString(); + if (propertyName == null) break; + + if (!reader.Read()) + { + throw new JsonSerializationException("Unexpected end when reading IDictionary"); + } + + var v = ReadValue(reader); + if (v != null) + obj[propertyName] = v; + break; + case JsonToken.Comment: + break; + case JsonToken.EndObject: + return obj; + } + } + + throw new JsonSerializationException("Unexpected end when reading IDictionary"); + } + + public override bool CanConvert(Type objectType) + { + return typeof(IDictionary).IsAssignableFrom(objectType); + } +} diff --git a/SW.Bitween.Web/SW.Bitween.Web.csproj b/SW.Bitween.Web/SW.Bitween.Web.csproj index 795a599f..b8f2030f 100644 --- a/SW.Bitween.Web/SW.Bitween.Web.csproj +++ b/SW.Bitween.Web/SW.Bitween.Web.csproj @@ -34,6 +34,7 @@ + diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index 7d62f424..1b136cfe 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -57,6 +57,7 @@ public void ConfigureServices(IServiceCollection services) services.AddMemoryCache(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); services.AddHostedService(); diff --git a/SW.Bitween.sln b/SW.Bitween.sln index 2d76a9c1..0c82ab3e 100644 --- a/SW.Bitween.sln +++ b/SW.Bitween.sln @@ -27,6 +27,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SW.Bitween.SampleValidator" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SW.Bitween.PgSql", "SW.Bitween.PgSql\SW.Bitween.PgSql.csproj", "{1474658D-E225-478E-80D6-D41A0376F88C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SW.Bitween.NativeAdapters", "SW.Bitween.NativeAdapters\SW.Bitween.NativeAdapters.csproj", "{5D7B6BD7-427E-4F1B-B4CA-CF6B3A3ED89F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -73,6 +75,10 @@ Global {1474658D-E225-478E-80D6-D41A0376F88C}.Debug|Any CPU.Build.0 = Debug|Any CPU {1474658D-E225-478E-80D6-D41A0376F88C}.Release|Any CPU.ActiveCfg = Release|Any CPU {1474658D-E225-478E-80D6-D41A0376F88C}.Release|Any CPU.Build.0 = Release|Any CPU + {5D7B6BD7-427E-4F1B-B4CA-CF6B3A3ED89F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D7B6BD7-427E-4F1B-B4CA-CF6B3A3ED89F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D7B6BD7-427E-4F1B-B4CA-CF6B3A3ED89F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D7B6BD7-427E-4F1B-B4CA-CF6B3A3ED89F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE