first commit
This commit is contained in:
82
.editorconfig
Normal file
82
.editorconfig
Normal file
@@ -0,0 +1,82 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# All files
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
# C# files
|
||||
[*.cs]
|
||||
# Organize usings
|
||||
dotnet_sort_system_directives_first = true
|
||||
dotnet_separate_import_directive_groups = false
|
||||
|
||||
# this. preferences
|
||||
dotnet_style_qualification_for_field = false:suggestion
|
||||
dotnet_style_qualification_for_property = false:suggestion
|
||||
dotnet_style_qualification_for_method = false:suggestion
|
||||
dotnet_style_qualification_for_event = false:suggestion
|
||||
|
||||
# Language keywords vs BCL types preferences
|
||||
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
|
||||
dotnet_style_predefined_type_for_member_access = true:suggestion
|
||||
|
||||
# var preferences
|
||||
csharp_style_var_for_built_in_types = true:suggestion
|
||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
csharp_style_var_elsewhere = true:suggestion
|
||||
|
||||
# Expression-level preferences
|
||||
dotnet_style_object_initializer = true:suggestion
|
||||
dotnet_style_collection_initializer = true:suggestion
|
||||
dotnet_style_prefer_auto_properties = true:suggestion
|
||||
|
||||
# Null-checking preferences
|
||||
csharp_style_throw_expression = true:suggestion
|
||||
csharp_style_conditional_delegate_call = true:suggestion
|
||||
dotnet_style_coalesce_expression = true:suggestion
|
||||
dotnet_style_null_propagation = true:suggestion
|
||||
|
||||
# New line preferences
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_finally = true
|
||||
|
||||
# Indentation preferences
|
||||
csharp_indent_case_contents = true
|
||||
csharp_indent_switch_labels = true
|
||||
|
||||
# Space preferences
|
||||
csharp_space_after_cast = false
|
||||
csharp_space_after_keywords_in_control_flow_statements = true
|
||||
|
||||
# Naming conventions
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case.severity = suggestion
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore_style
|
||||
|
||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
|
||||
|
||||
dotnet_naming_style.camel_case_underscore_style.required_prefix = _
|
||||
dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case
|
||||
|
||||
# JSON files
|
||||
[*.json]
|
||||
indent_size = 2
|
||||
|
||||
# XML files
|
||||
[*.{xml,csproj,props,targets}]
|
||||
indent_size = 2
|
||||
|
||||
# YAML files
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# Build artifacts
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# IDE
|
||||
.vs/
|
||||
.vscode/
|
||||
*.user
|
||||
*.suo
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
**/packages/*
|
||||
!**/packages/build/
|
||||
|
||||
# Test Results
|
||||
TestResults/
|
||||
*.trx
|
||||
|
||||
# Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
appsettings.*.local.json
|
||||
|
||||
# Database
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.db
|
||||
|
||||
# Published output
|
||||
publish/
|
||||
10
Directory.Build.props
Normal file
10
Directory.Build.props
Normal file
@@ -0,0 +1,10 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
# Build stage
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy csproj files and restore
|
||||
COPY ["iYHCT360.Domain/iYHCT360.Domain.csproj", "iYHCT360.Domain/"]
|
||||
COPY ["iYHCT360.Contracts/iYHCT360.Contracts.csproj", "iYHCT360.Contracts/"]
|
||||
COPY ["iYHCT360.Application/iYHCT360.Application.csproj", "iYHCT360.Application/"]
|
||||
COPY ["iYHCT360.Infrastructure/iYHCT360.Infrastructure.csproj", "iYHCT360.Infrastructure/"]
|
||||
COPY ["iYHCT360.WebAPI/iYHCT360.WebAPI.csproj", "iYHCT360.WebAPI/"]
|
||||
|
||||
RUN dotnet restore "iYHCT360.WebAPI/iYHCT360.WebAPI.csproj"
|
||||
|
||||
# Copy everything else and build
|
||||
COPY . .
|
||||
WORKDIR "/src/iYHCT360.WebAPI"
|
||||
RUN dotnet build "iYHCT360.WebAPI.csproj" -c Release -o /app/build
|
||||
|
||||
# Publish stage
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "iYHCT360.WebAPI.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# Final stage
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "iYHCT360.WebAPI.dll"]
|
||||
30
Dockerfile.admin
Normal file
30
Dockerfile.admin
Normal file
@@ -0,0 +1,30 @@
|
||||
# Build stage
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy csproj files and restore
|
||||
COPY ["iYHCT360.Domain/iYHCT360.Domain.csproj", "iYHCT360.Domain/"]
|
||||
COPY ["iYHCT360.Contracts/iYHCT360.Contracts.csproj", "iYHCT360.Contracts/"]
|
||||
COPY ["iYHCT360.Application/iYHCT360.Application.csproj", "iYHCT360.Application/"]
|
||||
COPY ["iYHCT360.Infrastructure/iYHCT360.Infrastructure.csproj", "iYHCT360.Infrastructure/"]
|
||||
COPY ["iYHCT360.AdminAPI/iYHCT360.AdminAPI.csproj", "iYHCT360.AdminAPI/"]
|
||||
|
||||
RUN dotnet restore "iYHCT360.AdminAPI/iYHCT360.AdminAPI.csproj"
|
||||
|
||||
# Copy everything else and build
|
||||
COPY . .
|
||||
WORKDIR "/src/iYHCT360.AdminAPI"
|
||||
RUN dotnet build "iYHCT360.AdminAPI.csproj" -c Release -o /app/build
|
||||
|
||||
# Publish stage
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "iYHCT360.AdminAPI.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# Final stage
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "iYHCT360.AdminAPI.dll"]
|
||||
16
MyNewProjectName.AdminAPI/Controllers/BaseApiController.cs
Normal file
16
MyNewProjectName.AdminAPI/Controllers/BaseApiController.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MyNewProjectName.AdminAPI.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Base API controller with common functionality
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/[controller]")]
|
||||
public abstract class BaseApiController : ControllerBase
|
||||
{
|
||||
private ISender? _mediator;
|
||||
|
||||
protected ISender Mediator => _mediator ??= HttpContext.RequestServices.GetRequiredService<ISender>();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Serilog.Context;
|
||||
|
||||
namespace MyNewProjectName.AdminAPI.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware to generate and track correlation ID for each request
|
||||
/// This ID is added to HTTP headers and Serilog log context for easy tracing
|
||||
/// </summary>
|
||||
public class CorrelationIdMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private const string CorrelationIdHeaderName = "X-Correlation-ID";
|
||||
private const string CorrelationIdLogPropertyName = "CorrelationId";
|
||||
|
||||
public CorrelationIdMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Get correlation ID from request header, or generate a new one
|
||||
var correlationId = context.Request.Headers[CorrelationIdHeaderName].FirstOrDefault()
|
||||
?? $"req-{Guid.NewGuid():N}";
|
||||
|
||||
// Add correlation ID to response header
|
||||
context.Response.Headers[CorrelationIdHeaderName] = correlationId;
|
||||
|
||||
// Add correlation ID to Serilog log context
|
||||
// All logs within this request will automatically include this correlation ID
|
||||
using (LogContext.PushProperty(CorrelationIdLogPropertyName, correlationId))
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using MyNewProjectName.Contracts.DTOs.Responses;
|
||||
using MyNewProjectName.Domain.Exceptions;
|
||||
using Serilog;
|
||||
|
||||
namespace MyNewProjectName.AdminAPI.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Global exception handling middleware
|
||||
/// Catches all unhandled exceptions, logs them to Serilog with full details,
|
||||
/// and returns a standardized error response without exposing stack traces
|
||||
/// </summary>
|
||||
public class ExceptionHandlingMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly Serilog.ILogger _logger;
|
||||
|
||||
public ExceptionHandlingMiddleware(RequestDelegate next, Serilog.ILogger logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await HandleExceptionAsync(context, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
||||
{
|
||||
// Get correlation ID from header (set by CorrelationIdMiddleware)
|
||||
var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault() ?? "unknown";
|
||||
|
||||
// Log full exception details to Serilog (including stack trace)
|
||||
// This will be searchable by CorrelationId
|
||||
_logger.Error(
|
||||
exception,
|
||||
"Unhandled exception occurred. CorrelationId: {CorrelationId}, Path: {Path}, Method: {Method}",
|
||||
correlationId,
|
||||
context.Request.Path,
|
||||
context.Request.Method
|
||||
);
|
||||
|
||||
// Safety check: If response has already started, we cannot modify headers/status code
|
||||
// This prevents InvalidOperationException: "Headers are read-only, response has already started"
|
||||
if (context.Response.HasStarted)
|
||||
{
|
||||
_logger.Warning(
|
||||
"Response has already started. Cannot modify HTTP status code or headers. CorrelationId: {CorrelationId}",
|
||||
correlationId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var response = context.Response;
|
||||
|
||||
// Clear any existing response data
|
||||
response.Clear();
|
||||
|
||||
response.ContentType = "application/json";
|
||||
|
||||
// Determine status code and user-friendly message
|
||||
var (statusCode, message, errors) = exception switch
|
||||
{
|
||||
ValidationException validationEx => (
|
||||
HttpStatusCode.BadRequest,
|
||||
"Validation failed",
|
||||
validationEx.Errors.SelectMany(e => e.Value).ToList()),
|
||||
|
||||
NotFoundException => (
|
||||
HttpStatusCode.NotFound,
|
||||
exception.Message,
|
||||
new List<string>()),
|
||||
|
||||
DomainException => (
|
||||
HttpStatusCode.BadRequest,
|
||||
exception.Message,
|
||||
new List<string>()),
|
||||
|
||||
UnauthorizedAccessException => (
|
||||
HttpStatusCode.Unauthorized,
|
||||
"Unauthorized",
|
||||
new List<string>()),
|
||||
|
||||
_ => (
|
||||
HttpStatusCode.InternalServerError,
|
||||
"An error occurred while processing your request",
|
||||
new List<string>())
|
||||
};
|
||||
|
||||
response.StatusCode = (int)statusCode;
|
||||
|
||||
// Return standardized error response (NO stack trace exposed to client)
|
||||
var result = JsonSerializer.Serialize(new ApiResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = message,
|
||||
Errors = errors.Any() ? errors : null
|
||||
}, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
await response.WriteAsync(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Serilog;
|
||||
|
||||
namespace MyNewProjectName.AdminAPI.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware to log request and response bodies
|
||||
/// WARNING: This can generate large log files. Use with caution in production.
|
||||
/// Consider enabling only for specific environments or endpoints.
|
||||
/// </summary>
|
||||
public class RequestResponseLoggingMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly Serilog.ILogger _logger;
|
||||
private readonly bool _enableRequestLogging;
|
||||
private readonly bool _enableResponseLogging;
|
||||
|
||||
// Paths that should NOT be logged (e.g., health checks, metrics)
|
||||
private static readonly string[] ExcludedPaths = new[]
|
||||
{
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/favicon.ico"
|
||||
};
|
||||
|
||||
public RequestResponseLoggingMiddleware(
|
||||
RequestDelegate next,
|
||||
Serilog.ILogger logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
_enableRequestLogging = configuration.GetValue<bool>("Logging:EnableRequestLogging", false);
|
||||
_enableResponseLogging = configuration.GetValue<bool>("Logging:EnableResponseLogging", false);
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Skip logging for excluded paths
|
||||
if (ExcludedPaths.Any(path => context.Request.Path.StartsWithSegments(path)))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var requestBody = string.Empty;
|
||||
var responseBody = string.Empty;
|
||||
|
||||
// Log request
|
||||
if (_enableRequestLogging)
|
||||
{
|
||||
requestBody = await ReadRequestBodyAsync(context.Request);
|
||||
_logger.Information(
|
||||
"Request: {Method} {Path} {QueryString} | Body: {RequestBody}",
|
||||
context.Request.Method,
|
||||
context.Request.Path,
|
||||
context.Request.QueryString,
|
||||
requestBody
|
||||
);
|
||||
}
|
||||
|
||||
// Capture response body
|
||||
var originalBodyStream = context.Response.Body;
|
||||
using var responseBodyStream = new MemoryStream();
|
||||
context.Response.Body = responseBodyStream;
|
||||
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
// Log response
|
||||
if (_enableResponseLogging)
|
||||
{
|
||||
responseBody = await ReadResponseBodyAsync(context.Response);
|
||||
await responseBodyStream.CopyToAsync(originalBodyStream);
|
||||
|
||||
_logger.Information(
|
||||
"Response: {StatusCode} | Duration: {Duration}ms | Body: {ResponseBody}",
|
||||
context.Response.StatusCode,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
responseBody
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
await responseBodyStream.CopyToAsync(originalBodyStream);
|
||||
}
|
||||
|
||||
context.Response.Body = originalBodyStream;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRequestBodyAsync(HttpRequest request)
|
||||
{
|
||||
// Enable buffering to allow reading the body multiple times
|
||||
request.EnableBuffering();
|
||||
|
||||
using var reader = new StreamReader(
|
||||
request.Body,
|
||||
encoding: Encoding.UTF8,
|
||||
detectEncodingFromByteOrderMarks: false,
|
||||
leaveOpen: true);
|
||||
|
||||
var body = await reader.ReadToEndAsync();
|
||||
request.Body.Position = 0; // Reset position for next middleware
|
||||
|
||||
// Truncate very long bodies to avoid huge logs
|
||||
return body.Length > 10000 ? body[..10000] + "... (truncated)" : body;
|
||||
}
|
||||
|
||||
private static async Task<string> ReadResponseBodyAsync(HttpResponse response)
|
||||
{
|
||||
response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(
|
||||
response.Body,
|
||||
encoding: Encoding.UTF8,
|
||||
detectEncodingFromByteOrderMarks: false,
|
||||
leaveOpen: true);
|
||||
|
||||
var body = await reader.ReadToEndAsync();
|
||||
response.Body.Seek(0, SeekOrigin.Begin); // Reset position
|
||||
|
||||
// Truncate very long bodies to avoid huge logs
|
||||
return body.Length > 10000 ? body[..10000] + "... (truncated)" : body;
|
||||
}
|
||||
}
|
||||
33
MyNewProjectName.AdminAPI/MyNewProjectName.AdminAPI.csproj
Normal file
33
MyNewProjectName.AdminAPI/MyNewProjectName.AdminAPI.csproj
Normal file
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
<!-- Serilog for structured logging -->
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<!-- OpenTelemetry for distributed tracing -->
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.0.0-beta.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyNewProjectName.Infrastructure\MyNewProjectName.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\MyNewProjectName.Application\MyNewProjectName.Application.csproj" />
|
||||
<ProjectReference Include="..\MyNewProjectName.Contracts\MyNewProjectName.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
MyNewProjectName.AdminAPI/MyNewProjectName.AdminAPI.http
Normal file
6
MyNewProjectName.AdminAPI/MyNewProjectName.AdminAPI.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@MyNewProjectName.AdminAPI_HostAddress = http://localhost:5011
|
||||
|
||||
GET {{MyNewProjectName.AdminAPI_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
76
MyNewProjectName.AdminAPI/Program.cs
Normal file
76
MyNewProjectName.AdminAPI/Program.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using MyNewProjectName.Application;
|
||||
using MyNewProjectName.Infrastructure;
|
||||
using MyNewProjectName.Infrastructure.Extensions;
|
||||
using MyNewProjectName.AdminAPI.Middleware;
|
||||
using Serilog;
|
||||
|
||||
// Create host builder with Serilog
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure Serilog
|
||||
builder.Host.UseSerilogLogging(builder.Configuration);
|
||||
|
||||
// Add OpenTelemetry distributed tracing
|
||||
builder.Services.AddOpenTelemetryTracing("MyNewProjectName.AdminAPI");
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// Add Application Layer
|
||||
builder.Services.AddApplication();
|
||||
|
||||
// Add Infrastructure Layer
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// Add Swagger
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
// Middleware pipeline order is critical:
|
||||
// 1. CorrelationIdMiddleware - Must be first to track all requests
|
||||
app.UseMiddleware<CorrelationIdMiddleware>();
|
||||
|
||||
// 2. RequestResponseLoggingMiddleware - Optional, enable only when needed
|
||||
// WARNING: This can generate large log files. Enable only for specific environments.
|
||||
// For AdminAPI, you might want to enable this more often for audit purposes.
|
||||
// Configure in appsettings.json: "Logging:EnableRequestLogging" and "Logging:EnableResponseLogging"
|
||||
app.UseMiddleware<RequestResponseLoggingMiddleware>();
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// 3. Authentication (built-in)
|
||||
app.UseAuthentication();
|
||||
|
||||
// 4. Authorization (built-in)
|
||||
app.UseAuthorization();
|
||||
|
||||
// 6. ExceptionHandlingMiddleware - Must be last to catch all exceptions
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
try
|
||||
{
|
||||
Log.Information("Starting MyNewProjectName.AdminAPI");
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application terminated unexpectedly");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
23
MyNewProjectName.AdminAPI/Properties/launchSettings.json
Normal file
23
MyNewProjectName.AdminAPI/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5011",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7298;http://localhost:5011",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
MyNewProjectName.AdminAPI/appsettings.Development.json
Normal file
8
MyNewProjectName.AdminAPI/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
43
MyNewProjectName.AdminAPI/appsettings.json
Normal file
43
MyNewProjectName.AdminAPI/appsettings.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=localhost;Database=MyNewProjectNameDb;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True"
|
||||
},
|
||||
"Jwt": {
|
||||
"SecretKey": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
|
||||
"Issuer": "MyNewProjectName",
|
||||
"Audience": "MyNewProjectName",
|
||||
"ExpirationInMinutes": 60,
|
||||
"RefreshTokenExpirationInDays": 7
|
||||
},
|
||||
"Redis": {
|
||||
"ConnectionString": "localhost:6379",
|
||||
"InstanceName": "MyNewProjectName:",
|
||||
"DefaultExpirationInMinutes": 30
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"System": "Warning"
|
||||
},
|
||||
"WriteToConsole": true,
|
||||
"WriteToFile": true,
|
||||
"FilePath": "logs/log-.txt",
|
||||
"RollingInterval": "Day",
|
||||
"RetainedFileCountLimit": 31,
|
||||
"SeqUrl": null,
|
||||
"ElasticsearchUrl": null
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
},
|
||||
"EnableRequestLogging": false,
|
||||
"EnableResponseLogging": false
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
49
MyNewProjectName.Application/Behaviors/LoggingBehavior.cs
Normal file
49
MyNewProjectName.Application/Behaviors/LoggingBehavior.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System.Diagnostics;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MyNewProjectName.Application.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// Logging behavior for MediatR pipeline
|
||||
/// </summary>
|
||||
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
{
|
||||
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
|
||||
_logger.LogInformation("Handling {RequestName}", requestName);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var response = await next();
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogInformation("Handled {RequestName} in {ElapsedMilliseconds}ms",
|
||||
requestName, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogError(ex, "Error handling {RequestName} after {ElapsedMilliseconds}ms",
|
||||
requestName, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
47
MyNewProjectName.Application/Behaviors/ValidationBehavior.cs
Normal file
47
MyNewProjectName.Application/Behaviors/ValidationBehavior.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace MyNewProjectName.Application.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// Validation behavior for MediatR pipeline
|
||||
/// </summary>
|
||||
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
{
|
||||
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||
|
||||
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
|
||||
{
|
||||
_validators = validators;
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_validators.Any())
|
||||
{
|
||||
return await next();
|
||||
}
|
||||
|
||||
var context = new ValidationContext<TRequest>(request);
|
||||
|
||||
var validationResults = await Task.WhenAll(
|
||||
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
|
||||
|
||||
var failures = validationResults
|
||||
.Where(r => r.Errors.Any())
|
||||
.SelectMany(r => r.Errors)
|
||||
.ToList();
|
||||
|
||||
if (failures.Any())
|
||||
{
|
||||
var errors = failures
|
||||
.GroupBy(e => e.PropertyName, e => e.ErrorMessage)
|
||||
.ToDictionary(g => g.Key, g => g.ToArray());
|
||||
|
||||
throw new MyNewProjectName.Domain.Exceptions.ValidationException(errors);
|
||||
}
|
||||
|
||||
return await next();
|
||||
}
|
||||
}
|
||||
36
MyNewProjectName.Application/DependencyInjection.cs
Normal file
36
MyNewProjectName.Application/DependencyInjection.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Reflection;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MyNewProjectName.Application.Behaviors;
|
||||
|
||||
namespace MyNewProjectName.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency Injection for Application Layer
|
||||
/// </summary>
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
|
||||
// Register AutoMapper
|
||||
services.AddAutoMapper(assembly);
|
||||
|
||||
// Register MediatR
|
||||
services.AddMediatR(cfg =>
|
||||
{
|
||||
cfg.RegisterServicesFromAssembly(assembly);
|
||||
// Logging phải đứng đầu tiên để ghi nhận request đến
|
||||
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
|
||||
// Sau đó mới đến Validation
|
||||
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
||||
});
|
||||
|
||||
// Register FluentValidation validators
|
||||
services.AddValidatorsFromAssembly(assembly);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MyNewProjectName.Application.Features.Sample.Commands.CreateSample;
|
||||
|
||||
/// <summary>
|
||||
/// Command to create a new sample
|
||||
/// </summary>
|
||||
public record CreateSampleCommand(string Name, string? Description) : IRequest<Guid>;
|
||||
@@ -0,0 +1,37 @@
|
||||
using MyNewProjectName.Domain.Entities;
|
||||
using MyNewProjectName.Domain.Interfaces;
|
||||
using MediatR;
|
||||
|
||||
namespace MyNewProjectName.Application.Features.Sample.Commands.CreateSample;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for CreateSampleCommand
|
||||
/// </summary>
|
||||
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, Guid>
|
||||
{
|
||||
private readonly IRepository<SampleEntity> _repository;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public CreateSampleCommandHandler(IRepository<SampleEntity> repository, IUnitOfWork unitOfWork)
|
||||
{
|
||||
_repository = repository;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Guid> Handle(CreateSampleCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = new SampleEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _repository.AddAsync(entity, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return entity.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MyNewProjectName.Application.Features.Sample.Commands.CreateSample;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for CreateSampleCommand
|
||||
/// </summary>
|
||||
public class CreateSampleCommandValidator : AbstractValidator<CreateSampleCommand>
|
||||
{
|
||||
public CreateSampleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Name is required.")
|
||||
.MaximumLength(200).WithMessage("Name must not exceed 200 characters.");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000).WithMessage("Description must not exceed 1000 characters.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MyNewProjectName.Application.Features.Sample.Queries.GetSamples;
|
||||
|
||||
/// <summary>
|
||||
/// Query to get all samples
|
||||
/// </summary>
|
||||
public record GetSamplesQuery : IRequest<List<SampleDto>>;
|
||||
@@ -0,0 +1,27 @@
|
||||
using AutoMapper;
|
||||
using MyNewProjectName.Domain.Entities;
|
||||
using MyNewProjectName.Domain.Interfaces;
|
||||
using MediatR;
|
||||
|
||||
namespace MyNewProjectName.Application.Features.Sample.Queries.GetSamples;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for GetSamplesQuery
|
||||
/// </summary>
|
||||
public class GetSamplesQueryHandler : IRequestHandler<GetSamplesQuery, List<SampleDto>>
|
||||
{
|
||||
private readonly IRepository<SampleEntity> _repository;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public GetSamplesQueryHandler(IRepository<SampleEntity> repository, IMapper mapper)
|
||||
{
|
||||
_repository = repository;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<List<SampleDto>> Handle(GetSamplesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entities = await _repository.GetAllAsync(cancellationToken);
|
||||
return _mapper.Map<List<SampleDto>>(entities);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using AutoMapper;
|
||||
using MyNewProjectName.Application.Mappings;
|
||||
using MyNewProjectName.Domain.Entities;
|
||||
|
||||
namespace MyNewProjectName.Application.Features.Sample.Queries.GetSamples;
|
||||
|
||||
/// <summary>
|
||||
/// Sample DTO
|
||||
/// </summary>
|
||||
public class SampleDto : IMapFrom<SampleEntity>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public void Mapping(Profile profile)
|
||||
{
|
||||
profile.CreateMap<SampleEntity, SampleDto>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// using MyNewProjectName.Domain.Entities;
|
||||
// using System.Security.Claims;
|
||||
|
||||
// namespace MyNewProjectName.Application.Interfaces.Common;
|
||||
|
||||
// public interface IJwtTokenGenerator
|
||||
// {
|
||||
// string GenerateAccessToken(User user, List<string> roles, Guid tenantId);
|
||||
|
||||
// string GenerateRefreshToken();
|
||||
|
||||
// ClaimsPrincipal GetPrincipalFromExpiredToken(string token);
|
||||
// }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MyNewProjectName.Application.Interfaces.Common;
|
||||
|
||||
public interface IPasswordHasher
|
||||
{
|
||||
string Hash(string password);
|
||||
bool Verify(string password, string hashedPassword);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MyNewProjectName.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for getting current user information
|
||||
/// </summary>
|
||||
public interface ICurrentUserService
|
||||
{
|
||||
string? UserId { get; }
|
||||
string? UserName { get; }
|
||||
bool? IsAuthenticated { get; }
|
||||
string? Role { get; }
|
||||
}
|
||||
10
MyNewProjectName.Application/Interfaces/IDateTimeService.cs
Normal file
10
MyNewProjectName.Application/Interfaces/IDateTimeService.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace MyNewProjectName.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for date time operations (for testability)
|
||||
/// </summary>
|
||||
public interface IDateTimeService
|
||||
{
|
||||
DateTime Now { get; }
|
||||
DateTime UtcNow { get; }
|
||||
}
|
||||
11
MyNewProjectName.Application/Mappings/IMapFrom.cs
Normal file
11
MyNewProjectName.Application/Mappings/IMapFrom.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using AutoMapper;
|
||||
|
||||
namespace MyNewProjectName.Application.Mappings;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for auto mapping registration
|
||||
/// </summary>
|
||||
public interface IMapFrom<T>
|
||||
{
|
||||
void Mapping(Profile profile) => profile.CreateMap(typeof(T), GetType());
|
||||
}
|
||||
55
MyNewProjectName.Application/Mappings/MappingProfile.cs
Normal file
55
MyNewProjectName.Application/Mappings/MappingProfile.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.Reflection;
|
||||
using AutoMapper;
|
||||
|
||||
namespace MyNewProjectName.Application.Mappings;
|
||||
|
||||
/// <summary>
|
||||
/// AutoMapper profile that auto-registers mappings from IMapFrom interface
|
||||
/// </summary>
|
||||
public class MappingProfile : Profile
|
||||
{
|
||||
public MappingProfile()
|
||||
{
|
||||
ApplyMappingsFromAssembly(Assembly.GetExecutingAssembly());
|
||||
}
|
||||
|
||||
private void ApplyMappingsFromAssembly(Assembly assembly)
|
||||
{
|
||||
var mapFromType = typeof(IMapFrom<>);
|
||||
|
||||
var mappingMethodName = nameof(IMapFrom<object>.Mapping);
|
||||
|
||||
bool HasInterface(Type t) => t.IsGenericType && t.GetGenericTypeDefinition() == mapFromType;
|
||||
|
||||
var types = assembly.GetExportedTypes()
|
||||
.Where(t => t.GetInterfaces().Any(HasInterface))
|
||||
.ToList();
|
||||
|
||||
var argumentTypes = new Type[] { typeof(Profile) };
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
var instance = Activator.CreateInstance(type);
|
||||
|
||||
var methodInfo = type.GetMethod(mappingMethodName);
|
||||
|
||||
if (methodInfo != null)
|
||||
{
|
||||
methodInfo.Invoke(instance, new object[] { this });
|
||||
}
|
||||
else
|
||||
{
|
||||
var interfaces = type.GetInterfaces().Where(HasInterface).ToList();
|
||||
|
||||
if (interfaces.Count > 0)
|
||||
{
|
||||
foreach (var @interface in interfaces)
|
||||
{
|
||||
var interfaceMethodInfo = @interface.GetMethod(mappingMethodName, argumentTypes);
|
||||
interfaceMethodInfo?.Invoke(instance, new object[] { this });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyNewProjectName.Domain\MyNewProjectName.Domain.csproj" />
|
||||
<ProjectReference Include="..\MyNewProjectName.Contracts\MyNewProjectName.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
37
MyNewProjectName.Contracts/Common/PagedList.cs
Normal file
37
MyNewProjectName.Contracts/Common/PagedList.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace MyNewProjectName.Contracts.Common
|
||||
{
|
||||
// PagedList model to support pagination in API responses
|
||||
public class PagedList<T>
|
||||
{
|
||||
// Items in the current page
|
||||
public List<T> Items { get; set; } = new List<T>();
|
||||
|
||||
// Total count of items across all pages
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
// Current page number
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
// Page size (items per page)
|
||||
public int PageSize { get; set; }
|
||||
|
||||
// Total number of pages
|
||||
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
|
||||
|
||||
// Flag indicating if there is a previous page
|
||||
public bool HasPreviousPage => PageNumber > 1;
|
||||
|
||||
// Flag indicating if there is a next page
|
||||
public bool HasNextPage => PageNumber < TotalPages;
|
||||
public PagedList(List<T> items, int totalCount, int pageNumber, int pageSize)
|
||||
{
|
||||
Items = items;
|
||||
TotalCount = totalCount;
|
||||
PageNumber = pageNumber;
|
||||
PageSize = pageSize;
|
||||
}
|
||||
|
||||
// Default constructor
|
||||
public PagedList() { }
|
||||
}
|
||||
}
|
||||
19
MyNewProjectName.Contracts/Common/PaginationParams.cs
Normal file
19
MyNewProjectName.Contracts/Common/PaginationParams.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace MyNewProjectName.Contracts.Common
|
||||
{
|
||||
// Parameters for handling pagination in requests
|
||||
public class PaginationParams
|
||||
{
|
||||
private const int MaxPageSize = 1000;
|
||||
private int _pageSize = 50;
|
||||
|
||||
// Page number (default is 1)
|
||||
public int PageNumber { get; set; } = 1;
|
||||
|
||||
// Page size with validation to ensure it doesn't exceed MaxPageSize
|
||||
public int PageSize
|
||||
{
|
||||
get => _pageSize;
|
||||
set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
MyNewProjectName.Contracts/Common/ServiceResponse.cs
Normal file
21
MyNewProjectName.Contracts/Common/ServiceResponse.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace MyNewProjectName.Contracts.Common
|
||||
{
|
||||
// Generic response model for all API endpoints
|
||||
public class ServiceResponse<T>
|
||||
{
|
||||
// Success status of the request
|
||||
public bool Success { get; set; } = true;
|
||||
|
||||
// Response message
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
// Response data
|
||||
public T? Data { get; set; }
|
||||
|
||||
// Error details if any
|
||||
public List<string>? Errors { get; set; }
|
||||
|
||||
// Field-specific validation errors for frontend form validation
|
||||
public Dictionary<string, List<string>>? FieldErrors { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace MyNewProjectName.Contracts.DTOs.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a sample
|
||||
/// </summary>
|
||||
public class CreateSampleRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
13
MyNewProjectName.Contracts/DTOs/Requests/PaginatedRequest.cs
Normal file
13
MyNewProjectName.Contracts/DTOs/Requests/PaginatedRequest.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace MyNewProjectName.Contracts.DTOs.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Base request for paginated queries
|
||||
/// </summary>
|
||||
public class PaginatedRequest
|
||||
{
|
||||
public int PageNumber { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 10;
|
||||
public string? SearchTerm { get; set; }
|
||||
public string? SortBy { get; set; }
|
||||
public bool SortDescending { get; set; } = false;
|
||||
}
|
||||
68
MyNewProjectName.Contracts/DTOs/Responses/ApiResponse.cs
Normal file
68
MyNewProjectName.Contracts/DTOs/Responses/ApiResponse.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
namespace MyNewProjectName.Contracts.DTOs.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Standard API response wrapper
|
||||
/// </summary>
|
||||
public class ApiResponse<T>
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public T? Data { get; set; }
|
||||
public List<string>? Errors { get; set; }
|
||||
|
||||
public static ApiResponse<T> SuccessResponse(T data, string? message = null)
|
||||
{
|
||||
return new ApiResponse<T>
|
||||
{
|
||||
Success = true,
|
||||
Data = data,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
public static ApiResponse<T> ErrorResponse(string error)
|
||||
{
|
||||
return new ApiResponse<T>
|
||||
{
|
||||
Success = false,
|
||||
Errors = new List<string> { error }
|
||||
};
|
||||
}
|
||||
|
||||
public static ApiResponse<T> ErrorResponse(List<string> errors)
|
||||
{
|
||||
return new ApiResponse<T>
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-generic API response
|
||||
/// </summary>
|
||||
public class ApiResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public List<string>? Errors { get; set; }
|
||||
|
||||
public static ApiResponse SuccessResponse(string? message = null)
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
public static ApiResponse ErrorResponse(string error)
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
Success = false,
|
||||
Errors = new List<string> { error }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace MyNewProjectName.Contracts.DTOs.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Paginated response wrapper
|
||||
/// </summary>
|
||||
public class PaginatedResponse<T>
|
||||
{
|
||||
public List<T> Items { get; set; } = new();
|
||||
public int PageNumber { get; set; }
|
||||
public int TotalPages { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
public bool HasPreviousPage { get; set; }
|
||||
public bool HasNextPage { get; set; }
|
||||
}
|
||||
13
MyNewProjectName.Contracts/DTOs/Responses/SampleResponse.cs
Normal file
13
MyNewProjectName.Contracts/DTOs/Responses/SampleResponse.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace MyNewProjectName.Contracts.DTOs.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Sample response DTO
|
||||
/// </summary>
|
||||
public class SampleResponse
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
12
MyNewProjectName.Domain/Common/AuditableEntity.cs
Normal file
12
MyNewProjectName.Domain/Common/AuditableEntity.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace MyNewProjectName.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Base entity with audit properties
|
||||
/// </summary>
|
||||
public abstract class AuditableEntity : BaseEntity
|
||||
{
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
}
|
||||
36
MyNewProjectName.Domain/Common/BaseEntity.cs
Normal file
36
MyNewProjectName.Domain/Common/BaseEntity.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace MyNewProjectName.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Base entity with common properties for all entities
|
||||
/// </summary>
|
||||
public abstract class BaseEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
private readonly List<IDomainEvent> _domainEvents = new();
|
||||
|
||||
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
|
||||
|
||||
public void AddDomainEvent(IDomainEvent domainEvent)
|
||||
{
|
||||
_domainEvents.Add(domainEvent);
|
||||
}
|
||||
|
||||
public void RemoveDomainEvent(IDomainEvent domainEvent)
|
||||
{
|
||||
_domainEvents.Remove(domainEvent);
|
||||
}
|
||||
|
||||
public void ClearDomainEvents()
|
||||
{
|
||||
_domainEvents.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for domain events
|
||||
/// </summary>
|
||||
public interface IDomainEvent
|
||||
{
|
||||
DateTime OccurredOn { get; }
|
||||
}
|
||||
11
MyNewProjectName.Domain/Common/ISoftDelete.cs
Normal file
11
MyNewProjectName.Domain/Common/ISoftDelete.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace MyNewProjectName.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for soft delete functionality
|
||||
/// </summary>
|
||||
public interface ISoftDelete
|
||||
{
|
||||
bool IsDeleted { get; set; }
|
||||
DateTime? DeletedAt { get; set; }
|
||||
string? DeletedBy { get; set; }
|
||||
}
|
||||
13
MyNewProjectName.Domain/Entities/SampleEntity.cs
Normal file
13
MyNewProjectName.Domain/Entities/SampleEntity.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using MyNewProjectName.Domain.Common;
|
||||
|
||||
namespace MyNewProjectName.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Sample entity - Replace with your actual entity
|
||||
/// </summary>
|
||||
public class SampleEntity : AuditableEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
11
MyNewProjectName.Domain/Events/BaseDomainEvent.cs
Normal file
11
MyNewProjectName.Domain/Events/BaseDomainEvent.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using MyNewProjectName.Domain.Common;
|
||||
|
||||
namespace MyNewProjectName.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Base domain event
|
||||
/// </summary>
|
||||
public abstract class BaseDomainEvent : IDomainEvent
|
||||
{
|
||||
public DateTime OccurredOn { get; } = DateTime.UtcNow;
|
||||
}
|
||||
21
MyNewProjectName.Domain/Exceptions/DomainException.cs
Normal file
21
MyNewProjectName.Domain/Exceptions/DomainException.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace MyNewProjectName.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Base exception for domain layer
|
||||
/// </summary>
|
||||
public class DomainException : Exception
|
||||
{
|
||||
public DomainException()
|
||||
{
|
||||
}
|
||||
|
||||
public DomainException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DomainException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
27
MyNewProjectName.Domain/Exceptions/NotFoundException.cs
Normal file
27
MyNewProjectName.Domain/Exceptions/NotFoundException.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace MyNewProjectName.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when an entity is not found
|
||||
/// </summary>
|
||||
public class NotFoundException : DomainException
|
||||
{
|
||||
public NotFoundException()
|
||||
: base()
|
||||
{
|
||||
}
|
||||
|
||||
public NotFoundException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public NotFoundException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
|
||||
public NotFoundException(string name, object key)
|
||||
: base($"Entity \"{name}\" ({key}) was not found.")
|
||||
{
|
||||
}
|
||||
}
|
||||
27
MyNewProjectName.Domain/Exceptions/ValidationException.cs
Normal file
27
MyNewProjectName.Domain/Exceptions/ValidationException.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace MyNewProjectName.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when validation fails
|
||||
/// </summary>
|
||||
public class ValidationException : DomainException
|
||||
{
|
||||
public IDictionary<string, string[]> Errors { get; }
|
||||
|
||||
public ValidationException()
|
||||
: base("One or more validation failures have occurred.")
|
||||
{
|
||||
Errors = new Dictionary<string, string[]>();
|
||||
}
|
||||
|
||||
public ValidationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
Errors = new Dictionary<string, string[]>();
|
||||
}
|
||||
|
||||
public ValidationException(IDictionary<string, string[]> errors)
|
||||
: base("One or more validation failures have occurred.")
|
||||
{
|
||||
Errors = errors;
|
||||
}
|
||||
}
|
||||
24
MyNewProjectName.Domain/Interfaces/IRepository.cs
Normal file
24
MyNewProjectName.Domain/Interfaces/IRepository.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Linq.Expressions;
|
||||
using MyNewProjectName.Domain.Common;
|
||||
|
||||
namespace MyNewProjectName.Domain.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Generic repository interface
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Entity type</typeparam>
|
||||
public interface IRepository<T> where T : BaseEntity
|
||||
{
|
||||
Task<T?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
|
||||
Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
|
||||
Task<bool> AnyAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
|
||||
Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(T entity, CancellationToken cancellationToken = default);
|
||||
Task AddRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default);
|
||||
void Update(T entity);
|
||||
void UpdateRange(IEnumerable<T> entities);
|
||||
void Remove(T entity);
|
||||
void RemoveRange(IEnumerable<T> entities);
|
||||
}
|
||||
12
MyNewProjectName.Domain/Interfaces/IUnitOfWork.cs
Normal file
12
MyNewProjectName.Domain/Interfaces/IUnitOfWork.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace MyNewProjectName.Domain.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Work pattern interface
|
||||
/// </summary>
|
||||
public interface IUnitOfWork : IDisposable
|
||||
{
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
Task BeginTransactionAsync(CancellationToken cancellationToken = default);
|
||||
Task CommitTransactionAsync(CancellationToken cancellationToken = default);
|
||||
Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
9
MyNewProjectName.Domain/MyNewProjectName.Domain.csproj
Normal file
9
MyNewProjectName.Domain/MyNewProjectName.Domain.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
43
MyNewProjectName.Domain/ValueObjects/ValueObject.cs
Normal file
43
MyNewProjectName.Domain/ValueObjects/ValueObject.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace MyNewProjectName.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for value objects
|
||||
/// </summary>
|
||||
public abstract class ValueObject
|
||||
{
|
||||
protected abstract IEnumerable<object?> GetEqualityComponents();
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj == null || obj.GetType() != GetType())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var other = (ValueObject)obj;
|
||||
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return GetEqualityComponents()
|
||||
.Select(x => x?.GetHashCode() ?? 0)
|
||||
.Aggregate((x, y) => x ^ y);
|
||||
}
|
||||
|
||||
public static bool operator ==(ValueObject? left, ValueObject? right)
|
||||
{
|
||||
if (left is null && right is null)
|
||||
return true;
|
||||
|
||||
if (left is null || right is null)
|
||||
return false;
|
||||
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
public static bool operator !=(ValueObject? left, ValueObject? right)
|
||||
{
|
||||
return !(left == right);
|
||||
}
|
||||
}
|
||||
65
MyNewProjectName.Infrastructure/DependencyInjection.cs
Normal file
65
MyNewProjectName.Infrastructure/DependencyInjection.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MyNewProjectName.Application.Interfaces;
|
||||
using MyNewProjectName.Domain.Interfaces;
|
||||
using MyNewProjectName.Infrastructure.Options;
|
||||
using MyNewProjectName.Infrastructure.Persistence.Context;
|
||||
using MyNewProjectName.Infrastructure.Persistence.Repositories;
|
||||
using MyNewProjectName.Infrastructure.Services;
|
||||
|
||||
namespace MyNewProjectName.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency Injection for Infrastructure Layer
|
||||
/// </summary>
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// Register Options Pattern
|
||||
services.Configure<DatabaseOptions>(configuration.GetSection(DatabaseOptions.SectionName));
|
||||
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
|
||||
services.Configure<RedisOptions>(configuration.GetSection(RedisOptions.SectionName));
|
||||
services.Configure<SerilogOptions>(configuration.GetSection(SerilogOptions.SectionName));
|
||||
|
||||
// Validate required Options on startup
|
||||
services.AddOptions<DatabaseOptions>()
|
||||
.Bind(configuration.GetSection(DatabaseOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// Validate JwtOptions only if section exists (optional for now)
|
||||
var jwtSection = configuration.GetSection(JwtOptions.SectionName);
|
||||
if (jwtSection.Exists() && jwtSection.GetChildren().Any())
|
||||
{
|
||||
services.AddOptions<JwtOptions>()
|
||||
.Bind(jwtSection)
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
}
|
||||
|
||||
// Register DbContext using Options Pattern
|
||||
services.AddDbContext<ApplicationDbContext>((serviceProvider, options) =>
|
||||
{
|
||||
var dbOptions = serviceProvider.GetRequiredService<IOptions<DatabaseOptions>>().Value;
|
||||
options.UseSqlServer(
|
||||
dbOptions.DefaultConnection,
|
||||
b => b.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName));
|
||||
});
|
||||
|
||||
// Register repositories
|
||||
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
|
||||
// Register HttpContextAccessor (required for CurrentUserService)
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
// Register services
|
||||
services.AddScoped<IDateTimeService, DateTimeService>();
|
||||
services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using System.Reflection;
|
||||
|
||||
namespace MyNewProjectName.Infrastructure.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring OpenTelemetry
|
||||
/// </summary>
|
||||
public static class OpenTelemetryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add OpenTelemetry distributed tracing
|
||||
/// </summary>
|
||||
public static IServiceCollection AddOpenTelemetryTracing(
|
||||
this IServiceCollection services,
|
||||
string serviceName = "MyNewProjectName")
|
||||
{
|
||||
services.AddOpenTelemetry()
|
||||
.WithTracing(builder =>
|
||||
{
|
||||
builder
|
||||
.SetResourceBuilder(ResourceBuilder
|
||||
.CreateDefault()
|
||||
.AddService(serviceName)
|
||||
.AddAttributes(new Dictionary<string, object>
|
||||
{
|
||||
["service.version"] = Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion ?? "1.0.0"
|
||||
}))
|
||||
.AddAspNetCoreInstrumentation(options =>
|
||||
{
|
||||
// Capture HTTP request/response details
|
||||
options.RecordException = true;
|
||||
options.EnrichWithHttpRequest = (activity, request) =>
|
||||
{
|
||||
activity.SetTag("http.request.method", request.Method);
|
||||
activity.SetTag("http.request.path", request.Path);
|
||||
activity.SetTag("http.request.query_string", request.QueryString);
|
||||
};
|
||||
options.EnrichWithHttpResponse = (activity, response) =>
|
||||
{
|
||||
activity.SetTag("http.response.status_code", response.StatusCode);
|
||||
};
|
||||
})
|
||||
.AddHttpClientInstrumentation(options =>
|
||||
{
|
||||
options.RecordException = true;
|
||||
options.EnrichWithHttpRequestMessage = (activity, request) =>
|
||||
{
|
||||
activity.SetTag("http.client.request.method", request.Method?.ToString());
|
||||
activity.SetTag("http.client.request.uri", request.RequestUri?.ToString());
|
||||
};
|
||||
})
|
||||
.AddEntityFrameworkCoreInstrumentation(options =>
|
||||
{
|
||||
options.SetDbStatementForText = true;
|
||||
options.EnrichWithIDbCommand = (activity, command) =>
|
||||
{
|
||||
activity.SetTag("db.command.text", command.CommandText);
|
||||
};
|
||||
})
|
||||
.AddSource(serviceName)
|
||||
// Export to console (for development)
|
||||
.AddConsoleExporter()
|
||||
// Export to OTLP (for production - requires OTLP collector)
|
||||
.AddOtlpExporter(options =>
|
||||
{
|
||||
// Configure OTLP endpoint if needed
|
||||
// options.Endpoint = new Uri("http://localhost:4317");
|
||||
});
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MyNewProjectName.Infrastructure.Options;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting.Compact;
|
||||
|
||||
namespace MyNewProjectName.Infrastructure.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring Serilog
|
||||
/// </summary>
|
||||
public static class SerilogExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configure Serilog with structured JSON logging
|
||||
/// </summary>
|
||||
public static IHostBuilder UseSerilogLogging(this IHostBuilder hostBuilder, IConfiguration configuration)
|
||||
{
|
||||
var serilogOptions = configuration.GetSection(SerilogOptions.SectionName).Get<SerilogOptions>()
|
||||
?? new SerilogOptions();
|
||||
|
||||
var loggerConfiguration = new LoggerConfiguration()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithEnvironmentName()
|
||||
.Enrich.WithMachineName()
|
||||
.Enrich.WithThreadId()
|
||||
.Enrich.WithProperty("Application", "MyNewProjectName");
|
||||
|
||||
// Set minimum level from configuration
|
||||
if (Enum.TryParse<LogEventLevel>(serilogOptions.MinimumLevel, out var minLevel))
|
||||
{
|
||||
loggerConfiguration.MinimumLevel.Is(minLevel);
|
||||
}
|
||||
|
||||
// Apply overrides from configuration
|
||||
foreach (var overrideConfig in serilogOptions.Override)
|
||||
{
|
||||
if (Enum.TryParse<LogEventLevel>(overrideConfig.Value, out var overrideLevel))
|
||||
{
|
||||
loggerConfiguration.MinimumLevel.Override(overrideConfig.Key, overrideLevel);
|
||||
}
|
||||
}
|
||||
|
||||
// Console sink with JSON formatting (Compact JSON format)
|
||||
if (serilogOptions.WriteToConsole)
|
||||
{
|
||||
loggerConfiguration.WriteTo.Console(new CompactJsonFormatter());
|
||||
}
|
||||
|
||||
// File sink with rolling
|
||||
if (serilogOptions.WriteToFile)
|
||||
{
|
||||
var rollingInterval = Enum.TryParse<RollingInterval>(serilogOptions.RollingInterval, out var interval)
|
||||
? interval
|
||||
: RollingInterval.Day;
|
||||
|
||||
loggerConfiguration.WriteTo.File(
|
||||
new CompactJsonFormatter(),
|
||||
serilogOptions.FilePath,
|
||||
rollingInterval: rollingInterval,
|
||||
retainedFileCountLimit: serilogOptions.RetainedFileCountLimit,
|
||||
shared: true);
|
||||
}
|
||||
|
||||
// Seq sink (optional)
|
||||
if (!string.IsNullOrWhiteSpace(serilogOptions.SeqUrl))
|
||||
{
|
||||
loggerConfiguration.WriteTo.Seq(serilogOptions.SeqUrl);
|
||||
}
|
||||
|
||||
// Elasticsearch sink (optional)
|
||||
if (!string.IsNullOrWhiteSpace(serilogOptions.ElasticsearchUrl))
|
||||
{
|
||||
// Note: Add Serilog.Sinks.Elasticsearch package if needed
|
||||
// loggerConfiguration.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(new Uri(serilogOptions.ElasticsearchUrl)));
|
||||
}
|
||||
|
||||
Log.Logger = loggerConfiguration.CreateLogger();
|
||||
|
||||
return hostBuilder.UseSerilog();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// using MyNewProjectName.Application.Interfaces;
|
||||
// using MyNewProjectName.Domain.Entities;
|
||||
// using Microsoft.Extensions.Options;
|
||||
// using Microsoft.IdentityModel.Tokens;
|
||||
// using System.IdentityModel.Tokens.Jwt;
|
||||
// using System.Security.Claims;
|
||||
// using System.Security.Cryptography;
|
||||
// using System.Text;
|
||||
// using MyNewProjectName.Application.Interfaces.Common;
|
||||
|
||||
// namespace MyNewProjectName.Infrastructure.Identity;
|
||||
|
||||
// public class JwtTokenGenerator : IJwtTokenGenerator
|
||||
// {
|
||||
// private readonly JwtSettings _jwtSettings;
|
||||
|
||||
// public JwtTokenGenerator(IOptions<JwtSettings> jwtOptions)
|
||||
// {
|
||||
// _jwtSettings = jwtOptions.Value;
|
||||
// }
|
||||
|
||||
// public string GenerateAccessToken(User user, List<string> roles, Guid tenantId)
|
||||
// {
|
||||
// var tokenHandler = new JwtSecurityTokenHandler();
|
||||
// var key = Encoding.UTF8.GetBytes(_jwtSettings.Secret);
|
||||
|
||||
// var claims = new List<Claim>
|
||||
// {
|
||||
// new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
// new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
// new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty),
|
||||
// new("id", user.Id.ToString()),
|
||||
// new("tenantId", tenantId.ToString())
|
||||
// };
|
||||
|
||||
// foreach (var role in roles)
|
||||
// {
|
||||
// claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
// }
|
||||
|
||||
// var tokenDescriptor = new SecurityTokenDescriptor
|
||||
// {
|
||||
// Subject = new ClaimsIdentity(claims),
|
||||
// Expires = DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenExpirationMinutes),
|
||||
// Issuer = _jwtSettings.Issuer,
|
||||
// Audience = _jwtSettings.Audience,
|
||||
// SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
|
||||
// };
|
||||
|
||||
// var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
// return tokenHandler.WriteToken(token);
|
||||
// }
|
||||
|
||||
// public string GenerateRefreshToken()
|
||||
// {
|
||||
// var randomNumber = new byte[32];
|
||||
// using var rng = RandomNumberGenerator.Create();
|
||||
// rng.GetBytes(randomNumber);
|
||||
// return Convert.ToBase64String(randomNumber);
|
||||
// }
|
||||
|
||||
// public ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
|
||||
// {
|
||||
// var tokenValidationParameters = new TokenValidationParameters
|
||||
// {
|
||||
// ValidateAudience = false,
|
||||
// ValidateIssuer = false,
|
||||
// ValidateIssuerSigningKey = true,
|
||||
// IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret)),
|
||||
// ValidateLifetime = false
|
||||
// };
|
||||
|
||||
// var tokenHandler = new JwtSecurityTokenHandler();
|
||||
// var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken);
|
||||
|
||||
// if (securityToken is not JwtSecurityToken jwtSecurityToken ||
|
||||
// !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
|
||||
// {
|
||||
// throw new SecurityTokenException("Invalid token");
|
||||
// }
|
||||
|
||||
// return principal;
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyNewProjectName.Application\MyNewProjectName.Application.csproj" />
|
||||
<ProjectReference Include="..\MyNewProjectName.Domain\MyNewProjectName.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.0" />
|
||||
<!-- Serilog packages for extension methods -->
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact" Version="2.0.0" />
|
||||
<!-- OpenTelemetry packages for extension methods -->
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.0.0-beta.7" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
17
MyNewProjectName.Infrastructure/Options/DatabaseOptions.cs
Normal file
17
MyNewProjectName.Infrastructure/Options/DatabaseOptions.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MyNewProjectName.Infrastructure.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Database connection configuration options
|
||||
/// </summary>
|
||||
public class DatabaseOptions
|
||||
{
|
||||
public const string SectionName = "ConnectionStrings";
|
||||
|
||||
/// <summary>
|
||||
/// Default database connection string
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "DefaultConnection is required")]
|
||||
public string DefaultConnection { get; set; } = string.Empty;
|
||||
}
|
||||
42
MyNewProjectName.Infrastructure/Options/JwtOptions.cs
Normal file
42
MyNewProjectName.Infrastructure/Options/JwtOptions.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MyNewProjectName.Infrastructure.Options;
|
||||
|
||||
/// <summary>
|
||||
/// JWT authentication configuration options
|
||||
/// </summary>
|
||||
public class JwtOptions
|
||||
{
|
||||
public const string SectionName = "Jwt";
|
||||
|
||||
/// <summary>
|
||||
/// Secret key for signing JWT tokens
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "JWT SecretKey is required")]
|
||||
[MinLength(32, ErrorMessage = "JWT SecretKey must be at least 32 characters long")]
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Token issuer
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "JWT Issuer is required")]
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Token audience
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "JWT Audience is required")]
|
||||
public string Audience { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Token expiration time in minutes
|
||||
/// </summary>
|
||||
[Range(1, 1440, ErrorMessage = "ExpirationInMinutes must be between 1 and 1440 (24 hours)")]
|
||||
public int ExpirationInMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Refresh token expiration time in days
|
||||
/// </summary>
|
||||
[Range(1, 365, ErrorMessage = "RefreshTokenExpirationInDays must be between 1 and 365")]
|
||||
public int RefreshTokenExpirationInDays { get; set; } = 7;
|
||||
}
|
||||
24
MyNewProjectName.Infrastructure/Options/RedisOptions.cs
Normal file
24
MyNewProjectName.Infrastructure/Options/RedisOptions.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace MyNewProjectName.Infrastructure.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Redis cache configuration options
|
||||
/// </summary>
|
||||
public class RedisOptions
|
||||
{
|
||||
public const string SectionName = "Redis";
|
||||
|
||||
/// <summary>
|
||||
/// Redis connection string
|
||||
/// </summary>
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Redis instance name for key prefixing
|
||||
/// </summary>
|
||||
public string InstanceName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Default cache expiration time in minutes
|
||||
/// </summary>
|
||||
public int DefaultExpirationInMinutes { get; set; } = 30;
|
||||
}
|
||||
54
MyNewProjectName.Infrastructure/Options/SerilogOptions.cs
Normal file
54
MyNewProjectName.Infrastructure/Options/SerilogOptions.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
namespace MyNewProjectName.Infrastructure.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Serilog logging configuration options
|
||||
/// </summary>
|
||||
public class SerilogOptions
|
||||
{
|
||||
public const string SectionName = "Serilog";
|
||||
|
||||
/// <summary>
|
||||
/// Minimum log level
|
||||
/// </summary>
|
||||
public string MinimumLevel { get; set; } = "Information";
|
||||
|
||||
/// <summary>
|
||||
/// Override log levels for specific namespaces
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Override { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Write to console
|
||||
/// </summary>
|
||||
public bool WriteToConsole { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Write to file
|
||||
/// </summary>
|
||||
public bool WriteToFile { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// File path for logs (relative to application root)
|
||||
/// </summary>
|
||||
public string FilePath { get; set; } = "logs/log-.txt";
|
||||
|
||||
/// <summary>
|
||||
/// Rolling interval for log files
|
||||
/// </summary>
|
||||
public string RollingInterval { get; set; } = "Day";
|
||||
|
||||
/// <summary>
|
||||
/// Retained file count limit
|
||||
/// </summary>
|
||||
public int RetainedFileCountLimit { get; set; } = 31;
|
||||
|
||||
/// <summary>
|
||||
/// Seq server URL (optional)
|
||||
/// </summary>
|
||||
public string? SeqUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Elasticsearch URL (optional)
|
||||
/// </summary>
|
||||
public string? ElasticsearchUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MyNewProjectName.Domain.Entities;
|
||||
|
||||
namespace MyNewProjectName.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Entity configuration for SampleEntity
|
||||
/// </summary>
|
||||
public class SampleEntityConfiguration : IEntityTypeConfiguration<SampleEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SampleEntity> builder)
|
||||
{
|
||||
builder.ToTable("Samples");
|
||||
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(e => e.Description)
|
||||
.HasMaxLength(1000);
|
||||
|
||||
builder.Property(e => e.CreatedBy)
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(e => e.UpdatedBy)
|
||||
.HasMaxLength(100);
|
||||
|
||||
// Index for common queries
|
||||
builder.HasIndex(e => e.Name);
|
||||
builder.HasIndex(e => e.IsActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MyNewProjectName.Domain.Common;
|
||||
using MyNewProjectName.Domain.Entities;
|
||||
using System.Reflection;
|
||||
|
||||
namespace MyNewProjectName.Infrastructure.Persistence.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Application database context
|
||||
/// </summary>
|
||||
public class ApplicationDbContext : DbContext
|
||||
{
|
||||
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<SampleEntity> Samples => Set<SampleEntity>();
|
||||
|
||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
|
||||
{
|
||||
switch (entry.State)
|
||||
{
|
||||
case EntityState.Added:
|
||||
entry.Entity.CreatedAt = DateTime.UtcNow;
|
||||
break;
|
||||
|
||||
case EntityState.Modified:
|
||||
entry.Entity.UpdatedAt = DateTime.UtcNow;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return await base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
|
||||
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Linq.Expressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MyNewProjectName.Domain.Common;
|
||||
using MyNewProjectName.Domain.Interfaces;
|
||||
using MyNewProjectName.Infrastructure.Persistence.Context;
|
||||
|
||||
namespace MyNewProjectName.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Generic repository implementation
|
||||
/// </summary>
|
||||
public class Repository<T> : IRepository<T> where T : BaseEntity
|
||||
{
|
||||
protected readonly ApplicationDbContext _context;
|
||||
protected readonly DbSet<T> _dbSet;
|
||||
|
||||
public Repository(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
_dbSet = context.Set<T>();
|
||||
}
|
||||
|
||||
public virtual async Task<T?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbSet.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
public virtual async Task<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbSet.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
||||
}
|
||||
|
||||
public virtual async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbSet.AnyAsync(predicate, cancellationToken);
|
||||
}
|
||||
|
||||
public virtual async Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (predicate == null)
|
||||
return await _dbSet.CountAsync(cancellationToken);
|
||||
|
||||
return await _dbSet.CountAsync(predicate, cancellationToken);
|
||||
}
|
||||
|
||||
public virtual async Task AddAsync(T entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbSet.AddAsync(entity, cancellationToken);
|
||||
}
|
||||
|
||||
public virtual async Task AddRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbSet.AddRangeAsync(entities, cancellationToken);
|
||||
}
|
||||
|
||||
public virtual void Update(T entity)
|
||||
{
|
||||
_dbSet.Attach(entity);
|
||||
_context.Entry(entity).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public virtual void UpdateRange(IEnumerable<T> entities)
|
||||
{
|
||||
_dbSet.AttachRange(entities);
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
_context.Entry(entity).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
|
||||
public virtual void Remove(T entity)
|
||||
{
|
||||
_dbSet.Remove(entity);
|
||||
}
|
||||
|
||||
public virtual void RemoveRange(IEnumerable<T> entities)
|
||||
{
|
||||
_dbSet.RemoveRange(entities);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using MyNewProjectName.Domain.Interfaces;
|
||||
using MyNewProjectName.Infrastructure.Persistence.Context;
|
||||
|
||||
namespace MyNewProjectName.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Work implementation
|
||||
/// </summary>
|
||||
public class UnitOfWork : IUnitOfWork
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private IDbContextTransaction? _transaction;
|
||||
|
||||
public UnitOfWork(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task BeginTransactionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task CommitTransactionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (_transaction != null)
|
||||
{
|
||||
await _transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await RollbackTransactionAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_transaction != null)
|
||||
{
|
||||
await _transaction.DisposeAsync();
|
||||
_transaction = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RollbackTransactionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_transaction != null)
|
||||
{
|
||||
await _transaction.RollbackAsync(cancellationToken);
|
||||
await _transaction.DisposeAsync();
|
||||
_transaction = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_transaction?.Dispose();
|
||||
_context.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using MyNewProjectName.Application.Interfaces;
|
||||
|
||||
namespace MyNewProjectName.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of ICurrentUserService
|
||||
/// Automatically extracts user information from HttpContext using IHttpContextAccessor
|
||||
/// This follows Clean Architecture principles - no dependency on concrete middleware
|
||||
/// </summary>
|
||||
public class CurrentUserService : ICurrentUserService
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public CurrentUserService(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public string? UserId
|
||||
{
|
||||
get
|
||||
{
|
||||
var user = _httpContextAccessor.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
return null;
|
||||
|
||||
return user.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? user.FindFirstValue("sub")
|
||||
?? user.FindFirstValue("userId");
|
||||
}
|
||||
}
|
||||
|
||||
public string? UserName
|
||||
{
|
||||
get
|
||||
{
|
||||
var user = _httpContextAccessor.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
return null;
|
||||
|
||||
return user.FindFirstValue(ClaimTypes.Name)
|
||||
?? user.FindFirstValue("name")
|
||||
?? user.FindFirstValue("username");
|
||||
}
|
||||
}
|
||||
|
||||
public bool? IsAuthenticated
|
||||
{
|
||||
get
|
||||
{
|
||||
return _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated;
|
||||
}
|
||||
}
|
||||
|
||||
public string? Role
|
||||
{
|
||||
get
|
||||
{
|
||||
var user = _httpContextAccessor.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
return null;
|
||||
|
||||
return user.FindFirstValue(ClaimTypes.Role)
|
||||
?? user.FindFirstValue("role");
|
||||
}
|
||||
}
|
||||
}
|
||||
12
MyNewProjectName.Infrastructure/Services/DateTimeService.cs
Normal file
12
MyNewProjectName.Infrastructure/Services/DateTimeService.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using MyNewProjectName.Application.Interfaces;
|
||||
|
||||
namespace MyNewProjectName.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// DateTime service implementation
|
||||
/// </summary>
|
||||
public class DateTimeService : IDateTimeService
|
||||
{
|
||||
public DateTime Now => DateTime.Now;
|
||||
public DateTime UtcNow => DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using FluentAssertions;
|
||||
using FluentValidation.TestHelper;
|
||||
using MyNewProjectName.Application.Features.Sample.Commands.CreateSample;
|
||||
|
||||
namespace MyNewProjectName.UnitTest.Application;
|
||||
|
||||
public class CreateSampleCommandValidatorTests
|
||||
{
|
||||
private readonly CreateSampleCommandValidator _validator;
|
||||
|
||||
public CreateSampleCommandValidatorTests()
|
||||
{
|
||||
_validator = new CreateSampleCommandValidator();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithValidCommand_ShouldNotHaveErrors()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSampleCommand("Test Name", "Test Description");
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(command);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithEmptyName_ShouldHaveError()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSampleCommand("", "Test Description");
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(command);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor(x => x.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithNameExceeding200Characters_ShouldHaveError()
|
||||
{
|
||||
// Arrange
|
||||
var longName = new string('a', 201);
|
||||
var command = new CreateSampleCommand(longName, "Test Description");
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(command);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor(x => x.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithDescriptionExceeding1000Characters_ShouldHaveError()
|
||||
{
|
||||
// Arrange
|
||||
var longDescription = new string('a', 1001);
|
||||
var command = new CreateSampleCommand("Test Name", longDescription);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(command);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor(x => x.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithNullDescription_ShouldNotHaveError()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSampleCommand("Test Name", null);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(command);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor(x => x.Description);
|
||||
}
|
||||
}
|
||||
59
MyNewProjectName.UnitTest/Domain/BaseEntityTests.cs
Normal file
59
MyNewProjectName.UnitTest/Domain/BaseEntityTests.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using FluentAssertions;
|
||||
using MyNewProjectName.Domain.Common;
|
||||
|
||||
namespace MyNewProjectName.UnitTest.Domain;
|
||||
|
||||
public class BaseEntityTests
|
||||
{
|
||||
private class TestEntity : BaseEntity { }
|
||||
|
||||
private class TestDomainEvent : IDomainEvent
|
||||
{
|
||||
public DateTime OccurredOn { get; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDomainEvent_ShouldAddEventToCollection()
|
||||
{
|
||||
// Arrange
|
||||
var entity = new TestEntity();
|
||||
var domainEvent = new TestDomainEvent();
|
||||
|
||||
// Act
|
||||
entity.AddDomainEvent(domainEvent);
|
||||
|
||||
// Assert
|
||||
entity.DomainEvents.Should().Contain(domainEvent);
|
||||
entity.DomainEvents.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveDomainEvent_ShouldRemoveEventFromCollection()
|
||||
{
|
||||
// Arrange
|
||||
var entity = new TestEntity();
|
||||
var domainEvent = new TestDomainEvent();
|
||||
entity.AddDomainEvent(domainEvent);
|
||||
|
||||
// Act
|
||||
entity.RemoveDomainEvent(domainEvent);
|
||||
|
||||
// Assert
|
||||
entity.DomainEvents.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearDomainEvents_ShouldRemoveAllEvents()
|
||||
{
|
||||
// Arrange
|
||||
var entity = new TestEntity();
|
||||
entity.AddDomainEvent(new TestDomainEvent());
|
||||
entity.AddDomainEvent(new TestDomainEvent());
|
||||
|
||||
// Act
|
||||
entity.ClearDomainEvents();
|
||||
|
||||
// Assert
|
||||
entity.DomainEvents.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
60
MyNewProjectName.UnitTest/Domain/ValueObjectTests.cs
Normal file
60
MyNewProjectName.UnitTest/Domain/ValueObjectTests.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using FluentAssertions;
|
||||
using MyNewProjectName.Domain.ValueObjects;
|
||||
|
||||
namespace MyNewProjectName.UnitTest.Domain;
|
||||
|
||||
public class ValueObjectTests
|
||||
{
|
||||
private class TestValueObject : ValueObject
|
||||
{
|
||||
public string Value1 { get; }
|
||||
public int Value2 { get; }
|
||||
|
||||
public TestValueObject(string value1, int value2)
|
||||
{
|
||||
Value1 = value1;
|
||||
Value2 = value2;
|
||||
}
|
||||
|
||||
protected override IEnumerable<object?> GetEqualityComponents()
|
||||
{
|
||||
yield return Value1;
|
||||
yield return Value2;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValueObjects_WithSameValues_ShouldBeEqual()
|
||||
{
|
||||
// Arrange
|
||||
var vo1 = new TestValueObject("test", 123);
|
||||
var vo2 = new TestValueObject("test", 123);
|
||||
|
||||
// Act & Assert
|
||||
vo1.Should().Be(vo2);
|
||||
(vo1 == vo2).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValueObjects_WithDifferentValues_ShouldNotBeEqual()
|
||||
{
|
||||
// Arrange
|
||||
var vo1 = new TestValueObject("test", 123);
|
||||
var vo2 = new TestValueObject("test", 456);
|
||||
|
||||
// Act & Assert
|
||||
vo1.Should().NotBe(vo2);
|
||||
(vo1 != vo2).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHashCode_ShouldBeSameForEqualObjects()
|
||||
{
|
||||
// Arrange
|
||||
var vo1 = new TestValueObject("test", 123);
|
||||
var vo2 = new TestValueObject("test", 123);
|
||||
|
||||
// Act & Assert
|
||||
vo1.GetHashCode().Should().Be(vo2.GetHashCode());
|
||||
}
|
||||
}
|
||||
26
MyNewProjectName.UnitTest/MyNewProjectName.UnitTest.csproj
Normal file
26
MyNewProjectName.UnitTest/MyNewProjectName.UnitTest.csproj
Normal file
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyNewProjectName.Domain\MyNewProjectName.Domain.csproj" />
|
||||
<ProjectReference Include="..\MyNewProjectName.Application\MyNewProjectName.Application.csproj" />
|
||||
<ProjectReference Include="..\MyNewProjectName.Infrastructure\MyNewProjectName.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
16
MyNewProjectName.WebAPI/Controllers/BaseApiController.cs
Normal file
16
MyNewProjectName.WebAPI/Controllers/BaseApiController.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MyNewProjectName.WebAPI.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Base API controller with common functionality
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public abstract class BaseApiController : ControllerBase
|
||||
{
|
||||
private ISender? _mediator;
|
||||
|
||||
protected ISender Mediator => _mediator ??= HttpContext.RequestServices.GetRequiredService<ISender>();
|
||||
}
|
||||
37
MyNewProjectName.WebAPI/Controllers/SamplesController.cs
Normal file
37
MyNewProjectName.WebAPI/Controllers/SamplesController.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using MyNewProjectName.Application.Features.Sample.Commands.CreateSample;
|
||||
using MyNewProjectName.Application.Features.Sample.Queries.GetSamples;
|
||||
using MyNewProjectName.Contracts.DTOs.Requests;
|
||||
using MyNewProjectName.Contracts.DTOs.Responses;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MyNewProjectName.WebAPI.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Sample controller to demonstrate CQRS pattern
|
||||
/// </summary>
|
||||
public class SamplesController : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// Get all samples
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<SampleResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetAll()
|
||||
{
|
||||
var result = await Mediator.Send(new GetSamplesQuery());
|
||||
return Ok(ApiResponse<List<SampleDto>>.SuccessResponse(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new sample
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ApiResponse<Guid>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateSampleRequest request)
|
||||
{
|
||||
var command = new CreateSampleCommand(request.Name, request.Description);
|
||||
var id = await Mediator.Send(command);
|
||||
return CreatedAtAction(nameof(GetAll), new { id }, ApiResponse<Guid>.SuccessResponse(id, "Sample created successfully"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Serilog.Context;
|
||||
|
||||
namespace MyNewProjectName.WebAPI.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware to generate and track correlation ID for each request
|
||||
/// This ID is added to HTTP headers and Serilog log context for easy tracing
|
||||
/// </summary>
|
||||
public class CorrelationIdMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private const string CorrelationIdHeaderName = "X-Correlation-ID";
|
||||
private const string CorrelationIdLogPropertyName = "CorrelationId";
|
||||
|
||||
public CorrelationIdMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Get correlation ID from request header, or generate a new one
|
||||
var correlationId = context.Request.Headers[CorrelationIdHeaderName].FirstOrDefault()
|
||||
?? $"req-{Guid.NewGuid():N}";
|
||||
|
||||
// Add correlation ID to response header
|
||||
context.Response.Headers[CorrelationIdHeaderName] = correlationId;
|
||||
|
||||
// Add correlation ID to Serilog log context
|
||||
// All logs within this request will automatically include this correlation ID
|
||||
using (LogContext.PushProperty(CorrelationIdLogPropertyName, correlationId))
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using MyNewProjectName.Contracts.DTOs.Responses;
|
||||
using MyNewProjectName.Domain.Exceptions;
|
||||
using Serilog;
|
||||
|
||||
namespace MyNewProjectName.WebAPI.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Global exception handling middleware
|
||||
/// Catches all unhandled exceptions, logs them to Serilog with full details,
|
||||
/// and returns a standardized error response without exposing stack traces
|
||||
/// </summary>
|
||||
public class ExceptionHandlingMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly Serilog.ILogger _logger;
|
||||
|
||||
public ExceptionHandlingMiddleware(RequestDelegate next, Serilog.ILogger logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await HandleExceptionAsync(context, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
||||
{
|
||||
// Get correlation ID from header (set by CorrelationIdMiddleware)
|
||||
var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault() ?? "unknown";
|
||||
|
||||
// Log full exception details to Serilog (including stack trace)
|
||||
// This will be searchable by CorrelationId
|
||||
_logger.Error(
|
||||
exception,
|
||||
"Unhandled exception occurred. CorrelationId: {CorrelationId}, Path: {Path}, Method: {Method}",
|
||||
correlationId,
|
||||
context.Request.Path,
|
||||
context.Request.Method
|
||||
);
|
||||
|
||||
// Safety check: If response has already started, we cannot modify headers/status code
|
||||
// This prevents InvalidOperationException: "Headers are read-only, response has already started"
|
||||
if (context.Response.HasStarted)
|
||||
{
|
||||
_logger.Warning(
|
||||
"Response has already started. Cannot modify HTTP status code or headers. CorrelationId: {CorrelationId}",
|
||||
correlationId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var response = context.Response;
|
||||
|
||||
// Clear any existing response data
|
||||
response.Clear();
|
||||
|
||||
response.ContentType = "application/json";
|
||||
|
||||
// Determine status code and user-friendly message
|
||||
var (statusCode, message, errors) = exception switch
|
||||
{
|
||||
ValidationException validationEx => (
|
||||
HttpStatusCode.BadRequest,
|
||||
"Validation failed",
|
||||
validationEx.Errors.SelectMany(e => e.Value).ToList()),
|
||||
|
||||
NotFoundException => (
|
||||
HttpStatusCode.NotFound,
|
||||
exception.Message,
|
||||
new List<string>()),
|
||||
|
||||
DomainException => (
|
||||
HttpStatusCode.BadRequest,
|
||||
exception.Message,
|
||||
new List<string>()),
|
||||
|
||||
UnauthorizedAccessException => (
|
||||
HttpStatusCode.Unauthorized,
|
||||
"Unauthorized",
|
||||
new List<string>()),
|
||||
|
||||
_ => (
|
||||
HttpStatusCode.InternalServerError,
|
||||
"An error occurred while processing your request",
|
||||
new List<string>())
|
||||
};
|
||||
|
||||
response.StatusCode = (int)statusCode;
|
||||
|
||||
// Return standardized error response (NO stack trace exposed to client)
|
||||
var result = JsonSerializer.Serialize(new ApiResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = message,
|
||||
Errors = errors.Any() ? errors : null
|
||||
}, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
await response.WriteAsync(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Serilog;
|
||||
|
||||
namespace MyNewProjectName.WebAPI.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware to log request and response bodies
|
||||
/// WARNING: This can generate large log files. Use with caution in production.
|
||||
/// Consider enabling only for specific environments or endpoints.
|
||||
/// </summary>
|
||||
public class RequestResponseLoggingMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly Serilog.ILogger _logger;
|
||||
private readonly bool _enableRequestLogging;
|
||||
private readonly bool _enableResponseLogging;
|
||||
|
||||
// Paths that should NOT be logged (e.g., health checks, metrics)
|
||||
private static readonly string[] ExcludedPaths = new[]
|
||||
{
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/favicon.ico"
|
||||
};
|
||||
|
||||
public RequestResponseLoggingMiddleware(
|
||||
RequestDelegate next,
|
||||
Serilog.ILogger logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
_enableRequestLogging = configuration.GetValue<bool>("Logging:EnableRequestLogging", false);
|
||||
_enableResponseLogging = configuration.GetValue<bool>("Logging:EnableResponseLogging", false);
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Skip logging for excluded paths
|
||||
if (ExcludedPaths.Any(path => context.Request.Path.StartsWithSegments(path)))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var requestBody = string.Empty;
|
||||
var responseBody = string.Empty;
|
||||
|
||||
// Log request
|
||||
if (_enableRequestLogging)
|
||||
{
|
||||
requestBody = await ReadRequestBodyAsync(context.Request);
|
||||
_logger.Information(
|
||||
"Request: {Method} {Path} {QueryString} | Body: {RequestBody}",
|
||||
context.Request.Method,
|
||||
context.Request.Path,
|
||||
context.Request.QueryString,
|
||||
requestBody
|
||||
);
|
||||
}
|
||||
|
||||
// Capture response body
|
||||
var originalBodyStream = context.Response.Body;
|
||||
using var responseBodyStream = new MemoryStream();
|
||||
context.Response.Body = responseBodyStream;
|
||||
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
// Log response
|
||||
if (_enableResponseLogging)
|
||||
{
|
||||
responseBody = await ReadResponseBodyAsync(context.Response);
|
||||
await responseBodyStream.CopyToAsync(originalBodyStream);
|
||||
|
||||
_logger.Information(
|
||||
"Response: {StatusCode} | Duration: {Duration}ms | Body: {ResponseBody}",
|
||||
context.Response.StatusCode,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
responseBody
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
await responseBodyStream.CopyToAsync(originalBodyStream);
|
||||
}
|
||||
|
||||
context.Response.Body = originalBodyStream;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRequestBodyAsync(HttpRequest request)
|
||||
{
|
||||
// Enable buffering to allow reading the body multiple times
|
||||
request.EnableBuffering();
|
||||
|
||||
using var reader = new StreamReader(
|
||||
request.Body,
|
||||
encoding: Encoding.UTF8,
|
||||
detectEncodingFromByteOrderMarks: false,
|
||||
leaveOpen: true);
|
||||
|
||||
var body = await reader.ReadToEndAsync();
|
||||
request.Body.Position = 0; // Reset position for next middleware
|
||||
|
||||
// Truncate very long bodies to avoid huge logs
|
||||
return body.Length > 10000 ? body[..10000] + "... (truncated)" : body;
|
||||
}
|
||||
|
||||
private static async Task<string> ReadResponseBodyAsync(HttpResponse response)
|
||||
{
|
||||
response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(
|
||||
response.Body,
|
||||
encoding: Encoding.UTF8,
|
||||
detectEncodingFromByteOrderMarks: false,
|
||||
leaveOpen: true);
|
||||
|
||||
var body = await reader.ReadToEndAsync();
|
||||
response.Body.Seek(0, SeekOrigin.Begin); // Reset position
|
||||
|
||||
// Truncate very long bodies to avoid huge logs
|
||||
return body.Length > 10000 ? body[..10000] + "... (truncated)" : body;
|
||||
}
|
||||
}
|
||||
33
MyNewProjectName.WebAPI/MyNewProjectName.WebAPI.csproj
Normal file
33
MyNewProjectName.WebAPI/MyNewProjectName.WebAPI.csproj
Normal file
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
<!-- Serilog for structured logging -->
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<!-- OpenTelemetry for distributed tracing -->
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.0.0-beta.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyNewProjectName.Infrastructure\MyNewProjectName.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\MyNewProjectName.Application\MyNewProjectName.Application.csproj" />
|
||||
<ProjectReference Include="..\MyNewProjectName.Contracts\MyNewProjectName.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
MyNewProjectName.WebAPI/MyNewProjectName.WebAPI.http
Normal file
6
MyNewProjectName.WebAPI/MyNewProjectName.WebAPI.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@MyNewProjectName.WebAPI_HostAddress = http://localhost:5044
|
||||
|
||||
GET {{MyNewProjectName.WebAPI_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
75
MyNewProjectName.WebAPI/Program.cs
Normal file
75
MyNewProjectName.WebAPI/Program.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using MyNewProjectName.Application;
|
||||
using MyNewProjectName.Infrastructure;
|
||||
using MyNewProjectName.Infrastructure.Extensions;
|
||||
using MyNewProjectName.WebAPI.Middleware;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure Serilog
|
||||
builder.Host.UseSerilogLogging(builder.Configuration);
|
||||
|
||||
// Add OpenTelemetry distributed tracing
|
||||
builder.Services.AddOpenTelemetryTracing("MyNewProjectName.WebAPI");
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// Add Application Layer
|
||||
builder.Services.AddApplication();
|
||||
|
||||
// Add Infrastructure Layer
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// Add Swagger
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
// Middleware pipeline order is critical:
|
||||
// 1. CorrelationIdMiddleware - Must be first to track all requests
|
||||
app.UseMiddleware<CorrelationIdMiddleware>();
|
||||
|
||||
// 2. RequestResponseLoggingMiddleware - Optional, enable only when needed
|
||||
// WARNING: This can generate large log files. Enable only for specific environments.
|
||||
// Configure in appsettings.json: "Logging:EnableRequestLogging" and "Logging:EnableResponseLogging"
|
||||
app.UseMiddleware<RequestResponseLoggingMiddleware>();
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// 3. Authentication (built-in)
|
||||
app.UseAuthentication();
|
||||
|
||||
// 4. Authorization (built-in)
|
||||
app.UseAuthorization();
|
||||
|
||||
// 6. ExceptionHandlingMiddleware - Must be last to catch all exceptions
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
try
|
||||
{
|
||||
Log.Information("Starting MyNewProjectName.WebAPI");
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application terminated unexpectedly");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
23
MyNewProjectName.WebAPI/Properties/launchSettings.json
Normal file
23
MyNewProjectName.WebAPI/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5044",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7285;http://localhost:5044",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
MyNewProjectName.WebAPI/appsettings.Development.json
Normal file
8
MyNewProjectName.WebAPI/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
43
MyNewProjectName.WebAPI/appsettings.json
Normal file
43
MyNewProjectName.WebAPI/appsettings.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=localhost;Database=MyNewProjectNameDb;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True"
|
||||
},
|
||||
"Jwt": {
|
||||
"SecretKey": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
|
||||
"Issuer": "MyNewProjectName",
|
||||
"Audience": "MyNewProjectName",
|
||||
"ExpirationInMinutes": 60,
|
||||
"RefreshTokenExpirationInDays": 7
|
||||
},
|
||||
"Redis": {
|
||||
"ConnectionString": "localhost:6379",
|
||||
"InstanceName": "MyNewProjectName:",
|
||||
"DefaultExpirationInMinutes": 30
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"System": "Warning"
|
||||
},
|
||||
"WriteToConsole": true,
|
||||
"WriteToFile": true,
|
||||
"FilePath": "logs/log-.txt",
|
||||
"RollingInterval": "Day",
|
||||
"RetainedFileCountLimit": 31,
|
||||
"SeqUrl": null,
|
||||
"ElasticsearchUrl": null
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
},
|
||||
"EnableRequestLogging": false,
|
||||
"EnableResponseLogging": false
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
118
MyNewProjectName.sln
Normal file
118
MyNewProjectName.sln
Normal file
@@ -0,0 +1,118 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.Domain", "MyNewProjectName.Domain\MyNewProjectName.Domain.csproj", "{40B880AC-5B91-4B47-8244-B32357283A12}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.Contracts", "MyNewProjectName.Contracts\MyNewProjectName.Contracts.csproj", "{EFA800B5-A636-4779-A488-F481C2520D94}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.Application", "MyNewProjectName.Application\MyNewProjectName.Application.csproj", "{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.Infrastructure", "MyNewProjectName.Infrastructure\MyNewProjectName.Infrastructure.csproj", "{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.WebAPI", "MyNewProjectName.WebAPI\MyNewProjectName.WebAPI.csproj", "{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.AdminAPI", "MyNewProjectName.AdminAPI\MyNewProjectName.AdminAPI.csproj", "{DF5C0545-C453-47C5-8AAC-22655434DDFB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.UnitTest", "MyNewProjectName.UnitTest\MyNewProjectName.UnitTest.csproj", "{6381CB4D-1AE7-420F-A2C1-0621EB659614}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{40B880AC-5B91-4B47-8244-B32357283A12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{40B880AC-5B91-4B47-8244-B32357283A12}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{40B880AC-5B91-4B47-8244-B32357283A12}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{40B880AC-5B91-4B47-8244-B32357283A12}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{40B880AC-5B91-4B47-8244-B32357283A12}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{40B880AC-5B91-4B47-8244-B32357283A12}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{40B880AC-5B91-4B47-8244-B32357283A12}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{40B880AC-5B91-4B47-8244-B32357283A12}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{40B880AC-5B91-4B47-8244-B32357283A12}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{40B880AC-5B91-4B47-8244-B32357283A12}.Release|x64.Build.0 = Release|Any CPU
|
||||
{40B880AC-5B91-4B47-8244-B32357283A12}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{40B880AC-5B91-4B47-8244-B32357283A12}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EFA800B5-A636-4779-A488-F481C2520D94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EFA800B5-A636-4779-A488-F481C2520D94}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EFA800B5-A636-4779-A488-F481C2520D94}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EFA800B5-A636-4779-A488-F481C2520D94}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EFA800B5-A636-4779-A488-F481C2520D94}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EFA800B5-A636-4779-A488-F481C2520D94}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EFA800B5-A636-4779-A488-F481C2520D94}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EFA800B5-A636-4779-A488-F481C2520D94}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EFA800B5-A636-4779-A488-F481C2520D94}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EFA800B5-A636-4779-A488-F481C2520D94}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EFA800B5-A636-4779-A488-F481C2520D94}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EFA800B5-A636-4779-A488-F481C2520D94}.Release|x86.Build.0 = Release|Any CPU
|
||||
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Release|x86.Build.0 = Release|Any CPU
|
||||
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Release|x64.Build.0 = Release|Any CPU
|
||||
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Release|x86.Build.0 = Release|Any CPU
|
||||
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
50
docker-compose.yml
Normal file
50
docker-compose.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
services:
|
||||
webapi:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "5000:8080"
|
||||
- "5001:8081"
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- ConnectionStrings__DefaultConnection=Server=sqlserver;Database=iYHCT360Db;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True
|
||||
depends_on:
|
||||
- sqlserver
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
adminapi:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.admin
|
||||
ports:
|
||||
- "5002:8080"
|
||||
- "5003:8081"
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- ConnectionStrings__DefaultConnection=Server=sqlserver;Database=iYHCT360Db;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True
|
||||
depends_on:
|
||||
- sqlserver
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
sqlserver:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
ports:
|
||||
- "1433:1433"
|
||||
environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- SA_PASSWORD=YourStrong!Passw0rd
|
||||
- MSSQL_PID=Developer
|
||||
volumes:
|
||||
- sqlserver-data:/var/opt/mssql
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
volumes:
|
||||
sqlserver-data:
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
334
docs/GitBranch.md
Normal file
334
docs/GitBranch.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# Huong Dan Dat Ten Git Branch Trong Du An
|
||||
|
||||
> **Tham khao:** [Git Branch Naming Conventions - Codiga](https://codiga.io/blog/git-branch-naming-conventions)
|
||||
|
||||
---
|
||||
|
||||
## Muc Luc
|
||||
|
||||
1. [Nguyen Tac Chung](#1-nguyen-tac-chung)
|
||||
2. [Cau Truc Ten Branch](#2-cau-truc-ten-branch)
|
||||
3. [Cac Loai Branch (Branch Types)](#3-cac-loai-branch-branch-types)
|
||||
4. [Bang Mau Ten Branch Theo Chuc Nang](#4-bang-mau-ten-branch-theo-chuc-nang)
|
||||
5. [Quy Tac Dat Ten (Good Practices)](#5-quy-tac-dat-ten-good-practices)
|
||||
6. [Mo Hinh Git Flow](#6-mo-hinh-git-flow)
|
||||
7. [Vi Du Thuc Te Trong Du An](#7-vi-du-thuc-te-trong-du-an)
|
||||
8. [Checklist Truoc Khi Tao Branch](#8-checklist-truoc-khi-tao-branch)
|
||||
|
||||
---
|
||||
|
||||
## 1. Nguyen Tac Chung
|
||||
|
||||
Theo bai viet tu Codiga, mot quy uoc dat ten branch tot giup:
|
||||
|
||||
| # | Loi ich | Mo ta |
|
||||
|---|---------|-------|
|
||||
| 1 | **Truy vet tac gia** | Biet ai da tao branch (developer nao) |
|
||||
| 2 | **Lien ket voi issue tracker** | De dang trace branch voi task/ticket tren JIRA, Trello, GitHub Issues... |
|
||||
| 3 | **Hieu muc dich branch** | Nhanh chong biet branch la bugfix, feature, hay hotfix |
|
||||
| 4 | **To chuc workflow** | Giu cho quy trinh lam viec co trat tu va hieu qua |
|
||||
|
||||
---
|
||||
|
||||
## 2. Cau Truc Ten Branch
|
||||
|
||||
### Format chung
|
||||
|
||||
```
|
||||
<type>/<ticket-id>-<short-description>
|
||||
```
|
||||
|
||||
Trong do:
|
||||
|
||||
| Thanh phan | Bat buoc | Mo ta | Vi du |
|
||||
|-----------|----------|-------|-------|
|
||||
| `type` | Co | Loai branch (feature, bugfix, hotfix...) | `feature` |
|
||||
| `ticket-id` | Co (neu co) | Ma ticket/issue tu issue tracker | `PROJ-1234` |
|
||||
| `short-description` | Co | Mo ta ngan 3-6 tu, phan cach bang dau `-` | `add-user-authentication` |
|
||||
|
||||
### Vi du day du
|
||||
|
||||
```
|
||||
feature/PROJ-1234-add-user-authentication
|
||||
bugfix/PROJ-5678-fix-login-redirect
|
||||
hotfix/PROJ-9012-patch-security-vulnerability
|
||||
```
|
||||
|
||||
### Format mo rong (co ten tac gia)
|
||||
|
||||
Neu team co nhieu nguoi lam chung mot ticket, them ten tac gia:
|
||||
|
||||
```
|
||||
<author>/<type>/<ticket-id>-<short-description>
|
||||
```
|
||||
|
||||
Vi du:
|
||||
|
||||
```
|
||||
julien/feature/1234-new-dashboard
|
||||
david/feature/1234-new-dashboard
|
||||
```
|
||||
|
||||
Dieu nay giup phan biet ro rang code cua tung developer cho cung mot task.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cac Loai Branch (Branch Types)
|
||||
|
||||
### Branch chinh (Long-lived branches)
|
||||
|
||||
| Branch | Muc dich | Duoc merge tu | Ghi chu |
|
||||
|--------|---------|---------------|---------|
|
||||
| `main` (hoac `master`) | Code production, luon o trang thai stable | `release`, `hotfix` | Khong bao gio commit truc tiep |
|
||||
| `develop` | Code moi nhat cho phien ban tiep theo | `feature`, `bugfix` | Nhanh tich hop chinh |
|
||||
| `staging` | Moi truong test truoc khi len production | `develop` | Tuy chon, tuy du an |
|
||||
|
||||
### Branch tam thoi (Short-lived branches)
|
||||
|
||||
| Prefix | Muc dich | Tao tu | Merge vao | Vi du |
|
||||
|--------|---------|--------|----------|-------|
|
||||
| `feature/` | Tinh nang moi | `develop` | `develop` | `feature/PROJ-101-add-login-page` |
|
||||
| `bugfix/` | Sua loi trong qua trinh phat trien | `develop` | `develop` | `bugfix/PROJ-202-fix-null-reference` |
|
||||
| `hotfix/` | Sua loi khan cap tren production | `main` | `main` va `develop` | `hotfix/PROJ-303-fix-payment-crash` |
|
||||
| `release/` | Chuan bi phien ban moi | `develop` | `main` va `develop` | `release/v1.2.0` |
|
||||
| `chore/` | Cong viec bao tri, refactor, CI/CD | `develop` | `develop` | `chore/update-dependencies` |
|
||||
| `docs/` | Cap nhat tai lieu | `develop` | `develop` | `docs/update-api-documentation` |
|
||||
| `test/` | Viet test hoac cai thien test | `develop` | `develop` | `test/add-unit-tests-user-service` |
|
||||
| `refactor/` | Tai cau truc code, khong thay doi chuc nang | `develop` | `develop` | `refactor/clean-up-user-repository` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Bang Mau Ten Branch Theo Chuc Nang
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
```
|
||||
feature/PROJ-101-add-jwt-authentication
|
||||
feature/PROJ-102-implement-refresh-token
|
||||
feature/PROJ-103-add-role-based-access
|
||||
bugfix/PROJ-104-fix-token-expiration
|
||||
hotfix/PROJ-105-patch-auth-bypass
|
||||
```
|
||||
|
||||
### CRUD Entity
|
||||
|
||||
```
|
||||
feature/PROJ-201-create-product-entity
|
||||
feature/PROJ-202-add-product-api-endpoints
|
||||
feature/PROJ-203-implement-product-search
|
||||
bugfix/PROJ-204-fix-product-update-validation
|
||||
```
|
||||
|
||||
### Infrastructure & DevOps
|
||||
|
||||
```
|
||||
chore/PROJ-301-setup-docker-compose
|
||||
chore/PROJ-302-configure-ci-cd-pipeline
|
||||
chore/PROJ-303-add-redis-caching
|
||||
chore/PROJ-304-setup-logging-serilog
|
||||
```
|
||||
|
||||
### Database & Migration
|
||||
|
||||
```
|
||||
feature/PROJ-401-add-migration-user-table
|
||||
feature/PROJ-402-seed-initial-data
|
||||
bugfix/PROJ-403-fix-migration-conflict
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
```
|
||||
docs/PROJ-501-update-readme
|
||||
docs/PROJ-502-add-api-swagger-docs
|
||||
docs/PROJ-503-create-deployment-guide
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Quy Tac Dat Ten (Good Practices)
|
||||
|
||||
### Nen lam
|
||||
|
||||
| Quy tac | Chi tiet | Vi du |
|
||||
|---------|---------|-------|
|
||||
| **Dung ten mo ta** | Ten branch phai phan anh ro noi dung thay doi | `feature/PROJ-101-add-user-authentication` |
|
||||
| **Giu ngan gon** | Chi 3-6 tu khoa, phan cach bang dau `-` | `bugfix/PROJ-202-fix-null-ref` |
|
||||
| **Viet thuong toan bo** | Khong viet hoa | `feature/add-login` |
|
||||
| **Dung dau `-` phan cach tu** | Khong dung dau cach, underscore, hoac camelCase | `fix-login-redirect` |
|
||||
| **Bat dau bang type prefix** | Luon co prefix xac dinh loai branch | `feature/`, `bugfix/`, `hotfix/` |
|
||||
| **Lien ket ticket ID** | Giup trace nguon goc thay doi | `PROJ-1234-...` |
|
||||
|
||||
### Khong nen lam
|
||||
|
||||
| Quy tac | Vi du sai | Vi du dung |
|
||||
|---------|----------|-----------|
|
||||
| **Khong dung ky tu dac biet** | `feature/add@user#auth` | `feature/add-user-auth` |
|
||||
| **Khong dung dau cach** | `feature/add user auth` | `feature/add-user-auth` |
|
||||
| **Khong viet hoa** | `Feature/Add-User-Auth` | `feature/add-user-auth` |
|
||||
| **Khong dat ten chung chung** | `feature/new-stuff` | `feature/PROJ-101-add-payment-gateway` |
|
||||
| **Khong dat ten qua dai** | `feature/PROJ-101-add-new-user-authentication-with-jwt-and-refresh-token-support-for-all-roles` | `feature/PROJ-101-add-jwt-auth` |
|
||||
| **Khong dung so thuong** | `feature/123` | `feature/PROJ-123-add-login` |
|
||||
| **Khong commit truc tiep vao main/develop** | — | Luon tao branch rieng |
|
||||
|
||||
---
|
||||
|
||||
## 6. Mo Hinh Git Flow
|
||||
|
||||
### So do tong quat
|
||||
|
||||
```
|
||||
main (production)
|
||||
|
|
||||
|--- hotfix/PROJ-xxx-fix-critical-bug
|
||||
| |
|
||||
| v
|
||||
| (merge vao main VA develop)
|
||||
|
|
||||
|--- release/v1.2.0
|
||||
| |
|
||||
| v
|
||||
| (merge vao main VA develop)
|
||||
|
|
||||
develop (integration)
|
||||
|
|
||||
|--- feature/PROJ-xxx-new-feature
|
||||
| |
|
||||
| v
|
||||
| (merge vao develop qua Pull Request)
|
||||
|
|
||||
|--- bugfix/PROJ-xxx-fix-bug
|
||||
| |
|
||||
| v
|
||||
| (merge vao develop qua Pull Request)
|
||||
|
|
||||
|--- chore/update-packages
|
||||
|
|
||||
v
|
||||
(merge vao develop qua Pull Request)
|
||||
```
|
||||
|
||||
### Quy trinh lam viec
|
||||
|
||||
1. **Tao branch** tu `develop` (hoac `main` cho hotfix)
|
||||
2. **Commit** thuong xuyen voi message ro rang
|
||||
3. **Push** branch len remote
|
||||
4. **Tao Pull Request** (PR) de review code
|
||||
5. **Review & Approve** boi it nhat 1 thanh vien khac
|
||||
6. **Merge** vao branch dich (squash merge hoac merge commit)
|
||||
7. **Xoa branch** sau khi merge thanh cong
|
||||
|
||||
### Lenh Git mau
|
||||
|
||||
```bash
|
||||
# Tao branch feature moi tu develop
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/PROJ-101-add-login-page
|
||||
|
||||
# Lam viec va commit
|
||||
git add .
|
||||
git commit -m "feat(PROJ-101): add login page UI"
|
||||
|
||||
# Push len remote
|
||||
git push origin feature/PROJ-101-add-login-page
|
||||
|
||||
# Sau khi merge PR, xoa branch local
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git branch -d feature/PROJ-101-add-login-page
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Vi Du Thuc Te Trong Du An
|
||||
|
||||
### Ap dung cho du an Clean Architecture (MyNewProjectName)
|
||||
|
||||
#### Sprint 1: Khoi tao du an
|
||||
|
||||
```bash
|
||||
# Cau hinh co ban
|
||||
chore/PROJ-001-setup-clean-architecture
|
||||
chore/PROJ-002-configure-dependency-injection
|
||||
chore/PROJ-003-setup-ef-core-database
|
||||
chore/PROJ-004-add-serilog-logging
|
||||
chore/PROJ-005-setup-docker-compose
|
||||
```
|
||||
|
||||
#### Sprint 2: Authentication
|
||||
|
||||
```bash
|
||||
# Tinh nang xac thuc
|
||||
feature/PROJ-010-add-user-entity
|
||||
feature/PROJ-011-implement-jwt-authentication
|
||||
feature/PROJ-012-add-refresh-token-flow
|
||||
feature/PROJ-013-implement-role-authorization
|
||||
bugfix/PROJ-014-fix-token-validation-error
|
||||
```
|
||||
|
||||
#### Sprint 3: CRUD cho SampleEntity
|
||||
|
||||
```bash
|
||||
# Tinh nang CRUD
|
||||
feature/PROJ-020-add-sample-entity-crud
|
||||
feature/PROJ-021-add-pagination-support
|
||||
feature/PROJ-022-implement-search-filter
|
||||
bugfix/PROJ-023-fix-sample-delete-cascade
|
||||
```
|
||||
|
||||
#### Hotfix khan cap
|
||||
|
||||
```bash
|
||||
# Sua loi tren production
|
||||
hotfix/PROJ-099-fix-sql-injection-vulnerability
|
||||
hotfix/PROJ-100-patch-cors-configuration
|
||||
```
|
||||
|
||||
### Quy uoc Commit Message (di kem voi branch)
|
||||
|
||||
De dong bo voi quy uoc branch, nen dung [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
```
|
||||
|
||||
| Type | Muc dich | Vi du |
|
||||
|------|---------|-------|
|
||||
| `feat` | Tinh nang moi | `feat(PROJ-101): add login page` |
|
||||
| `fix` | Sua loi | `fix(PROJ-202): resolve null reference in UserService` |
|
||||
| `chore` | Bao tri | `chore: update NuGet packages` |
|
||||
| `docs` | Tai lieu | `docs: update API documentation` |
|
||||
| `refactor` | Tai cau truc | `refactor: simplify UserRepository queries` |
|
||||
| `test` | Them/sua test | `test: add unit tests for AuthService` |
|
||||
| `style` | Format code | `style: apply editorconfig rules` |
|
||||
| `ci` | CI/CD | `ci: add GitHub Actions workflow` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Checklist Truoc Khi Tao Branch
|
||||
|
||||
- [ ] Ten branch co bat dau bang type prefix khong? (`feature/`, `bugfix/`, `hotfix/`...)
|
||||
- [ ] Ten branch co chua ticket/issue ID khong? (`PROJ-1234`)
|
||||
- [ ] Mo ta co ngan gon va ro rang khong? (3-6 tu)
|
||||
- [ ] Chi dung chu thuong, so, dau `-` va dau `/`?
|
||||
- [ ] Khong co ky tu dac biet, dau cach, hoac chu viet hoa?
|
||||
- [ ] Branch duoc tao tu dung branch nguon? (`develop` hoac `main`)
|
||||
- [ ] Da pull code moi nhat tu branch nguon truoc khi tao?
|
||||
|
||||
---
|
||||
|
||||
## Tom Tat Nhanh
|
||||
|
||||
```
|
||||
Format: <type>/<ticket-id>-<short-description>
|
||||
Vi du: feature/PROJ-101-add-user-authentication
|
||||
|
||||
Type: feature | bugfix | hotfix | release | chore | docs | test | refactor
|
||||
Chu y: - Viet thuong toan bo
|
||||
- Dung dau `-` phan cach tu
|
||||
- Giu ngan gon (3-6 tu)
|
||||
- Khong ky tu dac biet
|
||||
- Lien ket ticket ID
|
||||
- Xoa branch sau khi merge
|
||||
```
|
||||
468
docs/GitCommit.md
Normal file
468
docs/GitCommit.md
Normal file
@@ -0,0 +1,468 @@
|
||||
# Huong Dan Viet Git Commit Message Trong Du An
|
||||
|
||||
> **Tham khao:** [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
|
||||
---
|
||||
|
||||
## Muc Luc
|
||||
|
||||
1. [Nguyen Tac Chung](#1-nguyen-tac-chung)
|
||||
2. [Cau Truc Commit Message](#2-cau-truc-commit-message)
|
||||
3. [Cac Loai Type](#3-cac-loai-type)
|
||||
4. [Scope - Pham Vi Thay Doi](#4-scope---pham-vi-thay-doi)
|
||||
5. [Quy Tac Viet Description](#5-quy-tac-viet-description)
|
||||
6. [Commit Message Voi Body Va Footer](#6-commit-message-voi-body-va-footer)
|
||||
7. [Bang Vi Du Day Du](#7-bang-vi-du-day-du)
|
||||
8. [Vi Du Thuc Te Trong Du An](#8-vi-du-thuc-te-trong-du-an)
|
||||
9. [Nhung Loi Thuong Gap](#9-nhung-loi-thuong-gap)
|
||||
10. [Checklist Truoc Khi Commit](#10-checklist-truoc-khi-commit)
|
||||
|
||||
---
|
||||
|
||||
## 1. Nguyen Tac Chung
|
||||
|
||||
Viet commit message chuan giup:
|
||||
|
||||
| # | Loi ich | Mo ta |
|
||||
|---|---------|-------|
|
||||
| 1 | **Doc lich su de dang** | Nhin vao git log biet ngay thay doi gi |
|
||||
| 2 | **Tu dong tao changelog** | Cac tool co the tu dong tao changelog tu commit message |
|
||||
| 3 | **Lien ket voi issue tracker** | De dang trace commit voi task/ticket |
|
||||
| 4 | **Review code hieu qua** | Nguoi review hieu nhanh muc dich cua commit |
|
||||
| 5 | **Tu dong versioning** | Xac dinh phien ban tu dong (semantic versioning) dua tren type |
|
||||
|
||||
---
|
||||
|
||||
## 2. Cau Truc Commit Message
|
||||
|
||||
### Format chung
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
```
|
||||
|
||||
Trong do:
|
||||
|
||||
| Thanh phan | Bat buoc | Mo ta | Vi du |
|
||||
|-----------|----------|-------|-------|
|
||||
| `type` | Co | Loai thay doi (feat, fix, chore...) | `feat` |
|
||||
| `scope` | Khong | Pham vi/module bi anh huong | `auth`, `api`, `user` |
|
||||
| `description` | Co | Mo ta ngan, duoi 50 ky tu, viet hoa dau cau, khong dau cham cuoi | `add Google login` |
|
||||
|
||||
### Format day du (voi body va footer)
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
### Vi du nhanh
|
||||
|
||||
```
|
||||
feat(auth): add Google login
|
||||
fix(api): resolve 404 error
|
||||
docs(readme): update install guide
|
||||
chore: update dependencies
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Cac Loai Type
|
||||
|
||||
### Type chinh (thuong dung)
|
||||
|
||||
| Type | Muc dich | Anh huong version | Vi du |
|
||||
|------|---------|-------------------|-------|
|
||||
| `feat` | Them tinh nang moi | MINOR (1.x.0) | `feat(auth): add Google OAuth` |
|
||||
| `fix` | Sua loi | PATCH (1.0.x) | `fix(api): resolve 404 error` |
|
||||
| `docs` | Cap nhat tai lieu | Khong | `docs(readme): update install guide` |
|
||||
| `style` | Doi format/UI khong anh huong logic | Khong | `style: apply prettier formatting` |
|
||||
| `refactor` | Tai cau truc code, khong thay doi chuc nang | Khong | `refactor: simplify UserService logic` |
|
||||
| `perf` | Toi uu hieu nang | PATCH (1.0.x) | `perf: optimize database queries` |
|
||||
| `test` | Them hoac sua test | Khong | `test: add unit tests for AuthService` |
|
||||
| `chore` | Thay doi nho, bao tri, CI/CD | Khong | `chore: update dependencies` |
|
||||
|
||||
### Type bo sung (it dung hon)
|
||||
|
||||
| Type | Muc dich | Vi du |
|
||||
|------|---------|-------|
|
||||
| `build` | Thay doi build system hoac dependencies | `build: upgrade to .NET 8` |
|
||||
| `ci` | Thay doi CI/CD pipeline | `ci: add GitHub Actions workflow` |
|
||||
| `revert` | Hoan tac commit truoc do | `revert: revert feat(auth): add Google login` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Scope - Pham Vi Thay Doi
|
||||
|
||||
Scope la phan **tuy chon** nam trong dau ngoac `()`, xac dinh module/file/module cu the bi anh huong.
|
||||
|
||||
### Danh sach scope khuyen nghi cho du an Clean Architecture
|
||||
|
||||
| Scope | Layer/Module | Vi du |
|
||||
|-------|-------------|-------|
|
||||
| `domain` | MyNewProjectName.Domain | `feat(domain): add Order entity` |
|
||||
| `app` | MyNewProjectName.Application | `feat(app): add CreateUserCommand` |
|
||||
| `infra` | MyNewProjectName.Infrastructure | `feat(infra): configure EF Core DbContext` |
|
||||
| `api` | MyNewProjectName.WebAPI | `fix(api): resolve CORS issue` |
|
||||
| `admin` | MyNewProjectName.AdminAPI | `feat(admin): add dashboard endpoint` |
|
||||
| `contract` | MyNewProjectName.Contracts | `feat(contract): add UserDto` |
|
||||
| `test` | MyNewProjectName.UnitTest | `test(test): add UserService tests` |
|
||||
| `auth` | Module xac thuc | `feat(auth): implement JWT refresh token` |
|
||||
| `cache` | Module caching/Redis | `feat(cache): add Redis caching layer` |
|
||||
| `db` | Database/Migration | `feat(db): add migration for User table` |
|
||||
| `docker` | Docker/Container | `chore(docker): update docker-compose` |
|
||||
| `deps` | Dependencies/NuGet | `chore(deps): update MediatR to v12` |
|
||||
|
||||
### Quy tac scope
|
||||
|
||||
- Viet thuong toan bo
|
||||
- Ngan gon, 1-2 tu
|
||||
- Phai nhat quan trong toan du an
|
||||
- Co the bo qua neu thay doi anh huong nhieu module
|
||||
|
||||
---
|
||||
|
||||
## 5. Quy Tac Viet Description
|
||||
|
||||
### Nen lam
|
||||
|
||||
| Quy tac | Chi tiet | Vi du |
|
||||
|---------|---------|-------|
|
||||
| **Viet hoa chu dau** | Chu dau tien cua description viet hoa | `feat: Add login page` |
|
||||
| **Duoi 50 ky tu** | Giu description ngan gon | `fix: Resolve null reference in UserService` |
|
||||
| **Dung dong tu menh lenh** | Bat dau bang dong tu (add, fix, update, remove...) | `feat: Add user authentication` |
|
||||
| **Khong dau cham cuoi** | Khong ket thuc bang dau `.` | `docs: Update README` |
|
||||
| **Mo ta "lam gi"** | Tap trung vao ket qua, khong phai qua trinh | `fix: Resolve 404 on login redirect` |
|
||||
|
||||
### Khong nen lam
|
||||
|
||||
| Quy tac | Vi du sai | Vi du dung |
|
||||
|---------|----------|-----------|
|
||||
| **Khong viet chung chung** | `fix: Fix bug` | `fix(auth): Resolve token expiration error` |
|
||||
| **Khong qua dai** | `feat: Add new user authentication with JWT and refresh token and role-based access control` | `feat(auth): Add JWT authentication` |
|
||||
| **Khong dung qua khu** | `feat: Added login page` | `feat: Add login page` |
|
||||
| **Khong ghi ten file** | `fix: Fix UserService.cs` | `fix(app): Resolve null ref in user creation` |
|
||||
| **Khong dung tieng Viet trong type** | `feat: Them trang dang nhap` | `feat: Add login page` |
|
||||
|
||||
### Danh sach dong tu khuyen dung
|
||||
|
||||
| Dong tu | Khi nao dung | Vi du |
|
||||
|---------|-------------|-------|
|
||||
| `add` | Them moi | `feat: Add payment gateway` |
|
||||
| `remove` | Xoa bo | `refactor: Remove unused imports` |
|
||||
| `update` | Cap nhat | `docs: Update API documentation` |
|
||||
| `fix` | Sua loi | `fix: Fix null reference exception` |
|
||||
| `resolve` | Giai quyet | `fix: Resolve race condition in checkout` |
|
||||
| `implement` | Hien thuc | `feat: Implement search filter` |
|
||||
| `refactor` | Tai cau truc | `refactor: Refactor UserRepository` |
|
||||
| `optimize` | Toi uu | `perf: Optimize query performance` |
|
||||
| `configure` | Cau hinh | `chore: Configure Serilog logging` |
|
||||
| `migrate` | Di chuyen/migration | `feat: Migrate user table schema` |
|
||||
| `replace` | Thay the | `refactor: Replace raw SQL with EF Core` |
|
||||
| `rename` | Doi ten | `refactor: Rename UserDto to UserResponse` |
|
||||
| `move` | Di chuyen | `refactor: Move validators to shared folder` |
|
||||
| `simplify` | Don gian hoa | `refactor: Simplify error handling logic` |
|
||||
| `extract` | Tach ra | `refactor: Extract email service interface` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Commit Message Voi Body Va Footer
|
||||
|
||||
Khi commit phuc tap, can giai thich them, su dung body va footer:
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
<-- dong trong bat buoc
|
||||
<body>
|
||||
<-- dong trong bat buoc
|
||||
<footer>
|
||||
```
|
||||
|
||||
### Vi du 1: Commit co body
|
||||
|
||||
```
|
||||
feat(auth): Add JWT authentication
|
||||
|
||||
Implement JWT-based authentication using IdentityServer.
|
||||
Include access token and refresh token flow.
|
||||
Configure token expiration to 15 minutes for access token
|
||||
and 7 days for refresh token.
|
||||
```
|
||||
|
||||
### Vi du 2: Commit co body va footer (lien ket ticket)
|
||||
|
||||
```
|
||||
fix(api): Resolve 500 error on user creation
|
||||
|
||||
The API was returning 500 when creating a user with an existing email.
|
||||
Added proper validation check before inserting into database.
|
||||
Return 409 Conflict instead of 500 Internal Server Error.
|
||||
|
||||
Resolves: PROJ-1234
|
||||
```
|
||||
|
||||
### Vi du 3: Breaking change
|
||||
|
||||
```
|
||||
feat(api)!: Change response format for all endpoints
|
||||
|
||||
BREAKING CHANGE: All API responses now follow the new standard format:
|
||||
{
|
||||
"success": true,
|
||||
"data": {},
|
||||
"message": "",
|
||||
"errors": []
|
||||
}
|
||||
|
||||
Previous format with flat response body is no longer supported.
|
||||
Clients must update to handle the new wrapper format.
|
||||
|
||||
Resolves: PROJ-5678
|
||||
```
|
||||
|
||||
### Footer keywords
|
||||
|
||||
| Keyword | Muc dich | Vi du |
|
||||
|---------|---------|-------|
|
||||
| `Resolves:` | Dong issue/ticket | `Resolves: PROJ-1234` |
|
||||
| `Closes:` | Dong issue tren GitHub | `Closes: #123` |
|
||||
| `Related:` | Lien quan den issue khac | `Related: PROJ-5678` |
|
||||
| `BREAKING CHANGE:` | Thay doi khong tuong thich nguoc | `BREAKING CHANGE: API response format changed` |
|
||||
| `Co-authored-by:` | Dong tac gia | `Co-authored-by: Name <email>` |
|
||||
| `Reviewed-by:` | Nguoi review | `Reviewed-by: Name <email>` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Bang Vi Du Day Du
|
||||
|
||||
### feat - Them tinh nang moi
|
||||
|
||||
```bash
|
||||
feat(auth): Add Google OAuth login
|
||||
feat(api): Add pagination support for product list
|
||||
feat(domain): Add Order entity with value objects
|
||||
feat(app): Add CreateUserCommand with validation
|
||||
feat(infra): Add Redis caching for product queries
|
||||
feat(admin): Add dashboard statistics endpoint
|
||||
feat(contract): Add OrderResponseDto
|
||||
```
|
||||
|
||||
### fix - Sua loi
|
||||
|
||||
```bash
|
||||
fix(api): Resolve 404 error on login redirect
|
||||
fix(auth): Fix token expiration calculation
|
||||
fix(infra): Resolve database connection timeout
|
||||
fix(app): Fix null reference in GetUserQuery
|
||||
fix(domain): Fix value object equality comparison
|
||||
```
|
||||
|
||||
### docs - Tai lieu
|
||||
|
||||
```bash
|
||||
docs(readme): Update installation guide
|
||||
docs(api): Add Swagger annotations for OrderController
|
||||
docs: Add contributing guidelines
|
||||
docs: Update environment variables documentation
|
||||
```
|
||||
|
||||
### style - Format code
|
||||
|
||||
```bash
|
||||
style: Apply EditorConfig formatting rules
|
||||
style(api): Fix indentation in controllers
|
||||
style: Remove trailing whitespace
|
||||
```
|
||||
|
||||
### refactor - Tai cau truc
|
||||
|
||||
```bash
|
||||
refactor(app): Simplify UserService error handling
|
||||
refactor(infra): Extract IEmailService interface
|
||||
refactor: Move validation logic to domain layer
|
||||
refactor(api): Replace manual mapping with AutoMapper
|
||||
```
|
||||
|
||||
### perf - Toi uu hieu nang
|
||||
|
||||
```bash
|
||||
perf(infra): Optimize database queries with projection
|
||||
perf(api): Add response compression middleware
|
||||
perf(cache): Reduce Redis round trips with pipeline
|
||||
```
|
||||
|
||||
### test - Them/sua test
|
||||
|
||||
```bash
|
||||
test(app): Add unit tests for CreateUserCommand
|
||||
test(domain): Add tests for Order entity validation
|
||||
test(infra): Add integration tests for UserRepository
|
||||
test: Increase code coverage to 80%
|
||||
```
|
||||
|
||||
### chore - Bao tri
|
||||
|
||||
```bash
|
||||
chore: Update NuGet packages
|
||||
chore(deps): Upgrade to .NET 8
|
||||
chore(docker): Update docker-compose configuration
|
||||
chore(ci): Add GitHub Actions build workflow
|
||||
chore: Update .gitignore
|
||||
```
|
||||
|
||||
### build - Build system
|
||||
|
||||
```bash
|
||||
build: Upgrade to .NET 8 SDK
|
||||
build: Add Directory.Build.props for shared config
|
||||
build: Configure multi-stage Docker build
|
||||
```
|
||||
|
||||
### ci - CI/CD
|
||||
|
||||
```bash
|
||||
ci: Add GitHub Actions workflow for PR checks
|
||||
ci: Configure automatic deployment to staging
|
||||
ci: Add SonarQube code analysis step
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Vi Du Thuc Te Trong Du An
|
||||
|
||||
### Luong lam viec mot feature hoan chinh
|
||||
|
||||
Gia su lam task PROJ-101: Them tinh nang dang nhap
|
||||
|
||||
```bash
|
||||
# 1. Tao branch
|
||||
git checkout -b feature/PROJ-101-add-login
|
||||
|
||||
# 2. Them entity va domain logic
|
||||
git commit -m "feat(domain): Add User entity with email and password"
|
||||
|
||||
# 3. Them command/query
|
||||
git commit -m "feat(app): Add LoginCommand with FluentValidation"
|
||||
|
||||
# 4. Them infrastructure
|
||||
git commit -m "feat(infra): Implement UserRepository with EF Core"
|
||||
git commit -m "feat(infra): Add password hashing service"
|
||||
|
||||
# 5. Them API endpoint
|
||||
git commit -m "feat(api): Add AuthController with login endpoint"
|
||||
|
||||
# 6. Them test
|
||||
git commit -m "test(app): Add unit tests for LoginCommand handler"
|
||||
|
||||
# 7. Cap nhat tai lieu
|
||||
git commit -m "docs(api): Add Swagger docs for auth endpoints"
|
||||
|
||||
# 8. Push va tao PR
|
||||
git push origin feature/PROJ-101-add-login
|
||||
```
|
||||
|
||||
### Luong sua loi
|
||||
|
||||
```bash
|
||||
# 1. Tao branch
|
||||
git checkout -b bugfix/PROJ-202-fix-login-error
|
||||
|
||||
# 2. Sua loi
|
||||
git commit -m "fix(auth): Resolve incorrect password validation logic"
|
||||
|
||||
# 3. Them test cho truong hop loi
|
||||
git commit -m "test(auth): Add test for invalid password scenario"
|
||||
|
||||
# 4. Push va tao PR
|
||||
git push origin bugfix/PROJ-202-fix-login-error
|
||||
```
|
||||
|
||||
### Luong hotfix khan cap
|
||||
|
||||
```bash
|
||||
# 1. Tao branch tu main
|
||||
git checkout main
|
||||
git checkout -b hotfix/PROJ-303-fix-sql-injection
|
||||
|
||||
# 2. Sua loi
|
||||
git commit -m "fix(infra): Sanitize SQL parameters to prevent injection
|
||||
|
||||
The raw SQL query in SearchRepository was concatenating user input
|
||||
directly into the query string. Replaced with parameterized query
|
||||
using EF Core's FromSqlInterpolated method.
|
||||
|
||||
Resolves: PROJ-303"
|
||||
|
||||
# 3. Push va tao PR vao main
|
||||
git push origin hotfix/PROJ-303-fix-sql-injection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Nhung Loi Thuong Gap
|
||||
|
||||
| # | Loi sai | Vi du sai | Vi du dung |
|
||||
|---|--------|----------|-----------|
|
||||
| 1 | **Message qua chung chung** | `fix: Fix bug` | `fix(auth): Resolve token expiration error` |
|
||||
| 2 | **Khong co type** | `Add login page` | `feat: Add login page` |
|
||||
| 3 | **Dung qua khu** | `feat: Added new feature` | `feat: Add new feature` |
|
||||
| 4 | **Qua nhieu thay doi trong 1 commit** | `feat: Add login, register, forgot password` | Tach thanh 3 commit rieng |
|
||||
| 5 | **Commit file khong lien quan** | Commit ca file config lan feature | Chi commit file lien quan |
|
||||
| 6 | **Message tieng Viet** | `feat: Them trang dang nhap` | `feat: Add login page` |
|
||||
| 7 | **Dau cham cuoi** | `feat: Add login page.` | `feat: Add login page` |
|
||||
| 8 | **Khong co scope khi can thiet** | `fix: Fix null reference` | `fix(app): Resolve null ref in GetUserQuery` |
|
||||
| 9 | **Type sai** | `feat: Fix bug` | `fix: Resolve login error` |
|
||||
| 10 | **Description qua dai** | 100+ ky tu tren 1 dong | Giu duoi 50 ky tu, dung body cho chi tiet |
|
||||
|
||||
---
|
||||
|
||||
## 10. Checklist Truoc Khi Commit
|
||||
|
||||
- [ ] Commit message co dung format `<type>(<scope>): <description>` khong?
|
||||
- [ ] Type co chinh xac khong? (feat, fix, docs, style, refactor, perf, test, chore)
|
||||
- [ ] Scope co phan anh dung module bi anh huong khong?
|
||||
- [ ] Description co duoi 50 ky tu khong?
|
||||
- [ ] Description co bat dau bang dong tu menh lenh khong? (add, fix, update...)
|
||||
- [ ] Description co viet hoa chu dau khong?
|
||||
- [ ] Khong co dau cham cuoi trong description?
|
||||
- [ ] Moi commit chi chua 1 thay doi logic duy nhat?
|
||||
- [ ] Commit co lien ket ticket ID khong? (trong scope hoac footer)
|
||||
- [ ] Neu la breaking change, da danh dau `!` va them `BREAKING CHANGE:` trong footer?
|
||||
|
||||
---
|
||||
|
||||
## Tom Tat Nhanh
|
||||
|
||||
```
|
||||
Format: <type>(<scope>): <description>
|
||||
|
||||
Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
|
||||
|
||||
Scope: domain | app | infra | api | admin | contract | test | auth | cache | db | docker | deps
|
||||
|
||||
Vi du:
|
||||
feat(auth): Add Google OAuth login # scope = "auth" (module authentication)
|
||||
fix(api): Resolve 404 error # scope = "api" (API endpoints)
|
||||
docs(readme): Update install guide # scope = "readme" (file/module cu the)
|
||||
|
||||
feat: Them tinh nang moi (VD: feat(login): Add Google OAuth).
|
||||
fix: Sua loi (VD: fix(api): Resolve 404 error).
|
||||
docs: Cap nhat tai lieu.
|
||||
style: Doi format/UI khong anh huong logic.
|
||||
refactor: Tai cau truc code.
|
||||
perf: Toi uu hieu nang.
|
||||
chore: Thay doi nho (VD: chore: Update dependencies).
|
||||
|
||||
Quy tac:
|
||||
- Type va scope viet thuong
|
||||
- Description viet hoa chu dau, duoi 50 ky tu
|
||||
- Dung dong tu menh lenh (add, fix, update, remove, implement...)
|
||||
- Khong dau cham cuoi
|
||||
- 1 commit = 1 thay doi logic
|
||||
- Lien ket ticket ID khi co the
|
||||
```
|
||||
254
docs/Observability-Guide.md
Normal file
254
docs/Observability-Guide.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Observability Guide - Serilog & OpenTelemetry
|
||||
|
||||
## Tổng quan
|
||||
|
||||
Dự án đã được tích hợp **Serilog** (Structured Logging) và **OpenTelemetry** (Distributed Tracing) để hỗ trợ giám sát và debug ứng dụng khi chạy trên production/Docker.
|
||||
|
||||
## Serilog - Structured Logging
|
||||
|
||||
### Tính năng
|
||||
|
||||
- **JSON Format**: Log được ghi dưới dạng JSON, dễ dàng parse và filter
|
||||
- **Multiple Sinks**: Console, File, Seq, Elasticsearch
|
||||
- **Enrichment**: Tự động thêm thông tin như Environment, MachineName, ThreadId
|
||||
- **Rolling Files**: Tự động rotate log files theo ngày/tuần/tháng
|
||||
|
||||
### Cấu hình trong appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
},
|
||||
"WriteToConsole": true,
|
||||
"WriteToFile": true,
|
||||
"FilePath": "logs/log-.txt",
|
||||
"RollingInterval": "Day",
|
||||
"RetainedFileCountLimit": 31,
|
||||
"SeqUrl": "http://localhost:5341",
|
||||
"ElasticsearchUrl": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sử dụng trong Code
|
||||
|
||||
```csharp
|
||||
using Serilog;
|
||||
|
||||
public class MyService
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public MyService(ILogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void DoSomething()
|
||||
{
|
||||
// Structured logging với properties
|
||||
_logger.Information(
|
||||
"Processing order {OrderId} for user {UserId}",
|
||||
orderId,
|
||||
userId
|
||||
);
|
||||
|
||||
// Log với exception
|
||||
try
|
||||
{
|
||||
// ...
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to process order {OrderId}", orderId);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Log Levels
|
||||
|
||||
- **Verbose**: Chi tiết nhất, dùng cho debug
|
||||
- **Debug**: Thông tin debug
|
||||
- **Information**: Thông tin thông thường (mặc định)
|
||||
- **Warning**: Cảnh báo
|
||||
- **Error**: Lỗi
|
||||
- **Fatal**: Lỗi nghiêm trọng, ứng dụng có thể crash
|
||||
|
||||
### Tích hợp với Seq (Development)
|
||||
|
||||
1. Chạy Seq trong Docker:
|
||||
```bash
|
||||
docker run -d --name seq -p 5341:80 -e ACCEPT_EULA=Y datalust/seq:latest
|
||||
```
|
||||
|
||||
2. Cập nhật appsettings.json:
|
||||
```json
|
||||
{
|
||||
"Serilog": {
|
||||
"SeqUrl": "http://localhost:5341"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Xem logs tại: http://localhost:5341
|
||||
|
||||
### Tích hợp với ELK Stack (Production)
|
||||
|
||||
1. Cài đặt Elasticsearch và Kibana
|
||||
2. Cập nhật appsettings.json với ElasticsearchUrl
|
||||
3. Xem logs trong Kibana dashboard
|
||||
|
||||
## OpenTelemetry - Distributed Tracing
|
||||
|
||||
### Tính năng
|
||||
|
||||
- **Distributed Tracing**: Theo dõi request qua nhiều services
|
||||
- **Performance Monitoring**: Đo thời gian thực thi từng operation
|
||||
- **Automatic Instrumentation**: Tự động instrument ASP.NET Core, HTTP Client, EF Core
|
||||
- **OTLP Export**: Export traces đến các hệ thống như Jaeger, Zipkin, Grafana
|
||||
|
||||
### Cấu hình
|
||||
|
||||
OpenTelemetry đã được cấu hình tự động trong `Program.cs`:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddOpenTelemetryTracing("MyNewProjectName.WebAPI");
|
||||
```
|
||||
|
||||
### Instrumentation tự động
|
||||
|
||||
- **ASP.NET Core**: Tự động trace HTTP requests/responses
|
||||
- **HTTP Client**: Trace các HTTP calls đến external APIs
|
||||
- **Entity Framework Core**: Trace database queries
|
||||
|
||||
### Xem Traces
|
||||
|
||||
#### Option 1: Console (Development)
|
||||
|
||||
Traces được export ra console, có thể thấy trong log output.
|
||||
|
||||
#### Option 2: OTLP Collector (Production)
|
||||
|
||||
1. Chạy OTLP Collector:
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
otlp-collector:
|
||||
image: otel/opentelemetry-collector:latest
|
||||
ports:
|
||||
- "4317:4317" # OTLP gRPC receiver
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
volumes:
|
||||
- ./otel-collector-config.yaml:/etc/otelcol/config.yaml
|
||||
```
|
||||
|
||||
2. Cấu hình export trong code (nếu cần):
|
||||
```csharp
|
||||
.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri("http://otlp-collector:4317");
|
||||
})
|
||||
```
|
||||
|
||||
3. Export đến Jaeger, Zipkin, hoặc Grafana Tempo
|
||||
|
||||
### Tích hợp với Jaeger
|
||||
|
||||
1. Chạy Jaeger:
|
||||
```bash
|
||||
docker run -d --name jaeger \
|
||||
-p 16686:16686 \
|
||||
-p 4317:4317 \
|
||||
jaegertracing/all-in-one:latest
|
||||
```
|
||||
|
||||
2. Xem traces tại: http://localhost:16686
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Structured Logging
|
||||
|
||||
✅ **Tốt**: Sử dụng structured logging với properties
|
||||
```csharp
|
||||
_logger.Information("User {UserId} created order {OrderId}", userId, orderId);
|
||||
```
|
||||
|
||||
❌ **Không tốt**: String interpolation
|
||||
```csharp
|
||||
_logger.Information($"User {userId} created order {orderId}");
|
||||
```
|
||||
|
||||
### 2. Log Levels
|
||||
|
||||
- **Information**: Business events (user login, order created)
|
||||
- **Warning**: Unexpected situations (retry, fallback)
|
||||
- **Error**: Exceptions và failures
|
||||
- **Debug**: Chi tiết kỹ thuật (chỉ trong development)
|
||||
|
||||
### 3. Correlation ID
|
||||
|
||||
Sử dụng correlation ID để link logs và traces:
|
||||
|
||||
```csharp
|
||||
// Trong middleware
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
LogContext.PushProperty("CorrelationId", correlationId);
|
||||
```
|
||||
|
||||
### 4. Sensitive Data
|
||||
|
||||
❌ **Không log**:
|
||||
- Passwords
|
||||
- Credit card numbers
|
||||
- API keys
|
||||
- Personal information (PII)
|
||||
|
||||
✅ **Log**:
|
||||
- User IDs (không phải username/email)
|
||||
- Order IDs
|
||||
- Request/Response metadata (không có sensitive data)
|
||||
|
||||
## Monitoring trong Docker
|
||||
|
||||
Khi chạy trong Docker, logs được ghi vào:
|
||||
- **Console**: `docker logs <container-name>`
|
||||
- **Files**: `logs/log-*.txt` trong container
|
||||
- **Seq/ELK**: Nếu đã cấu hình
|
||||
|
||||
### Xem logs trong Docker
|
||||
|
||||
```bash
|
||||
# Xem logs real-time
|
||||
docker logs -f webapi
|
||||
|
||||
# Xem logs với timestamp
|
||||
docker logs -t webapi
|
||||
|
||||
# Xem logs của 100 dòng cuối
|
||||
docker logs --tail 100 webapi
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Logs không xuất hiện
|
||||
|
||||
1. Kiểm tra `Serilog:WriteToConsole` trong appsettings.json
|
||||
2. Kiểm tra `Serilog:MinimumLevel` có quá cao không
|
||||
3. Kiểm tra Docker logs: `docker logs <container-name>`
|
||||
|
||||
### Traces không hiển thị
|
||||
|
||||
1. Kiểm tra OpenTelemetry đã được add trong Program.cs
|
||||
2. Kiểm tra OTLP endpoint nếu dùng collector
|
||||
3. Kiểm tra network connectivity đến collector
|
||||
|
||||
### Performance Impact
|
||||
|
||||
- Serilog: Minimal impact, async logging
|
||||
- OpenTelemetry: ~1-2% overhead, có thể disable trong development nếu cần
|
||||
157
docs/OptionsPattern-Usage.md
Normal file
157
docs/OptionsPattern-Usage.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Options Pattern - Hướng dẫn sử dụng
|
||||
|
||||
## Tổng quan
|
||||
|
||||
Options Pattern là cách tiếp cận **Strongly-Typed** để quản lý cấu hình trong .NET, thay vì sử dụng `IConfiguration` trực tiếp với magic strings.
|
||||
|
||||
## Lợi ích
|
||||
|
||||
1. **Type Safety**: IntelliSense hỗ trợ, tránh lỗi chính tả
|
||||
2. **Validation**: Validate cấu hình ngay khi khởi động ứng dụng
|
||||
3. **Separation of Concerns**: Tách biệt Infrastructure (config) khỏi Application logic
|
||||
4. **Testability**: Dễ dàng mock và test
|
||||
|
||||
## Cách sử dụng trong Service
|
||||
|
||||
### Ví dụ: Sử dụng JwtOptions trong Service
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.Options;
|
||||
using MyNewProjectName.Infrastructure.Options;
|
||||
|
||||
public class JwtTokenService
|
||||
{
|
||||
private readonly JwtOptions _jwtOptions;
|
||||
|
||||
// Inject IOptions<JwtOptions> thay vì IConfiguration
|
||||
public JwtTokenService(IOptions<JwtOptions> jwtOptions)
|
||||
{
|
||||
_jwtOptions = jwtOptions.Value;
|
||||
}
|
||||
|
||||
public string GenerateToken()
|
||||
{
|
||||
// Sử dụng với IntelliSense hỗ trợ
|
||||
var secretKey = _jwtOptions.SecretKey;
|
||||
var issuer = _jwtOptions.Issuer;
|
||||
var expiration = _jwtOptions.ExpirationInMinutes;
|
||||
|
||||
// ... logic tạo token
|
||||
return token;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ví dụ: Sử dụng DatabaseOptions
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.Options;
|
||||
using MyNewProjectName.Infrastructure.Options;
|
||||
|
||||
public class DatabaseService
|
||||
{
|
||||
private readonly DatabaseOptions _dbOptions;
|
||||
|
||||
public DatabaseService(IOptions<DatabaseOptions> dbOptions)
|
||||
{
|
||||
_dbOptions = dbOptions.Value;
|
||||
}
|
||||
|
||||
public string GetConnectionString()
|
||||
{
|
||||
return _dbOptions.DefaultConnection;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Các Options Classes có sẵn
|
||||
|
||||
1. **DatabaseOptions** - Cấu hình kết nối database
|
||||
2. **JwtOptions** - Cấu hình JWT authentication
|
||||
3. **RedisOptions** - Cấu hình Redis cache
|
||||
4. **SerilogOptions** - Cấu hình Serilog logging
|
||||
|
||||
## Thêm Options Class mới
|
||||
|
||||
### Bước 1: Tạo Options Class
|
||||
|
||||
```csharp
|
||||
// MyNewProjectName.Infrastructure/Options/EmailOptions.cs
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MyNewProjectName.Infrastructure.Options;
|
||||
|
||||
public class EmailOptions
|
||||
{
|
||||
public const string SectionName = "Email";
|
||||
|
||||
[Required]
|
||||
public string SmtpServer { get; set; } = string.Empty;
|
||||
|
||||
[Range(1, 65535)]
|
||||
public int SmtpPort { get; set; } = 587;
|
||||
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string FromAddress { get; set; } = string.Empty;
|
||||
}
|
||||
```
|
||||
|
||||
### Bước 2: Đăng ký trong DependencyInjection.cs
|
||||
|
||||
```csharp
|
||||
// Trong MyNewProjectName.Infrastructure/DependencyInjection.cs
|
||||
services.Configure<EmailOptions>(configuration.GetSection(EmailOptions.SectionName));
|
||||
|
||||
// Validate (optional)
|
||||
services.AddOptions<EmailOptions>()
|
||||
.Bind(configuration.GetSection(EmailOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
```
|
||||
|
||||
### Bước 3: Thêm vào appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"Email": {
|
||||
"SmtpServer": "smtp.gmail.com",
|
||||
"SmtpPort": 587,
|
||||
"FromAddress": "noreply@example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Bước 4: Sử dụng trong Service
|
||||
|
||||
```csharp
|
||||
public class EmailService
|
||||
{
|
||||
private readonly EmailOptions _emailOptions;
|
||||
|
||||
public EmailService(IOptions<EmailOptions> emailOptions)
|
||||
{
|
||||
_emailOptions = emailOptions.Value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
Options classes sử dụng Data Annotations để validate:
|
||||
|
||||
- `[Required]` - Bắt buộc phải có giá trị
|
||||
- `[MinLength]` - Độ dài tối thiểu
|
||||
- `[Range]` - Giá trị trong khoảng
|
||||
- `[EmailAddress]` - Định dạng email
|
||||
- Và nhiều attributes khác...
|
||||
|
||||
Nếu validation fail, ứng dụng sẽ **không khởi động** và báo lỗi rõ ràng.
|
||||
|
||||
## IOptions vs IOptionsSnapshot vs IOptionsMonitor
|
||||
|
||||
- **IOptions<T>**: Giá trị được cache, không tự động reload khi config thay đổi
|
||||
- **IOptionsSnapshot<T>**: Tự động reload khi config thay đổi (scoped)
|
||||
- **IOptionsMonitor<T>**: Tự động reload và có thể subscribe để nhận thông báo (singleton)
|
||||
|
||||
Thông thường, sử dụng **IOptions<T>** là đủ.
|
||||
391
docs/Redis.md
Normal file
391
docs/Redis.md
Normal file
@@ -0,0 +1,391 @@
|
||||
# Hướng Dẫn Đặt Tên Redis Key Trong Dự Án
|
||||
|
||||
> **Tham khảo:** [Redis Namespace and Other Keys to Developing with Redis](https://redis.io/blog/5-key-takeaways-for-developing-with-redis/)
|
||||
|
||||
---
|
||||
|
||||
## Mục Lục
|
||||
|
||||
1. [Nguyên Tắc Chung](#1-nguyên-tắc-chung)
|
||||
2. [Quy Ước Đặt Tên Key (Naming Convention)](#2-quy-ước-đặt-tên-key-naming-convention)
|
||||
3. [Cấu Trúc Key Theo Namespace](#3-cấu-trúc-key-theo-namespace)
|
||||
4. [Bảng Mẫu Key Theo Chức Năng](#4-bảng-mẫu-key-theo-chức-năng)
|
||||
5. [Quy Tắc Về Độ Dài Key](#5-quy-tắc-về-độ-dài-key)
|
||||
6. [Chọn Đúng Data Structure](#6-chọn-đúng-data-structure)
|
||||
7. [Quản Lý Key: SCAN thay vì KEYS](#7-quản-lý-key-scan-thay-vì-keys)
|
||||
8. [Chiến Lược TTL & Expiration](#8-chiến-lược-ttl--expiration)
|
||||
9. [Cache Invalidation](#9-cache-invalidation)
|
||||
10. [Ví Dụ Thực Tế Trong Dự Án](#10-ví-dụ-thực-tế-trong-dự-án)
|
||||
|
||||
---
|
||||
|
||||
## 1. Nguyên Tắc Chung
|
||||
|
||||
Theo bài viết từ Redis, có **5 điểm quan trọng** khi phát triển với Redis:
|
||||
|
||||
| # | Nguyên tắc | Mô tả |
|
||||
|---|-----------|-------|
|
||||
| 1 | **Namespace cho key** | Sử dụng dấu `:` để phân tách các phần của tên key, giúp dễ quản lý và tìm kiếm |
|
||||
| 2 | **Giữ key ngắn gọn** | Key name cũng chiếm bộ nhớ — key dài 12 ký tự tốn thêm ~15% RAM so với key 6 ký tự (trên 1 triệu key) |
|
||||
| 3 | **Dùng đúng data structure** | Hash, List, Set, Sorted Set — mỗi loại phù hợp với một use case khác nhau |
|
||||
| 4 | **Dùng SCAN, không dùng KEYS** | Lệnh `KEYS` có thể block server, `SCAN` an toàn hơn cho production |
|
||||
| 5 | **Sử dụng Lua Scripts** | Xử lý logic phía server để giảm latency và tối ưu hiệu suất |
|
||||
|
||||
---
|
||||
|
||||
## 2. Quy Ước Đặt Tên Key (Naming Convention)
|
||||
|
||||
### Format chung
|
||||
|
||||
```
|
||||
{project}:{service}:{entity}:{identifier}
|
||||
```
|
||||
|
||||
- **Dấu phân cách:** Luôn dùng dấu hai chấm `:` (colon) — đây là **convention chuẩn** của Redis
|
||||
- **Chữ thường:** Tất cả các phần của key đều viết **lowercase**
|
||||
- **Không dấu cách, không ký tự đặc biệt:** Chỉ dùng chữ cái, số, dấu `:` và dấu `-` hoặc `_`
|
||||
|
||||
### Đúng
|
||||
|
||||
```
|
||||
myapp:user:profile:12345
|
||||
myapp:order:detail:ORD-001
|
||||
myapp:cache:product:list:page:1
|
||||
```
|
||||
|
||||
### Sai
|
||||
|
||||
```
|
||||
MyApp_User_Profile_12345 # Dùng underscore thay vì colon, viết hoa
|
||||
user profile 12345 # Có dấu cách
|
||||
MYAPP:USER:PROFILE:12345 # Viết hoa toàn bộ (lãng phí bộ nhớ)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Cấu Trúc Key Theo Namespace
|
||||
|
||||
Áp dụng cho dự án **Clean Architecture**, cấu trúc key nên phản ánh rõ layer và module:
|
||||
|
||||
```
|
||||
{app}:{layer}:{entity}:{action/scope}:{identifier}
|
||||
```
|
||||
|
||||
### Các prefix theo layer
|
||||
|
||||
| Prefix | Ý nghĩa | Ví dụ |
|
||||
|--------|---------|-------|
|
||||
| `app:cache` | Cache dữ liệu | `app:cache:product:list` |
|
||||
| `app:session` | Quản lý session | `app:session:user:abc123` |
|
||||
| `app:rate` | Rate limiting | `app:rate:api:login:192.168.1.1` |
|
||||
| `app:lock` | Distributed lock | `app:lock:order:process:ORD-001` |
|
||||
| `app:queue` | Message queue | `app:queue:email:pending` |
|
||||
| `app:temp` | Dữ liệu tạm thời | `app:temp:otp:user:12345` |
|
||||
| `app:pub` | Pub/Sub channels | `app:pub:notifications:user:12345` |
|
||||
| `app:counter` | Bộ đếm | `app:counter:visit:page:home` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Bảng Mẫu Key Theo Chức Năng
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
| Key Pattern | Data Type | TTL | Mô tả |
|
||||
|------------|-----------|-----|--------|
|
||||
| `app:session:{sessionId}` | Hash | 30 phút | Thông tin session người dùng |
|
||||
| `app:token:refresh:{userId}` | String | 7 ngày | Refresh token |
|
||||
| `app:token:blacklist:{jti}` | String | Thời gian còn lại của token | JWT bị thu hồi |
|
||||
| `app:temp:otp:{userId}` | String | 5 phút | Mã OTP xác thực |
|
||||
| `app:rate:login:{ip}` | String (counter) | 15 phút | Giới hạn số lần đăng nhập |
|
||||
|
||||
### Cache Dữ Liệu (CRUD)
|
||||
|
||||
| Key Pattern | Data Type | TTL | Mô tả |
|
||||
|------------|-----------|-----|--------|
|
||||
| `app:cache:{entity}:detail:{id}` | Hash/String | 10 phút | Cache chi tiết 1 entity |
|
||||
| `app:cache:{entity}:list:{hash}` | String (JSON) | 5 phút | Cache danh sách có phân trang/filter |
|
||||
| `app:cache:{entity}:count` | String | 5 phút | Cache tổng số record |
|
||||
| `app:cache:{entity}:ids:all` | Set | 10 phút | Tập hợp tất cả ID của entity |
|
||||
|
||||
> **`{hash}`** là hash MD5/SHA256 của query parameters (page, filter, sort) để tạo key unique cho mỗi truy vấn khác nhau.
|
||||
|
||||
### 🔔 Real-time & Pub/Sub
|
||||
|
||||
| Key Pattern | Data Type | TTL | Mô tả |
|
||||
|------------|-----------|-----|--------|
|
||||
| `app:pub:notification:{userId}` | Channel | — | Kênh thông báo realtime |
|
||||
| `app:queue:email:pending` | List | — | Hàng đợi gửi email |
|
||||
| `app:counter:online:users` | String | — | Đếm user đang online |
|
||||
|
||||
### 🔒 Distributed Locking
|
||||
|
||||
| Key Pattern | Data Type | TTL | Mô tả |
|
||||
|------------|-----------|-----|--------|
|
||||
| `app:lock:{resource}:{id}` | String | 30 giây | Lock tài nguyên để tránh race condition |
|
||||
|
||||
---
|
||||
|
||||
## 5. Quy Tắc Về Độ Dài Key
|
||||
|
||||
Theo bài viết từ Redis:
|
||||
|
||||
> *"Storing 1,000,000 keys, each set with a 32-character value, will consume about **96MB** when using 6-character key names, and **111MB** with 12-character names. This overhead of more than **15%** becomes quite significant as your number of keys grows."*
|
||||
|
||||
### Hướng dẫn cân bằng
|
||||
|
||||
| Quy tắc | Chi tiết |
|
||||
|---------|---------|
|
||||
| **Tối đa 50 ký tự** | Giữ tổng chiều dài key không quá 50 ký tự |
|
||||
| **Viết tắt hợp lý** | `usr` thay vì `user`, `prod` thay vì `product` — nhưng phải có **bảng chú giải** |
|
||||
| **Không lạm dụng viết tắt** | Key phải đọc được, tránh như `a:b:c:d:1` |
|
||||
| **Ưu tiên rõ ràng nếu < 10K keys** | Nếu dataset nhỏ, ưu tiên key dễ đọc hơn key ngắn |
|
||||
|
||||
### Bảng viết tắt chuẩn (nếu cần tối ưu)
|
||||
|
||||
| Viết tắt | Đầy đủ |
|
||||
|----------|--------|
|
||||
| `usr` | user |
|
||||
| `prod` | product |
|
||||
| `ord` | order |
|
||||
| `sess` | session |
|
||||
| `notif` | notification |
|
||||
| `cfg` | config |
|
||||
| `inv` | inventory |
|
||||
| `txn` | transaction |
|
||||
|
||||
---
|
||||
|
||||
## 6. Chọn Đúng Data Structure
|
||||
|
||||
Theo bài viết, việc chọn đúng cấu trúc dữ liệu giúp **tối ưu bộ nhớ và hiệu suất**:
|
||||
|
||||
| Data Structure | Khi nào dùng | Ví dụ trong dự án |
|
||||
|---------------|-------------|-------------------|
|
||||
| **String** | Giá trị đơn giản, counter, cache JSON | `app:cache:product:detail:123` → JSON string |
|
||||
| **Hash** | Object có nhiều field, profile user | `app:session:abc123` → `{userId, role, name, exp}` |
|
||||
| **List** | Queue, danh sách có thứ tự, cho phép trùng | `app:queue:email:pending` → FIFO queue |
|
||||
| **Set** | Tập hợp unique, kiểm tra membership | `app:cache:user:ids:all` → tập hợp user IDs |
|
||||
| **Sorted Set** | Leaderboard, ranking, timeline | `app:rank:score:board:game1` → ranking theo điểm |
|
||||
| **Bitmap** | Track true/false cho lượng lớn, analytics | `app:analytics:daily:login:2026-02-23` → bit per user |
|
||||
|
||||
### 💡 Tips quan trọng:
|
||||
- **Hash thay vì nhiều String**: Nhóm dữ liệu liên quan vào 1 Hash thay vì tạo nhiều key String riêng lẻ → tiết kiệm bộ nhớ đáng kể
|
||||
- **List thay vì Set**: Nếu không cần kiểm tra uniqueness, List nhanh hơn và tốn ít RAM hơn
|
||||
- **Tránh Sorted Set nếu không cần ranking**: Sorted Set tốn nhiều bộ nhớ và phức tạp nhất
|
||||
|
||||
---
|
||||
|
||||
## 7. Quản Lý Key: SCAN thay vì KEYS
|
||||
|
||||
### ⚠️ KHÔNG BAO GIỜ dùng `KEYS` trong production!
|
||||
|
||||
Lệnh `KEYS *` sẽ:
|
||||
- **Block toàn bộ Redis server** cho đến khi hoàn thành
|
||||
- **Tiêu tốn RAM** nguy hiểm
|
||||
- Gây **downtime** nếu dataset lớn
|
||||
|
||||
### Dùng `SCAN` để duyệt key an toàn
|
||||
|
||||
```bash
|
||||
# Cú pháp
|
||||
SCAN cursor [MATCH pattern] [COUNT count]
|
||||
|
||||
# Ví dụ: Tìm tất cả cache key của product
|
||||
SCAN 0 MATCH "app:cache:product:*" COUNT 100
|
||||
|
||||
# Dùng HSCAN cho Hash
|
||||
HSCAN app:session:abc123 0 MATCH "*"
|
||||
|
||||
# Dùng SSCAN cho Set
|
||||
SSCAN app:cache:user:ids:all 0 COUNT 50
|
||||
```
|
||||
|
||||
### Trong code C# (StackExchange.Redis):
|
||||
```csharp
|
||||
// Đúng: Dùng SCAN
|
||||
var server = redis.GetServer(endpoint);
|
||||
var keys = server.Keys(pattern: "app:cache:product:*", pageSize: 100);
|
||||
|
||||
// Sai: Dùng KEYS (block server)
|
||||
// var keys = server.Keys(pattern: "app:cache:product:*", pageSize: int.MaxValue);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Chiến Lược TTL & Expiration
|
||||
|
||||
| Loại dữ liệu | TTL khuyến nghị | Lý do |
|
||||
|--------------|----------------|-------|
|
||||
| **Cache API response** | 5 – 15 phút | Đảm bảo data tương đối fresh |
|
||||
| **Session** | 30 phút – 2 giờ | Theo session timeout của app |
|
||||
| **OTP / Verification** | 3 – 10 phút | Bảo mật |
|
||||
| **Refresh Token** | 7 – 30 ngày | Theo chính sách auth |
|
||||
| **Rate Limit Counter** | 1 – 60 phút | Theo window rate limit |
|
||||
| **Distributed Lock** | 10 – 60 giây | Tránh deadlock |
|
||||
| **Analytics / Counter** | Không expire hoặc 24h | Tùy yêu cầu business |
|
||||
|
||||
### ⚡ Luôn đặt TTL cho mọi cache key!
|
||||
|
||||
```csharp
|
||||
// Luôn set expiration khi SET
|
||||
await db.StringSetAsync("app:cache:product:detail:123", jsonData, TimeSpan.FromMinutes(10));
|
||||
|
||||
// Hoặc set TTL riêng
|
||||
await db.KeyExpireAsync("app:cache:product:detail:123", TimeSpan.FromMinutes(10));
|
||||
```
|
||||
|
||||
> ⚠️ **Cảnh báo:** Key không có TTL sẽ tồn tại mãi mãi → nguy cơ memory leak!
|
||||
|
||||
---
|
||||
|
||||
## 9. Cache Invalidation
|
||||
|
||||
Khi dữ liệu thay đổi trong database chính (SQL, MongoDB...), cần **xóa cache Redis tương ứng**:
|
||||
|
||||
### Pattern: Tag-based Invalidation
|
||||
|
||||
```
|
||||
# Khi tạo cache, thêm key vào một Set quản lý
|
||||
SADD app:tags:product app:cache:product:detail:123
|
||||
SADD app:tags:product app:cache:product:list:abc
|
||||
SADD app:tags:product app:cache:product:count
|
||||
|
||||
# Khi invalidate, duyệt Set và xóa tất cả
|
||||
SMEMBERS app:tags:product → lấy tất cả key liên quan
|
||||
DEL app:cache:product:detail:123 app:cache:product:list:abc ...
|
||||
DEL app:tags:product
|
||||
```
|
||||
|
||||
### Trong code C#:
|
||||
|
||||
```csharp
|
||||
public async Task InvalidateCacheByTagAsync(string tag)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
var tagKey = $"app:tags:{tag}";
|
||||
|
||||
// Lấy tất cả cache key thuộc tag này
|
||||
var members = await db.SetMembersAsync(tagKey);
|
||||
|
||||
if (members.Length > 0)
|
||||
{
|
||||
// Xóa tất cả cache key
|
||||
var keys = members.Select(m => (RedisKey)m.ToString()).ToArray();
|
||||
await db.KeyDeleteAsync(keys);
|
||||
}
|
||||
|
||||
// Xóa luôn tag set
|
||||
await db.KeyDeleteAsync(tagKey);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Ví Dụ Thực Tế Trong Dự Án
|
||||
|
||||
Áp dụng quy ước cho dự án **MyNewProjectName** (Clean Architecture):
|
||||
|
||||
### Entity: `SampleEntity`
|
||||
|
||||
```
|
||||
# Cache chi tiết
|
||||
app:cache:sample:detail:{id}
|
||||
|
||||
# Cache danh sách (hash = MD5 của query params)
|
||||
app:cache:sample:list:{queryHash}
|
||||
|
||||
# Cache count
|
||||
app:cache:sample:count
|
||||
|
||||
# Tag để invalidation
|
||||
app:tags:sample → Set chứa tất cả key cache liên quan
|
||||
|
||||
# Lock khi cập nhật
|
||||
app:lock:sample:update:{id}
|
||||
```
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
```
|
||||
# Session sau khi login
|
||||
app:session:{sessionId} → Hash { userId, role, loginAt, ip }
|
||||
|
||||
# Refresh token
|
||||
app:token:refresh:{userId} → "eyJhbGciOi..."
|
||||
|
||||
# OTP xác thực email
|
||||
app:temp:otp:{userId} → "123456" (TTL: 5 phút)
|
||||
|
||||
# Blacklist JWT đã revoke
|
||||
app:token:blacklist:{jti} → "1" (TTL: thời gian còn lại của token)
|
||||
|
||||
# Rate limit login
|
||||
app:rate:login:{ip} → counter (TTL: 15 phút, max: 5 lần)
|
||||
```
|
||||
|
||||
### Constant class trong C#
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Định nghĩa tất cả Redis key patterns sử dụng trong dự án.
|
||||
/// Sử dụng dấu ':' làm namespace separator theo convention chuẩn Redis.
|
||||
/// </summary>
|
||||
public static class RedisKeyPatterns
|
||||
{
|
||||
private const string Prefix = "app";
|
||||
|
||||
// ── Cache ──────────────────────────────────
|
||||
public static string CacheDetail(string entity, string id)
|
||||
=> $"{Prefix}:cache:{entity}:detail:{id}";
|
||||
|
||||
public static string CacheList(string entity, string queryHash)
|
||||
=> $"{Prefix}:cache:{entity}:list:{queryHash}";
|
||||
|
||||
public static string CacheCount(string entity)
|
||||
=> $"{Prefix}:cache:{entity}:count";
|
||||
|
||||
// ── Tags (cho cache invalidation) ──────────
|
||||
public static string Tag(string entity)
|
||||
=> $"{Prefix}:tags:{entity}";
|
||||
|
||||
// ── Session ────────────────────────────────
|
||||
public static string Session(string sessionId)
|
||||
=> $"{Prefix}:session:{sessionId}";
|
||||
|
||||
// ── Token ──────────────────────────────────
|
||||
public static string RefreshToken(string userId)
|
||||
=> $"{Prefix}:token:refresh:{userId}";
|
||||
|
||||
public static string BlacklistToken(string jti)
|
||||
=> $"{Prefix}:token:blacklist:{jti}";
|
||||
|
||||
// ── Temporary ──────────────────────────────
|
||||
public static string Otp(string userId)
|
||||
=> $"{Prefix}:temp:otp:{userId}";
|
||||
|
||||
// ── Rate Limiting ──────────────────────────
|
||||
public static string RateLimit(string action, string identifier)
|
||||
=> $"{Prefix}:rate:{action}:{identifier}";
|
||||
|
||||
// ── Distributed Lock ───────────────────────
|
||||
public static string Lock(string resource, string id)
|
||||
=> $"{Prefix}:lock:{resource}:{id}";
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Checklist Trước Khi Tạo Key Mới
|
||||
|
||||
- [ ] Key có sử dụng namespace với dấu `:` không?
|
||||
- [ ] Key có phản ánh đúng layer/module không?
|
||||
- [ ] Key có ngắn gọn nhưng vẫn dễ hiểu không? (< 50 ký tự)
|
||||
- [ ] Đã chọn đúng data structure (String/Hash/List/Set/Sorted Set)?
|
||||
- [ ] Đã đặt TTL phù hợp cho key?
|
||||
- [ ] Đã có chiến lược invalidation khi data thay đổi?
|
||||
- [ ] Đã thêm key pattern vào class `RedisKeyPatterns`?
|
||||
- [ ] Không dùng `KEYS` command trong code production?
|
||||
|
||||
---
|
||||
|
||||
> **💡 Ghi nhớ:** *"Luôn namespace key bằng dấu `:`, giữ key ngắn gọn, chọn đúng data structure, dùng SCAN thay vì KEYS, và luôn đặt TTL!"*
|
||||
BIN
docs/image.png
Normal file
BIN
docs/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 MiB |
67
rename-project.ps1
Normal file
67
rename-project.ps1
Normal file
@@ -0,0 +1,67 @@
|
||||
# PowerShell script to rename the project from iYHCT360 to a new name
|
||||
# Usage: .\rename-project.ps1 -NewName "NewProjectName"
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$NewName
|
||||
)
|
||||
|
||||
$OldName = "iYHCT360"
|
||||
|
||||
Write-Host "🔄 Renaming project from '$OldName' to '$NewName'..." -ForegroundColor Cyan
|
||||
|
||||
# Clean build artifacts first
|
||||
Write-Host "🧹 Cleaning build artifacts..." -ForegroundColor Yellow
|
||||
Get-ChildItem -Path . -Include bin,obj -Recurse -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Replace content in files
|
||||
Write-Host "📝 Replacing content in files..." -ForegroundColor Yellow
|
||||
$extensions = @("*.cs", "*.csproj", "*.sln", "*.json", "*.xml", "*.md", "*.props", "*.targets", "*.http")
|
||||
$excludePaths = @("*\bin\*", "*\obj\*", "*\.git\*")
|
||||
|
||||
foreach ($ext in $extensions) {
|
||||
Get-ChildItem -Path . -Filter $ext -Recurse -File |
|
||||
Where-Object {
|
||||
$path = $_.FullName
|
||||
-not ($excludePaths | Where-Object { $path -like $_ })
|
||||
} |
|
||||
ForEach-Object {
|
||||
$content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue
|
||||
if ($content -and $content -match $OldName) {
|
||||
Write-Host " Updating: $($_.FullName)" -ForegroundColor Gray
|
||||
$newContent = $content -replace $OldName, $NewName
|
||||
Set-Content -Path $_.FullName -Value $newContent -NoNewline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Rename files
|
||||
Write-Host "📁 Renaming files..." -ForegroundColor Yellow
|
||||
Get-ChildItem -Path . -Recurse -File |
|
||||
Where-Object { $_.Name -match $OldName } |
|
||||
ForEach-Object {
|
||||
$newFileName = $_.Name -replace $OldName, $NewName
|
||||
$newPath = Join-Path $_.Directory.FullName $newFileName
|
||||
Write-Host " Renaming: $($_.Name) -> $newFileName" -ForegroundColor Gray
|
||||
Rename-Item -Path $_.FullName -NewName $newFileName
|
||||
}
|
||||
|
||||
# Rename directories (from deepest to shallowest)
|
||||
Write-Host "📁 Renaming directories..." -ForegroundColor Yellow
|
||||
Get-ChildItem -Path . -Recurse -Directory |
|
||||
Where-Object { $_.Name -match $OldName } |
|
||||
Sort-Object { $_.FullName.Length } -Descending |
|
||||
ForEach-Object {
|
||||
$newDirName = $_.Name -replace $OldName, $NewName
|
||||
Write-Host " Renaming: $($_.Name) -> $newDirName" -ForegroundColor Gray
|
||||
Rename-Item -Path $_.FullName -NewName $newDirName
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " Project renamed successfully from '$OldName' to '$NewName'!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Next steps:" -ForegroundColor Cyan
|
||||
Write-Host " 1. Review the changes"
|
||||
Write-Host " 2. Update your connection string in appsettings.json"
|
||||
Write-Host " 3. Run 'dotnet restore'"
|
||||
Write-Host " 4. Run 'dotnet build'"
|
||||
81
rename-project.sh
Executable file
81
rename-project.sh
Executable file
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to rename the project from iYHCT360 to a new name
|
||||
# Usage: ./rename-project.sh NewProjectName
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: ./rename-project.sh NewProjectName"
|
||||
echo "Example: ./rename-project.sh MyAwesomeApp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OLD_NAME="iYHCT360"
|
||||
NEW_NAME="$1"
|
||||
|
||||
echo "🔄 Renaming project from '$OLD_NAME' to '$NEW_NAME'..."
|
||||
|
||||
# Function to rename files and directories
|
||||
rename_files() {
|
||||
# Rename directories (from deepest to shallowest)
|
||||
find . -depth -type d -name "*${OLD_NAME}*" 2>/dev/null | while read dir; do
|
||||
new_dir=$(echo "$dir" | sed "s/${OLD_NAME}/${NEW_NAME}/g")
|
||||
if [ "$dir" != "$new_dir" ]; then
|
||||
echo " Renaming directory: $dir -> $new_dir"
|
||||
mv "$dir" "$new_dir"
|
||||
fi
|
||||
done
|
||||
|
||||
# Rename files
|
||||
find . -type f -name "*${OLD_NAME}*" 2>/dev/null | while read file; do
|
||||
new_file=$(echo "$file" | sed "s/${OLD_NAME}/${NEW_NAME}/g")
|
||||
if [ "$file" != "$new_file" ]; then
|
||||
echo " Renaming file: $file -> $new_file"
|
||||
mv "$file" "$new_file"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Function to replace content in files
|
||||
replace_content() {
|
||||
echo "📝 Replacing content in files..."
|
||||
|
||||
# Find all text files and replace content
|
||||
find . -type f \( \
|
||||
-name "*.cs" -o \
|
||||
-name "*.csproj" -o \
|
||||
-name "*.sln" -o \
|
||||
-name "*.json" -o \
|
||||
-name "*.xml" -o \
|
||||
-name "*.md" -o \
|
||||
-name "*.props" -o \
|
||||
-name "*.targets" -o \
|
||||
-name "*.http" \
|
||||
\) ! -path "*/bin/*" ! -path "*/obj/*" ! -path "*/.git/*" 2>/dev/null | while read file; do
|
||||
if grep -q "${OLD_NAME}" "$file" 2>/dev/null; then
|
||||
echo " Updating: $file"
|
||||
sed -i "s/${OLD_NAME}/${NEW_NAME}/g" "$file"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Clean build artifacts first
|
||||
echo "🧹 Cleaning build artifacts..."
|
||||
find . -type d \( -name "bin" -o -name "obj" \) -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# Replace content first (before renaming directories)
|
||||
replace_content
|
||||
|
||||
# Then rename files and directories
|
||||
echo "📁 Renaming files and directories..."
|
||||
rename_files
|
||||
|
||||
echo ""
|
||||
echo " Project renamed successfully from '$OLD_NAME' to '$NEW_NAME'!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Review the changes"
|
||||
echo " 2. Update your connection string in appsettings.json"
|
||||
echo " 3. Run 'dotnet restore'"
|
||||
echo " 4. Run 'dotnet build'"
|
||||
Reference in New Issue
Block a user