commit d568ec697046f13f1cd3de82305b4b50131a0644 Author: HiveBeats Date: Sun Jul 14 19:45:34 2024 +0700 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/CodeReviewApp.sln b/CodeReviewApp.sln new file mode 100644 index 0000000..6c9d79d --- /dev/null +++ b/CodeReviewApp.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeReviewApp", "CodeReviewApp\CodeReviewApp.csproj", "{5DD91EDA-2CD0-494E-8EBA-2EBA26A636CC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DAL", "DAL\DAL.csproj", "{1FE01AB7-A762-45A7-8B49-49C821DB5D99}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "Domain\Domain.csproj", "{B9EB6FA6-7E87-4F6A-9B04-944DB2030D6D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5DD91EDA-2CD0-494E-8EBA-2EBA26A636CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DD91EDA-2CD0-494E-8EBA-2EBA26A636CC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DD91EDA-2CD0-494E-8EBA-2EBA26A636CC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DD91EDA-2CD0-494E-8EBA-2EBA26A636CC}.Release|Any CPU.Build.0 = Release|Any CPU + {1FE01AB7-A762-45A7-8B49-49C821DB5D99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1FE01AB7-A762-45A7-8B49-49C821DB5D99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1FE01AB7-A762-45A7-8B49-49C821DB5D99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1FE01AB7-A762-45A7-8B49-49C821DB5D99}.Release|Any CPU.Build.0 = Release|Any CPU + {B9EB6FA6-7E87-4F6A-9B04-944DB2030D6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9EB6FA6-7E87-4F6A-9B04-944DB2030D6D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9EB6FA6-7E87-4F6A-9B04-944DB2030D6D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9EB6FA6-7E87-4F6A-9B04-944DB2030D6D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/CodeReviewApp/CodeReviewApp.csproj b/CodeReviewApp/CodeReviewApp.csproj new file mode 100644 index 0000000..a8550d1 --- /dev/null +++ b/CodeReviewApp/CodeReviewApp.csproj @@ -0,0 +1,26 @@ + + + + net7.0 + enable + enable + Linux + + + + + + + + + + + .dockerignore + + + + + + + + diff --git a/CodeReviewApp/ConfiguringManager/Configurations.cs b/CodeReviewApp/ConfiguringManager/Configurations.cs new file mode 100644 index 0000000..bbbd18c --- /dev/null +++ b/CodeReviewApp/ConfiguringManager/Configurations.cs @@ -0,0 +1,14 @@ +namespace CodeReviewApp.ConfiguringManager; + +public static class Configurations +{ + + public static DatabaseSettings Database { get; set; } = new DatabaseSettings(); + public static RedisConfiguration Redis { get; set; } = new RedisConfiguration(); + + public static void SetProperties(IConfiguration configuration) + { + Database = configuration.GetSection("Database").Get(); + Redis = configuration.GetSection("Redis").Get(); + } +} \ No newline at end of file diff --git a/CodeReviewApp/ConfiguringManager/DatabaseSettings.cs b/CodeReviewApp/ConfiguringManager/DatabaseSettings.cs new file mode 100644 index 0000000..70b1e99 --- /dev/null +++ b/CodeReviewApp/ConfiguringManager/DatabaseSettings.cs @@ -0,0 +1,7 @@ +namespace CodeReviewApp.ConfiguringManager; + +public class DatabaseSettings +{ + public string ConnectionString { get; set; } = default!; + public int TimeOut { get; set; } +} \ No newline at end of file diff --git a/CodeReviewApp/ConfiguringManager/RedisConfiguration.cs b/CodeReviewApp/ConfiguringManager/RedisConfiguration.cs new file mode 100644 index 0000000..0d22a9c --- /dev/null +++ b/CodeReviewApp/ConfiguringManager/RedisConfiguration.cs @@ -0,0 +1,7 @@ +namespace CodeReviewApp.ConfiguringManager; + +public class RedisConfiguration +{ + public string Connection { get; set; } = default!; + public int CacheLifeTime { get; set; } +} \ No newline at end of file diff --git a/CodeReviewApp/Controllers/ProductController.cs b/CodeReviewApp/Controllers/ProductController.cs new file mode 100644 index 0000000..60c57d2 --- /dev/null +++ b/CodeReviewApp/Controllers/ProductController.cs @@ -0,0 +1,129 @@ +using AutoMapper; +using CodeReviewApp.ConfiguringManager; +using CodeReviewApp.Models; +using DAL.Repositories; +using Domain.Models; +using Microsoft.AspNetCore.Mvc; + +namespace CodeReviewApp.Controllers; + +public class ProductController: ControllerBase +{ + private readonly IProductRepository _productRepository; + private readonly ILogger _logger; + private readonly IMapper _mapper; + + public ProductController(IProductRepository productRepository, ILogger logger, IMapper mapper) + { + _productRepository = productRepository; + _logger = logger; + _mapper = mapper; + } + + [HttpGet] + public async Task GetAll() + { + _logger.LogInformation($"Api method {Request.Path.Value} was started"); + + var cts = new CancellationTokenSource(); + var token = cts.Token; + + return Ok(await _productRepository.GetAll( + Configurations.Redis.CacheLifeTime, token)); + } + + [HttpPost("add")] + public async Task Add([FromBody] ProductRequest productRequest) + { + _logger.LogInformation($"Api method {Request.Path.Value} was started"); + + if (!ModelState.IsValid) + { + _logger.LogWarning($"Api method {Request.Path.Value}, {ModelState}"); + return BadRequest(ModelState); + } + + var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromSeconds(Configurations.Database.TimeOut)); + var token = cts.Token; + + var entity = _mapper.Map(productRequest); + + try + { + await _productRepository.Create(entity, + Configurations.Redis.CacheLifeTime, token); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500); + } + + return Ok(); + } + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + _logger.LogInformation($"Api method {Request.Path.Value} was started"); + + var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromSeconds(Configurations.Database.TimeOut)); + var token = cts.Token; + + return Ok(await _productRepository.Get(id, Configurations.Redis.CacheLifeTime, token)); + } + + [HttpPost("delete/{id:int}")] + public async Task Delete(int id) + { + _logger.LogInformation($"Api method {Request.Path.Value} was started"); + + var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromSeconds(Configurations.Database.TimeOut)); + var token = cts.Token; + + try + { + await _productRepository.Delete(id, token); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500); + } + + return Ok(); + } + + [HttpPost("update")] + public async Task Update([FromBody] Product productRequest) + { + _logger.LogInformation($"Api method {Request.Path.Value} was started"); + + if (!ModelState.IsValid) + { + _logger.LogWarning($"Api method {Request.Path.Value}, {ModelState}"); + return BadRequest(ModelState); + } + + var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromSeconds(Configurations.Database.TimeOut)); + var token = cts.Token; + + try + { + await _productRepository.Update(productRequest, + Configurations.Redis.CacheLifeTime, token); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500); + } + + + return Ok(); + } +} \ No newline at end of file diff --git a/CodeReviewApp/Dockerfile b/CodeReviewApp/Dockerfile new file mode 100644 index 0000000..4312880 --- /dev/null +++ b/CodeReviewApp/Dockerfile @@ -0,0 +1,22 @@ +FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["CodeReviewApp/CodeReviewApp.csproj", "CodeReviewApp/"] +RUN dotnet restore "CodeReviewApp/CodeReviewApp.csproj" +COPY . . +WORKDIR "/src/CodeReviewApp" +RUN dotnet build "CodeReviewApp.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "CodeReviewApp.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "CodeReviewApp.dll"] diff --git a/CodeReviewApp/Models/ProductRequest.cs b/CodeReviewApp/Models/ProductRequest.cs new file mode 100644 index 0000000..4c8bbae --- /dev/null +++ b/CodeReviewApp/Models/ProductRequest.cs @@ -0,0 +1,16 @@ +namespace CodeReviewApp.Models; + +public class ProductRequest +{ + public string Name { get; set; } + public string Code { get; set; } + public Description Description { get; set; } +} + +public class Description +{ + public int Price { get; set; } + public string Currency { get; set; } + public string Metadata { get; set; } + public int ProductId { get; set; } +} \ No newline at end of file diff --git a/CodeReviewApp/Program.cs b/CodeReviewApp/Program.cs new file mode 100644 index 0000000..52834c2 --- /dev/null +++ b/CodeReviewApp/Program.cs @@ -0,0 +1,49 @@ +using CodeReviewApp.ConfiguringManager; +using DAL.Database; +using DAL.Repositories; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +var config = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .AddEnvironmentVariables() + .Build(); + +Configurations.SetProperties(config); + +builder.Services.AddStackExchangeRedisCache(options => +{ + options.Configuration = Configurations.Redis.Connection; +}); + +builder.Services.AddDbContext( + options => options.UseNpgsql(Configurations.Database.ConnectionString) +); + +builder.Services.AddAutoMapper(typeof(Program).Assembly); + +builder.Services.AddScoped(); + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/CodeReviewApp/Properties/launchSettings.json b/CodeReviewApp/Properties/launchSettings.json new file mode 100644 index 0000000..518388b --- /dev/null +++ b/CodeReviewApp/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:35916", + "sslPort": 44301 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5085", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7081;http://localhost:5085", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CodeReviewApp/appsettings.Development.json b/CodeReviewApp/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/CodeReviewApp/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CodeReviewApp/appsettings.json b/CodeReviewApp/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/CodeReviewApp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/DAL/Database/DatabaseContext.cs b/DAL/Database/DatabaseContext.cs new file mode 100644 index 0000000..401736c --- /dev/null +++ b/DAL/Database/DatabaseContext.cs @@ -0,0 +1,15 @@ +using Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace DAL.Database; + +public class DatabaseContext: DbContext +{ + public DbSet Products { get; set; } + public DbSet Descriptions { get; set; } + + public DatabaseContext(DbContextOptions options) : base(options) + { + Database.EnsureCreated(); + } +} \ No newline at end of file diff --git a/DAL/IRepository.cs b/DAL/IRepository.cs new file mode 100644 index 0000000..7b7aedb --- /dev/null +++ b/DAL/IRepository.cs @@ -0,0 +1,10 @@ +namespace DAL; + +public interface IRepository +{ + Task Get(int id, int cacheLifetime, CancellationToken token); + Task> GetAll(int cacheLifeTime, CancellationToken token); + Task Delete(int id, CancellationToken token); + Task Update(T entity, int cacheLifetime, CancellationToken token); + Task Create(T entity, int cacheLifeTime, CancellationToken token); +} \ No newline at end of file diff --git a/DAL/Repositories/IProductRepository.cs b/DAL/Repositories/IProductRepository.cs new file mode 100644 index 0000000..336c18b --- /dev/null +++ b/DAL/Repositories/IProductRepository.cs @@ -0,0 +1,8 @@ +using Domain.Models; + +namespace DAL.Repositories; + +public interface IProductRepository: IRepository +{ + +} \ No newline at end of file diff --git a/DAL/Repositories/ProductRepository.cs b/DAL/Repositories/ProductRepository.cs new file mode 100644 index 0000000..ffb8296 --- /dev/null +++ b/DAL/Repositories/ProductRepository.cs @@ -0,0 +1,149 @@ +using System.Text.Json; +using DAL.Database; +using Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; + +namespace DAL.Repositories; + +public class ProductRepository: IProductRepository +{ + private readonly DatabaseContext _db; + private readonly ILogger _logger; + private readonly IDistributedCache _cache; + + public ProductRepository(DatabaseContext db, ILogger logger, IDistributedCache cache) + { + _db = db; + _logger = logger; + _cache = cache; + } + + public async Task Get(int id, int cacheLifetime, CancellationToken token) + { + var productCache = await _cache.GetAsync(id.ToString(), token); + var product = new Product(); + + if(productCache is not null) + product = JsonSerializer.Deserialize(productCache); + + if (product is not null) + return product; + + product = await _db.Products.FindAsync(id) ?? + throw new KeyNotFoundException("Product not found"); + + _db.Entry(product).Reference(p => p.Description).Load(); + + await _cache.SetStringAsync( + product.Id.ToString(), + JsonSerializer.Serialize(product), + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(cacheLifetime) + }, token); + + return product; + } + + public async Task> GetAll(int cacheLifeTime, CancellationToken token) + { + var productsCache = await _cache.GetAsync("Products", token); + var products = new List(); + + + if (productsCache is not null) + products = JsonSerializer.Deserialize> (productsCache); + + if(products is not null && products.Count() > 0) + return products; + + products = await _db.Products + .Include(p => p.Description) + .ToListAsync(token) + ?? throw new NullReferenceException("Database is empty"); + + await _cache.SetStringAsync("Products", + JsonSerializer.Serialize(products), + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(cacheLifeTime) + }, token); + + return products; + } + + public async Task Delete(int id, CancellationToken token) + { + var product = await _db.Products.FindAsync(id) ?? + throw new KeyNotFoundException("Product not found"); + + _db.Products.Remove(product); + await _cache.RemoveAsync(id.ToString(), token); + + await _db.SaveChangesAsync(); + } + + public async Task Update(Product entity, int cacheLifetime, CancellationToken token) + { + var product = await _db.Products.FindAsync(entity.Id) ?? + throw new KeyNotFoundException("Product not found"); + + _db.Entry(product).Reference(p => p.Description).Load(); + + _db.Entry(product).CurrentValues.SetValues(entity); + _db.Entry(product.Description).CurrentValues.SetValues(entity.Description); + + await _cache.RemoveAsync(product.Id.ToString(), token); + await _cache.SetStringAsync(product.Id.ToString(), + JsonSerializer.Serialize(entity), + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(cacheLifetime) + }, token); + + await _db.SaveChangesAsync(); + } + + public async Task Create(Product entity, int cacheLifeTime, CancellationToken token) + { + using (var transaction = _db.Database.BeginTransaction()) + { + try + { + await _db.Products.AddAsync(entity); + + await _db.Descriptions.AddAsync(entity.Description); + + await _db.SaveChangesAsync(); + + transaction.Commit(); + + var products = await _db.Products + .Include(p => p.Description) + .ToListAsync() + ?? throw new NullReferenceException("Database is empty"); + + await _cache.RemoveAsync("Products", token); + await _cache.SetStringAsync("Products", + JsonSerializer.Serialize(products), + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(cacheLifeTime) + }, token); + } + catch (Exception ex) + { + _logger.LogError(ex.Message); + transaction.Rollback(); + + throw new Exception(ex.Message); + } + finally + { + transaction.Dispose(); + } + } + } +} \ No newline at end of file diff --git a/Domain/Models/Description.cs b/Domain/Models/Description.cs new file mode 100644 index 0000000..f71d2f5 --- /dev/null +++ b/Domain/Models/Description.cs @@ -0,0 +1,10 @@ +namespace Domain.Models; + +public class Description +{ + public int Id { get; set; } + public int Price { get; set; } + public string Currency { get; set; } + public string Metadata { get; set; } + public int ProductId { get; set; } +} \ No newline at end of file diff --git a/Domain/Models/Product.cs b/Domain/Models/Product.cs new file mode 100644 index 0000000..46fb101 --- /dev/null +++ b/Domain/Models/Product.cs @@ -0,0 +1,9 @@ +namespace Domain.Models; + +public class Product +{ + public int Id { get; set; } + public string Name { get; set; } + public string Code { get; set; } + public Description Description { get; set; } +} \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..aaac9e0 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "7.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file