using System.Text.Json.Serialization; using System.Text.Json; using System.Reactive.Linq; using System.Net.Http.Json; using System.Runtime.CompilerServices; using Polly; using System.Xml.XPath; using Polly.Bulkhead; namespace ZymonicServices; public abstract class ZymonicProcess where TProcessRequest : class, IZymonicProcess, new() where TProcessRequestForm : class, IZymonicProcessForm, new() where TProcessRequestAPI : class, IZymonicProcessApiRequest, new() where TProcessResponse : class, IZymonicProcessResponse, new() where TProcessResponseForm : class, IZymonicProcessResponseForm, new() where TProcessResponseAPI : class, IZymonicProcessApiResponse, new() { private IZymonicDbContextFactory _contextFactory; private IZymonicSettings _settings; private IZymonicAA _authService; private IZymonicLogger _logger; private HttpClient singleHttpClient; private ZymonicEventListener listener; private AsyncBulkheadPolicy _bulkPolicy; public ZymonicProcess(IZymonicLogger logger, IZymonicDbContextFactory contextFactory, IZymonicSettings settings, IZymonicAA authService, AsyncBulkheadPolicy bulkheadPolicy) { _contextFactory = contextFactory; _settings = settings; _authService = authService; _logger = logger; _bulkPolicy = bulkheadPolicy; singleHttpClient = new HttpClient(new ZymonicAPIHandler(_logger, _authService, new HttpLoggingHandler(_logger))) { BaseAddress = new Uri(_settings.BaseUri()), // TODO Make this configurable Timeout = TimeSpan.FromSeconds(120) }; // add trace/debug level logging and switch to that. listener = new ZymonicEventListener(_logger, ZymonicEventListener.NetworkingEvents); } private string? GetSettingOverride(string setting) { string? settingValue = _settings.Setting($"{this.GetType().Name}_{setting}"); if (settingValue is null) { settingValue = _settings.Setting(setting); } else { _logger.LogDebug("{Process} setting {Setting} has been overriden by a process specific value {SettingValue}", this.GetType().Name, setting, settingValue); } if (settingValue is not null) { _logger.LogDebug("{Process} setting {Setting} has been overriden by app specific value {SettingValue}", this.GetType().Name, setting, settingValue); } return settingValue; } public async Task DoTransition(string transition, TProcessRequestForm requestForm, Func ResponseChecker, bool debugMode, bool requireAuthenticate, CancellationToken ct, string? processId = null) { Guid guid = StoreTransition(transition, requestForm, debugMode, requireAuthenticate, processId); var retryPolicy = Policy.Handle(ex => ex is not OperationCanceledException) .OrResult(response => !ResponseChecker(response)) .WaitAndRetryAsync( retryCount: 10, sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)), onRetry: (outcome, timespan, attempt, context) => { _logger.LogWarning("Retrying transition due to failure: {Attempt} - {Timespan}", attempt, timespan); SetTransitionAttempted(guid); } ); var combinedPolicy = retryPolicy.WrapAsync(_bulkPolicy); try { TProcessResponseAPI response = await combinedPolicy.ExecuteAsync(async () => { SetTransitionInProgress(guid); TProcessResponseAPI result = await DoStoredTransition(guid, ct).ConfigureAwait(true); // Always set the processId as the Zymonic response may not have it. result.ProcessId = processId; return result; }); _logger.LogDebug("{Process} Transition {Transition} completed successfully", this.GetType().Name, transition); DeleteTransition(guid); return response; } catch (Exception ex) { ClearTransitionInProgress(guid); _logger.LogError(ex.ToString()); throw; } } public void DeleteTransition(Guid guid) { var context = _contextFactory.CreateDbContext(); var transitionAttempt = context.Set().Find(guid); if (transitionAttempt is null) { throw new Exception($"Transition attempt with Guid {guid} not found"); } context.Set().Remove(transitionAttempt); context.SaveChanges(); } public async Task DoStoredTransition(Guid guid, CancellationToken ct) { var context = _contextFactory.CreateDbContext(); var transitionAttempt = context.Set().Find(guid); if (transitionAttempt is null) { throw new Exception($"Transition attempt with Guid {guid} not found"); } TProcessRequestForm? requestFormDecoded = JsonSerializer.Deserialize(transitionAttempt.requestFormJson!, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, NumberHandling = JsonNumberHandling.AllowReadingFromString, Converters = { new EmptyStringToNumberConverter(), new EmptyStringToNumberConverter(), new EmptyStringToNumberConverter(), new EmptyStringToNumberConverter() } }); return await GetApiResults(transitionAttempt.transitionZName!, requestFormDecoded!, transitionAttempt.debugMode ?? false, transitionAttempt.requireAuthenticate ?? false, ct, transitionAttempt.processId).ConfigureAwait(true); } private void SetTransitionInProgress(Guid guid) { var context = _contextFactory.CreateDbContext(); var transitionAttempt = context.Set().Find(guid); if (transitionAttempt is null) { throw new Exception($"Transition attempt with Guid {guid} not found"); } transitionAttempt.inProgress = true; context.SaveChanges(); } private void ClearTransitionInProgress(Guid guid) { var context = _contextFactory.CreateDbContext(); var transitionAttempt = context.Set().Find(guid); if (transitionAttempt is null) { throw new Exception($"Transition attempt with Guid {guid} not found"); } transitionAttempt.inProgress = false; context.SaveChanges(); } private void SetTransitionAttempted(Guid guid) { var context = _contextFactory.CreateDbContext(); var transitionAttempt = context.Set().Find(guid); if (transitionAttempt is null) { throw new Exception($"Transition attempt with Guid {guid} not found"); } transitionAttempt.attemptCount = (transitionAttempt.attemptCount ?? 0) + 1; transitionAttempt.lastAttemptedAt = DateTime.UtcNow; context.SaveChanges(); } public Guid StoreTransition(string transition, TProcessRequestForm requestForm, bool debugMode, bool requireAuthenticate, string? processId = null) { var context = _contextFactory.CreateDbContext(); string requestFormJson = JsonSerializer.Serialize(requestForm, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }); ZymonicTransitionAttempt transitionAttempt = new ZymonicTransitionAttempt { processZName = this.GetType().Name, transitionZName = transition, requestFormJson = requestFormJson, requireAuthenticate = requireAuthenticate, debugMode = debugMode, processId = processId }; context.Set().Add(transitionAttempt); context.SaveChanges(); _logger.LogDebug("{Process} Transition {Transition} stored in local db, Guid {guid}", this.GetType().Name, transition, transitionAttempt.gId); return transitionAttempt.gId; } private async Task GetApiResults(string transition, TProcessRequestForm requestForm, bool debugMode, bool requireAuthenticated, CancellationToken ct, string? processId = null) { TProcessResponseAPI? apiDecodedResponse = null; _logger.LogDebug("{Process} GetApiResults Running on thread {Thread}", this.GetType().Name, System.Threading.Thread.CurrentThread.ManagedThreadId.ToString()); // Set-up the request TProcessRequestAPI requestAPI = new TProcessRequestAPI { ZymonicHeader = new ZymonicAPIHeader(_settings.SystemName(), "process", debugMode) }; // TODO allow user to disable this / override per form AddRecordFieldToForms(requestForm); // add the form and transition TProcessRequest processRequest = new TProcessRequest { Transition = transition, ProcessForm = requestForm, }; if (processId is not null) { processRequest.ZymonicHeader = new ZymonicHeader() { process_id = processId }; } requestAPI.processRequest = processRequest; // Json Options JsonSerializerOptions jsonOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, NumberHandling = JsonNumberHandling.AllowReadingFromString, Converters = { new EmptyStringToNumberConverter(), new EmptyStringToNumberConverter(), new EmptyStringToNumberConverter(), new EmptyStringToNumberConverter() } }; try { apiDecodedResponse = await ApiWrapper(requestAPI, jsonOptions, ct); if (apiDecodedResponse is null) { throw new ApiResponseMissingOrEmpty(); } if (requireAuthenticated && apiDecodedResponse.Authenticated != "Y") { throw new UnauthenticatedProcessResponseException(); } } catch (Exception ex) { _logger.LogError(ex.ToString()); throw; } return apiDecodedResponse; } public async Task ApiWrapper(TProcessRequestAPI request, JsonSerializerOptions jsonOptions, CancellationToken ct) { HttpResponseMessage response = await singleHttpClient.PostAsJsonAsync("", request, jsonOptions, ct).ConfigureAwait(true); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(jsonOptions).ConfigureAwait(true); } private void AddRecordFieldToForms(IZymonicProcessForm form, int recCount = 0) { var properties = form.GetType().GetProperties(); foreach (var property in properties) { if (typeof(IZymonicSubFormField).IsAssignableFrom(property.PropertyType)) { var subFormFieldInstance = property.GetValue(form) as IZymonicSubFormField; if (subFormFieldInstance != null) { processSubFormField(subFormFieldInstance); } } if (property.Name.Equals("record", StringComparison.OrdinalIgnoreCase)) { if (form.record is null || string.IsNullOrEmpty(form.record)) { if (recCount == 0) { form.record = "ZZNEW"; } else { form.record = $"ZZNEW{recCount.ToString()}"; } } } } } private void processSubFormField(IZymonicSubFormField subFormField) { int recCount = 1; var properties = subFormField.GetType().GetProperties(); foreach (var property in properties) { var propertyType = property.PropertyType; if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(List<>)) { var genericArgument = propertyType.GetGenericArguments()[0]; if (typeof(IZymonicProcessForm).IsAssignableFrom(genericArgument)) { var value = property.GetValue(subFormField); if (value is IEnumerable collection) { foreach (var item in collection) { if (item is IZymonicProcessForm form) { AddRecordFieldToForms(form, recCount); recCount++; } } } } } } } public class UnauthenticatedProcessResponseException : Exception { } }