diff --git a/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs b/src/Modules/Grand.Module.Api/ApiExplorer/MetadataApiDescriptionProvider.cs index 2b79de16d..c17d37261 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; @@ -106,6 +107,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)) { @@ -120,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 + } }); } } @@ -129,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 }); } } @@ -139,14 +148,39 @@ private void AddPostParameters(ApiDescription apiDescription, ControllerActionDe var actionParameters = apiDescription.ActionDescriptor.Parameters; foreach (var param in actionParameters) { + if (IsServiceParameter(param)) + continue; + + var parameterBindingSource = GetPostParameterBindingSource(param); + if (parameterBindingSource == null) + continue; + apiDescription.ParameterDescriptions.Add(new ApiParameterDescription { Name = param.Name, ModelMetadata = GetModel(param), - Source = BindingSource.Body, - Type = param.ParameterType + Source = parameterBindingSource, + Type = param.ParameterType, + ParameterDescriptor = param }); } } + private BindingSource? GetPostParameterBindingSource(ParameterDescriptor param) + { + 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 || + bindingSource == BindingSource.Header || + bindingSource == BindingSource.Form || + bindingSource == BindingSource.FormFile) + return bindingSource; + + return IsSimpleType(param.ParameterType) ? BindingSource.Query : BindingSource.Body; + } private ModelMetadata GetModel(ParameterDescriptor param) { ModelMetadata metadata; @@ -162,6 +196,18 @@ private ModelMetadata GetModel(ParameterDescriptor param) return metadata; } + 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; + + 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..886fd4e5b --- /dev/null +++ b/src/Tests/Grand.Module.Api.Tests/ApiExplorer/MetadataApiDescriptionProviderTests.cs @@ -0,0 +1,308 @@ +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; +using System.Threading; + +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); + } + + [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); + } + + [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); + } + + [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); + } + + [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( + 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 = bindingSource == null ? null : 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(); + } + + [HttpPost] + public IActionResult PostActionWithReturnUrl(TestModel model, string returnUrl) + { + return Ok(); + } + + [HttpPost] + public IActionResult PostActionWithCancellation(CancellationToken cancellationToken, TestModel model) + { + return Ok(); + } + + [HttpPost] + public IActionResult PostActionWithFormFile(Microsoft.AspNetCore.Http.IFormFile file) + { + return Ok(); + } + + [HttpGet] + public IActionResult GetActionWithComplexModel(TestModel model) + { + return Ok(); + } + } + + private class TestModel + { + public string Name { get; set; } = string.Empty; + } +}