DEV Community

Isaac Ojeda
Isaac Ojeda

Posted on

ASP.NET Core: REST API Multi-tenant

Introducción

En este post veremos nuevamente como hacer una aplicación multi-tenant, pero ahora enfocado a crear una REST API con ASP.NET Core.

Crear aplicaciones multi-tenant se vuelven un reto cuando se comienza en el desarrollo, aunque siempre recomiendo conocer los cimientos de las cosas, en esta ocasión veremos como crear una aplicación multi-tenant utilizando una librería muy útil, Finbuckle.

Finbuckle de verdad que facilita el trabajo y básicamente te lo hace todo, así que comenzemos.

Como siempre, aquí puedes consultar el código para que no batalles en seguir este post.

Web API Multi-Tenant

Para hacer una aplicación multi-tenant, una de las partes en las que se define el cómo haremos la implementación, es sabiendo ¿Cómo vamos a identificar a los tenants?

Nota 💡: Ya he hablado en múltiples ocasiones sobre este tema de aplicación multi-tenant. Puedes revisar las series para tener un mejor contexto
ASP.NET Core Multitenancy Series' Articles - DEV Community
ASP.NET: Authentication Multi-Tenant Series' Articles - DEV Community

En post pasado menciono las distintas formas de identificar un tenant de otro, los ejemplos que hemos trabajado siempre son por medio del host, pero las formas comunes son:
32

  • Host o subdomain: https://{my-tenant-name}.balusoft.com
  • Path: https://api.balusoft.com/{my-tenant-name}
  • Header (el que usaremos): https://api.balusoft.com incluyendo un header (con el nombre que quieras), ejem. X-Tenant: {my-tenant-name}
  • Cookie: Nunca lo he hecho, pero también podría funcionar, tener una cookie donde se establezca el tenant en el que usuario inició sesión.

Cuando hablamos de una API, no es necesario tener un dominio dedicado al tenant, puede ser el mismo dominio (ejem. https://api.balusoft.com) y la forma de diferenciar entre un tenant y otro es por algún Header o Path, no el dominio tal cual.

Lo que haremos hoy es eso, un servicio web que será multi-tenant, y cada tenant tendrá su propia base de datos. La forma de identificar cada tenant, será por medio de un Header que los clientes de la API tendrán que mandar y así identificar el tenant.

Nota 💡: Una aplicación multi-tenant no está obligada a que tenga una base de datos por tenant. Si revisas los post's mencionados anteriormente, sabrás que puedes tener aplicaciones seguras multi-tenant con una sola base de datos.

Por lo general, cuando hago uso de HTTP Headers "Custom" lo que siempre veo que se hace, es usar un prefijo "X-" para identificar que es un header no-standard. Esto no afecta en nada, pero al menos se busca prevenir no colisionar con encabezados que sí son estándar y los navegadores o servidores web pueden o necesitan usar.

Proyecto MultiTenantApi

Crearemos un proyecto web vacío para hacer todo desde cero:

dotnet new web -o MultiTenantApi

Utilizaremos los siguientes paquetes:

    <PackageReference Include="Finbuckle.MultiTenant.AspNetCore" Version="6.10.0" />
    <PackageReference Include="Finbuckle.MultiTenant.EntityFrameworkCore" Version="6.10.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.4">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.4" />
Enter fullscreen mode Exit fullscreen mode

Utilizamos Finbuckle para agregar soporte multi-tenant, es muy flexible y la verdad lo hace demasiado fácil.

Nota 💡: En post's anteriores hacemos la implementación a mano desde 0 siguiendo un approach muy similar (casi identico) a Finbuckle, si quieres aprender hacerlo tú, te recomiendo la serie Multitenancy

También utilizaremos EntityFramework Core simplemente para tener una persistencia que puede ser en memoria, pero sin ningún problema cambiarla a una base de datos real (como aquí que usaremos SQL Server).

Entities y DbContext

Necesitamos tener dos bases de datos en este approach que seguiremos, una base de datos "maestra" que contendrá toda la información de los tenants, como su cadena de conexión de base de datos y como su identificador (que lo usaremos para diferenciar los tenants).

Y para fines de puro ejemplo, tendremos una base de datos que será la de la aplicación, esta será la base de datos única por tenant, por lo que tendremos que crear varias de estas bases de datos para hacer las pruebas y confirmar que estamos creando una aplicación aislada entre cada tenant.

Entities > Product

Cómo siempre, siempre hago un catálogo de productos:

namespace MultiTenantApi.Entities;

public class Product
{
    public int ProductId { get; set; }
    public string Description { get; set; } = default!;
    public double Price { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Data > Api

Dentro de Data > Api tendremos el DbContext de la aplicación y sus migraciones:

using Finbuckle.MultiTenant;
using Microsoft.EntityFrameworkCore;
using MultiTenantApi.Entities;

namespace MultiTenantApi.Data.Api;

public class ApiDbContext : DbContext
{
    private readonly ITenantInfo? _tenant;
    private readonly IWebHostEnvironment _env;
    private readonly IConfiguration _config;

    public ApiDbContext(
        DbContextOptions<ApiDbContext> options,
        IWebHostEnvironment env,
        IMultiTenantContextAccessor multiTenantContextAccessor,
        IConfiguration config)
        : base(options)
    {
        _tenant = multiTenantContextAccessor.MultiTenantContext?.TenantInfo;
        _env = env;
        _config = config;
    }

    public DbSet<Product> Products => Set<Product>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        string? connectionString;

        if (_tenant is null && _env.IsDevelopment())
        {
            // Init/Dev connection string
            connectionString = _config.GetConnectionString("Default");
        }
        else
        {
            // Tenant connection string
            connectionString = _tenant!.ConnectionString;
        }

        optionsBuilder.UseSqlServer(connectionString);

        base.OnConfiguring(optionsBuilder);
    }
}
Enter fullscreen mode Exit fullscreen mode

Cuando registremos ApiDbContext como dependencia, no vamos a especificar su cadena de conexión ni su proveedor de base de datos, esto lo haremos aquí en el OnConfiguring porque será dinámico, según el tenant en el que se está accediendo en ese momento, estableceremos la cadena de conexión.

Aquí ocurre algo también, ya que este DbContext suele inicializarse cuando hacemos migraciones, por lo que no existirá un HttpContext. Si es el caso, significa que estamos en modo desarrollo (seguramente) y estamos haciendo una migración. Por eso cuando ocurre ese caso en particular, establecemos una cadena de conexión Default, que no será de ningún tenant real, solo de desarrollo.

IMultiTenantAccessor nos permite acceder a todo lo relevante del tenant actual, por ahora solo nos interesa la cadena de conexión, ya que eso es lo que hace la "magia" de simplemente nosotros utilizar un DbContext como siempre lo hacemos, pero aquí será redireccionado a una base de datos según el tenant.

Data > Tenants

Esta base de datos, como comenté antes, servirá para guardar un registro de todos nuestros tenants, funcionando como una base de datos "maestra" al cual la API accederá para conocer todos los tenants disponibles.

using Finbuckle.MultiTenant;
using Finbuckle.MultiTenant.Stores;
using Microsoft.EntityFrameworkCore;

namespace MultiTenantApi.Data.Tenants;


public class TenantsDbContext : EFCoreStoreDbContext<TenantInfo>
{
    public TenantsDbContext(DbContextOptions options) : base(options)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

Aquí estamos usando Finbuckle y su pre-implementación de un DbContext que contiene el entity TenantInfo con la información básica que necesitamos.

Nota 💡: Sin ningún problema se puede implementar la interfaz ITenantInfo para usarla en lugar de TenantInfo y agregar las propiedades que se necesiten.

Program

Ya solo resta conectar todos los cables dentro de Program para poder hacer pruebas y primero que nada, crear las bases de datos que utilizaremos en este demo.

using Finbuckle.MultiTenant;
using Microsoft.EntityFrameworkCore;
using MultiTenantApi.Data.Api;
using MultiTenantApi.Data.Tenants;

var builder = WebApplication.CreateBuilder(args);

// DB Context's
builder.Services.AddSqlServer<TenantsDbContext>(
    builder.Configuration.GetConnectionString("Tenants"));
builder.Services.AddDbContext<ApiDbContext>();

// Multitenancy support
builder.Services
    .AddMultiTenant<TenantInfo>()
    .WithHeaderStrategy("X-Tenant")
    .WithEFCoreStore<TenantsDbContext, TenantInfo>();

var app = builder.Build();

app.UseMultiTenant();

// Endpoints Van Aquí

await SeedTenantData();

app.Run();

async Task SeedTenantData()
{
    using var scope = app.Services.CreateScope();
    var store = scope.ServiceProvider.GetRequiredService<IMultiTenantStore<TenantInfo>>();
    var tenants = await store.GetAllAsync();

    if (tenants.Count() > 0)
    {
        return;
    }

    await store.TryAddAsync(new TenantInfo
    {
        Id = Guid.NewGuid().ToString(),
        Identifier = "tenant01",
        Name = "My Dev Tenant 01",
        ConnectionString = "Server=(localdb)\\mssqllocaldb;Database=ApiMultiTenant_Tenant01;Trusted_Connection=True;MultipleActiveResultSets=true"
    });

    await store.TryAddAsync(new TenantInfo
    {
        Id = Guid.NewGuid().ToString(),
        Identifier = "tenant02",
        Name = "My Dev Tenant 2",
        ConnectionString = "Server=(localdb)\\mssqllocaldb;Database=ApiMultiTenant_Tenant02;Trusted_Connection=True;MultipleActiveResultSets=true"
    });
}
Enter fullscreen mode Exit fullscreen mode

Aquí va la explicación:

  • DbContexts: Aquí registramos los dos DbContext's que tenemos. TenantsDbContext sí se indica su cadena de conexión, ya que esta será la "maestra" y ApiDbContext no se establece su cadena de conexión por que esta será dinámica desde el OnConfiguring que ya definimos antes.
  • MultiTenancy: En esta parte agregamos lo que Finbuckle ya tiene implementado para nosotros. Agregamos el soporte multi-tenant indicando que TenantInfo será la implementación de ITenantInfo que usaremos (si quisiéramos usar otro entity, aquí lo indicaríamos).
    • También indicamos que utilizaremos una estrategia de detección de tenants por medio de un encabezado http, aquí decimos que será el encabezado X-Tenant
    • Por último, indicamos el origen de los tenants, el cual será EntityFramework Core (hay distintos origenes de datos, puede ser por appsettings, en memoría o uno custom)
  • SeedTenantData: Este método lo único que hace es dar de alta dos tenants (tenant01 y tenant02) el cual utilizaremos para el demo y confirmar que esta implementación funciona.
    • Estamos indicando que cada uno tendrá una base de datos diferente, por lo que tendremos que crearlas con Scripts o con dotnet ef database update

Migraciones

Hasta este punto ya podemos hacer las migraciones, como tenemos dos contextos en una misma solución, es un poco diferente el hacerlo. Para TenantsDbContext hacemos lo siguiente:

dotnet ef migrations add FirstMigration -o Data/Tenants/Migrations --context TenantsDbContext
Enter fullscreen mode Exit fullscreen mode

El cual creará la carpeta Tenants > Migrations.

Y para ApiDbContext:

dotnet ef migrations add FirstMigration -o Data/Api/Migrations --context ApiDbContext
Enter fullscreen mode Exit fullscreen mode

Y también, creará su folder Api > Migrations. Aquí se utilizará la cadena de conexión Default que tengamos en el appsettings, podemos usar esta base de datos para crear otras bases de datos (o podemos generar los scripts con comandos dotnet-ef).

En fin yo así tengo mis dos bases de datos de prueba:

Image description

Y en cada una di de alta manualmente varios productos:

Image description

Endpoints

Para hacer nuestras pruebas, haremos dos endpoints (en donde estaba el comentario // Endpoints Van Aquí):

app.MapGet("/", (HttpContext httpContext) =>
{
    var tenantInfo = httpContext.GetMultiTenantContext<TenantInfo>()?.TenantInfo;

    if (tenantInfo is null)
    {
        return Results.BadRequest();
    }

    return Results.Ok(new
    {
        tenantInfo.Identifier,
        tenantInfo.Id
    });
});

app.MapGet("/api/products", (ApiDbContext context) =>
    context.Products.ToListAsync());
Enter fullscreen mode Exit fullscreen mode

Utilizando la extensión GetMultiTenantContext (o también utilizando IMultiTenantContextAccessor) podemos acceder a la info del tenant actual (determinado por el header X-Tenant) y también usamos el DbContext para consultar los productos.

Lo genial aquí es esto, ya todo está configurado para que funcione automáticamente de una forma aislada por tenant, todo lo que hagamos al ApiDbContext lo hará según el tenant.

Probando la solución

Utilizando Rest Client de VS Code podemos hacer las siguientes pruebas

@host = http://localhost:5087

### Tenant 01
GET {{host}}
Content-Type: application/json
X-Tenant: tenant01
Enter fullscreen mode Exit fullscreen mode

Image description

### Tenant 01 Products
GET {{host}}/api/products
Content-Type: application/json
X-Tenant: tenant01
Enter fullscreen mode Exit fullscreen mode

Image description

### Tenant 02
GET {{host}}
Content-Type: application/json
X-Tenant: tenant02
Enter fullscreen mode Exit fullscreen mode

Image description

### Tenant 02 Products
GET {{host}}/api/products
Content-Type: application/json
X-Tenant: tenant02
Enter fullscreen mode Exit fullscreen mode

Image description

Conclusión

Hemos creado una REST API multi-tenant con un aislamiento con base de datos, crear tu proyecto partiendo de aquí ya es como si desarrollaras una API como siempre lo haces.

Te recomiendo que visites los post's que menciono aquí, para que comprendas y aprendas más acerca de las distintas opciones que tenemos al desarrollar aplicaciones multi-tenant.

Con todo gusto atenderé tus dudas, sígueme en @balunatic y conectamos.

Te recomiendo que visites la documentación de Finbuckle, la verdad tiene mucho más funcionalidad, me gustaría abarcar más, pero será en otra ocasión.

🖖🏼

Referencias

Top comments (0)