diff --git a/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsAdminController.cs b/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsAdminController.cs index c39f132f10..8ca1263b54 100644 --- a/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsAdminController.cs +++ b/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsAdminController.cs @@ -12,13 +12,17 @@ namespace MvcSample.Web.ApiExplorerSamples public class ProductsAdminController : Controller { [HttpPut] - public void AddProduct([FromBody] Product product) + [Produces("application/json", Type = typeof(Product))] + public IActionResult AddProduct([FromBody] Product product) { + return null; } - [HttpPost("{id}")] - public void UpdateProduct([FromBody] Product product) + [HttpPost("{id?}")] + [Produces("application/json", Type = typeof(Product))] + public IActionResult UpdateProduct(UpdateProductDTO dto) { + return null; } [HttpPost("{id}/Stock")] diff --git a/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsController.cs b/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsController.cs index 4ca7567bd4..80fea76774 100644 --- a/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsController.cs +++ b/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsController.cs @@ -29,5 +29,12 @@ namespace MvcSample.Web.ApiExplorerSamples { return null; } + + [Produces("application/json", Type = typeof(ProductOrderConfirmation))] + [HttpPut("{order.acountId:int}/PlaceOrder")] + public IActionResult PlaceOrder(Order order) + { + return null; + } } } \ No newline at end of file diff --git a/samples/MvcSample.Web/Models/ApiExplorerSamples/Order.cs b/samples/MvcSample.Web/Models/ApiExplorerSamples/Order.cs new file mode 100644 index 0000000000..207742659a --- /dev/null +++ b/samples/MvcSample.Web/Models/ApiExplorerSamples/Order.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; +using System.Collections.Generic; + +namespace MvcSample.Web.ApiExplorerSamples +{ + public class Order + { + [FromRoute] + public int AccountId { get; set; } + + [FromBody] + public List Items { get; set; } + + [FromQuery] + public bool? IncludeWarranty { get; set; } + + public class OrderItem + { + public int ProductId { get; set; } + + public int Quantity { get; set; } + } + } +} \ No newline at end of file diff --git a/samples/MvcSample.Web/Models/ApiExplorerSamples/Product.cs b/samples/MvcSample.Web/Models/ApiExplorerSamples/Product.cs index 4b3c15ffeb..b3e659696d 100644 --- a/samples/MvcSample.Web/Models/ApiExplorerSamples/Product.cs +++ b/samples/MvcSample.Web/Models/ApiExplorerSamples/Product.cs @@ -5,6 +5,8 @@ namespace MvcSample.Web.ApiExplorerSamples { public class Product { + public int Id { get; set; } + public string Name { get; set; } public string Description { get; set; } diff --git a/samples/MvcSample.Web/Models/ApiExplorerSamples/ProductChangeDTO.cs b/samples/MvcSample.Web/Models/ApiExplorerSamples/ProductChangeDTO.cs new file mode 100644 index 0000000000..970d863b72 --- /dev/null +++ b/samples/MvcSample.Web/Models/ApiExplorerSamples/ProductChangeDTO.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace MvcSample.Web.ApiExplorerSamples +{ + public class ProductChangeDTO + { + public string Name { get; set; } + + public string Description { get; set; } + + public decimal Price { get; set; } + } +} \ No newline at end of file diff --git a/samples/MvcSample.Web/Models/ApiExplorerSamples/UpdateProductDTO.cs b/samples/MvcSample.Web/Models/ApiExplorerSamples/UpdateProductDTO.cs new file mode 100644 index 0000000000..99dde8198a --- /dev/null +++ b/samples/MvcSample.Web/Models/ApiExplorerSamples/UpdateProductDTO.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace MvcSample.Web.ApiExplorerSamples +{ + public class UpdateProductDTO + { + public int Id { get; set; } + + [FromBody] + public Product Product { get; set; } + + [FromHeader(Name = "Admin-User")] + public string AdminId { get; set; } + + public string Comments { get; set; } + } +} \ No newline at end of file diff --git a/samples/MvcSample.Web/Views/ApiExplorer/_ApiDescription.cshtml b/samples/MvcSample.Web/Views/ApiExplorer/_ApiDescription.cshtml index 708cc2da7b..f79624a5a8 100644 --- a/samples/MvcSample.Web/Views/ApiExplorer/_ApiDescription.cshtml +++ b/samples/MvcSample.Web/Views/ApiExplorer/_ApiDescription.cshtml @@ -2,38 +2,58 @@ @model ApiDescription
-

@(Model.HttpMethod ?? "*") - @(Model.RelativePath ?? "Unknown Url")

+

+ + @(Model.HttpMethod ?? "*") - @(Model.RelativePath ?? "Unknown Url") + +


-
For action: @Model.ActionDescriptor.DisplayName
-

Return Type: @(Model.ResponseType?.FullName ?? "Unknown Type")

+
Parameters:
@if (Model.ParameterDescriptions.Count > 0) { -

Parameters:

- + + + + + + + + + + @foreach (var parameter in Model.ParameterDescriptions) + { + + + + + + } + +
NameData TypeSource
@parameter.Name@(parameter.Type?.FullName ?? "Unknown Type")@parameter.Source.Id
} +
Response Formats
@if (Model.SupportedResponseFormats.Count > 0) { -

Response Formats:

- + + + + + + + + + + @foreach (var response in Model.SupportedResponseFormats) + { + + + + + + } + +
Data TypeMedia TypeFormatter
@Model.ResponseType.FullName@response.MediaType.ToString()@response.Formatter.GetType().Name
}
\ No newline at end of file diff --git a/samples/MvcSample.Web/wwwroot/Content/api-description.css b/samples/MvcSample.Web/wwwroot/Content/api-description.css index 8204e08646..9ffca2b105 100644 --- a/samples/MvcSample.Web/wwwroot/Content/api-description.css +++ b/samples/MvcSample.Web/wwwroot/Content/api-description.css @@ -3,8 +3,30 @@ padding: 5px; } +.api-description h5 { + margin-top: 20px; +} + .api-description hr { border: 0px; border-top: 1px solid #D44B4B; margin: 0px; + margin-bottom: 10px; +} + +.api-description table { + width: 100%; +} + +.api-description thead { + border-bottom: 1px solid black; +} + +.api-description td { + border-bottom: 1px solid #CCCCCC; + color: #666666; + padding: 5px; +} +.api-description tr:last-child td { + border-bottom: none; } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ControllerActionDescriptorBuilder.cs b/src/Microsoft.AspNet.Mvc.Core/ControllerActionDescriptorBuilder.cs index 730a2841f2..d45aeb2ad0 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ControllerActionDescriptorBuilder.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ControllerActionDescriptorBuilder.cs @@ -290,6 +290,13 @@ namespace Microsoft.AspNet.Mvc var apiExplorerIsVisible = action.ApiExplorer?.IsVisible ?? controller.ApiExplorer?.IsVisible ?? false; if (apiExplorerIsVisible) { + if (!IsAttributeRoutedAction(actionDescriptor)) + { + // ApiExplorer is only supported on attribute routed actions. + throw new InvalidOperationException(Resources.FormatApiExplorer_UnsupportedAction( + actionDescriptor.DisplayName)); + } + var apiExplorerActionData = new ApiDescriptionActionData() { GroupName = action.ApiExplorer?.GroupName ?? controller.ApiExplorer?.GroupName, diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterDescription.cs b/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterDescription.cs index 6a037efe0b..31ec6028f3 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterDescription.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterDescription.cs @@ -2,28 +2,38 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; using Microsoft.AspNet.Mvc.ModelBinding; -using Microsoft.AspNet.Routing; namespace Microsoft.AspNet.Mvc.Description { + /// + /// A metadata description of an input to an API. + /// public class ApiParameterDescription { - public bool IsOptional { get; set; } - + /// + /// Gets or sets the . + /// public ModelMetadata ModelMetadata { get; set; } + /// + /// Gets or sets the name. + /// public string Name { get; set; } - public ParameterDescriptor ParameterDescriptor { get; set; } + /// + /// Gets or sets the . + /// + public ApiParameterRouteInfo RouteInfo { get; set; } + /// + /// Gets or sets the . + /// public ApiParameterSource Source { get; set; } - public IEnumerable Constraints { get; set; } - - public object DefaultValue { get; set; } - + /// + /// Gets or sets the parameter type. + /// public Type Type { get; set; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterRouteInfo.cs b/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterRouteInfo.cs new file mode 100644 index 0000000000..7379f42048 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterRouteInfo.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNet.Routing; + +namespace Microsoft.AspNet.Mvc.Description +{ + /// + /// A metadata description of routing information for an . + /// + public class ApiParameterRouteInfo + { + /// + /// Gets or sets the set of objects for the parameter. + /// + /// + /// Route constraints are only applied when a value is bound from a URL's path. See + /// for the data source considered. + /// + public IEnumerable Constraints { get; set; } + + /// + /// Gets or sets the default value for the parameter. + /// + public object DefaultValue { get; set; } + + /// + /// Gets a value indicating whether not a parameter is considered optional by routing. + /// + /// + /// An optional parameter is considered optional by the routing system. This does not imply + /// that the parameter is considered optional by the action. + /// + /// If the parameter uses for the value of + /// then the value may also come from the + /// URL query string or form data. + /// + public bool IsOptional { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterSource.cs b/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterSource.cs index ce4121f295..8cd5f9886b 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterSource.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterSource.cs @@ -1,13 +1,130 @@ // Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using System.Diagnostics; +using Microsoft.AspNet.Mvc.Core; + namespace Microsoft.AspNet.Mvc.Description { - // This is a placeholder - see #886 - public enum ApiParameterSource + /// + /// A metadata description of the source of an for an HTTP request. + /// + [DebuggerDisplay("Source: {DisplayName}")] + public class ApiParameterSource : IEquatable { - Body, - Query, - Path + /// + /// An for the request body. + /// + public static readonly ApiParameterSource Body = new ApiParameterSource( + "Body", + Resources.ApiParameterSource_Body); + + /// + /// An for a custom model binder (unknown data source). + /// + public static readonly ApiParameterSource Custom = new ApiParameterSource( + "Custom", + Resources.ApiParameterSource_Custom); + + /// + /// An for the request form-data. + /// + public static readonly ApiParameterSource Form = new ApiParameterSource( + "Form", + Resources.ApiParameterSource_Form); + + /// + /// An for the request headers. + /// + public static readonly ApiParameterSource Header = new ApiParameterSource( + "Header", + Resources.ApiParameterSource_Header); + + /// + /// An for a parameter that should be hidden. Used when + /// a parameter cannot be set with user input. + /// + public static readonly ApiParameterSource Hidden = new ApiParameterSource( + "Hidden", + Resources.ApiParameterSource_Hidden); + + /// + /// An for model binding. Includes form-data, query-string + /// and headers from the request. + /// + public static readonly ApiParameterSource ModelBinding = new ApiParameterSource( + "ModelBinding", + Resources.ApiParameterSource_ModelBinding); + + /// + /// An for the request url path. + /// + public static readonly ApiParameterSource Path = new ApiParameterSource( + "Path", + Resources.ApiParameterSource_Path); + + /// + /// An for the request query-string. + /// + public static readonly ApiParameterSource Query = new ApiParameterSource( + "Query", + Resources.ApiParameterSource_Query); + + /// + /// Creates a new . + /// + /// The id. Used for comparison. + /// The display name. + public ApiParameterSource([NotNull] string id, string displayName) + { + Id = id; + DisplayName = displayName; + } + + /// + /// Gets the display name. + /// + public string DisplayName { get; } + + /// + /// Gets the id. + /// + public string Id { get; } + + /// + public bool Equals(ApiParameterSource other) + { + return other == null ? false : string.Equals(other.Id, Id, StringComparison.Ordinal); + } + + /// + public override bool Equals(object obj) + { + return Equals(obj as ApiParameterSource); + } + + /// + public override int GetHashCode() + { + return Id.GetHashCode(); + } + + /// + public static bool operator ==(ApiParameterSource s1, ApiParameterSource s2) + { + if (object.ReferenceEquals(s1, null)) + { + return object.ReferenceEquals(s2, null); ; + } + + return s1.Equals(s2); + } + + /// + public static bool operator !=(ApiParameterSource s1, ApiParameterSource s2) + { + return !(s1 == s2); + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/DefaultApiDescriptionProvider.cs b/src/Microsoft.AspNet.Mvc.Core/Description/DefaultApiDescriptionProvider.cs index 221b31758d..e0c2e9f4d0 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Description/DefaultApiDescriptionProvider.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Description/DefaultApiDescriptionProvider.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNet.Mvc.ModelBinding; @@ -35,8 +34,8 @@ namespace Microsoft.AspNet.Mvc.Description IModelMetadataProvider modelMetadataProvider) { _formattersProvider = formattersProvider; - _modelMetadataProvider = modelMetadataProvider; _constraintResolver = constraintResolver; + _modelMetadataProvider = modelMetadataProvider; } /// @@ -81,7 +80,8 @@ namespace Microsoft.AspNet.Mvc.Description var templateParameters = parsedTemplate?.Parameters?.ToList() ?? new List(); - GetParameters(apiDescription, action.Parameters, templateParameters); + var parameterContext = new ApiParameterContext(_modelMetadataProvider, action, templateParameters); + apiDescription.ParameterDescriptions.AddRange(GetParameters(parameterContext)); var responseMetadataAttributes = GetResponseMetadataAttributes(action); @@ -124,43 +124,90 @@ namespace Microsoft.AspNet.Mvc.Description return apiDescription; } - private void GetParameters( - ApiDescription apiDescription, - IList parameterDescriptors, - IList templateParameters) + private IList GetParameters(ApiParameterContext context) { - if (parameterDescriptors != null) + // First, get parameters from the model-binding/parameter-binding side of the world. + if (context.ActionDescriptor.Parameters != null) { - foreach (var parameter in parameterDescriptors) + foreach (var actionParameter in context.ActionDescriptor.Parameters) { - // Process together parameters that appear on the path template and on the - // action descriptor and do not come from the body. - TemplatePart templateParameter = null; - if (parameter.BinderMetadata as IFormatterBinderMetadata == null) - { - templateParameter = templateParameters - .FirstOrDefault(p => p.Name.Equals(parameter.Name, StringComparison.OrdinalIgnoreCase)); + var visitor = new PseudoModelBindingVisitor(context, actionParameter); + visitor.WalkParameter(); + } + } - if (templateParameter != null) + for (var i = context.Results.Count - 1; i >= 0; i--) + { + // Remove any 'hidden' parameters. These are things that can't come from user input, + // so they aren't worth showing. + if (context.Results[i].Source == ApiParameterSource.Hidden) + { + context.Results.RemoveAt(i); + } + } + + // Next, we want to join up any route parameters with those discovered from the action's parameters. + var routeParameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var routeParameter in context.RouteParameters) + { + routeParameters.Add(routeParameter.Name, CreateRouteInfo(routeParameter)); + } + + foreach (var parameter in context.Results) + { + if (parameter.Source == ApiParameterSource.Path || + parameter.Source == ApiParameterSource.ModelBinding || + parameter.Source == ApiParameterSource.Custom) + { + ApiParameterRouteInfo routeInfo; + if (routeParameters.TryGetValue(parameter.Name, out routeInfo)) + { + parameter.RouteInfo = routeInfo; + routeParameters.Remove(parameter.Name); + + if (parameter.Source == ApiParameterSource.ModelBinding && + !parameter.RouteInfo.IsOptional) { - templateParameters.Remove(templateParameter); + // If we didn't see any information about the parameter, but we have + // a route parameter that matches, let's switch it to path. + parameter.Source = ApiParameterSource.Path; } } - - apiDescription.ParameterDescriptions.Add(GetParameter(parameter, templateParameter)); } } - if (templateParameters.Count > 0) + // Lastly, create a parameter representation for each route parameter that did not find + // a partner. + foreach (var routeParameter in routeParameters) { - // Process parameters that only appear on the path template if any. - foreach (var templateParameter in templateParameters) + context.Results.Add(new ApiParameterDescription() { - var parameterDescription = - GetParameter(parameterDescriptor: null, templateParameter: templateParameter); - apiDescription.ParameterDescriptions.Add(parameterDescription); + Name = routeParameter.Key, + RouteInfo = routeParameter.Value, + Source = ApiParameterSource.Path, + }); + } + + return context.Results; + } + + private ApiParameterRouteInfo CreateRouteInfo(TemplatePart routeParameter) + { + var constraints = new List(); + if (routeParameter.InlineConstraints != null) + { + foreach (var constraint in routeParameter.InlineConstraints) + { + constraints.Add(_constraintResolver.ResolveConstraint(constraint.Constraint)); } } + + return new ApiParameterRouteInfo() + { + Constraints = constraints, + DefaultValue = routeParameter.DefaultValue, + IsOptional = routeParameter.IsOptional || routeParameter.DefaultValue != null, + }; } private IEnumerable GetHttpMethods(ControllerActionDescriptor action) @@ -216,117 +263,6 @@ namespace Microsoft.AspNet.Mvc.Description return string.Join("/", segments); } - private ApiParameterDescription GetParameter( - ParameterDescriptor parameterDescriptor, - TemplatePart templateParameter) - { - // This is a placeholder based on currently available functionality for parameters. See #886. - ApiParameterDescription parameterDescription = null; - - if (templateParameter != null && parameterDescriptor == null) - { - // The parameter is part of the route template but not part of the ActionDescriptor. - - // For now if a parameter is part of the template we will asume its value comes from the path. - // We will be more accurate when we implement #886. - parameterDescription = CreateParameterFromTemplate(templateParameter); - } - else if (templateParameter != null && parameterDescriptor != null) - { - // The parameter is part of the route template and part of the ActionDescriptor. - parameterDescription = CreateParameterFromTemplateAndParameterDescriptor( - templateParameter, - parameterDescriptor); - } - else if (templateParameter == null && parameterDescriptor != null) - { - // The parameter is part of the ActionDescriptor but is not part of the route template. - parameterDescription = CreateParameterFromParameterDescriptor(parameterDescriptor); - } - else - { - // We will never call this method with templateParameter == null && parameterDescriptor == null - Debug.Assert(parameterDescriptor != null); - } - - if (parameterDescription.Type != null) - { - parameterDescription.ModelMetadata = _modelMetadataProvider.GetMetadataForType( - modelAccessor: null, - modelType: parameterDescription.Type); - } - - return parameterDescription; - } - - private static ApiParameterDescription CreateParameterFromParameterDescriptor(ParameterDescriptor parameter) - { - var resourceParameter = new ApiParameterDescription - { - Name = parameter.Name, - ParameterDescriptor = parameter, - Type = parameter.ParameterType, - }; - - if (parameter.BinderMetadata as IFormatterBinderMetadata != null) - { - resourceParameter.Source = ApiParameterSource.Body; - } - else - { - resourceParameter.Source = ApiParameterSource.Query; - } - - return resourceParameter; - } - - private ApiParameterDescription CreateParameterFromTemplateAndParameterDescriptor( - TemplatePart templateParameter, - ParameterDescriptor parameter) - { - var resourceParameter = new ApiParameterDescription - { - Source = ApiParameterSource.Path, - IsOptional = IsOptionalParameter(templateParameter), - Name = parameter.Name, - ParameterDescriptor = parameter, - Constraints = GetConstraints(_constraintResolver, templateParameter.InlineConstraints), - DefaultValue = templateParameter.DefaultValue, - Type = parameter.ParameterType, - }; - - return resourceParameter; - } - - private static IEnumerable GetConstraints( - IInlineConstraintResolver constraintResolver, - IEnumerable constraints) - { - return - constraints - .Select(c => constraintResolver.ResolveConstraint(c.Constraint)) - .Where(c => c != null) - .ToArray(); - } - - private static bool IsOptionalParameter(TemplatePart templateParameter) - { - return templateParameter.IsOptional || templateParameter.DefaultValue != null; - } - - private ApiParameterDescription CreateParameterFromTemplate(TemplatePart templateParameter) - { - return new ApiParameterDescription - { - Source = ApiParameterSource.Path, - IsOptional = IsOptionalParameter(templateParameter), - Name = templateParameter.Name, - ParameterDescriptor = null, - Constraints = GetConstraints(_constraintResolver, templateParameter.InlineConstraints), - DefaultValue = templateParameter.DefaultValue, - }; - } - private IReadOnlyList GetResponseFormats( ControllerActionDescriptor action, IApiResponseMetadataProvider[] responseMetadataAttributes, @@ -442,7 +378,7 @@ namespace Microsoft.AspNet.Mvc.Description } // This technique for enumerating filters will intentionally ignore any filter that is an IFilterFactory - // for a filter that implements IApiResponseMetadataProvider. + // while searching for a filter that implements IApiResponseMetadataProvider. // // The workaround for that is to implement the metadata interface on the IFilterFactory. return action.FilterDescriptors @@ -450,5 +386,333 @@ namespace Microsoft.AspNet.Mvc.Description .OfType() .ToArray(); } + + private class ApiParameterContext + { + public ApiParameterContext( + IModelMetadataProvider metadataProvider, + ControllerActionDescriptor actionDescriptor, + IReadOnlyList routeParameters) + { + MetadataProvider = metadataProvider; + ActionDescriptor = actionDescriptor; + RouteParameters = routeParameters; + + Results = new List(); + } + + public ControllerActionDescriptor ActionDescriptor { get; } + + public IModelMetadataProvider MetadataProvider { get; } + + public IList Results { get; } + + public IReadOnlyList RouteParameters { get; } + } + + private class PseudoModelBindingVisitor + { + public PseudoModelBindingVisitor(ApiParameterContext context, ParameterDescriptor parameter) + { + Context = context; + Parameter = parameter; + + Visited = new HashSet(); + } + + public ApiParameterContext Context { get; } + + public ParameterDescriptor Parameter { get; } + + // Avoid infinite recursion by tracking properties. + private HashSet Visited { get; } + + public void WalkParameter() + { + var modelMetadata = Context.MetadataProvider.GetMetadataForParameter( + modelAccessor: null, + methodInfo: Context.ActionDescriptor.MethodInfo, + parameterName: Parameter.Name); + + var binderMetadata = Parameter.BinderMetadata; + if (binderMetadata != null) + { + modelMetadata.BinderMetadata = binderMetadata; + } + + var nameProvider = binderMetadata as IModelNameProvider; + if (nameProvider != null && nameProvider.Name != null) + { + modelMetadata.BinderModelName = nameProvider.Name; + } + + // Attempt to find a binding source for the parameter + // + // The default is ModelBinding (aka all default value providers) + var source = ApiParameterSource.ModelBinding; + if (!Visit(modelMetadata, source, containerName: string.Empty)) + { + // If we get here, then it means we didn't find a match for any of the model. This means that it's + // likely 'model-bound' in the traditional MVC sense (formdata + query string + route data) and + // doesn't use any IBinderMetadata. + // + // Add a single 'default' parameter description for the model. + Context.Results.Add(CreateResult(modelMetadata, source, containerName: string.Empty)); + } + } + + /// + /// Visits a node in a model, and attempts to create for any + /// model properties where we can definitely compute an answer. + /// + /// The metadata for the model. + /// The from the ambient context. + /// The current name prefix (to prepend to property names). + /// + /// true if the set of objects were created for the model. + /// false if no objects were created for the model. + /// + /// + /// Its the reponsibility of this method to create a parameter description for ALL of the current model + /// or NONE of it. If a parameter description is created for ANY sub-properties of the model, then a parameter + /// description will be created for ALL of them. + /// + private bool Visit(ModelMetadata modelMetadata, ApiParameterSource ambientSource, string containerName) + { + ApiParameterSource source; + if (GetSource(modelMetadata, out source)) + { + // We have a definite answer for this model. This is a greedy source like + // [FromBody] so there's no need to consider properties. + Context.Results.Add(CreateResult(modelMetadata, source, containerName)); + + return true; + } + + // For any property which is a leaf node, we don't want to keep traversing: + // + // 1) Collections - while it's possible to have binder attributes on the inside of a collection, + // it hardly seems useful, and would result in some very wierd binding. + // + // 2) Simple Types - These are generally part of the .net framework - primitives, or types which have a + // type converter from string. + // + // 3) Types with no properties. Obviously nothing to explore there. + // + if (modelMetadata.IsCollectionType || + !modelMetadata.IsComplexType || + !modelMetadata.Properties.Any()) + { + if (source == null || source == ambientSource) + { + // If it's a leaf node, and we have no new source then we don't know how to bind this. + // Return without creating any parameters, so that this can be included in the parent model. + return false; + } + else + { + // We found a new source, and this model has no properties. This is probabaly + // a simple type with an attribute like [FromQuery]. + Context.Results.Add(CreateResult(modelMetadata, source, containerName)); + return true; + } + } + + // This will come from composite model binding - so investigate what's going on with each property. + // + // Basically once we find something that we know how to bind, we want to treat all properties at that + // level (and higher levels) as separate parameters. + // + // Ex: + // + // public IActionResult PlaceOrder(OrderDTO order) {...} + // + // public class OrderDTO + // { + // public int AccountId { get; set; } + // + // [FromBody] + // public Order { get; set; } + // } + // + // This should result in two parameters: + // + // AccountId - source: Any + // Order - source: Body + // + + var propertyCount = 0; + var unboundProperties = new HashSet(); + + // We don't want to append the **parameter** name when building a model name. + var newContainerName = containerName; + if (modelMetadata.ContainerType != null) + { + newContainerName = GetName(containerName, modelMetadata); + } + + foreach (var propertyMetadata in modelMetadata.Properties) + { + propertyCount++; + var key = new PropertyKey(propertyMetadata, source); + + if (Visited.Add(key)) + { + if (!Visit(propertyMetadata, source ?? ambientSource, newContainerName)) + { + unboundProperties.Add(propertyMetadata); + } + } + else + { + unboundProperties.Add(propertyMetadata); + } + } + + if (unboundProperties.Count == propertyCount) + { + if (source == null || source == ambientSource) + { + // No properties were bound and we didn't find a new source, let the caller handle it. + return false; + } + else + { + // We found a new source, and didn't create a result for any of the properties yet, + // so create a result for the current object. + Context.Results.Add(CreateResult(modelMetadata, source, containerName)); + return true; + } + } + else + { + // This model was only partially bound, so create a result for all the other properties + foreach (var property in unboundProperties) + { + // Create a 'default' description for each property + Context.Results.Add(CreateResult(property, source ?? ambientSource, newContainerName)); + } + + return true; + } + } + + private ApiParameterDescription CreateResult( + ModelMetadata metadata, + ApiParameterSource source, + string containerName) + { + return new ApiParameterDescription() + { + ModelMetadata = metadata, + Name = GetName(containerName, metadata), + Source = source, + Type = metadata.ModelType, + }; + } + + private static string GetName(string containerName, ModelMetadata metadata) + { + if (!string.IsNullOrEmpty(metadata.BinderModelName)) + { + // Name was explicitly provided + return metadata.BinderModelName; + } + else + { + return ModelBindingHelper.CreatePropertyModelName(containerName, metadata.PropertyName); + } + } + + // This isn't extensible right now. + // + // Returns true if the source is greedy (means to stop exploring the model) + // Returns false if the source in unknown or known but not greedy (like [FromQuery]) + private static bool GetSource(ModelMetadata metadata, out ApiParameterSource source) + { + if (metadata.BinderMetadata == null) + { + // There's nothing we can figure out. + source = null; + return false; + } + + if (metadata.BinderMetadata is IFormatterBinderMetadata) + { + source = ApiParameterSource.Body; + return true; + } + else if (metadata.BinderMetadata is IHeaderBinderMetadata) + { + source = ApiParameterSource.Header; + return true; + } + else if (metadata.BinderMetadata is IServiceActivatorBinderMetadata) + { + source = ApiParameterSource.Hidden; + return true; + } + else if (metadata.BinderMetadata is IRouteDataValueProviderMetadata) + { + source = ApiParameterSource.Path; + return false; + } + else if (metadata.BinderMetadata is IQueryValueProviderMetadata) + { + source = ApiParameterSource.Query; + return false; + } + else if (metadata.BinderMetadata is IFormDataValueProviderMetadata) + { + source = ApiParameterSource.Form; + return false; + } + + var binderTypeMetadata = metadata.BinderMetadata as IBinderTypeProviderMetadata; + if (binderTypeMetadata != null && binderTypeMetadata.BinderType != null) + { + // This provides it's own model binder, so we can't really make a good + // estimate of where it comes from. + source = ApiParameterSource.Custom; + return true; + } + + // We're out of cases we know how to handle. + source = null; + return false; + } + + private struct PropertyKey + { + public readonly Type ContainerType; + + public readonly string PropertyName; + + public readonly ApiParameterSource Source; + + public PropertyKey(ModelMetadata metadata, ApiParameterSource source) + { + ContainerType = metadata.ContainerType; + PropertyName = metadata.PropertyName; + Source = source; + } + } + + private class PropertyKeyEqualityComparer : IEqualityComparer + { + public bool Equals(PropertyKey x, PropertyKey y) + { + return + x.ContainerType == y.ContainerType && + x.PropertyName == y.PropertyName && + x.Source == y.Source; + } + + public int GetHashCode(PropertyKey obj) + { + return obj.ContainerType.GetHashCode() ^ obj.PropertyName.GetHashCode() ^ obj.Source.GetHashCode(); + } + } + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ParameterBinding/ModelBindingHelper.cs b/src/Microsoft.AspNet.Mvc.Core/ParameterBinding/ModelBindingHelper.cs index 19080e1c64..a09b3f8a36 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ParameterBinding/ModelBindingHelper.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ParameterBinding/ModelBindingHelper.cs @@ -246,7 +246,7 @@ namespace Microsoft.AspNet.Mvc StringComparison.OrdinalIgnoreCase); } - private static string CreatePropertyModelName(string prefix, string propertyName) + public static string CreatePropertyModelName(string prefix, string propertyName) { if (string.IsNullOrEmpty(prefix)) { diff --git a/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs index e102501345..08263a8077 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs @@ -1562,6 +1562,150 @@ namespace Microsoft.AspNet.Mvc.Core return string.Format(CultureInfo.CurrentCulture, GetString("ResponseCache_SpecifyDuration"), p0, p1); } + /// + /// Body + /// + internal static string ApiParameterSource_Body + { + get { return GetString("ApiParameterSource_Body"); } + } + + /// + /// Body + /// + internal static string FormatApiParameterSource_Body() + { + return GetString("ApiParameterSource_Body"); + } + + /// + /// Custom + /// + internal static string ApiParameterSource_Custom + { + get { return GetString("ApiParameterSource_Custom"); } + } + + /// + /// Custom + /// + internal static string FormatApiParameterSource_Custom() + { + return GetString("ApiParameterSource_Custom"); + } + + /// + /// Header + /// + internal static string ApiParameterSource_Header + { + get { return GetString("ApiParameterSource_Header"); } + } + + /// + /// Header + /// + internal static string FormatApiParameterSource_Header() + { + return GetString("ApiParameterSource_Header"); + } + + /// + /// Hidden + /// + internal static string ApiParameterSource_Hidden + { + get { return GetString("ApiParameterSource_Hidden"); } + } + + /// + /// Hidden + /// + internal static string FormatApiParameterSource_Hidden() + { + return GetString("ApiParameterSource_Hidden"); + } + + /// + /// ModelBinding + /// + internal static string ApiParameterSource_ModelBinding + { + get { return GetString("ApiParameterSource_ModelBinding"); } + } + + /// + /// ModelBinding + /// + internal static string FormatApiParameterSource_ModelBinding() + { + return GetString("ApiParameterSource_ModelBinding"); + } + + /// + /// Path + /// + internal static string ApiParameterSource_Path + { + get { return GetString("ApiParameterSource_Path"); } + } + + /// + /// Path + /// + internal static string FormatApiParameterSource_Path() + { + return GetString("ApiParameterSource_Path"); + } + + /// + /// Query + /// + internal static string ApiParameterSource_Query + { + get { return GetString("ApiParameterSource_Query"); } + } + + /// + /// Query + /// + internal static string FormatApiParameterSource_Query() + { + return GetString("ApiParameterSource_Query"); + } + + /// + /// Form + /// + internal static string ApiParameterSource_Form + { + get { return GetString("ApiParameterSource_Form"); } + } + + /// + /// Form + /// + internal static string FormatApiParameterSource_Form() + { + return GetString("ApiParameterSource_Form"); + } + + /// + /// The action '{0}' has ApiExplorer enabled, but is using conventional routing. Only actions which use attribute routing support ApiExplorer. + /// + internal static string ApiExplorer_UnsupportedAction + { + get { return GetString("ApiExplorer_UnsupportedAction"); } + } + + /// + /// The action '{0}' has ApiExplorer enabled, but is using conventional routing. Only actions which use attribute routing support ApiExplorer. + /// + internal static string FormatApiExplorer_UnsupportedAction(object p0) + { + return string.Format(CultureInfo.CurrentCulture, GetString("ApiExplorer_UnsupportedAction"), p0); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNet.Mvc.Core/Resources.resx b/src/Microsoft.AspNet.Mvc.Core/Resources.resx index ccc5ba71be..51c569c1c4 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.Core/Resources.resx @@ -418,4 +418,31 @@ If the '{0}' property is not set to true, '{1}' property must be specified. + + Body + + + Custom + + + Header + + + Hidden + + + ModelBinding + + + Path + + + Query + + + Form + + + The action '{0}' has ApiExplorer enabled, but is using conventional routing. Only actions which use attribute routing support ApiExplorer. + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeHelper.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeHelper.cs index 760382cb51..20abd96240 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeHelper.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/TypeHelper.cs @@ -2,6 +2,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections; +using System.Collections.Generic; using System.ComponentModel; using System.Reflection; @@ -24,5 +26,18 @@ namespace Microsoft.AspNet.Mvc.ModelBinding { return TypeDescriptor.GetConverter(type).CanConvertFrom(typeof(string)); } + + internal static bool IsCollectionType(Type type) + { + if (type == typeof(string)) + { + // Even though string implements IEnumerable, we don't really think of it + // as a collection for the purposes of model binding. + return false; + } + + // We only need to look for IEnumerable, because IEnumerable extends it. + return typeof(IEnumerable).IsAssignableFrom(type); + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs index 53d129b3f2..23a558f5d3 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs @@ -137,6 +137,11 @@ namespace Microsoft.AspNet.Mvc.ModelBinding /// public virtual bool HideSurroundingHtml { get; set; } + public virtual bool IsCollectionType + { + get { return TypeHelper.IsCollectionType(ModelType); } + } + public virtual bool IsComplexType { get { return !TypeHelper.HasStringConverter(ModelType); } diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ControllerActionDescriptorProviderTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ControllerActionDescriptorProviderTests.cs index 168162a253..ecf66d8348 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ControllerActionDescriptorProviderTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ControllerActionDescriptorProviderTests.cs @@ -1092,6 +1092,24 @@ namespace Microsoft.AspNet.Mvc.Test Assert.Equal("Store", action.GetProperty().GroupName); } + [Theory] + [InlineData("A", typeof(ApiExplorerEnabledConventionalRoutedController))] + [InlineData("A", typeof(ApiExplorerEnabledActionConventionalRoutedController))] + public void ApiExplorer_ThrowsForContentionalRouting(string actionName, Type type) + { + var expected = string.Format( + "The action '{0}.{1}' has ApiExplorer enabled, but is using conventional routing. " + + "Only actions which use attribute routing support ApiExplorer.", + type.FullName, actionName); + + // Arrange + var provider = GetProvider(type.GetTypeInfo()); + + // Act & Assert + var ex = Assert.Throws(() => provider.GetDescriptors()); + Assert.Equal(expected, ex.Message); + } + // Verifies the sequence of conventions running [Fact] public void ApplyConventions_RunsInOrderOfDecreasingScope() @@ -1743,29 +1761,34 @@ namespace Microsoft.AspNet.Mvc.Test public int Name { get; set; } } + [Route("AttributeRouting/IsRequired/ForApiExplorer")] private class ApiExplorerNotVisibleController { public void Edit() { } } + [Route("AttributeRouting/IsRequired/ForApiExplorer")] [ApiExplorerSettings()] private class ApiExplorerVisibleController { public void Edit() { } } + [Route("AttributeRouting/IsRequired/ForApiExplorer")] [ApiExplorerSettings(IgnoreApi = true)] private class ApiExplorerExplicitlyNotVisibleController { public void Edit() { } } + [Route("AttributeRouting/IsRequired/ForApiExplorer")] private class ApiExplorerExplicitlyNotVisibleOnActionController { [ApiExplorerSettings(IgnoreApi = true)] public void Edit() { } } + [Route("AttributeRouting/IsRequired/ForApiExplorer")] [ApiExplorerSettings(IgnoreApi = true)] private class ApiExplorerVisibilityOverrideController { @@ -1775,25 +1798,28 @@ namespace Microsoft.AspNet.Mvc.Test public void Create() { } } + [Route("AttributeRouting/IsRequired/ForApiExplorer")] [ApiExplorerSettings(GroupName = "Store")] private class ApiExplorerNameOnControllerController { public void Edit() { } } - + [Route("AttributeRouting/IsRequired/ForApiExplorer")] private class ApiExplorerNameOnActionController { [ApiExplorerSettings(GroupName = "Blog")] public void Edit() { } } + [Route("AttributeRouting/IsRequired/ForApiExplorer")] [ApiExplorerSettings()] private class ApiExplorerNoNameController { public void Edit() { } } + [Route("AttributeRouting/IsRequired/ForApiExplorer")] [ApiExplorerSettings(GroupName = "Store")] private class ApiExplorerNameOverrideController { @@ -1851,5 +1877,22 @@ namespace Microsoft.AspNet.Mvc.Test private class ConstraintAttribute : Attribute, IActionConstraintMetadata { } + + [ApiExplorerSettings(GroupName = "Default")] + private class ApiExplorerEnabledConventionalRoutedController : Controller + { + public void A() + { + } + } + + [ApiExplorerSettings(IgnoreApi = true)] + private class ApiExplorerEnabledActionConventionalRoutedController : Controller + { + [ApiExplorerSettings(GroupName = "Default")] + public void A() + { + } + } } } diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Description/DefaultApiDescriptionProviderTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/Description/DefaultApiDescriptionProviderTest.cs index 1e10dbdb84..5117d68c81 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/Description/DefaultApiDescriptionProviderTest.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Description/DefaultApiDescriptionProviderTest.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNet.Mvc.ModelBinding; @@ -110,49 +111,6 @@ namespace Microsoft.AspNet.Mvc.Description Assert.Single(descriptions, d => d.HttpMethod == "GET"); } - // This is a test for the placeholder behavior - see #886 - [Fact] - public void GetApiDescription_PopulatesParameters() - { - // Arrange - var action = CreateActionDescriptor(); - action.Parameters = new List() - { - new ParameterDescriptor() - { - Name = "id", - ParameterType = typeof(int), - }, - new ParameterDescriptor() - { - BinderMetadata = new FromBodyAttribute(), - Name = "username", - ParameterType = typeof(string), - } - }; - - // Act - var descriptions = GetApiDescriptions(action); - - // Assert - var description = Assert.Single(descriptions); - Assert.Equal(2, description.ParameterDescriptions.Count); - - var id = Assert.Single(description.ParameterDescriptions, p => p.Name == "id"); - Assert.NotNull(id.ModelMetadata); - Assert.False(id.IsOptional); - Assert.Same(action.Parameters[0], id.ParameterDescriptor); - Assert.Equal(ApiParameterSource.Query, id.Source); - Assert.Equal(typeof(int), id.Type); - - var username = Assert.Single(description.ParameterDescriptions, p => p.Name == "username"); - Assert.NotNull(username.ModelMetadata); - Assert.False(username.IsOptional); - Assert.Same(action.Parameters[1], username.ParameterDescriptor); - Assert.Equal(ApiParameterSource.Body, username.Source); - Assert.Equal(typeof(string), username.Type); - } - [Theory] [InlineData("api/products/{id}", false, null, null)] [InlineData("api/products/{id?}", true, null, null)] @@ -181,22 +139,21 @@ namespace Microsoft.AspNet.Mvc.Description var parameter = Assert.Single(description.ParameterDescriptions); Assert.Equal(ApiParameterSource.Path, parameter.Source); - Assert.Equal(isOptional, parameter.IsOptional); + Assert.Equal(isOptional, parameter.RouteInfo.IsOptional); Assert.Equal("id", parameter.Name); - Assert.Null(parameter.ParameterDescriptor); if (constraintType != null) { - Assert.IsType(constraintType, Assert.Single(parameter.Constraints)); + Assert.IsType(constraintType, Assert.Single(parameter.RouteInfo.Constraints)); } if (defaultValue != null) { - Assert.Equal(defaultValue, parameter.DefaultValue); + Assert.Equal(defaultValue, parameter.RouteInfo.DefaultValue); } else { - Assert.Null(parameter.DefaultValue); + Assert.Null(parameter.RouteInfo.DefaultValue); } } @@ -217,15 +174,10 @@ namespace Microsoft.AspNet.Mvc.Description object defaultValue) { // Arrange - var action = CreateActionDescriptor(); + var action = CreateActionDescriptor(nameof(FromRouting)); action.AttributeRouteInfo = new AttributeRouteInfo { Template = template }; - var parameterDescriptor = new ParameterDescriptor - { - Name = "id", - ParameterType = typeof(int), - }; - action.Parameters = new List { parameterDescriptor }; + var parameterDescriptor = action.Parameters[0]; // Act var descriptions = GetApiDescriptions(action); @@ -235,82 +187,76 @@ namespace Microsoft.AspNet.Mvc.Description var parameter = Assert.Single(description.ParameterDescriptions); Assert.Equal(ApiParameterSource.Path, parameter.Source); - Assert.Equal(isOptional, parameter.IsOptional); + Assert.Equal(isOptional, parameter.RouteInfo.IsOptional); Assert.Equal("id", parameter.Name); - Assert.Equal(parameterDescriptor, parameter.ParameterDescriptor); if (constraintType != null) { - Assert.IsType(constraintType, Assert.Single(parameter.Constraints)); + Assert.IsType(constraintType, Assert.Single(parameter.RouteInfo.Constraints)); } if (defaultValue != null) { - Assert.Equal(defaultValue, parameter.DefaultValue); + Assert.Equal(defaultValue, parameter.RouteInfo.DefaultValue); } else { - Assert.Null(parameter.DefaultValue); + Assert.Null(parameter.RouteInfo.DefaultValue); } } + // Only a parameter which comes from a route or model binding or unknown should + // include route info. [Theory] - [InlineData("api/products/{id}", false, null, null)] - [InlineData("api/products/{id?}", true, null, null)] - [InlineData("api/products/{id=5}", true, null, "5")] - [InlineData("api/products/{id:int}", false, typeof(IntRouteConstraint), null)] - [InlineData("api/products/{id:int?}", true, typeof(IntRouteConstraint), null)] - [InlineData("api/products/{id:int=5}", true, typeof(IntRouteConstraint), "5")] - [InlineData("api/products/{*id}", false, null, null)] - [InlineData("api/products/{*id:int}", false, typeof(IntRouteConstraint), null)] - [InlineData("api/products/{*id:int=5}", true, typeof(IntRouteConstraint), "5")] - public void GetApiDescription_CreatesDifferentParameters_IfParameterDescriptorIsFromBody( + [InlineData("api/products/{id}", nameof(FromBody), "Body")] + [InlineData("api/products/{id}", nameof(FromHeader), "Header")] + public void GetApiDescription_ParameterDescription_DoesNotIncludeRouteInfo( string template, - bool isOptional, - Type constraintType, - object defaultValue) + string methodName, + string source) { // Arrange - var action = CreateActionDescriptor(); + var action = CreateActionDescriptor(methodName); action.AttributeRouteInfo = new AttributeRouteInfo { Template = template }; - var parameterDescriptor = new ParameterDescriptor - { - BinderMetadata = new FromBodyAttribute(), - Name = "id", - ParameterType = typeof(int), - }; - action.Parameters = new List { parameterDescriptor }; - // Act var descriptions = GetApiDescriptions(action); // Assert var description = Assert.Single(descriptions); + var parameters = description.ParameterDescriptions; - var bodyParameter = Assert.Single(description.ParameterDescriptions, p => p.Source == ApiParameterSource.Body); - Assert.False(bodyParameter.IsOptional); - Assert.Equal("id", bodyParameter.Name); - Assert.Equal(parameterDescriptor, bodyParameter.ParameterDescriptor); + var id = Assert.Single(parameters, p => p.Source == new ApiParameterSource(source, displayName: null)); + Assert.Null(id.RouteInfo); + } - var pathParameter = Assert.Single(description.ParameterDescriptions, p => p.Source == ApiParameterSource.Path); - Assert.Equal(isOptional, pathParameter.IsOptional); - Assert.Equal("id", pathParameter.Name); - Assert.Null(pathParameter.ParameterDescriptor); + // Only a parameter which comes from a route or model binding or unknown should + // include route info. If the source is model binding, we also check if it's an optional + // parameter, and only change the source if it's a match. + [Theory] + [InlineData("api/products/{id}", nameof(FromRouting), "Path")] + [InlineData("api/products/{id}", nameof(FromModelBinding), "Path")] + [InlineData("api/products/{id?}", nameof(FromModelBinding), "ModelBinding")] + [InlineData("api/products/{id=5}", nameof(FromModelBinding), "ModelBinding")] + [InlineData("api/products/{id}", nameof(FromCustom), "Custom")] + public void GetApiDescription_ParameterDescription_IncludesRouteInfo( + string template, + string methodName, + string source) + { + // Arrange + var action = CreateActionDescriptor(methodName); + action.AttributeRouteInfo = new AttributeRouteInfo { Template = template }; - if (constraintType != null) - { - Assert.IsType(constraintType, Assert.Single(pathParameter.Constraints)); - } + // Act + var descriptions = GetApiDescriptions(action); - if (defaultValue != null) - { - Assert.Equal(defaultValue, pathParameter.DefaultValue); - } - else - { - Assert.Null(pathParameter.DefaultValue); - } + // Assert + var description = Assert.Single(descriptions); + var parameters = description.ParameterDescriptions; + + var id = Assert.Single(parameters, p => p.Source == new ApiParameterSource(source, displayName: null)); + Assert.NotNull(id.RouteInfo); } [Theory] @@ -322,23 +268,16 @@ namespace Microsoft.AspNet.Mvc.Description bool expectedOptional) { // Arrange - var action = CreateActionDescriptor(); + var action = CreateActionDescriptor(nameof(FromRouting)); action.AttributeRouteInfo = new AttributeRouteInfo { Template = template }; - var parameterDescriptor = new ParameterDescriptor - { - Name = "id", - ParameterType = typeof(int), - }; - action.Parameters = new List { parameterDescriptor }; - // Act var descriptions = GetApiDescriptions(action); // Assert var description = Assert.Single(descriptions); var parameter = Assert.Single(description.ParameterDescriptions); - Assert.Equal(expectedOptional, parameter.IsOptional); + Assert.Equal(expectedOptional, parameter.RouteInfo.IsOptional); } [Theory] @@ -380,11 +319,11 @@ namespace Microsoft.AspNet.Mvc.Description var description = Assert.Single(descriptions); var id1 = Assert.Single(description.ParameterDescriptions, p => p.Name == "id1"); Assert.Equal(ApiParameterSource.Path, id1.Source); - Assert.Empty(id1.Constraints); + Assert.Empty(id1.RouteInfo.Constraints); var id2 = Assert.Single(description.ParameterDescriptions, p => p.Name == "id2"); Assert.Equal(ApiParameterSource.Path, id2.Source); - Assert.IsType(Assert.Single(id2.Constraints)); + Assert.IsType(Assert.Single(id2.RouteInfo.Constraints)); } [Fact] @@ -584,6 +523,378 @@ namespace Microsoft.AspNet.Mvc.Description Assert.Same(formatters[0], formats[0].Formatter); } + [Fact] + public void GetApiDescription_ParameterDescription_ModelBoundParameter() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsProduct)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var parameter = Assert.Single(description.ParameterDescriptions); + Assert.Equal("product", parameter.Name); + Assert.Same(ApiParameterSource.ModelBinding, parameter.Source); + } + + [Fact] + public void GetApiDescription_ParameterDescription_SourceFromRouteData() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsId_Route)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var parameter = Assert.Single(description.ParameterDescriptions); + Assert.Equal("id", parameter.Name); + Assert.Same(ApiParameterSource.Path, parameter.Source); + } + + [Fact] + public void GetApiDescription_ParameterDescription_SourceFromQueryString() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsId_Query)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var parameter = Assert.Single(description.ParameterDescriptions); + Assert.Equal("id", parameter.Name); + Assert.Same(ApiParameterSource.Query, parameter.Source); + } + + [Fact] + public void GetApiDescription_ParameterDescription_SourceFromBody() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsProduct_Body)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var parameter = Assert.Single(description.ParameterDescriptions); + Assert.Equal("product", parameter.Name); + Assert.Same(ApiParameterSource.Body, parameter.Source); + } + + [Fact] + public void GetApiDescription_ParameterDescription_SourceFromForm() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsProduct_Form)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var parameter = Assert.Single(description.ParameterDescriptions); + Assert.Equal("product", parameter.Name); + Assert.Same(ApiParameterSource.Form, parameter.Source); + } + + [Fact] + public void GetApiDescription_ParameterDescription_SourceFromHeader() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsId_Header)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var parameter = Assert.Single(description.ParameterDescriptions); + Assert.Equal("id", parameter.Name); + Assert.Same(ApiParameterSource.Header, parameter.Source); + } + + // 'Hidden' parameters are hidden (not returned). + [Fact] + public void GetApiDescription_ParameterDescription_SourceFromServices() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsFormatters_Services)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Empty(description.ParameterDescriptions); + } + + [Fact] + public void GetApiDescription_ParameterDescription_SourceFromCustomModelBinder() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsProduct_Custom)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var parameter = Assert.Single(description.ParameterDescriptions); + Assert.Equal("product", parameter.Name); + Assert.Same(ApiParameterSource.Custom, parameter.Source); + } + + [Fact] + public void GetApiDescription_ParameterDescription_SourceFromDefault_ModelBinderAttribute_WithoutBinderType() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsProduct_Default)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var parameter = Assert.Single(description.ParameterDescriptions); + Assert.Equal("product", parameter.Name); + Assert.Same(ApiParameterSource.ModelBinding, parameter.Source); + } + + [Fact] + public void GetApiDescription_ParameterDescription_ComplexDTO() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsProductChangeDTO)); + var parameterDescriptor = action.Parameters.Single(); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(4, description.ParameterDescriptions.Count); + + var id = Assert.Single(description.ParameterDescriptions, p => p.Name == "Id"); + Assert.Same(ApiParameterSource.Path, id.Source); + Assert.Equal(typeof(int), id.Type); + + var product = Assert.Single(description.ParameterDescriptions, p => p.Name == "Product"); + Assert.Same(ApiParameterSource.Body, product.Source); + Assert.Equal(typeof(Product), product.Type); + + var userId = Assert.Single(description.ParameterDescriptions, p => p.Name == "UserId"); + Assert.Same(ApiParameterSource.Header, userId.Source); + Assert.Equal(typeof(string), userId.Type); + + var comments = Assert.Single(description.ParameterDescriptions, p => p.Name == "Comments"); + Assert.Same(ApiParameterSource.ModelBinding, comments.Source); + Assert.Equal(typeof(string), comments.Type); + } + + // The method under test uses an attribute on the parameter to set a 'default' source + [Fact] + public void GetApiDescription_ParameterDescription_ComplexDTO_AmbientValueProviderMetadata() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsProductChangeDTO_Query)); + var parameterDescriptor = action.Parameters.Single(); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(4, description.ParameterDescriptions.Count); + + var id = Assert.Single(description.ParameterDescriptions, p => p.Name == "Id"); + Assert.Same(ApiParameterSource.Path, id.Source); + Assert.Equal(typeof(int), id.Type); + + var product = Assert.Single(description.ParameterDescriptions, p => p.Name == "Product"); + Assert.Same(ApiParameterSource.Body, product.Source); + Assert.Equal(typeof(Product), product.Type); + + var userId = Assert.Single(description.ParameterDescriptions, p => p.Name == "UserId"); + Assert.Same(ApiParameterSource.Header, userId.Source); + Assert.Equal(typeof(string), userId.Type); + + var comments = Assert.Single(description.ParameterDescriptions, p => p.Name == "Comments"); + Assert.Same(ApiParameterSource.Query, comments.Source); + Assert.Equal(typeof(string), comments.Type); + } + + [Fact] + public void GetApiDescription_ParameterDescription_ComplexDTO_AnotherLevel() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsOrderDTO)); + var parameterDescriptor = action.Parameters.Single(); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(4, description.ParameterDescriptions.Count); + + var id = Assert.Single(description.ParameterDescriptions, p => p.Name == "Id"); + Assert.Same(ApiParameterSource.Path, id.Source); + Assert.Equal(typeof(int), id.Type); + + var quantity = Assert.Single(description.ParameterDescriptions, p => p.Name == "Quantity"); + Assert.Same(ApiParameterSource.ModelBinding, quantity.Source); + Assert.Equal(typeof(int), quantity.Type); + + var productId = Assert.Single(description.ParameterDescriptions, p => p.Name == "Product.Id"); + Assert.Same(ApiParameterSource.ModelBinding, productId.Source); + Assert.Equal(typeof(int), productId.Type); + + var price = Assert.Single(description.ParameterDescriptions, p => p.Name == "Product.Price"); + Assert.Same(ApiParameterSource.Query, price.Source); + Assert.Equal(typeof(decimal), price.Type); + } + + // The method under test uses an attribute on the parameter to set a 'default' source + [Fact] + public void GetApiDescription_ParameterDescription_ComplexDTO_AnotherLevel_AmbientValueProviderMetadata() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsOrderDTO_Query)); + var parameterDescriptor = action.Parameters.Single(); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(3, description.ParameterDescriptions.Count); + + var id = Assert.Single(description.ParameterDescriptions, p => p.Name == "Id"); + Assert.Same(ApiParameterSource.Path, id.Source); + Assert.Equal(typeof(int), id.Type); + + var quantity = Assert.Single(description.ParameterDescriptions, p => p.Name == "Quantity"); + Assert.Same(ApiParameterSource.Query, quantity.Source); + Assert.Equal(typeof(int), quantity.Type); + + var product = Assert.Single(description.ParameterDescriptions, p => p.Name == "Product"); + Assert.Same(ApiParameterSource.Query, product.Source); + Assert.Equal(typeof(OrderProductDTO), product.Type); + } + + [Fact] + public void GetApiDescription_ParameterDescription_BreaksCycles() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsCycle)); + var parameterDescriptor = action.Parameters.Single(); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var c = Assert.Single(description.ParameterDescriptions); + Assert.Same(ApiParameterSource.Query, c.Source); + Assert.Equal("C.C", c.Name); + Assert.Equal(typeof(Cycle1), c.Type); + } + + [Fact] + public void GetApiDescription_ParameterDescription_DTOWithCollection() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsHasCollection)); + var parameterDescriptor = action.Parameters.Single(); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var products = Assert.Single(description.ParameterDescriptions); + Assert.Same(ApiParameterSource.Query, products.Source); + Assert.Equal("Products", products.Name); + Assert.Equal(typeof(Product[]), products.Type); + } + + // If a property/parameter is a collection, we automatically treat it as a leaf-node. + [Fact] + public void GetApiDescription_ParameterDescription_DTOWithCollection_ElementsWithBinderMetadataIgnored() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsHasCollection_Complex)); + var parameterDescriptor = action.Parameters.Single(); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var c = Assert.Single(description.ParameterDescriptions); + Assert.Same(ApiParameterSource.ModelBinding, c.Source); + Assert.Equal("c", c.Name); + Assert.Equal(typeof(HasCollection_Complex), c.Type); + } + + [Fact] + public void GetApiDescription_ParameterDescription_RedundentMetadataMergedWithParent() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsRedundentMetadata)); + var parameterDescriptor = action.Parameters.Single(); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var r = Assert.Single(description.ParameterDescriptions); + Assert.Same(ApiParameterSource.Query, r.Source); + Assert.Equal("r", r.Name); + Assert.Equal(typeof(RedundentMetadata), r.Type); + } + + [Fact] + public void GetApiDescription_ParameterDescription_RedundentMetadata_WithParameterMetadata() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsPerson)); + var parameterDescriptor = action.Parameters.Single(); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + + var name = Assert.Single(description.ParameterDescriptions, p => p.Name == "Name"); + Assert.Same(ApiParameterSource.Header, name.Source); + Assert.Equal(typeof(string), name.Type); + + var id = Assert.Single(description.ParameterDescriptions, p => p.Name == "Id"); + Assert.Same(ApiParameterSource.Form, id.Source); + Assert.Equal(typeof(int), id.Type); + } + private IReadOnlyList GetApiDescriptions(ActionDescriptor action) { return GetApiDescriptions(action, CreateFormatters()); @@ -602,18 +913,12 @@ namespace Microsoft.AspNet.Mvc.Description constraintResolver.Setup(c => c.ResolveConstraint("int")) .Returns(new IntRouteConstraint()); - var modelMetadataProvider = new Mock(MockBehavior.Strict); - modelMetadataProvider - .Setup(mmp => mmp.GetMetadataForType(null, It.IsAny())) - .Returns((Func accessor, Type type) => - { - return new ModelMetadata(modelMetadataProvider.Object, null, accessor, type, null); - }); + var modelMetadataProvider = new DataAnnotationsModelMetadataProvider(); var provider = new DefaultApiDescriptionProvider( formattersProvider.Object, constraintResolver.Object, - modelMetadataProvider.Object); + modelMetadataProvider); provider.Invoke(context, () => { }); return context.Results; @@ -646,6 +951,18 @@ namespace Microsoft.AspNet.Mvc.Description methodName ?? "ReturnsObject", BindingFlags.Instance | BindingFlags.NonPublic); + action.Parameters = new List(); + + foreach (var parameter in action.MethodInfo.GetParameters()) + { + action.Parameters.Add(new ParameterDescriptor() + { + BinderMetadata = parameter.GetCustomAttributes().OfType().FirstOrDefault(), + Name = parameter.Name, + ParameterType = parameter.ParameterType, + }); + } + return action; } @@ -699,12 +1016,198 @@ namespace Microsoft.AspNet.Mvc.Description return null; } + private void AcceptsProduct(Product product) + { + } + + private void AcceptsProduct_Body([FromBody] Product product) + { + } + + private void AcceptsProduct_Form([FromForm] Product product) + { + } + + // This will show up as source = model binding + private void AcceptsProduct_Default([ModelBinder] Product product) + { + } + + // This will show up as source = unknown + private void AcceptsProduct_Custom([ModelBinder(BinderType = typeof(BodyModelBinder))] Product product) + { + } + + private void AcceptsId_Route([FromRoute] int id) + { + } + + private void AcceptsId_Query([FromQuery] int id) + { + } + + private void AcceptsId_Header([FromHeader] int id) + { + } + + private void AcceptsFormatters_Services([FromServices] IOutputFormattersProvider formatters) + { + } + + private void AcceptsProductChangeDTO(ProductChangeDTO dto) + { + } + + private void AcceptsProductChangeDTO_Query([FromQuery] ProductChangeDTO dto) + { + } + + private void AcceptsOrderDTO(OrderDTO dto) + { + } + + private void AcceptsOrderDTO_Query([FromQuery] OrderDTO dto) + { + } + + private void AcceptsCycle(Cycle1 c) + { + } + + private void AcceptsHasCollection(HasCollection c) + { + } + + private void AcceptsHasCollection_Complex(HasCollection_Complex c) + { + } + + private void AcceptsRedundentMetadata([FromQuery] RedundentMetadata r) + { + } + + private void AcceptsPerson([FromForm] Person person) + { + } + + private void FromRouting([FromRoute] int id) + { + } + + private void FromModelBinding(int id) + { + } + + private void FromCustom([ModelBinder(BinderType = typeof(BodyModelBinder))] int id) + { + } + + private void FromHeader([FromHeader] int id) + { + } + + private void FromBody([FromBody] int id) + { + } + private class Product { + public int ProductId { get; set; } + + public string Name { get; set; } + + public string Description { get; set; } } private class Order { + public int OrderId { get; set; } + + public int ProductId { get; set; } + + public int Quantity { get; set; } + + public decimal Price { get; set; } + } + + private class ProductChangeDTO + { + [FromRoute] + public int Id { get; set; } + + [FromBody] + public Product Product { get; set; } + + [FromHeader] + public string UserId { get; set; } + + public string Comments { get; set; } + } + + private class OrderDTO + { + [FromRoute] + public int Id { get; set; } + + public int Quantity { get; set; } + + public OrderProductDTO Product { get; set; } + } + + private class OrderProductDTO + { + public int Id { get; set; } + + [FromQuery] + public decimal Price { get; set; } + } + + private class Cycle1 + { + public Cycle2 C { get; set; } + } + + private class Cycle2 + { + [FromQuery] + public Cycle1 C { get; set; } + } + + private class HasCollection + { + [FromQuery] + public Product[] Products { get; set; } + } + + private class HasCollection_Complex + { + public Child[] Items { get; set; } + } + + private class Child + { + [FromQuery] + public int Id { get; set; } + + public string Name { get; set; } + } + + private class RedundentMetadata + { + [FromQuery] + public int Id { get; set; } + + [FromQuery] + public string Name { get; set; } + } + + public class Person + { + [FromHeader(Name = "Name")] + public string Name { get; set; } + + [FromForm] + public int Id { get; set; } } private class MockFormatter : OutputFormatter diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/ApiExplorerTest.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/ApiExplorerTest.cs index 59ae5c105d..0e92daea96 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/ApiExplorerTest.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/ApiExplorerTest.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Mvc.Description; using Microsoft.AspNet.TestHost; using Newtonsoft.Json; using Xunit; @@ -176,9 +177,9 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests var parameter = Assert.Single(description.ParameterDescriptions); Assert.Equal("id", parameter.Name); - Assert.False(parameter.IsOptional); + Assert.False(parameter.RouteInfo.IsOptional); Assert.Equal("Path", parameter.Source); - Assert.Empty(parameter.ConstraintTypes); + Assert.Empty(parameter.RouteInfo.ConstraintTypes); } [Fact] @@ -201,9 +202,9 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests var parameter = Assert.Single(description.ParameterDescriptions); Assert.Equal("integer", parameter.Name); - Assert.False(parameter.IsOptional); + Assert.False(parameter.RouteInfo.IsOptional); Assert.Equal("Path", parameter.Source); - Assert.Equal("IntRouteConstraint", Assert.Single(parameter.ConstraintTypes)); + Assert.Equal("IntRouteConstraint", Assert.Single(parameter.RouteInfo.ConstraintTypes)); } [Fact] @@ -226,7 +227,7 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests var parameter = Assert.Single(description.ParameterDescriptions); Assert.Equal("parameter", parameter.Name); - Assert.False(parameter.IsOptional); + Assert.False(parameter.RouteInfo.IsOptional); Assert.Equal("Path", parameter.Source); } @@ -252,9 +253,9 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests var parameter = Assert.Single(description.ParameterDescriptions); Assert.Equal("integer", parameter.Name); - Assert.False(parameter.IsOptional); + Assert.False(parameter.RouteInfo.IsOptional); Assert.Equal("Path", parameter.Source); - Assert.Equal("IntRouteConstraint", Assert.Single(parameter.ConstraintTypes)); + Assert.Equal("IntRouteConstraint", Assert.Single(parameter.RouteInfo.ConstraintTypes)); } [Fact] @@ -281,19 +282,19 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests Assert.Equal(expectedRelativePath, description.RelativePath); var month = Assert.Single(description.ParameterDescriptions, p => p.Name == "month"); - Assert.False(month.IsOptional); + Assert.False(month.RouteInfo.IsOptional); Assert.Equal("Path", month.Source); - Assert.Equal("RangeRouteConstraint", Assert.Single(month.ConstraintTypes)); + Assert.Equal("RangeRouteConstraint", Assert.Single(month.RouteInfo.ConstraintTypes)); var day = Assert.Single(description.ParameterDescriptions, p => p.Name == "day"); - Assert.False(day.IsOptional); + Assert.False(day.RouteInfo.IsOptional); Assert.Equal("Path", day.Source); - Assert.Equal("IntRouteConstraint", Assert.Single(day.ConstraintTypes)); + Assert.Equal("IntRouteConstraint", Assert.Single(day.RouteInfo.ConstraintTypes)); var year = Assert.Single(description.ParameterDescriptions, p => p.Name == "year"); - Assert.False(year.IsOptional); + Assert.False(year.RouteInfo.IsOptional); Assert.Equal("Path", year.Source); - Assert.Equal("IntRouteConstraint", Assert.Single(year.ConstraintTypes)); + Assert.Equal("IntRouteConstraint", Assert.Single(year.RouteInfo.ConstraintTypes)); } [Fact] @@ -319,19 +320,19 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests Assert.Equal(expectedRelativePath, description.RelativePath); var month = Assert.Single(description.ParameterDescriptions, p => p.Name == "month"); - Assert.False(month.IsOptional); + Assert.False(month.RouteInfo.IsOptional); Assert.Equal("Path", month.Source); - Assert.Equal("RangeRouteConstraint", Assert.Single(month.ConstraintTypes)); + Assert.Equal("RangeRouteConstraint", Assert.Single(month.RouteInfo.ConstraintTypes)); var day = Assert.Single(description.ParameterDescriptions, p => p.Name == "day"); - Assert.True(day.IsOptional); - Assert.Equal("Path", day.Source); - Assert.Equal("IntRouteConstraint", Assert.Single(day.ConstraintTypes)); + Assert.True(day.RouteInfo.IsOptional); + Assert.Equal("ModelBinding", day.Source); + Assert.Equal("IntRouteConstraint", Assert.Single(day.RouteInfo.ConstraintTypes)); var year = Assert.Single(description.ParameterDescriptions, p => p.Name == "year"); - Assert.True(year.IsOptional); - Assert.Equal("Path", year.Source); - Assert.Equal("IntRouteConstraint", Assert.Single(year.ConstraintTypes)); + Assert.True(year.RouteInfo.IsOptional); + Assert.Equal("ModelBinding", year.Source); + Assert.Equal("IntRouteConstraint", Assert.Single(year.RouteInfo.ConstraintTypes)); } [Fact] @@ -383,8 +384,8 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests Assert.Equal("ApiExplorerRouteAndPathParametersInformation/Optional/{id}", description.RelativePath); var id = Assert.Single(description.ParameterDescriptions, p => p.Name == "id"); - Assert.True(id.IsOptional); - Assert.Equal("Path", id.Source); + Assert.True(id.RouteInfo.IsOptional); + Assert.Equal("ModelBinding", id.Source); } [Fact] @@ -722,6 +723,158 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests Assert.Equal(formatterType, format.FormatterType); } + [Fact] + public async Task ApiExplorer_Parameters_SimpleTypes_Default() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerParameters/SimpleParameters"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + var parameters = description.ParameterDescriptions; + + Assert.Equal(2, parameters.Count); + + var i = Assert.Single(parameters, p => p.Name == "i"); + Assert.Equal(ApiParameterSource.ModelBinding.Id, i.Source); + Assert.Equal(typeof(int).FullName, i.Type); + + var s = Assert.Single(parameters, p => p.Name == "s"); + Assert.Equal(ApiParameterSource.ModelBinding.Id, s.Source); + Assert.Equal(typeof(string).FullName, s.Type); + } + + [Fact] + public async Task ApiExplorer_Parameters_SimpleTypes_BinderMetadataOnParameters() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerParameters/SimpleParametersWithBinderMetadata"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + var parameters = description.ParameterDescriptions; + + Assert.Equal(2, parameters.Count); + + var i = Assert.Single(parameters, p => p.Name == "i"); + Assert.Equal(ApiParameterSource.Query.Id, i.Source); + Assert.Equal(typeof(int).FullName, i.Type); + + var s = Assert.Single(parameters, p => p.Name == "s"); + Assert.Equal(ApiParameterSource.Path.Id, s.Source); + Assert.Equal(typeof(string).FullName, s.Type); + } + + [Fact] + public async Task ApiExplorer_ParametersSimpleModel() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerParameters/SimpleModel"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + var parameters = description.ParameterDescriptions; + + Assert.Equal(1, parameters.Count); + + var product = Assert.Single(parameters, p => p.Name == "product"); + Assert.Equal(ApiParameterSource.ModelBinding.Id, product.Source); + Assert.Equal(typeof(ApiExplorerWebSite.Product).FullName, product.Type); + } + + [Fact] + public async Task ApiExplorer_Parameters_SimpleTypes_SimpleModel_FromBody() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerParameters/SimpleModelFromBody/5"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + var parameters = description.ParameterDescriptions; + + Assert.Equal(2, parameters.Count); + + var id = Assert.Single(parameters, p => p.Name == "id"); + Assert.Equal(ApiParameterSource.Path.Id, id.Source); + Assert.Equal(typeof(int).FullName, id.Type); + + var product = Assert.Single(parameters, p => p.Name == "product"); + Assert.Equal(ApiParameterSource.Body.Id, product.Source); + Assert.Equal(typeof(ApiExplorerWebSite.Product).FullName, product.Type); + } + + [Fact] + public async Task ApiExplorer_Parameters_SimpleTypes_ComplexModel() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerParameters/ComplexModel"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + var parameters = description.ParameterDescriptions; + + Assert.Equal(6, parameters.Count); + + var customerId = Assert.Single(parameters, p => p.Name == "CustomerId"); + Assert.Equal(ApiParameterSource.Query.Id, customerId.Source); + Assert.Equal(typeof(string).FullName, customerId.Type); + + var referrer = Assert.Single(parameters, p => p.Name == "Referrer"); + Assert.Equal(ApiParameterSource.Header.Id, referrer.Source); + Assert.Equal(typeof(string).FullName, referrer.Type); + + var quantity = Assert.Single(parameters, p => p.Name == "Details.Quantity"); + Assert.Equal(ApiParameterSource.Form.Id, quantity.Source); + Assert.Equal(typeof(int).FullName, quantity.Type); + + var product = Assert.Single(parameters, p => p.Name == "Details.Product"); + Assert.Equal(ApiParameterSource.Form.Id, product.Source); + Assert.Equal(typeof(ApiExplorerWebSite.Product).FullName, product.Type); + + var shippingInstructions = Assert.Single(parameters, p => p.Name == "Comments.ShippingInstructions"); + Assert.Equal(ApiParameterSource.Query.Id, shippingInstructions.Source); + Assert.Equal(typeof(string).FullName, shippingInstructions.Type); + + var feedback = Assert.Single(parameters, p => p.Name == "Comments.Feedback"); + Assert.Equal(ApiParameterSource.Form.Id, feedback.Source); + Assert.Equal(typeof(string).FullName, feedback.Type); + } + // Used to serialize data between client and server private class ApiExplorerData { @@ -741,15 +894,23 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests // Used to serialize data between client and server private class ApiExplorerParameterData { - public bool IsOptional { get; set; } - public string Name { get; set; } + public ApiExplorerParameterRouteInfo RouteInfo { get; set; } + public string Source { get; set; } public string Type { get; set; } + } + // Used to serialize data between client and server + private class ApiExplorerParameterRouteInfo + { public string[] ConstraintTypes { get; set; } + + public object DefaultValue { get; set; } + + public bool IsOptional { get; set; } } // Used to serialize data between client and server diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/ModelMetadataTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/ModelMetadataTest.cs index cbce75127a..06668c7401 100644 --- a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/ModelMetadataTest.cs +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Metadata/ModelMetadataTest.cs @@ -2,7 +2,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.Linq; #if ASPNET50 using Moq; @@ -84,6 +87,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding Assert.False(metadata.HideSurroundingHtml); Assert.True(metadata.HtmlEncode); Assert.False(metadata.IsComplexType); + Assert.False(metadata.IsCollectionType); Assert.False(metadata.IsNullableValueType); Assert.False(metadata.IsReadOnly); Assert.False(metadata.IsRequired); @@ -128,7 +132,12 @@ namespace Microsoft.AspNet.Mvc.ModelBinding var provider = new EmptyModelMetadataProvider(); // Act - var modelMetadata = new ModelMetadata(provider, null, null, type, null); + var modelMetadata = new ModelMetadata( + provider, + containerType: null, + modelAccessor: null, + modelType: type, + propertyName: null); // Assert Assert.False(modelMetadata.IsComplexType); @@ -145,12 +154,72 @@ namespace Microsoft.AspNet.Mvc.ModelBinding var provider = new EmptyModelMetadataProvider(); // Act - var modelMetadata = new ModelMetadata(provider, null, null, type, null); + var modelMetadata = new ModelMetadata( + provider, + containerType: null, + modelAccessor: null, + modelType: type, + propertyName: null); // Assert Assert.True(modelMetadata.IsComplexType); } + [Theory] + [InlineData(typeof(object))] + [InlineData(typeof(int))] + [InlineData(typeof(NonCollectionType))] + [InlineData(typeof(string))] + public void IsCollectionType_NonCollectionTypes(Type type) + { + // Arrange + var provider = new EmptyModelMetadataProvider(); + + // Act + var modelMetadata = new ModelMetadata( + provider, + containerType: null, + modelAccessor: null, + modelType: type, + propertyName: null); + + // Assert + Assert.False(modelMetadata.IsCollectionType); + } + + [Theory] + [InlineData(typeof(int[]))] + [InlineData(typeof(List))] + [InlineData(typeof(DerivedList))] + [InlineData(typeof(IEnumerable))] + [InlineData(typeof(IEnumerable))] + [InlineData(typeof(Collection))] + [InlineData(typeof(Dictionary))] + public void IsCollectionType_CollectionTypes(Type type) + { + // Arrange + var provider = new EmptyModelMetadataProvider(); + + // Act + var modelMetadata = new ModelMetadata( + provider, + containerType: null, + modelAccessor: null, + modelType: type, + propertyName: null); + + // Assert + Assert.True(modelMetadata.IsCollectionType); + } + + private class NonCollectionType + { + } + + private class DerivedList : List + { + } + // IsNullableValueType [Fact] diff --git a/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs b/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs index a00f14c7f9..b412d3c881 100644 --- a/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs +++ b/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Collections.Generic; using System.Linq; using Microsoft.AspNet.Mvc; @@ -9,11 +10,11 @@ using Microsoft.AspNet.Mvc.Description; namespace ApiExplorerWebSite { /// - /// An action filter that looks up and serializes Api Explorer data for the action. + /// A resource filter that looks up and serializes Api Explorer data for the action. /// /// This replaces the 'actual' output of the action. /// - public class ApiExplorerDataFilter : ActionFilterAttribute + public class ApiExplorerDataFilter : IResourceFilter { private readonly IApiDescriptionGroupCollectionProvider _descriptionProvider; @@ -22,7 +23,7 @@ namespace ApiExplorerWebSite _descriptionProvider = descriptionProvider; } - public override void OnActionExecuted(ActionExecutedContext context) + public void OnResourceExecuting(ResourceExecutingContext context) { var descriptions = new List(); foreach (var group in _descriptionProvider.ApiDescriptionGroups.Items) @@ -39,6 +40,11 @@ namespace ApiExplorerWebSite context.Result = new JsonResult(descriptions); } + public void OnResourceExecuted(ResourceExecutedContext context) + { + throw new NotImplementedException(); + } + private ApiExplorerData CreateSerializableData(ApiDescription description) { var data = new ApiExplorerData() @@ -53,13 +59,21 @@ namespace ApiExplorerWebSite { var parameterData = new ApiExplorerParameterData() { - IsOptional = parameter.IsOptional, Name = parameter.Name, - Source = parameter.Source.ToString(), - Type = parameter?.Type?.FullName, - ConstraintTypes = parameter?.Constraints?.Select(c => c.GetType().Name).ToArray(), + Source = parameter.Source.Id, + Type = parameter.Type?.FullName, }; + if (parameter.RouteInfo != null) + { + parameterData.RouteInfo = new ApiExplorerParameterRouteInfo() + { + ConstraintTypes = parameter.RouteInfo.Constraints?.Select(c => c.GetType().Name).ToArray(), + DefaultValue = parameter.RouteInfo.DefaultValue, + IsOptional = parameter.RouteInfo.IsOptional, + }; + } + data.ParameterDescriptions.Add(parameterData); } @@ -96,15 +110,23 @@ namespace ApiExplorerWebSite // Used to serialize data between client and server private class ApiExplorerParameterData { - public bool IsOptional { get; set; } - public string Name { get; set; } + public ApiExplorerParameterRouteInfo RouteInfo { get; set; } + public string Source { get; set; } public string Type { get; set; } + } + // Used to serialize data between client and server + private class ApiExplorerParameterRouteInfo + { public string[] ConstraintTypes { get; set; } + + public object DefaultValue { get; set; } + + public bool IsOptional { get; set; } } // Used to serialize data between client and server diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerParametersController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerParametersController.cs new file mode 100644 index 0000000000..d55ad41438 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerParametersController.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace ApiExplorerWebSite.Controllers +{ + [Route("ApiExplorerParameters/[action]")] + public class ApiExplorerParametersController : Controller + { + public void SimpleParameters(int i, string s) + { + } + + public void SimpleParametersWithBinderMetadata([FromQuery] int i, [FromRoute] string s) + { + } + + public void SimpleModel(Product product) + { + } + + [Route("{id}")] + public void SimpleModelFromBody(int id, [FromBody] Product product) + { + } + + public void ComplexModel([FromQuery] OrderDTO order) + { + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Models/CustomerCommentsDTO.cs b/test/WebSites/ApiExplorerWebSite/Models/CustomerCommentsDTO.cs new file mode 100644 index 0000000000..af89c259e9 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Models/CustomerCommentsDTO.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace ApiExplorerWebSite +{ + public class CustomerCommentsDTO + { + [FromQuery] + public string ShippingInstructions { get; set; } + + public string Feedback { get; set; } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Models/IOrderRepository.cs b/test/WebSites/ApiExplorerWebSite/Models/IOrderRepository.cs new file mode 100644 index 0000000000..a51b4cafa5 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Models/IOrderRepository.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace ApiExplorerWebSite +{ + public interface IOrderRepository + { + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Models/OrderDTO.cs b/test/WebSites/ApiExplorerWebSite/Models/OrderDTO.cs new file mode 100644 index 0000000000..51e0c2d33b --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Models/OrderDTO.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace ApiExplorerWebSite +{ + public class OrderDTO + { + [FromServices] + public IOrderRepository Repository { get; set; } + + public string CustomerId { get; set; } + + [FromHeader(Name = "Referrer")] + public string ReferrerId { get; set; } + + public OrderDetailsDTO Details { get; set; } + + [FromForm] + public CustomerCommentsDTO Comments { get; set; } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Models/OrderDetailsDTO.cs b/test/WebSites/ApiExplorerWebSite/Models/OrderDetailsDTO.cs new file mode 100644 index 0000000000..cd4d0a6a55 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Models/OrderDetailsDTO.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace ApiExplorerWebSite +{ + public class OrderDetailsDTO + { + [FromForm] + public int Quantity { get; set; } + + [FromForm] + public Product Product { get; set; } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Models/Product.cs b/test/WebSites/ApiExplorerWebSite/Models/Product.cs index 2d608bedd2..c5ae342045 100644 --- a/test/WebSites/ApiExplorerWebSite/Models/Product.cs +++ b/test/WebSites/ApiExplorerWebSite/Models/Product.cs @@ -5,5 +5,8 @@ namespace ApiExplorerWebSite { public class Product { + public int Id { get; set; } + + public string Name { get; set; } } } \ No newline at end of file