TL;DR
A few days ago, Nick Randolph published an excellent blog post about "Consuming REST API with Swagger / OpenAPI in Xamarin and Uno Applications". I read this article with great interest (and perhaps a touch of chagrin) as I was mid-way through writing a very similar article myself. While I found this post to be as detailed and pragmatic as Nick's always are, I feel he missed a few key elements about consuming strongly-typed ReST clients in Uno, particularly when it comes to consuming them from within a browser via the WebAssembly (WASM) project. In this post I will cover these additional points such that the reader is able to consume ReST endpoints, in the same manner, from all Uno head projects.
Intro
This article will now very much be a continuation of Nick's. If you haven't read Nick's post, I would encourage you to do so now so that you understand many of the approaches used here. Much like Nick, I will be using a ReST endpoint created for an earlier blog post, namely the "Cheeze.Store" API written for my "Less ReST, More HotChocolate" post.
All source code for this post can be found in my UnoWithSwagger repo on Github.
Typed Clients
In contrast to Nick's post, I will not be using dotnet openapi
to generate Typed Clients for my API but will instead continue to use the NSwag.MSBuild
package. This is for two reasons:
- Typed Client generation using
NSwag.MSBuild
uses an NSwag configuration file. This configuration file provides much greater control over the generated code than is currently possible withdotnet openapi
- Once configured,
NSwag.MSBuild
is able to generate Typed Clients directly from the ReST service's source code instead of needing aswagger.json
file. This saves a significant amount of time when you're writing a .NET ReST service as you don't need to start the service to update the client side code, thereby removing friction and allowing you to rapidly iterate the API.
If you're interested in using NSwag.MSBuild
to generate your Typed Clients then I cover the process quite thoroughly here.
Furthermore, rather than "newing up" a swaggerClient
manually, I will be using the Microsoft.Extensions.DependencyInjection
and Microsoft.Extensions.Http
packages to inject a correctly configured Typed Client into my view-model. This, I believe, is where Typed Clients really shine as this approach completely abstracts the source of the data such that the Typed Clients appear to just be another client side dependency.
Here is the Services
class I use for service registration:
public partial class Services
{
public static readonly Services Instance = new Services();
private readonly ServiceCollection _serviceCollection;
private readonly Lazy<IServiceProvider> _serviceProvider;
private Services()
{
_serviceCollection = new ServiceCollection();
_serviceProvider = new Lazy<IServiceProvider>(() => _serviceCollection.BuildServiceProvider());
}
private void RegisterGlobalServices(IServiceCollection services, ILogger logger)
{
services.AddHttpClient<Store.Client.IStoreClient, Store.Client.StoreClient>(
httpClient => httpClient.BaseAddress = new Uri("http://localhost:5000")
);
services.AddSingleton<ISchedulers, Schedulers>();
services.AddTransient<ViewModel>();
}
public void PerformRegistration(ILogger logger)
{
if (_serviceProvider.IsValueCreated) throw new InvalidOperationException("You cannot register services after the service provider has been created");
RegisterGlobalServices(_serviceCollection, logger);
}
public IServiceProvider Provider => _serviceProvider.Value;
}
Which is initialized from App.xaml.cs
as shown here:
sealed partial class App : Application
{
private readonly ILogger<App> _logger;
public App()
{
ConfigureFilters(global::Uno.Extensions.LogExtensionPoint.AmbientLoggerFactory);
_logger = global::Uno.Extensions.LogExtensionPoint.AmbientLoggerFactory.CreateLogger<App>();
Platform.Services.Instance.PerformRegistration(_logger);
this.InitializeComponent();
this.Suspending += OnSuspending;
}
...
}
And used (naively) from within the view to instantiate the ViewModel, which then acts as the view's data context:
public sealed partial class MainPage : Page
{
private readonly ViewModel _viewModel;
private IDisposable _behaviours;
public MainPage()
{
this.InitializeComponent();
_viewModel = Platform.Services.Instance.Provider.GetRequiredService<ViewModel>();
DataContext = _viewModel;
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
_behaviours = _viewModel.Activate();
}
protected override void OnNavigatedFrom(NavigationEventArgs e)
{
base.OnNavigatedFrom(e);
if (_behaviours != null)
{
_behaviours.Dispose();
_behaviours = null;
}
}
}
Finally, here is the ViewModel
implementation showing the use of the IStoreClient
Typed Client:
public class ViewModel : INotifyPropertyChanged
{
private readonly IStoreClient _storeClient;
private readonly Platform.ISchedulers _schedulers;
private readonly ILogger<ViewModel> _logger;
private readonly MVx.Observable.Command _loadCheese;
private readonly MVx.Observable.Property<IEnumerable<Store.Client.Cheese>> _cheeses;
public event PropertyChangedEventHandler PropertyChanged;
public ViewModel(IStoreClient storeClient, Platform.ISchedulers schedulers)
{
_storeClient = storeClient;
_schedulers = schedulers;
_logger = global::Uno.Extensions.LogExtensionPoint.AmbientLoggerFactory.CreateLogger<ViewModel>();
_loadCheese = new MVx.Observable.Command(true);
_cheeses = new MVx.Observable.Property<IEnumerable<Store.Client.Cheese>>(Enumerable.Empty<Store.Client.Cheese>(), nameof(Cheeses), args => PropertyChanged?.Invoke(this, args));
}
private IDisposable ShouldLoadCheeseWhenLoadCheeseInvoked()
{
return _loadCheese
.Do(_ => _logger.LogInformation("Loading Cheeses!"))
.SelectMany(_ => _storeClient.GetAsync())
.ObserveOn(_schedulers.Dispatcher)
.Subscribe(_cheeses);
}
public IDisposable Activate()
{
return new CompositeDisposable(
ShouldLoadCheeseWhenLoadCheeseInvoked()
);
}
public ICommand LoadCheese => _loadCheese;
public IEnumerable<Store.Client.Cheese> Cheeses => _cheeses.Get();
}
Note: This ViewModel uses my MVx.Observable
package:
Functional, Declarative and Reactive Extensions for MVVM & MVC patterns
A (mostly) unopinionated, light-weight alternative to ReactiveUI provided as a library not a framework.
This Missing Links
Now, regardless of how you've generated your Typed Clients, you will have added a reference to the client library to each of the head projects in your Uno solution. With the above code in place, you should be able to start the UWP head, click the "Load Cheeze!" button and see this:
However, starting the WASM head will result in the browser only showing the app's splash screen. If you bring up your browser's "developer tools" window (I use Chrome and Edge interchangeably) and view the console output you should see something like the following:
This error is due to way the Mono linker determines the assemblies and types that should - or shouldn't - be included in the WASM output. By default, only statically referenced types (i.e. those we're directly using in our code) will be included and downloaded into the browser when starting the app. As we don't directly reference "Microsoft.Extensions.Http.DefaultHttpClientFactory" this type isn't available to the app and therefore the DI container isn't able to instantiate it.
To resolve this, we need to explicitly instruct the Mono linker to include the types we need. This can be done by modifying the LinkerConfig.xml
file (within the WASM head project) to the following:
<linker>
<assembly fullname="Cheeze.App.Wasm" />
<assembly fullname="Uno.UI" />
<assembly fullname="Newtonsoft.Json" />
<assembly fullname="System.ComponentModel.Annotations"/>
<assembly fullname="Microsoft.Extensions.Http"/>
<assembly fullname="Microsoft.Extensions.Options"/>
<assembly fullname="Cheeze.Store.Client" />
<assembly fullname="System.Core">
<!-- This is required by JSon.NET and any expression.Compile caller -->
<type fullname="System.Linq.Expressions*" />
</assembly>
</linker>
With this done, we should now be able to start the Cheeze App within the browser:
Close, but no handler!
With Cheeze.App running in the browser, if we click the "Load Cheeze!" button now we should get... wait for it....
Nope, nothing.
Back to the browser's debugging tool's Console output and we're likely to see something along the lines of "Operation is not supported on this platform". This is due to the fact that, while running in the browser, the WASM head uses the browser to make HTTP calls. In order to do this, the HttpClient
used by the Typed Client implementation needs to be configured to use the WasmHttpHandler
as described here.
Note: Somewhat confusingly, I hit this error consistently while originally writing the Cheese.App but, after implemented the changes below then backing them out so I could write this post, I could not for the life of me get the error to occur again. I imagine it's something cached or not rebuilt but this does mean that I'm unable to share screenshots showing this error. Apologies.
Fortunately, getting HttpClient
to use the WasmHttpHandler
can be done completely transparently to the Typed Client by adding some additional configuration to our dependency injection setup. Shown below is the refactored Services.cs
class.
public partial class Services
{
public static readonly Services Service = new Services();
private readonly ServiceCollection _serviceCollection;
private readonly Lazy<IServiceProvider> _serviceProvider;
private Services()
{
_serviceCollection = new ServiceCollection();
_serviceProvider = new Lazy<IServiceProvider>(() => _serviceCollection.BuildServiceProvider());
}
partial void GetHttpMessageHandler(ref HttpMessageHandler handler);
private HttpMessageHandler PrimaryHttpMessageHandler()
{
HttpMessageHandler handler = null;
GetHttpMessageHandler(ref handler);
handler ??= new HttpClientHandler();
return handler;
}
private void RegisterGlobalServices(IServiceCollection services, ILogger logger)
{
services
.AddHttpClient<Store.Client.IStoreClient, Store.Client.StoreClient>(
httpClient => httpClient.BaseAddress = new Uri("http://localhost:5000"))
.ConfigurePrimaryHttpMessageHandler(PrimaryHttpMessageHandler);
services.AddSingleton<ISchedulers, Schedulers>();
services.AddTransient<ViewModel>();
}
public void PerformRegistration(ILogger logger)
{
if (_serviceProvider.IsValueCreated) throw new InvalidOperationException("You cannot register services after the service provider has been created");
RegisterGlobalServices(_serviceCollection, logger);
}
public IServiceProvider Provider => _serviceProvider.Value;
}
Note the addition of the .ConfigurePrimaryHttpMessageHandler(PrimaryHttpMessageHandler)
call and the GetHttpMessageHandler
partial method. The code here ensures that HttpClientHandler
is used as the default but allows this to be overriden by providing an implementation for the GetHttpMessageHandler
within platform specific code. Accordingly, a partial implementation of the Services.cs
class is added to the WASM
head project as follows:
public partial class Services
{
partial void GetHttpMessageHandler(ref HttpMessageHandler handler)
{
handler = new Uno.UI.Wasm.WasmHttpHandler();
}
}
Now when the implementation of the IStoreClient is injected into the ViewModel
it will be using an HttpClient
instance which is configured to use WasmHttpHandler
. Nice.
COR[s] BLIMEY!
Now when we start the WASM head and click the "Load Cheeze!" button we get... #$@&%*! ... still nothing.
Again, back to the browser's Console output and we'll see the culprit:
Remember how I said earlier that "the WASM head uses the browser to make HTTP calls"? Yup? Well, this therefore makes the requests beholden to CORS. As the GET
request emanating from our Cheeze.App is deemed to be from another origin (by virtue of running from a different port) our service refuses to answer the request and everything disappears in a puff of console output.
To resolve this issue, we need to change the service (Cheeze.Store) through the addition of a CORS policy, as shown below:
public class Startup
{
...
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
...
services.AddCors(o => o.AddPolicy(
"CorsPolicy",
builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
})
);
...
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
...
app.UseCors("CorsPolicy");
...
}
}
Note: The policy shown here is for debug only and shouldn't be used verbatim in production!!
Finally!
With all this in place and rebuilt, clicking the "Load Cheeze!" button in the browser finally gives us:
YAY!
Now, personally, I feel it's worth taking a moment here to reflect on this. With just a minor change in client side code (~12 loc) we're able to run exactly the same app both on the desktop and in the browser. I mean, look at it:
With no effort and just a couple of minor exceptions (font weight in UWP - left, and a scroll bar in the browser - right) the UI is pixel-perfect across two platforms that really couldn't be more dissimilar! I've said it before and I'll say it again, the Uno Platform team deserve massive kudos for providing a framework that allows developers to leverage existing skills (not to mention one of the best UI frameworks) to deliver apps across four (no, wait, FIVE!) disparate platforms.
Wrapping Up
While implementing WASM heads for Uno solutions, I've found the following helps smooth the process:
- Enable WASM debugging by add
inspectUri
toproperties/launchSettings.json
as shown here - Use Microsoft Edge to find errors (it's Console output seems have more info) but Chrome to hit breakpoints
- Create loggers via
global::Uno.Extensions.LogExtensionPoint.AmbientLoggerFactory.CreateLogger<T>()
. Uno uses an old version ofMicrosoft.Extensions.Logging
so injecting anILogger<T>
instance into a class doesn't (seem to) work for browser console output and certainly can't used used while registering services.
And that's it. I hope you've found this helpful. Should you like or use any of the code in this article please star the repository and, if you have any questions or comments, please feel free to drop me a line using any of the links below or from my about page.
Oh, and... Fine Cheese
Some content in the "Cheeze" app/repository has been borrowed - thus far without permission - from The Fine Cheese Co website. While I'm not affiliated with this company in any way - I just happen to like both cheese and their website - if you should end up ordering from them as a result of reading article, please let them know so they don't force me to change all the screen shots above. Thanks.