From 38c651767e3b7ddfe5171a339dceb9593d3df74e Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 10 Jul 2026 12:59:32 +0800 Subject: [PATCH] Support HTTP QUERY method - Treat QUERY as a safe method like GET: excluded from audit logging and non-transactional UOW - Add HTTP verb constants and Is* helpers to HttpMethodHelper --- .../api-development/auto-controllers.md | 2 + .../domain-driven-design/unit-of-work.md | 4 +- .../framework/infrastructure/audit-logging.md | 2 +- .../AspNetCore/Mvc/Uow/AbpUowActionFilter.cs | 5 +- .../AspNetCore/Mvc/Uow/AbpUowPageFilter.cs | 5 +- .../Auditing/AbpAuditingMiddleware.cs | 6 +- ...eUnitOfWorkTransactionBehaviourProvider.cs | 8 +- .../Volo/Abp/Auditing/AbpAuditingOptions.cs | 1 + .../Volo/Abp/Auditing/AuditingInterceptor.cs | 1 + .../Volo/Abp/Http/HttpMethodHelper.cs | 62 ++++++++--- .../Mvc/Auditing/AuditTestController_Tests.cs | 14 +++ .../Mvc/Uow/UnitOfWorkMiddleware_Tests.cs | 11 +- .../Mvc/Uow/UnitOfWorkPageFilter_Tests.cs | 11 +- .../Mvc/Uow/UnitOfWorkTestController.cs | 10 ++ .../Mvc/Uow/UnitOfWorkTestPage.cshtml.cs | 8 ++ ...fWorkTransactionBehaviourProvider_Tests.cs | 32 ++++++ .../AuditingInterceptor_HttpMethod_Tests.cs | 64 +++++++++++ .../DynamicProxying/IRegularTestController.cs | 2 + .../DynamicProxying/RegularTestController.cs | 7 ++ .../RegularTestControllerClientProxy_Tests.cs | 8 ++ .../Volo/Abp/Http/HttpMethodHelper_Tests.cs | 105 ++++++++++++++++++ 21 files changed, 336 insertions(+), 32 deletions(-) create mode 100644 framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider_Tests.cs create mode 100644 framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/AuditingInterceptor_HttpMethod_Tests.cs create mode 100644 framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/HttpMethodHelper_Tests.cs diff --git a/docs/en/framework/api-development/auto-controllers.md b/docs/en/framework/api-development/auto-controllers.md index 2c6f0ee39a..9591e544ed 100644 --- a/docs/en/framework/api-development/auto-controllers.md +++ b/docs/en/framework/api-development/auto-controllers.md @@ -62,6 +62,8 @@ ABP uses a naming convention while determining the HTTP method for a service met If you need to customize HTTP method for a particular method, then you can use one of the standard ASP.NET Core attributes ([HttpPost], [HttpGet], [HttpPut]... etc.). This requires to add [Microsoft.AspNetCore.Mvc.Core](https://www.nuget.org/packages/Microsoft.AspNetCore.Mvc.Core) nuget package to your project that contains the service. +The naming convention doesn't map the HTTP QUERY method (a safe method that carries its parameters in the request body, useful when a GET request would have too many query string parameters). If you want to expose an action as a QUERY endpoint, use the `[AcceptVerbs("QUERY")]` attribute explicitly. Such an action is treated as a safe method, so it is not audited and doesn't start a transactional unit of work by default, just like a GET request. However, unlike a GET request, a QUERY request still requires the anti-forgery token because it carries a request body. This is consistent with ASP.NET Core, which doesn't treat QUERY as an anti-forgery exempt method. + ### Route Route is calculated based on some conventions: diff --git a/docs/en/framework/architecture/domain-driven-design/unit-of-work.md b/docs/en/framework/architecture/domain-driven-design/unit-of-work.md index 697d7dba80..cfe851b249 100644 --- a/docs/en/framework/architecture/domain-driven-design/unit-of-work.md +++ b/docs/en/framework/architecture/domain-driven-design/unit-of-work.md @@ -38,10 +38,10 @@ All of these are automatically handled by the ABP. While the section above explains the UOW as it is database transaction, actually a UOW doesn't have to be transactional. By default; -* **HTTP GET** requests don't start a transactional UOW. They still starts a UOW, but **doesn't create a database transaction**. +* **HTTP GET** and **HTTP QUERY** requests don't start a transactional UOW. They still starts a UOW, but **doesn't create a database transaction**. * All other HTTP request types start a UOW with a database transaction, if database level transactions are supported by the underlying database provider. -This is because an HTTP GET request doesn't (and shouldn't) make any change in the database. You can change this behavior using the options explained below. +This is because they are safe HTTP methods that don't (and shouldn't) make any change in the database. You can change this behavior using the options explained below. ## Default Options diff --git a/docs/en/framework/infrastructure/audit-logging.md b/docs/en/framework/infrastructure/audit-logging.md index ee2cdf7e41..9dc7618281 100644 --- a/docs/en/framework/infrastructure/audit-logging.md +++ b/docs/en/framework/infrastructure/audit-logging.md @@ -48,7 +48,7 @@ Here, a list of the options you can configure: * `IsEnabledForAnonymousUsers` (default: `true`): If you want to write audit logs only for the authenticated users, set this to `false`. If you save audit logs for anonymous users, you will see `null` for `UserId` values for these users. * `AlwaysLogOnException` (default: `true`): If you set to true, it always saves the audit log on an exception/error case without checking other options (except `IsEnabled`, which completely disables the audit logging). * `IsEnabledForIntegrationService` (default: `false`): Audit Logging is disabled for [integration services](../api-development/integration-services.md) by default. Set this property as `true` to enable it. -* `IsEnabledForGetRequests` (default: `false`): HTTP GET requests should not make any change in the database normally and audit log system doesn't save audit log objects for GET request. Set this to `true` to enable it also for the GET requests. +* `IsEnabledForGetRequests` (default: `false`): Safe HTTP methods (GET, HEAD and QUERY) should not make any change in the database normally and the audit log system doesn't save audit log objects for these requests. Set this to `true` to enable it also for the safe requests. * `DisableLogActionInfo` (default: `false`):If you set to true, Will no longer log `AuditLogActionInfo`. * `ApplicationName`: If multiple applications are saving audit logs into a single database, set this property to your application name, so you can distinguish the logs of different applications. If you don't set, it will set from the `IApplicationInfoAccessor.ApplicationName` value, which is the entry assembly name by default. * `IgnoredTypes`: A list of `Type`s to be ignored for audit logging. If this is an entity type, changes for this type of entities will not be saved. This list is also used while serializing the action parameters. diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowActionFilter.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowActionFilter.cs index 5cd9bf8089..f1997fb8d4 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowActionFilter.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowActionFilter.cs @@ -1,5 +1,4 @@ using System; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -7,6 +6,7 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; using Volo.Abp.AspNetCore.Filters; using Volo.Abp.DependencyInjection; +using Volo.Abp.Http; using Volo.Abp.Threading; using Volo.Abp.Uow; @@ -81,7 +81,8 @@ public class AbpUowActionFilter : IAsyncActionFilter, IAbpFilter, ITransientDepe { var abpUnitOfWorkDefaultOptions = context.GetRequiredService>().Value; options.IsTransactional = abpUnitOfWorkDefaultOptions.CalculateIsTransactional( - autoValue: !string.Equals(context.HttpContext.Request.Method, HttpMethod.Get.Method, StringComparison.OrdinalIgnoreCase) + autoValue: !(HttpMethodHelper.IsGet(context.HttpContext.Request.Method) + || HttpMethodHelper.IsQuery(context.HttpContext.Request.Method)) ); } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowPageFilter.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowPageFilter.cs index b086df1a42..d8f86bf261 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowPageFilter.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowPageFilter.cs @@ -1,5 +1,4 @@ using System; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -7,6 +6,7 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; using Volo.Abp.AspNetCore.Filters; using Volo.Abp.DependencyInjection; +using Volo.Abp.Http; using Volo.Abp.Threading; using Volo.Abp.Uow; @@ -86,7 +86,8 @@ public class AbpUowPageFilter : IAsyncPageFilter, IAbpFilter, ITransientDependen { var abpUnitOfWorkDefaultOptions = context.GetRequiredService>().Value; options.IsTransactional = abpUnitOfWorkDefaultOptions.CalculateIsTransactional( - autoValue: !string.Equals(context.HttpContext.Request.Method, HttpMethod.Get.Method, StringComparison.OrdinalIgnoreCase) + autoValue: !(HttpMethodHelper.IsGet(context.HttpContext.Request.Method) + || HttpMethodHelper.IsQuery(context.HttpContext.Request.Method)) ); } diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs index 86cc997ae7..112b3be61a 100644 --- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs +++ b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Options; using Volo.Abp.AspNetCore.Middleware; using Volo.Abp.Auditing; using Volo.Abp.DependencyInjection; +using Volo.Abp.Http; using Volo.Abp.Uow; using Volo.Abp.Users; @@ -135,8 +136,9 @@ public class AbpAuditingMiddleware : AbpMiddlewareBase, ITransientDependency } if (!AuditingOptions.IsEnabledForGetRequests && - (string.Equals(httpContext.Request.Method, HttpMethods.Get, StringComparison.OrdinalIgnoreCase) || - string.Equals(httpContext.Request.Method, HttpMethods.Head, StringComparison.OrdinalIgnoreCase))) + (HttpMethodHelper.IsGet(httpContext.Request.Method) || + HttpMethodHelper.IsHead(httpContext.Request.Method) || + HttpMethodHelper.IsQuery(httpContext.Request.Method))) { return false; } diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider.cs index a1758d3a90..e323b323d1 100644 --- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider.cs @@ -1,8 +1,8 @@ using System; -using System.Net.Http; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using Volo.Abp.DependencyInjection; +using Volo.Abp.Http; using Volo.Abp.Uow; namespace Volo.Abp.AspNetCore.Uow; @@ -37,10 +37,8 @@ public class AspNetCoreUnitOfWorkTransactionBehaviourProvider : IUnitOfWorkTrans } } - return !string.Equals( - httpContext.Request.Method, - HttpMethod.Get.Method, StringComparison.OrdinalIgnoreCase - ); + var method = httpContext.Request.Method; + return !(HttpMethodHelper.IsGet(method) || HttpMethodHelper.IsQuery(method)); } } diff --git a/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AbpAuditingOptions.cs b/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AbpAuditingOptions.cs index f9f5284b29..81d87023f7 100644 --- a/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AbpAuditingOptions.cs +++ b/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AbpAuditingOptions.cs @@ -62,6 +62,7 @@ public class AbpAuditingOptions //TODO: Move this to asp.net core layer or convert it to a more dynamic strategy? /// /// Default: false. + /// When false, safe methods (GET, HEAD and QUERY) are excluded from audit logging. /// public bool IsEnabledForGetRequests { get; set; } diff --git a/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditingInterceptor.cs b/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditingInterceptor.cs index 52c6302da2..0dd70d68e7 100644 --- a/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditingInterceptor.cs +++ b/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditingInterceptor.cs @@ -193,6 +193,7 @@ public class AuditingInterceptor : AbpInterceptor, ITransientDependency if (!options.IsEnabledForGetRequests && (string.Equals(auditLogInfo.HttpMethod, "Get", StringComparison.OrdinalIgnoreCase) || string.Equals(auditLogInfo.HttpMethod, "Head", StringComparison.OrdinalIgnoreCase) || + string.Equals(auditLogInfo.HttpMethod, "Query", StringComparison.OrdinalIgnoreCase) || invocation.Method.Name.StartsWith("Get", StringComparison.OrdinalIgnoreCase))) { return false; diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/HttpMethodHelper.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/HttpMethodHelper.cs index 78ee75e901..1ef6961c29 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/HttpMethodHelper.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/HttpMethodHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -8,15 +8,25 @@ namespace Volo.Abp.Http; public static class HttpMethodHelper { - public const string DefaultHttpVerb = "POST"; + public const string Get = "GET"; + public const string Post = "POST"; + public const string Put = "PUT"; + public const string Delete = "DELETE"; + public const string Patch = "PATCH"; + public const string Head = "HEAD"; + public const string Options = "OPTIONS"; + public const string Trace = "TRACE"; + public const string Query = "QUERY"; + + public const string DefaultHttpVerb = Post; public static Dictionary ConventionalPrefixes { get; set; } = new Dictionary { - {"GET", new[] {"GetList", "GetAll", "Get"}}, - {"PUT", new[] {"Put", "Update"}}, - {"DELETE", new[] {"Delete", "Remove"}}, - {"POST", new[] {"Create", "Add", "Insert", "Post"}}, - {"PATCH", new[] {"Patch"}} + {Get, new[] {"GetList", "GetAll", "Get"}}, + {Put, new[] {"Put", "Update"}}, + {Delete, new[] {"Delete", "Remove"}}, + {Post, new[] {"Create", "Add", "Insert", "Post"}}, + {Patch, new[] {"Patch"}} }; public static string GetConventionalVerbForMethodName(string methodName) @@ -50,24 +60,44 @@ public static class HttpMethodHelper { switch (httpMethod?.ToUpperInvariant()) { - case "GET": + case Get: return HttpMethod.Get; - case "POST": + case Post: return HttpMethod.Post; - case "PUT": + case Put: return HttpMethod.Put; - case "DELETE": + case Delete: return HttpMethod.Delete; - case "OPTIONS": + case Options: return HttpMethod.Options; - case "TRACE": + case Trace: return HttpMethod.Trace; - case "HEAD": + case Head: return HttpMethod.Head; - case "PATCH": - return new HttpMethod("PATCH"); + case Patch: + return new HttpMethod(Patch); + case Query: + return new HttpMethod(Query); default: throw new AbpException("Unknown HTTP METHOD: " + httpMethod); } } + + public static bool IsGet(string? httpMethod) => string.Equals(httpMethod, Get, StringComparison.OrdinalIgnoreCase); + + public static bool IsPost(string? httpMethod) => string.Equals(httpMethod, Post, StringComparison.OrdinalIgnoreCase); + + public static bool IsPut(string? httpMethod) => string.Equals(httpMethod, Put, StringComparison.OrdinalIgnoreCase); + + public static bool IsDelete(string? httpMethod) => string.Equals(httpMethod, Delete, StringComparison.OrdinalIgnoreCase); + + public static bool IsPatch(string? httpMethod) => string.Equals(httpMethod, Patch, StringComparison.OrdinalIgnoreCase); + + public static bool IsHead(string? httpMethod) => string.Equals(httpMethod, Head, StringComparison.OrdinalIgnoreCase); + + public static bool IsOptions(string? httpMethod) => string.Equals(httpMethod, Options, StringComparison.OrdinalIgnoreCase); + + public static bool IsTrace(string? httpMethod) => string.Equals(httpMethod, Trace, StringComparison.OrdinalIgnoreCase); + + public static bool IsQuery(string? httpMethod) => string.Equals(httpMethod, Query, StringComparison.OrdinalIgnoreCase); } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Auditing/AuditTestController_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Auditing/AuditTestController_Tests.cs index dac9259368..02093e0343 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Auditing/AuditTestController_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Auditing/AuditTestController_Tests.cs @@ -58,6 +58,20 @@ public class AuditTestController_Tests : AspNetCoreMvcTestBase await _auditingStore.Received().DidNotReceive().SaveAsync(Arg.Any()); } + [Fact] + public async Task Should_Disable_AuditLog_For_Query_Requests() + { + _options.IsEnabledForGetRequests = false; + + using (var requestMessage = new HttpRequestMessage(new HttpMethod("QUERY"), "api/audit-test/audit-success")) + { + var response = await Client.SendAsync(requestMessage); + response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK); + } + + await _auditingStore.Received().DidNotReceive().SaveAsync(Arg.Any()); + } + [Fact] public async Task Should_Trigger_Middleware_And_AuditLog_Success_For_GetRequests() { diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs index 57e01df4f9..05e5d7b524 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Net.Http; +using System.Threading.Tasks; using Shouldly; using Xunit; @@ -18,4 +19,12 @@ public class UnitOfWorkMiddleware_Tests : AspNetCoreMvcTestBase var result = await Client.PostAsync("/api/unitofwork-test/ActionRequiresUowPost", null); result.IsSuccessStatusCode.ShouldBeTrue(); } + + [Fact] + public async Task Query_Actions_Should_Not_Be_Transactional() + { + using var requestMessage = new HttpRequestMessage(new HttpMethod("QUERY"), "/api/unitofwork-test/ActionRequiresUowQuery"); + var result = await Client.SendAsync(requestMessage); + result.IsSuccessStatusCode.ShouldBeTrue(); + } } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkPageFilter_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkPageFilter_Tests.cs index 6de88f20a8..43bca30e20 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkPageFilter_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkPageFilter_Tests.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Net.Http; +using System.Threading.Tasks; using Shouldly; using Xunit; @@ -18,4 +19,12 @@ public class UnitOfWorkPageFilter_Tests : AspNetCoreMvcTestBase var result = await Client.PostAsync("/Uow/UnitOfWorkTestPage?handler=RequiresUow", null); result.IsSuccessStatusCode.ShouldBeTrue(); } + + [Fact] + public async Task Query_Actions_Should_Not_Be_Transactional() + { + using var requestMessage = new HttpRequestMessage(new HttpMethod("QUERY"), "/Uow/UnitOfWorkTestPage?handler=RequiresUow"); + var result = await Client.SendAsync(requestMessage); + result.IsSuccessStatusCode.ShouldBeTrue(); + } } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs index bf05c55a31..ebf2c12a6a 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs @@ -34,6 +34,16 @@ public class UnitOfWorkTestController : AbpController return Content("OK"); } + [AcceptVerbs("QUERY")] + [Route("ActionRequiresUowQuery")] + public ActionResult ActionRequiresUowQuery() + { + CurrentUnitOfWork.ShouldNotBeNull(); + CurrentUnitOfWork.Options.IsTransactional.ShouldBeFalse(); + + return Content("OK"); + } + [HttpGet] [Route("HandledException")] [UnitOfWork(isTransactional: true)] diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestPage.cshtml.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestPage.cshtml.cs index ebf9e6cfd4..b0ce556405 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestPage.cshtml.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestPage.cshtml.cs @@ -31,6 +31,14 @@ public class UnitOfWorkTestPage : AbpPageModel return Content("OK"); } + public IActionResult OnQueryRequiresUow() + { + CurrentUnitOfWork.ShouldNotBeNull(); + CurrentUnitOfWork.Options.IsTransactional.ShouldBeFalse(); + + return Content("OK"); + } + [UnitOfWork(isTransactional: true)] public ObjectResult OnGetHandledException() { diff --git a/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider_Tests.cs new file mode 100644 index 0000000000..e5369e688e --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider_Tests.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; + +namespace Volo.Abp.AspNetCore.Uow; + +public class AspNetCoreUnitOfWorkTransactionBehaviourProvider_Tests +{ + private static AspNetCoreUnitOfWorkTransactionBehaviourProvider CreateProvider(string method) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = method; + + return new AspNetCoreUnitOfWorkTransactionBehaviourProvider( + new HttpContextAccessor { HttpContext = httpContext }, + Microsoft.Extensions.Options.Options.Create(new AspNetCoreUnitOfWorkTransactionBehaviourProviderOptions())); + } + + [Theory] + [InlineData("GET", false)] + [InlineData("QUERY", false)] + [InlineData("query", false)] + [InlineData("HEAD", true)] + [InlineData("POST", true)] + [InlineData("PUT", true)] + [InlineData("DELETE", true)] + public void IsTransactional_Should_Treat_Get_And_Query_As_Non_Transactional(string method, bool expected) + { + CreateProvider(method).IsTransactional.ShouldBe(expected); + } +} diff --git a/framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/AuditingInterceptor_HttpMethod_Tests.cs b/framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/AuditingInterceptor_HttpMethod_Tests.cs new file mode 100644 index 0000000000..8216448333 --- /dev/null +++ b/framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/AuditingInterceptor_HttpMethod_Tests.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NSubstitute; +using Xunit; + +namespace Volo.Abp.Auditing; + +public class AuditingInterceptor_HttpMethod_Tests : AbpAuditingTestBase +{ + protected IAuditingStore AuditingStore; + + private string? _httpMethod; + + protected override void AfterAddApplication(IServiceCollection services) + { + AuditingStore = Substitute.For(); + services.Replace(ServiceDescriptor.Singleton(AuditingStore)); + + services.Configure(options => + { + options.IsEnabledForGetRequests = false; + options.Contributors.Add(new TestHttpMethodAuditContributor(() => _httpMethod)); + }); + } + + [Fact] + public async Task Should_Not_Write_AuditLog_For_Query_Http_Method_Without_Explicit_Scope() + { + _httpMethod = "QUERY"; + + var auditedObject = GetRequiredService(); + await auditedObject.DoItAsync(new Auditing_Tests.InputObject { Value1 = "x", Value2 = 1 }); + + await AuditingStore.DidNotReceive().SaveAsync(Arg.Any()); + } + + [Fact] + public async Task Should_Write_AuditLog_For_Post_Http_Method_Without_Explicit_Scope() + { + _httpMethod = "POST"; + + var auditedObject = GetRequiredService(); + await auditedObject.DoItAsync(new Auditing_Tests.InputObject { Value1 = "x", Value2 = 1 }); + + await AuditingStore.Received().SaveAsync(Arg.Any()); + } + + public class TestHttpMethodAuditContributor : AuditLogContributor + { + private readonly Func _httpMethodFactory; + + public TestHttpMethodAuditContributor(Func httpMethodFactory) + { + _httpMethodFactory = httpMethodFactory; + } + + public override void PreContribute(AuditLogContributionContext context) + { + context.AuditInfo.HttpMethod = _httpMethodFactory(); + } + } +} diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs index 21786155bf..1814e92be3 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs @@ -22,6 +22,8 @@ public interface IRegularTestController Task PostObjectWithQueryAsync(Car bodyValue); + Task QueryObjectWithBodyAsync(Car bodyValue); + Task GetObjectWithUrlAsync(Car bodyValue); Task GetObjectandIdAsync(int id, Car bodyValue); diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs index 46b355090b..7372488c67 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs @@ -71,6 +71,13 @@ public class RegularTestController : AbpController, IRegularTestController return Task.FromResult(bodyValue); } + [AcceptVerbs("QUERY")] + [Route("query-object-with-body")] + public Task QueryObjectWithBodyAsync([FromBody] Car bodyValue) + { + return Task.FromResult(bodyValue); + } + [HttpGet] [Route("post-object-with-url/bodyValue")] public Task GetObjectWithUrlAsync(Car bodyValue) diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_Tests.cs index 01ec97d734..2e1af7ada0 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_Tests.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_Tests.cs @@ -97,6 +97,14 @@ public class RegularTestControllerClientProxy_Tests : AbpHttpClientTestBase result.Model.ShouldBe("Ford"); } + [Fact] + public async Task QueryObjectWithBodyAsync() + { + var result = await _controller.QueryObjectWithBodyAsync(new Car { Year = 1976, Model = "Ford", FirstReleaseDate = new DateTime(1976, 02, 22, 15, 0, 6, 22) }); + result.Year.ShouldBe(1976); + result.Model.ShouldBe("Ford"); + } + [Fact] public async Task PostObjectWithQueryAsync_With_Different_Culture() { diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/HttpMethodHelper_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/HttpMethodHelper_Tests.cs new file mode 100644 index 0000000000..ab6376c727 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/HttpMethodHelper_Tests.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using Shouldly; +using Xunit; + +namespace Volo.Abp.Http; + +public class HttpMethodHelper_Tests +{ + private static readonly Dictionary> Predicates = new() + { + [HttpMethodHelper.Get] = HttpMethodHelper.IsGet, + [HttpMethodHelper.Post] = HttpMethodHelper.IsPost, + [HttpMethodHelper.Put] = HttpMethodHelper.IsPut, + [HttpMethodHelper.Delete] = HttpMethodHelper.IsDelete, + [HttpMethodHelper.Patch] = HttpMethodHelper.IsPatch, + [HttpMethodHelper.Head] = HttpMethodHelper.IsHead, + [HttpMethodHelper.Options] = HttpMethodHelper.IsOptions, + [HttpMethodHelper.Trace] = HttpMethodHelper.IsTrace, + [HttpMethodHelper.Query] = HttpMethodHelper.IsQuery + }; + + [Theory] + [InlineData(HttpMethodHelper.Get)] + [InlineData(HttpMethodHelper.Post)] + [InlineData(HttpMethodHelper.Put)] + [InlineData(HttpMethodHelper.Delete)] + [InlineData(HttpMethodHelper.Patch)] + [InlineData(HttpMethodHelper.Head)] + [InlineData(HttpMethodHelper.Options)] + [InlineData(HttpMethodHelper.Trace)] + [InlineData(HttpMethodHelper.Query)] + public void Is_Predicates_Should_Match_Only_Their_Own_Verb_Ignoring_Case(string verb) + { + foreach (var (name, predicate) in Predicates) + { + var shouldMatch = name == verb; + predicate(verb).ShouldBe(shouldMatch); + predicate(verb.ToLowerInvariant()).ShouldBe(shouldMatch); + } + } + + [Fact] + public void Is_Predicates_Should_Return_False_For_Null() + { + foreach (var predicate in Predicates.Values) + { + predicate(null).ShouldBeFalse(); + } + } + + [Theory] + [InlineData("QUERY", true)] + [InlineData("query", true)] + [InlineData("Query", true)] + [InlineData("GET", false)] + [InlineData("POST", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void IsQuery_Should_Match_Query_Method_Ignoring_Case(string? httpMethod, bool expected) + { + HttpMethodHelper.IsQuery(httpMethod).ShouldBe(expected); + } + + [Fact] + public void ConvertToHttpMethod_Should_Support_Query() + { + HttpMethodHelper.ConvertToHttpMethod("QUERY").Method.ShouldBe("QUERY"); + HttpMethodHelper.ConvertToHttpMethod("query").Method.ShouldBe("QUERY"); + } + + [Theory] + [InlineData("GET")] + [InlineData("POST")] + [InlineData("PUT")] + [InlineData("DELETE")] + [InlineData("PATCH")] + [InlineData("QUERY")] + public void ConvertToHttpMethod_Should_Not_Throw_For_Known_Methods(string httpMethod) + { + Should.NotThrow(() => HttpMethodHelper.ConvertToHttpMethod(httpMethod)); + } + + [Fact] + public void ConvertToHttpMethod_Should_Throw_For_Unknown_Method() + { + Should.Throw(() => HttpMethodHelper.ConvertToHttpMethod("UNKNOWN")); + } + + [Theory] + [InlineData("GetFooAsync", "GET")] + [InlineData("GetListAsync", "GET")] + [InlineData("CreateFooAsync", "POST")] + [InlineData("UpdateFooAsync", "PUT")] + [InlineData("DeleteFooAsync", "DELETE")] + [InlineData("PatchFooAsync", "PATCH")] + [InlineData("DoSomethingAsync", "POST")] + // QUERY is intentionally NOT a naming convention: an action must opt in explicitly + // with [AcceptVerbs("QUERY")]. A method named Query* still maps to the default verb. + [InlineData("QueryFooAsync", "POST")] + public void GetConventionalVerbForMethodName_Should_Not_Map_Query_By_Name(string methodName, string expectedVerb) + { + HttpMethodHelper.GetConventionalVerbForMethodName(methodName).ShouldBe(expectedVerb); + } +}