From 2a26d5501159507b18e8b28c3e6217d7e14f947b Mon Sep 17 00:00:00 2001 From: "Eric Sibly [chullybun]" Date: Sun, 3 Mar 2024 19:50:35 -0800 Subject: [PATCH] v3.13.0 (#92) * Changes as decribed in the log. * Further ETag testing and changes. --- CHANGELOG.md | 12 ++ Common.targets | 2 +- .../WebApis/ValueContentResult.cs | 143 ++++++-------- src/CoreEx.AspNetCore/WebApis/WebApi.cs | 23 ++- .../WebApis/WebApiRequestOptions.cs | 31 ++-- .../Storage/TableWorkStatePersistence.cs | 2 + src/CoreEx.Database/DatabaseRecord.cs | 21 +++ .../Extended/DatabaseExtendedExtensions.cs | 4 +- src/CoreEx.Database/IDatabaseMapper.cs | 19 +- src/CoreEx.Database/IDatabaseMapperT.cs | 15 +- .../IDatabaseParametersExtensions.cs | 16 ++ src/CoreEx.Database/Mapping/DatabaseMapper.cs | 17 +- .../Mapping/DatabaseMapperExT.cs | 175 ++++++++++++++++++ .../Mapping/DatabaseMapperExT2.cs | 21 +++ .../Mapping/DatabaseMapperT.cs | 28 +-- .../Mapping/DatabaseMapperT2.cs | 1 + .../Mapping/IDatabaseMapperEx.cs | 20 ++ .../Mapping/IDatabaseMapperExT.cs | 26 +++ .../Mapping/PropertyColumnMapper.cs | 4 +- src/CoreEx.Validation/Rules/ExistsRule.cs | 14 +- src/CoreEx.Validation/ValidationExtensions.cs | 10 +- .../ValidationServiceCollectionExtensions.cs | 68 +++++-- src/CoreEx.Validation/ValidatorT.cs | 1 - src/CoreEx/Abstractions/ETagGenerator.cs | 118 ++++++------ .../IServiceCollectionExtensions.cs | 21 +++ src/CoreEx/Abstractions/ObjectExtensions.cs | 1 + .../Reflection/IReflectionCache.cs | 11 ++ .../Abstractions/Reflection/ITypeReflector.cs | 3 +- .../Reflection/PropertyExpression.cs | 2 +- .../Reflection/PropertyExpressionT.cs | 2 +- .../Reflection/TypeReflectorArgs.cs | 6 +- .../Abstractions/Reflection/TypeReflectorT.cs | 2 +- src/CoreEx/Configuration/SettingsBase.cs | 92 ++++----- src/CoreEx/Entities/CompositeKey.cs | 16 ++ src/CoreEx/Events/Subscribing/ErrorHandler.cs | 1 + .../Events/Subscribing/ErrorHandling.cs | 1 + src/CoreEx/ExecutionContext.cs | 44 ++++- src/CoreEx/Hosting/Work/WorkState.cs | 6 + src/CoreEx/Hosting/Work/WorkStateArgs.cs | 6 + .../Hosting/Work/WorkStateOrchestrator.cs | 24 ++- src/CoreEx/Http/HttpExtensions.cs | 6 +- src/CoreEx/Http/TypedHttpClientBase.cs | 1 - src/CoreEx/Http/TypedHttpClientBaseT.cs | 2 +- src/CoreEx/Json/IJsonPreFilterInspector.cs | 2 +- src/CoreEx/Mapping/Converters/ConverterT.cs | 12 ++ .../Converters/DateTimeToStringConverter.cs | 12 ++ .../EncodedStringToDateTimeConverter.cs | 12 ++ .../EncodedStringToUInt32Converter.cs | 12 ++ src/CoreEx/Mapping/Converters/IConverterT.cs | 20 +- .../Converters/ObjectToJsonConverter.cs | 12 ++ .../Converters/ReferenceDataCodeConverter.cs | 12 ++ .../Converters/ReferenceDataIdConverter.cs | 12 ++ .../Converters/StringToBase64Converter.cs | 12 ++ .../Converters/StringToTypeConverter.cs | 14 +- .../Converters/TypeToStringConverter.cs | 14 +- .../RefData/IReferenceDataCollection.cs | 2 +- src/CoreEx/RefData/ReferenceDataCollection.cs | 3 - .../RefData/ReferenceDataOrchestrator.cs | 16 +- .../Reflection/TypeReflectorTest.cs | 34 ++++ .../Configuration/SettingsBaseTest.cs | 23 ++- .../Hosting/Work/WorkStateOrchestratorTest.cs | 14 ++ .../Validation/Rules/ExistsRuleTest.cs | 10 +- .../Framework/WebApis/WebApiPublisherTest.cs | 2 +- .../Framework/WebApis/WebApiTest.cs | 77 +++++++- .../Framework/WebApis/WebApiWithResultTest.cs | 34 +++- 65 files changed, 1020 insertions(+), 379 deletions(-) create mode 100644 src/CoreEx.Database/Mapping/DatabaseMapperExT.cs create mode 100644 src/CoreEx.Database/Mapping/DatabaseMapperExT2.cs create mode 100644 src/CoreEx.Database/Mapping/IDatabaseMapperEx.cs create mode 100644 src/CoreEx.Database/Mapping/IDatabaseMapperExT.cs create mode 100644 src/CoreEx/Abstractions/Reflection/IReflectionCache.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8defbcaa..9efe61a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ Represents the **NuGet** versions. +## v3.13.0 +- *Enhancement*: Added `DatabaseMapperEx` enabling extended/explicit mapping where performance is critical versus existing that uses reflection and compiled expressions; can offer up to 40%+ improvement in some scenarios. +- *Enhancement*: The `AddMappers()` and `AddValidators()` extension methods now also support two or three assembly specification overloads. +- *Enhancement*: A `WorkState.UserName` has been added to enable the tracking of the user that initiated the work; this is then checked to ensure that only the initiating user can interact with their own work state. +- *Fixed:* The `ReferenceDataOrchestrator.GetByTypeAsync` has had the previous sync-over-async corrected to be fully async. +- *Fixed*: Validation extensions `Exists` and `ExistsAsync` which expect a non-null resultant value have been renamed to `ValueExists` and `ValueExistsAsync` to improve usability; also they are `IResult` aware and will act accordingly. +- *Fixed*: The `ETag` HTTP handling has been updated to correctly output and expect the weak `W/"xxxx"` format. +- *Fixed*: The `ETagGenerator` implementation has been further optimized to minimize unneccessary string allocations. +- *Fixed*: The `ValueContentResult` will only generate a response header ETag (`ETagGenerator`) for a `GET` or `HEAD` request. The underlying result `IETag.ETag` is used as-is where there is no query string; otherwise, generates as assumes query string will alter result (i.e. filtering, paging, sorting, etc.). The result `IETag.ETag` is unchanged so the consumer can still use as required for a further operation. +- *Fixed*: The `SettingsBase` has been optimized. The internal recursion checking has been removed and as such an endless loop (`StackOverflowException`) may occur where misconfigured; given frequency of `IConfiguration` usage the resulting performance is deemed more important. Additionally, `prefixes` are now optional. + - The existing support of referencing a settings property by name (`settings.GetValue("NamedProperty")`) and it using reflection to find before querying the `IConfiguration` has been removed. This was not a common, or intended usage, and was somewhat magical, and finally was non-performant. + ## v3.12.0 - *Enhancement*: Added new `CoreEx.Database.Postgres` project/package to support [PostgreSQL](https://www.postgresql.org/) database capabilities. Primarily encapsulates the open-source [`Npqsql`](https://www.npgsql.org/) .NET ADO database provider for PostgreSQL. - Added `EncodedStringToUInt32Converter` to support PostgreSQL `xmin` column encoding as the row version/etag. diff --git a/Common.targets b/Common.targets index c90a67c7..a0a3db9a 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 3.12.0 + 3.13.0 preview Avanade Avanade diff --git a/src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs b/src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs index 2a2f9592..ca8d4e71 100644 --- a/src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs +++ b/src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs @@ -9,11 +9,10 @@ using Microsoft.Net.Http.Headers; using System; using System.Collections; -using System.Diagnostics.CodeAnalysis; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Mime; -using System.Text; using System.Threading.Tasks; namespace CoreEx.AspNetCore.WebApis @@ -70,7 +69,7 @@ public override Task ExecuteResultAsync(ActionContext context) var headers = context.HttpContext.Response.GetTypedHeaders(); if (ETag != null) - headers.ETag = new EntityTagHeaderValue(ETagGenerator.FormatETag(ETag)); + headers.ETag = new EntityTagHeaderValue(ETagGenerator.FormatETag(ETag), true); if (Location != null) headers.Location = Location; @@ -149,34 +148,41 @@ public static bool TryCreateValueContentResult(T value, HttpStatusCode status throw new InvalidOperationException("Function has not returned a result; no AlternateStatusCode has been configured to return."); } + // Where there is etag support and it is null (assumes auto-generation) then generate from the full value JSON contents as the baseline value. + var isTextSerializationEnabled = ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled; + var etag = value is IETag vetag ? vetag.ETag : null; + if (etag is null) + { + if (isTextSerializationEnabled) + ExecutionContext.Current.IsTextSerializationEnabled = false; + + etag = ETagGenerator.Generate(jsonSerializer, value); + if (value is IETag vetag2) + vetag2.ETag = etag; + } + // Where IncludeText is selected then enable before serialization occurs. if (requestOptions.IncludeText && ExecutionContext.HasCurrent) ExecutionContext.Current.IsTextSerializationEnabled = true; - // Serialize and generate the etag whilst also applying any filtering of the data where selected. + // Serialize the value performing any filtering as per the request options. string? json = null; - bool hasETag = TryGetETag(val, out var etag); - - Action? inspector; if (requestOptions.IncludeFields != null && requestOptions.IncludeFields.Length > 0) - { - inspector = hasETag ? null : fi => etag = GenerateETag(requestOptions, val, fi.ToJsonString(), jsonSerializer); - jsonSerializer.TryApplyFilter(val, requestOptions.IncludeFields, out json, JsonPropertyFilter.Include, preFilterInspector: inspector); - } + jsonSerializer.TryApplyFilter(val, requestOptions.IncludeFields, out json, JsonPropertyFilter.Include); else if (requestOptions.ExcludeFields != null && requestOptions.ExcludeFields.Length > 0) - { - inspector = hasETag ? null : fi => etag = GenerateETag(requestOptions, val, fi.ToJsonString(), jsonSerializer); - jsonSerializer.TryApplyFilter(val, requestOptions.ExcludeFields, out json, JsonPropertyFilter.Exclude, preFilterInspector: inspector); - } + jsonSerializer.TryApplyFilter(val, requestOptions.ExcludeFields, out json, JsonPropertyFilter.Exclude); else - { json = jsonSerializer.Serialize(val); - if (!hasETag) - etag = GenerateETag(requestOptions, val, json, jsonSerializer); - } + + // Generate the etag from the final JSON serialization and check for not-modified. + var result = GenerateETag(requestOptions, val, json, jsonSerializer); + + // Reset the text serialization flag. + if (ExecutionContext.HasCurrent) + ExecutionContext.Current.IsTextSerializationEnabled = isTextSerializationEnabled; // Check for not-modified and return status accordingly. - if (checkForNotModified && etag == requestOptions.ETag) + if (checkForNotModified && result.etag == requestOptions.ETag) { primaryResult = null; alternateResult = new StatusCodeResult((int)HttpStatusCode.NotModified); @@ -184,92 +190,59 @@ public static bool TryCreateValueContentResult(T value, HttpStatusCode status } // Create and return the ValueContentResult. - primaryResult = new ValueContentResult(json, statusCode, etag, paging, location); + primaryResult = new ValueContentResult(result.json!, statusCode, result.etag ?? etag, paging, location); alternateResult = null; return true; } /// - /// Determines whether an or value exists and returns where found. - /// - /// The value. - /// The ETag for the value where it exists. - /// true indicates that the ETag value exists; otherwise, false to generate. - internal static bool TryGetETag(object value, [NotNullWhen(true)] out string? etag) - { - if (value is IETag ietag && ietag.ETag != null) - { - etag = ietag.ETag; - return true; - } - - if (ExecutionContext.HasCurrent && ExecutionContext.Current.ETag != null) - { - etag = ExecutionContext.Current.ETag; - return true; - } - - etag = null; - return false; - } - - /// - /// Establish the ETag for the value/json. + /// Establish (use existing or generate) the ETag for the value/json. /// /// The . /// The value. /// The value serialized to JSON. /// The . - /// It is expected that is invoked prior to this to determine whether generation is required. - internal static string GenerateETag(WebApiRequestOptions requestOptions, object value, string? json, IJsonSerializer jsonSerializer) + /// The etag and serialized JSON (where performed). + internal static (string? etag, string? json) GenerateETag(WebApiRequestOptions requestOptions, T value, string? json, IJsonSerializer jsonSerializer) { - if (value is IETag etag && etag.ETag != null) - return etag.ETag; + // Where not a GET or HEAD then no etag is generated; just use what we have. + if (!HttpMethods.IsGet(requestOptions.Request.Method) && !HttpMethods.IsHead(requestOptions.Request.Method)) + return (value is IETag etag ? etag.ETag : null, json); - if (ExecutionContext.HasCurrent && ExecutionContext.Current.ETag != null) - return ExecutionContext.Current.ETag; - - StringBuilder? sb = null; - if (value is not string && value is IEnumerable coll) + // Where no query string and there is an etag then that value should be leveraged as the fast-path. + if (!requestOptions.HasQueryString) { - sb = new StringBuilder(); - var hasEtags = true; + if (value is IETag etag && etag.ETag != null) + return (etag.ETag, json); - foreach (var item in coll) + // Where there is a collection then we need to generate a hash that represents the collection. + if (json is null && value is not string && value is IEnumerable coll) { - if (item is IETag cetag && cetag.ETag != null) - { - if (sb.Length > 0) - sb.Append(ETagGenerator.DividerCharacter); + var hasEtags = true; + var list = new List(); - sb.Append(cetag.ETag); - continue; + foreach (var item in coll) + { + if (item is IETag cetag && cetag.ETag is not null) + { + list.Add(cetag.ETag); + continue; + } + + // No longer can fast-path as there is no ETag. + hasEtags = false; + break; } - hasEtags = false; - break; - } - - if (!hasEtags) - { - sb.Clear(); - sb.Append(json ??= jsonSerializer.Serialize(value)); - } - - // A GET with a collection result should include path and query with the etag. - if (HttpMethods.IsGet(requestOptions.Request.Method)) - { - sb.Append(ETagGenerator.DividerCharacter); - - if (requestOptions.Request.Path.HasValue) - sb.Append(requestOptions.Request.Path.Value); - - sb.Append(requestOptions.Request.QueryString.ToString()); + // Where fast-path then return the hash for the etag list. + if (hasEtags) + return (ETagGenerator.GenerateHash([.. list]), json); } } - // Generate a hash to represent the ETag. - return ETagGenerator.GenerateHash(sb != null && sb.Length > 0 ? sb.ToString() : json ?? jsonSerializer.Serialize(value)); + // Serialize and then generate a hash to represent the etag. + json ??= jsonSerializer.Serialize(value); + return (ETagGenerator.GenerateHash(requestOptions.HasQueryString ? [json, requestOptions.Request.QueryString.ToString()] : [json]), json); } } } \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApi.cs b/src/CoreEx.AspNetCore/WebApis/WebApi.cs index e149656d..20a4446d 100644 --- a/src/CoreEx.AspNetCore/WebApis/WebApi.cs +++ b/src/CoreEx.AspNetCore/WebApis/WebApi.cs @@ -1,5 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Abstractions; using CoreEx.AspNetCore.Http; using CoreEx.Configuration; using CoreEx.Entities; @@ -710,19 +711,29 @@ private async Task PutInternalAsync(HttpRequest request, /// private ConcurrencyException? ConcurrencyETagMatching(WebApiParam wap, TValue getValue, TValue putValue, bool autoConcurrency) { - var et = putValue as IETag; - if (et != null || autoConcurrency) + var ptag = putValue as IETag; + if (ptag != null || autoConcurrency) { - string? etag = et?.ETag ?? wap.RequestOptions.ETag; + string? etag = wap.RequestOptions.ETag ?? ptag?.ETag; if (string.IsNullOrEmpty(etag)) return new ConcurrencyException($"An 'If-Match' header is required for an HTTP {wap.Request.Method} where the underlying entity supports concurrency (ETag)."); if (etag != null) { - if (!ValueContentResult.TryGetETag(getValue!, out var getEt)) - getEt = ValueContentResult.GenerateETag(wap.RequestOptions, getValue!, null, JsonSerializer); + var gtag = getValue is IETag getag ? getag.ETag : null; + if (gtag is null) + { + var isTextSerializationEnabled = ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled; + if (isTextSerializationEnabled) + ExecutionContext.Current.IsTextSerializationEnabled = false; + + gtag = ETagGenerator.Generate(JsonSerializer, getValue); + + if (ExecutionContext.HasCurrent) + ExecutionContext.Current.IsTextSerializationEnabled = isTextSerializationEnabled; + } - if (etag != getEt) + if (etag != gtag) return new ConcurrencyException(); } } diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs b/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs index 7c6cc81a..f9901cb3 100644 --- a/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs +++ b/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs @@ -4,7 +4,6 @@ using CoreEx.Http; using CoreEx.RefData; using Microsoft.AspNetCore.Http; -using Microsoft.Net.Http.Headers; using System; using System.Collections.Generic; using System.Linq; @@ -25,17 +24,13 @@ public class WebApiRequestOptions public WebApiRequestOptions(HttpRequest httpRequest) { Request = httpRequest.ThrowIfNull(nameof(httpRequest)); - GetQueryStringOptions(Request.Query); - - if (httpRequest.Headers != null && httpRequest.Headers.Count > 0) - { - if (httpRequest.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var vals) || httpRequest.Headers.TryGetValue(HeaderNames.IfMatch, out vals)) - { - var etag = vals.FirstOrDefault()?.Trim(); - if (!string.IsNullOrEmpty(etag)) - ETag = etag.Trim('\"'); - } - } + HasQueryString = GetQueryStringOptions(Request.Query); + + // Get the raw ETag from the request headers. + var rth = httpRequest.GetTypedHeaders(); + var etag = rth.IfNoneMatch.FirstOrDefault()?.Tag ?? rth.IfMatch.FirstOrDefault()?.Tag; + if (etag.HasValue) + ETag = etag.Value.Substring(1, etag.Value.Length - 2); } /// @@ -43,10 +38,15 @@ public WebApiRequestOptions(HttpRequest httpRequest) /// public HttpRequest Request { get; } + /// + /// Indicates whether the has a query string. + /// + public bool HasQueryString { get; } + /// /// Gets or sets the entity tag that was passed as either a If-None-Match header where ; otherwise, an If-Match header. /// - /// Automatically adds quoting to be ETag format compliant. + /// Represents the underlying ray value; i.e. is stripped of any W/"xxxx" formatting. public string? ETag { get; set; } /// @@ -79,10 +79,10 @@ public WebApiRequestOptions(HttpRequest httpRequest) /// /// Gets the options from the . /// - private void GetQueryStringOptions(IQueryCollection query) + private bool GetQueryStringOptions(IQueryCollection query) { if (query == null || query.Count == 0) - return; + return false; var fields = GetNamedQueryString(query, HttpConsts.IncludeFieldsQueryStringNames); if (!string.IsNullOrEmpty(fields)) @@ -96,6 +96,7 @@ private void GetQueryStringOptions(IQueryCollection query) IncludeInactive = HttpExtensions.ParseBoolValue(GetNamedQueryString(query, HttpConsts.IncludeInactiveQueryStringNames, "true")); Paging = GetPagingArgs(query); + return true; } /// diff --git a/src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs b/src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs index 8205c8cc..6f9f9326 100644 --- a/src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs +++ b/src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs @@ -62,6 +62,7 @@ public WorkStateEntity(WorkState state) : this() TypeName = state.TypeName; Key = state.Key; CorrelationId = state.CorrelationId; + UserName = state.UserName; Status = state.Status; Created = state.Created; Expiry = state.Expiry; @@ -161,6 +162,7 @@ public WorkDataEntity(BinaryData data) : this() TypeName = er.Value.TypeName, Key = er.Value.Key, CorrelationId = er.Value.CorrelationId, + UserName = er.Value.UserName, Status = er.Value.Status, Created = er.Value.Created, Expiry = er.Value.Expiry, diff --git a/src/CoreEx.Database/DatabaseRecord.cs b/src/CoreEx.Database/DatabaseRecord.cs index 40664a00..ccc6e718 100644 --- a/src/CoreEx.Database/DatabaseRecord.cs +++ b/src/CoreEx.Database/DatabaseRecord.cs @@ -23,6 +23,27 @@ public class DatabaseRecord(IDatabase database, DbDataReader dataReader) /// public DbDataReader DataReader { get; } = dataReader.ThrowIfNull(nameof(dataReader)); + /// + /// Gets the named column value. + /// + /// The column name. + /// The value. + public object? GetValue(string columnName) => GetValue(DataReader.GetOrdinal(columnName.ThrowIfNull(nameof(columnName)))); + + /// + /// Gets the specified column value. + /// + /// The ordinal index. + /// The value. + public object? GetValue(int ordinal) + { + if (DataReader.IsDBNull(ordinal)) + return default; + + var val = DataReader.GetValue(ordinal); + return val is DateTime dt ? Cleaner.Clean(dt, Database.DateTimeTransform) : val; + } + /// /// Gets the named column value. /// diff --git a/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs b/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs index 0a836bf1..72987ff9 100644 --- a/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs +++ b/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs @@ -299,7 +299,7 @@ public static Task DeleteAsync(this DatabaseCommand command, IDatabaseMapper map /// The value where found; otherwise, null. public static Task> GetWithResultAsync(this DatabaseCommand command, DatabaseArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, new() => command.ThrowIfNull(nameof(command)) - .Params(p => args.Mapper.MapPrimaryKeyParameters(p, OperationTypes.Get, key)) + .Params(p => args.Mapper.MapKeyToDb(key, p)) .SelectFirstOrDefaultWithResultAsync((IDatabaseMapper)args.Mapper, cancellationToken); /// @@ -392,7 +392,7 @@ public static Task DeleteWithResultAsync(this DatabaseCommand command, I public static async Task DeleteWithResultAsync(this DatabaseCommand command, DatabaseArgs args, CompositeKey key, CancellationToken cancellationToken = default) { var rowsAffectedResult = await command.ThrowIfNull(nameof(command)) - .Params(p => args.Mapper.MapPrimaryKeyParameters(p, OperationTypes.Get, key)) + .Params(p => args.Mapper.MapKeyToDb(key, p)) .ScalarWithResultAsync(cancellationToken).ConfigureAwait(false); return rowsAffectedResult.WhenAs(rowsAffected => rowsAffected < 1, _ => Result.NotFoundError()); diff --git a/src/CoreEx.Database/IDatabaseMapper.cs b/src/CoreEx.Database/IDatabaseMapper.cs index 74d29c06..f2cc5cb7 100644 --- a/src/CoreEx.Database/IDatabaseMapper.cs +++ b/src/CoreEx.Database/IDatabaseMapper.cs @@ -3,7 +3,6 @@ using CoreEx.Entities; using CoreEx.Mapping; using System; -using System.Data; namespace CoreEx.Database { @@ -34,21 +33,11 @@ public interface IDatabaseMapper void MapToDb(object? value, DatabaseParameterCollection parameters, OperationTypes operationType = OperationTypes.Unspecified); /// - /// Maps the primary key from the and adds to the . + /// Maps the and adds to the . /// - /// The . - /// The single that indicates that a is being performed; therefore, any key properties that are auto-generated will have a parameter - /// direction of versus . - /// The value. - void MapPrimaryKeyParameters(DatabaseParameterCollection parameters, OperationTypes operationType, object? value); - - /// - /// Maps the primary key for the listed and adds to the . - /// - /// The . - /// The single that indicates that a is being performed; therefore, any key properties that are auto-generated will have a parameter - /// direction of versus . /// The primary . - void MapPrimaryKeyParameters(DatabaseParameterCollection parameters, OperationTypes operationType, CompositeKey key); + /// The . + /// This is used to map the only the key parameters; for example a Get or Delete operation. + void MapKeyToDb(CompositeKey key, DatabaseParameterCollection parameters); } } \ No newline at end of file diff --git a/src/CoreEx.Database/IDatabaseMapperT.cs b/src/CoreEx.Database/IDatabaseMapperT.cs index c7111f2b..c936fe48 100644 --- a/src/CoreEx.Database/IDatabaseMapperT.cs +++ b/src/CoreEx.Database/IDatabaseMapperT.cs @@ -3,7 +3,6 @@ using CoreEx.Entities; using CoreEx.Mapping; using System; -using System.Data; namespace CoreEx.Database { @@ -39,18 +38,6 @@ public interface IDatabaseMapper : IDatabaseMapper void MapToDb(TSource? value, DatabaseParameterCollection parameters, OperationTypes operationType = OperationTypes.Unspecified); /// - void IDatabaseMapper.MapPrimaryKeyParameters(DatabaseParameterCollection parameters, OperationTypes operationType, object? value) => MapPrimaryKeyParameters(parameters, operationType, (TSource?)value); - - /// - /// Maps the primary key from the and adds to the . - /// - /// The . - /// The single that indicates that a is being performed; therefore, any key properties that are auto-generated will have a parameter - /// direction of versus . - /// The value. - void MapPrimaryKeyParameters(DatabaseParameterCollection parameters, OperationTypes operationType, TSource? value) => throw new NotSupportedException(); - - /// - void IDatabaseMapper.MapPrimaryKeyParameters(DatabaseParameterCollection parameters, OperationTypes operationType, CompositeKey key) => throw new NotSupportedException(); + void IDatabaseMapper.MapKeyToDb(CompositeKey key, DatabaseParameterCollection parameters) => throw new NotSupportedException(); } } \ No newline at end of file diff --git a/src/CoreEx.Database/IDatabaseParametersExtensions.cs b/src/CoreEx.Database/IDatabaseParametersExtensions.cs index 14ef4754..09e300d7 100644 --- a/src/CoreEx.Database/IDatabaseParametersExtensions.cs +++ b/src/CoreEx.Database/IDatabaseParametersExtensions.cs @@ -2,6 +2,7 @@ using CoreEx.Database.Mapping; using CoreEx.Entities; +using CoreEx.Mapping; using CoreEx.Mapping.Converters; using System; using System.Collections.Generic; @@ -450,5 +451,20 @@ public static TSelf PagingParams(this IDatabaseParameters paramete } #endregion + + /// + /// Sets the to when the is . + /// + /// The . + /// The single value being performed to enable conditional execution where appropriate. + /// The to support fluent-style method-chaining. + /// Where not then the will remain unchanged. + public static DbParameter SetDirectionToOutputOnCreate(this DbParameter parameter, OperationTypes operationType) + { + if (operationType == OperationTypes.Create) + parameter.Direction = ParameterDirection.Output; + + return parameter; + } } } \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/DatabaseMapper.cs b/src/CoreEx.Database/Mapping/DatabaseMapper.cs index f3c4fcb4..45f18a76 100644 --- a/src/CoreEx.Database/Mapping/DatabaseMapper.cs +++ b/src/CoreEx.Database/Mapping/DatabaseMapper.cs @@ -1,5 +1,9 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Entities; +using CoreEx.Mapping; +using System; + namespace CoreEx.Database.Mapping { /// @@ -8,16 +12,25 @@ namespace CoreEx.Database.Mapping public static class DatabaseMapper { /// - /// Creates a where properties are added manually. + /// Creates a where properties are added manually (leverages reflection). /// /// A . + /// Where performance is critical consider using . public static DatabaseMapper Create() where TSource : class, new() => new(false); /// - /// Creates a where properties are added automatically (assumes the property, column and parameter names share the same name). + /// Creates a where properties are added automatically using reflection (assumes the property, column and parameter names share the same name). /// /// An array of source property names to ignore. /// A . + /// Where performance is critical consider using . public static DatabaseMapper CreateAuto(params string[] ignoreSrceProperties) where TSource : class, new() => new(true, ignoreSrceProperties); + + /// + /// Creates a where the underlying implementation is added explicitly (extended, offers potential performance benefits). + /// + /// A . + public static DatabaseMapperEx CreateExtended(Action? mapFromDb = null, Action? mapKeyToDb = null, Action? mapToDb = null) where TSource : class, new() + => new(mapFromDb, mapKeyToDb, mapToDb); } } \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/DatabaseMapperExT.cs b/src/CoreEx.Database/Mapping/DatabaseMapperExT.cs new file mode 100644 index 00000000..97f1efd2 --- /dev/null +++ b/src/CoreEx.Database/Mapping/DatabaseMapperExT.cs @@ -0,0 +1,175 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using CoreEx.Mapping; +using System; + +namespace CoreEx.Database.Mapping +{ + /// + /// Provides mapping from a and database with the extended/explicitly provided logic. + /// + /// The source . + /// The implementation. + /// The implementation. + /// The implementation. + /// This enables the most optimized performance by enabling explicit code to be specified. + public class DatabaseMapperEx(Action? mapFromDb = null, Action? mapKeyToDb = null, Action? mapToDb = null) : IDatabaseMapperEx where TSource : class, new() + { + private readonly Action? _mapFromDb = mapFromDb; + private readonly Action? _mapToDb = mapToDb; + private readonly Action? _mapKeyToDb = mapKeyToDb; + private IDatabaseMapperEx? _extendMapper; + + /// + /// Indicates that a null should be returned from where the resulting value . + /// + /// Defaults to true. + public bool NullWhenInitial { get; set; } = true; + + /// + /// Inherits (includes) the mappings from the selected . + /// + /// The source ; must inherit from . + /// The to inherit from. + /// The to support fluent-style method-chaining. + public DatabaseMapperEx InheritMapper(IDatabaseMapperEx baseMapper) where TBase : class, new() + { + if (_extendMapper is not null) + throw new InvalidOperationException($"An {nameof(InheritMapper)} may only be invoked once for a mapper."); + + if (!typeof(TSource).IsSubclassOf(typeof(TBase))) + throw new ArgumentException($"Type {typeof(TSource).Name} must inherit from {typeof(TBase).Name}.", nameof(baseMapper)); + + _extendMapper = baseMapper.ThrowIfNull(nameof(baseMapper)); + return this; + } + + /// + public void MapFromDb(DatabaseRecord record, TSource value, OperationTypes operationType) + { + record.ThrowIfNull(nameof(record)); + value.ThrowIfNull(nameof(value)); + + _extendMapper?.MapFromDb(record, value, operationType); + _mapFromDb?.Invoke(record, value, operationType); + OnMapFromDb(record, value, operationType); + } + + /// + public TSource? MapFromDb(DatabaseRecord record, OperationTypes operationType = OperationTypes.Unspecified) + { + var value = new TSource(); + MapFromDb(record, value, operationType); + return NullWhenInitial ? ((value is not null && value is IInitial ii && ii.IsInitial) ? null : value) : null; + } + + /// + /// Extension opportunity when performing a . + /// + /// The source value. + /// The . + /// The single value being performed to enable conditional execution where appropriate. + /// The source value. + protected virtual void OnMapFromDb(DatabaseRecord record, TSource value, OperationTypes operationType) { } + + /// + public void MapToDb(TSource? value, DatabaseParameterCollection parameters, OperationTypes operationType = OperationTypes.Unspecified) + { + parameters.ThrowIfNull(nameof(parameters)); + if (value is not null) + { + _extendMapper?.MapToDb(value, parameters, operationType); + _mapToDb?.Invoke(value, parameters, operationType); + OnMapToDb(value, parameters, operationType); + } + } + + /// + /// Extension opportunity when performing a . + /// + /// The value. + /// The to update from the . + /// The single value being performed to enable conditional execution where appropriate. + protected virtual void OnMapToDb(TSource value, DatabaseParameterCollection parameters, OperationTypes operationType) { } + + /// + void IDatabaseMapper.MapKeyToDb(CompositeKey key, DatabaseParameterCollection parameters) + { + parameters.ThrowIfNull(nameof(parameters)); + _extendMapper?.MapKeyToDb(key, parameters); + _mapKeyToDb?.Invoke(key, parameters); + OnMapKeyToDb(key, parameters); + } + + /// + /// Extension opportunity when performing a . + /// + /// The primary . + /// The . + /// This is used to map the only the key parameters; for example a Get or Delete operation. + protected virtual void OnMapKeyToDb(CompositeKey key, DatabaseParameterCollection parameters) { } + + #region When* + + /// + /// When is a then the action is invoked. + /// + /// The singular . + /// The action to invoke. + public static void WhenGet(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.Get, operationType, action); + + /// + /// When is a then the action is invoked. + /// + /// The singular . + /// The action to invoke. + public static void WhenCreate(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.Create, operationType, action); + + /// + /// When is an then the action is invoked. + /// + /// The singular . + /// The action to invoke. + public static void WhenUpdate(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.Update, operationType, action); + + /// + /// When is a then the action is invoked. + /// + /// The singular . + /// The action to invoke. + public static void WhenDelete(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.Delete, operationType, action); + + /// + /// When is a then the action is invoked. + /// + /// The singular . + /// The action to invoke. + public static void WhenAnyExceptGet(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.AnyExceptGet, operationType, action); + + /// + /// When is a then the action is invoked. + /// + /// The singular . + /// The action to invoke. + public static void WhenAnyExceptCreate(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.AnyExceptCreate, operationType, action); + + /// + /// When is a then the action is invoked. + /// + /// The singular . + /// The action to invoke. + public static void WhenAnyExceptUpdate(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.AnyExceptUpdate, operationType, action); + + /// + /// When the matches the then the is invoked. + /// + private static void WhenOperationType(OperationTypes expectedOperationTypes, OperationTypes operationType, Action action) + { + if (expectedOperationTypes.HasFlag(operationType)) + action?.Invoke(); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/DatabaseMapperExT2.cs b/src/CoreEx.Database/Mapping/DatabaseMapperExT2.cs new file mode 100644 index 00000000..8e22e36c --- /dev/null +++ b/src/CoreEx.Database/Mapping/DatabaseMapperExT2.cs @@ -0,0 +1,21 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using System; + +namespace CoreEx.Database.Mapping +{ + /// + /// Provides with a singleton . + /// + /// The source . + /// The mapper . + public abstract class DatabaseMapperEx : DatabaseMapperEx where TSource : class, new() where TMapper : DatabaseMapperEx, new() + { + private static readonly TMapper _default = new(); + + /// + /// Gets the current instance of the mapper. + /// + public static TMapper Default => _default ?? throw new InvalidOperationException("An instance of this Mapper cannot be referenced as it is still being constructed; beware that you may have a circular reference within the constructor."); + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/DatabaseMapperT.cs b/src/CoreEx.Database/Mapping/DatabaseMapperT.cs index 8e7e32a0..d4f90692 100644 --- a/src/CoreEx.Database/Mapping/DatabaseMapperT.cs +++ b/src/CoreEx.Database/Mapping/DatabaseMapperT.cs @@ -14,9 +14,10 @@ namespace CoreEx.Database.Mapping { /// - /// Provides mapping from a and database. + /// Provides mapping from a and database using reflection. /// /// The source . + /// Where performance is critical consider using . public class DatabaseMapper : IDatabaseMapper, IDatabaseMapperMappings where TSource : class, new() { private readonly List _mappings = []; @@ -261,31 +262,13 @@ protected virtual void OnMapToDb(TSource value, DatabaseParameterCollection para /// The source value. protected virtual TSource? OnMapFromDb(TSource value, DatabaseRecord record, OperationTypes operationType) => value; - /// - void IDatabaseMapper.MapPrimaryKeyParameters(DatabaseParameterCollection parameters, OperationTypes operationType, TSource? value) - { - parameters.ThrowIfNull(nameof(parameters)); - if (value == null) return; - - foreach (var p in _mappings.Where(x => x.IsPrimaryKey)) - { - var dir = (operationType == OperationTypes.Create && p.IsPrimaryKeyGeneratedOnCreate) ? ParameterDirection.Output : ParameterDirection.Input; - var pval = DatabaseMapper.ConvertPropertyValueForDb(p, p.PropertyExpression.GetValue(value)); - - if (p.DbType.HasValue) - parameters.AddParameter(p.ParameterName, pval, p.DbType.Value, dir); - else - parameters.AddParameter(p.ParameterName, pval, dir); - } - } - /// /// Converts the property value for the database. /// private static object? ConvertPropertyValueForDb(IPropertyColumnMapper pcm, object? value) => pcm.Converter == null ? value : pcm.Converter.ConvertToDestination(value); /// - void IDatabaseMapper.MapPrimaryKeyParameters(DatabaseParameterCollection parameters, OperationTypes operationType, CompositeKey key) + void IDatabaseMapper.MapKeyToDb(CompositeKey key, DatabaseParameterCollection parameters) { parameters.ThrowIfNull(nameof(parameters)); var pk = _mappings.Where(x => x.IsPrimaryKey).ToArray(); @@ -295,13 +278,12 @@ void IDatabaseMapper.MapPrimaryKeyParameters(DatabaseParameterCollection paramet for (int i = 0; i < key.Args.Length; i++) { var p = pk[i]; - var dir = (operationType == OperationTypes.Create && p.IsPrimaryKeyGeneratedOnCreate) ? ParameterDirection.Output : ParameterDirection.Input; var pval = DatabaseMapper.ConvertPropertyValueForDb(p, key.Args[i]); if (p.DbType.HasValue) - parameters.AddParameter(p.ParameterName, pval, p.DbType.Value, dir); + parameters.AddParameter(p.ParameterName, pval, p.DbType.Value); else - parameters.AddParameter(p.ParameterName, pval, dir); + parameters.AddParameter(p.ParameterName, pval); } } } diff --git a/src/CoreEx.Database/Mapping/DatabaseMapperT2.cs b/src/CoreEx.Database/Mapping/DatabaseMapperT2.cs index f5cf6393..a2a01d7b 100644 --- a/src/CoreEx.Database/Mapping/DatabaseMapperT2.cs +++ b/src/CoreEx.Database/Mapping/DatabaseMapperT2.cs @@ -9,6 +9,7 @@ namespace CoreEx.Database.Mapping /// /// The source . /// The mapper . + /// Where performance is critical consider using . public abstract class DatabaseMapper : DatabaseMapper where TSource : class, new() where TMapper : DatabaseMapper, new() { private static readonly TMapper _default = new(); diff --git a/src/CoreEx.Database/Mapping/IDatabaseMapperEx.cs b/src/CoreEx.Database/Mapping/IDatabaseMapperEx.cs new file mode 100644 index 00000000..55dd8e9f --- /dev/null +++ b/src/CoreEx.Database/Mapping/IDatabaseMapperEx.cs @@ -0,0 +1,20 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Mapping; + +namespace CoreEx.Database.Mapping +{ + /// + /// Defines a database extended/explicit mapper. + /// + public interface IDatabaseMapperEx : IDatabaseMapper + { + /// + /// Maps from a into the + /// + /// The . + /// The value to map into. + /// The single value being performed to enable conditional execution where appropriate. + void MapFromDb(DatabaseRecord record, object value, OperationTypes operationType = OperationTypes.Unspecified); + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/IDatabaseMapperExT.cs b/src/CoreEx.Database/Mapping/IDatabaseMapperExT.cs new file mode 100644 index 00000000..bb5ac910 --- /dev/null +++ b/src/CoreEx.Database/Mapping/IDatabaseMapperExT.cs @@ -0,0 +1,26 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Mapping; + +namespace CoreEx.Database.Mapping +{ + /// + /// Defines a database extended/explicit mapper. + /// + /// The . + public interface IDatabaseMapperEx : IDatabaseMapperEx, IDatabaseMapper + { + /// + void IDatabaseMapperEx.MapFromDb(DatabaseRecord record, object value, OperationTypes operationType) + => MapFromDb(record, (TSource)value, operationType); + + /// + /// Maps from a into the + /// + /// The . + /// The value to extend map into. + /// The single value being performed to enable conditional execution where appropriate. + /// The updated . + void MapFromDb(DatabaseRecord record, TSource value, OperationTypes operationType = OperationTypes.Unspecified); + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/PropertyColumnMapper.cs b/src/CoreEx.Database/Mapping/PropertyColumnMapper.cs index 9719f725..ed296914 100644 --- a/src/CoreEx.Database/Mapping/PropertyColumnMapper.cs +++ b/src/CoreEx.Database/Mapping/PropertyColumnMapper.cs @@ -207,7 +207,7 @@ private void MapFromDb(DatabaseRecord record, TSource value, OperationTypes oper if (!OperationTypes.HasFlag(operationType)) return; - TSourceProperty? pval = default; + TSourceProperty? pval; if (Mapper != null) pval = (TSourceProperty?)Mapper.MapFromDb(record, operationType); else @@ -219,6 +219,8 @@ private void MapFromDb(DatabaseRecord record, TSource value, OperationTypes oper else pval = (TSourceProperty)Converter.ConvertToSource(record.DataReader.GetValue(ordinal))!; } + else + pval = default; } _propertyExpression.SetValue(value, pval); diff --git a/src/CoreEx.Validation/Rules/ExistsRule.cs b/src/CoreEx.Validation/Rules/ExistsRule.cs index 44093cb9..06643fbe 100644 --- a/src/CoreEx.Validation/Rules/ExistsRule.cs +++ b/src/CoreEx.Validation/Rules/ExistsRule.cs @@ -1,6 +1,7 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx using CoreEx.Http; +using CoreEx.Results; using System; using System.Net; using System.Net.Http; @@ -37,6 +38,7 @@ public class ExistsRule : ValueRuleBase /// Initializes a new instance of the class with an function that must return a value. /// /// The exists function. + /// Where the resultant value is an then existence is confirmed when and the the underlying is not null. public ExistsRule(Func> exists) => _existsNotNull = exists.ThrowIfNull(nameof(exists)); /// @@ -77,8 +79,18 @@ protected override async Task ValidateAsync(PropertyContext } else { - if (await _existsNotNull!(context.Parent.Value!, cancellationToken).ConfigureAwait(false) == null) + var value = await _existsNotNull!(context.Parent.Value!, cancellationToken).ConfigureAwait(false); + if (value == null) CreateErrorMessage(context); + + if (value is IResult ir) + { + if (ir.IsFailure) + context.Parent.SetFailureResult(new Result(ir.Error)); + + if (ir.Value is null) + CreateErrorMessage(context); + } } } diff --git a/src/CoreEx.Validation/ValidationExtensions.cs b/src/CoreEx.Validation/ValidationExtensions.cs index 7c6bbaf6..6c369830 100644 --- a/src/CoreEx.Validation/ValidationExtensions.cs +++ b/src/CoreEx.Validation/ValidationExtensions.cs @@ -238,7 +238,7 @@ public static IPropertyRule ExistsAsync( => rule.ThrowIfNull(nameof(rule)).AddRule(new ExistsRule(exists) { ErrorText = errorText }); /// - /// Adds a validation where the rule value is true to verify it exists (see ). + /// Adds a validation where the rule resultant value is true to verify it exists (see ). /// /// The entity . /// The property . @@ -257,20 +257,22 @@ public static IPropertyRule Exists(this /// The being extended. /// The exists function. /// The error message format text (overrides the default). + /// Where the resultant value is an then existence is confirmed when and the the underlying is not null. /// A . - public static IPropertyRule ExistsAsync(this IPropertyRule rule, Func> exists, LText? errorText = null) where TEntity : class + public static IPropertyRule ValueExistsAsync(this IPropertyRule rule, Func> exists, LText? errorText = null) where TEntity : class => rule.ThrowIfNull(nameof(rule)).AddRule(new ExistsRule(exists) { ErrorText = errorText }); /// - /// Adds a validation where the rule is not null to verify it exists (see ). + /// Adds a validation where the rule resultant value is not null to verify it exists (see ). /// /// The entity . /// The property . /// The being extended. /// The exists function. /// The error message format text (overrides the default). + /// Where the resultant value is an then existence is confirmed when and the the underlying is not null. /// A . - public static IPropertyRule Exists(this IPropertyRule rule, object? exists, LText? errorText = null) where TEntity : class + public static IPropertyRule ValueExists(this IPropertyRule rule, object? exists, LText? errorText = null) where TEntity : class => rule.ThrowIfNull(nameof(rule)).AddRule(new ExistsRule((_, __) => Task.FromResult(exists != null)) { ErrorText = errorText }); /// diff --git a/src/CoreEx.Validation/ValidationServiceCollectionExtensions.cs b/src/CoreEx.Validation/ValidationServiceCollectionExtensions.cs index 56564a39..6b77d829 100644 --- a/src/CoreEx.Validation/ValidationServiceCollectionExtensions.cs +++ b/src/CoreEx.Validation/ValidationServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using CoreEx.Validation; using System; using System.Linq; +using System.Reflection; namespace Microsoft.Extensions.DependencyInjection { @@ -53,28 +54,63 @@ private static IServiceCollection AddValidatorInternal(this IService /// /// The to infer the underlying . /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddValidators(this IServiceCollection services) + => AddValidators(services, [typeof(TAssembly).Assembly]); + + /// + /// Adds all the validators from the specified and as scoped services. + /// + /// The to infer the underlying . + /// The to infer the underlying . + /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddValidators(this IServiceCollection services) + => AddValidators(services, [typeof(TAssembly1).Assembly, typeof(TAssembly2).Assembly]); + + /// + /// Adds all the validators from the specified , and as scoped services. + /// + /// The to infer the underlying . + /// The to infer the underlying . + /// The to infer the underlying . + /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddValidators(this IServiceCollection services) + => AddValidators(services, [typeof(TAssembly1).Assembly, typeof(TAssembly2).Assembly, typeof(TAssembly3).Assembly]); + + /// + /// Adds all the validators from the specified as scoped services. + /// + /// The . + /// The assemblies. /// Indicates whether to include internally defined types. /// Indicates whether to also register the interfaces with (default); otherwise, with (just the validator instance itself). /// The for fluent-style method-chaining. - public static IServiceCollection AddValidators(this IServiceCollection services, bool includeInternalTypes = false, bool alsoRegisterInterfaces = true) + public static IServiceCollection AddValidators(this IServiceCollection services, Assembly[] assemblies, bool includeInternalTypes = false, bool alsoRegisterInterfaces = true) { - var av = alsoRegisterInterfaces - ? typeof(ValidationServiceCollectionExtensions).GetMethod(nameof(AddValidatorWithInterfacesInternal), System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)! - : typeof(ValidationServiceCollectionExtensions).GetMethod(nameof(AddValidatorInternal), System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)!; + services.ThrowIfNull(nameof(services)); - foreach (var match in from type in includeInternalTypes ? typeof(TAssembly).Assembly.GetTypes() : typeof(TAssembly).Assembly.GetExportedTypes() - where !type.IsAbstract && !type.IsGenericTypeDefinition - let interfaces = type.GetInterfaces() - let genericInterfaces = interfaces.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValidatorEx<>)) - let @interface = genericInterfaces.FirstOrDefault() - let valueType = @interface?.GetGenericArguments().FirstOrDefault() - where @interface != null - select new { valueType, type }) + foreach (var assembly in assemblies.Distinct()) { - if (alsoRegisterInterfaces) - av.MakeGenericMethod(match.valueType, match.type).Invoke(null, new object[] { services }); - else - av.MakeGenericMethod(match.type).Invoke(null, new object[] { services }); + var av = alsoRegisterInterfaces + ? typeof(ValidationServiceCollectionExtensions).GetMethod(nameof(AddValidatorWithInterfacesInternal), BindingFlags.Static | BindingFlags.NonPublic)! + : typeof(ValidationServiceCollectionExtensions).GetMethod(nameof(AddValidatorInternal), BindingFlags.Static | BindingFlags.NonPublic)!; + + foreach (var match in from type in includeInternalTypes ? assembly.GetTypes() : assembly.GetExportedTypes() + where !type.IsAbstract && !type.IsGenericTypeDefinition + let interfaces = type.GetInterfaces() + let genericInterfaces = interfaces.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValidatorEx<>)) + let @interface = genericInterfaces.FirstOrDefault() + let valueType = @interface?.GetGenericArguments().FirstOrDefault() + where @interface != null + select new { valueType, type }) + { + if (alsoRegisterInterfaces) + av.MakeGenericMethod(match.valueType, match.type).Invoke(null, new object[] { services }); + else + av.MakeGenericMethod(match.type).Invoke(null, new object[] { services }); + } } return services; diff --git a/src/CoreEx.Validation/ValidatorT.cs b/src/CoreEx.Validation/ValidatorT.cs index 99f9346c..28e2b4b4 100644 --- a/src/CoreEx.Validation/ValidatorT.cs +++ b/src/CoreEx.Validation/ValidatorT.cs @@ -5,7 +5,6 @@ using CoreEx.Localization; using CoreEx.Results; using System; -using System.Linq; using System.Linq.Expressions; using System.Reflection; using System.Threading; diff --git a/src/CoreEx/Abstractions/ETagGenerator.cs b/src/CoreEx/Abstractions/ETagGenerator.cs index 2835cec8..a112bf63 100644 --- a/src/CoreEx/Abstractions/ETagGenerator.cs +++ b/src/CoreEx/Abstractions/ETagGenerator.cs @@ -3,8 +3,7 @@ using CoreEx.Entities; using CoreEx.Json; using System; -using System.Globalization; -using System.Text; +using System.Security.Cryptography; namespace CoreEx.Abstractions { @@ -14,80 +13,61 @@ namespace CoreEx.Abstractions public static class ETagGenerator { /// - /// Represents the divider character where ETag value is made up of multiple parts. - /// - public const char DividerCharacter = '|'; - - /// - /// Generates an ETag for a value by serializing to JSON and performing an hash. + /// Generates an ETag for a value by serializing to JSON and performing an hash. /// /// The . /// The . /// The value. - /// Optional extra part(s) to append to the JSON to include in the underlying hash computation. /// The generated ETag. - public static string? Generate(IJsonSerializer jsonSerializer, T? value, params string[] parts) where T : class + public static string? Generate(IJsonSerializer jsonSerializer, T? value) { jsonSerializer.ThrowIfNull(nameof(jsonSerializer)); - if (value == null) return null; - // Where value is IFormattable/IComparable use ToString; otherwise, JSON serialize. - var txt = ConvertToString(jsonSerializer, value); - - if (parts.Length > 0) - { - var sb = new StringBuilder(txt); - foreach (var ex in parts) - { - sb.Append(DividerCharacter); - sb.Append(ex); - } - - txt = sb.ToString(); - } - - return GenerateHash(txt); - } - - /// - /// Generates a hash of the string using . - /// - /// The text value to hash. - /// The hashed value. - public static string GenerateHash(string text) - { - var buf = Encoding.UTF8.GetBytes(text.ThrowIfNull(nameof(text))); + // Serialize to JSON and then hash. + byte[] hash; + var bd = jsonSerializer.SerializeToBinaryData(value); #if NETSTANDARD2_1 - using var sha256 = System.Security.Cryptography.SHA256.Create(); - var hash = new BinaryData(sha256.ComputeHash(buf)); + using var sha256 = SHA256.Create(); + hash = sha256.ComputeHash(bd.ToArray()); #else - var hash = System.Security.Cryptography.SHA256.HashData(buf); + hash = SHA256.HashData(bd); #endif return Convert.ToBase64String(hash); } /// - /// Converts the to a corresponding . + /// Generates a hash of the parts using . /// - /// The . - /// The value to convert. - /// A representation of the value. - private static string ConvertToString(IJsonSerializer jsonSerializer, object? value) + /// The parts to hash. + /// The hashed value. + public static string? GenerateHash(params string[] parts) { - if (value == null) - return string.Empty; + if (parts == null || parts.Length == 0) + return null; - if (value is string str) - return str; + byte[] hash; +#if NETSTANDARD2_1 + var input = parts.Length == 1 ? parts[0] : string.Concat(parts); + using var sha256 = SHA256.Create(); + hash = sha256.ComputeHash(new BinaryData(input).ToArray()); +#else + if (parts.Length == 1) + hash = SHA256.HashData(new BinaryData(parts[0])); + else + { + using var ih = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + foreach (var part in parts) + { + ih.AppendData(new BinaryData(part)); + } - if (value is DateTime dte) - return dte.ToString("o"); + hash = ih.GetCurrentHash(); + } - return (value is IFormattable ic) - ? ic.ToString(null, CultureInfo.InvariantCulture) - : ((value is IComparable) ? value.ToString() : jsonSerializer.Serialize(value)) ?? string.Empty; +#endif + return Convert.ToBase64String(hash); } /// @@ -95,13 +75,37 @@ private static string ConvertToString(IJsonSerializer jsonSerializer, object? va /// /// The value to format. /// The formatted . - public static string? FormatETag(string? value) => value == null || (value.Length > 1 && value.StartsWith("\"", StringComparison.InvariantCultureIgnoreCase) && value.EndsWith("\"", StringComparison.InvariantCultureIgnoreCase)) ? value : "\"" + value + "\""; + public static string? FormatETag(string? value) + { + if (value is null) + return null; + + if (value.StartsWith('\"') && value.EndsWith('\"')) + return value; + + if (value.StartsWith("W/\"") && value.EndsWith('\"')) + return value[2..]; + + return $"\"{value}\""; + } /// - /// Parses an by removing double quotes character bookends; for example '"abc"' would be formatted as 'abc'. + /// Parses an by removing any weak prefix ('W/') double quotes character bookends; for example '"abc"' would be formatted as 'abc'. /// /// The to unformat. /// The unformatted value. - public static string? ParseETag(string? etag) => etag is not null && etag.Length > 1 && etag.StartsWith("\"", StringComparison.InvariantCultureIgnoreCase) && etag.EndsWith("\"", StringComparison.InvariantCultureIgnoreCase) ? etag[1..^1] : etag; + public static string? ParseETag(string? etag) + { + if (string.IsNullOrEmpty(etag)) + return null; + + if (etag.StartsWith('\"') && etag.EndsWith('\"')) + return etag[1..^1]; + + if (etag.StartsWith("W/\"") && etag.EndsWith('\"')) + return etag[2..^1]; + + return etag; + } } } \ No newline at end of file diff --git a/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs b/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs index 0c16b66a..64fc21f4 100644 --- a/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs +++ b/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs @@ -320,6 +320,27 @@ public static IServiceCollection AddReferenceDataOrchestrator(this IS public static IServiceCollection AddMappers(this IServiceCollection services) => AddMappers(services, [typeof(TAssembly).Assembly]); + /// + /// Registers all the (s) from the specified and into a new that is then registered as a singleton service. + /// + /// The to infer the underlying . + /// The to infer the underlying . + /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddMappers(this IServiceCollection services) + => AddMappers(services, [typeof(TAssembly1).Assembly, typeof(TAssembly2).Assembly]); + + /// + /// Registers all the (s) from the specified , and into a new that is then registered as a singleton service. + /// + /// The to infer the underlying . + /// The to infer the underlying . + /// The to infer the underlying . + /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddMappers(this IServiceCollection services) + => AddMappers(services, [typeof(TAssembly1).Assembly, typeof(TAssembly2).Assembly, typeof(TAssembly3).Assembly]); + /// /// Registers all the (s) from the specified into a new that is then registered as a singleton service. /// diff --git a/src/CoreEx/Abstractions/ObjectExtensions.cs b/src/CoreEx/Abstractions/ObjectExtensions.cs index 3f03de19..6d41097e 100644 --- a/src/CoreEx/Abstractions/ObjectExtensions.cs +++ b/src/CoreEx/Abstractions/ObjectExtensions.cs @@ -12,6 +12,7 @@ namespace CoreEx /// /// Provides standard extensions. /// + [System.Diagnostics.DebuggerStepThrough] public static class ObjectExtensions { /// diff --git a/src/CoreEx/Abstractions/Reflection/IReflectionCache.cs b/src/CoreEx/Abstractions/Reflection/IReflectionCache.cs new file mode 100644 index 00000000..110afd26 --- /dev/null +++ b/src/CoreEx/Abstractions/Reflection/IReflectionCache.cs @@ -0,0 +1,11 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using Microsoft.Extensions.Caching.Memory; + +namespace CoreEx.Abstractions.Reflection +{ + /// + /// Represents a cache for reflection operations. + /// + public interface IReflectionCache : IMemoryCache { } +} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Reflection/ITypeReflector.cs b/src/CoreEx/Abstractions/Reflection/ITypeReflector.cs index d077adcb..e70235ee 100644 --- a/src/CoreEx/Abstractions/Reflection/ITypeReflector.cs +++ b/src/CoreEx/Abstractions/Reflection/ITypeReflector.cs @@ -45,8 +45,7 @@ public interface ITypeReflector /// /// Gets all the properties for the . /// - /// A read-only collection. - IReadOnlyCollection GetProperties(); + IEnumerable GetProperties(); /// /// Gets the for the specified property name where it exists. diff --git a/src/CoreEx/Abstractions/Reflection/PropertyExpression.cs b/src/CoreEx/Abstractions/Reflection/PropertyExpression.cs index fd6f3973..3cbba158 100644 --- a/src/CoreEx/Abstractions/Reflection/PropertyExpression.cs +++ b/src/CoreEx/Abstractions/Reflection/PropertyExpression.cs @@ -17,7 +17,7 @@ public static partial class PropertyExpression /// /// Gets the . /// - internal static IMemoryCache Cache => ExecutionContext.GetService() ?? (_fallbackCache ??= new MemoryCache(new MemoryCacheOptions())); + internal static IMemoryCache Cache => ExecutionContext.GetService() ?? (_fallbackCache ??= new MemoryCache(new MemoryCacheOptions())); /// /// Validates, creates and compiles the property expression; whilst also determinig the property friendly . diff --git a/src/CoreEx/Abstractions/Reflection/PropertyExpressionT.cs b/src/CoreEx/Abstractions/Reflection/PropertyExpressionT.cs index 8b9ed25b..a6b97050 100644 --- a/src/CoreEx/Abstractions/Reflection/PropertyExpressionT.cs +++ b/src/CoreEx/Abstractions/Reflection/PropertyExpressionT.cs @@ -43,7 +43,7 @@ public class PropertyExpression : IPropertyExpression /// A which contains (in order) the compiled , member name and resulting property text. internal static PropertyExpression CreateInternal(Expression> propertyExpression, IJsonSerializer jsonSerializer) { - if ((propertyExpression.ThrowIfNull(nameof(propertyExpression))).Body.NodeType != ExpressionType.MemberAccess) + if (propertyExpression.ThrowIfNull(nameof(propertyExpression)).Body.NodeType != ExpressionType.MemberAccess) throw new InvalidOperationException("Only Member access expressions are supported."); var cache = PropertyExpression.Cache; diff --git a/src/CoreEx/Abstractions/Reflection/TypeReflectorArgs.cs b/src/CoreEx/Abstractions/Reflection/TypeReflectorArgs.cs index ddc38076..4f77d526 100644 --- a/src/CoreEx/Abstractions/Reflection/TypeReflectorArgs.cs +++ b/src/CoreEx/Abstractions/Reflection/TypeReflectorArgs.cs @@ -12,8 +12,8 @@ namespace CoreEx.Abstractions.Reflection /// Provides the arguments passed to and through a . /// /// The . Defaults to . - /// The to use versus instantiating each per use (expensive operation). - public class TypeReflectorArgs(IJsonSerializer? jsonSerializer = null, IMemoryCache? cache = null) + /// The to use versus instantiating each per use (expensive operation). + public class TypeReflectorArgs(IJsonSerializer? jsonSerializer = null, IReflectionCache? cache = null) { private static readonly Lazy _default = new(() => new TypeReflectorArgs()); @@ -31,7 +31,7 @@ public class TypeReflectorArgs(IJsonSerializer? jsonSerializer = null, IMemoryCa /// Gets the to use versus instantiating each per use. /// /// The and enable additional basic policy configuration for the cached items. - public IMemoryCache Cache { get; } = cache ?? new MemoryCache(new MemoryCacheOptions()); + public IMemoryCache Cache { get; } = (MemoryCache?)cache ?? new MemoryCache(new MemoryCacheOptions()); /// /// Gets or sets the absolute expiration . Default to 24 hours. diff --git a/src/CoreEx/Abstractions/Reflection/TypeReflectorT.cs b/src/CoreEx/Abstractions/Reflection/TypeReflectorT.cs index 5c1acdb1..033a03b5 100644 --- a/src/CoreEx/Abstractions/Reflection/TypeReflectorT.cs +++ b/src/CoreEx/Abstractions/Reflection/TypeReflectorT.cs @@ -125,7 +125,7 @@ public IPropertyReflector GetProperty(string name) /// /// Gets all the properties. /// - public IReadOnlyCollection GetProperties() => new ReadOnlyCollection(_properties.Values.OfType().ToList()); + public IEnumerable GetProperties() => _properties.Values; /// public ITypeReflector? GetItemTypeReflector() => _itemReflector ??= TypeReflector.GetReflector(Args, ItemType!); diff --git a/src/CoreEx/Configuration/SettingsBase.cs b/src/CoreEx/Configuration/SettingsBase.cs index 14e55d73..d56be3e8 100644 --- a/src/CoreEx/Configuration/SettingsBase.cs +++ b/src/CoreEx/Configuration/SettingsBase.cs @@ -1,16 +1,13 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Threading; using CoreEx.Hosting.Work; using CoreEx.Http; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.CompilerServices; namespace CoreEx.Configuration { @@ -19,9 +16,7 @@ namespace CoreEx.Configuration /// public abstract class SettingsBase { - private readonly ThreadLocal _isReflectionCall = new(); private readonly List _prefixes = []; - private readonly Dictionary _allProperties; private bool? _validationUseJsonNames; /// @@ -34,15 +29,13 @@ public SettingsBase(IConfiguration? configuration, params string[] prefixes) Configuration = configuration; Deployment = new DeploymentInfo(configuration); - foreach (var prefix in prefixes.ThrowIfNull(nameof(prefixes))) + foreach (var prefix in prefixes) { if (string.IsNullOrEmpty(prefix)) - throw new ArgumentException("Prefixes cannot be null or empty.", nameof(prefixes)); + throw new ArgumentException("A prefix cannot be null or empty.", nameof(prefixes)); _prefixes.Add(prefix.EndsWith('/') ? prefix : string.Concat(prefix, '/')); } - - _allProperties = GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy).ToDictionary(p => p.Name, p => p); } /// @@ -61,33 +54,22 @@ public SettingsBase(IConfiguration? configuration, params string[] prefixes) /// 'Product/Foo', 'Common/Foo', 'Foo' (no prefix), then finally the will be returned. public T GetValue([CallerMemberName] string key = "", T defaultValue = default!) { - key.ThrowIfNullOrEmpty(nameof(key)); + // One-off replace double underscore with colon; enables support for both types. + var ckey = key.ThrowIfNullOrEmpty(nameof(key)).Replace("__", ":"); - if (Configuration == null) + if (Configuration is null) return defaultValue; - // Do not allow recursive calls to go too deep - if (_allProperties.TryGetValue(key, out PropertyInfo? pi) && !_isReflectionCall.Value) - { - try - { - _isReflectionCall.Value = true; - return pi.GetValue(this) is T value ? value : defaultValue; - } - finally - { - _isReflectionCall.Value = false; - } - } - + // Try each prefix until found. T kv; foreach (var prefix in _prefixes) { - if (TryGetValue(string.Concat(prefix, key), out kv)) + if (TryGetValue(string.Concat(prefix, ckey), out kv)) return kv; } - return TryGetValue(key, out kv) ? kv : defaultValue; + // Final without prefix. + return TryGetValue(ckey, out kv) ? kv : defaultValue; } /// @@ -102,22 +84,6 @@ private bool TryGetValue(string key, out T value) return true; } - // Colon is read as double underscore by Configuration. - var alternateKey = key.Replace(":", "__"); - if (alternateKey != key && Configuration.GetSection(alternateKey)?.Value != null) - { - value = Configuration.GetValue(alternateKey)!; - return true; - } - - // Double underscore is read as ":" by Configuration. - alternateKey = key.Replace("__", ":"); - if (alternateKey != key && Configuration.GetSection(alternateKey)?.Value != null) - { - value = Configuration.GetValue(alternateKey)!; - return true; - } - value = default!; return false; } @@ -134,18 +100,22 @@ private bool TryGetValue(string key, out T value) /// Thrown where the has not been configured. public T GetRequiredValue([CallerMemberName] string key = "") { - key.ThrowIfNullOrEmpty(nameof(key)); + // One-off replace double underscore with colon; enables support for both types. + var ckey = key.ThrowIfNullOrEmpty(nameof(key)).Replace("__", ":"); + if (Configuration == null) - throw new InvalidOperationException("An IConfiguration instance is required where GetRequiredValue is used."); + throw new InvalidOperationException($"An IConfiguration instance is required where {nameof(GetRequiredValue)} is used."); + // Try each prefix until found. T kv; foreach (var prefix in _prefixes) { - if (TryGetValue(string.Concat(prefix, key), out kv)) + if (TryGetValue(string.Concat(prefix, ckey), out kv)) return kv; } - return TryGetValue(key, out kv) ? kv : throw new ArgumentException($"Configuration key '{key}' has not been configured and the value is required.", nameof(key)); + // Final without prefix. + return TryGetValue(ckey, out kv) ? kv : throw new ArgumentException($"Configuration key '{key}' has not been configured and the value is required.", nameof(key)); } /// @@ -163,6 +133,16 @@ public T GetRequiredValue([CallerMemberName] string key = "") /// public double HttpRetrySeconds => GetValue(nameof(HttpRetrySeconds), 1.8d); + /// + /// Gets the default timeout. Defaults to 90 seconds. + /// + public int HttpTimeoutSeconds => GetValue(defaultValue: 90); + + /// + /// Gets the default maximum retry delay. Defaults to 2 minutes. + /// + public TimeSpan HttpMaxRetryDelay => TimeSpan.FromSeconds(GetValue(defaultValue: 120)); + /// /// Indicates whether to the include the underlying content in the externally returned result. Defaults to false. /// @@ -178,16 +158,6 @@ public T GetRequiredValue([CallerMemberName] string key = "") /// public DeploymentInfo Deployment { get; } - /// - /// Gets the default timeout. Defaults to 90 seconds. - /// - public int HttpTimeoutSeconds => GetValue(defaultValue: 90); - - /// - /// Gets the default maximum retry delay. Defaults to 2 minutes. - /// - public TimeSpan MaxRetryDelay => TimeSpan.FromSeconds(GetValue(defaultValue: 120)); - /// /// Gets the ; i.e. page size. /// diff --git a/src/CoreEx/Entities/CompositeKey.cs b/src/CoreEx/Entities/CompositeKey.cs index 315a3af1..7429b8b6 100644 --- a/src/CoreEx/Entities/CompositeKey.cs +++ b/src/CoreEx/Entities/CompositeKey.cs @@ -105,6 +105,22 @@ public CompositeKey(params object?[] args) /// The are immutable. public ImmutableArray Args => _args; + /// + /// Asserts the length and throws an where the length is not as expected. + /// + /// The expected length. + /// The to support fluent-style method-chaining. + public CompositeKey AssertLength(int length) + { + if (length < 0) + throw new ArgumentOutOfRangeException(nameof(length), "Length must be greater than or equal to zero."); + + if (_args.Length != length) + throw new ArgumentException($"The number of arguments within the {nameof(CompositeKey)} must equal {length}.", nameof(length)); + + return this; + } + /// /// Determines whether the current is equal to another . /// diff --git a/src/CoreEx/Events/Subscribing/ErrorHandler.cs b/src/CoreEx/Events/Subscribing/ErrorHandler.cs index 680d5eee..4e296971 100644 --- a/src/CoreEx/Events/Subscribing/ErrorHandler.cs +++ b/src/CoreEx/Events/Subscribing/ErrorHandler.cs @@ -83,6 +83,7 @@ public async Task HandleErrorAsync(ErrorHandlerArgs args, CancellationToken canc await args.WorkOrchestrator.FailAsync(args.Identifier!, args.Exception.Message, cancellationToken); args.Instrumentation?.Instrument(args.ErrorHandling, args.Exception); + args.Logger.LogDebug(ex, LogFormat, args.Exception.Message, args.Exception.ExceptionSource, args.ErrorHandling.ToString()); break; case ErrorHandling.CompleteWithInformation: diff --git a/src/CoreEx/Events/Subscribing/ErrorHandling.cs b/src/CoreEx/Events/Subscribing/ErrorHandling.cs index 44fdb77f..f74c9d00 100644 --- a/src/CoreEx/Events/Subscribing/ErrorHandling.cs +++ b/src/CoreEx/Events/Subscribing/ErrorHandling.cs @@ -35,6 +35,7 @@ public enum ErrorHandling /// /// Indicates that when the corresponding error occurs this is expected and the current event/message should be completed without further processing and logging (i.e. silently). /// + /// A will be logged where applicable to support debugging. CompleteAsSilent, /// diff --git a/src/CoreEx/ExecutionContext.cs b/src/CoreEx/ExecutionContext.cs index 90514f17..ed993c7e 100644 --- a/src/CoreEx/ExecutionContext.cs +++ b/src/CoreEx/ExecutionContext.cs @@ -16,8 +16,9 @@ namespace CoreEx /// /// Represents a thread-bound (request) execution context using . /// - /// Used to house/pass context parameters and capabilities that are outside of the general operation arguments. This class should be extended by consumers where additional properties are required. - public class ExecutionContext : ITenantId + /// Used to house/pass context parameters and capabilities that are outside of the general operation arguments. This class should be extended by consumers where additional properties are required. + /// The implements ; however, from a standard implementation perspective there are no unmanaged resources leveraged. The will result in a . + public class ExecutionContext : ITenantId, IDisposable { private static readonly AsyncLocal _asyncLocal = new(); @@ -26,6 +27,8 @@ public class ExecutionContext : ITenantId private Lazy> _properties = new(true); private IReferenceDataContext? _referenceDataContext; private HashSet? _roles; + private bool _disposed; + private readonly object _lock = new(); /// /// Gets or sets the function to create a default instance. @@ -129,7 +132,7 @@ public static object GetRequiredService(Type type) /// /// Gets the . /// - /// This is automatically set via the . + /// This is automatically set via the . public IServiceProvider? ServiceProvider { get; set; } /// @@ -148,11 +151,6 @@ public static object GetRequiredService(Type type) /// public bool IsTextSerializationEnabled { get; set; } - /// - /// Gets or sets the result entity tag (where value does not support ). - /// - public string? ETag { get; set; } - /// /// Gets or sets the corresponding user name. /// @@ -199,21 +197,47 @@ public virtual ExecutionContext CreateCopy() { var ec = Create == null ? throw new InvalidOperationException($"The {nameof(Create)} function must not be null to create a copy.") : Create(); ec._timestamp = _timestamp; - ec._referenceDataContext = _referenceDataContext; ec._messages = _messages; ec._properties = _properties; + ec._referenceDataContext = _referenceDataContext; ec._roles = _roles; ec.ServiceProvider = ServiceProvider; ec.CorrelationId = CorrelationId; ec.OperationType = OperationType; ec.IsTextSerializationEnabled = IsTextSerializationEnabled; - ec.ETag = ETag; ec.UserName = UserName; ec.UserId = UserId; ec.TenantId = TenantId; return ec; } + /// + /// Dispose of resources. + /// + public void Dispose() + { + if (!_disposed) + { + lock (_lock) + { + if (!_disposed) + { + _disposed = true; + Dispose(true); + } + } + } + + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases the unmanaged resources used by the and optionally releases the managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) => Reset(); + #region Security /// diff --git a/src/CoreEx/Hosting/Work/WorkState.cs b/src/CoreEx/Hosting/Work/WorkState.cs index 5066acc4..be25a377 100644 --- a/src/CoreEx/Hosting/Work/WorkState.cs +++ b/src/CoreEx/Hosting/Work/WorkState.cs @@ -37,6 +37,12 @@ public class WorkState : IIdentifier /// public WorkStatus Status { get; set; } + /// + /// Gets or sets the owning user name. + /// + /// This provides a basic authorization-style opportunity by verifying only the initiating user has ongoing access. + public string? UserName { get; set; } + /// /// Gets or sets the . /// diff --git a/src/CoreEx/Hosting/Work/WorkStateArgs.cs b/src/CoreEx/Hosting/Work/WorkStateArgs.cs index 7ab897ec..f955689a 100644 --- a/src/CoreEx/Hosting/Work/WorkStateArgs.cs +++ b/src/CoreEx/Hosting/Work/WorkStateArgs.cs @@ -51,5 +51,11 @@ public class WorkStateArgs(string typeName, string? id = null) : IIdentifier /// The will default to the where not specified. public TimeSpan? Expiry { get; set; } + + /// + /// Gets or sets the owning user name. + /// + /// This provides a basic authorization opportunity by verifying only the initiating user has ongoing access. This will default to ; otherwise, null. + public string? UserName { get; set; } } } \ No newline at end of file diff --git a/src/CoreEx/Hosting/Work/WorkStateOrchestrator.cs b/src/CoreEx/Hosting/Work/WorkStateOrchestrator.cs index 54323205..c1f4e2f3 100644 --- a/src/CoreEx/Hosting/Work/WorkStateOrchestrator.cs +++ b/src/CoreEx/Hosting/Work/WorkStateOrchestrator.cs @@ -55,6 +55,11 @@ public TimeSpan ExpiryTimeSpan set => _expiryTimeSpan = value; } + /// + /// Indicates whether to check the where not null where performing a or . + /// + public bool CheckUserName { get; set; } = true; + /// /// Gets the for the specified . /// @@ -97,7 +102,8 @@ public async Task CreateAsync(WorkStateArgs args, CancellationToken c CorrelationId = args?.CorrelationId ?? (ExecutionContext.HasCurrent ? ExecutionContext.Current.CorrelationId : Guid.NewGuid().ToString()), Status = WorkStatus.Created, Created = now, - Expiry = now.Add(args?.Expiry ?? ExpiryTimeSpan) + Expiry = now.Add(args?.Expiry ?? ExpiryTimeSpan), + UserName = args?.UserName ?? (ExecutionContext.HasCurrent ? ExecutionContext.Current.UserName : null) }; await Persistence.CreateAsync(ws, cancellationToken).ConfigureAwait(false); @@ -343,11 +349,19 @@ public async Task SetDataAsync(string id, BinaryData data, CancellationToken can /// The work identifier. /// The . /// The where found; otherwise, null. - /// Will automatically set the to when the work is not and has expired (see ). + /// Will automatically set the to when the work is not and has expired (see ). + /// Additionally, the must equal the ; and, if is true then the must equal the + /// ensuring that the initiating user can only interact with their . Where the aforementioned does not equal then a null will be returned. public async Task GetAsync(string type, string id, CancellationToken cancellationToken = default) { var ws = await GetAsync(id, cancellationToken).ConfigureAwait(false); - return ws is null || ws.TypeName != type ? null : ws; + if (ws is null || ws.TypeName != type) + return null; + + if (CheckUserName && ws.UserName is not null && ws.UserName != (ExecutionContext.HasCurrent ? ExecutionContext.Current.UserName : null)) + return null; + + return ws; } /// @@ -357,7 +371,9 @@ public async Task SetDataAsync(string id, BinaryData data, CancellationToken can /// The work identifier. /// The . /// The where found; otherwise, null. - /// Will automatically set the to when the work is not and has expired (see ). + /// Will automatically set the to when the work is not and has expired (see ). + /// Additionally, the must equal the ; and, if is true then the must equal the + /// ensuring that the initiating user can only interact with their . Where the aforementioned does not equal then a null will be returned. public Task GetAsync(string id, CancellationToken cancellationToken = default) => GetAsync(WorkStateArgs.GetTypeName(), id, cancellationToken); #endregion diff --git a/src/CoreEx/Http/HttpExtensions.cs b/src/CoreEx/Http/HttpExtensions.cs index ed6b0556..d5eecd1d 100644 --- a/src/CoreEx/Http/HttpExtensions.cs +++ b/src/CoreEx/Http/HttpExtensions.cs @@ -89,16 +89,16 @@ public static HttpRequestMessage ApplyRequestOptions(this HttpRequestMessage htt /// The . /// The ETag value. /// The to support fluent-style method-chaining. - /// Automatically adds quoting to be ETag format compliant. + /// Automatically adds quoting to be ETag format compliant and sets the ETag as weak ('W/'). public static HttpRequestMessage ApplyETag(this HttpRequestMessage httpRequest, string? etag) { // Apply the ETag header. if (!string.IsNullOrEmpty(etag)) { if (httpRequest.Method == HttpMethod.Get || httpRequest.Method == HttpMethod.Head) - httpRequest.Headers.IfNoneMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue(ETagGenerator.FormatETag(etag)!)); + httpRequest.Headers.IfNoneMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue(ETagGenerator.FormatETag(etag)!, true)); else - httpRequest.Headers.IfMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue(ETagGenerator.FormatETag(etag)!)); + httpRequest.Headers.IfMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue(ETagGenerator.FormatETag(etag)!, true)); } return httpRequest; diff --git a/src/CoreEx/Http/TypedHttpClientBase.cs b/src/CoreEx/Http/TypedHttpClientBase.cs index fc3e508d..139ce006 100644 --- a/src/CoreEx/Http/TypedHttpClientBase.cs +++ b/src/CoreEx/Http/TypedHttpClientBase.cs @@ -105,7 +105,6 @@ private async Task CreateRequestInternalAsync(HttpMethod met // Access the query string. var uri = new Uri(requestUri, UriKind.RelativeOrAbsolute); - var ub = new UriBuilder(uri.IsAbsoluteUri ? uri : new Uri(new Uri("https://coreex"), requestUri)); var qs = HttpUtility.ParseQueryString(ub.Query); diff --git a/src/CoreEx/Http/TypedHttpClientBaseT.cs b/src/CoreEx/Http/TypedHttpClientBaseT.cs index 192224f0..428c9722 100644 --- a/src/CoreEx/Http/TypedHttpClientBaseT.cs +++ b/src/CoreEx/Http/TypedHttpClientBaseT.cs @@ -325,7 +325,7 @@ private async Task SendInternalAsync(HttpRequestMessage req delay ??= TimeSpan.FromSeconds(Math.Pow(options.RetrySeconds ?? 0, attempt)).Add(TimeSpan.FromMilliseconds(_random.Next(0, 500))); // Do not go over max delay. - var maxDelay = options.MaxRetryDelay ?? Settings.MaxRetryDelay; + var maxDelay = options.MaxRetryDelay ?? Settings.HttpMaxRetryDelay; return delay.Value > maxDelay ? maxDelay : delay.Value; }, onRetryAsync: async (result, timeSpan, retryCount, context) => diff --git a/src/CoreEx/Json/IJsonPreFilterInspector.cs b/src/CoreEx/Json/IJsonPreFilterInspector.cs index be36e6a8..6342ea8b 100644 --- a/src/CoreEx/Json/IJsonPreFilterInspector.cs +++ b/src/CoreEx/Json/IJsonPreFilterInspector.cs @@ -9,7 +9,7 @@ namespace CoreEx.Json public interface IJsonPreFilterInspector { /// - /// Gets the underlying JSON object (as per the underlying implementation). + /// Gets the underlying JSON object (as per the underlying implementation). /// object Json { get; } diff --git a/src/CoreEx/Mapping/Converters/ConverterT.cs b/src/CoreEx/Mapping/Converters/ConverterT.cs index 7a9bf2ca..bcb89a4e 100644 --- a/src/CoreEx/Mapping/Converters/ConverterT.cs +++ b/src/CoreEx/Mapping/Converters/ConverterT.cs @@ -25,5 +25,17 @@ public readonly struct Converter(Func. /// public readonly IValueConverter ToSource => _convertToSource; + + /// + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((TSource)source!); + + /// + public readonly object? ConvertToSource(object? destination) => ConvertToSource((TDestination)destination!); + + /// + public readonly TDestination ConvertToDestination(TSource source) => ToDestination.Convert(source); + + /// + public readonly TSource ConvertToSource(TDestination destination) => ToSource.Convert(destination); } } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/DateTimeToStringConverter.cs b/src/CoreEx/Mapping/Converters/DateTimeToStringConverter.cs index 9bb3537b..f2443d0b 100644 --- a/src/CoreEx/Mapping/Converters/DateTimeToStringConverter.cs +++ b/src/CoreEx/Mapping/Converters/DateTimeToStringConverter.cs @@ -50,5 +50,17 @@ public DateTimeToStringConverter(string format, IFormatProvider? formatProvider /// Gets the destination to source . /// public IValueConverter ToSource => _convertToSource; + + /// + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((DateTime?)source); + + /// + public readonly object? ConvertToSource(object? destination) => ConvertToSource((string?)destination); + + /// + public readonly string? ConvertToDestination(DateTime? source) => ToDestination.Convert(source); + + /// + public readonly DateTime? ConvertToSource(string? destination) => ToSource.Convert(destination); } } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/EncodedStringToDateTimeConverter.cs b/src/CoreEx/Mapping/Converters/EncodedStringToDateTimeConverter.cs index 2f51f761..5f2e8cda 100644 --- a/src/CoreEx/Mapping/Converters/EncodedStringToDateTimeConverter.cs +++ b/src/CoreEx/Mapping/Converters/EncodedStringToDateTimeConverter.cs @@ -31,5 +31,17 @@ public EncodedStringToDateTimeConverter() { } /// Gets the destination to source . /// public IValueConverter ToSource => _convertToSource; + + /// + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((string?)source); + + /// + public readonly object? ConvertToSource(object? destination) => ConvertToSource((DateTime?)destination); + + /// + public readonly DateTime? ConvertToDestination(string? source) => ToDestination.Convert(source); + + /// + public readonly string? ConvertToSource(DateTime? destination) => ToSource.Convert(destination); } } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/EncodedStringToUInt32Converter.cs b/src/CoreEx/Mapping/Converters/EncodedStringToUInt32Converter.cs index d4bf2877..80466dc9 100644 --- a/src/CoreEx/Mapping/Converters/EncodedStringToUInt32Converter.cs +++ b/src/CoreEx/Mapping/Converters/EncodedStringToUInt32Converter.cs @@ -31,5 +31,17 @@ public EncodedStringToUInt32Converter() { } /// Gets the destination to source . /// public IValueConverter ToSource => _convertToSource; + + /// + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((string?)source); + + /// + public readonly object? ConvertToSource(object? destination) => ConvertToSource((uint)destination!); + + /// + public readonly uint ConvertToDestination(string? source) => ToDestination.Convert(source); + + /// + public readonly string? ConvertToSource(uint destination) => ToSource.Convert(destination); } } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/IConverterT.cs b/src/CoreEx/Mapping/Converters/IConverterT.cs index cd2ffeaf..c24e4728 100644 --- a/src/CoreEx/Mapping/Converters/IConverterT.cs +++ b/src/CoreEx/Mapping/Converters/IConverterT.cs @@ -17,12 +17,6 @@ public interface IConverter : IConverter /// Type IConverter.DestinationType => typeof(TDestination); - /// - object? IConverter.ConvertToDestination(object? source) => ToDestination.Convert((TSource)source!); - - /// - object? IConverter.ConvertToSource(object? destination) => ToSource.Convert((TDestination)destination!); - /// /// Gets the source to destination . /// @@ -32,5 +26,19 @@ public interface IConverter : IConverter /// Gets the destination to source . /// IValueConverter ToSource { get; } + + /// + /// Converts the source to the destination value (converts to). + /// + /// The source value to convert. + /// The converted destination value. + TDestination ConvertToDestination(TSource source); + + /// + /// Converts the destination to the source value (converts back from). + /// + /// The destination value to convert. + /// The converted source value. + TSource ConvertToSource(TDestination destination); } } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/ObjectToJsonConverter.cs b/src/CoreEx/Mapping/Converters/ObjectToJsonConverter.cs index 45602b81..1cbf524a 100644 --- a/src/CoreEx/Mapping/Converters/ObjectToJsonConverter.cs +++ b/src/CoreEx/Mapping/Converters/ObjectToJsonConverter.cs @@ -44,5 +44,17 @@ public ObjectToJsonConverter(IJsonSerializer? jsonSerializer) /// Gets the destination to source . /// public IValueConverter ToSource => _convertToSource; + + /// + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((T?)source); + + /// + public readonly object? ConvertToSource(object? destination) => ConvertToSource((string?)destination); + + /// + public readonly string? ConvertToDestination(T? source) => ToDestination.Convert(source); + + /// + public readonly T? ConvertToSource(string? destination) => ToSource.Convert(destination); } } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/ReferenceDataCodeConverter.cs b/src/CoreEx/Mapping/Converters/ReferenceDataCodeConverter.cs index d348bf1f..6a904755 100644 --- a/src/CoreEx/Mapping/Converters/ReferenceDataCodeConverter.cs +++ b/src/CoreEx/Mapping/Converters/ReferenceDataCodeConverter.cs @@ -33,5 +33,17 @@ public ReferenceDataCodeConverter() { } /// Gets the destination to source . /// public IValueConverter ToSource => _convertToSource; + + /// + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((TRef?)source); + + /// + public readonly object? ConvertToSource(object? destination) => ConvertToSource((string?)destination); + + /// + public readonly string? ConvertToDestination(TRef? source) => ToDestination.Convert(source); + + /// + public readonly TRef? ConvertToSource(string? destination) => ToSource.Convert(destination); } } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/ReferenceDataIdConverter.cs b/src/CoreEx/Mapping/Converters/ReferenceDataIdConverter.cs index 590e1e69..44d596d8 100644 --- a/src/CoreEx/Mapping/Converters/ReferenceDataIdConverter.cs +++ b/src/CoreEx/Mapping/Converters/ReferenceDataIdConverter.cs @@ -35,5 +35,17 @@ public ReferenceDataIdConverter() { } /// Gets the destination to source . /// public IValueConverter ToSource => _convertToSource; + + /// + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((TRef?)source); + + /// + public readonly object? ConvertToSource(object? destination) => ConvertToSource((TId)destination!); + + /// + public readonly TId ConvertToDestination(TRef? source) => ToDestination.Convert(source); + + /// + public readonly TRef? ConvertToSource(TId destination) => ToSource.Convert(destination); } } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/StringToBase64Converter.cs b/src/CoreEx/Mapping/Converters/StringToBase64Converter.cs index 6ff3e7a2..9201d427 100644 --- a/src/CoreEx/Mapping/Converters/StringToBase64Converter.cs +++ b/src/CoreEx/Mapping/Converters/StringToBase64Converter.cs @@ -31,5 +31,17 @@ public StringToBase64Converter() { } /// Gets the destination to source . /// public IValueConverter ToSource => _convertToSource; + + /// + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((string?)source); + + /// + public readonly object? ConvertToSource(object? destination) => ConvertToSource((byte[]?)destination); + + /// + public readonly byte[]? ConvertToDestination(string? source) => ToDestination.Convert(source); + + /// + public readonly string? ConvertToSource(byte[]? destination) => ToSource.Convert(destination); } } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/StringToTypeConverter.cs b/src/CoreEx/Mapping/Converters/StringToTypeConverter.cs index efd7bd68..8d0dbba9 100644 --- a/src/CoreEx/Mapping/Converters/StringToTypeConverter.cs +++ b/src/CoreEx/Mapping/Converters/StringToTypeConverter.cs @@ -6,7 +6,7 @@ namespace CoreEx.Mapping.Converters { /// - /// Represents a to to conversion. + /// Represents a to to conversion using a . /// /// The to convert. /// See also . @@ -31,5 +31,17 @@ public StringToTypeConverter() { } /// public readonly IValueConverter ToSource => _convertToSource; + + /// + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((string?)source); + + /// + public readonly object? ConvertToSource(object? destination) => ConvertToSource((T)destination!); + + /// + public readonly T ConvertToDestination(string? source) => ToDestination.Convert(source); + + /// + public readonly string? ConvertToSource(T destination) => ToSource.Convert(destination); } } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/TypeToStringConverter.cs b/src/CoreEx/Mapping/Converters/TypeToStringConverter.cs index a0257095..65a36514 100644 --- a/src/CoreEx/Mapping/Converters/TypeToStringConverter.cs +++ b/src/CoreEx/Mapping/Converters/TypeToStringConverter.cs @@ -6,7 +6,7 @@ namespace CoreEx.Mapping.Converters { /// - /// Represents a to conversion. + /// Represents a to conversion using a . /// /// The to convert. /// See also . @@ -31,5 +31,17 @@ public TypeToStringConverter() { } /// public readonly IValueConverter ToSource => _convertToSource; + + /// + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((T)source!); + + /// + public readonly object? ConvertToSource(object? destination) => ConvertToSource((string?)destination); + + /// + public readonly string? ConvertToDestination(T source) => ToDestination.Convert(source); + + /// + public readonly T ConvertToSource(string? destination) => ToSource.Convert(destination); } } \ No newline at end of file diff --git a/src/CoreEx/RefData/IReferenceDataCollection.cs b/src/CoreEx/RefData/IReferenceDataCollection.cs index 7b9e4104..cb854c30 100644 --- a/src/CoreEx/RefData/IReferenceDataCollection.cs +++ b/src/CoreEx/RefData/IReferenceDataCollection.cs @@ -10,7 +10,7 @@ namespace CoreEx.RefData /// /// Provides and functionality for an collection. /// - public interface IReferenceDataCollection : IETag + public interface IReferenceDataCollection { /// /// Gets the underlying item . diff --git a/src/CoreEx/RefData/ReferenceDataCollection.cs b/src/CoreEx/RefData/ReferenceDataCollection.cs index 28f0adb5..2c5e5a72 100644 --- a/src/CoreEx/RefData/ReferenceDataCollection.cs +++ b/src/CoreEx/RefData/ReferenceDataCollection.cs @@ -44,9 +44,6 @@ protected virtual void OnInitialization() { } /// public ReferenceDataSortOrder SortOrder { get; set; } - /// - public string? ETag { get; set; } - /// /// Gets the item for the specified . /// diff --git a/src/CoreEx/RefData/ReferenceDataOrchestrator.cs b/src/CoreEx/RefData/ReferenceDataOrchestrator.cs index 9990a68d..b5aeaa6e 100644 --- a/src/CoreEx/RefData/ReferenceDataOrchestrator.cs +++ b/src/CoreEx/RefData/ReferenceDataOrchestrator.cs @@ -227,7 +227,7 @@ public ReferenceDataOrchestrator Register() where TProvider : IRefere /// /// The . /// The corresponding where found; otherwise, null. - public IReferenceDataCollection? GetByType(Type type) => Cache.TryGetValue(OnGetCacheKey(type), out IReferenceDataCollection? coll) ? coll! : Invokers.Invoker.RunSync(() => GetByTypeAsync(type)); + public IReferenceDataCollection? GetByType(Type type) => Cache.TryGetValue(OnGetCacheKey(type), out IReferenceDataCollection? coll) ? coll! : Invoker.RunSync(() => GetByTypeAsync(type)); /// /// Gets the for the specified (will throw where not found). @@ -241,7 +241,7 @@ public ReferenceDataOrchestrator Register() where TProvider : IRefere /// /// The . /// The corresponding where found; otherwise, will throw an . - public IReferenceDataCollection GetByTypeRequired(Type type) => Cache.TryGetValue(OnGetCacheKey(type), out IReferenceDataCollection? coll) ? coll! : Invokers.Invoker.RunSync(() => GetByTypeRequiredAsync(type)); + public IReferenceDataCollection GetByTypeRequired(Type type) => Cache.TryGetValue(OnGetCacheKey(type), out IReferenceDataCollection? coll) ? coll! : Invoker.RunSync(() => GetByTypeRequiredAsync(type)); /// /// Gets the for the specified . @@ -264,7 +264,7 @@ public ReferenceDataOrchestrator Register() where TProvider : IRefere var coll = await OnGetOrCreateAsync(type, (t, ct) => { - return ReferenceDataOrchestratorInvoker.Current.Invoke(this, ia => + return ReferenceDataOrchestratorInvoker.Current.Invoke(this, async ia => { if (ia.Activity is not null) { @@ -273,16 +273,17 @@ public ReferenceDataOrchestrator Register() where TProvider : IRefere } Logger.LogDebug("Reference data type {RefDataType} cache load start: ServiceProvider.CreateScope and Threading.ExecutionContext.SuppressFlow to support underlying cache data get.", type.FullName); - var ec = ExecutionContext.Current.CreateCopy(); + using var ec = ExecutionContext.Current.CreateCopy(); var rdo = _asyncLocal.Value; using var scope = ServiceProvider.CreateScope(); + Task task; using (System.Threading.ExecutionContext.SuppressFlow()) { - var task = Task.Run(() => GetByTypeInternalAsync(rdo, ec, scope, t, providerType, ia, ct)); - task.Wait(); - return task; + task = Task.Run(() => GetByTypeInternalAsync(rdo, ec, scope, t, providerType, ia, ct)); } + + return await task.ConfigureAwait(false); }, nameof(GetByTypeAsync)); }, cancellationToken).ConfigureAwait(false); @@ -312,7 +313,6 @@ private async Task GetByTypeInternalAsync(ReferenceDat var sw = Stopwatch.StartNew(); var provider = (IReferenceDataProvider)scope.ServiceProvider.GetRequiredService(providerType); var coll = (await provider.GetAsync(type, cancellationToken).ConfigureAwait(false)).Value; - coll.ETag = ETagGenerator.Generate(ServiceProvider.GetRequiredService(), coll)!; sw.Stop(); Logger.LogInformation("Reference data type {RefDataType} cache load finish: {ItemCount} items cached [{Elapsed}ms]", type.FullName, coll.Count, sw.Elapsed.TotalMilliseconds); diff --git a/tests/CoreEx.Test/Framework/Abstractions/Reflection/TypeReflectorTest.cs b/tests/CoreEx.Test/Framework/Abstractions/Reflection/TypeReflectorTest.cs index a0f549f3..ddccda6e 100644 --- a/tests/CoreEx.Test/Framework/Abstractions/Reflection/TypeReflectorTest.cs +++ b/tests/CoreEx.Test/Framework/Abstractions/Reflection/TypeReflectorTest.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Diagnostics; using System.Text.Json.Serialization; namespace CoreEx.Test.Framework.Abstractions.Reflection @@ -240,6 +241,39 @@ public void GetReflector_TypeCode_And_ItemType() Assert.That(TypeReflector.GetReflector(new TypeReflectorArgs()).ItemType, Is.EqualTo(null)); }); } + + [Test] + public void SetValues() + { + var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); + var p = new Person(); + tr.GetProperty("Id").PropertyExpression.SetValue(p, 88); + tr.GetProperty("Name").PropertyExpression.SetValue(p, "foo"); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < 100000; i++) + { + _ = new Person + { + Id = 88, + Name = "foo" + }; + } + + sw.Stop(); + System.Console.WriteLine($"Raw 100K validations - elapsed: {sw.Elapsed.TotalMilliseconds}ms (per {sw.Elapsed.TotalMilliseconds / 100000}ms)"); + + sw = Stopwatch.StartNew(); + for (int i = 0; i < 100000; i++) + { + p = new Person(); + tr.GetProperty("Id").PropertyExpression.SetValue(p, 88); + tr.GetProperty("Name").PropertyExpression.SetValue(p, "foo"); + } + + sw.Stop(); + System.Console.WriteLine($"Expression 100K validations - elapsed: {sw.Elapsed.TotalMilliseconds}ms (per {sw.Elapsed.TotalMilliseconds / 100000}ms)"); + } } public abstract class PersonBase diff --git a/tests/CoreEx.Test/Framework/Configuration/SettingsBaseTest.cs b/tests/CoreEx.Test/Framework/Configuration/SettingsBaseTest.cs index d3235735..de3a16b1 100644 --- a/tests/CoreEx.Test/Framework/Configuration/SettingsBaseTest.cs +++ b/tests/CoreEx.Test/Framework/Configuration/SettingsBaseTest.cs @@ -20,6 +20,7 @@ public SettingsForTesting(IConfiguration configuration, params string[] prefixes public string PropTest => GetValue(); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "")] public string PropTest2 => "some hardcoded value"; public string SomethingGlobal => GetValue(); @@ -27,7 +28,7 @@ public SettingsForTesting(IConfiguration configuration, params string[] prefixes } - private IConfiguration CreateTestConfiguration() + private static IConfiguration CreateTestConfiguration() { Environment.SetEnvironmentVariable("this_is_a_unittest_underscore__key", "underscoreValue"); ConfigurationBuilder builder = new(); @@ -47,7 +48,7 @@ private IConfiguration CreateTestConfiguration() [Test] public void CommonSettings_Should_Not_ThrowException_When_ConfigurationNull() { - new SettingsForTesting(null!); + _ = new SettingsForTesting(null!); } [Test] @@ -57,10 +58,10 @@ public void CommonSettings_Should_ThrowException_When_PrefixesNull() var configuration = CreateTestConfiguration(); // Act - Action act = () => new SettingsForTesting(configuration, prefixes: null!); + Action act = () => _ = new SettingsForTesting(configuration, prefixes: null!); // Assert - act.Should().Throw(); + act.Should().NotThrow(); } [Test] @@ -71,7 +72,7 @@ public void CommonSettings_Should_ThrowException_When_PrefixIsNullOrEmpty() var prefixes = new string[] { "foo", string.Empty }; // Act - Action act = () => new SettingsForTesting(configuration, prefixes); + Action act = () => _ = new SettingsForTesting(configuration, prefixes); // Assert act.Should().Throw(); @@ -161,14 +162,13 @@ public void GetValue_Should_Return_Value_When_SemicolonKeyUsedForDoubleUnderscor result.Should().Be("underscoreValue"); } - [Test] public void GetValue_Should_Return_Value_From_Property_When_Class_Has_it() { var configuration = CreateTestConfiguration(); var settings = new SettingsForTesting(configuration, new string[] { "Sample/", "Common/" }); - var result = settings.GetValue("PropTest2"); + var result = settings.PropTest2; // Assert result.Should().Be("some hardcoded value"); @@ -180,7 +180,7 @@ public void GetValue_Should_Return_Value_From_NestedProperty_When_Class_Has_it() var configuration = CreateTestConfiguration(); var settings = new SettingsForTesting(configuration, new string[] { "Sample/", "Common/" }); - var result = settings.GetValue("PropTestNested"); + var result = settings.PropTestNested; // Assert settings.SomethingGlobal.Should().Be("foo"); @@ -198,7 +198,7 @@ public void RunInParallel() var testResult = Parallel.ForEach(reps, (i) => { - var result = settings.GetValue("PropTestNested"); + var result = settings.PropTestNested; // Assert settings.SomethingGlobal.Should().Be("foo"); @@ -212,16 +212,15 @@ public void RunInParallel() static async Task ExamineValuesOfLocalObjectsEitherSideOfAwait(SettingsForTesting settings) { - var result = settings.GetValue("PropTestNested"); + var result = settings.PropTestNested; settings.SomethingGlobal.Should().Be("foo"); result.Should().Be("foo"); await Task.Delay(100); - result = settings.GetValue("PropTestNested"); + result = settings.PropTestNested; settings.SomethingGlobal.Should().Be("foo"); result.Should().Be("foo"); } - } } \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Hosting/Work/WorkStateOrchestratorTest.cs b/tests/CoreEx.Test/Framework/Hosting/Work/WorkStateOrchestratorTest.cs index ac44a8f7..f7957a14 100644 --- a/tests/CoreEx.Test/Framework/Hosting/Work/WorkStateOrchestratorTest.cs +++ b/tests/CoreEx.Test/Framework/Hosting/Work/WorkStateOrchestratorTest.cs @@ -62,6 +62,9 @@ private static async Task Orchestrate(WorkStateOrchestrator o) return; } + ExecutionContext.Reset(); + ExecutionContext.SetCurrent(new ExecutionContext { UserName = ExecutionContext.EnvironmentUserName }); + // Clean up before we begin. await o.Persistence.DeleteAsync("abc", default); @@ -130,6 +133,17 @@ private static async Task Orchestrate(WorkStateOrchestrator o) // Check different type; but same id. Confirms same type as extra pre-caution! wr = await o.GetAsync("other", "abc"); Assert.That(wr, Is.Null); + + // Check different username; but same id. Confirms same user as extra/extra pre-caution! + ExecutionContext.Reset(); + ExecutionContext.SetCurrent(new ExecutionContext { UserName = "other" }); + wr = await o.GetAsync("abc"); + Assert.That(wr, Is.Null); + + // Check no username set; but same id. Confirms same user as extra/extra/extra pre-caution! + ExecutionContext.Reset(); + wr = await o.GetAsync("abc"); + Assert.That(wr, Is.Null); } [Test] diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/ExistsRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/ExistsRuleTest.cs index 7641db54..9d925e98 100644 --- a/tests/CoreEx.Test/Framework/Validation/Rules/ExistsRuleTest.cs +++ b/tests/CoreEx.Test/Framework/Validation/Rules/ExistsRuleTest.cs @@ -2,6 +2,7 @@ using CoreEx.Validation; using CoreEx.Entities; using System.Threading.Tasks; +using CoreEx.Results; namespace CoreEx.Test.Framework.Validation.Rules { @@ -53,10 +54,10 @@ public async Task Validate_Value() Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); }); - v1 = await 123.Validate("value").ExistsAsync((_, __) => Task.FromResult(new object())).ValidateAsync(); + v1 = await 123.Validate("value").ValueExistsAsync((_, __) => Task.FromResult(new object())).ValidateAsync(); Assert.That(v1.HasErrors, Is.False); - v1 = await 123.Validate("value").ExistsAsync((_, __) => Task.FromResult((object?)null)).ValidateAsync(); + v1 = await 123.Validate("value").ValueExistsAsync((_, __) => Task.FromResult((object?)null)).ValidateAsync(); Assert.Multiple(() => { Assert.That(v1.HasErrors, Is.True); @@ -91,6 +92,11 @@ public async Task Validate_Value() Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); }); + + v1 = await 123.Validate("value").ValueExistsAsync(async (_, __) => await GetBlahAsync()).ValidateAsync(); + Assert.That(v1.HasErrors, Is.True); // Result.Success is not a valid value } + + private static Task GetBlahAsync() => Task.FromResult(Result.Success); } } \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/WebApis/WebApiPublisherTest.cs b/tests/CoreEx.Test/Framework/WebApis/WebApiPublisherTest.cs index c9fac26e..6c62a09e 100644 --- a/tests/CoreEx.Test/Framework/WebApis/WebApiPublisherTest.cs +++ b/tests/CoreEx.Test/Framework/WebApis/WebApiPublisherTest.cs @@ -329,7 +329,7 @@ public void Publish_With_Work_State() Assert.That(ed, Has.Length.EqualTo(1)); ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, ed[0].Value); - var ws2 = wso.GetAsync(ed[0].Id!).Result; + var ws2 = wso.GetAsync(ed[0].Id!).Result; Assert.That(ws2, Is.Not.Null); Assert.That(ws2.Status, Is.EqualTo(WorkStatus.Created)); diff --git a/tests/CoreEx.Test/Framework/WebApis/WebApiTest.cs b/tests/CoreEx.Test/Framework/WebApis/WebApiTest.cs index 169e9fe7..61df5b20 100644 --- a/tests/CoreEx.Test/Framework/WebApis/WebApiTest.cs +++ b/tests/CoreEx.Test/Framework/WebApis/WebApiTest.cs @@ -149,15 +149,58 @@ public void GetAsync_WithResult() } [Test] - public void GetAsync_WithETagValue() + public void GetAsync_WithETag() { using var test = FunctionTester.Create(); var vcr = test.Type() + .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"), r => Task.FromResult(new Person { Id = 1, Name = "Angela", ETag = "my-etag" }))) + .ToActionResultAssertor() + .AssertOK() + .Result as ValueContentResult; + + Assert.That(vcr, Is.Not.Null); + Assert.That(vcr!.ETag, Is.EqualTo("my-etag")); + + // Second time should be the same. + vcr = test.Type() + .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"), r => Task.FromResult(new Person { Id = 1, Name = "Angela", ETag = "my-etag" }))) + .ToActionResultAssertor() + .AssertOK() + .Result as ValueContentResult; + + Assert.That(vcr, Is.Not.Null); + Assert.That(vcr!.ETag, Is.EqualTo("my-etag")); + + // However, if a query string, then etag will need to be generated, as it possibly can influence result. + vcr = test.Type() .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(new Person { Id = 1, Name = "Angela", ETag = "my-etag" }))) .ToActionResultAssertor() .AssertOK() .Result as ValueContentResult; + Assert.That(vcr, Is.Not.Null); + Assert.That(vcr!.ETag, Is.Not.EqualTo("my-etag")); + + var vcr2 = test.Type() + .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(new Person { Id = 1, Name = "Angela", ETag = "my-etag" }))) + .ToActionResultAssertor() + .AssertOK() + .Result as ValueContentResult; + + Assert.That(vcr2, Is.Not.Null); + Assert.That(vcr2!.ETag, Is.EqualTo(vcr.ETag)); + } + + [Test] + public void GetAsync_WithETagValue() + { + using var test = FunctionTester.Create(); + var vcr = test.Type() + .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"), r => Task.FromResult(new Person { Id = 1, Name = "Angela", ETag = "my-etag" }))) + .ToActionResultAssertor() + .AssertOK() + .Result as ValueContentResult; + Assert.That(vcr, Is.Not.Null); Assert.That(vcr!.ETag, Is.EqualTo("my-etag")); } @@ -166,8 +209,8 @@ public void GetAsync_WithETagValue() public void GetAsync_WithETagValueNotModified() { using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"); - hr.Headers.Add(HeaderNames.IfMatch, "my-etag"); + var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"); + hr.Headers.Add(HeaderNames.IfMatch, "\\W\"my-etag\""); test.Type() .Run(f => f.GetAsync(hr, r => Task.FromResult(new Person { Id = 1, Name = "Angela", ETag = "my-etag" }))) @@ -180,13 +223,35 @@ public void GetAsync_WithGenETagValue() { using var test = FunctionTester.Create(); var vcr = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(new Person { Id = 1, Name = "Angela" }))) + .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"), r => Task.FromResult(new Person { Id = 1, Name = "Angela" }))) .ToActionResultAssertor() .AssertOK() .Result as ValueContentResult; Assert.That(vcr, Is.Not.Null); Assert.That(vcr!.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM=")); + + var p = test.JsonSerializer.Deserialize(vcr.Content!); + Assert.That(p, Is.Not.Null); + Assert.That(p.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM=")); + } + + [Test] + public void GetAsync_WithGenETagValue_QueryString() + { + using var test = FunctionTester.Create(); + var vcr = test.Type() + .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(new Person { Id = 1, Name = "Angela" }))) + .ToActionResultAssertor() + .AssertOK() + .Result as ValueContentResult; + + Assert.That(vcr, Is.Not.Null); + Assert.That(vcr!.ETag, Is.EqualTo("cpDn3xugV1xKSHF9AY4kQRNQ1yC/SU49xC66C92WZbE=")); + + var p = test.JsonSerializer.Deserialize(vcr.Content!); + Assert.That(p, Is.Not.Null); + Assert.That(p.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM=")); } [Test] @@ -194,7 +259,7 @@ public void GetAsync_WithGenETagValueNotModified() { using var test = FunctionTester.Create(); var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"); - hr.Headers.Add(HeaderNames.IfMatch, "iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM="); + hr.Headers.Add(HeaderNames.IfMatch, "\\W\"cpDn3xugV1xKSHF9AY4kQRNQ1yC/SU49xC66C92WZbE=\""); test.Type() .Run(f => f.GetAsync(hr, r => Task.FromResult(new Person { Id = 1, Name = "Angela" }))) @@ -607,7 +672,7 @@ public void PatchAsync_AutoConcurrency_Matched() .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(new Person { Id = 13, Name = "Deano" }), put: _ => Task.FromResult(new Person { Id = 13, Name = "Gazza" }), simulatedConcurrency: true)) .ToActionResultAssertor() .AssertOK() - .AssertValue(new Person { Id = 13, Name = "Gazza" }); + .AssertValue(new Person { Id = 13, Name = "Gazza", ETag = "tEEokPXk+4Q5MoiGqyAs1+6A00e2ww59Zm57LJgvBcg=" }); } private static HttpRequest CreatePatchRequest(UnitTestEx.NUnit.Internal.FunctionTester test, string? json, string? etag = null) diff --git a/tests/CoreEx.Test/Framework/WebApis/WebApiWithResultTest.cs b/tests/CoreEx.Test/Framework/WebApis/WebApiWithResultTest.cs index 826dc191..a5566ac7 100644 --- a/tests/CoreEx.Test/Framework/WebApis/WebApiWithResultTest.cs +++ b/tests/CoreEx.Test/Framework/WebApis/WebApiWithResultTest.cs @@ -48,7 +48,7 @@ public void GetWithResultAsync_WithETagValue() { using var test = FunctionTester.Create(); var vcr = test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(Result.Ok(new Person { Id = 1, Name = "Angela", ETag = "my-etag" })))) + .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"), r => Task.FromResult(Result.Ok(new Person { Id = 1, Name = "Angela", ETag = "my-etag" })))) .ToActionResultAssertor() .AssertOK() .Result as ValueContentResult; @@ -61,8 +61,8 @@ public void GetWithResultAsync_WithETagValue() public void GetWithResultAsync_WithETagValueNotModified() { using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"); - hr.Headers.Add(HeaderNames.IfMatch, "my-etag"); + var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"); + hr.Headers.Add(HeaderNames.IfMatch, "\\W\"my-etag\""); test.Type() .Run(f => f.GetWithResultAsync(hr, r => Task.FromResult(Result.Ok(new Person { Id = 1, Name = "Angela", ETag = "my-etag" })))) @@ -75,13 +75,35 @@ public void GetWithResultAsync_WithGenETagValue() { using var test = FunctionTester.Create(); var vcr = test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(Result.Ok(new Person { Id = 1, Name = "Angela" })))) + .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"), r => Task.FromResult(Result.Ok(new Person { Id = 1, Name = "Angela" })))) .ToActionResultAssertor() .AssertOK() .Result as ValueContentResult; Assert.That(vcr, Is.Not.Null); Assert.That(vcr!.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM=")); + + var p = test.JsonSerializer.Deserialize(vcr.Content!); + Assert.That(p, Is.Not.Null); + Assert.That(p.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM=")); + } + + [Test] + public void GetWithResultAsync_WithGenETagValue_QueryString() + { + using var test = FunctionTester.Create(); + var vcr = test.Type() + .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(Result.Ok(new Person { Id = 1, Name = "Angela" })))) + .ToActionResultAssertor() + .AssertOK() + .Result as ValueContentResult; + + Assert.That(vcr, Is.Not.Null); + Assert.That(vcr!.ETag, Is.EqualTo("cpDn3xugV1xKSHF9AY4kQRNQ1yC/SU49xC66C92WZbE=")); + + var p = test.JsonSerializer.Deserialize(vcr.Content!); + Assert.That(p, Is.Not.Null); + Assert.That(p.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM=")); } [Test] @@ -89,7 +111,7 @@ public void GetWithResultAsync_WithGenETagValueNotModified() { using var test = FunctionTester.Create(); var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"); - hr.Headers.Add(HeaderNames.IfMatch, "iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM="); + hr.Headers.Add(HeaderNames.IfMatch, "\\W\"cpDn3xugV1xKSHF9AY4kQRNQ1yC/SU49xC66C92WZbE=\""); test.Type() .Run(f => f.GetWithResultAsync(hr, r => Task.FromResult(Result.Ok(new Person { Id = 1, Name = "Angela" })))) @@ -523,7 +545,7 @@ public void PatchWithResultAsync_AutoConcurrency_Matched() .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(new Person { Id = 13, Name = "Deano" })), put: _ => Task.FromResult(Result.Ok(new Person { Id = 13, Name = "Gazza" })), simulatedConcurrency: true)) .ToActionResultAssertor() .AssertOK() - .AssertValue(new Person { Id = 13, Name = "Gazza" }); + .AssertValue(new Person { Id = 13, Name = "Gazza", ETag = "tEEokPXk+4Q5MoiGqyAs1+6A00e2ww59Zm57LJgvBcg=" }); } [Test]