Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up

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

NotificationsYou must be signed in to change notification settings

aelassas/microservices

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

95 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

BuildTest

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

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.

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.
  • Visual Studio 2022 >= 17.8.0
  • .NET 8.0
  • MongoDB
  • Postman
  • C#
  • ASP.NET Core
  • Ocelot
  • Swashbuckle
  • Serilog
  • JWT
  • MongoDB
  • xUnit
  • Moq

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

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.

Let's start with the simplest microservice,CatalogMicroservice.

CatalogMicroservice is responsible for managing the catalog.

Below is the model used byCatalogMicroservice:

publicclassCatalogItem{publicstaticreadonlystringDocumentName=nameof(CatalogItem);[BsonId][BsonRepresentation(BsonType.ObjectId)]publicstring?Id{get;init;}publicrequiredstringName{get;set;}publicstring?Description{get;set;}publicdecimalPrice{get;set;}}

Below is the repository interface:

publicinterfaceICatalogRepository{IList<CatalogItem>GetCatalogItems();CatalogItem?GetCatalogItem(stringcatalogItemId);voidInsertCatalogItem(CatalogItemcatalogItem);voidUpdateCatalogItem(CatalogItemcatalogItem);voidDeleteCatalogItem(stringcatagItemId);}

Below is the repository:

publicclassCatalogRepository(IMongoDatabasedb):ICatalogRepository{privatereadonlyIMongoCollection<CatalogItem>_col=db.GetCollection<CatalogItem>(CatalogItem.DocumentName);publicIList<CatalogItem>GetCatalogItems()=>_col.Find(FilterDefinition<CatalogItem>.Empty).ToList();publicCatalogItemGetCatalogItem(stringcatalogItemId)=>_col.Find(c=>c.Id==catalogItemId).FirstOrDefault();publicvoidInsertCatalogItem(CatalogItemcatalogItem)=>_col.InsertOne(catalogItem);publicvoidUpdateCatalogItem(CatalogItemcatalogItem)=>_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));publicvoidDeleteCatalogItem(stringcatalogItemId)=>_col.DeleteOne(c=>c.Id==catalogItemId);}

Below is the controller:

[Route("api/[controller]")][ApiController]publicclassCatalogController(ICatalogRepositorycatalogRepository):ControllerBase{// GET: api/<CatalogController>[HttpGet]publicIActionResultGet(){varcatalogItems=catalogRepository.GetCatalogItems();returnOk(catalogItems);}// GET api/<CatalogController>/653e4410614d711b7fc953a7[HttpGet("{id}")]publicIActionResultGet(stringid){varcatalogItem=catalogRepository.GetCatalogItem(id);returnOk(catalogItem);}// POST api/<CatalogController>[HttpPost]publicIActionResultPost([FromBody]CatalogItemcatalogItem){catalogRepository.InsertCatalogItem(catalogItem);returnCreatedAtAction(nameof(Get),new{id=catalogItem.Id},catalogItem);}// PUT api/<CatalogController>[HttpPut]publicIActionResultPut([FromBody]CatalogItem?catalogItem){if(catalogItem!=null){catalogRepository.UpdateCatalogItem(catalogItem);returnOk();}returnnewNoContentResult();}// DELETE api/<CatalogController>/653e4410614d711b7fc953a7[HttpDelete("{id}")]publicIActionResultDelete(stringid){catalogRepository.DeleteCatalogItem(id);returnOk();}}

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

// This method gets called by the runtime.// Use this method to add services to the container.publicvoidConfigureServices(IServiceCollectionservices){services.AddControllers();services.AddMongoDb(Configuration);services.AddSingleton<ICatalogRepository>(sp=>newCatalogRepository(sp.GetService<IMongoDatabase>()??thrownewException("IMongoDatabase not found")));services.AddSwaggerGen(c=>{c.SwaggerDoc("v1",newOpenApiInfo{Title="Catalog",Version="v1"});});// ...}

Below isAddMongoDB extension method:

publicstaticvoidAddMongoDb(thisIServiceCollectionservices,IConfigurationconfiguration){services.Configure<MongoOptions>(configuration.GetSection("mongo"));services.AddSingleton(c=>{varoptions=c.GetService<IOptions<MongoOptions>>();returnnewMongoClient(options.Value.ConnectionString);});services.AddSingleton(c=>{varoptions=c.GetService<IOptions<MongoOptions>>();varclient=c.GetService<MongoClient>();returnclient.GetDatabase(options.Value.Database);});}

Below isConfigure method inStartup.cs:

// This method gets called by the runtime.// Use this method to configure the HTTP request pipeline.publicvoidConfigure(IApplicationBuilderapp,IWebHostEnvironmentenv){if(env.IsDevelopment()){app.UseDeveloperExceptionPage();}app.UseSwagger();app.UseSwaggerUI(c=>{c.SwaggerEndpoint("/swagger/v1/swagger.json","Catalog V1");});varoption=newRewriteOptions();option.AddRedirect("^$","swagger");app.UseRewriter(option);// ...app.UseHttpsRedirection();app.UseRouting();app.UseAuthorization();app.UseEndpoints(endpoints=>{endpoints.MapControllers();});}

Below isappsettings.json:

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

API documentation is generated using Swashbuckle. Swagger middleware is configured inStartup.cs, inConfigureServices andConfigure methods inStartup.cs.

If you runCatalogMicroservice project using IISExpress or Docker, you will get the Swagger UI when accessinghttp://localhost:44326/:

Image

Now, let's move on toIdentityMicroservice.

IdentityMicroservice is responsible for authentication and managing users.

Below is the model used byIdentityMicroservice:

publicclassUser{publicstaticreadonlystringDocumentName=nameof(User);[BsonId][BsonRepresentation(BsonType.ObjectId)]publicstring?Id{get;init;}publicrequiredstringEmail{get;init;}publicrequiredstringPassword{get;set;}publicstring?Salt{get;set;}publicboolIsAdmin{get;init;}publicvoidSetPassword(stringpassword,IEncryptorencryptor){Salt=encryptor.GetSalt();Password=encryptor.GetHash(password,Salt);}publicboolValidatePassword(stringpassword,IEncryptorencryptor)=>Password==encryptor.GetHash(password,Salt);}

IEncryptor middleware is used for encrypting passwords.

Below is the repository interface:

publicinterfaceIUserRepository{User?GetUser(stringemail);voidInsertUser(Useruser);}

Below is the repository implementation:

publicclassUserRepository(IMongoDatabasedb):IUserRepository{privatereadonlyIMongoCollection<User>_col=db.GetCollection<User>(User.DocumentName);publicUser?GetUser(stringemail)=>_col.Find(u=>u.Email==email).FirstOrDefault();publicvoidInsertUser(Useruser)=>_col.InsertOne(user);}

Below is the controller:

[Route("api/[controller]")][ApiController]publicclassIdentityController(IUserRepositoryuserRepository,IJwtBuilderjwtBuilder,IEncryptorencryptor):ControllerBase{[HttpPost("login")]publicIActionResultLogin([FromBody]Useruser,[FromQuery(Name="d")]stringdestination="frontend"){varu=userRepository.GetUser(user.Email);if(u==null){returnNotFound("User not found.");}if(destination=="backend"&&!u.IsAdmin){returnBadRequest("Could not authenticate user.");}varisValid=u.ValidatePassword(user.Password,encryptor);if(!isValid){returnBadRequest("Could not authenticate user.");}vartoken=jwtBuilder.GetToken(u.Id);returnOk(token);}[HttpPost("register")]publicIActionResultRegister([FromBody]Useruser){varu=userRepository.GetUser(user.Email);if(u!=null){returnBadRequest("User already exists.");}user.SetPassword(user.Password,encryptor);userRepository.InsertUser(user);returnOk();}[HttpGet("validate")]publicIActionResultValidate([FromQuery(Name="email")]stringemail,[FromQuery(Name="token")]stringtoken){varu=userRepository.GetUser(email);if(u==null){returnNotFound("User not found.");}varuserId=jwtBuilder.ValidateToken(token);if(userId!=u.Id){returnBadRequest("Invalid token.");}returnOk(userId);}}

IUserRepository,IJwtBuilder andIEncryptor middlewares are added using dependency injection inStartup.cs:

// This method gets called by the runtime.// Use this method to add services to the container.publicvoidConfigureServices(IServiceCollectionservices){services.AddControllers();services.AddMongoDb(Configuration);services.AddJwt(Configuration);services.AddTransient<IEncryptor,Encryptor>();services.AddSingleton<IUserRepository>(sp=>newUserRepository(sp.GetService<IMongoDatabase>()??thrownewException("IMongoDatabase not found")));services.AddSwaggerGen(c=>{c.SwaggerDoc("v1",newOpenApiInfo{Title="User",Version="v1"});});// ...}

Below isAddJwt extension method:

publicstaticvoidAddJwt(thisIServiceCollectionservices,IConfigurationconfiguration){varoptions=newJwtOptions();varsection=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=newTokenValidationParameters{ValidateAudience=false,IssuerSigningKey=newSymmetricSecurityKey(Encoding.UTF8.GetBytes(options.Secret))};});}

IJwtBuilder is responsible for creating JWT tokens and validating them:

publicinterfaceIJwtBuilder{stringGetToken(stringuserId);stringValidateToken(stringtoken);}

Below is the implementation ofIJwtBuilder:

publicclassJwtBuilder(IOptions<JwtOptions>options):IJwtBuilder{privatereadonlyJwtOptions_options=options.Value;publicstringGetToken(stringuserId){varsigningKey=newSymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret));varsigningCredentials=newSigningCredentials(signingKey,SecurityAlgorithms.HmacSha256);varclaims=new[]{newClaim("userId",userId)};varexpirationDate=DateTime.Now.AddMinutes(_options.ExpiryMinutes);varjwt=newJwtSecurityToken(claims:claims,signingCredentials:signingCredentials,expires:expirationDate);varencodedJwt=newJwtSecurityTokenHandler().WriteToken(jwt);returnencodedJwt;}publicstringValidateToken(stringtoken){varprincipal=GetPrincipal(token);if(principal==null){returnstring.Empty;}ClaimsIdentityidentity;try{identity=(ClaimsIdentity)principal.Identity;}catch(NullReferenceException){returnstring.Empty;}varuserIdClaim=identity?.FindFirst("userId");if(userIdClaim==null){returnstring.Empty;}varuserId=userIdClaim.Value;returnuserId;}privateClaimsPrincipalGetPrincipal(stringtoken){try{vartokenHandler=newJwtSecurityTokenHandler();varjwtToken=(JwtSecurityToken)tokenHandler.ReadToken(token);if(jwtToken==null){returnnull;}varkey=Encoding.UTF8.GetBytes(_options.Secret);varparameters=newTokenValidationParameters(){RequireExpirationTime=true,ValidateIssuer=false,ValidateAudience=false,IssuerSigningKey=newSymmetricSecurityKey(key)};IdentityModelEventSource.ShowPII=true;ClaimsPrincipalprincipal=tokenHandler.ValidateToken(token,parameters,out_);returnprincipal;}catch(Exception){returnnull;}}}

IEncryptor is simply responsible for encrypting passwords:

publicinterfaceIEncryptor{stringGetSalt();stringGetHash(stringvalue,stringsalt);}

Below is the implementation ofIEncryptor:

publicclassEncryptor:IEncryptor{privateconstintSALT_SIZE=40;privateconstintITERATIONS_COUNT=10000;publicstringGetSalt(){varsaltBytes=newbyte[SALT_SIZE];varrng=RandomNumberGenerator.Create();rng.GetBytes(saltBytes);returnConvert.ToBase64String(saltBytes);}publicstringGetHash(stringvalue,stringsalt){varpbkdf2=newRfc2898DeriveBytes(value,GetBytes(salt),ITERATIONS_COUNT,HashAlgorithmName.SHA256);returnConvert.ToBase64String(pbkdf2.GetBytes(SALT_SIZE));}privatestaticbyte[]GetBytes(stringvalue){varbytes=newbyte[value.Length+sizeof(char)];Buffer.BlockCopy(value.ToCharArray(),0,bytes,0,bytes.Length);returnbytes;}}

Below isConfigure method inStartup.cs:

// This method gets called by the runtime.// Use this method to configure the HTTP request pipeline.publicvoidConfigure(IApplicationBuilderapp,IWebHostEnvironmentenv){if(env.IsDevelopment()){app.UseDeveloperExceptionPage();}app.UseSwagger();app.UseSwaggerUI(c=>{c.SwaggerEndpoint("/swagger/v1/swagger.json","Catalog V1");});varoption=newRewriteOptions();option.AddRedirect("^$","swagger");app.UseRewriter(option);// ...app.UseHttpsRedirection();app.UseRouting();app.UseAuthorization();app.UseEndpoints(endpoints=>{endpoints.MapControllers();});}

Below isappsettings.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}}

Now, let's testIdentityMicroservice.

Open Postman and execute the followingPOST requesthttp://localhost:44397/api/identity/register with the following payload to register a user:

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

Now, execute the followingPOST requesthttp://localhost:44397/api/identity/login with the following payload to create a JWT token:

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

Image

You can then check the generated token onjwt.io:

Image

That's it. You can execute the followingGET requesthttp://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 anObjectId.

If you runIdentityMicroservice project using IISExpress or Docker, you will get the Swagger UI when accessinghttp://localhost:44397/:

Image

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

First, we have to addjwt section inappsettings.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"}}

Then, we have to add JWT configuration inConfigureServices method inStartup.cs:

publicvoidConfigureServices(IServiceCollectionservices){services.AddControllers();services.AddJwtAuthentication(Configuration);// JWT Configuration// ...}

WhereAddJwtAuthentication extension method is implemented as follows:

publicstaticvoidAddJwtAuthentication(thisIServiceCollectionservices,IConfigurationconfiguration){varsection=configuration.GetSection("jwt");varoptions=section.Get<JwtOptions>();varkey=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=newTokenValidationParameters{ValidateIssuerSigningKey=true,IssuerSigningKey=newSymmetricSecurityKey(key),ValidateIssuer=false,ValidateAudience=false};});services.AddAuthorization(x=>{x.DefaultPolicy=newAuthorizationPolicyBuilder().AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme).RequireAuthenticatedUser().Build();});}

JwtMiddleware is responsible for validating JWT token:

publicclassJwtMiddleware(IJwtBuilderjwtBuilder):IMiddleware{publicasyncTaskInvokeAsync(HttpContextcontext,RequestDelegatenext){// Get the token from the Authorization headervarbearer=context.Request.Headers["Authorization"].ToString();vartoken=bearer.Replace("Bearer ",string.Empty);if(!string.IsNullOrEmpty(token)){// Verify the token using the IJwtBuildervaruserId=jwtBuilder.ValidateToken(token);if(ObjectId.TryParse(userId,out_)){// Store the userId in the HttpContext items for later usecontext.Items["userId"]=userId;}else{// If token or userId are invalid, send 401 Unauthorized statuscontext.Response.StatusCode=401;}}// Continue processing the requestawaitnext(context);}}

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

Then, we resigsterJwtMiddleware inConfigure method inStartup.cs:

publicvoidConfigure(IApplicationBuilderapp,IWebHostEnvironmentenv){// ...app.UseMiddleware<JwtMiddleware>();// JWT Middlewareapp.UseAuthentication();app.UseAuthorization();// ...}

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

// GET: api/<CatalogController>[HttpGet][Authorize]publicIActionResultGet(){varcatalogItems=_catalogRepository.GetCatalogItems();returnOk(catalogItems);}// ...

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 updateAddSwaggerGen inConfigureServices inStatup.cs:

publicvoidConfigureServices(IServiceCollectionservices){//...services.AddSwaggerGen(c=>{c.SwaggerDoc("v1",newOpenApiInfo{Title="Catalog",Version="v1"});c.AddSecurityDefinition("Bearer",newOpenApiSecurityScheme{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(newOpenApiSecurityRequirement{{newOpenApiSecurityScheme{Reference=newOpenApiReference{Type=ReferenceType.SecurityScheme,Id="Bearer"}},newstring[]{}}});});//...}

Now if you want to run Postman on catalog or cart microservices, you need to specify theBearer Token inAuthorization tab.

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

CartMicroservice is responsible for managing the cart.

Below are the models used byCartMicroservice:

publicclassCart{publicstaticreadonlystringDocumentName=nameof(Cart);[BsonId][BsonRepresentation(BsonType.ObjectId)]publicstring?Id{get;init;}[BsonRepresentation(BsonType.ObjectId)]publicstring?UserId{get;init;}publicList<CartItem>CartItems{get;init;}=new();}publicclassCartItem{[BsonRepresentation(BsonType.ObjectId)]publicstring?CatalogItemId{get;init;}publicrequiredstringName{get;set;}publicdecimalPrice{get;set;}publicintQuantity{get;set;}}

Below is the repository interface:

publicinterfaceICartRepository{IList<CartItem>GetCartItems(stringuserId);voidInsertCartItem(stringuserId,CartItemcartItem);voidUpdateCartItem(stringuserId,CartItemcartItem);voidDeleteCartItem(stringuserId,stringcartItemId);voidUpdateCatalogItem(stringcatalogItemId,stringname,decimalprice);voidDeleteCatalogItem(stringcatalogItemId);}

Below is the repository:

publicclassCartRepository(IMongoDatabasedb):ICartRepository{privatereadonlyIMongoCollection<Cart>_col=db.GetCollection<Cart>(Cart.DocumentName);publicIList<CartItem>GetCartItems(stringuserId)=>_col.Find(c=>c.UserId==userId).FirstOrDefault()?.CartItems??newList<CartItem>();publicvoidInsertCartItem(stringuserId,CartItemcartItem){varcart=_col.Find(c=>c.UserId==userId).FirstOrDefault();if(cart==null){cart=newCart{UserId=userId,CartItems=newList<CartItem>{cartItem}};_col.InsertOne(cart);}else{varci=cart.CartItems.FirstOrDefault(ci=>ci.CatalogItemId==cartItem.CatalogItemId);if(ci==null){cart.CartItems.Add(cartItem);}else{ci.Quantity++;}varupdate=Builders<Cart>.Update.Set(c=>c.CartItems,cart.CartItems);_col.UpdateOne(c=>c.UserId==userId,update);}}publicvoidUpdateCartItem(stringuserId,CartItemcartItem){varcart=_col.Find(c=>c.UserId==userId).FirstOrDefault();if(cart!=null){cart.CartItems.RemoveAll(ci=>ci.CatalogItemId==cartItem.CatalogItemId);cart.CartItems.Add(cartItem);varupdate=Builders<Cart>.Update.Set(c=>c.CartItems,cart.CartItems);_col.UpdateOne(c=>c.UserId==userId,update);}}publicvoidDeleteCartItem(stringuserId,stringcatalogItemId){varcart=_col.Find(c=>c.UserId==userId).FirstOrDefault();if(cart!=null){cart.CartItems.RemoveAll(ci=>ci.CatalogItemId==catalogItemId);varupdate=Builders<Cart>.Update.Set(c=>c.CartItems,cart.CartItems);_col.UpdateOne(c=>c.UserId==userId,update);}}publicvoidUpdateCatalogItem(stringcatalogItemId,stringname,decimalprice){// Update catalog item in cartsvarcarts=GetCarts(catalogItemId);foreach(varcartincarts){varcartItem=cart.CartItems.FirstOrDefault(i=>i.CatalogItemId==catalogItemId);if(cartItem!=null){cartItem.Name=name;cartItem.Price=price;varupdate=Builders<Cart>.Update.Set(c=>c.CartItems,cart.CartItems);_col.UpdateOne(c=>c.Id==cart.Id,update);}}}publicvoidDeleteCatalogItem(stringcatalogItemId){// Delete catalog item from cartsvarcarts=GetCarts(catalogItemId);foreach(varcartincarts){cart.CartItems.RemoveAll(i=>i.CatalogItemId==catalogItemId);varupdate=Builders<Cart>.Update.Set(c=>c.CartItems,cart.CartItems);_col.UpdateOne(c=>c.Id==cart.Id,update);}}privateIList<Cart>GetCarts(stringcatalogItemId)=>_col.Find(c=>c.CartItems.Any(i=>i.CatalogItemId==catalogItemId)).ToList();}

Below is the controller:

[Route("api/[controller]")][ApiController]publicclassCartController(ICartRepositorycartRepository):ControllerBase{// GET: api/<CartController>[HttpGet][Authorize]publicIActionResultGet([FromQuery(Name="u")]stringuserId){varcartItems=cartRepository.GetCartItems(userId);returnOk(cartItems);}// POST api/<CartController>[HttpPost][Authorize]publicIActionResultPost([FromQuery(Name="u")]stringuserId,[FromBody]CartItemcartItem){cartRepository.InsertCartItem(userId,cartItem);returnOk();}// PUT api/<CartController>[HttpPut][Authorize]publicIActionResultPut([FromQuery(Name="u")]stringuserId,[FromBody]CartItemcartItem){cartRepository.UpdateCartItem(userId,cartItem);returnOk();}// DELETE api/<CartController>[HttpDelete][Authorize]publicIActionResultDelete([FromQuery(Name="u")]stringuserId,[FromQuery(Name="ci")]stringcartItemId){cartRepository.DeleteCartItem(userId,cartItemId);returnOk();}// PUT api/<CartController>/update-catalog-item[HttpPut("update-catalog-item")][Authorize]publicIActionResultPut([FromQuery(Name="ci")]stringcatalogItemId,[FromQuery(Name="n")]stringname,[FromQuery(Name="p")]decimalprice){cartRepository.UpdateCatalogItem(catalogItemId,name,price);returnOk();}// DELETE api/<CartController>/delete-catalog-item[HttpDelete("delete-catalog-item")][Authorize]publicIActionResultDelete([FromQuery(Name="ci")]stringcatalogItemId){cartRepository.DeleteCatalogItem(catalogItemId);returnOk();}}

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

publicvoidConfigureServices(IServiceCollectionservices){services.AddControllers();services.AddJwtAuthentication(Configuration);// JWT Configurationservices.AddMongoDb(Configuration);services.AddSingleton<ICartRepository>(sp=>newCartRepository(sp.GetService<IMongoDatabase>()??thrownewException("IMongoDatabase not found")));// ...}

Configure method inStartup.cs is the same as the one inCatalogMicroservice.

Below isappsettings.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"}}

API documentation is generated using Swashbuckle. Swagger middleware is configured inStartup.cs, inConfigureServices andConfigure methods inStartup.cs.

If you runCartMicroservice project using IISExpress or Docker, you will get the Swagger UI when accessinghttp://localhost:44388/.

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 inProgram.cs as follows:

varbuilder=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();

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

Then, here isStartup.cs:

publicclassStartup(IConfigurationconfiguration){privateIConfigurationConfiguration{get;}=configuration;// This method gets called by the runtime.// Use this method to add services to the container.publicvoidConfigureServices(IServiceCollectionservices){services.AddControllers();services.AddOcelot(Configuration);services.AddJwtAuthentication(Configuration);// JWT Configurationservices.AddCors(options=>{options.AddPolicy("CorsPolicy",                builder=>builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());});services.AddHealthChecks().AddMongoDb(mongodbConnectionString:(Configuration.GetSection("mongo").Get<MongoOptions>()??thrownewException("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.publicasyncvoidConfigure(IApplicationBuilderapp,IWebHostEnvironmentenv){if(env.IsDevelopment()){app.UseDeveloperExceptionPage();}app.UseMiddleware<RequestResponseLogging>();app.UseCors("CorsPolicy");app.UseAuthentication();app.UseHealthChecks("/healthz",newHealthCheckOptions{Predicate= _=>true,ResponseWriter=UIResponseWriter.WriteHealthCheckUIResponse});app.UseHealthChecksUI();varoption=newRewriteOptions();option.AddRedirect("^$","healthchecks-ui");app.UseRewriter(option);awaitapp.UseOcelot();}}

RequestResponseLogging middleware is responsible for logging requests and responses:

publicclassRequestResponseLogging(RequestDelegatenext,ILogger<RequestResponseLogging>logger){publicasyncTaskInvokeAsync(HttpContextcontext){context.Request.EnableBuffering();varbuilder=newStringBuilder();varrequest=awaitFormatRequest(context.Request);builder.Append("Request: ").AppendLine(request);builder.AppendLine("Request headers:");foreach(varheaderincontext.Request.Headers){builder.Append(header.Key).Append(": ").AppendLine(header.Value);}varoriginalBodyStream=context.Response.Body;usingvarresponseBody=newMemoryStream();context.Response.Body=responseBody;awaitnext(context);varresponse=awaitFormatResponse(context.Response);builder.Append("Response: ").AppendLine(response);builder.AppendLine("Response headers: ");foreach(varheaderincontext.Response.Headers){builder.Append(header.Key).Append(": ").AppendLine(header.Value);}logger.LogInformation(builder.ToString());awaitresponseBody.CopyToAsync(originalBodyStream);}privatestaticasyncTask<string>FormatRequest(HttpRequestrequest){usingvarreader=newStreamReader(request.Body,encoding:Encoding.UTF8,detectEncodingFromByteOrderMarks:false,leaveOpen:true);varbody=awaitreader.ReadToEndAsync();varformattedRequest=$"{request.Method}{request.Scheme}://{request.Host}{request.Path}{request.QueryString} {body}";request.Body.Position=0;returnformattedRequest;}privatestaticasyncTask<string>FormatResponse(HttpResponseresponse){response.Body.Seek(0,SeekOrigin.Begin);stringtext=awaitnewStreamReader(response.Body).ReadToEndAsync();response.Body.Seek(0,SeekOrigin.Begin);return$"{response.StatusCode}:{text}";}}

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

Here isocelot.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/"}}

And finally, below isappsettings.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"}}

Now, let's test the frontend gateway.

First, execute the followingPOST requesthttp//localhost:44300/identity/login with the following payload to create a JWT token:

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

We already created that user while testingIdentityMicroservice. If you didn't create that user, you can create one by executing the followingPOST requesthttp://localhost:44300/identity/register with the same payload above.

Image

Then, go to Authorization tab in Postman, selectBearer Token type and copy paste the JWT token inToken field. Then, execute the followingGET request to retrieve the cataloghttp://localhost:44300/catalog:

Image

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

You can check the tokens onjwt.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 inocelot.json file.

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><metacharset="utf-8"/><title>Login</title><linkrel="icon"href="data:,"><linkhref="css/bootstrap.min.css"rel="stylesheet"/><linkhref="css/login.css"rel="stylesheet"/></head><body><divclass="header"></div><divclass="login"><table><tr><td>Email</td><td><inputid="email"type="text"autocomplete="off"class="form-control"/></td></tr><tr><td>Password</td><td><inputid="password"type="password"class="form-control"/></td></tr><tr><td></td><td><inputid="login"type="button"value="Login"class="btn btn-primary"/><inputid="register"type="button"value="Register"class="btn btn-secondary"/></td></tr></table></div><scriptsrc="js/login.js"type="module"></script></body></html>

Here issettings.js:

exportdefault{uri:"http://localhost:44300/"};

And here islogin.js:

importsettingsfrom"./settings.js";importcommonfrom"./common.js";window.onload=()=>{"use strict";localStorage.removeItem("auth");functionlogin(){constuser={"email":document.getElementById("email").value,"password":document.getElementById("password").value};common.post(settings.uri+"identity/login?d=frontend",(token)=>{constauth={"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";};};

common.js contains functions for executingGET,POST andDELETE requests:

exportdefault{post:async(url,callback,errorCallback,content,token)=>{try{constheaders={"Content-Type":"application/json;charset=UTF-8"};if(token){headers["Authorization"]=`Bearer${token}`;}constresponse=awaitfetch(url,{method:"POST",                headers,body:JSON.stringify(content)});if(response.ok){constdata=awaitresponse.text();if(callback){callback(data);}}else{if(errorCallback){errorCallback(response.status);}}}catch(err){if(errorCallback){errorCallback(err);}}},get:async(url,callback,errorCallback,token)=>{try{constheaders={"Content-Type":"application/json;charset=UTF-8"};if(token){headers["Authorization"]=`Bearer${token}`;}constresponse=awaitfetch(url,{method:"GET",                headers});if(response.ok){constdata=awaitresponse.text();if(callback){callback(data);}}else{if(errorCallback){errorCallback(response.status);}}}catch(err){if(errorCallback){errorCallback(err);}}},delete:async(url,callback,errorCallback,token)=>{try{constheaders={"Content-Type":"application/json;charset=UTF-8"};if(token){headers["Authorization"]=`Bearer${token}`;}constresponse=awaitfetch(url,{method:"DELETE",                headers});if(response.ok){if(callback){callback();}}else{if(errorCallback){errorCallback(response.status);}}}catch(err){if(errorCallback){errorCallback(err);}}}};

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

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 projectCatalogMicroservice.UnitTests was created. Then, a unit testing classCatalogControllerTest was created. This class contains unit testing methods of the catalog controller.

A reference of the projectCatalogMicroservice was added toCatalogMicroservice.UnitTests project.

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

A reference ofCatalogController was added toCatalogControllerTest:

privatereadonlyCatalogController_controller;

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

publicCatalogControllerTest(){varmockRepo=newMock<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=>{varitem=_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=newCatalogController(mockRepo.Object);}

where_items is a list ofCatalogItem:

privatestaticreadonlystringA54Id="653e4410614d711b7fc953a7";privatestaticreadonlystringA14Id="253e4410614d711b7fc953a7";privatereadonlyList<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}};

Then, here is the test ofGET api/catalog:

[Fact]publicvoidGetCatalogItemsTest(){varokObjectResult=_controller.Get();varokResult=Assert.IsType<OkObjectResult>(okObjectResult);varitems=Assert.IsType<List<CatalogItem>>(okResult.Value);Assert.Equal(2,items.Count);}

Here is the test ofGET api/catalog/{id}:

[Fact]publicvoidGetCatalogItemTest(){varid=A54Id;varokObjectResult=_controller.Get(id);varokResult=Assert.IsType<OkObjectResult>(okObjectResult);varitem=Assert.IsType<CatalogItem>(okResult.Value);Assert.Equal(id,item.Id);}

Here is the test ofPOST api/calatlog:

[Fact]publicvoidInsertCatalogItemTest(){varcreatedResponse=_controller.Post(newCatalogItem{Id="353e4410614d711b7fc953a7",Name="iPhone 15",Description="iPhone 15 mobile phone",Price=1500});varresponse=Assert.IsType<CreatedAtActionResult>(createdResponse);varitem=Assert.IsType<CatalogItem>(response.Value);Assert.Equal("iPhone 15",item.Name);}

Here is the test ofPUT api/catalog:

[Fact]publicvoidUpdateCatalogItemTest(){varid=A54Id;varokObjectResult=_controller.Put(newCatalogItem{Id=id,Name="Samsung Galaxy S23 Ultra",Description="Samsung Galaxy S23 Ultra mobile phone",Price=1500});Assert.IsType<OkResult>(okObjectResult);varitem=_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);}

Here is the test ofDELETE api/catalog/{id}:

[Fact]publicvoidDeleteCatalogItemTest(){varid=A54Id;varitem=_items.FirstOrDefault(i=>i.Id==id);Assert.NotNull(item);varokObjectResult=_controller.Delete(id);Assert.IsType<OkResult>(okObjectResult);item=_items.FirstOrDefault(i=>i.Id==id);Assert.Null(item);}

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

Here are the unit tests of cart microservice:

publicclassCartControllerTest{privatereadonlyCartController_controller;privatestaticreadonlystringUserId="653e43b8c76b6b56a720803e";privatestaticreadonlystringA54Id="653e4410614d711b7fc953a7";privatestaticreadonlystringA14Id="253e4410614d711b7fc953a7";privatereadonlyDictionary<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}}}};publicCartControllerTest(){varmockRepo=newMock<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,outvaritems)){items.Add(item);}else{_carts.Add(userId,newList<CartItem>{item});}});mockRepo.Setup(repo=>repo.UpdateCartItem(It.IsAny<string>(),It.IsAny<CartItem>())).Callback<string,CartItem>((userId,item)=>{if(_carts.TryGetValue(userId,outvaritems)){varcurrentItem=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)=>{varcartItems=_carts.Values.Where(items=>items.Any(i=>i.CatalogItemId==catalogItemId)).SelectMany(items=>items).ToList();foreach(varcartItemincartItems){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,outvaritems)){items.RemoveAll(i=>i.CatalogItemId==catalogItemId);}});mockRepo.Setup(repo=>repo.DeleteCatalogItem(It.IsAny<string>())).Callback<string>((catalogItemId)=>{foreach(varcartin_carts){cart.Value.RemoveAll(i=>i.CatalogItemId==catalogItemId);}});_controller=newCartController(mockRepo.Object);}[Fact]publicvoidGetCartItemsTest(){varokObjectResult=_controller.Get(UserId);varokResult=Assert.IsType<OkObjectResult>(okObjectResult);varitems=Assert.IsType<List<CartItem>>(okResult.Value);Assert.Equal(2,items.Count);}[Fact]publicvoidInsertCartItemTest(){varokObjectResult=_controller.Post(UserId,newCartItem{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]publicvoidUpdateCartItemTest(){varcatalogItemId=A54Id;varokObjectResult=_controller.Put(UserId,newCartItem{CatalogItemId=A54Id,Name="Samsung Galaxy A54",Price=550,Quantity=2});Assert.IsType<OkResult>(okObjectResult);varcatalogItem=_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]publicvoidDeleteCartItemTest(){varid=A14Id;varitems=_carts[UserId];varitem=items.FirstOrDefault(i=>i.CatalogItemId==id);Assert.NotNull(item);varokObjectResult=_controller.Delete(UserId,id);Assert.IsType<OkResult>(okObjectResult);item=items.FirstOrDefault(i=>i.CatalogItemId==id);Assert.Null(item);}[Fact]publicvoidUpdateCatalogItemTest(){varcatalogItemId=A54Id;varokObjectResult=_controller.Put(A54Id,"Samsung Galaxy A54",550);Assert.IsType<OkResult>(okObjectResult);varcatalogItem=_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]publicvoidDeleteCatalogItemTest(){varid=A14Id;varitems=_carts[UserId];varitem=items.FirstOrDefault(i=>i.CatalogItemId==id);Assert.NotNull(item);varokObjectResult=_controller.Delete(id);Assert.IsType<OkResult>(okObjectResult);item=items.FirstOrDefault(i=>i.CatalogItemId==id);Assert.Null(item);}}

Here are the unit tests of identity microservice:

publicclassIdentityControllerTest{privatereadonlyIdentityController_controller;privatestaticreadonlystringAdminUserId="653e4410614d711b7fc951a7";privatestaticreadonlystringFrontendUserId="653e4410614d711b7fc952a7";privatestaticreadonlyUserUnknownUser=new(){Id="653e4410614d711b7fc957a7",Email="unknown@store.com",Password="4kg245EBBE+1IF20pKSBafiNhE/+WydWZo41cfThUqh7tz7+n7Yn9w==",Salt="2lApH7EgXLHjYAvlmPIDAaQ5ypyXlH8PBVmOI+0zhMBu5HxZqIH7+w==",IsAdmin=false};privatereadonlyList<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}};privatestaticIConfigurationInitConfiguration(){varconfig=newConfigurationBuilder().AddJsonFile("appsettings.json").AddEnvironmentVariables().Build();returnconfig;}publicIdentityControllerTest(){varmockRepo=newMock<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);varconfiguration=InitConfiguration();varjwtSection=configuration.GetSection("jwt");varjwtOptions=Options.Create(newJwtOptions{Secret=jwtSection["secret"],ExpiryMinutes=int.Parse(jwtSection["expiryMinutes"]??"60")});_controller=newIdentityController(mockRepo.Object,newJwtBuilder(jwtOptions),newEncryptor());}[Fact]publicvoidLoginTest(){// User not foundvarnotFoundObjectResult=_controller.Login(UnknownUser);Assert.IsType<NotFoundObjectResult>(notFoundObjectResult);// Backend failurevaruser=newUser{Id=FrontendUserId,Email="jdoe@store.com",Password="aaaaaa",IsAdmin=false};varbadRequestObjectResult=_controller.Login(user,"backend");Assert.IsType<BadRequestObjectResult>(badRequestObjectResult);// Wrong passworduser.Password="bbbbbb";badRequestObjectResult=_controller.Login(user);Assert.IsType<BadRequestObjectResult>(badRequestObjectResult);// Frontend successuser.Password="aaaaaa";varokObjectResult=_controller.Login(user);varokResult=Assert.IsType<OkObjectResult>(okObjectResult);vartoken=Assert.IsType<string>(okResult.Value);Assert.NotEmpty(token);// Backend successvaradminUser=newUser{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]publicvoidRegisterTest(){// Failure (user already exists)varuser=newUser{Id=FrontendUserId,Email="jdoe@store.com",Password="aaaaaa",IsAdmin=false};varbadRequestObjectResult=_controller.Register(user);Assert.IsType<BadRequestObjectResult>(badRequestObjectResult);// Success (new user)user=newUser{Id="145e4410614d711b7fc952a7",Email="ctaylor@store.com",Password="cccccc",IsAdmin=false};varokResult=_controller.Register(user);Assert.IsType<OkResult>(okResult);Assert.NotNull(_users.FirstOrDefault(u=>u.Id==user.Id));}[Fact]publicvoidValidateTest(){// User not foundvarnotFoundObjectResult=_controller.Validate(UnknownUser.Email,string.Empty);Assert.IsType<NotFoundObjectResult>(notFoundObjectResult);// Invalid tokenvarbadRequestObjectResult=_controller.Validate("jdoe@store.com","zzz");Assert.IsType<BadRequestObjectResult>(badRequestObjectResult);// Successvaruser=newUser{Id=FrontendUserId,Email="jdoe@store.com",Password="aaaaaa",IsAdmin=false};varokObjectResult=_controller.Login(user);varokResult=Assert.IsType<OkObjectResult>(okObjectResult);vartoken=Assert.IsType<string>(okResult.Value);Assert.NotEmpty(token);okObjectResult=_controller.Validate(user.Email,token);okResult=Assert.IsType<OkObjectResult>(okObjectResult);varuserId=Assert.IsType<string>(okResult.Value);Assert.Equal(user.Id,userId);}}

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

Image

You can find unit test results onGitHub Actions.

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 configuredHealthChecks uris.

Then,ConfigureServices method inStartup.cs was updated as follows:

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

AndConfigure method inStartup.cs was updated as follows:

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

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}}

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

Image

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

To run the application, open the solutionstore.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, pressF5 to run the application.

You can access the frontend fromhttp://localhost:44317/.

You can access the backend fromhttp://localhost:44301/.

To login to the frontend for the first time, just click onRegister 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 throughhttp://localhost:44397/ and register or open Postman and execute the followingPOST requesthttp://localhost:44397/api/identity/register with the following payload:

{"email":"admin@store.com","password":"pass","isAdmin":true}

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

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

Below are all the endpoints:

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 (wherestore.sln file is located) and run the following command:

docker-compose up

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

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

Here is a screenshot of the application running on Ubuntu:

Image

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

version:"3.8"services:mongo:image:mongoports:       -27017:27017catalog:build:context:.dockerfile:src/microservices/CatalogMicroservice/Dockerfiledepends_on:      -mongoports:      -44326:80cart:build:context:.dockerfile:src/microservices/CartMicroservice/Dockerfiledepends_on:      -mongoports:      -44388:80identity:build:context:.dockerfile:src/microservices/IdentityMicroservice/Dockerfiledepends_on:      -mongoports:      -44397:80frontendgw:build:context:.dockerfile:src/gateways/FrontendGateway/Dockerfiledepends_on:      -mongo      -catalog      -cart      -identityports:      -44300:80backendgw:build:context:.dockerfile:src/gateways/BackendGateway/Dockerfiledepends_on:      -mongo      -catalog      -identityports:      -44359:80frontend:build:context:.dockerfile:src/uis/Frontend/Dockerfileports:      -44317:80backend:build:context:.dockerfile:src/uis/Backend/Dockerfileports:      -44301:80

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

For example, here isappsettings.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}}

Here isDockerfile of catalog microservice:

# syntax=docker/dockerfile:1FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS baseWORKDIR /appFROM mcr.microsoft.com/dotnet/sdk:8.0 AS buildWORKDIR /srcCOPY ["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/buildFROM build AS publishRUN dotnet publish"CatalogMicroservice.csproj" -c Release -o /app/publishFROM base AS finalWORKDIR /appENV ASPNETCORE_URLS=http://+:80EXPOSE 80EXPOSE 443COPY --from=publish /app/publish.ENTRYPOINT ["dotnet","CatalogMicroservice.dll"]

Multistage build is explainedhere. 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 isocelot.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/"}}

Here isappsettings.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}}

And finally, here isDockerfile of the frontend gateway:

# syntax=docker/dockerfile:1FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS baseWORKDIR /appFROM mcr.microsoft.com/dotnet/sdk:8.0 AS buildWORKDIR /srcCOPY ["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/buildFROM build AS publishRUN dotnet publish"FrontendGateway.csproj" -c Release -o /app/publishFROM base AS finalWORKDIR /appENV ASPNETCORE_URLS=http://+:80EXPOSE 80EXPOSE 443COPY --from=publish /app/publish.ENTRYPOINT ["dotnet","FrontendGateway.dll"]

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.


[8]ページ先頭

©2009-2025 Movatter.jp