Are you ready to build a Forum-Based Website? This is your chance! Let's go! We will start from User Service and Auth Service which is quite a tight coupling.
Design
Database
We will only have User data to be stored in MongoDB.
Architecture That Need to Consider
We will need Auth Service for Update/Delete User. We use Bearer Token and verify it.
User Service Base
Install Dependencies
Please make sure we've already in the app/user-service directory.
Install MongoDB.Driver. We will use this dependency to connect to MongoDB. Using this command to install: dotnet add UserService package MongoDB.Driver --version 2.16.1.
Install Isopoh.Cryptography.Argon2. We will use this to hash the user password. Using this command to install: dotnet add UserService package Isopoh.Cryptography.Argon2 --version 1.1.12
Install Redis.OM. We will use this to connect Redis Stack. dotnet add UserService package Redis.OM --version 0.1.9
namespaceUserService.Repository;usingSystem.Text.Json;usingIsopoh.Cryptography.Argon2;usingMicrosoft.Extensions.Options;usingMongoDB.Driver;usingUserService.Model;publicclassUserRepository:IUserRepository{privatereadonlyIMongoCollection<Users>_usersCollection;privatereadonlyILogger<UserRepository>_logger;publicUserRepository(IOptions<ForumApiDatabaseSettings>forumApiDatabaseSettings,ILogger<UserRepository>logger){_logger=logger;varmongoClient=newMongoClient(forumApiDatabaseSettings.Value.ConnectionString);varmongoDatabase=mongoClient.GetDatabase(forumApiDatabaseSettings.Value.DatabaseName);_usersCollection=mongoDatabase.GetCollection<Users>(forumApiDatabaseSettings.Value.UsersCollectionName);}publicasyncTask<List<Users>>GetUsers(){returnawait_usersCollection.Find(_=>true).ToListAsync();}publicasyncTask<Users?>GetUserById(Guidid){returnawait_usersCollection.Find(x=>x.Id==id).FirstOrDefaultAsync();}publicasyncTask<Users?>GetUserByEmail(stringemail){returnawait_usersCollection.Find(x=>x.Email==email).FirstOrDefaultAsync();}publicasyncTask<Users?>NewUser(Usersuser){if(user.Password==null){_logger.LogDebug("Didn't provide user Password when Create User. Data: {}",JsonSerializer.Serialize(user));returnnull;}varhashPassword=Argon2.Hash(user.Password);user.Password=hashPassword;await_usersCollection.InsertOneAsync(user);returnuser;}publicasyncTask<Users>UpdateUser(Guidid,Usersuser){user.Id=id;await_usersCollection.ReplaceOneAsync(x=>x.Id==id,user,newReplaceOptions(){IsUpsert=false,});returnuser;}publicasyncTask<Users?>UpdateUserPassword(Guidid,Usersuser){if(user.Password==null){returnnull;}user.Id=id;varhashPassword=Argon2.Hash(user.Password);user.Password=hashPassword;await_usersCollection.ReplaceOneAsync(x=>x.Id==id,user,newReplaceOptions(){IsUpsert=false,});returnuser;}publicasyncTaskDeleteUser(Guidid){await_usersCollection.DeleteOneAsync(x=>x.Id==id);}}
c. Add IUserRepository.cs and UserRepository.cs to DI (Dependency Injection) in Program.cs.
We will continue the function of User Service. Our function will write/update the cache after creating/updating the user. The read function will look at the cache first, after that fall back to MongoDB.
namespaceUserService.Service;usingSystem;usingSystem.Text.Json;usingSystem.Threading.Tasks;usingRedis.OM;usingRedis.OM.Searching;usingUserService.Model;usingUserService.Repository;publicclassUserServices:IUserServices{privatereadonlyIUserRepository_userRepository;privatereadonlyRedisCollection<Users>_userCache;privatereadonlyRedisConnectionProvider_provider;privatereadonlyILogger<UserServices>_logger;publicUserServices(IUserRepositoryuserRepository,RedisConnectionProviderprovider,ILogger<UserServices>logger){_logger=logger;_userRepository=userRepository;_provider=provider;_userCache=(RedisCollection<Users>)provider.RedisCollection<Users>();}// another code will be here}
Note: We will consider Auth Service connection using API call after we've done with Auth Service.
Read Function
We will have some functions. There are Read All, Read By Id, Read By Email.
For Read All users data, we will directly take from DynamoDB. Add this to UserService/Service/UserServices.cs.
publicasyncTask<IResult>NewUser(Usersuser){if(user.Email==null){_logger.LogDebug("User Didn't Provide Email. Data: {}",JsonSerializer.Serialize(user));returnResults.BadRequest(new{Message="Please Provide Email"});}varexisting=await_userRepository.GetUserByEmail(user.Email);if(existing!=null){_logger.LogDebug("Found Existing Email. Data: {}",JsonSerializer.Serialize(user));returnResults.BadRequest(new{Message="Users Exists"});}varcreatedUser=await_userRepository.NewUser(user);if(createdUser==null){_logger.LogDebug("Failed To Create User. Data: {}",JsonSerializer.Serialize(user));returnResults.BadRequest(new{Message="Failed When Create User"});}await_userCache.InsertAsync(createdUser);returnResults.Json(new{Message="Created",User=createdUser});}
Update Function
For update, we will separate to update common details of the User and update the password.
publicasyncTask<IResult>UpdateUser(Guidid,UserUpdateuser,HttpRequestrequest){// we will uncomment this later// var verifyResult = await verifyUserAccess(request, id);// if (verifyResult != null)// {// return verifyResult;// }varexisting=await_userRepository.GetUserById(user.Id);if(existing==null){returnResults.NotFound(new{Message="User Not Found"});}existing.Name=user.Name;varupdatedUser=await_userRepository.UpdateUser(id,existing);varexistingCache=await_userCache.Where(x=>x.Id==id).FirstOrDefaultAsync();if(existingCache==null){await_userCache.InsertAsync(updatedUser);}else{existingCache.Name=updatedUser.Name;await_userCache.Update(existingCache);}returnResults.Json(new{Message="Updated",User=existing});}publicasyncTask<IResult>UpdateUserPassword(Guidid,UserPassworduser,HttpRequestrequest){// we will uncomment this later// var verifyResult = await verifyUserAccess(request, id);// if (verifyResult != null)// {// return verifyResult;// }varexisting=await_userRepository.GetUserById(user.Id);if(existing==null){returnResults.NotFound(new{Message="User Not Found"});}existing.Password=user.Password;varupdatedUser=await_userRepository.UpdateUserPassword(id,existing);if(updatedUser==null){returnResults.BadRequest(new{Message="Failed to Update Password"});}varexistingCache=await_userCache.Where(x=>x.Id==id).FirstOrDefaultAsync();if(existingCache==null){await_userCache.InsertAsync(updatedUser);}else{existingCache.Password=updatedUser.Password;await_userCache.Update(existingCache);}returnResults.Json(new{Message="Updated",User=updatedUser});}
Delete User
publicasyncTask<IResult>DeleteUser(Guidid,HttpRequestrequest){// we will uncomment this later// var verifyResult = await verifyUserAccess(request, id);// if (verifyResult != null)// {// return verifyResult;// }varexisting=await_userRepository.GetUserById(id);if(existing==null){returnResults.NotFound(new{Message="User Not Found"});}await_userRepository.DeleteUser(id);await_userCache.Delete(existing);returnResults.Json(new{Message="Deleted"});}
Update Minimal API Route
Next, we will update our Program.cs to have API with those functions.
Prepare IndexCreationService. Create the file UserService/HostedService/IndexCreationService.
namespaceUserService.HostedServices;usingMicrosoft.Extensions.Logging;usingRedis.OM;usingUserService.Model;publicclassIndexCreationService:IHostedService{privatereadonlyRedisConnectionProvider_provider;privatereadonlyILogger<IndexCreationService>_logger;publicIndexCreationService(RedisConnectionProviderprovider,ILogger<IndexCreationService>logger){_provider=provider;_logger=logger;}publicasyncTaskStartAsync(CancellationTokencancellationToken){_logger.LogDebug("Create Index {}",typeof(Users));varresult=await_provider.Connection.CreateIndexAsync(typeof(Users));_logger.LogDebug("Create Index {} Result: {}",typeof(Users),result);}publicTaskStopAsync(CancellationTokencancellationToken){returnTask.CompletedTask;}}
usingAutoMapper;usingMicrosoft.AspNetCore.Mvc;usingRedis.OM;usingUserService.HostedServices;usingUserService.Model;usingUserService.Repository;usingUserService.Service;varbuilder=WebApplication.CreateBuilder(args);// ... other codes// Add Connection to Redisbuilder.Services.AddSingleton(newRedisConnectionProvider(builder.Configuration["RedisConnectionString"]));// Add User Services to DIbuilder.Services.AddScoped<IUserServices,UserServices>();// Add Index Creatorbuilder.Services.AddHostedService<IndexCreationService>();// add Automapperbuilder.Services.AddAutoMapper(typeof(UserProfile));// ... other codes// API Mapperapp.MapGet("/users",async([FromServices]IUserServicesuserServices)=>{returnawaituserServices.GetUsers();}).WithName("GetUsers");app.MapGet("/users/{id}",async([FromServices]IUserServicesuserServices,Guidid)=>{returnawaituserServices.GetUserById(id);}).WithName("GetUserById");app.MapGet("/userByEmail/{email}",async([FromServices]IUserServicesuserServices,stringemail)=>{returnawaituserServices.GetUserByEmail(email);}).WithName("GetUserByEmail");app.MapPost("/users",async([FromServices]IUserServicesuserServices,[FromServices]IMappermapper,[FromBody]UserCreationuserCreation)=>{varuser=mapper.Map<Users>(userCreation);returnawaituserServices.NewUser(user);}).WithName("CreateUser");app.MapPut("/users",async([FromServices]IUserServicesuserServices,[FromBody]UserUpdateuser,HttpRequestreq)=>{returnawaituserServices.UpdateUser(user.Id,user,req);}).WithName("UpdateUser");app.MapPut("/users/password",async([FromServices]IUserServicesuserServices,[FromBody]UserPassworduser,HttpRequestreq)=>{returnawaituserServices.UpdateUserPassword(user.Id,user,req);}).WithName("UpdateUserPassword");app.MapDelete("/users",async([FromServices]IUserServicesuserServices,[FromBody]ByIddata,HttpRequestreq)=>{returnawaituserServices.DeleteUser(data.Id,req);}).WithName("DeleteUser");
The User Service ready! Yey! You may try to run the project and call the API using curl, Postman, or other tools. You may check the final result here.
Auth Service
Install Dependencies
Install mongodb, redis-om, argon2, jsonwebtoken, and winston.
importloggerfrom"./logger";// other codesapp.use((req,res,next)=>{constrequestMeta={body:req.body,headers:req.headers,ip:req.ip,method:req.method,url:req.url,hostname:req.hostname,query:req.query,params:req.params,};logger.info("Getting Request",requestMeta);next();});
import{getTokenRepository}from"./entities/token";// other codesasyncfunctioninitDB(){consttokenRepository=awaitgetTokenRepository();awaittokenRepository.createIndex();}// other codesapp.listen(port,()=>{initDB().then(()=>{logger.info(`NODE_ENV: ${process.env.NODE_ENV}`);logger.info(`Server listen on port ${port}`);});});
import{Router}from"express";importargon2from"argon2";import{JwtPayload}from"jsonwebtoken";import{getTokenRepository}from"../entities/token";import{generateToken,verifyToken}from"../services/token";import{getUser}from"../repositories/user";importconstantsfrom"../constants";importloggerfrom"../logger";constfailedLoginMessage={message:"Failed to Login",};constfailedLogoutMessage={message:"Failed to Logout",};constcommonFailed={message:"Failed",};typeSuccessLogin={message:string;accessToken?:string;refreshToken?:string;};exportconstrouter=Router();router.post("/login",async (req,res)=>{constemail=req.body.email;constpassword=req.body.password;if (!email||!password){res.statusCode=401;res.json(failedLoginMessage);return;}constexistingUser=awaitgetUser(email);if (existingUser==null){res.statusCode=401;res.json(failedLoginMessage);return;}constexistingPass=existingUser.Password;try{constverify=awaitargon2.verify(existingPass,password);if (!verify){res.statusCode=401;res.json(failedLoginMessage);return;}}catch (err){logger.error("Error when verify token",err);res.statusCode=401;res.json(failedLoginMessage);return;}constuserID=existingUser._id.toString("hex");constpayload={id:userID,name:existingUser.Name,email:existingUser.Email,};constaccessToken=generateToken(payload,"ACCESS_TOKEN");constrefreshToken=generateToken(payload,"REFRESH_TOKEN");consttokenRepository=awaitgetTokenRepository();constcreatedToken=awaittokenRepository.createAndSave({token:refreshToken,});awaittokenRepository.expire(createdToken.entityId,constants.defaultExpiredSecond);constsuccessMessage:SuccessLogin={message:"Success",};successMessage["accessToken"]=accessToken;successMessage["refreshToken"]=refreshToken;res.json(successMessage);});// other codes
Setup auth routes in src/index.ts.
import{routerasauthRouter}from"./routes/auth";// other codesapp.use("/auth",authRouter);
Create UserService/Service/AuthService.cs. We will call the AuthService using HttpClient.
usingMicrosoft.Extensions.Options;usingNewtonsoft.Json;usingUserService.Model;namespaceUserService.Service;publicclassAuthService:IAuthService{privatereadonlyHttpClient_httpClient;privatereadonlystring_authVerify;privatereadonlyILogger<AuthService>_logger;publicAuthService(IHttpClientFactoryhttpClientFactory,IOptions<AuthServiceSettings>authServiceSetting,ILogger<AuthService>logger){_httpClient=httpClientFactory.CreateClient();_authVerify=authServiceSetting.Value.AuthServiceVerify;_logger=logger;}publicasyncTask<(bool,Guid)>Verify(stringbearerToken){if(string.IsNullOrEmpty(bearerToken)){_logger.LogDebug("Getting empty bearer.");return(false,Guid.Empty);}varsplitted=bearerToken.Split(' ');if(splitted.Length!=2){_logger.LogDebug("The Bearer Not Valid.");return(false,Guid.Empty);}varjsonBody=JsonContent.Create(new{token=splitted[1]});varresponse=await_httpClient.PostAsync(_authVerify,jsonBody);if(!response.IsSuccessStatusCode){_logger.LogDebug("Getting non 200 response from Auth Service. Response: {}",response.StatusCode);return(false,Guid.Empty);}GuiduserId=Guid.Empty;if(response.Contentisobject&&response.Content.Headers.ContentType!=null&&response.Content.Headers.ContentType.MediaType=="application/json"){varcontentStream=awaitresponse.Content.ReadAsStreamAsync();usingvarstreamReader=newStreamReader(contentStream);usingvarjsonReader=newJsonTextReader(streamReader);JsonSerializerserializer=newJsonSerializer();try{varresponseData=serializer.Deserialize<VerifyId>(jsonReader);if(responseData!=null){varbyteArray=Utils.StringToByteArrayFastest(responseData.Id);userId=newGuid(byteArray);}}catch(JsonReaderException){_logger.LogDebug("Invalid JSON from Auth Service.");}}return(true,userId);}privateclassVerifyId{publicstringId{get;set;}=null!;}}
Setup Program.cs to have HttpClient DI and AuthService in DI.
Update UserServices.cs. Don't forget to uncomment some codes from the previous step when we create the User Service.
publicclassUserServices:IUserServices{privatereadonlyIUserRepository_userRepository;privatereadonlyIAuthService_authService;privatereadonlyRedisCollection<Users>_userCache;privatereadonlyRedisConnectionProvider_provider;privatereadonlyILogger<UserServices>_logger;publicUserServices(IUserRepositoryuserRepository,IAuthServiceauthService,RedisConnectionProviderprovider,ILogger<UserServices>logger){_logger=logger;_userRepository=userRepository;_authService=authService;_provider=provider;_userCache=(RedisCollection<Users>)provider.RedisCollection<Users>();}// other codesprivateasyncTask<IResult?>verifyUserAccess(HttpRequestrequest,GuiduserId){varbearerToken=request.Headers.Authorization.ToString();var(success,id)=await_authService.Verify(bearerToken);if(!success){returnResults.Unauthorized();}if(id!=userId){returnResults.Unauthorized();}returnnull;}}
Finally!
We've learned to use Redis as Cache Data of User Data and also storing Jwt Token data. If you want to try your APIs, you may use this Postman Collection. You may compare your code with this. Other files that are different are optional to improve access to the codes.
We can easily implement RedisJSON using Redis-OM. For example User data, we can implement to store User data to RedisJSON using [Document(StorageType = StorageType.Json, Prefixes = new[] { "Users" }, IndexName = "users-idx")]. It's also easy for Redis-OM for Node.
docker-compose.yml : Containerize MongoDB & Redis. Will help for development.
Microservices Development
You will need to copy or modify docker-compose.yml to ignore the deployment of microservices.
Run Redis & MongoDB using docker compose up -d.
Go to the microservice you want to update and read the README.md of each directory to understand how to run them.
Development
Build Images of Microservices: docker compose build
Run all: docker compose up -d
Software Architecture
License
MIT
MIT License
Copyright (c) 2022 Bervianto Leo Pratama's Personal Projects
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute,
This article used Redis.OM version 0.1.9, there is a breaking change when using 0.2.0. If you wish to use 0.2.0, you can see this PR (Pull Request) to see the changes. I will use 0.2.0 for Thread Service so that you can know the difference.