Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 176 additions & 10 deletions src/Services/Product/Product.API/Controllers/ProductController.cs
Original file line number Diff line number Diff line change
@@ -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<ProductController> _logger;

public ProductController(ILogger<ProductController> logger)
public ProductController(ProductDbContext db, ILogger<ProductController> logger)
{
_db = db;
_logger = logger;
}

[HttpGet]
public IActionResult GetAll()
// External: GET /api/products -> gateway strips prefix -> GET /
[HttpGet("/")]
[ProducesResponseType(typeof(IEnumerable<ProductVM>), StatusCodes.Status200OK)]
Comment on lines +24 to +25

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Absolute route templates bypass controller-level route prefix convention

All action methods use absolute route templates starting with / (e.g., [HttpGet("/")], [HttpGet("/{id:int}")]). This is a deliberate departure from the [Route("api/[controller]")] convention used by every other controller in the solution (src/Services/Identity/Identity.API/Controllers/IdentityController.cs:6, src/Services/Customer/Customer.API/Controllers/CustomerController.cs:6, src/Services/Order/Order.API/Controllers/OrderController.cs:6). The design works correctly because the YARP gateway strips /api/products via PathRemovePrefix (src/ApiGateway/appsettings.json:29), so the service receives root-relative paths. However, this means the Product service's Swagger UI will show routes at /, /{id}, /categories rather than under a prefix — making direct-to-service testing less intuitive and establishing a different pattern from the other microservices that will need to be reconciled when they are similarly migrated.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional and pinned by the carve-out spec. The gateway route Match /api/products/{**catch-all} + PathRemovePrefix /api/products means the service receives the prefix stripped, so external GET /api/products arrives as GET /. Absolute routes ([HttpGet("/")], [HttpGet("/{id:int}")]) are required to map the resource controller to the service root. The other services still carry the placeholder [Route("api/[controller]")] from the scaffold and haven't been wired to their gateway contracts yet; reconciling that convention is part of their own carve-outs. Leaving as-is for this PR.

public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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)
{
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
product.ProductCategory = resolvedCategory;
}
Comment on lines +119 to +123

@devin-ai-integration devin-ai-integration Bot Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Update with null category name silently reclassifies product to 'Uncategorized'

In the Update action (ProductController.cs:95-128), ResolveCategoryAsync is always called with model.ProductCategoryName. If the API consumer sends a PUT with ProductCategoryName omitted (null), the method defaults to "Uncategorized" (ProductController.cs:170). This follows PUT semantics (full replacement), but it means a consumer intending to update only the product name must also echo back the current category name, or the product will be silently re-categorized. This is consistent with the Create behavior but could surprise API consumers expecting partial-update semantics.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional and, as noted, correct PUT full-replacement semantics — now consistent with Create (this is exactly the consistency that the previous review round asked for). A safer partial-update story (PATCH, or making category-name required on update) is a reasonable product decision but a behavioral/API-surface change beyond this carve-out's scope, so I'm leaving the PUT semantics as-is and flagging it for the requester.


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<IActionResult> 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();
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

// External: GET /api/products/categories -> GET /categories
[HttpGet("/categories")]
[ProducesResponseType(typeof(IEnumerable<string>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetCategories()
{
var categories = await _db.ProductCategories
.AsNoTracking()
.Select(c => c.Name)
.ToListAsync();

categories.Sort(StringComparer.OrdinalIgnoreCase);

return Ok(categories);
}

private async Task<Domain.ProductCategory> 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
};
}
5 changes: 3 additions & 2 deletions src/Services/Product/Product.API/Product.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
<ItemGroup>
<ProjectReference Include="..\Product.Domain\Product.Domain.csproj" />
<ProjectReference Include="..\Product.Infrastructure\Product.Infrastructure.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Infrastructure\Shared.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\..\Shared\Shared.Infrastructure\Shared.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.*" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.*" />
</ItemGroup>
Expand Down
70 changes: 68 additions & 2 deletions src/Services/Product/Product.API/Program.cs
Original file line number Diff line number Diff line change
@@ -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<string>() });
});
builder.Services.AddHealthChecks();

builder.Services.AddDbContext<ProductDbContext>(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())
Expand All @@ -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<ProductDbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();

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));
}
}
}
Comment on lines +71 to +89

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Database initialization blocks startup — health checks unavailable during retry window

The EnsureCreated() retry loop at Program.cs:71-89 runs synchronously (using Thread.Sleep) after the middleware pipeline is built but before app.Run(). This means the HTTP server is not listening during the up-to-30-second retry window. Container orchestrators or the gateway's health probes hitting /healthz will get connection refused during this period. The smoke test at e2e-smoke.sh:19 accommodates this with a 90-second wait, but in Kubernetes with tighter liveness probe deadlines this could cause unnecessary restarts. Moving DB initialization to an IHostedService or IStartupFilter (or using await db.Database.EnsureCreatedAsync() with Task.Delay) would allow the server to start accepting health checks immediately.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the conventional EF Core startup pattern (run EnsureCreated/Migrate before app.Run()), and it's bounded: the loop only sleeps while Postgres is unreachable and breaks immediately once it's ready (typically the first attempt). The retry exists purely to absorb compose startup ordering. The validation gate confirms /healthz returns 200 once up, and the e2e script's wait accommodates the brief window. A K8s deployment would normally pair this with a startupProbe/initialDelaySeconds rather than relying on liveness during init. Moving to an IHostedService/IStartupFilter is a reasonable hardening but out of scope for this carve-out; leaving as-is.


app.Run();
15 changes: 15 additions & 0 deletions src/Services/Product/Product.API/ViewModels/ProductVM.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
5 changes: 5 additions & 0 deletions src/Services/Product/Product.API/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
18 changes: 18 additions & 0 deletions src/Services/Product/Product.Domain/BaseEntity.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
21 changes: 21 additions & 0 deletions src/Services/Product/Product.Domain/Product.cs
Original file line number Diff line number Diff line change
@@ -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<Product> Children { get; } = [];
}
10 changes: 10 additions & 0 deletions src/Services/Product/Product.Domain/ProductCategory.cs
Original file line number Diff line number Diff line change
@@ -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<Product> Products { get; } = [];
}
Loading