5

Implementing Popular Third-Party Libraries

This chapter is about some popular third-party libraries for .NET that enable you to perform actions that either are not possible with the core .NET libraries or are better than the built-in functionality. These actions include manipulating images with ImageSharp, logging with Serilog, mapping objects to other objects with AutoMapper, making unit test assertions with FluentAssertions, validating data with FluentValidation, and generating PDFs with QuestPDF.

This chapter covers the following topics:

  • Which third-party libraries are most popular?
  • Working with images
  • Logging with Serilog
  • Mapping between objects
  • Making fluent assertions in unit testing
  • Validating data
  • Generating PDFs

Which third-party libraries are most popular?

To help me to decide which third-party libraries to include in this book, I researched which are downloaded most frequently at https://www.nuget.org/stats, and, as shown in the following table, they are:

Rank

Package

Downloads

1

newtonsoft.json

106,664,914

2

serilog

25,706,263

3

castle.core

18,426,836

4

newtonsoft.json.bson

18,018,770

5

awssdk.core

15,655,292

6

swashbuckle.aspnetcore.swagger

15,049,359

7

swashbuckle.aspnetcore.swaggergen

14,984,145

8

moq

13,864,846

9

automapper

13,390,653

10

serilog.sinks.file

13,043,367

12

polly

12,612,215

24

serilog.sinks.console

11,271,774

38

fluentvalidation

8,906,145

41

fluentassertions

8,419,263

100

nodatime

2,981,780

What is covered in my books

My book, C# 11 and .NET 7 – Modern Cross-Platform Development Fundamentals, introduces processing JSON using Newtonsoft.Json and documenting web services using Swashbuckle. For now, using Castle Core to generate dynamic proxies and typed dictionaries, or deploying to and integrating with Amazon Web Services (AWS), is out of scope for this book.

As well as raw download numbers, questions from readers and the usefulness of the library also contributed to my decision to include a library in this chapter, as summarized in the following list:

  • Most popular library for manipulating images: ImageSharp
  • Most popular library for logging: Serilog
  • Most popular library for object mapping: AutoMapper
  • Most popular library for unit test assertions: FluentAssertions
  • Most popular library for data validation: FluentValidation
  • Open-source library for generating PDFs: QuestPDF

What could be covered in my books

In future editions, I plan to add other libraries. Please let me know which libraries would be most important for your needs. Currently, the following are most likely to be included in the next edition:

Working with images

ImageSharp is a third-party cross-platform 2D graphics library. When .NET Core 1.0 was in development, there was negative feedback from the community about the missing System.Drawing namespace for working with 2D images. The ImageSharp project was started to fill that gap for modern .NET applications.

In their official documentation for System.Drawing, Microsoft says, “The System.Drawing namespace is not recommended for new development due to not being supported within a Windows or ASP.NET service, and it is not cross-platform. ImageSharp and SkiaSharp are recommended as alternatives.”

SixLabors released ImageSharp 2.0 on February 7, 2022, with WebP, Tiff, and Pbm format support, more efficient and faster memory pooling and allocation, and massive performance improvements for their JPEG and PNG formats. You can read the announcement at the following link: https://sixlabors.com/posts/announcing-imagesharp-200/.

Generating grayscale thumbnails

Let’s see what can be achieved with ImageSharp:

  1. Use your preferred code editor to add a new console app named WorkingWithImages to a Chapter05 solution/workspace.
  2. In the WorkingWithImages project, create an images folder and download to it the nine images from the following link: https://github.com/markjprice/apps-services-net7/tree/master/images/Categories.
  3. If you are using Visual Studio 2022, then the images folder and its files must be copied to the WorkingWithImagesinDebug et7 folder:
    1. In Solution Explorer, select all nine images.
    2. In Properties, set Copy To Output Directory to Copy Always.
    3. Open the project file and note the <ItemGroup> entries that will copy the nine images to the correct folder, as partially shown in the following markup:
      <ItemGroup>
        <None Update="imagescategories.jpeg">
          <CopyToOutputDirectory>Always</CopyToOutputDirectory>
        </None>
        <None Update="imagescategory1.jpeg">
          <CopyToOutputDirectory>Always</CopyToOutputDirectory>
        </None>
      ...
      
  4. In the WorkingWithImages project, globally and statically import the System.Console class and add a package reference for SixLabors.ImageSharp, as shown in the following markup:
    <ItemGroup>
      <Using Include="System.Console" Static="true" />
    </ItemGroup>
    <ItemGroup>
      <PackageReference Include="SixLabors.ImageSharp" Version="2.1.0" />
    </ItemGroup>
    
  5. Build the WorkingWithImages project.
  6. In Program.cs, delete the existing statements and then import some namespaces for working with images, as shown in the following code:
    using SixLabors.ImageSharp; // Image
    using SixLabors.ImageSharp.Processing; // Mutate extension method
    
  7. In Program.cs, enter statements to convert all the files in the images folder into grayscale thumbnails at one-tenth size, as shown in the following code:
    string imagesFolder = Path.Combine(
      Environment.CurrentDirectory, "images");
    WriteLine($"I will look for images in the following folder:
    {imagesFolder}");
    WriteLine();
    if (!Directory.Exists(imagesFolder))
    {
      WriteLine();
      WriteLine("Folder does not exist!");
      return;
    }
    IEnumerable<string> images =
      Directory.EnumerateFiles(imagesFolder);
    foreach (string imagePath in images)
    {
      if (Path.GetFileNameWithoutExtension(imagePath).EndsWith("-thumbnail"))
      {
        WriteLine($"Skipping:
      {imagePath}");
        WriteLine();
        continue; // this file has already been converted
      }
      string thumbnailPath = Path.Combine(
        Environment.CurrentDirectory, "images",
        Path.GetFileNameWithoutExtension(imagePath)
        + "-thumbnail" + Path.GetExtension(imagePath));
      using (Image image = Image.Load(imagePath))
      {
        WriteLine($"Converting:
      {imagePath}");
        WriteLine($"To:
      {thumbnailPath}");
        image.Mutate(x => x.Resize(image.Width / 10, image.Height / 10));
        image.Mutate(x => x.Grayscale());
        image.Save(thumbnailPath);
        WriteLine();
      }
    }
    WriteLine("Image processing complete. View the images folder.");
    
  8. Run the console app and note the images should be converted into grayscale thumbnails, as shown in the following partial output:
    I will look for images in the following folder:
    C:apps-services-net7Chapter05WorkingWithImagesinDebug
    et7.0images
    Converting:
      C:apps-services-net7Chapter05WorkingWithImagesinDebug
    et7.0imagescategories.jpeg
    To:
      C:apps-services-net7Chapter05WorkingWithImagesinDebug
    et7.0imagescategories-thumbnail.jpeg
    Converting:
      C:apps-services-net7Chapter05WorkingWithImagesinDebug
    et7.0imagescategory1.jpeg
    To:
      C:apps-services-net7Chapter05WorkingWithImagesinDebug
    et7.0imagescategory1-thumbnail.jpeg
    ...
    Converting:
      C:apps-services-net7Chapter05WorkingWithImagesinDebug
    et7.0imagescategory8.jpeg
    To:
      C:apps-services-net7Chapter05WorkingWithImagesinDebug
    et7.0imagescategory8-thumbnail.jpeg
    Image processing complete. View the images folder.
    
  9. In the filesystem, open the images folder and note the much-smaller-in-bytes grayscale thumbnails, as shown in Figure 5.1:

Figure 5.1: Images after processing

ImageSharp packages for drawing and the web

ImageSharp also has NuGet packages for programmatically drawing images and working with images on the web, as shown in the following list:

  • SixLabors.ImageSharp.Drawing
  • SixLabors.ImageSharp.Web

More Information: Learn more details at the following link: https://docs.sixlabors.com/.

Logging with Serilog

Although .NET includes logging frameworks, third-party logging providers give more power and flexibility by using structured event data. Serilog is the most popular.

Structured event data

Most systems write plain text messages to their logs.

Serilog can be told to write serialized structured data to the log. The @ symbol prefixing a parameter tells Serilog to serialize the object passed in, instead of just the result of calling the ToString method.

Later, that complex object can be queried for improved search and sort capabilities in the logs.

For example:

var lineitem = new { ProductId = 11, UnitPrice = 25.49, Quantity = 3 };
log.Information("Added {@LineItem} to shopping cart.", lineitem);

You can learn more about how Serilog handles structured data at the following link: https://github.com/serilog/serilog/wiki/Structured-Data.

Serilog sinks

All logging systems need to record the log entries somewhere. That could be to the console output, a file, or a more complex data store like a relational database or cloud data store. Serilog calls these sinks.

Serilog has hundreds of official and third-party sink packages for all the possible places you might want to record your logs. To use them, just include the appropriate package. The most popular are shown in the following list:

  • serilog.sinks.file
  • serilog.sinks.console
  • serilog.sinks.periodicbatching
  • serilog.sinks.debug
  • serilog.sinks.rollingfile (deprecated; use serilog.sinks.file instead)
  • serilog.sinks.applicationinsights
  • serilog.sinks.mssqlserver

There are more than 390 packages currently listed on Microsoft’s public NuGet feed: https://www.nuget.org/packages?q=serilog.sinks.

Logging to the console and a rolling file with Serilog

Let’s start:

  1. Use your preferred code editor to add a new console app named Serilogging to a Chapter05 solution/workspace:
    • In Visual Studio 2022, set the startup project to the current selection.
    • In Visual Studio Code, select Serilogging as the active OmniSharp project.
  2. In the Serilogging project, globally and statically import the System.Console class and add a package reference for Serilog, including sinks for console and file (which also supports rolling files), as shown in the following markup:
    <ItemGroup>
      <Using Include="System.Console" Static="true" />
    </ItemGroup>
    <ItemGroup>
      <PackageReference Include="Serilog" Version="2.10.0" />
      <PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
      <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
    </ItemGroup>
    
  3. Build the Serilogging project.
  4. In the Serilogging project, in the Models folder, add a new class file named ProductPageView.cs, and modify its contents, as shown in the following code:
    namespace Serilogging.Models;
    public class ProductPageView
    {
      public int ProductId { get; set; }
      public string? PageTitle { get; set; }
      public string? SiteSection { get; set; }
    }
    
  5. In Program.cs, delete the existing statements and then import some namespaces for working with Serilog, as shown in the following code:
    using Serilog; // Log, LoggerConfiguration, RollingInterval
    using Serilog.Core; // Logger
    using Serilogging.Models; // ProductPageView
    
  6. In Program.cs, create a logger configuration that will write to the console as well as configuring a rolling interval that means a new file is created each day, and write various levels of log entries, as shown in the following code:
    using Logger log = new LoggerConfiguration()
        .WriteTo.Console()
        .WriteTo.File("log.txt", rollingInterval: RollingInterval.Day)
        .CreateLogger();
    Log.Logger = log;
    Log.Information("The global logger has been configured.");
    Log.Warning("Danger, Serilog, danger!");
    Log.Error("This is an error!");
    Log.Fatal("Fatal problem!");
    ProductPageView pageView = new() { 
      PageTitle = "Chai", 
      SiteSection = "Beverages", 
      ProductId = 1 };
    Log.Information("{@PageView} occurred at {Viewed}",
      pageView, DateTimeOffset.UtcNow);
    // just before ending an application
    Log.CloseAndFlush();
    
  7. Run the console app and note the messages, as shown in the following output:
    [07:09:43 INF] The global logger has been configured.
    [07:09:43 WRN] Danger, Serilog, danger!
    [07:09:43 ERR] This is an error!
    [07:09:43 FTL] Fatal problem!
    [07:09:43 INF] {"ProductId": 1, "PageTitle": "Chai", "SiteSection": "Beverages", "$type": "ProductPageView"} occurred at 09/07/2022 15:08:44 +00:00
    
  8. Open the logYYYYMMDD.txt file, where YYYY is the year, MM is the month, and DD is the day, and note it contains the same messages.

More Information: Learn more details at the following link: https://serilog.net/.

Mapping between objects

One of the most boring parts of being a programmer is mapping between objects. It is common to need to integrate systems or components that have conceptually similar objects but with different structures.

Models for data are different for different parts of an application. Models that represent data in storage are often called entity models. Models that represent data that must be passed between layers are often called data transfer objects (DTO). Models that represent only the data that must be presented to a user are often called view models. All these models are likely to have commonalities but different structures.

AutoMapper is a popular package for mapping objects because it has conventions that make the work as easy as possible. For example, if you have a source member called CompanyName, it will be mapped to a destination member with the name CompanyName.

AutoMapper’s creator, Jimmy Bogard, has written an article about its design philosophy that is worth reading, available at the following link: https://jimmybogard.com/automappers-design-philosophy/.

Let’s see an example of AutoMapper in action. You will create four projects:

  • A class library for the entity and view models.
  • A class library to create mapper configurations for reuse in unit tests and actual projects.
  • A unit test project to test the mappings.
  • A console app to perform a live mapping.

We will construct an example object model that represents a customer and their shopping cart with a couple of items, and then map it to a summary view model to present to the user.

Testing an AutoMapper configuration

It is good practice to always validate your configuration for mappings before using them, so we will start by defining some models and a mapping between them, and then create a unit test for the mappings:

  1. Use your preferred code editor to add a new Class Library/classlib project named MappingObjects.Models to the Chapter05 solution/workspace.
  2. In the MappingObjects.Models project, delete the file named Class1.cs.
  3. In the MappingObjects.Models project, add a new class file named Customer.cs and modify its contents, as shown in the following code:
    namespace Packt.Entities;
    public record class Customer(
      string FirstName,
      string LastName
    );
    
  4. In the MappingObjects.Models project, add a new class file named LineItem.cs and modify its contents, as shown in the following code:
    namespace Packt.Entities;
    public record class LineItem(
      string ProductName,
      decimal UnitPrice,
      int Quantity
    );
    
  5. In the MappingObjects.Models project, add a new class file named Cart.cs and modify its contents, as shown in the following code:
    namespace Packt.Entities;
    public record class Cart(
      Customer Customer,
      List<LineItem> Items
    );
    
  6. In the MappingObjects.Models project, add a new class file named Summary.cs and modify its contents, as shown in the following code:
    namespace Packt.ViewModels;
    public class Summary
    {
      public string? FullName { get; set; }
      public decimal Total { get; set; }
    }
    

    For the entity models, we used records because they will be immutable. But an instance of Summary will be created and then its members populated automatically by AutoMapper, so it must be a normal mutable class with public properties that can be set.

  1. Use your preferred code editor to add a new Class Library/classlib project named MappingObjects.Mappers to the Chapter05 solution/workspace.
  2. In the MappingObjects.Mappers project, treat warnings as errors, add a reference to the latest AutoMapper package, and add a reference to the models project, as shown highlighted in the following markup:
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>net7.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
      </PropertyGroup>
      <ItemGroup>
        <PackageReference Include="AutoMapper" Version="11.0.1" />
      </ItemGroup>
      <ItemGroup>
        <ProjectReference Include=
          "..MappingObjects.ModelsMappingObjects.Models.csproj" />
      </ItemGroup>
    </Project>
    
  3. Build the MappingObjects.Mappers project to restore packages and compile referenced projects.
  4. In the MappingObjects.Mappers project, delete the file named Class1.cs.
  5. In the MappingObjects.Mappers project, add a new class file named CartToSummaryMapper.cs and modify its contents to create a mapper configuration that maps the FullName of the Summary to a combination of the FirstName and LastName from Customer, as shown in the following code:
    using AutoMapper; // MapperConfiguration
    using AutoMapper.Internal; // Internal() extension method
    using Packt.Entities; // Cart
    using Packt.ViewModels; // Summary
    namespace MappingObjects.Mappers;
    public static class CartToSummaryMapper
    {
      public static MapperConfiguration GetMapperConfiguration()
      {
        MapperConfiguration config = new(cfg =>
        {
          // fix issue with .NET 7 and its new MaxInteger method
          // https://github.com/AutoMapper/AutoMapper/issues/3988
          cfg.Internal().MethodMappingEnabled = false;
          // configure mapper using projections
          cfg.CreateMap<Cart, Summary>()
            // FullName
           .ForMember(dest => dest.FullName, opt => opt.MapFrom(src =>
              string.Format("{0} {1}", 
                src.Customer.FirstName,
                src.Customer.LastName)
            ));
        });
        return config;
      }
    };
    
  6. Use your preferred code editor to add a new xUnit Test Project/xunit named MappingObjects.Tests to the Chapter05 solution/workspace.
  7. In the MappingObjects.Tests project, add a package reference to AutoMapper, as shown highlighted in the following markup:
    <ItemGroup>
      <PackageReference Include="AutoMapper" Version="11.0.1" />
      <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
    
  8. In the MappingObjects.Tests project, add project references to MappingObjects.Models and MappingObjects.Mappers, as shown in the following markup:
    <ItemGroup>
      <ProjectReference Include=
        "..MappingObjects.MappersMappingObjects.Mappers.csproj" />
      <ProjectReference Include=
        "..MappingObjects.ModelsMappingObjects.Models.csproj" />
    </ItemGroup>
    
  9. Build the MappingObjects.Tests project.
  10. In the MappingObjects.Tests project, rename UnitTest1.cs to TestAutoMapperConfig.cs.
  11. Modify the contents of TestAutoMapperConfig.cs to get the mapper and then assert that the mapping is complete, as shown in the following code:
    using AutoMapper; // MapperConfiguration
    using MappingObjects.Mappers; // CartToSummaryMapper 
    namespace MappingObjects.Tests;
    public class TestAutoMapperConfig
    {  
      [Fact]
      public void TestSummaryMapping()
      {
        MapperConfiguration config =
          CartToSummaryMapper.GetMapperConfiguration();
        config.AssertConfigurationIsValid();
      }
    }
    
  12. Run the test:
    • In Visual Studio 2022, navigate to Test | Run All Tests.
    • In Visual Studio Code, in Terminal, enter dotnet test.
  13. Note the test fails because the Total member of the Summary view model is unmapped, as shown in Figure 5.2:

Figure 5.2: The test fails because the Total member is unmapped

  1. In the MappingObjects.Mappers project, in the mapper configuration, add a mapping for the Total member, as shown highlighted in the following code:
    MapperConfiguration config = new(cfg =>
    {
      // fix issue with .NET 7 and its new MaxInteger method
      // https://github.com/AutoMapper/AutoMapper/issues/3988
      cfg.Internal().MethodMappingEnabled = false;
      // configure mapper using projections
      cfg.CreateMap<Cart, Summary>()
        // FullName
        .ForMember(dest => dest.FullName, opt => opt.MapFrom(src =>
          string.Format("{0} {1}", 
            src.Customer.FirstName, src.Customer.LastName)
        ))
        // Total
        .ForMember(dest => dest.Total, opt => opt.MapFrom(
          src => src.Items.Sum(item => item.UnitPrice * item.Quantity)));
    });
    
  2. Run the test and note that this time it passes.

Performing live mappings between models

Now that we have validated the configuration of our mapping, we can use it in a live app:

  1. Use your preferred code editor to add a new Console App/console project named MappingObjects to the Chapter05 solution/workspace.
  2. In the MappingObjects project, globally and statically import the System.Console class, add a project reference for the two class libraries, and add a package reference for AutoMapper, as shown in the following markup:
    <ItemGroup>
      <Using Include="System.Console" Static="true" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference Include=
        "..MappingObjects.MappersMappingObjects.Mappers.csproj" />
      <ProjectReference Include=
        "..MappingObjects.ModelsMappingObjects.Models.csproj" />
    </ItemGroup>
    <ItemGroup>
      <PackageReference Include="AutoMapper" Version="11.0.1" />
    </ItemGroup>
    
  3. Build the MappingObjects project:
    • In Visual Studio Code, select MappingObjects as the active OmniSharp project.
  4. In Program.cs, delete the existing statements and then add some statements to construct an example object model that represents a customer and their shopping cart with a couple of items, and then map it to a summary view model to present to the user, as shown in the following code:
    using AutoMapper; // MapperConfiguration, IMapper
    using MappingObjects.Mappers; // CartToSummaryMapper
    using Packt.Entities; // Customer, Cart, LineItem
    using Packt.ViewModels; // Summary
    // Create an object model from "entity" model types that
    // might have come from a data store.
    Cart cart = new(
      Customer: new(
        FirstName: "John",
        LastName: "Smith"
      ), 
      Items: new()
      {
        new(ProductName: "Apples", UnitPrice: 0.49M, Quantity: 10),
        new(ProductName: "Bananas", UnitPrice: 0.99M, Quantity: 4)
      }
    );
    WriteLine($"{cart.Customer}");
    foreach (LineItem item in cart.Items)
    {
      WriteLine($"  {item}");
    }
    // Get the mapper configuration for converting a Cart to a Summary.
    MapperConfiguration config = CartToSummaryMapper.GetMapperConfiguration();
    // Create a mapper using the configuration.
    IMapper mapper = config.CreateMapper();
    // Perform the mapping.
    Summary summary = mapper.Map<Cart, Summary>(cart);
    // Output the result.
    WriteLine($"Summary: {summary.FullName} spent {summary.Total}.");
    
  5. Run the console app and note the successful result, as shown in the following code:
    Customer { FirstName = John, LastName = Smith }
      LineItem { ProductName = Apples, UnitPrice = 0.49, Quantity = 10 }
      LineItem { ProductName = Bananas, UnitPrice = 0.99, Quantity = 4 }
    Summary: John Smith spent 8.86.
    

Good Practice: There is a debate about when AutoMapper should be used that you can read about in an article (which has more links at the bottom) at the following link: https://www.anthonysteele.co.uk/AgainstAutoMapper.html.

More Information: Learn more details about AutoMapper at the following link: https://automapper.org/.

Making fluent assertions in unit testing

FluentAssertions are a set of extension methods that make writing and reading the code in unit tests and the error messages of failing tests more similar to a natural human language like English.

It works with most unit testing frameworks, including xUnit. When you add a package reference for a test framework, FluentAssertions will automatically find the package and use it for throwing exceptions.

After importing the FluentAssertions namespace, call the Should() extension method on a variable and then one of the hundreds of other extension methods to make assertions in a human-readable way. You can chain multiple assertions using the And() extension method or have separate statements, each calling Should().

Making assertions about strings

Let’s start by making assertions about a single string value:

  1. Use your preferred code editor to add a new xUnit Test Project/xunit named FluentTests to a Chapter05 solution/workspace.
    • In Visual Studio Code, select FluentTests as the active OmniSharp project.
  2. In the FluentTests project, add a package reference to FluentAssertions, as shown highlighted in the following markup:
    <ItemGroup>
      <PackageReference Include="FluentAssertions" Version="6.6.0" />
      <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
    
  3. Build the FluentTests project.
  4. Rename UnitTest1.cs to FluentExamples.cs.
  5. In FluentExamples.cs, import the namespace to make the fluent assertions extension methods available and write a test method for a string value, as shown in the following code:
    using FluentAssertions;
    namespace FluentTests
    {
      public class FluentExamples
      {
        [Fact]
        public void TestString()
        {
          string city = "London";
          string expectedCity = "London";
          city.Should().StartWith("Lo")
            .And.EndWith("on")
            .And.Contain("do")
            .And.HaveLength(6);
          city.Should().NotBeNull()
            .And.Be("London")
            .And.BeSameAs(expectedCity)
            .And.BeOfType<string>();
          city.Length.Should().Be(6);
        }
      }
    }
    
  6. Run the test:
    • In Visual Studio 2022, navigate to Test | Run All Tests.
    • In Visual Studio Code, in Terminal, enter dotnet test.
  7. Note the test passes.
  8. In the TestString method, in the expectedCity variable, delete the last n in London.
  9. Run the test and note it fails, as shown in the following output:
    Expected city "Londo" to end with "on".
    
  10. Add the n back in London.
  11. Run the test again to confirm the fix.

Making assertions about collections and arrays

Now let’s continue by making assertions about collections and arrays:

  1. In FluentExamples.cs, add a test method to explore collection assertions, as shown in the following code:
    [Fact]
    public void TestCollections()
    {
      string[] names = new[] { "Alice", "Bob", "Charlie" };
      names.Should().HaveCountLessThan(4,
        "because the maximum items should be 3 or fewer");
      names.Should().OnlyContain(name => name.Length <= 6);
    }
    
  2. Run the tests and note the collections test fails, as shown in the following output:
    Expected names to contain only items matching (name.Length <= 6), but {"Charlie"} do(es) not match.
    
  3. Change Charlie to Charly.
  4. Run the tests and note they succeed.

Making assertions about dates and times

Let’s start by making assertions about date and time values:

  1. In FluentExamples.cs, import the namespace for adding extension methods for named months and other useful date/time-related functionality, as shown in the following code:
    using FluentAssertions.Extensions; // February, March extension methods
    
  2. Add a test method to explore date/time assertions, as shown in the following code:
    [Fact]
    public void TestDateTimes()
    {
      DateTime when = new(
        hour: 9, minute: 30, second: 0,
        day: 25, month: 3, year: 2022);
      when.Should().Be(25.March(2022).At(9, 30));
      when.Should().BeOnOrAfter(23.March(2022));
      when.Should().NotBeSameDateAs(12.February(2022));
      when.Should().HaveYear(2022);
      DateTime due = new(
        hour: 11, minute: 0, second: 0,
        day: 25, month: 3, year: 2022);
      when.Should().BeAtLeast(2.Hours()).Before(due);
    }
    
  3. Run the tests and note the date/time test fails, as shown in the following output:
    Expected when <2022-03-25 09:30:00> to be at least 2h before <2022-03-25 11:00:00>, but it is behind by 1h and 30m.
    
  4. For the due variable, change the hour from 11 to 13.
  5. Run the tests and note the date/time test succeeds.

More Information: Learn more details at the following link: https://fluentassertions.com/.

Validating data

FluentValidation allows you to define strongly typed validation rules in a human-readable way.

You create a validator for a type by inheriting from AbstractValidator<T>, where T is the type that you want to validate. In the constructor, you call the RuleFor method to define one or more rules. If a rule should run only in specified scenarios, then you call the When method.

Understanding the built-in validators

FluentValidation ships with lots of useful built-in validator extension methods for defining rules, as shown in the following partial list:

  • Null, NotNull, Empty, NotEmpty
  • Equal, NotEqual
  • Length, MaxLength, MinLength
  • LessThan, LessThanOrEqualTo, GreaterThan, GreaterThanOrEqualTo
  • InclusiveBetween, ExclusiveBetween
  • ScalePrecision
  • Must (aka predicate)
  • Matches (aka regular expression), EmailAddress, CreditCard
  • IsInEnum, IsEnumName

Performing custom validation

The easiest way to create custom rules is to use Predicate to write a custom validation function. You can also call the Custom method to get maximum control.

Customizing validation messages

There are a few extension methods that are used to customize the validation messages output when data fails to pass the rules:

  • WithName: Change the name used for a property in the message.
  • WithSeverity: Change the default severity from Error to Warning or some other level.
  • WithErrorCode: Assign an error code that can be output in the message.
  • WithState: Add some state that can be used in the message.
  • WithMessage: Customize the format of the default message.

Defining a model and validator

Let’s see an example of FluentValidation in action. You will create three projects:

  • A class library for a model to validate that represents an order made by a customer
  • A class library for the validator for the model
  • A console app to perform a live validation

Let’s start:

  1. Use your preferred code editor to add a new Class Library/classlib project named FluentValidation.Models to the Chapter05 solution/workspace.
  2. In the FluentValidation.Models project, delete the file named Class1.cs.
  3. In the FluentValidation.Models project, add a new class file named CustomerLevel.cs and modify its contents to define an enum with three customer levels, Bronze, Silver, and Gold, as shown in the following code:
    namespace FluentValidation.Models;
    public enum CustomerLevel
    {
      Bronze,
      Silver,
      Gold
    }
    
  4. In the FluentValidation.Models project, add a new class file named Order.cs and modify its contents, as shown in the following code:
    namespace FluentValidation.Models;
    public class Order
    {
      public long OrderId { get; set; }
      public string? CustomerName { get; set; }
      public string? CustomerEmail { get; set; } 
      public CustomerLevel CustomerLevel { get; set; }
      public decimal Total { get; set; }
      public DateTime OrderDate { get; set; }
      public DateTime ShipDate { get; set; }
    }
    
  5. Use your preferred code editor to add a new Class Library/classlib project named FluentValidation.Validators to the Chapter05 solution/workspace.
  6. In the FluentValidation.Validators project, add a project reference to the models project and a package reference to the FluentValidation package, as shown in the following markup:
    <ItemGroup>
      <PackageReference Include="FluentValidation" Version="10.4.0" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference Include=
        "..FluentValidation.ModelsFluentValidation.Models.csproj" />
    </ItemGroup>
    
  7. Build the FluentValidation.Validators project.
  8. In the FluentValidation.Validators project, delete the file named Class1.cs.
  9. In the FluentValidation.Validators project, add a new class file named OrderValidator.cs and modify its contents, as shown in the following code:
    using FluentValidation.Models;
    namespace FluentValidation.Validators;
    public class OrderValidator : AbstractValidator<Order>
    {
      public OrderValidator()
      {
        RuleFor(order => order.OrderId)
          .NotEmpty(); // not default(long)
        RuleFor(order => order.CustomerName)
          .NotNull()
          .WithName("Name");
        RuleFor(order => order.CustomerName)
          .MinimumLength(5)
          .WithSeverity(Severity.Warning);
        RuleFor(order => order.CustomerEmail)
          .NotEmpty()
          .EmailAddress();
        RuleFor(order => order.CustomerLevel)
          .IsInEnum();
        RuleFor(order => order.Total)
          .GreaterThan(0);
        RuleFor(order => order.ShipDate)
          .GreaterThan(order => order.OrderDate);
        When(order => order.CustomerLevel == CustomerLevel.Gold, () =>
        {
          RuleFor(order => order.Total).LessThan(50M);
          RuleFor(order => order.Total).GreaterThanOrEqualTo(20M);
        }).Otherwise(() =>
        {
          RuleFor(order => order.Total).LessThan(20M);
        });
      }
    }
    

Testing the validator

Now we are ready to create a console app to test the validator on the model:

  1. Use your preferred code editor to add a new console app named FluentValidation.App to a Chapter05 solution/workspace.
    • In Visual Studio Code, select FluentValidation.App as the active OmniSharp project.
  2. In the FluentValidation.App project, globally and statically import the System.Console class and add project references for FluentValidation.Validators and FluentValidation.Models, as shown in the following markup:
    <ItemGroup>
      <Using Include="System.Console" Static="true" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference Include=
        "..FluentValidation.ModelsFluentValidation.Models.csproj" />
      <ProjectReference Include=
        "..FluentValidation.ValidatorsFluentValidation.Validators.csproj" />
    </ItemGroup>
    
  3. Build the FluentValidation.App project.
  4. In Program.cs, delete the existing statements and then add statements to create an order and validate it, as shown in the following code:
    using FluentValidation.Models; // Order
    using FluentValidation.Results; // ValidationResult
    using FluentValidation.Validators; // OrderValidator
    Order order = new()
    {
      // start with an invalid order
    };
    OrderValidator validator = new();
    ValidationResult result = validator.Validate(order);
    WriteLine($"CustomerName:  {order.CustomerName}");
    WriteLine($"CustomerEmail: {order.CustomerEmail}");
    WriteLine($"CustomerLevel: {order.CustomerLevel}");
    WriteLine($"OrderId:       {order.OrderId}");
    WriteLine($"OrderDate:     {order.OrderDate}");
    WriteLine($"ShipDate:      {order.ShipDate}");
    WriteLine($"Total:         {order.Total}");
    WriteLine();
    WriteLine($"IsValid:  {result.IsValid}");
    foreach (var item in result.Errors)
    {
      WriteLine($"  {item.Severity}: {item.ErrorMessage}");
    }
    
  5. Run the console app and note the failed rules, as shown in the following output:
    CustomerName:
    CustomerEmail:
    CustomerLevel: Bronze
    OrderId:       0
    OrderDate:     01/01/0001 00:00:00
    ShipDate:      01/01/0001 00:00:00
    Total:         0
    IsValid:  False
      Error: 'Order Id' must not be empty.
      Error: 'Name' must not be empty.
      Error: 'Customer Email' must not be empty.
      Error: 'Total' must be greater than '0'.
      Error: 'Ship Date' must be greater than '01/01/0001 00:00:00'.
    

    The text of the error messages will be automatically localized into your operating system’s native language.

  1. Set some property values for the order, as shown highlighted in the following code:
    Order order = new()
    {
      OrderId = 10001,
      CustomerName = "Abc",
      CustomerEmail = "abc&example.com",
      CustomerLevel = (CustomerLevel)4,
      OrderDate = new(2022, 12, 1),
      ShipDate = new(2022, 11, 5),
      Total = 49.99M
    };
    
  2. Run the console app and note the failed rules, as shown in the following output:
    CustomerName:  Abc
    CustomerEmail: abc&example.com
    CustomerLevel: 4
    OrderId:       10001
    OrderDate:     01/12/2022 00:00:00
    ShipDate:      05/11/2022 00:00:00
    Total:         49.99
    IsValid:  False
      Warning: The length of 'Customer Name' must be at least 5 characters. You entered 3 characters.
      Error: 'Customer Email' is not a valid email address.
      Error: 'Customer Level' has a range of values which does not include '4'.
      Error: 'Ship Date' must be greater than '01/12/2022 00:00:00'.
      Error: 'Total' must be less than '20'.
    
  3. Modify some property values for the order, as shown highlighted in the following code:
    Order order = new()
    {
      OrderId = 10001,
      CustomerName = "Abcdef",
      CustomerEmail = "abc@example.com",
      CustomerLevel = CustomerLevel.Gold,
      OrderDate = new(2022, 12, 1),
      ShipDate = new(2022, 12, 5),
      Total = 49.99M
    };
    
  4. Run the console app and note the order is now valid, as shown in the following output:
    CustomerName:  Abcdef
    CustomerEmail: [email protected]
    CustomerLevel: Gold
    OrderId:       10001
    OrderDate:     01/12/2022 00:00:00
    ShipDate:      05/12/2022 00:00:00
    Total:         49.99
    IsValid:  True
    

Integrating with ASP.NET Core

For automatic validation with ASP.NET Core, FluentValidation supports .NET Core 3.1 and later.

More Information: Learn more details at the following link: https://cecilphillip.com/fluent-validation-rules-with-asp-net-core/.

Generating PDFs

One of the most common requests I get when teaching C# and .NET is, “What open-source library is available to generate PDF files?”

There are many licensed libraries for generating PDF files, but over the years it has been difficult to find cross-platform open-source ones. QuestPDF is the latest example.

QuestPDF uses SkiaSharp and that has implementations for Windows, Mac, and Linux operating systems. The console app that you create in this section to generate PDFs is therefore cross-platform. But on an Apple Silicon Mac, like my Mac mini M1, I had to install the x64 version of .NET 7 and start the project using dotnet run -a x64. This tells the .NET SDK to use the x64 architecture, otherwise the SkiaSharp libraries give an error because they have not yet been built to target Arm64.

Creating class libraries to generate PDF documents

Let’s see an example of QuestPDF in action. You will create three projects:

  • A class library for a model that represents a catalog of product categories with names and images.
  • A class library for the document template.
  • A console app to perform a live generation of a PDF file.

Let’s start:

  1. Use your preferred code editor to add a new Class Library/classlib project named GeneratingPdf.Models to the Chapter05 solution/workspace.
  2. In the GeneratingPdf.Models project, delete the file named Class1.cs.
  3. In the GeneratingPdf.Models project, add a new class file named Category.cs and modify its contents to define a class with two properties for the name and identifier of a category, as shown in the following code:
    namespace GeneratingPdf.Models;
    public class Category
    {
      public int CategoryId { get; set; }
      public string CategoryName { get; set; } = null!;
    }
    

    Later, you will create an images folder with filenames that use the pattern categoryN.jpeg, where N is a number from 1 to 8 that matches the CategoryId values.

  1. In the GeneratingPdf.Models project, add a new class file named Catalog.cs and modify its contents to define a class with a property to store the eight categories, as shown in the following code:
    namespace GeneratingPdf.Models;
    public class Catalog
    {
      public List<Category> Categories { get; set; } = null!;
    }
    
  2. Use your preferred code editor to add a new Class Library/classlib project named GeneratingPdf.Document to the Chapter05 solution/workspace.
  3. In the GeneratingPdf.Document project, add a package reference for QuestPDF and a project reference for the models class library, as shown in the following markup:
    <ItemGroup>
      <PackageReference Include="QuestPDF" Version="2022.4.1" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference Include=
        "..GeneratingPdf.ModelsGeneratingPdf.Models.csproj" />
    </ItemGroup>
    
  4. Build the GeneratingPdf.Document project.
  5. In the GeneratingPdf.Document project, delete the file named Class1.cs.
  6. In the GeneratingPdf.Document project, add a new class file named CatalogDocument.cs.
  7. In CatalogDocument.cs, define a class that implements the IDocument interface to define a template with a header and a footer, and then output the eight categories, including name and image, as shown in the following code:
    using GeneratingPdf.Models; // Catalog
    using QuestPDF.Drawing; // DocumentMetadata
    using QuestPDF.Fluent; // Page
    using QuestPDF.Helpers; // Colors
    using QuestPDF.Infrastructure; // IDocument, IDocumentContainer
    namespace GeneratingPdf.Document;
    public class CatalogDocument : IDocument
    {
      public Catalog Model { get; }
      public CatalogDocument(Catalog model)
      {
        Model = model;
      }
      public void Compose(IDocumentContainer container)
      {
        container
          .Page(page =>
          {
            page.Margin(50 /* points */);
            page.Header()
              .Height(100).Background(Colors.Grey.Lighten1)
              .AlignCenter().Text("Catalogue")
              .Style(TextStyle.Default.FontSize(20));
            page.Content()
              .Background(Colors.Grey.Lighten3)
              .Table(table =>
              {
                table.ColumnsDefinition(columns =>
                {
                  columns.ConstantColumn(100);
                  columns.RelativeColumn();
                });
                foreach (var item in Model.Categories)
                {
                  table.Cell().Text(item.CategoryName);
                  string imagePath = Path.Combine(
                    Environment.CurrentDirectory, "images", 
                    $"category{item.CategoryId}.jpeg");
                  
                  table.Cell().Image(imagePath);
                }
              });
            page.Footer()
              .Height(50).Background(Colors.Grey.Lighten1)
              .AlignCenter().Text(x =>
              {
                x.CurrentPageNumber();
                x.Span(" of ");
                x.TotalPages();
              });
          });
      }
      public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
    }
    

Creating a console app to generate PDF documents

Now we can create a console app project that will use the class libraries to generate a PDF document:

  1. Use your preferred code editor to add a new console app named GeneratingPdf.App to a Chapter05 solution/workspace.
    • In Visual Studio Code, select GeneratingPdf.App as the active OmniSharp project.
  2. In the GeneratingPdf.App project, create an images folder and download to it the eight category images 1 to 8 from the following link: https://github.com/markjprice/apps-services-net7/tree/master/images/Categories.
  3. If you are using Visual Studio 2022, then the images folder and its files must be copied to the GeneratingPdf.AppinDebug et7 folder:
    1. In Solution Explorer, select all the images.
    2. In Properties, set Copy To Output Directory to Copy Always.
    3. Open the project file and note the <ItemGroup> entries that will copy the nine images to the correct folder, as partially shown in the following markup:
      <ItemGroup>
        <None Update="imagescategory1.jpeg">
          <CopyToOutputDirectory>Always</CopyToOutputDirectory>
        </None>
      ...
      
  4. In the GeneratingPdf.App project, globally and statically import the System.Console class and add a project reference for the document template class library, as shown in the following markup:
    <ItemGroup>
      <Using Include="System.Console" Static="true" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference Include=
        "..GeneratingPdf.DocumentGeneratingPdf.Document.csproj" />
    </ItemGroup>
    
  5. Build the GeneratingPdf.App project.
  6. In Program.cs, delete the existing statements and then add statements to create a catalog model, pass it to a catalog document, generate a PDF file, and then attempt to open the file using the appropriate operating system command, as shown in the following code:
    using GeneratingPdf.Document; // CatalogDocument
    using GeneratingPdf.Models; // Catalog, Category
    using QuestPDF.Fluent; // GeneratePdf extension method
    string filename = "catalog.pdf";
    Catalog model = new()
    {
      Categories = new()
      {
        new() { CategoryId = 1, CategoryName = "Beverages"},
        new() { CategoryId = 2, CategoryName = "Condiments"},
        new() { CategoryId = 3, CategoryName = "Confections"},
        new() { CategoryId = 4, CategoryName = "Dairy Products"},
        new() { CategoryId = 5, CategoryName = "Grains/Cereals"},
        new() { CategoryId = 6, CategoryName = "Meat/Poultry"},
        new() { CategoryId = 7, CategoryName = "Produce"},
        new() { CategoryId = 8, CategoryName = "Seafood"},
      }
    };
    CatalogDocument document = new(model);
    document.GeneratePdf(filename);
    WriteLine($"PDF catalog has been created: {filename}");
    try
    {
      if (OperatingSystem.IsWindows())
      {
        System.Diagnostics.Process.Start("explorer.exe", filename);
      }
      else
      {
        WriteLine("Open the file manually.");
      }
    }
    catch (Exception ex)
    {
      WriteLine($"{ex.GetType()} says {ex.Message}");
    }
    

    The Process class and its Start method should also be able to start processes on Mac and Linux, but getting the paths right can be tricky, so I’ve left that as an optional exercise for the reader. You can learn more about the Process class and its Start method at the following link: https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.process.start.

  1. Run the console app and note the PDF file generated, as shown in Figure 5.3:

Figure 5.3: A PDF file generated from C# code

More Information: Learn more details at the following link: https://www.questpdf.com/.

Practicing and exploring

Test your knowledge and understanding by answering some questions, getting some hands-on practice, and doing deeper research into the topics in this chapter.

Exercise 5.1 – Test your knowledge

Use the web to answer the following questions:

  1. What is the most downloaded third-party NuGet package of all time?
  2. What method do you call on the ImageSharp Image class to make a change like resizing the image or replacing colors with grayscale?
  3. What is a key benefit of using Serilog for logging?
  4. What is a Serilog sink?
  5. Should you always use a package like AutoMapper to map between objects?
  6. Which FluentAssertions method should you call to start a fluent assertion on a value?
  7. Which FluentAssertions method should you call to assert that all items in a sequence conform to a condition, like a string item must have less than six characters?
  8. Which FluentValidation class should you inherit from to define a custom validator?
  9. With FluentValidation, how can you set a rule to only apply in certain conditions?
  10. With QuestPDF, which interface must you implement to define a document for a PDF, and what methods of that interface must you implement?

Exercise 5.2 – Explore topics

Use the links on the following page to learn more detail about the topics covered in this chapter:

https://github.com/markjprice/apps-services-net7/blob/main/book-links.md#chapter-5---using-popular-third-party-libraries

Summary

In this chapter, you explored some third-party libraries that are popular with .NET developers to perform functions including:

  • Manipulating images using a Microsoft-recommended third-party library named ImageSharp.
  • Logging structured data with Serilog.
  • Mapping between objects, for example, entity models to view models.
  • Making fluent assertions in unit testing.
  • Validating data in an English language-readable way.
  • Generating a PDF file.

In the next chapter, we will look at advanced features of the C# compiler.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset