Tour of Heroes on Xamarin, talking to a real backend through generated client API
Background
"Tour of Heroes" is the official tutorial app of Angular 2+. The app contains some functional features and technical features which are common when building a real-world business application:
- A few screens presenting tables and nested data
- Data binding
- Navigation
- CRUD operations over a backend, and optionally through a generated client API
- Unit testing and integration testing
In this series of articles, I will demonstrate the programmer experiences of various frontend development platforms when building the same functional features: "Tour of Heroes", a fat client talking to a backend.
The frontend apps on Angular, Aurelia, React, Vue, Xamarin and MAUI are talking to the same ASP.NET (Core) backend through generated client APIs. To find the other articles in the same series, please search "Heroes" in my articles. And at the end of the series, some technical factors of programmer experiences will be discussed:
- Computing science
- Software engineering
- Learning curve
- Build size
- Runtime performance
- Debugging
Choosing a development platform involves a lot of non-technical factors which won't be discussed in this series.
References
Introduction
This article is focused on Xamarin.
Development platforms:
- Web API on ASP.NET and .NET Framework 4.8
- Xamarin Forms
Demo Repository
Checkout WebApiClientGenExamples in GitHub, and focus on the following areas:
ASP.NET Web API csproj also contains some MVC stuff.
This folder contains an Android app project and an iOS app project, and both reassemble the functionality of "Tour of Heroes". These two app projects share the following component projects:
Fonlow.Heroes.ViewModels
Fonlow.Heroes.View
DemoWebApi.ClientApiStandard
Remarks
WebApiClientGenExamples was established for testing NuGet packages of WebApiClientGen for .NET Framework and .NET standard, and demonstrating how to use the libraries in real world projects.
Using the Code
Prerequisites
- DemoWebApi.csproj has NuGet package
Fonlow.WebApiClientGen
imported. - Add CodeGenController.cs to DemoWebApi.csproj.
- DemoWebApi.csproj has CodeGen.json. This is optional, just for the convenience of running some PowerShell script to generate client APIs.
- CreateClientApi.ps1. This is optional. This script will launch the Web API on IIS Express and post the data in CodeGen.json.
Remarks
Depending on your CI/CD process, you may adjust item 3 and 4 above. For more details, please check:
Generate Client API
Run CreateClientApi.ps1, the generated codes will be written to DemoWebApi.ClientApiShared which is shared by .NET Framework library DemoWebApi.ClientApi
and .NET standard library DemoWebapi.ClientApiStandard
.
Data Models and API Functions
namespace DemoWebApi.Controllers.Client
{
public class Hero : object
{
public long Id { get; set; }
public string Name { get; set; }
}
}
Most data models used in the Xamarin client app from the generated client API library DemoWebApi.ClientApiStandard
.
public partial class Heroes
{
private System.Net.Http.HttpClient client;
private JsonSerializerSettings jsonSerializerSettings;
public Heroes(System.Net.Http.HttpClient client,
JsonSerializerSettings jsonSerializerSettings=null)
{
if (client == null)
throw new ArgumentNullException("Null HttpClient.", "client");
if (client.BaseAddress == null)
throw new ArgumentNullException
("HttpClient has no BaseAddress", "client");
this.client = client;
this.jsonSerializerSettings = jsonSerializerSettings;
}
public async Task DeleteAsync(long id)
{
var requestUri = "api/Heroes/"+id;
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Delete, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
}
finally
{
responseMessage.Dispose();
}
}
}
public async Task<DemoWebApi.Controllers.Client.Hero[]> GetAsync()
{
var requestUri = "api/Heroes";
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Get, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader =
new JsonTextReader(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero[]>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
public async Task<DemoWebApi.Controllers.Client.Hero> GetAsync(long id)
{
var requestUri = "api/Heroes/"+id;
using (var httpRequestMessage = new HttpRequestMessage
(HttpMethod.Get, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader = new JsonTextReader
(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
public async Task<DemoWebApi.Controllers.Client.Hero> PostAsync(string name)
{
var requestUri = "api/Heroes?name="+
(name == null ? "" : Uri.EscapeDataString(name));
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Post, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader = new JsonTextReader
(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
public async Task<DemoWebApi.Controllers.Client.Hero>
PostWithQueryAsync(string name)
{
var requestUri = "api/Heroes/q?name="+
(name == null ? "" : Uri.EscapeDataString(name));
using (var httpRequestMessage = new HttpRequestMessage
(HttpMethod.Post, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader = new JsonTextReader
(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
public async Task<DemoWebApi.Controllers.Client.Hero> PutAsync
(DemoWebApi.Controllers.Client.Hero hero)
{
var requestUri = "api/Heroes";
using (var httpRequestMessage = new HttpRequestMessage
(HttpMethod.Put, requestUri))
{
using (var requestWriter = new System.IO.StringWriter())
{
var requestSerializer = JsonSerializer.Create(jsonSerializerSettings);
requestSerializer.Serialize(requestWriter, hero);
var content = new StringContent
(requestWriter.ToString(), System.Text.Encoding.UTF8, "application/json");
httpRequestMessage.Content = content;
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader =
new JsonTextReader(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
}
public async Task<DemoWebApi.Controllers.Client.Hero[]> SearchAsync(string name)
{
var requestUri = "api/Heroes/search?name="+
(name == null ? "" : Uri.EscapeDataString(name));
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Get, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader =
new JsonTextReader(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero[]>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
public DemoWebApi.Controllers.Client.Hero[] Search(string name)
{
var requestUri = "api/Heroes/search?name="+
(name == null ? "" : Uri.EscapeDataString(name));
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Get, requestUri))
{
var responseMessage = client.SendAsync(httpRequestMessage).Result;
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = responseMessage.Content.ReadAsStreamAsync().Result;
using (JsonReader jsonReader =
new JsonTextReader(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero[]>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
}
View Models
View Models are contained in Fonlow.Heroes.ViewModels.csproj.
Fonlow.HeroesVM.HeroesVM
will be utilized by multiple views.
namespace Fonlow.Heroes.VM
{
public class HeroesVM : INotifyPropertyChanged
{
public HeroesVM()
{
DeleteCommand = new Command<long>(DeleteHero);
SearchCommand = new Command<string>(Search);
}
public void Load(IEnumerable<Hero> items)
{
Items = new ObservableCollection<Hero>(items);
NotifyPropertyChanged("Items");
NotifyPropertyChanged("Count");
}
public event PropertyChangedEventHandler PropertyChanged;
public ObservableCollection<Hero> Items { get; private set; }
public IEnumerable<Hero> Top4
{
get
{
if (Items == null)
{
return null;
}
return Items.Take(4);
}
}
Hero selected;
public Hero Selected
{
get { return selected; }
set
{
selected = value;
NotifyPropertyChanged("Selected");
NotifyPropertyChanged("AllowEdit");
}
}
public int Count
{
get
{
if (Items == null)
{
return 0;
}
return Items.Count;
}
}
public void NotifyPropertyChanged
([System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public ICommand DeleteCommand { get; private set; }
public ICommand SearchCommand { get; private set; }
async void DeleteHero(long id)
{
var first = Items.FirstOrDefault(d => d.Id == id);
if (first != null)
{
if (first.Id == Selected?.Id)
{
Selected = null;
}
await HeroesFunctions.DeleteAsync(id);
Items.Remove(first);
NotifyPropertyChanged("Items");
NotifyPropertyChanged("Count");
}
}
public bool AllowEdit
{
get
{
return Selected != null;
}
}
async void Search(string keyword)
{
var r = await HeroesFunctions.SearchAsync(keyword);
Items = new ObservableCollection<Hero>(r);
NotifyPropertyChanged("Items");
NotifyPropertyChanged("Count");
}
}
}
Views
Editing
HeroDetailPage.xaml
="1.0"="utf-8"
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Fonlow.Heroes.Views.HeroDetailPage">
<ContentPage.Content>
<StackLayout>
<Label Text="{Binding Name, StringFormat='{0} Details'}"
VerticalOptions="CenterAndExpand"
HorizontalOptions="CenterAndExpand" />
<Label Text="ID:"></Label>
<Entry Text="{Binding Id}" Placeholder="ID"></Entry>
<Label Text="Name:"></Label>
<Entry Text="{Binding Name}" Placeholder="Name"></Entry>
<Button Text="Save" Clicked="Save_Clicked"/>
</StackLayout>
</ContentPage.Content>
</ContentPage>
HeroDetailPage.xaml.cs (Codes Behind)
namespace Fonlow.Heroes.Views
{
[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class HeroDetailPage : ContentPage
{
public HeroDetailPage(long heroId)
{
InitializeComponent();
BindingContext = VM.HeroesFunctions.LoadHero(heroId);
}
Hero Model
{
get
{
return BindingContext as Hero;
}
}
private async void Save_Clicked(object sender, EventArgs e)
{
await VM.HeroesFunctions.SaveAsync(Model);
}
}
}
Heroes List
HeroesView.xaml
="1.0"="UTF-8"
<ContentView xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Fonlow.Heroes.Views.HeroesView"
xmlns:vmNS="clr-namespace:Fonlow.Heroes.VM;
assembly=Fonlow.Heroes.ViewModels"
x:Name="heroesView"
>
<ContentView.BindingContext>
<vmNS:HeroesVM/>
</ContentView.BindingContext>
<ContentView.Content>
<StackLayout>
<Label Text="My Heroes"/>
<Entry Placeholder="New Hero Name" Completed="Entry_Completed"/>
<ListView x:Name="HeroesListView" ItemsSource="{Binding Items}"
Header="Selected Heroes" Footer="{Binding Count, StringFormat='Total: {0}'}"
SelectedItem="{Binding Selected}"
ItemSelected="HeroesListView_ItemSelected"
>
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="50" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="50" />
</Grid.ColumnDefinitions>
<Label Text="{Binding Id}" Grid.Column="0"
TextColor="Yellow" BackgroundColor="SkyBlue"/>
<Label Text="{Binding Name}" Grid.Column="1"/>
<Button x:Name="DeleteButton" Text="X" Grid.Column="2"
Command="{Binding Source={x:Reference heroesView},
Path=BindingContext.DeleteCommand}"
CommandParameter="{Binding Id}"
/>
</Grid>
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<Label Text="{Binding Selected.Name, StringFormat='{0} is my hero'}"/>
<Button Text="View Details" Clicked="Edit_Clicked"
IsEnabled="{Binding AllowEdit}"></Button>
</StackLayout>
</ContentView.Content>
</ContentView>
The view is bound to view model Fonlow.Heroes.VM.HeroesVM
, and the visual components have bindings to respective the data and the functions of the view model.
HeroesView.xaml.cs
namespace Fonlow.Heroes.Views
{
[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class HeroesView : ContentView
{
public HeroesView()
{
InitializeComponent();
}
HeroesVM Model
{
get
{
return BindingContext as HeroesVM;
}
}
async void Edit_Clicked(object sender, EventArgs e)
{
await Navigation.PushAsync(new HeroDetailPage(Model.Selected.Id));
}
private void HeroesListView_ItemSelected
(object sender, SelectedItemChangedEventArgs e)
{
System.Diagnostics.Debug.WriteLine(e.SelectedItem == null);
}
private async void Entry_Completed(object sender, EventArgs e)
{
var text = ((Entry)sender).Text;
var hero= await HeroesFunctions.AddAsync(text);
Model.Items.Add(hero);
}
}
}
And the codes behind can access to the view model too.
Navigation
Navigation is often called routing in JavaScript SPA libraries and frameworks.
Xamarin.Forms
provide hierarchical navigation with some predefined navigation experiences.
[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class HeroesView : ContentView
{
public HeroesView()
{
InitializeComponent();
}
async void Edit_Clicked(object sender, EventArgs e)
{
await Navigation.PushAsync(new HeroDetailPage(Model.Selected.Id));
}
Navigations are generally implemented inside codes behind.
Integration Testing
Since the frontend of "Tour of Heroes" is a fat client, significant portion of the integration testing is against the backend.
using Fonlow.Testing;
using Xunit;
namespace IntegrationTests
{
public class HeroesFixture : DefaultHttpClient
{
public HeroesFixture()
{
Api = new DemoWebApi.Controllers.Client.Heroes(base.HttpClient);
}
public DemoWebApi.Controllers.Client.Heroes Api { get; private set; }
}
[Collection(TestConstants.IisExpressAndInit)]
public partial class HeroesApiIntegration : IClassFixture<HeroesFixture>
{
public HeroesApiIntegration(HeroesFixture fixture)
{
api = fixture.Api;
}
readonly DemoWebApi.Controllers.Client.Heroes api;
[Fact]
public async void TestGetAsyncHeroes()
{
var array = await api.GetAsync();
Assert.NotEmpty(array);
}
[Fact]
public void TestGetHeroes()
{
var array = api.Get();
Assert.NotEmpty(array);
}
[Fact]
public void TestGetHeroNotExists()
{
DemoWebApi.Controllers.Client.Hero h = api.Get(99999);
Assert.Null(h);
}
[Fact]
public void TestPost()
{
var hero = api.Post("Abc");
Assert.Equal("Abc", hero.Name);
}
[Fact]
public void TestPostWithQuery()
{
var hero = api.PostWithQuery("Xyz");
Assert.Equal("Xyz", hero.Name);
}
}
}
It is recommended the generated client API codes stay in its own csproj project, because of benefits:
- Convenient for crafting integration testing for different layers of the frontend codes.
- Convenient for versioning of the services and the client API codes.
- Exclude the generated codes from domain specific static code analysis.
- Isolated the generated codes from your hand-crafted codes, so you may have more accurate idea about the size and the complexity of your application codes.
Points of Interest
Through WebApiClientGen
, the client data models are almost 100% one to one mapping from the service data models, thus you as an application programmer will enjoy rich data type constraints provided by .NET. For example, numeric types sbyte, byte, short, ushort, int, uint, long, ulong, nint and nuint are also mapped to client data types. This is a blessing to building enterprise applications, since .NET design time and runtime can guard you.
In .NET programming, WPF, Xamarin and MAUI provide decent programmer experience through built-in MVVM architecture. In Web frontend development, particularly with SPA, the closest programmer experience that you could get is through Angular and its Reactive Forms.
History
- 18th November, 2023: Initial version