Notes on ASP.NET Core¶
I have written these notes while working on a couple of great books on the subject. See Sources used.
Models
- the M
in MVC
- contain the data that users work with. There are two broad types of model:
view models
, which represent just data passed from the controller to the view, anddomain models
, which contain the data in a business domain, along with the operations, transformations, and rules for creating, storing, and manipulating that data, collectively referred to as the model logic.
In ASP.NET Core MVC, controllers
are C# classes, usually derived from the Microsoft.AspNetCore.Mvc.Controller
class. Each public method in a class derived from Controller
is an action method
, which is associated with a URL
.
Conventions¶
- put the third-party JavaScript and CSS packages you rely on in the
wwwroot/lib
folder. - convention over configuration
- the controller for
/product
uri should have the nameProductController.cs
and reside in the/Controllers
folder; from other parts in the project, such as when using an HTML helper method, you specify the first part of the name (Product
), and MVC automatically appendsController
to the name and starts looking for the controller class. - views for
/product
uri associated withProductController
should all reside in the/Views/Product
folder. - MVC expects that the
default view
for anaction method
should be named after that method. For example, the default view associated with an action method calledList
should be calledList.cshtml
. Thus, for theList action method
in theProductController
class, thedefault view
is expected to be/Views/Product/List.cshtml
. Thedefault view
is used when you return the result of calling theView
method in an action method, like this:
1 | return View(); |
You can specify a different view by name, like this:
1 | return View("MyOtherView"); |
- When looking for a
view
, MVC looks in the folder named after the controller and then in the/Views/Shared
folder. This means that I can put views that will be used by more than one controller in the/Views/Shared
folder and MVC will find them. - The naming convention for
layouts
is to prefix the file with an underscore (_
) character, and layout files are placed in the/Views/Shared
folder. This layout is applied to all views by default through the/Views/_ViewStart.cshtml
file. If you do not want the default layout applied to views, you can change the settings inViewStart.cshtml
(or delete the file entirely) to specify another layout in the view, like this:
1 2 3 | @{ Layout = "~/_MyLayout.cshtml"; } |
- Or you can disable any layout for a given view, like this:
1 2 3 | @{ Layout = null; } |
Extension methods¶
Class extensions¶
Suppose we have a simple class:
1 2 3 4 5 6 7 8 9 | using System.Collections.Generic; namespace LanguageFeatures.Models { public class ShoppingCart { public IEnumerable<Product> Products { get; set; } } } |
We want to extend its functionality with a new method to calculate the total amount:
1 2 3 4 5 6 7 8 9 10 | public static decimal TotalPrices(this ShoppingCart cartParam) { decimal total = 0; foreach (Product prod in cartParam.Products) { total += prod?.Price ?? 0; } return total; } |
We can then use this extension method like this:
1 2 | ShoppingCart cart = new ShoppingCart { Products = Product.GetProducts() }; decimal cartTotal = cart.TotalPrices(); |
Interface extensions¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | using System.Collections; using System.Collections.Generic; namespace LanguageFeatures.Models { public class ShoppingCart : IEnumerable<Product> { public IEnumerable<Product> Products { get; set; } public IEnumerator<Product> GetEnumerator() { return Products.GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); } } } |
We can rewrite our extension method like this:
1 2 3 4 5 6 7 8 9 10 | public static decimal TotalPrices(this IEnumerable<Product> products) { decimal total = 0; foreach (Product prod in products) { total += prod?.Price ?? 0; } return total; } |
and use it with any object of type IEnumerable<Product>
like ShoppingCart
and Product
array:
1 2 3 4 5 6 7 8 9 10 | ShoppingCart cart = new ShoppingCart { Products = Product.GetProducts() }; Product[] productArray = { new Product {Name = "Kayak", Price = 275M}, new Product {Name = "Lifejacket", Price = 48.95M} }; decimal cartTotal = cart.TotalPrices(); decimal arrayTotal = productArray.TotalPrices(); |
Filtering¶
Extension methods can be used to filter
collections of objects. An extension method that operates on an IEnumerable<T>
and that also returns an IEnumerable<T>
can use the yield
keyword to apply selection criteria to items in the source data to produce a reduced set of results.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | using System.Collections.Generic; namespace LanguageFeatures.Models { public static class MyExtensionMethods { public static IEnumerable<Product> FilterByPrice( this IEnumerable<Product> productEnum, decimal minimumPrice) { foreach (Product prod in productEnum) { if ((prod?.Price ?? 0) >= minimumPrice) { yield return prod; } } } } } |
Lambda anonymous functions¶
We can repeat this process indefinitely and create a different filter method for every property and every combination of properties that we are interested in. A more elegant approach is to separate out the code that processes the enumeration from the selection criteria. C# makes this easy by allowing functions to be passed around as objects. We can then create a single extension method that filters an enumeration of Product objects but that delegates the decision about which ones are included in the results to a separate function.
1 2 3 4 5 6 7 8 9 10 11 12 | public static IEnumerable<Product> Filter( this IEnumerable<Product> productEnum, Func<Product, bool> selector) { foreach (Product prod in productEnum) { if (selector(prod)) { yield return prod; } } } |
Now, we can use this Filter
function as follows:
1 2 3 4 5 6 | decimal priceFilterTotal = productArray .Filter(p => (p?.Price ?? 0) >= 20) .TotalPrices(); decimal nameFilterTotal = productArray .Filter(p => p?.Name?[0] == 'S') .TotalPrices(); |
Lambdas
can also be used in class properties, e.g.:
1 | public bool NameBeginsWithS => Name?[0] == 'S'; |
Asynchronous methods¶
Add "System.Net.Http": "4.1.0"
dependency in project.json
.
Here is how we could make an asynchronous call “the hard way”:
1 2 3 4 5 6 7 8 9 10 | public static Task<long?> GetPageLengthWithoutAsyncAwait() { HttpClient client = new HttpClient(); var httpTask = client.GetAsync("http://apress.com"); // we could do other things here while the HTTP request is performed return httpTask.ContinueWith((Task<HttpResponseMessage> antecedent) => { return antecedent.Result.Content.Headers.ContentLength; }); } |
And here is the clever way:
1 2 3 4 5 6 7 | public static async Task<long?> GetPageLength() { HttpClient client = new HttpClient(); var httpMessage = await client.GetAsync("http://apress.com"); // we could do other things here while the HTTP request is performed return httpMessage.Content.Headers.ContentLength; } |
And we could use this in our controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | using LanguageFeatures.Models; using Microsoft.AspNetCore.Mvc; using System.Threading.Tasks; namespace LanguageFeatures.Controllers { public class HomeController : Controller { public async Task<ViewResult> Index() { long? length = await MyAsyncMethods.GetPageLength(); return View(new string[] { $"Length: {length}" }); } } } |
Getting property names with nameof¶
If we use lambdas the classic way:
1 | products.Select(p => $"Name: {p.Name}, Price: {p.Price}") |
we of course get no intellisense on Name:
and Price:
. So, if we change the property name in the class and forget to change these strings here, we get a mismatch.
Fortunately, we can now rewrite the same code like this:
1 | products.Select(p => $"{nameof(p.Name)}: {p.Name}, {nameof(p.Price)}: {p.Price}") |
but then we will get intellisense and type safety.
Workflow with an empty project¶
- create a new empty ASP.NET Core project
- add
"Microsoft.AspNetCore.Mvc": "1.0.1"
dependency inproject.json
- add
"System.Net.Http": "4.1.0"
dependency inproject.json
- add
Mvc
toStartup.cs
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace LanguageFeatures { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddMvc(); } public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) { app.UseMvcWithDefaultRoute(); } } } |
The JSON Configuration Files¶
Name | Description |
---|---|
global.json | This file, which is found in the Solution Items folder, is responsible for telling Visual Studio where to find the projects in the solution and which version of the .NET execution environment should be used to run the application. |
launchSettings.json | This file, which is revealed by expanding the Properties item in the MVC application project, is used to specify how the application is started. |
appsettings.json | This file is used to define application-specific settings. |
bower.json | This file is used by Bower to list the client-side packages that are installed into the project. |
bundleconfig.json | This file is used to bundle and minify JavaScript and CSS files. |
project.json | This file is used to specify the NuGet packages that are installed into the application. This file is also used for other project settings. |
project.lock.json | This file, which is revealed by expanding the project.json item in the Solution Explorer, contains detailed dependencies between packages installed in the project. It is generated automatically and should not be edited manually. |
Razor¶
View Imports File¶
Use _ViewImports.cshtml
file in the Views
folder to specify the standard include namespaces. Then you can omit them in the individual views.
View Start File¶
Normally, we have to specify the layout file we want in every view. Therefore, if we need to rename the layout file, we are going to have to find every view that refers to it and make a change, which will be an error-prone process and counter to the general theme of easy maintenance that runs through MVC development.
We can resolve this by using a view start file
. When it renders a view, MVC will look for a file called _ViewStart.cshtml
and we can put it in the Views
folder. The contents of this file will be treated as though they were contained in the view file itself, and we can use this feature to automatically set a value for the Layout property.
1 2 3 | @{ Layout = "_BasicLayout"; } |
Now, in the views that should use this _BasicLayout
, we can omit the layout line.
We do not have to specify that we want to use the view start file. MVC will locate the file and use its contents automatically. The values defined in the view file take precedence, which makes it easy to override the view start file.
We can also use multiple view start files
to set defaults for different parts of the application. Razor looks for the closest view start file to the view that it being processed, which means that you can override the default setting by adding a view start file to the Views/Home
or Views/Shared
folders, for example.
Caution
It is important to understand the difference between omitting the Layout property
from the view file and setting it to null
. If your view is self-contained and you do not want to use a layout, then set the Layout property to null. If you omit the Layout property, then MVC will assume that you do want a layout and that it should use the value it finds in the view start file.
ViewBag property¶
The ViewBag
property returns a dynamic object
that can be used to define arbitrary properties. Since the ViewBag
is dynamic, we don’t have to declare the property names in advance, but it does mean that Visual Studio is unable to provide autocomplete suggestions for view bag properties.
Attribute values¶
We can also use Razor expressions to set the value of element attributes
, not only on the content.
1 2 3 4 5 | <div data-productid="@Model.ProductID" data-stocklevel="@ViewBag.StockLevel"> <p>Product Name: @Model.Name</p> <p>Product Price: @($"{Model.Price:C2}")</p> <p>Stock Level: @ViewBag.StockLevel</p> </div> |
Switch statement¶
We have to cast the dynamic ViewBag properties to the right type once, inside the condition:
1 | @switch ((int)ViewBag.StockLevel) {...} |
After that, inside the body, we dont need to do it any more:
1 2 3 | default: @: @ViewBag.StockLevel in Stock break; |
We do not have to put the elements or expressions in quotes or denote them in any special way—the Razor engine will interpret these as output to be processed. However, if we want to insert literal text into the view when it is not contained in an HTML element, then we need to give Razor a helping hand and prefix the line with an @
character (see above).
Visual Studio tips and tricks¶
Enable Developer Exception Page¶
In Startup.cs
add this line to the Configure
method:
1 | app.UseDeveloperExceptionPage(); |
Browser Link Loader¶
Add
1 | "Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.0.0" |
to project.json
dependencies list. Also add
1 | app.UseBrowserLink(); |
to the Configure
method of Startup.cs
.
Run the project without debugging (Ctrl + F5) and you see this kind of code added to the HTML:
1 2 3 4 5 6 7 8 9 | <!-- Visual Studio Browser Link --> <script type="application/json" id="__browserLink_initializationData"> {"requestId":"497a61f26544432a857e06ab5d501b7f","requestMappingFromServer":false} </script> <script type="text/javascript" src="http://localhost:2701/029471b4ee954e43adc0d452954d080f/browserLink" async="async"> </script> <!-- End Browser Link --> |
Alas
I still see no added value to it. :( Unless it is just the synchronized browsing using multiple browsers. I also see this error in the browser console:
[14:07:17 GMT+0200 (West-Europa (zomertijd))] Browser Link: Failed to invoke return value
callback: TypeError: Cannot read property 'files' of null
Static files¶
ASP.NET Core includes support for delivering static files
from the wwwroot folder to clients but it isn’t enabled by default when the Empty template is used to create the project. To enable static file support, add
1 | "Microsoft.AspNetCore.StaticFiles": "1.0.0" |
to project.json
dependencies list. Also add
1 | app.UseStaticFiles(); |
to the Configure
method of Startup.cs
.
Bundling and minifying¶
Install Bundler and Minifier
extension for the Visual Studio. After that it is possible to add css
or js
files to the bundle by selecting them one by one and choosing Bundler & Minifier | Bundle and Minify Files (Shift + Alt + F)
from the right mouse button menu.
This will create a bundle.css
or bundle.js
file and also bundleconfig.json
in the project root folder. Make sure the order of the files is what you need as loading order
.
Unit Testing with xUnit Framework¶
Preparation¶
- create
test
folder inside the solution folder next tosrc
folder - add new
.NET Core | Class Library (.NET Core)
project inside thetest
folder - add this code to its
project.json
file (check the latest versions):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
{ "version": "1.0.0-*", "testRunner": "xunit", "dependencies": { "Microsoft.NETCore.App": { "type": "platform", "version": "1.0.1" }, "xunit": "2.1.0", "dotnet-test-xunit": "2.2.0-preview2-build1029" }, "frameworks": { "netcoreapp1.0": { "imports": ["dotnet5.6", "portable-net45+win8"] } } }
- this configuration tells Visual Studio that three packages are required:
- the
Microsoft.NETCore.App
package provides the.NET Core API
. - the
xunit
package provides the testing framework. - the
dotnet-test-xunit
package provides the integration betweenxUnit
andVisual Studio
.
- the
- add the main project reference to the dependencies, e.g.
1 2 3 4 5 6 7
{ "dependencies": { ... "WorkingWithVisualStudio": "1.0.0" ... } }
Fact and Theory¶
- In the
xUnit
framework, aFact
is one single unit test. Example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
[Fact] public void IndexActionModelIsComplete() { // Arrange var controller = new HomeController(); controller.Repository = new ModelCompleteFakeRepository(); // Act var model = (controller.Index() as ViewResult)?.ViewData.Model as IEnumerable<Product>; // Assert Assert.Equal(controller.Repository.Products, model, Comparer.Get<Product>((p1, p2) => p1.Name == p2.Name && p1.Price == p2.Price)); }
- A
Theory
is a way to parametrize the unit test in such a way that it becomes possible to run the same test multiple times, each time with a different set of parameter values. Example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
[Theory] [InlineData(275, 48.95, 19.50, 24.95)] [InlineData(5, 48.95, 19.50, 24.95)] public void IndexActionModelIsComplete(decimal price1, decimal price2, decimal price3, decimal price4) { // Arrange var controller = new HomeController(); controller.Repository = new ModelCompleteFakeRepository { Products = new Product[] { new Product {Name = "P1", Price = price1 }, new Product {Name = "P2", Price = price2 }, new Product {Name = "P3", Price = price3 }, new Product {Name = "P4", Price = price4 }, } }; // Act var model = (controller.Index() as ViewResult)?.ViewData.Model as IEnumerable<Product>; // Assert Assert.Equal(controller.Repository.Products, model, Comparer.Get<Product>((p1, p2) => p1.Name == p2.Name && p1.Price == p2.Price)); }
Getting Test Data from a Method or Property¶
We can create methods or properties giving us the enumerations for the test parameter values in a separate class, e.g. ProductTestData.cs
. Then we can use MemberData
attribute to specify it for the unit test to use. Example:
1 2 3 | [Theory] [ClassData(typeof(ProductTestData))] public void IndexActionModelIsComplete(Product[] products ) {...} |
If we want to include the test data in the same class as the unit tests, then you can use the MemberData
attribute instead of ClassData
. The MemberData
attribute is configured using a string that specifies the name of a static method that will provide an IEnumerable<object[]>
, where each object array in the sequence is a set of arguments for the test method. Example:
1 2 3 | [Theory] [MemberData("GetData")] public void IndexActionModelIsComplete(Product[] products ) {...} |
and the GetData
should look something like this
1 2 3 4 5 6 7 8 9 | public static IEnumerable<object[]> GetData { get { // ... yield return new object[] { 8, 21 }; yield return new object[] { 16, 987 }; } } |
Every yield
statement should return an array of objects to substitute for the Product properties. And the method itself should be of type IEnumerable<object[]>
.
Mocking with MOQ¶
Microsoft
created a special fork of the Moq
project and ported it to work with .NET Core
.
In order to be able to install the Moq NuGet package
, we need first to configure the NuGet Options
. Open Tools | Options | NuGet Package Manager
, click on Package Sources
and then on the green plus sign. Configure the new package source as follows:
Name: ASP.NET Contrib
Source: https://www.myget.org/F/aspnet-contrib/api/v3/index.json
Add these two packages to the package.json
in the test project:
1 2 | "moq.netcore": "4.4.0-beta8" "System.Diagnostics.TraceSource": "4.0.0" |
Usage example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | [Fact] public void RepositoryPropertyCalledOnce() { // Arrange var mock = new Mock<IRepository>(); mock.SetupGet(m => m.Products) .Returns(new[] { new Product { Name = "P1", Price = 100 } }); var controller = new HomeController { Repository = mock.Object }; // Act var result = controller.Index(); // Assert mock.VerifyGet(m => m.Products, Times.Once); } |
Explanation:
var mock = new Mock<IRepository>();
- define the interface to be mockedSetupGet(m => m.Products)
- specify the property to be tested.Returns(...)
- specify the test value to be returnedRepository = mock.Object
-Object
is the special property that gives back the object we mock for this testmock.VerifyGet(m => m.Products, Times.Once);
- one of the verify methods to inspect the getter property
SportsStore¶
- Create a new empty
ASP.NET Core Web Application (.NET Core)
project/solution. - Add these packages/tools to the
project.json
file:
1 2 3 4 5 6 7 8 9 10 11 12 13
"dependencies": { ... "Microsoft.AspNetCore.Razor.Tools": { "version": "1.0.0-preview2-final", "type": "build" }, "Microsoft.AspNetCore.StaticFiles": "1.0.0", "Microsoft.AspNetCore.Mvc": "1.0.1" }, "tools": { "Microsoft.AspNetCore.Razor.Tools": "1.0.0-preview2-final", ... },
- In addition to the packages in the
dependencies
section, there is an addition to thetools
section of theproject.json
file that configures theMicrosoft.AspNetCore.Razor.Tools
package for use in Visual Studio and enables IntelliSense for the built-in tag helpers, which are used to create HTML content that is tailored to the configuration of the MVC application. - Here is the
Startup.cs
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace SportsStore { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddMvc(); } public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) { app.UseDeveloperExceptionPage(); app.UseStatusCodePages(); app.UseStaticFiles(); app.UseMvcWithDefaultRoute(); } } }
- The
ConfigureServices
method is used to set up shared objects that can be used throughout the application through thedependency injection
feature. TheAddMvc
method that is called in theConfigureServices
method is an extension method that sets up the shared objects used in MVC applications. - The
Configure
method is used to set up the features that receive and processHTTP requests
. - add these folders:
Models
,Controllers
,Views
- in the
Views
folder add_ViewImports.cshtml
file:
1 2
@using SportsStore.Models @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
- create the unit test project
SportsStore.Tests
in thetest
folder. - Configure its
project .json
as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
{ "version": "1.0.0-*", "testRunner": "xunit", "dependencies": { "Microsoft.NETCore.App": { "type": "platform", "version": "1.0.1" }, "xunit": "2.1.0", "dotnet-test-xunit": "2.2.0-preview2-build1029", "moq.netcore": "4.4.0-beta8", "System.Diagnostics.TraceSource": "4.0.0", "SportsStore": "1.0.0" }, "frameworks": { "netcoreapp1.0": { "imports": [ "dotnet5.6", "portable-net45+win8" ] } } }
- Make sure that both
project.json
files refer to the sameASP.NET Core MVC
version. - Add your model class
Product.cs
, repository interfaceIProductRepository
and a simple fake repositoryFakeProductRepository
- Register the fake repository as a service in
Startup.cs
:
1 2 3 4 5
public void ConfigureServices(IServiceCollection services) { services.AddTransient<IProductRepository, FakeProductRepository>(); ... }
- Add the following standard views:
Views/Shared/_Layout.cshtml
Views/_ViewStart.cshtml
refering to_Layout.cshtml
by default- Example of setting up the default route in
Startup.cs
. Substitute
1
app.UseMvcWithDefaultRoute();
- with
1 2 3 4 5 6
app.UseMvc(routes => { routes.MapRoute( name: "default", template: "{controller=Product}/{action=List}/{id?}"); });
Scaffolding¶
Some people prefer to have certain features automatically created when they create new controller or views. That is called scaffolding
. If you want that, add the following packages to the project.json
file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | ... "dependencies": { ... "Microsoft.AspNetCore.StaticFiles": "1.0.0", "Microsoft.AspNetCore.Mvc": "1.0.0", "Microsoft.VisualStudio.Web.CodeGeneration.Tools": { "version": "1.0.0-preview2-final", "type": "build" }, "Microsoft.VisualStudio.Web.CodeGenerators.Mvc": { "version": "1.0.0-preview2-final", "type": "build" } } ... "tools": { ... "Microsoft.VisualStudio.Web.CodeGeneration.Tools": { "version": "1.0.0-preview2-final", "imports": [ "portable-net45+win8+dnxcore50", "portable-net45+win8" ] } } ... |
Setup the database¶
- add
EntityFramework
packages topackage.json
:
1 2 3 4 5
"dependencies": { ... "Microsoft.EntityFrameworkCore.SqlServer": "1.0.1", "Microsoft.EntityFrameworkCore.Tools": "1.0.0-preview2-final" }
- and the tools:
1 2 3 4 5 6 7
"tools": { ... "Microsoft.EntityFrameworkCore.Tools": { "version": "1.0.0-preview2-final", "imports": [ "portable-net45+win8+dnxcore50", "portable-net45+win8" ] } }
- The
database context class
is the bridge between theapplication
and theEF Core
and provides access to the application’s data using model objects. To create the database context class for theSportsStore
application, we add a class file calledApplicationDbContext.cs
to theModels
folder:
1 2 3 4 5 6 7 8 9 10 11
using Microsoft.EntityFrameworkCore; namespace SportsStore.Models { public class ApplicationDbContext : DbContext { public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { } public DbSet<Product> Products { get; set; } } }
- To populate the database initially with some data, we use
SeedData.cs
class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
using System.Linq; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; namespace SportsStore.Models { public static class SeedData { public static void EnsurePopulated(IApplicationBuilder app) { ApplicationDbContext context = app.ApplicationServices.GetRequiredService<ApplicationDbContext>(); if (!context.Products.Any()) { context.Products.AddRange( new Product { Name = "Kayak", Description = "A boat for one person", Category = "Watersports", Price = 275 }, ... context.SaveChanges(); } } } }
- The static
EnsurePopulated
method receives anIApplicationBuilder
argument, which is the class used in theConfigure
method of theStartup
class to register middleware classes to handle HTTP requests, which is where we ensure that the database has content. - The
EnsurePopulated
method obtains anApplicationDbContext
object through theIApplicationBuilder
interface and uses it to check whether there are anyProduct
objects in the database. If there are no objects, then the database is populated using a collection ofProduct
objects using theAddRange
method and then written to the database using theSaveChanges
method. - The next step is to create a class that implements the
IProductRepository
interface and gets its data usingEntity Framework Core
from theApplicationDbContext
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
using System.Collections.Generic; namespace SportsStore.Models { public class EFProductRepository : IProductRepository { private ApplicationDbContext context; public EFProductRepository(ApplicationDbContext ctx) { context = ctx; } public IEnumerable<Product> Products => context.Products; } }
- create
appsettings.json
file in the project root folder based on theASP.NET Configuration File
template and configure the connection string:
1 2 3 4 5 6 7
{ "Data": { "SportStoreProducts": { "ConnectionString": "Server=(localdb)\\MSSQLLocalDB;Database=SportsStore;Trusted_Connection=True;MultipleActiveResultSets=true" } } }
- add a new dependency to read the
json
configuration file:
1 2 3 4
"dependencies": { ... "Microsoft.Extensions.Configuration.Json": "1.0.0" },
- configure the
Startup.cs
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
... using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; namespace SportsStore { public class Startup { IConfigurationRoot Configuration; public Startup(IHostingEnvironment env) { Configuration = new ConfigurationBuilder() .SetBasePath(env.ContentRootPath) .AddJsonFile("appsettings.json").Build(); } public void ConfigureServices(IServiceCollection services) { services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(Configuration["Data:SportStoreProducts:ConnectionString"])); services.AddTransient<IProductRepository, EFProductRepository>(); ... } public void Configure( IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) { ... SeedData.EnsurePopulated(app); } } }
- open
Tools | Nuget Package Manager | Package Manager Console
to create and apply database migrations:
1 2
Add-Migration Initial Update-Database
- rebuild the solution and run - we will see the list of products loaded from the database.
ViewModels and TagHelpers¶
- If we want to customize the information to be used in the view, we can do that with
ViewModels
. For that we create aViewModels
subfolder inside theModels
folder. They canbe registered
in_ViewImports.cshtml
. TagHelpers
are one of the most useful ways that you can introduce C# logic into your views. The code for a tag helper can look tortured because C# and HTML don’t mix easily. But using tag helpers is preferable to including blocks of C# code in a view because a tag helper can be easilyunit tested
. Most MVC components, such as controllers and views, are discovered automatically, but tag helpershave to be registered
in_ViewImports.cshtml
.- To test the
tag helper
class, I call theProcess
method with test data and provide aTagHelperOutput
object that is inspected to see the HTML that was generated. - When we pass the data to the view via a view model, we need to replace the type. E.g. it can become
@model ProductsListViewModel
andModel.Products
instead of@model IEnumerable<Product>
andModel
.
Installing Bootstrap package¶
Add bower.json
file to the project root:
1 2 3 4 5 6 7 | { "name": "asp.net", "private": true, "dependencies": { "bootstrap": "3.3.7" } } |
This will add wwwroot/lib
folder and inside it bootstrap
and jquery
sources.
Improving the URLs¶
Normally, the page links look like this:
http://localhost/?page=2
But we can make them more user friendly by creating a scheme that follows the pattern of composable URLs
, which look like this:
http://localhost/Page2
To do that we register a new route in our MVC middleware (Configure
method in Startup.cs
):
1 2 3 4 | routes.MapRoute( name: "pagination", template: "Products/Page{page}", defaults: new { Controller = "Product", action = "List" }); |
It is important that this route is added before
the default route.
Enabling Sessions¶
Add these new packages to the package.json
:
1 2 3 | "Microsoft.AspNetCore.Session": "1.0.0", "Microsoft.Extensions.Caching.Memory": "1.0.0", "Microsoft.AspNetCore.Http.Extensions": "1.0.0" |
Register new services and middleware to the Startup.cs
:
1 2 3 4 5 6 7 8 9 10 11 12 | public void ConfigureServices(IServiceCollection services) { ... services.AddMemoryCache(); services.AddSession(); services.AddMvc(); } public void Configure(IApplicationBuilder app, ... app.UseSession(); app.UseMvc(...); } |
AddMemoryCache
The AddMemoryCache
method call sets up the in-memory data store. The AddSession
method registers the services used to access session data, and the UseSession
method allows the session system to automatically associate requests with sessions when they arrive from the client.
Annotations¶
BindNever
attribute prevents the user supplying values for these properties in an HTTP request.
Adding migrations¶
Suppose we add a new DbSet
to our ApplicationDbContext.cs
:
1 2 3 4 5 6 7 | public class ApplicationDbContext : DbContext { public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { } ... public DbSet<Order> Orders { get; set; } } |
To create the migration
, open the NuGet Package Manger Console
from the Tools ➤ NuGet Package Manage
menu and run the following command :
Add-Migration Orders
This command tells EF Core to take a new snapshot of the application, work out how it differs from the previous database version, and generate a new migration called Orders
. The name Orders
here is arbitrary but it is handy to let it reflect the change.
To update the database schema, run the following command:
Update-Database
Resetting the Database¶
When you are making frequent changes to the model, there will come a point when your migrations and your database schema get out of sync. The easiest thing to do is delete the database and start over. However, this applies only during development, of course, because you will lose any data you have stored.
- Select the
SQL Server Object Explorer
item from the Visual StudioView
menu and click theAdd Sql Server
button. - Enter
(localdb)\mssqllocaldb
into theServer Name
field and click theConnect
button. A new item will appear in theSQL Server Object Explorer
window, which you can expand to see theLocalDB
databases that have been created. - Right-click the database you want to remove and select
Delete
from the pop-up menu.Check the option to close the existing connections
and then click theOK
button to delete the database. - Once the database has been removed, run the following command from the
Package Manager Console
to create the database and apply the migrations you have created by running the following command:
Update-Database
- This will reset the database so that it accurately reflects your model and allow you to return to developing your application.
Using TempData¶
We are using TempData in the POST method in a controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | [HttpPost] public IActionResult Edit(Product product) { if (ModelState.IsValid) { _repository.SaveProduct(product); TempData["message"] = $"{product.Name} has been saved"; return RedirectToAction("Index"); } else { // there is something wrong with the data values return View(product); } } |
We check that the model binding process has been able to validate the data submitted to the user by reading the value of the ModelState.IsValid
property. If everything is OK, we save the changes to the repository and redirect the user to the Index
action so they see the modified list of products. If there is a problem with the data, we render the default view again so that the user can make corrections.
After we have saved the changes in the repository, we store a message using the TempData
feature, which is part of the ASP.NET Core session state
feature. This is a key/value
dictionary similar to the session data
and view bag
features we used previously.
The key difference from session data
is that temp data
persists until it is read. We cannot use ViewBag
in this situation because ViewBag
passes data between the controller
and view
, and it cannot hold data for longer than the current HTTP request
. When an edit succeeds, the browser is redirected to a new URL, so the ViewBag
data is lost.
We could use the session data
feature, but then the message would be persistent until we explicitly removed it, which we would rather not have to do.
So, the temp data
feature is the perfect fit. The data is restricted to a single user’s session
(so that users do not see each other’s TempData
) and will persist long enough for us to read it. We will read the data in the view
rendered by the action method
to which we redirect the user
The message will be displayed once and disappear if you reload the screen with the template using this temp data
, because TempData
is deleted when it is read. That is convenient since we do not want old messages hanging around.
Localization hell¶
By the time we get to editing with validation, something bad happens. It looks that by default the jQuery-validation
(client side validation) expects "en-US"
as its culture and the server side validation expects "nl-NL"
. Therefore, when I see €
as currency and ","
as decimal separator, I cannot change the price. Neither ","
nor "."
are accepted. One is rejected by the client side and the other by the server side validation.
Temporary workaround was to configure "en-US"
as the default culture, so that "$"
and "."
are displayed on the screen. Then the validation works.
Adding Identity¶
Add a new dependency to project.json
:
1 2 3 4 | "dependencies": { ... "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "1.0.0" } |
Add a new AppIdentityDbContext
class to Models
folder:
1 2 3 4 5 6 7 8 9 10 11 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace SportsStore.Models { public class AppIdentityDbContext : IdentityDbContext<IdentityUser> { public AppIdentityDbContext(DbContextOptions<AppIdentityDbContext> options) : base(options) { } } } |
and a new connection string in appsettings.json
file:
1 2 3 4 5 6 7 8 | { "Data": { ... "SportStoreIdentity": { "ConnectionString": "Server=(localdb)\\MSSQLLocalDB;Database=Identity;Trusted_Connection=True;MultipleActiveResultSets=true" } } } |
Add new services to ConfigureServices
in Startup.cs
:
1 2 3 4 5 6 7 8 9 10 | public void ConfigureServices(IServiceCollection services) { ... services.AddDbContext<AppIdentityDbContext>(options => options.UseSqlServer(Configuration["Data:SportStoreIdentity:ConnectionString"])); services.AddIdentity<IdentityUser, IdentityRole>() .AddEntityFrameworkStores<AppIdentityDbContext>(); ... } |
and new entries in Configure
:
1 2 3 4 5 6 | public void Configure() app.UseIdentity(); app.UseMvc(); SeedData.EnsurePopulated(app); IdentitySeedData.EnsurePopulated(app); } |
IdentitySeedData
should be created in the Models
folder to create an administrative account.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace SportsStore.Models { public static class IdentitySeedData { private const string adminUser = "Admin"; private const string adminPassword = "Secret123$"; public static async void EnsurePopulated(IApplicationBuilder app) { UserManager<IdentityUser> userManager = app.ApplicationServices .GetRequiredService<UserManager<IdentityUser>>(); IdentityUser user = await userManager.FindByIdAsync(adminUser); if (user == null) { user = new IdentityUser("Admin"); await userManager.CreateAsync(user, adminPassword); } } } } |
Adding Identity migrations¶
Add-Migration Initial -Context AppIdentityDbContext
Update-Database -Context AppIdentityDbContext
This will create the new database and add the AspNetUsers
and AspNetRoles
in it.
Adding Authorization¶
When ASP.NET Core Identity
is in place, we can apply Authorization
. We don’t want to stop unauthenticated users from accessing the other action methods in the Order
controller, so we have applied the Authorize
attribute only to the List
and MarkShipped
methods.
1 2 3 4 5 6 7 | [Authorize] public ViewResult List() => View(_repository.Orders.Where(o => !o.Shipped)); [HttpPost] [Authorize] public IActionResult MarkShipped(int orderID) {...} |
We want to protect all of the action methods defined by the Admin
controller, and we can do this by applying the Authorize
attribute to the controller class, which then applies the authorization policy to all the action methods it contains.
1 2 3 | [Authorize] public class AdminController : Controller {...} |
Caution
In general, using client-side data validation is a good idea. It offloads some of the work from your server and gives users immediate feedback about the data they are providing. However, you should not be tempted to perform authentication at the client, as this would typically involve sending valid credentials to the client so they can be used to check the username and password that the user has entered, or at least trusting the client’s report of whether they have successfully authenticated. Authentication should always be done at the server.