From 09928a28182e2438686772569cff8c2add1ea5b6 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Wed, 26 Nov 2014 11:25:19 -0800 Subject: [PATCH] Adds parameter information to ApiExplorer This change makes ApiDescription and ApiParameterDescription aware of all of the new features we built into model binding for enhanced DTO support (uber-binding). The main change is that instead of sticking just to the declared parameters on the action itself, we now traverse model metadata and break the parameters down based on their logical data source. This means that a model like the below will yield 3 parameters: public class ProductChangeCommandDTO { public int Id { get; set; } [FromBody] public ProductDetails Changes { get; set; } [FromQuery] public string AdminComments { get; set; } [FromServices] public IProductRepository Repository { get; set; } } The 'Repository' will be hidden, as it's not related to user input. Additionally, we treat different sources differently. In the above example, 'Changes' is from the body and will be treated as a leaf-node. However if you use nested DTOs that are bound from the query string (using [FromQuery]) or similar, we'll recursively explore to find as much structure as possible. This information is combined with data from the route template to give a much more complete picture than we ever could in the past for parameters, especially when DTO/Command pattern is used. --- .../ProductsAdminController.cs | 10 +- .../ApiExplorerSamples/ProductsController.cs | 7 + .../Models/ApiExplorerSamples/Order.cs | 27 + .../Models/ApiExplorerSamples/Product.cs | 2 + .../ApiExplorerSamples/ProductChangeDTO.cs | 14 + .../ApiExplorerSamples/UpdateProductDTO.cs | 20 + .../Views/ApiExplorer/_ApiDescription.cshtml | 70 +- .../wwwroot/Content/api-description.css | 22 + .../ControllerActionDescriptorBuilder.cs | 7 + .../Description/ApiParameterDescription.cs | 28 +- .../Description/ApiParameterRouteInfo.cs | 41 + .../Description/ApiParameterSource.cs | 127 ++- .../DefaultApiDescriptionProvider.cs | 540 +++++++++---- .../ParameterBinding/ModelBindingHelper.cs | 2 +- .../Properties/Resources.Designer.cs | 144 ++++ src/Microsoft.AspNet.Mvc.Core/Resources.resx | 27 + .../Internal/TypeHelper.cs | 15 + .../Metadata/ModelMetadata.cs | 5 + ...ControllerActionDescriptorProviderTests.cs | 45 +- .../DefaultApiDescriptionProviderTest.cs | 745 +++++++++++++++--- .../ApiExplorerTest.cs | 211 ++++- .../Metadata/ModelMetadataTest.cs | 73 +- .../ApiExplorerDataFilter.cs | 40 +- .../ApiExplorerParametersController.cs | 32 + .../Models/CustomerCommentsDTO.cs | 15 + .../Models/IOrderRepository.cs | 9 + .../ApiExplorerWebSite/Models/OrderDTO.cs | 23 + .../Models/OrderDetailsDTO.cs | 16 + .../ApiExplorerWebSite/Models/Product.cs | 3 + 29 files changed, 1981 insertions(+), 339 deletions(-) create mode 100644 samples/MvcSample.Web/Models/ApiExplorerSamples/Order.cs create mode 100644 samples/MvcSample.Web/Models/ApiExplorerSamples/ProductChangeDTO.cs create mode 100644 samples/MvcSample.Web/Models/ApiExplorerSamples/UpdateProductDTO.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterRouteInfo.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerParametersController.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Models/CustomerCommentsDTO.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Models/IOrderRepository.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Models/OrderDTO.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Models/OrderDetailsDTO.cs 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) - { -
  • - @parameter.Name - @(parameter?.Type?.FullName ?? "Unknown") - @parameter.Source.ToString() - @if (parameter.Constraints != null && parameter.Constraints.Any()) - { - var constraints = parameter.Constraints; - Write("-Constraint:" + string.Join(", ", constraints.Select(c => c.GetType()?.Name?.Replace("RouteConstraint", "")))); - } - - Default value: @(parameter?.DefaultValue ?? " none") -
  • - } -
+ + + + + + + + + + @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) - { -
  • @response.MediaType.ToString() - @response.Formatter.GetType().Name
  • - } -
+ + + + + + + + + + @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