Initial code commit.

This commit is contained in:
2026-02-03 10:44:31 +08:00
parent 8927c5ae0e
commit d69fe2cc1f
99 changed files with 10839 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "7.0.10",
"commands": [
"dotnet-ef"
]
}
}
}

View File

@@ -0,0 +1,26 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace Sufi.Demo.PeopleDirectory.UI.Server.Controllers
{
/// <summary>
/// Represent the base controller class.
/// </summary>
/// <typeparam name="T"></typeparam>
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public abstract class BaseApiController<T> : ControllerBase
{
private IMediator? _mediatorInstance;
private ILogger<T>? _loggerInstance;
/// <summary>
/// Gets the mediator for requests/responses.
/// </summary>
protected IMediator Mediator => _mediatorInstance ??= HttpContext.RequestServices.GetRequiredService<IMediator>();
/// <summary>
/// Gets the logger for current controller.
/// </summary>
protected ILogger<T> Logger => _loggerInstance ??= HttpContext.RequestServices.GetRequiredService<ILogger<T>>();
}
}

View File

@@ -0,0 +1,54 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using System.Net.Mime;
using System.Text.Json;
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
namespace Sufi.Demo.PeopleDirectory.UI.Server.Controllers.v1
{
[ApiVersion(1.0)]
public class BeaconController : BaseApiController<BeaconController>
{
[HttpGet]
[HttpPost]
public async Task<IActionResult> Index()
{
var beaconData = new Dictionary<string, string>();
if (Request.Method == "GET")
{
foreach (var param in Request.Query)
{
beaconData[param.Key] = param.Value.ToString();
}
}
else if (Request.Method == "POST")
{
var contentType = Request.ContentType?.ToLower();
if (contentType?.Contains(MediaTypeNames.Application.Json) == true)
{
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var jsonData = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(body)!;
foreach (var kvp in jsonData)
{
beaconData[kvp.Key] = kvp.Value.ToString();
}
}
else if (contentType?.Contains(MediaTypeNames.Application.FormUrlEncoded) == true)
{
var form = await Request.ReadFormAsync();
foreach (var kvp in form)
{
beaconData[kvp.Key] = kvp.Value.ToString();
}
}
}
return Ok();
}
}
}

View File

@@ -0,0 +1,51 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Commands;
using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetAll;
using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetById;
using System.ComponentModel.DataAnnotations;
namespace Sufi.Demo.PeopleDirectory.UI.Server.Controllers.v1
{
/// <summary>
/// A controller for manipulating contact data.
/// </summary>
[ApiVersion(1.0)]
public class ContactsController : BaseApiController<ContactsController>
{
/// <summary>
/// Get all contacts.
/// </summary>
/// <returns></returns>
[HttpGet]
public async Task<IActionResult> GetAll() => Ok(await Mediator.Send(new GetAllContactsQuery()));
/// <summary>
/// Get a contact by id.
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id) => Ok(await Mediator.Send(new GetContactByIdQuery { Id = id }));
/// <summary>
/// Create/Update a contact.
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> Post(AddEditContactCommand request) => Ok(await Mediator.Send(request));
/// <summary>
/// Delete a contact.
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpDelete("{id}")]
public async Task<IActionResult> Delete([Required] int id)
{
var command = new DeleteContactCommand { Id = id };
return Ok(await Mediator.Send(command));
}
}
}

View File

@@ -0,0 +1,28 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
namespace Sufi.Demo.PeopleDirectory.UI.Server.Controllers.v1
{
/// <summary>
/// Provides API endpoints for infrastructure-related operations.
/// </summary>
/// <remarks>This controller is part of the infrastructure layer and includes endpoints for monitoring and diagnostics.</remarks>
[ApiVersion(1.0)]
public class InfraController(
ILogger<InfraController> logger
) : BaseApiController<InfraController>
{
/// <summary>
/// Simulates a ping to check if the server is responsive.
/// </summary>
/// <returns></returns>
[Route("ping")]
[HttpGet]
public IActionResult Ping()
{
logger.LogInformation("InfraController.Ping method called.");
return Ok();
}
}
}

View File

@@ -0,0 +1,21 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
namespace Sufi.Demo.PeopleDirectory.UI.Server.Controllers.v2
{
/// <summary>
/// Provides API endpoints for infrastructure-related operations.
/// </summary>
/// <remarks>This controller is part of the infrastructure layer and includes endpoints for monitoring and diagnostics.</remarks>
[ApiVersion(2.0)]
public class InfraController : BaseApiController<InfraController>
{
/// <summary>
/// Simulates a ping to check if the server is responsive.
/// </summary>
/// <returns></returns>
[Route("ping")]
[HttpGet]
public IActionResult Ping() => Ok("Server is OK");
}
}

View File

@@ -0,0 +1,146 @@
using Asp.Versioning;
using Asp.Versioning.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Text;
using System.Text.Json;
namespace Sufi.Demo.PeopleDirectory.UI.Server.Extensions
{
/// <summary>
/// Extension class.
/// </summary>
public static class ServiceCollectionExtensions
{
internal static void RegisterSwagger(this IServiceCollection services)
{
services.ConfigureOptions<ConfigureSwaggerGenOptions>();
services.AddSwaggerGen(c =>
{
//TODO - Lowercase Swagger Documents
//c.DocumentFilter<LowercaseDocumentFilter>();
//Refer - https://gist.github.com/rafalkasa/01d5e3b265e5aa075678e0adfd54e23f
// include all project's xml comments
var baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
if (!assembly.IsDynamic)
{
var xmlFile = $"{assembly.GetName().Name}.xml";
var xmlPath = Path.Combine(baseDirectory, xmlFile);
if (File.Exists(xmlPath))
{
c.IncludeXmlComments(xmlPath);
}
}
}
c.OperationFilter<SwaggerDefaultValues>();
})
.AddApiVersioning(config =>
{
config.DefaultApiVersion = new ApiVersion(1, 0);
config.AssumeDefaultVersionWhenUnspecified = true;
config.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
}
}
internal class ConfigureSwaggerGenOptions(IApiVersionDescriptionProvider provider) : IConfigureOptions<SwaggerGenOptions>
{
public void Configure(SwaggerGenOptions options)
{
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description));
}
}
private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description)
{
var text = new StringBuilder("This application is for demo purposes by Sufi only.");
var info = new OpenApiInfo
{
Title = "Sufi.Demo.App",
Version = description.ApiVersion.ToString(),
License = new OpenApiLicense
{
Name = "MIT License",
Url = new Uri("https://opensource.org/licenses/MIT")
}
};
if (description.IsDeprecated)
{
text.Append("This Api version has been deprecated.");
}
if (description.SunsetPolicy is SunsetPolicy policy && policy.Date is DateTimeOffset when)
{
text.Append(" The Api will be sunset on ")
.Append(when.Date.ToShortDateString())
.Append('.');
}
info.Description = text.ToString();
return info;
}
}
internal class SwaggerDefaultValues : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var apiDescription = context.ApiDescription;
operation.Deprecated |= apiDescription.IsDeprecated();
foreach (var responseType in context.ApiDescription.SupportedResponseTypes)
{
var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString();
var response = operation.Responses[responseKey];
foreach (var contentType in response.Content.Keys)
{
if (!responseType.ApiResponseFormats.Any(x => x.MediaType == contentType))
{
response.Content.Remove(contentType);
}
}
}
if (operation.Parameters == null)
{
return;
}
foreach (var parameter in operation.Parameters)
{
var description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name);
parameter.Description ??= description.ModelMetadata.Description;
if (parameter.Schema.Default == null &&
description.DefaultValue != null &&
description.DefaultValue is not DBNull &&
description.ModelMetadata is ModelMetadata modelMetadata)
{
var json = JsonSerializer.Serialize(description.DefaultValue, modelMetadata.ModelType);
parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson(json);
}
parameter.Required |= description.IsRequired;
}
}
}
}

View File

@@ -0,0 +1,20 @@
namespace Sufi.Demo.PeopleDirectory.UI.Server.Options
{
/// <summary>
/// Represents configuration options for rate limiting functionality.
/// </summary>
/// <remarks>This type is used to define the parameters for controlling the rate at which operations are
/// allowed. It specifies the maximum number of permits and the time window during which the permits are
/// valid.</remarks>
public record RateLimitOptions
{
/// <summary>
/// Gets the maximum number of permits that can be issued.
/// </summary>
public int PermitLimit { get; init; } = 100;
/// <summary>
/// Gets the time interval that defines the window for rate-limiting operations (in seconds).
/// </summary>
public int Window { get; init; } = 60;
}
}

View File

@@ -0,0 +1,42 @@
@page
@model Sufi.Demo.PeopleDirectory.UI.Server.Pages.ErrorModel
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Error</title>
<link href="~/css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="~/css/app.css" rel="stylesheet" asp-append-version="true" />
</head>
<body>
<div class="main">
<div class="content px-4">
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Diagnostics;
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
namespace Sufi.Demo.PeopleDirectory.UI.Server.Pages
{
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
private readonly ILogger<ErrorModel> _logger;
public ErrorModel(ILogger<ErrorModel> logger)
{
_logger = logger;
}
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}
}

View File

@@ -0,0 +1,22 @@
using Serilog;
using Sufi.Demo.PeopleDirectory.UI.Server;
try
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.ConfigureServices()
.ConfigurePipeline();
// Ensure all pending migrations are applied.
await app.EnsureDatabaseMigrationAsync();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -0,0 +1,30 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:50212",
"sslPort": 44358
}
},
"profiles": {
"Sufi.Demo.PeopleDirectory.UI.Server": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7024;http://localhost:5243",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,129 @@
using Microsoft.EntityFrameworkCore;
using Serilog;
using Sufi.Demo.PeopleDirectory.Application.Extensions;
using Sufi.Demo.PeopleDirectory.Infrastructure;
using Sufi.Demo.PeopleDirectory.Persistence.Contexts;
using Sufi.Demo.PeopleDirectory.Persistence.Extensions;
using Sufi.Demo.PeopleDirectory.UI.Server.Extensions;
using Sufi.Demo.PeopleDirectory.UI.Server.Options;
using System.Threading.RateLimiting;
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
namespace Sufi.Demo.PeopleDirectory.UI.Server
{
public static class StartupExtensions
{
public static WebApplication ConfigureServices(this WebApplicationBuilder builder)
{
var configuration = builder.Configuration;
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.CreateLogger();
builder.Host.UseSerilog();
var rateLimitOptions = configuration.GetSection("RateLimit").Get<RateLimitOptions>()!;
var services = builder.Services;
var licenseKey = configuration.GetValue<string>("LuckyPennyLicenseKey") ?? string.Empty;
// Add services to the container.
services.AddHttpContextAccessor();
services.AddApplicationLayer(licenseKey)
.AddInfrastructureServices()
.AddPersistenceServices(configuration);
services.AddControllersWithViews();
services.AddRazorPages();
services.AddHealthChecks();
services.AddRateLimiter(options =>
{
// Apply for all requests.
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: partition => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = rateLimitOptions.PermitLimit,
Window = TimeSpan.FromSeconds(rateLimitOptions.Window)
}));
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Register Swagger services.
services.AddEndpointsApiExplorer();
services.RegisterSwagger();
return builder.Build();
}
public static WebApplication ConfigurePipeline(this WebApplication app)
{
app.UseSerilogRequestLogging(options =>
{
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent);
diagnosticContext.Set("ClientIP", httpContext.Connection.RemoteIpAddress?.ToString() ?? "");
};
});
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
app.ConfigureSwagger();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseRouting();
app.UseRateLimiter();
app.MapHealthChecks("/health");
app.MapRazorPages();
app.MapControllers();
app.MapFallbackToFile("index.html");
return app;
}
public static void ConfigureSwagger(this IApplicationBuilder app)
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.RoutePrefix = "swagger";
options.DisplayRequestDuration();
foreach (var desc in ((IEndpointRouteBuilder)app).DescribeApiVersions())
{
options.SwaggerEndpoint($"{desc.GroupName}/swagger.json",
desc.GroupName.ToUpperInvariant());
}
});
}
public static async Task<IApplicationBuilder> EnsureDatabaseMigrationAsync(this IApplicationBuilder app)
{
using (var scope = app.ApplicationServices.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await dbContext.Database.MigrateAsync();
}
return app;
}
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>19cb1e83-775a-41dd-a289-4d267a75a265</UserSecretsId>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.21" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.21" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.21">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Sufi.Demo.PeopleDirectory.Persistence\Sufi.Demo.PeopleDirectory.Persistence.csproj" />
<ProjectReference Include="..\..\..\Sufi.Demo.PeopleDirectory.Infrastructure\Sufi.Demo.PeopleDirectory.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\Sufi.Demo.PeopleDirectory.Persistence\Sufi.Demo.PeopleDirectory.Persistence.csproj" />
<ProjectReference Include="..\Client\Sufi.Demo.PeopleDirectory.UI.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnectionString": "Server=localhost:5432;Database=demo-contact;User Id=postgres;Password=Abcd@1234;"
},
"Serilog": {
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} <{Application}>{NewLine}{Exception}"
}
}
]
}
}

View File

@@ -0,0 +1,26 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Serilog": {
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} <{Application}>{NewLine}{Exception}"
}
},
{
"Name": "Seq",
"Args": {
"serverUrl": "",
"apiKey": "",
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} <{Application}>{NewLine}{Exception}"
}
}
]
}
}

View File

@@ -0,0 +1,37 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnectionString": ""
},
"Serilog": {
"Using": [
"Serilog.Sinks.Console",
"Serilog.Sinks.Seq"
],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"Enrich": [
"FromLogContext",
"WithProperty:Application"
],
"Properties": {
"Application": "Sufi.Demo.PeopleDirectory"
}
},
"RateLimit": {
"PermitLimit": 100,
"Window": 60
},
"LuckyPennyLicenseKey": ""
}