diff --git a/Controllers/TodoItemsController.cs b/Controllers/TodoItemsController.cs deleted file mode 100644 index 0ef138e7..00000000 --- a/Controllers/TodoItemsController.cs +++ /dev/null @@ -1,116 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using TodoApi.Models; - -namespace TodoApi.Controllers -{ - [Route("api/[controller]")] - [ApiController] - public class TodoItemsController : ControllerBase - { - private readonly TodoContext _context; - - public TodoItemsController(TodoContext context) - { - _context = context; - } - - [HttpGet] - public async Task>> GetTodoItems() - { - return await _context.TodoItems - .Select(x => ItemToDTO(x)) - .ToListAsync(); - } - - [HttpGet("{id}")] - public async Task> GetTodoItem(long id) - { - var todoItem = await _context.TodoItems.FindAsync(id); - - if (todoItem == null) - { - return NotFound(); - } - - return ItemToDTO(todoItem); - } - - [HttpPut("{id}")] - public async Task UpdateTodoItem(long id, TodoItemDTO todoItemDTO) - { - if (id != todoItemDTO.Id) - { - return BadRequest(); - } - - var todoItem = await _context.TodoItems.FindAsync(id); - if (todoItem == null) - { - return NotFound(); - } - - todoItem.Name = todoItemDTO.Name; - todoItem.IsComplete = todoItemDTO.IsComplete; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) when (!TodoItemExists(id)) - { - return NotFound(); - } - - return NoContent(); - } - - [HttpPost] - public async Task> CreateTodoItem(TodoItemDTO todoItemDTO) - { - var todoItem = new TodoItem - { - IsComplete = todoItemDTO.IsComplete, - Name = todoItemDTO.Name - }; - - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - return CreatedAtAction( - nameof(GetTodoItem), - new { id = todoItem.Id }, - ItemToDTO(todoItem)); - } - - [HttpDelete("{id}")] - public async Task DeleteTodoItem(long id) - { - var todoItem = await _context.TodoItems.FindAsync(id); - - if (todoItem == null) - { - return NotFound(); - } - - _context.TodoItems.Remove(todoItem); - await _context.SaveChangesAsync(); - - return NoContent(); - } - - private bool TodoItemExists(long id) => - _context.TodoItems.Any(e => e.Id == id); - - private static TodoItemDTO ItemToDTO(TodoItem todoItem) => - new TodoItemDTO - { - Id = todoItem.Id, - Name = todoItem.Name, - IsComplete = todoItem.IsComplete - }; - } -} diff --git a/Startup.cs b/Startup.cs deleted file mode 100644 index bbfbc83d..00000000 --- a/Startup.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.HttpsPolicy; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using TodoApi.Models; - -namespace TodoApi -{ - public class Startup - { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddDbContext(opt => - opt.UseInMemoryDatabase("TodoList")); - services.AddControllers(); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseHttpsRedirection(); - - app.UseRouting(); - - app.UseAuthorization(); - - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - }); - } - } -} diff --git a/TodoApiDTO.Api.Test/TodoApiDTO.Api.Test.csproj b/TodoApiDTO.Api.Test/TodoApiDTO.Api.Test.csproj new file mode 100644 index 00000000..ae8ccfa7 --- /dev/null +++ b/TodoApiDTO.Api.Test/TodoApiDTO.Api.Test.csproj @@ -0,0 +1,29 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/TodoApiDTO.Api.Test/TodoItemCreateDtoValidatorTest - Copy.cs b/TodoApiDTO.Api.Test/TodoItemCreateDtoValidatorTest - Copy.cs new file mode 100644 index 00000000..0473e7aa --- /dev/null +++ b/TodoApiDTO.Api.Test/TodoItemCreateDtoValidatorTest - Copy.cs @@ -0,0 +1,79 @@ +using Moq; +using TodoApiDTO.Api.Validation; +using TodoApiDTO.Core.Models; +using TodoApiDTO.Core.Services; + +namespace TodoApiDTO.Api.Test; + +public class TodoItemDtoValidatorTest +{ + [Fact] + public async Task NameIsNull_MustBeFail() + { + var mocks = new MockRepository(MockBehavior.Strict); + var todoService = mocks.Create(); + + var dto = new TodoItemDTO + { + Name = null + }; + + var validator = new TodoItemDTOValidator(todoService.Object); + var validationResult = await validator.CheckAsync(dto); + + Assert.False(validationResult.IsValid); + Assert.Equal("Name is required.", validationResult.ErrorMessage); + } + + [Fact] + public async Task NameIsUsed_MustBeFail() + { + const long id = 1; + const string name = "used"; + + var mocks = new MockRepository(MockBehavior.Strict); + var todoService = mocks.Create(); + + todoService + .Setup(s => s.GetNameIsUsedExceptOneAsync(id, name)) + .Returns(Task.FromResult(true)); + + var dto = new TodoItemDTO + { + Id = id, + Name = name + }; + + var validator = new TodoItemDTOValidator(todoService.Object); + var validationResult = await validator.CheckAsync(dto); + + Assert.False(validationResult.IsValid); + Assert.Equal("Name 'used' is not unique.", validationResult.ErrorMessage); + } + + [Fact] + public async Task MustBeSuccess() + { + const long id = 1; + const string name = "not_used"; + + var mocks = new MockRepository(MockBehavior.Strict); + var todoService = mocks.Create(); + + todoService + .Setup(s => s.GetNameIsUsedExceptOneAsync(id, name)) + .Returns(Task.FromResult(false)); + + var dto = new TodoItemDTO + { + Id = id, + Name = name + }; + + var validator = new TodoItemDTOValidator(todoService.Object); + var validationResult = await validator.CheckAsync(dto); + + Assert.True(validationResult.IsValid); + Assert.Null(validationResult.ErrorMessage); + } +} \ No newline at end of file diff --git a/TodoApiDTO.Api.Test/TodoItemCreateDtoValidatorTest.cs b/TodoApiDTO.Api.Test/TodoItemCreateDtoValidatorTest.cs new file mode 100644 index 00000000..8d59584a --- /dev/null +++ b/TodoApiDTO.Api.Test/TodoItemCreateDtoValidatorTest.cs @@ -0,0 +1,75 @@ +using Moq; +using TodoApiDTO.Api.Validation; +using TodoApiDTO.Core.Models; +using TodoApiDTO.Core.Services; + +namespace TodoApiDTO.Api.Test; + +public class TodoItemCreateDtoValidatorTest +{ + [Fact] + public async Task NameIsNull_MustBeFail() + { + var mocks = new MockRepository(MockBehavior.Strict); + var todoService = mocks.Create(); + + var dto = new TodoItemCreateDTO + { + Name = null + }; + + var validator = new TodoItemCreateDTOValidator(todoService.Object); + var validationResult = await validator.CheckAsync(dto); + + Assert.False(validationResult.IsValid); + Assert.Equal("Name is required.", validationResult.ErrorMessage); + } + + [Fact] + public async Task NameIsUsed_MustBeFail() + { + const string name = "used"; + + var mocks = new MockRepository(MockBehavior.Strict); + var todoService = mocks.Create(); + + todoService + .Setup(s => s.GetNameIsUsedAsync(name)) + .Returns(Task.FromResult(true)); + + var dto = new TodoItemCreateDTO + { + Name = name + }; + + var validator = new TodoItemCreateDTOValidator(todoService.Object); + var validationResult = await validator.CheckAsync(dto); + + Assert.False(validationResult.IsValid); + Assert.Equal("Name 'used' is not unique.", validationResult.ErrorMessage); + } + + [Fact] + public async Task MustBeSuccess() + { + const string name = "not_used"; + + var mocks = new MockRepository(MockBehavior.Strict); + var todoService = mocks.Create(); + + todoService + .Setup(s => s.GetNameIsUsedAsync(name)) + .Returns(Task.FromResult(false)); + + var dto = new TodoItemCreateDTO + { + Name = name + }; + + var validator = new TodoItemCreateDTOValidator(todoService.Object); + var validationResult = await validator.CheckAsync(dto); + + Assert.True(validationResult.IsValid); + Assert.Null(validationResult.ErrorMessage); + } +} \ No newline at end of file diff --git a/TodoApiDTO.Api.Test/Usings.cs b/TodoApiDTO.Api.Test/Usings.cs new file mode 100644 index 00000000..8c927eb7 --- /dev/null +++ b/TodoApiDTO.Api.Test/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/TodoApiDTO.Api/Controllers/TodoItemsController.cs b/TodoApiDTO.Api/Controllers/TodoItemsController.cs new file mode 100644 index 00000000..d822829c --- /dev/null +++ b/TodoApiDTO.Api/Controllers/TodoItemsController.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using TodoApiDTO.Api.Validation; +using TodoApiDTO.Core.Models; +using TodoApiDTO.Core.Services; + +namespace TodoApiDTO.Api.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class TodoItemsController : ControllerBase + { + private readonly IModelValidator _createModelValidator; + private readonly ILogger _logger; + private readonly ITodoService _service; + private readonly IModelValidator _updateModelValidator; + + public TodoItemsController( + IModelValidator createModelValidator, + IModelValidator updateModelValidator, + ITodoService service, + ILogger logger) + { + _createModelValidator = + createModelValidator ?? throw new ArgumentNullException(nameof(createModelValidator)); + + _updateModelValidator = + updateModelValidator ?? throw new ArgumentNullException(nameof(updateModelValidator)); + + _service = service ?? throw new ArgumentNullException(nameof(service)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + [HttpGet] + public async Task>> GetTodoItems() + { + return await _service.GetAllAsync(); + } + + [HttpGet("{id}")] + public async Task> GetTodoItem(long id) + { + if (id == 2) + { + throw new Exception("Test exception."); + } + + var dto = await _service.FindAsync(id); + + if (dto == null) + { + return NotFound(); + } + + return dto; + } + + [HttpPost] + public async Task> CreateTodoItem(TodoItemCreateDTO dto) + { + var validationResult = await _createModelValidator.CheckAsync(dto); + + if (!validationResult.IsValid) + { + return BadRequest(validationResult.ErrorMessage); + } + + var createdDto = await _service.CreateAsync(dto); + + _logger.LogInformation("TODO item {id} created {DT}", + createdDto.Id, + DateTime.UtcNow.ToLongTimeString()); + + return CreatedAtAction( + nameof(GetTodoItem), + new { id = createdDto.Id }, + createdDto); + } + + [HttpPut("{id}")] + public async Task UpdateTodoItem(long id, TodoItemDTO dto) + { + if (id != dto.Id) + { + return BadRequest(); + } + + var validationResult = await _updateModelValidator.CheckAsync(dto); + + if (!validationResult.IsValid) + { + return BadRequest(validationResult.ErrorMessage); + } + + try + { + await _service.UpdateAsync(dto); + } + catch (DbUpdateConcurrencyException) when (!TodoItemExists(id)) + { + return NotFound(); + } + + _logger.LogInformation("TODO item {id} updated {DT}", + id, + DateTime.UtcNow.ToLongTimeString()); + + return NoContent(); + } + + private bool TodoItemExists(long id) + { + return _service.GetIsExistAsync(id).Result; + } + + + [HttpDelete("{id}")] + public async Task DeleteTodoItem(long id) + { + if (!await _service.GetIsExistAsync(id)) + { + return NotFound(); + } + + await _service.DeleteAsync(id); + + _logger.LogInformation("TODO item {id} deleted {DT}", + id, + DateTime.UtcNow.ToLongTimeString()); + + return NoContent(); + } + } +} \ No newline at end of file diff --git a/TodoApiDTO.Api/Extensions/CustomExceptionMiddleware/ErrorDetails.cs b/TodoApiDTO.Api/Extensions/CustomExceptionMiddleware/ErrorDetails.cs new file mode 100644 index 00000000..7e6d5cd0 --- /dev/null +++ b/TodoApiDTO.Api/Extensions/CustomExceptionMiddleware/ErrorDetails.cs @@ -0,0 +1,15 @@ +using System.Text.Json; + +namespace TodoApiDTO.Api.Extensions.CustomExceptionMiddleware +{ + public class ErrorDetails + { + public int StatusCode { get; set; } + public string Message { get; set; } + + public override string ToString() + { + return JsonSerializer.Serialize(this); + } + } +} \ No newline at end of file diff --git a/TodoApiDTO.Api/Extensions/CustomExceptionMiddleware/ExceptionMiddleware.cs b/TodoApiDTO.Api/Extensions/CustomExceptionMiddleware/ExceptionMiddleware.cs new file mode 100644 index 00000000..860ca8da --- /dev/null +++ b/TodoApiDTO.Api/Extensions/CustomExceptionMiddleware/ExceptionMiddleware.cs @@ -0,0 +1,45 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace TodoApiDTO.Api.Extensions.CustomExceptionMiddleware +{ + public class ExceptionMiddleware + { + private readonly ILogger _logger; + private readonly RequestDelegate _next; + + public ExceptionMiddleware(RequestDelegate next, ILogger logger) + { + _logger = logger; + _next = next; + } + + public async Task InvokeAsync(HttpContext httpContext) + { + try + { + await _next(httpContext); + } + catch (Exception ex) + { + _logger.LogError($"Something went wrong: {ex}"); + await HandleExceptionAsync(httpContext, ex); + } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + + await context.Response.WriteAsync(new ErrorDetails + { + StatusCode = context.Response.StatusCode, + Message = "Internal Server Error." + }.ToString()); + } + } +} \ No newline at end of file diff --git a/TodoApiDTO.Api/Extensions/CustomExceptionMiddleware/ExceptionMiddlewareExtensions.cs b/TodoApiDTO.Api/Extensions/CustomExceptionMiddleware/ExceptionMiddlewareExtensions.cs new file mode 100644 index 00000000..362b90b2 --- /dev/null +++ b/TodoApiDTO.Api/Extensions/CustomExceptionMiddleware/ExceptionMiddlewareExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Builder; + +namespace TodoApiDTO.Api.Extensions.CustomExceptionMiddleware +{ + public static class ExceptionMiddlewareExtensions + { + #region Static + + public static void ConfigureCustomExceptionMiddleware(this IApplicationBuilder app) + { + app.UseMiddleware(); + } + + #endregion + } +} \ No newline at end of file diff --git a/TodoApiDTO.Api/Extensions/CustomLogging/FileLogger.cs b/TodoApiDTO.Api/Extensions/CustomLogging/FileLogger.cs new file mode 100644 index 00000000..7f3149d9 --- /dev/null +++ b/TodoApiDTO.Api/Extensions/CustomLogging/FileLogger.cs @@ -0,0 +1,56 @@ +using System; +using System.IO; +using Microsoft.Extensions.Logging; +using TodoApiDTO.Api.Helpers; + +namespace TodoApiDTO.Api.Extensions.CustomLogging +{ + public class FileLogger : ILogger, IDisposable + { + #region Static + + private static readonly object Lock = new object(); + + #endregion + + private readonly string filePath; + + public FileLogger(string path) + { + filePath = path; + } + + #region IDisposable Members + + public void Dispose() + { + } + + #endregion + + #region ILogger Members + + public IDisposable BeginScope(TState state) + { + return this; + } + + public bool IsEnabled(LogLevel logLevel) + { + //return logLevel == LogLevel.Trace; + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, + TState state, Exception exception, Func formatter) + { + lock (Lock) + { + ApplicationHelpers.PrepareCatalogOfFile(filePath); + File.AppendAllText(filePath, formatter(state, exception) + Environment.NewLine); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/TodoApiDTO.Api/Extensions/CustomLogging/FileLoggerExtensions.cs b/TodoApiDTO.Api/Extensions/CustomLogging/FileLoggerExtensions.cs new file mode 100644 index 00000000..9a769f19 --- /dev/null +++ b/TodoApiDTO.Api/Extensions/CustomLogging/FileLoggerExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Logging; + +namespace TodoApiDTO.Api.Extensions.CustomLogging +{ + public static class FileLoggerExtensions + { + #region Static + + public static ILoggingBuilder AddFile(this ILoggingBuilder builder, string filePath) + { + builder.AddProvider(new FileLoggerProvider(filePath)); + return builder; + } + + #endregion + } +} \ No newline at end of file diff --git a/TodoApiDTO.Api/Extensions/CustomLogging/FileLoggerProvider.cs b/TodoApiDTO.Api/Extensions/CustomLogging/FileLoggerProvider.cs new file mode 100644 index 00000000..f388afba --- /dev/null +++ b/TodoApiDTO.Api/Extensions/CustomLogging/FileLoggerProvider.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace TodoApiDTO.Api.Extensions.CustomLogging +{ + public class FileLoggerProvider : ILoggerProvider + { + private readonly string _path; + + public FileLoggerProvider(string path) + { + _path = !string.IsNullOrWhiteSpace(path) + ? path + : throw new ArgumentNullException(nameof(path)); + } + + #region ILoggerProvider Members + + public ILogger CreateLogger(string categoryName) + { + return new FileLogger(_path); + } + + public void Dispose() + { + } + + #endregion + } +} \ No newline at end of file diff --git a/TodoApiDTO.Api/Helpers/ApplicationHelpers.cs b/TodoApiDTO.Api/Helpers/ApplicationHelpers.cs new file mode 100644 index 00000000..af5314a3 --- /dev/null +++ b/TodoApiDTO.Api/Helpers/ApplicationHelpers.cs @@ -0,0 +1,21 @@ +using System.IO; + +namespace TodoApiDTO.Api.Helpers +{ + public static class ApplicationHelpers + { + #region Static + + public static void PrepareCatalogOfFile(string path) + { + var catalog = Path.GetDirectoryName(path); + + if (!Directory.Exists(catalog)) + { + Directory.CreateDirectory(catalog); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/Program.cs b/TodoApiDTO.Api/Program.cs similarity index 71% rename from Program.cs rename to TodoApiDTO.Api/Program.cs index b27ac16a..dc562c84 100644 --- a/Program.cs +++ b/TodoApiDTO.Api/Program.cs @@ -1,13 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -namespace TodoApi +namespace TodoApiDTO.Api { public class Program { diff --git a/Properties/launchSettings.json b/TodoApiDTO.Api/Properties/launchSettings.json similarity index 100% rename from Properties/launchSettings.json rename to TodoApiDTO.Api/Properties/launchSettings.json diff --git a/README.md b/TodoApiDTO.Api/README.md similarity index 100% rename from README.md rename to TodoApiDTO.Api/README.md diff --git a/TodoApiDTO.Api/Startup.cs b/TodoApiDTO.Api/Startup.cs new file mode 100644 index 00000000..842c7ffc --- /dev/null +++ b/TodoApiDTO.Api/Startup.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using TodoApiDTO.Api.Extensions.CustomExceptionMiddleware; +using TodoApiDTO.Api.Extensions.CustomLogging; +using TodoApiDTO.Api.Validation; +using TodoApiDTO.Core.Models; +using TodoApiDTO.Core.Services; +using TodoApiDTO.EF; +using TodoApiDTO.EF.Services; + +namespace TodoApiDTO.Api +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + var connectionString = Configuration.GetConnectionString("DefaultConnection"); + var logFilePath = Configuration["Logging:Target"]; + + services.AddDbContext(options => { options.UseSqlServer(connectionString); }); + + services.AddScoped, TodoItemCreateDTOValidator>(); + services.AddScoped, TodoItemDTOValidator>(); + services.AddScoped(); + + services.AddControllers(); + services.AddSwaggerGen(); + + services.AddLogging(builder => { builder.AddFile(logFilePath); }); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + app.UseSwagger(); + app.UseSwaggerUI(setupAction => + { + setupAction.SwaggerEndpoint("/swagger/v1/swagger.json", "TodoApiDTO API"); + setupAction.RoutePrefix = string.Empty; + }); + } + + app.ConfigureCustomExceptionMiddleware(); + + app.UseHttpsRedirection(); + + app.UseRouting(); + + app.UseAuthorization(); + + app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); + } + } +} \ No newline at end of file diff --git a/TodoApiDTO.csproj b/TodoApiDTO.Api/TodoApiDTO.Api.csproj similarity index 75% rename from TodoApiDTO.csproj rename to TodoApiDTO.Api/TodoApiDTO.Api.csproj index bba6f6af..eda2c2f9 100644 --- a/TodoApiDTO.csproj +++ b/TodoApiDTO.Api/TodoApiDTO.Api.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.1 @@ -12,7 +12,10 @@ + + + + - diff --git a/TodoApiDTO.Api/Validation/BaseModelValidator.cs b/TodoApiDTO.Api/Validation/BaseModelValidator.cs new file mode 100644 index 00000000..6123a97e --- /dev/null +++ b/TodoApiDTO.Api/Validation/BaseModelValidator.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace TodoApiDTO.Api.Validation +{ + public abstract class BaseModelValidator : IModelValidator + { + private readonly List>> _rules; + + protected BaseModelValidator() + { + _rules = new List>>(); + } + + protected void AddRule(Func> rule) + { + _rules.Add(rule); + } + + #region IModelValidator Members + + public async Task CheckAsync(TModel model) + { + foreach (var rule in _rules) + { + var message = await rule(model); + + if (message != null) + { + return ValidationResultFactory.GetFailedResult(message); + } + } + + return ValidationResultFactory.GetSuccessResult(); + } + + #endregion + } +} \ No newline at end of file diff --git a/TodoApiDTO.Api/Validation/IModelValidator.cs b/TodoApiDTO.Api/Validation/IModelValidator.cs new file mode 100644 index 00000000..e88b4af9 --- /dev/null +++ b/TodoApiDTO.Api/Validation/IModelValidator.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; + +namespace TodoApiDTO.Api.Validation +{ + public interface IModelValidator + { + Task CheckAsync(TModel model); + } + +} \ No newline at end of file diff --git a/TodoApiDTO.Api/Validation/TodoItemCreateDTOValidator.cs b/TodoApiDTO.Api/Validation/TodoItemCreateDTOValidator.cs new file mode 100644 index 00000000..9e1139c9 --- /dev/null +++ b/TodoApiDTO.Api/Validation/TodoItemCreateDTOValidator.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; +using TodoApiDTO.Core.Models; +using TodoApiDTO.Core.Services; + +namespace TodoApiDTO.Api.Validation +{ + public class TodoItemCreateDTOValidator : BaseModelValidator + { + public TodoItemCreateDTOValidator(ITodoService todoService) + { + AddRule(dto => + { + var message = string.IsNullOrEmpty(dto.Name) + ? "Name is required." + : null; + + return Task.FromResult(message); + }); + + AddRule(async dto => + { + if (await todoService.GetNameIsUsedAsync(dto.Name)) + { + return $"Name '{dto.Name}' is not unique."; + } + + return null; + }); + } + } +} \ No newline at end of file diff --git a/TodoApiDTO.Api/Validation/TodoItemDTOValidator.cs b/TodoApiDTO.Api/Validation/TodoItemDTOValidator.cs new file mode 100644 index 00000000..d0bee427 --- /dev/null +++ b/TodoApiDTO.Api/Validation/TodoItemDTOValidator.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using TodoApiDTO.Core.Models; +using TodoApiDTO.Core.Services; +using TodoApiDTO.EF.Services; + +namespace TodoApiDTO.Api.Validation +{ + public class TodoItemDTOValidator : BaseModelValidator + { + public TodoItemDTOValidator(ITodoService todoService) + { + AddRule(dto => + { + var message = string.IsNullOrEmpty(dto.Name) + ? "Name is required." + : null; + + return Task.FromResult(message); + }); + + AddRule(async dto => + { + if (await todoService.GetNameIsUsedExceptOneAsync(dto.Id, dto.Name)) + { + return $"Name '{dto.Name}' is not unique."; + } + + return null; + }); + } + } +} \ No newline at end of file diff --git a/TodoApiDTO.Api/Validation/ValidationResult.cs b/TodoApiDTO.Api/Validation/ValidationResult.cs new file mode 100644 index 00000000..d4891a42 --- /dev/null +++ b/TodoApiDTO.Api/Validation/ValidationResult.cs @@ -0,0 +1,15 @@ +namespace TodoApiDTO.Api.Validation +{ + public class ValidationResult + { + public ValidationResult(bool isValid, string errorMessage) + { + IsValid = isValid; + ErrorMessage = errorMessage; + } + + public bool IsValid { get; } + + public string ErrorMessage { get; } + } +} diff --git a/TodoApiDTO.Api/Validation/ValidationResultFactory.cs b/TodoApiDTO.Api/Validation/ValidationResultFactory.cs new file mode 100644 index 00000000..1c90c2b1 --- /dev/null +++ b/TodoApiDTO.Api/Validation/ValidationResultFactory.cs @@ -0,0 +1,21 @@ +namespace TodoApiDTO.Api.Validation +{ + public static class ValidationResultFactory + { + #region Static + + private static readonly ValidationResult Success = new ValidationResult(true, null); + + public static ValidationResult GetSuccessResult() + { + return Success; + } + + public static ValidationResult GetFailedResult(string message) + { + return new ValidationResult(false, message); + } + + #endregion + } +} \ No newline at end of file diff --git a/TodoApiDTO.Api/appsettings.Development.json b/TodoApiDTO.Api/appsettings.Development.json new file mode 100644 index 00000000..363743bc --- /dev/null +++ b/TodoApiDTO.Api/appsettings.Development.json @@ -0,0 +1,13 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Database=Todo;User ID=SA;Password=Rockler@6785;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + }, + "Target": "D:\\Log\\VelvetechTestTask.log" + } +} diff --git a/appsettings.json b/TodoApiDTO.Api/appsettings.json similarity index 100% rename from appsettings.json rename to TodoApiDTO.Api/appsettings.json diff --git a/TodoApiDTO.Core/Models/TodoItemCreateDTO.cs b/TodoApiDTO.Core/Models/TodoItemCreateDTO.cs new file mode 100644 index 00000000..e1f63883 --- /dev/null +++ b/TodoApiDTO.Core/Models/TodoItemCreateDTO.cs @@ -0,0 +1,8 @@ +namespace TodoApiDTO.Core.Models +{ + public class TodoItemCreateDTO + { + public string Name { get; set; } + public bool IsComplete { get; set; } + } +} \ No newline at end of file diff --git a/Models/TodoItemDTO.cs b/TodoApiDTO.Core/Models/TodoItemDTO.cs similarity index 72% rename from Models/TodoItemDTO.cs rename to TodoApiDTO.Core/Models/TodoItemDTO.cs index e66a500a..1218a71e 100644 --- a/Models/TodoItemDTO.cs +++ b/TodoApiDTO.Core/Models/TodoItemDTO.cs @@ -1,11 +1,9 @@ -namespace TodoApi.Models +namespace TodoApiDTO.Core.Models { - #region snippet public class TodoItemDTO { public long Id { get; set; } public string Name { get; set; } public bool IsComplete { get; set; } } - #endregion } diff --git a/TodoApiDTO.Core/Services/ITodoService.cs b/TodoApiDTO.Core/Services/ITodoService.cs new file mode 100644 index 00000000..efddbee3 --- /dev/null +++ b/TodoApiDTO.Core/Services/ITodoService.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using TodoApiDTO.Core.Models; + +namespace TodoApiDTO.Core.Services +{ + public interface ITodoService + { + Task> GetAllAsync(); + Task FindAsync(long id); + Task GetIsExistAsync(long id); + Task CreateAsync(TodoItemCreateDTO dto); + Task UpdateAsync(TodoItemDTO dto); + Task DeleteAsync(long id); + Task GetNameIsUsedAsync(string name); + Task GetNameIsUsedExceptOneAsync(long id, string name); + } +} \ No newline at end of file diff --git a/TodoApiDTO.Core/TodoApiDTO.Core.csproj b/TodoApiDTO.Core/TodoApiDTO.Core.csproj new file mode 100644 index 00000000..cb631906 --- /dev/null +++ b/TodoApiDTO.Core/TodoApiDTO.Core.csproj @@ -0,0 +1,7 @@ + + + + netcoreapp3.1 + + + diff --git a/Models/TodoItem.cs b/TodoApiDTO.EF/Entities/TodoItem.cs similarity index 76% rename from Models/TodoItem.cs rename to TodoApiDTO.EF/Entities/TodoItem.cs index 1f6e5465..ae801a03 100644 --- a/Models/TodoItem.cs +++ b/TodoApiDTO.EF/Entities/TodoItem.cs @@ -1,6 +1,5 @@ -namespace TodoApi.Models +namespace TodoApiDTO.EF.Entities { - #region snippet public class TodoItem { public long Id { get; set; } @@ -8,5 +7,4 @@ public class TodoItem public bool IsComplete { get; set; } public string Secret { get; set; } } - #endregion } \ No newline at end of file diff --git a/TodoApiDTO.EF/Migrations/20230321160041_001.Designer.cs b/TodoApiDTO.EF/Migrations/20230321160041_001.Designer.cs new file mode 100644 index 00000000..eae378bc --- /dev/null +++ b/TodoApiDTO.EF/Migrations/20230321160041_001.Designer.cs @@ -0,0 +1,45 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace TodoApiDTO.EF.Migrations +{ + [DbContext(typeof(TodoContext))] + [Migration("20230321160041_001")] + partial class _001 + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("TodoApiDTO.EF.Entities.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("IsComplete") + .HasColumnType("bit"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Secret") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TodoItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TodoApiDTO.EF/Migrations/20230321160041_001.cs b/TodoApiDTO.EF/Migrations/20230321160041_001.cs new file mode 100644 index 00000000..0dfc2a96 --- /dev/null +++ b/TodoApiDTO.EF/Migrations/20230321160041_001.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace TodoApiDTO.EF.Migrations +{ + public partial class _001 : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TodoItems", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(nullable: true), + IsComplete = table.Column(nullable: false), + Secret = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TodoItems", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TodoItems"); + } + } +} diff --git a/TodoApiDTO.EF/Migrations/TodoContextModelSnapshot.cs b/TodoApiDTO.EF/Migrations/TodoContextModelSnapshot.cs new file mode 100644 index 00000000..81d37ba1 --- /dev/null +++ b/TodoApiDTO.EF/Migrations/TodoContextModelSnapshot.cs @@ -0,0 +1,43 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace TodoApiDTO.EF.Migrations +{ + [DbContext(typeof(TodoContext))] + partial class TodoContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("TodoApiDTO.EF.Entities.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("IsComplete") + .HasColumnType("bit"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Secret") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TodoItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TodoApiDTO.EF/Services/TodoService.cs b/TodoApiDTO.EF/Services/TodoService.cs new file mode 100644 index 00000000..fedde3c5 --- /dev/null +++ b/TodoApiDTO.EF/Services/TodoService.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using TodoApiDTO.Core.Models; +using TodoApiDTO.Core.Services; +using TodoApiDTO.EF.Entities; + +namespace TodoApiDTO.EF.Services +{ + public class TodoService : ITodoService + { + #region Static + + private static TodoItemDTO EntityToDTO(TodoItem todoItem) + { + return new TodoItemDTO + { + Id = todoItem.Id, + Name = todoItem.Name, + IsComplete = todoItem.IsComplete + }; + } + + private static TodoItem DTOToEntity(TodoItemDTO todoItem) + { + return new TodoItem + { + Id = todoItem.Id, + Name = todoItem.Name, + IsComplete = todoItem.IsComplete + }; + } + + #endregion + + private readonly TodoContext _context; + + public TodoService(TodoContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + private async Task GetEntityRequiredAsync(long id) + { + var entity = await _context.TodoItems.FindAsync(id); + + if (entity == null) + { + throw new InvalidOperationException($"Entity id={id} not exist."); + } + + return entity; + } + + #region ITodoService Members + + public Task> GetAllAsync() + { + return _context.TodoItems + .Select(x => EntityToDTO(x)) + .ToListAsync(); + } + + public async Task FindAsync(long id) + { + var entity = await _context.TodoItems.FindAsync(id); + + if (entity == null) + { + return null; + } + + return EntityToDTO(entity); + } + + public Task GetIsExistAsync(long id) + { + return _context.TodoItems.AnyAsync(x => x.Id == id); + } + + public async Task CreateAsync(TodoItemCreateDTO dto) + { + var entity = new TodoItem + { + IsComplete = dto.IsComplete, + Name = dto.Name + }; + + _context.TodoItems.Add(entity); + await _context.SaveChangesAsync(); + + return EntityToDTO(entity); + } + + public async Task UpdateAsync(TodoItemDTO dto) + { + var entity = await GetEntityRequiredAsync(dto.Id); + + var next = DTOToEntity(dto); + _context.Entry(entity).CurrentValues.SetValues(next); + + await _context.SaveChangesAsync(); + } + + public async Task DeleteAsync(long id) + { + var entity = await GetEntityRequiredAsync(id); + + _context.TodoItems.Remove(entity); + await _context.SaveChangesAsync(); + } + + public Task GetNameIsUsedAsync(string name) + { + return _context.TodoItems.AnyAsync(x => x.Name == name); + } + + public Task GetNameIsUsedExceptOneAsync(long id, string name) + { + return _context.TodoItems.AnyAsync(x => x.Name == name && x.Id != id); + } + + #endregion + } +} \ No newline at end of file diff --git a/TodoApiDTO.EF/TodoApiDTO.EF.csproj b/TodoApiDTO.EF/TodoApiDTO.EF.csproj new file mode 100644 index 00000000..30221565 --- /dev/null +++ b/TodoApiDTO.EF/TodoApiDTO.EF.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp3.1 + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/Models/TodoContext.cs b/TodoApiDTO.EF/TodoContext.cs similarity index 83% rename from Models/TodoContext.cs rename to TodoApiDTO.EF/TodoContext.cs index 6e59e363..417354ea 100644 --- a/Models/TodoContext.cs +++ b/TodoApiDTO.EF/TodoContext.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; +using TodoApiDTO.EF.Entities; -namespace TodoApi.Models +namespace TodoApiDTO.EF { public class TodoContext : DbContext { diff --git a/TodoApiDTO.sln b/TodoApiDTO.sln index e49c182b..3d9a5417 100644 --- a/TodoApiDTO.sln +++ b/TodoApiDTO.sln @@ -1,9 +1,15 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30002.166 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33103.184 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TodoApiDTO", "TodoApiDTO.csproj", "{623124F9-F5BA-42DD-BC26-A1720774229C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TodoApiDTO.Api", "TodoApiDTO.Api\TodoApiDTO.Api.csproj", "{623124F9-F5BA-42DD-BC26-A1720774229C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TodoApiDTO.EF", "TodoApiDTO.EF\TodoApiDTO.EF.csproj", "{55E5B435-81EA-41B4-B7FD-BC871C9BFB9F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TodoApiDTO.Core", "TodoApiDTO.Core\TodoApiDTO.Core.csproj", "{89D4B67C-A014-4A16-84AE-D947EE84CE6A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TodoApiDTO.Api.Test", "TodoApiDTO.Api.Test\TodoApiDTO.Api.Test.csproj", "{8E275A17-21C0-43A3-8240-6352C2E3DAB0}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,6 +21,18 @@ Global {623124F9-F5BA-42DD-BC26-A1720774229C}.Debug|Any CPU.Build.0 = Debug|Any CPU {623124F9-F5BA-42DD-BC26-A1720774229C}.Release|Any CPU.ActiveCfg = Release|Any CPU {623124F9-F5BA-42DD-BC26-A1720774229C}.Release|Any CPU.Build.0 = Release|Any CPU + {55E5B435-81EA-41B4-B7FD-BC871C9BFB9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55E5B435-81EA-41B4-B7FD-BC871C9BFB9F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55E5B435-81EA-41B4-B7FD-BC871C9BFB9F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55E5B435-81EA-41B4-B7FD-BC871C9BFB9F}.Release|Any CPU.Build.0 = Release|Any CPU + {89D4B67C-A014-4A16-84AE-D947EE84CE6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {89D4B67C-A014-4A16-84AE-D947EE84CE6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {89D4B67C-A014-4A16-84AE-D947EE84CE6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {89D4B67C-A014-4A16-84AE-D947EE84CE6A}.Release|Any CPU.Build.0 = Release|Any CPU + {8E275A17-21C0-43A3-8240-6352C2E3DAB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E275A17-21C0-43A3-8240-6352C2E3DAB0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E275A17-21C0-43A3-8240-6352C2E3DAB0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E275A17-21C0-43A3-8240-6352C2E3DAB0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/appsettings.Development.json b/appsettings.Development.json deleted file mode 100644 index 8983e0fc..00000000 --- a/appsettings.Development.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - } -}