From 4b7236493f0dd16425de75724ae7c93ddcbb443d Mon Sep 17 00:00:00 2001 From: Te amo Date: Thu, 26 Feb 2026 14:04:18 +0700 Subject: [PATCH] first commit --- .editorconfig | 82 +++ .gitignore | 50 ++ Directory.Build.props | 10 + Dockerfile | 30 ++ Dockerfile.admin | 30 ++ .../Controllers/BaseApiController.cs | 16 + .../Middleware/CorrelationIdMiddleware.cs | 36 ++ .../Middleware/ExceptionHandlingMiddleware.cs | 114 +++++ .../RequestResponseLoggingMiddleware.cs | 133 +++++ .../MyNewProjectName.AdminAPI.csproj | 33 ++ .../MyNewProjectName.AdminAPI.http | 6 + MyNewProjectName.AdminAPI/Program.cs | 76 +++ .../Properties/launchSettings.json | 23 + .../appsettings.Development.json | 8 + MyNewProjectName.AdminAPI/appsettings.json | 43 ++ .../Behaviors/LoggingBehavior.cs | 49 ++ .../Behaviors/ValidationBehavior.cs | 47 ++ .../DependencyInjection.cs | 36 ++ .../CreateSample/CreateSampleCommand.cs | 8 + .../CreateSampleCommandHandler.cs | 37 ++ .../CreateSampleCommandValidator.cs | 19 + .../Queries/GetSamples/GetSamplesQuery.cs | 8 + .../GetSamples/GetSamplesQueryHandler.cs | 27 + .../Sample/Queries/GetSamples/SampleDto.cs | 22 + .../Interfaces/Common/IJwtTokenGenerator.cs | 14 + .../Interfaces/Common/IPasswordHasher.cs | 8 + .../Interfaces/ICurrentUserService.cs | 12 + .../Interfaces/IDateTimeService.cs | 10 + .../Mappings/IMapFrom.cs | 11 + .../Mappings/MappingProfile.cs | 55 ++ .../MyNewProjectName.Application.csproj | 15 + .../Common/PagedList.cs | 37 ++ .../Common/PaginationParams.cs | 19 + .../Common/ServiceResponse.cs | 21 + .../DTOs/Requests/CreateSampleRequest.cs | 10 + .../DTOs/Requests/PaginatedRequest.cs | 13 + .../DTOs/Responses/ApiResponse.cs | 68 +++ .../DTOs/Responses/PaginatedResponse.cs | 14 + .../DTOs/Responses/SampleResponse.cs | 13 + .../MyNewProjectName.Contracts.csproj | 9 + .../Common/AuditableEntity.cs | 12 + MyNewProjectName.Domain/Common/BaseEntity.cs | 36 ++ MyNewProjectName.Domain/Common/ISoftDelete.cs | 11 + .../Entities/SampleEntity.cs | 13 + .../Events/BaseDomainEvent.cs | 11 + .../Exceptions/DomainException.cs | 21 + .../Exceptions/NotFoundException.cs | 27 + .../Exceptions/ValidationException.cs | 27 + .../Interfaces/IRepository.cs | 24 + .../Interfaces/IUnitOfWork.cs | 12 + .../MyNewProjectName.Domain.csproj | 9 + .../ValueObjects/ValueObject.cs | 43 ++ .../DependencyInjection.cs | 65 +++ .../Extensions/OpenTelemetryExtensions.cs | 78 +++ .../Extensions/SerilogExtensions.cs | 89 ++++ .../Identity/JwtTokenGenerator.cs | 85 ++++ .../MyNewProjectName.Infrastructure.csproj | 34 ++ .../Options/DatabaseOptions.cs | 17 + .../Options/JwtOptions.cs | 42 ++ .../Options/RedisOptions.cs | 24 + .../Options/SerilogOptions.cs | 54 ++ .../SampleEntityConfiguration.cs | 35 ++ .../Context/ApplicationDbContext.cs | 45 ++ .../Persistence/Repositories/Repository.cs | 90 ++++ .../Persistence/Repositories/UnitOfWork.cs | 71 +++ .../Services/CurrentUserService.cs | 69 +++ .../Services/DateTimeService.cs | 12 + .../CreateSampleCommandValidatorTests.cs | 82 +++ .../Domain/BaseEntityTests.cs | 59 +++ .../Domain/ValueObjectTests.cs | 60 +++ .../MyNewProjectName.UnitTest.csproj | 26 + .../Controllers/BaseApiController.cs | 16 + .../Controllers/SamplesController.cs | 37 ++ .../Middleware/CorrelationIdMiddleware.cs | 36 ++ .../Middleware/ExceptionHandlingMiddleware.cs | 114 +++++ .../RequestResponseLoggingMiddleware.cs | 133 +++++ .../MyNewProjectName.WebAPI.csproj | 33 ++ .../MyNewProjectName.WebAPI.http | 6 + MyNewProjectName.WebAPI/Program.cs | 75 +++ .../Properties/launchSettings.json | 23 + .../appsettings.Development.json | 8 + MyNewProjectName.WebAPI/appsettings.json | 43 ++ MyNewProjectName.sln | 118 +++++ docker-compose.yml | 50 ++ docs/GitBranch.md | 334 +++++++++++++ docs/GitCommit.md | 468 ++++++++++++++++++ docs/Observability-Guide.md | 254 ++++++++++ docs/OptionsPattern-Usage.md | 157 ++++++ docs/Redis.md | 391 +++++++++++++++ docs/image.png | Bin 0 -> 6019220 bytes rename-project.ps1 | 67 +++ rename-project.sh | 81 +++ 92 files changed, 4999 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 Dockerfile create mode 100644 Dockerfile.admin create mode 100644 MyNewProjectName.AdminAPI/Controllers/BaseApiController.cs create mode 100644 MyNewProjectName.AdminAPI/Middleware/CorrelationIdMiddleware.cs create mode 100644 MyNewProjectName.AdminAPI/Middleware/ExceptionHandlingMiddleware.cs create mode 100644 MyNewProjectName.AdminAPI/Middleware/RequestResponseLoggingMiddleware.cs create mode 100644 MyNewProjectName.AdminAPI/MyNewProjectName.AdminAPI.csproj create mode 100644 MyNewProjectName.AdminAPI/MyNewProjectName.AdminAPI.http create mode 100644 MyNewProjectName.AdminAPI/Program.cs create mode 100644 MyNewProjectName.AdminAPI/Properties/launchSettings.json create mode 100644 MyNewProjectName.AdminAPI/appsettings.Development.json create mode 100644 MyNewProjectName.AdminAPI/appsettings.json create mode 100644 MyNewProjectName.Application/Behaviors/LoggingBehavior.cs create mode 100644 MyNewProjectName.Application/Behaviors/ValidationBehavior.cs create mode 100644 MyNewProjectName.Application/DependencyInjection.cs create mode 100644 MyNewProjectName.Application/Features/Sample/Commands/CreateSample/CreateSampleCommand.cs create mode 100644 MyNewProjectName.Application/Features/Sample/Commands/CreateSample/CreateSampleCommandHandler.cs create mode 100644 MyNewProjectName.Application/Features/Sample/Commands/CreateSample/CreateSampleCommandValidator.cs create mode 100644 MyNewProjectName.Application/Features/Sample/Queries/GetSamples/GetSamplesQuery.cs create mode 100644 MyNewProjectName.Application/Features/Sample/Queries/GetSamples/GetSamplesQueryHandler.cs create mode 100644 MyNewProjectName.Application/Features/Sample/Queries/GetSamples/SampleDto.cs create mode 100644 MyNewProjectName.Application/Interfaces/Common/IJwtTokenGenerator.cs create mode 100644 MyNewProjectName.Application/Interfaces/Common/IPasswordHasher.cs create mode 100644 MyNewProjectName.Application/Interfaces/ICurrentUserService.cs create mode 100644 MyNewProjectName.Application/Interfaces/IDateTimeService.cs create mode 100644 MyNewProjectName.Application/Mappings/IMapFrom.cs create mode 100644 MyNewProjectName.Application/Mappings/MappingProfile.cs create mode 100644 MyNewProjectName.Application/MyNewProjectName.Application.csproj create mode 100644 MyNewProjectName.Contracts/Common/PagedList.cs create mode 100644 MyNewProjectName.Contracts/Common/PaginationParams.cs create mode 100644 MyNewProjectName.Contracts/Common/ServiceResponse.cs create mode 100644 MyNewProjectName.Contracts/DTOs/Requests/CreateSampleRequest.cs create mode 100644 MyNewProjectName.Contracts/DTOs/Requests/PaginatedRequest.cs create mode 100644 MyNewProjectName.Contracts/DTOs/Responses/ApiResponse.cs create mode 100644 MyNewProjectName.Contracts/DTOs/Responses/PaginatedResponse.cs create mode 100644 MyNewProjectName.Contracts/DTOs/Responses/SampleResponse.cs create mode 100644 MyNewProjectName.Contracts/MyNewProjectName.Contracts.csproj create mode 100644 MyNewProjectName.Domain/Common/AuditableEntity.cs create mode 100644 MyNewProjectName.Domain/Common/BaseEntity.cs create mode 100644 MyNewProjectName.Domain/Common/ISoftDelete.cs create mode 100644 MyNewProjectName.Domain/Entities/SampleEntity.cs create mode 100644 MyNewProjectName.Domain/Events/BaseDomainEvent.cs create mode 100644 MyNewProjectName.Domain/Exceptions/DomainException.cs create mode 100644 MyNewProjectName.Domain/Exceptions/NotFoundException.cs create mode 100644 MyNewProjectName.Domain/Exceptions/ValidationException.cs create mode 100644 MyNewProjectName.Domain/Interfaces/IRepository.cs create mode 100644 MyNewProjectName.Domain/Interfaces/IUnitOfWork.cs create mode 100644 MyNewProjectName.Domain/MyNewProjectName.Domain.csproj create mode 100644 MyNewProjectName.Domain/ValueObjects/ValueObject.cs create mode 100644 MyNewProjectName.Infrastructure/DependencyInjection.cs create mode 100644 MyNewProjectName.Infrastructure/Extensions/OpenTelemetryExtensions.cs create mode 100644 MyNewProjectName.Infrastructure/Extensions/SerilogExtensions.cs create mode 100644 MyNewProjectName.Infrastructure/Identity/JwtTokenGenerator.cs create mode 100644 MyNewProjectName.Infrastructure/MyNewProjectName.Infrastructure.csproj create mode 100644 MyNewProjectName.Infrastructure/Options/DatabaseOptions.cs create mode 100644 MyNewProjectName.Infrastructure/Options/JwtOptions.cs create mode 100644 MyNewProjectName.Infrastructure/Options/RedisOptions.cs create mode 100644 MyNewProjectName.Infrastructure/Options/SerilogOptions.cs create mode 100644 MyNewProjectName.Infrastructure/Persistence/Configurations/SampleEntityConfiguration.cs create mode 100644 MyNewProjectName.Infrastructure/Persistence/Context/ApplicationDbContext.cs create mode 100644 MyNewProjectName.Infrastructure/Persistence/Repositories/Repository.cs create mode 100644 MyNewProjectName.Infrastructure/Persistence/Repositories/UnitOfWork.cs create mode 100644 MyNewProjectName.Infrastructure/Services/CurrentUserService.cs create mode 100644 MyNewProjectName.Infrastructure/Services/DateTimeService.cs create mode 100644 MyNewProjectName.UnitTest/Application/CreateSampleCommandValidatorTests.cs create mode 100644 MyNewProjectName.UnitTest/Domain/BaseEntityTests.cs create mode 100644 MyNewProjectName.UnitTest/Domain/ValueObjectTests.cs create mode 100644 MyNewProjectName.UnitTest/MyNewProjectName.UnitTest.csproj create mode 100644 MyNewProjectName.WebAPI/Controllers/BaseApiController.cs create mode 100644 MyNewProjectName.WebAPI/Controllers/SamplesController.cs create mode 100644 MyNewProjectName.WebAPI/Middleware/CorrelationIdMiddleware.cs create mode 100644 MyNewProjectName.WebAPI/Middleware/ExceptionHandlingMiddleware.cs create mode 100644 MyNewProjectName.WebAPI/Middleware/RequestResponseLoggingMiddleware.cs create mode 100644 MyNewProjectName.WebAPI/MyNewProjectName.WebAPI.csproj create mode 100644 MyNewProjectName.WebAPI/MyNewProjectName.WebAPI.http create mode 100644 MyNewProjectName.WebAPI/Program.cs create mode 100644 MyNewProjectName.WebAPI/Properties/launchSettings.json create mode 100644 MyNewProjectName.WebAPI/appsettings.Development.json create mode 100644 MyNewProjectName.WebAPI/appsettings.json create mode 100644 MyNewProjectName.sln create mode 100644 docker-compose.yml create mode 100644 docs/GitBranch.md create mode 100644 docs/GitCommit.md create mode 100644 docs/Observability-Guide.md create mode 100644 docs/OptionsPattern-Usage.md create mode 100644 docs/Redis.md create mode 100644 docs/image.png create mode 100644 rename-project.ps1 create mode 100755 rename-project.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f1edd71 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..caa2195 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..7135ddc --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + net9.0 + enable + enable + false + true + $(NoWarn);1591 + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e10dba4 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.admin b/Dockerfile.admin new file mode 100644 index 0000000..e4d0a3c --- /dev/null +++ b/Dockerfile.admin @@ -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"] diff --git a/MyNewProjectName.AdminAPI/Controllers/BaseApiController.cs b/MyNewProjectName.AdminAPI/Controllers/BaseApiController.cs new file mode 100644 index 0000000..c8e6188 --- /dev/null +++ b/MyNewProjectName.AdminAPI/Controllers/BaseApiController.cs @@ -0,0 +1,16 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace MyNewProjectName.AdminAPI.Controllers; + +/// +/// Base API controller with common functionality +/// +[ApiController] +[Route("api/admin/[controller]")] +public abstract class BaseApiController : ControllerBase +{ + private ISender? _mediator; + + protected ISender Mediator => _mediator ??= HttpContext.RequestServices.GetRequiredService(); +} diff --git a/MyNewProjectName.AdminAPI/Middleware/CorrelationIdMiddleware.cs b/MyNewProjectName.AdminAPI/Middleware/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..abcd391 --- /dev/null +++ b/MyNewProjectName.AdminAPI/Middleware/CorrelationIdMiddleware.cs @@ -0,0 +1,36 @@ +using Serilog.Context; + +namespace MyNewProjectName.AdminAPI.Middleware; + +/// +/// Middleware to generate and track correlation ID for each request +/// This ID is added to HTTP headers and Serilog log context for easy tracing +/// +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); + } + } +} diff --git a/MyNewProjectName.AdminAPI/Middleware/ExceptionHandlingMiddleware.cs b/MyNewProjectName.AdminAPI/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..6757069 --- /dev/null +++ b/MyNewProjectName.AdminAPI/Middleware/ExceptionHandlingMiddleware.cs @@ -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; + +/// +/// 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 +/// +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()), + + DomainException => ( + HttpStatusCode.BadRequest, + exception.Message, + new List()), + + UnauthorizedAccessException => ( + HttpStatusCode.Unauthorized, + "Unauthorized", + new List()), + + _ => ( + HttpStatusCode.InternalServerError, + "An error occurred while processing your request", + new List()) + }; + + 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); + } +} diff --git a/MyNewProjectName.AdminAPI/Middleware/RequestResponseLoggingMiddleware.cs b/MyNewProjectName.AdminAPI/Middleware/RequestResponseLoggingMiddleware.cs new file mode 100644 index 0000000..c7a6009 --- /dev/null +++ b/MyNewProjectName.AdminAPI/Middleware/RequestResponseLoggingMiddleware.cs @@ -0,0 +1,133 @@ +using System.Diagnostics; +using System.Text; +using Microsoft.Extensions.Configuration; +using Serilog; + +namespace MyNewProjectName.AdminAPI.Middleware; + +/// +/// 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. +/// +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("Logging:EnableRequestLogging", false); + _enableResponseLogging = configuration.GetValue("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 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 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; + } +} diff --git a/MyNewProjectName.AdminAPI/MyNewProjectName.AdminAPI.csproj b/MyNewProjectName.AdminAPI/MyNewProjectName.AdminAPI.csproj new file mode 100644 index 0000000..4441521 --- /dev/null +++ b/MyNewProjectName.AdminAPI/MyNewProjectName.AdminAPI.csproj @@ -0,0 +1,33 @@ + + + + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MyNewProjectName.AdminAPI/MyNewProjectName.AdminAPI.http b/MyNewProjectName.AdminAPI/MyNewProjectName.AdminAPI.http new file mode 100644 index 0000000..dd9f259 --- /dev/null +++ b/MyNewProjectName.AdminAPI/MyNewProjectName.AdminAPI.http @@ -0,0 +1,6 @@ +@MyNewProjectName.AdminAPI_HostAddress = http://localhost:5011 + +GET {{MyNewProjectName.AdminAPI_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/MyNewProjectName.AdminAPI/Program.cs b/MyNewProjectName.AdminAPI/Program.cs new file mode 100644 index 0000000..fd1b666 --- /dev/null +++ b/MyNewProjectName.AdminAPI/Program.cs @@ -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(); + +// 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(); + +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(); + +app.MapControllers(); + +try +{ + Log.Information("Starting MyNewProjectName.AdminAPI"); + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); + throw; +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/MyNewProjectName.AdminAPI/Properties/launchSettings.json b/MyNewProjectName.AdminAPI/Properties/launchSettings.json new file mode 100644 index 0000000..b2f08be --- /dev/null +++ b/MyNewProjectName.AdminAPI/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/MyNewProjectName.AdminAPI/appsettings.Development.json b/MyNewProjectName.AdminAPI/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/MyNewProjectName.AdminAPI/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/MyNewProjectName.AdminAPI/appsettings.json b/MyNewProjectName.AdminAPI/appsettings.json new file mode 100644 index 0000000..2aa297e --- /dev/null +++ b/MyNewProjectName.AdminAPI/appsettings.json @@ -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": "*" +} diff --git a/MyNewProjectName.Application/Behaviors/LoggingBehavior.cs b/MyNewProjectName.Application/Behaviors/LoggingBehavior.cs new file mode 100644 index 0000000..7faa916 --- /dev/null +++ b/MyNewProjectName.Application/Behaviors/LoggingBehavior.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace MyNewProjectName.Application.Behaviors; + +/// +/// Logging behavior for MediatR pipeline +/// +public class LoggingBehavior : IPipelineBehavior + where TRequest : notnull +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate 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; + } + } +} diff --git a/MyNewProjectName.Application/Behaviors/ValidationBehavior.cs b/MyNewProjectName.Application/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..42e6a5d --- /dev/null +++ b/MyNewProjectName.Application/Behaviors/ValidationBehavior.cs @@ -0,0 +1,47 @@ +using FluentValidation; +using MediatR; + +namespace MyNewProjectName.Application.Behaviors; + +/// +/// Validation behavior for MediatR pipeline +/// +public class ValidationBehavior : IPipelineBehavior + where TRequest : notnull +{ + private readonly IEnumerable> _validators; + + public ValidationBehavior(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (!_validators.Any()) + { + return await next(); + } + + var context = new ValidationContext(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(); + } +} diff --git a/MyNewProjectName.Application/DependencyInjection.cs b/MyNewProjectName.Application/DependencyInjection.cs new file mode 100644 index 0000000..07b1d46 --- /dev/null +++ b/MyNewProjectName.Application/DependencyInjection.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using MyNewProjectName.Application.Behaviors; + +namespace MyNewProjectName.Application; + +/// +/// Dependency Injection for Application Layer +/// +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; + } +} diff --git a/MyNewProjectName.Application/Features/Sample/Commands/CreateSample/CreateSampleCommand.cs b/MyNewProjectName.Application/Features/Sample/Commands/CreateSample/CreateSampleCommand.cs new file mode 100644 index 0000000..fb356d0 --- /dev/null +++ b/MyNewProjectName.Application/Features/Sample/Commands/CreateSample/CreateSampleCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace MyNewProjectName.Application.Features.Sample.Commands.CreateSample; + +/// +/// Command to create a new sample +/// +public record CreateSampleCommand(string Name, string? Description) : IRequest; diff --git a/MyNewProjectName.Application/Features/Sample/Commands/CreateSample/CreateSampleCommandHandler.cs b/MyNewProjectName.Application/Features/Sample/Commands/CreateSample/CreateSampleCommandHandler.cs new file mode 100644 index 0000000..73affa1 --- /dev/null +++ b/MyNewProjectName.Application/Features/Sample/Commands/CreateSample/CreateSampleCommandHandler.cs @@ -0,0 +1,37 @@ +using MyNewProjectName.Domain.Entities; +using MyNewProjectName.Domain.Interfaces; +using MediatR; + +namespace MyNewProjectName.Application.Features.Sample.Commands.CreateSample; + +/// +/// Handler for CreateSampleCommand +/// +public class CreateSampleCommandHandler : IRequestHandler +{ + private readonly IRepository _repository; + private readonly IUnitOfWork _unitOfWork; + + public CreateSampleCommandHandler(IRepository repository, IUnitOfWork unitOfWork) + { + _repository = repository; + _unitOfWork = unitOfWork; + } + + public async Task 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; + } +} diff --git a/MyNewProjectName.Application/Features/Sample/Commands/CreateSample/CreateSampleCommandValidator.cs b/MyNewProjectName.Application/Features/Sample/Commands/CreateSample/CreateSampleCommandValidator.cs new file mode 100644 index 0000000..45ac8d1 --- /dev/null +++ b/MyNewProjectName.Application/Features/Sample/Commands/CreateSample/CreateSampleCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace MyNewProjectName.Application.Features.Sample.Commands.CreateSample; + +/// +/// Validator for CreateSampleCommand +/// +public class CreateSampleCommandValidator : AbstractValidator +{ + 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."); + } +} diff --git a/MyNewProjectName.Application/Features/Sample/Queries/GetSamples/GetSamplesQuery.cs b/MyNewProjectName.Application/Features/Sample/Queries/GetSamples/GetSamplesQuery.cs new file mode 100644 index 0000000..7ea2f09 --- /dev/null +++ b/MyNewProjectName.Application/Features/Sample/Queries/GetSamples/GetSamplesQuery.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace MyNewProjectName.Application.Features.Sample.Queries.GetSamples; + +/// +/// Query to get all samples +/// +public record GetSamplesQuery : IRequest>; diff --git a/MyNewProjectName.Application/Features/Sample/Queries/GetSamples/GetSamplesQueryHandler.cs b/MyNewProjectName.Application/Features/Sample/Queries/GetSamples/GetSamplesQueryHandler.cs new file mode 100644 index 0000000..4d8da3c --- /dev/null +++ b/MyNewProjectName.Application/Features/Sample/Queries/GetSamples/GetSamplesQueryHandler.cs @@ -0,0 +1,27 @@ +using AutoMapper; +using MyNewProjectName.Domain.Entities; +using MyNewProjectName.Domain.Interfaces; +using MediatR; + +namespace MyNewProjectName.Application.Features.Sample.Queries.GetSamples; + +/// +/// Handler for GetSamplesQuery +/// +public class GetSamplesQueryHandler : IRequestHandler> +{ + private readonly IRepository _repository; + private readonly IMapper _mapper; + + public GetSamplesQueryHandler(IRepository repository, IMapper mapper) + { + _repository = repository; + _mapper = mapper; + } + + public async Task> Handle(GetSamplesQuery request, CancellationToken cancellationToken) + { + var entities = await _repository.GetAllAsync(cancellationToken); + return _mapper.Map>(entities); + } +} diff --git a/MyNewProjectName.Application/Features/Sample/Queries/GetSamples/SampleDto.cs b/MyNewProjectName.Application/Features/Sample/Queries/GetSamples/SampleDto.cs new file mode 100644 index 0000000..9dd60c7 --- /dev/null +++ b/MyNewProjectName.Application/Features/Sample/Queries/GetSamples/SampleDto.cs @@ -0,0 +1,22 @@ +using AutoMapper; +using MyNewProjectName.Application.Mappings; +using MyNewProjectName.Domain.Entities; + +namespace MyNewProjectName.Application.Features.Sample.Queries.GetSamples; + +/// +/// Sample DTO +/// +public class SampleDto : IMapFrom +{ + 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(); + } +} diff --git a/MyNewProjectName.Application/Interfaces/Common/IJwtTokenGenerator.cs b/MyNewProjectName.Application/Interfaces/Common/IJwtTokenGenerator.cs new file mode 100644 index 0000000..ed4c0f1 --- /dev/null +++ b/MyNewProjectName.Application/Interfaces/Common/IJwtTokenGenerator.cs @@ -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 roles, Guid tenantId); + +// string GenerateRefreshToken(); + +// ClaimsPrincipal GetPrincipalFromExpiredToken(string token); +// } + diff --git a/MyNewProjectName.Application/Interfaces/Common/IPasswordHasher.cs b/MyNewProjectName.Application/Interfaces/Common/IPasswordHasher.cs new file mode 100644 index 0000000..09434be --- /dev/null +++ b/MyNewProjectName.Application/Interfaces/Common/IPasswordHasher.cs @@ -0,0 +1,8 @@ +namespace MyNewProjectName.Application.Interfaces.Common; + +public interface IPasswordHasher +{ + string Hash(string password); + bool Verify(string password, string hashedPassword); +} + diff --git a/MyNewProjectName.Application/Interfaces/ICurrentUserService.cs b/MyNewProjectName.Application/Interfaces/ICurrentUserService.cs new file mode 100644 index 0000000..f886d81 --- /dev/null +++ b/MyNewProjectName.Application/Interfaces/ICurrentUserService.cs @@ -0,0 +1,12 @@ +namespace MyNewProjectName.Application.Interfaces; + +/// +/// Interface for getting current user information +/// +public interface ICurrentUserService +{ + string? UserId { get; } + string? UserName { get; } + bool? IsAuthenticated { get; } + string? Role { get; } +} diff --git a/MyNewProjectName.Application/Interfaces/IDateTimeService.cs b/MyNewProjectName.Application/Interfaces/IDateTimeService.cs new file mode 100644 index 0000000..6a627b3 --- /dev/null +++ b/MyNewProjectName.Application/Interfaces/IDateTimeService.cs @@ -0,0 +1,10 @@ +namespace MyNewProjectName.Application.Interfaces; + +/// +/// Interface for date time operations (for testability) +/// +public interface IDateTimeService +{ + DateTime Now { get; } + DateTime UtcNow { get; } +} diff --git a/MyNewProjectName.Application/Mappings/IMapFrom.cs b/MyNewProjectName.Application/Mappings/IMapFrom.cs new file mode 100644 index 0000000..ef99d1c --- /dev/null +++ b/MyNewProjectName.Application/Mappings/IMapFrom.cs @@ -0,0 +1,11 @@ +using AutoMapper; + +namespace MyNewProjectName.Application.Mappings; + +/// +/// Interface for auto mapping registration +/// +public interface IMapFrom +{ + void Mapping(Profile profile) => profile.CreateMap(typeof(T), GetType()); +} diff --git a/MyNewProjectName.Application/Mappings/MappingProfile.cs b/MyNewProjectName.Application/Mappings/MappingProfile.cs new file mode 100644 index 0000000..0a07120 --- /dev/null +++ b/MyNewProjectName.Application/Mappings/MappingProfile.cs @@ -0,0 +1,55 @@ +using System.Reflection; +using AutoMapper; + +namespace MyNewProjectName.Application.Mappings; + +/// +/// AutoMapper profile that auto-registers mappings from IMapFrom interface +/// +public class MappingProfile : Profile +{ + public MappingProfile() + { + ApplyMappingsFromAssembly(Assembly.GetExecutingAssembly()); + } + + private void ApplyMappingsFromAssembly(Assembly assembly) + { + var mapFromType = typeof(IMapFrom<>); + + var mappingMethodName = nameof(IMapFrom.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 }); + } + } + } + } + } +} diff --git a/MyNewProjectName.Application/MyNewProjectName.Application.csproj b/MyNewProjectName.Application/MyNewProjectName.Application.csproj new file mode 100644 index 0000000..7b08567 --- /dev/null +++ b/MyNewProjectName.Application/MyNewProjectName.Application.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/MyNewProjectName.Contracts/Common/PagedList.cs b/MyNewProjectName.Contracts/Common/PagedList.cs new file mode 100644 index 0000000..937911c --- /dev/null +++ b/MyNewProjectName.Contracts/Common/PagedList.cs @@ -0,0 +1,37 @@ +namespace MyNewProjectName.Contracts.Common +{ + // PagedList model to support pagination in API responses + public class PagedList + { + // Items in the current page + public List Items { get; set; } = new List(); + + // 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 items, int totalCount, int pageNumber, int pageSize) + { + Items = items; + TotalCount = totalCount; + PageNumber = pageNumber; + PageSize = pageSize; + } + + // Default constructor + public PagedList() { } + } +} \ No newline at end of file diff --git a/MyNewProjectName.Contracts/Common/PaginationParams.cs b/MyNewProjectName.Contracts/Common/PaginationParams.cs new file mode 100644 index 0000000..3ad8374 --- /dev/null +++ b/MyNewProjectName.Contracts/Common/PaginationParams.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/MyNewProjectName.Contracts/Common/ServiceResponse.cs b/MyNewProjectName.Contracts/Common/ServiceResponse.cs new file mode 100644 index 0000000..26b8521 --- /dev/null +++ b/MyNewProjectName.Contracts/Common/ServiceResponse.cs @@ -0,0 +1,21 @@ +namespace MyNewProjectName.Contracts.Common +{ + // Generic response model for all API endpoints + public class ServiceResponse + { + // 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? Errors { get; set; } + + // Field-specific validation errors for frontend form validation + public Dictionary>? FieldErrors { get; set; } + } +} \ No newline at end of file diff --git a/MyNewProjectName.Contracts/DTOs/Requests/CreateSampleRequest.cs b/MyNewProjectName.Contracts/DTOs/Requests/CreateSampleRequest.cs new file mode 100644 index 0000000..228bdc5 --- /dev/null +++ b/MyNewProjectName.Contracts/DTOs/Requests/CreateSampleRequest.cs @@ -0,0 +1,10 @@ +namespace MyNewProjectName.Contracts.DTOs.Requests; + +/// +/// Request to create a sample +/// +public class CreateSampleRequest +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } +} diff --git a/MyNewProjectName.Contracts/DTOs/Requests/PaginatedRequest.cs b/MyNewProjectName.Contracts/DTOs/Requests/PaginatedRequest.cs new file mode 100644 index 0000000..a295086 --- /dev/null +++ b/MyNewProjectName.Contracts/DTOs/Requests/PaginatedRequest.cs @@ -0,0 +1,13 @@ +namespace MyNewProjectName.Contracts.DTOs.Requests; + +/// +/// Base request for paginated queries +/// +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; +} diff --git a/MyNewProjectName.Contracts/DTOs/Responses/ApiResponse.cs b/MyNewProjectName.Contracts/DTOs/Responses/ApiResponse.cs new file mode 100644 index 0000000..a3e085f --- /dev/null +++ b/MyNewProjectName.Contracts/DTOs/Responses/ApiResponse.cs @@ -0,0 +1,68 @@ +namespace MyNewProjectName.Contracts.DTOs.Responses; + +/// +/// Standard API response wrapper +/// +public class ApiResponse +{ + public bool Success { get; set; } + public string? Message { get; set; } + public T? Data { get; set; } + public List? Errors { get; set; } + + public static ApiResponse SuccessResponse(T data, string? message = null) + { + return new ApiResponse + { + Success = true, + Data = data, + Message = message + }; + } + + public static ApiResponse ErrorResponse(string error) + { + return new ApiResponse + { + Success = false, + Errors = new List { error } + }; + } + + public static ApiResponse ErrorResponse(List errors) + { + return new ApiResponse + { + Success = false, + Errors = errors + }; + } +} + +/// +/// Non-generic API response +/// +public class ApiResponse +{ + public bool Success { get; set; } + public string? Message { get; set; } + public List? 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 { error } + }; + } +} diff --git a/MyNewProjectName.Contracts/DTOs/Responses/PaginatedResponse.cs b/MyNewProjectName.Contracts/DTOs/Responses/PaginatedResponse.cs new file mode 100644 index 0000000..883ca8c --- /dev/null +++ b/MyNewProjectName.Contracts/DTOs/Responses/PaginatedResponse.cs @@ -0,0 +1,14 @@ +namespace MyNewProjectName.Contracts.DTOs.Responses; + +/// +/// Paginated response wrapper +/// +public class PaginatedResponse +{ + public List 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; } +} diff --git a/MyNewProjectName.Contracts/DTOs/Responses/SampleResponse.cs b/MyNewProjectName.Contracts/DTOs/Responses/SampleResponse.cs new file mode 100644 index 0000000..8f3b1de --- /dev/null +++ b/MyNewProjectName.Contracts/DTOs/Responses/SampleResponse.cs @@ -0,0 +1,13 @@ +namespace MyNewProjectName.Contracts.DTOs.Responses; + +/// +/// Sample response DTO +/// +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; } +} diff --git a/MyNewProjectName.Contracts/MyNewProjectName.Contracts.csproj b/MyNewProjectName.Contracts/MyNewProjectName.Contracts.csproj new file mode 100644 index 0000000..125f4c9 --- /dev/null +++ b/MyNewProjectName.Contracts/MyNewProjectName.Contracts.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/MyNewProjectName.Domain/Common/AuditableEntity.cs b/MyNewProjectName.Domain/Common/AuditableEntity.cs new file mode 100644 index 0000000..85f2f2d --- /dev/null +++ b/MyNewProjectName.Domain/Common/AuditableEntity.cs @@ -0,0 +1,12 @@ +namespace MyNewProjectName.Domain.Common; + +/// +/// Base entity with audit properties +/// +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; } +} diff --git a/MyNewProjectName.Domain/Common/BaseEntity.cs b/MyNewProjectName.Domain/Common/BaseEntity.cs new file mode 100644 index 0000000..4592f56 --- /dev/null +++ b/MyNewProjectName.Domain/Common/BaseEntity.cs @@ -0,0 +1,36 @@ +namespace MyNewProjectName.Domain.Common; + +/// +/// Base entity with common properties for all entities +/// +public abstract class BaseEntity +{ + public Guid Id { get; set; } + + private readonly List _domainEvents = new(); + + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + public void AddDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Add(domainEvent); + } + + public void RemoveDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Remove(domainEvent); + } + + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } +} + +/// +/// Marker interface for domain events +/// +public interface IDomainEvent +{ + DateTime OccurredOn { get; } +} diff --git a/MyNewProjectName.Domain/Common/ISoftDelete.cs b/MyNewProjectName.Domain/Common/ISoftDelete.cs new file mode 100644 index 0000000..3acb5a5 --- /dev/null +++ b/MyNewProjectName.Domain/Common/ISoftDelete.cs @@ -0,0 +1,11 @@ +namespace MyNewProjectName.Domain.Common; + +/// +/// Interface for soft delete functionality +/// +public interface ISoftDelete +{ + bool IsDeleted { get; set; } + DateTime? DeletedAt { get; set; } + string? DeletedBy { get; set; } +} diff --git a/MyNewProjectName.Domain/Entities/SampleEntity.cs b/MyNewProjectName.Domain/Entities/SampleEntity.cs new file mode 100644 index 0000000..9efab6b --- /dev/null +++ b/MyNewProjectName.Domain/Entities/SampleEntity.cs @@ -0,0 +1,13 @@ +using MyNewProjectName.Domain.Common; + +namespace MyNewProjectName.Domain.Entities; + +/// +/// Sample entity - Replace with your actual entity +/// +public class SampleEntity : AuditableEntity +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public bool IsActive { get; set; } = true; +} diff --git a/MyNewProjectName.Domain/Events/BaseDomainEvent.cs b/MyNewProjectName.Domain/Events/BaseDomainEvent.cs new file mode 100644 index 0000000..7ef1476 --- /dev/null +++ b/MyNewProjectName.Domain/Events/BaseDomainEvent.cs @@ -0,0 +1,11 @@ +using MyNewProjectName.Domain.Common; + +namespace MyNewProjectName.Domain.Events; + +/// +/// Base domain event +/// +public abstract class BaseDomainEvent : IDomainEvent +{ + public DateTime OccurredOn { get; } = DateTime.UtcNow; +} diff --git a/MyNewProjectName.Domain/Exceptions/DomainException.cs b/MyNewProjectName.Domain/Exceptions/DomainException.cs new file mode 100644 index 0000000..b553a3a --- /dev/null +++ b/MyNewProjectName.Domain/Exceptions/DomainException.cs @@ -0,0 +1,21 @@ +namespace MyNewProjectName.Domain.Exceptions; + +/// +/// Base exception for domain layer +/// +public class DomainException : Exception +{ + public DomainException() + { + } + + public DomainException(string message) + : base(message) + { + } + + public DomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/MyNewProjectName.Domain/Exceptions/NotFoundException.cs b/MyNewProjectName.Domain/Exceptions/NotFoundException.cs new file mode 100644 index 0000000..472cb8e --- /dev/null +++ b/MyNewProjectName.Domain/Exceptions/NotFoundException.cs @@ -0,0 +1,27 @@ +namespace MyNewProjectName.Domain.Exceptions; + +/// +/// Exception thrown when an entity is not found +/// +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.") + { + } +} diff --git a/MyNewProjectName.Domain/Exceptions/ValidationException.cs b/MyNewProjectName.Domain/Exceptions/ValidationException.cs new file mode 100644 index 0000000..66e552d --- /dev/null +++ b/MyNewProjectName.Domain/Exceptions/ValidationException.cs @@ -0,0 +1,27 @@ +namespace MyNewProjectName.Domain.Exceptions; + +/// +/// Exception thrown when validation fails +/// +public class ValidationException : DomainException +{ + public IDictionary Errors { get; } + + public ValidationException() + : base("One or more validation failures have occurred.") + { + Errors = new Dictionary(); + } + + public ValidationException(string message) + : base(message) + { + Errors = new Dictionary(); + } + + public ValidationException(IDictionary errors) + : base("One or more validation failures have occurred.") + { + Errors = errors; + } +} diff --git a/MyNewProjectName.Domain/Interfaces/IRepository.cs b/MyNewProjectName.Domain/Interfaces/IRepository.cs new file mode 100644 index 0000000..56ad264 --- /dev/null +++ b/MyNewProjectName.Domain/Interfaces/IRepository.cs @@ -0,0 +1,24 @@ +using System.Linq.Expressions; +using MyNewProjectName.Domain.Common; + +namespace MyNewProjectName.Domain.Interfaces; + +/// +/// Generic repository interface +/// +/// Entity type +public interface IRepository where T : BaseEntity +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task> FindAsync(Expression> predicate, CancellationToken cancellationToken = default); + Task FirstOrDefaultAsync(Expression> predicate, CancellationToken cancellationToken = default); + Task AnyAsync(Expression> predicate, CancellationToken cancellationToken = default); + Task CountAsync(Expression>? predicate = null, CancellationToken cancellationToken = default); + Task AddAsync(T entity, CancellationToken cancellationToken = default); + Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); + void Update(T entity); + void UpdateRange(IEnumerable entities); + void Remove(T entity); + void RemoveRange(IEnumerable entities); +} diff --git a/MyNewProjectName.Domain/Interfaces/IUnitOfWork.cs b/MyNewProjectName.Domain/Interfaces/IUnitOfWork.cs new file mode 100644 index 0000000..5bc4cdd --- /dev/null +++ b/MyNewProjectName.Domain/Interfaces/IUnitOfWork.cs @@ -0,0 +1,12 @@ +namespace MyNewProjectName.Domain.Interfaces; + +/// +/// Unit of Work pattern interface +/// +public interface IUnitOfWork : IDisposable +{ + Task SaveChangesAsync(CancellationToken cancellationToken = default); + Task BeginTransactionAsync(CancellationToken cancellationToken = default); + Task CommitTransactionAsync(CancellationToken cancellationToken = default); + Task RollbackTransactionAsync(CancellationToken cancellationToken = default); +} diff --git a/MyNewProjectName.Domain/MyNewProjectName.Domain.csproj b/MyNewProjectName.Domain/MyNewProjectName.Domain.csproj new file mode 100644 index 0000000..125f4c9 --- /dev/null +++ b/MyNewProjectName.Domain/MyNewProjectName.Domain.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/MyNewProjectName.Domain/ValueObjects/ValueObject.cs b/MyNewProjectName.Domain/ValueObjects/ValueObject.cs new file mode 100644 index 0000000..60855ac --- /dev/null +++ b/MyNewProjectName.Domain/ValueObjects/ValueObject.cs @@ -0,0 +1,43 @@ +namespace MyNewProjectName.Domain.ValueObjects; + +/// +/// Base class for value objects +/// +public abstract class ValueObject +{ + protected abstract IEnumerable 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); + } +} diff --git a/MyNewProjectName.Infrastructure/DependencyInjection.cs b/MyNewProjectName.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..77a6c22 --- /dev/null +++ b/MyNewProjectName.Infrastructure/DependencyInjection.cs @@ -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; + +/// +/// Dependency Injection for Infrastructure Layer +/// +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + // Register Options Pattern + services.Configure(configuration.GetSection(DatabaseOptions.SectionName)); + services.Configure(configuration.GetSection(JwtOptions.SectionName)); + services.Configure(configuration.GetSection(RedisOptions.SectionName)); + services.Configure(configuration.GetSection(SerilogOptions.SectionName)); + + // Validate required Options on startup + services.AddOptions() + .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() + .Bind(jwtSection) + .ValidateDataAnnotations() + .ValidateOnStart(); + } + + // Register DbContext using Options Pattern + services.AddDbContext((serviceProvider, options) => + { + var dbOptions = serviceProvider.GetRequiredService>().Value; + options.UseSqlServer( + dbOptions.DefaultConnection, + b => b.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName)); + }); + + // Register repositories + services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); + services.AddScoped(); + + // Register HttpContextAccessor (required for CurrentUserService) + services.AddHttpContextAccessor(); + + // Register services + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/MyNewProjectName.Infrastructure/Extensions/OpenTelemetryExtensions.cs b/MyNewProjectName.Infrastructure/Extensions/OpenTelemetryExtensions.cs new file mode 100644 index 0000000..738b23a --- /dev/null +++ b/MyNewProjectName.Infrastructure/Extensions/OpenTelemetryExtensions.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using System.Reflection; + +namespace MyNewProjectName.Infrastructure.Extensions; + +/// +/// Extension methods for configuring OpenTelemetry +/// +public static class OpenTelemetryExtensions +{ + /// + /// Add OpenTelemetry distributed tracing + /// + public static IServiceCollection AddOpenTelemetryTracing( + this IServiceCollection services, + string serviceName = "MyNewProjectName") + { + services.AddOpenTelemetry() + .WithTracing(builder => + { + builder + .SetResourceBuilder(ResourceBuilder + .CreateDefault() + .AddService(serviceName) + .AddAttributes(new Dictionary + { + ["service.version"] = Assembly.GetExecutingAssembly() + .GetCustomAttribute()? + .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; + } +} diff --git a/MyNewProjectName.Infrastructure/Extensions/SerilogExtensions.cs b/MyNewProjectName.Infrastructure/Extensions/SerilogExtensions.cs new file mode 100644 index 0000000..d1a8e5a --- /dev/null +++ b/MyNewProjectName.Infrastructure/Extensions/SerilogExtensions.cs @@ -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; + +/// +/// Extension methods for configuring Serilog +/// +public static class SerilogExtensions +{ + /// + /// Configure Serilog with structured JSON logging + /// + public static IHostBuilder UseSerilogLogging(this IHostBuilder hostBuilder, IConfiguration configuration) + { + var serilogOptions = configuration.GetSection(SerilogOptions.SectionName).Get() + ?? 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(serilogOptions.MinimumLevel, out var minLevel)) + { + loggerConfiguration.MinimumLevel.Is(minLevel); + } + + // Apply overrides from configuration + foreach (var overrideConfig in serilogOptions.Override) + { + if (Enum.TryParse(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(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(); + } +} diff --git a/MyNewProjectName.Infrastructure/Identity/JwtTokenGenerator.cs b/MyNewProjectName.Infrastructure/Identity/JwtTokenGenerator.cs new file mode 100644 index 0000000..7f4ac18 --- /dev/null +++ b/MyNewProjectName.Infrastructure/Identity/JwtTokenGenerator.cs @@ -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 jwtOptions) +// { +// _jwtSettings = jwtOptions.Value; +// } + +// public string GenerateAccessToken(User user, List roles, Guid tenantId) +// { +// var tokenHandler = new JwtSecurityTokenHandler(); +// var key = Encoding.UTF8.GetBytes(_jwtSettings.Secret); + +// var claims = new List +// { +// 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; +// } +// } + diff --git a/MyNewProjectName.Infrastructure/MyNewProjectName.Infrastructure.csproj b/MyNewProjectName.Infrastructure/MyNewProjectName.Infrastructure.csproj new file mode 100644 index 0000000..a51f374 --- /dev/null +++ b/MyNewProjectName.Infrastructure/MyNewProjectName.Infrastructure.csproj @@ -0,0 +1,34 @@ + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + diff --git a/MyNewProjectName.Infrastructure/Options/DatabaseOptions.cs b/MyNewProjectName.Infrastructure/Options/DatabaseOptions.cs new file mode 100644 index 0000000..6840c70 --- /dev/null +++ b/MyNewProjectName.Infrastructure/Options/DatabaseOptions.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace MyNewProjectName.Infrastructure.Options; + +/// +/// Database connection configuration options +/// +public class DatabaseOptions +{ + public const string SectionName = "ConnectionStrings"; + + /// + /// Default database connection string + /// + [Required(ErrorMessage = "DefaultConnection is required")] + public string DefaultConnection { get; set; } = string.Empty; +} diff --git a/MyNewProjectName.Infrastructure/Options/JwtOptions.cs b/MyNewProjectName.Infrastructure/Options/JwtOptions.cs new file mode 100644 index 0000000..4588be5 --- /dev/null +++ b/MyNewProjectName.Infrastructure/Options/JwtOptions.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace MyNewProjectName.Infrastructure.Options; + +/// +/// JWT authentication configuration options +/// +public class JwtOptions +{ + public const string SectionName = "Jwt"; + + /// + /// Secret key for signing JWT tokens + /// + [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; + + /// + /// Token issuer + /// + [Required(ErrorMessage = "JWT Issuer is required")] + public string Issuer { get; set; } = string.Empty; + + /// + /// Token audience + /// + [Required(ErrorMessage = "JWT Audience is required")] + public string Audience { get; set; } = string.Empty; + + /// + /// Token expiration time in minutes + /// + [Range(1, 1440, ErrorMessage = "ExpirationInMinutes must be between 1 and 1440 (24 hours)")] + public int ExpirationInMinutes { get; set; } = 60; + + /// + /// Refresh token expiration time in days + /// + [Range(1, 365, ErrorMessage = "RefreshTokenExpirationInDays must be between 1 and 365")] + public int RefreshTokenExpirationInDays { get; set; } = 7; +} diff --git a/MyNewProjectName.Infrastructure/Options/RedisOptions.cs b/MyNewProjectName.Infrastructure/Options/RedisOptions.cs new file mode 100644 index 0000000..a02c036 --- /dev/null +++ b/MyNewProjectName.Infrastructure/Options/RedisOptions.cs @@ -0,0 +1,24 @@ +namespace MyNewProjectName.Infrastructure.Options; + +/// +/// Redis cache configuration options +/// +public class RedisOptions +{ + public const string SectionName = "Redis"; + + /// + /// Redis connection string + /// + public string ConnectionString { get; set; } = string.Empty; + + /// + /// Redis instance name for key prefixing + /// + public string InstanceName { get; set; } = string.Empty; + + /// + /// Default cache expiration time in minutes + /// + public int DefaultExpirationInMinutes { get; set; } = 30; +} diff --git a/MyNewProjectName.Infrastructure/Options/SerilogOptions.cs b/MyNewProjectName.Infrastructure/Options/SerilogOptions.cs new file mode 100644 index 0000000..b7c7947 --- /dev/null +++ b/MyNewProjectName.Infrastructure/Options/SerilogOptions.cs @@ -0,0 +1,54 @@ +namespace MyNewProjectName.Infrastructure.Options; + +/// +/// Serilog logging configuration options +/// +public class SerilogOptions +{ + public const string SectionName = "Serilog"; + + /// + /// Minimum log level + /// + public string MinimumLevel { get; set; } = "Information"; + + /// + /// Override log levels for specific namespaces + /// + public Dictionary Override { get; set; } = new(); + + /// + /// Write to console + /// + public bool WriteToConsole { get; set; } = true; + + /// + /// Write to file + /// + public bool WriteToFile { get; set; } = true; + + /// + /// File path for logs (relative to application root) + /// + public string FilePath { get; set; } = "logs/log-.txt"; + + /// + /// Rolling interval for log files + /// + public string RollingInterval { get; set; } = "Day"; + + /// + /// Retained file count limit + /// + public int RetainedFileCountLimit { get; set; } = 31; + + /// + /// Seq server URL (optional) + /// + public string? SeqUrl { get; set; } + + /// + /// Elasticsearch URL (optional) + /// + public string? ElasticsearchUrl { get; set; } +} diff --git a/MyNewProjectName.Infrastructure/Persistence/Configurations/SampleEntityConfiguration.cs b/MyNewProjectName.Infrastructure/Persistence/Configurations/SampleEntityConfiguration.cs new file mode 100644 index 0000000..0a53f79 --- /dev/null +++ b/MyNewProjectName.Infrastructure/Persistence/Configurations/SampleEntityConfiguration.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MyNewProjectName.Domain.Entities; + +namespace MyNewProjectName.Infrastructure.Persistence.Configurations; + +/// +/// Entity configuration for SampleEntity +/// +public class SampleEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/MyNewProjectName.Infrastructure/Persistence/Context/ApplicationDbContext.cs b/MyNewProjectName.Infrastructure/Persistence/Context/ApplicationDbContext.cs new file mode 100644 index 0000000..16ee55e --- /dev/null +++ b/MyNewProjectName.Infrastructure/Persistence/Context/ApplicationDbContext.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using MyNewProjectName.Domain.Common; +using MyNewProjectName.Domain.Entities; +using System.Reflection; + +namespace MyNewProjectName.Infrastructure.Persistence.Context; + +/// +/// Application database context +/// +public class ApplicationDbContext : DbContext +{ + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Samples => Set(); + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + foreach (var entry in ChangeTracker.Entries()) + { + 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); + } +} diff --git a/MyNewProjectName.Infrastructure/Persistence/Repositories/Repository.cs b/MyNewProjectName.Infrastructure/Persistence/Repositories/Repository.cs new file mode 100644 index 0000000..c789df6 --- /dev/null +++ b/MyNewProjectName.Infrastructure/Persistence/Repositories/Repository.cs @@ -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; + +/// +/// Generic repository implementation +/// +public class Repository : IRepository where T : BaseEntity +{ + protected readonly ApplicationDbContext _context; + protected readonly DbSet _dbSet; + + public Repository(ApplicationDbContext context) + { + _context = context; + _dbSet = context.Set(); + } + + public virtual async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _dbSet.FindAsync(new object[] { id }, cancellationToken); + } + + public virtual async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await _dbSet.ToListAsync(cancellationToken); + } + + public virtual async Task> FindAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + return await _dbSet.Where(predicate).ToListAsync(cancellationToken); + } + + public virtual async Task FirstOrDefaultAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + return await _dbSet.FirstOrDefaultAsync(predicate, cancellationToken); + } + + public virtual async Task AnyAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + return await _dbSet.AnyAsync(predicate, cancellationToken); + } + + public virtual async Task CountAsync(Expression>? 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 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 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 entities) + { + _dbSet.RemoveRange(entities); + } +} diff --git a/MyNewProjectName.Infrastructure/Persistence/Repositories/UnitOfWork.cs b/MyNewProjectName.Infrastructure/Persistence/Repositories/UnitOfWork.cs new file mode 100644 index 0000000..06f982e --- /dev/null +++ b/MyNewProjectName.Infrastructure/Persistence/Repositories/UnitOfWork.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore.Storage; +using MyNewProjectName.Domain.Interfaces; +using MyNewProjectName.Infrastructure.Persistence.Context; + +namespace MyNewProjectName.Infrastructure.Persistence.Repositories; + +/// +/// Unit of Work implementation +/// +public class UnitOfWork : IUnitOfWork +{ + private readonly ApplicationDbContext _context; + private IDbContextTransaction? _transaction; + + public UnitOfWork(ApplicationDbContext context) + { + _context = context; + } + + public async Task 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(); + } +} diff --git a/MyNewProjectName.Infrastructure/Services/CurrentUserService.cs b/MyNewProjectName.Infrastructure/Services/CurrentUserService.cs new file mode 100644 index 0000000..ee6cc79 --- /dev/null +++ b/MyNewProjectName.Infrastructure/Services/CurrentUserService.cs @@ -0,0 +1,69 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using MyNewProjectName.Application.Interfaces; + +namespace MyNewProjectName.Infrastructure.Services; + +/// +/// Implementation of ICurrentUserService +/// Automatically extracts user information from HttpContext using IHttpContextAccessor +/// This follows Clean Architecture principles - no dependency on concrete middleware +/// +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"); + } + } +} diff --git a/MyNewProjectName.Infrastructure/Services/DateTimeService.cs b/MyNewProjectName.Infrastructure/Services/DateTimeService.cs new file mode 100644 index 0000000..6e4b3f5 --- /dev/null +++ b/MyNewProjectName.Infrastructure/Services/DateTimeService.cs @@ -0,0 +1,12 @@ +using MyNewProjectName.Application.Interfaces; + +namespace MyNewProjectName.Infrastructure.Services; + +/// +/// DateTime service implementation +/// +public class DateTimeService : IDateTimeService +{ + public DateTime Now => DateTime.Now; + public DateTime UtcNow => DateTime.UtcNow; +} diff --git a/MyNewProjectName.UnitTest/Application/CreateSampleCommandValidatorTests.cs b/MyNewProjectName.UnitTest/Application/CreateSampleCommandValidatorTests.cs new file mode 100644 index 0000000..98cc820 --- /dev/null +++ b/MyNewProjectName.UnitTest/Application/CreateSampleCommandValidatorTests.cs @@ -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); + } +} diff --git a/MyNewProjectName.UnitTest/Domain/BaseEntityTests.cs b/MyNewProjectName.UnitTest/Domain/BaseEntityTests.cs new file mode 100644 index 0000000..4f572cb --- /dev/null +++ b/MyNewProjectName.UnitTest/Domain/BaseEntityTests.cs @@ -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(); + } +} diff --git a/MyNewProjectName.UnitTest/Domain/ValueObjectTests.cs b/MyNewProjectName.UnitTest/Domain/ValueObjectTests.cs new file mode 100644 index 0000000..4107a82 --- /dev/null +++ b/MyNewProjectName.UnitTest/Domain/ValueObjectTests.cs @@ -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 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()); + } +} diff --git a/MyNewProjectName.UnitTest/MyNewProjectName.UnitTest.csproj b/MyNewProjectName.UnitTest/MyNewProjectName.UnitTest.csproj new file mode 100644 index 0000000..5c286b3 --- /dev/null +++ b/MyNewProjectName.UnitTest/MyNewProjectName.UnitTest.csproj @@ -0,0 +1,26 @@ + + + + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/MyNewProjectName.WebAPI/Controllers/BaseApiController.cs b/MyNewProjectName.WebAPI/Controllers/BaseApiController.cs new file mode 100644 index 0000000..ba69745 --- /dev/null +++ b/MyNewProjectName.WebAPI/Controllers/BaseApiController.cs @@ -0,0 +1,16 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace MyNewProjectName.WebAPI.Controllers; + +/// +/// Base API controller with common functionality +/// +[ApiController] +[Route("api/[controller]")] +public abstract class BaseApiController : ControllerBase +{ + private ISender? _mediator; + + protected ISender Mediator => _mediator ??= HttpContext.RequestServices.GetRequiredService(); +} diff --git a/MyNewProjectName.WebAPI/Controllers/SamplesController.cs b/MyNewProjectName.WebAPI/Controllers/SamplesController.cs new file mode 100644 index 0000000..6c1fc82 --- /dev/null +++ b/MyNewProjectName.WebAPI/Controllers/SamplesController.cs @@ -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; + +/// +/// Sample controller to demonstrate CQRS pattern +/// +public class SamplesController : BaseApiController +{ + /// + /// Get all samples + /// + [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task GetAll() + { + var result = await Mediator.Send(new GetSamplesQuery()); + return Ok(ApiResponse>.SuccessResponse(result)); + } + + /// + /// Create a new sample + /// + [HttpPost] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + public async Task 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.SuccessResponse(id, "Sample created successfully")); + } +} diff --git a/MyNewProjectName.WebAPI/Middleware/CorrelationIdMiddleware.cs b/MyNewProjectName.WebAPI/Middleware/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..3485cbc --- /dev/null +++ b/MyNewProjectName.WebAPI/Middleware/CorrelationIdMiddleware.cs @@ -0,0 +1,36 @@ +using Serilog.Context; + +namespace MyNewProjectName.WebAPI.Middleware; + +/// +/// Middleware to generate and track correlation ID for each request +/// This ID is added to HTTP headers and Serilog log context for easy tracing +/// +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); + } + } +} diff --git a/MyNewProjectName.WebAPI/Middleware/ExceptionHandlingMiddleware.cs b/MyNewProjectName.WebAPI/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..f4be6c1 --- /dev/null +++ b/MyNewProjectName.WebAPI/Middleware/ExceptionHandlingMiddleware.cs @@ -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; + +/// +/// 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 +/// +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()), + + DomainException => ( + HttpStatusCode.BadRequest, + exception.Message, + new List()), + + UnauthorizedAccessException => ( + HttpStatusCode.Unauthorized, + "Unauthorized", + new List()), + + _ => ( + HttpStatusCode.InternalServerError, + "An error occurred while processing your request", + new List()) + }; + + 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); + } +} diff --git a/MyNewProjectName.WebAPI/Middleware/RequestResponseLoggingMiddleware.cs b/MyNewProjectName.WebAPI/Middleware/RequestResponseLoggingMiddleware.cs new file mode 100644 index 0000000..99449e9 --- /dev/null +++ b/MyNewProjectName.WebAPI/Middleware/RequestResponseLoggingMiddleware.cs @@ -0,0 +1,133 @@ +using System.Diagnostics; +using System.Text; +using Microsoft.Extensions.Configuration; +using Serilog; + +namespace MyNewProjectName.WebAPI.Middleware; + +/// +/// 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. +/// +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("Logging:EnableRequestLogging", false); + _enableResponseLogging = configuration.GetValue("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 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 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; + } +} diff --git a/MyNewProjectName.WebAPI/MyNewProjectName.WebAPI.csproj b/MyNewProjectName.WebAPI/MyNewProjectName.WebAPI.csproj new file mode 100644 index 0000000..4441521 --- /dev/null +++ b/MyNewProjectName.WebAPI/MyNewProjectName.WebAPI.csproj @@ -0,0 +1,33 @@ + + + + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MyNewProjectName.WebAPI/MyNewProjectName.WebAPI.http b/MyNewProjectName.WebAPI/MyNewProjectName.WebAPI.http new file mode 100644 index 0000000..88e90ff --- /dev/null +++ b/MyNewProjectName.WebAPI/MyNewProjectName.WebAPI.http @@ -0,0 +1,6 @@ +@MyNewProjectName.WebAPI_HostAddress = http://localhost:5044 + +GET {{MyNewProjectName.WebAPI_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/MyNewProjectName.WebAPI/Program.cs b/MyNewProjectName.WebAPI/Program.cs new file mode 100644 index 0000000..e6aa956 --- /dev/null +++ b/MyNewProjectName.WebAPI/Program.cs @@ -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(); + +// 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(); + +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(); + +app.MapControllers(); + +try +{ + Log.Information("Starting MyNewProjectName.WebAPI"); + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); + throw; +} +finally +{ + Log.CloseAndFlush(); +} + diff --git a/MyNewProjectName.WebAPI/Properties/launchSettings.json b/MyNewProjectName.WebAPI/Properties/launchSettings.json new file mode 100644 index 0000000..4fdd970 --- /dev/null +++ b/MyNewProjectName.WebAPI/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/MyNewProjectName.WebAPI/appsettings.Development.json b/MyNewProjectName.WebAPI/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/MyNewProjectName.WebAPI/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/MyNewProjectName.WebAPI/appsettings.json b/MyNewProjectName.WebAPI/appsettings.json new file mode 100644 index 0000000..2aa297e --- /dev/null +++ b/MyNewProjectName.WebAPI/appsettings.json @@ -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": "*" +} diff --git a/MyNewProjectName.sln b/MyNewProjectName.sln new file mode 100644 index 0000000..12c0552 --- /dev/null +++ b/MyNewProjectName.sln @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4185162 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/GitBranch.md b/docs/GitBranch.md new file mode 100644 index 0000000..be8f369 --- /dev/null +++ b/docs/GitBranch.md @@ -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 + +``` +/- +``` + +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: + +``` +//- +``` + +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 | 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: /- +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 +``` diff --git a/docs/GitCommit.md b/docs/GitCommit.md new file mode 100644 index 0000000..37d01dd --- /dev/null +++ b/docs/GitCommit.md @@ -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 + +``` +(): +``` + +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) + +``` +(): + + + +