From c2cb3ab793312d8d5db24e47faa6987f0a6cab09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:23:39 +0000 Subject: [PATCH 1/7] Initial plan From e4eb38287dc624773aee9a2290a994f1fa0cc2a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:26:24 +0000 Subject: [PATCH 2/7] Fix OpenAPI parameter discovery for actions using FromServices Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com> --- .../MetadataApiDescriptionProvider.cs | 14 ++ .../MetadataApiDescriptionProviderTests.cs | 120 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs diff --git a/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs b/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs index 2b79de16d..275ae54f1 100644 --- a/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs +++ b/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs @@ -106,6 +106,9 @@ private void AddGetParameters(ApiDescription apiDescription, ControllerActionDes var actionParameters = apiDescription.ActionDescriptor.Parameters; foreach (var param in actionParameters) { + if (IsServiceParameter(param)) + continue; + var paramType = param.ParameterType; if (!IsSimpleType(paramType)) { @@ -139,6 +142,9 @@ private void AddPostParameters(ApiDescription apiDescription, ControllerActionDe var actionParameters = apiDescription.ActionDescriptor.Parameters; foreach (var param in actionParameters) { + if (IsServiceParameter(param)) + continue; + apiDescription.ParameterDescriptions.Add(new ApiParameterDescription { Name = param.Name, ModelMetadata = GetModel(param), @@ -162,6 +168,14 @@ private ModelMetadata GetModel(ParameterDescriptor param) return metadata; } + private static bool IsServiceParameter(ParameterDescriptor param) + { + if (param.BindingInfo?.BindingSource == BindingSource.Services) + return true; + + return param is ControllerParameterDescriptor controllerParameterDescriptor && + controllerParameterDescriptor.ParameterInfo.GetCustomAttribute() != null; + } private static bool IsSimpleType(Type type) { return type.IsPrimitive diff --git a/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs b/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs new file mode 100644 index 000000000..e5dcf6e28 --- /dev/null +++ b/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs @@ -0,0 +1,120 @@ +using Grand.Module.Api.ApiExplorer; +using Grand.SharedKernel.Attributes; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ActionConstraints; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Reflection; + +namespace Grand.Module.Api.Tests.ApiExplorer; + +[TestClass] +public class MetadataApiDescriptionProviderTests +{ + [TestMethod] + public void OnProvidersExecuting_PostAction_IgnoresFromServicesParameterAndKeepsBodyParameter() + { + var provider = CreateProvider(); + var methodInfo = typeof(TestApiController).GetMethod(nameof(TestApiController.PostAction))!; + + var action = CreateActionDescriptor(methodInfo, "POST", [ + CreateControllerParameter(methodInfo.GetParameters()[0], BindingSource.Services), + CreateControllerParameter(methodInfo.GetParameters()[1], BindingSource.Body) + ]); + + var context = new ApiDescriptionProviderContext([action]); + + provider.OnProvidersExecuting(context); + + Assert.AreEqual(1, context.Results.Count); + var apiDescription = context.Results[0]; + Assert.AreEqual(1, apiDescription.ParameterDescriptions.Count); + Assert.AreEqual("model", apiDescription.ParameterDescriptions[0].Name); + } + + [TestMethod] + public void OnProvidersExecuting_GetAction_IgnoresFromServicesParameterAndKeepsQueryParameter() + { + var provider = CreateProvider(); + var methodInfo = typeof(TestApiController).GetMethod(nameof(TestApiController.GetAction))!; + + var action = CreateActionDescriptor(methodInfo, "GET", [ + CreateControllerParameter(methodInfo.GetParameters()[0], BindingSource.Services), + CreateControllerParameter(methodInfo.GetParameters()[1], BindingSource.Query) + ]); + + var context = new ApiDescriptionProviderContext([action]); + + provider.OnProvidersExecuting(context); + + Assert.AreEqual(1, context.Results.Count); + var apiDescription = context.Results[0]; + Assert.AreEqual(1, apiDescription.ParameterDescriptions.Count); + Assert.AreEqual("searchTerm", apiDescription.ParameterDescriptions[0].Name); + } + + private static MetadataApiDescriptionProvider CreateProvider() + { + return new MetadataApiDescriptionProvider( + Options.Create(new MvcOptions()), + new EmptyModelMetadataProvider(), + new Mock().Object, + Options.Create(new RouteOptions())); + } + + private static ControllerActionDescriptor CreateActionDescriptor( + System.Reflection.MethodInfo methodInfo, + string httpMethod, + IList parameters) + { + return new ControllerActionDescriptor { + ControllerName = "TestApi", + ActionName = methodInfo.Name, + ControllerTypeInfo = typeof(TestApiController).GetTypeInfo(), + MethodInfo = methodInfo, + Parameters = parameters, + ActionConstraints = [new HttpMethodActionConstraint([httpMethod])] + }; + } + + private static ControllerParameterDescriptor CreateControllerParameter( + System.Reflection.ParameterInfo parameterInfo, + BindingSource bindingSource) + { + return new ControllerParameterDescriptor { + Name = parameterInfo.Name, + ParameterType = parameterInfo.ParameterType, + ParameterInfo = parameterInfo, + BindingInfo = new BindingInfo { BindingSource = bindingSource } + }; + } + + [ApiGroup("v2")] + private class TestApiController : ControllerBase + { + [HttpPost] + public IActionResult PostAction([FromServices] IServiceProvider serviceProvider, TestModel model) + { + return Ok(); + } + + [HttpGet] + public IActionResult GetAction([FromServices] IServiceProvider serviceProvider, string searchTerm) + { + return Ok(); + } + } + + private class TestModel + { + public string Name { get; set; } + } +} From 444cb9c026c75d5aa18eeb743be2a89c6b3ceac4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:27:10 +0000 Subject: [PATCH 3/7] Add tests for FromServices parameter filtering in API descriptions Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com> --- .../ApiExplorer/MetadataApiDescriptionProviderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs b/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs index e5dcf6e28..e5d356edc 100644 --- a/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs +++ b/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs @@ -115,6 +115,6 @@ public IActionResult GetAction([FromServices] IServiceProvider serviceProvider, private class TestModel { - public string Name { get; set; } + public string Name { get; set; } = string.Empty; } } From 6d1db8c61a5cc5ad72ad589ed97304a173f8f86e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:38:07 +0000 Subject: [PATCH 4/7] Fix POST parameter source inference for OpenAPI metadata Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com> --- .../MetadataApiDescriptionProvider.cs | 9 ++- .../MetadataApiDescriptionProviderTests.cs | 55 ++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs b/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs index 275ae54f1..2a1ea14ab 100644 --- a/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs +++ b/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs @@ -148,11 +148,18 @@ private void AddPostParameters(ApiDescription apiDescription, ControllerActionDe apiDescription.ParameterDescriptions.Add(new ApiParameterDescription { Name = param.Name, ModelMetadata = GetModel(param), - Source = BindingSource.Body, + Source = GetPostParameterBindingSource(param), Type = param.ParameterType }); } } + private static BindingSource GetPostParameterBindingSource(ParameterDescriptor param) + { + if (param.BindingInfo?.BindingSource != null) + return param.BindingInfo.BindingSource; + + return IsSimpleType(param.ParameterType) ? BindingSource.Query : BindingSource.Body; + } private ModelMetadata GetModel(ParameterDescriptor param) { ModelMetadata metadata; diff --git a/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs b/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs index e5d356edc..9239c3fb9 100644 --- a/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs +++ b/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs @@ -61,6 +61,53 @@ public void OnProvidersExecuting_GetAction_IgnoresFromServicesParameterAndKeepsQ Assert.AreEqual("searchTerm", apiDescription.ParameterDescriptions[0].Name); } + [TestMethod] + public void OnProvidersExecuting_PostAction_WithComplexAndSimpleParameter_UsesBodyAndQuerySources() + { + var provider = CreateProvider(); + var methodInfo = typeof(TestApiController).GetMethod(nameof(TestApiController.PostActionWithReturnUrl))!; + + var action = CreateActionDescriptor(methodInfo, "POST", [ + CreateControllerParameter(methodInfo.GetParameters()[0], null), + CreateControllerParameter(methodInfo.GetParameters()[1], null) + ]); + + var context = new ApiDescriptionProviderContext([action]); + + provider.OnProvidersExecuting(context); + + Assert.AreEqual(1, context.Results.Count); + var apiDescription = context.Results[0]; + Assert.AreEqual(2, apiDescription.ParameterDescriptions.Count); + + var modelParameter = apiDescription.ParameterDescriptions.Single(x => x.Name == "model"); + Assert.AreEqual(BindingSource.Body, modelParameter.Source); + + var returnUrlParameter = apiDescription.ParameterDescriptions.Single(x => x.Name == "returnUrl"); + Assert.AreEqual(BindingSource.Query, returnUrlParameter.Source); + } + + [TestMethod] + public void OnProvidersExecuting_PostAction_IgnoresFromServicesAttribute_WhenBindingSourceIsMissing() + { + var provider = CreateProvider(); + var methodInfo = typeof(TestApiController).GetMethod(nameof(TestApiController.PostAction))!; + + var action = CreateActionDescriptor(methodInfo, "POST", [ + CreateControllerParameter(methodInfo.GetParameters()[0], null), + CreateControllerParameter(methodInfo.GetParameters()[1], BindingSource.Body) + ]); + + var context = new ApiDescriptionProviderContext([action]); + + provider.OnProvidersExecuting(context); + + Assert.AreEqual(1, context.Results.Count); + var apiDescription = context.Results[0]; + Assert.AreEqual(1, apiDescription.ParameterDescriptions.Count); + Assert.AreEqual("model", apiDescription.ParameterDescriptions[0].Name); + } + private static MetadataApiDescriptionProvider CreateProvider() { return new MetadataApiDescriptionProvider( @@ -93,7 +140,7 @@ private static ControllerParameterDescriptor CreateControllerParameter( Name = parameterInfo.Name, ParameterType = parameterInfo.ParameterType, ParameterInfo = parameterInfo, - BindingInfo = new BindingInfo { BindingSource = bindingSource } + BindingInfo = bindingSource == null ? null : new BindingInfo { BindingSource = bindingSource } }; } @@ -111,6 +158,12 @@ public IActionResult GetAction([FromServices] IServiceProvider serviceProvider, { return Ok(); } + + [HttpPost] + public IActionResult PostActionWithReturnUrl(TestModel model, string returnUrl) + { + return Ok(); + } } private class TestModel From 398cf466e80250c1e34c086beb64ad44071fbf49 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:48:15 +0000 Subject: [PATCH 5/7] Harden OpenAPI POST binding source handling Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com> --- .../MetadataApiDescriptionProvider.cs | 13 +++-- .../MetadataApiDescriptionProviderTests.cs | 54 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs b/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs index 2a1ea14ab..9cbeed6bc 100644 --- a/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs +++ b/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs @@ -155,8 +155,14 @@ private void AddPostParameters(ApiDescription apiDescription, ControllerActionDe } private static BindingSource GetPostParameterBindingSource(ParameterDescriptor param) { - if (param.BindingInfo?.BindingSource != null) - return param.BindingInfo.BindingSource; + var bindingSource = param.BindingInfo?.BindingSource; + if (bindingSource == BindingSource.Body || + bindingSource == BindingSource.Query || + bindingSource == BindingSource.Path || + bindingSource == BindingSource.Header || + bindingSource == BindingSource.Form || + bindingSource == BindingSource.FormFile) + return bindingSource; return IsSimpleType(param.ParameterType) ? BindingSource.Query : BindingSource.Body; } @@ -177,7 +183,8 @@ private ModelMetadata GetModel(ParameterDescriptor param) } private static bool IsServiceParameter(ParameterDescriptor param) { - if (param.BindingInfo?.BindingSource == BindingSource.Services) + if (param.BindingInfo?.BindingSource == BindingSource.Services || + param.BindingInfo?.BindingSource == BindingSource.Special) return true; return param is ControllerParameterDescriptor controllerParameterDescriptor && diff --git a/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs b/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs index 9239c3fb9..ade19d190 100644 --- a/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs +++ b/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs @@ -13,6 +13,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using System.Reflection; +using System.Threading; namespace Grand.Module.Api.Tests.ApiExplorer; @@ -108,6 +109,53 @@ public void OnProvidersExecuting_PostAction_IgnoresFromServicesAttribute_WhenBin Assert.AreEqual("model", apiDescription.ParameterDescriptions[0].Name); } + [TestMethod] + public void OnProvidersExecuting_PostAction_WithModelBindingSource_UsesBodyAndQuerySources() + { + var provider = CreateProvider(); + var methodInfo = typeof(TestApiController).GetMethod(nameof(TestApiController.PostActionWithReturnUrl))!; + + var action = CreateActionDescriptor(methodInfo, "POST", [ + CreateControllerParameter(methodInfo.GetParameters()[0], BindingSource.ModelBinding), + CreateControllerParameter(methodInfo.GetParameters()[1], BindingSource.ModelBinding) + ]); + + var context = new ApiDescriptionProviderContext([action]); + + provider.OnProvidersExecuting(context); + + Assert.AreEqual(1, context.Results.Count); + var apiDescription = context.Results[0]; + Assert.AreEqual(2, apiDescription.ParameterDescriptions.Count); + + var modelParameter = apiDescription.ParameterDescriptions.Single(x => x.Name == "model"); + Assert.AreEqual(BindingSource.Body, modelParameter.Source); + + var returnUrlParameter = apiDescription.ParameterDescriptions.Single(x => x.Name == "returnUrl"); + Assert.AreEqual(BindingSource.Query, returnUrlParameter.Source); + } + + [TestMethod] + public void OnProvidersExecuting_PostAction_IgnoresSpecialBindingSourceParameter() + { + var provider = CreateProvider(); + var methodInfo = typeof(TestApiController).GetMethod(nameof(TestApiController.PostActionWithCancellation))!; + + var action = CreateActionDescriptor(methodInfo, "POST", [ + CreateControllerParameter(methodInfo.GetParameters()[0], BindingSource.Special), + CreateControllerParameter(methodInfo.GetParameters()[1], BindingSource.Body) + ]); + + var context = new ApiDescriptionProviderContext([action]); + + provider.OnProvidersExecuting(context); + + Assert.AreEqual(1, context.Results.Count); + var apiDescription = context.Results[0]; + Assert.AreEqual(1, apiDescription.ParameterDescriptions.Count); + Assert.AreEqual("model", apiDescription.ParameterDescriptions[0].Name); + } + private static MetadataApiDescriptionProvider CreateProvider() { return new MetadataApiDescriptionProvider( @@ -164,6 +212,12 @@ public IActionResult PostActionWithReturnUrl(TestModel model, string returnUrl) { return Ok(); } + + [HttpPost] + public IActionResult PostActionWithCancellation(CancellationToken cancellationToken, TestModel model) + { + return Ok(); + } } private class TestModel From 9e7840b4ea2f82610524f6625f6e77fc41aeb10b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:00:30 +0000 Subject: [PATCH 6/7] Harden OpenAPI parameter filtering for non-request bindings Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com> --- .../MetadataApiDescriptionProvider.cs | 18 +++++++++++++--- .../MetadataApiDescriptionProviderTests.cs | 21 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs b/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs index 9cbeed6bc..68a871c98 100644 --- a/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs +++ b/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Routing.Template; using Microsoft.Extensions.Options; using System.Reflection; +using System.Threading; namespace Grand.Module.Api.ApiExplorer; @@ -145,17 +146,25 @@ private void AddPostParameters(ApiDescription apiDescription, ControllerActionDe if (IsServiceParameter(param)) continue; + var parameterBindingSource = GetPostParameterBindingSource(param); + if (parameterBindingSource == null) + continue; + apiDescription.ParameterDescriptions.Add(new ApiParameterDescription { Name = param.Name, ModelMetadata = GetModel(param), - Source = GetPostParameterBindingSource(param), + Source = parameterBindingSource, Type = param.ParameterType }); } } - private static BindingSource GetPostParameterBindingSource(ParameterDescriptor param) + private BindingSource? GetPostParameterBindingSource(ParameterDescriptor param) { - var bindingSource = param.BindingInfo?.BindingSource; + var bindingSource = param.BindingInfo?.BindingSource ?? GetModel(param).BindingSource; + + if (bindingSource != null && !bindingSource.IsFromRequest) + return null; + if (bindingSource == BindingSource.Body || bindingSource == BindingSource.Query || bindingSource == BindingSource.Path || @@ -183,6 +192,9 @@ private ModelMetadata GetModel(ParameterDescriptor param) } private static bool IsServiceParameter(ParameterDescriptor param) { + if (param.ParameterType == typeof(CancellationToken)) + return true; + if (param.BindingInfo?.BindingSource == BindingSource.Services || param.BindingInfo?.BindingSource == BindingSource.Special) return true; diff --git a/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs b/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs index ade19d190..92bd7ac44 100644 --- a/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs +++ b/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs @@ -156,6 +156,27 @@ public void OnProvidersExecuting_PostAction_IgnoresSpecialBindingSourceParameter Assert.AreEqual("model", apiDescription.ParameterDescriptions[0].Name); } + [TestMethod] + public void OnProvidersExecuting_PostAction_IgnoresCancellationToken_WhenBindingSourceIsMissing() + { + var provider = CreateProvider(); + var methodInfo = typeof(TestApiController).GetMethod(nameof(TestApiController.PostActionWithCancellation))!; + + var action = CreateActionDescriptor(methodInfo, "POST", [ + CreateControllerParameter(methodInfo.GetParameters()[0], null), + CreateControllerParameter(methodInfo.GetParameters()[1], null) + ]); + + var context = new ApiDescriptionProviderContext([action]); + + provider.OnProvidersExecuting(context); + + Assert.AreEqual(1, context.Results.Count); + var apiDescription = context.Results[0]; + Assert.AreEqual(1, apiDescription.ParameterDescriptions.Count); + Assert.AreEqual("model", apiDescription.ParameterDescriptions[0].Name); + } + private static MetadataApiDescriptionProvider CreateProvider() { return new MetadataApiDescriptionProvider( From ffd411a0c0fc4618ffb8fc97d6d82e276604a23c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 5 Jul 2026 20:18:46 +0200 Subject: [PATCH 7/7] Fix NRE in OpenAPI document generation for form-bound parameters MetadataApiDescriptionProvider emitted ApiParameterDescription objects without a ParameterDescriptor, which OpenApiDocumentService.GetFormRequestBody dereferences when grouping form parameters, crashing /openapi/v2.json with a NullReferenceException. Populate ParameterDescriptor on every emitted description and add regression tests. Co-Authored-By: Claude Opus 4.8 --- .../MetadataApiDescriptionProvider.cs | 12 +++- .../MetadataApiDescriptionProviderTests.cs | 60 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs b/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs index 68a871c98..c17d37261 100644 --- a/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs +++ b/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs @@ -124,7 +124,11 @@ private void AddGetParameters(ApiDescription apiDescription, ControllerActionDes Name = property.Name, ModelMetadata = _modelMetadataProvider.GetMetadataForType(property.PropertyType), Source = BindingSource.Query, - Type = property.PropertyType + Type = property.PropertyType, + ParameterDescriptor = new ParameterDescriptor { + Name = property.Name, + ParameterType = property.PropertyType + } }); } } @@ -133,7 +137,8 @@ private void AddGetParameters(ApiDescription apiDescription, ControllerActionDes Name = param.Name, ModelMetadata = GetModel(param), Source = BindingSource.Query, - Type = param.ParameterType + Type = param.ParameterType, + ParameterDescriptor = param }); } } @@ -154,7 +159,8 @@ private void AddPostParameters(ApiDescription apiDescription, ControllerActionDe Name = param.Name, ModelMetadata = GetModel(param), Source = parameterBindingSource, - Type = param.ParameterType + Type = param.ParameterType, + ParameterDescriptor = param }); } } diff --git a/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs b/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs index 92bd7ac44..886fd4e5b 100644 --- a/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs +++ b/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs @@ -177,6 +177,54 @@ public void OnProvidersExecuting_PostAction_IgnoresCancellationToken_WhenBinding Assert.AreEqual("model", apiDescription.ParameterDescriptions[0].Name); } + [TestMethod] + public void OnProvidersExecuting_FormFileParameter_SetsParameterDescriptor() + { + var provider = CreateProvider(); + var methodInfo = typeof(TestApiController).GetMethod(nameof(TestApiController.PostActionWithFormFile))!; + + var action = CreateActionDescriptor(methodInfo, "POST", [ + CreateControllerParameter(methodInfo.GetParameters()[0], BindingSource.FormFile) + ]); + + var context = new ApiDescriptionProviderContext([action]); + + provider.OnProvidersExecuting(context); + + Assert.AreEqual(1, context.Results.Count); + var apiDescription = context.Results[0]; + Assert.AreEqual(1, apiDescription.ParameterDescriptions.Count); + var parameter = apiDescription.ParameterDescriptions[0]; + Assert.AreEqual(BindingSource.FormFile, parameter.Source); + Assert.IsNotNull(parameter.ParameterDescriptor); + Assert.AreEqual("file", parameter.ParameterDescriptor.Name); + } + + [TestMethod] + public void OnProvidersExecuting_AllParameterDescriptions_HaveParameterDescriptor() + { + var provider = CreateProvider(); + var postMethod = typeof(TestApiController).GetMethod(nameof(TestApiController.PostActionWithReturnUrl))!; + var getMethod = typeof(TestApiController).GetMethod(nameof(TestApiController.GetActionWithComplexModel))!; + + var context = new ApiDescriptionProviderContext([ + CreateActionDescriptor(postMethod, "POST", [ + CreateControllerParameter(postMethod.GetParameters()[0], null), + CreateControllerParameter(postMethod.GetParameters()[1], null) + ]), + CreateActionDescriptor(getMethod, "GET", [ + CreateControllerParameter(getMethod.GetParameters()[0], null) + ]) + ]); + + provider.OnProvidersExecuting(context); + + var parameters = context.Results.SelectMany(x => x.ParameterDescriptions).ToList(); + Assert.IsTrue(parameters.Count > 0); + foreach (var parameter in parameters) + Assert.IsNotNull(parameter.ParameterDescriptor, $"ParameterDescriptor missing for '{parameter.Name}'"); + } + private static MetadataApiDescriptionProvider CreateProvider() { return new MetadataApiDescriptionProvider( @@ -239,6 +287,18 @@ public IActionResult PostActionWithCancellation(CancellationToken cancellationTo { return Ok(); } + + [HttpPost] + public IActionResult PostActionWithFormFile(Microsoft.AspNetCore.Http.IFormFile file) + { + return Ok(); + } + + [HttpGet] + public IActionResult GetActionWithComplexModel(TestModel model) + { + return Ok(); + } } private class TestModel