diff --git a/docs/Logging.md b/docs/Logging.md new file mode 100644 index 000000000..4bc7b1af7 --- /dev/null +++ b/docs/Logging.md @@ -0,0 +1,127 @@ +# Logging + +## HTTP Logging for API + +The HTTP Logging Middleware `HttpLoggingMiddleware.cs` is responsible for capturing detailed information about every HTTP request and response passing through the application. This includes logging the request and response metadata, body (if enabled), and any exceptions that occur during processing. The logged data can be stored in a database for auditing, debugging, or analytical purposes. + +### Key Features + +- Logs HTTP request and response metadata such as headers, method, URL, and body. +- Measures the duration of request processing. +- Captures exception details if an error occurs during processing. +- Configurable options to enable or disable logging of request and response bodies. + +### How It Works + +1. The middleware intercepts all HTTP requests and responses. +2. If configured, it reads the request and response bodies. +3. Exceptions encountered during request processing are captured and logged. +4. Asynchronous logging ensures minimal performance impact. +5. The collected data is structured into an `HttpLog` object and saved to the database. + +--- + +### Configuration Properties + +The `HttpLogging` section in the appsettings.json file allows you to customize the behavior of the HTTP logging middleware and background processor. Below are the available configuration properties: + +| Property | Type | Default Value | Description | +|------------------------|---------|---------------|-----------------------------------------------------------------------------| +| `Enable` | `bool` | `false` | Toggles the logging functionality on or off. | +| `EnableRequestBody` | `bool` | `false` | Enables or disables logging of the HTTP request body. | +| `EnableResponseBody` | `bool` | `false` | Enables or disables logging of the HTTP response body. | +| `BatchSize` | `int` | `50` | Specifies the number of log entries to process in a single batch. | + +### Example `appsettings.json` Configuration + +```json +"HttpLogging": { + "Enable": true, + "EnableRequestBody": false, + "EnableResponseBody": false, + "BatchSize": 50 +} +``` + +## HttpLog Details + +The `HttpLog` class is used to store detailed information about HTTP requests, responses, and exceptions encountered during processing. This data can be used for debugging, auditing, or performance analysis. + +### General Information + +| Property | Type | Description | +|-----------------------|--------|---------------------------------------------------------------| +| `StatusCode` | `int` | The HTTP status code returned in the response. | +| `Duration` | `long` | The total time taken to process the request, in milliseconds. | +| `AssemblyName` | `string` | The name of the assembly handling the request. | +| `AssemblyVersion` | `string` | The version of the assembly handling the request. | +| `ProcessId` | `int` | The process ID of the application handling the request. | +| `ProcessName` | `string` | The name of the process handling the request. | +| `ThreadId` | `int` | The thread ID of the thread handling the request. | +| `MemoryUsage` | `long` | The memory usage (in bytes) at the time of logging. | +| `MachineName` | `string` | The name of the machine handling the request. | +| `EnvironmentName` | `string` | The environment name (e.g., Development, Production). | +| `EnvironmentUserName` | `string` | The username of the user running the application. | +| `IsAuthenticated` | `bool` | Indicates whether the request is authenticated. | +| `Language` | `string` | The language preference of the user making the request. | +| `SessionId` | `string` | The session ID associated with the request. | +| `StartDate` | `DateTime` | The start date and time of the request. | +| `TraceId` | `string` | A unique identifier for tracing the request. | +| `UniqueId` | `string` | A unique identifier associated with the request. | +| `UserId` | `Guid` | The ID of the user making the request. | +| `UserIp` | `string` | The IP address of the user making the request. | +| `Username` | `string` | The username of the authenticated user. | +| `ApiTokenKey` | `string` | The API token key used for authentication (if applicable). | + +--- + +### Request Details + +| Property | Type | Description | +|--------------------|-------------------------|--------------------------------------------------------------| +| `ReqUrl` | `string` | The full URL of the request. | +| `ReqProtocol` | `string` | The protocol used in the request (e.g., HTTP/1.1). | +| `ReqMethod` | `string` | The HTTP method of the request (e.g., GET, POST). | +| `ReqScheme` | `string` | The scheme used in the request (e.g., http, https). | +| `ReqPathBase` | `string` | The base path of the request. | +| `ReqPath` | `string` | The path of the request. | +| `QueryString` | `string` | The query string of the request. | +| `ReqContentType` | `string` | The content type of the request. | +| `ReqContentLength` | `long?` | The content length of the request body (if available). | +| `ReqBody` | `string?` | The body of the request (if enabled). | +| `ReqHeaders` | `Dictionary` | The HTTP headers of the request. | + +--- + +### Response Details + +| Property | Type | Description | +|--------------------|-------------------------|--------------------------------------------------------------| +| `ResContentType` | `string` | The content type of the response. | +| `ResContentLength` | `long?` | The content length of the response body (if available). | +| `ResBody` | `string?` | The body of the response (if enabled). | +| `ResHeaders` | `Dictionary` | The HTTP headers of the response. | + +--- + +### Exception Details + +| Property | Type | Description | +|----------------|-------------|---------------------------------------------------------------| +| `ExData` | `IDictionary?` | Additional data associated with the exception. | +| `ExHelpLink` | `string?` | A link to documentation or support for the exception. | +| `ExHResult` | `int?` | The HRESULT code for the exception. | +| `ExMessage` | `string?` | The message describing the exception. | +| `ExSource` | `string?` | The source of the exception (e.g., assembly or method name). | +| `ExStackTrace` | `string?` | The stack trace for the exception. | + +--- + +### Usage + +The `HttpLog` class provides a structured format for capturing request, response, and exception data. It is typically populated by the HTTP Logging Middleware and stored in a database or other logging infrastructure for further analysis. + +This data is useful for: +- Debugging and resolving issues in the application. +- Auditing HTTP interactions for compliance or monitoring. +- Analyzing application performance and identifying bottlenecks. diff --git a/docs/README.md b/docs/README.md index a838ba72a..133da6499 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,2 +1,7 @@ -# FluentCMS.Docs -FluentCMS documentation +# FluentCMS + +## Documentation + +## Logging + +For detailed information about HTTP Logging functionality, configuration, and usage, refer to the [Logging Documentation](Logging.md). diff --git a/src/Backend/FluentCMS.Entities/HttpLog.cs b/src/Backend/FluentCMS.Entities/HttpLog.cs new file mode 100644 index 000000000..979c0a8f2 --- /dev/null +++ b/src/Backend/FluentCMS.Entities/HttpLog.cs @@ -0,0 +1,231 @@ +using System.Collections; + +namespace FluentCMS.Entities; + +/// +/// Represents an HTTP log entry that captures detailed information about an HTTP request, response, and any associated exceptions. +/// +public sealed class HttpLog : Entity +{ + /// + /// Gets or sets the HTTP status code of the response. + /// + public int StatusCode { get; set; } + + /// + /// Gets or sets the duration of the request processing in milliseconds. + /// + public long Duration { get; set; } + + /// + /// Gets or sets the name of the assembly handling the request. + /// + public string AssemblyName { get; set; } = string.Empty; + + /// + /// Gets or sets the version of the assembly handling the request. + /// + public string AssemblyVersion { get; set; } = string.Empty; + + /// + /// Gets or sets the process ID of the application handling the request. + /// + public int ProcessId { get; set; } + + /// + /// Gets or sets the name of the process handling the request. + /// + public string ProcessName { get; set; } = string.Empty; + + /// + /// Gets or sets the thread ID where the request is being handled. + /// + public int ThreadId { get; set; } + + /// + /// Gets or sets the memory usage in bytes at the time of logging. + /// + public long MemoryUsage { get; set; } + + /// + /// Gets or sets the name of the machine handling the request. + /// + public string MachineName { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the environment (e.g., Development, Production). + /// + public string EnvironmentName { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the user running the environment. + /// + public string EnvironmentUserName { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the request is authenticated. + /// + public bool IsAuthenticated { get; set; } + + /// + /// Gets or sets the language of the request. + /// + public string Language { get; set; } = string.Empty; + + /// + /// Gets or sets the session ID associated with the request. + /// + public string SessionId { get; set; } = string.Empty; + + /// + /// Gets or sets the start date and time of the request. + /// + public DateTime StartDate { get; set; } + + /// + /// Gets or sets the trace ID for distributed tracing. + /// + public string TraceId { get; set; } = string.Empty; + + /// + /// Gets or sets the unique ID for the request. + /// + public string UniqueId { get; set; } = string.Empty; + + /// + /// Gets or sets the user ID associated with the request. + /// + public Guid UserId { get; set; } + + /// + /// Gets or sets the IP address of the user making the request. + /// + public string UserIp { get; set; } = string.Empty; + + /// + /// Gets or sets the username of the user making the request. + /// + public string Username { get; set; } = string.Empty; + + /// + /// Gets or sets the API token key used for authentication. + /// + public string ApiTokenKey { get; set; } = string.Empty; + + #region Request + + /// + /// Gets or sets the URL of the request. + /// + public string ReqUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the protocol of the request (e.g., HTTP/1.1). + /// + public string ReqProtocol { get; set; } = string.Empty; + + /// + /// Gets or sets the HTTP method of the request (e.g., GET, POST). + /// + public string ReqMethod { get; set; } = string.Empty; + + /// + /// Gets or sets the scheme of the request (e.g., http, https). + /// + public string ReqScheme { get; set; } = string.Empty; + + /// + /// Gets or sets the base path of the request. + /// + public string ReqPathBase { get; set; } = string.Empty; + + /// + /// Gets or sets the path of the request. + /// + public string ReqPath { get; set; } = string.Empty; + + /// + /// Gets or sets the query string of the request. + /// + public string QueryString { get; set; } = string.Empty; + + /// + /// Gets or sets the content type of the request. + /// + public string ReqContentType { get; set; } = string.Empty; + + /// + /// Gets or sets the content length of the request body. + /// + public long? ReqContentLength { get; set; } + + /// + /// Gets or sets the body of the request. + /// + public string? ReqBody { get; set; } + + /// + /// Gets or sets the headers of the request. + /// + public Dictionary ReqHeaders { get; set; } = new(); + + #endregion + + #region Response + + /// + /// Gets or sets the content type of the response. + /// + public string ResContentType { get; set; } = string.Empty; + + /// + /// Gets or sets the content length of the response body. + /// + public long? ResContentLength { get; set; } + + /// + /// Gets or sets the body of the response. + /// + public string? ResBody { get; set; } + + /// + /// Gets or sets the headers of the response. + /// + public Dictionary ResHeaders { get; set; } = new(); + + #endregion + + #region Exception + + /// + /// Gets or sets additional data associated with an exception. + /// + public IDictionary? ExData { get; set; } + + /// + /// Gets or sets the help link for the exception. + /// + public string? ExHelpLink { get; set; } + + /// + /// Gets or sets the HResult code for the exception. + /// + public int? ExHResult { get; set; } + + /// + /// Gets or sets the message of the exception. + /// + public string? ExMessage { get; set; } + + /// + /// Gets or sets the source of the exception. + /// + public string? ExSource { get; set; } + + /// + /// Gets or sets the stack trace of the exception. + /// + public string? ExStackTrace { get; set; } + + #endregion +} diff --git a/src/Backend/Repositories/FluentCMS.Repositories.Abstractions/IApiExecutionContext.cs b/src/Backend/FluentCMS.Entities/IApiExecutionContext.cs similarity index 92% rename from src/Backend/Repositories/FluentCMS.Repositories.Abstractions/IApiExecutionContext.cs rename to src/Backend/FluentCMS.Entities/IApiExecutionContext.cs index fc5717759..035cf156a 100644 --- a/src/Backend/Repositories/FluentCMS.Repositories.Abstractions/IApiExecutionContext.cs +++ b/src/Backend/FluentCMS.Entities/IApiExecutionContext.cs @@ -1,4 +1,4 @@ -namespace FluentCMS; +namespace FluentCMS.Entities; // TODO: move this to a project which include server side shared resources which will be accessible in any other server projects @@ -62,4 +62,10 @@ public interface IApiExecutionContext /// Returns an empty string if the user is not authenticated. /// string Username { get; } + + + /// + /// Gets the API token key associated with the current request, which can be used for + /// + string ApiTokenKey { get; } } diff --git a/src/Backend/FluentCMS.Services/HttpLogService.cs b/src/Backend/FluentCMS.Services/HttpLogService.cs new file mode 100644 index 000000000..b45adafb8 --- /dev/null +++ b/src/Backend/FluentCMS.Services/HttpLogService.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Services; + +public interface IHttpLogService : IAutoRegisterService +{ + void Log(HttpLog httpLog); +} + +public class HttpLogService(IHttpLogChannel httpLogChannel) : IHttpLogService +{ + public void Log(HttpLog httpLog) + { + httpLogChannel.Write(httpLog); + } +} diff --git a/src/Backend/FluentCMS.Services/Logging/BackgroundHttpLogProcessor.cs b/src/Backend/FluentCMS.Services/Logging/BackgroundHttpLogProcessor.cs new file mode 100644 index 000000000..8cec31b1c --- /dev/null +++ b/src/Backend/FluentCMS.Services/Logging/BackgroundHttpLogProcessor.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace FluentCMS.Services; + +public class BackgroundHttpLogProcessor(IHttpLogChannel logChannel, IServiceProvider serviceProvider, IOptions options) : BackgroundService +{ + private readonly HttpLogConfig _config = options.Value ?? new HttpLogConfig(); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_config.Enable) + return; + + var batch = new List(); + + try + { + await foreach (var log in logChannel.ReadAllAsync(stoppingToken)) + { + batch.Add(log); + + // Process the batch when the size reaches the limit + if (batch.Count >= _config.BatchSize) + { + await ProcessBatchAsync(batch, stoppingToken); + batch.Clear(); + } + } + } + catch (OperationCanceledException) + { + // Graceful exit on cancellation + } + catch (Exception) + { + // Log or handle unexpected errors + // TODO: Add logging + } + + // Process remaining logs before shutdown + if (batch.Count > 0) + { + await ProcessBatchAsync(batch, stoppingToken); + } + } + + private async Task ProcessBatchAsync(IEnumerable batch, CancellationToken cancellationToken) + { + try + { + // Create a new scope for each log entry + using var scope = serviceProvider.CreateScope(); + var httpLogRepository = scope.ServiceProvider.GetRequiredService(); + await httpLogRepository.CreateMany(batch, cancellationToken); + } + catch (Exception) + { + // Handle errors during batch processing, e.g., log or retry logic + // TODO: Add logging + } + } +} diff --git a/src/Backend/FluentCMS.Services/Logging/HttpLogChannel.cs b/src/Backend/FluentCMS.Services/Logging/HttpLogChannel.cs new file mode 100644 index 000000000..0aad41f38 --- /dev/null +++ b/src/Backend/FluentCMS.Services/Logging/HttpLogChannel.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Options; +using System.Threading.Channels; + +namespace FluentCMS.Services; + +public interface IHttpLogChannel +{ + void Write(HttpLog httpLog); + IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken = default); +} + +public class HttpLogChannel : IHttpLogChannel +{ + private readonly Channel _logChannel; + private readonly HttpLogConfig _config; + + public HttpLogChannel(IOptions options) + { + _logChannel = Channel.CreateUnbounded(); + _config = options.Value ?? new HttpLogConfig(); + } + + public void Write(HttpLog httpLog) + { + if (!_config.Enable) + return; + + if (!_logChannel.Writer.TryWrite(httpLog)) + { + // Handle overflow if needed + // TODO: For now, just ignore the log entry + } + } + + public IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken = default) + { + return _logChannel.Reader.ReadAllAsync(cancellationToken); + } +} + diff --git a/src/Backend/FluentCMS.Services/Logging/HttpLogConfig.cs b/src/Backend/FluentCMS.Services/Logging/HttpLogConfig.cs new file mode 100644 index 000000000..d0ec79540 --- /dev/null +++ b/src/Backend/FluentCMS.Services/Logging/HttpLogConfig.cs @@ -0,0 +1,9 @@ +namespace FluentCMS.Services; + +public class HttpLogConfig +{ + public bool Enable { get; set; } = false; + public bool EnableRequestBody { get; set; } = false; + public bool EnableResponseBody { get; set; } = false; + public int BatchSize { get; set; } = 20; +} diff --git a/src/Backend/FluentCMS.Services/ServiceExtensions.cs b/src/Backend/FluentCMS.Services/ServiceExtensions.cs index 1d8ba6f51..36fd12de5 100644 --- a/src/Backend/FluentCMS.Services/ServiceExtensions.cs +++ b/src/Backend/FluentCMS.Services/ServiceExtensions.cs @@ -11,9 +11,12 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddAutoMapper(typeof(MappingProfile)); services.AddScoped(); - services.AddScoped(); + services.AddOptions().BindConfiguration("HttpLogging"); + services.AddSingleton(); + services.AddHostedService(); + AddIdentity(services); RegisterServices(services); diff --git a/src/Backend/FluentCMS.Web.Api/ApiExecutionContext.cs b/src/Backend/FluentCMS.Web.Api/ApiExecutionContext.cs index 7fdf47e30..1319645a9 100644 --- a/src/Backend/FluentCMS.Web.Api/ApiExecutionContext.cs +++ b/src/Backend/FluentCMS.Web.Api/ApiExecutionContext.cs @@ -15,6 +15,7 @@ public class ApiExecutionContext : IApiExecutionContext public const string SESSION_ID_HEADER_KEY = "X_Session_Id"; public const string UNIQUE_USER_ID_HEADER_KEY = "X-Unique-Id"; public const string DEFAULT_LANGUAGE = "en-US"; + public const string API_AUTH_HEADER = "X-API-AUTH"; // Properties to store various contextual information public string TraceId { get; } = string.Empty; // Unique identifier for the current request @@ -26,6 +27,7 @@ public class ApiExecutionContext : IApiExecutionContext public Guid UserId { get; } = Guid.Empty; // User ID extracted from the user's claims, default is Guid.Empty public string Username { get; } = string.Empty; // Username extracted from the user's claims, default is empty string public bool IsAuthenticated { get; } = false; // Indicates if the user is authenticated, default is false + public string ApiTokenKey { get; } = string.Empty; // API token key extracted from the request headers /// /// Constructor initializes the ApiExecutionContext with the current HTTP context information. @@ -60,6 +62,9 @@ public ApiExecutionContext(IHttpContextAccessor accessor) // Determine if the user is authenticated IsAuthenticated = user.Identity?.IsAuthenticated ?? false; + + // extract the API token key from the request headers + ApiTokenKey = context.Request?.Headers?.FirstOrDefault(_ => _.Key.Equals(API_AUTH_HEADER, StringComparison.OrdinalIgnoreCase)).Value.ToString() ?? string.Empty; } } } diff --git a/src/Backend/FluentCMS.Web.Api/Controllers/_Test.cs b/src/Backend/FluentCMS.Web.Api/Controllers/_Test.cs new file mode 100644 index 000000000..32736b4c6 --- /dev/null +++ b/src/Backend/FluentCMS.Web.Api/Controllers/_Test.cs @@ -0,0 +1,107 @@ +namespace FluentCMS.Web.Api.Controllers; + +public class PersonController : BaseGlobalController +{ + private readonly List _persons = + [ + new() { Id=1, FirstName = "John", LastName = "Doe", Age = 30 }, + new() { Id=2, FirstName = "Jane", LastName = "Doe", Age = 25 }, + new() { Id=3, FirstName = "Alice", LastName = "Smith", Age = 35 }, + new() { Id=4, FirstName = "Bob", LastName = "Smith", Age = 40 }, + ]; + + [HttpGet] + public async Task> GetAll(CancellationToken cancellationToken = default) + { + await Task.Delay(100, cancellationToken); + return OkPaged(_persons); + } + + [HttpPost] + public async Task> Create([FromBody] Person person, CancellationToken cancellationToken = default) + { + await Task.Delay(100, cancellationToken); + person.Id = _persons.Max(p => p.Id) + 1; + _persons.Add(person); + return Ok(person); + } + + [HttpPost] + public async Task> Update([FromBody] Person person, CancellationToken cancellationToken = default) + { + await Task.Delay(100, cancellationToken); + var existingPerson = _persons.FirstOrDefault(p => p.Id == person.Id); + if (existingPerson == null) + { + return Ok(person); + } + existingPerson.FirstName = person.FirstName; + existingPerson.LastName = person.LastName; + existingPerson.Age = person.Age; + return Ok(existingPerson); + } + + [HttpGet] + public async Task> GetAllRaw(CancellationToken cancellationToken = default) + { + await Task.Delay(100, cancellationToken); + return _persons; + } + + [HttpPost] + public async Task CreateRaw([FromBody] Person person, CancellationToken cancellationToken = default) + { + await Task.Delay(100, cancellationToken); + person.Id = _persons.Max(p => p.Id) + 1; + _persons.Add(person); + return person; + } + + [HttpPost] + public async Task UpdateRaw([FromBody] Person person, CancellationToken cancellationToken = default) + { + await Task.Delay(100, cancellationToken); + var existingPerson = _persons.FirstOrDefault(p => p.Id == person.Id); + if (existingPerson == null) + { + return person; + } + existingPerson.FirstName = person.FirstName; + existingPerson.LastName = person.LastName; + existingPerson.Age = person.Age; + return existingPerson; + } + + // status 500, example of a method that throw an exception + [HttpGet] + public async Task> GetException500(CancellationToken cancellationToken = default) + { + await Task.Delay(100, cancellationToken); + throw new Exception("This is an exception"); + } + + // status 500, example of a method that throw an exception + [HttpGet] + public async Task GetException500Raw(CancellationToken cancellationToken = default) + { + await Task.Delay(100, cancellationToken); + throw new Exception("This is an exception"); + } + + // status 404, example of a method that throw an exception + [HttpGet] + public async Task GetException404Raw(CancellationToken cancellationToken = default) + { + await Task.Delay(100, cancellationToken); + return new NotFoundResult(); + } +} + +public class Person +{ + [Required] + public int Id { get; set; } + public string FirstName { get; set; } = default!; + public string LastName { get; set; } = default!; + public int Age { get; set; } +} diff --git a/src/Backend/FluentCMS.Web.Api/Extentions/ApiServiceExtensions.cs b/src/Backend/FluentCMS.Web.Api/Extentions/ApiServiceExtensions.cs index 0d15572d7..dae768a3a 100644 --- a/src/Backend/FluentCMS.Web.Api/Extentions/ApiServiceExtensions.cs +++ b/src/Backend/FluentCMS.Web.Api/Extentions/ApiServiceExtensions.cs @@ -5,7 +5,6 @@ using FluentCMS.Web.Api.Middleware; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using System.Security.Cryptography; @@ -86,7 +85,7 @@ public static IServiceCollection AddApiServices(this IServiceCollection services services.AddHttpContextAccessor(); - services.AddScoped(sp => new ApiExecutionContext(sp.GetRequiredService())); + services.AddScoped(); services.AddAutoMapper(typeof(ApiServiceExtensions)); @@ -105,6 +104,7 @@ public static WebApplication UseApiService(this WebApplication app) { // this will be executed only when the path starts with "/api" app.UseMiddleware(); + app.UseMiddleware(); }); app.UseAuthorization(); diff --git a/src/Backend/FluentCMS.Web.Api/Filters/ApiResultActionFilter.cs b/src/Backend/FluentCMS.Web.Api/Filters/ApiResultActionFilter.cs index 851bccf3e..fa7338588 100644 --- a/src/Backend/FluentCMS.Web.Api/Filters/ApiResultActionFilter.cs +++ b/src/Backend/FluentCMS.Web.Api/Filters/ApiResultActionFilter.cs @@ -2,14 +2,8 @@ namespace FluentCMS.Web.Api.Filters; -public class ApiResultActionFilter : IAsyncActionFilter +public class ApiResultActionFilter(IApiExecutionContext apiExecutionContext) : IAsyncActionFilter { - private readonly IApiExecutionContext _apiExecutionContext; - - public ApiResultActionFilter(IApiExecutionContext apiExecutionContext) - { - _apiExecutionContext = apiExecutionContext; - } public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { // Execute the action @@ -25,10 +19,10 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE return; var apiResult = (IApiResult)value; - apiResult.Duration = (DateTime.UtcNow - _apiExecutionContext.StartDate).TotalMilliseconds; - apiResult.SessionId = _apiExecutionContext.SessionId; - apiResult.TraceId = _apiExecutionContext.TraceId; - apiResult.UniqueId = _apiExecutionContext.UniqueId; + apiResult.Duration = (DateTime.UtcNow - apiExecutionContext.StartDate).TotalMilliseconds; + apiResult.SessionId = apiExecutionContext.SessionId; + apiResult.TraceId = apiExecutionContext.TraceId; + apiResult.UniqueId = apiExecutionContext.UniqueId; apiResult.Status = 200; apiResult.IsSuccess = true; } diff --git a/src/Backend/FluentCMS.Web.Api/Filters/ApiResultExceptionFilter.cs b/src/Backend/FluentCMS.Web.Api/Filters/ApiResultExceptionFilter.cs index 5ac8a39c9..cccf86d61 100644 --- a/src/Backend/FluentCMS.Web.Api/Filters/ApiResultExceptionFilter.cs +++ b/src/Backend/FluentCMS.Web.Api/Filters/ApiResultExceptionFilter.cs @@ -2,15 +2,8 @@ namespace FluentCMS.Web.Api.Filters; -public class ApiResultExceptionFilter : IExceptionFilter +public class ApiResultExceptionFilter(IApiExecutionContext apiExecutionContext) : IExceptionFilter { - private readonly IApiExecutionContext _apiExecutionContext; - - public ApiResultExceptionFilter(IApiExecutionContext apiExecutionContext) - { - _apiExecutionContext = apiExecutionContext; - } - public void OnException(ExceptionContext context) { if (!context.ActionDescriptor.IsApiResultType()) @@ -18,10 +11,10 @@ public void OnException(ExceptionContext context) var apiResult = new ApiResult { - Duration = (DateTime.UtcNow - _apiExecutionContext.StartDate).TotalMilliseconds, - SessionId = _apiExecutionContext.SessionId, - TraceId = _apiExecutionContext.TraceId, - UniqueId = _apiExecutionContext.UniqueId, + Duration = (DateTime.UtcNow - apiExecutionContext.StartDate).TotalMilliseconds, + SessionId = apiExecutionContext.SessionId, + TraceId = apiExecutionContext.TraceId, + UniqueId = apiExecutionContext.UniqueId, Status = 500, IsSuccess = false, }; diff --git a/src/Backend/FluentCMS.Web.Api/Middleware/HttpLoggingMiddleware.cs b/src/Backend/FluentCMS.Web.Api/Middleware/HttpLoggingMiddleware.cs new file mode 100644 index 000000000..67eee8b0a --- /dev/null +++ b/src/Backend/FluentCMS.Web.Api/Middleware/HttpLoggingMiddleware.cs @@ -0,0 +1,215 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text; + +namespace FluentCMS.Web.Api.Middleware; + +internal sealed class HttpLoggingMiddleware +{ + private readonly RequestDelegate _next; + private readonly HttpLogConfig _httpLogConfig; + private readonly Assembly? _assembly; + private readonly AssemblyName? _assemblyName; + +#pragma warning disable IDE0290 // Use primary constructor + public HttpLoggingMiddleware(RequestDelegate next, IOptions options) +#pragma warning restore IDE0290 // Use primary constructor + { + _next = next; + _httpLogConfig = options.Value ?? new HttpLogConfig(); + _assembly = Assembly.GetEntryAssembly(); + _assemblyName = _assembly?.GetName(); + } + + public async Task Invoke(HttpContext context, IHttpLogService httpLogService, ISetupService setupService) + { + if (!_httpLogConfig.Enable || !await setupService.IsInitialized()) + { + await _next(context); + return; + } + + var stopwatch = Stopwatch.StartNew(); + Exception? exception = null; + + if (!_httpLogConfig.EnableResponseBody) + { + try + { + await _next(context); + } + catch (Exception ex) + { + exception = ex; + throw; + } + finally + { + stopwatch.Stop(); + + var httpLog = new HttpLog(); + + // Create a list of tasks to run simultaneously + var tasks = new[] + { + FillHttpLog(httpLog, context, stopwatch), + FillRequest(httpLog, context.Request), + FillResponse(httpLog, context.Response, null), + FillException(httpLog, context, exception) + }; + + // Wait for all tasks to complete + await Task.WhenAll(tasks); + httpLogService.Log(httpLog); + } + } + + if (_httpLogConfig.EnableResponseBody) + { + + var originalResponseStream = context.Response.Body; + + await using var responseMemoryStream = new MemoryStream(); + context.Response.Body = responseMemoryStream; + + try + { + await _next(context); + } + catch (Exception ex) + { + exception = ex; + throw; + } + finally + { + stopwatch.Stop(); + + var responseBody = await ReadResponseBody(context, originalResponseStream, responseMemoryStream); + + var httpLog = new HttpLog(); + + // Create a list of tasks to run simultaneously + var tasks = new[] + { + FillHttpLog(httpLog, context, stopwatch), + FillRequest(httpLog, context.Request), + FillResponse(httpLog, context.Response, responseBody), + FillException(httpLog, context, exception) + }; + + // Wait for all tasks to complete + await Task.WhenAll(tasks); + httpLogService.Log(httpLog); + } + } + } + + private async Task ReadRequestBody(HttpRequest request) + { + if (!_httpLogConfig.EnableRequestBody) + return string.Empty; + + request.EnableBuffering(); + + using var reader = new StreamReader(request.Body, encoding: Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + var requestBody = await reader.ReadToEndAsync(); + request.Body.Position = 0; + + return requestBody; + } + + private static async Task ReadResponseBody(HttpContext context, Stream originalResponseStream, Stream? memoryStream) + { + if (memoryStream == null) + return string.Empty; + + memoryStream.Position = 0; + using var reader = new StreamReader(memoryStream, encoding: Encoding.UTF8); + var responseBody = await reader.ReadToEndAsync(); + memoryStream.Position = 0; + await memoryStream.CopyToAsync(originalResponseStream); + context.Response.Body = originalResponseStream; + + return responseBody; + } + + private async Task FillHttpLog(HttpLog httpLog, HttpContext context, Stopwatch stopwatch) + { + var thread = Thread.CurrentThread; + var process = Process.GetCurrentProcess(); + var apiContext = context.RequestServices.GetRequiredService(); + + httpLog.StatusCode = context.Response.StatusCode; + httpLog.Duration = stopwatch.ElapsedMilliseconds; + httpLog.AssemblyName = _assemblyName?.Name ?? string.Empty; + httpLog.AssemblyVersion = _assemblyName?.Version?.ToString() ?? string.Empty; + httpLog.ProcessId = process.Id; + httpLog.ProcessName = process.ProcessName; + httpLog.ThreadId = thread.ManagedThreadId; + httpLog.MemoryUsage = process.PrivateMemorySize64; + httpLog.MachineName = Environment.MachineName; + httpLog.EnvironmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? string.Empty; + httpLog.EnvironmentUserName = Environment.UserName; + httpLog.ApiTokenKey = apiContext.ApiTokenKey; + httpLog.IsAuthenticated = apiContext.IsAuthenticated; + httpLog.Language = apiContext.Language; + httpLog.SessionId = apiContext.SessionId; + httpLog.StartDate = apiContext.StartDate; + httpLog.TraceId = apiContext.TraceId; + httpLog.UniqueId = apiContext.UniqueId; + httpLog.UserId = apiContext.UserId; + httpLog.UserIp = apiContext.UserIp; + httpLog.Username = apiContext.Username; + + await Task.CompletedTask; + } + + private async Task FillRequest(HttpLog httpLog, HttpRequest request) + { + var requestBody = _httpLogConfig.EnableRequestBody ? await ReadRequestBody(request) : null; + + httpLog.ReqUrl = request.GetDisplayUrl(); + httpLog.ReqProtocol = request.Protocol; + httpLog.ReqMethod = request.Method; + httpLog.ReqScheme = request.Scheme; + httpLog.ReqPathBase = request.PathBase; + httpLog.ReqPath = request.Path; + httpLog.QueryString = request.QueryString.Value ?? string.Empty; + httpLog.ReqContentType = request.ContentType ?? string.Empty; + httpLog.ReqContentLength = request.ContentLength; + httpLog.ReqBody = requestBody; + httpLog.ReqHeaders = request.Headers?.ToDictionary(x => x.Key, x => x.Value.ToString()) ?? []; + } + + private static async Task FillResponse(HttpLog httpLog, HttpResponse response, string? responseBody) + { + httpLog.ResContentType = response.ContentType ?? string.Empty; + httpLog.ResContentLength = response.ContentLength; + httpLog.ResBody = responseBody; + httpLog.ResHeaders = response.Headers?.ToDictionary(x => x.Key, x => x.Value.ToString()) ?? []; + await Task.CompletedTask; + } + private static async Task FillException(HttpLog httpLog, HttpContext context, Exception? exception) + { + exception ??= context.Features.Get()?.Error; + + if (exception == null) + return; + + httpLog.ExData = exception.Data; + httpLog.ExHelpLink = exception.HelpLink; + httpLog.ExHResult = exception.HResult; + httpLog.ExMessage = exception.Message; + httpLog.ExSource = exception.Source; + httpLog.ExStackTrace = exception.StackTrace; + + await Task.CompletedTask; + } +} diff --git a/src/Backend/FluentCMS.Web.Api/Middleware/JwtAuthorizationMiddleware.cs b/src/Backend/FluentCMS.Web.Api/Middleware/JwtAuthorizationMiddleware.cs index e63c75020..fce6e707e 100644 --- a/src/Backend/FluentCMS.Web.Api/Middleware/JwtAuthorizationMiddleware.cs +++ b/src/Backend/FluentCMS.Web.Api/Middleware/JwtAuthorizationMiddleware.cs @@ -17,7 +17,9 @@ public class JwtAuthorizationMiddleware /// Constructor to initialize the middleware with the next delegate in the pipeline. /// /// The next delegate/middleware in the request pipeline. +#pragma warning disable IDE0290 // Use primary constructor public JwtAuthorizationMiddleware(RequestDelegate next) +#pragma warning restore IDE0290 // Use primary constructor { _next = next; // Store the next middleware delegate } diff --git a/src/Backend/FluentCMS.Web.Api/Models/ApiPagingResult.cs b/src/Backend/FluentCMS.Web.Api/Models/ApiPagingResult.cs index 578a9ebf1..d5972ca3d 100644 --- a/src/Backend/FluentCMS.Web.Api/Models/ApiPagingResult.cs +++ b/src/Backend/FluentCMS.Web.Api/Models/ApiPagingResult.cs @@ -1,29 +1,77 @@ namespace FluentCMS.Web.Api.Models; +/// +/// Represents a paginated result of an API call, with metadata about the pagination. +/// +/// The type of the data contained in the paginated result. public interface IApiPagingResult : IApiResult> { + /// + /// Gets the number of items per page. + /// int PageSize { get; } + + /// + /// Gets the total number of pages. + /// int TotalPages { get; } + + /// + /// Gets the current page number. + /// int PageNumber { get; } + + /// + /// Gets the total number of items across all pages. + /// long TotalCount { get; } + + /// + /// Gets a value indicating whether there is a previous page. + /// bool HasPrevious { get; } + + /// + /// Gets a value indicating whether there is a next page. + /// bool HasNext { get; } } +/// +/// Provides a concrete implementation of the interface. +/// +/// The type of the data contained in the paginated result. public class ApiPagingResult : ApiResult>, IApiPagingResult { + /// public int PageSize { get; } + + /// public int TotalPages { get; } + + /// public int PageNumber { get; } + + /// public long TotalCount { get; } + + /// public bool HasPrevious { get; } + + /// public bool HasNext { get; } + + /// + /// Initializes a new instance of the class with the specified data. + /// + /// The paginated data. public ApiPagingResult(IEnumerable data) : base(data) { - // Initialize properties based on the data - PageNumber = 1; + // Initialize properties based on the provided data. + PageNumber = 1; // Assuming default value for demonstration. PageSize = data.Count(); TotalCount = data.Count(); + TotalPages = (int)Math.Ceiling((double)TotalCount / PageSize); HasPrevious = PageNumber > 1; HasNext = PageNumber < TotalPages; } diff --git a/src/Backend/FluentCMS.Web.Api/Models/ApiResult.cs b/src/Backend/FluentCMS.Web.Api/Models/ApiResult.cs index ff4065994..dc25382f3 100644 --- a/src/Backend/FluentCMS.Web.Api/Models/ApiResult.cs +++ b/src/Backend/FluentCMS.Web.Api/Models/ApiResult.cs @@ -1,35 +1,103 @@ namespace FluentCMS.Web.Api.Models; +/// +/// Represents the result of an API call. +/// public interface IApiResult { + /// + /// Gets the list of errors associated with the API result. + /// List Errors { get; } + + /// + /// Gets or sets the unique trace identifier for tracking the request. + /// string TraceId { get; set; } + + /// + /// Gets or sets the session identifier associated with the request. + /// string SessionId { get; set; } + + /// + /// Gets or sets the unique identifier for this specific API result. + /// string UniqueId { get; set; } + + /// + /// Gets or sets the duration of the request in milliseconds. + /// double Duration { get; set; } + + /// + /// Gets or sets the HTTP status code of the API result. + /// int Status { get; set; } + + /// + /// Gets or sets a value indicating whether the API call was successful. + /// bool IsSuccess { get; set; } } + +/// +/// Represents the result of an API call with a specific data type. +/// +/// The type of the data returned by the API call. public interface IApiResult : IApiResult { + /// + /// Gets the data returned by the API call. + /// TData? Data { get; } } +/// +/// Provides a concrete implementation of the interface. +/// +/// The type of the data returned by the API call. public class ApiResult : IApiResult { + /// + /// Gets the list of errors associated with the API result. + /// public List Errors { get; } = []; + + /// public string TraceId { get; set; } = string.Empty; + + /// public string SessionId { get; set; } = string.Empty; + + /// public string UniqueId { get; set; } = string.Empty; + + /// public double Duration { get; set; } + + /// public int Status { get; set; } + + /// + /// Gets the data returned by the API call. + /// public TData? Data { get; } + + /// public bool IsSuccess { get; set; } = true; + /// + /// Initializes a new instance of the class. + /// public ApiResult() { } + /// + /// Initializes a new instance of the class with the specified data. + /// + /// The data returned by the API call. public ApiResult(TData data) { Data = data; diff --git a/src/Backend/Repositories/FluentCMS.Repositories.Abstractions/IHttpLogRepository.cs b/src/Backend/Repositories/FluentCMS.Repositories.Abstractions/IHttpLogRepository.cs new file mode 100644 index 000000000..917e28f8e --- /dev/null +++ b/src/Backend/Repositories/FluentCMS.Repositories.Abstractions/IHttpLogRepository.cs @@ -0,0 +1,6 @@ +namespace FluentCMS.Repositories.Abstractions; + +public interface IHttpLogRepository +{ + Task CreateMany(IEnumerable httpLogs, CancellationToken cancellationToken = default); +} diff --git a/src/Backend/Repositories/FluentCMS.Repositories.EFCore.SqlServer/Setup.sql b/src/Backend/Repositories/FluentCMS.Repositories.EFCore.SqlServer/Setup.sql index ee19382a8..20584fcd0 100644 --- a/src/Backend/Repositories/FluentCMS.Repositories.EFCore.SqlServer/Setup.sql +++ b/src/Backend/Repositories/FluentCMS.Repositories.EFCore.SqlServer/Setup.sql @@ -1,4 +1,57 @@ -/****** Object: Table [dbo].[ApiTokenPolicies] Script Date: 2024-11-24 5:32:26 PM ******/ +CREATE TABLE HttpLogs ( + Id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, -- Assuming inherited from Entity + StatusCode INT NOT NULL, + Duration BIGINT NOT NULL, + AssemblyName NVARCHAR(255) NOT NULL, + AssemblyVersion NVARCHAR(50) NOT NULL, + ProcessId INT NOT NULL, + ProcessName NVARCHAR(255) NOT NULL, + ThreadId INT NOT NULL, + MemoryUsage BIGINT NOT NULL, + MachineName NVARCHAR(255) NOT NULL, + EnvironmentName NVARCHAR(100) NOT NULL, + EnvironmentUserName NVARCHAR(255) NOT NULL, + IsAuthenticated BIT NOT NULL, + Language NVARCHAR(50) NOT NULL, + SessionId NVARCHAR(255) NOT NULL, + StartDate DATETIME2 NOT NULL, + TraceId NVARCHAR(255) NOT NULL, + UniqueId NVARCHAR(255) NOT NULL, + UserId UNIQUEIDENTIFIER NOT NULL, + UserIp NVARCHAR(50) NOT NULL, + Username NVARCHAR(255) NOT NULL, + ApiTokenKey NVARCHAR(255) NOT NULL, + + -- Request details + ReqUrl NVARCHAR(MAX) NOT NULL, + ReqProtocol NVARCHAR(50) NOT NULL, + ReqMethod NVARCHAR(50) NOT NULL, + ReqScheme NVARCHAR(50) NOT NULL, + ReqPathBase NVARCHAR(255) NOT NULL, + ReqPath NVARCHAR(255) NOT NULL, + QueryString NVARCHAR(MAX) NOT NULL, + ReqContentType NVARCHAR(255) NOT NULL, + ReqContentLength BIGINT NULL, + ReqBody NVARCHAR(MAX) NULL, + ReqHeaders NVARCHAR(MAX) NOT NULL, -- Store headers as JSON + + -- Response details + ResContentType NVARCHAR(255) NOT NULL, + ResContentLength BIGINT NULL, + ResBody NVARCHAR(MAX) NULL, + ResHeaders NVARCHAR(MAX) NOT NULL, -- Store headers as JSON + + -- Exception details + ExData NVARCHAR(MAX) NULL, -- Store exception data as JSON or serialized string + ExHelpLink NVARCHAR(MAX) NULL, + ExHResult INT NULL, + ExMessage NVARCHAR(MAX) NULL, + ExSource NVARCHAR(MAX) NULL, + ExStackTrace NVARCHAR(MAX) NULL +); + + +/****** Object: Table [dbo].[ApiTokenPolicies] Script Date: 2024-11-24 5:32:26 PM ******/ SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON diff --git a/src/Backend/Repositories/FluentCMS.Repositories.EFCore.Sqlite/Setup.sql b/src/Backend/Repositories/FluentCMS.Repositories.EFCore.Sqlite/Setup.sql index 5d701f843..9222ac35a 100644 --- a/src/Backend/Repositories/FluentCMS.Repositories.EFCore.Sqlite/Setup.sql +++ b/src/Backend/Repositories/FluentCMS.Repositories.EFCore.Sqlite/Setup.sql @@ -1,4 +1,57 @@ --- Table: ApiTokenPolicies +-- Table: HttpLogs +CREATE TABLE HttpLogs ( + Id TEXT NOT NULL PRIMARY KEY, -- Assuming Id is a GUID stored as TEXT + StatusCode INTEGER NOT NULL, + Duration INTEGER NOT NULL, + AssemblyName TEXT NOT NULL, + AssemblyVersion TEXT NOT NULL, + ProcessId INTEGER NOT NULL, + ProcessName TEXT NOT NULL, + ThreadId INTEGER NOT NULL, + MemoryUsage INTEGER NOT NULL, + MachineName TEXT NOT NULL, + EnvironmentName TEXT NOT NULL, + EnvironmentUserName TEXT NOT NULL, + IsAuthenticated INTEGER NOT NULL, -- SQLite uses 0 (false) and 1 (true) for boolean + Language TEXT NOT NULL, + SessionId TEXT NOT NULL, + StartDate TEXT NOT NULL, -- ISO 8601 format for DATETIME + TraceId TEXT NOT NULL, + UniqueId TEXT NOT NULL, + UserId TEXT NOT NULL, -- GUID stored as TEXT + UserIp TEXT NOT NULL, + Username TEXT NOT NULL, + ApiTokenKey TEXT NOT NULL, + + -- Request details + ReqUrl TEXT NOT NULL, + ReqProtocol TEXT NOT NULL, + ReqMethod TEXT NOT NULL, + ReqScheme TEXT NOT NULL, + ReqPathBase TEXT NOT NULL, + ReqPath TEXT NOT NULL, + QueryString TEXT NOT NULL, + ReqContentType TEXT NOT NULL, + ReqContentLength INTEGER NULL, + ReqBody TEXT NULL, + ReqHeaders TEXT NOT NULL, -- Store headers as JSON + + -- Response details + ResContentType TEXT NOT NULL, + ResContentLength INTEGER NULL, + ResBody TEXT NULL, + ResHeaders TEXT NOT NULL, -- Store headers as JSON + + -- Exception details + ExData TEXT NULL, -- Store exception data as JSON or serialized string + ExHelpLink TEXT NULL, + ExHResult INTEGER NULL, + ExMessage TEXT NULL, + ExSource TEXT NULL, + ExStackTrace TEXT NULL +); + +-- Table: ApiTokenPolicies CREATE TABLE ApiTokenPolicies ( Id TEXT NOT NULL PRIMARY KEY, ApiTokenId TEXT NOT NULL, diff --git a/src/Backend/Repositories/FluentCMS.Repositories.EFCore/EFCoreServiceExtensions.cs b/src/Backend/Repositories/FluentCMS.Repositories.EFCore/EFCoreServiceExtensions.cs index dc1a6f141..464e0c86c 100644 --- a/src/Backend/Repositories/FluentCMS.Repositories.EFCore/EFCoreServiceExtensions.cs +++ b/src/Backend/Repositories/FluentCMS.Repositories.EFCore/EFCoreServiceExtensions.cs @@ -16,6 +16,7 @@ public static IServiceCollection AddEFCoreRepositories(this IServiceCollection s services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Backend/Repositories/FluentCMS.Repositories.EFCore/FluentCmsDbContext.cs b/src/Backend/Repositories/FluentCMS.Repositories.EFCore/FluentCmsDbContext.cs index 295571192..f2dd84c3f 100644 --- a/src/Backend/Repositories/FluentCMS.Repositories.EFCore/FluentCmsDbContext.cs +++ b/src/Backend/Repositories/FluentCMS.Repositories.EFCore/FluentCmsDbContext.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Identity; +using System.Collections; using System.Text.Json; namespace FluentCMS.Repositories.EFCore; @@ -18,6 +19,7 @@ public class FluentCmsDbContext(DbContextOptions options) : public DbSet Files { get; set; } = default!; public DbSet Folders { get; set; } = default!; public DbSet GlobalSettings { get; set; } = default!; + public DbSet HttpLogs { get; set; } = default!; public DbSet Layouts { get; set; } = default!; public DbSet Pages { get; set; } = default!; public DbSet Permissions { get; set; } = default!; @@ -126,6 +128,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) claims => JsonSerializer.Deserialize>>(claims, jsonSerializerOptions) ?? new List>()); }); + modelBuilder.Entity(entity => + { + entity.Property(h => h.ReqHeaders) + .HasConversion( + v => JsonSerializer.Serialize(v, jsonSerializerOptions), + v => JsonSerializer.Deserialize>(v, jsonSerializerOptions) ?? new Dictionary()); + entity.Property(h => h.ResHeaders) + .HasConversion( + v => JsonSerializer.Serialize(v, jsonSerializerOptions), + v => JsonSerializer.Deserialize>(v, jsonSerializerOptions) ?? new Dictionary()); + entity.Property(h => h.ExData) + .HasConversion( + v => JsonSerializer.Serialize(v, jsonSerializerOptions), + v => JsonSerializer.Deserialize(v, jsonSerializerOptions) ?? new Dictionary()); + }); + base.OnModelCreating(modelBuilder); } diff --git a/src/Backend/Repositories/FluentCMS.Repositories.EFCore/Repositories/HttpLogRepository.cs b/src/Backend/Repositories/FluentCMS.Repositories.EFCore/Repositories/HttpLogRepository.cs new file mode 100644 index 000000000..a55e2332b --- /dev/null +++ b/src/Backend/Repositories/FluentCMS.Repositories.EFCore/Repositories/HttpLogRepository.cs @@ -0,0 +1,15 @@ +namespace FluentCMS.Repositories.EFCore; + +public class HttpLogRepository(FluentCmsDbContext dbContext) : IHttpLogRepository +{ + public async Task CreateMany(IEnumerable httpLogs, CancellationToken cancellationToken = default) + { + foreach (var http in httpLogs) + { + if (http.Id == Guid.Empty) + http.Id = Guid.NewGuid(); + } + await dbContext.HttpLogs.AddRangeAsync(httpLogs, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Backend/Repositories/FluentCMS.Repositories.LiteDb/HttpLogRepository.cs b/src/Backend/Repositories/FluentCMS.Repositories.LiteDb/HttpLogRepository.cs new file mode 100644 index 000000000..731aeed48 --- /dev/null +++ b/src/Backend/Repositories/FluentCMS.Repositories.LiteDb/HttpLogRepository.cs @@ -0,0 +1,35 @@ +namespace FluentCMS.Repositories.LiteDb; + +public class HttpLogRepository(ILiteDBContext liteDbContext) : IHttpLogRepository +{ + public async Task CreateMany(IEnumerable httpLogs, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (httpLogs == null) + return; + + // Create a list of tasks to insert the HTTP logs into the appropriate collection. + var tasks = httpLogs.GroupBy(log => GetCollectionName(log.StatusCode)) + .Select(async group => + { + // Get the collection name from the group. + var collectionName = group.Key; + // Get the collection from the database. + var collection = liteDbContext.Database.GetCollection(collectionName); + // Insert the HTTP logs into the collection. + await collection.InsertBulkAsync(group); + }); + + // Wait for all tasks to complete. + await Task.WhenAll(tasks); + } + + private static string GetCollectionName(int statusCode) => statusCode switch + { + < 500 and >= 400 => "httphog400", + < 400 and >= 300 => "httplog300", + >= 500 => "httplog500", + _ => "httplog200" + }; +} diff --git a/src/Backend/Repositories/FluentCMS.Repositories.LiteDb/LiteDbServiceExtensions.cs b/src/Backend/Repositories/FluentCMS.Repositories.LiteDb/LiteDbServiceExtensions.cs index 534e00d6f..d7ede2440 100644 --- a/src/Backend/Repositories/FluentCMS.Repositories.LiteDb/LiteDbServiceExtensions.cs +++ b/src/Backend/Repositories/FluentCMS.Repositories.LiteDb/LiteDbServiceExtensions.cs @@ -19,6 +19,7 @@ public static IServiceCollection AddLiteDbRepositories(this IServiceCollection s services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Backend/Repositories/FluentCMS.Repositories.MongoDB/GlobalSettingsRepository.cs b/src/Backend/Repositories/FluentCMS.Repositories.MongoDB/GlobalSettingsRepository.cs index df2d7fd23..0eccd8684 100644 --- a/src/Backend/Repositories/FluentCMS.Repositories.MongoDB/GlobalSettingsRepository.cs +++ b/src/Backend/Repositories/FluentCMS.Repositories.MongoDB/GlobalSettingsRepository.cs @@ -1,17 +1,8 @@ namespace FluentCMS.Repositories.MongoDB; -public class GlobalSettingsRepository : IGlobalSettingsRepository +public class GlobalSettingsRepository(IMongoDBContext mongoDbContext, IApiExecutionContext apiExecutionContext) : IGlobalSettingsRepository { - private readonly IMongoCollection _collection; - private readonly IApiExecutionContext _apiExecutionContext; - private readonly IMongoDBContext _mongoDbContext; - - public GlobalSettingsRepository(IMongoDBContext mongoDbContext, IApiExecutionContext apiExecutionContext) - { - _collection = mongoDbContext.Database.GetCollection(nameof(GlobalSettings).ToLowerInvariant()); - _apiExecutionContext = apiExecutionContext; - _mongoDbContext = mongoDbContext; - } + private readonly IMongoCollection _collection = mongoDbContext.Database.GetCollection(nameof(GlobalSettings).ToLowerInvariant()); public async Task Get(CancellationToken cancellationToken = default) { @@ -47,12 +38,12 @@ private void SetAuditableFieldsForCreate(GlobalSettings settings) { settings.Id = Guid.NewGuid(); settings.CreatedAt = DateTime.UtcNow; - settings.CreatedBy = _apiExecutionContext.Username; + settings.CreatedBy = apiExecutionContext.Username; } private void SetAuditableFieldsForUpdate(GlobalSettings settings) { settings.ModifiedAt = DateTime.UtcNow; - settings.ModifiedBy = _apiExecutionContext.Username; + settings.ModifiedBy = apiExecutionContext.Username; } } diff --git a/src/Backend/Repositories/FluentCMS.Repositories.MongoDB/HttpLogRepository.cs b/src/Backend/Repositories/FluentCMS.Repositories.MongoDB/HttpLogRepository.cs new file mode 100644 index 000000000..a855462d2 --- /dev/null +++ b/src/Backend/Repositories/FluentCMS.Repositories.MongoDB/HttpLogRepository.cs @@ -0,0 +1,36 @@ + +namespace FluentCMS.Repositories.MongoDB; + +public class HttpLogRepository(IMongoDBContext mongoDbContext) : IHttpLogRepository +{ + public async Task CreateMany(IEnumerable httpLogs, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (httpLogs == null) + return; + + // Create a list of tasks to insert the HTTP logs into the appropriate collection. + var tasks = httpLogs.GroupBy(log => GetCollectionName(log.StatusCode)) + .Select(async group => + { + // Get the collection name from the group. + var collectionName = group.Key; + // Get the collection from the database. + var collection = mongoDbContext.Database.GetCollection(collectionName); + // Insert the HTTP logs into the collection. + await collection.InsertManyAsync(group, cancellationToken: cancellationToken); + }); + + // Wait for all tasks to complete. + await Task.WhenAll(tasks); + } + + private static string GetCollectionName(int statusCode) => statusCode switch + { + < 500 and >= 400 => "httphog400", + < 400 and >= 300 => "httplog300", + >= 500 => "httplog500", + _ => "httplog200" + }; +} diff --git a/src/Backend/Repositories/FluentCMS.Repositories.MongoDB/MongoDBServiceExtensions.cs b/src/Backend/Repositories/FluentCMS.Repositories.MongoDB/MongoDBServiceExtensions.cs index 0223285e0..aa3a00a1f 100644 --- a/src/Backend/Repositories/FluentCMS.Repositories.MongoDB/MongoDBServiceExtensions.cs +++ b/src/Backend/Repositories/FluentCMS.Repositories.MongoDB/MongoDBServiceExtensions.cs @@ -25,6 +25,7 @@ public static IServiceCollection AddMongoDbRepositories(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/FluentCMS/appsettings.json b/src/FluentCMS/appsettings.json index cbc929209..4999ff7c6 100644 --- a/src/FluentCMS/appsettings.json +++ b/src/FluentCMS/appsettings.json @@ -1,7 +1,7 @@ { - "Database": "LiteDb", + "Database": "mongodb", "ConnectionStrings": { - "DefaultConnection": "Filename=./fluentcms.db" + "DefaultConnection": "mongodb://localhost:27017/fluentcms" }, "ClientSettings": { "Url": "http://localhost:5000", @@ -35,5 +35,11 @@ "Secret": "YOUR_SECRET_KEY_SHOULD_BE_HERE!" } } + }, + "HttpLogging": { + "Enable": true, + "EnableRequestBody": true, + "EnableResponseBody": true, + "BatchSize": 1 } } diff --git a/src/Shared/Exceptions/AppApiClientException.cs b/src/Shared/Exceptions/AppApiClientException.cs deleted file mode 100644 index 4393f4587..000000000 --- a/src/Shared/Exceptions/AppApiClientException.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace FluentCMS; - -public class AppApiClientException : AppException -{ - public AppApiClientException() : base("ApiClient") - { - } -} diff --git a/src/Shared/Exceptions/AppError.cs b/src/Shared/Exceptions/AppError.cs index cc70076e8..2e4de3ada 100644 --- a/src/Shared/Exceptions/AppError.cs +++ b/src/Shared/Exceptions/AppError.cs @@ -1,19 +1,40 @@ namespace FluentCMS; +/// +/// Represents an error associated with an API call. +/// public class AppError { + /// + /// Gets or sets the error code. + /// public string Code { get; set; } = string.Empty; + + /// + /// Gets or sets the description of the error. + /// public string Description { get; set; } = string.Empty; + /// + /// Initializes a new instance of the class. + /// public AppError() { } + /// + /// Initializes a new instance of the class with the specified error code. + /// + /// The error code. public AppError(string code) { Code = code; } + /// + /// Returns a string representation of the . + /// + /// A string containing the error code and description. public override string ToString() { return $"{Code}-{Description}"; diff --git a/src/Shared/Exceptions/AppException.cs b/src/Shared/Exceptions/AppException.cs index a1cf174c3..b57972f26 100644 --- a/src/Shared/Exceptions/AppException.cs +++ b/src/Shared/Exceptions/AppException.cs @@ -2,36 +2,71 @@ namespace FluentCMS; +/// +/// Represents a custom application exception with additional context, such as the source method and type, and associated errors. +/// public class AppException : ApplicationException { + /// + /// Gets or sets the name of the type where the exception occurred. + /// public string? TypeName { get; set; } + + /// + /// Gets or sets the name of the method where the exception occurred. + /// public string? MethodName { get; set; } + + /// + /// Gets the list of errors associated with this exception. + /// public List Errors { get; } = []; + /// + /// Initializes a new instance of the class with the specified error code. + /// + /// The error code associated with this exception. public AppException(string code) : base(code) { Errors.Add(new AppError(code)); CaptureExceptionSource(); } + /// + /// Initializes a new instance of the class with the specified error code and inner exception. + /// + /// The error code associated with this exception. + /// The inner exception that caused this exception. public AppException(string code, Exception? innerException) : base(code, innerException) { Errors.Add(new AppError(code)); CaptureExceptionSource(); } + /// + /// Initializes a new instance of the class with a collection of error codes. + /// + /// The error codes associated with this exception. public AppException(IEnumerable codes) : base(string.Empty) { Errors.AddRange(codes.Select(c => new AppError(c))); CaptureExceptionSource(); } + /// + /// Initializes a new instance of the class with a collection of error codes and an inner exception. + /// + /// The error codes associated with this exception. + /// The inner exception that caused this exception. public AppException(IEnumerable codes, Exception? innerException) : base(string.Empty, innerException) { Errors.AddRange(codes.Select(c => new AppError(c))); CaptureExceptionSource(); } + /// + /// Captures the source type and method where the exception occurred. + /// private void CaptureExceptionSource() { var stackTrace = new StackTrace(this, true);