Adding Support for consumes.
Consumes has overriding behavior and the one closest to action wins.
This commit is contained in:
parent
dfb02e58f8
commit
60fa4a6f45
17
Mvc.sln
17
Mvc.sln
|
|
@ -1,7 +1,7 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio 14
|
||||
VisualStudioVersion = 14.0.22416.0
|
||||
VisualStudioVersion = 14.0.22303.1
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{DAAE4C74-D06F-4874-A166-33305D2643CE}"
|
||||
EndProject
|
||||
|
|
@ -116,6 +116,8 @@ Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "LoggingWebSite", "test\WebS
|
|||
EndProject
|
||||
Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "ErrorPageMiddlewareWebSite", "test\WebSites\ErrorPageMiddlewareWebSite\ErrorPageMiddlewareWebSite.kproj", "{AD545A5B-2BA5-4314-88AC-FC2ACF2CC718}"
|
||||
EndProject
|
||||
Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "ActionConstraintsWebSite", "test\WebSites\ActionConstraintsWebSite\ActionConstraintsWebSite.kproj", "{AF210F69-9D31-43AF-AC3A-CD366E252218}"
|
||||
EndProject
|
||||
Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "CustomRouteWebSite", "test\WebSites\CustomRouteWebSite\CustomRouteWebSite.kproj", "{364EC3C6-C9DB-45E0-A0F2-1EE61E4B429B}"
|
||||
EndProject
|
||||
Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "ResponseCacheWebSite", "test\WebSites\ResponseCacheWebSite\ResponseCacheWebSite.kproj", "{BDEEBE09-C0C4-433C-B0B8-8478C9776996}"
|
||||
|
|
@ -644,6 +646,18 @@ Global
|
|||
{AD545A5B-2BA5-4314-88AC-FC2ACF2CC718}.Release|Mixed Platforms.Build.0 = Release|Any CPU
|
||||
{AD545A5B-2BA5-4314-88AC-FC2ACF2CC718}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{AD545A5B-2BA5-4314-88AC-FC2ACF2CC718}.Release|x86.Build.0 = Release|Any CPU
|
||||
{AF210F69-9D31-43AF-AC3A-CD366E252218}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AF210F69-9D31-43AF-AC3A-CD366E252218}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AF210F69-9D31-43AF-AC3A-CD366E252218}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
|
||||
{AF210F69-9D31-43AF-AC3A-CD366E252218}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
|
||||
{AF210F69-9D31-43AF-AC3A-CD366E252218}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{AF210F69-9D31-43AF-AC3A-CD366E252218}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{AF210F69-9D31-43AF-AC3A-CD366E252218}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AF210F69-9D31-43AF-AC3A-CD366E252218}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AF210F69-9D31-43AF-AC3A-CD366E252218}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
|
||||
{AF210F69-9D31-43AF-AC3A-CD366E252218}.Release|Mixed Platforms.Build.0 = Release|Any CPU
|
||||
{AF210F69-9D31-43AF-AC3A-CD366E252218}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{AF210F69-9D31-43AF-AC3A-CD366E252218}.Release|x86.Build.0 = Release|Any CPU
|
||||
{364EC3C6-C9DB-45E0-A0F2-1EE61E4B429B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{364EC3C6-C9DB-45E0-A0F2-1EE61E4B429B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{364EC3C6-C9DB-45E0-A0F2-1EE61E4B429B}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
|
||||
|
|
@ -724,6 +738,7 @@ Global
|
|||
{0A6BB4C0-48D3-4E7F-952B-B8917345E075} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C}
|
||||
{0AD78AB5-D67C-49BC-81B1-0C51BFA82B5E} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C}
|
||||
{AD545A5B-2BA5-4314-88AC-FC2ACF2CC718} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C}
|
||||
{AF210F69-9D31-43AF-AC3A-CD366E252218} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C}
|
||||
{364EC3C6-C9DB-45E0-A0F2-1EE61E4B429B} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C}
|
||||
{BDEEBE09-C0C4-433C-B0B8-8478C9776996} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C}
|
||||
EndGlobalSection
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Microsoft.AspNet.Mvc
|
||||
{
|
||||
/// <summary>
|
||||
/// Specifies the allowed content types which can be used to select the action based on request's content-type.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
|
||||
public class ConsumesAttribute : Attribute, IResourceFilter, IConsumesActionConstraint
|
||||
{
|
||||
public static readonly int ConsumesActionConstraintOrder = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="ConsumesAttribute"/>.
|
||||
/// </summary>
|
||||
public ConsumesAttribute([NotNull] string contentType, params string[] otherContentTypes)
|
||||
{
|
||||
ContentTypes = GetContentTypes(contentType, otherContentTypes);
|
||||
}
|
||||
|
||||
// The value used is a non default value so that it avoids getting mixed with other action constraints
|
||||
// with default order.
|
||||
/// <inheritdoc />
|
||||
int IActionConstraint.Order { get; } = ConsumesActionConstraintOrder;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IList<MediaTypeHeaderValue> ContentTypes { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnResourceExecuting([NotNull] ResourceExecutingContext context)
|
||||
{
|
||||
// Only execute if the current filter is the one which is closest to the action.
|
||||
// Ignore all other filters. This is to ensure we have a overriding behavior.
|
||||
if (IsApplicable(context.ActionDescriptor))
|
||||
{
|
||||
MediaTypeHeaderValue requestContentType = null;
|
||||
MediaTypeHeaderValue.TryParse(context.HttpContext.Request.ContentType, out requestContentType);
|
||||
|
||||
// Only execute if this is the last filter before calling the action.
|
||||
// This ensures that we only run the filter which is closest to the action.
|
||||
if (requestContentType != null &&
|
||||
!ContentTypes.Any(contentType => contentType.IsSubsetOf(requestContentType)))
|
||||
{
|
||||
context.Result = new UnsupportedMediaTypeResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnResourceExecuted([NotNull] ResourceExecutedContext context)
|
||||
{
|
||||
}
|
||||
|
||||
public bool Accept(ActionConstraintContext context)
|
||||
{
|
||||
// If this constraint is not closest to the action, it will be skipped.
|
||||
if (!IsApplicable(context.CurrentCandidate.Action))
|
||||
{
|
||||
// Since the constraint is to be skipped, returning true here
|
||||
// will let the current candidate ignore this constraint and will
|
||||
// be selected based on other constraints for this action.
|
||||
return true;
|
||||
}
|
||||
|
||||
MediaTypeHeaderValue requestContentType = null;
|
||||
MediaTypeHeaderValue.TryParse(context.RouteContext.HttpContext.Request.ContentType, out requestContentType);
|
||||
|
||||
// If the request content type is null we need to act like pass through.
|
||||
// In case there is a single candidate with a constraint it should be selected.
|
||||
// If there are multiple actions with consumes action constraints this should result in ambiguous exception
|
||||
// unless there is another action without a consumes constraint.
|
||||
if (requestContentType == null)
|
||||
{
|
||||
var isActionWithoutConsumeConstraintPresent = context.Candidates.Any(
|
||||
candidate => candidate.Constraints == null ||
|
||||
!candidate.Constraints.Any(constraint => constraint is IConsumesActionConstraint));
|
||||
|
||||
return !isActionWithoutConsumeConstraintPresent;
|
||||
}
|
||||
|
||||
if (ContentTypes.Any(c => c.IsSubsetOf(requestContentType)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var firstCandidate = context.Candidates[0];
|
||||
if (firstCandidate != context.CurrentCandidate)
|
||||
{
|
||||
// If the current candidate is not same as the first candidate,
|
||||
// we need not probe other candidates to see if they apply.
|
||||
// Only the first candidate is allowed to probe other candidates and based on the result select itself.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Run the matching logic for all IConsumesActionConstraints we can find, and see what matches.
|
||||
// 1). If we have a unique best match, then only that constraint should return true.
|
||||
// 2). If we have multiple matches, then all constraints that match will return true
|
||||
// , resulting in ambiguity(maybe).
|
||||
// 3). If we have no matches, then we choose the first constraint to return true.It will later return a 415
|
||||
foreach (var candidate in context.Candidates)
|
||||
{
|
||||
if (candidate == firstCandidate)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tempContext = new ActionConstraintContext()
|
||||
{
|
||||
Candidates = context.Candidates,
|
||||
RouteContext = context.RouteContext,
|
||||
CurrentCandidate = candidate
|
||||
};
|
||||
|
||||
if (candidate.Constraints == null || candidate.Constraints.Count() == 0 ||
|
||||
candidate.Constraints.Any(constraint => constraint is IConsumesActionConstraint &&
|
||||
constraint.Accept(tempContext)))
|
||||
{
|
||||
// There is someone later in the chain which can handle the request.
|
||||
// end the process here.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// There is no one later in the chain that can handle this content type return a false positive so that
|
||||
// later we can detect and return a 415.
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsApplicable(ActionDescriptor actionDescriptor)
|
||||
{
|
||||
// If there are multiple IConsumeActionConstraints which are defined at the class and
|
||||
// at the action level, the one closest to the action overrides the others. To ensure this
|
||||
// we take advantage of the fact that ConsumesAttribute is both an IActionFilter and an
|
||||
// IConsumeActionConstraint. Since filterdescriptor collection is ordered (the last filter is the one
|
||||
// closest to the action), we apply this constraint only if there is no IConsumeActionConstraint after this.
|
||||
return actionDescriptor.FilterDescriptors.Last(
|
||||
filter => filter.Filter is IConsumesActionConstraint).Filter == this;
|
||||
|
||||
}
|
||||
|
||||
private List<MediaTypeHeaderValue> GetContentTypes(string firstArg, string[] args)
|
||||
{
|
||||
var contentTypes = new List<MediaTypeHeaderValue>();
|
||||
contentTypes.Add(MediaTypeHeaderValue.Parse(firstArg));
|
||||
foreach (var item in args)
|
||||
{
|
||||
var contentType = MediaTypeHeaderValue.Parse(item);
|
||||
contentTypes.Add(contentType);
|
||||
}
|
||||
|
||||
return contentTypes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// An <see cref="IActionConstraint"/> constraint that identifies a type which can be used to select an action
|
||||
/// based on incoming request.
|
||||
/// </summary>
|
||||
public interface IConsumesActionConstraint : IActionConstraint
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// A <see cref="HttpStatusCodeResult"/> that when
|
||||
/// executed will produce a UnsupportedMediaType (415) response.
|
||||
/// </summary>
|
||||
public class UnsupportedMediaTypeResult : HttpStatusCodeResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="UnsupportedMediaTypeResult"/>.
|
||||
/// </summary>
|
||||
public UnsupportedMediaTypeResult() : base(415)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
// 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.Http.Core;
|
||||
using Microsoft.AspNet.Routing;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNet.Mvc
|
||||
{
|
||||
public class ConsumesAttributeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("application")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void Constructor_ForInvalidContentType_Throws(string contentType)
|
||||
{
|
||||
// Arrange
|
||||
var expectedMessage = string.Format("Invalid value '{0}'.", contentType ?? "<null>");
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<FormatException>(() => new ConsumesAttribute(contentType));
|
||||
Assert.Equal(expectedMessage, exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("application/json")]
|
||||
[InlineData("application/json;Parameter1=12")]
|
||||
[InlineData("text/xml")]
|
||||
public void Accept_MatchesForMachingRequestContentType(string contentType)
|
||||
{
|
||||
// Arrange
|
||||
var constraint = new ConsumesAttribute("application/json", "text/xml");
|
||||
var action = new ActionDescriptor()
|
||||
{
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(constraint, FilterScope.Action) }
|
||||
};
|
||||
|
||||
var context = new ActionConstraintContext();
|
||||
context.Candidates = new List<ActionSelectorCandidate>()
|
||||
{
|
||||
new ActionSelectorCandidate(action, new [] { constraint }),
|
||||
};
|
||||
|
||||
context.CurrentCandidate = context.Candidates[0];
|
||||
context.RouteContext = CreateRouteContext(contentType: contentType);
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(constraint.Accept(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accept_TheFirstCandidateReturnsFalse_IfALaterOneMatches()
|
||||
{
|
||||
// Arrange
|
||||
var constraint1 = new ConsumesAttribute("application/json", "text/xml");
|
||||
var action1 = new ActionDescriptor()
|
||||
{
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(constraint1, FilterScope.Action) }
|
||||
};
|
||||
|
||||
var constraint2 = new Mock<ITestConsumeConstraint>();
|
||||
var action2 = new ActionDescriptor()
|
||||
{
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(constraint2.Object, FilterScope.Action) }
|
||||
};
|
||||
|
||||
constraint2.Setup(o => o.Accept(It.IsAny<ActionConstraintContext>()))
|
||||
.Returns(true);
|
||||
|
||||
var context = new ActionConstraintContext();
|
||||
context.Candidates = new List<ActionSelectorCandidate>()
|
||||
{
|
||||
new ActionSelectorCandidate(action1, new [] { constraint1 }),
|
||||
new ActionSelectorCandidate(action2, new [] { constraint2.Object }),
|
||||
};
|
||||
|
||||
context.CurrentCandidate = context.Candidates[0];
|
||||
context.RouteContext = CreateRouteContext(contentType: "application/custom");
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(constraint1.Accept(context));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("application/custom")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void Accept_ForNoMatchingCandidates_SelectsTheFirstCandidate(string contentType)
|
||||
{
|
||||
// Arrange
|
||||
var constraint1 = new ConsumesAttribute("application/json", "text/xml");
|
||||
var action1 = new ActionDescriptor()
|
||||
{
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(constraint1, FilterScope.Action) }
|
||||
};
|
||||
|
||||
var constraint2 = new Mock<ITestConsumeConstraint>();
|
||||
var action2 = new ActionDescriptor()
|
||||
{
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(constraint2.Object, FilterScope.Action) }
|
||||
};
|
||||
|
||||
constraint2.Setup(o => o.Accept(It.IsAny<ActionConstraintContext>()))
|
||||
.Returns(false);
|
||||
|
||||
var context = new ActionConstraintContext();
|
||||
context.Candidates = new List<ActionSelectorCandidate>()
|
||||
{
|
||||
new ActionSelectorCandidate(action1, new [] { constraint1 }),
|
||||
new ActionSelectorCandidate(action2, new [] { constraint2.Object }),
|
||||
};
|
||||
|
||||
context.CurrentCandidate = context.Candidates[0];
|
||||
context.RouteContext = CreateRouteContext(contentType: contentType);
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(constraint1.Accept(context));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void Accept_ForNoRequestType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType)
|
||||
{
|
||||
// Arrange
|
||||
var constraint1 = new ConsumesAttribute("application/json");
|
||||
var actionWithConstraint = new ActionDescriptor()
|
||||
{
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(constraint1, FilterScope.Action) }
|
||||
};
|
||||
|
||||
var constraint2 = new ConsumesAttribute("text/xml");
|
||||
var actionWithConstraint2 = new ActionDescriptor()
|
||||
{
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(constraint2, FilterScope.Action) }
|
||||
};
|
||||
|
||||
var actionWithoutConstraint = new ActionDescriptor();
|
||||
|
||||
var context = new ActionConstraintContext();
|
||||
context.Candidates = new List<ActionSelectorCandidate>()
|
||||
{
|
||||
new ActionSelectorCandidate(actionWithConstraint, new [] { constraint1 }),
|
||||
new ActionSelectorCandidate(actionWithConstraint2, new [] { constraint2 }),
|
||||
new ActionSelectorCandidate(actionWithoutConstraint, new List<IActionConstraint>()),
|
||||
};
|
||||
|
||||
context.RouteContext = CreateRouteContext(contentType: contentType);
|
||||
|
||||
// Act & Assert
|
||||
context.CurrentCandidate = context.Candidates[0];
|
||||
Assert.False(constraint1.Accept(context));
|
||||
context.CurrentCandidate = context.Candidates[1];
|
||||
Assert.False(constraint2.Accept(context));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("application/xml")]
|
||||
[InlineData("application/custom")]
|
||||
[InlineData("invalid/invalid")]
|
||||
public void Accept_UnrecognizedMediaType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType)
|
||||
{
|
||||
// Arrange
|
||||
var actionWithoutConstraint = new ActionDescriptor();
|
||||
var constraint1 = new ConsumesAttribute("application/json");
|
||||
var actionWithConstraint = new ActionDescriptor()
|
||||
{
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(constraint1, FilterScope.Action) }
|
||||
};
|
||||
|
||||
var constraint2 = new ConsumesAttribute("text/xml");
|
||||
var actionWithConstraint2 = new ActionDescriptor()
|
||||
{
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(constraint2, FilterScope.Action) }
|
||||
};
|
||||
|
||||
var context = new ActionConstraintContext();
|
||||
context.Candidates = new List<ActionSelectorCandidate>()
|
||||
{
|
||||
new ActionSelectorCandidate(actionWithConstraint, new [] { constraint1 }),
|
||||
new ActionSelectorCandidate(actionWithConstraint2, new [] { constraint2 }),
|
||||
new ActionSelectorCandidate(actionWithoutConstraint, new List<IActionConstraint>()),
|
||||
};
|
||||
|
||||
context.RouteContext = CreateRouteContext(contentType: contentType);
|
||||
|
||||
// Act & Assert
|
||||
context.CurrentCandidate = context.Candidates[0];
|
||||
Assert.False(constraint1.Accept(context));
|
||||
|
||||
context.CurrentCandidate = context.Candidates[1];
|
||||
Assert.False(constraint2.Accept(context));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void Accept_ForNoRequestType_ReturnsTrueForAllConstraints(string contentType)
|
||||
{
|
||||
// Arrange
|
||||
var constraint1 = new ConsumesAttribute("application/json");
|
||||
var actionWithConstraint = new ActionDescriptor()
|
||||
{
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(constraint1, FilterScope.Action) }
|
||||
};
|
||||
|
||||
var constraint2 = new ConsumesAttribute("text/xml");
|
||||
var actionWithConstraint2 = new ActionDescriptor()
|
||||
{
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(constraint2, FilterScope.Action) }
|
||||
};
|
||||
|
||||
var actionWithoutConstraint = new ActionDescriptor();
|
||||
|
||||
var context = new ActionConstraintContext();
|
||||
context.Candidates = new List<ActionSelectorCandidate>()
|
||||
{
|
||||
new ActionSelectorCandidate(actionWithConstraint, new [] { constraint1 }),
|
||||
new ActionSelectorCandidate(actionWithConstraint2, new [] { constraint2 }),
|
||||
};
|
||||
|
||||
context.RouteContext = CreateRouteContext(contentType: contentType);
|
||||
|
||||
// Act & Assert
|
||||
context.CurrentCandidate = context.Candidates[0];
|
||||
Assert.True(constraint1.Accept(context));
|
||||
context.CurrentCandidate = context.Candidates[1];
|
||||
Assert.True(constraint2.Accept(context));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("application/xml")]
|
||||
[InlineData("application/custom")]
|
||||
public void OnResourceExecuting_ForNoContentTypeMatch_SetsUnsupportedMediaTypeResult(string contentType)
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.ContentType = contentType;
|
||||
var consumesFilter = new ConsumesAttribute("application/json");
|
||||
var actionWithConstraint = new ActionDescriptor()
|
||||
{
|
||||
ActionConstraints = new List<IActionConstraintMetadata>() { consumesFilter },
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(consumesFilter, FilterScope.Action) }
|
||||
};
|
||||
var actionContext = new ActionContext(httpContext, new RouteData(), actionWithConstraint);
|
||||
|
||||
var resourceExecutingContext = new ResourceExecutingContext(actionContext, new[] { consumesFilter });
|
||||
|
||||
// Act
|
||||
consumesFilter.OnResourceExecuting(resourceExecutingContext);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resourceExecutingContext.Result);
|
||||
Assert.IsType<UnsupportedMediaTypeResult>(resourceExecutingContext.Result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void OnResourceExecuting_NullOrEmptyRequestContentType_IsNoOp(string contentType)
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.ContentType = contentType;
|
||||
var consumesFilter = new ConsumesAttribute("application/json");
|
||||
var actionWithConstraint = new ActionDescriptor()
|
||||
{
|
||||
ActionConstraints = new List<IActionConstraintMetadata>() { consumesFilter },
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(consumesFilter, FilterScope.Action) }
|
||||
};
|
||||
var actionContext = new ActionContext(httpContext, new RouteData(), actionWithConstraint);
|
||||
|
||||
var resourceExecutingContext = new ResourceExecutingContext(actionContext, new[] { consumesFilter });
|
||||
|
||||
// Act
|
||||
consumesFilter.OnResourceExecuting(resourceExecutingContext);
|
||||
|
||||
// Assert
|
||||
Assert.Null(resourceExecutingContext.Result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("application/xml")]
|
||||
[InlineData("application/json")]
|
||||
public void OnResourceExecuting_ForAContentTypeMatch_IsNoOp(string contentType)
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.ContentType = contentType;
|
||||
var consumesFilter = new ConsumesAttribute("application/json", "application/xml");
|
||||
var actionWithConstraint = new ActionDescriptor()
|
||||
{
|
||||
ActionConstraints = new List<IActionConstraintMetadata>() { consumesFilter },
|
||||
FilterDescriptors =
|
||||
new List<FilterDescriptor>() { new FilterDescriptor(consumesFilter, FilterScope.Action) }
|
||||
};
|
||||
var actionContext = new ActionContext(httpContext, new RouteData(), actionWithConstraint);
|
||||
var resourceExecutingContext = new ResourceExecutingContext(actionContext, new[] { consumesFilter });
|
||||
|
||||
// Act
|
||||
consumesFilter.OnResourceExecuting(resourceExecutingContext);
|
||||
|
||||
// Assert
|
||||
Assert.Null(resourceExecutingContext.Result);
|
||||
}
|
||||
|
||||
private static RouteContext CreateRouteContext(string contentType = null, object routeValues = null)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
if (contentType != null)
|
||||
{
|
||||
httpContext.Request.ContentType = contentType;
|
||||
}
|
||||
|
||||
var routeContext = new RouteContext(httpContext);
|
||||
routeContext.RouteData = new RouteData();
|
||||
|
||||
foreach (var kvp in new RouteValueDictionary(routeValues))
|
||||
{
|
||||
routeContext.RouteData.Values.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
return routeContext;
|
||||
}
|
||||
|
||||
public interface ITestConsumeConstraint : IConsumesActionConstraint, IResourceFilter
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
// 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.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Serialization;
|
||||
using ActionConstraintsWebSite;
|
||||
using Microsoft.AspNet.Builder;
|
||||
using Microsoft.AspNet.TestHost;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNet.Mvc.FunctionalTests
|
||||
{
|
||||
public class ConsumesAttributeTests
|
||||
{
|
||||
private readonly IServiceProvider _provider = TestHelper.CreateServices("ActionConstraintsWebSite");
|
||||
private readonly Action<IApplicationBuilder> _app = new Startup().Configure;
|
||||
|
||||
[Fact]
|
||||
public async Task NoRequestContentType_SelectsActionWithoutConstraint()
|
||||
{
|
||||
// Arrange
|
||||
var server = TestServer.Create(_provider, _app);
|
||||
var client = server.CreateClient();
|
||||
var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
"http://localhost/ConsumesAttribute_Company/CreateProduct");
|
||||
|
||||
// Act
|
||||
var response = await client.SendAsync(request);
|
||||
var product = JsonConvert.DeserializeObject<Product>(
|
||||
await response.Content.ReadAsStringAsync());
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Null(product);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoRequestContentType_Throws_IfMultipleActionsWithConstraints()
|
||||
{
|
||||
// Arrange
|
||||
var server = TestServer.Create(_provider, _app);
|
||||
var client = server.CreateClient();
|
||||
var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
"http://localhost/ConsumesAttribute_AmbiguousActions/CreateProduct");
|
||||
|
||||
// Act
|
||||
var response = await client.SendAsync(request);
|
||||
var exception = response.GetServerException();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
Assert.Equal(typeof(AmbiguousActionException).FullName, exception.ExceptionType);
|
||||
Assert.Equal(
|
||||
"Multiple actions matched. The following actions matched route data and had all constraints "+
|
||||
"satisfied:____ActionConstraintsWebSite.ConsumesAttribute_NoFallBackActionController."+
|
||||
"CreateProduct__ActionConstraintsWebSite.ConsumesAttribute_NoFallBackActionController.CreateProduct",
|
||||
exception.ExceptionMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoRequestContentType_Selects_IfASingleActionWithConstraintIsPresent()
|
||||
{
|
||||
// Arrange
|
||||
var server = TestServer.Create(_provider, _app);
|
||||
var client = server.CreateClient();
|
||||
|
||||
var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
"http://localhost/ConsumesAttribute_PassThrough/CreateProduct");
|
||||
|
||||
// Act
|
||||
var response = await client.SendAsync(request);
|
||||
var product = JsonConvert.DeserializeObject<Product>(
|
||||
await response.Content.ReadAsStringAsync());
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Null(product);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("application/json")]
|
||||
[InlineData("text/json")]
|
||||
public async Task Selects_Action_BasedOnRequestContentType(string requestContentType)
|
||||
{
|
||||
// Arrange
|
||||
var server = TestServer.Create(_provider, _app);
|
||||
var client = server.CreateClient();
|
||||
|
||||
var input = "{SampleString:\""+requestContentType+"\"}";
|
||||
var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
"http://localhost/ConsumesAttribute_AmbiguousActions/CreateProduct");
|
||||
request.Content = new StringContent(input, Encoding.UTF8, requestContentType);
|
||||
// Act
|
||||
var response = await client.SendAsync(request);
|
||||
var product = JsonConvert.DeserializeObject<Product>(
|
||||
await response.Content.ReadAsStringAsync());
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(requestContentType, product.SampleString);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("application/json")]
|
||||
[InlineData("text/json")]
|
||||
public async Task ActionLevelAttribute_OveridesClassLevel(string requestContentType)
|
||||
{
|
||||
// Arrange
|
||||
var server = TestServer.Create(_provider, _app);
|
||||
var client = server.CreateClient();
|
||||
|
||||
var input = "{SampleString:\"" + requestContentType + "\"}";
|
||||
var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
"http://localhost/ConsumesAttribute_OverridesBase/CreateProduct");
|
||||
request.Content = new StringContent(input, Encoding.UTF8, requestContentType);
|
||||
var expectedString = "ConsumesAttribute_OverridesBaseController_" + requestContentType;
|
||||
|
||||
// Act
|
||||
var response = await client.SendAsync(request);
|
||||
var product = JsonConvert.DeserializeObject<Product>(
|
||||
await response.Content.ReadAsStringAsync());
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(expectedString, product.SampleString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DerivedClassLevelAttribute_OveridesBaseClassLevel()
|
||||
{
|
||||
// Arrange
|
||||
var server = TestServer.Create(_provider, _app);
|
||||
var client = server.CreateClient();
|
||||
|
||||
var input = "<Product xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" " +
|
||||
"xmlns=\"http://schemas.datacontract.org/2004/07/ActionConstraintsWebSite\">" +
|
||||
"<SampleString>application/xml</SampleString></Product>";
|
||||
var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
"http://localhost/ConsumesAttribute_Overrides/CreateProduct");
|
||||
request.Content = new StringContent(input, Encoding.UTF8, "application/xml");
|
||||
var expectedString = "ConsumesAttribute_OverridesController_application/xml";
|
||||
|
||||
// Act
|
||||
var response = await client.SendAsync(request);
|
||||
var responseString = await response.Content.ReadAsStringAsync();
|
||||
var product = JsonConvert.DeserializeObject<Product>(responseString);
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(expectedString, product.SampleString);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
"warningsAsErrors": "true"
|
||||
},
|
||||
"dependencies": {
|
||||
"ActionConstraintsWebSite": "1.0.0",
|
||||
"ActionResultsWebSite": "1.0.0",
|
||||
"ActivatorWebSite": "1.0.0",
|
||||
"AddServicesWebSite": "1.0.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="__ToolsVersion__" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">14.0</VisualStudioVersion>
|
||||
<VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VSToolsPath)\AspNet\Microsoft.Web.AspNet.Props" Condition="'$(VSToolsPath)' != ''" />
|
||||
<PropertyGroup Label="Globals">
|
||||
<ProjectGuid>af210f69-9d31-43af-ac3a-cd366e252218</ProjectGuid>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x86'" Label="Configuration">
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x86'" Label="Configuration">
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<SchemaVersion>2.0</SchemaVersion>
|
||||
<DevelopmentServerPort>41642</DevelopmentServerPort>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VSToolsPath)\AspNet\Microsoft.Web.AspNet.targets" Condition="'$(VSToolsPath)' != ''" />
|
||||
</Project>
|
||||
|
|
@ -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 ActionConstraintsWebSite
|
||||
{
|
||||
[Route("ConsumesAttribute_AmbiguousActions/[action]")]
|
||||
public class ConsumesAttribute_NoFallBackActionController : Controller
|
||||
{
|
||||
[Consumes("application/json", "text/json")]
|
||||
public Product CreateProduct([FromBody] Product_Json jsonInput)
|
||||
{
|
||||
return jsonInput;
|
||||
}
|
||||
|
||||
[Consumes("application/xml")]
|
||||
public Product CreateProduct([FromBody] Product_Xml xmlInput)
|
||||
{
|
||||
return xmlInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
namespace ActionConstraintsWebSite
|
||||
{
|
||||
[Consumes("application/json")]
|
||||
public class ConsumesAttribute_OverridesBaseController : Controller
|
||||
{
|
||||
[Consumes("text/json")]
|
||||
public Product CreateProduct([FromBody] Product_Json product)
|
||||
{
|
||||
// should be picked if request content type is application/xml and not application/json.
|
||||
product.SampleString = "ConsumesAttribute_OverridesBaseController_text/json";
|
||||
return product;
|
||||
}
|
||||
|
||||
public virtual IActionResult CreateProduct([FromBody] Product product)
|
||||
{
|
||||
// should be picked if request content type is application/json.
|
||||
product.SampleString = "ConsumesAttribute_OverridesBaseController_application/json";
|
||||
return new ObjectResult(product);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
using Microsoft.AspNet.Mvc;
|
||||
|
||||
namespace ActionConstraintsWebSite
|
||||
{
|
||||
[Consumes("application/xml")]
|
||||
public class ConsumesAttribute_OverridesController : ConsumesAttribute_OverridesBaseController
|
||||
{
|
||||
public override IActionResult CreateProduct([FromBody] Product product)
|
||||
{
|
||||
// should be picked if request content type is text/json.
|
||||
product.SampleString = "ConsumesAttribute_OverridesController_application/xml";
|
||||
return new JsonResult(product);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
using System;
|
||||
using Microsoft.AspNet.Mvc;
|
||||
|
||||
namespace ActionConstraintsWebSite
|
||||
{
|
||||
[Route("ConsumesAttribute_PassThrough/[action]")]
|
||||
public class ConsumesAttribute_PassThroughController : Controller
|
||||
{
|
||||
[Consumes("application/json")]
|
||||
public Product CreateProduct([FromBody] Product_Json jsonInput)
|
||||
{
|
||||
return jsonInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
// 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;
|
||||
|
||||
namespace ActionConstraintsWebSite
|
||||
{
|
||||
[Route("ConsumesAttribute_Company/[action]")]
|
||||
public class ConsumesAttribute_WithFallbackActionController : Controller
|
||||
{
|
||||
[Consumes("application/json")]
|
||||
public Product CreateProduct([FromBody] Product_Json jsonInput)
|
||||
{
|
||||
return jsonInput;
|
||||
}
|
||||
|
||||
[Consumes("application/xml")]
|
||||
public Product CreateProduct([FromBody] Product_Xml xmlInput)
|
||||
{
|
||||
return xmlInput;
|
||||
}
|
||||
|
||||
public Product CreateProduct([FromBody] Product_Text defaultInput)
|
||||
{
|
||||
return defaultInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ActionConstraintsWebSite
|
||||
{
|
||||
public class Product
|
||||
{
|
||||
[Range(10, 100)]
|
||||
public int SampleInt { get; set; }
|
||||
|
||||
[MinLength(15)]
|
||||
public string SampleString { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ActionConstraintsWebSite
|
||||
{
|
||||
public class Product_Json : Product
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ActionConstraintsWebSite
|
||||
{
|
||||
public class Product_Xml : Product
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ActionConstraintsWebSite
|
||||
{
|
||||
public class Product_Text : Product
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// 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 ActionConstraintsWebSite
|
||||
{
|
||||
public class Startup
|
||||
{
|
||||
public void Configure(IApplicationBuilder app)
|
||||
{
|
||||
var configuration = app.GetTestConfiguration();
|
||||
|
||||
app.UseServices(services =>
|
||||
{
|
||||
services.AddMvc(configuration);
|
||||
services.Configure<MvcOptions>(options =>
|
||||
{
|
||||
options.AddXmlDataContractSerializerFormatter();
|
||||
});
|
||||
});
|
||||
|
||||
app.UseErrorReporter();
|
||||
|
||||
app.UseMvc(routes =>
|
||||
{
|
||||
routes.MapRoute(
|
||||
name: "default",
|
||||
template: "{controller}/{action}/{id?}");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"commands": {
|
||||
"web": "Microsoft.AspNet.Hosting server=Microsoft.AspNet.Server.WebListener server.urls=http://localhost:5001",
|
||||
"kestrel": "Microsoft.AspNet.Hosting --server Kestrel --server.urls http://localhost:5000"
|
||||
},
|
||||
"dependencies": {
|
||||
"Kestrel": "1.0.0-*",
|
||||
"Microsoft.AspNet.Mvc": "6.0.0-*",
|
||||
"Microsoft.AspNet.Mvc.TestConfiguration": "1.0.0",
|
||||
"Microsoft.AspNet.Server.IIS": "1.0.0-*",
|
||||
"Microsoft.AspNet.Server.WebListener": "1.0.0-*",
|
||||
"Microsoft.AspNet.StaticFiles": "1.0.0-*"
|
||||
},
|
||||
"frameworks": {
|
||||
"aspnet50": { },
|
||||
"aspnetcore50": { }
|
||||
},
|
||||
"webroot": "wwwroot"
|
||||
}
|
||||
Binary file not shown.
Loading…
Reference in New Issue