diff --git a/.gitignore b/.gitignore index a62e968..77dd692 100644 --- a/.gitignore +++ b/.gitignore @@ -482,3 +482,5 @@ $RECYCLE.BIN/ */**/obj/Release /exercise.pizzashopapi/appsettings.json /exercise.pizzashopapi/appsettings.Development.json + +/exercise.pizzashopapi/Migrations/* diff --git a/exercise.pizzashopapi/DTO/CustomerResponse.cs b/exercise.pizzashopapi/DTO/CustomerResponse.cs new file mode 100644 index 0000000..0623c4c --- /dev/null +++ b/exercise.pizzashopapi/DTO/CustomerResponse.cs @@ -0,0 +1,7 @@ +namespace exercise.pizzashopapi.DTO; + +public class CustomerResponse +{ + public int Id { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/exercise.pizzashopapi/DTO/OrderPut.cs b/exercise.pizzashopapi/DTO/OrderPut.cs new file mode 100644 index 0000000..c5467a1 --- /dev/null +++ b/exercise.pizzashopapi/DTO/OrderPut.cs @@ -0,0 +1,6 @@ +namespace exercise.pizzashopapi.DTO; + +public class OrderPut +{ + public List ToppingIds { get; set; } +} \ No newline at end of file diff --git a/exercise.pizzashopapi/DTO/OrderResponse.cs b/exercise.pizzashopapi/DTO/OrderResponse.cs new file mode 100644 index 0000000..f538e47 --- /dev/null +++ b/exercise.pizzashopapi/DTO/OrderResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using exercise.pizzashopapi.Enums; + +namespace exercise.pizzashopapi.DTO; + +public class OrderResponse +{ + public int Id { get; set; } + public string Status { get; set; } + public DateTime OrderDate { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CustomerResponse? Customer { get; set; } + public PizzaResponse Pizza { get; set; } + public IEnumerable Toppings { get; set; } = new List(); +} \ No newline at end of file diff --git a/exercise.pizzashopapi/DTO/PizzaResponse.cs b/exercise.pizzashopapi/DTO/PizzaResponse.cs new file mode 100644 index 0000000..df6a8b9 --- /dev/null +++ b/exercise.pizzashopapi/DTO/PizzaResponse.cs @@ -0,0 +1,8 @@ +namespace exercise.pizzashopapi.DTO; + +public class PizzaResponse +{ + public int Id { get; set; } + public string Name { get; set; } + public decimal Price { get; set; } +} \ No newline at end of file diff --git a/exercise.pizzashopapi/Data/DataContext.cs b/exercise.pizzashopapi/Data/DataContext.cs index 129199e..14a0e56 100644 --- a/exercise.pizzashopapi/Data/DataContext.cs +++ b/exercise.pizzashopapi/Data/DataContext.cs @@ -1,28 +1,53 @@ -using exercise.pizzashopapi.Models; +using System.Diagnostics; +using exercise.pizzashopapi.Models; using Microsoft.EntityFrameworkCore; -namespace exercise.pizzashopapi.Data +namespace exercise.pizzashopapi.Data; + +public class DataContext : DbContext { - public class DataContext : DbContext - { - private string connectionString; - public DataContext() - { - var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); - connectionString = configuration.GetValue("ConnectionStrings:DefaultConnectionString"); + private readonly string _connectionString; - } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(connectionString); + public DataContext() + { + var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + _connectionString = configuration.GetValue("ConnectionStrings:DefaultConnectionString")!; + } - //set primary of order? + public DbSet Pizzas { get; set; } + public DbSet Customers { get; set; } + public DbSet Orders { get; set; } + public DbSet Toppings { get; set; } - //seed data? + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasKey(o => o.Id); + modelBuilder.Entity() + .HasOne(o => o.Customer) + .WithMany(c => c.Orders) + .HasForeignKey(o => o.CustomerId); + modelBuilder.Entity() + .HasOne(o => o.Pizza) + .WithMany() + .HasForeignKey(o => o.PizzaId); + modelBuilder.Entity() + .HasMany(o => o.Toppings) + .WithMany() + .UsingEntity>( + "OrderToppings", + r => r.HasOne().WithMany().HasForeignKey("ToppingId"), + l => l.HasOne().WithMany().HasForeignKey("OrderId") + ); + modelBuilder.Entity() + .HasKey(c => c.Id); + modelBuilder.Entity() + .HasKey(p => p.Id); + } - } - public DbSet Pizzas { get; set; } - public DbSet Customers { get; set; } - public DbSet Orders { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString); + optionsBuilder.LogTo(message => Debug.WriteLine(message)); } -} +} \ No newline at end of file diff --git a/exercise.pizzashopapi/Data/Seeder.cs b/exercise.pizzashopapi/Data/Seeder.cs index f87fbef..37ffecd 100644 --- a/exercise.pizzashopapi/Data/Seeder.cs +++ b/exercise.pizzashopapi/Data/Seeder.cs @@ -10,22 +10,43 @@ public async static void SeedPizzaShopApi(this WebApplication app) { if(!db.Customers.Any()) { - db.Add(new Customer() { Name="Nigel" }); - db.Add(new Customer() { Name = "Dave" }); + db.Add(new Customer() { Id = 1, Name="Nigel" }); + db.Add(new Customer() { Id = 2, Name = "Dave" }); + db.Add(new Customer() { Id = 3, Name = "Magnus" }); await db.SaveChangesAsync(); } if(!db.Pizzas.Any()) { - db.Add(new Pizza() { Name = "Cheese & Pineapple" }); - db.Add(new Pizza() { Name = "Vegan Cheese Tastic" }); + db.Add(new Pizza() { Id = 1, Name = "Cheese & Pineapple", Price = 9.99m }); + db.Add(new Pizza() { Id = 2, Name = "Vegan Cheese Tastic", Price = 12.99m }); + db.Add(new Pizza() { Id = 3, Name = "Mighty Meat", Price = 14.99m }); await db.SaveChangesAsync(); - } - - //order data - if(1==1) + + if (!db.Toppings.Any()) { + db.Add(new Topping() { Id = 1, Name = "Bacon" }); + db.Add(new Topping() { Id = 2, Name = "Mushrooms" }); + db.Add(new Topping() { Id = 3, Name = "Olives" }); + await db.SaveChangesAsync(); + } + if(!db.Orders.Any()) + { + db.Add(new Order() { CustomerId = 1, PizzaId = 1, OrderDate = DateTime.Parse("2021-01-01").ToUniversalTime() }); + db.Add(new Order() { CustomerId = 2, PizzaId = 2, OrderDate = DateTime.Parse("2021-01-01").ToUniversalTime(), Delivered = true }); + var order = db.Add(new Order() { CustomerId = 3, PizzaId = 3, OrderDate = DateTime.Parse("2025-01-27").ToUniversalTime() }); + + await db.SaveChangesAsync(); + + // Toppings for testing + //var order = db.Orders.Find(2); + order.Entity.Toppings = new List + { + await db.Toppings.FindAsync(1), + await db.Toppings.FindAsync(2) + }; + await db.SaveChangesAsync(); } } diff --git a/exercise.pizzashopapi/EndPoints/PizzaShopApi.cs b/exercise.pizzashopapi/EndPoints/PizzaShopApi.cs index f8be2b0..66cc12f 100644 --- a/exercise.pizzashopapi/EndPoints/PizzaShopApi.cs +++ b/exercise.pizzashopapi/EndPoints/PizzaShopApi.cs @@ -1,4 +1,7 @@ -using exercise.pizzashopapi.Repository; +using AutoMapper; +using exercise.pizzashopapi.DTO; +using exercise.pizzashopapi.Models; +using exercise.pizzashopapi.Repository; using Microsoft.AspNetCore.Mvc; namespace exercise.pizzashopapi.EndPoints @@ -7,9 +10,81 @@ public static class PizzaShopApi { public static void ConfigurePizzaShopApi(this WebApplication app) { - + var ordergroup = app.MapGroup("orders"); + ordergroup.MapGet("/", GetOrders); + ordergroup.MapGet("/{id}", GetOrder); + ordergroup.MapGet("/customer/{id}", GetOrdersByCustomerId); + ordergroup.MapPut("/{id}", UpdateOrder); + ordergroup.MapPost("/markDelivered/{id}", MarkOrderDelivered); } - + private static async Task GetOrders(IRepository repository, IMapper mapper) + { + var orders = await repository.GetAll(o => o.Customer, o => o.Pizza, o => o.Toppings); + var response = mapper.Map>(orders); + + return TypedResults.Ok(response); + } + + private static async Task GetOrder(IRepository repository, IMapper mapper, int id) + { + var order = await repository.Get(o => o.Id == id, o => o.Customer, o => o.Pizza, o => o.Toppings); + var response = mapper.Map(order); + + return TypedResults.Ok(response); + } + + private static async Task GetOrdersByCustomerId(IRepository repository, IMapper mapper, int id) + { + var orders = await repository.GetAll(o => o.CustomerId == id, o => o.Pizza, o => o.Toppings); + var response = mapper.Map>(orders); + + return TypedResults.Ok(response); + } + + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + private static async Task UpdateOrder(IRepository orderRepository, IRepository toppingRepository, IMapper mapper, int id, [FromBody] OrderPut body) + { + var order = await orderRepository.Get(o => o.Id == id); + if (order == null) return TypedResults.NotFound(); + + var validToppings = new HashSet(); + foreach (var toppingId in body.ToppingIds) + { + var topping = await toppingRepository.Get(t => t.Id == toppingId); + if (topping != null) + { + validToppings.Add(topping); + } + } + + foreach (var topping in validToppings) + { + if (order.Toppings is not null && !order.Toppings.Contains(topping)) + { + return TypedResults.BadRequest("Can't remove already added toppings."); + } + } + + order.Toppings = validToppings.ToList(); + await orderRepository.Update(order); + + return TypedResults.NoContent(); + } + + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + private static async Task MarkOrderDelivered(IRepository repository, int id) + { + var order = await repository.Get(o => o.Id == id); + if (order == null) return TypedResults.NotFound(); + + order.Delivered = true; + await repository.Update(order); + + return TypedResults.NoContent(); + } } } diff --git a/exercise.pizzashopapi/Enums/OrderStatus.cs b/exercise.pizzashopapi/Enums/OrderStatus.cs new file mode 100644 index 0000000..4087e73 --- /dev/null +++ b/exercise.pizzashopapi/Enums/OrderStatus.cs @@ -0,0 +1,9 @@ +namespace exercise.pizzashopapi.Enums; + +public enum OrderStatus +{ + Preparing, + Cooking, + Delivering, + Delivered +} \ No newline at end of file diff --git a/exercise.pizzashopapi/Mapper/AutoMapperProfile.cs b/exercise.pizzashopapi/Mapper/AutoMapperProfile.cs new file mode 100644 index 0000000..c38f2dd --- /dev/null +++ b/exercise.pizzashopapi/Mapper/AutoMapperProfile.cs @@ -0,0 +1,15 @@ +using AutoMapper; + +namespace exercise.pizzashopapi.Mapper; + +public class AutoMapperProfile : Profile +{ + public AutoMapperProfile() + { + CreateMap() + .ForMember(dest => dest.Customer, opt => opt.MapFrom(src => src.Customer)) + .ForMember(dest => dest.Toppings, opt => opt.MapFrom(src => src.Toppings.Select(t => t.Name))); + CreateMap(); + CreateMap(); + } +} \ No newline at end of file diff --git a/exercise.pizzashopapi/Models/Customer.cs b/exercise.pizzashopapi/Models/Customer.cs index 2ca83bd..76ca184 100644 --- a/exercise.pizzashopapi/Models/Customer.cs +++ b/exercise.pizzashopapi/Models/Customer.cs @@ -6,5 +6,6 @@ public class Customer { public int Id { get; set; } public string Name { get; set; } + public virtual List? Orders { get; set; } } } diff --git a/exercise.pizzashopapi/Models/Order.cs b/exercise.pizzashopapi/Models/Order.cs index fbe6113..0360611 100644 --- a/exercise.pizzashopapi/Models/Order.cs +++ b/exercise.pizzashopapi/Models/Order.cs @@ -1,10 +1,40 @@ using System.ComponentModel.DataAnnotations.Schema; +using exercise.pizzashopapi.Enums; namespace exercise.pizzashopapi.Models { public class Order { - - + public int Id { get; set; } + public int? CustomerId { get; set; } + public int PizzaId { get; set; } + public Customer? Customer { get; set; } + public Pizza Pizza { get; set; } + //public List? ToppingIds { get; set; } + public List? Toppings { get; set; } + public DateTime OrderDate { get; set; } + public bool Delivered { get; set; } + [NotMapped] + public OrderStatus Status + { + get + { + if (Delivered) + { + return OrderStatus.Delivered; + } + return (DateTime.UtcNow - OrderDate) switch + { + var d when d.TotalMinutes < 3 => OrderStatus.Preparing, + var d when d.TotalMinutes < 12 => OrderStatus.Cooking, + _ => OrderStatus.Delivering + }; + } + } + + public Order() + { + OrderDate = DateTime.UtcNow; + } } } diff --git a/exercise.pizzashopapi/Models/Topping.cs b/exercise.pizzashopapi/Models/Topping.cs new file mode 100644 index 0000000..4c5b211 --- /dev/null +++ b/exercise.pizzashopapi/Models/Topping.cs @@ -0,0 +1,7 @@ +namespace exercise.pizzashopapi.Models; + +public class Topping +{ + public int Id { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/exercise.pizzashopapi/Program.cs b/exercise.pizzashopapi/Program.cs index c04a440..7383e5c 100644 --- a/exercise.pizzashopapi/Program.cs +++ b/exercise.pizzashopapi/Program.cs @@ -1,5 +1,7 @@ using exercise.pizzashopapi.Data; using exercise.pizzashopapi.EndPoints; +using exercise.pizzashopapi.Mapper; +using exercise.pizzashopapi.Models; using exercise.pizzashopapi.Repository; var builder = WebApplication.CreateBuilder(args); @@ -7,11 +9,15 @@ // Add services to the container. builder.Services.AddControllers(); -builder.Services.AddScoped(); +builder.Services.AddScoped, Repository>(); +builder.Services.AddScoped, Repository>(); +builder.Services.AddScoped, Repository>(); +builder.Services.AddScoped, Repository>(); builder.Services.AddDbContext(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddAutoMapper(typeof(AutoMapperProfile)); var app = builder.Build(); diff --git a/exercise.pizzashopapi/Repository/IRepository.cs b/exercise.pizzashopapi/Repository/IRepository.cs index b114ea8..f64ebd7 100644 --- a/exercise.pizzashopapi/Repository/IRepository.cs +++ b/exercise.pizzashopapi/Repository/IRepository.cs @@ -1,11 +1,12 @@ -using exercise.pizzashopapi.Models; +using System.Linq.Expressions; namespace exercise.pizzashopapi.Repository { - public interface IRepository + public interface IRepository where T : class { - Task> GetOrdersByCustomer(int id); - - + Task> GetAll(params Expression>[] includes); + Task> GetAll(Expression> predicate, params Expression>[] includes); + Task Get(Expression> predicate, params Expression>[] includes); + Task Update(T entity); } } diff --git a/exercise.pizzashopapi/Repository/Repository.cs b/exercise.pizzashopapi/Repository/Repository.cs index e109fce..9345442 100644 --- a/exercise.pizzashopapi/Repository/Repository.cs +++ b/exercise.pizzashopapi/Repository/Repository.cs @@ -1,14 +1,40 @@ -using exercise.pizzashopapi.Data; -using exercise.pizzashopapi.Models; +using System.Linq.Expressions; +using exercise.pizzashopapi.Data; +using Microsoft.EntityFrameworkCore; -namespace exercise.pizzashopapi.Repository +namespace exercise.pizzashopapi.Repository; + +public class Repository(DataContext db) : IRepository where T : class { - public class Repository : IRepository + public async Task> GetAll(params Expression>[] includes) + { + IQueryable query = db.Set(); + foreach (var expression in includes) query = query.Include(expression); + + return await query.ToListAsync(); + } + + public async Task> GetAll(Expression> predicate, + params Expression>[] includes) + { + IQueryable query = db.Set(); + foreach (var expression in includes) query = query.Include(expression); + + return await query.Where(predicate).ToListAsync(); + } + + public async Task Get(Expression> predicate, params Expression>[] includes) + { + IQueryable query = db.Set(); + foreach (var expression in includes) query = query.Include(expression); + + return await query.FirstOrDefaultAsync(predicate); + } + + public async Task Update(T entity) { - private DataContext _db; - public Task> GetOrdersByCustomer(int id) - { - throw new NotImplementedException(); - } + db.Set().Update(entity); + await db.SaveChangesAsync(); + return entity; } -} +} \ No newline at end of file diff --git a/exercise.pizzashopapi/exercise.pizzashopapi.csproj b/exercise.pizzashopapi/exercise.pizzashopapi.csproj index 624203b..4a3bdd5 100644 --- a/exercise.pizzashopapi/exercise.pizzashopapi.csproj +++ b/exercise.pizzashopapi/exercise.pizzashopapi.csproj @@ -11,6 +11,7 @@ + all