diff --git a/src/Services/Product/Product.API/Controllers/ProductController.cs b/src/Services/Product/Product.API/Controllers/ProductController.cs index 710c58f..7c8654a 100644 --- a/src/Services/Product/Product.API/Controllers/ProductController.cs +++ b/src/Services/Product/Product.API/Controllers/ProductController.cs @@ -1,29 +1,195 @@ +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)); } - [HttpGet("{id}")] - public IActionResult GetById(int id) + // External: POST /api/products -> POST / + [HttpPost("/")] + [ProducesResponseType(typeof(ProductVM), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Create([FromBody] ProductVM model) { - // TODO: Implement — migrate logic from monolith - return Ok(new { service = "Product", id }); + 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(); + + // 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} + [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; + + var resolvedCategory = await ResolveCategoryAsync(model.ProductCategoryName); + if (resolvedCategory.Name != product.ProductCategory?.Name) + { + product.ProductCategory = resolvedCategory; + } + + await _db.SaveChangesAsync(); + + return Ok(MapToVM(product)); + } + + // External: DELETE /api/products/{id} -> DELETE /{id} + [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(); + + 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"