Skip to content

Latest commit

 

History

History
1120 lines (918 loc) · 47.4 KB

File metadata and controls

1120 lines (918 loc) · 47.4 KB

How to Create a Web API Service Backend for a Blazor WebAssembly Application

This example demonstrates how you can create a Web API service backend and a Blazor WebAssembly application frontend. The backend uses EF Core for data access.

The application works with blog post data. It authenticates a user (looks for available cookies), determines user permissions, and selectively enables the following user actions:

  • View existing Posts
  • View photos of Post authors
  • Create new Post records
  • Archive a Post record
  • Display a report based on Post records

Prerequisites

  • Visual Studio 2022 v17.0+
  • .NET SDK 6.0+
  • DevExpress Libraries v22.2+. Download and run our Unified Component Installer. Make sure to enable the Cross-Platform .NET App UI & Web API service (XAF) option in the list of products to install. The installer will register local NuGet package sources and Visual Studio templates required for this tutorial.

You don't have to use the DevExpress Unified Component Installer if you simply want to run the example project or use the project as a boilerplate for your application. You can manually register your NuGet feed URL in Visual Studio as described in the following article: Setup Visual Studio's NuGet Package Manager.

NOTE

If you installed a pre-release version of our components or obtained a hotfix from DevExpress, NuGet packages will not be restored automatically (you will need to update them manually). For more information, please refer to the following article: Updating Packages. Remember to enable the Include prerelease option.

Build and Test a Basic Web API Service App (Backend)

Create a Web API project using the XAF Solution Wizard

  1. In Visual Studio, create a new project. Use the DevExpress XAF Template Gallery template. Enter appropriate project-related information and start the Wizard. On the first page, select only the following tile: Service (ASP.NET Core Web API).

  1. Select Entity Framework as your ORM.

  1. Select Standard Authentication to generate relevant JWT authentication scaffolding code.

  1. If you own a DevExpress Universal Subscription, the next page allows you to select additional modules for your Web API Service. Make certain to select Reports as this module is required to complete the tutorial (last step). You can select other modules if you plan to extend the application. Click Finish.

  1. Modify the following file: WebAPI/Properties/launchSettings.json. Remove the IIS Express profile to utilize Kestrel server ports. The code snippet below shows the resulting "profiles" section:

    Properties/launchSettings.json:

    "profiles": {
       "WebAPI.WebApi": {
          "commandName": "Project",
          "dotnetRunMessages": "true",
          "launchBrowser": true,
          "launchUrl": "swagger",
          "applicationUrl": "https://localhost:5001;http://localhost:5000",
          "environmentVariables": {
          "ASPNETCORE_ENVIRONMENT": "Development"
          }
       }
    }

For more information, refer to the following DevExpress help topic: Create a Standalone Web API Application.

Declare a data model

  1. Declare the Post object.

    BusinessObjects/Post.cs

    [VisibleInReports]
    public class Post : BaseObject {
       public virtual string Title { get; set; }
       public virtual string Content { get; set; }
       public virtual ApplicationUser Author { get; set; }
       public override void OnCreated() {
          base.OnCreated();
          Author = ObjectSpace.FindObject<ApplicationUser>(CriteriaOperator.Parse("ID=CurrentUserId()"));
       }
    }

    In the code sample above, the Post class inherits BaseObject to simplify data model implementation. This tutorial makes use of the following BaseObject features:

    • Predefined Guid-type primary key field (ID)
    • OnCreated lifecycle method
    • ObjectSpace property (allows you to communicate with the underlying data layer)

    For more information, refer to the following DevExpress help topic: BaseObjectSpace.

  2. Modify Entity Framework's DBContext: add a DbSet corresponding to the new business object.

    BusinessObjects\WebAPIDbContext.cs:

    public DbSet<Post> Posts { get; set; }
  3. Add an Email property to the ApplicationUser object.

    BusinessObjects/ApplicationUser.cs:

    public class ApplicationUser : PermissionPolicyUser, ISecurityUserWithLoginInfo {
       // ...
       [FieldSize(255)]
       public virtual String Email { get; set; }
    }
  4. Modify the Startup.cs file to register built-in CRUD endpoints for the Post object.

    Startup.cs:

    services
      .AddXafWebApi(Configuration, options => {
      	// Make your business objects available in the Web API and generate the GET, POST, PUT, and DELETE HTTP methods for it.
      	// options.BusinessObject<YourBusinessObject>();
      	options.BusinessObject<Post>();
      });

Set up a development database connection

The XAF Solution Wizard generates the connection string and startup code required to store persistent data in a SQL Server Express LocalDB database (only available for Microsoft Windows). If you are planning to develop your Web API backend on a non-Windows machine, consider using SQLite instead.

To use SQLite, add the Microsoft.EntityFrameworkCore.Sqlite v6 NuGet package to your project's dependencies. In Startup.cs, write the following code in the ConfigureServices method:

Startup.cs:

public void ConfigureServices(IServiceCollection services) {
   // ...
   services.AddDbContextFactory<WebAPIEFCoreDbContext>((serviceProvider, options) => { 
      if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
         string connectionString = Configuration.GetConnectionString("ConnectionString");
         options.UseSqlServer(connectionString);
      }
      else {
         string sqliteDBPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WebAPIDemo.db");
         options.UseSqlite($"Data Source={sqliteDBPath}");
      }
      // ...
   }
}

Generate initial data

  1. Open the WebAPI/DatabaseUpdate/Updater.cs file and add the following code to the UpdateDatabaseAfterUpdateSchema method to create users (Editor and Viewer), assign roles/permissions, and create sample Post objects:

    DatabaseUpdate\Updater.cs:

    var editorUser = ObjectSpace.FirstOrDefault<ApplicationUser>(user=>user.UserName=="Editor")??ObjectSpace.CreateObject<ApplicationUser>();
    if (ObjectSpace.IsNewObject(editorUser)) {
       //create Editor User/Role
       editorUser.UserName="Editor";
       editorUser.Email = "Editor@example.com";
    
       var editorRole = ObjectSpace.CreateObject<PermissionPolicyRole>();
       editorRole.Name = "EditorRole";
       editorRole.AddTypePermission<Post>(SecurityOperations.CRUDAccess, SecurityPermissionState.Allow);
       editorRole.AddTypePermission<ApplicationUser>(SecurityOperations.CRUDAccess, SecurityPermissionState.Allow);
       editorUser.Roles.Add(editorRole);
       editorUser.Roles.Add(defaultRole);
       editorUser.Photo = ObjectSpace.CreateObject<MediaDataObject>();
       editorUser.Photo.MediaData = GetResourceByName("Janete");
    
       //create Viewer User/Role
       var viewerUser = ObjectSpace.CreateObject<ApplicationUser>();
       viewerUser.UserName = "Viewer";
       viewerUser.Email = "Viewer@example.com";
    
       viewerUser.Photo = ObjectSpace.CreateObject<MediaDataObject>();
       viewerUser.Photo.MediaData = GetResourceByName("John");
       var viewerRole = ObjectSpace.CreateObject<PermissionPolicyRole>();
       viewerRole.Name = "ViewerRole";
       viewerRole.AddTypePermission<Post>(SecurityOperations.Read, SecurityPermissionState.Allow);
       viewerRole.AddTypePermission<ApplicationUser>(SecurityOperations.Read, SecurityPermissionState.Allow);
       viewerUser.Roles.Add(viewerRole);
       viewerUser.Roles.Add(defaultRole);
    
       //commit
       ObjectSpace.CommitChanges();
    
       //assign authentication type
       foreach (var user in new[] { editorUser, viewerUser }.Cast<ISecurityUserWithLoginInfo>()) {
          user.CreateUserLoginInfo(SecurityDefaults.PasswordAuthentication,
             ObjectSpace.GetKeyValueAsString(user));
       }
    
       //sample posts
       var post = ObjectSpace.CreateObject<Post>();
       post.Title = "Hello World";
       post.Content = "This is a FREE API for everybody";
       post.Author=editorUser;
       post = ObjectSpace.CreateObject<Post>();
       post.Title = "Hello Blazor WebAssembly";
       post.Content = "Please smash the like button to help our videos get discovered";
       post.Author=editorUser;
    }

Configure application security

  1. Modify the ConfigureServices method in Startup.cs to allow cross origin authenticated requests (CORS) from the Blazor WebAssembly application.

    Startup.cs:

    services.AddCors(options => options.AddPolicy(
       "Open", builder => builder.SetIsOriginAllowed(_ => true).AllowAnyHeader().AllowAnyMethod().AllowCredentials()));

    Additionally, modify the Configure method:

    Startup.cs:

    app.UseCors("Open");
  2. To use cookies, modify authentication/authorization code as follows:

    Startup.cs:

    var authentication = services.AddAuthentication(customBearerSchemeName);
    authentication.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
    // ...
    services.AddAuthorization(options => {
       options.DefaultPolicy = new AuthorizationPolicyBuilder(
          CookieAuthenticationDefaults.AuthenticationScheme,
          JwtBearerDefaults.AuthenticationScheme,
          customBearerSchemeName)
             .RequireAuthenticatedUser()
             .RequireXafAuthentication()
             .Build();
    });
  3. Create a LoginAsync endpoint in API/Security/AuthenticationController.cs to authenticate a user and send the authentication cookie to the client.

    API/Security/AuthenticationController.cs:

    [HttpPost(nameof(LoginAsync))]
    [SwaggerOperation("Checks if the user with the specified logon parameters exists in the database. If a user exists, authenticates this user.", "Refer to the following help topic for more information on authentication methods in the XAF Security System: <a href='https://docs.devexpress.com/eXpressAppFramework/119064/data-security-and-safety/security-system/authentication'>Authentication</a>.")]
    public async Task<ActionResult> LoginAsync([FromBody] [SwaggerRequestBody(@"For example: <br /> { ""userName"": ""Admin"", ""password"": """" }")]
       AuthenticationStandardLogonParameters logonParameters) {
       try {
          var user=await Task.Run(() => _securityAuthenticationService.Authenticate(logonParameters));
          if (user == null) return Unauthorized("User name or password is incorrect.");
          using var objectSpace = _nonSecuredObjectSpaceFactory.CreateNonSecuredObjectSpace<ApplicationUser>();
          var userOid = new Guid(user.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value);
          var xafUser = objectSpace.FirstOrDefault<ApplicationUser>(u => u.ID == userOid);
          (((ClaimsIdentity)user.Identity)!).AddClaims(new[]{new Claim(nameof(ApplicationUser.ID),xafUser.ID.ToString()),new Claim(nameof(ApplicationUser.IsActive),xafUser.IsActive.ToString()), });
          return new SignInResult(CookieAuthenticationDefaults.AuthenticationScheme,
                user, new AuthenticationProperties { AllowRefresh = true, ExpiresUtc = DateTimeOffset.Now.AddDays(1), IsPersistent = true, });
       } catch(AuthenticationException) {
          return Unauthorized("User name or password is incorrect.");
       }
    }
  4. In the same file, create a LogoutAsync endpoint used to log out and invalidate the cookie.

    API/Security/AuthenticationController.cs:

    [HttpPost(nameof(LogoutAsync))]
    public async Task<SignOutResult> LogoutAsync() {
       await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
       return new SignOutResult(CookieAuthenticationDefaults.AuthenticationScheme);
    }
  5. Create an authorized UserInfo endpoint used to obtain user information and validate the cookie from the client when needed (for example, when a user refreshes the page).

    API/Security/AuthenticationController.cs:

    [Authorize][HttpGet(nameof(UserInfo))]
    public ActionResult UserInfo() {
       if (HttpContext.User.Identity?.IsAuthenticated == null || !HttpContext.User.Identity.IsAuthenticated)
          return Unauthorized();
       var claims = ((ClaimsIdentity)HttpContext.User.Identity).Claims.ToArray();
       return Ok(new {
          UserName= HttpContext.User.Identity.Name, 
          ID = claims.First(claim => claim.Type == nameof(ApplicationUser.ID)).Value.ToString(),
          IsActive = claims.First(claim => claim.Type == nameof(ApplicationUser.IsActive)).Value.ToString()
       });
    }

Use Swagger UI to test the Web API service

At this point, you can run your Web API service and use Swagger interface to authenticate as previously defined users and test generated endpoints (for example, query available posts). Refer to the following article for additional information Test the Web API with Swagger or Postman.

Create a Blazor WebAssembly application (Frontend)

Create a new Blazor WebAssembly project

  1. Use either the .NET CLI or Visual Studio New Project wizard to add a new Blazor WebAssembly project to the solution.

    dotnet new blazorwasm -o Blazor.WebAssembly
  2. Add the following NuGet packages to the Blazor WebAssembly project.

    dotnet add package Blazored.LocalStorage --version 4.3.0
    dotnet add package Microsoft.AspNetCore.Components.WebAssembly.Authentication --version 7.0.4
    dotnet add package Microsoft.Extensions.Http --version 7.0.0
    dotnet add package Microsoft.AspNetCore.Components.WebAssembly.Authentication --version 7.0.4
    dotnet add package Simple.OData.Client --version 6.0.1
    dotnet add package DevExpress.Blazor --version 22.2.4

Add registration code

  1. Register services. Modify the Program.cs file as follows:

    Program.cs:

    builder.Services.AddOptions();
    builder.Services.AddAuthorizationCore();
    builder.Services.AddBlazoredLocalStorage();
    builder.Services.AddDevExpressBlazor(options => {
       options.BootstrapVersion = DevExpress.Blazor.BootstrapVersion.v5;
       options.SizeMode = DevExpress.Blazor.SizeMode.Medium;
    });
  2. Register DevExpress styles in the header of wwwroot/index.html.

    wwwroot/index.html:

    <link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.css" rel="stylesheet" asp-append-version="true" />
  3. Add the following lines to _Imports.razor.

    _Imports.razor:

    @using Microsoft.AspNetCore.Authorization
    @using Microsoft.AspNetCore.Components.Authorization
    @using Blazored.LocalStorage
    @using Microsoft.AspNetCore.Components
    @using Microsoft.AspNetCore.Components.WebAssembly.Authentication
    @using System.Web
    @using DevExpress.Blazor
    @using System.ComponentModel.DataAnnotations

Configure cookie-based authentication on the frontend

  1. Configure the HTTP client. Modify the HttpClient registration code to use the Web API service's base address and add a handler to set the browser's request credentials.

    Services/CookieHandler.cs:

    public class CookieHandler : DelegatingHandler {
          protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
             request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
             return await base.SendAsync(request, cancellationToken);
          }
       }

    Program.cs:

    using Blazor.WebAssembly.Services;
    // ...
    builder.Services.AddHttpClient("API", options => options.BaseAddress = new Uri("https://localhost:5001/api/"))
       .AddHttpMessageHandler<CookieHandler>();
  2. Implement a user model class.

    Models/UserModel.cs:

    namespace Blazor.WebAssembly.Models;
    
    public class UserModel {
       public Guid XafUserId { get; set; }
       public string LoginProviderUserId { get; set; } = null!;
       public string UserName { get; set; } = null!;
       public string? Email { get; set; }
    }
  3. Implement and register a custom AuthenticationStateProvider:

    Services/WebAPIAuthenticationStateProvider.cs:

    using System.Security.Claims;
    using Blazor.WebAssembly.Models;
    using Microsoft.AspNetCore.Components.Authorization;
    // ...
    public class WebAPIAuthenticationStateProvider : AuthenticationStateProvider {
       private ClaimsPrincipal _claimsPrincipal = new(new ClaimsIdentity());
       public override Task<AuthenticationState> GetAuthenticationStateAsync()
          => Task.FromResult<AuthenticationState>(new(_claimsPrincipal));
    
       public void ClearAuthInfo() {
          _claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
          NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
       }
    
       public void SetAuthInfo(UserModel user) {
          _claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(new[]{
                new Claim(nameof(UserModel.Email), user.Email ?? ""),
                new Claim(nameof(UserModel.XafUserId), user.XafUserId.ToString()),
                new Claim(nameof(UserModel.LoginProviderUserId), user.LoginProviderUserId.ToString()),
                new Claim(ClaimTypes.Name, user.UserName),
                new Claim("ID", user.ToString()!) }, "AuthCookie"));
          NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
       }
    }

    Program.cs:

    builder.Services.AddScoped<WebAPIAuthenticationStateProvider>();
    builder.Services.AddScoped<AuthenticationStateProvider>(provider => provider.GetRequiredService<WebAPIAuthenticationStateProvider>());
  4. Create and register an IWebAPI service to call authentication endpoints of the Web API service backend.

    Services/WebAPI.cs:

    using System.Net;
    using System.Net.Http.Json;
    using Blazor.WebAssembly.Models;
    // ...
    
    public interface IWebAPI {
       Task<HttpResponseMessage> LoginAsync(string? userName, string? password);
       Task<(string message, UserModel? user)> GetUserAsync();
       Task<bool> LogoutAsync();
    }
    
    public class WebAPI : IWebAPI {
       private readonly HttpClient _httpClient;
    
       public WebAPI(IHttpClientFactory httpClientFactory)
          => _httpClient = httpClientFactory.CreateClient("API");
    
       public async Task<HttpResponseMessage> LoginAsync(string? userName, string? password)
          => (await _httpClient.PostAsJsonAsync("Authentication/LoginAsync", new { userName, password }));
    
       public async Task<(string message, UserModel? user)> GetUserAsync() {
          try {
                var response = await _httpClient.GetAsync("Authentication/UserInfo");
                return response.IsSuccessStatusCode ? ("Success", await response.Content.ReadFromJsonAsync<UserModel>())
                   : response.StatusCode == HttpStatusCode.Unauthorized ? ("Unauthorized", null) : ("Failed", null);
          }
          catch (Exception e) {
                return ("Failed", null);
          }
       }
    
       public async Task<bool> LogoutAsync()
          => (await _httpClient.PostAsync("Authentication/LogoutAsync", null)).IsSuccessStatusCode;
    }

    Program.cs:

    builder.Services.AddScoped<IWebAPI, WebAPI>();
  5. Create a RedirectToLogin component:

    Shared/RedirectToLogin.razor:

    @inject NavigationManager Navigation
    
    @code {
       protected override void OnInitialized() 
          => Navigation.NavigateTo($"Login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}");
    }
  6. Modify the App.Razor file. In the markup, replace the RouteView component with AuthorizeRouteView.

    App.Razor:

    <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
       <NotAuthorized>
          <RedirectToLogin />
       </NotAuthorized>
    </AuthorizeRouteView>
  7. To validate the authentication cookie, modify the App.Razor file as follows.

    Inject services:

    App.Razor:

    @using Blazor.WebAssembly.Services
    @inject ILocalStorageService LocalStorageService;
    @inject IWebAPI WebApi
    @inject WebAPIAuthenticationStateProvider AuthStateProvider

    Enclose all markup within a CascadingAuthenticationState component.

    App.Razor:

    <CascadingAuthenticationState>
       @* App.Razor markup *@
    </CascadingAuthenticationState>

    Handle the Router's OnNavigateAsync event to obtain the user profile or re-populate it if needed.

    App.Razor:

    <Router AppAssembly="@typeof(App).Assembly" 
       OnNavigateAsync="@OnNavigateAsync" 
       >
       @* ... *@
    @code{
       private async Task OnNavigateAsync(NavigationContext args) {
          var isAuthenticated =  await LocalStorageService.GetItemAsync<string>("IsAuthenticated");
          var claimsPrincipal = (await AuthStateProvider.GetAuthenticationStateAsync()).User;
          if(!string.IsNullOrEmpty(isAuthenticated) && !claimsPrincipal.Identity!.IsAuthenticated ){
                var user = await WebApi.GetUserAsync();
                if (user.message == "Success") {
                   AuthStateProvider.SetAuthInfo(user.user!);
                }
                else if(user.message == "Unauthorized") {
                   AuthStateProvider.ClearAuthInfo();
                   await LocalStorageService.RemoveItemAsync("IsAuthenticated");
                   await WebApi.LogoutAsync();
                } else if(user.message == "Failed") {
                   AuthStateProvider.ClearAuthInfo();
                   await LocalStorageService.RemoveItemAsync("IsAuthenticated");
                }
          }
       }
    }
  8. Create login components. To do this, create a LoginDisplay.razor file inside the Shared folder. In this file, implement the following functionality:

    • Use the AuthorizeView component to display either the Login or Logout component.
    • Handle the Logout event.
    • Display user profile information in a DxPopup component.

    Shared/LoginDisplay.razor:

    @using Blazor.WebAssembly.Models
    @using Blazor.WebAssembly.Services
    @inject WebAPIAuthenticationStateProvider AuthStateProvider
    @inject NavigationManager NavigationManager
    @inject IWebAPI WebApi
    @inject ILocalStorageService LocalStorageService;
    <AuthorizeView>
       <Authorized>
          <a href="" @onclick="OnProfileClicked">Hello @context.User.Identity?.Name</a>
          <button type="button" @onclick="Logout" class="nav-link btn btn-link">Log out</button>
       </Authorized>
       <NotAuthorized>
          <a href="/login">Log in</a>
       </NotAuthorized>
    </AuthorizeView>
    <DxPopup HeaderText="Profile" @bind-Visible="@PopupVisible">
       <BodyContentTemplate>
          <div>
                <label for="userId">UserID:</label>
                <label id="userId">@_userId</label>
          </div>
          <div>
                <label for="loginProviderUserId">LoginProviderUserID:</label>
                <label id="loginProviderUserId">@_loginProviderUserId</label>
          </div>
          <div>
                <label for="Email">Email:</label>
                <label id="Email">@_userEmail</label>
          </div>
       </BodyContentTemplate>
    </DxPopup>
    
    @code {
       private string? _userId;
       private string? _loginProviderUserId;
       private string? _userEmail;
       bool PopupVisible { get; set; } = false;
    
       private async Task Logout() {
          if (await WebApi.LogoutAsync()) {
                AuthStateProvider.ClearAuthInfo();
                await LocalStorageService.RemoveItemAsync("IsAuthenticated");
                NavigationManager.NavigateTo("/", true);
          }
       }
    
       private async Task OnProfileClicked() {
          var userClaims = (await AuthStateProvider.GetAuthenticationStateAsync()).User.Claims;
          _loginProviderUserId = userClaims.First(claim => claim.Type == nameof(UserModel.LoginProviderUserId)).Value;
          _userId = userClaims.First(claim => claim.Type == nameof(UserModel.XafUserId)).Value;
          _userEmail = userClaims.First(claim => claim.Type == nameof(UserModel.Email)).Value;
          PopupVisible = true;
       }
    
    }

    Render the component inside MainLayout.razor next to the About button.

    Shared/MainLayout.razor:

    <div class="top-row px-4">
       <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
       <LoginDisplay></LoginDisplay>
    </div>

    Create a Login.razor page.

    Pages/Login.razor:

    @page "/login"
    @using Blazor.WebAssembly.Services
    @inject IWebAPI WebApi
    @inject NavigationManager NavigationManager
    @inject ILocalStorageService LocalStorageService;
    <div class="d-flex justify-content-center" >
          <ErrorBoundary @ref="_errorBoundary">
                <ChildContent>
                   <EditForm Model="@this" OnSubmit="LoginAsync" Context="EditFormContext">
                      <DataAnnotationsValidator/>
                      <div class="card-body  align-content-around">
                            <DxFormLayout>
                               <DxFormLayoutItem ColSpanMd="12">
                                  <DxTextBox @bind-Text="@UserName" NullText="Username" ClearButtonDisplayMode="DataEditorClearButtonDisplayMode.Auto"/>
                                  <div class="text-danger"><ValidationMessage For="@(() => UserName)"/></div>
                               </DxFormLayoutItem>
                               <DxFormLayoutItem ColSpanMd="12">
                                  <DxTextBox @bind-Text="@Password" NullText="Password" Password="true" ClearButtonDisplayMode="DataEditorClearButtonDisplayMode.Auto"/>
                                  <div class="text-danger"><ValidationMessage For="@(() => Password)"/></div>
                               </DxFormLayoutItem>
                               <DxFormLayoutItem ColSpanMd="12">
                                  <div class="text-danger"><ValidationMessage For="@(() => AuthenticationResult)"/></div>
                               </DxFormLayoutItem>
                               <DxFormLayoutItem ColSpanMd="12">
                                  <DxButton Text="Login" RenderStyle="ButtonRenderStyle.Primary" SubmitFormOnClick="true"/>
                               </DxFormLayoutItem>
                            </DxFormLayout>
                      </div>
                   </EditForm>
                </ChildContent>
                <ErrorContent Context="e">
                   <DxPopup HeaderText="@e.GetType().ToString()" Visible="true" Closed="() => _errorBoundary.Recover()">
                      @e.Message
                   </DxPopup>
                </ErrorContent>
          </ErrorBoundary>      
    </div>
    @code {
       private ErrorBoundary _errorBoundary = new();
       [Required]
       public string? UserName { get; set; } 
       public string? Password { get; set; }
       [DisplayStringPropertyValue()]
       public string? AuthenticationResult { get; set; } 
       private async Task LoginAsync(EditContext editContext) {
          AuthenticationResult = "";
          if (editContext.Validate()){
                var responseMessage = await WebApi.LoginAsync(UserName,Password);
                if (!responseMessage.IsSuccessStatusCode) {
                   AuthenticationResult = $"Authentication error ({responseMessage.StatusCode}): {await responseMessage.Content.ReadAsStringAsync()}";
                   editContext.Validate();
                   return;
                }
                await LocalStorageService.SetItemAsStringAsync("IsAuthenticated", "true");
                NavigationManager.NavigateTo(HttpUtility.ParseQueryString(new Uri(NavigationManager.Uri).Query).Get("returnUrl") ?? "/");
          }
       }
    }
  9. Run the Blazor WebAssembly application and test authentication logic.

    • Invalid credentials:

      Wrong Credentials

    • Unhandled error:

      Unhandled Error

    • Successful authentication:

      Login Success

    • User profile information:

      User Profile

Request and display Post records

  1. Create a Post model class:

    Models/Post.cs:

    namespace Blazor.WebAssembly.Models;
    
    public class Post {
       public int PostId { get; set; }
       public string? Title { get; set; }
       public string? Content { get; set; }
    }
  2. Implement a custom data source for the DxGrid control. In data source implementation, use an OData client to fetch posts from the Web API Service:

    Services/SimpleODataClientDataSource.cs:

    using System.Collections;
    using Blazor.WebAssembly.Models;
    using DevExpress.Blazor;
    using DevExpress.Data.Filtering;
    using DevExpress.Data.Filtering.Helpers;
    using Simple.OData.Client;
    // ...
    public class SimpleODataClientDataSource : GridCustomDataSource {
       private readonly ODataClient _client;
       public SimpleODataClientDataSource(IHttpClientFactory httpClientFactory)
          => _client = new ODataClient(new ODataClientSettings(httpClientFactory.CreateClient("API"), new Uri("odata/", UriKind.Relative)));
    
       public override async Task<int> GetItemCountAsync(GridCustomDataSourceCountOptions options, CancellationToken cancellationToken)
          => await ApplyFiltering(options.FilterCriteria, _client.For<Post>()).Count()
             .FindScalarAsync<int>(cancellationToken);
    
       public override async Task<IList> GetItemsAsync(GridCustomDataSourceItemsOptions options, CancellationToken cancellationToken) {
          var filteredClient = ApplyFiltering(options.FilterCriteria, _client.For<Post>().Top(options.Count).Skip(options.StartIndex));
          return (await ApplySorting(options, filteredClient).FindEntriesAsync(cancellationToken)).ToList();
       }
    
       private static IBoundClient<Post> ApplyFiltering(CriteriaOperator criteria, IBoundClient<Post> boundClient)
          => !criteria.ReferenceEqualsNull() ? boundClient.Filter(ToSimpleClientCriteria(criteria)) : boundClient;
    
       private static string ToSimpleClientCriteria(CriteriaOperator criteria)
          => $"{criteria}".Replace("[", "").Replace("]", "");
    
       private static IBoundClient<Post> ApplySorting(GridCustomDataSourceItemsOptions options, IBoundClient<Post> boundClient) {
          if(options.SortInfo == null) {
                return boundClient;
          }
    
          return options.SortInfo.Any() ? boundClient.OrderBy(options.SortInfo
                   .Where(info => !info.DescendingSortOrder).Select(info => info.FieldName).ToArray())
                .OrderByDescending(options.SortInfo
                   .Where(info => info.DescendingSortOrder).Select(info => info.FieldName).ToArray()) : boundClient;
    
       }
    }

    For more information, refer to the following documentation topic: GridCustomDataSource Class.

  3. Add a DxGrid control bound to your custom data source to the FetchData.razor page.

    Pages/FetchData.razor:

    @page "/fetchdata"
    @using Blazor.WebAssembly.Models
    @using Blazor.WebAssembly.Services
    @attribute [Authorize]
    @inject IJSRuntime Js;
    @inject IWebAPI WebAPI;
    @inject SimpleODataClientDataSource DataSource;
    <DxGrid Data="@DataSource" CssClass="mw-1100" KeyFieldName="ID" ShowFilterRow="true">
       <Columns>
          @*<DxGridSelectionColumn Width="104px" />*@
          <DxGridDataColumn Caption="Title" FieldName="Title"/>
          <DxGridDataColumn Caption="Content" FieldName="Content"/>
       </Columns>
    </DxGrid>

    Run the Blazor WebAssembly application and select the Fetch data tab to view the result.

Extend backend and frontend apps

Display author photo

  1. Implement a Web API service endpoint to serve the author's photo based on the post ID.

    First, add the Photo property to the ApplicationUser persistent class:

    BusinessObjects/ApplicationUser.cs:

    public class ApplicationUser : PermissionPolicyUser, ISecurityUserWithLoginInfo {
       // ...
       public virtual MediaDataObject Photo { get; set; }
    }

    Modify the Module Updater. Add logic to assign photos to predefined users:

    DatabaseUpdate\Updater.cs:

    // ...
    editorUser.Photo = ObjectSpace.CreateObject<MediaDataObject>();
    editorUser.Photo.MediaData = GetResourceByName("Janete");
    viewerUser.Photo = ObjectSpace.CreateObject<MediaDataObject>();
    viewerUser.Photo.MediaData = GetResourceByName("John");
    // ...

    NOTE

    In our example code, the GetResourceByName method returns a byte array representation of an account image based on name. You can find this method's implementation in the Web API project's DatabaseUpdate/Updater.cs file. Note that this implementation requires image resources to be compiled into the application's assembly (the Build Action option for JPG files must be set to Embedded resource).

    Add CustomEndPointController inside the WebAPI/API directory, and inject ISecurityProvider and IObjectSpaceFactory. Implement a controller action that serves post author photos.

    API/CustomEndPointController.cs:

    [ApiController]
    [Route("api/[controller]")]
    [Authorize]
    public class CustomEndPointController : ControllerBase {
       private readonly ISecurityProvider _securityProvider;
       private readonly IObjectSpaceFactory _securedObjectSpaceFactory;
    
       public CustomEndPointController(ISecurityProvider securityProvider, IObjectSpaceFactory securedObjectSpaceFactory) {
          _securityProvider = securityProvider;
          _securedObjectSpaceFactory = securedObjectSpaceFactory;
       }
    
       [HttpGet("AuthorPhoto/{postId}")]
       public FileStreamResult AuthorPhoto(Guid postId) {
          using var objectSpace = _securedObjectSpaceFactory.CreateObjectSpace(typeof(Post));
          var post = objectSpace.GetObjectByKey<Post>(postId);
          var photoBytes = post.Author.Photo.MediaData;
          return File(new MemoryStream(photoBytes), "application/octet-stream");
       }  
    }
  2. In the Blazor WebAssembly project, update the IWebAPI service with a method that requests a post author's photo.

    Services/WebAPI.cs:

    public interface IWebAPI {
       // ...
       Task<byte[]> GetAuthorPhotoAsync(Guid postId);
    }
    public class WebAPI : IWebAPI {
       // ...
       public async Task<byte[]> GetAuthorPhotoAsync(Guid postId)
          => await _httpClient.GetByteArrayAsync($"CustomEndPoint/AuthorPhoto/{postId}");
    }
  3. Update the FetchData.razor page's layout:

    Pages/FetchData.razor:

    <DxGrid Data="@DataSource" CssClass="mw-1100" KeyFieldName="ID" ShowFilterRow="true">
       <Columns>
          @* ... *@ 
          <DxGridDataColumn FieldName="ID" Caption="Photo" AllowSort="false" Width="90px" MinWidth="100" TextAlignment="GridTextAlignment.Center">
                <CellDisplayTemplate><button class="btn btn-link" @onclick="() => ShowPhoto((Guid)context.Value)">Photo</button></CellDisplayTemplate>
          </DxGridDataColumn>
       </Columns>
    </DxGrid>
    
    <DxPopup HeaderText="Photo" @bind-Visible="ShowingPhoto">
       <BodyContentTemplate>
          <img src="@_imageDataUrl" alt="user photo" />
       </BodyContentTemplate>
    </DxPopup>
    
    @code {
       bool ShowingPhoto { get; set; }
       private string _imageDataUrl = "";
       private async Task ShowPhoto(Guid contextValue) {
          _imageDataUrl = $"data:image/jpg+xml;base64,{Convert.ToBase64String(await WebAPI.GetAuthorPhotoAsync(contextValue))}";
          ShowingPhoto = true;
       }
    }

Add post editing functionality

  1. The Web API Service automatically generates OData endpoints required to create and edit business objects. However, the application's security system is configured to disallow post creation and editing for certain users. To check permissions before a user can try and submit a post, implement a custom CanCreate endpoint.

    API/CustomEndPointController.cs:

    [HttpGet(nameof(CanCreate))]
    public IActionResult CanCreate(string typeName) {
        var strategy = (SecurityStrategy)_securityProvider.GetSecurity();
        var objectType = strategy.TypesInfo.PersistentTypes.First(info => info.Name  == typeName).Type;
        return Ok(strategy.CanCreate(objectType));
    }
  2. In the Blazor WebAssembly project, update the IWebAPI service with a method that queries the CanCreate endpoint to check if the current user can edit Posts.

    Services/WebAPI.cs:

    public interface IWebAPI {
       // ...
       Task<bool> CanCreateAsync();
    }
    public class WebAPI: IWebAPI {
       // ...
       public async Task<bool> CanCreateAsync() 
          => await _httpClient.GetFromJsonAsync<bool>("CustomEndpoint/CanCreate?typename=Post");
    }
  3. Update the DxGrid's data source with methods required to edit and delete posts.

    Services/SimpleODataClientDataSource.cs:

    public class SimpleODataClientDataSource : GridCustomDataSource {
       // ...
       public async Task DeleteAsync(Post instance)
       => await _client.For<Post>().Key(instance.ID).DeleteEntryAsync();
    
       public async Task AddOrUpdateAsync(Post instance, bool update = false) {
          if (!update) {
                await _client.For<Post>().Set(new { instance.Title, instance.Content }).InsertEntryAsync();
          }
          else {
                await _client.For<Post>().Key(instance.ID).Set(instance).UpdateEntryAsync();
          }
       }
    }
  4. Add a command column with New, Edit, and Delete commands to the DxGrid and implement logic required to process CRUD operations.

    Pages/FetchData.razor:

    <DxGrid Data="@DataSource" CssClass="mw-1100" KeyFieldName="ID" ShowFilterRow="true"
       EditModelSaving="async e => await DataSource.AddOrUpdateAsync((Post)e.EditModel,!e.IsNew)"
       DataItemDeleting="async e => await DataSource.DeleteAsync((Post)e.DataItem)"
       EditMode="GridEditMode.EditRow" EditorRenderMode="GridEditorRenderMode.Integrated">
       <Columns>
          <DxGridCommandColumn Width="160px" Visible="AllowEdit" />
          <DxGridDataColumn Caption="Title" FieldName="Title">
                <CellEditTemplate>
                   <DxTextBox @bind-Text="((Post)context.EditModel).Title"></DxTextBox>
                </CellEditTemplate>
          </DxGridDataColumn>
          <DxGridDataColumn Caption="Content" FieldName="Content">
                <CellEditTemplate>
                   <DxTextBox @bind-Text="((Post)context.EditModel).Content"></DxTextBox>
                </CellEditTemplate>
          </DxGridDataColumn>
          <DxGridDataColumn FieldName="ID" Caption="Photo" AllowSort="false" Width="90px" MinWidth="100" TextAlignment="GridTextAlignment.Center">
                <CellDisplayTemplate><button class="btn btn-link" @onclick="() => ShowPhoto((Guid)context.Value)">Photo</button></CellDisplayTemplate>
          </DxGridDataColumn>
       </Columns>
    </DxGrid>
    @* ... *@ 
    @code {
       // ...
       public bool AllowEdit { get; set; }
    
       protected override async Task OnInitializedAsync() {
          await base.OnInitializedAsync();
          AllowEdit = await WebAPI.CanCreateAsync();
       }
    }

Allow users to archive records

  1. In the Web API service, create an Archive endpoint. This endpoint will obtain a post from the database and archive the post to disk. The following controller action implementation uses securedObjectSpaceFactory to communicate with the data store so that all data operations respect appropriate security permissions.

    API/CustomEndPointController.cs:

    [HttpPost(nameof(Archive))]
    public async Task<IActionResult> Archive([FromBody] Post post) {
        using var objectSpace = _securedObjectSpaceFactory.CreateObjectSpace<Post>();
        post = objectSpace.GetObject(post);
        var photo = post.Author.Photo.MediaResource.MediaData;
        await System.IO.File.WriteAllTextAsync($"{post.ID}",
            JsonSerializer.Serialize(new { photo, post.Title, post.Content, post.Author.UserName }));
        return Ok();
    }
  2. Extend the Blazor WebAssembly application's IWebAPI service with a method that archives a post.

    Services/WebAPI.cs:

    public interface IWebAPI {
       Task ArchiveAsync(Post post);
       // ...
    }
    public class WebAPI: IWebAPI {
       public async Task ArchiveAsync(Post post) 
          => await _httpClient.PostAsJsonAsync("CustomEndPoint/Archive", post);
       // ...
    }
  3. In FetchData.razor, add a selection column to DxGrid, a popup that displays the operation's result, and a button that a user clicks to archive the selected post.

    Pages/FetchData.razor:

    <DxButton SizeMode="SizeMode.Small" Text="Archive" RenderStyle="ButtonRenderStyle.Secondary" Click="OnArchive" Attributes="@(new Dictionary<string, object> {  ["title"] = "Archive Post selection." })" />
    <DxGrid Data="@DataSource" 
       @bind-SelectedDataItems="SelectedDataItems" 
       SelectionMode="GridSelectionMode.Single" 
       @* ... *@ >
       <Columns>
          <DxGridSelectionColumn Width="104px" />
          @* ... *@
       </Columns>
    </DxGrid>
    @* ... *@
    <DxPopup HeaderText="Archive" @bind-Visible="ArchiveClicked" BodyText="This post is saved to disk">
       <BodyContentTemplate>
          <div>
                @if (SelectedDataItems == null) {
                   <p>Please select a Post from the list.</p>
                }
                else {
                   <p>This post is saved to disk.</p>
                }
          </div>
       </BodyContentTemplate>
    </DxPopup>
    @code {
       // ...
       IReadOnlyList<object>? SelectedDataItems { get; set; }
       bool ArchiveClicked { get; set; }
       private async Task OnArchive(MouseEventArgs arg) {
          if (SelectedDataItems != null) {
                await WebAPI.ArchiveAsync(SelectedDataItems.Cast<Post>().First());
          }
          ArchiveClicked = true;
       }
    }

Add reporting functionality

The XAF Reports module ships as part of the DevExpress Universal Subscription. You can use this module to easily integrate DevExpress Reports into your backend Web API service. Skip this step if you do not own the DevExpress Universal Subscription.

To create and initialize a report:

  1. Add a DevExpress Report component using the Visual Studio's New Item wizard.

  2. Drag and drop a CollectionDataSource component from the Visual Studio toolbox and change its ObjectTypeName to WebAPI.BusinessObjects.Post.

  3. Drag and drop all discovered fields from the Field List window onto the Report details surface.

  4. Use a predefined reports updater to initialize the report.

    Module.cs:

    public override IEnumerable<ModuleUpdater> GetModuleUpdaters(IObjectSpace objectSpace, Version versionFromDB) {
         var predefinedReportsUpdater = new PredefinedReportsUpdater(Application, objectSpace, versionFromDB);
         predefinedReportsUpdater.AddPredefinedReport<XtraReport1>("Post Report",typeof(Post));
         return new ModuleUpdater[] { new DatabaseUpdate.Updater(objectSpace, versionFromDB),predefinedReportsUpdater };
     }

    For more information on report generation, refer to the following DevExpress help topic: Create a Report in Visual Studio.

    For more information on the use of predefined static reports in XAF, refer to the following DevExpress help topic: Create Predefined Static Reports.

    Watch video: Preview Reports as PDF in .NET MAUI Apps using Backend Web API service Endpoints with EF Core

  5. Create a GetReport endpoint. It redirects to the built-in DownloadByName endpoint and returns a Report with title Post Report.

    API/CustomEndPointController.cs:

    [HttpGet(nameof(GetReport))]
    public RedirectResult GetReport() 
        => Redirect("~/api/report/DownloadByName(Post Report)");
  6. Extend the Blazor WebAssembly application's IWebAPI service with a method that requests a report.

    Services/WebAPI.cs:

    public interface IWebAPI {
       Task<byte[]> ShapePostsAsync();
       // ...
    }
    public class WebAPI: IWebAPI {
       public async Task<byte[]> ShapePostsAsync()
          => await _httpClient.GetByteArrayAsync("report/DownloadByName(Post Report)");
       // ...
    }
  7. Add a button and code required to call the IWebAPI.ShapePostsAsync method to FetchData.razor.

    Pages/FetchData.razor:

    <DxButton SizeMode="SizeMode.Small" Text="Archive" RenderStyle="ButtonRenderStyle.Secondary" Click="OnArchive" Attributes="@(new Dictionary<string, object> {  ["title"] = "Archive Post selection." })" />
    @* ... *@
    @code {
       // ...
       private async Task OnShape()
          => await Js.InvokeAsync<object>("saveAsFile", "Report.pdf", Convert.ToBase64String(await WebAPI.ShapePostsAsync()));
    }