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
139 changes: 129 additions & 10 deletions src/Services/Order/Order.API/Controllers/OrderController.cs
Original file line number Diff line number Diff line change
@@ -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<OrderController> _logger;

public OrderController(ILogger<OrderController> logger)
public OrderController(OrderDbContext db, ILogger<OrderController> logger)
{
_db = db;
_logger = logger;
}

[HttpGet]
public IActionResult GetAll()
[HttpGet("/")]
[ProducesResponseType(typeof(IEnumerable<OrderVM>), StatusCodes.Status200OK)]
public async Task<IActionResult> 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();
Comment on lines +33 to +36

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.

🚩 GetAll loads entire orders table without pagination

The GetAll endpoint (OrderController.cs:33-36) executes ToListAsync() over the full Orders table including all OrderDetails. Unlike the Notification service which accepts page and pageSize query parameters (NotificationController.cs:27), this endpoint has no pagination. With a large dataset this will cause high memory usage and slow responses. This is a design concern worth addressing before production use.

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.

Valid design concern, but out of scope for this carve-out task, which pins the resource controller surface (GET / → list). No functional bug here. Pagination (matching Notification's page/pageSize) would be a sensible follow-up before production; leaving it out to keep this PR focused on the decoupling + auth + persistence gate.


return Ok(orders.Select(o => o.ToViewModel()));
}

[HttpGet("/{id:int}")]
[ProducesResponseType(typeof(OrderVM), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<IActionResult> 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);

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.

🚩 Created response Location header will be wrong for gateway clients

The Create action returns Created($"/{order.Id}", result) at OrderController.cs:79, which sets the Location header to e.g. /{id}. Since the service sits behind a YARP gateway that strips /api/orders, clients accessing POST /api/orders will receive Location: /42 — resolving to http://gateway:5000/42 rather than http://gateway:5000/api/orders/42. This is a known trade-off of the prefix-stripping gateway pattern and cannot be easily fixed without the service knowing its external prefix. Other services (e.g. Notification at NotificationController.cs:101-104) similarly hardcode internal paths in responses. Consider whether this matters for your API consumers.

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 the finding notes this is the known prefix-stripping gateway trade-off (same as Notification's hardcoded internal paths). The task pins absolute service-root routing with the gateway stripping /api/orders, and the service has no knowledge of its external prefix. Leaving as-is to stay consistent with sibling services and within task scope; happy to revisit if a configurable external-prefix convention is adopted across services.

}

[HttpGet("{id}")]
public IActionResult GetById(int id)
[HttpPut("/{id:int}")]
[ProducesResponseType(typeof(OrderVM), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<IActionResult> 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;
}
}
2 changes: 0 additions & 2 deletions src/Services/Order/Order.API/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
50 changes: 50 additions & 0 deletions src/Services/Order/Order.API/Mapping/OrderMappingExtensions.cs
Original file line number Diff line number Diff line change
@@ -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
};
}
3 changes: 1 addition & 2 deletions src/Services/Order/Order.API/Order.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@
<ItemGroup>
<ProjectReference Include="..\Order.Domain\Order.Domain.csproj" />
<ProjectReference Include="..\Order.Infrastructure\Order.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
76 changes: 74 additions & 2 deletions src/Services/Order/Order.API/Program.cs
Original file line number Diff line number Diff line change
@@ -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<string>() });
});
builder.Services.AddHealthChecks();

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

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");

Expand Down
30 changes: 30 additions & 0 deletions src/Services/Order/Order.API/ViewModels/OrderVM.cs
Original file line number Diff line number Diff line change
@@ -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<OrderDetailVM> 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; }
}
5 changes: 5 additions & 0 deletions src/Services/Order/Order.API/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Empty file.
18 changes: 18 additions & 0 deletions src/Services/Order/Order.Domain/Entities/BaseEntity.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading