From c0bf21fdb4905a7ea06f9b3a107c3e36c2f4e70d Mon Sep 17 00:00:00 2001 From: Aziz Kudaikulov Date: Tue, 22 Aug 2023 19:34:12 +0600 Subject: [PATCH] refactor, add sql server database, add logging --- Controllers/TodoItemsController.cs | 110 +++++++++++------- Migrations/20230822121205_Initial.Designer.cs | 62 ++++++++++ Migrations/20230822121205_Initial.cs | 41 +++++++ Migrations/TodoContextModelSnapshot.cs | 60 ++++++++++ Models/TodoContext.cs | 14 ++- Models/TodoItem.cs | 12 +- Models/TodoItemDTO.cs | 35 +++++- Program.cs | 6 - Seeds/SeedTodo.cs | 19 +++ Services/ITodoService.cs | 22 ++++ Services/TodoService.cs | 49 ++++++++ Startup.cs | 52 +++++++-- TodoApiDTO.csproj | 10 +- appsettings.json | 3 + 14 files changed, 428 insertions(+), 67 deletions(-) create mode 100644 Migrations/20230822121205_Initial.Designer.cs create mode 100644 Migrations/20230822121205_Initial.cs create mode 100644 Migrations/TodoContextModelSnapshot.cs create mode 100644 Seeds/SeedTodo.cs create mode 100644 Services/ITodoService.cs create mode 100644 Services/TodoService.cs diff --git a/Controllers/TodoItemsController.cs b/Controllers/TodoItemsController.cs index 0ef138e7..8f463dcb 100644 --- a/Controllers/TodoItemsController.cs +++ b/Controllers/TodoItemsController.cs @@ -1,66 +1,94 @@ +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; using System.Collections.Generic; -using System.Linq; +using System.Threading; using System.Threading.Tasks; using TodoApi.Models; +using TodoApi.Services; namespace TodoApi.Controllers { + [Produces("application/json")] [Route("api/[controller]")] [ApiController] - public class TodoItemsController : ControllerBase + public sealed 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 ?? throw new ArgumentNullException(nameof(todoService)); + + _logger = logger; } + /// List of all todo items + /// Returns the list of all todo items + /// Returns the list of all todo items [HttpGet] - public async Task>> GetTodoItems() + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetTodoItems(CancellationToken cancellationToken) { - return await _context.TodoItems - .Select(x => ItemToDTO(x)) - .ToListAsync(); + return await _todoService + .List() + .ToListAsync(cancellationToken); } - [HttpGet("{id}")] + /// Get the todo item for the id + /// The todo's id to get the todo item for + /// The todo item for the id + /// The todo item for the id + /// The todo item for this id not found + [HttpGet("{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> GetTodoItem(long id) { - var todoItem = await _context.TodoItems.FindAsync(id); + var todoItem = await _todoService.FindAsync(id); if (todoItem == null) { return NotFound(); } - return ItemToDTO(todoItem); + return TodoItemDTO.From(todoItem); } + /// Update the todo item for the id + /// The todo's id to update the todo item for + /// The todo's information to update for + /// Cancellation token to monitor cancellation + /// The todo item for the id updated successfully + /// The todo item for this id not found + /// The Id field value in request body doesnt match with id parameter [HttpPut("{id}")] - public async Task UpdateTodoItem(long id, TodoItemDTO todoItemDTO) + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task UpdateTodoItem(long id, TodoItemDTO todoItemDTO, CancellationToken cancellationToken) { if (id != todoItemDTO.Id) { return BadRequest(); } - var todoItem = await _context.TodoItems.FindAsync(id); + var todoItem = await _todoService.FindAsync(id); if (todoItem == null) { return NotFound(); } - todoItem.Name = todoItemDTO.Name; - todoItem.IsComplete = todoItemDTO.IsComplete; + todoItem.Update(todoItemDTO.Name, todoItemDTO.IsComplete); try { - await _context.SaveChangesAsync(); + await _todoService.SaveChangesAsync(cancellationToken); } - catch (DbUpdateConcurrencyException) when (!TodoItemExists(id)) + catch (DbUpdateConcurrencyException) when (!_todoService.TodoItemExists(id)) { return NotFound(); } @@ -68,49 +96,45 @@ public async Task UpdateTodoItem(long id, TodoItemDTO todoItemDTO return NoContent(); } + /// Create new todo item + /// The todo's information to create for + /// Cancellation token to monitor cancellation + /// The created todo item + /// New todo item created successfully [HttpPost] - public async Task> CreateTodoItem(TodoItemDTO todoItemDTO) + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task> CreateTodoItem(TodoItemDTO todoItemDTO, CancellationToken cancellationToken) { - var todoItem = new TodoItem - { - IsComplete = todoItemDTO.IsComplete, - Name = todoItemDTO.Name - }; + var todoItem = todoItemDTO.ToTodoItem(); - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); + await _todoService.CreateAsync(todoItem, cancellationToken); return CreatedAtAction( nameof(GetTodoItem), new { id = todoItem.Id }, - ItemToDTO(todoItem)); + TodoItemDTO.From(todoItem)); } + /// Delete todo item for this id + /// The todo's id to delete the todo item for + /// Cancellation token to monitor cancellation + /// The todo item for the id deleted successfully + /// The todo item for this id not found [HttpDelete("{id}")] - public async Task DeleteTodoItem(long id) + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteTodoItem(long id, CancellationToken cancellationToken) { - var todoItem = await _context.TodoItems.FindAsync(id); + var todoItem = await _todoService.FindAsync(id); if (todoItem == null) { return NotFound(); } - _context.TodoItems.Remove(todoItem); - await _context.SaveChangesAsync(); + await _todoService.RemoveAsync(todoItem, cancellationToken); 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/Migrations/20230822121205_Initial.Designer.cs b/Migrations/20230822121205_Initial.Designer.cs new file mode 100644 index 00000000..d281ce6a --- /dev/null +++ b/Migrations/20230822121205_Initial.Designer.cs @@ -0,0 +1,62 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TodoApi.Models; + +namespace TodoApiDTO.Migrations +{ + [DbContext(typeof(TodoContext))] + [Migration("20230822121205_Initial")] + partial class Initial + { + 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("TodoApi.Models.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"); + + b.HasData( + new + { + Id = 1L, + IsComplete = true, + Name = "Todo #1", + Secret = "Secret A" + }, + new + { + Id = 2L, + IsComplete = false, + Name = "Todo #2", + Secret = "Secret B" + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20230822121205_Initial.cs b/Migrations/20230822121205_Initial.cs new file mode 100644 index 00000000..6a7b7501 --- /dev/null +++ b/Migrations/20230822121205_Initial.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace TodoApiDTO.Migrations +{ + public partial class Initial : 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); + }); + + migrationBuilder.InsertData( + table: "TodoItems", + columns: new[] { "Id", "IsComplete", "Name", "Secret" }, + values: new object[] { 1L, true, "Todo #1", "Secret A" }); + + migrationBuilder.InsertData( + table: "TodoItems", + columns: new[] { "Id", "IsComplete", "Name", "Secret" }, + values: new object[] { 2L, false, "Todo #2", "Secret B" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TodoItems"); + } + } +} diff --git a/Migrations/TodoContextModelSnapshot.cs b/Migrations/TodoContextModelSnapshot.cs new file mode 100644 index 00000000..f461e094 --- /dev/null +++ b/Migrations/TodoContextModelSnapshot.cs @@ -0,0 +1,60 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TodoApi.Models; + +namespace TodoApiDTO.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("TodoApi.Models.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"); + + b.HasData( + new + { + Id = 1L, + IsComplete = true, + Name = "Todo #1", + Secret = "Secret A" + }, + new + { + Id = 2L, + IsComplete = false, + Name = "Todo #2", + Secret = "Secret B" + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Models/TodoContext.cs b/Models/TodoContext.cs index 6e59e363..35f548b0 100644 --- a/Models/TodoContext.cs +++ b/Models/TodoContext.cs @@ -1,14 +1,20 @@ using Microsoft.EntityFrameworkCore; +using TodoApi.Seeds; namespace TodoApi.Models { public class TodoContext : DbContext { public TodoContext(DbContextOptions options) - : base(options) - { - } + : base(options) { } public DbSet TodoItems { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + SeedTodo.Seed(modelBuilder); + + base.OnModelCreating(modelBuilder); + } } -} \ No newline at end of file +} diff --git a/Models/TodoItem.cs b/Models/TodoItem.cs index 1f6e5465..5675289f 100644 --- a/Models/TodoItem.cs +++ b/Models/TodoItem.cs @@ -4,9 +4,15 @@ public class TodoItem { public long Id { get; set; } - public string Name { get; set; } + public string Name { get; set; } = ""; public bool IsComplete { get; set; } - public string Secret { get; set; } + public string Secret { get; set; } = ""; + + public void Update(string name, bool isComplete) + { + Name = name; + IsComplete = isComplete; + } } #endregion -} \ No newline at end of file +} diff --git a/Models/TodoItemDTO.cs b/Models/TodoItemDTO.cs index e66a500a..e16513cc 100644 --- a/Models/TodoItemDTO.cs +++ b/Models/TodoItemDTO.cs @@ -1,11 +1,40 @@ -namespace TodoApi.Models +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace TodoApi.Models { #region snippet public class TodoItemDTO { - public long Id { get; set; } - public string Name { get; set; } + /// Todo's Id + public long? Id { get; set; } + + /// Todo's name + [Required] + public string Name { get; set; } = ""; + + /// Completion condition of this todo + [DefaultValue(false)] public bool IsComplete { get; set; } + + public static TodoItemDTO From(TodoItem todoItem) + { + return new TodoItemDTO + { + Id = todoItem.Id, + Name = todoItem.Name, + IsComplete = todoItem.IsComplete + }; + } + + public TodoItem ToTodoItem() + { + return new TodoItem + { + IsComplete = IsComplete, + Name = Name + }; + } } #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/Seeds/SeedTodo.cs b/Seeds/SeedTodo.cs new file mode 100644 index 00000000..ea5d5cb8 --- /dev/null +++ b/Seeds/SeedTodo.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using TodoApi.Models; + +namespace TodoApi.Seeds +{ + public static class SeedTodo + { + public static void Seed(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasData + ( + new TodoItem { Id = 1, Name = "Todo #1", IsComplete = true, Secret = "Secret A" }, + new TodoItem { Id = 2, Name = "Todo #2", IsComplete = false, Secret = "Secret B" } + ); + } + } +} diff --git a/Services/ITodoService.cs b/Services/ITodoService.cs new file mode 100644 index 00000000..3840581a --- /dev/null +++ b/Services/ITodoService.cs @@ -0,0 +1,22 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using TodoApi.Models; + +namespace TodoApi.Services +{ + public interface ITodoService + { + bool TodoItemExists(long id); + + IQueryable List(); + + Task FindAsync(long id); + + Task RemoveAsync(TodoItem todoItem, CancellationToken cancellationToken); + + Task CreateAsync(TodoItem todoItem, CancellationToken cancellationToken); + + Task SaveChangesAsync(CancellationToken cancellationToken); + } +} diff --git a/Services/TodoService.cs b/Services/TodoService.cs new file mode 100644 index 00000000..ee95d997 --- /dev/null +++ b/Services/TodoService.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using TodoApi.Models; + +namespace TodoApi.Services +{ + public sealed class TodoService : ITodoService + { + private readonly TodoContext _context; + + public TodoService(TodoContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public bool TodoItemExists(long id) => + _context.TodoItems.Any(e => e.Id == id); + + public IQueryable List() + { + return _context.TodoItems + .Select(x => TodoItemDTO.From(x)); + } + + public async Task FindAsync(long id) + { + return await _context.TodoItems.FindAsync(id); + } + + public async Task RemoveAsync(TodoItem todoItem, CancellationToken cancellationToken) + { + _context.TodoItems.Remove(todoItem); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task CreateAsync(TodoItem todoItem, CancellationToken cancellationToken) + { + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + await _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/Startup.cs b/Startup.cs index bbfbc83d..37603183 100644 --- a/Startup.cs +++ b/Startup.cs @@ -1,17 +1,16 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using System.IO; +using System.Reflection; 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 Microsoft.OpenApi.Models; using TodoApi.Models; +using TodoApi.Services; namespace TodoApi { @@ -28,12 +27,38 @@ public Startup(IConfiguration configuration) public void ConfigureServices(IServiceCollection services) { services.AddDbContext(opt => - opt.UseInMemoryDatabase("TodoList")); + opt.UseSqlServer( + Configuration.GetConnectionString("Todo") + )); + + services.AddTransient(options => + new TodoService(options.GetRequiredService())); + services.AddControllers(); + + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo + { + Version = "v1", + Title = "Todo API", + Description = "API to manage Todo list", + Contact = new OpenApiContact + { + Name = "Aziz Kudaikulov", + Email = "aziz.kudaikulov@gmail.com", + Url = new Uri("https://github.com/azizka85") + } + }); + + var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + c.IncludeXmlComments(xmlPath); + }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory) { if (env.IsDevelopment()) { @@ -42,6 +67,13 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseHttpsRedirection(); + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Todo API V1"); + c.RoutePrefix = string.Empty; + }); + app.UseRouting(); app.UseAuthorization(); @@ -50,6 +82,12 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { endpoints.MapControllers(); }); + + loggerFactory.AddFile( + Path.Combine( + AppContext.BaseDirectory, + "Logs", + "log.txt")); } } } diff --git a/TodoApiDTO.csproj b/TodoApiDTO.csproj index bba6f6af..92c8dbf6 100644 --- a/TodoApiDTO.csproj +++ b/TodoApiDTO.csproj @@ -1,7 +1,13 @@ - netcoreapp3.1 + netcoreapp3.1 + enable + + + + true + $(NoWarn);1591 @@ -12,6 +18,8 @@ + + diff --git a/appsettings.json b/appsettings.json index d9d9a9bf..a7d5c510 100644 --- a/appsettings.json +++ b/appsettings.json @@ -1,4 +1,7 @@ { + "ConnectionStrings": { + "Todo": "Server=sql.bsite.net\\MSSQL2016;Database=azizkaz_todos;User ID=azizkaz_todos;Password=lock;" + }, "Logging": { "LogLevel": { "Default": "Information",