From 25f376160651cb6792241a5fb13f9af31e47ece6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:38:18 +0000 Subject: [PATCH 1/4] Extract Product bounded context into standalone Product microservice - Port Product/ProductCategory entities + BaseEntity into Product.Domain (drop OrderDetails nav; keep Parent/Children + ProductCategory) - Port verbatim fluent config into ProductDbContext (AppProducts/AppProductCategories, Name index, 18,2 precision, Parent->Children Restrict); EnsureCreated on boot - Add ProductVM DTO + full CRUD controller with real EF persistence, [Authorize], [ProducesResponseType], Swagger - Wire JWT bearer (HS256) per canonical Identity contract; UseAuthentication/UseAuthorization - Use absolute routes so gateway prefix-stripping maps /api/products -> / - Fix scaffold Shared project reference path (..\..\Shared -> ..\..\..\Shared) - Add e2e-smoke.sh (health 200 + unauth 401 through gateway) --- .../Controllers/ProductController.cs | 179 +++++++++++++++++- .../Product/Product.API/Product.API.csproj | 5 +- src/Services/Product/Product.API/Program.cs | 70 ++++++- .../Product.API/ViewModels/ProductVM.cs | 15 ++ .../Product/Product.API/appsettings.json | 5 + .../Product/Product.Domain/BaseEntity.cs | 18 ++ .../Product/Product.Domain/Product.cs | 21 ++ .../Product/Product.Domain/ProductCategory.cs | 10 + .../Data/ProductDbContext.cs | 27 ++- src/Services/Product/e2e-smoke.sh | 47 +++++ 10 files changed, 382 insertions(+), 15 deletions(-) create mode 100644 src/Services/Product/Product.API/ViewModels/ProductVM.cs create mode 100644 src/Services/Product/Product.Domain/BaseEntity.cs create mode 100644 src/Services/Product/Product.Domain/Product.cs create mode 100644 src/Services/Product/Product.Domain/ProductCategory.cs create mode 100755 src/Services/Product/e2e-smoke.sh diff --git a/src/Services/Product/Product.API/Controllers/ProductController.cs b/src/Services/Product/Product.API/Controllers/ProductController.cs index 710c58f..a6fc74d 100644 --- a/src/Services/Product/Product.API/Controllers/ProductController.cs +++ b/src/Services/Product/Product.API/Controllers/ProductController.cs @@ -1,29 +1,188 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Product.API.ViewModels; +using Product.Infrastructure.Data; +using ProductEntity = Product.Domain.Product; namespace Product.API.Controllers; [ApiController] -[Route("api/[controller]")] +[Authorize] public class ProductController : ControllerBase { + private readonly ProductDbContext _db; private readonly ILogger _logger; - public ProductController(ILogger logger) + public ProductController(ProductDbContext db, ILogger logger) { + _db = db; _logger = logger; } - [HttpGet] - public IActionResult GetAll() + // External: GET /api/products -> gateway strips prefix -> GET / + [HttpGet("/")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetAll() { - // TODO: Implement — migrate logic from monolith's ProductController - return Ok(new { service = "Product", status = "scaffold" }); + var products = await _db.Products + .Include(p => p.ProductCategory) + .AsNoTracking() + .ToListAsync(); + + return Ok(products.Select(MapToVM)); + } + + // External: GET /api/products/{id} -> GET /{id} + [HttpGet("/{id:int}")] + [ProducesResponseType(typeof(ProductVM), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById(int id) + { + var product = await _db.Products + .Include(p => p.ProductCategory) + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == id); + + if (product == null) + return NotFound(id); + + return Ok(MapToVM(product)); + } + + // External: POST /api/products -> POST / + [HttpPost("/")] + [ProducesResponseType(typeof(ProductVM), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Create([FromBody] ProductVM model) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + if (string.IsNullOrWhiteSpace(model.Name)) + return BadRequest("Name is required."); + + var category = await ResolveCategoryAsync(model.ProductCategoryName); + + var product = new ProductEntity + { + Name = model.Name, + Description = model.Description, + Icon = model.Icon, + BuyingPrice = model.BuyingPrice, + SellingPrice = model.SellingPrice, + UnitsInStock = model.UnitsInStock, + IsActive = model.IsActive, + IsDiscontinued = model.IsDiscontinued, + ProductCategory = category, + CreatedDate = DateTime.UtcNow, + UpdatedDate = DateTime.UtcNow + }; + + _db.Products.Add(product); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetById), new { id = product.Id }, MapToVM(product)); + } + + // External: PUT /api/products/{id} -> PUT /{id} + [HttpPut("/{id:int}")] + [ProducesResponseType(typeof(ProductVM), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Update(int id, [FromBody] ProductVM model) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var product = await _db.Products + .Include(p => p.ProductCategory) + .FirstOrDefaultAsync(p => p.Id == id); + + if (product == null) + return NotFound(id); + + if (string.IsNullOrWhiteSpace(model.Name)) + return BadRequest("Name is required."); + + product.Name = model.Name; + product.Description = model.Description; + product.Icon = model.Icon; + product.BuyingPrice = model.BuyingPrice; + product.SellingPrice = model.SellingPrice; + product.UnitsInStock = model.UnitsInStock; + product.IsActive = model.IsActive; + product.IsDiscontinued = model.IsDiscontinued; + product.UpdatedDate = DateTime.UtcNow; + + if (!string.IsNullOrWhiteSpace(model.ProductCategoryName) && + model.ProductCategoryName != product.ProductCategory.Name) + { + product.ProductCategory = await ResolveCategoryAsync(model.ProductCategoryName); + } + + await _db.SaveChangesAsync(); + + return Ok(MapToVM(product)); } - [HttpGet("{id}")] - public IActionResult GetById(int id) + // External: DELETE /api/products/{id} -> DELETE /{id} + [HttpDelete("/{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) { - // TODO: Implement — migrate logic from monolith - return Ok(new { service = "Product", id }); + var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == id); + if (product == null) + return NotFound(id); + + _db.Products.Remove(product); + await _db.SaveChangesAsync(); + + return NoContent(); } + + // External: GET /api/products/categories -> GET /categories + [HttpGet("/categories")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetCategories() + { + var categories = await _db.ProductCategories + .AsNoTracking() + .Select(c => c.Name) + .ToListAsync(); + + categories.Sort(StringComparer.OrdinalIgnoreCase); + + return Ok(categories); + } + + private async Task ResolveCategoryAsync(string? name) + { + var categoryName = string.IsNullOrWhiteSpace(name) ? "Uncategorized" : name; + + var existing = await _db.ProductCategories + .FirstOrDefaultAsync(c => c.Name == categoryName); + + return existing ?? new Domain.ProductCategory + { + Name = categoryName, + CreatedDate = DateTime.UtcNow, + UpdatedDate = DateTime.UtcNow + }; + } + + private static ProductVM MapToVM(ProductEntity product) => new() + { + Id = product.Id, + Name = product.Name, + Description = product.Description, + Icon = product.Icon, + BuyingPrice = product.BuyingPrice, + SellingPrice = product.SellingPrice, + UnitsInStock = product.UnitsInStock, + IsActive = product.IsActive, + IsDiscontinued = product.IsDiscontinued, + ProductCategoryName = product.ProductCategory?.Name + }; } diff --git a/src/Services/Product/Product.API/Product.API.csproj b/src/Services/Product/Product.API/Product.API.csproj index 68be876..7526f2e 100644 --- a/src/Services/Product/Product.API/Product.API.csproj +++ b/src/Services/Product/Product.API/Product.API.csproj @@ -7,10 +7,11 @@ - - + + + diff --git a/src/Services/Product/Product.API/Program.cs b/src/Services/Product/Product.API/Program.cs index 73146ef..fb7b5cd 100644 --- a/src/Services/Product/Product.API/Program.cs +++ b/src/Services/Product/Product.API/Program.cs @@ -1,16 +1,58 @@ -using Product.Infrastructure.Data; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using Product.Infrastructure.Data; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo { Title = "Product.API", Version = "v1" }); + + var scheme = new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } + }; + options.AddSecurityDefinition("Bearer", scheme); + options.AddSecurityRequirement(new OpenApiSecurityRequirement { [scheme] = Array.Empty() }); +}); builder.Services.AddHealthChecks(); builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +// Canonical JWT contract (HS256) — must match the Identity service token issuance. +var jwtSection = builder.Configuration.GetSection("Jwt"); +var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSection["Key"]!)); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.MapInboundClaims = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSection["Issuer"], + ValidAudience = jwtSection["Audience"], + IssuerSigningKey = signingKey, + NameClaimType = "name", + RoleClaimType = "role" + }; + }); +builder.Services.AddAuthorization(); + var app = builder.Build(); if (app.Environment.IsDevelopment()) @@ -19,7 +61,31 @@ app.UseSwaggerUI(); } +app.UseAuthentication(); +app.UseAuthorization(); + app.MapControllers(); app.MapHealthChecks("/healthz"); +// Create the schema on boot (Postgres may not be ready immediately under compose). +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService>(); + + for (var attempt = 1; attempt <= 10; attempt++) + { + try + { + db.Database.EnsureCreated(); + break; + } + catch (Exception ex) when (attempt < 10) + { + logger.LogWarning(ex, "Database not ready (attempt {Attempt}/10); retrying in 3s...", attempt); + Thread.Sleep(TimeSpan.FromSeconds(3)); + } + } +} + app.Run(); diff --git a/src/Services/Product/Product.API/ViewModels/ProductVM.cs b/src/Services/Product/Product.API/ViewModels/ProductVM.cs new file mode 100644 index 0000000..4042a8f --- /dev/null +++ b/src/Services/Product/Product.API/ViewModels/ProductVM.cs @@ -0,0 +1,15 @@ +namespace Product.API.ViewModels; + +public class ProductVM +{ + public int Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Icon { get; set; } + public decimal BuyingPrice { get; set; } + public decimal SellingPrice { get; set; } + public int UnitsInStock { get; set; } + public bool IsActive { get; set; } + public bool IsDiscontinued { get; set; } + public string? ProductCategoryName { get; set; } +} diff --git a/src/Services/Product/Product.API/appsettings.json b/src/Services/Product/Product.API/appsettings.json index b998b08..5aa594b 100644 --- a/src/Services/Product/Product.API/appsettings.json +++ b/src/Services/Product/Product.API/appsettings.json @@ -7,5 +7,10 @@ }, "ConnectionStrings": { "DefaultConnection": "Host=localhost;Database=productdb;Username=postgres;Password=postgres" + }, + "Jwt": { + "Key": "quickapp-dev-only-signing-key-change-me-please-32+chars", + "Issuer": "quickapp-identity", + "Audience": "quickapp" } } diff --git a/src/Services/Product/Product.Domain/BaseEntity.cs b/src/Services/Product/Product.Domain/BaseEntity.cs new file mode 100644 index 0000000..235937f --- /dev/null +++ b/src/Services/Product/Product.Domain/BaseEntity.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Product.Domain; + +public abstract class BaseEntity +{ + public int Id { get; set; } + + [MaxLength(40)] + public string? CreatedBy { get; set; } + + [MaxLength(40)] + public string? UpdatedBy { get; set; } + + public DateTime CreatedDate { get; set; } + + public DateTime UpdatedDate { get; set; } +} diff --git a/src/Services/Product/Product.Domain/Product.cs b/src/Services/Product/Product.Domain/Product.cs new file mode 100644 index 0000000..e5584af --- /dev/null +++ b/src/Services/Product/Product.Domain/Product.cs @@ -0,0 +1,21 @@ +namespace Product.Domain; + +public class Product : BaseEntity +{ + public required string Name { get; set; } + public string? Description { get; set; } + public string? Icon { get; set; } + public decimal BuyingPrice { get; set; } + public decimal SellingPrice { get; set; } + public int UnitsInStock { get; set; } + public bool IsActive { get; set; } + public bool IsDiscontinued { get; set; } + + public int? ParentId { get; set; } + public Product? Parent { get; set; } + + public int ProductCategoryId { get; set; } + public required ProductCategory ProductCategory { get; set; } + + public ICollection Children { get; } = []; +} diff --git a/src/Services/Product/Product.Domain/ProductCategory.cs b/src/Services/Product/Product.Domain/ProductCategory.cs new file mode 100644 index 0000000..328770d --- /dev/null +++ b/src/Services/Product/Product.Domain/ProductCategory.cs @@ -0,0 +1,10 @@ +namespace Product.Domain; + +public class ProductCategory : BaseEntity +{ + public required string Name { get; set; } + public string? Description { get; set; } + public string? Icon { get; set; } + + public ICollection Products { get; } = []; +} diff --git a/src/Services/Product/Product.Infrastructure/Data/ProductDbContext.cs b/src/Services/Product/Product.Infrastructure/Data/ProductDbContext.cs index 37cfb81..c5e7ce8 100644 --- a/src/Services/Product/Product.Infrastructure/Data/ProductDbContext.cs +++ b/src/Services/Product/Product.Infrastructure/Data/ProductDbContext.cs @@ -1,4 +1,6 @@ using Microsoft.EntityFrameworkCore; +using Product.Domain; +using ProductEntity = Product.Domain.Product; namespace Product.Infrastructure.Data; @@ -8,9 +10,32 @@ public ProductDbContext(DbContextOptions options) : base(optio { } + public DbSet Products => Set(); + public DbSet ProductCategories => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - // TODO: Configure entity mappings migrated from monolith + + modelBuilder.Entity(entity => + { + entity.Property(p => p.Name).IsRequired().HasMaxLength(100); + entity.Property(p => p.Description).HasMaxLength(500); + entity.ToTable("AppProductCategories"); + }); + + modelBuilder.Entity(entity => + { + entity.Property(p => p.Name).IsRequired().HasMaxLength(100); + entity.HasIndex(p => p.Name); + entity.Property(p => p.Description).HasMaxLength(500); + entity.Property(p => p.Icon).IsUnicode(false).HasMaxLength(256); + entity.Property(p => p.BuyingPrice).HasPrecision(18, 2); + entity.Property(p => p.SellingPrice).HasPrecision(18, 2); + entity.HasOne(p => p.Parent) + .WithMany(p => p.Children) + .OnDelete(DeleteBehavior.Restrict); + entity.ToTable("AppProducts"); + }); } } diff --git a/src/Services/Product/e2e-smoke.sh b/src/Services/Product/e2e-smoke.sh new file mode 100755 index 0000000..7f4693d --- /dev/null +++ b/src/Services/Product/e2e-smoke.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# E2E smoke test for the Product microservice, exercised THROUGH the API Gateway. +# +# Asserts: +# 1. GET /api/products/healthz -> 200 (health is open, no token required) +# 2. GET /api/products -> 401 (every resource endpoint requires a JWT) +# +# Exits non-zero on the first failed assertion. +# +# Prereq: bring up only the services this gate needs, e.g. +# docker compose -f src/docker-compose.yml up -d --build postgres product-service api-gateway +# +set -euo pipefail + +GATEWAY_URL="${GATEWAY_URL:-http://localhost:5000}" +HEALTH_URL="${GATEWAY_URL}/api/products/healthz" +PRODUCTS_URL="${GATEWAY_URL}/api/products" +MAX_WAIT="${MAX_WAIT:-90}" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +http_status() { + curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1" +} + +echo "==> Waiting for gateway health at ${HEALTH_URL} (up to ${MAX_WAIT}s)..." +deadline=$(( $(date +%s) + MAX_WAIT )) +status=000 +while [ "$(date +%s)" -lt "$deadline" ]; do + status="$(http_status "${HEALTH_URL}" || echo 000)" + if [ "$status" = "200" ]; then + break + fi + sleep 3 +done + +echo "==> [1/2] Health check (expect 200): ${HEALTH_URL}" +[ "$status" = "200" ] || fail "health check returned ${status}, expected 200" +echo " OK (200)" + +echo "==> [2/2] Unauthenticated products (expect 401): ${PRODUCTS_URL}" +unauth_status="$(http_status "${PRODUCTS_URL}" || echo 000)" +[ "$unauth_status" = "401" ] || fail "unauthenticated GET ${PRODUCTS_URL} returned ${unauth_status}, expected 401" +echo " OK (401)" + +echo "==> Product service E2E smoke PASSED" From f2b9b9b7fc9c4d995e7e3f993ea8b65fc017e3e9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:44:49 +0000 Subject: [PATCH 2/4] Use null-conditional category access in Update to avoid 500 on orphaned category --- .../Product/Product.API/Controllers/ProductController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Services/Product/Product.API/Controllers/ProductController.cs b/src/Services/Product/Product.API/Controllers/ProductController.cs index a6fc74d..9b84f5b 100644 --- a/src/Services/Product/Product.API/Controllers/ProductController.cs +++ b/src/Services/Product/Product.API/Controllers/ProductController.cs @@ -116,7 +116,7 @@ public async Task Update(int id, [FromBody] ProductVM model) product.UpdatedDate = DateTime.UtcNow; if (!string.IsNullOrWhiteSpace(model.ProductCategoryName) && - model.ProductCategoryName != product.ProductCategory.Name) + model.ProductCategoryName != product.ProductCategory?.Name) { product.ProductCategory = await ResolveCategoryAsync(model.ProductCategoryName); } From b42ecdba711c88c5f58dfd9941f0dcf1969d8731 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:54:28 +0000 Subject: [PATCH 3/4] Make Update category handling consistent with Create (always resolve, default Uncategorized) --- .../Product/Product.API/Controllers/ProductController.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Services/Product/Product.API/Controllers/ProductController.cs b/src/Services/Product/Product.API/Controllers/ProductController.cs index 9b84f5b..823fe8b 100644 --- a/src/Services/Product/Product.API/Controllers/ProductController.cs +++ b/src/Services/Product/Product.API/Controllers/ProductController.cs @@ -115,10 +115,10 @@ public async Task Update(int id, [FromBody] ProductVM model) product.IsDiscontinued = model.IsDiscontinued; product.UpdatedDate = DateTime.UtcNow; - if (!string.IsNullOrWhiteSpace(model.ProductCategoryName) && - model.ProductCategoryName != product.ProductCategory?.Name) + var resolvedCategory = await ResolveCategoryAsync(model.ProductCategoryName); + if (resolvedCategory.Name != product.ProductCategory?.Name) { - product.ProductCategory = await ResolveCategoryAsync(model.ProductCategoryName); + product.ProductCategory = resolvedCategory; } await _db.SaveChangesAsync(); From a8724fb77fa91000cf60edebc28dbbecaa4d1394 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:04:11 +0000 Subject: [PATCH 4/4] Harden Delete (409 on products with children) and emit gateway-relative Location on Create --- .../Product/Product.API/Controllers/ProductController.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Services/Product/Product.API/Controllers/ProductController.cs b/src/Services/Product/Product.API/Controllers/ProductController.cs index 823fe8b..7c8654a 100644 --- a/src/Services/Product/Product.API/Controllers/ProductController.cs +++ b/src/Services/Product/Product.API/Controllers/ProductController.cs @@ -82,7 +82,8 @@ public async Task Create([FromBody] ProductVM model) _db.Products.Add(product); await _db.SaveChangesAsync(); - return CreatedAtAction(nameof(GetById), new { id = product.Id }, MapToVM(product)); + // Location reflects the public gateway path; the service itself receives /api/products stripped. + return Created($"/api/products/{product.Id}", MapToVM(product)); } // External: PUT /api/products/{id} -> PUT /{id} @@ -130,12 +131,18 @@ public async Task Update(int id, [FromBody] ProductVM model) [HttpDelete("/{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task Delete(int id) { var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == id); if (product == null) return NotFound(id); + // Parent->Children uses DeleteBehavior.Restrict; deleting a parent with children + // would raise an FK violation, so reject it explicitly. + if (await _db.Products.AnyAsync(p => p.ParentId == id)) + return Conflict($"Product {id} has child products and cannot be deleted."); + _db.Products.Remove(product); await _db.SaveChangesAsync();