Nanoservices – Part 5

Nanoservices – Part 5

Last time I covered the data layer for our mime-types nanoservice. That article completes my presentation of a working .NET 5.0 / Blazor based nanoservice. The only thing I have left to cover is a quick client for accessing that nanoservice, from a C# client application. I’ll cover that now.

I decided not to add the client into the CG.Obsidian project. Instead, I created a new NUGET package, called CG.Obsidian.Web.Client, for that purpose. Why create another project for the client? Because I view NUGET packages as something different than applications, or websites. Not only are they physically different kinds of projects, but they each have a different kind of CI/CD pipeline, for getting them built, tested, and pushed out to where I want them to go. So, for that reason, I created a new NUGET project for the client.

The source code for this client package is HERE. The package itself can be found HERE.

I’ll begin by describing the image I have, in my head, of how I might want to interact with my nanoservice, from one of my C# projects. I picture starting with the Startup class, like this;

public class Startup
{
    public IConfiguration Configuration { get; }

    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddObsidianClients(
            Configuration.GetSection("Clients")
            );
    }
}

That would register my client, using settings from the Clients section of an appSettings.json configuration file, like this:

{
  "Clients": {
    "BaseAddress" : "address to server here"
  }
}

Then, after that, I picture using the client like this:

class MyTest
{
   private readonly IObsidianClient _client;
   public MyTest(IObsidianClient client)
   {
      _client = client;
   }

   public async Task RunTest()
   {
      var mimeTypes = _client.FindByExtensionsAsync(".jpg");
      // TODO : use the mimeTypes value here.
   }
}

Where I would rely on the ASP.NET DI container to inject the IObsidianClient object into the constructor. Then, once I have that object instance, I would call the FindByExtensionsAsync method to perform the actual conversion.

With that as a goal, let’s start by defining the IObisianClient interface, which looks like this:

public interface IObsidianClient : IClient
{
    Task<string[]> FindByExtensionsAsync(
        string extension,
        CancellationToken cancellationToken = default
        );
}

The interface implements the IClient interface, which is part of the CG.Business NUGET package, available HERE. The interface has a single method, for converting from a file extension to an array of mime types.

We implement the IObisidianClient interface using the ObsidianClient class. That listing is shown here:

public class ObsidianClient : 
    ClientBase<ObsidianClientOptions>, 
    IObsidianClient
{
    protected IHttpClientFactory HttpClientFactory { get; }

    public ObsidianClient(
        IOptions<ObsidianClientOptions> options,
        IHttpClientFactory httpClientFactory
        ) : base(options)
    {
        Guard.Instance().ThrowIfNull(httpClientFactory, nameof(httpClientFactory));
        HttpClientFactory = httpClientFactory;
    }

    public virtual async Task<string[]> FindByExtensionsAsync(
        string extension,
        CancellationToken cancellationToken = default
        )
    {
        try
        {
            Guard.Instance().ThrowIfNullOrEmpty(extension, nameof(extension));

            var httpClient = HttpClientFactory.CreateClient();
            httpClient.BaseAddress = new Uri(Options.Value.BaseAddress);

            var response = await httpClient.PostAsync(
                "api/MimeTypes",
                new StringContent(
                    extension.StartsWith('"') && extension.EndsWith('"') 
                        ? $"{extension}" : $"\"{extension}\"",
                    Encoding.UTF8, 
                    "application/json"
                    ),
                cancellationToken
                ).ConfigureAwait(false);

            response.EnsureSuccessStatusCode();

            var json = await response.Content.ReadAsStringAsync(
                cancellationToken
                ).ConfigureAwait(false);

            var mimeTypes = JsonSerializer.Deserialize<string[]>(json);
            return mimeTypes;
        }
        catch(Exception ex)
        {
            throw new ClientException(
                message: $"Failed to query for mime type(s) by file extension!",
                innerException: ex
                );
        }
    }
}

The class derives from the ClientBase class, which also comes from the CG.Business NUGET package. ClientBase contains a property named Options, which contains a reference to an IOptions<ObsidianClientOptions> instance. We’ll get that object from the constructor …

The constructor accepts two parameters: The options that we just discussed, and an HttpClientFactory object. Both parameter values are typically injected through the ASP.NET DI container. We pass the options parameter to the base class’s constructor. We store the HttpClientFactory parameter in the HttpClientFactory property.

The FindByExtensionsAsync method starts by using the HTTPClientFactory to create an HttpClient instance. This is what we’ll use to make the HTTP calls back to the nanoservice. We set the base address, for the HTTP client, using the BaseAddress property, on the ObsidianClientOptions class. Next, we call the PostAsync method, on the HttpClient object. That sends our POST request to the nanoservice, on the “api/MimeTypes” endpoint, and then it returns the results to us. We then call EnsureSuccessStatusCodeto make sure we received a 200 status code, from the nanoservice. Once we know the call succeeded, we read the JSON from the response content, into the json variable. Finally, we deserialize that JSON into an array of strings and return that array to the caller.

Some might note that we’re not using any kind of authentication for this call. For now, I see no reason to lock this service down. If that changes, in the future, I’ll add the authentication at that time.

The only other thing we have left to cover is the extension method, AddObsidianClients, which we’ll use to register our client type with the ASP.NET DI container. That method looks like this:

public static partial class ServiceCollectionExtensions
{
    public static IServiceCollection AddObsidianClients(
        this IServiceCollection serviceCollection,
        IConfiguration configuration,
        ServiceLifetime serviceLifetime = ServiceLifetime.Scoped
        )
    {
        Guard.Instance().ThrowIfNull(serviceCollection, nameof(serviceCollection))
            .ThrowIfNull(configuration, nameof(configuration));

        serviceCollection.ConfigureOptions<ObsidianClientOptions>(
            DataProtector.Instance(),
            configuration
            );

        serviceCollection.AddHttpClient();
        serviceCollection.Add<IObsidianClient, ObsidianClient>(serviceLifetime);

        return serviceCollection;
    }
}

This method begins by calling ConfigureOptions, to populate, unprotect, verify, and register our ObsidianClientOptions type, as a service, with the ASP.NET DI container. The ConfigureOptions extension method is part of the CG.Options NUGET package, which is available HERE. If you look closely, you might notice that I’m also using a DataProtector instance. DataProtector is part of the CG.DataProtection NUGET package, which is available HERE.

Once we have the options taken care of, the only thing left to do is register the HTTPClient type (in case it wasn’t otherwise registered), and to register our obsidian client type. This way, all our required types are properly registered, and we’ve taken care up populating and stashing the required options, for the client’s use at runtime.

That’s about it for this series of articles. I hope my nanoservice walkthrough has been enlightening and enjoyable. I chose to present this project here because I think it’s a perfect example of a working nanoservice – one that’s not too big to easily understand, and not too contrived to be worthless as a learning tool.

To be sure, there are still things I could add, to make the nanoservice more resilient, and/or more performant. For instance, I think caching makes sense, since mime-types seldom change. I also think caching locally, on disk, as part of the client, could help deal with scenarios where the nanoservice might be temporarily unavailable. I’ll probably address those issues, and others, at some point in the future. I may even turn those enhancements into an article, or two. For now, I hope everyone enjoys my code. I also hope my efforts spark some ideas, on your part, for making your own nanoservices.

If you make a cool nanoservice let me know!

Photo by Sigmund on Unsplash