From 1dfcbdaf99d01c1d5c2743cefb653f9ba71fb59c Mon Sep 17 00:00:00 2001 From: "Eric Sibly [chullybun]" Date: Wed, 23 Oct 2024 09:06:09 -0700 Subject: [PATCH] v3.27.3 (#129) * v3.27.3 - *Fixed:* The `ExecutionContext.Messages` were not being returned as intended within the `x-messages` HTTP Response header; enabled within the `ExtendedStatusCodeResult` and `ExtendedContentResult` on success only (status code `>= 200` and `<= 299`). Note these messages are JSON serialized as the underlying `MessageItemCollection` type. * ixed: The AgentTester has been updated to return a HttpResultAssertor where the operation returns a HttpResult to enable further assertions to be made on the Result itself. * Move files to correct namespace. * Add additional tests for assertor. --- CHANGELOG.md | 4 + Common.targets | 2 +- .../Services/EmployeeService.cs | 10 +- .../My.Hr.UnitTest/EmployeeControllerTest.cs | 19 +++- .../Http/HttpResultExtensions.cs | 16 +++ .../WebApis/ExtendedContentResult.cs | 12 +++ .../WebApis/ExtendedStatusCodeResult.cs | 12 +++ .../Storage/BlobLeaseSynchronizer.cs | 21 ++-- src/CoreEx.Database/Database.cs | 3 +- .../AspNetCore/AgentTester.cs | 21 ++-- .../AspNetCore/AgentTesterT.cs | 10 +- .../Assertors/HttpResultAssertor.cs | 21 ++++ .../Assertors/HttpResultAssertorT.cs | 36 +++++++ src/CoreEx/Entities/MessageItem.cs | 2 +- src/CoreEx/Entities/MessageItemCollection.cs | 4 +- src/CoreEx/Events/EventPublisher.cs | 4 - src/CoreEx/ExecutionContext.cs | 101 +++++++++++++++--- src/CoreEx/Hosting/ServiceBase.cs | 2 +- src/CoreEx/Http/HttpResultBase.cs | 2 +- .../Framework/Http/HttpResultTest.cs | 15 +++ .../Framework/UnitTesting/AgentTest.cs | 18 +++- .../Framework/WebApis/WebApiTest.cs | 38 +++++++ 22 files changed, 313 insertions(+), 60 deletions(-) create mode 100644 src/CoreEx.UnitTesting/Assertors/HttpResultAssertor.cs create mode 100644 src/CoreEx.UnitTesting/Assertors/HttpResultAssertorT.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a8ef53e..c40fc0b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Represents the **NuGet** versions. +## v3.27.3 +- *Fixed:* The `ExecutionContext.Messages` were not being returned as intended within the `x-messages` HTTP Response header; enabled within the `ExtendedStatusCodeResult` and `ExtendedContentResult` on success only (status code `>= 200` and `<= 299`). Note these messages are JSON serialized as the underlying `MessageItemCollection` type. +- *Fixed:* The `AgentTester` has been updated to return a `HttpResultAssertor` where the operation returns a `HttpResult` to enable further assertions to be made on the `Result` itself. + ## v3.27.2 - *Fixed:* The `IServiceCollection.AddCosmosDb` extension method was registering as a singleton; this has been corrected to register as scoped. The dependent `CosmosClient` should remain a singleton as is [best practice](https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/best-practice-dotnet). diff --git a/Common.targets b/Common.targets index 0245c7be..b5a6b74f 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 3.27.2 + 3.27.3 preview Avanade Avanade diff --git a/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs b/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs index c375d848..a78a3f4d 100644 --- a/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs +++ b/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs @@ -31,8 +31,14 @@ public EmployeeService(HrDbContext dbContext, IEventPublisher publisher, HrSetti _settings = settings; } - public async Task GetEmployeeAsync(Guid id) - => await _dbContext.Employees.FirstOrDefaultAsync(e => e.Id == id); + public async Task GetEmployeeAsync(Guid id) + { + var emp = await _dbContext.Employees.FirstOrDefaultAsync(e => e.Id == id); + if (emp is not null && emp.Birthday.HasValue && emp.Birthday.Value.Year < 2000) + CoreEx.ExecutionContext.Current.Messages.Add(MessageType.Warning, "Employee is considered old."); + + return emp; + } public Task GetAllAsync(QueryArgs? query, PagingArgs? paging) => _dbContext.Employees.Where(_queryConfig, query).OrderBy(_queryConfig, query).ToCollectionResultAsync(paging); diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs index 6c5f2e05..a13c35f3 100644 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs +++ b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs @@ -51,7 +51,7 @@ public void A110_Get_Found() { using var test = ApiTester.Create(); - test.Controller() + var resp = test.Controller() .Run(c => c.GetAsync(1.ToGuid())) .AssertOK() .AssertValue(new Employee @@ -64,7 +64,22 @@ public void A110_Get_Found() Birthday = new DateTime(1985, 03, 18, 0, 0, 0, DateTimeKind.Unspecified), StartDate = new DateTime(2000, 12, 11, 0, 0, 0, DateTimeKind.Unspecified), PhoneNo = "(425) 612 8113" - }, nameof(Employee.ETag)); + }, nameof(Employee.ETag)) + .Response; + + // Also, validate the context header messages. + var result = HttpResult.CreateAsync(resp).GetAwaiter().GetResult(); + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Messages, Is.Not.Null); + }); + Assert.That(result.Messages, Has.Count.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(result.Messages[0].Type, Is.EqualTo(MessageType.Warning)); + Assert.That(result.Messages[0].Text, Is.EqualTo("Employee is considered old.")); + }); } [Test] diff --git a/src/CoreEx.AspNetCore/Http/HttpResultExtensions.cs b/src/CoreEx.AspNetCore/Http/HttpResultExtensions.cs index 53e82263..b0992412 100644 --- a/src/CoreEx.AspNetCore/Http/HttpResultExtensions.cs +++ b/src/CoreEx.AspNetCore/Http/HttpResultExtensions.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Net.Http.Headers; using System; +using System.Collections.Generic; using System.Globalization; using System.IO; using System.Net; @@ -172,5 +173,20 @@ public static void AddPagingResult(this IHeaderDictionary headers, PagingResult? if (paging.TotalPages.HasValue) headers[HttpConsts.PagingTotalPagesHeaderName] = paging.TotalPages.Value.ToString(CultureInfo.InvariantCulture); } + + /// + /// Adds the to the . + /// + /// The . + /// The . + /// The optional . + public static void AddMessages(this IHeaderDictionary headers, MessageItemCollection? messages, IJsonSerializer? jsonSerializer = null) + { + if (messages is null || messages.Count == 0) + return; + + jsonSerializer ??= JsonSerializer.Default; + headers.TryAdd(HttpConsts.MessagesHeaderName, jsonSerializer.Serialize(messages)); + } } } \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/ExtendedContentResult.cs b/src/CoreEx.AspNetCore/WebApis/ExtendedContentResult.cs index d79010de..0884fd82 100644 --- a/src/CoreEx.AspNetCore/WebApis/ExtendedContentResult.cs +++ b/src/CoreEx.AspNetCore/WebApis/ExtendedContentResult.cs @@ -1,5 +1,7 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.AspNetCore.Http; +using CoreEx.Entities; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using System; @@ -13,6 +15,13 @@ namespace CoreEx.AspNetCore.WebApis /// public class ExtendedContentResult : ContentResult, IExtendedActionResult { + /// + /// Gets or sets the . + /// + /// Defaults to the . + /// Note: These are only written to the headers where the is considered successful; i.e. is in the 200-299 range. + public MessageItemCollection? Messages { get; set; } = ExecutionContext.HasCurrent && ExecutionContext.Current.HasMessages ? ExecutionContext.Current.Messages : null; + /// [JsonIgnore] public Func? BeforeExtension { get; set; } @@ -24,6 +33,9 @@ public class ExtendedContentResult : ContentResult, IExtendedActionResult /// public override async Task ExecuteResultAsync(ActionContext context) { + if (StatusCode >= 200 || StatusCode <= 299) + context.HttpContext.Response.Headers.AddMessages(Messages); + if (BeforeExtension != null) await BeforeExtension(context.HttpContext.Response).ConfigureAwait(false); diff --git a/src/CoreEx.AspNetCore/WebApis/ExtendedStatusCodeResult.cs b/src/CoreEx.AspNetCore/WebApis/ExtendedStatusCodeResult.cs index 55b7e386..4e903f74 100644 --- a/src/CoreEx.AspNetCore/WebApis/ExtendedStatusCodeResult.cs +++ b/src/CoreEx.AspNetCore/WebApis/ExtendedStatusCodeResult.cs @@ -1,5 +1,7 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.AspNetCore.Http; +using CoreEx.Entities; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using System; @@ -26,6 +28,13 @@ public ExtendedStatusCodeResult(HttpStatusCode statusCode) : this((int)statusCod /// public Uri? Location { get; set; } + /// + /// Gets or sets the . + /// + /// Defaults to the . + /// Note: These are only written to the headers where the is considered successful; i.e. is in the 200-299 range. + public MessageItemCollection? Messages { get; set; } = ExecutionContext.HasCurrent && ExecutionContext.Current.HasMessages ? ExecutionContext.Current.Messages : null; + /// [JsonIgnore] public Func? BeforeExtension { get; set; } @@ -37,6 +46,9 @@ public ExtendedStatusCodeResult(HttpStatusCode statusCode) : this((int)statusCod /// public override async Task ExecuteResultAsync(ActionContext context) { + if (StatusCode >= 200 || StatusCode <= 299) + context.HttpContext.Response.Headers.AddMessages(Messages); + if (Location != null) context.HttpContext.Response.GetTypedHeaders().Location = Location; diff --git a/src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs b/src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs index 77d736bf..8ebc276e 100644 --- a/src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs +++ b/src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs @@ -124,15 +124,6 @@ public void Exit(string? name = null) /// public void Dispose() { - if (!_disposed) - { - _disposed = true; - if (_timer.IsValueCreated) - _timer.Value.Dispose(); - - _dict.Values.ForEach(ReleaseLease); - } - Dispose(true); GC.SuppressFinalize(this); } @@ -141,7 +132,17 @@ public void Dispose() /// 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) { } + protected virtual void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _disposed = true; + if (_timer.IsValueCreated) + _timer.Value.Dispose(); + + _dict.Values.ForEach(ReleaseLease); + } + } /// /// Gets the full name. diff --git a/src/CoreEx.Database/Database.cs b/src/CoreEx.Database/Database.cs index 3896ba8d..3a66fea0 100644 --- a/src/CoreEx.Database/Database.cs +++ b/src/CoreEx.Database/Database.cs @@ -187,7 +187,6 @@ protected virtual void Dispose(bool disposing) public async ValueTask DisposeAsync() { await DisposeAsyncCore().ConfigureAwait(false); - Dispose(false); GC.SuppressFinalize(this); } @@ -203,6 +202,8 @@ public virtual async ValueTask DisposeAsyncCore() await _dbConn.DisposeAsync().ConfigureAwait(false); _dbConn = null; } + + Dispose(); } } } \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/AspNetCore/AgentTester.cs b/src/CoreEx.UnitTesting/AspNetCore/AgentTester.cs index 795fe983..ed3b1027 100644 --- a/src/CoreEx.UnitTesting/AspNetCore/AgentTester.cs +++ b/src/CoreEx.UnitTesting/AspNetCore/AgentTester.cs @@ -23,15 +23,15 @@ public class AgentTester(TesterBase owner, TestServer testServer) : Http /// Runs the test by executing a method. /// /// The function to execution. - /// An . - public HttpResponseMessageAssertor Run(Func> func) => RunAsync(func).GetAwaiter().GetResult(); + /// An . + public HttpResultAssertor Run(Func> func) => RunAsync(func).GetAwaiter().GetResult(); /// /// Runs the test by executing a method. /// /// The function to execution. - /// An . - public HttpResponseMessageAssertor Run(Func>> func) => RunAsync(func).GetAwaiter().GetResult(); + /// An . + public HttpResultAssertor Run(Func>> func) => RunAsync(func).GetAwaiter().GetResult(); /// /// Runs the test by executing a method. @@ -44,8 +44,8 @@ public class AgentTester(TesterBase owner, TestServer testServer) : Http /// Runs the test by executing a method. /// /// The function to execution. - /// An . - public async Task RunAsync(Func> func) + /// An . + public async Task RunAsync(Func> func) { func.ThrowIfNull(nameof(func)); @@ -58,15 +58,15 @@ public async Task RunAsync(Func /// Runs the test by executing a method. /// /// The function to execution. - /// An . - public async Task> RunAsync(Func>> func) + /// An . + public async Task> RunAsync(Func>> func) { func.ThrowIfNull(nameof(func)); @@ -82,7 +82,7 @@ public async Task> RunAsync(Func(Owner, res.Value, res.Response) : new HttpResponseMessageAssertor(Owner, res.Response); + return res.IsSuccess ? new HttpResultAssertor(Owner, res.Value, res) : new HttpResultAssertor(Owner, res); } /// @@ -103,7 +103,6 @@ public async Task RunAsync(Func /// Perform the assertion of any expectations. /// diff --git a/src/CoreEx.UnitTesting/AspNetCore/AgentTesterT.cs b/src/CoreEx.UnitTesting/AspNetCore/AgentTesterT.cs index d9ebaeb4..09507cb0 100644 --- a/src/CoreEx.UnitTesting/AspNetCore/AgentTesterT.cs +++ b/src/CoreEx.UnitTesting/AspNetCore/AgentTesterT.cs @@ -25,8 +25,8 @@ public class AgentTester(TesterBase owner, TestServer testServer /// Runs the test by executing a method. /// /// The function to execution. - /// An . - public HttpResponseMessageAssertor Run(Func>> func) => RunAsync(func).GetAwaiter().GetResult(); + /// An . + public HttpResultAssertor Run(Func>> func) => RunAsync(func).GetAwaiter().GetResult(); /// /// Runs the test by executing a method. @@ -39,8 +39,8 @@ public class AgentTester(TesterBase owner, TestServer testServer /// Runs the test by executing a method. /// /// The function to execution. - /// An . - public async Task> RunAsync(Func>> func) + /// An . + public async Task> RunAsync(Func>> func) { func.ThrowIfNull(nameof(func)); @@ -56,7 +56,7 @@ public async Task> RunAsync(Func(Owner, res.Value, res.Response) : new HttpResponseMessageAssertor(Owner, res.Response); + return res.IsSuccess ? new HttpResultAssertor(Owner, res.Value, res) : new HttpResultAssertor(Owner, res); } /// diff --git a/src/CoreEx.UnitTesting/Assertors/HttpResultAssertor.cs b/src/CoreEx.UnitTesting/Assertors/HttpResultAssertor.cs new file mode 100644 index 00000000..1db30345 --- /dev/null +++ b/src/CoreEx.UnitTesting/Assertors/HttpResultAssertor.cs @@ -0,0 +1,21 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx; +using CoreEx.Http; +using UnitTestEx.Abstractions; + +namespace UnitTestEx.Assertors +{ + /// + /// Represents the test assert helper. + /// + /// The owning . + /// The . + public class HttpResultAssertor(TesterBase owner, HttpResult result) : HttpResponseMessageAssertor(owner, result.ThrowIfNull(nameof(result)).Response) + { + /// + /// Gets the . + /// + public HttpResult Result { get; private set; } = result; + } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Assertors/HttpResultAssertorT.cs b/src/CoreEx.UnitTesting/Assertors/HttpResultAssertorT.cs new file mode 100644 index 00000000..330f073d --- /dev/null +++ b/src/CoreEx.UnitTesting/Assertors/HttpResultAssertorT.cs @@ -0,0 +1,36 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx; +using CoreEx.Http; +using System; +using UnitTestEx.Abstractions; + +namespace UnitTestEx.Assertors +{ + /// + /// Represents the test assert helper with a specified result . + /// + /// + public class HttpResultAssertor : HttpResponseMessageAssertor + { + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The . + public HttpResultAssertor(TesterBase owner, HttpResult result) : base(owner, result.ThrowIfNull(nameof(result)).Response) => Result = result; + + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The value already deserialized. + /// + public HttpResultAssertor(TesterBase owner, TValue value, HttpResult result) : base(owner, value, result.ThrowIfNull(nameof(result)).Response) => Result = result; + + /// + /// Gets the . + /// + public HttpResult Result { get; private set; } + } +} \ No newline at end of file diff --git a/src/CoreEx/Entities/MessageItem.cs b/src/CoreEx/Entities/MessageItem.cs index bb9e5395..38bf4d80 100644 --- a/src/CoreEx/Entities/MessageItem.cs +++ b/src/CoreEx/Entities/MessageItem.cs @@ -72,7 +72,7 @@ public static MessageItem CreateErrorMessage(string property, LText format, para #endregion /// - /// Gets the message severity validatorType. + /// Gets the message severity type. /// public MessageType Type { get; set; } diff --git a/src/CoreEx/Entities/MessageItemCollection.cs b/src/CoreEx/Entities/MessageItemCollection.cs index 93f8fc8c..192b3673 100644 --- a/src/CoreEx/Entities/MessageItemCollection.cs +++ b/src/CoreEx/Entities/MessageItemCollection.cs @@ -186,14 +186,14 @@ public MessageItem Add(string? property, MessageType type, LText format, params /// /// Gets a new for a selected . /// - /// Message validatorType. + /// Message severity type. /// A new . public MessageItemCollection GetMessagesForType(MessageType type) => new(this.Where(x => x.Type == type)); /// /// Gets a new for a selected and . /// - /// Message validatorType. + /// Message severity type. /// The name of the property that the message relates to. /// A new . public MessageItemCollection GetMessagesForType(MessageType type, string property) => new(this.Where(x => x.Type == type && x.Property == property)); diff --git a/src/CoreEx/Events/EventPublisher.cs b/src/CoreEx/Events/EventPublisher.cs index 330e567b..a5f1309e 100644 --- a/src/CoreEx/Events/EventPublisher.cs +++ b/src/CoreEx/Events/EventPublisher.cs @@ -18,7 +18,6 @@ namespace CoreEx.Events public class EventPublisher(EventDataFormatter? eventDataFormatter, IEventSerializer eventSerializer, IEventSender eventSender) : IEventPublisher, IDisposable { private readonly ConcurrentQueue<(string? Destination, EventData Event)> _queue = new(); - private bool _disposed; /// /// Gets the . @@ -105,9 +104,6 @@ public Task SendAsync(CancellationToken cancellationToken = default) => EventPub /// Thrown when there are unsent events. public void Dispose() { - if (!_disposed) - _disposed = true; - Dispose(true); GC.SuppressFinalize(this); } diff --git a/src/CoreEx/ExecutionContext.cs b/src/CoreEx/ExecutionContext.cs index 74aedca4..63f943c5 100644 --- a/src/CoreEx/ExecutionContext.cs +++ b/src/CoreEx/ExecutionContext.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Threading; namespace CoreEx @@ -22,11 +23,12 @@ public class ExecutionContext : ITenantId, IDisposable private static readonly AsyncLocal _asyncLocal = new(); private DateTime? _timestamp; - private Lazy _messages = new(true); + private Lazy _messages = new(CreateWithNoErrorTypeSupport, true); private Lazy> _properties = new(true); private IReferenceDataContext? _referenceDataContext; private HashSet? _roles; private HashSet? _permissions; + private bool _isCopied; private bool _disposed; private readonly object _lock = new(); @@ -43,20 +45,27 @@ public class ExecutionContext : ITenantId, IDisposable /// /// Gets the current for the executing thread graph (see ). /// - /// Where not previously set (see then the will be invoked as a backup to create an instance. + /// Where not previously set (see ) then the will be invoked as a backup to create an instance on first access. + /// The should be used to dispose and clear the current where no longer needed. public static ExecutionContext Current => _asyncLocal.Value ??= Create?.Invoke() ?? throw new InvalidOperationException("There is currently no ExecutionContext.Current instance; this must be set (SetCurrent) prior to access. Use ExecutionContext.HasCurrent to verify value and avoid this exception if appropriate."); /// - /// Resets (clears) the . + /// Resets (disposes and clears) the . /// - public static void Reset() => _asyncLocal.Value = null; + public static void Reset() + { + if (HasCurrent) + Current.Dispose(); + + _asyncLocal.Value = null; + } /// /// Sets the instance (only allowed where is false). /// /// The instance. - public static void SetCurrent(ExecutionContext? executionContext) + public static void SetCurrent(ExecutionContext executionContext) { if (HasCurrent) throw new InvalidOperationException("The SetCurrent method can only be used where there is no Current instance."); @@ -173,10 +182,18 @@ public static object GetRequiredService(Type type) public DateTime Timestamp { get => _timestamp ??= SystemTime.UtcNow; set => _timestamp = Cleaner.Clean(value); } /// - /// Gets the to be passed back to the originating consumer. + /// Gets the that is intended to be returned to the originating consumer. /// + /// This is not intended to be a replacement for returning errors/exceptions; as such, if a with a of is added a corresponding + /// will be thrown. This is ultimately intended for warning and information messages that provide additional context outside of the intended operation result. + /// There are no guarantees that these messages will be returned; it is the responsibility of the hosting process to manage. public MessageItemCollection Messages { get => _messages.Value; } + /// + /// Indicates whether there are any . + /// + public bool HasMessages => _messages.IsValueCreated && _messages.Value.Count > 0; + /// /// Gets the properties for passing/storing additional data. /// @@ -189,10 +206,40 @@ public static object GetRequiredService(Type type) public IReferenceDataContext ReferenceDataContext => _referenceDataContext ??= (GetService() ?? new ReferenceDataContext()); /// - /// Creates a copy of the using the function to instantiate before copying all underlying properties. + /// Indicates whether this instance was created as a result of a operation. + /// + public bool IsACopy => _isCopied; + + /// + /// Creates a new (or uses the specified ) and returns the new . + /// + /// The optional . + /// The as an . + /// Performs a followed by a corresponding . + /// Useful for scoped scenarios where the underlying will be automatically invoked, such as the following: + /// + /// using var ec = ExecutionContext.CreateNew(); + /// + /// // or + /// + /// using (ExecutionContext.CreateNew()) + /// { + /// } + /// + /// + public static ExecutionContext CreateNew(ExecutionContext? executionContext = null) + { + Reset(); + SetCurrent(executionContext ?? Create?.Invoke() ?? new ExecutionContext()); + return Current; + } + + /// + /// Creates a copy of the using the function to instantiate before copying or referencing all underlying properties. /// /// The new instance. - /// Note: the , and Roles share same instance, i.e. are not copied. + /// This is intended for advanced scenarios and may have unintended consequences where not used correctly. + /// Note: the , , , Roles and Permissions share same instance, i.e. are not copied. public virtual ExecutionContext CreateCopy() { var ec = Create == null ? throw new InvalidOperationException($"The {nameof(Create)} function must not be null to create a copy.") : Create(); @@ -201,6 +248,7 @@ public virtual ExecutionContext CreateCopy() ec._properties = _properties; ec._referenceDataContext = _referenceDataContext; ec._roles = _roles; + ec._permissions = _permissions; ec.ServiceProvider = ServiceProvider; ec.CorrelationId = CorrelationId; ec.OperationType = OperationType; @@ -208,6 +256,7 @@ public virtual ExecutionContext CreateCopy() ec.UserName = UserName; ec.UserId = UserId; ec.TenantId = TenantId; + ec._isCopied = true; return ec; } @@ -216,27 +265,49 @@ public virtual ExecutionContext CreateCopy() /// public void Dispose() { - if (!_disposed) + 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) + { + if (disposing && !_disposed) { lock (_lock) { if (!_disposed) { + if (!_isCopied && _messages.IsValueCreated) + _messages.Value.CollectionChanged -= Messages_CollectionChanged; + _disposed = true; - Dispose(true); } } } + } - Dispose(true); - GC.SuppressFinalize(this); + /// + /// Create a new with the contrainst that no messages can be added. + /// + private static MessageItemCollection CreateWithNoErrorTypeSupport() + { + var messages = new MessageItemCollection(); + messages.CollectionChanged += Messages_CollectionChanged; + return messages; } /// - /// Releases the unmanaged resources used by the and optionally releases the managed resources. + /// Handles the CollectionChanged event to ensure that no error messages are added. /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) => Reset(); + private static void Messages_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + if (e.NewItems is not null && e.NewItems.OfType().Any(m => m.Type == MessageType.Error)) + throw new InvalidOperationException("An error message can not be added to the ExecutionContext.Messages collection; this is intended for warning and information messages only."); + } #region Security diff --git a/src/CoreEx/Hosting/ServiceBase.cs b/src/CoreEx/Hosting/ServiceBase.cs index f00807ef..c69281d3 100644 --- a/src/CoreEx/Hosting/ServiceBase.cs +++ b/src/CoreEx/Hosting/ServiceBase.cs @@ -88,7 +88,7 @@ await ServiceInvoker.Current.InvokeAsync(this, async (_, cancellationToken) => { // Create a scope in which to perform the execution. using var scope = ServiceProvider.CreateScope(); - CoreEx.ExecutionContext.Reset(); + ExecutionContext.Reset(); try { diff --git a/src/CoreEx/Http/HttpResultBase.cs b/src/CoreEx/Http/HttpResultBase.cs index f745bd31..f65fe5e1 100644 --- a/src/CoreEx/Http/HttpResultBase.cs +++ b/src/CoreEx/Http/HttpResultBase.cs @@ -53,7 +53,7 @@ protected HttpResultBase(HttpResponseMessage response, BinaryData? content) try { - return System.Text.Json.JsonSerializer.Deserialize(mic); + return Json.JsonSerializer.Default.Deserialize(mic); } catch { diff --git a/tests/CoreEx.Test/Framework/Http/HttpResultTest.cs b/tests/CoreEx.Test/Framework/Http/HttpResultTest.cs index 1f0776eb..91f807f7 100644 --- a/tests/CoreEx.Test/Framework/Http/HttpResultTest.cs +++ b/tests/CoreEx.Test/Framework/Http/HttpResultTest.cs @@ -1,7 +1,9 @@ using CoreEx.Http; using NUnit.Framework; using System; +using System.Linq; using System.Net; +using System.Net.Http; using System.Threading.Tasks; namespace CoreEx.Test.Framework.Http @@ -26,5 +28,18 @@ public async Task Create_InternalException() Assert.That(rr.Error, Is.TypeOf()); }); } + + [Test] + public async Task Messages() + { + var r = new HttpResponseMessage(System.Net.HttpStatusCode.OK); + r.Headers.Add("x-messages", """[{"type":"Warning","text":"Please renew licence."}]"""); + + var hr = await HttpResult.CreateAsync(r); + Assert.That(hr, Is.Not.Null); + Assert.That(hr.Messages, Has.Count.EqualTo(1)); + Assert.That(hr.Messages[0].Type, Is.EqualTo(CoreEx.Entities.MessageType.Warning)); + Assert.That(hr.Messages[0].Text, Is.EqualTo("Please renew licence.")); + } } } \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/UnitTesting/AgentTest.cs b/tests/CoreEx.Test/Framework/UnitTesting/AgentTest.cs index da7311d2..49c2e0be 100644 --- a/tests/CoreEx.Test/Framework/UnitTesting/AgentTest.cs +++ b/tests/CoreEx.Test/Framework/UnitTesting/AgentTest.cs @@ -11,6 +11,7 @@ using CoreEx.Http; using CoreEx.TestFunction.Models; using UnitTestEx.Expectations; +using UnitTestEx.Assertors; namespace CoreEx.Test.Framework.UnitTesting { @@ -35,30 +36,39 @@ public void Get() public void Update_Error() { var test = ApiTester.Create(); - test.Agent().With() + var result = test.Agent().With() .ExpectErrorType(CoreEx.Abstractions.ErrorType.ValidationError) .ExpectError("Zed is dead.") - .Run(a => a.UpdateAsync(new Product { Name = "Apple", Price = 0.79m }, "Zed")); + .Run(a => a.UpdateAsync(new Product { Name = "Apple", Price = 0.79m }, "Zed")) + .Result; + + Assert.That(result.Response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.BadRequest)); } [Test] public void Delete() { var test = ApiTester.Create(); - test.Agent().With() + var res = test.Agent().With() .Run(a => a.DeleteAsync("abc")) .AssertNoContent(); + + var result = (HttpResultAssertor)res; + Assert.That(result.Response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.NoContent)); } [Test] public void Catalogue() { var test = ApiTester.Create(); - var x = test.Agent().With() + var res = test.Agent().With() .Run(a => a.CatalogueAsync("abc")) .AssertOK() .AssertContentTypePlainText() .AssertContent("Catalog for 'abc'."); + + var result = (HttpResultAssertor)res; + Assert.That(result.Response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.OK)); } public class ProductAgent(HttpClient client, IJsonSerializer jsonSerializer, CoreEx.ExecutionContext executionContext) : CoreEx.Http.TypedHttpClientBase(client, jsonSerializer, executionContext) diff --git a/tests/CoreEx.Test/Framework/WebApis/WebApiTest.cs b/tests/CoreEx.Test/Framework/WebApis/WebApiTest.cs index 61df5b20..a47bc690 100644 --- a/tests/CoreEx.Test/Framework/WebApis/WebApiTest.cs +++ b/tests/CoreEx.Test/Framework/WebApis/WebApiTest.cs @@ -376,6 +376,44 @@ public void GetAsync_WithCollection_FieldsExclude() .AssertValue(new PersonCollection { new Person { Id = 1 } }); } + [Test] + public void GetAsync_WithMessages_ErrorStatusCode() + { + using var test = FunctionTester.Create(); + var result = test.Type() + .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest"), r => + { + ExecutionContext.Current.Messages.Add(MessageType.Warning, "Please renew licence."); + return Task.FromResult(null); + })) + .ToActionResultAssertor() + .Assert(HttpStatusCode.NotFound) + .Result as StatusCodeResult; + + Assert.That(result, Is.Not.Null); + } + + [Test] + public void GetAsync_WithMessages_SuccessStatusCode() + { + using var test = FunctionTester.Create(); + var result = test.Type() + .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest"), r => + { + ExecutionContext.Current.Messages.Add(MessageType.Warning, "Please renew licence."); + return Task.FromResult("This is ok."); + })) + .ToActionResultAssertor() + .Assert(HttpStatusCode.OK) + .Result as ValueContentResult; + + Assert.That(result, Is.Not.Null); + Assert.That(result.Messages, Is.Not.Null); + Assert.That(result.Messages, Has.Count.EqualTo(1)); + Assert.That(result.Messages[0].Type, Is.EqualTo(MessageType.Warning)); + Assert.That(result.Messages[0].Text, Is.EqualTo("Please renew licence.")); + } + [Test] public void PostAsync_NoValueNoResult() {