Calling our own backend server from an MAUI app


Let’s try something simple today! Let’s call a backend REST API from an MAUI app. And not just any backend on the internet. We don’t want to connect to some suspicious, anonymous server! Just our own simple and safe newly created backend.

How hard could it be?

Let’s start with a simple MAUI app called Apollo1. Next, let’s add an ASP.NET Core empty project, Houston1. The result is this

We want a simple demo, so we won’t shoot for the moon and will only aim for the following humble targets:

  • Make Houston1 return simple object as JSON text
  • Make Apollo1 call Houston1, obtain the result and put it as text on the screen
  • While doing the above, apply as much common sense and good practices as possible

Let’s start with Houston1. With the ASP minimal API style, it is not just simple. It is beautiful!

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => new HoustonAnswer(StatusCode: 1001, Text: "This is Houston!"));

app.Run();

public record HoustonAnswer(int StatusCode, string Text);

We know that only HTTPS is the way to go with mobile app backend communication, so we remove the evil and insecure HTTP from the “Houston1 / Properties / launchSetting.json”. And while we are here, change the port of our backend to something with more zeroes.

{
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:41077",
      "sslPort": 44330
    }
  },
  "profiles": {
    "https": {
      "commandName": "Project",
      "launchBrowser": true,
      "applicationUrl": "https://localhost:16000",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      },
      "dotnetRunMessages": true
    },
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

Et voila – Houston1 is ready. We can run it from VS or the console with a simple “dotnet run.”

https://localhost:16000/
{"statusCode":1001,"text":"This is Houston!"}
Houston1 reporting for duty on HTTPS

Next is the MAUI app. We simplify the Apollo1 XAML to a single label bound to a code-behind class property. Also, we add an “on-appearing” callback which calls the backend.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
	     x:Name="ThisPage"
             BindingContext="{Reference ThisPage}"
             x:Class="Apollo1.MainPage"
             Appearing="ThisPage_Appearing">

	<StackLayout>
		<Label Text="{Binding Message}"
		       HorizontalOptions="CenterAndExpand"
		       VerticalOptions="CenterAndExpand"/>
	</StackLayout>

</ContentPage>
namespace Apollo1;

public partial class MainPage : ContentPage {
	public MainPage() {
		InitializeComponent();
	}

	public string Message {
		get => _message;
		set {
			_message = value;
			OnPropertyChanged(nameof(Message));
		}
	}

	async void ThisPage_Appearing(System.Object sender, System.EventArgs evt) {
		using var client = new HttpClient();
		var response = await client.GetAsync("https://localhost:16000");
		var json = await response.Content.ReadAsStringAsync();
		Message = json;
	}

	string _message = "...";
}

Looks good. Let’s run it and see the result. The first is iOS. Run, Apollo1, run!

System.Net.Http.HttpRequestException
The certificate for this server is invalid. You might be connecting to a server that is pretending to be "localhost‚" which could put your confidential information at risk.
Error on iOS

What? Are you kidding me? Do you ask me to prove “localhost” is my local host machine?

OK, that was unexpected (and a bit shocking), but before proceeding with the analysis, let’s check Android.

System.Net.WebException
Failed to connect to localhost/127.0.0.1:16000
Error on Android

So the same err… Wait a minute! It’s a different error – it cannot connect to localhost/127.0.01. It differs from connecting successfully but having trouble validating the server SSL certificate.

OK, so in summary, we have the following issues:

  1. On Android, we cannot connect to our backend at all. It is logical when we think about it, as the Android Emulator is a complete virtual machine with its own networking and “localhost,” To solve this, we should try to connect by IP address instead.
  2. On iOS we were able to connect to the backend, but the certificate was rejected. We have to ask iOS networking to accept our certificate to solve this.
    • And we will probably have the same kind of issue on Android if we were to solve problem #1
  3. On both platforms, a simple backend connection issue results in a crash of the app. We need to improve this – during the development phase, it may be acceptable to see the problem as early as possible. Still, when we release this to customers, we need to be more flexible.

Now that we have a list of problems to chase, we start with the analysis.

The first one seems simple: we will make Houston1 available by local IP address.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => new HoustonAnswer(StatusCode: 1001, Text: "This is Houston!"));
app.Urls.Add("https://192.168.1.64:16000/");
app.Run();

public record HoustonAnswer(int StatusCode, string Text);

We change Apollo1 to use the same local IP address and re-run the Android test.

System.Net.WebException
java.security.cert.CertPathValidatorException
Trust anchor for certification path not found.
Same error on Android as on iOS

Bingo! We now have the same kind of error as iOS, and the universe is at peace – our multi-platform Apollo1 fails similarly on multiple platforms!

Solving problem #2 requires a custom validation of the server certificate. This sounds much more complex than it is. And while we are changing the HttpClient calls, we can also kill problem #3 by just swallowing the exception (at least for non-debug builds).

async void ThisPage_Appearing(System.Object sender, System.EventArgs evt) {
	try {
		ServicePointManager.ServerCertificateValidationCallback += delegate { return true; };
		using var client = new HttpClient(new HttpClientHandler() {
			ServerCertificateCustomValidationCallback = delegate { return true; },
		});

		var response = await client.GetAsync("https://192.168.1.64:16000");
		var json = await response.Content.ReadAsStringAsync();
		Message = json;
	}
	catch (Exception e) {
		Console.WriteLine(e);
#if DEBUG
		throw;
#endif
	}
}

Let’s re-run our tests.

Ah, finally, some results! But all this was the initial, obvious stuff. Let’s drill down!

The HttpClient class is a tricky one. It implements IDisposable, which is why we instantiated it with “using,” but this technic is considered incorrect. The recommended solution (at least for the mobile platform) is to use a singleton.

We need to also plan for the future: we now use a hard-coded IP address for our server and ignore any SSL issues. This is nice for locally debugging both the client and the server but is a bad idea for deployed backends with genuine security certificates. It may not be suitable even for local debugging if our colleague clones the repo on a different machine or we open our project while on vacation, using a network connection with a different IP address.

The requirements start to form:

  1. We need a singleton instantiation of the HttpClient
  2. We must be able to easily “switch” between different backend “base” URLs
    • We don’t need to do this at run-time – a compile-time solution is good enough. And a safer one…
  3. We need to request optional features, such as do-not-validate-the-SSL-certificate
  4. The backend “base” URL is also referenced in the backend itself, so we need it available in a shared assembly, visible from both Houston1 and Apollo1 projects

A straightforward solution could look like the following “Tools / ApiClient.cs” placed in a new “Class Library” project.

using System;
using System.Net;

namespace MissionShared1.Tools {
	public partial class ApiClient {
		public static async Task<HttpResponseMessage> GetAsync(string sub_url) {
			var url = String.Join("/", new[] { BASE_URL, sub_url?.Trim('/') });
			var res = await _http_client.GetAsync(url);
			return res;
		}

		private ApiClient() { }

		static ApiClient() {
#pragma warning disable CS0162
			if (IS_MOCK_SSL) {
				ServicePointManager.ServerCertificateValidationCallback += delegate { return true; };
				_http_client = new HttpClient(new HttpClientHandler() {
					ServerCertificateCustomValidationCallback = delegate { return true; },
				});
			}
			else {
				_http_client = new HttpClient();
			}
#pragma warning restore CS0162
		}

		static HttpClient _http_client;

#if !DEBUG
		public const string BASE_URL = "https://my.real.backendurl.com:16384";
		const bool IS_MOCK_SSL = false;
#endif
	}
}

We implemented the singleton in the second simplest possible way – via a static constructor. This is simple and good for a demo, but it could be done better if we need more flexibility. For example, we could try to dispose of the HttpClient instance when the app is minimized and create a new one when restored to focus. In this case, we’ll have to develop a service for initialization that can be called anytime – it is not too complicated either, but it’s not the few lines above, so we’ll just forget about it for the moment.

However, we don’t forget our list of requirements! During the initial instantiation of HttpClient, we also consider the compile-time parameter for “mock-up” SSL validation.

We wrapped the actual HttpClient calls in our services. This gives us much simpler calls to the backend, as we should include only the endpoint API path without the base server URI. And since the base URL is mentioned only here, switching it to something else is much simpler.

Now, this is only part of the implementation. You probably noticed that this is a partial class, and the URL and Is-Mock constants are defined for Release mode builds only. Yeah, we have another partial class with the Debug mode values. Behold, the miraculous “Tools / ApiClient.Local.cs”

namespace MissionShared1.Tools {
	public partial class ApiClient {
#if DEBUG
		public const string BASE_URL = "https://192.168.1.64:16000";
		const bool IS_MOCK_SSL = true;
#endif
	}
}

OK, miraculous is too much of an exaggeration. Let’s say it is just tricky. The trick is that we don’t put this file in the source control. In this sample repository, it is listed in the “.gitignore” file. It contains compile-time parameters that are local to the machine on which we are debugging, so we can have different versions of it for every different team member that builds the project.

As this file will always be created on the initial checkout/clone of the project, we can also include a “template” file – a sample implementation suitable for a quick start by duplicating it and removing the “.sample” suffix from the name.

Tools
ApiClient.cs
ApiClient.Local.cs
ApiClient.Local.cs.sample

Well, it looks better now:

  • We have a fixed backend URL for the Release builds, so we’ll never forget some test or staging versions when preparing Apollo1 for launch
  • We have simple compile-time parameters to control the Debug backend URL and the process of SSL mocking by changing the values in the file “ApiClient.Local.cs”
  • We have more straightforward calls to the backend rest API, as we don’t need to include the repetitive base URL part

Looks like we accomplished our humble targets!

All the sources are available in the following GitHub repo: https://github.com/sjavashev/Apollo1

Have fun coding!

,

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.