├── Directory.Build.targets ├── Tests ├── UnitTests │ ├── Todo.WebApi.UnitTests │ │ ├── Properties │ │ │ └── AssemblyInfo.cs │ │ ├── Controllers │ │ │ ├── VerifySnapshots │ │ │ │ ├── HealthCheckControllerTests.GetHealthReportAsync_WhenTimeoutExceptionOccurs_ReturnsExpectedHealthReport.verified.txt │ │ │ │ └── HealthCheckControllerTests.GetHealthReportAsync_WhenUnexpectedExceptionOccurs_ReturnsExpectedHealthReport.verified.txt │ │ │ └── HealthCheckControllerTests.cs │ │ ├── ModuleInitializer.cs │ │ ├── Todo.WebApi.UnitTests.csproj │ │ └── ExceptionHandling │ │ │ └── ExceptionMappingResultsTests.cs │ ├── Todo.Services.UnitTests │ │ ├── Properties │ │ │ └── AssemblyInfo.cs │ │ ├── ModuleInitializer.cs │ │ ├── Security │ │ │ ├── VerifySnapshots │ │ │ │ └── JwtServiceTests.GenerateJwtAsync_WhenUsingValidInput_MustReturnExpectedResult.verified.txt │ │ │ └── JwtServiceTests.cs │ │ └── Todo.Services.UnitTests.csproj │ ├── Todo.Telemetry.UnitTests │ │ ├── Properties │ │ │ └── AssemblyInfo.cs │ │ ├── Serilog │ │ │ └── ConfigurationExtensions.cs │ │ └── Todo.Telemetry.UnitTests.csproj │ └── Todo.ApplicationFlows.UnitTests │ │ ├── Properties │ │ └── AssemblyInfo.cs │ │ └── Todo.ApplicationFlows.UnitTests.csproj ├── ArchitectureTests │ └── Todo.ArchitectureTests │ │ ├── Properties │ │ └── AssemblyInfo.cs │ │ └── Todo.ArchitectureTests.csproj ├── IntegrationTests │ ├── Todo.WebApi.IntegrationTests │ │ ├── Properties │ │ │ └── AssemblyInfo.cs │ │ ├── Todo.WebApi.IntegrationTests.csproj.user │ │ ├── VerifyChecksTests.cs │ │ ├── Controllers │ │ │ ├── VerifySnapshots │ │ │ │ ├── ConfigurationControllerTests.GetConfigurationDebugView_WhenCalled_MustBehaveAsExpected_environmentName=Staging_expectedStatusCode=Forbidden.verified.txt │ │ │ │ ├── ConfigurationControllerTests.GetConfigurationDebugView_WhenCalled_MustBehaveAsExpected_environmentName=DemoInAzure_expectedStatusCode=Forbidden.verified.txt │ │ │ │ ├── ConfigurationControllerTests.GetConfigurationDebugView_WhenCalled_MustBehaveAsExpected_environmentName=Production_expectedStatusCode=Forbidden.verified.txt │ │ │ │ ├── ConfigurationControllerTests.GetConfigurationDebugView_WhenCalled_MustBehaveAsExpected_environmentName=AcceptanceTests_expectedStatusCode=Forbidden.verified.txt │ │ │ │ ├── ConfigurationControllerTests.GetConfigurationDebugView_WhenCalled_MustBehaveAsExpected_environmentName=IntegrationTests_expectedStatusCode=Forbidden.verified.txt │ │ │ │ ├── TodoControllerTests.DeleteAsync_UsingNewlyCreatedTodoItem_ReturnsExpectedResult.verified.txt │ │ │ │ ├── TodoControllerTests.DeleteAsync_WhenRequestIsNotAuthorized_ReturnsExpectedResult.verified.txt │ │ │ │ ├── TodoControllerTests.GetByIdAsync_WhenRequestIsNotAuthorized_ReturnsExpectedResult.verified.txt │ │ │ │ ├── HealthCheckControllerTests.GetHealthReportAsync_WhenRequestIsNotAuthorized_ReturnsUnauthorizedHttpStatusCode.verified.txt │ │ │ │ ├── TodoControllerTests.GetByQueryAsync_WhenRequestIsNotAuthorized_ReturnsExpectedResult.verified.txt │ │ │ │ ├── ConfigurationControllerTests.GetConfigurationDebugView_WhenCalled_MustBehaveAsExpected_environmentName=Development_expectedStatusCode=OK.verified.txt │ │ │ │ ├── TodoControllerTests.GetByQueryAsync_UsingDefaults_ReturnsExpectedResult.verified.txt │ │ │ │ ├── TodoControllerTests.GetByIdAsync_UsingNewlyCreatedItem_ReturnsExpectedResult.verified.txt │ │ │ │ ├── HealthCheckControllerTests.GetHealthReportAsync_UsingValidInput_ReturnsExpectedHealthReport.verified.txt │ │ │ │ ├── TodoControllerTests.GetByIdAsync_UsingNonExistingId_ReturnsExpectedResult.verified.txt │ │ │ │ ├── TodoControllerTests.CreateAsync_WhenRequestIsNotAuthorized_ReturnsExpectedResult.verified.txt │ │ │ │ ├── TodoControllerTests.UpdateAsync_WhenRequestIsNotAuthorized_ReturnsExpectedResult.verified.txt │ │ │ │ ├── TodoControllerTests.UpdateAsync_UsingNewlyCreatedTodoItem_ReturnsExpectedResult.verified.txt │ │ │ │ ├── TodoControllerTests.CreateAsync_UsingInvalidTodoItem_ReturnsExpectedResult.verified.txt │ │ │ │ └── TodoControllerTests.CreateAsync_UsingValidTodoItemReturnsExpectedResult.verified.txt │ │ │ └── ConfigurationControllerTests.cs │ │ ├── ModuleInitializer.cs │ │ └── Todo.WebApi.IntegrationTests.csproj │ ├── Todo.Persistence.IntegrationTests │ │ ├── Properties │ │ │ └── AssemblyInfo.cs │ │ └── Todo.Persistence.IntegrationTests.csproj │ └── Todo.ApplicationFlows.IntegrationTests │ │ ├── Properties │ │ └── AssemblyInfo.cs │ │ ├── VerifySnapshots │ │ └── TransactionalBaseApplicationFlowTests.ExecuteAsync_WhenAllStepsSucceeds_MustSucceed.verified.txt │ │ ├── ModuleInitializer.cs │ │ └── Todo.ApplicationFlows.IntegrationTests.csproj ├── AcceptanceTests │ └── Todo.WebApi.AcceptanceTests │ │ ├── Properties │ │ └── AssemblyInfo.cs │ │ ├── Drivers │ │ ├── UserDetails.cs │ │ └── NewTodoItemInfo.cs │ │ ├── Infrastructure │ │ ├── TcpPortProvider.cs │ │ ├── SetupSystemUnderTest.cs │ │ └── ScenarioDependencies.cs │ │ ├── Todo.WebApi.AcceptanceTests.csproj │ │ └── Features │ │ └── AddTodoItem.feature ├── Infrastructure │ ├── Directory.Build.props │ └── Todo.WebApi.TestInfrastructure │ │ ├── CouldNotGetJwtTokenException.cs │ │ └── Todo.WebApi.TestInfrastructure.csproj ├── Directory.Build.props ├── Directory.Build.targets └── .artifactignore ├── .gitattributes ├── Sources ├── Todo.WebApi │ ├── Models │ │ ├── JwtModel.cs │ │ ├── GenerateJwtModel.cs │ │ ├── TodoItemModel.cs │ │ ├── GenerateJwtOptions.cs │ │ ├── NewTodoItemModel.cs │ │ ├── UpdateTodoItemModel.cs │ │ └── TodoItemQueryModel.cs │ ├── ExceptionHandling │ │ ├── ExceptionMappingResult.cs │ │ ├── Configuration │ │ │ └── ExceptionHandlingOptions.cs │ │ └── ExceptionMappingResults.cs │ ├── Authorization │ │ ├── Policies.cs │ │ ├── HasScopeRequirement.cs │ │ └── HasScopeHandler.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Todo.WebApi.csproj │ ├── appsettings.DemoInAzure.json │ ├── Program.cs │ ├── appsettings.Development.json │ ├── appsettings.IntegrationTests.json │ ├── Controllers │ │ └── ConfigurationController.cs │ └── appsettings.AcceptanceTests.json ├── Todo.Services │ ├── Security │ │ ├── JwtInfo.cs │ │ ├── IJwtService.cs │ │ ├── GenerateJwtInfo.cs │ │ ├── JwtService.cs │ │ └── PrincipalExtensions.cs │ ├── Todo.Services.csproj │ ├── TodoItemManagement │ │ ├── DeleteTodoItemInfo.cs │ │ ├── TodoItemInfo.cs │ │ ├── NewTodoItemInfo.cs │ │ ├── EntityNotFoundException.cs │ │ ├── UpdateTodoItemInfo.cs │ │ ├── ITodoItemService.cs │ │ └── TodoItemQuery.cs │ └── DependencyInjection │ │ └── ServicesModule.cs ├── Todo.Commons │ ├── Constants │ │ ├── EnvironmentVariables.cs │ │ ├── Logging.cs │ │ ├── ConnectionStrings.cs │ │ └── EnvironmentNames.cs │ ├── StartupLogic │ │ ├── IStartupLogicTask.cs │ │ ├── IStartupLogicTaskExecutor.cs │ │ └── HostExtensions.cs │ ├── Todo.Commons.csproj │ └── Diagnostics │ │ └── ActivitySources.cs ├── Todo.Telemetry │ ├── OpenTelemetry │ │ └── Configuration │ │ │ ├── Exporters │ │ │ ├── AzureMonitorOptions.cs │ │ │ ├── OpenTelemetryExporterOptions.cs │ │ │ └── JaegerOptions.cs │ │ │ ├── Instrumentation │ │ │ ├── EntityFrameworkCoreOptions.cs │ │ │ └── OpenTelemetryInstrumentationOptions.cs │ │ │ ├── Logging │ │ │ └── LoggingOptions.cs │ │ │ └── OpenTelemetryOptions.cs │ ├── Http │ │ ├── IHttpContextLoggingHandler.cs │ │ ├── HttpLoggingMiddlewareExtensions.cs │ │ ├── ConversationIdProviderMiddlewareExtensions.cs │ │ ├── IHttpObjectConverter.cs │ │ ├── StreamExtensions.cs │ │ ├── HttpLoggingActivator.cs │ │ └── ConversationIdProviderMiddleware.cs │ ├── Serilog │ │ ├── SerilogConstants.cs │ │ ├── Destructuring │ │ │ ├── NewTodoItemInfoDestructuringPolicy.cs │ │ │ ├── DeleteTodoItemInfoDestructuringPolicy.cs │ │ │ ├── UpdateTodoItemInfoDestructuringPolicy.cs │ │ │ └── TodoItemQueryDestructuringPolicy.cs │ │ └── FileSinkMetadataLogger.cs │ ├── DependencyInjection │ │ └── TelemetryModule.cs │ └── Todo.Telemetry.csproj ├── Todo.Persistence │ ├── Migrations │ │ ├── TodoDbContextModelSnapshot.CustomAttributes.cs │ │ ├── 20200124210915_AddIndexForNameColumnInsideTodoItemsTable.cs │ │ ├── 20200124211005_AddIndexForCreatedByColumnInsideTodoItemsTable.cs │ │ ├── 20200515210035_AddSupportForOptimisticLockingToTodoTable.cs │ │ ├── 20221117205342_AddVersionColumnToTheTodoItemsTable.cs │ │ ├── 20200124212126_ConsolidateCreatedByAndNameColumnsIntoAnUniqueIndexInsideTodoItemsTable.cs │ │ ├── 20200124210404_InitialSchema.cs │ │ ├── 20200124210404_InitialSchema.Designer.cs │ │ ├── 20200124210915_AddIndexForNameColumnInsideTodoItemsTable.Designer.cs │ │ ├── 20200124211005_AddIndexForCreatedByColumnInsideTodoItemsTable.Designer.cs │ │ ├── 20200124212126_ConsolidateCreatedByAndNameColumnsIntoAnUniqueIndexInsideTodoItemsTable.Designer.cs │ │ ├── TodoDbContextModelSnapshot.cs │ │ └── 20200515210035_AddSupportForOptimisticLockingToTodoTable.Designer.cs │ ├── Todo.Persistence.csproj │ ├── TodoDbContext.cs │ ├── Entities │ │ └── Configurations │ │ │ └── TodoItemConfiguration.cs │ └── DependencyInjection │ │ └── ServiceCollectionExtensions.cs ├── Todo.ApplicationFlows │ ├── Todo.ApplicationFlows.csproj │ ├── Security │ │ ├── IGenerateJwtFlow.cs │ │ └── GenerateJwtFlow.cs │ ├── TodoItems │ │ ├── IFetchTodoItemByIdFlow.cs │ │ ├── IFetchTodoItemsFlow.cs │ │ ├── IAddTodoItemFlow.cs │ │ ├── IDeleteTodoItemFlow.cs │ │ ├── IUpdateTodoItemFlow.cs │ │ ├── AddTodoItemFlow.cs │ │ ├── UpdateTodoItemFlow.cs │ │ ├── DeleteTodoItemFlow.cs │ │ ├── FetchTodoItemsFlow.cs │ │ └── FetchTodoItemByIdFlow.cs │ ├── ApplicationFlowOptions.cs │ ├── TransactionOptions.cs │ ├── IApplicationFlow.cs │ ├── SimpleApplicationFlow.cs │ ├── ApplicationEvents │ │ └── StartupLogicTaskExecutor.cs │ ├── TransactionalBaseApplicationFlow.cs │ └── DependencyInjection │ │ └── ApplicationFlowsModule.cs └── Directory.Build.props ├── .sonarlint ├── sonar.settings.json ├── Todo.slconfig ├── Todo.WebApi.slconfig ├── aspnet-core-logging_secrets_settings.json └── aspnet-core-logging │ └── CSharp │ └── SonarLint.xml ├── .gitignore ├── LICENSE ├── Build ├── db4tests │ └── docker-compose.yml ├── start-docker-on-macOS.sh └── SonarBuildBreaker.ps1 ├── Directory.Build.props └── nuget.config /Directory.Build.targets: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.WebApi.UnitTests/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | [assembly: NUnit.Framework.Category("UnitTests")] 2 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.Services.UnitTests/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | [assembly: NUnit.Framework.Category("UnitTests")] 2 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.Telemetry.UnitTests/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | [assembly: NUnit.Framework.Category("UnitTests")] 2 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.ApplicationFlows.UnitTests/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | [assembly: NUnit.Framework.Category("UnitTests")] 2 | -------------------------------------------------------------------------------- /Tests/ArchitectureTests/Todo.ArchitectureTests/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | [assembly: NUnit.Framework.Category("ArchitectureTests")] 2 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | [assembly: NUnit.Framework.Category("IntegrationTests")] 2 | -------------------------------------------------------------------------------- /Tests/AcceptanceTests/Todo.WebApi.AcceptanceTests/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | [assembly: Xunit.AssemblyTrait("TestCategory", "AcceptanceTests")] 2 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.Persistence.IntegrationTests/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | [assembly: NUnit.Framework.Category("IntegrationTests")] 2 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.ApplicationFlows.IntegrationTests/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | [assembly: NUnit.Framework.Category("IntegrationTests")] 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.verified.txt text eol=lf working-tree-encoding=UTF-8 2 | *.verified.xml text eol=lf working-tree-encoding=UTF-8 3 | *.verified.json text eol=lf working-tree-encoding=UTF-8 -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Models/JwtModel.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.Models 2 | { 3 | public class JwtModel 4 | { 5 | public string AccessToken { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/Todo.Services/Security/JwtInfo.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services.Security 2 | { 3 | public class JwtInfo 4 | { 5 | public string AccessToken { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Tests/AcceptanceTests/Todo.WebApi.AcceptanceTests/Drivers/UserDetails.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.AcceptanceTests.Drivers 2 | { 3 | public record UserDetails(string UserName, string Password); 4 | } 5 | -------------------------------------------------------------------------------- /Tests/AcceptanceTests/Todo.WebApi.AcceptanceTests/Drivers/NewTodoItemInfo.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.AcceptanceTests.Drivers 2 | { 3 | public record NewTodoItemInfo(string Name, bool IsComplete); 4 | } 5 | -------------------------------------------------------------------------------- /.sonarlint/sonar.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "sonar.exclusions": [ 3 | ], 4 | "sonar.global.exclusions": [ 5 | "**/build-wrapper-dump.json" 6 | ], 7 | "sonar.inclusions": [ 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Todo.Commons/Constants/EnvironmentVariables.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Commons.Constants 2 | { 3 | public static class EnvironmentVariables 4 | { 5 | public const string Prefix = "TODO_WEB_API_BY_SATRAPU_"; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/ExceptionHandling/ExceptionMappingResult.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.ExceptionHandling 2 | { 3 | using System.Net; 4 | 5 | public sealed record ExceptionMappingResult(HttpStatusCode HttpStatusCode, string RootCauseKey); 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Models/GenerateJwtModel.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.Models 2 | { 3 | public class GenerateJwtModel 4 | { 5 | public string UserName { get; set; } 6 | 7 | public string Password { get; set; } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/OpenTelemetry/Configuration/Exporters/AzureMonitorOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.OpenTelemetry.Configuration.Exporters 2 | { 3 | public class AzureMonitorOptions 4 | { 5 | public bool Enabled { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Migrations/TodoDbContextModelSnapshot.CustomAttributes.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Persistence.Migrations 2 | { 3 | using System.Diagnostics.CodeAnalysis; 4 | 5 | [ExcludeFromCodeCoverage] 6 | public partial class TodoDbContextModelSnapshot 7 | { 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/OpenTelemetry/Configuration/Instrumentation/EntityFrameworkCoreOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.OpenTelemetry.Configuration.Instrumentation 2 | { 3 | public class EntityFrameworkCoreOptions 4 | { 5 | public bool SetDbStatementForText { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/Todo.Services/Security/IJwtService.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services.Security 2 | { 3 | using System.Threading.Tasks; 4 | 5 | /// 6 | /// 7 | /// 8 | public interface IJwtService 9 | { 10 | Task GenerateJwtAsync(GenerateJwtInfo generateJwtInfo); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/OpenTelemetry/Configuration/Instrumentation/OpenTelemetryInstrumentationOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.OpenTelemetry.Configuration.Instrumentation 2 | { 3 | public class OpenTelemetryInstrumentationOptions 4 | { 5 | public EntityFrameworkCoreOptions EntityFrameworkCore { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Todo.WebApi.IntegrationTests.csproj.user: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | IIS Express 5 | 6 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/OpenTelemetry/Configuration/Exporters/OpenTelemetryExporterOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.OpenTelemetry.Configuration.Exporters 2 | { 3 | public class OpenTelemetryExporterOptions 4 | { 5 | public AzureMonitorOptions AzureMonitor { get; set; } 6 | public JaegerOptions Jaeger { get; set; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/OpenTelemetry/Configuration/Exporters/JaegerOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.OpenTelemetry.Configuration.Exporters 2 | { 3 | public class JaegerOptions 4 | { 5 | public string AgentHost { get; set; } 6 | 7 | public int AgentPort { get; set; } 8 | 9 | public bool Enabled { get; set; } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/VerifyChecksTests.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi 2 | { 3 | using System.Threading.Tasks; 4 | 5 | using NUnit.Framework; 6 | 7 | using VerifyNUnit; 8 | 9 | [TestFixture] 10 | public class VerifyChecksTests 11 | { 12 | [Test] 13 | public Task Run() => VerifyChecks.Run(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/Todo.ApplicationFlows.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.sonarlint/Todo.slconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ServerUri": "https://sonarcloud.io/", 3 | "Organization": { 4 | "Key": "satrapu-github", 5 | "Name": "Bogdan Marian" 6 | }, 7 | "ProjectKey": "aspnet-core-logging", 8 | "ProjectName": "aspnet-core-logging", 9 | "Profiles": { 10 | "CSharp": { 11 | "ProfileKey": "AVxYJ9ttFTbgxqUNcKz1", 12 | "ProfileTimestamp": "2022-07-05T10:19:10Z" 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.WebApi.UnitTests/Controllers/VerifySnapshots/HealthCheckControllerTests.GetHealthReportAsync_WhenTimeoutExceptionOccurs_ReturnsExpectedHealthReport.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Value: { 3 | HealthReport: { 4 | Status: Unhealthy, 5 | Description: Failed to check dependencies due to a timeout, 6 | Duration: 0:00:02, 7 | Dependencies: [] 8 | } 9 | }, 10 | StatusCode: 503 11 | } -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/Security/IGenerateJwtFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.Security 2 | { 3 | using Todo.Services.Security; 4 | 5 | /// 6 | /// Application flow used for generating JSON web tokens needed for authentication and authorization purposes. 7 | /// 8 | public interface IGenerateJwtFlow : IApplicationFlow 9 | { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.WebApi.UnitTests/Controllers/VerifySnapshots/HealthCheckControllerTests.GetHealthReportAsync_WhenUnexpectedExceptionOccurs_ReturnsExpectedHealthReport.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Value: { 3 | HealthReport: { 4 | Status: Unhealthy, 5 | Description: Failed to check dependencies due to an unexpected error, 6 | Duration: 0:00:02, 7 | Dependencies: [] 8 | } 9 | }, 10 | StatusCode: 503 11 | } -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/TodoItems/IFetchTodoItemByIdFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.TodoItems 2 | { 3 | using Services.TodoItemManagement; 4 | 5 | /// 6 | /// Application flow used for fetching a instance matching a given identifier. 7 | /// 8 | public interface IFetchTodoItemByIdFlow : IApplicationFlow 9 | { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/Todo.Services/Todo.Services.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Sources/Todo.Commons/StartupLogic/IStartupLogicTask.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Commons.StartupLogic 2 | { 3 | using System.Threading.Tasks; 4 | 5 | /// 6 | /// Executes logic during application startup. 7 | /// 8 | public interface IStartupLogicTask 9 | { 10 | /// 11 | /// Executes logic during application startup. 12 | /// 13 | Task ExecuteAsync(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Todo.Commons/Todo.Commons.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Sources/Todo.Services/TodoItemManagement/DeleteTodoItemInfo.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services.TodoItemManagement 2 | { 3 | using System.ComponentModel.DataAnnotations; 4 | using System.Security.Principal; 5 | 6 | public class DeleteTodoItemInfo 7 | { 8 | [Required] 9 | [Range(1, long.MaxValue)] 10 | public long? Id { get; set; } 11 | 12 | [Required] 13 | public IPrincipal Owner { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/Infrastructure/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | false 9 | false 10 | 11 | 12 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/TodoItems/IFetchTodoItemsFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.TodoItems 2 | { 3 | using System.Collections.Generic; 4 | 5 | using Services.TodoItemManagement; 6 | 7 | /// 8 | /// Application flow used for fetching instances matching a given query. 9 | /// 10 | public interface IFetchTodoItemsFlow : IApplicationFlow> 11 | { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/OpenTelemetry/Configuration/Logging/LoggingOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.OpenTelemetry.Configuration.Logging 2 | { 3 | public class LoggingOptions 4 | { 5 | public bool AttachLogsToActivity { get; set; } 6 | 7 | public bool Enabled { get; set; } 8 | 9 | public bool IncludeScopes { get; set; } 10 | 11 | public bool IncludeFormattedMessage { get; set; } 12 | 13 | public bool ParseStateValues { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/TodoItems/IAddTodoItemFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.TodoItems 2 | { 3 | using System.Diagnostics.CodeAnalysis; 4 | 5 | using Services.TodoItemManagement; 6 | 7 | /// 8 | /// Application flow used for creating a new todo item. 9 | /// 10 | [SuppressMessage("ReSharper", "S1135", Justification = "The todo word represents an entity")] 11 | public interface IAddTodoItemFlow : IApplicationFlow 12 | { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/TodoItems/IDeleteTodoItemFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.TodoItems 2 | { 3 | using System.Diagnostics.CodeAnalysis; 4 | 5 | using Services.TodoItemManagement; 6 | 7 | /// 8 | /// Application flow used for deleting an existing todo item. 9 | /// 10 | [SuppressMessage("ReSharper", "S1135", Justification = "The todo word represents an entity")] 11 | public interface IDeleteTodoItemFlow : IApplicationFlow 12 | { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/TodoItems/IUpdateTodoItemFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.TodoItems 2 | { 3 | using System.Diagnostics.CodeAnalysis; 4 | 5 | using Services.TodoItemManagement; 6 | 7 | /// 8 | /// Application flow used for updating an existing todo item. 9 | /// 10 | [SuppressMessage("ReSharper", "S1135", Justification = "The todo word represents an entity")] 11 | public interface IUpdateTodoItemFlow : IApplicationFlow 12 | { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Todo.Commons/StartupLogic/IStartupLogicTaskExecutor.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Commons.StartupLogic 2 | { 3 | using System.Threading.Tasks; 4 | 5 | /// 6 | /// Executes application startup logic by invoking all registered instances. 7 | /// 8 | public interface IStartupLogicTaskExecutor 9 | { 10 | /// 11 | /// Executes all registered instances. 12 | /// 13 | Task ExecuteAsync(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.sonarlint/Todo.WebApi.slconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ServerUri": "https://sonarcloud.io/", 3 | "Organization": { 4 | "Key": "satrapu-github", 5 | "Name": "Bogdan Marian" 6 | }, 7 | "ProjectKey": "aspnet-core-logging", 8 | "ProjectName": "aspnet-core-logging", 9 | "Profiles": { 10 | "Secrets": { 11 | "ProfileKey": "AYXoTKev9Ao2yLWbM_j-", 12 | "ProfileTimestamp": "2023-01-25T09:40:14Z" 13 | }, 14 | "CSharp": { 15 | "ProfileKey": "AVxYJ9ttFTbgxqUNcKz1", 16 | "ProfileTimestamp": "2023-03-09T14:55:47Z" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/OpenTelemetry/Configuration/OpenTelemetryOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.OpenTelemetry.Configuration 2 | { 3 | using Exporters; 4 | 5 | using Instrumentation; 6 | 7 | using Logging; 8 | 9 | public class OpenTelemetryOptions 10 | { 11 | public bool Enabled { get; set; } 12 | 13 | public LoggingOptions Logging { get; set; } 14 | 15 | public OpenTelemetryInstrumentationOptions Instrumentation { get; set; } 16 | 17 | public OpenTelemetryExporterOptions Exporters { get; set; } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Todo.Services/TodoItemManagement/TodoItemInfo.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services.TodoItemManagement 2 | { 3 | using System; 4 | 5 | public class TodoItemInfo 6 | { 7 | public long Id { get; set; } 8 | 9 | public string Name { get; set; } 10 | 11 | public bool IsComplete { get; set; } 12 | 13 | public string CreatedBy { get; set; } 14 | 15 | public DateTime CreatedOn { get; set; } 16 | 17 | public string LastUpdatedBy { get; set; } 18 | 19 | public DateTime? LastUpdatedOn { get; set; } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Todo.Services/TodoItemManagement/NewTodoItemInfo.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services.TodoItemManagement 2 | { 3 | using System.ComponentModel.DataAnnotations; 4 | using System.Security.Principal; 5 | 6 | public class NewTodoItemInfo 7 | { 8 | [Required(AllowEmptyStrings = false)] 9 | [MinLength(2)] 10 | [MaxLength(100)] 11 | public string Name { get; set; } 12 | 13 | [Required] 14 | public bool? IsComplete { get; set; } 15 | 16 | [Required] 17 | public IPrincipal Owner { get; set; } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Models/TodoItemModel.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable UnusedAutoPropertyAccessor.Global 2 | 3 | namespace Todo.WebApi.Models 4 | { 5 | using System; 6 | 7 | public class TodoItemModel 8 | { 9 | public long Id { get; set; } 10 | 11 | public string Name { get; set; } 12 | 13 | public bool IsComplete { get; set; } 14 | 15 | public string CreatedBy { get; set; } 16 | 17 | public DateTime CreatedOn { get; set; } 18 | 19 | public string LastUpdatedBy { get; set; } 20 | 21 | public DateTime? LastUpdatedOn { get; set; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Models/GenerateJwtOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.Models 2 | { 3 | /// 4 | /// Contains options used when generating JSON web tokens. 5 | /// 6 | public class GenerateJwtOptions 7 | { 8 | /// 9 | /// Gets or sets the audience for the JSON web tokens. 10 | /// 11 | public string Audience { get; set; } 12 | 13 | /// 14 | /// Gets or sets 15 | /// 16 | public string Issuer { get; set; } 17 | 18 | public string Secret { get; set; } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/Infrastructure/Todo.WebApi.TestInfrastructure/CouldNotGetJwtTokenException.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.TestInfrastructure 2 | { 3 | using System; 4 | using System.Net.Http; 5 | 6 | public class CouldNotGetJwtException : Exception 7 | { 8 | public CouldNotGetJwtException(HttpResponseMessage httpResponseMessage) : 9 | base($"Failed to get an JSON web token for testing purposes due to: [{httpResponseMessage.ReasonPhrase}]. " 10 | + $"Detailed error message is: [{httpResponseMessage.Content.ReadAsStringAsync().Result}]") 11 | { 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Http/IHttpContextLoggingHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Http 2 | { 3 | using Microsoft.AspNetCore.Http; 4 | 5 | /// 6 | /// Handles the logic of logging instances. 7 | /// 8 | public interface IHttpContextLoggingHandler 9 | { 10 | /// 11 | /// Checks whether the given should be logged or not. 12 | /// 13 | /// 14 | /// 15 | bool ShouldLog(HttpContext httpContext); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Authorization/Policies.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.Authorization 2 | { 3 | public static class Policies 4 | { 5 | public static class Infrastructure 6 | { 7 | public const string HealthCheck = "get:health"; 8 | } 9 | 10 | public static class TodoItems 11 | { 12 | public const string CreateTodoItem = "create:todo"; 13 | public const string UpdateTodoItem = "update:todo"; 14 | public const string DeleteTodoItem = "delete:todo"; 15 | public const string GetTodoItems = "get:todo"; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Todo.Persistence.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/ApplicationFlowOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows 2 | { 3 | /// 4 | /// Configures the behavior of a particular application flow. 5 | /// 6 | // ReSharper disable once ClassNeverInstantiated.Global 7 | public class ApplicationFlowOptions 8 | { 9 | /// 10 | /// Gets or sets the options to use when configuring the transaction used by a particular application flow. 11 | /// 12 | // ReSharper disable once UnusedAutoPropertyAccessor.Global 13 | public TransactionOptions TransactionOptions { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | false 6 | true 7 | true 8 | ..\..\..\.sonarlint\aspnet-core-loggingCSharp.ruleset 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Tests/Directory.Build.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Sources/Todo.Services/TodoItemManagement/EntityNotFoundException.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services.TodoItemManagement 2 | { 3 | using System; 4 | 5 | /// 6 | /// Thrown when failed to fetch an entity using a key. 7 | /// 8 | public class EntityNotFoundException : Exception 9 | { 10 | public EntityNotFoundException(Type entityType, object entityKey) 11 | : base($"Could not find entity of type \"{entityType?.FullName}\" using key \"{entityKey}\"") 12 | { 13 | base.Data.Add("EntityType", entityType?.FullName); 14 | base.Data.Add("EntityKey", entityKey); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Models/NewTodoItemModel.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.Models 2 | { 3 | using System.ComponentModel.DataAnnotations; 4 | 5 | public class NewTodoItemModel 6 | { 7 | /// 8 | /// Gets or sets the name of this to do item. 9 | /// 10 | [Required(AllowEmptyStrings = false)] 11 | [MinLength(2)] 12 | [MaxLength(100)] 13 | public string Name { get; set; } 14 | 15 | /// 16 | /// Gets or sets whether this to do item has been completed. 17 | /// 18 | [Required] 19 | public bool? IsComplete { get; set; } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Models/UpdateTodoItemModel.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.Models 2 | { 3 | using System.ComponentModel.DataAnnotations; 4 | 5 | public class UpdateTodoItemModel 6 | { 7 | /// 8 | /// Gets or sets the name of this to do item. 9 | /// 10 | [Required(AllowEmptyStrings = false)] 11 | [MinLength(2)] 12 | [MaxLength(100)] 13 | public string Name { get; set; } 14 | 15 | /// 16 | /// Gets or sets whether this to do item has been completed. 17 | /// 18 | [Required] 19 | public bool? IsComplete { get; set; } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.WebApi.UnitTests/ModuleInitializer.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi 2 | { 3 | using System.Runtime.CompilerServices; 4 | 5 | using VerifyTests; 6 | 7 | public static class ModuleInitializer 8 | { 9 | internal static readonly VerifySettings VerifySettings = new(); 10 | 11 | [ModuleInitializer] 12 | public static void Initialize() 13 | { 14 | VerifierSettings.InitializePlugins(); 15 | Recording.Start(); 16 | 17 | VerifySettings.UseDirectory("VerifySnapshots"); 18 | VerifySettings.ScrubEmptyLines(); 19 | VerifySettings.ScrubInlineGuids(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.Services.UnitTests/ModuleInitializer.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services 2 | { 3 | using System.Runtime.CompilerServices; 4 | 5 | using VerifyTests; 6 | 7 | public static class ModuleInitializer 8 | { 9 | internal static readonly VerifySettings VerifySettings = new(); 10 | 11 | [ModuleInitializer] 12 | public static void Initialize() 13 | { 14 | VerifierSettings.InitializePlugins(); 15 | Recording.Start(); 16 | 17 | VerifySettings.UseDirectory("VerifySnapshots"); 18 | VerifySettings.ScrubEmptyLines(); 19 | VerifySettings.ScrubInlineGuids(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Todo.Services/Security/GenerateJwtInfo.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services.Security 2 | { 3 | /// 4 | /// Contains the details needed to generate a JSON web token based on a user name and password. 5 | /// 6 | public sealed class GenerateJwtInfo 7 | { 8 | public string UserName { get; set; } 9 | 10 | // ReSharper disable once UnusedAutoPropertyAccessor.Global 11 | public string Password { get; set; } 12 | 13 | public string Audience { get; set; } 14 | 15 | public string Issuer { get; set; } 16 | 17 | public string Secret { get; set; } 18 | 19 | public string[] Scopes { get; set; } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Todo.Services/TodoItemManagement/UpdateTodoItemInfo.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services.TodoItemManagement 2 | { 3 | using System.ComponentModel.DataAnnotations; 4 | using System.Security.Principal; 5 | 6 | public class UpdateTodoItemInfo 7 | { 8 | [Required] 9 | [Range(1, long.MaxValue)] 10 | public long? Id { get; set; } 11 | 12 | [Required(AllowEmptyStrings = false)] 13 | [MinLength(2)] 14 | [MaxLength(100)] 15 | public string Name { get; set; } 16 | 17 | [Required] 18 | public bool? IsComplete { get; set; } 19 | 20 | [Required] 21 | public IPrincipal Owner { get; set; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.ApplicationFlows.IntegrationTests/VerifySnapshots/TransactionalBaseApplicationFlowTests.ExecuteAsync_WhenAllStepsSucceeds_MustSucceed.verified.txt: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | Id: {Scrubbed}, 4 | Name: todo-item--Guid_1--#1, 5 | IsComplete: false, 6 | CreatedBy: test-user--Guid_2, 7 | CreatedOn: DateTime_1 8 | }, 9 | { 10 | Id: {Scrubbed}, 11 | Name: todo-item--Guid_1--#2, 12 | IsComplete: false, 13 | CreatedBy: test-user--Guid_2, 14 | CreatedOn: DateTime_2 15 | }, 16 | { 17 | Id: {Scrubbed}, 18 | Name: todo-item--Guid_1--#3, 19 | IsComplete: false, 20 | CreatedBy: test-user--Guid_2, 21 | CreatedOn: DateTime_3 22 | } 23 | ] -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.Telemetry.UnitTests/Serilog/ConfigurationExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Serilog 2 | { 3 | using System.IO; 4 | using System.Text; 5 | 6 | using Microsoft.Extensions.Configuration; 7 | 8 | internal static class ConfigurationExtensions 9 | { 10 | internal static IConfiguration ToConfiguration(this string jsonFragment) 11 | { 12 | using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonFragment)); 13 | 14 | ConfigurationBuilder configurationBuilder = new(); 15 | configurationBuilder.AddJsonStream(stream); 16 | return configurationBuilder.Build(); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Todo.Commons/Diagnostics/ActivitySources.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Commons.Diagnostics 2 | { 3 | using System.Diagnostics; 4 | using System.Reflection; 5 | 6 | public static class ActivitySources 7 | { 8 | private const string ActivitySourceName = "TodoWebApi"; 9 | 10 | static ActivitySources() 11 | { 12 | TodoWebApi = new ActivitySource 13 | ( 14 | name: ActivitySourceName, 15 | version: Assembly.GetEntryAssembly()!.GetCustomAttribute()!.InformationalVersion 16 | ); 17 | } 18 | 19 | public static ActivitySource TodoWebApi { get; } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/ConfigurationControllerTests.GetConfigurationDebugView_WhenCalled_MustBehaveAsExpected_environmentName=Staging_expectedStatusCode=Forbidden.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: Forbidden, 7 | ReasonPhrase: Forbidden, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | } 14 | ], 15 | TrailingHeaders: [], 16 | RequestMessage: { 17 | Version: 1.1, 18 | Method: { 19 | Method: GET 20 | }, 21 | RequestUri: http://localhost/api/configuration, 22 | Headers: [] 23 | }, 24 | IsSuccessStatusCode: false 25 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/ConfigurationControllerTests.GetConfigurationDebugView_WhenCalled_MustBehaveAsExpected_environmentName=DemoInAzure_expectedStatusCode=Forbidden.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: Forbidden, 7 | ReasonPhrase: Forbidden, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | } 14 | ], 15 | TrailingHeaders: [], 16 | RequestMessage: { 17 | Version: 1.1, 18 | Method: { 19 | Method: GET 20 | }, 21 | RequestUri: http://localhost/api/configuration, 22 | Headers: [] 23 | }, 24 | IsSuccessStatusCode: false 25 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/ConfigurationControllerTests.GetConfigurationDebugView_WhenCalled_MustBehaveAsExpected_environmentName=Production_expectedStatusCode=Forbidden.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: Forbidden, 7 | ReasonPhrase: Forbidden, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | } 14 | ], 15 | TrailingHeaders: [], 16 | RequestMessage: { 17 | Version: 1.1, 18 | Method: { 19 | Method: GET 20 | }, 21 | RequestUri: http://localhost/api/configuration, 22 | Headers: [] 23 | }, 24 | IsSuccessStatusCode: false 25 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.ApplicationFlows.IntegrationTests/ModuleInitializer.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows 2 | { 3 | using System.Runtime.CompilerServices; 4 | 5 | using VerifyTests; 6 | 7 | public static class ModuleInitializer 8 | { 9 | internal static readonly VerifySettings VerifySettings = new(); 10 | 11 | [ModuleInitializer] 12 | public static void Initialize() 13 | { 14 | VerifierSettings.InitializePlugins(); 15 | Recording.Start(); 16 | 17 | VerifySettings.UseDirectory("VerifySnapshots"); 18 | VerifySettings.ScrubEmptyLines(); 19 | VerifySettings.ScrubInlineGuids(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/ConfigurationControllerTests.GetConfigurationDebugView_WhenCalled_MustBehaveAsExpected_environmentName=AcceptanceTests_expectedStatusCode=Forbidden.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: Forbidden, 7 | ReasonPhrase: Forbidden, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | } 14 | ], 15 | TrailingHeaders: [], 16 | RequestMessage: { 17 | Version: 1.1, 18 | Method: { 19 | Method: GET 20 | }, 21 | RequestUri: http://localhost/api/configuration, 22 | Headers: [] 23 | }, 24 | IsSuccessStatusCode: false 25 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/ConfigurationControllerTests.GetConfigurationDebugView_WhenCalled_MustBehaveAsExpected_environmentName=IntegrationTests_expectedStatusCode=Forbidden.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: Forbidden, 7 | ReasonPhrase: Forbidden, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | } 14 | ], 15 | TrailingHeaders: [], 16 | RequestMessage: { 17 | Version: 1.1, 18 | Method: { 19 | Method: GET 20 | }, 21 | RequestUri: http://localhost/api/configuration, 22 | Headers: [] 23 | }, 24 | IsSuccessStatusCode: false 25 | } -------------------------------------------------------------------------------- /Sources/Todo.WebApi/ExceptionHandling/Configuration/ExceptionHandlingOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.ExceptionHandling.Configuration 2 | { 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | /// 6 | /// Contains exception handling related configuration options. 7 | /// 8 | public class ExceptionHandlingOptions 9 | { 10 | /// 11 | /// Gets or sets whether to include details when converting an instance of class 12 | /// to an instance of class. 13 | /// 14 | // ReSharper disable once UnusedAutoPropertyAccessor.Global 15 | public bool IncludeDetails { get; set; } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/TodoControllerTests.DeleteAsync_UsingNewlyCreatedTodoItem_ReturnsExpectedResult.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: NoContent, 7 | ReasonPhrase: No Content, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | } 14 | ], 15 | TrailingHeaders: [], 16 | RequestMessage: { 17 | Version: 1.1, 18 | Method: { 19 | Method: DELETE 20 | }, 21 | RequestUri: {Scrubbed}, 22 | Headers: [ 23 | { 24 | Authorization: [ 25 | Bearer 26 | ] 27 | } 28 | ] 29 | }, 30 | IsSuccessStatusCode: true 31 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/TodoControllerTests.DeleteAsync_WhenRequestIsNotAuthorized_ReturnsExpectedResult.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: Unauthorized, 7 | ReasonPhrase: Unauthorized, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | }, 14 | { 15 | WWW-Authenticate: [ 16 | Bearer 17 | ] 18 | } 19 | ], 20 | TrailingHeaders: [], 21 | RequestMessage: { 22 | Version: 1.1, 23 | Method: { 24 | Method: DELETE 25 | }, 26 | RequestUri: http://localhost/api/todo/2147483647, 27 | Headers: [] 28 | }, 29 | IsSuccessStatusCode: false 30 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/TodoControllerTests.GetByIdAsync_WhenRequestIsNotAuthorized_ReturnsExpectedResult.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: Unauthorized, 7 | ReasonPhrase: Unauthorized, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | }, 14 | { 15 | WWW-Authenticate: [ 16 | Bearer 17 | ] 18 | } 19 | ], 20 | TrailingHeaders: [], 21 | RequestMessage: { 22 | Version: 1.1, 23 | Method: { 24 | Method: GET 25 | }, 26 | RequestUri: http://localhost/api/todo/2147483647, 27 | Headers: [] 28 | }, 29 | IsSuccessStatusCode: false 30 | } -------------------------------------------------------------------------------- /.sonarlint/aspnet-core-logging_secrets_settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "sonarlint.rules": { 3 | "secrets:S6338": { 4 | "level": "On", 5 | "severity": "Blocker" 6 | }, 7 | "secrets:S6337": { 8 | "level": "On", 9 | "severity": "Blocker" 10 | }, 11 | "secrets:S6290": { 12 | "level": "On", 13 | "severity": "Blocker" 14 | }, 15 | "secrets:S6334": { 16 | "level": "On", 17 | "severity": "Blocker" 18 | }, 19 | "secrets:S6336": { 20 | "level": "On", 21 | "severity": "Blocker" 22 | }, 23 | "secrets:S6335": { 24 | "level": "On", 25 | "severity": "Blocker" 26 | }, 27 | "secrets:S6292": { 28 | "level": "On", 29 | "severity": "Blocker" 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/HealthCheckControllerTests.GetHealthReportAsync_WhenRequestIsNotAuthorized_ReturnsUnauthorizedHttpStatusCode.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: Unauthorized, 7 | ReasonPhrase: Unauthorized, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | }, 14 | { 15 | WWW-Authenticate: [ 16 | Bearer 17 | ] 18 | } 19 | ], 20 | TrailingHeaders: [], 21 | RequestMessage: { 22 | Version: 1.1, 23 | Method: { 24 | Method: GET 25 | }, 26 | RequestUri: http://localhost/api/health, 27 | Headers: [] 28 | }, 29 | IsSuccessStatusCode: false 30 | } -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Models/TodoItemQueryModel.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable MemberCanBePrivate.Global 2 | // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global 3 | 4 | namespace Todo.WebApi.Models 5 | { 6 | using System.ComponentModel.DataAnnotations; 7 | 8 | public class TodoItemQueryModel 9 | { 10 | public long? Id { get; set; } 11 | 12 | public string NamePattern { get; set; } 13 | 14 | public bool? IsComplete { get; set; } 15 | 16 | [Range(1, 1000)] 17 | public int PageSize { get; set; } = 25; 18 | 19 | [Range(0, int.MaxValue)] 20 | public int PageIndex { get; set; } 21 | 22 | public string SortBy { get; set; } 23 | 24 | public bool? IsSortAscending { get; set; } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Authorization/HasScopeRequirement.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.Authorization 2 | { 3 | using System; 4 | 5 | using Microsoft.AspNetCore.Authorization; 6 | 7 | /// 8 | /// Based on: https://auth0.com/docs/quickstart/backend/aspnet-core-webapi#validate-scopes. 9 | /// 10 | public class HasScopeRequirement : IAuthorizationRequirement 11 | { 12 | public string Issuer { get; } 13 | 14 | public string Scope { get; } 15 | 16 | public HasScopeRequirement(string scope, string issuer) 17 | { 18 | Scope = scope ?? throw new ArgumentNullException(nameof(scope)); 19 | Issuer = issuer ?? throw new ArgumentNullException(nameof(issuer)); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/.artifactignore: -------------------------------------------------------------------------------- 1 | # Ensure only specific items are published as artifacts when running an Azure Pipeline. 2 | # See more about this file here: https://learn.microsoft.com/en-us/azure/devops/artifacts/reference/artifactignore?view=azure-devops. 3 | **/* 4 | 5 | # Publish test results 6 | !**/TestResults/TestResults.xml 7 | !**/Tests/AcceptanceTests/**/TestExecution.json 8 | !**/Tests/AcceptanceTests/LivingDoc.html 9 | 10 | # Publish code coverage results 11 | !**/coverage.opencover.xml 12 | !**/Tests/.CodeCoverageReport/Cobertura.xml 13 | 14 | # Publish application logs generated while running integration tests 15 | !**/Logs/*.log 16 | 17 | # Publish logs generated when starting the Docker Compose services 18 | # used by integration tests 19 | !**/Tests/.ComposeService-Logs/**/*.log 20 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/TodoControllerTests.GetByQueryAsync_WhenRequestIsNotAuthorized_ReturnsExpectedResult.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: Unauthorized, 7 | ReasonPhrase: Unauthorized, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | }, 14 | { 15 | WWW-Authenticate: [ 16 | Bearer 17 | ] 18 | } 19 | ], 20 | TrailingHeaders: [], 21 | RequestMessage: { 22 | Version: 1.1, 23 | Method: { 24 | Method: GET 25 | }, 26 | RequestUri: http://localhost/api/todo?PageIndex=0&PageSize=5&SortBy=CreatedOn&IsSortAscending=False, 27 | Headers: [] 28 | }, 29 | IsSuccessStatusCode: false 30 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/ConfigurationControllerTests.GetConfigurationDebugView_WhenCalled_MustBehaveAsExpected_environmentName=Development_expectedStatusCode=OK.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [ 5 | { 6 | Content-Type: [ 7 | text/plain; charset=utf-8 8 | ] 9 | } 10 | ] 11 | }, 12 | StatusCode: OK, 13 | ReasonPhrase: OK, 14 | Headers: [ 15 | { 16 | ConversationId: [ 17 | Guid_1 18 | ] 19 | } 20 | ], 21 | TrailingHeaders: [], 22 | RequestMessage: { 23 | Version: 1.1, 24 | Method: { 25 | Method: GET 26 | }, 27 | RequestUri: http://localhost/api/configuration, 28 | Headers: [] 29 | }, 30 | IsSuccessStatusCode: true 31 | } -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Migrations/20200124210915_AddIndexForNameColumnInsideTodoItemsTable.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Persistence.Migrations 2 | { 3 | using Microsoft.EntityFrameworkCore.Migrations; 4 | 5 | public partial class AddIndexForNameColumnInsideTodoItemsTable : Migration 6 | { 7 | protected override void Up(MigrationBuilder migrationBuilder) 8 | { 9 | migrationBuilder.CreateIndex( 10 | name: "IX_TodoItems_Name", 11 | table: "TodoItems", 12 | column: "Name"); 13 | } 14 | 15 | protected override void Down(MigrationBuilder migrationBuilder) 16 | { 17 | migrationBuilder.DropIndex( 18 | name: "IX_TodoItems_Name", 19 | table: "TodoItems"); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/TransactionOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows 2 | { 3 | using System; 4 | using System.Transactions; 5 | 6 | /// 7 | /// Configures the behavior of the transactions occurring inside a particular application flow. 8 | /// 9 | // ReSharper disable once ClassNeverInstantiated.Global 10 | public class TransactionOptions 11 | { 12 | /// 13 | /// Gets or sets the transaction isolation level. 14 | /// 15 | // ReSharper disable once UnusedAutoPropertyAccessor.Global 16 | public IsolationLevel IsolationLevel { get; set; } 17 | 18 | /// 19 | /// Gets or sets the transaction timeout. 20 | /// 21 | public TimeSpan Timeout { get; set; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Migrations/20200124211005_AddIndexForCreatedByColumnInsideTodoItemsTable.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Persistence.Migrations 2 | { 3 | using Microsoft.EntityFrameworkCore.Migrations; 4 | 5 | public partial class AddIndexForCreatedByColumnInsideTodoItemsTable : Migration 6 | { 7 | protected override void Up(MigrationBuilder migrationBuilder) 8 | { 9 | migrationBuilder.CreateIndex( 10 | name: "IX_TodoItems_CreatedBy", 11 | table: "TodoItems", 12 | column: "CreatedBy"); 13 | } 14 | 15 | protected override void Down(MigrationBuilder migrationBuilder) 16 | { 17 | migrationBuilder.DropIndex( 18 | name: "IX_TodoItems_CreatedBy", 19 | table: "TodoItems"); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/TodoControllerTests.GetByQueryAsync_UsingDefaults_ReturnsExpectedResult.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [ 5 | { 6 | Content-Type: [ 7 | application/json; charset=utf-8 8 | ] 9 | } 10 | ] 11 | }, 12 | StatusCode: OK, 13 | ReasonPhrase: OK, 14 | Headers: [ 15 | { 16 | ConversationId: [ 17 | Guid_1 18 | ] 19 | } 20 | ], 21 | TrailingHeaders: [], 22 | RequestMessage: { 23 | Version: 1.1, 24 | Method: { 25 | Method: GET 26 | }, 27 | RequestUri: {Scrubbed}, 28 | Headers: [ 29 | { 30 | Authorization: [ 31 | Bearer 32 | ] 33 | } 34 | ] 35 | }, 36 | IsSuccessStatusCode: true 37 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/TodoControllerTests.GetByIdAsync_UsingNewlyCreatedItem_ReturnsExpectedResult.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [ 5 | { 6 | Content-Type: [ 7 | application/json; charset=utf-8 8 | ] 9 | } 10 | ] 11 | }, 12 | StatusCode: OK, 13 | ReasonPhrase: OK, 14 | Headers: [ 15 | { 16 | ConversationId: [ 17 | Guid_1 18 | ] 19 | } 20 | ], 21 | TrailingHeaders: [], 22 | RequestMessage: { 23 | Version: 1.1, 24 | Method: { 25 | Method: GET 26 | }, 27 | RequestUri: {Scrubbed}, 28 | Headers: [ 29 | { 30 | Authorization: [ 31 | Bearer 32 | ] 33 | } 34 | ] 35 | }, 36 | IsSuccessStatusCode: true 37 | } -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Migrations/20200515210035_AddSupportForOptimisticLockingToTodoTable.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Persistence.Migrations 2 | { 3 | using Microsoft.EntityFrameworkCore.Migrations; 4 | 5 | public partial class AddSupportForOptimisticLockingToTodoTable : Migration 6 | { 7 | protected override void Up(MigrationBuilder migrationBuilder) 8 | { 9 | migrationBuilder.AddColumn( 10 | name: "xmin", 11 | table: "TodoItems", 12 | type: "xid", 13 | nullable: false, 14 | defaultValue: 0u); 15 | } 16 | 17 | protected override void Down(MigrationBuilder migrationBuilder) 18 | { 19 | migrationBuilder.DropColumn( 20 | name: "xmin", 21 | table: "TodoItems"); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Migrations/20221117205342_AddVersionColumnToTheTodoItemsTable.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Persistence.Migrations 2 | { 3 | using System.Diagnostics.CodeAnalysis; 4 | 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | 7 | /// 8 | public partial class AddVersionColumnToTheTodoItemsTable : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | 14 | } 15 | 16 | /// 17 | [SuppressMessage("Critical Code Smell", "S1186:Methods should not be empty", 18 | Justification = "The newly added Version property to the TodoItem entity will not result in database changes")] 19 | protected override void Down(MigrationBuilder migrationBuilder) 20 | { 21 | 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/HealthCheckControllerTests.GetHealthReportAsync_UsingValidInput_ReturnsExpectedHealthReport.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [ 5 | { 6 | Content-Type: [ 7 | application/json; charset=utf-8 8 | ] 9 | } 10 | ] 11 | }, 12 | StatusCode: OK, 13 | ReasonPhrase: OK, 14 | Headers: [ 15 | { 16 | ConversationId: [ 17 | Guid_1 18 | ] 19 | } 20 | ], 21 | TrailingHeaders: [], 22 | RequestMessage: { 23 | Version: 1.1, 24 | Method: { 25 | Method: GET 26 | }, 27 | RequestUri: http://localhost/api/health, 28 | Headers: [ 29 | { 30 | Authorization: [ 31 | Bearer 32 | ] 33 | } 34 | ] 35 | }, 36 | IsSuccessStatusCode: true 37 | } -------------------------------------------------------------------------------- /Sources/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | false 11 | false 12 | ..\..\.sonarlint\aspnet-core-loggingCSharp.ruleset 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/TodoControllerTests.GetByIdAsync_UsingNonExistingId_ReturnsExpectedResult.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [ 5 | { 6 | Content-Type: [ 7 | application/problem+json; charset=utf-8 8 | ] 9 | } 10 | ] 11 | }, 12 | StatusCode: NotFound, 13 | ReasonPhrase: Not Found, 14 | Headers: [ 15 | { 16 | ConversationId: [ 17 | Guid_1 18 | ] 19 | } 20 | ], 21 | TrailingHeaders: [], 22 | RequestMessage: { 23 | Version: 1.1, 24 | Method: { 25 | Method: GET 26 | }, 27 | RequestUri: http://localhost/api/todo/-9223372036854775808, 28 | Headers: [ 29 | { 30 | Authorization: [ 31 | Bearer 32 | ] 33 | } 34 | ] 35 | }, 36 | IsSuccessStatusCode: false 37 | } -------------------------------------------------------------------------------- /Tests/ArchitectureTests/Todo.ArchitectureTests/Todo.ArchitectureTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | Library 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Serilog/SerilogConstants.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Serilog 2 | { 3 | /// 4 | /// Contains constants related to Serilog configuration. 5 | /// 6 | internal static class SerilogConstants 7 | { 8 | /// 9 | /// Contains constants related to Serilog configuration sections. 10 | /// 11 | internal static class SectionNames 12 | { 13 | internal const string Using = "Serilog:Using"; 14 | } 15 | 16 | /// 17 | /// Contains constants representing the Serilog sinks used by this application. 18 | /// 19 | internal static class SinkShortNames 20 | { 21 | internal const string ApplicationInsights = "Serilog.Sinks.ApplicationInsights"; 22 | internal const string File = "Serilog.Sinks.File"; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Todo.Commons/Constants/Logging.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Commons.Constants 2 | { 3 | /// 4 | /// Contains constants related to logging various events generated by this application and its host. 5 | /// 6 | public static class Logging 7 | { 8 | /// 9 | /// Represents the name of the folder where log files will be created. 10 | /// 11 | public const string LogsHomeEnvironmentVariable = "LOGS_HOME"; 12 | 13 | /// 14 | /// Represents the identifier of the conversation grouping several events related to the same operation. 15 | /// 16 | public const string ConversationId = "ConversationId"; 17 | 18 | /// 19 | /// Represents the name of an application flow. 20 | /// 21 | public const string ApplicationFlowName = "ApplicationFlowName"; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Todo.Commons/Constants/ConnectionStrings.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Commons.Constants 2 | { 3 | /// 4 | /// Contains constants related to the connection strings used by this application. 5 | /// 6 | public static class ConnectionStrings 7 | { 8 | /// 9 | /// Represents the name of the connection string to be used when running integration tests. 10 | /// 11 | public const string UsedByIntegrationTests = "IntegrationTests"; 12 | 13 | /// 14 | /// Represents the name of the connection string to be used when running acceptance tests. 15 | /// 16 | public const string UsedByAcceptanceTests = "AcceptanceTests"; 17 | 18 | /// 19 | /// Represents the name of the connection string to be used when running the application. 20 | /// 21 | public const string UsedByApplication = "Application"; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/IApplicationFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows 2 | { 3 | using System.Security.Principal; 4 | using System.Threading.Tasks; 5 | 6 | /// 7 | /// An application flow implements a specific feature needed for business or technical reasons. 8 | /// 9 | /// The type of the input needed to execute this flow. 10 | /// The type of the outcome of this flow. 11 | public interface IApplicationFlow 12 | { 13 | /// 14 | /// Executes this flow. 15 | /// 16 | /// The input needed to execute this flow. 17 | /// The user who initiated executing this flow. 18 | /// An instance of type. 19 | Task ExecuteAsync(TInput input, IPrincipal flowInitiator); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/TodoControllerTests.CreateAsync_WhenRequestIsNotAuthorized_ReturnsExpectedResult.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: Unauthorized, 7 | ReasonPhrase: Unauthorized, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | }, 14 | { 15 | WWW-Authenticate: [ 16 | Bearer 17 | ] 18 | } 19 | ], 20 | TrailingHeaders: [], 21 | RequestMessage: { 22 | Version: 1.1, 23 | Content: { 24 | Headers: [ 25 | { 26 | Content-Type: [ 27 | application/json; charset=utf-8 28 | ] 29 | }, 30 | { 31 | Content-Length: [ 32 | 31 33 | ] 34 | } 35 | ] 36 | }, 37 | Method: { 38 | Method: POST 39 | }, 40 | RequestUri: http://localhost/api/todo, 41 | Headers: [] 42 | }, 43 | IsSuccessStatusCode: false 44 | } -------------------------------------------------------------------------------- /Sources/Todo.Commons/StartupLogic/HostExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Commons.StartupLogic 2 | { 3 | using System.Threading.Tasks; 4 | 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Hosting; 7 | 8 | /// 9 | /// Contains extension methods applicable to objects. 10 | /// 11 | public static class HostExtensions 12 | { 13 | /// 14 | /// Runs application startup logic (e.g., database migrations), then the host. 15 | /// 16 | /// The instance to run. 17 | public static async Task RunWithTasksAsync(this IHost host) 18 | { 19 | await using AsyncServiceScope asyncServiceScope = host.Services.CreateAsyncScope(); 20 | await asyncServiceScope.ServiceProvider.GetRequiredService().ExecuteAsync(); 21 | await host.RunAsync(); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/TodoControllerTests.UpdateAsync_WhenRequestIsNotAuthorized_ReturnsExpectedResult.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: Unauthorized, 7 | ReasonPhrase: Unauthorized, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | }, 14 | { 15 | WWW-Authenticate: [ 16 | Bearer 17 | ] 18 | } 19 | ], 20 | TrailingHeaders: [], 21 | RequestMessage: { 22 | Version: 1.1, 23 | Content: { 24 | Headers: [ 25 | { 26 | Content-Type: [ 27 | application/json; charset=utf-8 28 | ] 29 | }, 30 | { 31 | Content-Length: [ 32 | 31 33 | ] 34 | } 35 | ] 36 | }, 37 | Method: { 38 | Method: PUT 39 | }, 40 | RequestUri: http://localhost/api/todo/2147483647, 41 | Headers: [] 42 | }, 43 | IsSuccessStatusCode: false 44 | } -------------------------------------------------------------------------------- /Tests/Infrastructure/Todo.WebApi.TestInfrastructure/Todo.WebApi.TestInfrastructure.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | Library 5 | Todo.WebApi.TestInfrastructure 6 | 10 | true 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | false 23 | 24 | 25 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:57747", 8 | "sslPort": 44353 9 | } 10 | }, 11 | "profiles": { 12 | "IIS Express": { 13 | "commandName": "Project", 14 | "launchBrowser": false, 15 | "launchUrl": "api/todo", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "Development": { 21 | "commandName": "Project", 22 | "launchBrowser": false, 23 | "launchUrl": "api/todo", 24 | "environmentVariables": { 25 | "ASPNETCORE_ENVIRONMENT": "Development" 26 | }, 27 | "applicationUrl": "https://localhost:5001;http://localhost:5000" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Visual Studio artifacts 2 | .vs/ 3 | bin/ 4 | obj/ 5 | 6 | # Visual Studio Code artifacts 7 | .vscode/ 8 | /Tests/Infrastructure/TestInfrastructure/Properties/launchSettings.json 9 | /Tests/Infrastructure/TestInfrastructure/TestInfrastructure.csproj.user 10 | 11 | # Rider IDE artifacts 12 | .idea/ 13 | mono_crash.mem.* 14 | Todo.sln.DotSettings.user 15 | 16 | # ReSharper artifacts 17 | *.sln.DotSettings.user 18 | 19 | # Application logs 20 | /**/Logs/ 21 | 22 | # Tests 23 | /Tests/**/.TestResults/ 24 | /Tests/**/TestResults/ 25 | LivingDoc.html 26 | 27 | # Code coverage 28 | /Tests/**/.CodeCoverageResults/ 29 | /Tests/**/coverage.cobertura.xml 30 | /Tests/**/coverage.opencover.xml 31 | /Tests/**/coverage.json 32 | 33 | # SonarQube scanner artifacts 34 | .sonarqube/ 35 | 36 | # File containing environment variables used by docker-compose 37 | *.env 38 | 39 | # Report generated by dotnet-format tool 40 | ./dotnet-format-report.json 41 | 42 | # Log file generated by dotnet-format tool 43 | /formatDiagnosticLog.binlog 44 | 45 | # Verify 46 | *.received.* 47 | *.received/ 48 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/Security/GenerateJwtFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.Security 2 | { 3 | using System; 4 | using System.Security.Principal; 5 | using System.Threading.Tasks; 6 | 7 | using Microsoft.Extensions.Logging; 8 | 9 | using Todo.Services.Security; 10 | 11 | /// 12 | /// An implementation. 13 | /// 14 | public class GenerateJwtFlow : NonTransactionalBaseApplicationFlow, IGenerateJwtFlow 15 | { 16 | private readonly IJwtService jwtService; 17 | 18 | public GenerateJwtFlow(IJwtService jwtService, ILogger logger) : 19 | base("Security/GenerateJwt", logger) 20 | { 21 | this.jwtService = jwtService ?? throw new ArgumentNullException(nameof(jwtService)); 22 | } 23 | 24 | protected override async Task ExecuteFlowStepsAsync(GenerateJwtInfo input, IPrincipal flowInitiator) 25 | { 26 | return await jwtService.GenerateJwtAsync(input); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2018-2025 Bogdan Marian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Http/HttpLoggingMiddlewareExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Http 2 | { 3 | using System; 4 | 5 | using Microsoft.AspNetCore.Builder; 6 | 7 | /// 8 | /// Contains extension methods applicable to instances. 9 | /// 10 | public static class LoggingMiddlewareExtensions 11 | { 12 | /// 13 | /// Adds middleware for logging the current object. 14 | /// 15 | /// 16 | /// 17 | // ReSharper disable once UnusedMethodReturnValue.Global 18 | internal static IApplicationBuilder UseHttpLogging(this IApplicationBuilder applicationBuilder) 19 | { 20 | if (applicationBuilder == null) 21 | { 22 | throw new ArgumentNullException(nameof(applicationBuilder)); 23 | } 24 | 25 | applicationBuilder.UseMiddleware(); 26 | return applicationBuilder; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/AcceptanceTests/Todo.WebApi.AcceptanceTests/Infrastructure/TcpPortProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | namespace Todo.WebApi.AcceptanceTests.Infrastructure 4 | { 5 | using System.Net; 6 | using System.Net.Sockets; 7 | 8 | public class TcpPortProvider 9 | { 10 | private static readonly int AvailableTcpPort; 11 | 12 | static TcpPortProvider() 13 | { 14 | AvailableTcpPort = InternalGetAvailableTcpPort(); 15 | } 16 | 17 | [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Ensure only one TCP port is opened while executing acceptance tests")] 18 | public int GetAvailableTcpPort() 19 | { 20 | return AvailableTcpPort; 21 | } 22 | 23 | private static int InternalGetAvailableTcpPort() 24 | { 25 | using TcpListener tcpListener = new(IPAddress.Loopback, 0); 26 | tcpListener.Start(); 27 | 28 | int availableTcpPort = ((IPEndPoint)tcpListener.LocalEndpoint).Port; 29 | 30 | tcpListener.Stop(); 31 | return availableTcpPort; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Http/ConversationIdProviderMiddlewareExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Http 2 | { 3 | using System; 4 | 5 | using Microsoft.AspNetCore.Builder; 6 | 7 | /// 8 | /// Contains extension methods applicable to instances. 9 | /// 10 | public static class ConversationIdProviderMiddlewareExtensions 11 | { 12 | /// 13 | /// Adds middleware for providing a conversation ID to each HTTP request. 14 | /// 15 | /// Configures ASP.NET Core request processing pipeline. 16 | /// The given instance. 17 | public static IApplicationBuilder UseConversationId(this IApplicationBuilder applicationBuilder) 18 | { 19 | if (applicationBuilder == null) 20 | { 21 | throw new ArgumentNullException(nameof(applicationBuilder)); 22 | } 23 | 24 | applicationBuilder.UseMiddleware(); 25 | return applicationBuilder; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Build/db4tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db4it: 3 | image: "${db_docker_image}" 4 | healthcheck: 5 | test: > 6 | pg_isready \ 7 | --host=localhost \ 8 | --port=5432 \ 9 | --dbname=${db_name} \ 10 | --username=${db_username} 11 | interval: "2s" 12 | timeout: "5s" 13 | retries: 5 14 | start_period: "1s" 15 | stdin_open: false 16 | tty: false 17 | environment: 18 | POSTGRES_DB: "${db_name}" 19 | POSTGRES_USER: "${db_username}" 20 | POSTGRES_PASSWORD: "${db_password}" 21 | ports: 22 | - "5432" 23 | 24 | db4at: 25 | image: "${db_docker_image}" 26 | healthcheck: 27 | test: > 28 | pg_isready \ 29 | --host=localhost \ 30 | --port=5432 \ 31 | --dbname=${db_name} \ 32 | --username=${db_username} 33 | interval: "2s" 34 | timeout: "5s" 35 | retries: 5 36 | start_period: "1s" 37 | stdin_open: false 38 | tty: false 39 | environment: 40 | POSTGRES_DB: "${db_name}" 41 | POSTGRES_USER: "${db_username}" 42 | POSTGRES_PASSWORD: "${db_password}" 43 | ports: 44 | - "5432" 45 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/TodoItems/AddTodoItemFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.TodoItems 2 | { 3 | using System; 4 | using System.Security.Principal; 5 | using System.Threading.Tasks; 6 | 7 | using Microsoft.Extensions.Logging; 8 | 9 | using Services.TodoItemManagement; 10 | 11 | /// 12 | /// An implementation. 13 | /// 14 | public class AddTodoItemFlow : TransactionalBaseApplicationFlow, IAddTodoItemFlow 15 | { 16 | private readonly ITodoItemService todoItemService; 17 | 18 | public AddTodoItemFlow(ITodoItemService todoItemService, ApplicationFlowOptions applicationFlowOptions, ILogger logger) 19 | : base("TodoItem/Add", applicationFlowOptions, logger) 20 | { 21 | this.todoItemService = todoItemService ?? throw new ArgumentNullException(nameof(todoItemService)); 22 | } 23 | 24 | protected override async Task ExecuteFlowStepsAsync(NewTodoItemInfo input, IPrincipal flowInitiator) 25 | { 26 | input.Owner = flowInitiator; 27 | 28 | return await todoItemService.AddAsync(input); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.Services.UnitTests/Security/VerifySnapshots/JwtServiceTests.GenerateJwtAsync_WhenUsingValidInput_MustReturnExpectedResult.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Audiences: [ 3 | test-audience 4 | ], 5 | Claims: [ 6 | { 7 | nameid: c29tZS10ZXN0LXVzZXI= 8 | }, 9 | { 10 | scope: resource1 resource2 11 | }, 12 | { 13 | nbf: Scrubbed 14 | }, 15 | { 16 | exp: Scrubbed 17 | }, 18 | { 19 | iat: Scrubbed 20 | }, 21 | { 22 | iss: test 23 | }, 24 | { 25 | aud: test-audience 26 | } 27 | ], 28 | EncodedHeader: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9, 29 | EncodedPayload: {Scrubbed}, 30 | Header: { 31 | alg: HS256, 32 | typ: JWT 33 | }, 34 | Issuer: test, 35 | Payload: { 36 | aud: test-audience, 37 | exp: {Scrubbed}, 38 | iat: {Scrubbed}, 39 | iss: test, 40 | nameid: c29tZS10ZXN0LXVzZXI=, 41 | nbf: {Scrubbed}, 42 | scope: resource1 resource2 43 | }, 44 | RawData: {Scrubbed}, 45 | RawHeader: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9, 46 | RawPayload: {Scrubbed}, 47 | RawSignature: {Scrubbed}, 48 | SignatureAlgorithm: HS256, 49 | ValidFrom: DateTime_1, 50 | ValidTo: DateTime_2, 51 | IssuedAt: DateTime_1 52 | } -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/DependencyInjection/TelemetryModule.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.DependencyInjection 2 | { 3 | using Autofac; 4 | 5 | using Commons.StartupLogic; 6 | 7 | using Http; 8 | 9 | using Serilog; 10 | 11 | /// 12 | /// Configures telemetry related services used by this application. 13 | /// 14 | public class TelemetryModule : Module 15 | { 16 | /// 17 | /// Gets or sets whether HTTP requests and their responses will be logged. 18 | /// 19 | public bool EnableHttpLogging { get; set; } 20 | 21 | protected override void Load(ContainerBuilder builder) 22 | { 23 | if (EnableHttpLogging) 24 | { 25 | builder 26 | .RegisterType() 27 | .AsSelf() 28 | .As() 29 | .As() 30 | .SingleInstance(); 31 | } 32 | 33 | builder 34 | .RegisterType() 35 | .As() 36 | .SingleInstance(); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Authorization/HasScopeHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.Authorization 2 | { 3 | using System; 4 | using System.Security.Claims; 5 | using System.Threading.Tasks; 6 | 7 | using Microsoft.AspNetCore.Authorization; 8 | 9 | /// 10 | /// Based on: https://auth0.com/docs/quickstart/backend/aspnet-core-webapi#validate-scopes. 11 | /// 12 | public class HasScopeHandler : AuthorizationHandler 13 | { 14 | protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, HasScopeRequirement requirement) 15 | { 16 | Claim scopeClaim = context.User.FindFirst(claim => claim.Type == "scope" && claim.Issuer == requirement.Issuer); 17 | 18 | if (scopeClaim != null) 19 | { 20 | string[] scopes = scopeClaim.Value.Split(separator: ' '); 21 | 22 | if (Array.Exists(scopes, scope => scope == requirement.Scope)) 23 | { 24 | context.Succeed(requirement); 25 | return Task.CompletedTask; 26 | } 27 | } 28 | 29 | context.Fail(); 30 | return Task.CompletedTask; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Http/IHttpObjectConverter.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Http 2 | { 3 | using System.Threading.Tasks; 4 | 5 | using Microsoft.AspNetCore.Http; 6 | 7 | /// 8 | /// Converts HTTP objects to strings intended to be logged using 9 | /// method. 10 | /// 11 | public interface IHttpObjectConverter 12 | { 13 | /// 14 | /// Converts the given to a string. 15 | /// 16 | /// The instance to be converted. 17 | /// The string representation of an instance. 18 | Task ToLogMessageAsync(HttpRequest httpRequest); 19 | 20 | /// 21 | /// Converts the given to a string. 22 | /// 23 | /// The instance to be converted. 24 | /// The string representation of an instance. 25 | Task ToLogMessageAsync(HttpResponse httpResponse); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/TodoItems/UpdateTodoItemFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.TodoItems 2 | { 3 | using System; 4 | using System.Security.Principal; 5 | using System.Threading.Tasks; 6 | 7 | using Microsoft.Extensions.Logging; 8 | 9 | using Services.TodoItemManagement; 10 | 11 | /// 12 | /// An implementation. 13 | /// 14 | public class UpdateTodoItemFlow : TransactionalBaseApplicationFlow, IUpdateTodoItemFlow 15 | { 16 | private readonly ITodoItemService todoItemService; 17 | 18 | public UpdateTodoItemFlow(ITodoItemService todoItemService, ApplicationFlowOptions applicationFlowOptions, ILogger logger) 19 | : base("TodoItem/Update", applicationFlowOptions, logger) 20 | { 21 | this.todoItemService = todoItemService ?? throw new ArgumentNullException(nameof(todoItemService)); 22 | } 23 | 24 | protected override async Task ExecuteFlowStepsAsync(UpdateTodoItemInfo input, IPrincipal flowInitiator) 25 | { 26 | input.Owner = flowInitiator; 27 | await todoItemService.UpdateAsync(input); 28 | return null; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/TodoItems/DeleteTodoItemFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.TodoItems 2 | { 3 | using System; 4 | using System.Security.Principal; 5 | using System.Threading.Tasks; 6 | 7 | using Microsoft.Extensions.Logging; 8 | 9 | using Services.TodoItemManagement; 10 | 11 | /// 12 | /// An implementation. 13 | /// 14 | public class DeleteTodoItemFlow : TransactionalBaseApplicationFlow, IDeleteTodoItemFlow 15 | { 16 | private readonly ITodoItemService todoItemService; 17 | 18 | public DeleteTodoItemFlow(ITodoItemService todoItemService, ApplicationFlowOptions applicationFlowOptions, ILogger logger) 19 | : base("TodoItem/Delete", applicationFlowOptions, logger) 20 | { 21 | this.todoItemService = todoItemService ?? throw new ArgumentNullException(nameof(todoItemService)); 22 | } 23 | 24 | protected override async Task ExecuteFlowStepsAsync(DeleteTodoItemInfo input, IPrincipal flowInitiator) 25 | { 26 | input.Owner = flowInitiator; 27 | await todoItemService.DeleteAsync(input); 28 | 29 | return null; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.Telemetry.UnitTests/Todo.Telemetry.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | Library 5 | Todo.Telemetry 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | true 27 | [*]* 28 | false 29 | 30 | 31 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Todo.WebApi.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <_Parameter1>Todo.WebApi.UnitTests 14 | 15 | 16 | 17 | 18 | 19 | <_Parameter1>Todo.WebApi.IntegrationTests 20 | 21 | 22 | 23 | 24 | 25 | 26 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/TodoDbContext.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Persistence 2 | { 3 | using System; 4 | 5 | using Entities; 6 | using Entities.Configurations; 7 | 8 | using Microsoft.EntityFrameworkCore; 9 | 10 | // ReSharper disable once ClassNeverInstantiated.Global 11 | public class TodoDbContext : DbContext 12 | { 13 | static TodoDbContext() 14 | { 15 | // @satrapu 2021-12-02: Temporarily disable a breaking change introduced when migrating 16 | // Npgsql.EntityFrameworkCore.PostgreSQL NuGet package from v5.x to v6.x. 17 | // See more about this breaking change and its fix here: 18 | // https://www.npgsql.org/efcore/release-notes/6.0.html#opting-out-of-the-new-timestamp-mapping-logic. 19 | AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); 20 | } 21 | 22 | public TodoDbContext(DbContextOptions options) : base(options) 23 | { 24 | } 25 | 26 | // ReSharper disable once UnusedAutoPropertyAccessor.Global 27 | public DbSet TodoItems { get; set; } 28 | 29 | protected override void OnModelCreating(ModelBuilder modelBuilder) 30 | { 31 | modelBuilder.ApplyConfiguration(new TodoItemConfiguration()); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.ApplicationFlows.UnitTests/Todo.ApplicationFlows.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | Library 5 | Todo.ApplicationFlows 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | true 27 | [*]* 28 | false 29 | 30 | 31 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 10 | true 11 | 12 | 15 | 12.0 16 | 17 | 22 | true 23 | true 24 | NU1603 25 | true 26 | 27 | 28 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.WebApi.UnitTests/Todo.WebApi.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | Library 5 | Todo.WebApi 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | true 28 | [*]* 29 | false 30 | 31 | 32 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Serilog/Destructuring/NewTodoItemInfoDestructuringPolicy.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Serilog.Destructuring 2 | { 3 | using System.Collections.Generic; 4 | 5 | using global::Serilog.Core; 6 | using global::Serilog.Events; 7 | 8 | using Services.Security; 9 | using Services.TodoItemManagement; 10 | 11 | /// 12 | /// Instructs Serilog how to log instances of class. 13 | /// 14 | // ReSharper disable once UnusedType.Global 15 | public class NewTodoItemInfoDestructuringPolicy : IDestructuringPolicy 16 | { 17 | public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, 18 | out LogEventPropertyValue result) 19 | { 20 | if (value is NewTodoItemInfo newTodoItemInfo) 21 | { 22 | result = new StructureValue(new List 23 | { 24 | new(nameof(newTodoItemInfo.Name), new ScalarValue(newTodoItemInfo.Name)), 25 | new(nameof(newTodoItemInfo.Owner), new ScalarValue(newTodoItemInfo.Owner.GetNameOrDefault())) 26 | }); 27 | 28 | return true; 29 | } 30 | 31 | result = null; 32 | return false; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Serilog/Destructuring/DeleteTodoItemInfoDestructuringPolicy.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Serilog.Destructuring 2 | { 3 | using System.Collections.Generic; 4 | 5 | using global::Serilog.Core; 6 | using global::Serilog.Events; 7 | 8 | using Services.Security; 9 | using Services.TodoItemManagement; 10 | 11 | /// 12 | /// Instructs Serilog how to log instances of class. 13 | /// 14 | // ReSharper disable once UnusedType.Global 15 | public class DeleteTodoItemInfoDestructuringPolicy : IDestructuringPolicy 16 | { 17 | public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, 18 | out LogEventPropertyValue result) 19 | { 20 | if (value is DeleteTodoItemInfo deleteTodoItemInfo) 21 | { 22 | result = new StructureValue(new List 23 | { 24 | new(nameof(deleteTodoItemInfo.Id), new ScalarValue(deleteTodoItemInfo.Id)), 25 | new(nameof(deleteTodoItemInfo.Owner),new ScalarValue(deleteTodoItemInfo.Owner.GetNameOrDefault())) 26 | }); 27 | 28 | return true; 29 | } 30 | 31 | result = null; 32 | return false; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Todo.Telemetry.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | 5 | 6 | 7 | 8 | <_Parameter1>Todo.Telemetry.UnitTests 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.Persistence.IntegrationTests/Todo.Persistence.IntegrationTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | Library 5 | Todo.Persistence 6 | 7 | 8 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | true 30 | [*]* 31 | false 32 | 33 | 34 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/TodoItems/FetchTodoItemsFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.TodoItems 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Security.Principal; 6 | using System.Threading.Tasks; 7 | 8 | using Microsoft.Extensions.Logging; 9 | 10 | using Services.TodoItemManagement; 11 | 12 | /// 13 | /// An implementation. 14 | /// 15 | public class FetchTodoItemsFlow : TransactionalBaseApplicationFlow>, IFetchTodoItemsFlow 16 | { 17 | private readonly ITodoItemService todoItemService; 18 | 19 | public FetchTodoItemsFlow(ITodoItemService todoItemService, ApplicationFlowOptions applicationFlowOptions, ILogger logger) 20 | : base("TodoItems/FetchByQuery", applicationFlowOptions, logger) 21 | { 22 | this.todoItemService = todoItemService ?? throw new ArgumentNullException(nameof(todoItemService)); 23 | } 24 | 25 | protected override async Task> ExecuteFlowStepsAsync(TodoItemQuery input, IPrincipal flowInitiator) 26 | { 27 | // Ensure that the application fetches data belonging to the current user only (usually the one initiating 28 | // the current flow). 29 | input.Owner = flowInitiator; 30 | return await todoItemService.GetByQueryAsync(input); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Http/StreamExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Http 2 | { 3 | using System; 4 | using System.IO; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | /// 9 | /// Contains extension methods applicable to instances. 10 | /// 11 | public static class StreamExtensions 12 | { 13 | private const int BufferSize = 1024; 14 | 15 | /// 16 | /// Reads the whole content of a given instance and then sets its position to the beginning. 17 | /// 18 | /// The to read and reset. 19 | /// The contents as a string. 20 | public static Task ReadAndResetAsync(this Stream stream) 21 | { 22 | ArgumentNullException.ThrowIfNull(stream); 23 | 24 | return ReadAndResetInternalAsync(stream); 25 | } 26 | 27 | private static async Task ReadAndResetInternalAsync(this Stream stream) 28 | { 29 | ArgumentNullException.ThrowIfNull(stream); 30 | stream.Seek(0, SeekOrigin.Begin); 31 | 32 | using StreamReader streamReader = new(stream, Encoding.UTF8, true, BufferSize, true); 33 | string result = await streamReader.ReadToEndAsync(); 34 | 35 | stream.Seek(0, SeekOrigin.Begin); 36 | return result; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/appsettings.DemoInAzure.json: -------------------------------------------------------------------------------- 1 | { 2 | "MigrateDatabase": true, 3 | "GenerateJwt": { 4 | "Issuer": "https://demo-in-azure.auth.todo-by-satrapu.com", 5 | "Audience": "https://demo-in-azure.api.todo-by-satrapu.com" 6 | }, 7 | "Serilog": { 8 | "LevelSwitches": { 9 | "$controlSwitch": "Debug" 10 | }, 11 | "Using": [ 12 | "Serilog.Sinks.ApplicationInsights" 13 | ], 14 | "WriteTo": [ 15 | { 16 | "Name": "ApplicationInsights", 17 | "Args": { 18 | // @satrapu 2022-07-16: Provide a dummy connection string to ensure Serilog.Sinks.ApplicationInsights does not complain 19 | // about a missing or invalid value; when this application runs in Azure, the proper connection string will be provided. 20 | "connectionString": "InstrumentationKey=00000000-0000-0000-0000-000000000000;", 21 | "telemetryConverter": "Serilog.Sinks.ApplicationInsights.TelemetryConverters.TraceTelemetryConverter, Serilog.Sinks.ApplicationInsights" 22 | } 23 | } 24 | ], 25 | "Properties": { 26 | "Application": "Todo.WebApi.DemoInAzure" 27 | } 28 | }, 29 | "OpenTelemetry": { 30 | "Exporters": { 31 | "AzureMonitor": { 32 | "Enabled": true 33 | }, 34 | "Jaeger": { 35 | "Enabled": false 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Serilog/Destructuring/UpdateTodoItemInfoDestructuringPolicy.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Serilog.Destructuring 2 | { 3 | using System.Collections.Generic; 4 | 5 | using global::Serilog.Core; 6 | using global::Serilog.Events; 7 | 8 | using Services.Security; 9 | using Services.TodoItemManagement; 10 | 11 | /// 12 | /// Instructs Serilog how to log instances of class. 13 | /// 14 | // ReSharper disable once UnusedType.Global 15 | public class UpdateTodoItemInfoDestructuringPolicy : IDestructuringPolicy 16 | { 17 | public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, 18 | out LogEventPropertyValue result) 19 | { 20 | if (value is UpdateTodoItemInfo updateTodoItemInfo) 21 | { 22 | result = new StructureValue(new List 23 | { 24 | new(nameof(updateTodoItemInfo.Id), new ScalarValue(updateTodoItemInfo.Id)), 25 | new(nameof(updateTodoItemInfo.Name), new ScalarValue(updateTodoItemInfo.Name)), 26 | new(nameof(updateTodoItemInfo.IsComplete), new ScalarValue(updateTodoItemInfo.IsComplete)), 27 | new(nameof(updateTodoItemInfo.Owner), new ScalarValue(updateTodoItemInfo.Owner.GetNameOrDefault())) 28 | }); 29 | 30 | return true; 31 | } 32 | 33 | result = null; 34 | return false; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Migrations/20200124212126_ConsolidateCreatedByAndNameColumnsIntoAnUniqueIndexInsideTodoItemsTable.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Persistence.Migrations 2 | { 3 | using Microsoft.EntityFrameworkCore.Migrations; 4 | 5 | public partial class ConsolidateCreatedByAndNameColumnsIntoAnUniqueIndexInsideTodoItemsTable : Migration 6 | { 7 | protected override void Up(MigrationBuilder migrationBuilder) 8 | { 9 | migrationBuilder.DropIndex( 10 | name: "IX_TodoItems_CreatedBy", 11 | table: "TodoItems"); 12 | 13 | migrationBuilder.DropIndex( 14 | name: "IX_TodoItems_Name", 15 | table: "TodoItems"); 16 | 17 | migrationBuilder.CreateIndex( 18 | name: "IX_TodoItems_CreatedBy_Name", 19 | table: "TodoItems", 20 | columns: new[] { "CreatedBy", "Name" }, 21 | unique: true); 22 | } 23 | 24 | protected override void Down(MigrationBuilder migrationBuilder) 25 | { 26 | migrationBuilder.DropIndex( 27 | name: "IX_TodoItems_CreatedBy_Name", 28 | table: "TodoItems"); 29 | 30 | migrationBuilder.CreateIndex( 31 | name: "IX_TodoItems_CreatedBy", 32 | table: "TodoItems", 33 | column: "CreatedBy"); 34 | 35 | migrationBuilder.CreateIndex( 36 | name: "IX_TodoItems_Name", 37 | table: "TodoItems", 38 | column: "Name"); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.Services.UnitTests/Todo.Services.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | Library 5 | Todo.Services 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | true 34 | [*]* 35 | false 36 | 37 | 38 | -------------------------------------------------------------------------------- /Tests/AcceptanceTests/Todo.WebApi.AcceptanceTests/Infrastructure/SetupSystemUnderTest.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.AcceptanceTests.Infrastructure 2 | { 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Threading.Tasks; 5 | 6 | using TechTalk.SpecFlow; 7 | using TechTalk.SpecFlow.Infrastructure; 8 | 9 | [Binding] 10 | [SuppressMessage("Sonar", "S1118", Justification = "Class must be instantiable by SpecFlow test runner")] 11 | public class SetupSystemUnderTest 12 | { 13 | private const string SystemUnderTestProcessKey = $"{nameof(SystemUnderTest)}.Process"; 14 | 15 | [BeforeFeature] 16 | public static async Task StartSystemUnderTestAsync(FeatureContext featureContext) 17 | { 18 | SystemUnderTest systemUnderTestProcess = await SystemUnderTest.StartNewAsync 19 | ( 20 | port: featureContext.FeatureContainer.Resolve().GetAvailableTcpPort(), 21 | specFlowOutputHelper: featureContext.FeatureContainer.Resolve() 22 | ); 23 | 24 | featureContext.Add(SystemUnderTestProcessKey, systemUnderTestProcess); 25 | } 26 | 27 | [AfterFeature] 28 | public static async Task StopSystemUnderTestAsync(FeatureContext featureContext) 29 | { 30 | SystemUnderTest systemUnderTest = featureContext.Get(SystemUnderTestProcessKey); 31 | 32 | if (systemUnderTest is not null) 33 | { 34 | await systemUnderTest.DisposeAsync(); 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/SimpleApplicationFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows 2 | { 3 | using System; 4 | using System.Security.Principal; 5 | using System.Threading.Tasks; 6 | 7 | using Microsoft.Extensions.Logging; 8 | 9 | /// 10 | /// Executes instances using the same mechanism employed when executing heavier 11 | /// application flows which implement interface. 12 | /// 13 | public static class SimpleApplicationFlow 14 | { 15 | public static async Task ExecuteAsync(string flowName, Func simpleApplicationFlow, IPrincipal flowInitiator, ILogger logger) 16 | { 17 | await new InternalNonTransactionalApplicationFlow(flowName, simpleApplicationFlow, logger).ExecuteAsync(null, flowInitiator); 18 | } 19 | 20 | private sealed class InternalNonTransactionalApplicationFlow : NonTransactionalBaseApplicationFlow 21 | { 22 | private readonly Func simpleApplicationFlow; 23 | 24 | public InternalNonTransactionalApplicationFlow(string flowName, Func simpleApplicationFlow, ILogger logger) : base(flowName, logger) 25 | { 26 | this.simpleApplicationFlow = simpleApplicationFlow; 27 | } 28 | 29 | protected override async Task ExecuteFlowStepsAsync(object input, IPrincipal flowInitiator) 30 | { 31 | await simpleApplicationFlow(); 32 | return Task.CompletedTask; 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Entities/Configurations/TodoItemConfiguration.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Persistence.Entities.Configurations 2 | { 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 5 | 6 | internal class TodoItemConfiguration : IEntityTypeConfiguration 7 | { 8 | public void Configure(EntityTypeBuilder builder) 9 | { 10 | builder.ToTable("TodoItems"); 11 | 12 | builder.HasKey(todoItem => todoItem.Id); 13 | 14 | builder.Property(todoItem => todoItem.Id) 15 | .ValueGeneratedOnAdd(); 16 | 17 | builder.Property(todoItem => todoItem.Name) 18 | .IsRequired() 19 | .HasMaxLength(100); 20 | 21 | builder.Property(todoItem => todoItem.IsComplete) 22 | .IsRequired(); 23 | 24 | builder.Property(todoItem => todoItem.CreatedBy) 25 | .IsRequired() 26 | .HasMaxLength(100); 27 | 28 | builder.Property(todoItem => todoItem.CreatedOn) 29 | .IsRequired(); 30 | 31 | builder.Property(todoItem => todoItem.LastUpdatedBy) 32 | .IsRequired(false) 33 | .HasMaxLength(100); 34 | 35 | builder.Property(todoItem => todoItem.LastUpdatedOn) 36 | .IsRequired(false); 37 | 38 | builder.Property(todoItem => todoItem.Version) 39 | .IsRowVersion(); 40 | 41 | builder.HasIndex(nameof(TodoItem.CreatedBy), nameof(TodoItem.Name)) 42 | .IsUnique(); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Migrations/20200124210404_InitialSchema.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Persistence.Migrations 2 | { 3 | using System; 4 | 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | 9 | public partial class InitialSchema : Migration 10 | { 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.CreateTable( 14 | name: "TodoItems", 15 | columns: table => new 16 | { 17 | Id = table.Column(nullable: false) 18 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 19 | Name = table.Column(maxLength: 100, nullable: false), 20 | IsComplete = table.Column(nullable: false), 21 | CreatedBy = table.Column(maxLength: 100, nullable: false), 22 | CreatedOn = table.Column(nullable: false), 23 | LastUpdatedBy = table.Column(maxLength: 100, nullable: true), 24 | LastUpdatedOn = table.Column(nullable: true) 25 | }, 26 | constraints: table => 27 | { 28 | table.PrimaryKey("PK_TodoItems", x => x.Id); 29 | }); 30 | } 31 | 32 | protected override void Down(MigrationBuilder migrationBuilder) 33 | { 34 | migrationBuilder.DropTable( 35 | name: "TodoItems"); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 20 | 23 | 24 | 27 | 30 | 31 | 32 | 33 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | using Autofac; 4 | using Autofac.Extensions.DependencyInjection; 5 | 6 | using Microsoft.AspNetCore.Builder; 7 | using Microsoft.Extensions.Configuration; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Hosting; 10 | using Microsoft.Extensions.Logging; 11 | 12 | using Todo.Commons.Constants; 13 | using Todo.Commons.StartupLogic; 14 | using Todo.WebApi; 15 | 16 | WebApplicationBuilder builder = WebApplication.CreateBuilder(args); 17 | 18 | builder.Configuration 19 | .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) 20 | .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true) 21 | .AddEnvironmentVariables(prefix: EnvironmentVariables.Prefix) 22 | .AddCommandLine(args); 23 | 24 | builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()); 25 | 26 | Startup startup = new(configuration: builder.Configuration, webHostEnvironment: builder.Environment); 27 | startup.ConfigureServices(builder.Services); 28 | 29 | builder.Host.ConfigureContainer(startup.ConfigureContainer); 30 | 31 | WebApplication app = builder.Build(); 32 | 33 | startup.Configure 34 | ( 35 | applicationBuilder: app, 36 | hostApplicationLifetime: app.Services.GetRequiredService(), 37 | serviceProvider: app.Services, 38 | logger: app.Services.GetRequiredService().CreateLogger() 39 | ); 40 | 41 | await app.RunWithTasksAsync(); 42 | 43 | [SuppressMessage("Sonar", "S1118", Justification = "Class is needed by WebApplicationFactory")] 44 | [ExcludeFromCodeCoverage] 45 | public partial class Program 46 | { 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/TodoItems/FetchTodoItemByIdFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.TodoItems 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Security.Principal; 7 | using System.Threading.Tasks; 8 | 9 | using Microsoft.Extensions.Logging; 10 | 11 | using Services.TodoItemManagement; 12 | 13 | /// 14 | /// An implementation. 15 | /// 16 | public class FetchTodoItemByIdFlow : TransactionalBaseApplicationFlow, IFetchTodoItemByIdFlow 17 | { 18 | private readonly ITodoItemService todoItemService; 19 | 20 | public FetchTodoItemByIdFlow(ITodoItemService todoItemService, ApplicationFlowOptions applicationFlowOptions, ILogger logger) 21 | : base("TodoItem/FetchById", applicationFlowOptions, logger) 22 | { 23 | this.todoItemService = todoItemService ?? throw new ArgumentNullException(nameof(todoItemService)); 24 | } 25 | 26 | protected override async Task ExecuteFlowStepsAsync(long input, IPrincipal flowInitiator) 27 | { 28 | TodoItemQuery todoItemQuery = new() 29 | { 30 | Id = input, 31 | // Ensure that the application fetches data belonging to the current user only (usually the one 32 | // initiating the current flow). 33 | Owner = flowInitiator 34 | }; 35 | 36 | IList todoItemInfos = await todoItemService.GetByQueryAsync(todoItemQuery); 37 | TodoItemInfo result = todoItemInfos.FirstOrDefault(); 38 | return result; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/AcceptanceTests/Todo.WebApi.AcceptanceTests/Todo.WebApi.AcceptanceTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | Library 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | appsettings.AcceptanceTests.json 29 | PreserveNewest 30 | 31 | 32 | appsettings.json 33 | PreserveNewest 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.ApplicationFlows.IntegrationTests/Todo.ApplicationFlows.IntegrationTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | Library 5 | Todo.ApplicationFlows 6 | 7 | 8 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | all 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | true 32 | 33 | [Todo.WebApi.TestInfrastructure]* 34 | 35 | [*]* 36 | false 37 | 38 | 39 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "MigrateDatabase": true, 3 | "GenerateJwt": { 4 | "Issuer": "https://development.auth.todo-by-satrapu.com", 5 | "Audience": "https://development.api.todo-by-satrapu.com" 6 | }, 7 | "Serilog": { 8 | "LevelSwitches": { 9 | "$controlSwitch": "Debug" 10 | }, 11 | "Using": [ 12 | "Serilog.Sinks.Console", 13 | "Serilog.Sinks.Seq" 14 | ], 15 | "WriteTo": [ 16 | { 17 | "Name": "Console", 18 | "Args": { 19 | "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console", 20 | "outputTemplate": "{Timestamp:HH:mm:ss.fff} {Level:u3} | trid: {TraceId} sid: {SpanId} pid: {ParentId} cid:{ConversationId} fid:{ApplicationFlowName} thid:{ThreadId} | {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}" 21 | } 22 | }, 23 | { 24 | "Name": "Seq", 25 | "Args": { 26 | "serverUrl": "http://localhost:5341", 27 | "controlLevelSwitch": "$controlSwitch" 28 | } 29 | } 30 | ], 31 | "Properties": { 32 | "Application": "Todo.WebApi.Development" 33 | } 34 | }, 35 | "ExceptionHandling": { 36 | "IncludeDetails": true 37 | }, 38 | "HttpLogging": { 39 | "Enabled": true 40 | }, 41 | "ConfigurationDebugViewEndpointEnabled": true, 42 | "OpenTelemetry": { 43 | "Exporters": { 44 | "Jaeger": { 45 | "AgentHost": "localhost", 46 | "AgentPort": 6831 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Build/start-docker-on-macOS.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Install Docker on macOS via CLI commands. 4 | 5 | # Fail script in case a command fails or in case of unset variables - see more here: https://www.davidpashley.com/articles/writing-robust-shell-scripts/. 6 | set -o errexit 7 | set -o nounset 8 | 9 | # Check for the right Docker Compose version here: https://github.com/docker/compose/releases. 10 | # Installation steps can be found here: https://docs.docker.com/compose/install/standalone/. 11 | dockerComposeVersion='2.32.2' 12 | 13 | # Check for how to customize Colima VM here: https://github.com/abiosoft/colima?tab=readme-ov-file#customizing-the-vm. 14 | colimaCpuCount=2 15 | colimaMemorySizeInGigabytes=2 16 | colimaDiskSizeInGigabytes=10 17 | 18 | # Install Docker CLI 19 | echo "Installing Docker CLI ..." 20 | brew install docker 21 | echo 'Checking Docker CLI installation ...' 22 | docker --version 23 | echo "Docker CLI has been installed successfully" 24 | 25 | # Install Docker Compose 26 | echo "Installing Docker Compose with version: $dockerComposeVersion ..." 27 | sudo curl -L https://github.com/docker/compose/releases/download/v$dockerComposeVersion/docker-compose-darwin-x86_64 -o /usr/local/bin/docker-compose -v 28 | sudo chmod +x /usr/local/bin/docker-compose 29 | echo 'Checking Docker Compose installation ...' 30 | docker-compose version 31 | echo "Docker Compose has been installed successfully" 32 | 33 | # Install Colima 34 | brew install colima 35 | 36 | # Start Colima container runtime. 37 | # Check for Colima usage here: https://github.com/abiosoft/colima?tab=readme-ov-file#usage. 38 | echo 'Starting Colima container runtime ...' 39 | colima start --cpu $colimaCpuCount --memory $colimaMemorySizeInGigabytes --disk $colimaDiskSizeInGigabytes 40 | echo 'Colima container runtime has started' 41 | echo 'All good :)' 42 | -------------------------------------------------------------------------------- /Sources/Todo.Commons/Constants/EnvironmentNames.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Commons.Constants 2 | { 3 | using Microsoft.Extensions.Hosting; 4 | 5 | /// 6 | /// Contains constants related to the environments where this application will run. 7 | /// 8 | public static class EnvironmentNames 9 | { 10 | /// 11 | /// Represents the name of the local development environment. 12 | /// 13 | public static readonly string Development = Environments.Development; 14 | 15 | /// 16 | /// Represents the name of the environment where integration tests are run 17 | /// (usually a local development machine or a CI environment, like Azure DevOps). 18 | /// 19 | public const string IntegrationTests = "IntegrationTests"; 20 | 21 | /// 22 | /// Represents the name of the environment where acceptance tests are run 23 | /// (could be a local development machine, CI environment or even cloud). 24 | /// 25 | public const string AcceptanceTests = "AcceptanceTests"; 26 | 27 | /// 28 | /// Represents the name of the Azure environment used for demonstrating various application features. 29 | /// 30 | // ReSharper disable once UnusedMember.Global 31 | public const string DemoInAzure = "DemoInAzure"; 32 | 33 | /// 34 | /// Represents the name of the staging environment. 35 | /// 36 | public static readonly string Staging = Environments.Staging; 37 | 38 | /// 39 | /// Represents the name of the production environment. 40 | /// 41 | public static readonly string Production = Environments.Production; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/appsettings.IntegrationTests.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "IntegrationTests": "" 4 | }, 5 | "MigrateDatabase": true, 6 | "GenerateJwt": { 7 | "Issuer": "https://integrationtests.auth.todo-by-satrapu.com", 8 | "Audience": "https://integrationtests.api.todo-by-satrapu.com" 9 | }, 10 | "Serilog": { 11 | "LevelSwitches": { 12 | "$controlSwitch": "Debug" 13 | }, 14 | "Using": [ 15 | "Serilog.Sinks.Console", 16 | "Serilog.Sinks.File" 17 | ], 18 | "WriteTo": [ 19 | { 20 | "Name": "Console", 21 | "Args": { 22 | "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console", 23 | "outputTemplate": "{Timestamp:HH:mm:ss.fff} {Level:u3} | trid: {TraceId} sid: {SpanId} pid: {ParentId} cid:{ConversationId} fid:{ApplicationFlowName} thid:{ThreadId} | {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}" 24 | } 25 | }, 26 | { 27 | "Name": "File", 28 | "Args": { 29 | "path": "%LOGS_HOME%/todo-web-api.integration-tests.log", 30 | "outputTemplate": "{Timestamp:HH:mm:ss.fff} {Level:u3} | trid: {TraceId} sid: {SpanId} pid: {ParentId} cid:{ConversationId} fid:{ApplicationFlowName} thid:{ThreadId} | {SourceContext}{NewLine}{Message:lj}{NewLine}{Properties}{NewLine}{Exception}" 31 | } 32 | } 33 | ], 34 | "Properties": { 35 | "Application": "Todo.WebApi.IntegrationTests" 36 | } 37 | }, 38 | "ExceptionHandling": { 39 | "IncludeDetails": true 40 | }, 41 | "HttpLogging": { 42 | "Enabled": true 43 | }, 44 | "OpenTelemetry": { 45 | "Enabled": false 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/AcceptanceTests/Todo.WebApi.AcceptanceTests/Features/AddTodoItem.feature: -------------------------------------------------------------------------------- 1 | Feature: Add todo items 2 | As a user I want to be able to add new todo items so I won't forget about the important things I need to take care of each day. 3 | 4 | @add-new-todo-item 5 | @expected-positive-result 6 | Scenario: Add new todo item 7 | Given the current user is authorized to add todo items 8 | When the current user tries adding a new todo item 9 | | Name | IsComplete | 10 | | Add more acceptance tests | true | 11 | Then the system must add the new todo item 12 | And reply with a success response 13 | | HttpStatusCode | LocationHeaderValueMatchExpression | 14 | | 201 | http*:*//*/api/todo/* | 15 | 16 | @add-new-todo-item 17 | @expected-negative-result 18 | Scenario: Unauthenticated user cannot add todo items 19 | Given the current user is not authenticated 20 | When the current user tries adding a new todo item 21 | | Name | IsComplete | 22 | | Authenticate user first | false | 23 | Then the system must not add the new todo item 24 | And reply with a failed response 25 | | HttpStatusCode | 26 | | 401 | 27 | 28 | @add-new-todo-item 29 | @expected-negative-result 30 | Scenario: Unauthorized user cannot add todo items 31 | Given the current user is not authorized to add todo items 32 | When the current user tries adding a new todo item 33 | | Name | IsComplete | 34 | | Grant the appropriate permissions to the current user first | false | 35 | Then the system must not add the new todo item 36 | And reply with a failed response 37 | | HttpStatusCode | 38 | | 403 | 39 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/Controllers/ConfigurationController.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.Controllers 2 | { 3 | using System; 4 | 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Hosting; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.Hosting; 10 | 11 | /// 12 | /// Controller used for dealing with application configuration. 13 | /// 14 | [Route("api/configuration")] 15 | [Authorize] 16 | [ApiController] 17 | public class ConfigurationController : ControllerBase 18 | { 19 | private readonly IConfigurationRoot configurationRoot; 20 | private readonly IWebHostEnvironment webHostEnvironment; 21 | 22 | public ConfigurationController(IConfiguration applicationConfiguration, IWebHostEnvironment webHostEnvironment) 23 | { 24 | configurationRoot = applicationConfiguration as IConfigurationRoot 25 | ?? throw new ArgumentException("Expected configuration root", nameof(applicationConfiguration)); 26 | 27 | this.webHostEnvironment = webHostEnvironment ?? throw new ArgumentNullException(nameof(webHostEnvironment)); 28 | } 29 | 30 | /// 31 | /// Display each application configuration property, along with its source. 32 | /// 33 | /// 34 | [HttpGet] 35 | [AllowAnonymous] 36 | public ActionResult GetConfigurationDebugView() 37 | { 38 | bool isDebugViewEnabled = configurationRoot.GetValue("ConfigurationDebugViewEndpointEnabled"); 39 | 40 | if (!webHostEnvironment.IsDevelopment() || !isDebugViewEnabled) 41 | { 42 | return Forbid(); 43 | } 44 | 45 | return new ObjectResult(configurationRoot.GetDebugView()); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Serilog/Destructuring/TodoItemQueryDestructuringPolicy.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Serilog.Destructuring 2 | { 3 | using System.Collections.Generic; 4 | 5 | using global::Serilog.Core; 6 | using global::Serilog.Events; 7 | 8 | using Services.Security; 9 | using Services.TodoItemManagement; 10 | 11 | /// 12 | /// Instructs Serilog how to log instances of class. 13 | /// 14 | // ReSharper disable once UnusedType.Global 15 | public class TodoItemQueryDestructuringPolicy : IDestructuringPolicy 16 | { 17 | public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, 18 | out LogEventPropertyValue result) 19 | { 20 | if (value is TodoItemQuery todoItemQuery) 21 | { 22 | result = new StructureValue(new List 23 | { 24 | new(nameof(todoItemQuery.Id), new ScalarValue(todoItemQuery.Id)), 25 | new(nameof(todoItemQuery.NamePattern), new ScalarValue(todoItemQuery.NamePattern)), 26 | new(nameof(todoItemQuery.IsComplete), new ScalarValue(todoItemQuery.IsComplete)), 27 | new(nameof(todoItemQuery.Owner),new ScalarValue(todoItemQuery.Owner.GetNameOrDefault())), 28 | new(nameof(todoItemQuery.PageIndex), new ScalarValue(todoItemQuery.PageIndex)), 29 | new(nameof(todoItemQuery.PageSize), new ScalarValue(todoItemQuery.PageSize)), 30 | new(nameof(todoItemQuery.SortBy), new ScalarValue(todoItemQuery.SortBy)), 31 | new(nameof(todoItemQuery.IsSortAscending), new ScalarValue(todoItemQuery.IsSortAscending)) 32 | }); 33 | 34 | return true; 35 | } 36 | 37 | result = null; 38 | return false; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Todo.Services/TodoItemManagement/ITodoItemService.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services.TodoItemManagement 2 | { 3 | using System.Collections.Generic; 4 | using System.Diagnostics.CodeAnalysis; 5 | using System.Threading.Tasks; 6 | 7 | /// 8 | /// Manages the lifecycle of todo items. 9 | /// 10 | [SuppressMessage("ReSharper", "S1135", Justification = "The todo word represents an entity")] 11 | public interface ITodoItemService 12 | { 13 | /// 14 | /// Fetches all todo items found inside the underlying persistent storage which match a given set of conditions. 15 | /// 16 | /// The conditions to use when matching todo items. 17 | /// A list of todo items matching the given conditions. 18 | Task> GetByQueryAsync(TodoItemQuery todoItemQuery); 19 | 20 | /// 21 | /// Persists a new todo item to the underlying persistent storage. 22 | /// 23 | /// The template used for creating the new todo item. 24 | /// The identified of the new todo item. 25 | Task AddAsync(NewTodoItemInfo newTodoItemInfo); 26 | 27 | /// 28 | /// Updates an existing todo item found inside the underlying persistent storage. 29 | /// 30 | /// The template used for updating the existing todo item. 31 | Task UpdateAsync(UpdateTodoItemInfo updateTodoItemInfo); 32 | 33 | /// 34 | /// Removes an existing todo item from the underlying persistent storage. 35 | /// 36 | /// The template used for removing the existing todo item. 37 | Task DeleteAsync(DeleteTodoItemInfo deleteTodoItemInfo); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Http/HttpLoggingActivator.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Http 2 | { 3 | using System; 4 | 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.Extensions.Configuration; 7 | 8 | /// 9 | /// Contains extension methods used for enabling HTTP logging for this application. 10 | /// 11 | public static class HttpLoggingActivator 12 | { 13 | /// 14 | /// Enables logging each HTTP request and its response inside the current ASP.NET Core request processing 15 | /// pipeline. 16 | /// 17 | /// Configures ASP.NET Core request processing pipeline. 18 | /// The application configuration. 19 | /// The given instance. 20 | /// Thrown when either 21 | /// or is null. 22 | public static IApplicationBuilder UseHttpLogging(this IApplicationBuilder applicationBuilder, 23 | IConfiguration configuration) 24 | { 25 | if (applicationBuilder == null) 26 | { 27 | throw new ArgumentNullException(nameof(applicationBuilder)); 28 | } 29 | 30 | if (configuration == null) 31 | { 32 | throw new ArgumentNullException(nameof(configuration)); 33 | } 34 | 35 | // ReSharper disable once SettingNotFoundInConfiguration 36 | bool isHttpLoggingEnabled = configuration.GetValue("HttpLogging:Enabled"); 37 | 38 | if (isHttpLoggingEnabled) 39 | { 40 | applicationBuilder.UseMiddleware(); 41 | } 42 | 43 | return applicationBuilder; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.WebApi.UnitTests/ExceptionHandling/ExceptionMappingResultsTests.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.ExceptionHandling 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Transactions; 6 | 7 | using FluentAssertions; 8 | 9 | using Npgsql; 10 | 11 | using NUnit.Framework; 12 | 13 | using Persistence.Entities; 14 | 15 | using Services.TodoItemManagement; 16 | 17 | /// 18 | /// Contains unit tests targeting class. 19 | /// 20 | [TestFixture] 21 | public class ExceptionMappingResultsTests 22 | { 23 | [Test] 24 | [TestCaseSource(nameof(GetExceptions))] 25 | public void GetMappingResult_WhenUsingKnownExceptionType_ReturnsExpectedResult(Exception exception, ExceptionMappingResult expectedResult) 26 | { 27 | // Act 28 | ExceptionMappingResult actualResult = ExceptionMappingResults.GetMappingResult(exception); 29 | 30 | // Assert 31 | actualResult.Should().Be(expectedResult); 32 | } 33 | 34 | private static IEnumerable GetExceptions() 35 | { 36 | return new List 37 | { 38 | new object[]{ null, ExceptionMappingResults.GenericError }, 39 | new object[]{ new Exception("Hard-coded exception"), ExceptionMappingResults.GenericError }, 40 | new object[]{ new EntityNotFoundException(typeof(TodoItem), "test-entity-key"), ExceptionMappingResults.EntityNotFound }, 41 | new object[]{ new NpgsqlException(), ExceptionMappingResults.DatabaseError }, 42 | new object[]{ new Exception("Hard-coded exception with a cause", new NpgsqlException()), ExceptionMappingResults.DatabaseError }, 43 | new object[]{ new TransactionException("Hard-coded exception"), ExceptionMappingResults.DatabaseError } 44 | }; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/ModuleInitializer.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi 2 | { 3 | using System.Runtime.CompilerServices; 4 | using System.Text.RegularExpressions; 5 | 6 | using VerifyTests; 7 | 8 | public static partial class ModuleInitializer 9 | { 10 | private static readonly Regex RegexForBearerToken = GetRegexForBearerToken(); 11 | private static readonly Regex RegexForTodoItemDirectUrl = GetRegexForTodoItemDirectUrl(); 12 | 13 | internal static readonly VerifySettings VerifySettings = new(); 14 | 15 | [ModuleInitializer] 16 | public static void Initialize() 17 | { 18 | VerifierSettings.InitializePlugins(); 19 | Recording.Start(); 20 | 21 | VerifySettings.UseDirectory("VerifySnapshots"); 22 | VerifySettings.ScrubEmptyLines(); 23 | VerifySettings.ScrubInlineGuids(); 24 | VerifySettings.ScrubMember("TraceId"); 25 | VerifySettings.ScrubMember("traceId"); 26 | VerifySettings.ScrubLinesWithReplace 27 | ( 28 | line => 29 | { 30 | if (RegexForBearerToken.IsMatch(line)) 31 | { 32 | return "Bearer "; 33 | } 34 | 35 | if (RegexForTodoItemDirectUrl.IsMatch(line)) 36 | { 37 | return "http://localhost/api/todo/"; 38 | } 39 | 40 | return line; 41 | } 42 | ); 43 | } 44 | 45 | [GeneratedRegex(@"Bearer [a-z0-9\.]+", RegexOptions.Compiled | RegexOptions.IgnoreCase)] 46 | private static partial Regex GetRegexForBearerToken(); 47 | 48 | [GeneratedRegex(@"http://localhost/api/todo/\d+", RegexOptions.Compiled | RegexOptions.Singleline)] 49 | private static partial Regex GetRegexForTodoItemDirectUrl(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/appsettings.AcceptanceTests.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "AcceptanceTests": "" 4 | }, 5 | "MigrateDatabase": true, 6 | "DeleteDatabaseBeforeRunningMigrations": true, 7 | "GenerateJwt": { 8 | "Issuer": "https://acceptancetests.auth.todo-by-satrapu.com", 9 | "Audience": "https://acceptancetests.api.todo-by-satrapu.com" 10 | }, 11 | "Serilog": { 12 | "LevelSwitches": { 13 | "$controlSwitch": "Debug" 14 | }, 15 | "Using": [ 16 | "Serilog.Sinks.Console", 17 | "Serilog.Sinks.File" 18 | ], 19 | "WriteTo": [ 20 | { 21 | "Name": "Console", 22 | "Args": { 23 | "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console", 24 | "outputTemplate": "{Timestamp:HH:mm:ss.fff} {Level:u3} | trid: {TraceId} sid: {SpanId} pid: {ParentId} cid:{ConversationId} fid:{ApplicationFlowName} thid:{ThreadId} | {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}" 25 | } 26 | }, 27 | { 28 | "Name": "File", 29 | "Args": { 30 | "path": "%LOGS_HOME%/todo-web-api.acceptance-tests.log", 31 | "outputTemplate": "{Timestamp:HH:mm:ss.fff} {Level:u3} | trid: {TraceId} sid: {SpanId} pid: {ParentId} cid:{ConversationId} fid:{ApplicationFlowName} thid:{ThreadId} | {SourceContext}{NewLine}{Message:lj}{NewLine}{Properties}{NewLine}{Exception}{NewLine}" 32 | } 33 | } 34 | ], 35 | "Properties": { 36 | "Application": "Todo.WebApi.AcceptanceTests" 37 | } 38 | }, 39 | "ExceptionHandling": { 40 | "IncludeDetails": true 41 | }, 42 | "HttpLogging": { 43 | "Enabled": true 44 | }, 45 | "OpenTelemetry": { 46 | "Enabled": false 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/DependencyInjection/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Persistence.DependencyInjection 2 | { 3 | using System; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | using Microsoft.EntityFrameworkCore; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Microsoft.Extensions.Diagnostics.HealthChecks; 11 | 12 | public static class ServiceCollectionExtensions 13 | { 14 | private static readonly TimeSpan MaxWaitTimeForDbContextHealthCheck = TimeSpan.FromSeconds(2); 15 | 16 | public static IServiceCollection AddPersistenceHealthChecks(this IServiceCollection services) 17 | { 18 | ArgumentNullException.ThrowIfNull(services); 19 | 20 | return 21 | services 22 | .AddHealthChecks() 23 | .AddDbContextCheck 24 | ( 25 | name: "Persistent storage", 26 | failureStatus: HealthStatus.Unhealthy, 27 | customTestQuery: (todoDbContext, _) => IsTodoDbContextHealthyAsync(todoDbContext) 28 | ) 29 | .Services; 30 | } 31 | 32 | private static async Task IsTodoDbContextHealthyAsync(TodoDbContext todoDbContext) 33 | { 34 | using CancellationTokenSource cancellationTokenSource = new(delay: MaxWaitTimeForDbContextHealthCheck); 35 | 36 | try 37 | { 38 | await 39 | todoDbContext.TodoItems 40 | .Select(x => x.Id) 41 | .FirstOrDefaultAsync(cancellationToken: cancellationTokenSource.Token) 42 | .WaitAsync(timeout: MaxWaitTimeForDbContextHealthCheck); 43 | 44 | return true; 45 | } 46 | catch 47 | { 48 | return false; 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Todo.WebApi.IntegrationTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | Library 5 | Todo.WebApi 6 | 10 | true 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | all 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | true 38 | 39 | [Todo.WebApi.TestInfrastructure]* 40 | 41 | [*]* 42 | false 43 | 44 | 45 | -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Http/ConversationIdProviderMiddleware.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Http 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Threading.Tasks; 6 | 7 | using Commons.Constants; 8 | 9 | using Microsoft.AspNetCore.Http; 10 | using Microsoft.Extensions.Logging; 11 | using Microsoft.Extensions.Primitives; 12 | 13 | /// 14 | /// Provides conversation IDs to each request to allow grouping them into conversations. 15 | /// 16 | // ReSharper disable once ClassNeverInstantiated.Global 17 | public class ConversationIdProviderMiddleware 18 | { 19 | private readonly RequestDelegate nextRequestDelegate; 20 | private readonly ILogger logger; 21 | 22 | public ConversationIdProviderMiddleware(RequestDelegate nextRequestDelegate, 23 | ILogger logger) 24 | { 25 | this.nextRequestDelegate = 26 | nextRequestDelegate ?? throw new ArgumentNullException(nameof(nextRequestDelegate)); 27 | 28 | this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); 29 | } 30 | 31 | public async Task Invoke(HttpContext httpContext) 32 | { 33 | string conversationIdKey = Logging.ConversationId; 34 | 35 | if (!httpContext.Request.Headers.TryGetValue(conversationIdKey, out StringValues conversationId) 36 | || string.IsNullOrWhiteSpace(conversationId)) 37 | { 38 | conversationId = Guid.NewGuid().ToString("N"); 39 | httpContext.Request.Headers.Append(conversationIdKey, conversationId); 40 | } 41 | 42 | httpContext.Response.Headers.Append(conversationIdKey, conversationId); 43 | 44 | using (logger.BeginScope(new Dictionary 45 | { 46 | [conversationIdKey] = conversationId.ToString() 47 | })) 48 | { 49 | await nextRequestDelegate(httpContext); 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/Todo.Services/Security/JwtService.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services.Security 2 | { 3 | using System; 4 | using System.IdentityModel.Tokens.Jwt; 5 | using System.Security.Claims; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | 9 | using Microsoft.IdentityModel.Tokens; 10 | 11 | /// 12 | /// An implementation. 13 | ///
14 | /// Based on: https://dotnetcoretutorials.com/2020/01/15/creating-and-validating-jwt-tokens-in-asp-net-core/. 15 | ///
16 | public class JwtService : IJwtService 17 | { 18 | public async Task GenerateJwtAsync(GenerateJwtInfo generateJwtInfo) 19 | { 20 | byte[] userNameAsBytes = Encoding.UTF8.GetBytes(generateJwtInfo.UserName); 21 | string userNameAsBase64 = Convert.ToBase64String(userNameAsBytes); 22 | SymmetricSecurityKey symmetricSecurityKey = new(Encoding.UTF8.GetBytes(generateJwtInfo.Secret)); 23 | 24 | SecurityTokenDescriptor securityTokenDescriptor = new() 25 | { 26 | Audience = generateJwtInfo.Audience, 27 | Issuer = generateJwtInfo.Issuer, 28 | Expires = DateTime.UtcNow.AddMonths(6), 29 | SigningCredentials = new SigningCredentials(symmetricSecurityKey, SecurityAlgorithms.HmacSha256Signature), 30 | Subject = new ClaimsIdentity 31 | ( 32 | claims: 33 | [ 34 | new Claim(ClaimTypes.NameIdentifier, userNameAsBase64), 35 | new Claim("scope", string.Join(separator: ' ', generateJwtInfo.Scopes ?? [])) 36 | ] 37 | ) 38 | }; 39 | 40 | JwtSecurityTokenHandler jwtSecurityTokenHandler = new(); 41 | SecurityToken securityToken = jwtSecurityTokenHandler.CreateToken(securityTokenDescriptor); 42 | 43 | JwtInfo jwtInfo = new() 44 | { 45 | AccessToken = jwtSecurityTokenHandler.WriteToken(securityToken) 46 | }; 47 | 48 | return await Task.FromResult(jwtInfo); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Todo.WebApi/ExceptionHandling/ExceptionMappingResults.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.ExceptionHandling 2 | { 3 | using System; 4 | using System.Net; 5 | using System.Transactions; 6 | 7 | using Npgsql; 8 | 9 | using Services.TodoItemManagement; 10 | 11 | /// 12 | /// Maps instances to instances. 13 | /// 14 | public static class ExceptionMappingResults 15 | { 16 | public static readonly ExceptionMappingResult EntityNotFound = 17 | new(HttpStatusCode.NotFound, "entity-not-found"); 18 | 19 | public static readonly ExceptionMappingResult DatabaseError = 20 | new(HttpStatusCode.ServiceUnavailable, "database-error"); 21 | 22 | public static readonly ExceptionMappingResult GenericError = 23 | new(HttpStatusCode.InternalServerError, "internal-server-error"); 24 | 25 | /// 26 | /// Maps the given to an instance. 27 | /// 28 | /// The instance to map. 29 | /// An instance. 30 | public static ExceptionMappingResult GetMappingResult(Exception exception) 31 | { 32 | return exception switch 33 | { 34 | EntityNotFoundException _ => EntityNotFound, 35 | 36 | // Return HTTP status code 503 in case calling the underlying database resulted in an exception. 37 | // See more here: https://stackoverflow.com/q/1434315. 38 | NpgsqlException _ => DatabaseError, 39 | 40 | // Also return HTTP status code 503 in case the inner exception was thrown by a call made against the 41 | // underlying database. 42 | { InnerException: NpgsqlException _ } => DatabaseError, 43 | 44 | TransactionException _ => DatabaseError, 45 | 46 | // Fall-back to HTTP status code 500. 47 | _ => GenericError 48 | }; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Todo.Services/Security/PrincipalExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services.Security 2 | { 3 | using System; 4 | using System.Security.Principal; 5 | 6 | /// 7 | /// Contains extension methods applicable to instances. 8 | /// 9 | public static class PrincipalExtensions 10 | { 11 | /// 12 | /// Gets the name associated with the given instance. 13 | /// 14 | /// The instance whose name is to be fetched. 15 | /// The name associated with the given . 16 | /// Thrown in case the given is null 17 | /// or its property is null. 18 | public static string GetName(this IPrincipal principal) 19 | { 20 | if (principal == null) 21 | { 22 | throw new ArgumentNullException(nameof(principal)); 23 | } 24 | 25 | if (principal.Identity == null) 26 | { 27 | throw new ArgumentException("Principal identity cannot be null", nameof(principal)); 28 | } 29 | 30 | return principal.Identity.Name; 31 | } 32 | 33 | /// 34 | /// Gets the name associated with the given instance or a default value, 35 | /// in case is null. 36 | /// 37 | /// The instance whose name is to be fetched. 38 | /// The default value to use in case is null. 39 | /// The name associated with the given . 40 | public static string GetNameOrDefault(this IPrincipal principal, string defaultName = "") 41 | { 42 | if (principal == null) 43 | { 44 | return defaultName; 45 | } 46 | 47 | return GetName(principal); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.Services.UnitTests/Security/JwtServiceTests.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services.Security 2 | { 3 | using System.IdentityModel.Tokens.Jwt; 4 | using System.Threading.Tasks; 5 | 6 | using FluentAssertions; 7 | using FluentAssertions.Execution; 8 | 9 | using NUnit.Framework; 10 | 11 | using VerifyNUnit; 12 | 13 | using VerifyTests; 14 | 15 | /// 16 | /// Contains unit tests targeting class. 17 | /// 18 | [TestFixture] 19 | public class JwtServiceTests 20 | { 21 | [Test] 22 | public async Task GenerateJwtAsync_WhenUsingValidInput_MustReturnExpectedResult() 23 | { 24 | // Arrange 25 | VerifySettings verifySettings = new(ModuleInitializer.VerifySettings); 26 | verifySettings.ScrubMember("nbf"); 27 | verifySettings.ScrubMember("exp"); 28 | verifySettings.ScrubMember("iat"); 29 | verifySettings.ScrubMember("EncodedPayload"); 30 | verifySettings.ScrubMember("RawData"); 31 | verifySettings.ScrubMember("RawPayload"); 32 | verifySettings.ScrubMember("RawSignature"); 33 | 34 | GenerateJwtInfo generateJwtInfo = new() 35 | { 36 | UserName = "some-test-user", 37 | Password = "some-password", 38 | Scopes = ["resource1", "resource2"], 39 | Audience = "test-audience", 40 | Issuer = "test", 41 | Secret = "!z%*mEPs>_[9`MZ\"P:@rm%#zYnGA=HOn 14 | /// Configures business related services used by this application. 15 | /// 16 | public class ServicesModule : Module 17 | { 18 | /// 19 | /// Gets or sets the name of the environment where this application runs. 20 | /// 21 | public string EnvironmentName { get; init; } 22 | 23 | protected override void Load(ContainerBuilder builder) 24 | { 25 | bool isDevelopmentEnvironment = EnvironmentNames.Development.Equals(EnvironmentName); 26 | bool isIntegrationTestsEnvironment = EnvironmentNames.IntegrationTests.Equals(EnvironmentName); 27 | bool isAcceptanceTestsEnvironment = EnvironmentNames.AcceptanceTests.Equals(EnvironmentName); 28 | 29 | PersistenceModule persistenceModule = new() 30 | { 31 | ConnectionStringName = GetConnectionStringNameByEnvironment(EnvironmentName), 32 | EnableDetailedErrors = isDevelopmentEnvironment || isIntegrationTestsEnvironment || isAcceptanceTestsEnvironment, 33 | EnableSensitiveDataLogging = isDevelopmentEnvironment || isIntegrationTestsEnvironment || isAcceptanceTestsEnvironment 34 | }; 35 | 36 | builder.RegisterModule(persistenceModule); 37 | 38 | builder 39 | .RegisterType() 40 | .As() 41 | .SingleInstance(); 42 | 43 | builder 44 | .RegisterType() 45 | .As() 46 | .InstancePerLifetimeScope(); 47 | } 48 | 49 | private static string GetConnectionStringNameByEnvironment(string environmentName) 50 | { 51 | return environmentName switch 52 | { 53 | EnvironmentNames.AcceptanceTests => ConnectionStrings.UsedByAcceptanceTests, 54 | EnvironmentNames.IntegrationTests => ConnectionStrings.UsedByIntegrationTests, 55 | _ => ConnectionStrings.UsedByApplication 56 | }; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.sonarlint/aspnet-core-logging/CSharp/SonarLint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | sonar.cs.analyzeGeneratedCode 6 | false 7 | 8 | 9 | sonar.cs.file.suffixes 10 | .cs 11 | 12 | 13 | sonar.cs.ignoreHeaderComments 14 | true 15 | 16 | 17 | sonar.cs.roslyn.ignoreIssues 18 | false 19 | 20 | 21 | 22 | 23 | S107 24 | 25 | 26 | max 27 | 7 28 | 29 | 30 | 31 | 32 | S110 33 | 34 | 35 | max 36 | 5 37 | 38 | 39 | 40 | 41 | S1479 42 | 43 | 44 | maximum 45 | 30 46 | 47 | 48 | 49 | 50 | S2342 51 | 52 | 53 | flagsAttributeFormat 54 | ^([A-Z]{1,3}[a-z0-9]+)*([A-Z]{2})?s$ 55 | 56 | 57 | format 58 | ^([A-Z]{1,3}[a-z0-9]+)*([A-Z]{2})?$ 59 | 60 | 61 | 62 | 63 | S2436 64 | 65 | 66 | max 67 | 2 68 | 69 | 70 | maxMethod 71 | 3 72 | 73 | 74 | 75 | 76 | S3776 77 | 78 | 79 | propertyThreshold 80 | 3 81 | 82 | 83 | threshold 84 | 15 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Migrations/20200124210404_InitialSchema.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using Todo.Persistence; 9 | 10 | namespace Todo.Persistence.Migrations 11 | { 12 | [DbContext(typeof(TodoDbContext))] 13 | [Migration("20200124210404_InitialSchema")] 14 | partial class InitialSchema 15 | { 16 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) 21 | .HasAnnotation("ProductVersion", "3.1.1") 22 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 23 | 24 | modelBuilder.Entity("Todo.Persistence.Entities.TodoItem", b => 25 | { 26 | b.Property("Id") 27 | .ValueGeneratedOnAdd() 28 | .HasColumnType("bigint") 29 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); 30 | 31 | b.Property("CreatedBy") 32 | .IsRequired() 33 | .HasColumnType("character varying(100)") 34 | .HasMaxLength(100); 35 | 36 | b.Property("CreatedOn") 37 | .HasColumnType("timestamp without time zone"); 38 | 39 | b.Property("IsComplete") 40 | .HasColumnType("boolean"); 41 | 42 | b.Property("LastUpdatedBy") 43 | .HasColumnType("character varying(100)") 44 | .HasMaxLength(100); 45 | 46 | b.Property("LastUpdatedOn") 47 | .HasColumnType("timestamp without time zone"); 48 | 49 | b.Property("Name") 50 | .IsRequired() 51 | .HasColumnType("character varying(100)") 52 | .HasMaxLength(100); 53 | 54 | b.HasKey("Id"); 55 | 56 | b.ToTable("TodoItems"); 57 | }); 58 | #pragma warning restore 612, 618 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/TodoControllerTests.UpdateAsync_UsingNewlyCreatedTodoItem_ReturnsExpectedResult.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: NoContent, 7 | ReasonPhrase: No Content, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | } 14 | ], 15 | TrailingHeaders: [], 16 | RequestMessage: { 17 | Version: 1.1, 18 | Content: { 19 | ObjectType: UpdateTodoItemModel, 20 | Formatter: { 21 | UseDataContractJsonSerializer: false, 22 | Indent: false, 23 | MaxDepth: 256, 24 | SerializerSettings: { 25 | ContractResolver: { 26 | DynamicCodeGeneration: false, 27 | SerializeCompilerGeneratedMembers: false, 28 | IgnoreSerializableInterface: false, 29 | IgnoreSerializableAttribute: false, 30 | IgnoreIsSpecifiedMembers: false, 31 | IgnoreShouldSerializeMembers: false 32 | }, 33 | DateFormatString: yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK, 34 | MaxDepth: 64, 35 | DateTimeZoneHandling: RoundtripKind, 36 | DateParseHandling: DateTime, 37 | Culture: (Default), 38 | CheckAdditionalContent: false 39 | }, 40 | SupportedMediaTypes: [ 41 | { 42 | MediaType: application/json 43 | }, 44 | { 45 | MediaType: text/json 46 | } 47 | ], 48 | SupportedEncodings: [ 49 | utf-8, 50 | utf-16 51 | ], 52 | MediaTypeMappings: [ 53 | { 54 | HeaderName: x-requested-with, 55 | HeaderValue: XMLHttpRequest, 56 | HeaderValueComparison: OrdinalIgnoreCase, 57 | IsValueSubstring: true, 58 | MediaType: { 59 | MediaType: application/json 60 | } 61 | } 62 | ] 63 | }, 64 | Value: { 65 | Name: CHANGED--it--Guid_2, 66 | IsComplete: false 67 | }, 68 | Headers: [ 69 | { 70 | Content-Type: [ 71 | application/json; charset=utf-8 72 | ] 73 | }, 74 | { 75 | Content-Length: [ 76 | 79 77 | ] 78 | } 79 | ] 80 | }, 81 | Method: { 82 | Method: PUT 83 | }, 84 | RequestUri: {Scrubbed}, 85 | Headers: [ 86 | { 87 | Authorization: [ 88 | Bearer 89 | ] 90 | } 91 | ] 92 | }, 93 | IsSuccessStatusCode: true 94 | } -------------------------------------------------------------------------------- /Sources/Todo.Services/TodoItemManagement/TodoItemQuery.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Services.TodoItemManagement 2 | { 3 | using System.ComponentModel.DataAnnotations; 4 | using System.Diagnostics.CodeAnalysis; 5 | using System.Security.Principal; 6 | 7 | [SuppressMessage("ReSharper", "S1135", Justification = "The todo word represents an entity")] 8 | public class TodoItemQuery 9 | { 10 | public const int DefaultPageIndex = 0; 11 | public const int DefaultPageSize = 25; 12 | 13 | /// 14 | /// Gets or sets the id of the todo item to be fetched using this query. 15 | /// 16 | public long? Id { get; set; } 17 | 18 | /// 19 | /// Gets or sets the pattern the name of the todo items must match to be fetched using this query. 20 | ///
21 | /// This pattern may contain wild-cards - see more here: 22 | /// https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbfunctionsextensions.like?view=efcore-7.0. 23 | ///
24 | public string NamePattern { get; set; } 25 | 26 | /// 27 | /// Gets or sets whether this query will fetch todo items which have been completed. 28 | /// 29 | public bool? IsComplete { get; set; } 30 | 31 | /// 32 | /// Gets or sets the user who has created the todo items to be fetched using this query. 33 | /// 34 | [Required] 35 | public IPrincipal Owner { get; set; } 36 | 37 | /// 38 | /// Gets or sets the maximum number of todo items to be fetched using this query. 39 | /// 40 | [Required] 41 | [Range(1, 1000)] 42 | public int? PageSize { get; set; } = DefaultPageSize; 43 | 44 | /// 45 | /// Gets or sets the 0-based index of the current batch of todo items to be fetched using this query. 46 | /// 47 | [Required] 48 | [Range(0, int.MaxValue)] 49 | public int? PageIndex { get; set; } = DefaultPageIndex; 50 | 51 | /// 52 | /// Gets or sets the property name used for sorting the todo items to be fetched using this query. 53 | /// 54 | public string SortBy { get; set; } 55 | 56 | /// 57 | /// Gets or sets whether the todo items to be fetched using this query will be sorted using 58 | /// the property in an ascending order. 59 | /// 60 | public bool? IsSortAscending { get; set; } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/TodoControllerTests.CreateAsync_UsingInvalidTodoItem_ReturnsExpectedResult.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [ 5 | { 6 | Content-Type: [ 7 | application/problem+json; charset=utf-8 8 | ] 9 | } 10 | ] 11 | }, 12 | StatusCode: UnprocessableEntity, 13 | ReasonPhrase: Unprocessable Entity, 14 | Headers: [ 15 | { 16 | ConversationId: [ 17 | Guid_1 18 | ] 19 | } 20 | ], 21 | TrailingHeaders: [], 22 | RequestMessage: { 23 | Version: 1.1, 24 | Content: { 25 | ObjectType: NewTodoItemModel, 26 | Formatter: { 27 | UseDataContractJsonSerializer: false, 28 | Indent: false, 29 | MaxDepth: 256, 30 | SerializerSettings: { 31 | ContractResolver: { 32 | DynamicCodeGeneration: false, 33 | SerializeCompilerGeneratedMembers: false, 34 | IgnoreSerializableInterface: false, 35 | IgnoreSerializableAttribute: false, 36 | IgnoreIsSpecifiedMembers: false, 37 | IgnoreShouldSerializeMembers: false 38 | }, 39 | DateFormatString: yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK, 40 | MaxDepth: 64, 41 | DateTimeZoneHandling: RoundtripKind, 42 | DateParseHandling: DateTime, 43 | Culture: (Default), 44 | CheckAdditionalContent: false 45 | }, 46 | SupportedMediaTypes: [ 47 | { 48 | MediaType: application/json 49 | }, 50 | { 51 | MediaType: text/json 52 | } 53 | ], 54 | SupportedEncodings: [ 55 | utf-8, 56 | utf-16 57 | ], 58 | MediaTypeMappings: [ 59 | { 60 | HeaderName: x-requested-with, 61 | HeaderValue: XMLHttpRequest, 62 | HeaderValueComparison: OrdinalIgnoreCase, 63 | IsValueSubstring: true, 64 | MediaType: { 65 | MediaType: application/json 66 | } 67 | } 68 | ] 69 | }, 70 | Value: {}, 71 | Headers: [ 72 | { 73 | Content-Type: [ 74 | application/json; charset=utf-8 75 | ] 76 | }, 77 | { 78 | Content-Length: [ 79 | 31 80 | ] 81 | } 82 | ] 83 | }, 84 | Method: { 85 | Method: POST 86 | }, 87 | RequestUri: http://localhost/api/todo, 88 | Headers: [ 89 | { 90 | Authorization: [ 91 | Bearer 92 | ] 93 | } 94 | ] 95 | }, 96 | IsSuccessStatusCode: false 97 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/ConfigurationControllerTests.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.Controllers 2 | { 3 | using System.Collections.Generic; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Threading.Tasks; 7 | 8 | using Commons.Constants; 9 | 10 | using NUnit.Framework; 11 | 12 | using TestInfrastructure; 13 | 14 | using VerifyNUnit; 15 | 16 | /// 17 | /// Contains integration tests targeting class. 18 | /// 19 | [TestFixture] 20 | public class ConfigurationControllerTests 21 | { 22 | [Test] 23 | [TestCaseSource(nameof(GetConfigurationEndpointContext))] 24 | public async Task GetConfigurationDebugView_WhenCalled_MustBehaveAsExpected(string environmentName, HttpStatusCode expectedStatusCode) 25 | { 26 | // Arrange 27 | await using TestWebApplicationFactory testWebApplicationFactory = await TestWebApplicationFactory.CreateAsync 28 | ( 29 | applicationName: nameof(ConfigurationControllerTests), 30 | environmentName, 31 | shouldRunStartupLogicTasks: false 32 | ); 33 | 34 | using HttpClient httpClient = testWebApplicationFactory.CreateClient(); 35 | 36 | // Act 37 | using HttpResponseMessage response = await httpClient.GetAsync("api/configuration"); 38 | 39 | // Assert 40 | await Verifier.Verify(response, settings: ModuleInitializer.VerifySettings); 41 | } 42 | 43 | private static IEnumerable GetConfigurationEndpointContext() 44 | { 45 | yield return 46 | [ 47 | EnvironmentNames.Development, 48 | HttpStatusCode.OK 49 | ]; 50 | 51 | yield return 52 | [ 53 | EnvironmentNames.IntegrationTests, 54 | HttpStatusCode.Forbidden 55 | ]; 56 | 57 | yield return 58 | [ 59 | EnvironmentNames.AcceptanceTests, 60 | HttpStatusCode.Forbidden 61 | ]; 62 | 63 | yield return 64 | [ 65 | EnvironmentNames.DemoInAzure, 66 | HttpStatusCode.Forbidden 67 | ]; 68 | 69 | yield return 70 | [ 71 | EnvironmentNames.Staging, 72 | HttpStatusCode.Forbidden 73 | ]; 74 | 75 | yield return 76 | [ 77 | EnvironmentNames.Production, 78 | HttpStatusCode.Forbidden 79 | ]; 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Migrations/20200124210915_AddIndexForNameColumnInsideTodoItemsTable.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using Todo.Persistence; 9 | 10 | namespace Todo.Persistence.Migrations 11 | { 12 | [DbContext(typeof(TodoDbContext))] 13 | [Migration("20200124210915_AddIndexForNameColumnInsideTodoItemsTable")] 14 | partial class AddIndexForNameColumnInsideTodoItemsTable 15 | { 16 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) 21 | .HasAnnotation("ProductVersion", "3.1.1") 22 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 23 | 24 | modelBuilder.Entity("Todo.Persistence.Entities.TodoItem", b => 25 | { 26 | b.Property("Id") 27 | .ValueGeneratedOnAdd() 28 | .HasColumnType("bigint") 29 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); 30 | 31 | b.Property("CreatedBy") 32 | .IsRequired() 33 | .HasColumnType("character varying(100)") 34 | .HasMaxLength(100); 35 | 36 | b.Property("CreatedOn") 37 | .HasColumnType("timestamp without time zone"); 38 | 39 | b.Property("IsComplete") 40 | .HasColumnType("boolean"); 41 | 42 | b.Property("LastUpdatedBy") 43 | .HasColumnType("character varying(100)") 44 | .HasMaxLength(100); 45 | 46 | b.Property("LastUpdatedOn") 47 | .HasColumnType("timestamp without time zone"); 48 | 49 | b.Property("Name") 50 | .IsRequired() 51 | .HasColumnType("character varying(100)") 52 | .HasMaxLength(100); 53 | 54 | b.HasKey("Id"); 55 | 56 | b.HasIndex("Name"); 57 | 58 | b.ToTable("TodoItems"); 59 | }); 60 | #pragma warning restore 612, 618 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Todo.WebApi.IntegrationTests/Controllers/VerifySnapshots/TodoControllerTests.CreateAsync_UsingValidTodoItemReturnsExpectedResult.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Content: { 4 | Headers: [] 5 | }, 6 | StatusCode: Created, 7 | ReasonPhrase: Created, 8 | Headers: [ 9 | { 10 | ConversationId: [ 11 | Guid_1 12 | ] 13 | }, 14 | { 15 | Location: [ 16 | http://localhost/api/todo/ 17 | ] 18 | } 19 | ], 20 | TrailingHeaders: [], 21 | RequestMessage: { 22 | Version: 1.1, 23 | Content: { 24 | ObjectType: NewTodoItemModel, 25 | Formatter: { 26 | UseDataContractJsonSerializer: false, 27 | Indent: false, 28 | MaxDepth: 256, 29 | SerializerSettings: { 30 | ContractResolver: { 31 | DynamicCodeGeneration: false, 32 | SerializeCompilerGeneratedMembers: false, 33 | IgnoreSerializableInterface: false, 34 | IgnoreSerializableAttribute: false, 35 | IgnoreIsSpecifiedMembers: false, 36 | IgnoreShouldSerializeMembers: false 37 | }, 38 | DateFormatString: yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK, 39 | MaxDepth: 64, 40 | DateTimeZoneHandling: RoundtripKind, 41 | DateParseHandling: DateTime, 42 | Culture: (Default), 43 | CheckAdditionalContent: false 44 | }, 45 | SupportedMediaTypes: [ 46 | { 47 | MediaType: application/json 48 | }, 49 | { 50 | MediaType: text/json 51 | } 52 | ], 53 | SupportedEncodings: [ 54 | utf-8, 55 | utf-16 56 | ], 57 | MediaTypeMappings: [ 58 | { 59 | HeaderName: x-requested-with, 60 | HeaderValue: XMLHttpRequest, 61 | HeaderValueComparison: OrdinalIgnoreCase, 62 | IsValueSubstring: true, 63 | MediaType: { 64 | MediaType: application/json 65 | } 66 | } 67 | ] 68 | }, 69 | Value: { 70 | Name: it--Guid_2, 71 | IsComplete: true 72 | }, 73 | Headers: [ 74 | { 75 | Content-Type: [ 76 | application/json; charset=utf-8 77 | ] 78 | }, 79 | { 80 | Content-Length: [ 81 | 69 82 | ] 83 | } 84 | ] 85 | }, 86 | Method: { 87 | Method: POST 88 | }, 89 | RequestUri: http://localhost/api/todo, 90 | Headers: [ 91 | { 92 | Authorization: [ 93 | Bearer 94 | ] 95 | } 96 | ] 97 | }, 98 | IsSuccessStatusCode: true 99 | } -------------------------------------------------------------------------------- /Sources/Todo.Telemetry/Serilog/FileSinkMetadataLogger.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Telemetry.Serilog 2 | { 3 | using System; 4 | using System.Threading.Tasks; 5 | using System.Security.Principal; 6 | 7 | using ApplicationFlows; 8 | 9 | using Commons.Constants; 10 | using Commons.StartupLogic; 11 | 12 | using Microsoft.Extensions.Configuration; 13 | using Microsoft.Extensions.Logging; 14 | 15 | /// 16 | /// Logs metadata related to the current Serilog file sink, if one has been configured. 17 | /// 18 | public class FileSinkMetadataLogger : IStartupLogicTask 19 | { 20 | private const string FlowName = "ApplicationStartup/ExecuteStartupLogicTasks/LogFileSinkDirectory"; 21 | private static readonly IPrincipal Principal = new GenericPrincipal(new GenericIdentity("serilog-logging-provider"), []); 22 | 23 | private readonly IConfiguration configuration; 24 | private readonly ILogger logger; 25 | 26 | /// 27 | /// Creates a new instance of the class. 28 | /// 29 | /// Application configuration. 30 | /// Logs events generated by this instance. 31 | /// Thrown in case any of the above parameters is null. 32 | public FileSinkMetadataLogger(IConfiguration configuration, ILogger logger) 33 | { 34 | this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); 35 | this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); 36 | } 37 | 38 | /// 39 | /// Logs the path pointing to the folder where Serilog file sink will write the events generated by this application. 40 | /// 41 | public Task ExecuteAsync() 42 | { 43 | return SimpleApplicationFlow.ExecuteAsync(FlowName, LogFileSinkDirectory, Principal, logger); 44 | } 45 | 46 | private Task LogFileSinkDirectory() 47 | { 48 | if (SerilogActivator.IsFileSinkConfigured(configuration)) 49 | { 50 | string logsHomeDirectory = Environment.GetEnvironmentVariable(Logging.LogsHomeEnvironmentVariable); 51 | logger.LogInformation("The currently configured Serilog file sink will write files to the directory: [{LogsHomeDirectory}]", logsHomeDirectory); 52 | } 53 | else 54 | { 55 | logger.LogInformation("No Serilog file sink has been configured"); 56 | } 57 | 58 | return Task.CompletedTask; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/AcceptanceTests/Todo.WebApi.AcceptanceTests/Infrastructure/ScenarioDependencies.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.AcceptanceTests.Infrastructure 2 | { 3 | using System; 4 | using System.Net.Http.Headers; 5 | using System.Reflection; 6 | 7 | using Microsoft.Extensions.Configuration; 8 | using Microsoft.Extensions.DependencyInjection; 9 | 10 | using SolidToken.SpecFlow.DependencyInjection; 11 | 12 | using Commons.Constants; 13 | 14 | using Drivers; 15 | 16 | using Services.Security; 17 | 18 | public static class ScenarioDependencies 19 | { 20 | private const string DefaultProductName = "Todo.WebApi.AcceptanceTests"; 21 | private const string DefaultProductVersion = "1.0.0.0"; 22 | 23 | [ScenarioDependencies] 24 | public static IServiceCollection CreateScenarioDependencies() 25 | { 26 | AssemblyName assemblyName = Assembly.GetExecutingAssembly().GetName(); 27 | ServiceCollection services = new(); 28 | 29 | services 30 | .AddSingleton() 31 | .AddSingleton() 32 | .AddSingleton() 33 | .AddSingleton 34 | ( 35 | new ConfigurationBuilder() 36 | .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) 37 | .AddJsonFile($"appsettings.{EnvironmentNames.AcceptanceTests}.json", optional: false, reloadOnChange: false) 38 | .AddEnvironmentVariables(prefix: EnvironmentVariables.Prefix) 39 | .Build() 40 | ) 41 | .AddHttpClient(name: TodoWebApiDriver.HttpClientName, (serviceProvider, httpClient) => 42 | { 43 | TcpPortProvider tcpPortProvider = serviceProvider.GetRequiredService(); 44 | int port = tcpPortProvider.GetAvailableTcpPort(); 45 | 46 | httpClient.BaseAddress = new Uri(uriString: $"http://localhost:{port}", uriKind: UriKind.Absolute); 47 | httpClient.DefaultRequestHeaders.UserAgent.Add 48 | ( 49 | item: new ProductInfoHeaderValue 50 | ( 51 | product: new ProductHeaderValue 52 | ( 53 | name: assemblyName.Name ?? DefaultProductName, 54 | version: assemblyName.Version?.ToString() ?? DefaultProductVersion 55 | ) 56 | ) 57 | ); 58 | }); 59 | 60 | return services; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Migrations/20200124211005_AddIndexForCreatedByColumnInsideTodoItemsTable.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using Todo.Persistence; 9 | 10 | namespace Todo.Persistence.Migrations 11 | { 12 | [DbContext(typeof(TodoDbContext))] 13 | [Migration("20200124211005_AddIndexForCreatedByColumnInsideTodoItemsTable")] 14 | partial class AddIndexForCreatedByColumnInsideTodoItemsTable 15 | { 16 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) 21 | .HasAnnotation("ProductVersion", "3.1.1") 22 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 23 | 24 | modelBuilder.Entity("Todo.Persistence.Entities.TodoItem", b => 25 | { 26 | b.Property("Id") 27 | .ValueGeneratedOnAdd() 28 | .HasColumnType("bigint") 29 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); 30 | 31 | b.Property("CreatedBy") 32 | .IsRequired() 33 | .HasColumnType("character varying(100)") 34 | .HasMaxLength(100); 35 | 36 | b.Property("CreatedOn") 37 | .HasColumnType("timestamp without time zone"); 38 | 39 | b.Property("IsComplete") 40 | .HasColumnType("boolean"); 41 | 42 | b.Property("LastUpdatedBy") 43 | .HasColumnType("character varying(100)") 44 | .HasMaxLength(100); 45 | 46 | b.Property("LastUpdatedOn") 47 | .HasColumnType("timestamp without time zone"); 48 | 49 | b.Property("Name") 50 | .IsRequired() 51 | .HasColumnType("character varying(100)") 52 | .HasMaxLength(100); 53 | 54 | b.HasKey("Id"); 55 | 56 | b.HasIndex("CreatedBy"); 57 | 58 | b.HasIndex("Name"); 59 | 60 | b.ToTable("TodoItems"); 61 | }); 62 | #pragma warning restore 612, 618 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Migrations/20200124212126_ConsolidateCreatedByAndNameColumnsIntoAnUniqueIndexInsideTodoItemsTable.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using Todo.Persistence; 9 | 10 | namespace Todo.Persistence.Migrations 11 | { 12 | [DbContext(typeof(TodoDbContext))] 13 | [Migration("20200124212126_ConsolidateCreatedByAndNameColumnsIntoAnUniqueIndexInsideTodoItemsTable")] 14 | partial class ConsolidateCreatedByAndNameColumnsIntoAnUniqueIndexInsideTodoItemsTable 15 | { 16 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) 21 | .HasAnnotation("ProductVersion", "3.1.1") 22 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 23 | 24 | modelBuilder.Entity("Todo.Persistence.Entities.TodoItem", b => 25 | { 26 | b.Property("Id") 27 | .ValueGeneratedOnAdd() 28 | .HasColumnType("bigint") 29 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); 30 | 31 | b.Property("CreatedBy") 32 | .IsRequired() 33 | .HasColumnType("character varying(100)") 34 | .HasMaxLength(100); 35 | 36 | b.Property("CreatedOn") 37 | .HasColumnType("timestamp without time zone"); 38 | 39 | b.Property("IsComplete") 40 | .HasColumnType("boolean"); 41 | 42 | b.Property("LastUpdatedBy") 43 | .HasColumnType("character varying(100)") 44 | .HasMaxLength(100); 45 | 46 | b.Property("LastUpdatedOn") 47 | .HasColumnType("timestamp without time zone"); 48 | 49 | b.Property("Name") 50 | .IsRequired() 51 | .HasColumnType("character varying(100)") 52 | .HasMaxLength(100); 53 | 54 | b.HasKey("Id"); 55 | 56 | b.HasIndex("CreatedBy", "Name") 57 | .IsUnique(); 58 | 59 | b.ToTable("TodoItems"); 60 | }); 61 | #pragma warning restore 612, 618 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Migrations/TodoDbContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 7 | using Todo.Persistence; 8 | 9 | #nullable disable 10 | 11 | namespace Todo.Persistence.Migrations 12 | { 13 | [DbContext(typeof(TodoDbContext))] 14 | partial class TodoDbContextModelSnapshot : ModelSnapshot 15 | { 16 | protected override void BuildModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("ProductVersion", "7.0.0") 21 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 22 | 23 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 24 | 25 | modelBuilder.Entity("Todo.Persistence.Entities.TodoItem", b => 26 | { 27 | b.Property("Id") 28 | .ValueGeneratedOnAdd() 29 | .HasColumnType("bigint"); 30 | 31 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 32 | 33 | b.Property("CreatedBy") 34 | .IsRequired() 35 | .HasMaxLength(100) 36 | .HasColumnType("character varying(100)"); 37 | 38 | b.Property("CreatedOn") 39 | .HasColumnType("timestamp without time zone"); 40 | 41 | b.Property("IsComplete") 42 | .HasColumnType("boolean"); 43 | 44 | b.Property("LastUpdatedBy") 45 | .HasMaxLength(100) 46 | .HasColumnType("character varying(100)"); 47 | 48 | b.Property("LastUpdatedOn") 49 | .HasColumnType("timestamp without time zone"); 50 | 51 | b.Property("Name") 52 | .IsRequired() 53 | .HasMaxLength(100) 54 | .HasColumnType("character varying(100)"); 55 | 56 | b.Property("Version") 57 | .IsConcurrencyToken() 58 | .ValueGeneratedOnAddOrUpdate() 59 | .HasColumnType("xid") 60 | .HasColumnName("xmin"); 61 | 62 | b.HasKey("Id"); 63 | 64 | b.HasIndex("CreatedBy", "Name") 65 | .IsUnique(); 66 | 67 | b.ToTable("TodoItems", (string)null); 68 | }); 69 | #pragma warning restore 612, 618 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/ApplicationEvents/StartupLogicTaskExecutor.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.ApplicationEvents 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Security.Principal; 6 | using System.Threading.Tasks; 7 | 8 | using Microsoft.Extensions.Hosting; 9 | using Microsoft.Extensions.Logging; 10 | 11 | using Commons.StartupLogic; 12 | 13 | /// 14 | /// An implementation. 15 | /// 16 | public class StartupLogicTaskExecutor : IStartupLogicTaskExecutor 17 | { 18 | private const string FlowName = "ApplicationStartup/ExecuteStartupLogicTasks"; 19 | private static readonly IPrincipal Principal = new GenericPrincipal(new GenericIdentity("execute-application-startup-logic"), []); 20 | 21 | private readonly IEnumerable startupLogicTasks; 22 | private readonly IHostApplicationLifetime hostApplicationLifetime; 23 | private readonly ILogger logger; 24 | 25 | public StartupLogicTaskExecutor 26 | ( 27 | IEnumerable startupLogicTasks, 28 | IHostApplicationLifetime hostApplicationLifetime, 29 | ILogger logger 30 | ) 31 | { 32 | this.startupLogicTasks = startupLogicTasks ?? throw new ArgumentNullException(nameof(startupLogicTasks)); 33 | this.hostApplicationLifetime = hostApplicationLifetime ?? throw new ArgumentNullException(nameof(hostApplicationLifetime)); 34 | this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); 35 | } 36 | 37 | public Task ExecuteAsync() 38 | { 39 | return SimpleApplicationFlow.ExecuteAsync(FlowName, InternalExecuteAsync, Principal, logger); 40 | } 41 | 42 | private async Task InternalExecuteAsync() 43 | { 44 | try 45 | { 46 | foreach (IStartupLogicTask startupLogicTask in startupLogicTasks) 47 | { 48 | string startupLogicTaskName = startupLogicTask.GetType().AssemblyQualifiedName; 49 | 50 | logger.LogInformation("Application startup logic task: [{ApplicationStartedEventListener}] is about to run ...", startupLogicTaskName); 51 | await startupLogicTask.ExecuteAsync(); 52 | logger.LogInformation("Application startup logic task: [{ApplicationStartedEventListener}] has run successfully", startupLogicTaskName); 53 | } 54 | } 55 | catch (Exception exception) 56 | { 57 | logger.LogCritical(exception, "An error has occurred while executing application startup logic; application will stop"); 58 | hostApplicationLifetime.StopApplication(); 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/TransactionalBaseApplicationFlow.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows 2 | { 3 | using System; 4 | using System.Security.Principal; 5 | using System.Threading.Tasks; 6 | using System.Transactions; 7 | 8 | using Microsoft.Extensions.Logging; 9 | 10 | /// 11 | /// Base class for all application flows which make use of transactions. 12 | /// 13 | public abstract class TransactionalBaseApplicationFlow : NonTransactionalBaseApplicationFlow 14 | { 15 | private readonly ApplicationFlowOptions applicationFlowOptions; 16 | 17 | /// 18 | /// Creates a new instance of a particular application flow. 19 | /// 20 | /// The name used for identifying the flow. 21 | /// The options to use when executing this application flow. 22 | /// The instance used for logging any message originating 23 | /// from the flow. 24 | /// Thrown in case the given is null or 25 | /// white-space only. 26 | /// Thrown when the given is null 27 | protected TransactionalBaseApplicationFlow(string flowName, ApplicationFlowOptions applicationFlowOptions, ILogger logger) : base(flowName, logger) 28 | { 29 | this.applicationFlowOptions = applicationFlowOptions ?? throw new ArgumentNullException(nameof(applicationFlowOptions)); 30 | } 31 | 32 | /// 33 | /// Performs operations common to any application flow: validating the input, 34 | /// wrapping the flow in a transaction and finally executing each flow step. 35 | /// 36 | /// The flow input. 37 | /// The user who initiated executing this flow. 38 | /// The flow output. 39 | protected override async Task InternalExecuteAsync(TInput input, IPrincipal flowInitiator) 40 | { 41 | System.Transactions.TransactionOptions transactionOptions = new() 42 | { 43 | IsolationLevel = applicationFlowOptions.TransactionOptions.IsolationLevel, 44 | Timeout = applicationFlowOptions.TransactionOptions.Timeout 45 | }; 46 | 47 | using TransactionScope transactionScope = new(TransactionScopeOption.Required, transactionOptions, TransactionScopeAsyncFlowOption.Enabled); 48 | TOutput output = await base.InternalExecuteAsync(input, flowInitiator); 49 | transactionScope.Complete(); 50 | 51 | return output; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Build/SonarBuildBreaker.ps1: -------------------------------------------------------------------------------- 1 | # Checks whether the given Git branch has passed SonarCloud quality gate via an web API call. 2 | # 3 | # SonarCloud Web API 4 | # Methods: https://sonarcloud.io/web_api/api/ 5 | # Authentication: https://docs.sonarqube.org/display/DEV/Web+API#WebAPI-UserToken. 6 | Param ( 7 | # Represents the base URL for the SonarCloud web API. 8 | [String] 9 | [Parameter(Mandatory = $true)] 10 | $SonarProjectKey, 11 | 12 | # Represents the base URL for the SonarCloud web API. 13 | [String] 14 | [Parameter(Mandatory = $true)] 15 | $SonarServerBaseUrl, 16 | 17 | # Represents the token used for authenticating against SonarCloud. 18 | [String] 19 | [Parameter(Mandatory = $true)] 20 | $SonarToken, 21 | 22 | # Represents the name of the Git branch to be checked via SonarCloud. 23 | [String] 24 | [Parameter(Mandatory = $true)] 25 | $GitBranchName 26 | ) 27 | 28 | $SonarDashboardUrl = "$SonarServerBaseUrl/dashboard?id=$SonarProjectKey" 29 | 30 | $TokenAsBytes = [System.Text.Encoding]::UTF8.GetBytes(("$SonarToken" + ":")) 31 | $Base64Token = [System.Convert]::ToBase64String($TokenAsBytes) 32 | $AuthorizationHeaderValue = [String]::Format("Basic {0}", $Base64Token) 33 | $Headers = @{ 34 | Authorization = $AuthorizationHeaderValue; 35 | AcceptType = "application/json" 36 | } 37 | 38 | # See more about the HTTP request below here: https://sonarcloud.io/web_api/api/qualitygates/project_status. 39 | $SonarWebApiUrl = "{0}/api/qualitygates/project_status?projectKey={1}" -f $SonarServerBaseUrl, $SonarProjectKey 40 | 41 | if ($GitBranchName.StartsWith("refs/pull/")) 42 | { 43 | # The branch *is* a pull request. 44 | # $GitBranchName looks something like this: refs/pull/PULL_REQUEST_ID/merge (e.g. refs/pull/12/merge). 45 | # The interesting part is, of course, PULL_REQUEST_ID. 46 | $PullRequestIdWithSuffix = $GitBranchName -Replace "refs/pull/", "" 47 | $PullRequestId = $PullRequestIdWithSuffix -Replace "/merge", "" 48 | $SonarWebApiUrl = $SonarWebApiUrl + "&pullRequest=" + $PullRequestId 49 | } 50 | else 51 | { 52 | # The branch is *not* a pull request. 53 | $SonarWebApiUrl = $SonarWebApiUrl + "&branch=" + $GitBranchName 54 | } 55 | 56 | $Response = Invoke-WebRequest -Uri $SonarWebApiUrl ` 57 | -Headers $Headers ` 58 | -UseBasicParsing ` 59 | -ErrorAction Stop ` 60 | | ConvertFrom-Json 61 | 62 | if ($Response.projectStatus.status -eq 'OK') 63 | { 64 | Write-Output "Quality gate PASSED. Please check it here: $SonarDashboardUrl" 65 | exit 0 66 | } 67 | elseif ($Response.projectStatus.status -eq 'NONE') 68 | { 69 | Write-Output "There is no quality gate to be passed (yet)" 70 | exit 0 71 | } 72 | 73 | Write-Output "##vso[task.LogIssue type=error;] Quality gate FAILED. Please check it here: $SonarDashboardUrl" 74 | Write-Output "##vso[task.complete result=Failed;]" 75 | exit 1 76 | -------------------------------------------------------------------------------- /Sources/Todo.Persistence/Migrations/20200515210035_AddSupportForOptimisticLockingToTodoTable.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using Todo.Persistence; 9 | 10 | namespace Todo.Persistence.Migrations 11 | { 12 | [DbContext(typeof(TodoDbContext))] 13 | [Migration("20200515210035_AddSupportForOptimisticLockingToTodoTable")] 14 | partial class AddSupportForOptimisticLockingToTodoTable 15 | { 16 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) 21 | .HasAnnotation("ProductVersion", "3.1.4") 22 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 23 | 24 | modelBuilder.Entity("Todo.Persistence.Entities.TodoItem", b => 25 | { 26 | b.Property("Id") 27 | .ValueGeneratedOnAdd() 28 | .HasColumnType("bigint") 29 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); 30 | 31 | b.Property("CreatedBy") 32 | .IsRequired() 33 | .HasColumnType("character varying(100)") 34 | .HasMaxLength(100); 35 | 36 | b.Property("CreatedOn") 37 | .HasColumnType("timestamp without time zone"); 38 | 39 | b.Property("IsComplete") 40 | .HasColumnType("boolean"); 41 | 42 | b.Property("LastUpdatedBy") 43 | .HasColumnType("character varying(100)") 44 | .HasMaxLength(100); 45 | 46 | b.Property("LastUpdatedOn") 47 | .HasColumnType("timestamp without time zone"); 48 | 49 | b.Property("Name") 50 | .IsRequired() 51 | .HasColumnType("character varying(100)") 52 | .HasMaxLength(100); 53 | 54 | b.Property("xmin") 55 | .IsConcurrencyToken() 56 | .ValueGeneratedOnAddOrUpdate() 57 | .HasColumnType("xid"); 58 | 59 | b.HasKey("Id"); 60 | 61 | b.HasIndex("CreatedBy", "Name") 62 | .IsUnique(); 63 | 64 | b.ToTable("TodoItems"); 65 | }); 66 | #pragma warning restore 612, 618 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/UnitTests/Todo.WebApi.UnitTests/Controllers/HealthCheckControllerTests.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.WebApi.Controllers 2 | { 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.Extensions.Diagnostics.HealthChecks; 5 | 6 | using System; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | 10 | using NUnit.Framework; 11 | 12 | using VerifyNUnit; 13 | 14 | /// 15 | /// Contains unit tests targeting class. 16 | /// 17 | [TestFixture] 18 | public class HealthCheckControllerTests 19 | { 20 | [Test] 21 | public async Task GetHealthReportAsync_WhenTimeoutExceptionOccurs_ReturnsExpectedHealthReport() 22 | { 23 | // Arrange 24 | HealthCheckServiceThrowingExceptions healthCheckServiceThrowingExceptions = new 25 | ( 26 | exceptionToThrow: new TimeoutException 27 | ( 28 | message: "This is a hard-coded timeout exception created for testing purposes" 29 | ) 30 | ); 31 | 32 | HealthCheckController classUnderTest = new(healthCheckService: healthCheckServiceThrowingExceptions); 33 | 34 | // Act 35 | ActionResult actionResult = await classUnderTest.GetHealthReportAsync(cancellationToken: CancellationToken.None); 36 | 37 | // Assert 38 | await Verifier.Verify(actionResult, settings: ModuleInitializer.VerifySettings); 39 | } 40 | 41 | [Test] 42 | public async Task GetHealthReportAsync_WhenUnexpectedExceptionOccurs_ReturnsExpectedHealthReport() 43 | { 44 | // Arrange 45 | HealthCheckServiceThrowingExceptions healthCheckServiceThrowingExceptions = new 46 | ( 47 | exceptionToThrow: new Exception(message: "This is a hard-coded exception created for testing purposes") 48 | ); 49 | 50 | HealthCheckController classUnderTest = new(healthCheckService: healthCheckServiceThrowingExceptions); 51 | 52 | // Act 53 | ActionResult actionResult = await classUnderTest.GetHealthReportAsync(cancellationToken: CancellationToken.None); 54 | 55 | // Assert 56 | await Verifier.Verify(actionResult, settings: ModuleInitializer.VerifySettings); 57 | } 58 | 59 | private class HealthCheckServiceThrowingExceptions : HealthCheckService 60 | { 61 | private readonly Exception exceptionToThrow; 62 | 63 | public HealthCheckServiceThrowingExceptions(Exception exceptionToThrow) 64 | { 65 | this.exceptionToThrow = exceptionToThrow; 66 | } 67 | 68 | public override Task CheckHealthAsync(Func predicate, CancellationToken cancellationToken = default) 69 | { 70 | throw exceptionToThrow; 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/Todo.ApplicationFlows/DependencyInjection/ApplicationFlowsModule.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.ApplicationFlows.DependencyInjection 2 | { 3 | using ApplicationEvents; 4 | 5 | using ApplicationFlows; 6 | 7 | using Autofac; 8 | 9 | using Commons.StartupLogic; 10 | 11 | using Microsoft.Extensions.Configuration; 12 | 13 | using Security; 14 | 15 | using Todo.Services.DependencyInjection; 16 | 17 | using TodoItems; 18 | 19 | /// 20 | /// Configures application flow related services used by this application. 21 | /// 22 | public class ApplicationFlowsModule : Module 23 | { 24 | private const string ApplicationFlowsConfigurationSectionName = "ApplicationFlows"; 25 | 26 | /// 27 | /// Gets or sets the name of the environment where this application runs. 28 | /// 29 | public string EnvironmentName { get; init; } 30 | 31 | /// 32 | /// Gets or sets the configuration used by this application. 33 | /// 34 | public IConfiguration ApplicationConfiguration { get; init; } 35 | 36 | protected override void Load(ContainerBuilder builder) 37 | { 38 | builder.RegisterModule(new ServicesModule 39 | { 40 | EnvironmentName = EnvironmentName 41 | }); 42 | 43 | builder 44 | .Register(_ => ApplicationConfiguration.GetSection(ApplicationFlowsConfigurationSectionName).Get()) 45 | .SingleInstance(); 46 | 47 | builder 48 | .RegisterType() 49 | .As() 50 | .InstancePerLifetimeScope(); 51 | 52 | builder 53 | .RegisterType() 54 | .As() 55 | .InstancePerLifetimeScope(); 56 | 57 | builder 58 | .RegisterType() 59 | .As() 60 | .InstancePerLifetimeScope(); 61 | 62 | builder 63 | .RegisterType() 64 | .As() 65 | .InstancePerLifetimeScope(); 66 | 67 | builder 68 | .RegisterType() 69 | .As() 70 | .InstancePerLifetimeScope(); 71 | 72 | builder 73 | .RegisterType() 74 | .As() 75 | .InstancePerLifetimeScope(); 76 | 77 | builder 78 | .RegisterType() 79 | .As() 80 | .SingleInstance(); 81 | 82 | builder 83 | .RegisterType() 84 | .As() 85 | .SingleInstance(); 86 | } 87 | } 88 | } 89 | --------------------------------------------------------------------------------