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:
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 |
|
106,664,914 |
2 |
|
25,706,263 |
3 |
|
18,426,836 |
4 |
|
18,018,770 |
5 |
|
15,655,292 |
6 |
|
15,049,359 |
7 |
|
14,984,145 |
8 |
|
13,864,846 |
9 |
|
13,390,653 |
10 |
|
13,043,367 |
12 |
|
12,612,215 |
24 |
|
11,271,774 |
38 |
|
8,906,145 |
41 |
|
8,419,263 |
100 |
|
2,981,780 |
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:
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:
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/.
Let’s see what can be achieved with ImageSharp:
WorkingWithImages
to a Chapter05
solution/workspace.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.images
folder and its files must be copied to the WorkingWithImagesinDebug
et7
folder:<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>
...
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>
WorkingWithImages
project.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
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.");
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.
images
folder and note the much-smaller-in-bytes grayscale thumbnails, as shown in Figure 5.1:Figure 5.1: Images after processing
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/.
Although .NET includes logging frameworks, third-party logging providers give more power and flexibility by using structured event data. Serilog is the most popular.
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.
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.
Let’s start:
Serilogging
to a Chapter05
solution/workspace:Serilogging
as the active OmniSharp project.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>
Serilogging
project.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; }
}
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
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();
[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
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/.
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:
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.
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:
classlib
project named MappingObjects.Models
to the Chapter05
solution/workspace.MappingObjects.Models
project, delete the file named Class1.cs
.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
);
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
);
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
);
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.
classlib
project named MappingObjects.Mappers
to the Chapter05
solution/workspace.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>
MappingObjects.Mappers
project to restore packages and compile referenced projects.MappingObjects.Mappers
project, delete the file named Class1.cs
.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;
}
};
xunit
named MappingObjects.Tests
to the Chapter05
solution/workspace.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" />
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>
MappingObjects.Tests
project.MappingObjects.Tests
project, rename UnitTest1.cs
to TestAutoMapperConfig.cs
.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();
}
}
dotnet test
.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
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)));
});
Now that we have validated the configuration of our mapping, we can use it in a live app:
console
project named MappingObjects
to the Chapter05
solution/workspace.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>
MappingObjects
project:MappingObjects
as the active OmniSharp project.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}.");
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/.
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()
.
Let’s start by making assertions about a single string
value:
xunit
named FluentTests
to a Chapter05
solution/workspace.FluentTests
as the active OmniSharp project.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" />
FluentTests
project.UnitTest1.cs
to FluentExamples.cs
.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);
}
}
}
dotnet test
.TestString
method, in the expectedCity
variable, delete the last n
in London
.Expected city "Londo" to end with "on".
n
back in London
.Now let’s continue by making assertions about collections and arrays:
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);
}
Expected names to contain only items matching (name.Length <= 6), but {"Charlie"} do(es) not match.
Charlie
to Charly
.Let’s start by making assertions about date and time values:
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
[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);
}
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.
due
variable, change the hour from 11
to 13
.More Information: Learn more details at the following link: https://fluentassertions.com/.
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.
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
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.
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.Let’s see an example of FluentValidation in action. You will create three projects:
Let’s start:
classlib
project named FluentValidation.Models
to the Chapter05
solution/workspace.FluentValidation.Models
project, delete the file named Class1.cs
.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
}
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; }
}
classlib
project named FluentValidation.Validators
to the Chapter05
solution/workspace.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>
FluentValidation.Validators
project.FluentValidation.Validators
project, delete the file named Class1.cs
.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);
});
}
}
Now we are ready to create a console app to test the validator on the model:
FluentValidation.App
to a Chapter05
solution/workspace.FluentValidation.App
as the active OmniSharp project.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>
FluentValidation.App
project.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}");
}
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.
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
};
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'.
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
};
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
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/.
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.
Let’s see an example of QuestPDF in action. You will create three projects:
Let’s start:
classlib
project named GeneratingPdf.Models
to the Chapter05
solution/workspace.GeneratingPdf.Models
project, delete the file named Class1.cs
.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.
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!;
}
classlib
project named GeneratingPdf.Document
to the Chapter05
solution/workspace.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>
GeneratingPdf.Document
project.GeneratingPdf.Document
project, delete the file named Class1.cs
.GeneratingPdf.Document
project, add a new class file named CatalogDocument.cs
.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;
}
Now we can create a console app project that will use the class libraries to generate a PDF document:
GeneratingPdf.App
to a Chapter05
solution/workspace.GeneratingPdf.App
as the active OmniSharp project.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.images
folder and its files must be copied to the GeneratingPdf.AppinDebug
et7
folder:<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>
...
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>
GeneratingPdf.App
project.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.
Figure 5.3: A PDF file generated from C# code
More Information: Learn more details at the following link: https://www.questpdf.com/.
Test your knowledge and understanding by answering some questions, getting some hands-on practice, and doing deeper research into the topics in this chapter.
Use the web to answer the following questions:
Image
class to make a change like resizing the image or replacing colors with grayscale?string
item must have less than six characters?Use the links on the following page to learn more detail about the topics covered in this chapter:
In this chapter, you explored some third-party libraries that are popular with .NET developers to perform functions including:
In the next chapter, we will look at advanced features of the C# compiler.