From 4cd5b4c1b32459893788210c59ed0b78a0330daa Mon Sep 17 00:00:00 2001 From: basem Date: Thu, 19 Feb 2026 17:26:44 +0200 Subject: [PATCH 01/52] create basic structure --- .../Ecommerce.Core/Ecommerce.Core.csproj | 15 ++++++++ .../Ecommerce.Core/Models/Category.cs | 6 ++++ EcommerceApi/Ecommerce.Core/Models/Product.cs | 6 ++++ EcommerceApi/Ecommerce.Core/Models/Sale.cs | 6 ++++ .../Ecommerce.Core/Models/SaleItem.cs | 6 ++++ EcommerceApi/Ecommerce.Data/AppDbContext.cs | 6 ++++ .../Ecommerce.Data/Ecommerce.Data.csproj | 17 ++++++++++ .../Ecommerce.Services.csproj | 13 +++++++ .../Ecommerce.Web/Ecommerce.Web.csproj | 24 +++++++++++++ EcommerceApi/Ecommerce.Web/Program.cs | 21 ++++++++++++ .../Properties/launchSettings.json | 14 ++++++++ EcommerceApi/Ecommerce.Web/appsettings.json | 9 +++++ EcommerceApi/Ecommerce.sln | 34 +++++++++++++++++++ 13 files changed, 177 insertions(+) create mode 100644 EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj create mode 100644 EcommerceApi/Ecommerce.Core/Models/Category.cs create mode 100644 EcommerceApi/Ecommerce.Core/Models/Product.cs create mode 100644 EcommerceApi/Ecommerce.Core/Models/Sale.cs create mode 100644 EcommerceApi/Ecommerce.Core/Models/SaleItem.cs create mode 100644 EcommerceApi/Ecommerce.Data/AppDbContext.cs create mode 100644 EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj create mode 100644 EcommerceApi/Ecommerce.Services/Ecommerce.Services.csproj create mode 100644 EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj create mode 100644 EcommerceApi/Ecommerce.Web/Program.cs create mode 100644 EcommerceApi/Ecommerce.Web/Properties/launchSettings.json create mode 100644 EcommerceApi/Ecommerce.Web/appsettings.json create mode 100644 EcommerceApi/Ecommerce.sln diff --git a/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj new file mode 100644 index 00000000..a3f70c3e --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + + + + + + + + + diff --git a/EcommerceApi/Ecommerce.Core/Models/Category.cs b/EcommerceApi/Ecommerce.Core/Models/Category.cs new file mode 100644 index 00000000..ea3df6a6 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Models/Category.cs @@ -0,0 +1,6 @@ +namespace Ecommerce.Core.Models; + +public class Category +{ + +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/Product.cs b/EcommerceApi/Ecommerce.Core/Models/Product.cs new file mode 100644 index 00000000..619529ac --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Models/Product.cs @@ -0,0 +1,6 @@ +namespace Ecommerce.Core.Models; + +public class Product +{ + +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/Sale.cs b/EcommerceApi/Ecommerce.Core/Models/Sale.cs new file mode 100644 index 00000000..53c01ce7 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Models/Sale.cs @@ -0,0 +1,6 @@ +namespace Ecommerce.Core.Models; + +public class Sale +{ + +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/SaleItem.cs b/EcommerceApi/Ecommerce.Core/Models/SaleItem.cs new file mode 100644 index 00000000..a58cc506 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Models/SaleItem.cs @@ -0,0 +1,6 @@ +namespace Ecommerce.Core.Models; + +public class SaleItem +{ + +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/AppDbContext.cs b/EcommerceApi/Ecommerce.Data/AppDbContext.cs new file mode 100644 index 00000000..8d530e41 --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/AppDbContext.cs @@ -0,0 +1,6 @@ +namespace Ecommerce.Data; + +public class AppDbContext +{ + +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj b/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj new file mode 100644 index 00000000..49d076cc --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/EcommerceApi/Ecommerce.Services/Ecommerce.Services.csproj b/EcommerceApi/Ecommerce.Services/Ecommerce.Services.csproj new file mode 100644 index 00000000..2ee12597 --- /dev/null +++ b/EcommerceApi/Ecommerce.Services/Ecommerce.Services.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj new file mode 100644 index 00000000..53e156d8 --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + Ecommerce.Web + + + + + + + + + + + + + + + + + diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs new file mode 100644 index 00000000..2f04047b --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -0,0 +1,21 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Properties/launchSettings.json b/EcommerceApi/Ecommerce.Web/Properties/launchSettings.json new file mode 100644 index 00000000..22822427 --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5248", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/EcommerceApi/Ecommerce.Web/appsettings.json b/EcommerceApi/Ecommerce.Web/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/EcommerceApi/Ecommerce.sln b/EcommerceApi/Ecommerce.sln new file mode 100644 index 00000000..348daeea --- /dev/null +++ b/EcommerceApi/Ecommerce.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ecommerce.Web", "Ecommerce.Web\Ecommerce.Web.csproj", "{979046FC-E2EC-4F6B-96FF-A85CE9B986B2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ecommerce.Core", "Ecommerce.Core\Ecommerce.Core.csproj", "{0DB2EFAE-16FB-498E-8E74-080596BEDBB2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ecommerce.Data", "Ecommerce.Data\Ecommerce.Data.csproj", "{0C65FD30-B3EF-4FB8-A8B8-7C369B2052D3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ecommerce.Services", "Ecommerce.Services\Ecommerce.Services.csproj", "{CD7B41D5-8906-4BAF-BF04-02304FA819B1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {979046FC-E2EC-4F6B-96FF-A85CE9B986B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {979046FC-E2EC-4F6B-96FF-A85CE9B986B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {979046FC-E2EC-4F6B-96FF-A85CE9B986B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {979046FC-E2EC-4F6B-96FF-A85CE9B986B2}.Release|Any CPU.Build.0 = Release|Any CPU + {0DB2EFAE-16FB-498E-8E74-080596BEDBB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0DB2EFAE-16FB-498E-8E74-080596BEDBB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0DB2EFAE-16FB-498E-8E74-080596BEDBB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0DB2EFAE-16FB-498E-8E74-080596BEDBB2}.Release|Any CPU.Build.0 = Release|Any CPU + {0C65FD30-B3EF-4FB8-A8B8-7C369B2052D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C65FD30-B3EF-4FB8-A8B8-7C369B2052D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C65FD30-B3EF-4FB8-A8B8-7C369B2052D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C65FD30-B3EF-4FB8-A8B8-7C369B2052D3}.Release|Any CPU.Build.0 = Release|Any CPU + {CD7B41D5-8906-4BAF-BF04-02304FA819B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD7B41D5-8906-4BAF-BF04-02304FA819B1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD7B41D5-8906-4BAF-BF04-02304FA819B1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD7B41D5-8906-4BAF-BF04-02304FA819B1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal From f3071a4b3907cfe2ea6cc76a2ad3334918aba7ea Mon Sep 17 00:00:00 2001 From: basem Date: Mon, 23 Feb 2026 03:35:45 +0200 Subject: [PATCH 02/52] Create base entities and DbContext with migrations --- .../Ecommerce.Core/Models/Category.cs | 5 +- EcommerceApi/Ecommerce.Core/Models/Product.cs | 8 +- EcommerceApi/Ecommerce.Core/Models/Sale.cs | 7 +- .../Ecommerce.Core/Models/SaleItem.cs | 7 +- EcommerceApi/Ecommerce.Data/AppDbContext.cs | 39 ++++- .../Ecommerce.Data/Ecommerce.Data.csproj | 5 + .../20260223013358_Initial.Designer.cs | 164 ++++++++++++++++++ .../Migrations/20260223013358_Initial.cs | 118 +++++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 161 +++++++++++++++++ .../Ecommerce.Web/Ecommerce.Web.csproj | 5 + EcommerceApi/Ecommerce.Web/Program.cs | 9 +- 11 files changed, 518 insertions(+), 10 deletions(-) create mode 100644 EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.Designer.cs create mode 100644 EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.cs create mode 100644 EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs diff --git a/EcommerceApi/Ecommerce.Core/Models/Category.cs b/EcommerceApi/Ecommerce.Core/Models/Category.cs index ea3df6a6..c4cbb582 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Category.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Category.cs @@ -2,5 +2,8 @@ namespace Ecommerce.Core.Models; public class Category { - + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public ICollection? Products { get; set; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/Product.cs b/EcommerceApi/Ecommerce.Core/Models/Product.cs index 619529ac..c54f26cf 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Product.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Product.cs @@ -2,5 +2,11 @@ namespace Ecommerce.Core.Models; public class Product { - + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public decimal Price { get; set; } + public string? Description { get; set; } + public string? ImageUrl { get; set; } + public int CategoryId { get; set; } + public Category? Category { get; set; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/Sale.cs b/EcommerceApi/Ecommerce.Core/Models/Sale.cs index 53c01ce7..a40c1e31 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Sale.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Sale.cs @@ -2,5 +2,8 @@ namespace Ecommerce.Core.Models; public class Sale { - -} \ No newline at end of file + public int Id { get; set; } + public DateTime CreationDate { get; set; } + public decimal TotalPrice { get; set; } + public ICollection? Items { get; set; } = new List(); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/SaleItem.cs b/EcommerceApi/Ecommerce.Core/Models/SaleItem.cs index a58cc506..70dd78de 100644 --- a/EcommerceApi/Ecommerce.Core/Models/SaleItem.cs +++ b/EcommerceApi/Ecommerce.Core/Models/SaleItem.cs @@ -2,5 +2,10 @@ namespace Ecommerce.Core.Models; public class SaleItem { - + public int ProductId { get; set; } + public Product? Product { get; set; } + public int SaleId { get; set; } + public Sale? Sale { get; set; } + public decimal UnitPriceAtTimeOfSale { get; set; } + public int Quantity { get; set; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/AppDbContext.cs b/EcommerceApi/Ecommerce.Data/AppDbContext.cs index 8d530e41..9944566b 100644 --- a/EcommerceApi/Ecommerce.Data/AppDbContext.cs +++ b/EcommerceApi/Ecommerce.Data/AppDbContext.cs @@ -1,6 +1,41 @@ +using Ecommerce.Core.Models; +using Microsoft.EntityFrameworkCore; + + namespace Ecommerce.Data; -public class AppDbContext +public class AppDbContext(DbContextOptions options) : DbContext(options) + { - + public DbSet Products { get; set; } + + public DbSet Categories { get; set; } + + public DbSet Sales { get; set; } + + public DbSet SaleItems { get; set; } + + + protected override void OnModelCreating(ModelBuilder modelBuilder) + + { + modelBuilder + .Entity() + .HasKey(si => new { si.SaleId, si.ProductId }); + + modelBuilder + .Entity() + .Property(p => p.Price) + .HasPrecision(18, 2); + + modelBuilder + .Entity() + .Property(s => s.TotalPrice) + .HasPrecision(18, 2); + + modelBuilder + .Entity() + .Property(si => si.UnitPriceAtTimeOfSale) + .HasPrecision(18, 2); + } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj b/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj index 49d076cc..bfa8d297 100644 --- a/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj +++ b/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj @@ -4,6 +4,7 @@ net10.0 enable enable + bin/ @@ -14,4 +15,8 @@ + + + + diff --git a/EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.Designer.cs b/EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.Designer.cs new file mode 100644 index 00000000..34be7fdb --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.Designer.cs @@ -0,0 +1,164 @@ +// +using System; +using Ecommerce.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Ecommerce.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260223013358_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Ecommerce.Core.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ImageUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Sale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("TotalPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.SaleItem", b => + { + b.Property("SaleId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPriceAtTimeOfSale") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("SaleId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("SaleItems"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Product", b => + { + b.HasOne("Ecommerce.Core.Models.Category", "Category") + .WithMany("Products") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.SaleItem", b => + { + b.HasOne("Ecommerce.Core.Models.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ecommerce.Core.Models.Sale", "Sale") + .WithMany("Items") + .HasForeignKey("SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Sale"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Category", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Sale", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.cs b/EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.cs new file mode 100644 index 00000000..3be978c2 --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Migrations/20260223013358_Initial.cs @@ -0,0 +1,118 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ecommerce.Data.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Categories", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Categories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Sales", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CreationDate = table.Column(type: "datetime2", nullable: false), + TotalPrice = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Sales", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Products", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(max)", nullable: false), + Price = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true), + ImageUrl = table.Column(type: "nvarchar(max)", nullable: true), + CategoryId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.Id); + table.ForeignKey( + name: "FK_Products_Categories_CategoryId", + column: x => x.CategoryId, + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SaleItems", + columns: table => new + { + ProductId = table.Column(type: "int", nullable: false), + SaleId = table.Column(type: "int", nullable: false), + UnitPriceAtTimeOfSale = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false), + Quantity = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SaleItems", x => new { x.SaleId, x.ProductId }); + table.ForeignKey( + name: "FK_SaleItems_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SaleItems_Sales_SaleId", + column: x => x.SaleId, + principalTable: "Sales", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Products_CategoryId", + table: "Products", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_SaleItems_ProductId", + table: "SaleItems", + column: "ProductId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SaleItems"); + + migrationBuilder.DropTable( + name: "Products"); + + migrationBuilder.DropTable( + name: "Sales"); + + migrationBuilder.DropTable( + name: "Categories"); + } + } +} diff --git a/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs b/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 00000000..43ac6189 --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,161 @@ +// +using System; +using Ecommerce.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Ecommerce.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Ecommerce.Core.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ImageUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Sale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("TotalPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.SaleItem", b => + { + b.Property("SaleId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPriceAtTimeOfSale") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("SaleId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("SaleItems"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Product", b => + { + b.HasOne("Ecommerce.Core.Models.Category", "Category") + .WithMany("Products") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.SaleItem", b => + { + b.HasOne("Ecommerce.Core.Models.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ecommerce.Core.Models.Sale", "Sale") + .WithMany("Items") + .HasForeignKey("SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Sale"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Category", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("Ecommerce.Core.Models.Sale", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj index 53e156d8..9cc5e959 100644 --- a/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj +++ b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj @@ -5,10 +5,15 @@ enable enable Ecommerce.Web + bin/ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs index 2f04047b..d2ca3ade 100644 --- a/EcommerceApi/Ecommerce.Web/Program.cs +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -1,10 +1,13 @@ -var builder = WebApplication.CreateBuilder(args); +using Ecommerce.Data; +using Microsoft.EntityFrameworkCore; -// Add services to the container. +var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); +builder.Services.AddDbContext( + options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")) + ); var app = builder.Build(); From 99475dd34d5f0584d589fa7b0071a1484e7de1fd Mon Sep 17 00:00:00 2001 From: basem Date: Tue, 24 Feb 2026 02:04:38 +0200 Subject: [PATCH 03/52] Create UnitOfWork and Repositories Interfaces and Implementations --- .../Ecommerce.Core/Ecommerce.Core.csproj | 1 - .../Repositories/ICategoryRepository.cs | 7 ++++ .../Repositories/IGenericRepository.cs | 10 ++++++ .../Repositories/IProductRepository.cs | 7 ++++ .../Repositories/ISaleRepository.cs | 7 ++++ .../Interfaces/Repositories/IUnitOfWork.cs | 13 +++++++ .../Ecommerce.Data/Ecommerce.Data.csproj | 4 --- .../Repositories/CategoryRepository.cs | 8 +++++ .../Repositories/GenericRepository.cs | 35 +++++++++++++++++++ .../Repositories/ProductRepository.cs | 8 +++++ .../Repositories/SaleRepository.cs | 8 +++++ .../Ecommerce.Data/Repositories/UnitOfWork.cs | 19 ++++++++++ 12 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ICategoryRepository.cs create mode 100644 EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs create mode 100644 EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IProductRepository.cs create mode 100644 EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ISaleRepository.cs create mode 100644 EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IUnitOfWork.cs create mode 100644 EcommerceApi/Ecommerce.Data/Repositories/CategoryRepository.cs create mode 100644 EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs create mode 100644 EcommerceApi/Ecommerce.Data/Repositories/ProductRepository.cs create mode 100644 EcommerceApi/Ecommerce.Data/Repositories/SaleRepository.cs create mode 100644 EcommerceApi/Ecommerce.Data/Repositories/UnitOfWork.cs diff --git a/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj index a3f70c3e..8a5f9faa 100644 --- a/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj +++ b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj @@ -8,7 +8,6 @@ - diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ICategoryRepository.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ICategoryRepository.cs new file mode 100644 index 00000000..7f30b13d --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ICategoryRepository.cs @@ -0,0 +1,7 @@ +using Ecommerce.Core.Models; + +namespace Ecommerce.Core.Interfaces.Repositories; + +public interface ICategoryRepository : IGenericRepository +{ +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs new file mode 100644 index 00000000..4a0d2b9a --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs @@ -0,0 +1,10 @@ +namespace Ecommerce.Core.Interfaces.Repositories; + +public interface IGenericRepository where T : class +{ + Task> GetAllAsync(); + Task GetByIdAsync(int id); + void Add(T entity); + void Update(T entity); + void Delete(T entity); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IProductRepository.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IProductRepository.cs new file mode 100644 index 00000000..fc1cb2e9 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IProductRepository.cs @@ -0,0 +1,7 @@ +using Ecommerce.Core.Models; + +namespace Ecommerce.Core.Interfaces.Repositories; + +public interface IProductRepository : IGenericRepository +{ +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ISaleRepository.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ISaleRepository.cs new file mode 100644 index 00000000..9fe74d86 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ISaleRepository.cs @@ -0,0 +1,7 @@ +using Ecommerce.Core.Models; + +namespace Ecommerce.Core.Interfaces.Repositories; + +public interface ISaleRepository : IGenericRepository +{ +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IUnitOfWork.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IUnitOfWork.cs new file mode 100644 index 00000000..e48c37dd --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IUnitOfWork.cs @@ -0,0 +1,13 @@ +using Ecommerce.Core.Models; + +namespace Ecommerce.Core.Interfaces.Repositories; + +public interface IUnitOfWork +{ + IProductRepository Products { get; } + ICategoryRepository Categories { get; } + ISaleRepository Sales { get; } + + Task CompleteAsync(); + void Dispose(); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj b/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj index bfa8d297..5ef6e2e9 100644 --- a/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj +++ b/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj @@ -11,10 +11,6 @@ - - - - diff --git a/EcommerceApi/Ecommerce.Data/Repositories/CategoryRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/CategoryRepository.cs new file mode 100644 index 00000000..d6b33bad --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Repositories/CategoryRepository.cs @@ -0,0 +1,8 @@ +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Models; + +namespace Ecommerce.Data.Repositories; + +public class CategoryRepository(AppDbContext context) : GenericRepository(context), ICategoryRepository +{ +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs new file mode 100644 index 00000000..985be819 --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs @@ -0,0 +1,35 @@ +using Ecommerce.Core.Interfaces.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Ecommerce.Data.Repositories; + +public class GenericRepository(AppDbContext context) : IGenericRepository + where T : class +{ + private readonly AppDbContext _context = context; + + public async Task> GetAllAsync() + { + return await _context.Set().ToListAsync(); + } + + public async Task GetByIdAsync(int id) + { + return await _context.Set().FindAsync(id); + } + + public void Add(T entity) + { + _context.Add(entity); + } + + public void Update(T entity) + { + _context.Update(entity); + } + + public void Delete(T entity) + { + _context.Remove(entity); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Repositories/ProductRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/ProductRepository.cs new file mode 100644 index 00000000..7a176494 --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Repositories/ProductRepository.cs @@ -0,0 +1,8 @@ +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Models; + +namespace Ecommerce.Data.Repositories; + +public class ProductRepository(AppDbContext context) : GenericRepository(context), IProductRepository +{ +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Repositories/SaleRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/SaleRepository.cs new file mode 100644 index 00000000..162ebc9a --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Repositories/SaleRepository.cs @@ -0,0 +1,8 @@ +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Models; + +namespace Ecommerce.Data.Repositories; + +public class SaleRepository(AppDbContext context) : GenericRepository(context), ISaleRepository +{ +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Repositories/UnitOfWork.cs b/EcommerceApi/Ecommerce.Data/Repositories/UnitOfWork.cs new file mode 100644 index 00000000..f97af319 --- /dev/null +++ b/EcommerceApi/Ecommerce.Data/Repositories/UnitOfWork.cs @@ -0,0 +1,19 @@ +using Ecommerce.Core.Interfaces.Repositories; + +namespace Ecommerce.Data.Repositories; + +public class UnitOfWork(AppDbContext context) : IUnitOfWork +{ + public IProductRepository Products => new ProductRepository(context); + public ICategoryRepository Categories => new CategoryRepository(context); + public ISaleRepository Sales => new SaleRepository(context); + public async Task CompleteAsync() + { + return await context.SaveChangesAsync(); + } + + public void Dispose() + { + context.Dispose(); + } +} \ No newline at end of file From 867c0346aeebadca7c8799e802394a22e660f042 Mon Sep 17 00:00:00 2001 From: basem Date: Mon, 2 Mar 2026 03:29:58 +0200 Subject: [PATCH 04/52] Apply soft delete feature --- EcommerceApi/Ecommerce.Core/Models/Category.cs | 3 +++ EcommerceApi/Ecommerce.Core/Models/Product.cs | 3 +++ EcommerceApi/Ecommerce.Core/Models/Sale.cs | 3 +++ EcommerceApi/Ecommerce.Data/AppDbContext.cs | 17 +++++++++++------ .../Migrations/AppDbContextModelSnapshot.cs | 18 ++++++++++++++++++ 5 files changed, 38 insertions(+), 6 deletions(-) diff --git a/EcommerceApi/Ecommerce.Core/Models/Category.cs b/EcommerceApi/Ecommerce.Core/Models/Category.cs index c4cbb582..5d7204d8 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Category.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Category.cs @@ -6,4 +6,7 @@ public class Category public string Name { get; set; } = string.Empty; public string? Description { get; set; } public ICollection? Products { get; set; } + + public bool IsDeleted { get; set; } = false; + public DateTime? DeletedAt { get; set; } = null; } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/Product.cs b/EcommerceApi/Ecommerce.Core/Models/Product.cs index c54f26cf..89e42536 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Product.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Product.cs @@ -9,4 +9,7 @@ public class Product public string? ImageUrl { get; set; } public int CategoryId { get; set; } public Category? Category { get; set; } + + public bool IsDeleted { get; set; } + public DateTime? DeletedAt { get; set; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/Sale.cs b/EcommerceApi/Ecommerce.Core/Models/Sale.cs index a40c1e31..7d279c61 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Sale.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Sale.cs @@ -6,4 +6,7 @@ public class Sale public DateTime CreationDate { get; set; } public decimal TotalPrice { get; set; } public ICollection? Items { get; set; } = new List(); + + public bool IsDeleted { get; set; } + public DateTime? DeletedAt { get; set; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/AppDbContext.cs b/EcommerceApi/Ecommerce.Data/AppDbContext.cs index 9944566b..f8e31f40 100644 --- a/EcommerceApi/Ecommerce.Data/AppDbContext.cs +++ b/EcommerceApi/Ecommerce.Data/AppDbContext.cs @@ -19,20 +19,25 @@ public class AppDbContext(DbContextOptions options) : DbContext(op protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder - .Entity() - .HasKey(si => new { si.SaleId, si.ProductId }); - modelBuilder .Entity() + .HasQueryFilter(p => !p.IsDeleted) .Property(p => p.Price) .HasPrecision(18, 2); - + modelBuilder .Entity() + .HasQueryFilter(p => !p.IsDeleted) .Property(s => s.TotalPrice) .HasPrecision(18, 2); - + + modelBuilder.Entity() + .HasQueryFilter(p => !p.IsDeleted); + + modelBuilder + .Entity() + .HasKey(si => new { si.SaleId, si.ProductId }); + modelBuilder .Entity() .Property(si => si.UnitPriceAtTimeOfSale) diff --git a/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs b/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs index 43ac6189..186e96bb 100644 --- a/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs @@ -30,9 +30,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("DeletedAt") + .HasColumnType("datetime2"); + b.Property("Description") .HasColumnType("nvarchar(max)"); + b.Property("IsDeleted") + .HasColumnType("bit"); + b.Property("Name") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -53,12 +59,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CategoryId") .HasColumnType("int"); + b.Property("DeletedAt") + .HasColumnType("datetime2"); + b.Property("Description") .HasColumnType("nvarchar(max)"); b.Property("ImageUrl") .HasColumnType("nvarchar(max)"); + b.Property("IsDeleted") + .HasColumnType("bit"); + b.Property("Name") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -85,6 +97,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreationDate") .HasColumnType("datetime2"); + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + b.Property("TotalPrice") .HasPrecision(18, 2) .HasColumnType("decimal(18,2)"); From c77f4ce0bb34ebde5b17331ae01ed7082ce30d22 Mon Sep 17 00:00:00 2001 From: basem Date: Mon, 2 Mar 2026 03:32:06 +0200 Subject: [PATCH 05/52] Inject UnitOfWork to DI Container. Fix UnitOfWork performance issue or creating a new object each time needed in a request. --- EcommerceApi/Ecommerce.Data/Repositories/UnitOfWork.cs | 9 ++++++--- EcommerceApi/Ecommerce.Web/Program.cs | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/EcommerceApi/Ecommerce.Data/Repositories/UnitOfWork.cs b/EcommerceApi/Ecommerce.Data/Repositories/UnitOfWork.cs index f97af319..7ba86f5f 100644 --- a/EcommerceApi/Ecommerce.Data/Repositories/UnitOfWork.cs +++ b/EcommerceApi/Ecommerce.Data/Repositories/UnitOfWork.cs @@ -4,9 +4,12 @@ namespace Ecommerce.Data.Repositories; public class UnitOfWork(AppDbContext context) : IUnitOfWork { - public IProductRepository Products => new ProductRepository(context); - public ICategoryRepository Categories => new CategoryRepository(context); - public ISaleRepository Sales => new SaleRepository(context); + private IProductRepository? _products; + private ICategoryRepository? _categories; + private ISaleRepository? _sales; + public IProductRepository Products => _products ??= new ProductRepository(context); + public ICategoryRepository Categories => _categories ??= new CategoryRepository(context); + public ISaleRepository Sales => _sales ??= new SaleRepository(context); public async Task CompleteAsync() { return await context.SaveChangesAsync(); diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs index d2ca3ade..ef244b2c 100644 --- a/EcommerceApi/Ecommerce.Web/Program.cs +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -1,4 +1,6 @@ +using Ecommerce.Core.Interfaces.Repositories; using Ecommerce.Data; +using Ecommerce.Data.Repositories; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -8,6 +10,7 @@ builder.Services.AddDbContext( options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")) ); +builder.Services.AddScoped(); var app = builder.Build(); From ef660f4782fdf64d9a7640adc02fb4a3fade9330 Mon Sep 17 00:00:00 2001 From: basem Date: Mon, 2 Mar 2026 03:34:01 +0200 Subject: [PATCH 06/52] Create a general response class to return for each service --- EcommerceApi/Ecommerce.Core/DTOs/Result.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Result.cs diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Result.cs b/EcommerceApi/Ecommerce.Core/DTOs/Result.cs new file mode 100644 index 00000000..ab3b7194 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Result.cs @@ -0,0 +1,18 @@ +namespace Ecommerce.Core.DTOs; + +public class Result +{ + public bool IsSuccess { get; set; } + public T? Data { get; set; } + public string? Message { get; set; } + + public static Result Success(T data) + { + return new Result { IsSuccess = true, Data = data }; + } + + public static Result Fail(string message) + { + return new Result { IsSuccess = false, Message = message }; + } +} From 95637bdea523ecc21a8a38a59f648bf578f67ea9 Mon Sep 17 00:00:00 2001 From: basem Date: Mon, 2 Mar 2026 17:14:11 +0200 Subject: [PATCH 07/52] Apply ISoftDeletable interface. Update "delete" logic in GenericRepository --- .../Ecommerce.Core/Interfaces/Common/ISoftDeletable.cs | 7 +++++++ .../Interfaces/Repositories/IGenericRepository.cs | 4 +++- EcommerceApi/Ecommerce.Core/Models/Category.cs | 8 +++++--- EcommerceApi/Ecommerce.Core/Models/Product.cs | 4 +++- EcommerceApi/Ecommerce.Core/Models/Sale.cs | 8 +++++--- .../Ecommerce.Data/Repositories/GenericRepository.cs | 7 +++++-- EcommerceApi/Ecommerce.Web/Program.cs | 8 ++++++-- 7 files changed, 34 insertions(+), 12 deletions(-) create mode 100644 EcommerceApi/Ecommerce.Core/Interfaces/Common/ISoftDeletable.cs diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Common/ISoftDeletable.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Common/ISoftDeletable.cs new file mode 100644 index 00000000..2741b83a --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Common/ISoftDeletable.cs @@ -0,0 +1,7 @@ +namespace Ecommerce.Core.Interfaces.Common; + +public interface ISoftDeletable +{ + bool IsDeleted { get; set; } + DateTime? DeletedAt { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs index 4a0d2b9a..d785cff0 100644 --- a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs @@ -1,3 +1,5 @@ +using Ecommerce.Core.Interfaces.Common; + namespace Ecommerce.Core.Interfaces.Repositories; public interface IGenericRepository where T : class @@ -6,5 +8,5 @@ public interface IGenericRepository where T : class Task GetByIdAsync(int id); void Add(T entity); void Update(T entity); - void Delete(T entity); + void Delete(ISoftDeletable entity); } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/Category.cs b/EcommerceApi/Ecommerce.Core/Models/Category.cs index 5d7204d8..78b1905a 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Category.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Category.cs @@ -1,12 +1,14 @@ +using Ecommerce.Core.Interfaces.Common; + namespace Ecommerce.Core.Models; -public class Category +public class Category : ISoftDeletable { public int Id { get; set; } public string Name { get; set; } = string.Empty; public string? Description { get; set; } public ICollection? Products { get; set; } - public bool IsDeleted { get; set; } = false; - public DateTime? DeletedAt { get; set; } = null; + public bool IsDeleted { get; set; } + public DateTime? DeletedAt { get; set; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/Product.cs b/EcommerceApi/Ecommerce.Core/Models/Product.cs index 89e42536..7f854bbf 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Product.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Product.cs @@ -1,6 +1,8 @@ +using Ecommerce.Core.Interfaces.Common; + namespace Ecommerce.Core.Models; -public class Product +public class Product : ISoftDeletable { public int Id { get; set; } public string Name { get; set; } = string.Empty; diff --git a/EcommerceApi/Ecommerce.Core/Models/Sale.cs b/EcommerceApi/Ecommerce.Core/Models/Sale.cs index 7d279c61..abca2dfa 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Sale.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Sale.cs @@ -1,12 +1,14 @@ +using Ecommerce.Core.Interfaces.Common; + namespace Ecommerce.Core.Models; -public class Sale +public class Sale : ISoftDeletable { public int Id { get; set; } public DateTime CreationDate { get; set; } public decimal TotalPrice { get; set; } public ICollection? Items { get; set; } = new List(); - + public bool IsDeleted { get; set; } public DateTime? DeletedAt { get; set; } -} \ No newline at end of file +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs index 985be819..4c39c6ff 100644 --- a/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs +++ b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs @@ -1,3 +1,4 @@ +using Ecommerce.Core.Interfaces.Common; using Ecommerce.Core.Interfaces.Repositories; using Microsoft.EntityFrameworkCore; @@ -28,8 +29,10 @@ public void Update(T entity) _context.Update(entity); } - public void Delete(T entity) + public void Delete(ISoftDeletable entity) { - _context.Remove(entity); + entity.IsDeleted = true; + entity.DeletedAt = DateTime.UtcNow; + _context.Update(entity); } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs index ef244b2c..31e4cb09 100644 --- a/EcommerceApi/Ecommerce.Web/Program.cs +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -1,16 +1,20 @@ using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Interfaces.Services; using Ecommerce.Data; using Ecommerce.Data.Repositories; +using Ecommerce.Services; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllers(); -builder.Services.AddOpenApi(); builder.Services.AddDbContext( options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")) ); builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddControllers(); +builder.Services.AddOpenApi(); var app = builder.Build(); From ef30dfe041123082240fe07297908b621698c361 Mon Sep 17 00:00:00 2001 From: basem Date: Mon, 2 Mar 2026 17:15:10 +0200 Subject: [PATCH 08/52] Add Category Module(DTOs, Service, Controller) --- .../DTOs/Category/CategoryDto.cs | 8 ++ .../DTOs/Category/CreateCategoryDto.cs | 7 ++ .../DTOs/Category/UpdateCategoryDto.cs | 7 ++ .../Ecommerce.Core/Ecommerce.Core.csproj | 5 - .../Interfaces/Services/ICategoryService.cs | 14 +++ .../Ecommerce.Services/CategoryService.cs | 101 ++++++++++++++++++ .../Controllers/CategoryController.cs | 52 +++++++++ .../Ecommerce.Web/Ecommerce.Web.csproj | 4 - 8 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Category/CategoryDto.cs create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Category/CreateCategoryDto.cs create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Category/UpdateCategoryDto.cs create mode 100644 EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs create mode 100644 EcommerceApi/Ecommerce.Services/CategoryService.cs create mode 100644 EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Category/CategoryDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Category/CategoryDto.cs new file mode 100644 index 00000000..0ce59ee4 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Category/CategoryDto.cs @@ -0,0 +1,8 @@ +namespace Ecommerce.Core.DTOs.Category; + +public class CategoryDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Category/CreateCategoryDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Category/CreateCategoryDto.cs new file mode 100644 index 00000000..93b8d4d2 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Category/CreateCategoryDto.cs @@ -0,0 +1,7 @@ +namespace Ecommerce.Core.DTOs.Category; + +public class CreateCategoryDto +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Category/UpdateCategoryDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Category/UpdateCategoryDto.cs new file mode 100644 index 00000000..b6f59cfc --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Category/UpdateCategoryDto.cs @@ -0,0 +1,7 @@ +namespace Ecommerce.Core.DTOs.Category; + +public class UpdateCategoryDto +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj index 8a5f9faa..237d6616 100644 --- a/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj +++ b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj @@ -6,9 +6,4 @@ enable - - - - - diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs new file mode 100644 index 00000000..2e610cb1 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs @@ -0,0 +1,14 @@ +using Ecommerce.Core.DTOs; +using Ecommerce.Core.DTOs.Category; +using Ecommerce.Core.Models; + +namespace Ecommerce.Core.Interfaces.Services; + +public interface ICategoryService +{ + Task> CreateCategoryAsync(CreateCategoryDto category); + Task> GetCategoryAsync(int id); + Task>> GetAllCategoriesAsync(); + Task> UpdateCategoryAsync(int id, UpdateCategoryDto category); + Task> DeleteCategoryAsync(int id); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Services/CategoryService.cs b/EcommerceApi/Ecommerce.Services/CategoryService.cs new file mode 100644 index 00000000..efd315d1 --- /dev/null +++ b/EcommerceApi/Ecommerce.Services/CategoryService.cs @@ -0,0 +1,101 @@ +using Ecommerce.Core.DTOs; +using Ecommerce.Core.DTOs.Category; +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Models; + +namespace Ecommerce.Services; + +public class CategoryService(IUnitOfWork unitOfWork) : ICategoryService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork; + public async Task> CreateCategoryAsync(CreateCategoryDto category) + { + Category newCategory = new() + { + Name = category.Name, + Description = category.Description + }; + _unitOfWork.Categories.Add(newCategory); + int rowsAffected = await _unitOfWork.CompleteAsync(); + if (rowsAffected == 0) + { + return Result.Fail("Failed to create category"); + } + + CategoryDto dto = new() + { + Id = newCategory.Id, + Name = newCategory.Name, + Description = newCategory.Description + }; + return Result.Success(dto); + } + + public async Task> GetCategoryAsync(int id) + { + var category = await _unitOfWork.Categories.GetByIdAsync(id); + if (category is null) + { + return Result.Fail($"Category with Id {id} was not found"); + } + + CategoryDto dto = new() + { + Id = category.Id, + Name = category.Name, + Description = category.Description + }; + return Result.Success(dto); + } + + public async Task>> GetAllCategoriesAsync() + { + var categories = await _unitOfWork.Categories.GetAllAsync(); + return Result>.Success( + categories.Select(c => new CategoryDto() + { + Id= c.Id, + Name = c.Name, + Description = c.Description + }) + ); + } + + public async Task> UpdateCategoryAsync(int id, UpdateCategoryDto category) + { + var categoryFromDb = await _unitOfWork.Categories.GetByIdAsync(id); + if(categoryFromDb is null) + return Result.Fail($"Category with Id {id} was not found"); + categoryFromDb.Name = category.Name; + categoryFromDb.Description = category.Description; + _unitOfWork.Categories.Update(categoryFromDb); + var rowsAffected = await _unitOfWork.CompleteAsync(); + if (rowsAffected == 0) + { + return Result.Fail("Failed to update category"); + } + + return Result.Success(new CategoryDto() + { + Id = categoryFromDb.Id, + Name = categoryFromDb.Name, + Description = categoryFromDb.Description + }); + } + + public async Task> DeleteCategoryAsync(int id) + { + var categoryFromDb = await _unitOfWork.Categories.GetByIdAsync(id); + if(categoryFromDb is null) + return Result.Fail($"Category with Id {id} was not found"); + + _unitOfWork.Categories.Delete(categoryFromDb); + var rowsAffected = await _unitOfWork.CompleteAsync(); + if (rowsAffected == 0) + { + return Result.Fail("Failed to delete category"); + } + return Result.Success(true); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs b/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs new file mode 100644 index 00000000..94f61f67 --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs @@ -0,0 +1,52 @@ +using Ecommerce.Core.DTOs.Category; +using Ecommerce.Core.Interfaces.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Ecommerce.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class CategoryController : ControllerBase +{ + private readonly ICategoryService _categoryService; + + public CategoryController(ICategoryService categoryService) + { + _categoryService = categoryService; + } + + [HttpGet] + public async Task GetAll() + { + var categories = await _categoryService.GetAllCategoriesAsync(); + return Ok(categories.Data); + } + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var result = await _categoryService.GetCategoryAsync(id); + return result.IsSuccess ? Ok(result.Data) : NotFound(result.Message); + } + + [HttpPost] + public async Task Create(CreateCategoryDto category) + { + var result = await _categoryService.CreateCategoryAsync(category); + return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); + } + + [HttpPut("{id:int}")] + public async Task Update(int id, UpdateCategoryDto category) + { + var result = await _categoryService.UpdateCategoryAsync(id, category); + return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var result = await _categoryService.DeleteCategoryAsync(id); + return result.IsSuccess ? NoContent() : BadRequest(result.Message); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj index 9cc5e959..28323f02 100644 --- a/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj +++ b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj @@ -16,10 +16,6 @@ - - - - From 2a068e4d9e8bcf93fc9b483ab55ca3069c425396 Mon Sep 17 00:00:00 2001 From: basem Date: Tue, 3 Mar 2026 04:46:23 +0200 Subject: [PATCH 09/52] fix update category logic --- EcommerceApi/Ecommerce.Services/CategoryService.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/EcommerceApi/Ecommerce.Services/CategoryService.cs b/EcommerceApi/Ecommerce.Services/CategoryService.cs index efd315d1..7dddf1a1 100644 --- a/EcommerceApi/Ecommerce.Services/CategoryService.cs +++ b/EcommerceApi/Ecommerce.Services/CategoryService.cs @@ -69,12 +69,8 @@ public async Task> UpdateCategoryAsync(int id, UpdateCategor return Result.Fail($"Category with Id {id} was not found"); categoryFromDb.Name = category.Name; categoryFromDb.Description = category.Description; - _unitOfWork.Categories.Update(categoryFromDb); - var rowsAffected = await _unitOfWork.CompleteAsync(); - if (rowsAffected == 0) - { - return Result.Fail("Failed to update category"); - } + + await _unitOfWork.CompleteAsync(); return Result.Success(new CategoryDto() { From c7f788d7866301ea5baf2f84f0a658ce72b351dd Mon Sep 17 00:00:00 2001 From: basem Date: Sat, 7 Mar 2026 05:50:43 +0200 Subject: [PATCH 10/52] Add pagination support to GetAllAsync and include properties to GetAllAsync and GetByIdAsync --- .../Interfaces/Common/IBaseEntity.cs | 6 ++++ .../Repositories/IGenericRepository.cs | 7 ++-- .../Ecommerce.Core/Models/Category.cs | 2 +- EcommerceApi/Ecommerce.Core/Models/Product.cs | 4 +-- EcommerceApi/Ecommerce.Core/Models/Sale.cs | 2 +- .../Repositories/GenericRepository.cs | 35 ++++++++++++++++--- 6 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 EcommerceApi/Ecommerce.Core/Interfaces/Common/IBaseEntity.cs diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Common/IBaseEntity.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Common/IBaseEntity.cs new file mode 100644 index 00000000..6311f46b --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Common/IBaseEntity.cs @@ -0,0 +1,6 @@ +namespace Ecommerce.Core.Interfaces.Common; + +public interface IBaseEntity +{ + int Id { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs index d785cff0..a83090c2 100644 --- a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs @@ -1,11 +1,14 @@ +using System.Linq.Expressions; using Ecommerce.Core.Interfaces.Common; +using Ecommerce.Core.Utilities; namespace Ecommerce.Core.Interfaces.Repositories; public interface IGenericRepository where T : class { - Task> GetAllAsync(); - Task GetByIdAsync(int id); + Task> GetAllAsync(params Expression>[] includeProperties); + Task> GetAllAsync(PaginationParams paginationParams, params Expression>[] includeProperties); + Task GetByIdAsync(int id, params Expression>[] includes); void Add(T entity); void Update(T entity); void Delete(ISoftDeletable entity); diff --git a/EcommerceApi/Ecommerce.Core/Models/Category.cs b/EcommerceApi/Ecommerce.Core/Models/Category.cs index 78b1905a..3361d81e 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Category.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Category.cs @@ -2,7 +2,7 @@ namespace Ecommerce.Core.Models; -public class Category : ISoftDeletable +public class Category : IBaseEntity, ISoftDeletable { public int Id { get; set; } public string Name { get; set; } = string.Empty; diff --git a/EcommerceApi/Ecommerce.Core/Models/Product.cs b/EcommerceApi/Ecommerce.Core/Models/Product.cs index 7f854bbf..6db21721 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Product.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Product.cs @@ -2,7 +2,7 @@ namespace Ecommerce.Core.Models; -public class Product : ISoftDeletable +public class Product : IBaseEntity, ISoftDeletable { public int Id { get; set; } public string Name { get; set; } = string.Empty; @@ -11,7 +11,7 @@ public class Product : ISoftDeletable public string? ImageUrl { get; set; } public int CategoryId { get; set; } public Category? Category { get; set; } - + public bool IsDeleted { get; set; } public DateTime? DeletedAt { get; set; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/Sale.cs b/EcommerceApi/Ecommerce.Core/Models/Sale.cs index abca2dfa..425c93b7 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Sale.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Sale.cs @@ -2,7 +2,7 @@ namespace Ecommerce.Core.Models; -public class Sale : ISoftDeletable +public class Sale : IBaseEntity, ISoftDeletable { public int Id { get; set; } public DateTime CreationDate { get; set; } diff --git a/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs index 4c39c6ff..61bde700 100644 --- a/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs +++ b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs @@ -1,22 +1,47 @@ +using System.Linq.Expressions; using Ecommerce.Core.Interfaces.Common; using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Utilities; using Microsoft.EntityFrameworkCore; namespace Ecommerce.Data.Repositories; public class GenericRepository(AppDbContext context) : IGenericRepository - where T : class + where T : class, IBaseEntity { private readonly AppDbContext _context = context; - public async Task> GetAllAsync() + public async Task> GetAllAsync(params Expression>[] includeProperties) { - return await _context.Set().ToListAsync(); + if (includeProperties.Length == 0) + return await _context.Set().ToListAsync(); + + var query = _context.Set().AsQueryable(); + foreach (var includeProperty in includeProperties) + query = query.Include(includeProperty); + return await query.ToListAsync(); } - public async Task GetByIdAsync(int id) + public Task> GetAllAsync(PaginationParams paginationParams, params Expression>[] includeProperties) { - return await _context.Set().FindAsync(id); + var query = _context.Set().AsQueryable(); + if (includeProperties.Length > 0) + { + foreach (var includeProperty in includeProperties) + query = query.Include(includeProperty); + } + return query.Skip((paginationParams.PageNumber - 1) * paginationParams.PageSize) + .Take(paginationParams.PageSize) + .ToListAsync(); + } + + public async Task GetByIdAsync(int id, params Expression>[] includeProperties) + { + if (includeProperties.Length == 0) return await _context.Set().FindAsync(id); + var query = _context.Set().AsQueryable(); + foreach (var includeProperty in includeProperties) + query = query.Include(includeProperty); + return await query.FirstOrDefaultAsync(e => e.Id == id); } public void Add(T entity) From 5d0f861375c30cf79e9f1a47adda48b45c078305 Mon Sep 17 00:00:00 2001 From: basem Date: Sat, 7 Mar 2026 05:52:03 +0200 Subject: [PATCH 11/52] Move Result to Utilities folder and update references --- .../Ecommerce.Core/Interfaces/Services/ICategoryService.cs | 3 +-- EcommerceApi/Ecommerce.Core/{DTOs => Utilities}/Result.cs | 2 +- EcommerceApi/Ecommerce.Services/CategoryService.cs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) rename EcommerceApi/Ecommerce.Core/{DTOs => Utilities}/Result.cs (91%) diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs index 2e610cb1..eb627aab 100644 --- a/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs @@ -1,6 +1,5 @@ -using Ecommerce.Core.DTOs; using Ecommerce.Core.DTOs.Category; -using Ecommerce.Core.Models; +using Ecommerce.Core.Utilities; namespace Ecommerce.Core.Interfaces.Services; diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Result.cs b/EcommerceApi/Ecommerce.Core/Utilities/Result.cs similarity index 91% rename from EcommerceApi/Ecommerce.Core/DTOs/Result.cs rename to EcommerceApi/Ecommerce.Core/Utilities/Result.cs index ab3b7194..715db21c 100644 --- a/EcommerceApi/Ecommerce.Core/DTOs/Result.cs +++ b/EcommerceApi/Ecommerce.Core/Utilities/Result.cs @@ -1,4 +1,4 @@ -namespace Ecommerce.Core.DTOs; +namespace Ecommerce.Core.Utilities; public class Result { diff --git a/EcommerceApi/Ecommerce.Services/CategoryService.cs b/EcommerceApi/Ecommerce.Services/CategoryService.cs index 7dddf1a1..18857fa7 100644 --- a/EcommerceApi/Ecommerce.Services/CategoryService.cs +++ b/EcommerceApi/Ecommerce.Services/CategoryService.cs @@ -1,8 +1,8 @@ -using Ecommerce.Core.DTOs; using Ecommerce.Core.DTOs.Category; using Ecommerce.Core.Interfaces.Repositories; using Ecommerce.Core.Interfaces.Services; using Ecommerce.Core.Models; +using Ecommerce.Core.Utilities; namespace Ecommerce.Services; From 296cee7a47ec778b6de79e7f82031d006fc884ef Mon Sep 17 00:00:00 2001 From: basem Date: Sat, 7 Mar 2026 05:50:43 +0200 Subject: [PATCH 12/52] # This is a combination of 2 commits. # This is the 1st commit message: Add pagination support to GetAllAsync and include properties to GetAllAsync and GetByIdAsync # The commit message #2 will be skipped: # fixup! Add pagination support to GetAllAsync and include properties to GetAllAsync and GetByIdAsync --- .../Interfaces/Common/IBaseEntity.cs | 6 ++++ .../Repositories/IGenericRepository.cs | 7 ++-- .../Ecommerce.Core/Models/Category.cs | 2 +- EcommerceApi/Ecommerce.Core/Models/Product.cs | 4 +-- EcommerceApi/Ecommerce.Core/Models/Sale.cs | 2 +- .../Utilities/PaginationParams.cs | 14 ++++++++ .../Repositories/GenericRepository.cs | 35 ++++++++++++++++--- 7 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 EcommerceApi/Ecommerce.Core/Interfaces/Common/IBaseEntity.cs create mode 100644 EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Common/IBaseEntity.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Common/IBaseEntity.cs new file mode 100644 index 00000000..6311f46b --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Common/IBaseEntity.cs @@ -0,0 +1,6 @@ +namespace Ecommerce.Core.Interfaces.Common; + +public interface IBaseEntity +{ + int Id { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs index d785cff0..a83090c2 100644 --- a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs @@ -1,11 +1,14 @@ +using System.Linq.Expressions; using Ecommerce.Core.Interfaces.Common; +using Ecommerce.Core.Utilities; namespace Ecommerce.Core.Interfaces.Repositories; public interface IGenericRepository where T : class { - Task> GetAllAsync(); - Task GetByIdAsync(int id); + Task> GetAllAsync(params Expression>[] includeProperties); + Task> GetAllAsync(PaginationParams paginationParams, params Expression>[] includeProperties); + Task GetByIdAsync(int id, params Expression>[] includes); void Add(T entity); void Update(T entity); void Delete(ISoftDeletable entity); diff --git a/EcommerceApi/Ecommerce.Core/Models/Category.cs b/EcommerceApi/Ecommerce.Core/Models/Category.cs index 78b1905a..3361d81e 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Category.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Category.cs @@ -2,7 +2,7 @@ namespace Ecommerce.Core.Models; -public class Category : ISoftDeletable +public class Category : IBaseEntity, ISoftDeletable { public int Id { get; set; } public string Name { get; set; } = string.Empty; diff --git a/EcommerceApi/Ecommerce.Core/Models/Product.cs b/EcommerceApi/Ecommerce.Core/Models/Product.cs index 7f854bbf..6db21721 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Product.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Product.cs @@ -2,7 +2,7 @@ namespace Ecommerce.Core.Models; -public class Product : ISoftDeletable +public class Product : IBaseEntity, ISoftDeletable { public int Id { get; set; } public string Name { get; set; } = string.Empty; @@ -11,7 +11,7 @@ public class Product : ISoftDeletable public string? ImageUrl { get; set; } public int CategoryId { get; set; } public Category? Category { get; set; } - + public bool IsDeleted { get; set; } public DateTime? DeletedAt { get; set; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/Sale.cs b/EcommerceApi/Ecommerce.Core/Models/Sale.cs index abca2dfa..425c93b7 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Sale.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Sale.cs @@ -2,7 +2,7 @@ namespace Ecommerce.Core.Models; -public class Sale : ISoftDeletable +public class Sale : IBaseEntity, ISoftDeletable { public int Id { get; set; } public DateTime CreationDate { get; set; } diff --git a/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs b/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs new file mode 100644 index 00000000..f212c8e5 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs @@ -0,0 +1,14 @@ +namespace Ecommerce.Core.Utilities; + +public class PaginationParams +{ + private const int DefaultPageSize = 50; + public int PageNumber { get; set; } = 1; + + public int PageSize + { + get; + init => + field = value > DefaultPageSize ? DefaultPageSize : value; + } = 10; +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs index 4c39c6ff..61bde700 100644 --- a/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs +++ b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs @@ -1,22 +1,47 @@ +using System.Linq.Expressions; using Ecommerce.Core.Interfaces.Common; using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Utilities; using Microsoft.EntityFrameworkCore; namespace Ecommerce.Data.Repositories; public class GenericRepository(AppDbContext context) : IGenericRepository - where T : class + where T : class, IBaseEntity { private readonly AppDbContext _context = context; - public async Task> GetAllAsync() + public async Task> GetAllAsync(params Expression>[] includeProperties) { - return await _context.Set().ToListAsync(); + if (includeProperties.Length == 0) + return await _context.Set().ToListAsync(); + + var query = _context.Set().AsQueryable(); + foreach (var includeProperty in includeProperties) + query = query.Include(includeProperty); + return await query.ToListAsync(); } - public async Task GetByIdAsync(int id) + public Task> GetAllAsync(PaginationParams paginationParams, params Expression>[] includeProperties) { - return await _context.Set().FindAsync(id); + var query = _context.Set().AsQueryable(); + if (includeProperties.Length > 0) + { + foreach (var includeProperty in includeProperties) + query = query.Include(includeProperty); + } + return query.Skip((paginationParams.PageNumber - 1) * paginationParams.PageSize) + .Take(paginationParams.PageSize) + .ToListAsync(); + } + + public async Task GetByIdAsync(int id, params Expression>[] includeProperties) + { + if (includeProperties.Length == 0) return await _context.Set().FindAsync(id); + var query = _context.Set().AsQueryable(); + foreach (var includeProperty in includeProperties) + query = query.Include(includeProperty); + return await query.FirstOrDefaultAsync(e => e.Id == id); } public void Add(T entity) From 89df08ac8783efd390cf72e6169286af12692e85 Mon Sep 17 00:00:00 2001 From: basem Date: Sat, 7 Mar 2026 05:52:03 +0200 Subject: [PATCH 13/52] Move Result to Utilities folder and update references --- .../Ecommerce.Core/Interfaces/Services/ICategoryService.cs | 3 +-- EcommerceApi/Ecommerce.Core/{DTOs => Utilities}/Result.cs | 2 +- EcommerceApi/Ecommerce.Services/CategoryService.cs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) rename EcommerceApi/Ecommerce.Core/{DTOs => Utilities}/Result.cs (91%) diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs index 2e610cb1..eb627aab 100644 --- a/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ICategoryService.cs @@ -1,6 +1,5 @@ -using Ecommerce.Core.DTOs; using Ecommerce.Core.DTOs.Category; -using Ecommerce.Core.Models; +using Ecommerce.Core.Utilities; namespace Ecommerce.Core.Interfaces.Services; diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Result.cs b/EcommerceApi/Ecommerce.Core/Utilities/Result.cs similarity index 91% rename from EcommerceApi/Ecommerce.Core/DTOs/Result.cs rename to EcommerceApi/Ecommerce.Core/Utilities/Result.cs index ab3b7194..715db21c 100644 --- a/EcommerceApi/Ecommerce.Core/DTOs/Result.cs +++ b/EcommerceApi/Ecommerce.Core/Utilities/Result.cs @@ -1,4 +1,4 @@ -namespace Ecommerce.Core.DTOs; +namespace Ecommerce.Core.Utilities; public class Result { diff --git a/EcommerceApi/Ecommerce.Services/CategoryService.cs b/EcommerceApi/Ecommerce.Services/CategoryService.cs index 7dddf1a1..18857fa7 100644 --- a/EcommerceApi/Ecommerce.Services/CategoryService.cs +++ b/EcommerceApi/Ecommerce.Services/CategoryService.cs @@ -1,8 +1,8 @@ -using Ecommerce.Core.DTOs; using Ecommerce.Core.DTOs.Category; using Ecommerce.Core.Interfaces.Repositories; using Ecommerce.Core.Interfaces.Services; using Ecommerce.Core.Models; +using Ecommerce.Core.Utilities; namespace Ecommerce.Services; From 5ca2f5c356a263173f5c641ec44af6ec3bc575ed Mon Sep 17 00:00:00 2001 From: basem Date: Sat, 7 Mar 2026 06:06:10 +0200 Subject: [PATCH 14/52] Create Product Module(Controller,Service, DTOs) --- .../DTOs/Product/CreateProductDto.cs | 10 ++ .../Ecommerce.Core/DTOs/Product/ProductDto.cs | 12 ++ .../DTOs/Product/UpdateProductDto.cs | 10 ++ .../Interfaces/Services/IProductService.cs | 14 ++ .../Ecommerce.Services/ProductService.cs | 121 ++++++++++++++++++ .../Controllers/ProductController.cs | 47 +++++++ 6 files changed, 214 insertions(+) create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs create mode 100644 EcommerceApi/Ecommerce.Core/Interfaces/Services/IProductService.cs create mode 100644 EcommerceApi/Ecommerce.Services/ProductService.cs create mode 100644 EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs new file mode 100644 index 00000000..ea99a857 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs @@ -0,0 +1,10 @@ +namespace Ecommerce.Core.DTOs.Product; + +public class CreateProductDto +{ + public string Name { get; set; } = string.Empty; + public decimal Price { get; set; } + public string? Description { get; set; } + public string? ImageUrl { get; set; } + public int CategoryId { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs new file mode 100644 index 00000000..4306663e --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs @@ -0,0 +1,12 @@ +namespace Ecommerce.Core.DTOs.Product; + +public class ProductDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public decimal Price { get; set; } + public string? Description { get; set; } + public string? ImageUrl { get; set; } + public int CategoryId { get; set; } + public string CategoryName { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs new file mode 100644 index 00000000..06b84559 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs @@ -0,0 +1,10 @@ +namespace Ecommerce.Core.DTOs.Product; + +public class UpdateProductDto +{ + public string Name { get; set; } = string.Empty; + public decimal Price { get; set; } + public string? Description { get; set; } + public string? ImageUrl { get; set; } + public int CategoryId { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Services/IProductService.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Services/IProductService.cs new file mode 100644 index 00000000..59684b4c --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Services/IProductService.cs @@ -0,0 +1,14 @@ +using Ecommerce.Core.DTOs; +using Ecommerce.Core.DTOs.Product; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Core.Interfaces.Services; + +public interface IProductService +{ + Task> CreateProductAsync(CreateProductDto product); + Task> GetProductAsync(int id); + Task>> GetAllProductsAsync(PaginationParams paginationParams); + Task> UpdateProductAsync(int id, UpdateProductDto product); + Task> DeleteProductAsync(int id); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Services/ProductService.cs b/EcommerceApi/Ecommerce.Services/ProductService.cs new file mode 100644 index 00000000..bf4dde5f --- /dev/null +++ b/EcommerceApi/Ecommerce.Services/ProductService.cs @@ -0,0 +1,121 @@ +using Ecommerce.Core.DTOs.Product; +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Models; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Services; + +public class ProductService(IUnitOfWork unitOfWork) : IProductService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork; + + public async Task> CreateProductAsync(CreateProductDto product) + { + var category = await _unitOfWork.Categories.GetByIdAsync(product.CategoryId); + if (category is null) + { + return Result.Fail($"Category with Id {product.CategoryId} was not found"); + } + Product newProduct = new() + { + Name = product.Name, + Description = product.Description, + Price = product.Price, + CategoryId = product.CategoryId + }; + _unitOfWork.Products.Add(newProduct); + int rowsAffected = await _unitOfWork.CompleteAsync(); + if (rowsAffected == 0) + { + return Result.Fail("Failed to create category"); + } + ProductDto dto = new() + { + Id = newProduct.Id, + Name = newProduct.Name, + Description = newProduct.Description, + Price = newProduct.Price, + CategoryId = newProduct.CategoryId, + CategoryName = category.Name + }; + return Result.Success(dto); + } + + public async Task> GetProductAsync(int id) + { + var product = await _unitOfWork.Products.GetByIdAsync(id, p => p.Category); + if (product is null) + { + return Result.Fail($"Product with Id {id} was not found"); + } + + ProductDto dto = new() + { + Id = product.Id, + Name = product.Name, + Description = product.Description, + Price = product.Price, + CategoryId = product.CategoryId, + CategoryName = product.Category?.Name ?? string.Empty + }; + return Result.Success(dto); + } + + public async Task>> GetAllProductsAsync(PaginationParams paginationParams) + { + var products = await _unitOfWork.Products.GetAllAsync(paginationParams); + return Result>.Success(products.Select(p => new ProductDto() + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + Price = p.Price, + CategoryId = p.CategoryId, + CategoryName = p.Category?.Name ?? string.Empty + })); + } + + public async Task> UpdateProductAsync(int id, UpdateProductDto product) + { + var category = await _unitOfWork.Categories.GetByIdAsync(product.CategoryId); + if (category is null) + return Result.Fail($"Category with Id {id} was not found"); + + var productFromDb = await _unitOfWork.Products.GetByIdAsync(id); + if(productFromDb is null) + return Result.Fail($"Product with Id {id} was not found"); + + productFromDb.Name = product.Name; + productFromDb.Description = product.Description; + productFromDb.Price = product.Price; + productFromDb.ImageUrl = product.ImageUrl; + productFromDb.CategoryId = product.CategoryId; + + await _unitOfWork.CompleteAsync(); + + return Result.Success(new ProductDto() + { + Id = productFromDb.Id, + Name = productFromDb.Name, + Description = productFromDb.Description, + Price = productFromDb.Price, + CategoryId = productFromDb.CategoryId, + CategoryName = category.Name + }); + } + + public async Task> DeleteProductAsync(int id) + { + var product = await _unitOfWork.Products.GetByIdAsync(id); + if(product is null) + return Result.Fail($"Product with Id {id} was not found"); + _unitOfWork.Products.Delete(product); + var rowsAffected = await _unitOfWork.CompleteAsync(); + if (rowsAffected == 0) + { + return Result.Fail("Failed to delete product"); + } + return Result.Success(true); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs b/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs new file mode 100644 index 00000000..8f6f0a80 --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs @@ -0,0 +1,47 @@ +using Ecommerce.Core.DTOs.Product; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Utilities; +using Microsoft.AspNetCore.Mvc; + +namespace Ecommerce.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ProductController(IProductService productService) : ControllerBase +{ + private readonly IProductService _productService = productService; + [HttpGet] + public async Task GetAll([FromQuery] PaginationParams paginationParams) + { + var products = await _productService.GetAllProductsAsync(paginationParams); + return Ok(products.Data); + } + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var product = await _productService.GetProductAsync(id); + return product.IsSuccess ? Ok(product.Data) : NotFound(product.Message); + } + + [HttpPost] + public async Task Create(CreateProductDto product) + { + var result = await _productService.CreateProductAsync(product); + return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var result = await _productService.DeleteProductAsync(id); + return result.IsSuccess ? NoContent() : BadRequest(result.Message); + } + + [HttpPut("{id:int}")] + public async Task Update(int id, UpdateProductDto product) + { + var result = await _productService.UpdateProductAsync(id, product); + return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); + } +} \ No newline at end of file From ded8d3923ed41624570e8d044038a022133e920c Mon Sep 17 00:00:00 2001 From: basem Date: Sat, 7 Mar 2026 06:27:22 +0200 Subject: [PATCH 15/52] Add Product Service to DI Container. Fix category including property to product. Fix a repository method to be async --- EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs | 4 ++-- EcommerceApi/Ecommerce.Services/ProductService.cs | 2 +- EcommerceApi/Ecommerce.Web/Program.cs | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs index 61bde700..5a9b04bb 100644 --- a/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs +++ b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs @@ -22,7 +22,7 @@ public async Task> GetAllAsync(params Expression>[] inc return await query.ToListAsync(); } - public Task> GetAllAsync(PaginationParams paginationParams, params Expression>[] includeProperties) + public async Task> GetAllAsync(PaginationParams paginationParams, params Expression>[] includeProperties) { var query = _context.Set().AsQueryable(); if (includeProperties.Length > 0) @@ -30,7 +30,7 @@ public Task> GetAllAsync(PaginationParams paginationParams, params Expre foreach (var includeProperty in includeProperties) query = query.Include(includeProperty); } - return query.Skip((paginationParams.PageNumber - 1) * paginationParams.PageSize) + return await query.Skip((paginationParams.PageNumber - 1) * paginationParams.PageSize) .Take(paginationParams.PageSize) .ToListAsync(); } diff --git a/EcommerceApi/Ecommerce.Services/ProductService.cs b/EcommerceApi/Ecommerce.Services/ProductService.cs index bf4dde5f..124b2543 100644 --- a/EcommerceApi/Ecommerce.Services/ProductService.cs +++ b/EcommerceApi/Ecommerce.Services/ProductService.cs @@ -64,7 +64,7 @@ public async Task> GetProductAsync(int id) public async Task>> GetAllProductsAsync(PaginationParams paginationParams) { - var products = await _unitOfWork.Products.GetAllAsync(paginationParams); + var products = await _unitOfWork.Products.GetAllAsync(paginationParams, p => p.Category); return Result>.Success(products.Select(p => new ProductDto() { Id = p.Id, diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs index 31e4cb09..3279c0d4 100644 --- a/EcommerceApi/Ecommerce.Web/Program.cs +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -12,6 +12,7 @@ ); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddOpenApi(); From 7b0751ce2965ec27e9888e8d8a4437f8878b9004 Mon Sep 17 00:00:00 2001 From: basem Date: Sun, 8 Mar 2026 23:38:10 +0200 Subject: [PATCH 16/52] Add quntity attribute to product table --- .../Ecommerce.Core/DTOs/Product/CreateProductDto.cs | 1 + EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs | 1 + .../Ecommerce.Core/DTOs/Product/UpdateProductDto.cs | 1 + EcommerceApi/Ecommerce.Core/Models/Product.cs | 1 + .../Migrations/AppDbContextModelSnapshot.cs | 3 +++ EcommerceApi/Ecommerce.Services/ProductService.cs | 8 ++++++++ 6 files changed, 15 insertions(+) diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs index ea99a857..812bc3f0 100644 --- a/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs +++ b/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs @@ -4,6 +4,7 @@ public class CreateProductDto { public string Name { get; set; } = string.Empty; public decimal Price { get; set; } + public int Quantity { get; set; } = 1; public string? Description { get; set; } public string? ImageUrl { get; set; } public int CategoryId { get; set; } diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs index 4306663e..200eb2f6 100644 --- a/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs +++ b/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs @@ -5,6 +5,7 @@ public class ProductDto public int Id { get; set; } public string Name { get; set; } = string.Empty; public decimal Price { get; set; } + public int Quantity { get; set; } public string? Description { get; set; } public string? ImageUrl { get; set; } public int CategoryId { get; set; } diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs index 06b84559..a6910a50 100644 --- a/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs +++ b/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs @@ -4,6 +4,7 @@ public class UpdateProductDto { public string Name { get; set; } = string.Empty; public decimal Price { get; set; } + public int Quantity { get; set; } public string? Description { get; set; } public string? ImageUrl { get; set; } public int CategoryId { get; set; } diff --git a/EcommerceApi/Ecommerce.Core/Models/Product.cs b/EcommerceApi/Ecommerce.Core/Models/Product.cs index 6db21721..75835ff0 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Product.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Product.cs @@ -7,6 +7,7 @@ public class Product : IBaseEntity, ISoftDeletable public int Id { get; set; } public string Name { get; set; } = string.Empty; public decimal Price { get; set; } + public int Quantity { get; set; } public string? Description { get; set; } public string? ImageUrl { get; set; } public int CategoryId { get; set; } diff --git a/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs b/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs index 186e96bb..8128b255 100644 --- a/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs @@ -79,6 +79,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasPrecision(18, 2) .HasColumnType("decimal(18,2)"); + b.Property("Quantity") + .HasColumnType("int"); + b.HasKey("Id"); b.HasIndex("CategoryId"); diff --git a/EcommerceApi/Ecommerce.Services/ProductService.cs b/EcommerceApi/Ecommerce.Services/ProductService.cs index 124b2543..b8af94b0 100644 --- a/EcommerceApi/Ecommerce.Services/ProductService.cs +++ b/EcommerceApi/Ecommerce.Services/ProductService.cs @@ -34,6 +34,8 @@ public async Task> CreateProductAsync(CreateProductDto produc { Id = newProduct.Id, Name = newProduct.Name, + ImageUrl = newProduct.ImageUrl, + Quantity = newProduct.Quantity, Description = newProduct.Description, Price = newProduct.Price, CategoryId = newProduct.CategoryId, @@ -54,6 +56,8 @@ public async Task> GetProductAsync(int id) { Id = product.Id, Name = product.Name, + ImageUrl = product.ImageUrl, + Quantity = product.Quantity, Description = product.Description, Price = product.Price, CategoryId = product.CategoryId, @@ -69,6 +73,8 @@ public async Task>> GetAllProductsAsync(Paginatio { Id = p.Id, Name = p.Name, + ImageUrl = p.ImageUrl, + Quantity = p.Quantity, Description = p.Description, Price = p.Price, CategoryId = p.CategoryId, @@ -98,6 +104,8 @@ public async Task> UpdateProductAsync(int id, UpdateProductDt { Id = productFromDb.Id, Name = productFromDb.Name, + ImageUrl = productFromDb.ImageUrl, + Quantity = productFromDb.Quantity, Description = productFromDb.Description, Price = productFromDb.Price, CategoryId = productFromDb.CategoryId, From 963bb4cd021a3dacc2f3ee983fb35254615bb2c7 Mon Sep 17 00:00:00 2001 From: basem Date: Sun, 8 Mar 2026 23:38:44 +0200 Subject: [PATCH 17/52] fix page number issue when entered less than 1 --- EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs b/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs index f212c8e5..9cb8c68a 100644 --- a/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs +++ b/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs @@ -3,7 +3,13 @@ namespace Ecommerce.Core.Utilities; public class PaginationParams { private const int DefaultPageSize = 50; - public int PageNumber { get; set; } = 1; + + public int PageNumber + { + get; + init => + field = value > 0 ? value : 1; + } = 1; public int PageSize { From 480fe77edf95f221ca2b1e97be650b5160443f89 Mon Sep 17 00:00:00 2001 From: basem Date: Tue, 10 Mar 2026 23:14:44 +0200 Subject: [PATCH 18/52] Add Find method to IGenericRepository to support querying by condition and include properties. --- .../Interfaces/Repositories/IGenericRepository.cs | 1 + .../Ecommerce.Data/Repositories/GenericRepository.cs | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs index a83090c2..1936b513 100644 --- a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/IGenericRepository.cs @@ -8,6 +8,7 @@ public interface IGenericRepository where T : class { Task> GetAllAsync(params Expression>[] includeProperties); Task> GetAllAsync(PaginationParams paginationParams, params Expression>[] includeProperties); + Task> FindAsync(Expression> predicate, params Expression>[] includeProperties); Task GetByIdAsync(int id, params Expression>[] includes); void Add(T entity); void Update(T entity); diff --git a/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs index 5a9b04bb..376cac74 100644 --- a/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs +++ b/EcommerceApi/Ecommerce.Data/Repositories/GenericRepository.cs @@ -35,6 +35,17 @@ public async Task> GetAllAsync(PaginationParams paginationParams, params .ToListAsync(); } + public async Task> FindAsync(Expression> predicate, params Expression>[] includeProperties) + { + var query = _context.Set().AsQueryable(); + if (includeProperties.Length > 0) + { + foreach (var includeProperty in includeProperties) + query = query.Include(includeProperty); + } + return await query.Where(predicate).ToListAsync(); + } + public async Task GetByIdAsync(int id, params Expression>[] includeProperties) { if (includeProperties.Length == 0) return await _context.Set().FindAsync(id); From b33be1ee68f107c9c10a0708e2114cf135132971 Mon Sep 17 00:00:00 2001 From: basem Date: Tue, 10 Mar 2026 23:17:46 +0200 Subject: [PATCH 19/52] Create Sale DTOs, Interface, and register ISaleService in DI Container. --- .../Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs | 12 +++ .../Ecommerce.Core/DTOs/Sale/SaleDto.cs | 17 ++++ .../Interfaces/Services/ISaleService.cs | 11 +++ .../Ecommerce.Services/SaleService.cs | 77 +++++++++++++++++++ EcommerceApi/Ecommerce.Web/Program.cs | 1 + 5 files changed, 118 insertions(+) create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs create mode 100644 EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs create mode 100644 EcommerceApi/Ecommerce.Services/SaleService.cs diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs new file mode 100644 index 00000000..66f59d9e --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs @@ -0,0 +1,12 @@ +namespace Ecommerce.Core.DTOs.Sale; + +public class CreateSaleDto +{ + public List Items { get; set; } = new(); +} + +public class CreateSaleItemDto +{ + public int ProductId { get; set; } + public int Quantity { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs new file mode 100644 index 00000000..6f345161 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs @@ -0,0 +1,17 @@ +namespace Ecommerce.Core.DTOs.Sale; + +public class SaleDto +{ + public int Id { get; set; } + public DateTime CreationDate { get; set; } + public decimal TotalPrice { get; set; } + public List Items { get; set; } = new(); +} + +public class SaleItemDto +{ + public int ProductId { get; set; } + public string? ProductName { get; set; } = string.Empty; + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs new file mode 100644 index 00000000..9be1652d --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs @@ -0,0 +1,11 @@ +using Ecommerce.Core.DTOs.Sale; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Core.Interfaces.Services; + +public interface ISaleService +{ + Task> CreateSaleAsync(CreateSaleDto sale); + Task> GetSaleAsync(int id); + Task>> GetAllSalesAsync(); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Services/SaleService.cs b/EcommerceApi/Ecommerce.Services/SaleService.cs new file mode 100644 index 00000000..97958020 --- /dev/null +++ b/EcommerceApi/Ecommerce.Services/SaleService.cs @@ -0,0 +1,77 @@ +using Ecommerce.Core.DTOs.Sale; +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Models; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Services; + +public class SaleService(IUnitOfWork unitOfWork) : ISaleService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork; + public async Task> CreateSaleAsync(CreateSaleDto sale) + { + if (sale.Items.Count == 0) + return Result.Fail("Sale must have at least one item"); + var productIds = sale.Items.Select(i => i.ProductId).ToList(); + var productsFromDb = await _unitOfWork.Products.FindAsync(p => productIds.Contains(p.Id)); + var saleItems = new List(); + foreach (var saleItem in sale.Items) + { + var product = productsFromDb.FirstOrDefault(p => p.Id == saleItem.ProductId); + if (product is null) + return Result.Fail($"Product with Id {saleItem.ProductId} was not found"); + if (product.Quantity < saleItem.Quantity) + return Result.Fail($"Insufficient stock for product {product.Name}"); + + product.Quantity -= saleItem.Quantity; + saleItems.Add(new SaleItem() + { + ProductId = product.Id, + Product = product, + Quantity = saleItem.Quantity, + UnitPriceAtTimeOfSale = product.Price + }); + } + + var saleEntity = new Sale() + { + CreationDate = DateTime.UtcNow, + Items = saleItems, + TotalPrice = saleItems.Sum(i => i.Quantity * i.UnitPriceAtTimeOfSale) + }; + + _unitOfWork.Sales.Add(saleEntity); + + var rowsAffected = await _unitOfWork.CompleteAsync(); + if (rowsAffected == 0) + { + return Result.Fail("Failed to create sale"); + } + + var saleDto = new SaleDto() + { + CreationDate = saleEntity.CreationDate, + TotalPrice = saleEntity.TotalPrice, + Id = saleEntity.Id, + Items = saleItems.Select(si => new SaleItemDto() + { + ProductId = si.ProductId, + ProductName = si.Product?.Name, + Quantity = si.Quantity, + UnitPrice = si.UnitPriceAtTimeOfSale + }).ToList() + }; + return Result.Success(saleDto); + } + + public Task> GetSaleAsync(int id) + { + throw new NotImplementedException(); + } + + public Task>> GetAllSalesAsync() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs index 3279c0d4..8b2ea548 100644 --- a/EcommerceApi/Ecommerce.Web/Program.cs +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -13,6 +13,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddOpenApi(); From 779f9dced66fdb1f1a5e53fd141c83106453b8e4 Mon Sep 17 00:00:00 2001 From: basem Date: Tue, 10 Mar 2026 23:17:46 +0200 Subject: [PATCH 20/52] Create Sale DTOs, Interface, register ISaleService in DI Container, and CreateSale business logic. --- .../Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs | 12 +++ .../Ecommerce.Core/DTOs/Sale/SaleDto.cs | 17 ++++ .../Interfaces/Services/ISaleService.cs | 11 +++ .../Ecommerce.Services/SaleService.cs | 77 +++++++++++++++++++ EcommerceApi/Ecommerce.Web/Program.cs | 1 + 5 files changed, 118 insertions(+) create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs create mode 100644 EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs create mode 100644 EcommerceApi/Ecommerce.Services/SaleService.cs diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs new file mode 100644 index 00000000..66f59d9e --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs @@ -0,0 +1,12 @@ +namespace Ecommerce.Core.DTOs.Sale; + +public class CreateSaleDto +{ + public List Items { get; set; } = new(); +} + +public class CreateSaleItemDto +{ + public int ProductId { get; set; } + public int Quantity { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs new file mode 100644 index 00000000..6f345161 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs @@ -0,0 +1,17 @@ +namespace Ecommerce.Core.DTOs.Sale; + +public class SaleDto +{ + public int Id { get; set; } + public DateTime CreationDate { get; set; } + public decimal TotalPrice { get; set; } + public List Items { get; set; } = new(); +} + +public class SaleItemDto +{ + public int ProductId { get; set; } + public string? ProductName { get; set; } = string.Empty; + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs new file mode 100644 index 00000000..9be1652d --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs @@ -0,0 +1,11 @@ +using Ecommerce.Core.DTOs.Sale; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Core.Interfaces.Services; + +public interface ISaleService +{ + Task> CreateSaleAsync(CreateSaleDto sale); + Task> GetSaleAsync(int id); + Task>> GetAllSalesAsync(); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Services/SaleService.cs b/EcommerceApi/Ecommerce.Services/SaleService.cs new file mode 100644 index 00000000..97958020 --- /dev/null +++ b/EcommerceApi/Ecommerce.Services/SaleService.cs @@ -0,0 +1,77 @@ +using Ecommerce.Core.DTOs.Sale; +using Ecommerce.Core.Interfaces.Repositories; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Models; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Services; + +public class SaleService(IUnitOfWork unitOfWork) : ISaleService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork; + public async Task> CreateSaleAsync(CreateSaleDto sale) + { + if (sale.Items.Count == 0) + return Result.Fail("Sale must have at least one item"); + var productIds = sale.Items.Select(i => i.ProductId).ToList(); + var productsFromDb = await _unitOfWork.Products.FindAsync(p => productIds.Contains(p.Id)); + var saleItems = new List(); + foreach (var saleItem in sale.Items) + { + var product = productsFromDb.FirstOrDefault(p => p.Id == saleItem.ProductId); + if (product is null) + return Result.Fail($"Product with Id {saleItem.ProductId} was not found"); + if (product.Quantity < saleItem.Quantity) + return Result.Fail($"Insufficient stock for product {product.Name}"); + + product.Quantity -= saleItem.Quantity; + saleItems.Add(new SaleItem() + { + ProductId = product.Id, + Product = product, + Quantity = saleItem.Quantity, + UnitPriceAtTimeOfSale = product.Price + }); + } + + var saleEntity = new Sale() + { + CreationDate = DateTime.UtcNow, + Items = saleItems, + TotalPrice = saleItems.Sum(i => i.Quantity * i.UnitPriceAtTimeOfSale) + }; + + _unitOfWork.Sales.Add(saleEntity); + + var rowsAffected = await _unitOfWork.CompleteAsync(); + if (rowsAffected == 0) + { + return Result.Fail("Failed to create sale"); + } + + var saleDto = new SaleDto() + { + CreationDate = saleEntity.CreationDate, + TotalPrice = saleEntity.TotalPrice, + Id = saleEntity.Id, + Items = saleItems.Select(si => new SaleItemDto() + { + ProductId = si.ProductId, + ProductName = si.Product?.Name, + Quantity = si.Quantity, + UnitPrice = si.UnitPriceAtTimeOfSale + }).ToList() + }; + return Result.Success(saleDto); + } + + public Task> GetSaleAsync(int id) + { + throw new NotImplementedException(); + } + + public Task>> GetAllSalesAsync() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs index 3279c0d4..8b2ea548 100644 --- a/EcommerceApi/Ecommerce.Web/Program.cs +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -13,6 +13,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddOpenApi(); From f4eec04f934c2b504c9387144fef1776ae180d48 Mon Sep 17 00:00:00 2001 From: basem Date: Fri, 13 Mar 2026 03:23:34 +0200 Subject: [PATCH 21/52] Update collection navigation properties not to be nullable and changed set accessors for better type safety. --- EcommerceApi/Ecommerce.Core/Models/Category.cs | 2 +- EcommerceApi/Ecommerce.Core/Models/Sale.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/EcommerceApi/Ecommerce.Core/Models/Category.cs b/EcommerceApi/Ecommerce.Core/Models/Category.cs index 3361d81e..b5178308 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Category.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Category.cs @@ -7,7 +7,7 @@ public class Category : IBaseEntity, ISoftDeletable public int Id { get; set; } public string Name { get; set; } = string.Empty; public string? Description { get; set; } - public ICollection? Products { get; set; } + public ICollection Products { get; set; } = new List(); public bool IsDeleted { get; set; } public DateTime? DeletedAt { get; set; } diff --git a/EcommerceApi/Ecommerce.Core/Models/Sale.cs b/EcommerceApi/Ecommerce.Core/Models/Sale.cs index 425c93b7..fc24ad88 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Sale.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Sale.cs @@ -5,9 +5,9 @@ namespace Ecommerce.Core.Models; public class Sale : IBaseEntity, ISoftDeletable { public int Id { get; set; } - public DateTime CreationDate { get; set; } - public decimal TotalPrice { get; set; } - public ICollection? Items { get; set; } = new List(); + public DateTime CreationDate { get; init; } + public decimal TotalPrice { get; init; } + public ICollection Items { get; init; } = new List(); public bool IsDeleted { get; set; } public DateTime? DeletedAt { get; set; } From af2fe3ea7d543cb5f75c571cf85748ac23cd8406 Mon Sep 17 00:00:00 2001 From: basem Date: Fri, 13 Mar 2026 03:25:17 +0200 Subject: [PATCH 22/52] Create SaleController, add SaleService get methods --- .../Repositories/ISaleRepository.cs | 1 + .../Interfaces/Services/ISaleService.cs | 2 +- .../Repositories/SaleRepository.cs | 10 ++++++ .../Ecommerce.Services/SaleService.cs | 35 ++++++++++++++++--- .../Controllers/SaleController.cs | 34 ++++++++++++++++++ 5 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ISaleRepository.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ISaleRepository.cs index 9fe74d86..c70422dd 100644 --- a/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ISaleRepository.cs +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Repositories/ISaleRepository.cs @@ -4,4 +4,5 @@ namespace Ecommerce.Core.Interfaces.Repositories; public interface ISaleRepository : IGenericRepository { + Task GetSaleWithItemsAsync(int id); } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs index 9be1652d..77df451c 100644 --- a/EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Services/ISaleService.cs @@ -7,5 +7,5 @@ public interface ISaleService { Task> CreateSaleAsync(CreateSaleDto sale); Task> GetSaleAsync(int id); - Task>> GetAllSalesAsync(); + Task>> GetAllSalesAsync(PaginationParams paginationParams); } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/Repositories/SaleRepository.cs b/EcommerceApi/Ecommerce.Data/Repositories/SaleRepository.cs index 162ebc9a..ca16775e 100644 --- a/EcommerceApi/Ecommerce.Data/Repositories/SaleRepository.cs +++ b/EcommerceApi/Ecommerce.Data/Repositories/SaleRepository.cs @@ -1,8 +1,18 @@ using Ecommerce.Core.Interfaces.Repositories; using Ecommerce.Core.Models; +using Microsoft.EntityFrameworkCore; namespace Ecommerce.Data.Repositories; public class SaleRepository(AppDbContext context) : GenericRepository(context), ISaleRepository { + private readonly AppDbContext _context = context; + public async Task GetSaleWithItemsAsync(int id) + { + return await _context.Sales + .Include(s => s.Items) + .ThenInclude(si => si.Product) + .IgnoreQueryFilters() + .FirstOrDefaultAsync(s => s.Id == id); + } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Services/SaleService.cs b/EcommerceApi/Ecommerce.Services/SaleService.cs index 97958020..2fbfd2c6 100644 --- a/EcommerceApi/Ecommerce.Services/SaleService.cs +++ b/EcommerceApi/Ecommerce.Services/SaleService.cs @@ -9,6 +9,7 @@ namespace Ecommerce.Services; public class SaleService(IUnitOfWork unitOfWork) : ISaleService { private readonly IUnitOfWork _unitOfWork = unitOfWork; + public async Task> CreateSaleAsync(CreateSaleDto sale) { if (sale.Items.Count == 0) @@ -40,7 +41,7 @@ public async Task> CreateSaleAsync(CreateSaleDto sale) Items = saleItems, TotalPrice = saleItems.Sum(i => i.Quantity * i.UnitPriceAtTimeOfSale) }; - + _unitOfWork.Sales.Add(saleEntity); var rowsAffected = await _unitOfWork.CompleteAsync(); @@ -65,13 +66,37 @@ public async Task> CreateSaleAsync(CreateSaleDto sale) return Result.Success(saleDto); } - public Task> GetSaleAsync(int id) + public async Task> GetSaleAsync(int id) { - throw new NotImplementedException(); + var sale = await _unitOfWork.Sales.GetSaleWithItemsAsync(id); + if (sale is null) + return Result.Fail($"Sale with Id {id} was not found"); + SaleDto dto = new() + { + CreationDate = sale.CreationDate, + Id = sale.Id, + Items = sale.Items?.Select(si => new SaleItemDto() + { + ProductId = si.ProductId, + ProductName = si.Product?.Name ?? string.Empty, + Quantity = si.Quantity, + UnitPrice = si.UnitPriceAtTimeOfSale + }).ToList() ?? new List(), + TotalPrice = sale.TotalPrice + }; + return Result.Success(dto); } - public Task>> GetAllSalesAsync() + public async Task>> GetAllSalesAsync(PaginationParams paginationParams) { - throw new NotImplementedException(); + var sales = await _unitOfWork.Sales.GetAllAsync(paginationParams); + List salesDto = new(); + salesDto.AddRange(salesDto.Select(s => new SaleDto() + { + CreationDate = s.CreationDate, + Id = s.Id, + TotalPrice = s.TotalPrice, + })); + return Result>.Success(salesDto); } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs b/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs new file mode 100644 index 00000000..e52c8fcf --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs @@ -0,0 +1,34 @@ +using Ecommerce.Core.DTOs.Sale; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Utilities; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace Ecommerce.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class SaleController(ISaleService saleService) : ControllerBase +{ + private readonly ISaleService _saleService = saleService; + [HttpGet] + public async Task GetAll(PaginationParams paginationParams) + { + var sales = await _saleService.GetAllSalesAsync(paginationParams); + return Ok(sales.Data); + } + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var sale = await _saleService.GetSaleAsync(id); + return sale.IsSuccess ? Ok(sale.Data) : NotFound(sale.Message); + } + + [HttpPost] + public async Task Create(CreateSaleDto sale) + { + var result = await _saleService.CreateSaleAsync(sale); + return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); + } +} \ No newline at end of file From 92eaf59e08a19f4c8ed0b5c9518ab7a747d0019f Mon Sep 17 00:00:00 2001 From: basem Date: Fri, 13 Mar 2026 03:44:22 +0200 Subject: [PATCH 23/52] fix empty salesDto list in GetAllSalesAsync, update GetAll endpoint to assign the parameter from query string --- EcommerceApi/Ecommerce.Services/SaleService.cs | 5 ++--- EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs | 7 ++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/EcommerceApi/Ecommerce.Services/SaleService.cs b/EcommerceApi/Ecommerce.Services/SaleService.cs index 2fbfd2c6..f68de80a 100644 --- a/EcommerceApi/Ecommerce.Services/SaleService.cs +++ b/EcommerceApi/Ecommerce.Services/SaleService.cs @@ -90,13 +90,12 @@ public async Task> GetSaleAsync(int id) public async Task>> GetAllSalesAsync(PaginationParams paginationParams) { var sales = await _unitOfWork.Sales.GetAllAsync(paginationParams); - List salesDto = new(); - salesDto.AddRange(salesDto.Select(s => new SaleDto() + var salesDto = sales.Select(s => new SaleDto() { CreationDate = s.CreationDate, Id = s.Id, TotalPrice = s.TotalPrice, - })); + }).ToList(); return Result>.Success(salesDto); } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs b/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs index e52c8fcf..6e1da89f 100644 --- a/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs +++ b/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs @@ -11,13 +11,14 @@ namespace Ecommerce.Web.Controllers; public class SaleController(ISaleService saleService) : ControllerBase { private readonly ISaleService _saleService = saleService; + [HttpGet] - public async Task GetAll(PaginationParams paginationParams) + public async Task GetAll([FromQuery] PaginationParams paginationParams) { var sales = await _saleService.GetAllSalesAsync(paginationParams); return Ok(sales.Data); } - + [HttpGet("{id:int}")] public async Task GetById(int id) { @@ -29,6 +30,6 @@ public async Task GetById(int id) public async Task Create(CreateSaleDto sale) { var result = await _saleService.CreateSaleAsync(sale); - return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); + return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); } } \ No newline at end of file From 62c8da733b2328d8136f04f2a08893c2471fb8a9 Mon Sep 17 00:00:00 2001 From: basem Date: Tue, 17 Mar 2026 04:16:33 +0200 Subject: [PATCH 24/52] Fix quantity assigned to default value and add validation for negative quantity. --- EcommerceApi/Ecommerce.Services/ProductService.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/EcommerceApi/Ecommerce.Services/ProductService.cs b/EcommerceApi/Ecommerce.Services/ProductService.cs index b8af94b0..65cc39a0 100644 --- a/EcommerceApi/Ecommerce.Services/ProductService.cs +++ b/EcommerceApi/Ecommerce.Services/ProductService.cs @@ -17,13 +17,18 @@ public async Task> CreateProductAsync(CreateProductDto produc { return Result.Fail($"Category with Id {product.CategoryId} was not found"); } + Product newProduct = new() { Name = product.Name, Description = product.Description, + ImageUrl = product.ImageUrl, + Quantity = product.Quantity, Price = product.Price, CategoryId = product.CategoryId }; + if(newProduct.Quantity <= 0) + return Result.Fail($"Quantity cannot be negative"); _unitOfWork.Products.Add(newProduct); int rowsAffected = await _unitOfWork.CompleteAsync(); if (rowsAffected == 0) @@ -91,10 +96,13 @@ public async Task> UpdateProductAsync(int id, UpdateProductDt var productFromDb = await _unitOfWork.Products.GetByIdAsync(id); if(productFromDb is null) return Result.Fail($"Product with Id {id} was not found"); + if(productFromDb.Quantity <= 0) + return Result.Fail($"Quantity cannot be negative"); productFromDb.Name = product.Name; productFromDb.Description = product.Description; productFromDb.Price = product.Price; + productFromDb.Quantity = product.Quantity; productFromDb.ImageUrl = product.ImageUrl; productFromDb.CategoryId = product.CategoryId; From 8e09dc7b506f968ca52bf49d05189f09ad7f52ff Mon Sep 17 00:00:00 2001 From: basem Date: Tue, 17 Mar 2026 04:20:05 +0200 Subject: [PATCH 25/52] Fix quantity validation for sale item quantity was not handling zero stock scenario. --- EcommerceApi/Ecommerce.Services/SaleService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EcommerceApi/Ecommerce.Services/SaleService.cs b/EcommerceApi/Ecommerce.Services/SaleService.cs index f68de80a..2a5b22dc 100644 --- a/EcommerceApi/Ecommerce.Services/SaleService.cs +++ b/EcommerceApi/Ecommerce.Services/SaleService.cs @@ -22,7 +22,7 @@ public async Task> CreateSaleAsync(CreateSaleDto sale) var product = productsFromDb.FirstOrDefault(p => p.Id == saleItem.ProductId); if (product is null) return Result.Fail($"Product with Id {saleItem.ProductId} was not found"); - if (product.Quantity < saleItem.Quantity) + if (product.Quantity < saleItem.Quantity || product.Quantity == 0) return Result.Fail($"Insufficient stock for product {product.Name}"); product.Quantity -= saleItem.Quantity; From 9a3ed5b743a5003481aa5e0cad963bb8a2e9cd6e Mon Sep 17 00:00:00 2001 From: basemkasem <69323267+basemkasem@users.noreply.github.com> Date: Tue, 17 Mar 2026 08:38:49 +0200 Subject: [PATCH 26/52] Add README.md for E-commerce API documentation Added detailed documentation for the E-commerce API, including features, architecture, technologies, installation instructions, API endpoints, usage examples, and future enhancements. --- README.md | 306 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..f8f322e6 --- /dev/null +++ b/README.md @@ -0,0 +1,306 @@ +# E-commerce API + +A RESTful API built with ASP.NET Core for managing an e-commerce system with products, categories, and sales tracking. + +## Features + +- **Product Management**: CRUD operations for products with inventory tracking +- **Category Management**: Organize products into categories +- **Sales Processing**: Create and track sales with automatic inventory deduction +- **Soft Delete**: Entities are soft-deleted, allowing data recovery +- **Pagination**: Efficient data retrieval with pagination support +- **Result Pattern**: Consistent error handling and response formatting + +## Architecture + +This project follows **Clean Architecture** principles with clear separation of concerns: + +``` +EcommerceApi/ +├── Ecommerce.Core/ # Domain layer (Models, DTOs, Interfaces) +│ ├── Models/ # Domain entities +│ ├── DTOs/ # Data Transfer Objects +│ ├── Interfaces/ # Service and repository contracts +│ └── Utilities/ # Shared utilities (Result, Pagination) +├── Ecommerce.Data/ # Data access layer +│ ├── Repositories/ # Repository implementations +│ ├── Migrations/ # EF Core migrations +│ └── AppDbContext.cs # Database context +├── Ecommerce.Services/ # Business logic layer +│ ├── CategoryService.cs +│ ├── ProductService.cs +│ └── SaleService.cs +└── Ecommerce.Web/ # Presentation layer (API) + ├── Controllers/ # API controllers + └── Program.cs # Application entry point +``` + +## Technologies + +- **.NET 10.0** +- **ASP.NET Core Web API** +- **Entity Framework Core** with SQL Server +- **Repository Pattern** with Unit of Work +- **Dependency Injection** +- **OpenAPI/Swagger** for API documentation + +## Getting Started + +### Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- SQL Server (LocalDB, Express, or full version) +- Visual Studio 2022 / JetBrains Rider / VS Code + +### Installation + +1. **Clone the repository** + ```bash + git clone + cd EcommerceApi + ``` + +2. **Configure the database connection** + + Update the connection string in `Ecommerce.Web/appsettings.json`: + ```json + { + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=EcommerceDb;Trusted_Connection=true;" + } + } + ``` + +3. **Apply database migrations** + ```bash + cd Ecommerce.Web + dotnet ef database update + ``` + +4. **Run the application** + ```bash + dotnet run --project Ecommerce.Web + ``` + +5. **Access the API** + - API: `http://localhost:5000` or `https://localhost:5001` + - OpenAPI UI: Navigate to `/openapi/v1.json` (in development mode) + +## API Endpoints + +### Categories + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/category` | Get all categories | +| GET | `/api/category/{id}` | Get category by ID | +| POST | `/api/category` | Create a new category | +| PUT | `/api/category/{id}` | Update a category | +| DELETE | `/api/category/{id}` | Delete a category (soft delete) | + +### Products + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/product?pageNumber=1&pageSize=10` | Get all products (paginated) | +| GET | `/api/product/{id}` | Get product by ID | +| POST | `/api/product` | Create a new product | +| PUT | `/api/product/{id}` | Update a product | +| DELETE | `/api/product/{id}` | Delete a product (soft delete) | + +### Sales + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/sale?pageNumber=1&pageSize=10` | Get all sales (paginated) | +| GET | `/api/sale/{id}` | Get sale by ID with items | +| POST | `/api/sale` | Create a new sale | + +## API Usage Examples + +### Create a Category + +```bash +POST /api/category +Content-Type: application/json + +{ + "name": "Electronics", + "description": "Electronic devices and gadgets" +} +``` + +### Create a Product + +```bash +POST /api/product +Content-Type: application/json + +{ + "name": "Laptop", + "description": "High-performance laptop", + "price": 999.99, + "quantity": 50, + "imageUrl": "https://example.com/laptop.jpg", + "categoryId": 1 +} +``` + +### Create a Sale + +```bash +POST /api/sale +Content-Type: application/json + +{ + "items": [ + { + "productId": 1, + "quantity": 2 + }, + { + "productId": 2, + "quantity": 1 + } + ] +} +``` + +**Note**: Creating a sale automatically: +- Validates product availability +- Deducts inventory quantities +- Captures prices at time of sale +- Calculates total price + +### Pagination + +Products and Sales support pagination: + +```bash +GET /api/product?pageNumber=2&pageSize=20 +``` + +## Database Schema + +### Core Entities + +**Category** +- `Id` (int, PK) +- `Name` (string, required) +- `Description` (string, nullable) +- `IsDeleted` (bool) +- `DeletedAt` (DateTime?) + +**Product** +- `Id` (int, PK) +- `Name` (string, required) +- `Price` (decimal(18,2)) +- `Quantity` (int) +- `Description` (string, nullable) +- `ImageUrl` (string, nullable) +- `CategoryId` (int, FK) +- `IsDeleted` (bool) +- `DeletedAt` (DateTime?) + +**Sale** +- `Id` (int, PK) +- `CreationDate` (DateTime) +- `TotalPrice` (decimal(18,2)) +- `IsDeleted` (bool) +- `DeletedAt` (DateTime?) + +**SaleItem** (Many-to-Many) +- `SaleId` (int, PK, FK) +- `ProductId` (int, PK, FK) +- `Quantity` (int) +- `UnitPriceAtTimeOfSale` (decimal(18,2)) + +## Design Patterns + +- **Repository Pattern**: Abstracts data access logic +- **Unit of Work**: Manages transactions across repositories +- **Result Pattern**: Standardized success/failure responses +- **Dependency Injection**: Loose coupling between layers +- **Soft Delete**: Data preservation with `ISoftDeletable` interface + +## Development + +### Project Structure + +- **Ecommerce.Core**: Contains business entities, DTOs, and interfaces (no dependencies) +- **Ecommerce.Data**: Implements data access using EF Core +- **Ecommerce.Services**: Implements business logic +- **Ecommerce.Web**: ASP.NET Core Web API project + +### Adding Migrations + +```bash +dotnet ef migrations add MigrationName --project Ecommerce.Data --startup-project Ecommerce.Web +``` + +### Applying Migrations + +```bash +dotnet ef database update --project Ecommerce.Data --startup-project Ecommerce.Web +``` + +## Error Handling + +The API uses a custom `Result` pattern for consistent error responses: + +**Success Response:** +```json +{ + "data": { ... }, + "isSuccess": true, + "message": null +} +``` + +**Error Response:** +```json +{ + "data": null, + "isSuccess": false, + "message": "Error description" +} +``` + +## Validation + +Current validation includes: +- Category existence validation when creating/updating products +- Product existence and stock validation when creating sales +- Quantity validation (must be positive) +- Stock deduction with availability checks + +## Future Enhancements + +- [ ] Add authentication & authorization (JWT) +- [ ] Implement input validation with FluentValidation +- [ ] Add global exception handling middleware +- [ ] Implement logging (Serilog) +- [ ] Add unit and integration tests +- [ ] Implement caching for frequently accessed data +- [ ] Add API versioning +- [ ] Implement rate limiting +- [ ] Add CORS configuration +- [ ] Create Update/Delete operations for Sales +- [ ] Add filtering and sorting capabilities +- [ ] Implement search functionality + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License. + +## Contact + +For questions or support, please open an issue in the repository. From 0f6c64f6928fe420dddb0e04921d3efbe8ed0cc6 Mon Sep 17 00:00:00 2001 From: basem Date: Thu, 19 Mar 2026 05:38:49 +0200 Subject: [PATCH 27/52] Add Postman collection json file --- .../ECommerce API.postman_collection.json | 688 ++++++++++++++++++ 1 file changed, 688 insertions(+) create mode 100644 EcommerceApi/ECommerce API.postman_collection.json diff --git a/EcommerceApi/ECommerce API.postman_collection.json b/EcommerceApi/ECommerce API.postman_collection.json new file mode 100644 index 00000000..5bd6706c --- /dev/null +++ b/EcommerceApi/ECommerce API.postman_collection.json @@ -0,0 +1,688 @@ +{ + "info": { + "_postman_id": "55790843-a96f-4c97-b21d-1b3b07b830a6", + "name": "ECommerce API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "31837703" + }, + "item": [ + { + "name": "Categories", + "item": [ + { + "name": "Create Category", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Electronics\",\n \"description\": \"Smartphones, laptops, and gadgets.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/category", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category" + ] + } + }, + "response": [] + }, + { + "name": "Get All Categories", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Get All Categories: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Categories: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})", + "", + "pm.test('Get All Categories: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Categories: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})", + "", + "pm.test('Get All Categories: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Categories: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/category", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category" + ] + } + }, + "response": [] + }, + { + "name": "Get Category", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/category/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete Category", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Delete Category: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})", + "", + "pm.test('Delete Category: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})", + "", + "pm.test('Delete Category: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})" + ] + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/category/2", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category", + "2" + ] + } + }, + "response": [] + }, + { + "name": "Update Category", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Electronics\",\n \"description\": \"Smartphones, laptops, TVs\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/category/3", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category", + "3" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Products", + "item": [ + { + "name": "Create Product", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Shampoo Clear\",\n \"price\": 120,\n \"quantity\": 55,\n \"discription\": \"\",\n \"imageUrl\": \"\",\n \"categoryId\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/product", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Get All Products", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Get All Products: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Products: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})", + "", + "pm.test('Get All Products: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Products: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/product?pageNumber=1&pageSize=2", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product" + ], + "query": [ + { + "key": "pageNumber", + "value": "1" + }, + { + "key": "pageSize", + "value": "2" + } + ] + } + }, + "response": [] + }, + { + "name": "Get a Product", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/product/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete Product", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Delete Product: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})", + "", + "pm.test('Delete Product: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})" + ] + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/product/3", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product", + "3" + ] + } + }, + "response": [] + }, + { + "name": "Update Product", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Shampoo Clear\",\n \"price\": 120,\n \"quantity\": 55,\n \"discription\": \"\",\n \"imageUrl\": \"\",\n \"categoryId\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/product/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product", + "1" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Sales", + "item": [ + { + "name": "Create Sale", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"items\": [\n {\n \"productId\": 1,\n \"quantity\": 1\n },\n {\n \"productId\": 2,\n \"quantity\": 4\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/sale", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sale" + ] + } + }, + "response": [] + }, + { + "name": "Get All Sales", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Get All Sales: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Sales: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})", + "", + "pm.test('Get All Sales: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Sales: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/sale?pageNumber=1&pageSize=2", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sale" + ], + "query": [ + { + "key": "pageNumber", + "value": "1" + }, + { + "key": "pageSize", + "value": "2" + } + ] + } + }, + "response": [] + }, + { + "name": "Get a sale", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/sale/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sale", + "1" + ] + } + }, + "response": [] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// -------- Baseline collection-level tests & helpers --------", + "// These tests run after every request in this collection.", + "", + "pm.test(\"Response time is under 2000ms\", function () {", + " pm.expect(pm.response.responseTime).to.be.below(2000);", + "});", + "", + "pm.test(\"Status code is successful (2xx)\", function () {", + " pm.expect(pm.response.code).to.be.within(200, 299);", + "});", + "", + "// Content-Type checks (only when response has a body)", + "pm.test(\"Content-Type is JSON when body is present\", function () {", + " const hasBody = pm.response.text() && pm.response.text().trim().length > 0;", + " if (!hasBody) return;", + "", + " pm.response.to.have.header(\"Content-Type\");", + " const ct = (pm.response.headers.get(\"Content-Type\") || \"\").toLowerCase();", + " pm.expect(ct).to.include(\"application/json\");", + "});", + "", + "// Basic JSON parse check (only when body is present)", + "pm.test(\"Response body is valid JSON when present\", function () {", + " const hasBody = pm.response.text() && pm.response.text().trim().length > 0;", + " if (!hasBody) return;", + "", + " pm.response.to.be.json;", + "});", + "", + "// Helper: get path id from the current request URL, if any.", + "function getLastPathId() {", + " try {", + " const url = pm.request.url;", + " const segments = (url.path || []).filter(Boolean);", + " const last = segments[segments.length - 1];", + " if (last && /^\\d+$/.test(last)) return parseInt(last, 10);", + " // handle templated ids like {{productId}}", + " if (last && last.includes(\"{{\") && last.includes(\"}}\")) {", + " const varName = last.replace(/\\{\\{|\\}\\}/g, \"\").trim();", + " const v = pm.collectionVariables.get(varName) || pm.environment.get(varName) || pm.globals.get(varName);", + " if (v && /^\\d+$/.test(String(v))) return parseInt(v, 10);", + " }", + " } catch (e) {}", + " return null;", + "}", + "", + "// Helper: try to pull an id from common response shapes.", + "function extractId(json) {", + " if (!json || typeof json !== \"object\") return null;", + " if (typeof json.id === \"number\") return json.id;", + " if (typeof json.productId === \"number\") return json.productId;", + " if (typeof json.categoryId === \"number\") return json.categoryId;", + " if (typeof json.saleId === \"number\") return json.saleId;", + " if (json.data && typeof json.data === \"object\" && typeof json.data.id === \"number\") return json.data.id;", + " return null;", + "}", + "", + "// Expose helpers to request-level tests (by attaching to globals)", + "// Note: Postman sandbox doesn't support true exports; attach to pm.", + "pm.collectionVariables.set(\"_lastPathId\", String(getLastPathId() ?? \"\"));", + "", + "// Also keep the last extracted id available for request-level scripts to use if desired.", + "try {", + " const hasBody = pm.response.text() && pm.response.text().trim().length > 0;", + " if (hasBody && pm.response.headers.get(\"Content-Type\")?.toLowerCase().includes(\"application/json\")) {", + " const json = pm.response.json();", + " const extracted = extractId(json);", + " if (typeof extracted === \"number\") {", + " pm.collectionVariables.set(\"_lastExtractedId\", String(extracted));", + " }", + " }", + "} catch (e) {", + " // ignore", + "}", + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "" + } + ] +} \ No newline at end of file From a6d94333ff7a6d8b135e86fe7875c71e3238ade3 Mon Sep 17 00:00:00 2001 From: basemkasem <69323267+basemkasem@users.noreply.github.com> Date: Fri, 27 Mar 2026 06:30:19 +0200 Subject: [PATCH 28/52] Update README Removed contributing guidelines and license section. --- README.md | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/README.md b/README.md index f8f322e6..871c5186 100644 --- a/README.md +++ b/README.md @@ -291,16 +291,4 @@ Current validation includes: ## Contributing -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/AmazingFeature`) -3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) -4. Push to the branch (`git push origin feature/AmazingFeature`) -5. Open a Pull Request - -## License - -This project is licensed under the MIT License. - -## Contact - -For questions or support, please open an issue in the repository. +This is a study project from [The C# Academy](https://thecsharpacademy.com/). Feel free to fork and experiment! From 5df25cd3ada7a90ef0566cf2ebcd9c3d1281adfd Mon Sep 17 00:00:00 2001 From: basem Date: Sat, 28 Mar 2026 09:26:29 +0200 Subject: [PATCH 29/52] Add validation to CreateProductDto and UpdateProductDto, update controller to use FluentValidation for validation. --- .../Ecommerce.Core/Ecommerce.Core.csproj | 8 +++++ .../Products/CreateProductDtoValidator.cs | 34 +++++++++++++++++++ .../Products/UpdateProductDtoValidation.cs | 22 ++++++++++++ .../Controllers/ProductController.cs | 19 ++++++++++- .../Ecommerce.Web/Ecommerce.Web.csproj | 1 + EcommerceApi/Ecommerce.Web/Program.cs | 5 +++ 6 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 EcommerceApi/Ecommerce.Core/Validators/Products/CreateProductDtoValidator.cs create mode 100644 EcommerceApi/Ecommerce.Core/Validators/Products/UpdateProductDtoValidation.cs diff --git a/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj index 237d6616..66076932 100644 --- a/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj +++ b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj @@ -6,4 +6,12 @@ enable + + + + + + + + diff --git a/EcommerceApi/Ecommerce.Core/Validators/Products/CreateProductDtoValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Products/CreateProductDtoValidator.cs new file mode 100644 index 00000000..119cd93f --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Products/CreateProductDtoValidator.cs @@ -0,0 +1,34 @@ +using Ecommerce.Core.DTOs.Product; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Products; + +public class CreateProductDtoValidator : AbstractValidator +{ + public CreateProductDtoValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Product name is required.") + .MaximumLength(100) + .WithMessage("Product name cannot exceed 100 characters."); + + RuleFor(x => x.Price) + .GreaterThan(0) + .WithMessage("Price must be greater than zero."); + + RuleFor(x => x.CategoryId) + .GreaterThan(0) + .WithMessage("A valid Category ID is required."); + + RuleFor(x => x.ImageUrl) + .Must(url => Uri.IsWellFormedUriString(url, UriKind.Absolute)) + .When(x => !string.IsNullOrEmpty(x.ImageUrl)) + .WithMessage("If an Image URL is provided, it must be a valid web address."); + + RuleFor(x => x.Quantity) + .GreaterThanOrEqualTo(0) + .WithMessage("Quantity must be greater than or equal to zero."); + + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Validators/Products/UpdateProductDtoValidation.cs b/EcommerceApi/Ecommerce.Core/Validators/Products/UpdateProductDtoValidation.cs new file mode 100644 index 00000000..0ac4e9ad --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Products/UpdateProductDtoValidation.cs @@ -0,0 +1,22 @@ +using Ecommerce.Core.DTOs.Product; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Products; + +public class UpdateProductDtoValidation : AbstractValidator +{ + public UpdateProductDtoValidation() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Product name is required.") + .MaximumLength(100).WithMessage("Product name cannot exceed 100 characters."); + + RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than zero."); + RuleFor(x => x.Quantity).GreaterThanOrEqualTo(0).WithMessage("Quantity must be greater than or equal to zero."); + RuleFor(x => x.CategoryId).GreaterThan(0).WithMessage("A valid Category ID is required."); + RuleFor(x => x.ImageUrl) + .Must(url => Uri.IsWellFormedUriString(url, UriKind.Absolute)) + .When(x => !string.IsNullOrEmpty(x.ImageUrl)) + .WithMessage("If an Image URL is provided, it must be a valid web address."); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs b/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs index 8f6f0a80..b16a8d58 100644 --- a/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs +++ b/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs @@ -1,15 +1,19 @@ using Ecommerce.Core.DTOs.Product; using Ecommerce.Core.Interfaces.Services; using Ecommerce.Core.Utilities; +using FluentValidation; using Microsoft.AspNetCore.Mvc; namespace Ecommerce.Web.Controllers; [ApiController] [Route("api/[controller]")] -public class ProductController(IProductService productService) : ControllerBase +public class ProductController(IProductService productService, IValidator createValidator, IValidator updateValidator) : ControllerBase { private readonly IProductService _productService = productService; + private readonly IValidator _createValidator = createValidator; + private readonly IValidator _updateValidator = updateValidator; + [HttpGet] public async Task GetAll([FromQuery] PaginationParams paginationParams) { @@ -27,6 +31,13 @@ public async Task GetById(int id) [HttpPost] public async Task Create(CreateProductDto product) { + var validationResult = await _createValidator.ValidateAsync(product); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => x.ErrorMessage).ToList(); + return BadRequest(new {Errors = errors}); + } + var result = await _productService.CreateProductAsync(product); return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); } @@ -41,6 +52,12 @@ public async Task Delete(int id) [HttpPut("{id:int}")] public async Task Update(int id, UpdateProductDto product) { + var validationResult = await _updateValidator.ValidateAsync(product); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => x.ErrorMessage).ToList(); + return BadRequest(new {Errors = errors}); + } var result = await _productService.UpdateProductAsync(id, product); return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); } diff --git a/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj index 28323f02..af807aee 100644 --- a/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj +++ b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj @@ -9,6 +9,7 @@ + all diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs index 8b2ea548..f5349ae2 100644 --- a/EcommerceApi/Ecommerce.Web/Program.cs +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -1,8 +1,10 @@ using Ecommerce.Core.Interfaces.Repositories; using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Validators.Products; using Ecommerce.Data; using Ecommerce.Data.Repositories; using Ecommerce.Services; +using FluentValidation; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -15,6 +17,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddValidatorsFromAssemblyContaining(); +builder.Services.AddValidatorsFromAssemblyContaining(); + builder.Services.AddControllers(); builder.Services.AddOpenApi(); From 20109548203daa4b0a4d6555493eb86bb41f16e5 Mon Sep 17 00:00:00 2001 From: basem Date: Sat, 28 Mar 2026 09:28:05 +0200 Subject: [PATCH 30/52] fix quantity validation incorrect check in ProductService --- EcommerceApi/Ecommerce.Services/ProductService.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/EcommerceApi/Ecommerce.Services/ProductService.cs b/EcommerceApi/Ecommerce.Services/ProductService.cs index 65cc39a0..76773f01 100644 --- a/EcommerceApi/Ecommerce.Services/ProductService.cs +++ b/EcommerceApi/Ecommerce.Services/ProductService.cs @@ -96,8 +96,6 @@ public async Task> UpdateProductAsync(int id, UpdateProductDt var productFromDb = await _unitOfWork.Products.GetByIdAsync(id); if(productFromDb is null) return Result.Fail($"Product with Id {id} was not found"); - if(productFromDb.Quantity <= 0) - return Result.Fail($"Quantity cannot be negative"); productFromDb.Name = product.Name; productFromDb.Description = product.Description; From cf974920bdb422e9b7ecafdcc9d8ce4325f06245 Mon Sep 17 00:00:00 2001 From: basem Date: Sat, 28 Mar 2026 09:49:05 +0200 Subject: [PATCH 31/52] fix unnecessary repeatitve validation in the same assembly assignment --- EcommerceApi/Ecommerce.Web/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs index f5349ae2..db032739 100644 --- a/EcommerceApi/Ecommerce.Web/Program.cs +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -18,7 +18,6 @@ builder.Services.AddScoped(); builder.Services.AddValidatorsFromAssemblyContaining(); -builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddControllers(); builder.Services.AddOpenApi(); From 26ff99c1cb7ae77867c39c35e21dcdf248ca4029 Mon Sep 17 00:00:00 2001 From: basem Date: Sat, 28 Mar 2026 09:49:44 +0200 Subject: [PATCH 32/52] Enhance error message format in validation errors to include property names and error messages in ProductController --- EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs b/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs index b16a8d58..3c0ce367 100644 --- a/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs +++ b/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs @@ -34,7 +34,7 @@ public async Task Create(CreateProductDto product) var validationResult = await _createValidator.ValidateAsync(product); if (!validationResult.IsValid) { - var errors = validationResult.Errors.Select(x => x.ErrorMessage).ToList(); + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); return BadRequest(new {Errors = errors}); } @@ -55,7 +55,7 @@ public async Task Update(int id, UpdateProductDto product) var validationResult = await _updateValidator.ValidateAsync(product); if (!validationResult.IsValid) { - var errors = validationResult.Errors.Select(x => x.ErrorMessage).ToList(); + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); return BadRequest(new {Errors = errors}); } var result = await _productService.UpdateProductAsync(id, product); From cd1d3cc3b7a1752ba63436574f09004f56584c59 Mon Sep 17 00:00:00 2001 From: basem Date: Sat, 28 Mar 2026 09:51:15 +0200 Subject: [PATCH 33/52] Add validation to CreateCategoryDto and UpdateCategoryDto, update controller to use FluentValidation for validation. --- .../Categories/CreateCategoryValidator.cs | 16 ++++++++++++ .../Categories/UpdateCategoryValidator.cs | 16 ++++++++++++ .../Controllers/CategoryController.cs | 25 +++++++++++++------ 3 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryValidator.cs create mode 100644 EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryValidator.cs diff --git a/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryValidator.cs new file mode 100644 index 00000000..8b7fa6dc --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryValidator.cs @@ -0,0 +1,16 @@ +using Ecommerce.Core.DTOs.Category; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Categories; + +public class CreateCategoryValidator : AbstractValidator +{ + public CreateCategoryValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Category name is required.") + .MaximumLength(50) + .WithMessage("Category name cannot exceed 50 characters."); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryValidator.cs new file mode 100644 index 00000000..f607701b --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryValidator.cs @@ -0,0 +1,16 @@ +using Ecommerce.Core.DTOs.Category; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Categories; + +public class UpdateCategoryValidator : AbstractValidator +{ + public UpdateCategoryValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Category name is required.") + .MaximumLength(50) + .WithMessage("Category name cannot exceed 50 characters."); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs b/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs index 94f61f67..ef35fe97 100644 --- a/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs +++ b/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs @@ -1,19 +1,18 @@ using Ecommerce.Core.DTOs.Category; using Ecommerce.Core.Interfaces.Services; +using FluentValidation; using Microsoft.AspNetCore.Mvc; namespace Ecommerce.Web.Controllers; [ApiController] [Route("api/[controller]")] -public class CategoryController : ControllerBase +public class CategoryController(ICategoryService categoryService, IValidator createValidator, IValidator updateValidator) : ControllerBase { - private readonly ICategoryService _categoryService; - - public CategoryController(ICategoryService categoryService) - { - _categoryService = categoryService; - } + private readonly ICategoryService _categoryService = categoryService; + private readonly IValidator _createValidator = createValidator; + private readonly IValidator _updateValidator = updateValidator; + [HttpGet] public async Task GetAll() @@ -32,6 +31,12 @@ public async Task GetById(int id) [HttpPost] public async Task Create(CreateCategoryDto category) { + var validationResult = await _createValidator.ValidateAsync(category); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); + return BadRequest(new {Errors = errors}); + } var result = await _categoryService.CreateCategoryAsync(category); return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); } @@ -39,6 +44,12 @@ public async Task Create(CreateCategoryDto category) [HttpPut("{id:int}")] public async Task Update(int id, UpdateCategoryDto category) { + var validationResult = await _updateValidator.ValidateAsync(category); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); + return BadRequest(new {Errors = errors}); + } var result = await _categoryService.UpdateCategoryAsync(id, category); return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); } From 2250178ee4bb71ad548afe3e30b635802e16cd76 Mon Sep 17 00:00:00 2001 From: basem Date: Sat, 28 Mar 2026 10:01:57 +0200 Subject: [PATCH 34/52] Change names of validators to match DTOs --- ...eateCategoryValidator.cs => CreateCategoryDtoValidator.cs} | 4 ++-- ...dateCategoryValidator.cs => UpdateCategoryDtoValidator.cs} | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename EcommerceApi/Ecommerce.Core/Validators/Categories/{CreateCategoryValidator.cs => CreateCategoryDtoValidator.cs} (73%) rename EcommerceApi/Ecommerce.Core/Validators/Categories/{UpdateCategoryValidator.cs => UpdateCategoryDtoValidator.cs} (73%) diff --git a/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryDtoValidator.cs similarity index 73% rename from EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryValidator.cs rename to EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryDtoValidator.cs index 8b7fa6dc..cd1b0a03 100644 --- a/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryValidator.cs +++ b/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryDtoValidator.cs @@ -3,9 +3,9 @@ namespace Ecommerce.Core.Validators.Categories; -public class CreateCategoryValidator : AbstractValidator +public class CreateCategoryDtoValidator : AbstractValidator { - public CreateCategoryValidator() + public CreateCategoryDtoValidator() { RuleFor(x => x.Name) .NotEmpty() diff --git a/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryDtoValidator.cs similarity index 73% rename from EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryValidator.cs rename to EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryDtoValidator.cs index f607701b..f24a79e4 100644 --- a/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryValidator.cs +++ b/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryDtoValidator.cs @@ -3,9 +3,9 @@ namespace Ecommerce.Core.Validators.Categories; -public class UpdateCategoryValidator : AbstractValidator +public class UpdateCategoryDtoValidator : AbstractValidator { - public UpdateCategoryValidator() + public UpdateCategoryDtoValidator() { RuleFor(x => x.Name) .NotEmpty() From 833f12b69cbdb9d59b8a0650cedabaea12b1c443 Mon Sep 17 00:00:00 2001 From: basem Date: Sat, 28 Mar 2026 10:02:22 +0200 Subject: [PATCH 35/52] Create CreateSaleDtoValidator and Add FluentValidation for Sale and SaleItem DTOs --- .../Ecommerce.Core/Ecommerce.Core.csproj | 4 --- .../Sales/CreateSaleDtoValidator.cs | 29 +++++++++++++++++++ .../Controllers/SaleController.cs | 11 +++++-- 3 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 EcommerceApi/Ecommerce.Core/Validators/Sales/CreateSaleDtoValidator.cs diff --git a/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj index 66076932..0b5645da 100644 --- a/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj +++ b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj @@ -10,8 +10,4 @@ - - - - diff --git a/EcommerceApi/Ecommerce.Core/Validators/Sales/CreateSaleDtoValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Sales/CreateSaleDtoValidator.cs new file mode 100644 index 00000000..9621fd4c --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Sales/CreateSaleDtoValidator.cs @@ -0,0 +1,29 @@ +using Ecommerce.Core.DTOs.Sale; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Sales; + +public class CreateSaleDtoValidator : AbstractValidator +{ + public CreateSaleDtoValidator() + { + RuleFor(x => x.Items) + .NotEmpty() + .WithMessage("Sale must have at least one item."); + RuleForEach(x => x.Items) + .SetValidator(new CreateSaleItemDtoValidator()); + } +} + +public class CreateSaleItemDtoValidator : AbstractValidator +{ + public CreateSaleItemDtoValidator() + { + RuleFor(x => x.ProductId) + .GreaterThan(0) + .WithMessage("Product Id must be greater than zero."); + RuleFor(x => x.Quantity) + .GreaterThan(0) + .WithMessage("Quantity must be greater than zero."); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs b/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs index 6e1da89f..4e43acff 100644 --- a/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs +++ b/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs @@ -1,16 +1,17 @@ using Ecommerce.Core.DTOs.Sale; using Ecommerce.Core.Interfaces.Services; using Ecommerce.Core.Utilities; -using Microsoft.AspNetCore.Http.HttpResults; +using FluentValidation; using Microsoft.AspNetCore.Mvc; namespace Ecommerce.Web.Controllers; [ApiController] [Route("api/[controller]")] -public class SaleController(ISaleService saleService) : ControllerBase +public class SaleController(ISaleService saleService, IValidator createValidator) : ControllerBase { private readonly ISaleService _saleService = saleService; + private readonly IValidator _createValidator = createValidator; [HttpGet] public async Task GetAll([FromQuery] PaginationParams paginationParams) @@ -29,6 +30,12 @@ public async Task GetById(int id) [HttpPost] public async Task Create(CreateSaleDto sale) { + var validationResult = await _createValidator.ValidateAsync(sale); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); + return BadRequest(new {Errors = errors}); + } var result = await _saleService.CreateSaleAsync(sale); return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); } From fe1ebc8edfab78aaef7b86fa428717f8aaa67769 Mon Sep 17 00:00:00 2001 From: basem Date: Tue, 31 Mar 2026 21:49:11 +0200 Subject: [PATCH 36/52] Apply global exception handling --- .../Extensions/GlobalExceptionHandler.cs | 30 +++++++++++++++++++ EcommerceApi/Ecommerce.Web/Program.cs | 11 +++++++ 2 files changed, 41 insertions(+) create mode 100644 EcommerceApi/Ecommerce.Web/Extensions/GlobalExceptionHandler.cs diff --git a/EcommerceApi/Ecommerce.Web/Extensions/GlobalExceptionHandler.cs b/EcommerceApi/Ecommerce.Web/Extensions/GlobalExceptionHandler.cs new file mode 100644 index 00000000..741b22fc --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Extensions/GlobalExceptionHandler.cs @@ -0,0 +1,30 @@ +using System.Net; +using Microsoft.AspNetCore.Diagnostics; + +namespace Ecommerce.Web.Extensions; + +public class GlobalExceptionHandler(IProblemDetailsService problemDetailsService) : IExceptionHandler +{ + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + httpContext.Response.StatusCode = exception switch + { + ApplicationException => (int)HttpStatusCode.BadRequest, + _ => (int)HttpStatusCode.InternalServerError + }; + httpContext.Response.ContentType = "application/json"; + + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext() + { + HttpContext = httpContext, + Exception = exception, + ProblemDetails = new() + { + Status = httpContext.Response.StatusCode, + Type = exception.GetType().Name, + Title = "Internal Server Error", + Detail = exception.Message + } + }); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs index db032739..9d73abff 100644 --- a/EcommerceApi/Ecommerce.Web/Program.cs +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -4,11 +4,21 @@ using Ecommerce.Data; using Ecommerce.Data.Repositories; using Ecommerce.Services; +using Ecommerce.Web.Extensions; using FluentValidation; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); +builder.Services.AddProblemDetails(configure => +{ + configure.CustomizeProblemDetails = context => + { + context.ProblemDetails.Extensions.TryAdd("requestId", context.HttpContext.TraceIdentifier); + }; +}); +builder.Services.AddExceptionHandler(); + builder.Services.AddDbContext( options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")) ); @@ -27,6 +37,7 @@ // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { + app.UseExceptionHandler(); app.MapOpenApi(); } From b9087c5fb43c08075a5ffbe69859784655c52c0f Mon Sep 17 00:00:00 2001 From: basem Date: Tue, 31 Mar 2026 22:29:12 +0200 Subject: [PATCH 37/52] fix production error handling and add safe error messages --- .../Extensions/GlobalExceptionHandler.cs | 12 +++++++++--- EcommerceApi/Ecommerce.Web/Program.cs | 4 +++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/EcommerceApi/Ecommerce.Web/Extensions/GlobalExceptionHandler.cs b/EcommerceApi/Ecommerce.Web/Extensions/GlobalExceptionHandler.cs index 741b22fc..e0135a66 100644 --- a/EcommerceApi/Ecommerce.Web/Extensions/GlobalExceptionHandler.cs +++ b/EcommerceApi/Ecommerce.Web/Extensions/GlobalExceptionHandler.cs @@ -7,23 +7,29 @@ public class GlobalExceptionHandler(IProblemDetailsService problemDetailsService { public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { - httpContext.Response.StatusCode = exception switch + var statusCode = exception switch { ApplicationException => (int)HttpStatusCode.BadRequest, _ => (int)HttpStatusCode.InternalServerError }; + + httpContext.Response.StatusCode = statusCode; httpContext.Response.ContentType = "application/json"; + var safeErrorMessage = statusCode == (int)HttpStatusCode.InternalServerError + ? "An unexpected error occurred on the server. Please try again later." + : exception.Message; + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext() { HttpContext = httpContext, Exception = exception, ProblemDetails = new() { - Status = httpContext.Response.StatusCode, + Status = statusCode, Type = exception.GetType().Name, Title = "Internal Server Error", - Detail = exception.Message + Detail = safeErrorMessage } }); } diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs index 9d73abff..56e3ae5f 100644 --- a/EcommerceApi/Ecommerce.Web/Program.cs +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -35,9 +35,11 @@ var app = builder.Build(); // Configure the HTTP request pipeline. + +app.UseExceptionHandler(); + if (app.Environment.IsDevelopment()) { - app.UseExceptionHandler(); app.MapOpenApi(); } From 5fe7a34833dfc8f3b8ea77d98477348e8c172ca4 Mon Sep 17 00:00:00 2001 From: basemkasem <69323267+basemkasem@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:53:40 +0200 Subject: [PATCH 38/52] update postman collection --- .../ECommerce API.postman_collection.json | 1255 +++++++++++++++-- 1 file changed, 1141 insertions(+), 114 deletions(-) diff --git a/EcommerceApi/ECommerce API.postman_collection.json b/EcommerceApi/ECommerce API.postman_collection.json index 5bd6706c..d475f181 100644 --- a/EcommerceApi/ECommerce API.postman_collection.json +++ b/EcommerceApi/ECommerce API.postman_collection.json @@ -10,31 +10,975 @@ "name": "Categories", "item": [ { - "name": "Create Category", + "name": "Create", + "item": [ + { + "name": "Valid Create Category", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Electronics\",\n \"description\": \"Smartphones, laptops, and gadgets.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/category", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category" + ] + } + }, + "response": [] + }, + { + "name": "Invalid Create Category", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"description\": \"Smartphones, laptops, and gadgets.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/category", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Update", + "item": [ + { + "name": "Valid Update Category", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Electronics\",\n \"description\": \"Smartphones, laptops, TVs\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/category/3", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category", + "3" + ] + } + }, + "response": [] + }, + { + "name": "Invalid Update Category", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"description\": \"Smartphones, laptops, TVs\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/category/3", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category", + "3" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Get All Categories", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Get All Categories: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Categories: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})", + "", + "pm.test('Get All Categories: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Categories: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})", + "", + "pm.test('Get All Categories: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Categories: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/category", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category" + ] + } + }, + "response": [] + }, + { + "name": "Get Category", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/category/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete Category", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Delete Category: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})", + "", + "pm.test('Delete Category: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})", + "", + "pm.test('Delete Category: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})" + ] + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/category/2", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category", + "2" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Products", + "item": [ + { + "name": "Create", + "item": [ + { + "name": "Valid Create Product", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Orange Juice\",\n \"price\": 15,\n \"quantity\": 55,\n \"discription\": \"\",\n \"imageUrl\": \"\",\n \"categoryId\": 4\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/product", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Invalid Create Product", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"price\": 0,\n \"quantity\": -5,\n \"discription\": \"\",\n \"imageUrl\": \"link\",\n \"categoryId\": 0\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/product", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Update", + "item": [ + { + "name": "Valid Update Product", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Orange Juice\",\n \"price\": 15,\n \"quantity\": 55,\n \"discription\": \"\",\n \"imageUrl\": \"\",\n \"categoryId\": 4\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/product/5", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product", + "5" + ] + } + }, + "response": [] + }, + { + "name": "Invalid Update Product", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"price\": 0,\n \"quantity\": -5,\n \"discription\": \"\",\n \"imageUrl\": \"link\",\n \"categoryId\": 0\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/product/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product", + "1" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Get All Products", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Get All Products: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Products: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})", + "", + "pm.test('Get All Products: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Products: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/product?pageNumber=1&pageSize=2", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product" + ], + "query": [ + { + "key": "pageNumber", + "value": "1" + }, + { + "key": "pageSize", + "value": "2" + } + ] + } + }, + "response": [] + }, + { + "name": "Get a Product", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/product/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete Product", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Delete Product: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})", + "", + "pm.test('Delete Product: status is 200 or 204', function () {", + " pm.expect(pm.response.code).to.be.oneOf([", + " 200,", + " 204", + " ]);", + "})" + ] + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/product/3", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product", + "3" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Sales", + "item": [ + { + "name": "Create", + "item": [ + { + "name": "Valid Create Sale", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"items\": [\n {\n \"productId\": 1,\n \"quantity\": 1\n },\n {\n \"productId\": 2,\n \"quantity\": 4\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/sale", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sale" + ] + } + }, + "response": [] + }, + { + "name": "Invalid Create Sale", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"items\": [\n {\n \"productId\": 0,\n \"quantity\": 1\n },\n {\n \"productId\": 2,\n \"quantity\": 0\n },\n {\n \"productId\": 3,\n \"quantity\": -1\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/sale", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sale" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Get All Sales", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Get All Sales: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Sales: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})", + "", + "pm.test('Get All Sales: response is array or paginated object', function () {", + " const json = pm.response.json();", + " const isArray = Array.isArray(json);", + " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", + " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", + "})", + "", + "pm.test('Get All Sales: pagination fields are valid when present', function () {", + " const json = pm.response.json();", + " if (!json || typeof json !== 'object' || Array.isArray(json))", + " return;", + " const numFields = [", + " 'pageNumber',", + " 'pageSize',", + " 'totalCount',", + " 'total',", + " 'count'", + " ];", + " numFields.forEach(f => {", + " if (Object.prototype.hasOwnProperty.call(json, f)) {", + " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", + " }", + " });", + "})" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/sale?pageNumber=1&pageSize=2", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sale" + ], + "query": [ + { + "key": "pageNumber", + "value": "1" + }, + { + "key": "pageSize", + "value": "2" + } + ] + } + }, + "response": [] + }, + { + "name": "Get a sale", "request": { - "method": "POST", + "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Electronics\",\n \"description\": \"Smartphones, laptops, and gadgets.\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, "url": { - "raw": "{{baseUrl}}/api/category", + "raw": "{{baseUrl}}/api/sale/1", "host": [ "{{baseUrl}}" ], "path": [ "api", - "category" + "sale", + "1" ] } }, "response": [] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// -------- Baseline collection-level tests & helpers --------", + "// These tests run after every request in this collection.", + "", + "pm.test(\"Response time is under 2000ms\", function () {", + " pm.expect(pm.response.responseTime).to.be.below(2000);", + "});", + "", + "pm.test(\"Status code is successful (2xx)\", function () {", + " pm.expect(pm.response.code).to.be.within(200, 299);", + "});", + "", + "// Content-Type checks (only when response has a body)", + "pm.test(\"Content-Type is JSON when body is present\", function () {", + " const hasBody = pm.response.text() && pm.response.text().trim().length > 0;", + " if (!hasBody) return;", + "", + " pm.response.to.have.header(\"Content-Type\");", + " const ct = (pm.response.headers.get(\"Content-Type\") || \"\").toLowerCase();", + " pm.expect(ct).to.include(\"application/json\");", + "});", + "", + "// Basic JSON parse check (only when body is present)", + "pm.test(\"Response body is valid JSON when present\", function () {", + " const hasBody = pm.response.text() && pm.response.text().trim().length > 0;", + " if (!hasBody) return;", + "", + " pm.response.to.be.json;", + "});", + "", + "// Helper: get path id from the current request URL, if any.", + "function getLastPathId() {", + " try {", + " const url = pm.request.url;", + " const segments = (url.path || []).filter(Boolean);", + " const last = segments[segments.length - 1];", + " if (last && /^\\d+$/.test(last)) return parseInt(last, 10);", + " // handle templated ids like {{productId}}", + " if (last && last.includes(\"{{\") && last.includes(\"}}\")) {", + " const varName = last.replace(/\\{\\{|\\}\\}/g, \"\").trim();", + " const v = pm.collectionVariables.get(varName) || pm.environment.get(varName) || pm.globals.get(varName);", + " if (v && /^\\d+$/.test(String(v))) return parseInt(v, 10);", + " }", + " } catch (e) {}", + " return null;", + "}", + "", + "// Helper: try to pull an id from common response shapes.", + "function extractId(json) {", + " if (!json || typeof json !== \"object\") return null;", + " if (typeof json.id === \"number\") return json.id;", + " if (typeof json.productId === \"number\") return json.productId;", + " if (typeof json.categoryId === \"number\") return json.categoryId;", + " if (typeof json.saleId === \"number\") return json.saleId;", + " if (json.data && typeof json.data === \"object\" && typeof json.data.id === \"number\") return json.data.id;", + " return null;", + "}", + "", + "// Expose helpers to request-level tests (by attaching to globals)", + "// Note: Postman sandbox doesn't support true exports; attach to pm.", + "pm.collectionVariables.set(\"_lastPathId\", String(getLastPathId() ?? \"\"));", + "", + "// Also keep the last extracted id available for request-level scripts to use if desired.", + "try {", + " const hasBody = pm.response.text() && pm.response.text().trim().length > 0;", + " if (hasBody && pm.response.headers.get(\"Content-Type\")?.toLowerCase().includes(\"application/json\")) {", + " const json = pm.response.json();", + " const extracted = extractId(json);", + " if (typeof extracted === \"number\") {", + " pm.collectionVariables.set(\"_lastExtractedId\", String(extracted));", + " }", + " }", + "} catch (e) {", + " // ignore", + "}", + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "" + }, + { + "key": "_lastPathId", + "value": "" + },{ + "info": { + "_postman_id": "55790843-a96f-4c97-b21d-1b3b07b830a6", + "name": "ECommerce API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "31837703" + }, + "item": [ + { + "name": "Categories", + "item": [ + { + "name": "Create", + "item": [ + { + "name": "Valid Create Category", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Electronics\",\n \"description\": \"Smartphones, laptops, and gadgets.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/category", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category" + ] + } + }, + "response": [] + }, + { + "name": "Invalid Create Category", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"description\": \"Smartphones, laptops, and gadgets.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/category", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Update", + "item": [ + { + "name": "Valid Update Category", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Electronics\",\n \"description\": \"Smartphones, laptops, TVs\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/category/3", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category", + "3" + ] + } + }, + "response": [] + }, + { + "name": "Invalid Update Category", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"description\": \"Smartphones, laptops, TVs\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/category/3", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "category", + "3" + ] + } + }, + "response": [] + } + ] }, { "name": "Get All Categories", @@ -205,34 +1149,6 @@ } }, "response": [] - }, - { - "name": "Update Category", - "request": { - "method": "PUT", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Electronics\",\n \"description\": \"Smartphones, laptops, TVs\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/api/category/3", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "category", - "3" - ] - } - }, - "response": [] } ] }, @@ -240,31 +1156,124 @@ "name": "Products", "item": [ { - "name": "Create Product", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Shampoo Clear\",\n \"price\": 120,\n \"quantity\": 55,\n \"discription\": \"\",\n \"imageUrl\": \"\",\n \"categoryId\": 1\n}", - "options": { - "raw": { - "language": "json" + "name": "Create", + "item": [ + { + "name": "Valid Create Product", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Orange Juice\",\n \"price\": 15,\n \"quantity\": 55,\n \"discription\": \"\",\n \"imageUrl\": \"\",\n \"categoryId\": 4\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/product", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product" + ] } - } + }, + "response": [] }, - "url": { - "raw": "{{baseUrl}}/api/product", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "product" - ] + { + "name": "Invalid Create Product", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"price\": 0,\n \"quantity\": -5,\n \"discription\": \"\",\n \"imageUrl\": \"link\",\n \"categoryId\": 0\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/product", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product" + ] + } + }, + "response": [] } - }, - "response": [] + ] + }, + { + "name": "Update", + "item": [ + { + "name": "Valid Update Product", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Orange Juice\",\n \"price\": 15,\n \"quantity\": 55,\n \"discription\": \"\",\n \"imageUrl\": \"\",\n \"categoryId\": 4\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/product/5", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product", + "5" + ] + } + }, + "response": [] + }, + { + "name": "Invalid Update Product", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"price\": 0,\n \"quantity\": -5,\n \"discription\": \"\",\n \"imageUrl\": \"link\",\n \"categoryId\": 0\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/product/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "product", + "1" + ] + } + }, + "response": [] + } + ] }, { "name": "Get All Products", @@ -413,34 +1422,6 @@ } }, "response": [] - }, - { - "name": "Update Product", - "request": { - "method": "PUT", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Shampoo Clear\",\n \"price\": 120,\n \"quantity\": 55,\n \"discription\": \"\",\n \"imageUrl\": \"\",\n \"categoryId\": 1\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/api/product/1", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "product", - "1" - ] - } - }, - "response": [] } ] }, @@ -448,31 +1429,63 @@ "name": "Sales", "item": [ { - "name": "Create Sale", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"items\": [\n {\n \"productId\": 1,\n \"quantity\": 1\n },\n {\n \"productId\": 2,\n \"quantity\": 4\n }\n ]\n}", - "options": { - "raw": { - "language": "json" + "name": "Create", + "item": [ + { + "name": "Valid Create Sale", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"items\": [\n {\n \"productId\": 1,\n \"quantity\": 1\n },\n {\n \"productId\": 2,\n \"quantity\": 4\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/sale", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sale" + ] } - } + }, + "response": [] }, - "url": { - "raw": "{{baseUrl}}/api/sale", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "sale" - ] + { + "name": "Invalid Create Sale", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"items\": [\n {\n \"productId\": 0,\n \"quantity\": 1\n },\n {\n \"productId\": 2,\n \"quantity\": 0\n },\n {\n \"productId\": 3,\n \"quantity\": -1\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/sale", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "sale" + ] + } + }, + "response": [] } - }, - "response": [] + ] }, { "name": "Get All Sales", @@ -683,6 +1696,20 @@ { "key": "baseUrl", "value": "" + }, + { + "key": "_lastPathId", + "value": "" + }, + { + "key": "_lastExtractedId", + "value": "" + } + ] +} + { + "key": "_lastExtractedId", + "value": "" } ] -} \ No newline at end of file +} From 5ba1eb3f286edf7327f0b48798ef717656cd208c Mon Sep 17 00:00:00 2001 From: basemkasem <69323267+basemkasem@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:03:17 +0200 Subject: [PATCH 39/52] Implement input validation and exception handling features Created input validation and exception handling from the future enhancements section and added to the features section. --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 871c5186..4815df81 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ A RESTful API built with ASP.NET Core for managing an e-commerce system with pro - **Soft Delete**: Entities are soft-deleted, allowing data recovery - **Pagination**: Efficient data retrieval with pagination support - **Result Pattern**: Consistent error handling and response formatting +- **Exception Handling**: Add global exception handling middleware +- **Input Validation**: Implement input validation with FluentValidation ## Architecture @@ -277,15 +279,12 @@ Current validation includes: ## Future Enhancements - [ ] Add authentication & authorization (JWT) -- [ ] Implement input validation with FluentValidation -- [ ] Add global exception handling middleware - [ ] Implement logging (Serilog) - [ ] Add unit and integration tests - [ ] Implement caching for frequently accessed data - [ ] Add API versioning - [ ] Implement rate limiting - [ ] Add CORS configuration -- [ ] Create Update/Delete operations for Sales - [ ] Add filtering and sorting capabilities - [ ] Implement search functionality From fb9e8e1c73473ddf2c61116f223f8a68d0ad2199 Mon Sep 17 00:00:00 2001 From: basemkasem <69323267+basemkasem@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:08:54 +0200 Subject: [PATCH 40/52] Update API URL and task list in README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4815df81..ef2babcc 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ EcommerceApi/ ``` 5. **Access the API** - - API: `http://localhost:5000` or `https://localhost:5001` + - API: `http://localhost:5248` - OpenAPI UI: Navigate to `/openapi/v1.json` (in development mode) ## API Endpoints @@ -282,7 +282,7 @@ Current validation includes: - [ ] Implement logging (Serilog) - [ ] Add unit and integration tests - [ ] Implement caching for frequently accessed data -- [ ] Add API versioning +- [ ] Add API versioning (Currently Working On) - [ ] Implement rate limiting - [ ] Add CORS configuration - [ ] Add filtering and sorting capabilities From c87f47ee04a928442f286e83bdadda44cbb9f5dc Mon Sep 17 00:00:00 2001 From: basemkasem <69323267+basemkasem@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:18:18 +0200 Subject: [PATCH 41/52] Revise exception handling and input validation details Updated exception handling and input validation sections for clarity and consistency. --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ef2babcc..87aa256a 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ A RESTful API built with ASP.NET Core for managing an e-commerce system with pro - **Soft Delete**: Entities are soft-deleted, allowing data recovery - **Pagination**: Efficient data retrieval with pagination support - **Result Pattern**: Consistent error handling and response formatting -- **Exception Handling**: Add global exception handling middleware -- **Input Validation**: Implement input validation with FluentValidation +- **Global Exception Handling**: Middleware for centralized error handling across all requests +- **Input Validation**: Request validation using FluentValidation with descriptive error messages ## Architecture @@ -270,11 +270,13 @@ The API uses a custom `Result` pattern for consistent error responses: ## Validation -Current validation includes: -- Category existence validation when creating/updating products -- Product existence and stock validation when creating sales -- Quantity validation (must be positive) -- Stock deduction with availability checks +Input validation is handled by FluentValidation and covers: + +- Required fields (e.g., product name, category name) +- Positive quantity values +- Price must be greater than zero +- Category existence when creating/updating products +- Product existence and stock availability when creating sales ## Future Enhancements From 86df173061b8a02218595a7e21ec693e55094999 Mon Sep 17 00:00:00 2001 From: basem Date: Mon, 6 Apr 2026 13:45:20 +0200 Subject: [PATCH 42/52] Add Api Versioning Feature --- .../Controllers/CategoryController.cs | 3 ++- .../Controllers/ProductController.cs | 3 ++- .../Controllers/SaleController.cs | 3 ++- .../Ecommerce.Web/Ecommerce.Web.csproj | 2 ++ EcommerceApi/Ecommerce.Web/Program.cs | 18 +++++++++++++++--- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs b/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs index ef35fe97..db1c0bd2 100644 --- a/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs +++ b/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs @@ -1,3 +1,4 @@ +using Asp.Versioning; using Ecommerce.Core.DTOs.Category; using Ecommerce.Core.Interfaces.Services; using FluentValidation; @@ -6,7 +7,7 @@ namespace Ecommerce.Web.Controllers; [ApiController] -[Route("api/[controller]")] +[Route("api/v{v:apiVersion}/[controller]")] public class CategoryController(ICategoryService categoryService, IValidator createValidator, IValidator updateValidator) : ControllerBase { private readonly ICategoryService _categoryService = categoryService; diff --git a/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs b/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs index 3c0ce367..a822637d 100644 --- a/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs +++ b/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs @@ -1,3 +1,4 @@ +using Asp.Versioning; using Ecommerce.Core.DTOs.Product; using Ecommerce.Core.Interfaces.Services; using Ecommerce.Core.Utilities; @@ -7,7 +8,7 @@ namespace Ecommerce.Web.Controllers; [ApiController] -[Route("api/[controller]")] +[Route("api/v{v:apiVersion}/[controller]")] public class ProductController(IProductService productService, IValidator createValidator, IValidator updateValidator) : ControllerBase { private readonly IProductService _productService = productService; diff --git a/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs b/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs index 4e43acff..1a8cfa72 100644 --- a/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs +++ b/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs @@ -1,3 +1,4 @@ +using Asp.Versioning; using Ecommerce.Core.DTOs.Sale; using Ecommerce.Core.Interfaces.Services; using Ecommerce.Core.Utilities; @@ -7,7 +8,7 @@ namespace Ecommerce.Web.Controllers; [ApiController] -[Route("api/[controller]")] +[Route("api/v{v:apiVersion}/[controller]")] public class SaleController(ISaleService saleService, IValidator createValidator) : ControllerBase { private readonly ISaleService _saleService = saleService; diff --git a/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj index af807aee..38eec5d6 100644 --- a/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj +++ b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj @@ -9,6 +9,8 @@ + + diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs index 56e3ae5f..fdc53727 100644 --- a/EcommerceApi/Ecommerce.Web/Program.cs +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -1,3 +1,4 @@ +using Asp.Versioning; using Ecommerce.Core.Interfaces.Repositories; using Ecommerce.Core.Interfaces.Services; using Ecommerce.Core.Validators.Products; @@ -19,9 +20,9 @@ }); builder.Services.AddExceptionHandler(); -builder.Services.AddDbContext( - options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")) - ); +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")) +); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -29,6 +30,17 @@ builder.Services.AddValidatorsFromAssemblyContaining(); +builder.Services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.ReportApiVersions = true; + options.AssumeDefaultVersionWhenUnspecified = true; + options.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), + new HeaderApiVersionReader("X-Api-Version")); + }) + .AddMvc(); + builder.Services.AddControllers(); builder.Services.AddOpenApi(); From a21fe01c21a1b0592e6fdb59425305bd0fbd1bd1 Mon Sep 17 00:00:00 2001 From: basemkasem <69323267+basemkasem@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:46:23 +0200 Subject: [PATCH 43/52] Update urls to have apiVersion Variable --- .../ECommerce API.postman_collection.json | 927 +----------------- 1 file changed, 49 insertions(+), 878 deletions(-) diff --git a/EcommerceApi/ECommerce API.postman_collection.json b/EcommerceApi/ECommerce API.postman_collection.json index d475f181..25214fbf 100644 --- a/EcommerceApi/ECommerce API.postman_collection.json +++ b/EcommerceApi/ECommerce API.postman_collection.json @@ -27,12 +27,13 @@ } }, "url": { - "raw": "{{baseUrl}}/api/category", + "raw": "{{baseUrl}}/api/{{apiVersion}}/category", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "category" ] } @@ -54,12 +55,13 @@ } }, "url": { - "raw": "{{baseUrl}}/api/category", + "raw": "{{baseUrl}}/api/{{apiVersion}}/category", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "category" ] } @@ -86,12 +88,13 @@ } }, "url": { - "raw": "{{baseUrl}}/api/category/3", + "raw": "{{baseUrl}}/api/{{apiVersion}}/category/3", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "category", "3" ] @@ -114,12 +117,13 @@ } }, "url": { - "raw": "{{baseUrl}}/api/category/3", + "raw": "{{baseUrl}}/api/{{apiVersion}}/category/3", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "category", "3" ] @@ -135,858 +139,6 @@ { "listen": "test", "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Get All Categories: response is array or paginated object', function () {", - " const json = pm.response.json();", - " const isArray = Array.isArray(json);", - " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", - " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", - "})", - "", - "pm.test('Get All Categories: pagination fields are valid when present', function () {", - " const json = pm.response.json();", - " if (!json || typeof json !== 'object' || Array.isArray(json))", - " return;", - " const numFields = [", - " 'pageNumber',", - " 'pageSize',", - " 'totalCount',", - " 'total',", - " 'count'", - " ];", - " numFields.forEach(f => {", - " if (Object.prototype.hasOwnProperty.call(json, f)) {", - " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", - " }", - " });", - "})", - "", - "pm.test('Get All Categories: response is array or paginated object', function () {", - " const json = pm.response.json();", - " const isArray = Array.isArray(json);", - " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", - " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", - "})", - "", - "pm.test('Get All Categories: pagination fields are valid when present', function () {", - " const json = pm.response.json();", - " if (!json || typeof json !== 'object' || Array.isArray(json))", - " return;", - " const numFields = [", - " 'pageNumber',", - " 'pageSize',", - " 'totalCount',", - " 'total',", - " 'count'", - " ];", - " numFields.forEach(f => {", - " if (Object.prototype.hasOwnProperty.call(json, f)) {", - " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", - " }", - " });", - "})", - "", - "pm.test('Get All Categories: response is array or paginated object', function () {", - " const json = pm.response.json();", - " const isArray = Array.isArray(json);", - " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", - " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", - "})", - "", - "pm.test('Get All Categories: pagination fields are valid when present', function () {", - " const json = pm.response.json();", - " if (!json || typeof json !== 'object' || Array.isArray(json))", - " return;", - " const numFields = [", - " 'pageNumber',", - " 'pageSize',", - " 'totalCount',", - " 'total',", - " 'count'", - " ];", - " numFields.forEach(f => {", - " if (Object.prototype.hasOwnProperty.call(json, f)) {", - " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", - " }", - " });", - "})" - ] - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/category", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "category" - ] - } - }, - "response": [] - }, - { - "name": "Get Category", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/category/1", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "category", - "1" - ] - } - }, - "response": [] - }, - { - "name": "Delete Category", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Delete Category: status is 200 or 204', function () {", - " pm.expect(pm.response.code).to.be.oneOf([", - " 200,", - " 204", - " ]);", - "})", - "", - "pm.test('Delete Category: status is 200 or 204', function () {", - " pm.expect(pm.response.code).to.be.oneOf([", - " 200,", - " 204", - " ]);", - "})", - "", - "pm.test('Delete Category: status is 200 or 204', function () {", - " pm.expect(pm.response.code).to.be.oneOf([", - " 200,", - " 204", - " ]);", - "})" - ] - } - } - ], - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/category/2", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "category", - "2" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Products", - "item": [ - { - "name": "Create", - "item": [ - { - "name": "Valid Create Product", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Orange Juice\",\n \"price\": 15,\n \"quantity\": 55,\n \"discription\": \"\",\n \"imageUrl\": \"\",\n \"categoryId\": 4\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/api/product", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "product" - ] - } - }, - "response": [] - }, - { - "name": "Invalid Create Product", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"\",\n \"price\": 0,\n \"quantity\": -5,\n \"discription\": \"\",\n \"imageUrl\": \"link\",\n \"categoryId\": 0\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/api/product", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "product" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Update", - "item": [ - { - "name": "Valid Update Product", - "request": { - "method": "PUT", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Orange Juice\",\n \"price\": 15,\n \"quantity\": 55,\n \"discription\": \"\",\n \"imageUrl\": \"\",\n \"categoryId\": 4\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/api/product/5", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "product", - "5" - ] - } - }, - "response": [] - }, - { - "name": "Invalid Update Product", - "request": { - "method": "PUT", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"\",\n \"price\": 0,\n \"quantity\": -5,\n \"discription\": \"\",\n \"imageUrl\": \"link\",\n \"categoryId\": 0\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/api/product/1", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "product", - "1" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Get All Products", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Get All Products: response is array or paginated object', function () {", - " const json = pm.response.json();", - " const isArray = Array.isArray(json);", - " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", - " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", - "})", - "", - "pm.test('Get All Products: pagination fields are valid when present', function () {", - " const json = pm.response.json();", - " if (!json || typeof json !== 'object' || Array.isArray(json))", - " return;", - " const numFields = [", - " 'pageNumber',", - " 'pageSize',", - " 'totalCount',", - " 'total',", - " 'count'", - " ];", - " numFields.forEach(f => {", - " if (Object.prototype.hasOwnProperty.call(json, f)) {", - " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", - " }", - " });", - "})", - "", - "pm.test('Get All Products: response is array or paginated object', function () {", - " const json = pm.response.json();", - " const isArray = Array.isArray(json);", - " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", - " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", - "})", - "", - "pm.test('Get All Products: pagination fields are valid when present', function () {", - " const json = pm.response.json();", - " if (!json || typeof json !== 'object' || Array.isArray(json))", - " return;", - " const numFields = [", - " 'pageNumber',", - " 'pageSize',", - " 'totalCount',", - " 'total',", - " 'count'", - " ];", - " numFields.forEach(f => {", - " if (Object.prototype.hasOwnProperty.call(json, f)) {", - " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", - " }", - " });", - "})" - ] - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/product?pageNumber=1&pageSize=2", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "product" - ], - "query": [ - { - "key": "pageNumber", - "value": "1" - }, - { - "key": "pageSize", - "value": "2" - } - ] - } - }, - "response": [] - }, - { - "name": "Get a Product", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/product/1", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "product", - "1" - ] - } - }, - "response": [] - }, - { - "name": "Delete Product", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Delete Product: status is 200 or 204', function () {", - " pm.expect(pm.response.code).to.be.oneOf([", - " 200,", - " 204", - " ]);", - "})", - "", - "pm.test('Delete Product: status is 200 or 204', function () {", - " pm.expect(pm.response.code).to.be.oneOf([", - " 200,", - " 204", - " ]);", - "})" - ] - } - } - ], - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/product/3", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "product", - "3" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Sales", - "item": [ - { - "name": "Create", - "item": [ - { - "name": "Valid Create Sale", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"items\": [\n {\n \"productId\": 1,\n \"quantity\": 1\n },\n {\n \"productId\": 2,\n \"quantity\": 4\n }\n ]\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/api/sale", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "sale" - ] - } - }, - "response": [] - }, - { - "name": "Invalid Create Sale", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"items\": [\n {\n \"productId\": 0,\n \"quantity\": 1\n },\n {\n \"productId\": 2,\n \"quantity\": 0\n },\n {\n \"productId\": 3,\n \"quantity\": -1\n }\n ]\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/api/sale", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "sale" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Get All Sales", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Get All Sales: response is array or paginated object', function () {", - " const json = pm.response.json();", - " const isArray = Array.isArray(json);", - " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", - " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", - "})", - "", - "pm.test('Get All Sales: pagination fields are valid when present', function () {", - " const json = pm.response.json();", - " if (!json || typeof json !== 'object' || Array.isArray(json))", - " return;", - " const numFields = [", - " 'pageNumber',", - " 'pageSize',", - " 'totalCount',", - " 'total',", - " 'count'", - " ];", - " numFields.forEach(f => {", - " if (Object.prototype.hasOwnProperty.call(json, f)) {", - " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", - " }", - " });", - "})", - "", - "pm.test('Get All Sales: response is array or paginated object', function () {", - " const json = pm.response.json();", - " const isArray = Array.isArray(json);", - " const hasItemsArray = json && typeof json === 'object' && (Array.isArray(json.items) || Array.isArray(json.data) || Array.isArray(json.results));", - " pm.expect(isArray || hasItemsArray, 'array or object with items/data/results array').to.equal(true);", - "})", - "", - "pm.test('Get All Sales: pagination fields are valid when present', function () {", - " const json = pm.response.json();", - " if (!json || typeof json !== 'object' || Array.isArray(json))", - " return;", - " const numFields = [", - " 'pageNumber',", - " 'pageSize',", - " 'totalCount',", - " 'total',", - " 'count'", - " ];", - " numFields.forEach(f => {", - " if (Object.prototype.hasOwnProperty.call(json, f)) {", - " pm.expect(json[f], `${ f } should be a number`).to.be.a('number');", - " }", - " });", - "})" - ] - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/sale?pageNumber=1&pageSize=2", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "sale" - ], - "query": [ - { - "key": "pageNumber", - "value": "1" - }, - { - "key": "pageSize", - "value": "2" - } - ] - } - }, - "response": [] - }, - { - "name": "Get a sale", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/sale/1", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "sale", - "1" - ] - } - }, - "response": [] - } - ] - } - ], - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "packages": {}, - "requests": {}, - "exec": [ - "" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "// -------- Baseline collection-level tests & helpers --------", - "// These tests run after every request in this collection.", - "", - "pm.test(\"Response time is under 2000ms\", function () {", - " pm.expect(pm.response.responseTime).to.be.below(2000);", - "});", - "", - "pm.test(\"Status code is successful (2xx)\", function () {", - " pm.expect(pm.response.code).to.be.within(200, 299);", - "});", - "", - "// Content-Type checks (only when response has a body)", - "pm.test(\"Content-Type is JSON when body is present\", function () {", - " const hasBody = pm.response.text() && pm.response.text().trim().length > 0;", - " if (!hasBody) return;", - "", - " pm.response.to.have.header(\"Content-Type\");", - " const ct = (pm.response.headers.get(\"Content-Type\") || \"\").toLowerCase();", - " pm.expect(ct).to.include(\"application/json\");", - "});", - "", - "// Basic JSON parse check (only when body is present)", - "pm.test(\"Response body is valid JSON when present\", function () {", - " const hasBody = pm.response.text() && pm.response.text().trim().length > 0;", - " if (!hasBody) return;", - "", - " pm.response.to.be.json;", - "});", - "", - "// Helper: get path id from the current request URL, if any.", - "function getLastPathId() {", - " try {", - " const url = pm.request.url;", - " const segments = (url.path || []).filter(Boolean);", - " const last = segments[segments.length - 1];", - " if (last && /^\\d+$/.test(last)) return parseInt(last, 10);", - " // handle templated ids like {{productId}}", - " if (last && last.includes(\"{{\") && last.includes(\"}}\")) {", - " const varName = last.replace(/\\{\\{|\\}\\}/g, \"\").trim();", - " const v = pm.collectionVariables.get(varName) || pm.environment.get(varName) || pm.globals.get(varName);", - " if (v && /^\\d+$/.test(String(v))) return parseInt(v, 10);", - " }", - " } catch (e) {}", - " return null;", - "}", - "", - "// Helper: try to pull an id from common response shapes.", - "function extractId(json) {", - " if (!json || typeof json !== \"object\") return null;", - " if (typeof json.id === \"number\") return json.id;", - " if (typeof json.productId === \"number\") return json.productId;", - " if (typeof json.categoryId === \"number\") return json.categoryId;", - " if (typeof json.saleId === \"number\") return json.saleId;", - " if (json.data && typeof json.data === \"object\" && typeof json.data.id === \"number\") return json.data.id;", - " return null;", - "}", - "", - "// Expose helpers to request-level tests (by attaching to globals)", - "// Note: Postman sandbox doesn't support true exports; attach to pm.", - "pm.collectionVariables.set(\"_lastPathId\", String(getLastPathId() ?? \"\"));", - "", - "// Also keep the last extracted id available for request-level scripts to use if desired.", - "try {", - " const hasBody = pm.response.text() && pm.response.text().trim().length > 0;", - " if (hasBody && pm.response.headers.get(\"Content-Type\")?.toLowerCase().includes(\"application/json\")) {", - " const json = pm.response.json();", - " const extracted = extractId(json);", - " if (typeof extracted === \"number\") {", - " pm.collectionVariables.set(\"_lastExtractedId\", String(extracted));", - " }", - " }", - "} catch (e) {", - " // ignore", - "}", - "" - ] - } - } - ], - "variable": [ - { - "key": "baseUrl", - "value": "" - }, - { - "key": "_lastPathId", - "value": "" - },{ - "info": { - "_postman_id": "55790843-a96f-4c97-b21d-1b3b07b830a6", - "name": "ECommerce API", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "31837703" - }, - "item": [ - { - "name": "Categories", - "item": [ - { - "name": "Create", - "item": [ - { - "name": "Valid Create Category", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Electronics\",\n \"description\": \"Smartphones, laptops, and gadgets.\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/api/category", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "category" - ] - } - }, - "response": [] - }, - { - "name": "Invalid Create Category", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"\",\n \"description\": \"Smartphones, laptops, and gadgets.\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/api/category", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "category" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Update", - "item": [ - { - "name": "Valid Update Category", - "request": { - "method": "PUT", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Electronics\",\n \"description\": \"Smartphones, laptops, TVs\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/api/category/3", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "category", - "3" - ] - } - }, - "response": [] - }, - { - "name": "Invalid Update Category", - "request": { - "method": "PUT", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"\",\n \"description\": \"Smartphones, laptops, TVs\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/api/category/3", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "category", - "3" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Get All Categories", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", "exec": [ "pm.test('Get All Categories: response is array or paginated object', function () {", " const json = pm.response.json();", @@ -1062,7 +214,10 @@ " }", " });", "})" - ] + ], + "type": "text/javascript", + "packages": {}, + "requests": {} } } ], @@ -1070,12 +225,13 @@ "method": "GET", "header": [], "url": { - "raw": "{{baseUrl}}/api/category", + "raw": "{{baseUrl}}/api/{{apiVersion}}/category", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "category" ] } @@ -1088,12 +244,13 @@ "method": "GET", "header": [], "url": { - "raw": "{{baseUrl}}/api/category/1", + "raw": "{{baseUrl}}/api/{{apiVersion}}/category/1", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "category", "1" ] @@ -1137,12 +294,13 @@ "method": "DELETE", "header": [], "url": { - "raw": "{{baseUrl}}/api/category/2", + "raw": "{{baseUrl}}/api/{{apiVersion}}/category/2", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "category", "2" ] @@ -1173,12 +331,13 @@ } }, "url": { - "raw": "{{baseUrl}}/api/product", + "raw": "{{baseUrl}}/api/{{apiVersion}}/product", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "product" ] } @@ -1200,12 +359,13 @@ } }, "url": { - "raw": "{{baseUrl}}/api/product", + "raw": "{{baseUrl}}/api/{{apiVersion}}/product", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "product" ] } @@ -1232,12 +392,13 @@ } }, "url": { - "raw": "{{baseUrl}}/api/product/5", + "raw": "{{baseUrl}}/api/{{apiVersion}}/product/5", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "product", "5" ] @@ -1260,12 +421,13 @@ } }, "url": { - "raw": "{{baseUrl}}/api/product/1", + "raw": "{{baseUrl}}/api/{{apiVersion}}/product/1", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "product", "1" ] @@ -1281,7 +443,6 @@ { "listen": "test", "script": { - "type": "text/javascript", "exec": [ "pm.test('Get All Products: response is array or paginated object', function () {", " const json = pm.response.json();", @@ -1332,7 +493,10 @@ " }", " });", "})" - ] + ], + "type": "text/javascript", + "packages": {}, + "requests": {} } } ], @@ -1340,12 +504,13 @@ "method": "GET", "header": [], "url": { - "raw": "{{baseUrl}}/api/product?pageNumber=1&pageSize=2", + "raw": "{{baseUrl}}/api/{{apiVersion}}/product?pageNumber=1&pageSize=2", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "product" ], "query": [ @@ -1368,12 +533,13 @@ "method": "GET", "header": [], "url": { - "raw": "{{baseUrl}}/api/product/1", + "raw": "{{baseUrl}}/api/{{apiVersion}}/product/1", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "product", "1" ] @@ -1410,12 +576,13 @@ "method": "DELETE", "header": [], "url": { - "raw": "{{baseUrl}}/api/product/3", + "raw": "{{baseUrl}}/api/{{apiVersion}}/product/3", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "product", "3" ] @@ -1446,12 +613,13 @@ } }, "url": { - "raw": "{{baseUrl}}/api/sale", + "raw": "{{baseUrl}}/api/{{apiVersion}}/sale", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "sale" ] } @@ -1473,12 +641,13 @@ } }, "url": { - "raw": "{{baseUrl}}/api/sale", + "raw": "{{baseUrl}}/api/{{apiVersion}}/sale", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "sale" ] } @@ -1552,12 +721,13 @@ "method": "GET", "header": [], "url": { - "raw": "{{baseUrl}}/api/sale?pageNumber=1&pageSize=2", + "raw": "{{baseUrl}}/api/{{apiVersion}}/sale?pageNumber=1&pageSize=2", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "sale" ], "query": [ @@ -1580,12 +750,13 @@ "method": "GET", "header": [], "url": { - "raw": "{{baseUrl}}/api/sale/1", + "raw": "{{baseUrl}}/api/{{apiVersion}}/sale/1", "host": [ "{{baseUrl}}" ], "path": [ "api", + "{{apiVersion}}", "sale", "1" ] @@ -1612,6 +783,7 @@ "listen": "test", "script": { "type": "text/javascript", + "requests": {}, "exec": [ "// -------- Baseline collection-level tests & helpers --------", "// These tests run after every request in this collection.", @@ -1704,12 +876,11 @@ { "key": "_lastExtractedId", "value": "" - } - ] -} + }, { - "key": "_lastExtractedId", - "value": "" + "key": "apiVersion", + "value": "", + "type": "default" } ] } From e51adde91cee82fb355a9454306ffb9a92c0f4a8 Mon Sep 17 00:00:00 2001 From: basemkasem <69323267+basemkasem@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:05:00 +0200 Subject: [PATCH 44/52] Enhance README with API versioning feature Updated README to reflect API versioning and added FluentValidation details. --- README.md | 82 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 87aa256a..6440c12e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A RESTful API built with ASP.NET Core for managing an e-commerce system with pro - **Result Pattern**: Consistent error handling and response formatting - **Global Exception Handling**: Middleware for centralized error handling across all requests - **Input Validation**: Request validation using FluentValidation with descriptive error messages +- **API Versioning**: URL segment versioning via Asp.Versioning (current version: v1) ## Architecture @@ -23,7 +24,8 @@ EcommerceApi/ │ ├── Models/ # Domain entities │ ├── DTOs/ # Data Transfer Objects │ ├── Interfaces/ # Service and repository contracts -│ └── Utilities/ # Shared utilities (Result, Pagination) +│ ├── Utilities/ # Shared utilities (Result, Pagination) +| └── Validators/ # FluentValidation validators ├── Ecommerce.Data/ # Data access layer │ ├── Repositories/ # Repository implementations │ ├── Migrations/ # EF Core migrations @@ -34,6 +36,7 @@ EcommerceApi/ │ └── SaleService.cs └── Ecommerce.Web/ # Presentation layer (API) ├── Controllers/ # API controllers + ├── Extensions/ # Global Exception handler └── Program.cs # Application entry point ``` @@ -44,7 +47,9 @@ EcommerceApi/ - **Entity Framework Core** with SQL Server - **Repository Pattern** with Unit of Work - **Dependency Injection** -- **OpenAPI/Swagger** for API documentation +- **OpenAPI/Postman** for API documentation +- **FluentValidation** for input validation +- **Asp.Versioning** for API versioning ## Getting Started @@ -94,50 +99,50 @@ EcommerceApi/ | Method | Endpoint | Description | |--------|----------|-------------| -| GET | `/api/category` | Get all categories | -| GET | `/api/category/{id}` | Get category by ID | -| POST | `/api/category` | Create a new category | -| PUT | `/api/category/{id}` | Update a category | -| DELETE | `/api/category/{id}` | Delete a category (soft delete) | +| GET | `/api/v1/category` | Get all categories | +| GET | `/api/v1/category/{id}` | Get category by ID | +| POST | `/api/v1/category` | Create a new category | +| PUT | `/api/v1/category/{id}` | Update a category | +| DELETE | `/api/v1/category/{id}` | Delete a category (soft delete) | ### Products | Method | Endpoint | Description | |--------|----------|-------------| -| GET | `/api/product?pageNumber=1&pageSize=10` | Get all products (paginated) | -| GET | `/api/product/{id}` | Get product by ID | -| POST | `/api/product` | Create a new product | -| PUT | `/api/product/{id}` | Update a product | -| DELETE | `/api/product/{id}` | Delete a product (soft delete) | +| GET | `/api/v1/product?pageNumber=1&pageSize=10` | Get all products (paginated) | +| GET | `/api/v1/product/{id}` | Get product by ID | +| POST | `/api/v1/product` | Create a new product | +| PUT | `/api/v1/product/{id}` | Update a product | +| DELETE | `/api/v1/product/{id}` | Delete a product (soft delete) | ### Sales | Method | Endpoint | Description | |--------|----------|-------------| -| GET | `/api/sale?pageNumber=1&pageSize=10` | Get all sales (paginated) | -| GET | `/api/sale/{id}` | Get sale by ID with items | -| POST | `/api/sale` | Create a new sale | +| GET | `/api/v1/sale?pageNumber=1&pageSize=10` | Get all sales (paginated) | +| GET | `/api/v1/sale/{id}` | Get sale by ID with items | +| POST | `/api/v1/sale` | Create a new sale | ## API Usage Examples ### Create a Category - -```bash -POST /api/category + +```http +POST /api/v1/category Content-Type: application/json - + { "name": "Electronics", "description": "Electronic devices and gadgets" } ``` - + ### Create a Product - -```bash -POST /api/product + +```http +POST /api/v1/product Content-Type: application/json - + { "name": "Laptop", "description": "High-performance laptop", @@ -147,13 +152,13 @@ Content-Type: application/json "categoryId": 1 } ``` - + ### Create a Sale - -```bash -POST /api/sale + +```http +POST /api/v1/sale Content-Type: application/json - + { "items": [ { @@ -178,8 +183,8 @@ Content-Type: application/json Products and Sales support pagination: -```bash -GET /api/product?pageNumber=2&pageSize=20 +```http +GET /api/v1/product?pageNumber=2&pageSize=20 ``` ## Database Schema @@ -272,19 +277,30 @@ The API uses a custom `Result` pattern for consistent error responses: Input validation is handled by FluentValidation and covers: -- Required fields (e.g., product name, category name) +- Required fields (e.g. product name, category name) - Positive quantity values - Price must be greater than zero - Category existence when creating/updating products - Product existence and stock availability when creating sales +## API Versioning + +This API uses **URL segment versioning** via the `Asp.Versioning` package. The version is embedded directly in the route: + +``` +/api/v{version}/{resource} +``` + +**Current version:** `v1` + +All endpoints are prefixed with `/api/v1/`. When new versions are introduced, older versions remain accessible at their original paths to avoid breaking changes. + ## Future Enhancements - [ ] Add authentication & authorization (JWT) - [ ] Implement logging (Serilog) - [ ] Add unit and integration tests - [ ] Implement caching for frequently accessed data -- [ ] Add API versioning (Currently Working On) - [ ] Implement rate limiting - [ ] Add CORS configuration - [ ] Add filtering and sorting capabilities From 1b62a7fd93c31e883cdb771d92cd4c3bd99cf93d Mon Sep 17 00:00:00 2001 From: basemkasem <69323267+basemkasem@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:28:53 +0200 Subject: [PATCH 45/52] Mark authentication & authorization task as in progress Updated the status of the authentication & authorization task to indicate it is currently in progress. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6440c12e..382f74ec 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ All endpoints are prefixed with `/api/v1/`. When new versions are introduced, ol ## Future Enhancements -- [ ] Add authentication & authorization (JWT) +- [ ] Add authentication & authorization (JWT) **(Currently Working On)** - [ ] Implement logging (Serilog) - [ ] Add unit and integration tests - [ ] Implement caching for frequently accessed data From e793f5725a3ccbaf35edc2c0b44c7365ce8c97af Mon Sep 17 00:00:00 2001 From: basem Date: Wed, 8 Apr 2026 23:08:50 +0200 Subject: [PATCH 46/52] Switch all DTOs to use record type. --- .../DTOs/Category/CategoryDto.cs | 8 ++++---- .../DTOs/Category/CreateCategoryDto.cs | 6 +++--- .../DTOs/Category/UpdateCategoryDto.cs | 6 +++--- .../DTOs/Product/CreateProductDto.cs | 14 ++++++------- .../Ecommerce.Core/DTOs/Product/ProductDto.cs | 18 ++++++++--------- .../DTOs/Product/UpdateProductDto.cs | 14 ++++++------- .../Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs | 10 +++++----- .../Ecommerce.Core/DTOs/Sale/SaleDto.cs | 20 +++++++++---------- .../Utilities/PaginationParams.cs | 2 +- .../Ecommerce.Core/Utilities/Result.cs | 8 ++++---- 10 files changed, 53 insertions(+), 53 deletions(-) diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Category/CategoryDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Category/CategoryDto.cs index 0ce59ee4..c9296e53 100644 --- a/EcommerceApi/Ecommerce.Core/DTOs/Category/CategoryDto.cs +++ b/EcommerceApi/Ecommerce.Core/DTOs/Category/CategoryDto.cs @@ -1,8 +1,8 @@ namespace Ecommerce.Core.DTOs.Category; -public class CategoryDto +public record CategoryDto { - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public string? Description { get; set; } + public int Id { get; init; } + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Category/CreateCategoryDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Category/CreateCategoryDto.cs index 93b8d4d2..389ebbe1 100644 --- a/EcommerceApi/Ecommerce.Core/DTOs/Category/CreateCategoryDto.cs +++ b/EcommerceApi/Ecommerce.Core/DTOs/Category/CreateCategoryDto.cs @@ -1,7 +1,7 @@ namespace Ecommerce.Core.DTOs.Category; -public class CreateCategoryDto +public record CreateCategoryDto { - public string Name { get; set; } = string.Empty; - public string? Description { get; set; } + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Category/UpdateCategoryDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Category/UpdateCategoryDto.cs index b6f59cfc..49256761 100644 --- a/EcommerceApi/Ecommerce.Core/DTOs/Category/UpdateCategoryDto.cs +++ b/EcommerceApi/Ecommerce.Core/DTOs/Category/UpdateCategoryDto.cs @@ -1,7 +1,7 @@ namespace Ecommerce.Core.DTOs.Category; -public class UpdateCategoryDto +public record UpdateCategoryDto { - public string Name { get; set; } = string.Empty; - public string? Description { get; set; } + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs index 812bc3f0..25b1f5e9 100644 --- a/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs +++ b/EcommerceApi/Ecommerce.Core/DTOs/Product/CreateProductDto.cs @@ -1,11 +1,11 @@ namespace Ecommerce.Core.DTOs.Product; -public class CreateProductDto +public record CreateProductDto { - public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } - public int Quantity { get; set; } = 1; - public string? Description { get; set; } - public string? ImageUrl { get; set; } - public int CategoryId { get; set; } + public string Name { get; init; } = string.Empty; + public decimal Price { get; init; } + public int Quantity { get; init; } = 1; + public string? Description { get; init; } + public string? ImageUrl { get; init; } + public int CategoryId { get; init; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs index 200eb2f6..76cf9645 100644 --- a/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs +++ b/EcommerceApi/Ecommerce.Core/DTOs/Product/ProductDto.cs @@ -1,13 +1,13 @@ namespace Ecommerce.Core.DTOs.Product; -public class ProductDto +public record ProductDto { - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } - public int Quantity { get; set; } - public string? Description { get; set; } - public string? ImageUrl { get; set; } - public int CategoryId { get; set; } - public string CategoryName { get; set; } = string.Empty; + public int Id { get; init; } + public string Name { get; init; } = string.Empty; + public decimal Price { get; init; } + public int Quantity { get; init; } + public string? Description { get; init; } + public string? ImageUrl { get; init; } + public int CategoryId { get; init; } + public string CategoryName { get; init; } = string.Empty; } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs index a6910a50..a105d551 100644 --- a/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs +++ b/EcommerceApi/Ecommerce.Core/DTOs/Product/UpdateProductDto.cs @@ -1,11 +1,11 @@ namespace Ecommerce.Core.DTOs.Product; -public class UpdateProductDto +public record UpdateProductDto { - public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } - public int Quantity { get; set; } - public string? Description { get; set; } - public string? ImageUrl { get; set; } - public int CategoryId { get; set; } + public string Name { get; init; } = string.Empty; + public decimal Price { get; init; } + public int Quantity { get; init; } + public string? Description { get; init; } + public string? ImageUrl { get; init; } + public int CategoryId { get; init; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs index 66f59d9e..cc867d5d 100644 --- a/EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs +++ b/EcommerceApi/Ecommerce.Core/DTOs/Sale/CreateSaleDto.cs @@ -1,12 +1,12 @@ namespace Ecommerce.Core.DTOs.Sale; -public class CreateSaleDto +public record CreateSaleDto { - public List Items { get; set; } = new(); + public List Items { get; init; } = new(); } -public class CreateSaleItemDto +public record CreateSaleItemDto { - public int ProductId { get; set; } - public int Quantity { get; set; } + public int ProductId { get; init; } + public int Quantity { get; init; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs index 6f345161..b8dd6af9 100644 --- a/EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs +++ b/EcommerceApi/Ecommerce.Core/DTOs/Sale/SaleDto.cs @@ -1,17 +1,17 @@ namespace Ecommerce.Core.DTOs.Sale; -public class SaleDto +public record SaleDto { - public int Id { get; set; } - public DateTime CreationDate { get; set; } - public decimal TotalPrice { get; set; } - public List Items { get; set; } = new(); + public int Id { get; init; } + public DateTime CreationDate { get; init; } + public decimal TotalPrice { get; init; } + public List Items { get; init; } = new(); } -public class SaleItemDto +public record SaleItemDto { - public int ProductId { get; set; } - public string? ProductName { get; set; } = string.Empty; - public decimal UnitPrice { get; set; } - public int Quantity { get; set; } + public int ProductId { get; init; } + public string? ProductName { get; init; } = string.Empty; + public decimal UnitPrice { get; init; } + public int Quantity { get; init; } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs b/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs index 9cb8c68a..8a4fed79 100644 --- a/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs +++ b/EcommerceApi/Ecommerce.Core/Utilities/PaginationParams.cs @@ -1,6 +1,6 @@ namespace Ecommerce.Core.Utilities; -public class PaginationParams +public record PaginationParams { private const int DefaultPageSize = 50; diff --git a/EcommerceApi/Ecommerce.Core/Utilities/Result.cs b/EcommerceApi/Ecommerce.Core/Utilities/Result.cs index 715db21c..a6a4dfd7 100644 --- a/EcommerceApi/Ecommerce.Core/Utilities/Result.cs +++ b/EcommerceApi/Ecommerce.Core/Utilities/Result.cs @@ -1,10 +1,10 @@ namespace Ecommerce.Core.Utilities; -public class Result +public record Result { - public bool IsSuccess { get; set; } - public T? Data { get; set; } - public string? Message { get; set; } + public bool IsSuccess { get; private init; } + public T? Data { get; private init; } + public string? Message { get; private init; } public static Result Success(T data) { From 94b0f82a3ce00fc6daee927f35382e85af6bf99d Mon Sep 17 00:00:00 2001 From: basem Date: Thu, 9 Apr 2026 20:40:30 +0200 Subject: [PATCH 47/52] Fix magic numbers and add validation for product and category descreiption field. --- .../Ecommerce.Core/Models/Category.cs | 3 +++ EcommerceApi/Ecommerce.Core/Models/Product.cs | 3 +++ .../Categories/CreateCategoryDtoValidator.cs | 9 +++++-- .../Categories/UpdateCategoryDtoValidator.cs | 9 +++++-- .../Products/CreateProductDtoValidator.cs | 9 +++++-- .../Products/UpdateProductDtoValidation.cs | 26 +++++++++++++++---- 6 files changed, 48 insertions(+), 11 deletions(-) diff --git a/EcommerceApi/Ecommerce.Core/Models/Category.cs b/EcommerceApi/Ecommerce.Core/Models/Category.cs index b5178308..384c3077 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Category.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Category.cs @@ -4,6 +4,9 @@ namespace Ecommerce.Core.Models; public class Category : IBaseEntity, ISoftDeletable { + public const int MaxNameLength = 50; + public const int MaxDescriptionLength = 250; + public int Id { get; set; } public string Name { get; set; } = string.Empty; public string? Description { get; set; } diff --git a/EcommerceApi/Ecommerce.Core/Models/Product.cs b/EcommerceApi/Ecommerce.Core/Models/Product.cs index 75835ff0..48977865 100644 --- a/EcommerceApi/Ecommerce.Core/Models/Product.cs +++ b/EcommerceApi/Ecommerce.Core/Models/Product.cs @@ -4,6 +4,9 @@ namespace Ecommerce.Core.Models; public class Product : IBaseEntity, ISoftDeletable { + public const int MaxNameLength = 50; + public const int MaxDescriptionLength = 250; + public int Id { get; set; } public string Name { get; set; } = string.Empty; public decimal Price { get; set; } diff --git a/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryDtoValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryDtoValidator.cs index cd1b0a03..50302bd5 100644 --- a/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryDtoValidator.cs +++ b/EcommerceApi/Ecommerce.Core/Validators/Categories/CreateCategoryDtoValidator.cs @@ -1,4 +1,5 @@ using Ecommerce.Core.DTOs.Category; +using Ecommerce.Core.Models; using FluentValidation; namespace Ecommerce.Core.Validators.Categories; @@ -10,7 +11,11 @@ public CreateCategoryDtoValidator() RuleFor(x => x.Name) .NotEmpty() .WithMessage("Category name is required.") - .MaximumLength(50) - .WithMessage("Category name cannot exceed 50 characters."); + .MaximumLength(Category.MaxNameLength) + .WithMessage($"Category name cannot exceed {Category.MaxNameLength} characters."); + + RuleFor(x => x.Description) + .MaximumLength(Category.MaxDescriptionLength) + .WithMessage($"Category description cannot exceed {Category.MaxDescriptionLength} characters."); } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryDtoValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryDtoValidator.cs index f24a79e4..c586ec04 100644 --- a/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryDtoValidator.cs +++ b/EcommerceApi/Ecommerce.Core/Validators/Categories/UpdateCategoryDtoValidator.cs @@ -1,4 +1,5 @@ using Ecommerce.Core.DTOs.Category; +using Ecommerce.Core.Models; using FluentValidation; namespace Ecommerce.Core.Validators.Categories; @@ -10,7 +11,11 @@ public UpdateCategoryDtoValidator() RuleFor(x => x.Name) .NotEmpty() .WithMessage("Category name is required.") - .MaximumLength(50) - .WithMessage("Category name cannot exceed 50 characters."); + .MaximumLength(Category.MaxNameLength) + .WithMessage($"Category name cannot exceed {Category.MaxNameLength} characters."); + + RuleFor(x => x.Description) + .MaximumLength(Category.MaxDescriptionLength) + .WithMessage($"Category description cannot exceed {Category.MaxDescriptionLength} characters."); } } \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Validators/Products/CreateProductDtoValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Products/CreateProductDtoValidator.cs index 119cd93f..de906f9c 100644 --- a/EcommerceApi/Ecommerce.Core/Validators/Products/CreateProductDtoValidator.cs +++ b/EcommerceApi/Ecommerce.Core/Validators/Products/CreateProductDtoValidator.cs @@ -1,4 +1,5 @@ using Ecommerce.Core.DTOs.Product; +using Ecommerce.Core.Models; using FluentValidation; namespace Ecommerce.Core.Validators.Products; @@ -10,8 +11,12 @@ public CreateProductDtoValidator() RuleFor(x => x.Name) .NotEmpty() .WithMessage("Product name is required.") - .MaximumLength(100) - .WithMessage("Product name cannot exceed 100 characters."); + .MaximumLength(Product.MaxNameLength) + .WithMessage($"Product name cannot exceed {Product.MaxNameLength} characters."); + + RuleFor(x => x.Description) + .MaximumLength(Product.MaxDescriptionLength) + .WithMessage($"Product description cannot exceed {Product.MaxDescriptionLength} characters."); RuleFor(x => x.Price) .GreaterThan(0) diff --git a/EcommerceApi/Ecommerce.Core/Validators/Products/UpdateProductDtoValidation.cs b/EcommerceApi/Ecommerce.Core/Validators/Products/UpdateProductDtoValidation.cs index 0ac4e9ad..daf0ee0e 100644 --- a/EcommerceApi/Ecommerce.Core/Validators/Products/UpdateProductDtoValidation.cs +++ b/EcommerceApi/Ecommerce.Core/Validators/Products/UpdateProductDtoValidation.cs @@ -1,4 +1,5 @@ using Ecommerce.Core.DTOs.Product; +using Ecommerce.Core.Models; using FluentValidation; namespace Ecommerce.Core.Validators.Products; @@ -8,12 +9,27 @@ public class UpdateProductDtoValidation : AbstractValidator public UpdateProductDtoValidation() { RuleFor(x => x.Name) - .NotEmpty().WithMessage("Product name is required.") - .MaximumLength(100).WithMessage("Product name cannot exceed 100 characters."); + .NotEmpty() + .WithMessage("Product name is required.") + .MaximumLength(Product.MaxNameLength) + .WithMessage($"Product name cannot exceed {Product.MaxNameLength} characters."); + + RuleFor(x => x.Description) + .MaximumLength(Product.MaxDescriptionLength) + .WithMessage($"Product description cannot exceed {Product.MaxDescriptionLength} characters."); + + RuleFor(x => x.Price) + .GreaterThan(0) + .WithMessage("Price must be greater than zero."); + + RuleFor(x => x.Quantity) + .GreaterThanOrEqualTo(0) + .WithMessage("Quantity must be greater than or equal to zero."); + + RuleFor(x => x.CategoryId) + .GreaterThan(0) + .WithMessage("A valid Category ID is required."); - RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than zero."); - RuleFor(x => x.Quantity).GreaterThanOrEqualTo(0).WithMessage("Quantity must be greater than or equal to zero."); - RuleFor(x => x.CategoryId).GreaterThan(0).WithMessage("A valid Category ID is required."); RuleFor(x => x.ImageUrl) .Must(url => Uri.IsWellFormedUriString(url, UriKind.Absolute)) .When(x => !string.IsNullOrEmpty(x.ImageUrl)) From caa9944646c07f15a105183df1b2301c38d55dac Mon Sep 17 00:00:00 2001 From: basem Date: Thu, 9 Apr 2026 20:59:53 +0200 Subject: [PATCH 48/52] add ConnectionStrings section to appsettings.json --- EcommerceApi/Ecommerce.Web/appsettings.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/EcommerceApi/Ecommerce.Web/appsettings.json b/EcommerceApi/Ecommerce.Web/appsettings.json index 10f68b8c..95628f44 100644 --- a/EcommerceApi/Ecommerce.Web/appsettings.json +++ b/EcommerceApi/Ecommerce.Web/appsettings.json @@ -5,5 +5,7 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "ConnectionStrings": { + "DefaultConnection": "Server=.;Database=ecommerceDb;user id=DbUsername;password=DbPassword;Encrypt=true;TrustServerCertificate=true;" + } } From 7d8ea084eed174853b126c83824e883f187291d7 Mon Sep 17 00:00:00 2001 From: basem Date: Fri, 10 Apr 2026 10:31:58 +0200 Subject: [PATCH 49/52] remove unnecessary check --- EcommerceApi/Ecommerce.Services/ProductService.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/EcommerceApi/Ecommerce.Services/ProductService.cs b/EcommerceApi/Ecommerce.Services/ProductService.cs index 76773f01..d777b185 100644 --- a/EcommerceApi/Ecommerce.Services/ProductService.cs +++ b/EcommerceApi/Ecommerce.Services/ProductService.cs @@ -27,13 +27,12 @@ public async Task> CreateProductAsync(CreateProductDto produc Price = product.Price, CategoryId = product.CategoryId }; - if(newProduct.Quantity <= 0) - return Result.Fail($"Quantity cannot be negative"); + _unitOfWork.Products.Add(newProduct); int rowsAffected = await _unitOfWork.CompleteAsync(); if (rowsAffected == 0) { - return Result.Fail("Failed to create category"); + return Result.Fail("Failed to create product"); } ProductDto dto = new() { From 15c3a461e5e994d44c857c6edead71d6345b37e6 Mon Sep 17 00:00:00 2001 From: basem Date: Tue, 14 Apr 2026 16:21:19 +0200 Subject: [PATCH 50/52] Add Jwt authentication and authorization support to the application. --- .../DTOs/Auth/AuthResponseDto.cs | 3 + .../Ecommerce.Core/DTOs/Auth/LoginDto.cs | 7 + .../Ecommerce.Core/DTOs/Auth/RegisterDto.cs | 10 + .../Ecommerce.Core/Ecommerce.Core.csproj | 6 + .../Interfaces/Services/IAuthService.cs | 10 + .../Ecommerce.Core/Models/ApplicationUser.cs | 8 + .../Validators/Auth/LoginDtoValidator.cs | 22 ++ .../Validators/Auth/RegisterDtoValidator.cs | 31 +++ EcommerceApi/Ecommerce.Data/AppDbContext.cs | 5 +- .../Ecommerce.Data/Ecommerce.Data.csproj | 5 + .../Migrations/AppDbContextModelSnapshot.cs | 252 ++++++++++++++++++ .../Ecommerce.Services/AuthService.cs | 80 ++++++ .../Ecommerce.Services.csproj | 22 ++ .../Controllers/AuthController.cs | 53 ++++ .../Controllers/CategoryController.cs | 6 +- .../Controllers/ProductController.cs | 6 +- .../Controllers/SaleController.cs | 3 + .../Ecommerce.Web/Ecommerce.Web.csproj | 2 + EcommerceApi/Ecommerce.Web/Program.cs | 24 +- EcommerceApi/Ecommerce.Web/appsettings.json | 6 + 20 files changed, 556 insertions(+), 5 deletions(-) create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Auth/AuthResponseDto.cs create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Auth/LoginDto.cs create mode 100644 EcommerceApi/Ecommerce.Core/DTOs/Auth/RegisterDto.cs create mode 100644 EcommerceApi/Ecommerce.Core/Interfaces/Services/IAuthService.cs create mode 100644 EcommerceApi/Ecommerce.Core/Models/ApplicationUser.cs create mode 100644 EcommerceApi/Ecommerce.Core/Validators/Auth/LoginDtoValidator.cs create mode 100644 EcommerceApi/Ecommerce.Core/Validators/Auth/RegisterDtoValidator.cs create mode 100644 EcommerceApi/Ecommerce.Services/AuthService.cs create mode 100644 EcommerceApi/Ecommerce.Web/Controllers/AuthController.cs diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Auth/AuthResponseDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Auth/AuthResponseDto.cs new file mode 100644 index 00000000..adfbf556 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Auth/AuthResponseDto.cs @@ -0,0 +1,3 @@ +namespace Ecommerce.Core.DTOs.Auth; + +public record AuthResponseDto(string Token, DateTime Expires); \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Auth/LoginDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Auth/LoginDto.cs new file mode 100644 index 00000000..7de485ea --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Auth/LoginDto.cs @@ -0,0 +1,7 @@ +namespace Ecommerce.Core.DTOs.Auth; + +public record LoginDto +{ + public string Email { get; init; } = string.Empty; + public string Password { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/DTOs/Auth/RegisterDto.cs b/EcommerceApi/Ecommerce.Core/DTOs/Auth/RegisterDto.cs new file mode 100644 index 00000000..e4d0a29f --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/DTOs/Auth/RegisterDto.cs @@ -0,0 +1,10 @@ +namespace Ecommerce.Core.DTOs.Auth; + +public record RegisterDto +{ + public string Username { get; init; } = string.Empty; + public string Email { get; init; } = string.Empty; + public string Password { get; init; } = string.Empty; + public string? Address { get; init; } + public string? PhoneNumber { get; init; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj index 0b5645da..226ca994 100644 --- a/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj +++ b/EcommerceApi/Ecommerce.Core/Ecommerce.Core.csproj @@ -10,4 +10,10 @@ + + + ..\..\..\..\..\..\..\..\..\..\home\basem\.nuget\packages\microsoft.extensions.identity.stores\10.0.3\lib\net10.0\Microsoft.Extensions.Identity.Stores.dll + + + diff --git a/EcommerceApi/Ecommerce.Core/Interfaces/Services/IAuthService.cs b/EcommerceApi/Ecommerce.Core/Interfaces/Services/IAuthService.cs new file mode 100644 index 00000000..444d4e0e --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Interfaces/Services/IAuthService.cs @@ -0,0 +1,10 @@ +using Ecommerce.Core.DTOs.Auth; +using Ecommerce.Core.Utilities; + +namespace Ecommerce.Core.Interfaces.Services; + +public interface IAuthService +{ + Task> Register(RegisterDto registerDto); + Task> Login(LoginDto loginDto); +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Models/ApplicationUser.cs b/EcommerceApi/Ecommerce.Core/Models/ApplicationUser.cs new file mode 100644 index 00000000..e8eab69b --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Models/ApplicationUser.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Identity; + +namespace Ecommerce.Core.Models; + +public class ApplicationUser : IdentityUser +{ + public string? Address { get; set; } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Validators/Auth/LoginDtoValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Auth/LoginDtoValidator.cs new file mode 100644 index 00000000..bb3cbdd0 --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Auth/LoginDtoValidator.cs @@ -0,0 +1,22 @@ +using Ecommerce.Core.DTOs.Auth; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Auth; + +public class LoginDtoValidator : AbstractValidator +{ + public LoginDtoValidator() + { + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("Email is required.") + .EmailAddress() + .WithMessage("Invalid email format."); + + RuleFor(x => x.Password) + .NotEmpty() + .WithMessage("Password is required.") + .Matches(@"^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$") + .WithMessage("Password must contain at least 8 characters, one uppercase letter, one number, and one special character."); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Core/Validators/Auth/RegisterDtoValidator.cs b/EcommerceApi/Ecommerce.Core/Validators/Auth/RegisterDtoValidator.cs new file mode 100644 index 00000000..de8633ad --- /dev/null +++ b/EcommerceApi/Ecommerce.Core/Validators/Auth/RegisterDtoValidator.cs @@ -0,0 +1,31 @@ +using Ecommerce.Core.DTOs.Auth; +using FluentValidation; + +namespace Ecommerce.Core.Validators.Auth; + +public class RegisterDtoValidator : AbstractValidator +{ + public RegisterDtoValidator() + { + RuleFor(x => x.Username) + .NotEmpty() + .WithMessage("Username is required."); + + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("Email is required.") + .EmailAddress() + .WithMessage("Invalid email format."); + + RuleFor(x => x.Password) + .NotEmpty() + .WithMessage("Password is required.") + .Matches(@"^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$") + .WithMessage("Password must contain at least 8 characters, one uppercase letter, one number, and one special character."); + + RuleFor(x => x.PhoneNumber) + .Matches(@"^01[0125][0-9]{8}$") + .When(x => !string.IsNullOrEmpty(x.PhoneNumber)) + .WithMessage("Invalid phone number format."); + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Data/AppDbContext.cs b/EcommerceApi/Ecommerce.Data/AppDbContext.cs index f8e31f40..9582ff3c 100644 --- a/EcommerceApi/Ecommerce.Data/AppDbContext.cs +++ b/EcommerceApi/Ecommerce.Data/AppDbContext.cs @@ -1,10 +1,11 @@ using Ecommerce.Core.Models; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace Ecommerce.Data; -public class AppDbContext(DbContextOptions options) : DbContext(options) +public class AppDbContext(DbContextOptions options) : IdentityDbContext(options) { public DbSet Products { get; set; } @@ -17,8 +18,8 @@ public class AppDbContext(DbContextOptions options) : DbContext(op protected override void OnModelCreating(ModelBuilder modelBuilder) - { + base.OnModelCreating(modelBuilder); modelBuilder .Entity() .HasQueryFilter(p => !p.IsDeleted) diff --git a/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj b/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj index 5ef6e2e9..f5e2bf03 100644 --- a/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj +++ b/EcommerceApi/Ecommerce.Data/Ecommerce.Data.csproj @@ -12,7 +12,12 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs b/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs index 8128b255..334afce9 100644 --- a/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/EcommerceApi/Ecommerce.Data/Migrations/AppDbContextModelSnapshot.cs @@ -22,6 +22,74 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("Ecommerce.Core.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + modelBuilder.Entity("Ecommerce.Core.Models.Category", b => { b.Property("Id") @@ -137,6 +205,139 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SaleItems"); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + modelBuilder.Entity("Ecommerce.Core.Models.Product", b => { b.HasOne("Ecommerce.Core.Models.Category", "Category") @@ -167,6 +368,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Sale"); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Ecommerce.Core.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Ecommerce.Core.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ecommerce.Core.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Ecommerce.Core.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Ecommerce.Core.Models.Category", b => { b.Navigation("Products"); diff --git a/EcommerceApi/Ecommerce.Services/AuthService.cs b/EcommerceApi/Ecommerce.Services/AuthService.cs new file mode 100644 index 00000000..2ccc8900 --- /dev/null +++ b/EcommerceApi/Ecommerce.Services/AuthService.cs @@ -0,0 +1,80 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Ecommerce.Core.DTOs.Auth; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Models; +using Ecommerce.Core.Utilities; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace Ecommerce.Services; + +public class AuthService( + UserManager userManager, + IConfiguration configuration + ) : IAuthService +{ + public async Task> Register(RegisterDto registerDto) + { + ApplicationUser user = new() + { + UserName = registerDto.Username, + Email = registerDto.Email + }; + var result = await userManager.CreateAsync(user, registerDto.Password); + if (!result.Succeeded) + { + var errors = string.Join("\n", result.Errors.Select(x => $"{x.Code} => {x.Description}")); + return Result.Fail(errors); + } + + return Result.Success("User created successfully"); + } + + public async Task> Login(LoginDto loginDto) + { + var user = await userManager.FindByEmailAsync(loginDto.Email); + if (user is null || !await userManager.CheckPasswordAsync(user, loginDto.Password)) + return Result.Fail("Invalid email or password"); + + var userRoles = await userManager.GetRolesAsync(user); + + var userClaims = new List() + { + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.Sub, user.Id), + new(JwtRegisteredClaimNames.Email, user.Email!), + new(JwtRegisteredClaimNames.Name, user.UserName!) + }; + + foreach (var role in userRoles) + userClaims.Add(new Claim(ClaimTypes.Role, role)); + + var token = GenerateJwt(userClaims); + + return Result.Success( + new AuthResponseDto( + new JwtSecurityTokenHandler().WriteToken(token), + token.ValidTo + ) + ); + + JwtSecurityToken GenerateJwt(List claims) + { + SymmetricSecurityKey authSecret = new(Encoding.UTF8.GetBytes(configuration["Jwt:Secret"]!)); + + var signingCredentials = + new SigningCredentials(authSecret, SecurityAlgorithms.HmacSha256); + + return new JwtSecurityToken( + issuer: configuration["Jwt:Issuer"], + audience: configuration["Jwt:Audience"], + claims: userClaims, + expires: DateTime.Now.AddHours(configuration.GetValue("Jwt:ExpirationInHours")), + signingCredentials: signingCredentials + ); + } + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Services/Ecommerce.Services.csproj b/EcommerceApi/Ecommerce.Services/Ecommerce.Services.csproj index 2ee12597..5652b597 100644 --- a/EcommerceApi/Ecommerce.Services/Ecommerce.Services.csproj +++ b/EcommerceApi/Ecommerce.Services/Ecommerce.Services.csproj @@ -10,4 +10,26 @@ + + + ..\..\..\..\..\..\..\..\..\..\home\basem\.nuget\packages\microsoft.extensions.configuration.abstractions\10.0.3\lib\net10.0\Microsoft.Extensions.Configuration.Abstractions.dll + + + ..\..\..\..\..\..\..\..\..\..\home\basem\.nuget\packages\microsoft.extensions.identity.core\10.0.3\lib\net10.0\Microsoft.Extensions.Identity.Core.dll + + + ..\..\..\..\..\..\..\..\..\..\home\basem\.nuget\packages\microsoft.extensions.identity.stores\10.0.3\lib\net10.0\Microsoft.Extensions.Identity.Stores.dll + + + ..\..\..\..\..\..\..\..\..\..\home\basem\.nuget\packages\microsoft.identitymodel.tokens\7.7.1\lib\net8.0\Microsoft.IdentityModel.Tokens.dll + + + ..\..\..\..\..\..\..\..\..\..\home\basem\.nuget\packages\system.identitymodel.tokens.jwt\7.7.1\lib\net8.0\System.IdentityModel.Tokens.Jwt.dll + + + + + + + diff --git a/EcommerceApi/Ecommerce.Web/Controllers/AuthController.cs b/EcommerceApi/Ecommerce.Web/Controllers/AuthController.cs new file mode 100644 index 00000000..ec91dbdc --- /dev/null +++ b/EcommerceApi/Ecommerce.Web/Controllers/AuthController.cs @@ -0,0 +1,53 @@ +using Asp.Versioning; +using Ecommerce.Core.DTOs.Auth; +using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Models; +using FluentValidation; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace Ecommerce.Web.Controllers +{ + [Route("api/v{v:apiVersion}/[controller]")] + [ApiController] + public class AuthController( + IValidator registerValidator, + IValidator loginValidator, + UserManager userManager, + IAuthService authService + ) : ControllerBase + { + [HttpPost("Register")] + public async Task Register(RegisterDto registerDto) + { + var validationResult = await registerValidator.ValidateAsync(registerDto); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); + return BadRequest(new { Errors = errors }); + } + + var result = await authService.Register(registerDto); + if (!result.IsSuccess) + return BadRequest(new { Error = result.Message }); + return Ok(result.Data); + } + + [HttpPost("Login")] + public async Task Login(LoginDto loginDto) + { + var validationResult = await loginValidator.ValidateAsync(loginDto); + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => $"{x.PropertyName} => {x.ErrorMessage}").ToList(); + return BadRequest(new { Errors = errors }); + } + + var result = await authService.Login(loginDto); + if (result.IsSuccess) + return Ok(result.Data); + + return Unauthorized(new { Error = result.Message }); + } + } +} \ No newline at end of file diff --git a/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs b/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs index db1c0bd2..c5614da5 100644 --- a/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs +++ b/EcommerceApi/Ecommerce.Web/Controllers/CategoryController.cs @@ -2,6 +2,7 @@ using Ecommerce.Core.DTOs.Category; using Ecommerce.Core.Interfaces.Services; using FluentValidation; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Ecommerce.Web.Controllers; @@ -29,6 +30,7 @@ public async Task GetById(int id) return result.IsSuccess ? Ok(result.Data) : NotFound(result.Message); } + [Authorize(Roles = "Admin")] [HttpPost] public async Task Create(CreateCategoryDto category) { @@ -42,6 +44,7 @@ public async Task Create(CreateCategoryDto category) return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); } + [Authorize(Roles = "Admin")] [HttpPut("{id:int}")] public async Task Update(int id, UpdateCategoryDto category) { @@ -54,7 +57,8 @@ public async Task Update(int id, UpdateCategoryDto category) var result = await _categoryService.UpdateCategoryAsync(id, category); return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); } - + + [Authorize(Roles = "Admin")] [HttpDelete("{id:int}")] public async Task Delete(int id) { diff --git a/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs b/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs index a822637d..ea64baed 100644 --- a/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs +++ b/EcommerceApi/Ecommerce.Web/Controllers/ProductController.cs @@ -3,6 +3,7 @@ using Ecommerce.Core.Interfaces.Services; using Ecommerce.Core.Utilities; using FluentValidation; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Ecommerce.Web.Controllers; @@ -29,6 +30,7 @@ public async Task GetById(int id) return product.IsSuccess ? Ok(product.Data) : NotFound(product.Message); } + [Authorize(Roles = "Admin")] [HttpPost] public async Task Create(CreateProductDto product) { @@ -43,13 +45,15 @@ public async Task Create(CreateProductDto product) return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Message); } + [Authorize(Roles = "Admin")] [HttpDelete("{id:int}")] public async Task Delete(int id) { var result = await _productService.DeleteProductAsync(id); return result.IsSuccess ? NoContent() : BadRequest(result.Message); } - + + [Authorize(Roles = "Admin")] [HttpPut("{id:int}")] public async Task Update(int id, UpdateProductDto product) { diff --git a/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs b/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs index 1a8cfa72..bad722ee 100644 --- a/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs +++ b/EcommerceApi/Ecommerce.Web/Controllers/SaleController.cs @@ -3,6 +3,7 @@ using Ecommerce.Core.Interfaces.Services; using Ecommerce.Core.Utilities; using FluentValidation; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Ecommerce.Web.Controllers; @@ -14,6 +15,7 @@ public class SaleController(ISaleService saleService, IValidator private readonly ISaleService _saleService = saleService; private readonly IValidator _createValidator = createValidator; + [Authorize(Roles = "Admin")] [HttpGet] public async Task GetAll([FromQuery] PaginationParams paginationParams) { @@ -28,6 +30,7 @@ public async Task GetById(int id) return sale.IsSuccess ? Ok(sale.Data) : NotFound(sale.Message); } + [Authorize] [HttpPost] public async Task Create(CreateSaleDto sale) { diff --git a/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj index 38eec5d6..3d390119 100644 --- a/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj +++ b/EcommerceApi/Ecommerce.Web/Ecommerce.Web.csproj @@ -12,11 +12,13 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/EcommerceApi/Ecommerce.Web/Program.cs b/EcommerceApi/Ecommerce.Web/Program.cs index fdc53727..00854661 100644 --- a/EcommerceApi/Ecommerce.Web/Program.cs +++ b/EcommerceApi/Ecommerce.Web/Program.cs @@ -1,13 +1,18 @@ +using System.Text; using Asp.Versioning; using Ecommerce.Core.Interfaces.Repositories; using Ecommerce.Core.Interfaces.Services; +using Ecommerce.Core.Models; using Ecommerce.Core.Validators.Products; using Ecommerce.Data; using Ecommerce.Data.Repositories; using Ecommerce.Services; using Ecommerce.Web.Extensions; using FluentValidation; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); @@ -23,16 +28,33 @@ builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")) ); +builder.Services.AddIdentity() + .AddEntityFrameworkStores(); + +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; +}).AddJwtBearer(options => +{ + options.TokenValidationParameters.ValidIssuer = builder.Configuration["Jwt:Issuer"]; + options.TokenValidationParameters.ValidAudience = builder.Configuration["Jwt:Audience"]; + options.TokenValidationParameters.IssuerSigningKey = + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)); +}); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddApiVersioning(options => { - options.DefaultApiVersion = new ApiVersion(1, 0); + options.DefaultApiVersion = new ApiVersion(1); options.ReportApiVersions = true; options.AssumeDefaultVersionWhenUnspecified = true; options.ApiVersionReader = ApiVersionReader.Combine( diff --git a/EcommerceApi/Ecommerce.Web/appsettings.json b/EcommerceApi/Ecommerce.Web/appsettings.json index 95628f44..76146697 100644 --- a/EcommerceApi/Ecommerce.Web/appsettings.json +++ b/EcommerceApi/Ecommerce.Web/appsettings.json @@ -7,5 +7,11 @@ }, "ConnectionStrings": { "DefaultConnection": "Server=.;Database=ecommerceDb;user id=DbUsername;password=DbPassword;Encrypt=true;TrustServerCertificate=true;" + }, + "Jwt": { + "Secret": "Set_a_32_Character_Secret_Key_Here", + "ExpirationInHours": 1, + "Issuer": "IssuerUrl", + "Audience": "AudienceUrl" } } From 3a6189f85bc6c45174be5e6abe87ff96a851a03e Mon Sep 17 00:00:00 2001 From: basem Date: Tue, 14 Apr 2026 16:44:14 +0200 Subject: [PATCH 51/52] fix claims parameter call in GenerateJwt method --- EcommerceApi/Ecommerce.Services/AuthService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EcommerceApi/Ecommerce.Services/AuthService.cs b/EcommerceApi/Ecommerce.Services/AuthService.cs index 2ccc8900..a85b4206 100644 --- a/EcommerceApi/Ecommerce.Services/AuthService.cs +++ b/EcommerceApi/Ecommerce.Services/AuthService.cs @@ -71,7 +71,7 @@ JwtSecurityToken GenerateJwt(List claims) return new JwtSecurityToken( issuer: configuration["Jwt:Issuer"], audience: configuration["Jwt:Audience"], - claims: userClaims, + claims: claims, expires: DateTime.Now.AddHours(configuration.GetValue("Jwt:ExpirationInHours")), signingCredentials: signingCredentials ); From c46a6e0253937345cc22cbfad6cdfff1355aefec Mon Sep 17 00:00:00 2001 From: basemkasem <69323267+basemkasem@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:41:50 +0200 Subject: [PATCH 52/52] Clarify authentication details in README Updated README to clarify authentication and authorization details, and removed redundant mention of JWT in future enhancements. --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 382f74ec..4c8bbd2b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ A RESTful API built with ASP.NET Core for managing an e-commerce system with pro - **Product Management**: CRUD operations for products with inventory tracking - **Category Management**: Organize products into categories - **Sales Processing**: Create and track sales with automatic inventory deduction +- **Authentication & Authorization:** Secure, stateless user authentication utilizing ASP.NET Core Identity and JSON Web Tokens (JWT). + Implements role-based access control to lock down administrative endpoints while safely exposing public browsing routes. - **Soft Delete**: Entities are soft-deleted, allowing data recovery - **Pagination**: Efficient data retrieval with pagination support - **Result Pattern**: Consistent error handling and response formatting @@ -50,6 +52,7 @@ EcommerceApi/ - **OpenAPI/Postman** for API documentation - **FluentValidation** for input validation - **Asp.Versioning** for API versioning +- **Json Web Tokens** for token-based Authentication and Authorization ## Getting Started @@ -297,8 +300,7 @@ All endpoints are prefixed with `/api/v1/`. When new versions are introduced, ol ## Future Enhancements -- [ ] Add authentication & authorization (JWT) **(Currently Working On)** -- [ ] Implement logging (Serilog) +- [ ] Implement logging - [ ] Add unit and integration tests - [ ] Implement caching for frequently accessed data - [ ] Implement rate limiting