I have some sample c# code that demonstrates feature management in .net core.
I have this class that exposes an endpoint:
namespace Widgets.Controllers
{
[Authorize]
public class FeatureToggleSampleController : ControllerBase
{
private readonly IFeatureManagerSnapshot featureManager;
public FeatureToggleSampleController(
IFeatureManagerSnapshot featureManager)
{
this.featureManager = featureManager;
}
[Route("toggles/demo")]
[HttpGet]
[FeatureGate(nameof(WidgetToggles.sampleToggleName))]
public string ShowSampleToggle(MeFunctionsRequest request)
{
return "sample toggle is enabled";
}
}
}
we are using a filter, to have the feature mana
[FilterAlias("WidgetsFeatures")]
public class WidgetFeaturesFilter : IWidgetFeaturesFilter
{
private readonly IServiceScopeFactory scopeFactory;
public WidgetFeaturesFilter(IServiceScopeFactory scopeFactory)
{
this.scopeFactory = scopeFactory;
}
public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
{
using var scope = scopeFactory.CreateScope();
var featureRepo = scope.ServiceProvider.GetService<IGenericRepository<WidgetFeature>>();
var feature = featureRepo.Retrieve(filter: "[Name] = #FeatureName", filterParameters: new { context.FeatureName }).FirstOrDefault();
return Task.FromResult(feature != null && feature.Enabled);
}
}
In our startup, we add the service like this:
services.AddFeatureManagement(Configuration.GetSection("FeatureManagement")).AddFeatureFilter<WidgetsFeaturesFilter>();
And, this is the json file where we define the toggles:
{
"FeatureManagement": {
"sampleToggleName": {
"EnabledFor": [
{
"Name": "WidgetsFeatures"
}
]
}
}
}
When the bool is true in the database, hitting http://localhost/toggles/demo returns the "sample toggle is enabled" method message. And when I change the value in the database to false, it 404s in the browser. Everything seems to be working as far as the toggle logic itself.
Now I want to demo how to unit test controllers and other modules that use feature toggles.
So far I have written two unit test code based on a sample i found. The first one works - although I'm not sure if it's because i've written the test correctly. But gives me the string I'm looking for.
The second one fails because the mock is not working. I've asked the mockFeatureManager to return false as far as the toggle status, but it still behaves as if the toggle is enabled.
[Collection("UnitTests")]
public class FeatureToggleSampleControllerShould
{
[Fact]
public async void Return_String_If_Toggle_Enabled()
{
var mockFeatureManager = new Mock<IFeatureManagerSnapshot>();
mockFeatureManager
.Setup(x=>x.IsEnabledAsync(nameof(WidgetsFeatureToggles.sampleToggleName)))
.Returns(Task.FromResult(true));
var featureManager = mockFeatureManager.Object;
Assert.True(await featureManager.IsEnabledAsync(nameof(WidgetsFeatureToggles.sampleToggleName)));
var sampleControler = new FeatureToggleSampleController(TestHelper.Appsettings,featureManager);
Assert.Equal("sample toggle is enabled",sampleControler.ShowSampleToggle());
}
[Fact]
public async void Return_404_If_Toggle_Enabled()
{
var mockFeatureManager = new Mock<IFeatureManagerSnapshot>();
mockFeatureManager
.Setup(x=>x.IsEnabledAsync(nameof(WidgetsFeatureToggles.sampleToggleName)))
.Returns(Task.FromResult(false));
var featureManager = mockFeatureManager.Object;
Assert.False(await featureManager.IsEnabledAsync(nameof(WidgetsFeatureToggles.sampleToggleName)));
var sampleControler = new FeatureToggleSampleController(TestHelper.Appsettings,featureManager);
try{
sampleControler.ShowSampleToggle();
} catch (Exception ex) {
Assert.Contains("404",ex.Message);
}
}
}
I don't think the bug is with the actual code, but the unit test.
But any tips would be appreciated.
EDIT 2
This was the post I was using as an example: IFeatureManager Unit Testing
In my case, I'm not using azure ...
Related
When configuring the builder in "ASP.NET Core", I want to put the data model I created myself.
It is intended to be delivered to middleware or "Add Scoped" services.
I've tried the following code:
TestUtilsSettingModel temp = new TestUtilsSettingModel()
{
Test01 = 1000,
Test03 = "Input!!"
};
//error
builder.Services
.Configure<TestUtilsSettingModel>(temp);
//error
builder.Services
.Configure<TestUtilsSettingModel>(
new Action<TestUtilsSettingModel>(temp));
//https://stackoverflow.com/a/45324839/6725889
builder.Configuration["TestUtilsSetting:Test01"] = 1000.ToString();
Test Code and project here.
"Configuration["TestUtilsSetting:Test01"]" works but
You have to put all the data yourself.
Can't you just pass the whole data model?
Here is the test code:
class to receive options:
Code here.
public interface ITestUtils
{
}
public class TestUtils: ITestUtils
{
private readonly TestUtilsSettingModel _appSettings;
public TestUtils(IOptions<TestUtilsSettingModel> appSettings)
{
_appSettings = appSettings.Value;
}
}
Inject from "Program.cs" or "Startup.cs"
Code here.
builder.Services.AddScoped<ITestUtils, TestUtils>();
Used in controller constructor :
Code here.
public WeatherForecastController(
ILogger<WeatherForecastController> logger
, ITestUtils testMiddleware)
{
_logger = logger;
this._TestMiddleware = testMiddleware;
}
I studied 'iservicecollection extension' to solve this problem.
I found a solution.
The following code is equivalent to
'builder.Configuration["TestUtilsSetting:Test01"] = 1000.ToString();'
builder.Services.Configure<TestUtilsSettingModel>(options =>
{
options.Test01 = 1000;
});
You still have to put each one in.
('options = temp' not work)
So do something like this:
Add the following method to 'TestUtilsSettingModel'.
public void ToCopy(TestUtilsSettingModel testUtilsSettingModel)
{
this.Test01 = testUtilsSettingModel.Test01;
this.Test02 = testUtilsSettingModel.Test02;
this.Test03 = testUtilsSettingModel.Test03;
}
In 'Program.cs', pass the model as follows.
builder.Services.Configure<TestUtilsSettingModel>(options =>
{
options.ToCopy(temp);
});
enter image description here
I have a unit test that is basically testing the behaviour of EF Core. The class I am trying to test looks like this:
namespace MusicPortal.Repository.Repository
{
public class ArtistRepository : IArtistRepository
{
private readonly MusicPortalDbContext _context;
public ArtistRepository(MusicPortalDbContext context)
{
_context = context;
}
public async Task<MusicPortalDatabaseResponse<bool>> AddNewArtist(Artist artist)
{
try
{
await _context.Artists.AddAsync(new Artist
{
ArtistType = ArtistTypes.Band,
City = artist.City,
Country = artist.Country,
Genre = artist.Genre,
Name = artist.Name,
ProfileImageUrl = artist.ProfileImageUrl
});
_context.SaveChanges();
return new MusicPortalDatabaseResponse<bool>
{
HasError = false,
Exception = null,
Response = true
};
}
catch (Exception e)
{
return new MusicPortalDatabaseResponse<bool>
{
HasError = true,
Exception = e,
Response = false
};
}
}
}
}
And I have the following Unit Test for it using Moq
namespace MusicPortal.Tests.Repository.ArtistRepository.AddNewArtist
{
[TestFixture]
public class GivenAddingANewArtistToADatabaseFails
{
private Mock<DbSet<Artist>> _mockArtistDbSet;
private Mock<MusicPortalDbContext> _mockContext;
private IArtistRepository _artistRepository;
private MusicPortalDatabaseResponse<bool> _addArtistToDbResponse;
[OneTimeSetUp]
public async Task Setup()
{
_mockArtistDbSet = new Mock<DbSet<Artist>>();
_mockContext = new Mock<MusicPortalDbContext>();
_mockArtistDbSet
.Setup(x => x.AddAsync(It.IsAny<Artist>(), It.IsAny<CancellationToken>()))
.Callback((Artist artist, CancellationToken token) => { })
.ReturnsAsync(It.IsAny<EntityEntry<Artist>>());
_mockContext
.Setup(x => x.SaveChanges())
.Throws(new Exception("Cannot save new Artist to Database"));
_artistRepository = new MusicPortal.Repository.Repository.ArtistRepository(_mockContext.Object);
_addArtistToDbResponse = await _artistRepository.AddNewArtist(It.IsAny<Artist>());
}
[Test]
public void ThenANegativeResultIsReturned() // pass
{
Assert.IsFalse(_addArtistToDbResponse.Response);
Assert.IsTrue(_addArtistToDbResponse.HasError);
Assert.IsInstanceOf<Exception>(_addArtistToDbResponse.Exception);
}
[Test]
public void ThenTheArtistContextAddMethodIsCalledOnce() //fail
{
_mockArtistDbSet.Verify(x => x.AddAsync(It.IsAny<Artist>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Test]
public void ThenTheArtistsContextSaveMethodIsNeverCalled() //pass
{
_mockContext.Verify(x => x.SaveChanges(), Times.Never);
}
}
}
The first and last assertion pass but ThenTheArtistContextAddMethodIsCalledOnce() fails due to the following error:
MusicPortal.Tests.Repository.ArtistRepository.AddNewArtist.GivenAddingANewArtistToADatabaseFails.ThenTheArtistContextAddMethodIsCalledOnce
Moq.MockException :
Expected invocation on the mock once, but was 0 times: x => x.AddAsync(It.IsAny(), It.IsAny())
Performed invocations:
Mock:1> (x):
No invocations performed.
at Moq.Mock.Verify(Mock mock, LambdaExpression expression, Times times, String failMessage)
at Moq.Mock1.Verify[TResult](Expression1 expression, Func`1 times)
at MusicPortal.Tests.Repository.ArtistRepository.AddNewArtist.GivenAddingANewArtistToADatabaseFails.ThenTheArtistContextAddMethodIsCalledOnce() in MusicPortal.Tests\Repository\ArtistRepository\AddNewArtist\GivenAddingANewArtistToADatabaseFails.cs:line 53
I'm understanding that the problem code is c#
_mockArtistDbSet
.Setup(x => x.AddAsync(It.IsAny<Artist>(), It.IsAny<CancellationToken>()))
.Callback((Artist artist, CancellationToken token) => { })
.ReturnsAsync(It.IsAny<EntityEntry<Artist>>());
And I know the problem is most likely due to async issue but I don't know why, or what the actual problem is. Any advice, solutions?
You declare and setup _mockArtistDbSet, but you don't use/attach it to the _mockContext. I think you need to add something like:
_mockContext.Setup(m => m.Artist).Returns(_mockArtistDbSet.Object);
So it looks like EF Core is not so easily tested with async tasks such as SaveChangesAsync and AddAsync. In the end, I followed the MS guide for testing EF core and created a mock context. The only downside being I can only test happy path. Although error paths are tested by the service which consumes the repository, I was hoping for more test coverage on the repository layer.
Anyway, here's the spec
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using MusicPortal.Core.Context;
using MusicPortal.Core.Repository;
using MusicPortal.Tests.Repository.ArtistRepository.TestHelpers;
using MusicPortal.Tests.Repository.ArtistRepository.TestHelpers.MockDB;
using NUnit.Framework;
using MockArtistRepository = MusicPortal.Repository.Repository.ArtistRepository;
namespace MusicPortal.Tests.Repository.ArtistRepository.AddNewArtist
{
[TestFixture]
public class GivenANewArtistToInsertIntoTheDb
{
private DbContextOptions<MusicPortalDbContext> _options;
private MusicPortalDatabaseResponse<bool> _mockResponse;
[OneTimeSetUp]
public async Task Setup()
{
_options = new MockDbFactory("MusicPortalDB").Options;
using (var context = new MusicPortalDbContext(_options))
{
var artistRepository = new MockArtistRepository(context);
_mockResponse = await artistRepository.AddNewArtist(MockRepositoryData.Artist);
}
}
[Test]
public void AndThenAPositiveResultIsReturned()
{
Assert.Null(_mockResponse.Exception);
Assert.IsTrue(_mockResponse.Response);
Assert.IsFalse(_mockResponse.HasError);
}
[Test]
public void ThenTheArtistShouldBeSavedWithNoProblem()
{
using (var context = new MusicPortalDbContext(_options))
{
Assert.AreEqual(1, context.Artists.Count());
}
}
}
}
and the Mock Database:
using System;
using Microsoft.EntityFrameworkCore;
using MusicPortal.Core.Context;
using MusicPortal.Core.DBModels;
namespace MusicPortal.Tests.Repository.ArtistRepository.TestHelpers.MockDB
{
public sealed class MockDbFactory
{
public DbContextOptions<MusicPortalDbContext> Options { get; }
public MockDbFactory(string dbName)
{
Options = new DbContextOptionsBuilder<MusicPortalDbContext>()
.UseInMemoryDatabase(dbName)
.Options;
}
public void AddArtistsToContext()
{
using (var context = new MusicPortalDbContext(Options))
{
context.Artists.Add(new Artist
{
City = "Orange County",
Country = "USA",
Events = null,
Genre = "Pop Punk",
Id = Guid.Parse("8a07504b-8152-4d8b-8e21-74bf64322ebc"),
Merchandise = null,
Name = "A Day To Remember",
ArtistType = "Band",
ProfileImageUrl = "https://placehold.it/30x30"
});
context.SaveChanges();
}
}
}
}
I hope this helps anyone looking at the same issue. The lesson learned is don't use Async unless you absolutely have to.
We are creating unit tests for an application and ran into a problem creating certain tests.
We are unit testing the following Handle() method of the class ActivateCommandHandler:
public class ActivateCommand : IRequest<HttpResponseMessage>
{
public string Controller { get; set; }
public ActivateCommand(string controllername)
{
this.Controller = controllername;
}
}
public class ActivateCommandHandler : CommandHandler<ActivateCommand, HttpResponseMessage>
{
protected readonly ICommandsGateway _commandsGateway;
protected readonly EndpointSettings _endpoints;
protected readonly IUserProfile _userprofile;
public ActivateCommandHandler(IMediator mediator, ICommandsGateway commandsGateway, IOptions<EndpointSettings> endpoints, IValidationContext validationContext, IUserProfile currentUser) : base(mediator, validationContext, currentUser)
{
_commandsGateway = commandsGateway;
_endpoints = endpoints.Value;
_userprofile = currentUser;
}
public override async Task<HttpResponseMessage> Handle(ActivateCommand command, CancellationToken cancellationToken)
{
if (_endpoints.EndpointExists(command.Controller))
{
// Check whether the second server controller is deactivated
string peercontroller = _endpoints.GetPeerController(command.Controller);
if (!string.IsNullOrEmpty(peercontroller))
{
BaseRedundancySwitchStatus controllerStatus = await _commandsGateway.GetRedundancySwitchStatus(_endpoints.GetEndpointAddress(peercontroller));
if ((controllerStatus.CurrentState == "Activated") || (controllerStatus.CurrentState == "ActivatedWithWarning") || (controllerStatus.CurrentState == "Activating"))
{
var resp = new HttpResponseMessage(HttpStatusCode.Conflict)
{
Content = new StringContent($"{peercontroller},{controllerStatus.CurrentState}")
};
return resp;
}
}
var result = await _commandsGateway.PostActivateCommand(_endpoints.GetEndpointAddress(command.Controller));
return result;
}
else
{
throw new InvalidControllerNameException($"ERROR: The controller {command.Controller} does not exist as an endpoint on this Control Center!");
}
}
}
For this the following were mocked: _endpoints, command and _commandsGateway (interface). This works great for unit testing the parameter validation. But we now want to test the behaviour when the peercontroller status is set to a specific value.
To do this we are trying to mock out the function _commandsGateway.GetRedundancySwitchStatus(). The following is the actual test implementation. We mock the _commandsGateway.GetRedundancySwitchStatus() function to return the expected BaseRedundancySwitchStatus with CurrentState set to "Activated". After that we call the handler of the actual function to be tested and check whether we get the expected error.
[Fact]
public async void ShouldHaveErrors_PeerControllerStateActivated()
{
var command = new ActivateCommand("Server Controller Slave1");
BaseRedundancySwitchStatus result = new BaseRedundancySwitchStatus()
{
CurrentState = "Activated"
};
_commandsGateway
.Setup(s => s.GetRedundancySwitchStatus("Server Controller Slave1"))
.ReturnsAsync(result);
HttpResponseMessage res = await _handler.Handle(command, CancellationToken.None);
Assert.True(res.StatusCode == System.Net.HttpStatusCode.Conflict);
}
Debugging the code, when I step through the code in the Handle() method where _commandsGateway.GetRedundancySwitchStatus is called, I can see that _endpoints.GetEndpointAddress(command.Controller) (which is the parameter) is called and the correct value is returned. After this the debugger steps to the next line without any indication of having executed the mock GetRedundancySwitchStatus() function. Inspecting the controllerStatus variable the value is null. I would expect the value to be the BaseRedundancySwitchStatus object which is supposed to be returned by the mocked GetRedundancySwitchStatus() function.
Where are we going wrong?
I hava a problem with unit testing asp.net core mvc controller!
the problem is that in my controller, i use sessions:
[HttpPost]
public IActionResult OpretLobby(LobbyViewModel lobby)
{
try
{
//find brugeren der har lavet lobby
var currentUser = HttpContext.Session.GetObjectFromJson<User>("user");
//save as a lobby
ILobby nyLobby = new Lobby(currentUser.Username)
{
Id = lobby.Id
};
SessionExtension.SetObjectAsJson(HttpContext.Session, lobby.Id, nyLobby);
//add to the list
_lobbyList.Add(nyLobby);
return RedirectToAction("Lobby","Lobby",lobby);
}
this all works perfectly well online on the server, nothing wrong here.
BUT when it comes to the demand of unit testing this whole thing, its not so perfect anymore.
basicly the problem is, that i cant get access to my session from a test.. i have tryed in many ways to create mocks and what not, but most of the solutions work for .net framework, and not for .net core for some reason! please help im in pain!
note:
i used a dummy version of a test to isolate this problem:
[Test]
public void TestIsWorking()
{
SessionExtension.SetObjectAsJson(uut.HttpContext.Session, "user", _savedUser);
//ViewResult result = uut.OpretLobby(lobbyViewModel) as ViewResult;
//Assert.AreEqual("OpretLobby", result.ViewName);
Assert.AreEqual(1,1);
}
goes wrong also here trying to set the session for a user :/
It seems that GetObjectFromJson is an extension method. If so, we could not mock static method easily.
I normally create an abstraction for that kind of scenario. Then register it in DI container, and inject the dependency to the controller.
Startup.cs
public class Startup
{
...
public void ConfigureServices(IServiceCollection services)
{
...
services.AddScoped<IUserSession, UserSession>();
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
...
}
...
}
Abstraction
public class User
{
public string Username { get; set; }
}
public interface IUserSession
{
User User { get; }
}
public class UserSession : IUserSession
{
private readonly IHttpContextAccessor _httpContextAccessor;
public UserSession(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public User User => _httpContextAccessor.HttpContext.Session.GetObjectFromJson<User>("user");
}
public static class SessionExtensions
{
public static User GetObjectFromJson<User>(
this ISession sesson, string json) where User : new()
{
return new User(); // Dummy extension method just to test OP's code
}
}
Controller
public class HomeController : Controller
{
private readonly IUserSession _userSession;
public HomeController(IUserSession userSession)
{
_userSession = userSession;
}
public IActionResult OpretLobby()
{
var currentUser = _userSession.User;
return View(currentUser);
}
}
}
Unit Tests
using AspNetCore.Controllers;
using Microsoft.AspNetCore.Mvc;
using Moq;
using Xunit;
namespace XUnitTestProject1
{
public class HomeControllerTests
{
private readonly User _user;
public HomeControllerTests()
{
_user = new User {Username = "johndoe"};
}
[Fact]
public void OpretLobby_Test()
{
// Arrange
var mockUserSession = new Mock<IUserSession>();
mockUserSession.Setup(x => x.User).Returns(_user);
var sut = new HomeController(mockUserSession.Object);
// Act
var result = sut.OpretLobby();
// Assert
var viewResult = Assert.IsType<ViewResult>(result);
var user = Assert.IsType<User>(viewResult.Model);
Assert.Equal(_user.Username, user.Username);
}
}
}
#nicholas-ladefoged
I would rather recommend you use integration testing when you need to get some content from session
asp.net core has an awesome TestHost nuget package which can help you validate logic using integration way for testing.
Try add a below code snippet
TestServer server = new TestServer(new WebHostBuilder()
.UseStartup<Startup>()
.ConfigureTestServices(services =>
{
services.AddHttpContextAccessor();
}));
var client = server.CreateClient();
var response = await client.GetAsync(""); // I'm using GetAsync just for sample
here you go! You will have a real session that you can test
BTW, Moq library hasn't ability to mock a static methods, so there a lot of issues. I think that will be more easier to use an integration test in your situation
Win solved it! Here is the final test code if anyone is wondering.
// Arrange
var mockUserSession = new Mock<IUserSession>();
mockUserSession.Setup(x => x.User).Returns(_savedUser);
var sut = new LobbyController(FakeSwagCommunication,mockUserSession.Object);
// Act
var result = sut.OpretLobby(_lobbyViewModel);
// Assert
Assert.IsInstanceOf<RedirectToActionResult>(result);
I've got a pretty basic controller method that returns a list of Customers. I want it to return the List View when a user browses to it, and return JSON to requests that have application/json in the Accept header.
Is that possible in ASP.NET Core MVC 1.0?
I've tried this:
[HttpGet("")]
public async Task<IActionResult> List(int page = 1, int count = 20)
{
var customers = await _customerService.GetCustomers(page, count);
return Ok(customers.Select(c => new { c.Id, c.Name }));
}
But that returns JSON by default, even if it's not in the Accept list. If I hit "/customers" in my browser, I get the JSON output, not my view.
I thought I might need to write an OutputFormatter that handled text/html, but I can't figure out how I can call the View() method from an OutputFormatter, since those methods are on Controller, and I'd need to know the name of the View I wanted to render.
Is there a method or property I can call to check if MVC will be able to find an OutputFormatter to render? Something like the following:
[HttpGet("")]
public async Task<IActionResult> List(int page = 1, int count = 20)
{
var customers = await _customerService.GetCustomers(page, count);
if(Response.WillUseContentNegotiation)
{
return Ok(customers.Select(c => new { c.Id, c.Name }));
}
else
{
return View(customers.Select(c => new { c.Id, c.Name }));
}
}
I think this is a reasonable use case as it would simplify creating APIs that return both HTML and JSON/XML/etc from a single controller. This would allow for progressive enhancement, as well as several other benefits, though it might not work well in cases where the API and Mvc behavior needs to be drastically different.
I have done this with a custom filter, with some caveats below:
public class ViewIfAcceptHtmlAttribute : Attribute, IActionFilter
{
public void OnActionExecuted(ActionExecutedContext context)
{
if (context.HttpContext.Request.Headers["Accept"].ToString().Contains("text/html"))
{
var originalResult = context.Result as ObjectResult;
var controller = context.Controller as Controller;
if(originalResult != null && controller != null)
{
var model = originalResult.Value;
var newResult = controller.View(model);
newResult.StatusCode = originalResult.StatusCode;
context.Result = newResult;
}
}
}
public void OnActionExecuting(ActionExecutingContext context)
{
}
}
which can be added to a controller or action:
[ViewIfAcceptHtml]
[Route("/foo/")]
public IActionResult Get(){
return Ok(new Foo());
}
or registered globally in Startup.cs
services.AddMvc(x=>
{
x.Filters.Add(new ViewIfAcceptHtmlAttribute());
});
This works for my use case and accomplishes the goal of supporting text/html and application/json from the same controller. I suspect isn't the "best" approach as it side-steps the custom formatters. Ideally (in my mind), this code would just be another Formatter like Xml and Json, but that outputs Html using the View rendering engine. That interface is a little more involved, though, and this was the simplest thing that works for now.
I haven't tried this, but could you just test for that content type in the request and return accordingly:
var result = customers.Select(c => new { c.Id, c.Name });
if (Request.Headers["Accept"].Contains("application/json"))
return Json(result);
else
return View(result);
I liked Daniel's idea and felt inspired, so here's a convention based approach as well. Because often the ViewModel needs to include a little bit more 'stuff' than just the raw data returned from the API, and it also might need to check different stuff before it does its work, this will allow for that and help in following a ViewModel for every View principal. Using this convention, you can write two controller methods <Action> and <Action>View both of which will map to the same route. The constraint applied will choose <Action>View if "text/html" is in the Accept header.
public class ContentNegotiationConvention : IActionModelConvention
{
public void Apply(ActionModel action)
{
if (action.ActionName.ToLower().EndsWith("view"))
{
//Make it match to the action of the same name without 'view', exa: IndexView => Index
action.ActionName = action.ActionName.Substring(0, action.ActionName.Length - 4);
foreach (var selector in action.Selectors)
//Add a constraint which will choose this action over the API action when the content type is apprpriate
selector.ActionConstraints.Add(new TextHtmlContentTypeActionConstraint());
}
}
}
public class TextHtmlContentTypeActionConstraint : ContentTypeActionConstraint
{
public TextHtmlContentTypeActionConstraint() : base("text/html") { }
}
public class ContentTypeActionConstraint : IActionConstraint, IActionConstraintMetadata
{
string _contentType;
public ContentTypeActionConstraint(string contentType)
{
_contentType = contentType;
}
public int Order => -10;
public bool Accept(ActionConstraintContext context) =>
context.RouteContext.HttpContext.Request.Headers["Accept"].ToString().Contains(_contentType);
}
which is added in startup here:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(o => { o.Conventions.Add(new ContentNegotiationConvention()); });
}
In you controller, you can write method pairs like:
public class HomeController : Controller
{
public ObjectResult Index()
{
//General checks
return Ok(new IndexDataModel() { Property = "Data" });
}
public ViewResult IndexView()
{
//View specific checks
return View(new IndexViewModel(Index()));
}
}
Where I've created ViewModel classes meant to take the output of API actions, another pattern which connects the API to the View output and reinforces the intent that these two represent the same action:
public class IndexViewModel : ViewModelBase
{
public string ViewOnlyProperty { get; set; }
public string ExposedDataModelProperty { get; set; }
public IndexViewModel(IndexDataModel model) : base(model)
{
ExposedDataModelProperty = model?.Property;
ViewOnlyProperty = ExposedDataModelProperty + " for a View";
}
public IndexViewModel(ObjectResult apiResult) : this(apiResult.Value as IndexDataModel) { }
}
public class ViewModelBase
{
protected ApiModelBase _model;
public ViewModelBase(ApiModelBase model)
{
_model = model;
}
}
public class ApiModelBase { }
public class IndexDataModel : ApiModelBase
{
public string Property { get; internal set; }
}