DEV Community

Cover image for Microservices using ASP.NET Core, Ocelot, MongoDB and JWT
aelassas
aelassas

Posted on • Edited on

Microservices using ASP.NET Core, Ocelot, MongoDB and JWT

Contents

  1. Introduction
  2. Best Practices
  3. Development Environment
  4. Prerequisites
  5. Architecture
  6. Source Code
  7. Microservices
    1. Catalog Microservice
    2. Identity Microservice
    3. Adding JWT to Catalog Microservice
    4. Cart Microservice
  8. API Gateways
  9. Client Apps
  10. Unit Tests
  11. Monitoring using Health Checks
  12. How to Run the Application
  13. How to Deploy the Application
  14. References
  15. History

You can find source code here: https://github.com/aelassas/microservices

Introduction

Image

A microservices architecture consists of a collection of small, independent, and loosely coupled services. Each service is self-contained, implements a single business capability, is responsible for persisting its own data, is a separate codebase, and can be deployed independently.

API gateways are entry points for clients. Instead of calling services directly, clients call the API gateway, which forwards the call to the appropriate services.

There are multiple advantages using microservices architecture:

  • Developers can better understand the functionality of a service.
  • Failure in one service does not impact other services.
  • It's easier to manage bug fixes and feature releases.
  • Services can be deployed in multiple servers to enhance performance.
  • Services are easy to change and test.
  • Services are easy and fast to deploy.
  • Allows to choose technology that is suited for a particular functionality.

Before choosing microservices architecture, here are some challenges to consider:

  • Services are simple but the entire system as a whole is more complex.
  • Communication between services can be complex.
  • More services equals more resources.
  • Global testing can be difficult.
  • Debugging can be harder.

Microservices architecture is great for large companies, but can be complicated for small companies who need to create and iterate quickly, and don't want to get into complex orchestration.

This article provides a comprehensive guide on building microservices using ASP.NET Core, constructing API gateways using Ocelot, establishing repositories using MongoDB, managing JWT in microservices, unit testing microservices using xUnit and Moq, monitoring microservices using health checks, and finally deploying microservices using Docker.

Best Practices

Here's a breakdown of some best practices:

  • Single Responsibility: Each microservice should have a single responsibility or purpose. This means that it should do one thing and do it well. This makes it easier to understand, develop, test, and maintain each microservice.
  • Separate Data Store: Microservices should ideally have their own data storage. This can be a separate database, which is isolated from other microservices. This isolation ensures that changes or issues in one microservice's data won't affect others.
  • Asynchronous Communication: Use asynchronous communication patterns, like message queues or publish-subscribe systems, to enable communication. This makes the system more resilient and decouples services from each other.
  • Containerization: Use containerization technologies like Docker to package and deploy microservices. Containers provide a consistent and isolated environment for your microservices, making it easier to manage and scale them.
  • Orchestration: Use container orchestration tools like Kubernetes to manage and scale your containers. Kubernetes provides features for load balancing, scaling, and monitoring, making it a great choice for orchestrating microservices.
  • Build and Deploy Separation: Keep the build and deployment processes separate. This means that the build process should result in a deployable artifact, like a Docker container image, which can then be deployed in different environments without modification.
  • Stateless: Microservices should be stateless as much as possible. Any necessary state should be stored in the database or an external data store. Stateless services are easier to scale and maintain.
  • Micro Frontends: If you're building a web application, consider using the micro frontends approach. This involves breaking down the user interface into smaller, independently deployable components that can be developed and maintained by separate teams.

Development Environment

  • Visual Studio 2022 >= 17.8.0
  • .NET 8.0
  • MongoDB
  • Postman

Prerequisites

  • C#
  • ASP.NET Core
  • Ocelot
  • Swashbuckle
  • Serilog
  • JWT
  • MongoDB
  • xUnit
  • Moq

Architecture

Image

There are three microservices:

  • Catalog microservice: allows to manage the catalog.
  • Cart microservice: allows to manage the cart.
  • Identity microservice: allows to manage authentication and users.

Each microservice implements a single business capability and has its own dedicated database. This is called database-per-service pattern. This pattern allows for better separation of concerns, data isolation, and scalability. In a microservices architecture, services are designed to be small, focused, and independent, each responsible for a specific functionality. To maintain this separation, it's essential to ensure that each microservice manages its data independently. Here are other pros of this pattern:

  • Data schema can be modified without impacting other microservices.
  • Each microservice has its own data store, preventing accidental or unauthorized access to another service's data.
  • Since each microservice and its database are separate, they can be scaled independently based on their specific needs.
  • Each microservice can choose the database technology that best suits its requirements, without being bound to a single, monolithic database.
  • If one of the database server is down, this will not affect to other services.

There are two API gateways, one for the frontend and one for the backend.

Below is the frontend API gateway:

  • GET /catalog: retrieves catalog items.
  • GET /catalog/{id}: retrieves a catalog item.
  • GET /cart: retrieves cart items.
  • POST /cart: adds a cart item.
  • PUT /cart: updates a cart item.
  • DELETE /cart: deletes a cart item.
  • POST /identity/login: performs a login.
  • POST /identity/register: registers a user.
  • GET /identity/validate: validates a JWT token.

Below is the backend API gateway:

  • GET /catalog: retrieves catalog items.
  • GET /catalog/{id}: retrieves a catalog item.
  • POST /catalog: creates a catalog item.
  • PUT /catalog: updates a catalog item.
  • DELETE /catalog/{id}: deletes a catalog item.
  • PUT /cart/update-catalog-item: updates a catalog item in carts.
  • DELETE /cart/delete-catalog-item: deletes catalog item references from carts.
  • POST /identity/login: performs a login.
  • GET /identity/validate: validates a JWT token.

Finally, there are two client apps. A frontend for accessing the store and a backend for managing the store.

The frontend allows registered users to see the available catalog items, allows to add catalog items to the cart, and remove catalog items from the cart.

Here is a screenshot of the store page in the frontend:

Image

The backend allows admin users to see the available catalog items, allows to add new catalog items, update catalog items, and remove catalog items.

Here is a screenshot of the store page in the backend:

Image

Source Code

Image

  • CatalogMicroservice project contains the source code of the microservice managing the catalog.
  • CartMicroservice project contains the source code of the microservice managing the cart.
  • IdentityMicroservice project contains the source code of the microservice managing authentication and users.
  • Middleware project contains the source code of common functionalities used by microservices.
  • FrontendGateway project contains the source code of the frontend API gateway.
  • BackendGateway project contains the source code of the backend API gateway.
  • Frontend project contains the source code of the frontend client app.
  • Backend project contains the source code of the backend client app.
  • test solution folder contains unit tests of all microservices.

Microservices and gateways are developed using ASP.NET Core and C#. Client apps are developed using HTML and Vanilla JavaScript in order to focus on microservices.

Microservices

Catalog Microservice

Let's start with the simplest microservice, CatalogMicroservice.

CatalogMicroservice is responsible for managing the catalog.

Below is the model used by CatalogMicroservice:


public class CatalogItem
{
    public static readonly string DocumentName = nameof(CatalogItem);

    [BsonId]
    [BsonRepresentation(BsonType.ObjectId)]
    public string? Id { get; init; }
    public required string Name { get; set; }
    public string? Description { get; set; }
    public decimal Price { get; set; }
}

Enter fullscreen mode Exit fullscreen mode

Below is the repository interface:


public interface ICatalogRepository
{
    IList<CatalogItem> GetCatalogItems();
    CatalogItem? GetCatalogItem(string catalogItemId);
    void InsertCatalogItem(CatalogItem catalogItem);
    void UpdateCatalogItem(CatalogItem catalogItem);
    void DeleteCatalogItem(string catagItemId);
}

Enter fullscreen mode Exit fullscreen mode

Below is the repository:


public class CatalogRepository(IMongoDatabase db) : ICatalogRepository
{
    private readonly IMongoCollection<CatalogItem> _col = 
                     db.GetCollection<CatalogItem>(CatalogItem.DocumentName);

    public IList<CatalogItem> GetCatalogItems() =>
        _col.Find(FilterDefinition<CatalogItem>.Empty).ToList();

    public CatalogItem GetCatalogItem(string catalogItemId) =>
        _col.Find(c => c.Id == catalogItemId).FirstOrDefault();

    public void InsertCatalogItem(CatalogItem catalogItem) =>
        _col.InsertOne(catalogItem);

    public void UpdateCatalogItem(CatalogItem catalogItem) =>
        _col.UpdateOne(c => c.Id == catalogItem.Id, Builders<CatalogItem>.Update
            .Set(c => c.Name, catalogItem.Name)
            .Set(c => c.Description, catalogItem.Description)
            .Set(c => c.Price, catalogItem.Price));

    public void DeleteCatalogItem(string catalogItemId) =>
        _col.DeleteOne(c => c.Id == catalogItemId);
}

Enter fullscreen mode Exit fullscreen mode

Below is the controller:


[Route("api/[controller]")]
[ApiController]
public class CatalogController(ICatalogRepository catalogRepository) : ControllerBase
{
    // GET: api/<CatalogController>
    [HttpGet]
    public IActionResult Get()
    {
        var catalogItems = catalogRepository.GetCatalogItems();
        return Ok(catalogItems);
    }

    // GET api/<CatalogController>/653e4410614d711b7fc953a7
    [HttpGet("{id}")]
    public IActionResult Get(string id)
    {
        var catalogItem = catalogRepository.GetCatalogItem(id);
        return Ok(catalogItem);
    }

    // POST api/<CatalogController>
    [HttpPost]
    public IActionResult Post([FromBody] CatalogItem catalogItem)
    {
        catalogRepository.InsertCatalogItem(catalogItem);
        return CreatedAtAction(nameof(Get), new { id = catalogItem.Id }, catalogItem);
    }

    // PUT api/<CatalogController>
    [HttpPut]
    public IActionResult Put([FromBody] CatalogItem? catalogItem)
    {
        if (catalogItem != null)
        {
            catalogRepository.UpdateCatalogItem(catalogItem);
            return Ok();
        }
        return new NoContentResult();
    }

    // DELETE api/<CatalogController>/653e4410614d711b7fc953a7
    [HttpDelete("{id}")]
    public IActionResult Delete(string id)
    {
        catalogRepository.DeleteCatalogItem(id);
        return Ok();
    }
}

Enter fullscreen mode Exit fullscreen mode

ICatalogRepository is added using dependency injection in Startup.cs in order to make the microservice testable:


// This method gets called by the runtime. 
// Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddMongoDb(Configuration);
    services.AddSingleton<ICatalogRepository>(sp =>
        new CatalogRepository(sp.GetService<IMongoDatabase>() ?? 
            throw new Exception("IMongoDatabase not found"))
    );
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "Catalog", Version = "v1" });
    });

    // ...
}

Enter fullscreen mode Exit fullscreen mode

Below is AddMongoDB extension method:


public static void AddMongoDb
  (this IServiceCollection services, IConfiguration configuration)
{
    services.Configure<MongoOptions>(configuration.GetSection("mongo"));
    services.AddSingleton(c =>
    {
        var options = c.GetService<IOptions<MongoOptions>>();

        return new MongoClient(options.Value.ConnectionString);
    });
    services.AddSingleton(c =>
    {
        var options = c.GetService<IOptions<MongoOptions>>();
        var client = c.GetService<MongoClient>();

        return client.GetDatabase(options.Value.Database);
    });
}

Enter fullscreen mode Exit fullscreen mode

Below is Configure method in Startup.cs:

// This method gets called by the runtime. 
// Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseSwagger();

    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "Catalog V1");
    });

    var option = new RewriteOptions();
    option.AddRedirect("^$", "swagger");
    app.UseRewriter(option);

    // ...

    app.UseHttpsRedirection();

    app.UseRouting();

    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}
Enter fullscreen mode Exit fullscreen mode

Below is appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "mongo": {
    "connectionString": "mongodb://127.0.0.1:27017",
    "database": "store-catalog"
  }
}

Enter fullscreen mode Exit fullscreen mode

API documentation is generated using Swashbuckle. Swagger middleware is configured in Startup.cs, in ConfigureServices and Configure methods in Startup.cs.

If you run CatalogMicroservice project using IISExpress or Docker, you will get the Swagger UI when accessing http://localhost:44326/:

Image

Identity Microservice

Now, let's move on to IdentityMicroservice.

IdentityMicroservice is responsible for authentication and managing users.

Below is the model used by IdentityMicroservice:

public class User
{
    public static readonly string DocumentName = nameof(User);

    [BsonId]
    [BsonRepresentation(BsonType.ObjectId)]
    public string? Id { get; init; }
    public required string Email { get; init; }
    public required string Password { get; set; }
    public string? Salt { get; set; }
    public bool IsAdmin { get; init; }

    public void SetPassword(string password, IEncryptor encryptor)
    {
        Salt = encryptor.GetSalt();
        Password = encryptor.GetHash(password, Salt);
    }

    public bool ValidatePassword(string password, IEncryptor encryptor) =>
        Password == encryptor.GetHash(password, Salt);
}
Enter fullscreen mode Exit fullscreen mode

IEncryptor middleware is used for encrypting passwords.

Below is the repository interface:

public interface IUserRepository
{
    User? GetUser(string email);
    void InsertUser(User user);
}
Enter fullscreen mode Exit fullscreen mode

Below is the repository implementation:

public class UserRepository(IMongoDatabase db) : IUserRepository
{
    private readonly IMongoCollection<User> _col = 
                     db.GetCollection<User>(User.DocumentName);

    public User? GetUser(string email) =>
        _col.Find(u => u.Email == email).FirstOrDefault();

    public void InsertUser(User user) =>
        _col.InsertOne(user);
}
Enter fullscreen mode Exit fullscreen mode

Below is the controller:

[Route("api/[controller]")]
[ApiController]
public class IdentityController
  (IUserRepository userRepository, IJwtBuilder jwtBuilder, IEncryptor encryptor)
    : ControllerBase
{
    [HttpPost("login")]
    public IActionResult Login([FromBody] User user, 
           [FromQuery(Name = "d")] string destination = "frontend")
    {
        var u = userRepository.GetUser(user.Email);

        if (u == null)
        {
            return NotFound("User not found.");
        }

        if (destination == "backend" && !u.IsAdmin)
        {
            return BadRequest("Could not authenticate user.");
        }

        var isValid = u.ValidatePassword(user.Password, encryptor);

        if (!isValid)
        {
            return BadRequest("Could not authenticate user.");
        }

        var token = jwtBuilder.GetToken(u.Id);

        return Ok(token);
    }

    [HttpPost("register")]
    public IActionResult Register([FromBody] User user)
    {
        var u = userRepository.GetUser(user.Email);

        if (u != null)
        {
            return BadRequest("User already exists.");
        }

        user.SetPassword(user.Password, encryptor);
        userRepository.InsertUser(user);

        return Ok();
    }

    [HttpGet("validate")]
    public IActionResult Validate([FromQuery(Name = "email")] string email, 
                                  [FromQuery(Name = "token")] string token)
    {
        var u = userRepository.GetUser(email);

        if (u == null)
        {
            return NotFound("User not found.");
        }

        var userId = jwtBuilder.ValidateToken(token);

        if (userId != u.Id)
        {
            return BadRequest("Invalid token.");
        }

        return Ok(userId);
    }
}
Enter fullscreen mode Exit fullscreen mode

IUserRepository, IJwtBuilder and IEncryptor middlewares are added using dependency injection in Startup.cs:

// This method gets called by the runtime. 
// Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddMongoDb(Configuration);
    services.AddJwt(Configuration);
    services.AddTransient<IEncryptor, Encryptor>();
    services.AddSingleton<IUserRepository>(sp =>
        new UserRepository(sp.GetService<IMongoDatabase>() ?? 
            throw new Exception("IMongoDatabase not found"))
    );
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "User", Version = "v1" });
    });

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Below is AddJwt extension method:

public static void AddJwt(this IServiceCollection services, IConfiguration configuration)
{
    var options = new JwtOptions();
    var section = configuration.GetSection("jwt");
    section.Bind(options);
    services.Configure<JwtOptions>(section);
    services.AddSingleton<IJwtBuilder, JwtBuilder>();
    services.AddAuthentication()
        .AddJwtBearer(cfg =>
        {
            cfg.RequireHttpsMetadata = false;
            cfg.SaveToken = true;
            cfg.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateAudience = false,
                IssuerSigningKey = new SymmetricSecurityKey
                                   (Encoding.UTF8.GetBytes(options.Secret))
            };
        });
}
Enter fullscreen mode Exit fullscreen mode

IJwtBuilder is responsible for creating JWT tokens and validating them:

public interface IJwtBuilder
{
    string GetToken(string userId);
    string ValidateToken(string token);
}
Enter fullscreen mode Exit fullscreen mode

Below is the implementation of IJwtBuilder:

public class JwtBuilder(IOptions<JwtOptions> options) : IJwtBuilder
{
    private readonly JwtOptions _options = options.Value;

    public string GetToken(string userId)
    {
        var signingKey = new SymmetricSecurityKey
                         (Encoding.UTF8.GetBytes(_options.Secret));
        var signingCredentials = new SigningCredentials
                                 (signingKey, SecurityAlgorithms.HmacSha256);
        var claims = new[]
        {
            new Claim("userId", userId)
        };
        var expirationDate = DateTime.Now.AddMinutes(_options.ExpiryMinutes);
        var jwt = new JwtSecurityToken(claims: claims, 
                  signingCredentials: signingCredentials, expires: expirationDate);
        var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt);

        return encodedJwt;
    }

    public string ValidateToken(string token)
    {
        var principal = GetPrincipal(token);
        if (principal == null)
        {
            return string.Empty;
        }

        ClaimsIdentity identity;
        try
        {
            identity = (ClaimsIdentity)principal.Identity;
        }
        catch (NullReferenceException)
        {
            return string.Empty;
        }
        var userIdClaim = identity?.FindFirst("userId");
        if (userIdClaim == null)
        {
            return string.Empty;
        }
        var userId = userIdClaim.Value;
        return userId;
    }

    private ClaimsPrincipal GetPrincipal(string token)
    {
        try
        {
            var tokenHandler = new JwtSecurityTokenHandler();
            var jwtToken = (JwtSecurityToken)tokenHandler.ReadToken(token);
            if (jwtToken == null)
            {
                return null;
            }
            var key = Encoding.UTF8.GetBytes(_options.Secret);
            var parameters = new TokenValidationParameters()
            {
                RequireExpirationTime = true,
                ValidateIssuer = false,
                ValidateAudience = false,
                IssuerSigningKey = new SymmetricSecurityKey(key)
            };
            IdentityModelEventSource.ShowPII = true;
            ClaimsPrincipal principal = 
                  tokenHandler.ValidateToken(token, parameters, out _);
            return principal;
        }
        catch (Exception)
        {
            return null;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

IEncryptor is simply responsible for encrypting passwords:

public interface IEncryptor
{
    string GetSalt();
    string GetHash(string value, string salt);
}
Enter fullscreen mode Exit fullscreen mode

Below is the implementation of IEncryptor:

public class Encryptor: IEncryptor
{
    private const int SALT_SIZE = 40;
    private const int ITERATIONS_COUNT = 10000;

    public string GetSalt()
    {
        var saltBytes = new byte[SALT_SIZE];
        var rng = RandomNumberGenerator.Create();
        rng.GetBytes(saltBytes);

        return Convert.ToBase64String(saltBytes);
    }

    public string GetHash(string value, string salt)
    {
        var pbkdf2 = new Rfc2898DeriveBytes
            (value, GetBytes(salt), ITERATIONS_COUNT, HashAlgorithmName.SHA256);

        return Convert.ToBase64String(pbkdf2.GetBytes(SALT_SIZE));
    }

    private static byte[] GetBytes(string value)
    {
        var bytes = new byte[value.Length + sizeof(char)];
        Buffer.BlockCopy(value.ToCharArray(), 0, bytes, 0, bytes.Length);

        return bytes;
    }
}
Enter fullscreen mode Exit fullscreen mode

Below is Configure method in Startup.cs:

// This method gets called by the runtime. 
// Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseSwagger();

    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "Catalog V1");
    });

    var option = new RewriteOptions();
    option.AddRedirect("^$", "swagger");
    app.UseRewriter(option);

    // ...

    app.UseHttpsRedirection();

    app.UseRouting();

    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}
Enter fullscreen mode Exit fullscreen mode

Below is appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "mongo": {
    "connectionString": "mongodb://127.0.0.1:27017",
    "database": "store-identity"
  },
  "jwt": {
    "secret": "9095a623-a23a-481a-aa0c-e0ad96edc103",
    "expiryMinutes": 60
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's test IdentityMicroservice.

Open Postman and execute the following POST request http://localhost:44397/api/identity/register with the following payload to register a user:

{
  "email": "user@store.com",
  "password": "pass"
}

Enter fullscreen mode Exit fullscreen mode

Now, execute the following POST request http://localhost:44397/api/identity/login with the following payload to create a JWT token:

{
  "email": "user@store.com",
  "password": "pass"
}
Enter fullscreen mode Exit fullscreen mode

Image

You can then check the generated token on jwt.io:

Image

That's it. You can execute the following GET request http://localhost:44397/api/identity/validate?email={email}&token={token} in the same way to validate a JWT token. If the token is valid, the response will be the user Id which is an ObjectId.

If you run IdentityMicroservice project using IISExpress or Docker, you will get the Swagger UI when accessing http://localhost:44397/:

Image

Adding JWT to Catalog Microservice

Now, let's add JWT authentication to catalog microservice.

First, we have to add jwt section in appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "jwt": {
    "secret": "9095a623-a23a-481a-aa0c-e0ad96edc103"
  },
  "mongo": {
    "connectionString": "mongodb://127.0.0.1:27017",
    "database": "store-catalog"
  }
}
Enter fullscreen mode Exit fullscreen mode

Then, we have to add JWT configuration in ConfigureServices method in Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    services.AddJwtAuthentication(Configuration); // JWT Configuration

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Where AddJwtAuthentication extension method is implemented as follows:

public static void AddJwtAuthentication
    (this IServiceCollection services, IConfiguration configuration)
{
    var section = configuration.GetSection("jwt");
    var options = section.Get<JwtOptions>();
    var key = Encoding.UTF8.GetBytes(options.Secret);
    section.Bind(options);
    services.Configure<JwtOptions>(section);

    services.AddSingleton<IJwtBuilder, JwtBuilder>();
    services.AddTransient<JwtMiddleware>();

    services.AddAuthentication(x =>
        {
            x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
            x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
        })
        .AddJwtBearer(x =>
        {
            x.RequireHttpsMetadata = false;
            x.SaveToken = true;
            x.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(key),
                ValidateIssuer = false,
                ValidateAudience = false
            };
        });

    services.AddAuthorization(x =>
    {
        x.DefaultPolicy = new AuthorizationPolicyBuilder()
            .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
            .RequireAuthenticatedUser()
            .Build();
    });
}
Enter fullscreen mode Exit fullscreen mode

JwtMiddleware is responsible for validating JWT token:

public class JwtMiddleware(IJwtBuilder jwtBuilder) : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        // Get the token from the Authorization header
        var bearer = context.Request.Headers["Authorization"].ToString();
        var token = bearer.Replace("Bearer ", string.Empty);

        if (!string.IsNullOrEmpty(token))
        {
            // Verify the token using the IJwtBuilder
            var userId = jwtBuilder.ValidateToken(token);

            if (ObjectId.TryParse(userId, out _))
            {
                // Store the userId in the HttpContext items for later use
                context.Items["userId"] = userId;
            }
            else
            {
                // If token or userId are invalid, send 401 Unauthorized status
                context.Response.StatusCode = 401;
            }
        }

        // Continue processing the request
        await next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

If the JWT token or the user ID are invalid, we send 401 Unauthorized status.

Then, we resigster JwtMiddleware in Configure method in Startup.cs:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ...

    app.UseMiddleware<JwtMiddleware>(); // JWT Middleware

    app.UseAuthentication();

    app.UseAuthorization();

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Then, we specify that we require JWT authentication for our endpoints in CatalogController.cs through [Authorize] attribute:

// GET: api/<CatalogController>
[HttpGet]
[Authorize]
public IActionResult Get()
{
    var catalogItems = _catalogRepository.GetCatalogItems();
    return Ok(catalogItems);
}

// ...
Enter fullscreen mode Exit fullscreen mode

Now, catalog microservice is secured through JWT authentication. Cart microservice was secured in the same way.

Finally, we need to add JWT authentication to Swagger. To do so, we need to update AddSwaggerGen in ConfigureServices in Statup.cs:

public void ConfigureServices(IServiceCollection services)
{
    //...

    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "Catalog", Version = "v1" });
        c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
        {
            In = ParameterLocation.Header,
            Description = "Please insert JWT token with the prefix Bearer into field",
            Name = "Authorization",
            Type = SecuritySchemeType.ApiKey,
            Scheme = "bearer",
            BearerFormat = "JWT"
        });
        c.AddSecurityRequirement(new OpenApiSecurityRequirement {
            {
                new OpenApiSecurityScheme
                {
                    Reference = new OpenApiReference
                    {
                        Type = ReferenceType.SecurityScheme,
                        Id = "Bearer"
                    }
                },
                new string[] { }
            }
        });
    });

    //...
}
Enter fullscreen mode Exit fullscreen mode

Now if you want to run Postman on catalog or cart microservices, you need to specify the Bearer Token in Authorization tab.

If you want to test catalog or cart microservices with Swagger, you need to click on Authorize button and enter the JWT token with the prefix Bearer into authorization field.

Cart Microservice

CartMicroservice is responsible for managing the cart.

Below are the models used by CartMicroservice:

public class Cart
{
    public static readonly string DocumentName = nameof(Cart);

    [BsonId]
    [BsonRepresentation(BsonType.ObjectId)]
    public string? Id { get; init; }
    [BsonRepresentation(BsonType.ObjectId)]
    public string? UserId { get; init; }
    public List<CartItem> CartItems { get; init; } = new();
}

public class CartItem
{
    [BsonRepresentation(BsonType.ObjectId)]
    public string? CatalogItemId { get; init; }
    public required string Name { get; set; }
    public decimal Price { get; set; }
    public int Quantity { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Below is the repository interface:

public interface ICartRepository
{
    IList<CartItem> GetCartItems(string userId);
    void InsertCartItem(string userId, CartItem cartItem);
    void UpdateCartItem(string userId, CartItem cartItem);
    void DeleteCartItem(string userId, string cartItemId);
    void UpdateCatalogItem(string catalogItemId, string name, decimal price);
    void DeleteCatalogItem(string catalogItemId);
}
Enter fullscreen mode Exit fullscreen mode

Below is the repository:

public class CartRepository(IMongoDatabase db) : ICartRepository
{
    private readonly IMongoCollection<Cart> _col = 
                           db.GetCollection<Cart>(Cart.DocumentName);

    public IList<CartItem> GetCartItems(string userId) =>
        _col
        .Find(c => c.UserId == userId)
        .FirstOrDefault()?.CartItems ?? new List<CartItem>();

    public void InsertCartItem(string userId, CartItem cartItem)
    {
        var cart = _col.Find(c => c.UserId == userId).FirstOrDefault();
        if (cart == null)
        {
            cart = new Cart
            {
                UserId = userId,
                CartItems = new List<CartItem> { cartItem }
            };
            _col.InsertOne(cart);
        }
        else
        {
            var ci = cart
                .CartItems
                .FirstOrDefault(ci => ci.CatalogItemId == cartItem.CatalogItemId);

            if (ci == null)
            {
                cart.CartItems.Add(cartItem);
            }
            else
            {
                ci.Quantity++;
            }

            var update = Builders<Cart>.Update
                .Set(c => c.CartItems, cart.CartItems);
            _col.UpdateOne(c => c.UserId == userId, update);
        }
    }

    public void UpdateCartItem(string userId, CartItem cartItem)
    {
        var cart = _col.Find(c => c.UserId == userId).FirstOrDefault();
        if (cart != null)
        {
            cart.CartItems.RemoveAll(ci => ci.CatalogItemId == cartItem.CatalogItemId);
            cart.CartItems.Add(cartItem);
            var update = Builders<Cart>.Update
                .Set(c => c.CartItems, cart.CartItems);
            _col.UpdateOne(c => c.UserId == userId, update);
        }
    }

    public void DeleteCartItem(string userId, string catalogItemId)
    {
        var cart = _col.Find(c => c.UserId == userId).FirstOrDefault();
        if (cart != null)
        {
            cart.CartItems.RemoveAll(ci => ci.CatalogItemId == catalogItemId);
            var update = Builders<Cart>.Update
                .Set(c => c.CartItems, cart.CartItems);
            _col.UpdateOne(c => c.UserId == userId, update);
        }
    }

    public void UpdateCatalogItem(string catalogItemId, string name, decimal price)
    {
        // Update catalog item in carts
        var carts = GetCarts(catalogItemId);
        foreach (var cart in carts)
        {
            var cartItem = cart.CartItems.FirstOrDefault
                           (i => i.CatalogItemId == catalogItemId);
            if (cartItem != null)
            {
                cartItem.Name = name;
                cartItem.Price = price;
                var update = Builders<Cart>.Update
                    .Set(c => c.CartItems, cart.CartItems);
                _col.UpdateOne(c => c.Id == cart.Id, update);
            }
        }
    }

    public void DeleteCatalogItem(string catalogItemId)
    {
        // Delete catalog item from carts
        var carts = GetCarts(catalogItemId);
        foreach (var cart in carts)
        {
            cart.CartItems.RemoveAll(i => i.CatalogItemId == catalogItemId);
            var update = Builders<Cart>.Update
                .Set(c => c.CartItems, cart.CartItems);
            _col.UpdateOne(c => c.Id == cart.Id, update);
        }
    }

    private IList<Cart> GetCarts(string catalogItemId) =>
        _col.Find(c => c.CartItems.Any(i => i.CatalogItemId == catalogItemId)).ToList();
}
Enter fullscreen mode Exit fullscreen mode

Below is the controller:

[Route("api/[controller]")]
[ApiController]
public class CartController(ICartRepository cartRepository) : ControllerBase
{
    // GET: api/<CartController>
    [HttpGet]
    [Authorize]
    public IActionResult Get([FromQuery(Name = "u")] string userId)
    {
        var cartItems = cartRepository.GetCartItems(userId);
        return Ok(cartItems);
    }

    // POST api/<CartController>
    [HttpPost]
    [Authorize]
    public IActionResult Post([FromQuery(Name = "u")] string userId, 
                              [FromBody] CartItem cartItem)
    {
        cartRepository.InsertCartItem(userId, cartItem);
        return Ok();
    }

    // PUT api/<CartController>
    [HttpPut]
    [Authorize]
    public IActionResult Put([FromQuery(Name = "u")] string userId, 
                             [FromBody] CartItem cartItem)
    {
        cartRepository.UpdateCartItem(userId, cartItem);
        return Ok();
    }

    // DELETE api/<CartController>
    [HttpDelete]
    [Authorize]
    public IActionResult Delete([FromQuery(Name = "u")] string userId, 
                                [FromQuery(Name = "ci")] string cartItemId)
    {
        cartRepository.DeleteCartItem(userId, cartItemId);
        return Ok();
    }

    // PUT api/<CartController>/update-catalog-item
    [HttpPut("update-catalog-item")]
    [Authorize]
    public IActionResult Put([FromQuery(Name = "ci")] string catalogItemId, 
    [FromQuery(Name = "n")] string name, [FromQuery(Name = "p")] decimal price)
    {
        cartRepository.UpdateCatalogItem(catalogItemId, name, price);
        return Ok();
    }

    // DELETE api/<CartController>/delete-catalog-item
    [HttpDelete("delete-catalog-item")]
    [Authorize]
    public IActionResult Delete([FromQuery(Name = "ci")] string catalogItemId)
    {
        cartRepository.DeleteCatalogItem(catalogItemId);
        return Ok();
    }
}
Enter fullscreen mode Exit fullscreen mode

ICartRepository is added using dependency injection in Startup.cs in order to make the microservice testable:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    services.AddJwtAuthentication(Configuration); // JWT Configuration

    services.AddMongoDb(Configuration);

    services.AddSingleton<ICartRepository>(sp =>
        new CartRepository(sp.GetService<IMongoDatabase>() ?? 
            throw new Exception("IMongoDatabase not found"))
    );

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Configure method in Startup.cs is the same as the one in CatalogMicroservice.

Below is appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "jwt": {
    "secret": "9095a623-a23a-481a-aa0c-e0ad96edc103"
  },
  "mongo": {
    "connectionString": "mongodb://127.0.0.1:27017",
    "database": "store-cart"
  }
}
Enter fullscreen mode Exit fullscreen mode

API documentation is generated using Swashbuckle. Swagger middleware is configured in Startup.cs, in ConfigureServices and Configure methods in Startup.cs.

If you run CartMicroservice project using IISExpress or Docker, you will get the Swagger UI when accessing http://localhost:44388/.

API Gateways

There are two API gateways, one for the frontend and one for the backend.

Let's start with the frontend.

ocelot.json configuration file was added in Program.cs as follows:

var builder = Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((hostingContext, config) =>
        {
            config
                .SetBasePath(hostingContext.HostingEnvironment.ContentRootPath)
                .AddJsonFile("appsettings.json", true, true)
                .AddJsonFile("ocelot.json", false, true)
                .AddJsonFile($"appsettings.
                {hostingContext.HostingEnvironment.EnvironmentName}.json", true, true)
                .AddJsonFile($"ocelot.
                {hostingContext.HostingEnvironment.EnvironmentName}.json", 
                 optional: false, reloadOnChange: true)
                .AddEnvironmentVariables();

            if (hostingContext.HostingEnvironment.EnvironmentName == "Development")
            {
                config.AddJsonFile("appsettings.Local.json", true, true);
            }
        })
        .UseSerilog((_, config) =>
        {
            config
                .MinimumLevel.Information()
                .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
                .Enrich.FromLogContext()
                .WriteTo.Console();
        })
        .ConfigureWebHostDefaults(webBuilder =>
        {
            webBuilder.UseStartup<Startup>();
        });

builder.Build().Run();
Enter fullscreen mode Exit fullscreen mode

Serilog is configured to write logs to the console. You can, of course, write logs to text files using WriteTo.File(@"Logs\store.log") and Serilog.Sinks.File nuget package.

Then, here is Startup.cs:

public class Startup(IConfiguration configuration)
{
    private IConfiguration Configuration { get; } = configuration;

    // This method gets called by the runtime. 
    // Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();

        services.AddOcelot(Configuration);

        services.AddJwtAuthentication(Configuration); // JWT Configuration

        services.AddCors(options =>
        {
            options.AddPolicy("CorsPolicy",
                builder => builder.AllowAnyOrigin()
                    .AllowAnyMethod()
                    .AllowAnyHeader());
        });

        services.AddHealthChecks()
            .AddMongoDb(
                mongodbConnectionString: (
                    Configuration.GetSection("mongo").Get<MongoOptions>()
                    ?? throw new Exception("mongo configuration section not found")
                ).ConnectionString,
                name: "mongo",
                failureStatus: HealthStatus.Unhealthy
            );

        services.AddHealthChecksUI().AddInMemoryStorage();
    }

    // This method gets called by the runtime. Use this method 
    // to configure the HTTP request pipeline.
    public async void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseMiddleware<RequestResponseLogging>();

        app.UseCors("CorsPolicy");

        app.UseAuthentication();

        app.UseHealthChecks("/healthz", new HealthCheckOptions
        {
            Predicate = _ => true,
            ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
        });

        app.UseHealthChecksUI();

        var option = new RewriteOptions();
        option.AddRedirect("^$", "healthchecks-ui");
        app.UseRewriter(option);

        await app.UseOcelot();
    }
}
Enter fullscreen mode Exit fullscreen mode

RequestResponseLogging middleware is responsible for logging requests and responses:

public class RequestResponseLogging(RequestDelegate next, ILogger<RequestResponseLogging> logger)
{
    public async Task InvokeAsync(HttpContext context)
    {
        context.Request.EnableBuffering();
        var builder = new StringBuilder();
        var request = await FormatRequest(context.Request);
        builder.Append("Request: ").AppendLine(request);
        builder.AppendLine("Request headers:");

        foreach (var header in context.Request.Headers)
        {
            builder.Append(header.Key).Append(": ").AppendLine(header.Value);
        }

        var originalBodyStream = context.Response.Body;
        using var responseBody = new MemoryStream();
        context.Response.Body = responseBody;
        await next(context);

        var response = await FormatResponse(context.Response);
        builder.Append("Response: ").AppendLine(response);
        builder.AppendLine("Response headers: ");

        foreach (var header in context.Response.Headers)
        {
            builder.Append(header.Key).Append(": ").AppendLine(header.Value);
        }

        logger.LogInformation(builder.ToString());

        await responseBody.CopyToAsync(originalBodyStream);
    }

    private static async Task<string> FormatRequest(HttpRequest request)
    {
        using var reader = new StreamReader(
            request.Body,
            encoding: Encoding.UTF8,
            detectEncodingFromByteOrderMarks: false,
            leaveOpen: true);
        var body = await reader.ReadToEndAsync();
        var formattedRequest = $"{request.Method} 
        {request.Scheme}://{request.Host}{request.Path}{request.QueryString} {body}";
        request.Body.Position = 0;
        return formattedRequest;
    }

    private static async Task<string> FormatResponse(HttpResponse response)
    {
        response.Body.Seek(0, SeekOrigin.Begin);
        string text = await new StreamReader(response.Body).ReadToEndAsync();
        response.Body.Seek(0, SeekOrigin.Begin);
        return $"{response.StatusCode}: {text}";
    }
}
Enter fullscreen mode Exit fullscreen mode

We used logging in the gateway so that we don't need to check the logs of each microservice.

Here is ocelot.Development.json:

{
  "Routes": [
    {
      "DownstreamPathTemplate": "/api/catalog",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44326
        }
      ],
      "UpstreamPathTemplate": "/catalog",
      "UpstreamHttpMethod": [ "GET" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/catalog/{id}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44326
        }
      ],
      "UpstreamPathTemplate": "/catalog/{id}",
      "UpstreamHttpMethod": [ "GET" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/cart",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44388
        }
      ],
      "UpstreamPathTemplate": "/cart",
      "UpstreamHttpMethod": [ "GET" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/cart",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44388
        }
      ],
      "UpstreamPathTemplate": "/cart",
      "UpstreamHttpMethod": [ "POST" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/cart",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44388
        }
      ],
      "UpstreamPathTemplate": "/cart",
      "UpstreamHttpMethod": [ "PUT" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/cart",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44388
        }
      ],
      "UpstreamPathTemplate": "/cart",
      "UpstreamHttpMethod": [ "DELETE" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/identity/login",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44397
        }
      ],
      "UpstreamPathTemplate": "/identity/login",
      "UpstreamHttpMethod": [ "POST" ]
    },
    {
      "DownstreamPathTemplate": "/api/identity/register",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44397
        }
      ],
      "UpstreamPathTemplate": "/identity/register",
      "UpstreamHttpMethod": [ "POST" ]
    },
    {
      "DownstreamPathTemplate": "/api/identity/validate",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44397
        }
      ],
      "UpstreamPathTemplate": "/identity/validate",
      "UpstreamHttpMethod": [ "GET" ]
    }
  ],
  "GlobalConfiguration": {
    "BaseUrl": "http://localhost:44300/"
  }
}
Enter fullscreen mode Exit fullscreen mode

And finally, below is appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "jwt": {
    "secret": "9095a623-a23a-481a-aa0c-e0ad96edc103"
  },
  "mongo": {
    "connectionString": "mongodb://127.0.0.1:27017"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's test the frontend gateway.

First, execute the following POST request http//localhost:44300/identity/login with the following payload to create a JWT token:

{
  "email": "user@store.com",
  "password": "pass"
}
Enter fullscreen mode Exit fullscreen mode

We already created that user while testing IdentityMicroservice. If you didn't create that user, you can create one by executing the following POST request http://localhost:44300/identity/register with the same payload above.

Image

Then, go to Authorization tab in Postman, select Bearer Token type and copy paste the JWT token in Token field. Then, execute the following GET request to retrieve the catalog http://localhost:44300/catalog:

Image

If the JWT token is not valid, the response will be 401 Unauthorized.

You can check the tokens on jwt.io:

Image

If we open the console in Visual Studio, we can see all the logs:

Image

That's it! You can test the other API methods in the same way.

The backend gateway is done pretty much the same way. The only difference is in ocelot.json file.

Client Apps

There are two client apps. One for the frontend and one for the backend.

The client apps are made using HTML and Vanilla JavaScript for the sake of simplicity.

Let's pick the login page of the frontend for example. Here is the HTML:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Login</title>
    <link rel="icon" href="data:,">
    <link href="css/bootstrap.min.css" rel="stylesheet" />
    <link href="css/login.css" rel="stylesheet" />
</head>
<body>
    <div class="header"></div>
    <div class="login">
        <table>
            <tr>
                <td>Email</td>
                <td><input id="email" type="text" autocomplete="off" 
                                      class="form-control" /></td>
            </tr>
            <tr>
                <td>Password</td>
                <td><input id="password" type="password" class="form-control" /></td>
            </tr>
            <tr>
                <td></td>
                <td>
                    <input id="login" type="button" value="Login" 
                                         class="btn btn-primary" />
                    <input id="register" type="button" value="Register" 
                                         class="btn btn-secondary" />
                </td>
            </tr>
        </table>
    </div>
    <script src="js/login.js" type="module"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Here is settings.js:

export default {
    uri: "http://localhost:44300/"
};
Enter fullscreen mode Exit fullscreen mode

And here is login.js:

import settings from "./settings.js";
import common from "./common.js";

window.onload = () => {
    "use strict";

    localStorage.removeItem("auth");

    function login() {
        const user = {
            "email": document.getElementById("email").value,
            "password": document.getElementById("password").value
        };

        common.post(settings.uri + "identity/login?d=frontend", (token) => {
            const auth = {
                "email": user.email,
                "token": token
            };
            localStorage.setItem("auth", JSON.stringify(auth));
            location.href = "/store.html";
        }, () => {
            alert("Wrong credentials.");
        }, user);
    };

    document.getElementById("login").onclick = () => {
        login();
    };

    document.getElementById("password").onkeyup = (e) => {
        if (e.key === 'Enter') {
            login();
        }
    };

    document.getElementById("register").onclick = () => {
        location.href = "/register.html";
    };
};
Enter fullscreen mode Exit fullscreen mode

common.js contains functions for executing GET, POST and DELETE requests:

export default {
    post: async (url, callback, errorCallback, content, token) => {
        try {
            const headers = {
                "Content-Type": "application/json;charset=UTF-8"
            };
            if (token) {
                headers["Authorization"] = `Bearer ${token}`;
            }
            const response = await fetch(url, {
                method: "POST",
                headers,
                body: JSON.stringify(content)
            });
            if (response.ok) {
                const data = await response.text();
                if (callback) {
                    callback(data);
                }
            } else {
                if (errorCallback) {
                    errorCallback(response.status);
                }
            }
        } catch (err) {
            if (errorCallback) {
                errorCallback(err);
            }
        }
    },
    get: async (url, callback, errorCallback, token) => {
        try {
            const headers = {
                "Content-Type": "application/json;charset=UTF-8"
            };
            if (token) {
                headers["Authorization"] = `Bearer ${token}`;
            }
            const response = await fetch(url, {
                method: "GET",
                headers
            });
            if (response.ok) {
                const data = await response.text();

                if (callback) {
                    callback(data);
                }
            } else {
                if (errorCallback) {
                    errorCallback(response.status);
                }
            }
        } catch (err) {
            if (errorCallback) {
                errorCallback(err);
            }
        }
    },
    delete: async (url, callback, errorCallback, token) => {
        try {
            const headers = {
                "Content-Type": "application/json;charset=UTF-8"
            };
            if (token) {
                headers["Authorization"] = `Bearer ${token}`;
            }
            const response = await fetch(url, {
                method: "DELETE",
                headers
            });

            if (response.ok) {
                if (callback) {
                    callback();
                }
            } else {
                if (errorCallback) {
                    errorCallback(response.status);
                }
            }
        } catch (err) {
            if (errorCallback) {
                errorCallback(err);
            }
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

The other pages in the frontend and in the backend are done pretty much the same way.

In the frontend, there are four pages. A login page, a page for registering users, a page for accessing the store, and a page for accessing the cart.

The frontend allows registered users to see the available catalog items, add catalog items to the cart, and remove catalog items from the cart.

Here is a screenshot of the store page in the frontend:

Image

In the backend, there are two pages. A login page and a page for managing the store.

The backend allows admin users to see the available catalog items, create new catalog items, update catalog items, and remove catalog items.

Here is a screenshot of the store page in the backend:

Image

Unit Tests

In this section, we will be unit testing all the microservices using xUnit and Moq.

When unit testing controller logic, only the contents of a single action are tested, not the behavior of its dependencies or of the framework itself.

xUnit simplifies the testing process and allows us to spend more time focusing on writing our tests.

Moq is a popular and friendly mocking framework for .NET. We will be using it in order to mock repositories and middleware services.

To unit test catalog microservice, first a xUnit testing project CatalogMicroservice.UnitTests was created. Then, a unit testing class CatalogControllerTest was created. This class contains unit testing methods of the catalog controller.

A reference of the project CatalogMicroservice was added to CatalogMicroservice.UnitTests project.

Then, Moq was added using Nuget package manager. At this point, we can start focusing on writing our tests.

A reference of CatalogController was added to CatalogControllerTest:

private readonly CatalogController _controller;
Enter fullscreen mode Exit fullscreen mode

Then, in the constructor of our unit test class, a mock repository was added as follows:

public CatalogControllerTest()
{
    var mockRepo = new Mock<ICatalogRepository>();
    mockRepo.Setup(repo => repo.GetCatalogItems()).Returns(_items);
    mockRepo.Setup(repo => repo.GetCatalogItem(It.IsAny<string>()))
        .Returns<string>(id => _items.FirstOrDefault(i => i.Id == id));
    mockRepo.Setup(repo => repo.InsertCatalogItem(It.IsAny<CatalogItem>()))
        .Callback<CatalogItem>(_items.Add);
    mockRepo.Setup(repo => repo.UpdateCatalogItem(It.IsAny<CatalogItem>()))
        .Callback<CatalogItem>(i =>
        {
            var item = _items.FirstOrDefault(catalogItem => catalogItem.Id == i.Id);
            if (item != null)
            {
                item.Name = i.Name;
                item.Description = i.Description;
                item.Price = i.Price;
            }
        });
    mockRepo.Setup(repo => repo.DeleteCatalogItem(It.IsAny<string>()))
        .Callback<string>(id => _items.RemoveAll(i => i.Id == id));
    _controller = new CatalogController(mockRepo.Object);
}
Enter fullscreen mode Exit fullscreen mode

where _items is a list of CatalogItem:

private static readonly string A54Id = "653e4410614d711b7fc953a7";
private static readonly string A14Id = "253e4410614d711b7fc953a7";
private readonly List<CatalogItem> _items = new()
{
    new()
    {
        Id = A54Id,
        Name = "Samsung Galaxy A54 5G",
        Description = "Samsung Galaxy A54 5G mobile phone",
        Price = 500
    },
    new()
    {
        Id = A14Id,
        Name = "Samsung Galaxy A14 5G",
        Description = "Samsung Galaxy A14 5G mobile phone",
        Price = 200
    }
};
Enter fullscreen mode Exit fullscreen mode

Then, here is the test of GET api/catalog:

[Fact]
public void GetCatalogItemsTest()
{
    var okObjectResult = _controller.Get();
    var okResult = Assert.IsType<OkObjectResult>(okObjectResult);
    var items = Assert.IsType<List<CatalogItem>>(okResult.Value);
    Assert.Equal(2, items.Count);
}
Enter fullscreen mode Exit fullscreen mode

Here is the test of GET api/catalog/{id}:

[Fact]
public void GetCatalogItemTest()
{
    var id = A54Id;
    var okObjectResult = _controller.Get(id);
    var okResult = Assert.IsType<OkObjectResult>(okObjectResult);
    var item = Assert.IsType<CatalogItem>(okResult.Value);
    Assert.Equal(id, item.Id);
}
Enter fullscreen mode Exit fullscreen mode

Here is the test of POST api/calatlog:

[Fact]
public void InsertCatalogItemTest()
{
    var createdResponse = _controller.Post(
        new CatalogItem
        {
            Id = "353e4410614d711b7fc953a7",
            Name = "iPhone 15",
            Description = "iPhone 15 mobile phone",
            Price = 1500
        }
    );
    var response = Assert.IsType<CreatedAtActionResult>(createdResponse);
    var item = Assert.IsType<CatalogItem>(response.Value);
    Assert.Equal("iPhone 15", item.Name);
}
Enter fullscreen mode Exit fullscreen mode

Here is the test of PUT api/catalog:

[Fact]
public void UpdateCatalogItemTest()
{
    var id = A54Id;
    var okObjectResult = _controller.Put(
        new CatalogItem
        {
            Id = id,
            Name = "Samsung Galaxy S23 Ultra",
            Description = "Samsung Galaxy S23 Ultra mobile phone",
            Price = 1500
        });
    Assert.IsType<OkResult>(okObjectResult);
    var item = _items.FirstOrDefault(i => i.Id == id);
    Assert.NotNull(item);
    Assert.Equal("Samsung Galaxy S23 Ultra", item.Name);
    okObjectResult = _controller.Put(null);
    Assert.IsType<NoContentResult>(okObjectResult);
}
Enter fullscreen mode Exit fullscreen mode

Here is the test of DELETE api/catalog/{id}:

[Fact]
public void DeleteCatalogItemTest()
{
    var id = A54Id;
    var item = _items.FirstOrDefault(i => i.Id == id);
    Assert.NotNull(item);
    var okObjectResult = _controller.Delete(id);
    Assert.IsType<OkResult>(okObjectResult);
    item = _items.FirstOrDefault(i => i.Id == id);
    Assert.Null(item);
}
Enter fullscreen mode Exit fullscreen mode

Unit tests of cart microservice and identity microservice were written in the same way.

Here are the unit tests of cart microservice:

public class CartControllerTest
{
    private readonly CartController _controller;
    private static readonly string UserId = "653e43b8c76b6b56a720803e";
    private static readonly string A54Id = "653e4410614d711b7fc953a7";
    private static readonly string A14Id = "253e4410614d711b7fc953a7";
    private readonly Dictionary<string, List<CartItem>> _carts = new()
    {
        {
            UserId,
            new()
            {
                new()
                {
                    CatalogItemId = A54Id,
                    Name = "Samsung Galaxy A54 5G",
                    Price = 500,
                    Quantity = 1
                },
                new()
                {
                    CatalogItemId = A14Id,
                    Name = "Samsung Galaxy A14 5G",
                    Price = 200,
                    Quantity = 2
                }
            }
        }
    };

    public CartControllerTest()
    {
        var mockRepo = new Mock<ICartRepository>();
        mockRepo.Setup(repo => repo.GetCartItems(It.IsAny<string>()))
            .Returns<string>(id => _carts[id]);
        mockRepo.Setup(repo => repo.InsertCartItem(It.IsAny<string>(), 
                               It.IsAny<CartItem>()))
            .Callback<string, CartItem>((userId, item) =>
            {
                if (_carts.TryGetValue(userId, out var items))
                {
                    items.Add(item);
                }
                else
                {
                    _carts.Add(userId, new List<CartItem> { item });
                }
            });
        mockRepo.Setup(repo => repo.UpdateCartItem(It.IsAny<string>(), 
                               It.IsAny<CartItem>()))
            .Callback<string, CartItem>((userId, item) =>
            {
                if (_carts.TryGetValue(userId, out var items))
                {
                    var currentItem = items.FirstOrDefault
                        (i => i.CatalogItemId == item.CatalogItemId);
                    if (currentItem != null)
                    {
                        currentItem.Name = item.Name;
                        currentItem.Price = item.Price;
                        currentItem.Quantity = item.Quantity;
                    }
                }
            });
        mockRepo.Setup(repo => repo.UpdateCatalogItem
                 (It.IsAny<string>(), It.IsAny<string>(), It.IsAny<decimal>()))
            .Callback<string, string, decimal>((catalogItemId, name, price) =>
            {
                var cartItems = _carts
                .Values
                .Where(items => items.Any(i => i.CatalogItemId == catalogItemId))
                .SelectMany(items => items)
                .ToList();

                foreach (var cartItem in cartItems)
                {
                    cartItem.Name = name;
                    cartItem.Price = price;
                }
            });
        mockRepo.Setup(repo => repo.DeleteCartItem
                      (It.IsAny<string>(), It.IsAny<string>()))
            .Callback<string, string>((userId, catalogItemId) =>
            {
                if (_carts.TryGetValue(userId, out var items))
                {
                    items.RemoveAll(i => i.CatalogItemId == catalogItemId);
                }
            });
        mockRepo.Setup(repo => repo.DeleteCatalogItem(It.IsAny<string>()))
            .Callback<string>((catalogItemId) =>
            {
                foreach (var cart in _carts)
                {
                    cart.Value.RemoveAll(i => i.CatalogItemId == catalogItemId);
                }
            });
        _controller = new CartController(mockRepo.Object);
    }

    [Fact]
    public void GetCartItemsTest()
    {
        var okObjectResult = _controller.Get(UserId);
        var okResult = Assert.IsType<OkObjectResult>(okObjectResult);
        var items = Assert.IsType<List<CartItem>>(okResult.Value);
        Assert.Equal(2, items.Count);
    }

    [Fact]
    public void InsertCartItemTest()
    {
        var okObjectResult = _controller.Post(
            UserId,
            new CartItem
            {
                CatalogItemId = A54Id,
                Name = "Samsung Galaxy A54 5G",
                Price = 500,
                Quantity = 1
            }
        );
        Assert.IsType<OkResult>(okObjectResult);
        Assert.NotNull(_carts[UserId].FirstOrDefault(i => i.CatalogItemId == A54Id));
    }

    [Fact]
    public void UpdateCartItemTest()
    {
        var catalogItemId = A54Id;
        var okObjectResult = _controller.Put(
            UserId,
            new CartItem
            {
                CatalogItemId = A54Id,
                Name = "Samsung Galaxy A54",
                Price = 550,
                Quantity = 2
            }
        );
        Assert.IsType<OkResult>(okObjectResult);
        var catalogItem = _carts[UserId].FirstOrDefault
                          (i => i.CatalogItemId == catalogItemId);
        Assert.NotNull(catalogItem);
        Assert.Equal("Samsung Galaxy A54", catalogItem.Name);
        Assert.Equal(550, catalogItem.Price);
        Assert.Equal(2, catalogItem.Quantity);
    }

    [Fact]
    public void DeleteCartItemTest()
    {
        var id = A14Id;
        var items = _carts[UserId];
        var item = items.FirstOrDefault(i => i.CatalogItemId == id);
        Assert.NotNull(item);
        var okObjectResult = _controller.Delete(UserId, id);
        Assert.IsType<OkResult>(okObjectResult);
        item = items.FirstOrDefault(i => i.CatalogItemId == id);
        Assert.Null(item);
    }

    [Fact]
    public void UpdateCatalogItemTest()
    {
        var catalogItemId = A54Id;
        var okObjectResult = _controller.Put(
            A54Id,
            "Samsung Galaxy A54",
            550
        );
        Assert.IsType<OkResult>(okObjectResult);
        var catalogItem = _carts[UserId].FirstOrDefault
                         (i => i.CatalogItemId == catalogItemId);
        Assert.NotNull(catalogItem);
        Assert.Equal("Samsung Galaxy A54", catalogItem.Name);
        Assert.Equal(550, catalogItem.Price);
        Assert.Equal(1, catalogItem.Quantity);
    }

    [Fact]
    public void DeleteCatalogItemTest()
    {
        var id = A14Id;
        var items = _carts[UserId];
        var item = items.FirstOrDefault(i => i.CatalogItemId == id);
        Assert.NotNull(item);
        var okObjectResult = _controller.Delete(id);
        Assert.IsType<OkResult>(okObjectResult);
        item = items.FirstOrDefault(i => i.CatalogItemId == id);
        Assert.Null(item);
    }
}
Enter fullscreen mode Exit fullscreen mode

Here are the unit tests of identity microservice:

public class IdentityControllerTest
{
    private readonly IdentityController _controller;
    private static readonly string AdminUserId = "653e4410614d711b7fc951a7";
    private static readonly string FrontendUserId = "653e4410614d711b7fc952a7";
    private static readonly User UnknownUser = new()
    {
        Id = "653e4410614d711b7fc957a7",
        Email = "unknown@store.com",
        Password = "4kg245EBBE+1IF20pKSBafiNhE/+WydWZo41cfThUqh7tz7+n7Yn9w==",
        Salt = "2lApH7EgXLHjYAvlmPIDAaQ5ypyXlH8PBVmOI+0zhMBu5HxZqIH7+w==",
        IsAdmin = false
    };
    private readonly List<User> _users = new()
    {
        new()
        {
            Id = AdminUserId,
            Email = "admin@store.com",
            Password = "Ukg255EBBE+1IF20pKSBafiNhE/+WydWZo41cfThUqh7tz7+n7Yn9w==",
            Salt = "4lApH7EgXLHjYAvlmPIDAaQ5ypyXlH8PBVmOI+0zhMBu5HxZqIH7+w==",
            IsAdmin = true
        },
        new()
        {
            Id = FrontendUserId,
            Email = "jdoe@store.com",
            Password = "Vhq8Klm83fCVILYhCzp2vKUJ/qSB+tmP/a9bD3leUnp1acBjS2I5jg==",
            Salt = "7+UwBowz/iv/sW7q+eYhJSfa6HiMQtJXyHuAShU+c1bUo6QUL4LIPA==",
            IsAdmin = false
        }
    };

    private static IConfiguration InitConfiguration()
    {
        var config = new ConfigurationBuilder()
            .AddJsonFile("appsettings.json")
            .AddEnvironmentVariables()
            .Build();
        return config;
    }

    public IdentityControllerTest()
    {
        var mockRepo = new Mock<IUserRepository>();
        mockRepo.Setup(repo => repo.GetUser(It.IsAny<string>()))
            .Returns<string>(email => _users.FirstOrDefault(u => u.Email == email));
        mockRepo.Setup(repo => repo.InsertUser(It.IsAny<User>()))
            .Callback<User>(_users.Add);
        var configuration = InitConfiguration();
        var jwtSection = configuration.GetSection("jwt");
        var jwtOptions = Options.Create(new JwtOptions
        {
            Secret = jwtSection["secret"],
            ExpiryMinutes = int.Parse(jwtSection["expiryMinutes"] ?? "60")
        });
        _controller = new IdentityController
                      (mockRepo.Object, new JwtBuilder(jwtOptions), new Encryptor());
    }

    [Fact]
    public void LoginTest()
    {
        // User not found
        var notFoundObjectResult = _controller.Login(UnknownUser);
        Assert.IsType<NotFoundObjectResult>(notFoundObjectResult);

        // Backend failure
        var user = new User
        {
            Id = FrontendUserId,
            Email = "jdoe@store.com",
            Password = "aaaaaa",
            IsAdmin = false
        };
        var badRequestObjectResult = _controller.Login(user, "backend");
        Assert.IsType<BadRequestObjectResult>(badRequestObjectResult);

        // Wrong password
        user.Password = "bbbbbb";
        badRequestObjectResult = _controller.Login(user);
        Assert.IsType<BadRequestObjectResult>(badRequestObjectResult);

        // Frontend success
        user.Password = "aaaaaa";
        var okObjectResult = _controller.Login(user);
        var okResult = Assert.IsType<OkObjectResult>(okObjectResult);
        var token = Assert.IsType<string>(okResult.Value);
        Assert.NotEmpty(token);

        // Backend success
        var adminUser = new User
        {
            Id = AdminUserId,
            Email = "admin@store.com",
            Password = "aaaaaa",
            IsAdmin = true
        };
        okObjectResult = _controller.Login(adminUser, "backend");
        okResult = Assert.IsType<OkObjectResult>(okObjectResult);
        token = Assert.IsType<string>(okResult.Value);
        Assert.NotEmpty(token);
    }

    [Fact]
    public void RegisterTest()
    {
        // Failure (user already exists)
        var user = new User
        {
            Id = FrontendUserId,
            Email = "jdoe@store.com",
            Password = "aaaaaa",
            IsAdmin = false
        };
        var badRequestObjectResult = _controller.Register(user);
        Assert.IsType<BadRequestObjectResult>(badRequestObjectResult);

        // Success (new user)
        user = new User
        {
            Id ="145e4410614d711b7fc952a7",
            Email = "ctaylor@store.com",
            Password = "cccccc",
            IsAdmin = false
        };
        var okResult = _controller.Register(user);
        Assert.IsType<OkResult>(okResult);
        Assert.NotNull(_users.FirstOrDefault(u => u.Id == user.Id));
    }

    [Fact]
    public void ValidateTest()
    {
        // User not found
        var notFoundObjectResult = _controller.Validate(UnknownUser.Email, string.Empty);
        Assert.IsType<NotFoundObjectResult>(notFoundObjectResult);

        // Invalid token
        var badRequestObjectResult = _controller.Validate("jdoe@store.com", "zzz");
        Assert.IsType<BadRequestObjectResult>(badRequestObjectResult);

        // Success
        var user = new User
        {
            Id = FrontendUserId,
            Email = "jdoe@store.com",
            Password = "aaaaaa",
            IsAdmin = false
        };
        var okObjectResult = _controller.Login(user);
        var okResult = Assert.IsType<OkObjectResult>(okObjectResult);
        var token = Assert.IsType<string>(okResult.Value);
        Assert.NotEmpty(token);
        okObjectResult = _controller.Validate(user.Email, token);
        okResult = Assert.IsType<OkObjectResult>(okObjectResult);
        var userId = Assert.IsType<string>(okResult.Value);
        Assert.Equal(user.Id, userId);
    }
}
Enter fullscreen mode Exit fullscreen mode

If we run the unit tests, we will notice that they all pass:

Image

You can find unit test results on GitHub Actions.

Monitoring using Health Checks

In this section, we will see how to add health checks to catalog microservice for monitoring purposes.

Health checks are endpoints provided by a service to check whether the service is running properly.

Heath checks are used to monitor services such as:

  • Database (SQL Server, Oracle, MySql, MongoDB, etc.)
  • External API connectivity
  • Disk connectivity (read/write)
  • Cache service (Redis, Memcached, etc.)

If you don't find an implementation that suits you, you can create your own custom implementation.

To add health checks to catalog microservice, the following nuget packages were added:

  • AspNetCore.HealthChecks.MongoDb
  • AspNetCore.HealthChecks.UI
  • AspNetCore.HealthChecks.UI.Client
  • AspNetCore.HealthChecks.UI.InMemory.Storage

AspNetCore.HealthChecks.MongoDb package is used to check the health of MongoDB.

AspNetCore.HealthChecks.UI packages are used to use health check UI that stores and shows the health checks results from the configured HealthChecks uris.

Then, ConfigureServices method in Startup.cs was updated as follows:

services.AddHealthChecks()
    .AddMongoDb(
        mongodbConnectionString: (
            Configuration.GetSection("mongo").Get<MongoOptions>()
            ?? throw new Exception("mongo configuration section not found")
        ).ConnectionString,
        name: "mongo",
        failureStatus: HealthStatus.Unhealthy
    );
services.AddHealthChecksUI().AddInMemoryStorage();
Enter fullscreen mode Exit fullscreen mode

And Configure method in Startup.cs was updated as follows:

app.UseHealthChecks("/healthz", new HealthCheckOptions
{
    Predicate = _ => true,
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

app.UseHealthChecksUI();
Enter fullscreen mode Exit fullscreen mode

Finally, appsettings.json was updated as follows:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "mongo": {
    "connectionString": "mongodb://127.0.0.1:27017",
    "database": "store-catalog"
  },
  "jwt": {
    "secret": "9095a623-a23a-481a-aa0c-e0ad96edc103",
    "expiryMinutes": 60
  },
  "HealthChecksUI": {
    "HealthChecks": [
      {
        "Name": "HTTP-Api-Basic",
        "Uri": "http://localhost:44397/healthz"
      }
    ],
    "EvaluationTimeOnSeconds": 10,
    "MinimumSecondsBetweenFailureNotifications": 60
  }
}
Enter fullscreen mode Exit fullscreen mode

If we run catalog microservice, we will get the following UI when accessing http://localhost:44326/healthchecks-ui:

Image

That's it. Health checks of other microservices and gateways were implemented in the same way.

How to Run the Application

To run the application, open the solution store.sln in Visual Studio 2022 as administrator.

You will need to install MongoDB if it is not installed.

First, right click on the solution, click on properties and select multiple startup projects. Select all the projects as startup projects except Middleware and unit tests projects.

Then, press F5 to run the application.

You can access the frontend from http://localhost:44317/.

You can access the backend from http://localhost:44301/.

To login to the frontend for the first time, just click on Register to create a new user and login.

To login to the backend for the first time, you will need to create an admin user. To do so, open Swagger through http://localhost:44397/ and register or open Postman and execute the following POST request http://localhost:44397/api/identity/register with the following payload:

{
  "email": "admin@store.com",
  "password": "pass",
  "isAdmin": true
}
Enter fullscreen mode Exit fullscreen mode

Finally, you can login to the backend with the admin user you created.

If you want to modify MongoDB connection string, you need to update appsettings.json of microservices and gateways.

Below are all the endpoints:

How to Deploy the Application

You can deploy the application using Docker.

You will need to install Docker it is not installed.

First, copy the source code to a folder on your machine.

Then open a terminal, go to that folder (where store.sln file is located) and run the following command:

docker-compose up
Enter fullscreen mode Exit fullscreen mode

That's it, the application will be deployed and will run.

Then, you can access the frontend from http://:44317/ and the backend from http://:44301/.

Here is a screenshot of the application running on Ubuntu:

Image

For those who want to understand how the deployment is done, here is docker-compose.yml:

version: "3.8"
services:
  mongo:
    image: mongo
    ports:
       - 27017:27017

  catalog:
    build:
      context: .
      dockerfile: src/microservices/CatalogMicroservice/Dockerfile
    depends_on:
      - mongo
    ports:
      - 44326:80

  cart:
    build:
      context: .
      dockerfile: src/microservices/CartMicroservice/Dockerfile
    depends_on:
      - mongo
    ports:
      - 44388:80

  identity:
    build:
      context: .
      dockerfile: src/microservices/IdentityMicroservice/Dockerfile
    depends_on:
      - mongo
    ports:
      - 44397:80

  frontendgw:
    build:
      context: .
      dockerfile: src/gateways/FrontendGateway/Dockerfile
    depends_on:
      - mongo
      - catalog
      - cart
      - identity
    ports:
      - 44300:80

  backendgw:
    build:
      context: .
      dockerfile: src/gateways/BackendGateway/Dockerfile
    depends_on:
      - mongo
      - catalog
      - identity
    ports:
      - 44359:80

  frontend:
    build:
      context: .
      dockerfile: src/uis/Frontend/Dockerfile
    ports:
      - 44317:80

  backend:
    build:
      context: .
      dockerfile: src/uis/Backend/Dockerfile
    ports:
      - 44301:80
Enter fullscreen mode Exit fullscreen mode

Then, appsettings.Production.json was used in microservices and gateways, and ocelot.Production.json was used in gateways.

For example, here is appsettings.Production.json of catalog microservice:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "mongo": {
    "connectionString": "mongodb://mongo",
    "database": "store-catalog"
  },
  "HealthChecksUI": {
    "HealthChecks": [
      {
        "Name": "HTTP-Api-Basic",
        "Uri": "http://catalog/healthz"
      }
    ],
    "EvaluationTimeOnSeconds": 10,
    "MinimumSecondsBetweenFailureNotifications": 60
  }
}
Enter fullscreen mode Exit fullscreen mode

Here is Dockerfile of catalog microservice:

# syntax=docker/dockerfile:1

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["src/microservices/CatalogMicroservice/CatalogMicroservice.csproj", 
      "microservices/CatalogMicroservice/"]
COPY src/middlewares middlewares/
RUN dotnet restore "microservices/CatalogMicroservice/CatalogMicroservice.csproj"

WORKDIR "/src/microservices/CatalogMicroservice"
COPY src/microservices/CatalogMicroservice .
RUN dotnet build "CatalogMicroservice.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "CatalogMicroservice.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:80
EXPOSE 80
EXPOSE 443
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "CatalogMicroservice.dll"]
Enter fullscreen mode Exit fullscreen mode

Multistage build is explained here. It helps make the process of building containers more efficient, and makes containers smaller by allowing them to contain only the bits that your app needs at run time.

Here is ocelot.Production.json of the frontend gateway:

{
  "Routes": [
    {
      "DownstreamPathTemplate": "/api/catalog",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "catalog",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/catalog",
      "UpstreamHttpMethod": [ "GET" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/catalog/{id}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "catalog",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/catalog/{id}",
      "UpstreamHttpMethod": [ "GET" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/cart",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "cart",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/cart",
      "UpstreamHttpMethod": [ "GET" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/cart",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "cart",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/cart",
      "UpstreamHttpMethod": [ "POST" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/cart",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "cart",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/cart",
      "UpstreamHttpMethod": [ "PUT" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/cart",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "cart",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/cart",
      "UpstreamHttpMethod": [ "DELETE" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/identity/login",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "identity",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/identity/login",
      "UpstreamHttpMethod": [ "POST" ]
    },
    {
      "DownstreamPathTemplate": "/api/identity/register",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "identity",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/identity/register",
      "UpstreamHttpMethod": [ "POST" ]
    },
    {
      "DownstreamPathTemplate": "/api/identity/validate",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "identity",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/identity/validate",
      "UpstreamHttpMethod": [ "GET" ]
    }
  ],
  "GlobalConfiguration": {
    "BaseUrl": "http://localhost:44300/"
  }
}
Enter fullscreen mode Exit fullscreen mode

Here is appsettings.Production.json of the frontend gateway:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "jwt": {
    "secret": "9095a623-a23a-481a-aa0c-e0ad96edc103"
  },
  "mongo": {
    "connectionString": "mongodb://mongo"
  },
  "HealthChecksUI": {
    "HealthChecks": [
      {
        "Name": "HTTP-Api-Basic",
        "Uri": "http://frontendgw/healthz"
      }
    ],
    "EvaluationTimeOnSeconds": 10,
    "MinimumSecondsBetweenFailureNotifications": 60
  }
}
Enter fullscreen mode Exit fullscreen mode

And finally, here is Dockerfile of the frontend gateway:

# syntax=docker/dockerfile:1

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["src/gateways/FrontendGateway/FrontendGateway.csproj", "gateways/FrontendGateway/"]
COPY src/middlewares middlewares/
RUN dotnet restore "gateways/FrontendGateway/FrontendGateway.csproj"

WORKDIR "/src/gateways/FrontendGateway"
COPY src/gateways/FrontendGateway .
RUN dotnet build "FrontendGateway.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "FrontendGateway.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:80
EXPOSE 80
EXPOSE 443
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FrontendGateway.dll"]
Enter fullscreen mode Exit fullscreen mode

The configurations of other microservices and the backend gateway are done in pretty much the same way.

That's it! I hope you enjoyed reading this article.

References

Top comments (1)

Collapse
 
dotnetfullstackdev profile image
DotNet Full Stack Dev

@aelassas great article, which covers most of the concepts in Micro Services!
I explored the most familiar Micro Services concepts and tried to explain them in these articles and would appreciate your thoughts on them.