diff --git a/CLAUDE.md b/CLAUDE.md index f2c9726..82fe71c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,17 +24,21 @@ ```csharp public abstract partial record Result { private Result() { } } ``` -- **Extension methods on IDbConnection/IDbTransaction only** -- **Pattern match, don't if** - Switch expressions on type -- **No skipping tests** - Failing = OK, Skip = illegal -- **E2E tests only** - No mocks, integration testing -- **Type aliases for Results** - `using XResult = Result` -- **Immutable** - Records, `ImmutableList`, `FrozenSet`, `ImmutableArray` -- **NO REGEX** - ANTLR or SqlParserCS -- **XMLDOC on public members** - Except tests -- **< 450 LOC per file** -- **No commented code** - Delete it -- **No placeholders** - Leave compile errors with TODO +- **Skipping tests = ⛔️ ILLEGAL** - Failing tests = OK. Aggressively unskip tests +- **Test at the highest level** - Avoid mocks. Only full integration testing +- **Keep files under 450 LOC and functions under 20 LOC** +- **Always use type aliases (using) for result types** - Don't write like this: `new Result.Ok` +- **All tables must have a SINGLE primary key** +- **Primary keys MUST be UUIDs** +- **No singletons** - Inject `Func` into static methods +- **Immutable types!** - Use records. Don't use `List`. Use `ImmutableList` `FrozenSet` or `ImmutableArray` +- **NO REGEX** - Parse SQL with ANTLR .g4 grammars or SqlParserCS library +- **All public members require XMLDOC** - Except in test projects +- **One type per file** (except small records) +- **No commented-out code** - Delete it +- **No consecutive Console.WriteLine** - Use single string interpolation +- **No placeholders** - If incomplete, leave LOUD compilation error with TODO +- **Never use Fluent Assertions** ## CSS - **MINIMAL CSS** - Do not duplicate CSS clases diff --git a/DataProvider/DataProvider.Example.Tests/DataProviderIntegrationTests.cs b/DataProvider/DataProvider.Example.Tests/DataProviderIntegrationTests.cs index 70b88cb..2a8df7d 100644 --- a/DataProvider/DataProvider.Example.Tests/DataProviderIntegrationTests.cs +++ b/DataProvider/DataProvider.Example.Tests/DataProviderIntegrationTests.cs @@ -711,10 +711,7 @@ public async Task PredicateBuilder_Or_E2E_CombinesPredicatesWithOrLogic() var predicate = PredicateBuilder.False(); predicate = predicate.Or(c => c.CustomerName == "Acme Corp"); predicate = predicate.Or(c => c.CustomerName == "Tech Solutions"); - var query = SelectStatement - .From("Customer") - .Where(predicate) - .OrderBy(c => c.CustomerName); + var query = SelectStatement.From("Customer").Where(predicate).OrderBy(c => c.CustomerName); // Act var statement = query.ToSqlStatement(); @@ -820,10 +817,7 @@ public async Task PredicateBuilder_DynamicAndConditions_E2E_BuildsFilterChains() predicate = predicate.And(c => c.Email != null); predicate = predicate.And(c => c.CustomerName != null); - var query = SelectStatement - .From("Customer") - .Where(predicate) - .OrderBy(c => c.CustomerName); + var query = SelectStatement.From("Customer").Where(predicate).OrderBy(c => c.CustomerName); var statement = query.ToSqlStatement(); var result = _connection.GetRecords(statement, s => s.ToSQLite(), MapCustomer); diff --git a/DataProvider/DataProvider.Example/SampleDataSeeder.cs b/DataProvider/DataProvider.Example/SampleDataSeeder.cs index 91cd90e..f514eb3 100644 --- a/DataProvider/DataProvider.Example/SampleDataSeeder.cs +++ b/DataProvider/DataProvider.Example/SampleDataSeeder.cs @@ -16,8 +16,22 @@ internal static class SampleDataSeeder IDbTransaction transaction ) { - // Insert Customers + // Generate UUIDs for all entities var customer1Id = Guid.NewGuid().ToString(); + var customer2Id = Guid.NewGuid().ToString(); + var invoiceId = Guid.NewGuid().ToString(); + var invoiceLine1Id = Guid.NewGuid().ToString(); + var invoiceLine2Id = Guid.NewGuid().ToString(); + var address1Id = Guid.NewGuid().ToString(); + var address2Id = Guid.NewGuid().ToString(); + var address3Id = Guid.NewGuid().ToString(); + var order1Id = Guid.NewGuid().ToString(); + var order2Id = Guid.NewGuid().ToString(); + var orderItem1Id = Guid.NewGuid().ToString(); + var orderItem2Id = Guid.NewGuid().ToString(); + var orderItem3Id = Guid.NewGuid().ToString(); + + // Insert Customers var customer1Result = await transaction .InsertCustomerAsync( customer1Id, @@ -33,7 +47,6 @@ IDbTransaction transaction value: new StringSqlError((customer1Result as IntSqlError)!.Value) ); - var customer2Id = Guid.NewGuid().ToString(); var customer2Result = await transaction .InsertCustomerAsync( customer2Id, @@ -50,7 +63,6 @@ IDbTransaction transaction ); // Insert Invoice - var invoiceId = Guid.NewGuid().ToString(); var invoiceResult = await transaction .InsertInvoiceAsync( invoiceId, @@ -72,7 +84,7 @@ IDbTransaction transaction // Insert InvoiceLines var invoiceLine1Result = await transaction .InsertInvoiceLineAsync( - Guid.NewGuid().ToString(), + invoiceLine1Id, invoiceId, "Software License", 1, @@ -90,7 +102,7 @@ IDbTransaction transaction var invoiceLine2Result = await transaction .InsertInvoiceLineAsync( - Guid.NewGuid().ToString(), + invoiceLine2Id, invoiceId, "Support Package", 1, @@ -109,7 +121,7 @@ IDbTransaction transaction // Insert Addresses var address1Result = await transaction .InsertAddressAsync( - Guid.NewGuid().ToString(), + address1Id, customer1Id, "123 Business Ave", "New York", @@ -126,7 +138,7 @@ IDbTransaction transaction var address2Result = await transaction .InsertAddressAsync( - Guid.NewGuid().ToString(), + address2Id, customer1Id, "456 Main St", "Albany", @@ -143,7 +155,7 @@ IDbTransaction transaction var address3Result = await transaction .InsertAddressAsync( - Guid.NewGuid().ToString(), + address3Id, customer2Id, "789 Tech Blvd", "San Francisco", @@ -159,7 +171,6 @@ IDbTransaction transaction ); // Insert Orders - var order1Id = Guid.NewGuid().ToString(); var order1Result = await transaction .InsertOrdersAsync(order1Id, "ORD-001", "2024-01-10", customer1Id, 500.00, "Completed") .ConfigureAwait(false); @@ -169,7 +180,6 @@ IDbTransaction transaction value: new StringSqlError((order1Result as IntSqlError)!.Value) ); - var order2Id = Guid.NewGuid().ToString(); var order2Result = await transaction .InsertOrdersAsync(order2Id, "ORD-002", "2024-01-11", customer2Id, 750.00, "Processing") .ConfigureAwait(false); @@ -181,14 +191,7 @@ IDbTransaction transaction // Insert OrderItems var orderItem1Result = await transaction - .InsertOrderItemAsync( - Guid.NewGuid().ToString(), - order1Id, - "Widget A", - 2, - 100.00, - 200.00 - ) + .InsertOrderItemAsync(orderItem1Id, order1Id, "Widget A", 2.0, 100.00, 200.00) .ConfigureAwait(false); if (orderItem1Result is not IntSqlOk) return ( @@ -197,14 +200,7 @@ IDbTransaction transaction ); var orderItem2Result = await transaction - .InsertOrderItemAsync( - Guid.NewGuid().ToString(), - order1Id, - "Widget B", - 3, - 100.00, - 300.00 - ) + .InsertOrderItemAsync(orderItem2Id, order1Id, "Widget B", 3.0, 100.00, 300.00) .ConfigureAwait(false); if (orderItem2Result is not IntSqlOk) return ( @@ -213,14 +209,7 @@ IDbTransaction transaction ); var orderItem3Result = await transaction - .InsertOrderItemAsync( - Guid.NewGuid().ToString(), - order2Id, - "Service Package", - 1, - 750.00, - 750.00 - ) + .InsertOrderItemAsync(orderItem3Id, order2Id, "Service Package", 1.0, 750.00, 750.00) .ConfigureAwait(false); if (orderItem3Result is not IntSqlOk) return ( diff --git a/Gatekeeper/Gatekeeper.Api.Tests/AuthorizationTests.cs b/Gatekeeper/Gatekeeper.Api.Tests/AuthorizationTests.cs index cbeda03..3d1faa1 100644 --- a/Gatekeeper/Gatekeeper.Api.Tests/AuthorizationTests.cs +++ b/Gatekeeper/Gatekeeper.Api.Tests/AuthorizationTests.cs @@ -528,8 +528,7 @@ string permissionCode ), }; - var permId = - existingPerm?.id + var permId = existingPerm?.id ?? throw new InvalidOperationException( $"Permission '{permissionCode}' not found in seeded database" ); @@ -553,9 +552,7 @@ string permissionCode if (grantResult is Result.Error grantErr) { - throw new InvalidOperationException( - $"Failed to insert grant: {grantErr.Value.Message}" - ); + throw new InvalidOperationException($"Failed to insert grant: {grantErr.Value.Message}"); } tx.Commit(); @@ -588,8 +585,7 @@ string permissionCode ), }; - var permId = - existingPerm?.id + var permId = existingPerm?.id ?? throw new InvalidOperationException( $"Permission '{permissionCode}' not found in seeded database" ); diff --git a/Gatekeeper/Gatekeeper.Api/FileLoggerProvider.cs b/Gatekeeper/Gatekeeper.Api/FileLoggerProvider.cs new file mode 100644 index 0000000..7846a68 --- /dev/null +++ b/Gatekeeper/Gatekeeper.Api/FileLoggerProvider.cs @@ -0,0 +1,110 @@ +namespace Gatekeeper.Api; + +/// +/// Extension methods for adding file logging. +/// +public static class FileLoggingExtensions +{ + /// + /// Adds file logging to the logging builder. + /// + public static ILoggingBuilder AddFileLogging(this ILoggingBuilder builder, string path) + { + // CA2000: DI container takes ownership and disposes when application shuts down +#pragma warning disable CA2000 + builder.Services.AddSingleton(new FileLoggerProvider(path)); +#pragma warning restore CA2000 + return builder; + } +} + +/// +/// Simple file logger provider for writing logs to disk. +/// +public sealed class FileLoggerProvider : ILoggerProvider +{ + private readonly string _path; + private readonly object _lock = new(); + + /// + /// Initializes a new instance of FileLoggerProvider. + /// + public FileLoggerProvider(string path) + { + _path = path; + } + + /// + /// Creates a logger for the specified category. + /// + public ILogger CreateLogger(string categoryName) => new FileLogger(_path, categoryName, _lock); + + /// + /// Disposes the provider. + /// + public void Dispose() + { + // Nothing to dispose - singleton managed by DI container + } +} + +/// +/// Simple file logger that appends log entries to a file. +/// +public sealed class FileLogger : ILogger +{ + private readonly string _path; + private readonly string _category; + private readonly object _lock; + + /// + /// Initializes a new instance of FileLogger. + /// + public FileLogger(string path, string category, object lockObj) + { + _path = path; + _category = category; + _lock = lockObj; + } + + /// + /// Begins a logical operation scope. + /// + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + /// + /// Checks if the given log level is enabled. + /// + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + /// + /// Writes a log entry to the file. + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var message = formatter(state, exception); + var line = + $"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff} [{logLevel}] {_category}: {message}"; + if (exception != null) + { + line += Environment.NewLine + exception; + } + + lock (_lock) + { + File.AppendAllText(_path, line + Environment.NewLine); + } + } +} diff --git a/Gatekeeper/Gatekeeper.Api/GlobalUsings.cs b/Gatekeeper/Gatekeeper.Api/GlobalUsings.cs index afe3110..43efc50 100644 --- a/Gatekeeper/Gatekeeper.Api/GlobalUsings.cs +++ b/Gatekeeper/Gatekeeper.Api/GlobalUsings.cs @@ -32,6 +32,10 @@ System.Collections.Immutable.ImmutableList, Selecta.SqlError >.Ok, Selecta.SqlError>; +global using GetSessionRevokedError = Outcome.Result< + System.Collections.Immutable.ImmutableList, + Selecta.SqlError +>.Error, Selecta.SqlError>; // Query result type aliases global using GetUserByEmailOk = Outcome.Result< System.Collections.Immutable.ImmutableList, diff --git a/Gatekeeper/Gatekeeper.Api/Program.cs b/Gatekeeper/Gatekeeper.Api/Program.cs index 8a98e90..293bb1c 100644 --- a/Gatekeeper/Gatekeeper.Api/Program.cs +++ b/Gatekeeper/Gatekeeper.Api/Program.cs @@ -1,12 +1,15 @@ #pragma warning disable IDE0037 // Use inferred member name -using System.Security.Cryptography; using System.Text; using Gatekeeper.Api; using Microsoft.AspNetCore.Http.Json; var builder = WebApplication.CreateBuilder(args); +// File logging +var logPath = Path.Combine(AppContext.BaseDirectory, "gatekeeper.log"); +builder.Logging.AddFileLogging(logPath); + builder.Services.Configure(options => options.SerializerOptions.PropertyNamingPolicy = null ); @@ -44,7 +47,7 @@ var signingKeyBase64 = builder.Configuration["Jwt:SigningKey"]; var signingKey = string.IsNullOrEmpty(signingKeyBase64) - ? RandomNumberGenerator.GetBytes(32) + ? new byte[32] // Default dev key (32 zeros) - MUST match Clinical/Scheduling APIs : Convert.FromBase64String(signingKeyBase64); builder.Services.AddSingleton(new JwtConfig(signingKey, TimeSpan.FromHours(24))); diff --git a/Gatekeeper/Gatekeeper.Api/TokenService.cs b/Gatekeeper/Gatekeeper.Api/TokenService.cs index 355fdd9..1ee8a3a 100644 --- a/Gatekeeper/Gatekeeper.Api/TokenService.cs +++ b/Gatekeeper/Gatekeeper.Api/TokenService.cs @@ -1,8 +1,7 @@ -namespace Gatekeeper.Api; - using System.Security.Cryptography; using System.Text; +namespace Gatekeeper.Api; /// /// JWT token generation and validation service. /// @@ -149,23 +148,19 @@ public static async Task ValidateTokenAsync( } /// - /// Revokes a token by JTI. + /// Revokes a token by JTI using DataProvider generated method. /// - public static async Task RevokeTokenAsync(SqliteConnection conn, string jti) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE gk_session SET is_revoked = 1 WHERE id = @jti"; - cmd.Parameters.AddWithValue("@jti", jti); - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } + public static async Task RevokeTokenAsync(SqliteConnection conn, string jti) => + _ = await conn.RevokeSessionAsync(jti).ConfigureAwait(false); private static async Task IsTokenRevokedAsync(SqliteConnection conn, string jti) { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT is_revoked FROM gk_session WHERE id = @jti"; - cmd.Parameters.AddWithValue("@jti", jti); - var result = await cmd.ExecuteScalarAsync().ConfigureAwait(false); - return result is long revoked && revoked == 1; + var result = await conn.GetSessionRevokedAsync(jti).ConfigureAwait(false); + return result switch + { + GetSessionRevokedOk ok => ok.Value.FirstOrDefault()?.is_revoked == 1, + GetSessionRevokedError => false, + }; } private static string Base64UrlEncode(byte[] input) => diff --git a/Samples/Clinical/Clinical.Api/FileLoggerProvider.cs b/Samples/Clinical/Clinical.Api/FileLoggerProvider.cs new file mode 100644 index 0000000..0e21fbb --- /dev/null +++ b/Samples/Clinical/Clinical.Api/FileLoggerProvider.cs @@ -0,0 +1,110 @@ +namespace Clinical.Api; + +/// +/// Extension methods for adding file logging. +/// +public static class FileLoggingExtensions +{ + /// + /// Adds file logging to the logging builder. + /// + public static ILoggingBuilder AddFileLogging(this ILoggingBuilder builder, string path) + { + // CA2000: DI container takes ownership and disposes when application shuts down +#pragma warning disable CA2000 + builder.Services.AddSingleton(new FileLoggerProvider(path)); +#pragma warning restore CA2000 + return builder; + } +} + +/// +/// Simple file logger provider for writing logs to disk. +/// +public sealed class FileLoggerProvider : ILoggerProvider +{ + private readonly string _path; + private readonly object _lock = new(); + + /// + /// Initializes a new instance of FileLoggerProvider. + /// + public FileLoggerProvider(string path) + { + _path = path; + } + + /// + /// Creates a logger for the specified category. + /// + public ILogger CreateLogger(string categoryName) => new FileLogger(_path, categoryName, _lock); + + /// + /// Disposes the provider. + /// + public void Dispose() + { + // Nothing to dispose - singleton managed by DI container + } +} + +/// +/// Simple file logger that appends log entries to a file. +/// +public sealed class FileLogger : ILogger +{ + private readonly string _path; + private readonly string _category; + private readonly object _lock; + + /// + /// Initializes a new instance of FileLogger. + /// + public FileLogger(string path, string category, object lockObj) + { + _path = path; + _category = category; + _lock = lockObj; + } + + /// + /// Begins a logical operation scope. + /// + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + /// + /// Checks if the given log level is enabled. + /// + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + /// + /// Writes a log entry to the file. + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var message = formatter(state, exception); + var line = + $"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff} [{logLevel}] {_category}: {message}"; + if (exception != null) + { + line += Environment.NewLine + exception; + } + + lock (_lock) + { + File.AppendAllText(_path, line + Environment.NewLine); + } + } +} diff --git a/Samples/Clinical/Clinical.Api/Program.cs b/Samples/Clinical/Clinical.Api/Program.cs index 66c03ec..2ad19c8 100644 --- a/Samples/Clinical/Clinical.Api/Program.cs +++ b/Samples/Clinical/Clinical.Api/Program.cs @@ -8,6 +8,10 @@ var builder = WebApplication.CreateBuilder(args); +// File logging +var logPath = Path.Combine(AppContext.BaseDirectory, "clinical.log"); +builder.Logging.AddFileLogging(logPath); + // Configure JSON to use PascalCase property names builder.Services.Configure(options => { diff --git a/Samples/Clinical/Clinical.Sync/SyncWorker.cs b/Samples/Clinical/Clinical.Sync/SyncWorker.cs index a7a624e..4ff9dcd 100644 --- a/Samples/Clinical/Clinical.Sync/SyncWorker.cs +++ b/Samples/Clinical/Clinical.Sync/SyncWorker.cs @@ -136,12 +136,17 @@ private async Task PerformSync(CancellationToken cancellationToken) DateTimeOffset.Now ); + using var conn = _getConnection(); + + // Get last sync version + var lastVersion = GetLastSyncVersion(conn); + using var httpClient = new HttpClient { BaseAddress = new Uri(_schedulingApiUrl) }; httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", GenerateSyncToken()); var changesResponse = await httpClient - .GetAsync("/sync/changes?limit=100", cancellationToken) + .GetAsync($"/sync/changes?fromVersion={lastVersion}&limit=100", cancellationToken) .ConfigureAwait(false); if (!changesResponse.IsSuccessStatusCode) { @@ -166,7 +171,6 @@ private async Task PerformSync(CancellationToken cancellationToken) _logger.Log(LogLevel.Information, "Processing {Count} changes", changes.Count); - using var conn = _getConnection(); await using var transaction = await conn.BeginTransactionAsync(cancellationToken) .ConfigureAwait(false); @@ -182,10 +186,16 @@ private async Task PerformSync(CancellationToken cancellationToken) } await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + + // Update last sync version to the maximum version we processed + var maxVersion = changes.Max(c => c.Version); + UpdateLastSyncVersion(conn, maxVersion); + _logger.Log( LogLevel.Information, - "Successfully synced {Count} provider changes", - practitionerChanges.Count + "Successfully synced {Count} provider changes, updated version to {Version}", + practitionerChanges.Count, + maxVersion ); } catch (Exception ex) @@ -269,6 +279,40 @@ ON CONFLICT(ProviderId) DO UPDATE SET ); } + private static long GetLastSyncVersion(SqliteConnection connection) + { + // Ensure _sync_state table exists + using var createCmd = connection.CreateCommand(); + createCmd.CommandText = """ + CREATE TABLE IF NOT EXISTS _sync_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """; + createCmd.ExecuteNonQuery(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = + "SELECT value FROM _sync_state WHERE key = 'last_scheduling_sync_version'"; + + var result = cmd.ExecuteScalar(); + return result is string str && long.TryParse(str, out var version) ? version : 0; + } + + private static void UpdateLastSyncVersion(SqliteConnection connection, long version) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO _sync_state (key, value) VALUES ('last_scheduling_sync_version', @version) + ON CONFLICT (key) DO UPDATE SET value = excluded.value + """; + cmd.Parameters.AddWithValue( + "@version", + version.ToString(System.Globalization.CultureInfo.InvariantCulture) + ); + cmd.ExecuteNonQuery(); + } + private static readonly string[] SyncRoles = ["sync-client", "clinician", "scheduler", "admin"]; /// diff --git a/Samples/Scheduling/Scheduling.Api/FileLoggerProvider.cs b/Samples/Scheduling/Scheduling.Api/FileLoggerProvider.cs new file mode 100644 index 0000000..760f5cc --- /dev/null +++ b/Samples/Scheduling/Scheduling.Api/FileLoggerProvider.cs @@ -0,0 +1,110 @@ +namespace Scheduling.Api; + +/// +/// Extension methods for adding file logging. +/// +public static class FileLoggingExtensions +{ + /// + /// Adds file logging to the logging builder. + /// + public static ILoggingBuilder AddFileLogging(this ILoggingBuilder builder, string path) + { + // CA2000: DI container takes ownership and disposes when application shuts down +#pragma warning disable CA2000 + builder.Services.AddSingleton(new FileLoggerProvider(path)); +#pragma warning restore CA2000 + return builder; + } +} + +/// +/// Simple file logger provider for writing logs to disk. +/// +public sealed class FileLoggerProvider : ILoggerProvider +{ + private readonly string _path; + private readonly object _lock = new(); + + /// + /// Initializes a new instance of FileLoggerProvider. + /// + public FileLoggerProvider(string path) + { + _path = path; + } + + /// + /// Creates a logger for the specified category. + /// + public ILogger CreateLogger(string categoryName) => new FileLogger(_path, categoryName, _lock); + + /// + /// Disposes the provider. + /// + public void Dispose() + { + // Nothing to dispose - singleton managed by DI container + } +} + +/// +/// Simple file logger that appends log entries to a file. +/// +public sealed class FileLogger : ILogger +{ + private readonly string _path; + private readonly string _category; + private readonly object _lock; + + /// + /// Initializes a new instance of FileLogger. + /// + public FileLogger(string path, string category, object lockObj) + { + _path = path; + _category = category; + _lock = lockObj; + } + + /// + /// Begins a logical operation scope. + /// + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + /// + /// Checks if the given log level is enabled. + /// + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + /// + /// Writes a log entry to the file. + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var message = formatter(state, exception); + var line = + $"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff} [{logLevel}] {_category}: {message}"; + if (exception != null) + { + line += Environment.NewLine + exception; + } + + lock (_lock) + { + File.AppendAllText(_path, line + Environment.NewLine); + } + } +} diff --git a/Samples/Scheduling/Scheduling.Api/Program.cs b/Samples/Scheduling/Scheduling.Api/Program.cs index fb7b5ea..7cbeae5 100644 --- a/Samples/Scheduling/Scheduling.Api/Program.cs +++ b/Samples/Scheduling/Scheduling.Api/Program.cs @@ -8,6 +8,10 @@ var builder = WebApplication.CreateBuilder(args); +// File logging +var logPath = Path.Combine(AppContext.BaseDirectory, "scheduling.log"); +builder.Logging.AddFileLogging(logPath); + // Configure JSON to use PascalCase property names builder.Services.Configure(options => { diff --git a/Website/src/index.njk b/Website/src/index.njk index eb03e7a..e43abcb 100644 --- a/Website/src/index.njk +++ b/Website/src/index.njk @@ -15,12 +15,6 @@ description: "Simplifying Database Connectivity in .NET with source-generated SQ View on GitHub -
-{% highlight "sql" %} -SELECT * FROM Orders -WHERE Status = 'Active'; -{% endhighlight %} -