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
261 changes: 247 additions & 14 deletions src/Microservices.sln

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,29 +1,107 @@
using Customer.API.Mapping;
using Customer.API.ViewModels;
using Customer.Domain.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace Customer.API.Controllers;

/// <summary>
/// Served both at the service root (the gateway's /api/customers route strips
/// its prefix before forwarding) and at /api/customers for direct calls.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Route("/")]
[Route("api/customers")]
public class CustomerController : ControllerBase
{
private readonly ICustomerRepository _repository;
private readonly ILogger<CustomerController> _logger;

public CustomerController(ILogger<CustomerController> logger)
public CustomerController(ICustomerRepository repository, ILogger<CustomerController> logger)
{
_repository = repository;
_logger = logger;
}

[HttpGet]
public IActionResult GetAll()
public async Task<ActionResult<IEnumerable<CustomerVM>>> GetAll()
{
// TODO: Implement — migrate logic from monolith's CustomerController
return Ok(new { service = "Customer", status = "scaffold" });
var customers = await _repository.GetAllCustomersDataAsync();
return Ok(CustomerMapper.ToViewModels(customers));
}

[HttpGet("{id}")]
public IActionResult GetById(int id)
[HttpGet("top-active/{count:int}")]
public async Task<ActionResult<IEnumerable<CustomerVM>>> GetTopActiveCustomers(int count)
{
// TODO: Implement — migrate logic from monolith
return Ok(new { service = "Customer", id });
if (count <= 0)
return BadRequest("Count must be greater than zero");

var customers = await _repository.GetTopActiveCustomersAsync(count);
return Ok(CustomerMapper.ToViewModels(customers));
}

[HttpGet("{id:int}")]
public async Task<ActionResult<CustomerVM>> GetById(int id)
{
var customer = await _repository.GetByIdAsync(id);
if (customer is null)
return NotFound();

return Ok(CustomerMapper.ToViewModel(customer));
}

[HttpPost]
public async Task<ActionResult<CustomerVM>> Post([FromBody] CustomerVM model)
{
if (Validate(model) is { } error)
return BadRequest(error);

var created = await _repository.AddAsync(CustomerMapper.ToEntity(model));
_logger.LogInformation("Created customer {CustomerId}", created.Id);

return CreatedAtAction(nameof(GetById), new { id = created.Id }, CustomerMapper.ToViewModel(created));
}

[HttpPut("{id:int}")]
public async Task<ActionResult<CustomerVM>> Put(int id, [FromBody] CustomerVM model)
{
if (Validate(model) is { } error)
return BadRequest(error);

var updated = await _repository.UpdateAsync(id, CustomerMapper.ToEntity(model));
if (updated is null)
return NotFound();

return Ok(CustomerMapper.ToViewModel(updated));
}

[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var deleted = await _repository.DeleteAsync(id);
if (!deleted)
return NotFound();

return NoContent();
}

/// <summary>
/// Mirrors the monolith's CustomerViewModelValidator rules.
/// </summary>
private static string? Validate(CustomerVM? model)
{
if (model is null)
return "Customer payload cannot be empty";

if (string.IsNullOrWhiteSpace(model.Name))
return "Customer name cannot be empty";

if (string.IsNullOrWhiteSpace(model.Gender))
return "Gender cannot be empty";

if (CustomerMapper.ParseGender(model.Gender) is null)
return $"'{model.Gender}' is not a valid gender";

return null;
}
}
4 changes: 2 additions & 2 deletions src/Services/Customer/Customer.API/Customer.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
<ItemGroup>
<ProjectReference Include="..\Customer.Domain\Customer.Domain.csproj" />
<ProjectReference Include="..\Customer.Infrastructure\Customer.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.EntityFrameworkCore.Design" Version="10.*" />
Expand Down
44 changes: 44 additions & 0 deletions src/Services/Customer/Customer.API/Mapping/CustomerMapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using Customer.API.ViewModels;
using Customer.Domain.Entities;
using CustomerEntity = Customer.Domain.Entities.Customer;

namespace Customer.API.Mapping;

public static class CustomerMapper
{
public static CustomerVM ToViewModel(CustomerEntity customer) => new()
{
Id = customer.Id,
Name = customer.Name,
Email = customer.Email,
PhoneNumber = customer.PhoneNumber,
Address = customer.Address,
City = customer.City,
Gender = customer.Gender.ToString(),
Orders = customer.OrderRefs
.OrderBy(r => r.OrderId)
.Select(r => new OrderVM { Id = r.OrderId })
.ToList()
};

public static IEnumerable<CustomerVM> ToViewModels(IEnumerable<CustomerEntity> customers) =>
customers.Select(ToViewModel);

public static CustomerEntity ToEntity(CustomerVM vm) => new()
{
Name = vm.Name ?? string.Empty,
Email = vm.Email ?? string.Empty,
PhoneNumber = vm.PhoneNumber,
Address = vm.Address,
City = vm.City,
Gender = ParseGender(vm.Gender) ?? Gender.None
};

public static Gender? ParseGender(string? gender)
{
if (string.IsNullOrWhiteSpace(gender))
return null;

return Enum.TryParse<Gender>(gender, ignoreCase: true, out var parsed) ? parsed : null;
}
}
33 changes: 33 additions & 0 deletions src/Services/Customer/Customer.API/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Customer.Domain.Interfaces;
using Customer.Infrastructure.Data;
using Customer.Infrastructure.Repositories;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
Expand All @@ -11,8 +13,12 @@
builder.Services.AddDbContext<CustomerDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();

var app = builder.Build();

await MigrateAndSeedAsync(app);

if (app.Environment.IsDevelopment())
{
app.UseSwagger();
Expand All @@ -23,3 +29,30 @@
app.MapHealthChecks("/healthz");

app.Run();

static async Task MigrateAndSeedAsync(WebApplication app)
{
var logger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Startup");

// Postgres is started alongside this service by compose and may not accept
// connections yet on the first attempts.
const int maxAttempts = 10;
for (var attempt = 1; ; attempt++)
{
try
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<CustomerDbContext>();
await db.Database.MigrateAsync();
await CustomerDbSeeder.SeedAsync(db);
return;
}
catch (Exception ex) when (attempt < maxAttempts)
{
logger.LogWarning(ex, "Database not ready (attempt {Attempt}/{MaxAttempts}); retrying", attempt, maxAttempts);
await Task.Delay(TimeSpan.FromSeconds(3));
}
}
}

public partial class Program;
18 changes: 18 additions & 0 deletions src/Services/Customer/Customer.API/ViewModels/CustomerVM.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Customer.API.ViewModels;

/// <summary>
/// Wire shape carried over unchanged from the monolith's CustomerVM so existing
/// clients can be repointed at the gateway without changes.
/// </summary>
public class CustomerVM
{
public int Id { get; set; }
public string? Name { get; set; }
public string? Email { get; set; }
public string? PhoneNumber { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? Gender { get; set; }

public ICollection<OrderVM>? Orders { get; set; }
}
13 changes: 13 additions & 0 deletions src/Services/Customer/Customer.API/ViewModels/OrderVM.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Customer.API.ViewModels;

/// <summary>
/// Order projection of the monolith's OrderVM. The Customer service only owns
/// the order identifier; Discount and Comments are owned by the Order service
/// and are left at their defaults here.
/// </summary>
public class OrderVM
{
public int Id { get; set; }
public decimal Discount { get; set; }
public string? Comments { get; set; }
}
Empty file.
23 changes: 23 additions & 0 deletions src/Services/Customer/Customer.Domain/Entities/Customer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Customer.Domain.Entities;

public class Customer
{
public int Id { get; set; }
public required string Name { get; set; }
public required string Email { get; set; }
public string? PhoneNumber { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public Gender Gender { get; set; }

public string? CreatedBy { get; set; }
public string? UpdatedBy { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; }

/// <summary>
/// Orders placed by this customer, referenced by identifier only. Order data
/// itself is owned by the Order service.
/// </summary>
public ICollection<CustomerOrderRef> OrderRefs { get; } = [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Customer.Domain.Entities;

public class CustomerOrderRef
{
public int Id { get; set; }
public int CustomerId { get; set; }
public int OrderId { get; set; }
}
8 changes: 8 additions & 0 deletions src/Services/Customer/Customer.Domain/Entities/Gender.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Customer.Domain.Entities;

public enum Gender
{
None,
Female,
Male
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using CustomerEntity = Customer.Domain.Entities.Customer;

namespace Customer.Domain.Interfaces;

public interface ICustomerRepository
{
Task<IReadOnlyList<CustomerEntity>> GetAllCustomersDataAsync();
Task<IReadOnlyList<CustomerEntity>> GetTopActiveCustomersAsync(int count);
Task<CustomerEntity?> GetByIdAsync(int id);
Task<CustomerEntity> AddAsync(CustomerEntity customer);
Task<CustomerEntity?> UpdateAsync(int id, CustomerEntity customer);
Task<bool> DeleteAsync(int id);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Customer.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using CustomerEntity = Customer.Domain.Entities.Customer;

namespace Customer.Infrastructure.Data;

Expand All @@ -8,9 +10,35 @@ public CustomerDbContext(DbContextOptions<CustomerDbContext> options) : base(opt
{
}

public DbSet<CustomerEntity> Customers => Set<CustomerEntity>();
public DbSet<CustomerOrderRef> CustomerOrderRefs => Set<CustomerOrderRef>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// TODO: Configure entity mappings migrated from monolith

modelBuilder.Entity<CustomerEntity>(entity =>
{
entity.ToTable("Customers");
entity.HasKey(c => c.Id);
entity.Property(c => c.Name).IsRequired().HasMaxLength(100);
entity.HasIndex(c => c.Name);
entity.Property(c => c.Email).HasMaxLength(100);
entity.Property(c => c.PhoneNumber).IsUnicode(false).HasMaxLength(30);
entity.Property(c => c.City).HasMaxLength(50);
entity.Property(c => c.CreatedBy).HasMaxLength(40);
entity.Property(c => c.UpdatedBy).HasMaxLength(40);
entity.HasMany(c => c.OrderRefs)
.WithOne()
.HasForeignKey(r => r.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
});

modelBuilder.Entity<CustomerOrderRef>(entity =>
{
entity.ToTable("CustomerOrderRefs");
entity.HasKey(r => r.Id);
entity.HasIndex(r => r.OrderId).IsUnique();
});
}
}
Loading