From 3cd6d3e060c96f11ab0db43492a14da82a2988f9 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Wed, 27 Aug 2014 14:28:26 -0700 Subject: [PATCH] Adding Api Explorer --- Mvc.sln | 15 +- .../MvcSample.Web/ApiExplorerController.cs | 22 + .../MvcSample.Web/Content/api-description.css | 10 + .../ProductsAdminController.cs | 38 ++ .../ApiExplorerSamples/ProductsController.cs | 31 ++ .../Models/ApiExplorerSamples/Product.cs | 13 + .../ProductOrderConfirmation.cs | 15 + .../Views/ApiExplorer/All.cshtml | 23 + .../Views/ApiExplorer/_ApiDescription.cshtml | 31 ++ .../ActionDescriptor.cs | 6 + .../ActionDescriptorExtensions.cs | 43 ++ .../Description/ApiDescription.cs | 81 +++ .../Description/ApiDescriptionActionData.cs | 18 + .../ApiDescriptionCollectionGroupProvider.cs | 62 +++ .../Description/ApiDescriptionExtensions.cs | 43 ++ .../Description/ApiDescriptionGroup.cs | 34 ++ .../ApiDescriptionGroupCollection.cs | 34 ++ .../ApiDescriptionProviderContext.cs | 34 ++ .../ApiExplorerSettingsAttribute.cs | 25 + .../Description/ApiParameterDescription.cs | 23 + .../Description/ApiParameterSource.cs | 12 + .../Description/ApiResponseFormat.cs | 23 + .../DefaultApiDescriptionProvider.cs | 301 ++++++++++ .../IApiDescriptionGroupCollectionProvider.cs | 16 + .../IApiDescriptionGroupNameProvider.cs | 16 + .../IApiDescriptionVisibilityProvider.cs | 17 + .../IApiResponseMetadataProvider.cs} | 10 +- .../Filters/ProducesAttribute.cs | 16 +- .../ReflectedActionDescriptorProvider.cs | 19 + .../ReflectedActionModel.cs | 26 + .../ReflectedControllerModel.cs | 27 +- src/Microsoft.AspNet.Mvc/MvcServices.cs | 6 + .../DefaultApiDescriptionProviderTest.cs | 508 +++++++++++++++++ .../ReflectedActionDescriptorProviderTests.cs | 174 ++++++ .../ReflectedActionModelTests.cs | 36 +- .../ReflectedControllerModelTests.cs | 58 +- .../ApiExplorerTest.cs | 513 ++++++++++++++++++ .../project.json | 1 + .../ApiExplorerDataFilter.cs | 114 ++++ ...ApiExplorerVisibilityDisabledConvention.cs | 33 ++ .../ApiExplorerVisibilityEnabledConvention.cs | 25 + .../ApiExplorerWebSite.kproj | 24 + .../ApiExplorerHttpMethodController.cs | 30 + ...piExplorerNameSetByConventionController.cs | 16 + .../ApiExplorerNameSetExplicitlyController.cs | 23 + ...piExplorerResponseContentTypeController.cs | 46 ++ ...seContentTypeOverrideOnActionController.cs | 26 + ...rResponseTypeOverrideOnActionController.cs | 25 + ...orerResponseTypeWithAttributeController.cs | 46 ++ ...rResponseTypeWithoutAttributeController.cs | 83 +++ ...isibilityDisabledByConventionController.cs | 16 + ...VisibilityEnabledByConventionController.cs | 16 + ...plorerVisibilitySetExplicitlyController.cs | 23 + .../ApiExplorerWebSite/Models/Customer.cs | 9 + .../ApiExplorerWebSite/Models/Product.cs | 9 + .../ProducesTypeAttribute.cs | 25 + test/WebSites/ApiExplorerWebSite/Startup.cs | 44 ++ test/WebSites/ApiExplorerWebSite/project.json | 11 + 58 files changed, 3010 insertions(+), 14 deletions(-) create mode 100644 samples/MvcSample.Web/ApiExplorerController.cs create mode 100644 samples/MvcSample.Web/Content/api-description.css create mode 100644 samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsAdminController.cs create mode 100644 samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsController.cs create mode 100644 samples/MvcSample.Web/Models/ApiExplorerSamples/Product.cs create mode 100644 samples/MvcSample.Web/Models/ApiExplorerSamples/ProductOrderConfirmation.cs create mode 100644 samples/MvcSample.Web/Views/ApiExplorer/All.cshtml create mode 100644 samples/MvcSample.Web/Views/ApiExplorer/_ApiDescription.cshtml create mode 100644 src/Microsoft.AspNet.Mvc.Core/ActionDescriptorExtensions.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/ApiDescription.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionActionData.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionCollectionGroupProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionExtensions.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionGroup.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionGroupCollection.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionProviderContext.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/ApiExplorerSettingsAttribute.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterDescription.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterSource.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/ApiResponseFormat.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/DefaultApiDescriptionProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/IApiDescriptionGroupCollectionProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/IApiDescriptionGroupNameProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Description/IApiDescriptionVisibilityProvider.cs rename src/Microsoft.AspNet.Mvc.Core/{IProducesMetadataProvider.cs => Description/IApiResponseMetadataProvider.cs} (67%) create mode 100644 test/Microsoft.AspNet.Mvc.Core.Test/Description/DefaultApiDescriptionProviderTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.FunctionalTests/ApiExplorerTest.cs create mode 100644 test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs create mode 100644 test/WebSites/ApiExplorerWebSite/ApiExplorerVisibilityDisabledConvention.cs create mode 100644 test/WebSites/ApiExplorerWebSite/ApiExplorerVisibilityEnabledConvention.cs create mode 100644 test/WebSites/ApiExplorerWebSite/ApiExplorerWebSite.kproj create mode 100644 test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerHttpMethodController.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerNameSetByConventionController.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerNameSetExplicitlyController.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseContentTypeController.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseContentTypeOverrideOnActionController.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithAttributeController.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithoutAttributeController.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityDisabledByConventionController.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityEnabledByConventionController.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilitySetExplicitlyController.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Models/Customer.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Models/Product.cs create mode 100644 test/WebSites/ApiExplorerWebSite/ProducesTypeAttribute.cs create mode 100644 test/WebSites/ApiExplorerWebSite/Startup.cs create mode 100644 test/WebSites/ApiExplorerWebSite/project.json diff --git a/Mvc.sln b/Mvc.sln index b4a38ef9f8..a00d847532 100644 --- a/Mvc.sln +++ b/Mvc.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 14 -VisualStudioVersion = 14.0.22115.0 +VisualStudioVersion = 14.0.22013.1 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{DAAE4C74-D06F-4874-A166-33305D2643CE}" EndProject @@ -80,6 +80,8 @@ Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "XmlSerializerWebSite", "tes EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "UrlHelperWebSite", "test\WebSites\UrlHelperWebSite\UrlHelperWebSite.kproj", "{A192E504-2881-41DC-90D1-B7F1DD1134E8}" EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "ApiExplorerWebSite", "test\WebSites\ApiExplorerWebSite\ApiExplorerWebSite.kproj", "{61061528-071E-424E-965A-07BCC2F02672}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -410,6 +412,16 @@ Global {A192E504-2881-41DC-90D1-B7F1DD1134E8}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU {A192E504-2881-41DC-90D1-B7F1DD1134E8}.Release|Mixed Platforms.Build.0 = Release|Any CPU {A192E504-2881-41DC-90D1-B7F1DD1134E8}.Release|x86.ActiveCfg = Release|Any CPU + {61061528-071E-424E-965A-07BCC2F02672}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61061528-071E-424E-965A-07BCC2F02672}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61061528-071E-424E-965A-07BCC2F02672}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {61061528-071E-424E-965A-07BCC2F02672}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {61061528-071E-424E-965A-07BCC2F02672}.Debug|x86.ActiveCfg = Debug|Any CPU + {61061528-071E-424E-965A-07BCC2F02672}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61061528-071E-424E-965A-07BCC2F02672}.Release|Any CPU.Build.0 = Release|Any CPU + {61061528-071E-424E-965A-07BCC2F02672}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {61061528-071E-424E-965A-07BCC2F02672}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {61061528-071E-424E-965A-07BCC2F02672}.Release|x86.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -448,5 +460,6 @@ Global {1976AC4A-FEA4-4587-A158-D9F79736D2B6} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {96107AC0-18E2-474D-BAB4-2FFF2185FBCD} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {A192E504-2881-41DC-90D1-B7F1DD1134E8} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} + {61061528-071E-424E-965A-07BCC2F02672} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} EndGlobalSection EndGlobal diff --git a/samples/MvcSample.Web/ApiExplorerController.cs b/samples/MvcSample.Web/ApiExplorerController.cs new file mode 100644 index 0000000000..334b46899a --- /dev/null +++ b/samples/MvcSample.Web/ApiExplorerController.cs @@ -0,0 +1,22 @@ +// 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 Microsoft.AspNet.Mvc.Description; + +namespace MvcSample.Web +{ + [Route("ApiExplorer")] + public class ApiExplorerController : Controller + { + [Activate] + public IApiDescriptionGroupCollectionProvider Provider { get; set; } + + [HttpGet] + public IActionResult All() + { + var descriptions = Provider.ApiDescriptionGroups.Items; + return View(descriptions); + } + } +} \ No newline at end of file diff --git a/samples/MvcSample.Web/Content/api-description.css b/samples/MvcSample.Web/Content/api-description.css new file mode 100644 index 0000000000..8204e08646 --- /dev/null +++ b/samples/MvcSample.Web/Content/api-description.css @@ -0,0 +1,10 @@ +.api-description { + margin: 10px 0px 10px 0px; + padding: 5px; +} + +.api-description hr { + border: 0px; + border-top: 1px solid #D44B4B; + margin: 0px; +} \ No newline at end of file diff --git a/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsAdminController.cs b/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsAdminController.cs new file mode 100644 index 0000000000..5db309cc24 --- /dev/null +++ b/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsAdminController.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNet.Mvc; +using System; +using System.Collections.Generic; + +namespace MvcSample.Web.ApiExplorerSamples +{ + [ApiExplorerSettings(GroupName = "Admin API")] + [Route("api/Admin/Products")] + public class ProductsAdminController : Controller + { + [HttpPut] + public void AddProduct([FromBody] Product product) + { + } + + [HttpPost("{id}")] + public void UpdateProduct([FromBody] Product product) + { + } + + [HttpPost("{id}/Stock")] + public void SetQuantityInStock(int id, int quantity) + { + } + + [HttpPost("{id}/Price")] + public void SetPrice(int id, decimal price) + { + } + + [Produces("application/json", "application/xml")] + [HttpGet("{id}/Orders")] + public IEnumerable GetOrders(DateTime? fromData = null, DateTime? toDate = null) + { + return null; + } + } +} \ No newline at end of file diff --git a/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsController.cs b/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsController.cs new file mode 100644 index 0000000000..fd64e2b472 --- /dev/null +++ b/samples/MvcSample.Web/Controllers/ApiExplorerSamples/ProductsController.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.AspNet.Mvc; +using System.Collections.Generic; + +namespace MvcSample.Web.ApiExplorerSamples +{ + [ApiExplorerSettings(GroupName = "Public API")] + [Produces("application/json")] + [Route("api/Products")] + public class ProductsController : Controller + { + [HttpGet("{id}")] + public Product GetById(int id) + { + return null; + } + + [HttpGet("Search/{name}")] + public IEnumerable SearchByName(string name) + { + return null; + } + + [Produces("application/json", Type = typeof(ProductOrderConfirmation))] + [HttpPut("{id}/Buy")] + public IActionResult Buy(int projectId, int quantity = 1) + { + return null; + } + } +} \ No newline at end of file diff --git a/samples/MvcSample.Web/Models/ApiExplorerSamples/Product.cs b/samples/MvcSample.Web/Models/ApiExplorerSamples/Product.cs new file mode 100644 index 0000000000..c05cce51d6 --- /dev/null +++ b/samples/MvcSample.Web/Models/ApiExplorerSamples/Product.cs @@ -0,0 +1,13 @@ +using System; + +namespace MvcSample.Web.ApiExplorerSamples +{ + public class Product + { + 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/ProductOrderConfirmation.cs b/samples/MvcSample.Web/Models/ApiExplorerSamples/ProductOrderConfirmation.cs new file mode 100644 index 0000000000..92dbff1032 --- /dev/null +++ b/samples/MvcSample.Web/Models/ApiExplorerSamples/ProductOrderConfirmation.cs @@ -0,0 +1,15 @@ +using System; + +namespace MvcSample.Web.ApiExplorerSamples +{ + public class ProductOrderConfirmation + { + public Product Product { get; set; } + + public decimal PricePerUnit { get; set; } + + public int Quantity { get; set; } + + public decimal TotalPrice { get; set; } + } +} \ No newline at end of file diff --git a/samples/MvcSample.Web/Views/ApiExplorer/All.cshtml b/samples/MvcSample.Web/Views/ApiExplorer/All.cshtml new file mode 100644 index 0000000000..195576b70f --- /dev/null +++ b/samples/MvcSample.Web/Views/ApiExplorer/All.cshtml @@ -0,0 +1,23 @@ +@using Microsoft.AspNet.Mvc.Description +@model IReadOnlyList + +@section header +{ + +} + +
+ + @foreach (var group in Model) + { +
+

Group: @group.GroupName

+ + @foreach (var item in group.Items) + { + await Html.RenderPartialAsync("_ApiDescription", item); + } + +
+ } +
\ No newline at end of file diff --git a/samples/MvcSample.Web/Views/ApiExplorer/_ApiDescription.cshtml b/samples/MvcSample.Web/Views/ApiExplorer/_ApiDescription.cshtml new file mode 100644 index 0000000000..b0e24064b4 --- /dev/null +++ b/samples/MvcSample.Web/Views/ApiExplorer/_ApiDescription.cshtml @@ -0,0 +1,31 @@ +@using Microsoft.AspNet.Mvc.Description +@model ApiDescription + +
+

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

+
+
For action: @Model.ActionDescriptor.DisplayName
+

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

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

Parameters:

+
    + @foreach (var parameter in Model.ParameterDescriptions) + { +
  • @parameter.Name - @parameter.Type.FullName - @parameter.Source.ToString()
  • + } +
+ } + + @if (Model.SupportedResponseFormats.Count > 0) + { +

Response Formats:

+
    + @foreach(var response in Model.SupportedResponseFormats) + { +
  • @response.MediaType.RawValue - @response.Formatter.GetType().Name
  • + } +
+ } +
\ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionDescriptor.cs b/src/Microsoft.AspNet.Mvc.Core/ActionDescriptor.cs index eadf1bf6ef..d19c27b5a0 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ActionDescriptor.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ActionDescriptor.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNet.Mvc { public ActionDescriptor() { + Properties = new Dictionary(); RouteValueDefaults = new Dictionary(StringComparer.OrdinalIgnoreCase); } @@ -34,5 +35,10 @@ namespace Microsoft.AspNet.Mvc /// A friendly name for this action. /// public virtual string DisplayName { get; set; } + + /// + /// Stores arbitrary metadata properties associated with the . + /// + public IDictionary Properties { get; private set; } } } diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionDescriptorExtensions.cs b/src/Microsoft.AspNet.Mvc.Core/ActionDescriptorExtensions.cs new file mode 100644 index 0000000000..1d961f2513 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/ActionDescriptorExtensions.cs @@ -0,0 +1,43 @@ +// 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 Microsoft.AspNet.Mvc +{ + /// + /// Extension methods for . + /// + public static class ActionDescriptorExtensions + { + /// + /// Gets the value of a property from the collection + /// using the provided value of as the key. + /// + /// The type of the property. + /// The action descriptor. + /// The property or the default value of . + public static T GetProperty([NotNull] this ActionDescriptor actionDescriptor) + { + object value; + if (actionDescriptor.Properties.TryGetValue(typeof(T), out value)) + { + return (T)value; + } + else + { + return default(T); + } + } + + /// + /// Sets the value of an property in the collection using + /// the provided value of as the key. + /// + /// The type of the property. + /// The action descriptor. + /// The value of the property. + public static void SetProperty([NotNull] this ActionDescriptor actionDescriptor, [NotNull] T value) + { + actionDescriptor.Properties[typeof(T)] = value; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescription.cs b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescription.cs new file mode 100644 index 0000000000..ada31a2e60 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescription.cs @@ -0,0 +1,81 @@ +// 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 Microsoft.AspNet.Mvc.ModelBinding; + +namespace Microsoft.AspNet.Mvc.Description +{ + /// + /// Represents an API exposed by this application. + /// + public class ApiDescription + { + /// + /// Creates a new instance of . + /// + public ApiDescription() + { + Properties = new Dictionary(); + ParameterDescriptions = new List(); + SupportedResponseFormats = new List(); + } + + /// + /// The for this api. + /// + public ActionDescriptor ActionDescriptor { get; set; } + + /// + /// The group name for this api. + /// + public string GroupName { get; set; } + + /// + /// The supported HTTP method for this api, or null if all HTTP methods are supported. + /// + public string HttpMethod { get; set; } + + /// + /// The list of for this api. + /// + public List ParameterDescriptions { get; private set; } + + /// + /// Stores arbitrary metadata properties associated with the . + /// + public IDictionary Properties { get; private set; } + + /// + /// The relative url path template (relative to application root) for this api. + /// + public string RelativePath { get; set; } + + /// + /// The for the or null. + /// + /// + /// Will be null if is null. + /// + public ModelMetadata ResponseModelMetadata { get; set; } + + /// + /// The CLR data type of the response or null. + /// + /// + /// Will be null if the action returns no response, or if the response type is unclear. Use + /// on an action method to specify a response type. + /// + public Type ResponseType { get; set; } + + /// + /// A list of possible formats for a response. + /// + /// + /// Will be empty if the action returns no response, or if the response type is unclear. Use + /// on an action method to specify a response type. + /// + public IList SupportedResponseFormats { get; private set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionActionData.cs b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionActionData.cs new file mode 100644 index 0000000000..cd37e39226 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionActionData.cs @@ -0,0 +1,18 @@ +// 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 Microsoft.AspNet.Mvc.Description +{ + /// + /// Represents data used to build an , stored as part of the + /// . + /// + public class ApiDescriptionActionData + { + /// + /// The of objects for the associated + /// action. + /// + public string GroupName { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionCollectionGroupProvider.cs b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionCollectionGroupProvider.cs new file mode 100644 index 0000000000..30edc5b6a9 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionCollectionGroupProvider.cs @@ -0,0 +1,62 @@ +// 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.Linq; +using Microsoft.Framework.DependencyInjection; + +namespace Microsoft.AspNet.Mvc.Description +{ + /// + public class ApiDescriptionGroupCollectionProvider : IApiDescriptionGroupCollectionProvider + { + private readonly IActionDescriptorsCollectionProvider _actionDescriptorCollectionProvider; + private readonly INestedProviderManager _apiDescriptionProvider; + + private ApiDescriptionGroupCollection _apiDescriptionGroups; + + /// + /// Creates a new instance of . + /// + /// + /// The . + /// + /// + /// The . + /// + public ApiDescriptionGroupCollectionProvider( + IActionDescriptorsCollectionProvider actionDescriptorCollectionProvider, + INestedProviderManager apiDescriptionProvider) + { + _actionDescriptorCollectionProvider = actionDescriptorCollectionProvider; + _apiDescriptionProvider = apiDescriptionProvider; + } + + /// + public ApiDescriptionGroupCollection ApiDescriptionGroups + { + get + { + var actionDescriptors = _actionDescriptorCollectionProvider.ActionDescriptors; + if (_apiDescriptionGroups == null || _apiDescriptionGroups.Version != actionDescriptors.Version) + { + _apiDescriptionGroups = GetCollection(actionDescriptors); + } + + return _apiDescriptionGroups; + } + } + + private ApiDescriptionGroupCollection GetCollection(ActionDescriptorsCollection actionDescriptors) + { + var context = new ApiDescriptionProviderContext(actionDescriptors.Items); + _apiDescriptionProvider.Invoke(context); + + var groups = context.Results + .GroupBy(d => d.GroupName) + .Select(g => new ApiDescriptionGroup(g.Key, g.ToArray())) + .ToArray(); + + return new ApiDescriptionGroupCollection(groups, actionDescriptors.Version); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionExtensions.cs b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionExtensions.cs new file mode 100644 index 0000000000..c1dfc550a4 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionExtensions.cs @@ -0,0 +1,43 @@ +// 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 Microsoft.AspNet.Mvc.Description +{ + /// + /// Extension methods for . + /// + public static class ApiDescriptionExtensions + { + /// + /// Gets the value of a property from the collection + /// using the provided value of as the key. + /// + /// The type of the property. + /// The . + /// The property or the default value of . + public static T GetProperty([NotNull] this ApiDescription apiDescription) + { + object value; + if (apiDescription.Properties.TryGetValue(typeof(T), out value)) + { + return (T)value; + } + else + { + return default(T); + } + } + + /// + /// Sets the value of an property in the collection using + /// the provided value of as the key. + /// + /// The type of the property. + /// The . + /// The value of the property. + public static void SetProperty([NotNull] this ApiDescription apiDescription, [NotNull] T value) + { + apiDescription.Properties[typeof(T)] = value; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionGroup.cs b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionGroup.cs new file mode 100644 index 0000000000..4cd76d408a --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionGroup.cs @@ -0,0 +1,34 @@ +// 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; + +namespace Microsoft.AspNet.Mvc.Description +{ + /// + /// Represents a group of related apis. + /// + public class ApiDescriptionGroup + { + /// + /// Creates a new . + /// + /// The group name. + /// A collection of items for this group. + public ApiDescriptionGroup(string groupName, IReadOnlyList items) + { + GroupName = groupName; + Items = items; + } + + /// + /// The group name. + /// + public string GroupName { get; private set; } + + /// + /// A collection of items for this group. + /// + public IReadOnlyList Items { get; private set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionGroupCollection.cs b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionGroupCollection.cs new file mode 100644 index 0000000000..5727f0d450 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionGroupCollection.cs @@ -0,0 +1,34 @@ +// 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; + +namespace Microsoft.AspNet.Mvc.Description +{ + /// + /// A cached collection of . + /// + public class ApiDescriptionGroupCollection + { + /// + /// Initializes a new instance of the . + /// + /// The list of . + /// The unique version of discovered groups. + public ApiDescriptionGroupCollection([NotNull] IReadOnlyList items, int version) + { + Items = items; + Version = version; + } + + /// + /// Returns the list of . + /// + public IReadOnlyList Items { get; private set; } + + /// + /// Returns the unique version of the current items. + /// + public int Version { get; private set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionProviderContext.cs b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionProviderContext.cs new file mode 100644 index 0000000000..e4a0bfb14b --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiDescriptionProviderContext.cs @@ -0,0 +1,34 @@ +// 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; + +namespace Microsoft.AspNet.Mvc.Description +{ + /// + /// A context object for providers. + /// + public class ApiDescriptionProviderContext + { + /// + /// Creates a new instance of . + /// + /// The list of actions. + public ApiDescriptionProviderContext([NotNull] IReadOnlyList actions) + { + Actions = actions; + + Results = new List(); + } + + /// + /// The list of actions. + /// + public IReadOnlyList Actions { get; private set; } + + /// + /// The list of resulting . + /// + public List Results { get; private set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/ApiExplorerSettingsAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/Description/ApiExplorerSettingsAttribute.cs new file mode 100644 index 0000000000..fe6a7732b8 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiExplorerSettingsAttribute.cs @@ -0,0 +1,25 @@ +// 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 Microsoft.AspNet.Mvc.Description; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// Controls the visibility and group name for an of the associated + /// controller class or action method. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class ApiExplorerSettingsAttribute : + Attribute, + IApiDescriptionGroupNameProvider, + IApiDescriptionVisibilityProvider + { + /// + public string GroupName { get; set; } + + /// + public bool IgnoreApi { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterDescription.cs b/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterDescription.cs new file mode 100644 index 0000000000..a9dba0415a --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterDescription.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 System; +using Microsoft.AspNet.Mvc.ModelBinding; + +namespace Microsoft.AspNet.Mvc.Description +{ + public class ApiParameterDescription + { + public bool IsOptional { get; set; } + + public ModelMetadata ModelMetadata { get; set; } + + public string Name { get; set; } + + public ParameterDescriptor ParameterDescriptor { get; set; } + + public ApiParameterSource Source { get; set; } + + public Type Type { 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 new file mode 100644 index 0000000000..81376af670 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiParameterSource.cs @@ -0,0 +1,12 @@ +// 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 Microsoft.AspNet.Mvc.Description +{ + // This is a placeholder - see #886 + public enum ApiParameterSource + { + Body, + Query, + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/ApiResponseFormat.cs b/src/Microsoft.AspNet.Mvc.Core/Description/ApiResponseFormat.cs new file mode 100644 index 0000000000..c4d6582b11 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/ApiResponseFormat.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.HeaderValueAbstractions; + +namespace Microsoft.AspNet.Mvc.Description +{ + /// + /// Represents a possible format for the body of a response. + /// + public class ApiResponseFormat + { + /// + /// The formatter used to output this response. + /// + public IOutputFormatter Formatter { get; set; } + + /// + /// The media type of the response. + /// + public MediaTypeHeaderValue MediaType { get; set; } + } +} \ 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 new file mode 100644 index 0000000000..fde6eaadc4 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/DefaultApiDescriptionProvider.cs @@ -0,0 +1,301 @@ +// 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 System.Threading.Tasks; +using Microsoft.AspNet.Mvc.HeaderValueAbstractions; +using Microsoft.AspNet.Mvc.ModelBinding; +using Microsoft.Framework.DependencyInjection; + +namespace Microsoft.AspNet.Mvc.Description +{ + /// + /// Implements a provider of for actions represented + /// by . + /// + public class DefaultApiDescriptionProvider : INestedProvider + { + private readonly IOutputFormattersProvider _formattersProvider; + private readonly IModelMetadataProvider _modelMetadataProvider; + + /// + /// Creates a new instance of . + /// + /// The . + /// The . + public DefaultApiDescriptionProvider( + IOutputFormattersProvider formattersProvider, + IModelMetadataProvider modelMetadataProvider) + { + _formattersProvider = formattersProvider; + _modelMetadataProvider = modelMetadataProvider; + } + + /// + public int Order { get; private set; } + + /// + public void Invoke(ApiDescriptionProviderContext context, Action callNext) + { + foreach (var action in context.Actions.OfType()) + { + var extensionData = action.GetProperty(); + if (extensionData != null) + { + var httpMethods = GetHttpMethods(action); + foreach (var httpMethod in httpMethods) + { + context.Results.Add(CreateApiDescription(action, httpMethod, extensionData.GroupName)); + } + } + } + + callNext(); + } + + private ApiDescription CreateApiDescription( + ReflectedActionDescriptor action, + string httpMethod, + string groupName) + { + var apiDescription = new ApiDescription() + { + ActionDescriptor = action, + GroupName = groupName, + HttpMethod = httpMethod, + RelativePath = GetRelativePath(action), + }; + + if (action.Parameters != null) + { + foreach (var parameter in action.Parameters) + { + apiDescription.ParameterDescriptions.Add(GetParameter(parameter)); + } + } + + var responseMetadataAttributes = GetResponseMetadataAttributes(action); + + // We only provide response info if we can figure out a type that is a user-data type. + // Void /Task object/IActionResult will result in no data. + var declaredReturnType = GetDeclaredReturnType(action); + + // Now 'simulate' an action execution. This attempts to figure out to the best of our knowledge + // what the logical data type is using filters. + var runtimeReturnType = GetRuntimeReturnType(declaredReturnType, responseMetadataAttributes); + + // We might not be able to figure out a good runtime return type. If that's the case we don't + // provide any information about outputs. The workaround is to attribute the action. + if (runtimeReturnType == typeof(void)) + { + // As a special case, if the return type is void - we want to surface that information + // specifically, but nothing else. This can be overridden with a filter/attribute. + apiDescription.ResponseType = runtimeReturnType; + } + else if (runtimeReturnType != null) + { + apiDescription.ResponseType = runtimeReturnType; + + apiDescription.ResponseModelMetadata = _modelMetadataProvider.GetMetadataForType( + modelAccessor: null, + modelType: runtimeReturnType); + + var formats = GetResponseFormats( + action, + responseMetadataAttributes, + declaredReturnType, + runtimeReturnType); + + foreach (var format in formats) + { + apiDescription.SupportedResponseFormats.Add(format); + } + } + + return apiDescription; + } + + private IEnumerable GetHttpMethods(ReflectedActionDescriptor action) + { + if (action.MethodConstraints != null && action.MethodConstraints.Count > 0) + { + return action.MethodConstraints.SelectMany(c => c.HttpMethods); + } + else + { + return new string[] { null }; + } + } + + private string GetRelativePath(ReflectedActionDescriptor action) + { + // This is a placeholder for functionality which will correctly generate the relative path + // stub of an action. See: #885 + if (action.AttributeRouteInfo != null && + action.AttributeRouteInfo.Template != null) + { + return action.AttributeRouteInfo.Template; + } + + return null; + } + + private ApiParameterDescription GetParameter(ParameterDescriptor parameter) + { + // This is a placeholder based on currently available functionality for parameters. See #886. + var resourceParameter = new ApiParameterDescription() + { + IsOptional = parameter.IsOptional, + Name = parameter.Name, + ParameterDescriptor = parameter, + }; + + if (parameter.ParameterBindingInfo != null) + { + resourceParameter.Type = parameter.ParameterBindingInfo.ParameterType; + resourceParameter.Source = ApiParameterSource.Query; + } + + if (parameter.BodyParameterInfo != null) + { + resourceParameter.Type = parameter.BodyParameterInfo.ParameterType; + resourceParameter.Source = ApiParameterSource.Body; + } + + if (resourceParameter.Type != null) + { + resourceParameter.ModelMetadata = _modelMetadataProvider.GetMetadataForType( + modelAccessor: null, + modelType: resourceParameter.Type); + } + + return resourceParameter; + } + + private IReadOnlyList GetResponseFormats( + ReflectedActionDescriptor action, + IApiResponseMetadataProvider[] responseMetadataAttributes, + Type declaredType, + Type runtimeType) + { + var results = new List(); + + // Walk through all 'filter' attributes in order, and allow each one to see or override + // the results of the previous ones. This is similar to the execution path for content-negotiation. + var contentTypes = new List(); + if (responseMetadataAttributes != null) + { + foreach (var metadataAttribute in responseMetadataAttributes) + { + metadataAttribute.SetContentTypes(contentTypes); + } + } + + if (contentTypes.Count == 0) + { + contentTypes.Add(null); + } + + var formatters = _formattersProvider.OutputFormatters; + foreach (var contentType in contentTypes) + { + foreach (var formatter in formatters) + { + var supportedTypes = formatter.GetSupportedContentTypes(declaredType, runtimeType, contentType); + if (supportedTypes != null) + { + foreach (var supportedType in supportedTypes) + { + results.Add(new ApiResponseFormat() + { + Formatter = formatter, + MediaType = supportedType, + }); + } + } + } + } + + return results; + } + + private Type GetDeclaredReturnType(ReflectedActionDescriptor action) + { + var declaredReturnType = action.MethodInfo.ReturnType; + if (declaredReturnType == typeof(void) || + declaredReturnType == typeof(Task)) + { + return typeof(void); + } + + // Unwrap the type if it's a Task. The Task (non-generic) case was already handled. + var unwrappedType = TypeHelper.GetTaskInnerTypeOrNull(declaredReturnType) ?? declaredReturnType; + + // If the method is declared to return IActionResult or a derived class, that information + // isn't valuable to the formatter. + if (typeof(IActionResult).IsAssignableFrom(unwrappedType)) + { + return null; + } + else + { + return unwrappedType; + } + } + + private Type GetRuntimeReturnType(Type declaredReturnType, IApiResponseMetadataProvider[] metadataAttributes) + { + // Walk through all of the filter attributes and allow them to set the type. This will execute them + // in filter-order allowing the desired behavior for overriding. + if (metadataAttributes != null) + { + Type typeSetByAttribute = null; + foreach (var metadataAttribute in metadataAttributes) + { + if (metadataAttribute.Type != null) + { + typeSetByAttribute = metadataAttribute.Type; + } + } + + // If one of the filters set a type, then trust it. + if (typeSetByAttribute != null) + { + return typeSetByAttribute; + } + } + + // If we get here, then a filter didn't give us an answer, so we need to figure out if we + // want to use the declared return type. + // + // We've already excluded Task, void, and IActionResult at this point. + // + // If the action might return any object, then assume we don't know anything about it. + if (declaredReturnType == typeof(object)) + { + return null; + } + + return declaredReturnType; + } + + private IApiResponseMetadataProvider[] GetResponseMetadataAttributes(ReflectedActionDescriptor action) + { + if (action.FilterDescriptors == null) + { + return null; + } + + // This technique for enumerating filters will intentionally ignore any filter that is an IFilterFactory + // for a filter that implements IApiResponseMetadataProvider. + // + // The workaround for that is to implement the metadata interface on the IFilterFactory. + return action.FilterDescriptors + .Select(fd => fd.Filter) + .OfType() + .ToArray(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/IApiDescriptionGroupCollectionProvider.cs b/src/Microsoft.AspNet.Mvc.Core/Description/IApiDescriptionGroupCollectionProvider.cs new file mode 100644 index 0000000000..5c283d9755 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/IApiDescriptionGroupCollectionProvider.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. + +namespace Microsoft.AspNet.Mvc.Description +{ + /// + /// Provides access to a collection of . + /// + public interface IApiDescriptionGroupCollectionProvider + { + /// + /// Gets a collection of . + /// + ApiDescriptionGroupCollection ApiDescriptionGroups { get; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/IApiDescriptionGroupNameProvider.cs b/src/Microsoft.AspNet.Mvc.Core/Description/IApiDescriptionGroupNameProvider.cs new file mode 100644 index 0000000000..2ecd2cfb33 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/IApiDescriptionGroupNameProvider.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. + +namespace Microsoft.AspNet.Mvc.Description +{ + /// + /// Represents group name metadata for an . + /// + public interface IApiDescriptionGroupNameProvider + { + /// + /// The group name for the of the associated action or controller. + /// + string GroupName { get; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Description/IApiDescriptionVisibilityProvider.cs b/src/Microsoft.AspNet.Mvc.Core/Description/IApiDescriptionVisibilityProvider.cs new file mode 100644 index 0000000000..e5e425d3ff --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Description/IApiDescriptionVisibilityProvider.cs @@ -0,0 +1,17 @@ +// 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 Microsoft.AspNet.Mvc.Description +{ + /// + /// Represents visibility metadata for an . + /// + public interface IApiDescriptionVisibilityProvider + { + /// + /// If false then no objects will be created for the associated controller + /// or action. + /// + bool IgnoreApi { get; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/IProducesMetadataProvider.cs b/src/Microsoft.AspNet.Mvc.Core/Description/IApiResponseMetadataProvider.cs similarity index 67% rename from src/Microsoft.AspNet.Mvc.Core/IProducesMetadataProvider.cs rename to src/Microsoft.AspNet.Mvc.Core/Description/IApiResponseMetadataProvider.cs index 2dc8f58fd6..62a911a23f 100644 --- a/src/Microsoft.AspNet.Mvc.Core/IProducesMetadataProvider.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Description/IApiResponseMetadataProvider.cs @@ -5,21 +5,21 @@ using System; using System.Collections.Generic; using Microsoft.AspNet.Mvc.HeaderValueAbstractions; -namespace Microsoft.AspNet.Mvc +namespace Microsoft.AspNet.Mvc.Description { /// /// Provides a return type and a set of possible content types returned by a successful execution of the action. /// - public interface IProducesMetadataProvider + public interface IApiResponseMetadataProvider { /// /// Optimistic return type of the action. /// - Type Type { get; set; } + Type Type { get; } /// - /// A collection of allowed content types which can be produced by the action. + /// Configures a collection of allowed content types which can be produced by the action. /// - IList ContentTypes { get; set; } + void SetContentTypes(IList contentTypes); } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Filters/ProducesAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/Filters/ProducesAttribute.cs index 534a0345c9..4ec57cb42f 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Filters/ProducesAttribute.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Filters/ProducesAttribute.cs @@ -3,8 +3,7 @@ using System; using System.Collections.Generic; -using System.Linq; -using Microsoft.AspNet.Mvc.Core; +using Microsoft.AspNet.Mvc.Description; using Microsoft.AspNet.Mvc.HeaderValueAbstractions; namespace Microsoft.AspNet.Mvc @@ -14,7 +13,7 @@ namespace Microsoft.AspNet.Mvc /// which can be used to select a formatter while executing . /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] - public class ProducesAttribute : ResultFilterAttribute, IProducesMetadataProvider + public class ProducesAttribute : ResultFilterAttribute, IApiResponseMetadataProvider { public ProducesAttribute(string contentType, params string[] additionalContentTypes) { @@ -32,7 +31,7 @@ namespace Microsoft.AspNet.Mvc if (objectResult != null) { - objectResult.ContentTypes = ContentTypes; + SetContentTypes(objectResult.ContentTypes); } } @@ -48,5 +47,14 @@ namespace Microsoft.AspNet.Mvc return contentTypes; } + + public void SetContentTypes(IList contentTypes) + { + contentTypes.Clear(); + foreach (var contentType in ContentTypes) + { + contentTypes.Add(contentType); + } + } } } diff --git a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs index 7e4cbb2602..eda11359d9 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using Microsoft.AspNet.Mvc.Core; +using Microsoft.AspNet.Mvc.Description; using Microsoft.AspNet.Mvc.Filters; using Microsoft.AspNet.Mvc.ReflectedModelBuilder; using Microsoft.AspNet.Mvc.Routing; @@ -143,6 +144,7 @@ namespace Microsoft.AspNet.Mvc foreach (var actionDescriptor in actionDescriptors) { + AddApiExplorerInfo(actionDescriptor, action, controller); AddActionFilters(actionDescriptor, action.Filters, controller.Filters, model.Filters); AddActionConstraints(actionDescriptor, action, controller); AddControllerRouteConstraints( @@ -347,6 +349,23 @@ namespace Microsoft.AspNet.Mvc return actionDescriptor; } + private static void AddApiExplorerInfo( + ReflectedActionDescriptor actionDescriptor, + ReflectedActionModel action, + ReflectedControllerModel controller) + { + var apiExplorerIsVisible = action.ApiExplorerIsVisible ?? controller.ApiExplorerIsVisible ?? false; + if (apiExplorerIsVisible) + { + var apiExplorerActionData = new ApiDescriptionActionData() + { + GroupName = action.ApiExplorerGroupName ?? controller.ApiExplorerGroupName, + }; + + actionDescriptor.SetProperty(apiExplorerActionData); + } + } + private static void AddActionFilters( ReflectedActionDescriptor actionDescriptor, IEnumerable actionFilters, diff --git a/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedActionModel.cs b/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedActionModel.cs index 1fe10bdced..6f62554746 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedActionModel.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedActionModel.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using Microsoft.AspNet.Mvc.Description; using Microsoft.AspNet.Mvc.Routing; namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder @@ -28,6 +29,18 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder AttributeRouteModel = new ReflectedAttributeRouteModel(routeTemplateAttribute); } + var apiExplorerNameAttribute = Attributes.OfType().FirstOrDefault(); + if (apiExplorerNameAttribute != null) + { + ApiExplorerGroupName = apiExplorerNameAttribute.GroupName; + } + + var apiExplorerVisibilityAttribute = Attributes.OfType().FirstOrDefault(); + if (apiExplorerVisibilityAttribute != null) + { + ApiExplorerIsVisible = !apiExplorerVisibilityAttribute.IgnoreApi; + } + HttpMethods = new List(); Parameters = new List(); } @@ -47,5 +60,18 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder public List Parameters { get; private set; } public ReflectedAttributeRouteModel AttributeRouteModel { get; set; } + + /// + /// If true, objects will be created for this action. If null + /// then the value of will be used. + /// + public bool? ApiExplorerIsVisible { get; set; } + + /// + /// The value for of objects created + /// for actions defined by this controller. If null then the value of + /// will be used. + /// + public string ApiExplorerGroupName { get; set; } } } diff --git a/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedControllerModel.cs b/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedControllerModel.cs index 760e616bba..88be13d70e 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedControllerModel.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedControllerModel.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using Microsoft.AspNet.Mvc.Description; using Microsoft.AspNet.Mvc.Routing; namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder @@ -31,6 +32,18 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder .Select(rtp => new ReflectedAttributeRouteModel(rtp)) .ToList(); + var apiExplorerNameAttribute = Attributes.OfType().FirstOrDefault(); + if (apiExplorerNameAttribute != null) + { + ApiExplorerGroupName = apiExplorerNameAttribute.GroupName; + } + + var apiExplorerVisibilityAttribute = Attributes.OfType().FirstOrDefault(); + if (apiExplorerVisibilityAttribute != null) + { + ApiExplorerIsVisible = !apiExplorerVisibilityAttribute.IgnoreApi; + } + ControllerName = controllerType.Name.EndsWith("Controller", StringComparison.Ordinal) ? controllerType.Name.Substring(0, controllerType.Name.Length - "Controller".Length) : controllerType.Name; @@ -49,5 +62,17 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder public List RouteConstraints { get; private set; } public List AttributeRoutes { get; private set; } + + /// + /// If true, objects will be created for actions defined by this + /// controller. + /// + public bool? ApiExplorerIsVisible { get; set; } + + /// + /// The value for of objects created + /// for actions defined by this controller. + /// + public string ApiExplorerGroupName { get; set; } } -} \ No newline at end of file +} diff --git a/src/Microsoft.AspNet.Mvc/MvcServices.cs b/src/Microsoft.AspNet.Mvc/MvcServices.cs index fe4fbd6de0..f175ce64a3 100644 --- a/src/Microsoft.AspNet.Mvc/MvcServices.cs +++ b/src/Microsoft.AspNet.Mvc/MvcServices.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Microsoft.AspNet.Mvc.Core; +using Microsoft.AspNet.Mvc.Description; using Microsoft.AspNet.Mvc.Filters; using Microsoft.AspNet.Mvc.Internal; using Microsoft.AspNet.Mvc.ModelBinding; @@ -103,6 +104,11 @@ namespace Microsoft.AspNet.Mvc yield return describe.Singleton(); + yield return describe.Singleton(); + yield return describe.Transient, + DefaultApiDescriptionProvider>(); + yield return describe.Describe( typeof(INestedProviderManager<>), diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Description/DefaultApiDescriptionProviderTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/Description/DefaultApiDescriptionProviderTest.cs new file mode 100644 index 0000000000..e70c2f9c08 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Description/DefaultApiDescriptionProviderTest.cs @@ -0,0 +1,508 @@ +// 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.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNet.Mvc.HeaderValueAbstractions; +using Microsoft.AspNet.Mvc.ModelBinding; +using Microsoft.AspNet.Mvc.Routing; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Description +{ + public class DefaultApiDescriptionProviderTest + { + [Fact] + public void GetApiDescription_IgnoresNonReflectedActionDescriptor() + { + // Arrange + var action = new ActionDescriptor(); + action.SetProperty(new ApiDescriptionActionData()); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + Assert.Empty(descriptions); + } + + [Fact] + public void GetApiDescription_IgnoresActionWithoutApiExplorerData() + { + // Arrange + var action = new ReflectedActionDescriptor(); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + Assert.Empty(descriptions); + } + + [Fact] + public void GetApiDescription_PopulatesActionDescriptor() + { + // Arrange + var action = CreateActionDescriptor(); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Same(action, description.ActionDescriptor); + } + + [Fact] + public void GetApiDescription_PopulatesGroupName() + { + // Arrange + var action = CreateActionDescriptor(); + action.GetProperty().GroupName = "Customers"; + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal("Customers", description.GroupName); + } + + [Fact] + public void GetApiDescription_HttpMethodIsNullWithoutConstraint() + { + // Arrange + var action = CreateActionDescriptor(); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Null(description.HttpMethod); + } + + + [Fact] + public void GetApiDescription_CreatesMultipleDescriptionsForMultipleHttpMethods() + { + // Arrange + var action = CreateActionDescriptor(); + action.MethodConstraints = new List() + { + new HttpMethodConstraint(new string[] { "PUT", "POST" }), + new HttpMethodConstraint(new string[] { "GET" }), + }; + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + Assert.Equal(3, descriptions.Count); + + Assert.Single(descriptions, d => d.HttpMethod == "PUT"); + Assert.Single(descriptions, d => d.HttpMethod == "POST"); + 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", + IsOptional = true, + ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)), + }, + new ParameterDescriptor() + { + Name = "username", + BodyParameterInfo = new BodyParameterInfo(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.True(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); + } + + // This is a placeholder based on current functionality - see #885 + [Fact] + public void GetApiDescription_PopluatesRelativePath() + { + // Arrange + var action = CreateActionDescriptor(); + action.AttributeRouteInfo = new AttributeRouteInfo(); + action.AttributeRouteInfo.Template = "api/Products/{id}"; + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal("api/Products/{id}", description.RelativePath); + } + + [Fact] + public void GetApiDescription_PopluatesResponseType_WithProduct() + { + // Arrange + var action = CreateActionDescriptor(nameof(ReturnsProduct)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(typeof(Product), description.ResponseType); + Assert.NotNull(description.ResponseModelMetadata); + } + + [Fact] + public void GetApiDescription_PopluatesResponseType_WithTaskOfProduct() + { + // Arrange + var action = CreateActionDescriptor(nameof(ReturnsTaskOfProduct)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(typeof(Product), description.ResponseType); + Assert.NotNull(description.ResponseModelMetadata); + } + + [Theory] + [InlineData(nameof(ReturnsObject))] + [InlineData(nameof(ReturnsActionResult))] + [InlineData(nameof(ReturnsJsonResult))] + [InlineData(nameof(ReturnsTaskOfObject))] + [InlineData(nameof(ReturnsTaskOfActionResult))] + [InlineData(nameof(ReturnsTaskOfJsonResult))] + public void GetApiDescription_DoesNotPopluatesResponseInformation_WhenUnknown(string methodName) + { + // Arrange + var action = CreateActionDescriptor(methodName); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Null(description.ResponseType); + Assert.Null(description.ResponseModelMetadata); + Assert.Empty(description.SupportedResponseFormats); + } + + [Theory] + [InlineData(nameof(ReturnsVoid))] + [InlineData(nameof(ReturnsTask))] + public void GetApiDescription_DoesNotPopluatesResponseInformation_WhenVoid(string methodName) + { + // Arrange + var action = CreateActionDescriptor(methodName); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(typeof(void), description.ResponseType); + Assert.Null(description.ResponseModelMetadata); + Assert.Empty(description.SupportedResponseFormats); + } + + [Theory] + [InlineData(nameof(ReturnsObject))] + [InlineData(nameof(ReturnsVoid))] + [InlineData(nameof(ReturnsActionResult))] + [InlineData(nameof(ReturnsJsonResult))] + [InlineData(nameof(ReturnsTaskOfObject))] + [InlineData(nameof(ReturnsTask))] + [InlineData(nameof(ReturnsTaskOfActionResult))] + [InlineData(nameof(ReturnsTaskOfJsonResult))] + public void GetApiDescription_PopluatesResponseInformation_WhenSetByFilter(string methodName) + { + // Arrange + var action = CreateActionDescriptor(methodName); + var filter = new ContentTypeAttribute("text/*") + { + Type = typeof(Order) + }; + + action.FilterDescriptors = new List(); + action.FilterDescriptors.Add(new FilterDescriptor(filter, FilterScope.Action)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(typeof(Order), description.ResponseType); + Assert.NotNull(description.ResponseModelMetadata); + } + + [Fact] + public void GetApiDescription_IncludesResponseFormats() + { + // Arrange + var action = CreateActionDescriptor(nameof(ReturnsProduct)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(4, description.SupportedResponseFormats.Count); + + var formats = description.SupportedResponseFormats; + Assert.Single(formats, f => f.MediaType.RawValue == "text/json"); + Assert.Single(formats, f => f.MediaType.RawValue == "application/json"); + Assert.Single(formats, f => f.MediaType.RawValue == "text/xml"); + Assert.Single(formats, f => f.MediaType.RawValue == "application/xml"); + } + + [Fact] + public void GetApiDescription_IncludesResponseFormats_FilteredByAttribute() + { + // Arrange + var action = CreateActionDescriptor(nameof(ReturnsProduct)); + + action.FilterDescriptors = new List(); + action.FilterDescriptors.Add(new FilterDescriptor(new ContentTypeAttribute("text/*"), FilterScope.Action)); + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(2, description.SupportedResponseFormats.Count); + + var formats = description.SupportedResponseFormats; + Assert.Single(formats, f => f.MediaType.RawValue == "text/json"); + Assert.Single(formats, f => f.MediaType.RawValue == "text/xml"); + } + + [Fact] + public void GetApiDescription_IncludesResponseFormats_FilteredByType() + { + // Arrange + var action = CreateActionDescriptor(nameof(ReturnsObject)); + var filter = new ContentTypeAttribute("text/*") + { + Type = typeof(Order) + }; + + action.FilterDescriptors = new List(); + action.FilterDescriptors.Add(new FilterDescriptor(filter, FilterScope.Action)); + + var formatters = CreateFormatters(); + + // This will just format Order + formatters[0].SupportedTypes.Add(typeof(Order)); + + // This will just format Product + formatters[1].SupportedTypes.Add(typeof(Product)); + + // Act + var descriptions = GetApiDescriptions(action, formatters); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(1, description.SupportedResponseFormats.Count); + Assert.Equal(typeof(Order), description.ResponseType); + Assert.NotNull(description.ResponseModelMetadata); + + var formats = description.SupportedResponseFormats; + Assert.Single(formats, f => f.MediaType.RawValue == "text/json"); + Assert.Same(formatters[0], formats[0].Formatter); + } + + private IReadOnlyList GetApiDescriptions(ActionDescriptor action) + { + return GetApiDescriptions(action, CreateFormatters()); + } + + private IReadOnlyList GetApiDescriptions(ActionDescriptor action, List formatters) + { + var context = new ApiDescriptionProviderContext(new ActionDescriptor[] { action }); + + var formattersProvider = new Mock(MockBehavior.Strict); + formattersProvider.Setup(fp => fp.OutputFormatters).Returns(formatters); + + 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 provider = new DefaultApiDescriptionProvider(formattersProvider.Object, modelMetadataProvider.Object); + provider.Invoke(context, () => { }); + return context.Results; + } + + private List CreateFormatters() + { + // Include some default formatters that look reasonable, some tests will override this. + var formatters = new List() + { + new MockFormatter(), + new MockFormatter(), + }; + + formatters[0].SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json")); + formatters[0].SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/json")); + + formatters[1].SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/xml")); + formatters[1].SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/xml")); + + return formatters; + } + + private ReflectedActionDescriptor CreateActionDescriptor(string methodName = null) + { + var action = new ReflectedActionDescriptor(); + action.SetProperty(new ApiDescriptionActionData()); + + action.MethodInfo = GetType().GetMethod( + methodName ?? "ReturnsObject", + BindingFlags.Instance | BindingFlags.NonPublic); + + + return action; + } + + private object ReturnsObject() + { + return null; + } + + private void ReturnsVoid() + { + + } + + private IActionResult ReturnsActionResult() + { + return null; + } + + private JsonResult ReturnsJsonResult() + { + return null; + } + + private Task ReturnsTaskOfProduct() + { + return null; + } + + private Task ReturnsTaskOfObject() + { + return null; + } + + private Task ReturnsTask() + { + return null; + } + + private Task ReturnsTaskOfActionResult() + { + return null; + } + + private Task ReturnsTaskOfJsonResult() + { + return null; + } + + private Product ReturnsProduct() + { + return null; + } + + private class Product + { + } + + private class Order + { + } + + private class MockFormatter : OutputFormatter + { + public List SupportedTypes { get; } = new List(); + + public override Task WriteResponseBodyAsync(OutputFormatterContext context) + { + throw new NotImplementedException(); + } + + protected override bool CanWriteType(Type declaredType, Type actualType) + { + if (SupportedTypes.Count == 0) + { + return true; + } + else if ((actualType ?? declaredType) == null) + { + return false; + } + else + { + return SupportedTypes.Contains(actualType ?? declaredType); + } + } + } + + private class ContentTypeAttribute : Attribute, IFilter, IApiResponseMetadataProvider + { + public ContentTypeAttribute(string mediaType) + { + ContentTypes.Add(MediaTypeHeaderValue.Parse(mediaType)); + } + + public List ContentTypes { get; } = new List(); + + public Type Type { get; set; } + + public void SetContentTypes(IList contentTypes) + { + contentTypes.Clear(); + foreach (var contentType in ContentTypes) + { + contentTypes.Add(contentType); + } + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedActionDescriptorProviderTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedActionDescriptorProviderTests.cs index 7da7bd1375..7920ddb4ca 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedActionDescriptorProviderTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedActionDescriptorProviderTests.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using Microsoft.AspNet.Routing; +using Microsoft.AspNet.Mvc.Description; using Microsoft.AspNet.Mvc.Routing; using Moq; using Xunit; @@ -1016,6 +1017,119 @@ namespace Microsoft.AspNet.Mvc.Test Assert.Equal("stub/{controller}/{action}", action.AttributeRouteInfo.Template); } + [Fact] + public void ApiExplorer_SetsExtensionData_WhenVisible() + { + // Arrange + var provider = GetProvider(typeof(ApiExplorerVisibleController).GetTypeInfo()); + + // Act + var actions = provider.GetDescriptors(); + + // Assert + var action = Assert.Single(actions); + Assert.NotNull(action.GetProperty()); + } + + [Fact] + public void ApiExplorer_SetsExtensionData_WhenVisible_CanOverrideControllerOnAction() + { + // Arrange + var provider = GetProvider(typeof(ApiExplorerVisibilityOverrideController).GetTypeInfo()); + + // Act + var actions = provider.GetDescriptors(); + + // Assert + Assert.Equal(2, actions.Count()); + + var action = Assert.Single(actions, a => a.Name == "Edit"); + Assert.NotNull(action.GetProperty()); + + action = Assert.Single(actions, a => a.Name == "Create"); + Assert.Null(action.GetProperty()); + } + + [Theory] + [InlineData(typeof(ApiExplorerNotVisibleController))] + [InlineData(typeof(ApiExplorerExplicitlyNotVisibleController))] + [InlineData(typeof(ApiExplorerExplicitlyNotVisibleOnActionController))] + public void ApiExplorer_DoesNotSetExtensionData_WhenNotVisible(Type controllerType) + { + // Arrange + var provider = GetProvider(controllerType.GetTypeInfo()); + + // Act + var actions = provider.GetDescriptors(); + + // Assert + var action = Assert.Single(actions); + Assert.Null(action.GetProperty()); + } + + [Fact] + public void ApiExplorer_SetsName_DefaultToNull() + { + // Arrange + var provider = GetProvider(typeof(ApiExplorerNoNameController).GetTypeInfo()); + + // Act + var actions = provider.GetDescriptors(); + + // Assert + + var action = Assert.Single(actions); + Assert.Null(action.GetProperty().GroupName); + } + + [Fact] + public void ApiExplorer_SetsName_OnController() + { + // Arrange + var provider = GetProvider(typeof(ApiExplorerNameOnControllerController).GetTypeInfo()); + + // Act + var actions = provider.GetDescriptors(); + + // Assert + + var action = Assert.Single(actions); + Assert.Equal("Store", action.GetProperty().GroupName); + } + + [Fact] + public void ApiExplorer_SetsName_OnAction() + { + // Arrange + var provider = GetProvider(typeof(ApiExplorerNameOnActionController).GetTypeInfo()); + + // Act + var actions = provider.GetDescriptors(); + + // Assert + var action = Assert.Single(actions); + Assert.Equal("Blog", action.GetProperty().GroupName); + } + + [Fact] + public void ApiExplorer_SetsName_CanOverrideControllerOnAction() + { + // Arrange + var provider = GetProvider(typeof(ApiExplorerNameOverrideController).GetTypeInfo()); + + // Act + var actions = provider.GetDescriptors(); + + // Assert + Assert.Equal(2, actions.Count()); + + var action = Assert.Single(actions, a => a.Name == "Edit"); + Assert.Equal("Blog", action.GetProperty().GroupName); + + action = Assert.Single(actions, a => a.Name == "Create"); + Assert.Equal("Store", action.GetProperty().GroupName); + } + private ReflectedActionDescriptorProvider GetProvider( TypeInfo controllerTypeInfo, IEnumerable filters = null) @@ -1404,5 +1518,65 @@ namespace Microsoft.AspNet.Mvc.Test public int Id { get; set; } public int Name { get; set; } } + + private class ApiExplorerNotVisibleController + { + public void Edit() { } + } + + [ApiExplorerSettings()] + private class ApiExplorerVisibleController + { + public void Edit() { } + } + + [ApiExplorerSettings(IgnoreApi = true)] + private class ApiExplorerExplicitlyNotVisibleController + { + public void Edit() { } + } + + private class ApiExplorerExplicitlyNotVisibleOnActionController + { + [ApiExplorerSettings(IgnoreApi = true)] + public void Edit() { } + } + + [ApiExplorerSettings(IgnoreApi = true)] + private class ApiExplorerVisibilityOverrideController + { + [ApiExplorerSettings(IgnoreApi = false)] + public void Edit() { } + + public void Create() { } + } + + [ApiExplorerSettings(GroupName = "Store")] + private class ApiExplorerNameOnControllerController + { + public void Edit() { } + } + + + private class ApiExplorerNameOnActionController + { + [ApiExplorerSettings(GroupName = "Blog")] + public void Edit() { } + } + + [ApiExplorerSettings()] + private class ApiExplorerNoNameController + { + public void Edit() { } + } + + [ApiExplorerSettings(GroupName = "Store")] + private class ApiExplorerNameOverrideController + { + [ApiExplorerSettings(GroupName = "Blog")] + public void Edit() { } + + public void Create() { } + } } } diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedModelBuilder/ReflectedActionModelTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedModelBuilder/ReflectedActionModelTests.cs index 5c51d5da5b..d6986e44d6 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedModelBuilder/ReflectedActionModelTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedModelBuilder/ReflectedActionModelTests.cs @@ -38,6 +38,34 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder.Test Assert.IsType(model.Filters[0]); } + [Fact] + public void ReflectedActionModel_PopulatesApiExplorerInfo() + { + // Arrange + var actionMethod = typeof(BlogController).GetMethod("Create"); + + // Act + var model = new ReflectedActionModel(actionMethod); + + // Assert + Assert.Equal(false, model.ApiExplorerIsVisible); + Assert.Equal("Blog", model.ApiExplorerGroupName); + } + + [Fact] + public void ReflectedActionModel_PopulatesApiExplorerInfo_NoAttribute() + { + // Arrange + var actionMethod = typeof(BlogController).GetMethod("Edit"); + + // Act + var model = new ReflectedActionModel(actionMethod); + + // Assert + Assert.Null(model.ApiExplorerIsVisible); + Assert.Null(model.ApiExplorerGroupName); + } + private class BlogController { [MyOther] @@ -46,6 +74,12 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder.Test public void Edit() { } + + [ApiExplorerSettings(IgnoreApi = true, GroupName = "Blog")] + public void Create() + { + + } } private class MyFilterAttribute : Attribute, IFilter @@ -56,4 +90,4 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder.Test { } } -} \ No newline at end of file +} diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedModelBuilder/ReflectedControllerModelTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedModelBuilder/ReflectedControllerModelTests.cs index 128985d8e5..a8e5bbd7ac 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedModelBuilder/ReflectedControllerModelTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedModelBuilder/ReflectedControllerModelTests.cs @@ -20,11 +20,12 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder.Test var model = new ReflectedControllerModel(controllerType.GetTypeInfo()); // Assert - Assert.Equal(5, model.Attributes.Count); + Assert.Equal(6, model.Attributes.Count); Assert.Single(model.Attributes, a => a is MyOtherAttribute); Assert.Single(model.Attributes, a => a is MyFilterAttribute); Assert.Single(model.Attributes, a => a is MyRouteConstraintAttribute); + Assert.Single(model.Attributes, a => a is ApiExplorerSettingsAttribute); var routes = model.Attributes.OfType().ToList(); Assert.Equal(2, routes.Count()); @@ -102,11 +103,54 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder.Test Assert.Single(model.AttributeRoutes, r => r.Template.Equals("Microblog")); } + [Fact] + public void ReflectedControllerModel_PopulatesApiExplorerInfo() + { + // Arrange + var controllerType = typeof(BlogController); + + // Act + var model = new ReflectedControllerModel(controllerType.GetTypeInfo()); + + // Assert + Assert.Equal(true, model.ApiExplorerIsVisible); + Assert.Equal("Blog", model.ApiExplorerGroupName); + } + + [Fact] + public void ReflectedControllerModel_PopulatesApiExplorerInfo_Inherited() + { + // Arrange + var controllerType = typeof(DerivedController); + + // Act + var model = new ReflectedControllerModel(controllerType.GetTypeInfo()); + + // Assert + Assert.Equal(true, model.ApiExplorerIsVisible); + Assert.Equal("API", model.ApiExplorerGroupName); + } + + [Fact] + public void ReflectedControllerModel_PopulatesApiExplorerInfo_NoAttribute() + { + // Arrange + var controllerType = typeof(Store); + + // Act + var model = new ReflectedControllerModel(controllerType.GetTypeInfo()); + + // Assert + Assert.Null(model.ApiExplorerIsVisible); + Assert.Null(model.ApiExplorerGroupName); + } + [MyOther] [MyFilter] [MyRouteConstraint] [Route("Blog")] [Route("Microblog")] + [ApiExplorerSettings(GroupName = "Blog")] private class BlogController { } @@ -115,6 +159,16 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder.Test { } + private class DerivedController : BaseController + { + } + + [ApiExplorerSettings(GroupName = "API")] + private class BaseController + { + + } + private class MyRouteConstraintAttribute : RouteConstraintAttribute { public MyRouteConstraintAttribute() @@ -131,4 +185,4 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder.Test { } } -} \ No newline at end of file +} diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/ApiExplorerTest.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/ApiExplorerTest.cs new file mode 100644 index 0000000000..cf40035607 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/ApiExplorerTest.cs @@ -0,0 +1,513 @@ +// 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.Threading.Tasks; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.TestHost; +using Xunit; +using Newtonsoft.Json; +using System.Net.Http; + +namespace Microsoft.AspNet.Mvc.FunctionalTests +{ + public class ApiExplorerTest + { + private readonly IServiceProvider _provider = TestHelper.CreateServices("ApiExplorerWebSite"); + private readonly Action _app = new ApiExplorer.Startup().Configure; + + [Fact] + public async Task ApiExplorer_IsVisible_EnabledWithConvention() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerVisbilityEnabledByConvention"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + Assert.Single(result); + } + + [Fact] + public async Task ApiExplorer_IsVisible_DisabledWithConvention() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerVisbilityDisabledByConvention"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task ApiExplorer_IsVisible_DisabledWithAttribute() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerVisibilitySetExplicitly/Disabled"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task ApiExplorer_IsVisible_EnabledWithAttribute() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerVisibilitySetExplicitly/Enabled"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + Assert.Single(result); + } + + [Fact] + public async Task ApiExplorer_GroupName_SetByConvention() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerNameSetByConvention"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + Assert.Equal(description.GroupName, "ApiExplorerNameSetByConvention"); + } + + [Fact] + public async Task ApiExplorer_GroupName_SetByAttributeOnController() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerNameSetExplicitly/SetOnController"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + Assert.Equal(description.GroupName, "SetOnController"); + } + + [Fact] + public async Task ApiExplorer_GroupName_SetByAttributeOnAction() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerNameSetExplicitly/SetOnAction"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + Assert.Equal(description.GroupName, "SetOnAction"); + } + + [Fact] + public async Task ApiExplorer_HttpMethod_All() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerHttpMethod/All"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + Assert.Null(description.HttpMethod); + } + + [Fact] + public async Task ApiExplorer_HttpMethod_Single() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerHttpMethod/Get"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + Assert.Equal("GET", description.HttpMethod); + } + + // This is hitting one action with two allowed methods (using [AcceptVerbs]). This should + // return two api descriptions. + [Theory] + [InlineData("PUT")] + [InlineData("POST")] + public async Task ApiExplorer_HttpMethod_Single(string httpMethod) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var request = new HttpRequestMessage( + new HttpMethod(httpMethod), + "http://localhost/ApiExplorerHttpMethod/Single"); + + // Act + var response = await client.SendAsync(request); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + Assert.Equal(2, result.Count); + + Assert.Single(result, d => d.HttpMethod == "PUT"); + Assert.Single(result, d => d.HttpMethod == "POST"); + } + + [Theory] + [InlineData("GetVoid")] + [InlineData("GetTask")] + public async Task ApiExplorer_ResponseType_VoidWithoutAttribute(string action) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync( + "http://localhost/ApiExplorerResponseTypeWithoutAttribute/" + action); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + Assert.Equal(typeof(void).FullName, description.ResponseType); + } + + [Theory] + [InlineData("GetObject")] + [InlineData("GetIActionResult")] + [InlineData("GetDerivedActionResult")] + [InlineData("GetTaskOfObject")] + [InlineData("GetTaskOfIActionResult")] + [InlineData("GetTaskOfDerivedActionResult")] + public async Task ApiExplorer_ResponseType_UnknownWithoutAttribute(string action) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync( + "http://localhost/ApiExplorerResponseTypeWithoutAttribute/" + action); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + Assert.Null(description.ResponseType); + } + + [Theory] + [InlineData("GetProduct", "ApiExplorer.Product")] + [InlineData("GetInt", "System.Int32")] + [InlineData("GetTaskOfProduct", "ApiExplorer.Product")] + [InlineData("GetTaskOfInt", "System.Int32")] + public async Task ApiExplorer_ResponseType_KnownWithoutAttribute(string action, string type) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync( + "http://localhost/ApiExplorerResponseTypeWithoutAttribute/" + action); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + Assert.Equal(type, description.ResponseType); + } + + [Theory] + [InlineData("GetVoid", "ApiExplorer.Customer")] + [InlineData("GetObject", "ApiExplorer.Product")] + [InlineData("GetIActionResult", "System.String")] + [InlineData("GetProduct", "ApiExplorer.Customer")] + [InlineData("GetTask", "System.Int32")] + public async Task ApiExplorer_ResponseType_KnownWithAttribute(string action, string type) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync( + "http://localhost/ApiExplorerResponseTypeWithAttribute/" + action); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + Assert.Equal(type, description.ResponseType); + } + + [Theory] + [InlineData("Controller", "ApiExplorer.Product")] + [InlineData("Action", "ApiExplorer.Customer")] + public async Task ApiExplorer_ResponseType_OverrideOnAction(string action, string type) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync( + "http://localhost/ApiExplorerResponseTypeOverrideOnAction/" + action); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + Assert.Equal(type, description.ResponseType); + } + + [Fact] + public async Task ApiExplorer_ResponseContentType_Unset() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerResponseContentType/Unset"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + + var formats = description.SupportedResponseFormats; + Assert.Equal(4, formats.Count); + + var textXml = Assert.Single(formats, f => f.MediaType == "text/xml"); + Assert.Equal(typeof(XmlDataContractSerializerOutputFormatter).FullName, textXml.FormatterType); + var applicationXml = Assert.Single(formats, f => f.MediaType == "application/xml"); + Assert.Equal(typeof(XmlDataContractSerializerOutputFormatter).FullName, applicationXml.FormatterType); + + var textJson = Assert.Single(formats, f => f.MediaType == "text/json"); + Assert.Equal(typeof(JsonOutputFormatter).FullName, textJson.FormatterType); + var applicationJson = Assert.Single(formats, f => f.MediaType == "application/json"); + Assert.Equal(typeof(JsonOutputFormatter).FullName, applicationJson.FormatterType); + } + + // uses [Produces("*/*")] + [Fact] + public async Task ApiExplorer_ResponseContentType_AllTypes() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerResponseContentType/AllTypes"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + + var formats = description.SupportedResponseFormats; + Assert.Equal(4, formats.Count); + + var textXml = Assert.Single(formats, f => f.MediaType == "text/xml"); + Assert.Equal(typeof(XmlDataContractSerializerOutputFormatter).FullName, textXml.FormatterType); + var applicationXml = Assert.Single(formats, f => f.MediaType == "application/xml"); + Assert.Equal(typeof(XmlDataContractSerializerOutputFormatter).FullName, applicationXml.FormatterType); + + var textJson = Assert.Single(formats, f => f.MediaType == "text/json"); + Assert.Equal(typeof(JsonOutputFormatter).FullName, textJson.FormatterType); + var applicationJson = Assert.Single(formats, f => f.MediaType == "application/json"); + Assert.Equal(typeof(JsonOutputFormatter).FullName, applicationJson.FormatterType); + } + + [Fact] + public async Task ApiExplorer_ResponseContentType_Range() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerResponseContentType/Range"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + + var formats = description.SupportedResponseFormats; + Assert.Equal(2, formats.Count); + + var textXml = Assert.Single(formats, f => f.MediaType == "text/xml"); + Assert.Equal(typeof(XmlDataContractSerializerOutputFormatter).FullName, textXml.FormatterType); + + var textJson = Assert.Single(formats, f => f.MediaType == "text/json"); + Assert.Equal(typeof(JsonOutputFormatter).FullName, textJson.FormatterType); + } + + [Fact] + public async Task ApiExplorer_ResponseContentType_Specific() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerResponseContentType/Specific"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + + var formats = description.SupportedResponseFormats; + Assert.Equal(1, formats.Count); + + var applicationJson = Assert.Single(formats, f => f.MediaType == "application/json"); + Assert.Equal(typeof(JsonOutputFormatter).FullName, applicationJson.FormatterType); + } + + [Fact] + public async Task ApiExplorer_ResponseContentType_NoMatch() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/ApiExplorerResponseContentType/NoMatch"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + + var formats = description.SupportedResponseFormats; + Assert.Empty(formats); + } + + [Theory] + [InlineData("Controller", "text/xml", "Microsoft.AspNet.Mvc.XmlDataContractSerializerOutputFormatter")] + [InlineData("Action", "application/json", "Microsoft.AspNet.Mvc.JsonOutputFormatter")] + public async Task ApiExplorer_ResponseContentType_OverrideOnAction( + string action, + string contentType, + string formatterType) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync( + "http://localhost/ApiExplorerResponseContentTypeOverrideOnAction/" + action); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + + var format = Assert.Single(description.SupportedResponseFormats); + Assert.Equal(contentType, format.MediaType); + Assert.Equal(formatterType, format.FormatterType); + } + + // Used to serialize data between client and server + private class ApiExplorerData + { + public string GroupName { get; set; } + + public string HttpMethod { get; set; } + + public List ParameterDescriptions { get; } = new List(); + + public string RelativePath { get; set; } + + public string ResponseType { get; set; } + + public List SupportedResponseFormats { get; } = new List(); + } + + // Used to serialize data between client and server + private class ApiExplorerParameterData + { + public bool IsOptional { get; set; } + + public string Name { get; set; } + + public string Source { get; set; } + + public string Type { get; set; } + } + + // Used to serialize data between client and server + private class ApiExplorerResponseData + { + public string MediaType { get; set; } + + public string FormatterType { get; set; } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/project.json b/test/Microsoft.AspNet.Mvc.FunctionalTests/project.json index cbd297cd35..f17e0ea94a 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/project.json +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/project.json @@ -6,6 +6,7 @@ "ActivatorWebSite": "", "AddServicesWebSite": "", "AntiForgeryWebSite": "", + "ApiExplorerWebSite": "", "BasicWebSite": "", "CompositeViewEngine": "", "ConnegWebsite": "", diff --git a/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs b/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs new file mode 100644 index 0000000000..49e28d566f --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs @@ -0,0 +1,114 @@ +// 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.Mvc; +using Microsoft.AspNet.Mvc.Description; + +namespace ApiExplorer +{ + /// + /// An action filter that looks up and serializes Api Explorer data for the action. + /// + /// This replaces the 'actual' output of the action. + /// + public class ApiExplorerDataFilter : ActionFilterAttribute + { + private readonly IApiDescriptionGroupCollectionProvider _descriptionProvider; + + public ApiExplorerDataFilter(IApiDescriptionGroupCollectionProvider descriptionProvider) + { + _descriptionProvider = descriptionProvider; + } + + public override void OnActionExecuted(ActionExecutedContext context) + { + var descriptions = new List(); + foreach (var group in _descriptionProvider.ApiDescriptionGroups.Items) + { + foreach (var description in group.Items) + { + if (context.ActionDescriptor == description.ActionDescriptor) + { + descriptions.Add(CreateSerializableData(description)); + } + } + } + + context.Result = new JsonResult(descriptions); + } + + private ApiExplorerData CreateSerializableData(ApiDescription description) + { + var data = new ApiExplorerData() + { + GroupName = description.GroupName, + HttpMethod = description.HttpMethod, + RelativePath = description.RelativePath, + ResponseType = description.ResponseType?.FullName, + }; + + foreach (var parameter in description.ParameterDescriptions) + { + var parameterData = new ApiExplorerParameterData() + { + IsOptional = parameter.IsOptional, + Name = parameter.Name, + Source = parameter.Source.ToString(), + Type = parameter.Type.FullName, + }; + + data.ParameterDescriptions.Add(parameterData); + } + + foreach (var response in description.SupportedResponseFormats) + { + var responseData = new ApiExplorerResponseData() + { + FormatterType = response.Formatter.GetType().FullName, + MediaType = response.MediaType.RawValue, + }; + + data.SupportedResponseFormats.Add(responseData); + } + + return data; + } + + // Used to serialize data between client and server + private class ApiExplorerData + { + public string GroupName { get; set; } + + public string HttpMethod { get; set; } + + public List ParameterDescriptions { get; } = new List(); + + public string RelativePath { get; set; } + + public string ResponseType { get; set; } + + public List SupportedResponseFormats { get; } = new List(); + } + + // Used to serialize data between client and server + private class ApiExplorerParameterData + { + public bool IsOptional { get; set; } + + public string Name { get; set; } + + public string Source { get; set; } + + public string Type { get; set; } + } + + // Used to serialize data between client and server + private class ApiExplorerResponseData + { + public string MediaType { get; set; } + + public string FormatterType { get; set; } + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/ApiExplorerVisibilityDisabledConvention.cs b/test/WebSites/ApiExplorerWebSite/ApiExplorerVisibilityDisabledConvention.cs new file mode 100644 index 0000000000..97ca9edb91 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/ApiExplorerVisibilityDisabledConvention.cs @@ -0,0 +1,33 @@ +// 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.Reflection; +using Microsoft.AspNet.Mvc.ReflectedModelBuilder; + +namespace ApiExplorer +{ + // Disables ApiExplorer for a specific controller type. + // This is part of the test that validates that ApiExplorer can be configured via + // convention + public class ApiExplorerVisibilityDisabledConvention : IReflectedApplicationModelConvention + { + private readonly TypeInfo _type; + + public ApiExplorerVisibilityDisabledConvention(Type type) + { + _type = type.GetTypeInfo(); + } + + public void OnModelCreated(ReflectedApplicationModel model) + { + foreach (var controller in model.Controllers) + { + if (controller.ControllerType == _type) + { + controller.ApiExplorerIsVisible = false; + } + } + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/ApiExplorerVisibilityEnabledConvention.cs b/test/WebSites/ApiExplorerWebSite/ApiExplorerVisibilityEnabledConvention.cs new file mode 100644 index 0000000000..cd01caf9ee --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/ApiExplorerVisibilityEnabledConvention.cs @@ -0,0 +1,25 @@ +// 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.ReflectedModelBuilder; + +namespace ApiExplorer +{ + // Enables ApiExplorer for controllers that haven't explicitly configured it. + // This is part of the test that validates that ApiExplorer can be configured via + // convention + public class ApiExplorerVisibilityEnabledConvention : IReflectedApplicationModelConvention + { + public void OnModelCreated(ReflectedApplicationModel model) + { + foreach (var controller in model.Controllers) + { + if (controller.ApiExplorerIsVisible == null) + { + controller.ApiExplorerIsVisible = true; + controller.ApiExplorerGroupName = controller.ControllerName; + } + } + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/ApiExplorerWebSite.kproj b/test/WebSites/ApiExplorerWebSite/ApiExplorerWebSite.kproj new file mode 100644 index 0000000000..7c5e9fce2e --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/ApiExplorerWebSite.kproj @@ -0,0 +1,24 @@ + + + + 12.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + 61061528-071e-424e-965a-07bcc2f02672 + Web + ApiExplorer + + + ConsoleDebugger + + + WebDebugger + + + 2.0 + 7591 + + + \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerHttpMethodController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerHttpMethodController.cs new file mode 100644 index 0000000000..9f3230e8fb --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerHttpMethodController.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNet.Mvc; +using System; + +namespace ApiExplorer +{ + [Route("ApiExplorerHttpMethod")] + public class ApiExplorerHttpMethodController : Controller + { + [Route("All")] + public void All() + { + } + + [HttpGet("Get")] + public void Get() + { + } + + [AcceptVerbs("PUT", "POST", Route = "Single")] + public void PutOrPost() + { + } + + [HttpGet("MultipleActions")] + [HttpPut("MultipleActions")] + public void MultipleActions() + { + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerNameSetByConventionController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerNameSetByConventionController.cs new file mode 100644 index 0000000000..d5aeda810a --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerNameSetByConventionController.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 ApiExplorer +{ + [Route("ApiExplorerNameSetByConvention")] + public class ApiExplorerNameSetByConventionController : Controller + { + [HttpGet] + public void Get() + { + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerNameSetExplicitlyController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerNameSetExplicitlyController.cs new file mode 100644 index 0000000000..a6e49ad5d9 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerNameSetExplicitlyController.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 ApiExplorer +{ + [ApiExplorerSettings(GroupName = "SetOnController")] + [Route("ApiExplorerNameSetExplicitly")] + public class ApiExplorerNameSetExplicitlyController : Controller + { + [HttpGet("SetOnController")] + public void SetOnController() + { + } + + [ApiExplorerSettings(GroupName = "SetOnAction")] + [HttpGet("SetOnAction")] + public void SetOnAction() + { + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseContentTypeController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseContentTypeController.cs new file mode 100644 index 0000000000..60c36bbdd6 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseContentTypeController.cs @@ -0,0 +1,46 @@ +// 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.Threading.Tasks; + +namespace ApiExplorer +{ + [Route("ApiExplorerResponseContentType/[Action]")] + public class ApiExplorerResponseContentTypeController : Controller + { + [HttpGet] + public Product Unset() + { + return null; + } + + [HttpGet] + [Produces("*/*")] + public Product AllTypes() + { + return null; + } + + [HttpGet] + [Produces("text/*")] + public Product Range() + { + return null; + } + + [HttpGet] + [Produces("application/json")] + public Product Specific() + { + return null; + } + + [HttpGet] + [Produces("application/hal+json")] + public Product NoMatch() + { + return null; + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseContentTypeOverrideOnActionController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseContentTypeOverrideOnActionController.cs new file mode 100644 index 0000000000..d86b429cd6 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseContentTypeOverrideOnActionController.cs @@ -0,0 +1,26 @@ +// 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.Threading.Tasks; + +namespace ApiExplorer +{ + [Produces("text/xml")] + [Route("ApiExplorerResponseContentTypeOverrideOnAction")] + public class ApiExplorerResponseContentTypeOverrideOnActionController : Controller + { + [HttpGet("Controller")] + public Product GetController() + { + return null; + } + + [HttpGet("Action")] + [Produces("application/json")] + public Product GetAction() + { + return null; + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs new file mode 100644 index 0000000000..47bc73506f --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs @@ -0,0 +1,25 @@ +// 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.Threading.Tasks; + +namespace ApiExplorer +{ + [Produces("*/*", Type = typeof(Product))] + [Route("ApiExplorerResponseTypeOverrideOnAction")] + public class ApiExplorerResponseTypeOverrideOnActionController : Controller + { + [HttpGet("Controller")] + public void GetController() + { + } + + [HttpGet("Action")] + [ProducesType(typeof(Customer))] + public object GetAction() + { + return null; + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithAttributeController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithAttributeController.cs new file mode 100644 index 0000000000..24a54a0942 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithAttributeController.cs @@ -0,0 +1,46 @@ +// 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.Threading.Tasks; + +namespace ApiExplorer +{ + [Route("ApiExplorerResponseTypeWithAttribute/[Action]")] + public class ApiExplorerResponseTypeWithAttributeController : Controller + { + [HttpGet] + [ProducesType(typeof(Customer))] + public void GetVoid() + { + } + + [HttpGet] + [Produces("*/*", Type = typeof(Product))] + public object GetObject() + { + return null; + } + + [HttpGet] + [Produces("application/json", Type = typeof(string))] + public IActionResult GetIActionResult() + { + return new EmptyResult(); + } + + [HttpGet] + [Produces("application/json", Type = typeof(int))] + public Task GetTask() + { + return null; + } + + [HttpGet] + [ProducesType(typeof(Customer))] // It's possible to lie about what type you return + public Product GetProduct() + { + return null; + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithoutAttributeController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithoutAttributeController.cs new file mode 100644 index 0000000000..fc7149f033 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithoutAttributeController.cs @@ -0,0 +1,83 @@ +// 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.Threading.Tasks; + +namespace ApiExplorer +{ + [Route("ApiExplorerResponseTypeWithoutAttribute/[Action]")] + public class ApiExplorerResponseTypeWithoutAttributeController : Controller + { + [HttpGet] + public void GetVoid() + { + } + + [HttpGet] + public object GetObject() + { + return null; + } + + [HttpGet] + public IActionResult GetIActionResult() + { + return new EmptyResult(); + } + + [HttpGet] + public ObjectResult GetDerivedActionResult() + { + return new ObjectResult(null); + } + + [HttpGet] + public Product GetProduct() + { + return null; + } + + [HttpGet] + public int GetInt() + { + return 0; + } + + [HttpGet] + public Task GetTask() + { + return Task.FromResult(null); + } + + [HttpGet] + public Task GetTaskOfObject() + { + return Task.FromResult(null); + } + + [HttpGet] + public Task GetTaskOfIActionResult() + { + return Task.FromResult(new EmptyResult()); + } + + [HttpGet] + public Task GetTaskOfDerivedActionResult() + { + return Task.FromResult(new ObjectResult(null)); + } + + [HttpGet] + public Task GetTaskOfProduct() + { + return Task.FromResult(null); + } + + [HttpGet] + public Task GetTaskOfInt() + { + return Task.FromResult(0); + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityDisabledByConventionController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityDisabledByConventionController.cs new file mode 100644 index 0000000000..43c677b106 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityDisabledByConventionController.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 ApiExplorer +{ + [Route("ApiExplorerVisbilityDisabledByConvention")] + public class ApiExplorerVisbilityDisabledByConventionController : Controller + { + [HttpGet] + public void Get() + { + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityEnabledByConventionController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityEnabledByConventionController.cs new file mode 100644 index 0000000000..bf9c9f695f --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilityEnabledByConventionController.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 ApiExplorer +{ + [Route("ApiExplorerVisbilityEnabledByConvention")] + public class ApiExplorerVisbilityEnabledByConventionController : Controller + { + [HttpGet] + public void Get() + { + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilitySetExplicitlyController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilitySetExplicitlyController.cs new file mode 100644 index 0000000000..bf6d6b9b8c --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerVisibilitySetExplicitlyController.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 ApiExplorer +{ + [ApiExplorerSettings(IgnoreApi = true)] + [Route("ApiExplorerVisibilitySetExplicitly")] + public class ApiExplorerVisibilitySetExplicitlyController : Controller + { + [ApiExplorerSettings(IgnoreApi = false)] + [HttpGet("Enabled")] + public void Enabled() + { + } + + [HttpGet("Disabled")] + public void Disabled() + { + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Models/Customer.cs b/test/WebSites/ApiExplorerWebSite/Models/Customer.cs new file mode 100644 index 0000000000..161b1d8039 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Models/Customer.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 ApiExplorer +{ + public class Customer + { + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Models/Product.cs b/test/WebSites/ApiExplorerWebSite/Models/Product.cs new file mode 100644 index 0000000000..af1d536275 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Models/Product.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 ApiExplorer +{ + public class Product + { + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/ProducesTypeAttribute.cs b/test/WebSites/ApiExplorerWebSite/ProducesTypeAttribute.cs new file mode 100644 index 0000000000..fea0b4f31c --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/ProducesTypeAttribute.cs @@ -0,0 +1,25 @@ +// 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 Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Mvc.Description; +using Microsoft.AspNet.Mvc.HeaderValueAbstractions; + +namespace ApiExplorer +{ + public class ProducesTypeAttribute : ResultFilterAttribute, IApiResponseMetadataProvider + { + public ProducesTypeAttribute(Type type) + { + Type = type; + } + + public Type Type { get; private set; } + + public void SetContentTypes(IList contentTypes) + { + } + } +} \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Startup.cs b/test/WebSites/ApiExplorerWebSite/Startup.cs new file mode 100644 index 0000000000..f2d68f8531 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Startup.cs @@ -0,0 +1,44 @@ +// 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.Builder; +using Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Routing; +using Microsoft.Framework.DependencyInjection; + +namespace ApiExplorer +{ + public class Startup + { + public void Configure(IApplicationBuilder app) + { + var configuration = app.GetTestConfiguration(); + + app.UseServices(services => + { + services.AddMvc(configuration); + services.AddSingleton(); + + services.SetupOptions(options => + { + options.Filters.AddService(typeof(ApiExplorerDataFilter)); + + options.ApplicationModelConventions.Add(new ApiExplorerVisibilityEnabledConvention()); + options.ApplicationModelConventions.Add(new ApiExplorerVisibilityDisabledConvention( + typeof(ApiExplorerVisbilityDisabledByConventionController))); + + options.OutputFormatters.Clear(); + options.OutputFormatters.Add(new JsonOutputFormatter( + JsonOutputFormatter.CreateDefaultSettings(), + indent: false)); + options.OutputFormatters.Add(new XmlDataContractSerializerOutputFormatter()); + }); + }); + + app.UseMvc(routes => + { + routes.MapRoute("default", "{controller}/{action}"); + }); + } + } +} diff --git a/test/WebSites/ApiExplorerWebSite/project.json b/test/WebSites/ApiExplorerWebSite/project.json new file mode 100644 index 0000000000..719fb15327 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/project.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "Microsoft.AspNet.Mvc": "", + "Microsoft.AspNet.Server.IIS": "1.0.0-*", + "Microsoft.AspNet.Mvc.TestConfiguration": "" + }, + "frameworks": { + "aspnet50": { }, + "aspnetcore50": { } + } +} \ No newline at end of file