diff --git a/src/Services/Order/Order.API/Controllers/OrderController.cs b/src/Services/Order/Order.API/Controllers/OrderController.cs index 1621bae..6194ee4 100644 --- a/src/Services/Order/Order.API/Controllers/OrderController.cs +++ b/src/Services/Order/Order.API/Controllers/OrderController.cs @@ -1,29 +1,148 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Order.API.Mapping; +using Order.API.ViewModels; +using Order.Infrastructure.Data; namespace Order.API.Controllers; +// Resource controller mounted at the service ROOT. The API gateway strips the +// "/api/orders" prefix, so external "GET /api/orders" -> "GET /" here and +// "GET /api/orders/{id}" -> "GET /{id}". Absolute routes ("/...") are used so the +// service-internal paths match the gateway-stripped paths exactly. [ApiController] -[Route("api/[controller]")] +[Authorize] +[Produces("application/json")] +[ProducesResponseType(StatusCodes.Status401Unauthorized)] public class OrderController : ControllerBase { + private readonly OrderDbContext _db; private readonly ILogger _logger; - public OrderController(ILogger logger) + public OrderController(OrderDbContext db, ILogger logger) { + _db = db; _logger = logger; } - [HttpGet] - public IActionResult GetAll() + [HttpGet("/")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetAll() { - // TODO: Implement — migrate logic from monolith's OrderController - return Ok(new { service = "Order", status = "scaffold" }); + var orders = await _db.Orders + .AsNoTracking() + .Include(o => o.OrderDetails) + .ToListAsync(); + + return Ok(orders.Select(o => o.ToViewModel())); + } + + [HttpGet("/{id:int}")] + [ProducesResponseType(typeof(OrderVM), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById(int id) + { + var order = await _db.Orders + .AsNoTracking() + .Include(o => o.OrderDetails) + .FirstOrDefaultAsync(o => o.Id == id); + + if (order is null) + return NotFound(id); + + return Ok(order.ToViewModel()); + } + + [HttpPost("/")] + [ProducesResponseType(typeof(OrderVM), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Create([FromBody] OrderVM model) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var order = model.ToEntity(); + foreach (var line in model.OrderDetails) + order.OrderDetails.Add(line.ToEntity()); + + var now = DateTime.UtcNow; + var userName = User.Identity?.Name; + Stamp(order, now, userName, isNew: true); + foreach (var detail in order.OrderDetails) + Stamp(detail, now, userName, isNew: true); + + _db.Orders.Add(order); + await _db.SaveChangesAsync(); + + var result = order.ToViewModel(); + return Created($"/{order.Id}", result); } - [HttpGet("{id}")] - public IActionResult GetById(int id) + [HttpPut("/{id:int}")] + [ProducesResponseType(typeof(OrderVM), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Update(int id, [FromBody] OrderVM model) { - // TODO: Implement — migrate logic from monolith - return Ok(new { service = "Order", id }); + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var order = await _db.Orders + .Include(o => o.OrderDetails) + .FirstOrDefaultAsync(o => o.Id == id); + + if (order is null) + return NotFound(id); + + model.ApplyTo(order); + + var now = DateTime.UtcNow; + var userName = User.Identity?.Name; + + _db.OrderDetails.RemoveRange(order.OrderDetails); + order.OrderDetails.Clear(); + foreach (var line in model.OrderDetails) + { + var detail = line.ToEntity(); + Stamp(detail, now, userName, isNew: true); + order.OrderDetails.Add(detail); + } + + Stamp(order, now, userName, isNew: false); + + await _db.SaveChangesAsync(); + + return Ok(order.ToViewModel()); + } + + [HttpDelete("/{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + var order = await _db.Orders + .Include(o => o.OrderDetails) + .FirstOrDefaultAsync(o => o.Id == id); + + if (order is null) + return NotFound(id); + + _db.Orders.Remove(order); + await _db.SaveChangesAsync(); + + return NoContent(); + } + + private static void Stamp(Order.Domain.Entities.BaseEntity entity, DateTime now, string? userName, bool isNew) + { + if (isNew) + { + entity.CreatedDate = now; + entity.CreatedBy = userName; + } + + entity.UpdatedDate = now; + entity.UpdatedBy = userName; } } diff --git a/src/Services/Order/Order.API/Dockerfile b/src/Services/Order/Order.API/Dockerfile index eb9131b..5fc0f17 100644 --- a/src/Services/Order/Order.API/Dockerfile +++ b/src/Services/Order/Order.API/Dockerfile @@ -7,8 +7,6 @@ WORKDIR /src COPY ["Services/Order/Order.API/Order.API.csproj", "Services/Order/Order.API/"] COPY ["Services/Order/Order.Domain/Order.Domain.csproj", "Services/Order/Order.Domain/"] COPY ["Services/Order/Order.Infrastructure/Order.Infrastructure.csproj", "Services/Order/Order.Infrastructure/"] -COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"] -COPY ["Shared/Shared.Infrastructure/Shared.Infrastructure.csproj", "Shared/Shared.Infrastructure/"] RUN dotnet restore "Services/Order/Order.API/Order.API.csproj" COPY . . WORKDIR "/src/Services/Order/Order.API" diff --git a/src/Services/Order/Order.API/Mapping/OrderMappingExtensions.cs b/src/Services/Order/Order.API/Mapping/OrderMappingExtensions.cs new file mode 100644 index 0000000..fbdda29 --- /dev/null +++ b/src/Services/Order/Order.API/Mapping/OrderMappingExtensions.cs @@ -0,0 +1,50 @@ +using Order.API.ViewModels; +using OrderEntity = Order.Domain.Entities.Order; +using OrderDetailEntity = Order.Domain.Entities.OrderDetail; + +namespace Order.API.Mapping; + +public static class OrderMappingExtensions +{ + public static OrderVM ToViewModel(this OrderEntity entity) => new() + { + Id = entity.Id, + Discount = entity.Discount, + Comments = entity.Comments, + CustomerId = entity.CustomerId, + OrderDetails = entity.OrderDetails + .Select(d => d.ToViewModel()) + .ToList() + }; + + public static OrderDetailVM ToViewModel(this OrderDetailEntity entity) => new() + { + Id = entity.Id, + ProductId = entity.ProductId, + Quantity = entity.Quantity, + UnitPrice = entity.UnitPrice, + Discount = entity.Discount + }; + + public static OrderEntity ToEntity(this OrderVM vm) => new() + { + Discount = vm.Discount, + Comments = vm.Comments, + CustomerId = vm.CustomerId + }; + + public static void ApplyTo(this OrderVM vm, OrderEntity entity) + { + entity.Discount = vm.Discount; + entity.Comments = vm.Comments; + entity.CustomerId = vm.CustomerId; + } + + public static OrderDetailEntity ToEntity(this OrderDetailVM vm) => new() + { + ProductId = vm.ProductId, + Quantity = vm.Quantity, + UnitPrice = vm.UnitPrice, + Discount = vm.Discount + }; +} diff --git a/src/Services/Order/Order.API/Order.API.csproj b/src/Services/Order/Order.API/Order.API.csproj index 54aad4b..4841a19 100644 --- a/src/Services/Order/Order.API/Order.API.csproj +++ b/src/Services/Order/Order.API/Order.API.csproj @@ -7,10 +7,9 @@ - - + diff --git a/src/Services/Order/Order.API/Program.cs b/src/Services/Order/Order.API/Program.cs index 4512675..cde6c00 100644 --- a/src/Services/Order/Order.API/Program.cs +++ b/src/Services/Order/Order.API/Program.cs @@ -1,24 +1,96 @@ -using Order.Infrastructure.Data; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using Order.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 = "Order.API", Version = "v1" }); + + var scheme = new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Enter the JWT bearer token issued by the Identity service.", + 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 exactly. +var jwt = builder.Configuration.GetSection("Jwt"); +var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt["Key"]!)); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.MapInboundClaims = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwt["Issuer"], + ValidAudience = jwt["Audience"], + IssuerSigningKey = signingKey, + NameClaimType = "name", + RoleClaimType = "role" + }; + }); + +builder.Services.AddAuthorization(); + var app = builder.Build(); +// Create the schema on boot. The database container may not be ready yet +// (depends_on does not wait for readiness), so retry transient connection failures. +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService>(); + + const int maxAttempts = 12; + for (var attempt = 1; ; attempt++) + { + try + { + db.Database.EnsureCreated(); + break; + } + catch (Exception ex) when (attempt < maxAttempts) + { + logger.LogWarning(ex, "Database not ready (attempt {Attempt}/{Max}); retrying in 5s", attempt, maxAttempts); + Thread.Sleep(TimeSpan.FromSeconds(5)); + } + } +} + if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } +app.UseAuthentication(); +app.UseAuthorization(); + app.MapControllers(); app.MapHealthChecks("/healthz"); diff --git a/src/Services/Order/Order.API/ViewModels/OrderVM.cs b/src/Services/Order/Order.API/ViewModels/OrderVM.cs new file mode 100644 index 0000000..c729e32 --- /dev/null +++ b/src/Services/Order/Order.API/ViewModels/OrderVM.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace Order.API.ViewModels; + +public class OrderVM +{ + public int Id { get; set; } + + public decimal Discount { get; set; } + + [StringLength(500)] + public string? Comments { get; set; } + + public int CustomerId { get; set; } + + public List OrderDetails { get; set; } = []; +} + +public class OrderDetailVM +{ + public int Id { get; set; } + + public int ProductId { get; set; } + + public int Quantity { get; set; } + + public decimal UnitPrice { get; set; } + + public decimal Discount { get; set; } +} diff --git a/src/Services/Order/Order.API/appsettings.json b/src/Services/Order/Order.API/appsettings.json index d830094..494d491 100644 --- a/src/Services/Order/Order.API/appsettings.json +++ b/src/Services/Order/Order.API/appsettings.json @@ -7,5 +7,10 @@ }, "ConnectionStrings": { "DefaultConnection": "Host=localhost;Database=orderdb;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/Order/Order.Domain/Entities/.gitkeep b/src/Services/Order/Order.Domain/Entities/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Services/Order/Order.Domain/Entities/BaseEntity.cs b/src/Services/Order/Order.Domain/Entities/BaseEntity.cs new file mode 100644 index 0000000..d927ea6 --- /dev/null +++ b/src/Services/Order/Order.Domain/Entities/BaseEntity.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Order.Domain.Entities; + +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/Order/Order.Domain/Entities/Order.cs b/src/Services/Order/Order.Domain/Entities/Order.cs new file mode 100644 index 0000000..54b9d5d --- /dev/null +++ b/src/Services/Order/Order.Domain/Entities/Order.cs @@ -0,0 +1,14 @@ +namespace Order.Domain.Entities; + +// Order aggregate root for the Order bounded context. +// Other bounded contexts are referenced by id only (e.g. CustomerId). The navigations and +// staff-id fields that the monolith carried for those contexts are intentionally dropped here. +public class Order : BaseEntity +{ + public decimal Discount { get; set; } + public string? Comments { get; set; } + + public int CustomerId { get; set; } + + public ICollection OrderDetails { get; } = []; +} diff --git a/src/Services/Order/Order.Domain/Entities/OrderDetail.cs b/src/Services/Order/Order.Domain/Entities/OrderDetail.cs new file mode 100644 index 0000000..3073d47 --- /dev/null +++ b/src/Services/Order/Order.Domain/Entities/OrderDetail.cs @@ -0,0 +1,15 @@ +namespace Order.Domain.Entities; + +// Line item within the Order bounded context. +// The catalog context is referenced by id only (ProductId); its navigation from the monolith is intentionally dropped. +public class OrderDetail : BaseEntity +{ + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } + public decimal Discount { get; set; } + + public int ProductId { get; set; } + + public int OrderId { get; set; } + public Order? Order { get; set; } +} diff --git a/src/Services/Order/Order.Infrastructure/Data/OrderDbContext.cs b/src/Services/Order/Order.Infrastructure/Data/OrderDbContext.cs index 4f80d89..a45f4e3 100644 --- a/src/Services/Order/Order.Infrastructure/Data/OrderDbContext.cs +++ b/src/Services/Order/Order.Infrastructure/Data/OrderDbContext.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Order.Domain.Entities; namespace Order.Infrastructure.Data; @@ -8,9 +9,30 @@ public OrderDbContext(DbContextOptions options) : base(options) { } + public DbSet Orders => Set(); + + public DbSet OrderDetails => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - // TODO: Configure entity mappings migrated from monolith + + // Ported verbatim from the monolith's ApplicationDbContext for the members kept in this context. + modelBuilder.Entity().Property(o => o.Comments).HasMaxLength(500); + modelBuilder.Entity().Property(o => o.Discount).HasPrecision(18, 2); + modelBuilder.Entity().ToTable("AppOrders"); + + modelBuilder.Entity().Property(d => d.UnitPrice).HasPrecision(18, 2); + modelBuilder.Entity().Property(d => d.Discount).HasPrecision(18, 2); + modelBuilder.Entity().ToTable("AppOrderDetails"); + + // Relationship within this bounded context (Order <-> OrderDetail). + // Relationships to other contexts are intentionally NOT configured — that data lives in + // other services and is referenced here by id only (CustomerId / ProductId). + modelBuilder.Entity() + .HasOne(d => d.Order) + .WithMany(o => o.OrderDetails) + .HasForeignKey(d => d.OrderId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/Services/Order/e2e-smoke.sh b/src/Services/Order/e2e-smoke.sh new file mode 100755 index 0000000..266d7d5 --- /dev/null +++ b/src/Services/Order/e2e-smoke.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# +# E2E smoke test for the Order service, exercised THROUGH the API gateway. +# Asserts: +# 1. GET /api/orders/healthz -> 200 (service is up, health route reachable) +# 2. GET /api/orders (no JWT) -> 401 (every endpoint is [Authorize]-protected) +# +# Exits non-zero on any failure so it can gate CI / compose-based verification. +# +# Usage: +# ./e2e-smoke.sh # uses http://localhost:5000 +# GATEWAY_URL=http://host:5000 ./e2e-smoke.sh +set -euo pipefail + +GATEWAY_URL="${GATEWAY_URL:-http://localhost:5000}" +fail=0 + +check() { + local desc="$1" url="$2" expected="$3" + local actual + actual="$(curl -s -o /dev/null -w '%{http_code}' "$url")" + if [[ "$actual" == "$expected" ]]; then + echo "PASS: $desc -> $actual (expected $expected)" + else + echo "FAIL: $desc -> $actual (expected $expected) [$url]" + fail=1 + fi +} + +echo "Order service E2E smoke (gateway: $GATEWAY_URL)" +check "health through gateway" "$GATEWAY_URL/api/orders/healthz" 200 +check "unauthenticated list -> 401" "$GATEWAY_URL/api/orders" 401 + +if [[ "$fail" -ne 0 ]]; then + echo "E2E smoke FAILED" + exit 1 +fi + +echo "E2E smoke PASSED"