diff --git a/Business/Services/TodoService.cs b/Business/Services/TodoService.cs new file mode 100644 index 00000000..2d5a6e6e --- /dev/null +++ b/Business/Services/TodoService.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Threading.Tasks; +using TodoApi.Models; +using TodoApiDTO.Models; + +namespace TodoApiDTO.Services +{ + public class TodoService : ITodoService + { + private readonly ITodoRepository _todoRepository; + + public TodoService(ITodoRepository todoRepository) + { + _todoRepository = todoRepository; + } + + public async Task Create(TodoItemDTO createInput) + { + if (createInput == null) throw new ArgumentNullException(nameof(createInput)); + return await _todoRepository.Create(createInput); + } + + public async Task Delete(long itemToDeleteId) + { + return await _todoRepository.Delete(itemToDeleteId); + } + + public async Task GetById(long itemId) + { + return await _todoRepository.GetById(itemId); + } + + public async Task Update(TodoItemDTO updateInput) + { + if (updateInput == null) throw new ArgumentNullException(nameof(updateInput)); + return await _todoRepository.Update(updateInput); + } + + public DbSet GetList() + { + return _todoRepository.GetList(); + } + } +} diff --git a/Business/Utilities/ServiceHelpers.cs b/Business/Utilities/ServiceHelpers.cs new file mode 100644 index 00000000..196dce82 --- /dev/null +++ b/Business/Utilities/ServiceHelpers.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; +using TodoApi.Models; + +namespace TodoApiDTO.Services +{ + public static class ServiceHelpers + { + public static void Configure(IServiceCollection services) + { + services.AddScoped(); + } + } +} diff --git a/Controllers/TodoItemsController.cs b/Controllers/TodoItemsController.cs index 0ef138e7..36114196 100644 --- a/Controllers/TodoItemsController.cs +++ b/Controllers/TodoItemsController.cs @@ -1,9 +1,12 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using TodoApi.Models; +using TodoApiDTO.Models; namespace TodoApi.Controllers { @@ -11,106 +14,148 @@ namespace TodoApi.Controllers [ApiController] public class TodoItemsController : ControllerBase { - private readonly TodoContext _context; + private readonly ITodoService _todoService; + private readonly ILogger _logger; - public TodoItemsController(TodoContext context) + public TodoItemsController(ITodoService todoService, ILogger logger) { - _context = context; + _todoService = todoService; + _logger = logger; } [HttpGet] public async Task>> GetTodoItems() { - return await _context.TodoItems - .Select(x => ItemToDTO(x)) - .ToListAsync(); + try + { + return await _todoService.GetList() + .Select(x => TodoItemDTO.ItemToDTO(x)) + .ToListAsync(); + } + catch (Exception ex) + { + _logger.LogError($"GetTodoItems: {ex.Message}\n{ex.StackTrace}"); + return BadRequest(ex.Message); + } } [HttpGet("{id}")] public async Task> GetTodoItem(long id) { - var todoItem = await _context.TodoItems.FindAsync(id); - - if (todoItem == null) + if (id < 0) { - return NotFound(); + _logger.LogError($"GetTodoItem: invalid id value ({id})"); + return BadRequest("Invalid id"); } - return ItemToDTO(todoItem); + try + { + var todoItem = await _todoService.GetById(id); + if (todoItem == null) + { + _logger.LogError($"GetTodoItem: item not found (id:{id})"); + return NotFound("TODO item not found"); + } + + return TodoItemDTO.ItemToDTO(todoItem); + } + catch (Exception ex) + { + _logger.LogError($"GetTodoItem (id:{id}): {ex.Message}\n{ex.StackTrace}"); + return BadRequest(ex.Message); + } } - [HttpPut("{id}")] + [HttpPut("{id}/update")] public async Task UpdateTodoItem(long id, TodoItemDTO todoItemDTO) { - if (id != todoItemDTO.Id) + if (id < 0) { - return BadRequest(); + _logger.LogError($"UpdateTodoItem: invalid id value ({id})"); + return BadRequest("Invalid id"); } - - var todoItem = await _context.TodoItems.FindAsync(id); - if (todoItem == null) + if (todoItemDTO == null) { - return NotFound(); + _logger.LogError($"UpdateTodoItem: request is null (id:{id})"); + return BadRequest("Request is null"); } - todoItem.Name = todoItemDTO.Name; - todoItem.IsComplete = todoItemDTO.IsComplete; + if (id != todoItemDTO.Id) + { + _logger.LogError($"UpdateTodoItem: ids mismatch (parameter id:{id}, request id:{todoItemDTO?.Id})"); + return BadRequest("Ids mismatch"); + } try { - await _context.SaveChangesAsync(); + var result = await _todoService.Update(todoItemDTO); + return HandleActionResult(result, $"UpdateTodoItem (id:{id})"); } - catch (DbUpdateConcurrencyException) when (!TodoItemExists(id)) + catch (Exception ex) { - return NotFound(); + _logger.LogError($"UpdateTodoItem (id:{id}): {ex.Message}\n{ex.StackTrace}"); + return BadRequest(ex.Message); } - - return NoContent(); } + [Route("create")] [HttpPost] public async Task> CreateTodoItem(TodoItemDTO todoItemDTO) { - var todoItem = new TodoItem + try { - IsComplete = todoItemDTO.IsComplete, - Name = todoItemDTO.Name - }; - - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - return CreatedAtAction( - nameof(GetTodoItem), - new { id = todoItem.Id }, - ItemToDTO(todoItem)); + var todoItem = await _todoService.Create(todoItemDTO); + if (todoItem == null) + { + _logger.LogError($"CreateTodoItem: cannot create item"); + return BadRequest("Couldn't create TODO item"); + } + + return CreatedAtAction( + nameof(GetTodoItem), + new { id = todoItem.Id }, + TodoItemDTO.ItemToDTO(todoItem)); + } + catch (Exception ex) + { + _logger.LogError($"CreateTodoItem: {ex.Message}\n{ex.StackTrace}"); + return BadRequest(ex.Message); + } } - [HttpDelete("{id}")] + [HttpDelete("{id}/delete")] public async Task DeleteTodoItem(long id) { - var todoItem = await _context.TodoItems.FindAsync(id); - - if (todoItem == null) + if (id < 0) { - return NotFound(); + _logger.LogError($"DeleteTodoItem: invalid id ({id})"); + return BadRequest("Invalid id"); } - _context.TodoItems.Remove(todoItem); - await _context.SaveChangesAsync(); - - return NoContent(); + try + { + var result = await _todoService.Delete(id); + return HandleActionResult(result, $"DeleteTodoItem (id:{id})"); + } + catch (Exception ex) + { + _logger.LogError($"DeleteTodoItem (id:{id}): {ex.Message}\n{ex.StackTrace}"); + return BadRequest(ex.Message); + } } - - private bool TodoItemExists(long id) => - _context.TodoItems.Any(e => e.Id == id); - - private static TodoItemDTO ItemToDTO(TodoItem todoItem) => - new TodoItemDTO + + private IActionResult HandleActionResult(TodoItemActionResult result, string actionName) + { + switch (result) { - Id = todoItem.Id, - Name = todoItem.Name, - IsComplete = todoItem.IsComplete - }; + case TodoItemActionResult.Success: + return NoContent(); + case TodoItemActionResult.NotFound: + _logger.LogError($"{actionName}: item not found"); + return NotFound("TODO item not found"); + default: + return NoContent(); + } + } } } diff --git a/Data/Repositories/TodoRepository.cs b/Data/Repositories/TodoRepository.cs new file mode 100644 index 00000000..53ac9c91 --- /dev/null +++ b/Data/Repositories/TodoRepository.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using System.Linq; +using System.Threading.Tasks; +using TodoApi.Data; +using TodoApi.Models; +using TodoApiDTO.Models; + +namespace TodoApiDTO.Data +{ + public class TodoRepository : ITodoRepository + { + private readonly TodoContext _context; + + public TodoRepository(TodoContext context) + { + _context = context; + } + + public async Task Create(TodoItemDTO input) + { + var createdTodoItem = await _context.TodoItems.AddAsync(new TodoItem { IsComplete = input.IsComplete, Name = input.Name }); + await _context.SaveChangesAsync(); + + return createdTodoItem?.Entity; + } + + public async Task Delete(long itemToDeleteId) + { + var itemToDelete = await _context.TodoItems.FindAsync(itemToDeleteId); + + if (itemToDelete == null) + { + return TodoItemActionResult.NotFound; + } + + _context.TodoItems.Remove(itemToDelete); + await _context.SaveChangesAsync(); + + return TodoItemActionResult.Success; + } + + public async Task GetById(long itemId) + { + return await _context.TodoItems.FindAsync(itemId); + } + + public DbSet GetList() + { + return _context.TodoItems; + } + + public async Task Update(TodoItemDTO input) + { + var todoItemToUpdate = await _context.TodoItems.FindAsync(input.Id); + if (todoItemToUpdate == null) + { + return TodoItemActionResult.NotFound; + } + + todoItemToUpdate.Name = input.Name; + todoItemToUpdate.IsComplete = input.IsComplete; + + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException ex) when (!_context.TodoItems.Any(e => e.Id == input.Id)) + { + throw ex; + } + + return TodoItemActionResult.Success; + } + } +} diff --git a/Models/TodoContext.cs b/Data/TodoContext.cs similarity index 85% rename from Models/TodoContext.cs rename to Data/TodoContext.cs index 6e59e363..05b0c28e 100644 --- a/Models/TodoContext.cs +++ b/Data/TodoContext.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; +using TodoApi.Models; -namespace TodoApi.Models +namespace TodoApi.Data { public class TodoContext : DbContext { diff --git a/Data/Utilities/DataHelpers.cs b/Data/Utilities/DataHelpers.cs new file mode 100644 index 00000000..3d7c76fd --- /dev/null +++ b/Data/Utilities/DataHelpers.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TodoApi.Data; +using TodoApiDTO.Models; + +namespace TodoApiDTO.Data +{ + public static class DataHelpers + { + public static void SetDbConnection(IServiceCollection services, IConfiguration config) + { + services.AddDbContext(opt => opt.UseSqlServer(config.GetConnectionString("MsSqlAuth"))); + } + + public static void Configure(IServiceCollection services) + { + services.AddScoped(); + } + } +} diff --git a/Models/Repositories/ITodoRepository.cs b/Models/Repositories/ITodoRepository.cs new file mode 100644 index 00000000..f61ee512 --- /dev/null +++ b/Models/Repositories/ITodoRepository.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using System.Collections.Generic; +using System.Threading.Tasks; +using TodoApi.Models; + +namespace TodoApiDTO.Models +{ + public interface ITodoRepository + { + Task GetById(long itemId); + Task Create(TodoItemDTO input); + Task Update(TodoItemDTO input); + Task Delete(long itemToDeleteId); + DbSet GetList(); + } +} diff --git a/Models/Services/ITodoService.cs b/Models/Services/ITodoService.cs new file mode 100644 index 00000000..6a2d887c --- /dev/null +++ b/Models/Services/ITodoService.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using System.Collections.Generic; +using System.Threading.Tasks; +using TodoApiDTO.Models; + +namespace TodoApi.Models +{ + public interface ITodoService + { + Task GetById(long itemId); + Task Create(TodoItemDTO createInput); + Task Update(TodoItemDTO updateInput); + Task Delete(long itemToDeleteId); + DbSet GetList(); + } +} diff --git a/Models/TodoItem.cs b/Models/TodoItem/TodoItem.cs similarity index 62% rename from Models/TodoItem.cs rename to Models/TodoItem/TodoItem.cs index 1f6e5465..2b2c941f 100644 --- a/Models/TodoItem.cs +++ b/Models/TodoItem/TodoItem.cs @@ -1,8 +1,11 @@ -namespace TodoApi.Models +using System.ComponentModel.DataAnnotations.Schema; + +namespace TodoApi.Models { #region snippet public class TodoItem { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public long Id { get; set; } public string Name { get; set; } public bool IsComplete { get; set; } diff --git a/Models/TodoItem/TodoItemActionResult.cs b/Models/TodoItem/TodoItemActionResult.cs new file mode 100644 index 00000000..7afc28fe --- /dev/null +++ b/Models/TodoItem/TodoItemActionResult.cs @@ -0,0 +1,8 @@ +namespace TodoApiDTO.Models +{ + public enum TodoItemActionResult: byte + { + Success = 0, + NotFound = 2 + } +} diff --git a/Models/TodoItem/TodoItemDTO.cs b/Models/TodoItem/TodoItemDTO.cs new file mode 100644 index 00000000..20bf1709 --- /dev/null +++ b/Models/TodoItem/TodoItemDTO.cs @@ -0,0 +1,21 @@ +namespace TodoApi.Models +{ + #region snippet + public class TodoItemDTO + { + public long Id { get; set; } + public string Name { get; set; } + public bool IsComplete { get; set; } + + private TodoItemDTO() { } + + public static TodoItemDTO ItemToDTO(TodoItem todoItem) => + new TodoItemDTO + { + Id = todoItem.Id, + Name = todoItem.Name, + IsComplete = todoItem.IsComplete + }; + } + #endregion +} diff --git a/Models/TodoItemDTO.cs b/Models/TodoItemDTO.cs deleted file mode 100644 index e66a500a..00000000 --- a/Models/TodoItemDTO.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace TodoApi.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/Program.cs b/Program.cs index b27ac16a..4fe9a571 100644 --- a/Program.cs +++ b/Program.cs @@ -1,11 +1,5 @@ -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 { diff --git a/README.md b/README.md index 466e41fd..65dfa52e 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# TodoService \ No newline at end of file +# TodoService + + 1 \ No newline at end of file diff --git a/Scripts/13-03-2023 sharikov add TodoItems.sql b/Scripts/13-03-2023 sharikov add TodoItems.sql new file mode 100644 index 00000000..0ec096de --- /dev/null +++ b/Scripts/13-03-2023 sharikov add TodoItems.sql @@ -0,0 +1,8 @@ +CREATE TABLE TodoItems +( + Id bigint NOT NULL IDENTITY(1,1), + Name nvarchar(255), + IsComplete bit NOT NULL, + Secret nvarchar(255), + PRIMARY KEY (Id) +); \ No newline at end of file diff --git a/Startup.cs b/Startup.cs index bbfbc83d..75958eda 100644 --- a/Startup.cs +++ b/Startup.cs @@ -1,17 +1,11 @@ -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; +using TodoApiDTO; +using TodoApiDTO.Data; +using TodoApiDTO.Services; namespace TodoApi { @@ -27,8 +21,14 @@ public Startup(IConfiguration configuration) // 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")); + DataHelpers.SetDbConnection(services, Configuration); + DataHelpers.Configure(services); + + ServiceHelpers.Configure(services); + + StartupHelpers.InitSwagger(services); + StartupHelpers.InitLogging(services); + services.AddControllers(); } @@ -40,6 +40,13 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseDeveloperExceptionPage(); } + app.UseSwagger(); + + app.UseSwaggerUI(c => { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "v1.0.0"); + c.RoutePrefix = string.Empty; + }); + app.UseHttpsRedirection(); app.UseRouting(); diff --git a/TodoApiDTO.csproj b/TodoApiDTO.csproj index bba6f6af..f760bf83 100644 --- a/TodoApiDTO.csproj +++ b/TodoApiDTO.csproj @@ -1,9 +1,16 @@ - + netcoreapp3.1 + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -12,6 +19,8 @@ + + diff --git a/Utilities/StartupHelpers.cs b/Utilities/StartupHelpers.cs new file mode 100644 index 00000000..96f93b2e --- /dev/null +++ b/Utilities/StartupHelpers.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi.Models; +using System; +using System.Text; + +namespace TodoApiDTO +{ + public static class StartupHelpers + { + public static void InitSwagger(IServiceCollection services) + { + services.AddSwaggerGen(); + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo + { + Version = "v1", + Title = "TODO List - API для управления списком задач", + Description = "Тестовое задание (вариант №1) для Velvetech by Шариков Роман a.k.a. R0m43ss", + }); + }); + + } + + public static void InitLogging(IServiceCollection services) + { + services.AddLogging(loggingBuilder => { + loggingBuilder.AddFile("!log\\{0:yyyy}-{0:MM}-{0:dd}.log", fileLoggerOptions => { + fileLoggerOptions.FormatLogFileName = fileName => { + return string.Format(fileName, DateTime.Now); + }; + fileLoggerOptions.FilterLogEntry = (logMessage) => { + return logMessage.LogLevel == LogLevel.Error; + }; + fileLoggerOptions.FormatLogEntry = (logMessage) => + { + var sb = new StringBuilder(); + sb.Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss ")); + sb.Append($"{Enum.GetName(typeof(LogLevel), logMessage.LogLevel).ToUpper()} "); + sb.Append(logMessage.Message); + return sb.ToString(); + }; + }); + }); + } + } +} diff --git a/appsettings.json b/appsettings.json index d9d9a9bf..2ca5a612 100644 --- a/appsettings.json +++ b/appsettings.json @@ -1,10 +1,15 @@ { "Logging": { "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" + "Default": "None", + "Microsoft": "None", + "Microsoft.Hosting.Lifetime": "None", + "Microsoft.EntityFrameworkCore.Database.Command": "None" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ConnectionStrings": { + "WinAuth": "Server=localhost\\SQLEXPRESS;Database=master;Trusted_Connection=True;MultipleActiveResultSets=True", + "MsSqlAuth": "Server=localhost\\SQLEXPRESS;Database=master;User ID=sharikov;Password=1234567890;Trusted_Connection=True;MultipleActiveResultSets=True" + } }