Giving Uno Some Swagger

Using NSwag Typed Clients with Uno & WebAssembly

Published on 15 June 2020

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:

  1. 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 with dotnet openapi
  2. Once configured, NSwag.MSBuild is able to generate Typed Clients directly from the ReST service's source code instead of needing a swagger.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.

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:

UWP Head Running

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:

WASM DefaultHttpClientFactory could not be located

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:

Cheeze.App running in 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:

Still No Data

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:

WASM with data

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:

Side By Side

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 to properties/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 of Microsoft.Extensions.Logging so injecting an ILogger<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.