More Details About Razor Pages In ASP.NET Core 2.0

Introduction

In this article, you will learn more basic usages and details about Razor Pages in ASP.NET Core 2.0.

Background

In my last article - Building A Simple Web App Using Razor Pages, I built a simple web app to show you the new feature named Razor Pages in ASP.NET Core 2.0. However, it doesn't contain some useful and basic functions. So, I decided to write one more article to show more details of Razor Pages.

What I will show you in this article is as follows-

  • Directives
  • Tag Helpers
  • Change the Root Directory
  • Authorize Pages
  • Something about Index.cshtml

Directives

@page

This directive must be the first Razor directive on a Razor Page. If you put other directives before @page, you may get a Not Found(404) result on your browser!.

By the way, if you don't use this directive in Razor Pages, the RouteModels of context will not route it so that you will get a Not Found(404) result as well.

We can find the reason via RazorProjectPageRouteModelProvider’s OnProvidersExecuting method.

  1. public void OnProvidersExecuting(PageRouteModelProviderContext context)  
  2. {  
  3.     foreach (var item in _project.EnumerateItems(_pagesOptions.RootDirectory))  
  4.     {  
  5.         if (item.FileName.StartsWith("_"))  
  6.         {  
  7.             // Pages like _ViewImports should not be routable.  
  8.             continue;  
  9.         }  
  10.   
  11.         if (!PageDirectiveFeature.TryGetPageDirective(_logger, item, out var routeTemplate))  
  12.         {  
  13.             // .cshtml pages without @page are not RazorPages.  
  14.             continue;  
  15.         }  
  16.   
  17.         var routeModel = new PageRouteModel(  
  18.             relativePath: item.CombinedPath,  
  19.             viewEnginePath: item.FilePathWithoutExtension);  
  20.         PageSelectorModel.PopulateDefaults(routeModel, routeTemplate);  
  21.   
  22.         context.RouteModels.Add(routeModel);  
  23.     }  
  24. }  

@functions

This directive is not a new directive, we also use it in an ASP.NET page as well. This directive can let us wrap up reusable code such as methods, and then we can call those methods from other parts of the page easily.

However, there is something we should notice.

In my last article, I used a separated version which contains two files for a page. You also can use a combined version which only contains a cshtml file for a page. At this time, @functions directive will help a lot.

Here is the sample of those two versions.

Combined version first:

  1. @page  
  2. @{  
  3.     @ViewData["Title"] = "combined version";  
  4. }  
  5.   
  6. @functions{  
  7.     public string Msg { get; set; }  
  8.   
  9.     public void OnGet()  
  10.     {  
  11.         this.Msg = "Catcher Wong";  
  12.     }  
  13. }  
  14. <h1>@Model.Msg</h1>  
  15. <p>Razor Pages with Combine</p>  

Separated versions do the same things like the previous code.

The cshtml file first -

  1. @page  
  2. @{  
  3.     @ViewData["Title"] = "combined version";  
  4. }  
  5. <h1>@Model.Msg</h1>  
  6. <p>Razor Pages with Combine</p>  

The cs file later -

  1. public class AboutModel : PageModel  
  2. {  
  3.     public string Msg { get; set; }  
  4.   
  5.     public void OnGet()  
  6.     {  
  7.         Message = "Catcher Wong";  
  8.     }  
  9. }  

@namespace and @model

@namespace directive is related to @model directive, so I put them together. The usage of the @model directive is the same as an ASP.NET page, so I will not introduce it again.

@namespace directive will set the namespace for the page. And after using the @namespace directive, you don't need to include the namespace anymore in the page so that it can make the @model simple.

Not only you can use @namespace directive in one of your pages but also  _ViewImports.cshtml which is a global setting of your Razor pages. See an example here:

Without @namespace directive

@model Web.PagesDemo.IndexModel

With @namespace directive

  1. @namespace Web.PagesDemo  
  2. @model IndexModel  
  3. @inject  

This directive means that the page uses @inject for constructor dependency injection.

@inject sample here ,

  1. @page  
  2. @namespace Web.PagesDemo  
  3. @model Index1Model  
  4. @inject IStudentService service  
  5.   
  6. @functions{   
  7.   
  8.     public IEnumerable<Student> StudentList;  
  9.   
  10.     public async Task OnGetAsync()  
  11.     {  
  12.         this.StudentList = await service.GetStudentListAsync();        
  13.     }  
  14. }  

Do the same thing like the above code not using @inject .

  1. public class IndexModel : PageModel    
  2. {    
  3.     private readonly IStudentService _studentService;    
  4.     public IndexModel(IStudentService studentService)    
  5.     {    
  6.         this._studentService = studentService;    
  7.     }    
  8.     
  9.     public IEnumerable<Student> StudentList;    
  10.     
  11.     public async Task OnGetAsync()    
  12.     {    
  13.         this.StudentList = await _studentService.GetStudentListAsync();    
  14.     }    
  15. }  

Razor Pages allow us to use two ways to finish it - one is combined, the other is separated. As for me, I prefer to use the separated one because it makes the pages more clear and make us focus on the business.

Tag Helpers

There are some Tag Helpers we may often use in Razor Pages.

asp-page and asp-route-

asp-page can help us to define the route more conveniently. 

  1. <a asp-page="/Students/Index">Index</a>  

Sometimes, the link contains route data with other information, such as Id, Name etc. At this scenario, we need to combine asp-page and asp-route- to do this job.

For example, when we want to edit the information of a student, we may visit http://localhost:5000/Edit/1 or http://localhost:5000/Edit?id=1 to open the edit page at first.

Here, we use asp-route- to define more route data.

  1. <a asp-page="/Students/Edit" asp-route-id="1">Edit</a>  

At this time, it will generate the link like this : http://localhost:5000/Edit?id=1. This link contains a query string ?id=1!

ASP.NET Core 2.0

But sometimes, we will customize the route to make the linksimpler. For the above generated link, we may expect that it will generate http://localhost:5000/Edit/1 

We can customize the route by adding a route template enclosed in double quotes after the @page directive.

@page "{id}"...

Now, you may find that the generated link is as we expect.

ASP.NET Core 2.0

Note

  1. The value of asp-page is a relative path. And it can help us to build websites with a complex structure.
  2. When customizing the route , we also can add constraint on the route template. For example , we can restrict that the id must be an integer , so we can edit the route template like this {id:int} .

asp-page-handler

asp-page-handler can help us deal with multiple handlers in one page .

Let's explanain what it means.

Assuming that there are two buttons in a page, one of them will create a record in database, the other one will remove a record in database. How can we do that? Maybe you will use the JAVASCRIPT to finish it but you also can finish it using something special in Razor Pages.

For the above scenario, what the two buttons do can be considered acting as multiple handlers.

And now, I will take an example to show you how to use this Tag Helper.

  1. @page  
  2. @namespace Web.PagesDemo  
  3. @model IndexModel  
  4. @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers  
  5. <html xmlns="http://www.w3.org/1999/xhtml">  
  6. <head>  
  7. <title></title>  
  8. </head>  
  9. <body>  
  10.     <form method="post" >     
  11.         <button type="submit" asp-page-handler="Test">Test</button>  
  12.         <button type="submit" asp-page-handler="Other">Btn-Other</button>  
  13.     </form>  
  14.   
  15. <p>@Model.Msg</p>  
  16. </body>  
  17. </html>  
  18.   
  19. public class IndexModel : PageModel  
  20. {          
  21.     public string Msg { get; set; }  
  22.   
  23.     public void OnGet()  
  24.     {  
  25.         this.Msg = "Catcher";  
  26.     }  
  27.   
  28.     public void OnPostTest()  
  29.     {  
  30.         this.Msg = "POST-Test";  
  31.     }  
  32.   
  33.     public void OnPostOther()  
  34.     {  
  35.         this.Msg = "POST-Other";  
  36.     }  
  37. }  

The preceding code uses named handler methods. Named handler methods are created by taking the text in the name after On<HTTP Verb> and before Async (if present). In the preceding example, the page methods are OnPostTest and OnPostOther. With OnPost and Async removed, the handler names are Test and Other.

Let's take a look at what the Razor Pages do to process the handlers.

  1. public class DefaultPageHandlerMethodSelector : IPageHandlerMethodSelector  
  2. {  
  3.     private const string Handler = "handler";  
  4.   
  5.     public HandlerMethodDescriptor Select(PageContext context)  
  6.     {  
  7.         var handlers = SelectHandlers(context);  
  8.         if (handlers == null || handlers.Count == 0)  
  9.         {  
  10.             return null;  
  11.         }  
  12.   
  13.         List<HandlerMethodDescriptor> ambiguousMatches = null;  
  14.         HandlerMethodDescriptor bestMatch = null;  
  15.         for (var score = 2; score >= 0; score--)  
  16.         {  
  17.             for (var i = 0; i < handlers.Count; i++)  
  18.             {  
  19.                 var handler = handlers[i];  
  20.                 if (GetScore(handler) == score)  
  21.                 {  
  22.                     if (bestMatch == null)  
  23.                     {  
  24.                         bestMatch = handler;  
  25.                         continue;  
  26.                     }  
  27.   
  28.                     if (ambiguousMatches == null)  
  29.                     {  
  30.                         ambiguousMatches = new List<HandlerMethodDescriptor>();  
  31.                         ambiguousMatches.Add(bestMatch);  
  32.                     }  
  33.   
  34.                     ambiguousMatches.Add(handler);  
  35.                 }  
  36.             }  
  37.   
  38.             if (ambiguousMatches != null)  
  39.             {  
  40.                 var ambiguousMethods = string.Join(", ", ambiguousMatches.Select(m => m.MethodInfo));  
  41.                 throw new InvalidOperationException(Resources.FormatAmbiguousHandler(Environment.NewLine, ambiguousMethods));  
  42.             }  
  43.   
  44.             if (bestMatch != null)  
  45.             {  
  46.                 return bestMatch;  
  47.             }  
  48.         }  
  49.   
  50.         return null;  
  51.     }  
  52.   
  53.     private static List<HandlerMethodDescriptor> SelectHandlers(PageContext context)  
  54.     {  
  55.         var handlers = context.ActionDescriptor.HandlerMethods;  
  56.         List<HandlerMethodDescriptor> handlersToConsider = null;  
  57.   
  58.         var handlerName = Convert.ToString(context.RouteData.Values[Handler]);  
  59.   
  60.         if (string.IsNullOrEmpty(handlerName) &&  
  61.             context.HttpContext.Request.Query.TryGetValue(Handler, out StringValues queryValues))  
  62.         {  
  63.             handlerName = queryValues[0];  
  64.         }  
  65.   
  66.         for (var i = 0; i < handlers.Count; i++)  
  67.         {  
  68.             var handler = handlers[i];  
  69.             if (handler.HttpMethod != null &&  
  70.                 !string.Equals(handler.HttpMethod, context.HttpContext.Request.Method, StringComparison.OrdinalIgnoreCase))  
  71.             {  
  72.                 continue;  
  73.             }  
  74.             else if (handler.Name != null &&  
  75.                 !handler.Name.Equals(handlerName, StringComparison.OrdinalIgnoreCase))  
  76.             {  
  77.                 continue;  
  78.             }  
  79.   
  80.             if (handlersToConsider == null)  
  81.             {  
  82.                 handlersToConsider = new List<HandlerMethodDescriptor>();  
  83.             }  
  84.   
  85.             handlersToConsider.Add(handler);  
  86.         }  
  87.   
  88.         return handlersToConsider;  
  89.     }  
  90.   
  91.     private static int GetScore(HandlerMethodDescriptor descriptor)  
  92.     {  
  93.         if (descriptor.Name != null)  
  94.         {  
  95.             return 2;  
  96.         }  
  97.         else if (descriptor.HttpMethod != null)  
  98.         {  
  99.             return 1;  
  100.         }  
  101.         else  
  102.         {  
  103.             return 0;  
  104.         }  
  105.     }  
  106. }  

Here is the result of the above sample!

Clicking the Test button .

ASP.NET Core 2.0

Clicking the Btn_Other button.

ASP.NET Core 2.0
You may notice that the link after clicking the button contains the query string ?handler=xxx . At this time, you can customize it by yourself based on the above introduction.

Change the Root Directory

Normally, the default root directory of the Razor Pages is Pages. If we don't want to use the default directory, we can change it by the following code.

  1. public void ConfigureServices(IServiceCollection services)  
  2. {  
  3.     services.AddMvc()  
  4.             .WithRazorPagesRoot("/PagesDemo");  
  5.             //can do same thing  
  6.             //.AddRazorPagesOptions(x => x.RootDirectory = "/PagesDemo");  
  7. }  

What we need to know is that the root directory must start with /  Otherwise , we will get the following error.

Unhandled Exception: System.ArgumentException: Path must be a root relative path that starts with a forward slash '/'.

Furthermore, let's take a look at the source code of RazorPagesOptions.

  1. public class RazorPagesOptions  
  2. {  
  3.     private string _root = "/Pages";  
  4.   
  5.     public PageConventionCollection Conventions { get; } = new PageConventionCollection();  
  6.   
  7.     public string RootDirectory  
  8.     {  
  9.         get => _root;  
  10.         set  
  11.         {  
  12.             if (string.IsNullOrEmpty(value))  
  13.             {  
  14.                 throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(value));  
  15.             }  
  16.               
  17.             if (value[0] != '/')  
  18.             {  
  19.                 throw new ArgumentException(Resources.PathMustBeRootRelativePath, nameof(value));  
  20.             }  
  21.   
  22.             _root = value;  
  23.         }  
  24.     }  
  25. }  

Authorize Pages

Most of the time, not all pages can be accessible to everyone. Some of the pages need authentication so that we can validate whether the user can do this.

What we need to do is to do some configuration in our Startup class.

First of all, we need to indicate how many folders or pages need Authentication.

The following code shows you how to do that.

  1. services.AddMvc()                      
  2.         .WithRazorPagesRoot("/PagesDemo")  
  3.         .AddRazorPagesOptions(x =>  
  4.         {  
  5.             //Configure Authentication Here  
  6.             //the whole folder  
  7.             //x.Conventions.AuthorizeFolder("/Auth");  
  8.             //the special page   
  9.             x.Conventions.AuthorizePage("/Auth/Index");  
  10.         });  

If you want to authorize the whole folder , you can use AuthorizeFolder to complete you job.

If you want to authorize some single pages , you can use AuthorizePage.

What's more, we need to specify which type of Authentication we will use.

I will take JwtBearer for example here.

Because there are some differences between 1.x and 2.0 , we need to compare with the document of the migration : Migrating Authentication and Identity to ASP.NET Core 2.0

  1. services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)  
  2.         .AddJwtBearer(options =>  
  3.         {  
  4.             options.Audience = "http://youdomain.com:8000/";  
  5.             options.Authority = "https://youdomain.com:8001/";  
  6.             /* if the Authority is not start with HTTPS , 
  7.             we must set RequireHttpsMetadata with false  
  8.             for development , otherwise , it will get 
  9.             the MetadataAddress or Authority must use HTTPS error  */  
  10.             //options.RequireHttpsMetadata = false;  
  11.         }); 

Note

If your Authority is not use HTTPS , you must set the RequireHttpsMetadata = false during the development.

Here is the entire code of ConfigureServices .

  1. public void ConfigureServices(IServiceCollection services)  
  2. {  
  3.     services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)  
  4.             .AddJwtBearer(options =>  
  5.             {  
  6.                 options.Audience = "http://youdomain.com:8000/";  
  7.                 options.Authority = "https://youdomain.com:8001/";  
  8.             });  
  9.   
  10.     services.AddMvc()                      
  11.             .WithRazorPagesRoot("/PagesDemo")  
  12.             .AddRazorPagesOptions(x =>  
  13.             {  
  14.                 //Configure Authentication Here  
  15.                 x.Conventions.AuthorizeFolder("/Auth");  
  16.             });  
  17. }  

There is an extra step you can do or not do , because it will affect the result of Authentication.

  1. public void Configure(IApplicationBuilder app, IHostingEnvironment env)  
  2. {  
  3. //other config  
  4.       
  5.     app.UseAuthentication();  
  6. }  

At this time , if we visit http://yourdomain.com/Auth , the browser will tell us that we need to Authenticate to visit this page .

ASP.NET Core 2.0

We also can find some information in the console .

ASP.NET Core 2.0

Something about Index.cshtml

Sometimes, we create a sub folder under the Pages, most of the time, we will create a Razor Page named Index.cshtml .

For example , we create a sub folder named Sub under Pages and also create a Index.cshtml in the sub folder as well. At this time, when we visit this Index.cshtml page , both http://localhost:port/Sub and http://localhost:port/Sub/Index can access.

May be you will feel confused about this , this is based on the select model of pages. The following code demostrates how the ASP.NET team can implement it.

  1. private const string IndexFileName = "Index.cshtml";  
  2. public static void PopulateDefaults(PageRouteModel model, string routeTemplate)  
  3. {  
  4.     if (AttributeRouteModel.IsOverridePattern(routeTemplate))  
  5.     {  
  6.         throw new InvalidOperationException(string.Format(  
  7.             Resources.PageActionDescriptorProvider_RouteTemplateCannotBeOverrideable,  
  8.             model.RelativePath));  
  9.     }  
  10.   
  11.     var selectorModel = CreateSelectorModel(model.ViewEnginePath, routeTemplate);  
  12.     model.Selectors.Add(selectorModel);  
  13.   
  14.     var fileName = Path.GetFileName(model.RelativePath);  
  15.     if (string.Equals(IndexFileName, fileName, StringComparison.OrdinalIgnoreCase))  
  16.     {  
  17.         // For pages ending in /Index.cshtml, we want to allow incoming routing, but  
  18.         // force outgoing routes to match to the path sans /Index.  
  19.         selectorModel.AttributeRouteModel.SuppressLinkGeneration = true;  
  20.   
  21.         var parentDirectoryPath = model.ViewEnginePath;  
  22.         var index = parentDirectoryPath.LastIndexOf('/');  
  23.         if (index == -1)  
  24.         {  
  25.             parentDirectoryPath = string.Empty;  
  26.         }  
  27.         else  
  28.         {  
  29.             parentDirectoryPath = parentDirectoryPath.Substring(0, index);  
  30.         }  
  31.         model.Selectors.Add(CreateSelectorModel(parentDirectoryPath, routeTemplate));  
  32.     }  
  33. }  

Summary

This article shows you more details about Razor Pages in ASP.NET Core 2.0. And for some usages, I also showed you the source code of the Mvc-rel-2.0.0 in order to tell you how the team implements the feature.