The Datasync Community Toolkit - Day 6: Client basics
This article is the sixth in a series of articles about the [Datasync Community Toolkit][toolkit], which is a set of open source libraries for building client-server applications where the application data is available offline. The Datasync Community Toolkit allows you to connect to any database, use any authentication, and provides robust authorization rules. You implement each side of the application (client and server) using .NET - ASP.NET Core Web APIs on the server side, and any .NET client technology (including WPF, WinUI and MAUI) on the client side.
This is the first article about the client-side of things. If you missed the server-side articles, check out this set:
- Creating a service project
- The standard repositories
- Custom repositories
- Access control restrictions
- Real-time notifications
Over the next few weeks, I’ll go over the basics - setting up an online client, switching to an offline client, modifying the offline client for a couple of specific use cases, and adding authentication to the process. Each article will have an associated project you can use that is based on WPF (Windows Presentation Framework) and uses some of the other Community Toolkits as well. You should be able to run the application against a local service each time.
You may remember from earlier articles that the service side of the component is merely a Web API that implements standard CRUD operations against a repository, along with an OData v4 query capability. You don’t need a special library. In fact, my web based todo app that is based on TodoMVC did nothing more than HTTP calls behind the scenes.
The basic project
Todays topic is online operations, so I had better introduce todays sample as well.
There are two parts to todays sample - a service (which we will get to later) and a client. As provided, the client works “offline only”. It uses an in-memory database that is recreated whenever the application is started. There is no persistence. How do you get it?
- Download the adrianhall/samples repository.
- Switch to the
datasync-day6
folder. - Open the solution file.
- Build and run the
ClientApp
project.
This project is based on the todoapp sample that is provided with the Community Datasync Toolkit, but has all the datasync code removed.
It’s a good idea to take some time to understand this project before moving on. The MainWindow.xaml
contains all the UI code, and uses a TodoListViewModel
to handle the interactions with the service. The view model then calls methods within the ITodoService
to do the database changes. The InMemoryTodoService
is a special implementation of the service that uses a concurrent dictionary to store the data. There are four methods to implement:
- Get all items
- Get a single item by ID
- Add a new item
- Replace an existing item
It’s worthwhile noting that I’ve set up the TodoItem
model so that each entity contains the same metadata that we use for the server:
Id
is a globally unique ID stored as a string.UpdatedAt
is theDateTimeOffset
that the entity was last changed.Version
is an opaque string that changes whenever the entity changes.Deleted
is a boolean to indicate that the entity is deleted.
These are implemented in the OfflineClientEntity
abstract clas - something you can use in your own projects. Finally, everything is hooked up using the CommunityToolkit.MVVM project and dependency injection.
It’s worthwhile taking some time to study the application prior to adding the datasync toolkit to it. I won’t be covering WPF application development. You can also convert a similar application written in any C# client framework - including Avalonia, MAUI, WinUI, or the Uno Platform. Theoretically, as long as it supports .NET8, it should work. I haven’t tried Unity (in fact, I’ve never written anything in Unity), and there may be side effects for other platforms that I haven’t tried. The majority will work, however.
Online operations
Before we talk about offline operations (which is the topic for the next article), I’m going to talk about online operations. I mentioned earlier that I had created the web version of this application using plain HTTP calls. You also have the ability to use a client when using .NET. I started by creating the basic TodoApp server (called ServerApp) running on https://localhost:7181
, then configured the solution so that both server and client started concurrently.
The package to add for client operations is CommunityToolkit.Datasync.Client
and it’s available on NuGet. Once added, you can easily set up a new ITodoService
implementation that does the operations you need. Let’s take a look at the OnlineTodoService
class that is included in the project:
public class OnlineTodoService : ITodoService
{
private const string baseUrl = "https://localhost:7181";
private readonly DatasyncServiceClient<TodoItem> client;
public OnlineTodoService()
{
var clientOptions = new HttpClientOptions()
{
Endpoint = new Uri(baseUrl)
};
client = new(clientOptions);
}
public async Task<TodoItem> AddTodoItemAsync(string title, CancellationToken cancellationToken = default)
{
ServiceResponse<TodoItem> response = await client.AddAsync(new TodoItem { Title = title }, cancellationToken);
if (response.IsSuccessful && response.HasValue)
{
return response.Value!;
}
throw new ApplicationException(response.ReasonPhrase);
}
public async Task<List<TodoItem>> GetAllTodoItemsAsync(CancellationToken cancellationToken = default)
{
var list = await client.ToListAsync(cancellationToken);
return list;
}
public async Task<TodoItem?> GetTodoItemAsync(string id, CancellationToken cancellationToken = default)
{
ServiceResponse<TodoItem> response = await client.GetAsync(id, cancellationToken);
if (response.IsSuccessful && response.HasValue)
{
return response.Value!;
}
throw new ApplicationException(response.ReasonPhrase);
}
public async Task<TodoItem> ReplaceTodoItemAsync(TodoItem updatedItem, CancellationToken cancellationToken = default)
{
ServiceResponse<TodoItem> response = await client.ReplaceAsync(updatedItem, cancellationToken);
if ( (response.IsSuccessful && response.HasValue)
{
return response.Value!;
}
throw new ApplicationException(response.ReasonPhrase);
}
}
There are three things I want to point out here:
- All access to the table is coordinated through a
DatasyncServiceClient<T>
. You have a lot of options here, which I’ll go through in a moment, but you need one of these for each table you access. Since it’s going across the network, it’s best if this is placed in a singleton class. We ensure that this class is a singleton within the dependency injection setup. - Three of the methods look almost identical. Each of the methods that operates on a single entity returns a
ServiceResponse<T>
, so you have full access to the underlying HTTP response and can react accordingly. In addition, the content is decoded and deserialized for you. You have access to both the raw content and the deserialized content if appropriate. Use.HasContent
and/or.HasValue
to determine what was returned. - The client uses “LINQ-lite” for query operations - a subset of LINQ methods that are supported by the remote service. You can’t do joins, splits, and there is a very restrictive set of methods available for
.Where()
. Don’t assume that the full power of LINQ is available - it isn’t.
Before I go on, change the service collection in App.xaml.cs
to use the new service definition:
Services = new ServiceCollection()
.AddSingleton<ITodoService, OnlineTodoService>()
.AddTransient<TodoListViewModel>()
.AddScoped<IAlertService, AlertService>()
.AddScoped<IAppInitializer, AppInitializer>()
.BuildServiceProvider();
Then run the server/client combination. You can add an entity to the client application, then use the browser to browse to https://localhost:7181/tables/todoitem
to see the stored item:
The many ways of setting up the client
The client library has got MANY ways of setting up the client. Let’s take a look at the choices you will be making:
1. How do I create a HttpClient
?
The first thing to think about is how do I create a HttpClient
? All communication is routed through a HttpClient
with a BaseAddress
property set to the root of the server. So you can just say something like this:
HttpClient client = new HttpClient() { BaseAddress = new Uri("https://localhost:7181/") };
Uri relativeUri = new Uri("/tables/todoitem", UriKind.Relative);
DatasyncServiceClient<TodoItem> serviceClient = new(relativeUri, client);
This is perhaps the simplest mechanism there is. The service client join the provided relativeUri to the base address of the client and that’s the endpoint that will receive the communication. Alternatively, you can just use a basic HttpClient
and specify the URI absolutely:
Uri tableUri = new Uri("https://localhost:7181/tables/todoitem");
DatasyncServiceClient<TodoItem> serviceClient = new(tableUri, new HttpClient());
However, we can also specify a list a delegating handlers to use (e.g. for logging, authentication, or adding API keys) and build a HttpClient
ourselves. We do this by using the HttpClientOptions
:
HttpClientOptions options = new()
{
Endpoint = new Uri("https://localhost:7181"),
HttpPipeline = [
LoggingDelegatingHandler(),
AuthenticationDelegatingHandler()
],
Timeout = TimeSpan.FromSeconds(60),
UserAgent = "Datasync/Awesome-Datasync-Agent"
};
Once you have these options, you can create a HttpClientFactory
, which is provided with the Community Datasync Toolkit:
IHttpClientFactory factory = new HttpClientFactory(options);
HttpClient httpClient = factory.CreateClient();
Uri relativeUri = new Uri("/tables/todoitem", UriKind.Relative);
var serviceClient = new DatasyncServiceClient<TodoItem>(relativeUri, httpClient);
This gives you much flexibility in how you construct the client. You can let the datasync library do it for you or you can tune every single aspect of the HTTP transaction.
2. JSON Serialization and Deserialization
I don’t recommend you mess with the JSON serialization and deserialization settings. They are configured to match what is provided by default with the Datasync server. However, if you have added a converter to the server, you need to add the same converter to the client. In this case, you can pass you new JsonSerializerOptions
to the DatasyncServiceClient<T>
:
var serviceClient = new DatasyncServiceClient<TodoItem>(relativeUri, httpClient, jsonSerializerOptions);
Operations that can be done online
You can do the following write operations online:
// Add
var response = await serviceClient.AddAsync(clientSideItem, options);
// Delete
var response = await serviceClient.RemoveAsync(clientSideItem, options);
// Replace
var response = await serviceClient.ReplaceAsync(clientSideItem, options);
For each of these operations, you get a ServiceResponse
or ServiceResponse<T>
back. There are also variations of these methods so that you can pick and choose which elements to use and how to set up the request. In the case of a successful request, response.IsSuccessful
will be true and if the operation is an Add or Replace, then response.HasValue
will also be true and response.Value
will contain the value of the entity that was stored in the server. You should use this value instead of the client-side version you have been using. You’ll see how this is done in the example OnlineTodoService
.
The options is type DatasyncServiceOptions
and has the following properties:
ThrowIfMissing
will throw an exception if you ask to remove or replace an entity that does not exist. If false, then you should check the ServiceResponse that is returned to determine success or failure. This is useful when you are removing an entity and don’t really care if the entity is missing or not.IncludeDeleted
, when set to true, will also try to replace deleted items. This allows you to “undelete” a soft-deleted record.Version
is set to the version of the entity. If not set, then the operation is forced.
By default, Version
is set to the version of the entity, IncludeDeleted
is false, and ThrowIfMissing
is false.
Next, let’s take a look at the read operations:
// Get a single entity by Id
var response = await serviceClient.GetAsync(id, options);
Finally, there are lots more details on the query interface in the documentation. While “limited” by LINQ standards, it’s still incredibly powerful.
Wrap up
So, why start with online operations if this is a datasync library? Firstly, figuring all the options that can be used to communicate with the server is important. When things go wrong (and they will), figuring out what to do about it requires that you understand what is going on. Switching to an online view (and adding logging) is a great way to get started.
Secondly, many applications require both online and offline capabilities - even in the lowly todo app. Let’s say I have categories and items. All items belong to a category. However, there are hundreds of categories and not all the categories are equally important. I can do an online search for the categories I am interested in, then only pull records for those categories. Similarly, I might have a list of all customers, but only synchronize customers I actively work with rather than all customers.
Having an online operation allows flexibility in creating my clients.
Leave a comment