diff --git a/2-modular-monolith-architecture-style/src/Api/src/Api.csproj b/2-modular-monolith-architecture-style/src/Api/src/Api.csproj new file mode 100644 index 0000000..2e573a8 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Api/src/Api.csproj @@ -0,0 +1,14 @@ + + + + Api + + + + + + + + + + diff --git a/2-modular-monolith-architecture-style/src/Api/src/Extensions/SharedInfrastructureExtensions.cs b/2-modular-monolith-architecture-style/src/Api/src/Extensions/SharedInfrastructureExtensions.cs new file mode 100644 index 0000000..f7aca65 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Api/src/Extensions/SharedInfrastructureExtensions.cs @@ -0,0 +1,122 @@ +using System.Threading.RateLimiting; +using BuildingBlocks.Core; +using BuildingBlocks.Exception; +using BuildingBlocks.HealthCheck; +using BuildingBlocks.Jwt; +using BuildingBlocks.Logging; +using BuildingBlocks.Mapster; +using BuildingBlocks.MassTransit; +using BuildingBlocks.OpenApi; +using BuildingBlocks.OpenTelemetryCollector; +using BuildingBlocks.PersistMessageProcessor; +using BuildingBlocks.ProblemDetails; +using BuildingBlocks.Web; +using Figgle; +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Serilog; + +namespace Api.Extensions; + +public static class SharedInfrastructureExtensions +{ + public static WebApplicationBuilder AddSharedInfrastructure(this WebApplicationBuilder builder) + { + builder.Host.UseDefaultServiceProvider( + (context, options) => + { + // Service provider validation + // ref: https://andrewlock.net/new-in-asp-net-core-3-service-provider-validation/ + options.ValidateScopes = context.HostingEnvironment.IsDevelopment() || + context.HostingEnvironment.IsStaging() || + context.HostingEnvironment.IsEnvironment("tests"); + + options.ValidateOnBuild = true; + }); + + var appOptions = builder.Services.GetOptions(nameof(AppOptions)); + Console.WriteLine(FiggleFonts.Standard.Render(appOptions.Name)); + + builder.AddCustomSerilog(builder.Environment); + builder.Services.AddScoped(); + builder.Services.AddJwt(); + builder.Services.AddTransient(); + builder.Services.AddPersistMessageProcessor(); + + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddControllers(); + builder.Services.AddAspnetOpenApi(); + builder.Services.AddCustomVersioning(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped(); + + builder.Services.AddCustomMassTransit( + builder.Environment, + TransportType.InMemory, + AppDomain.CurrentDomain.GetAssemblies()); + + builder.Services.Configure( + options => options.SuppressModelStateInvalidFilter = true); + + builder.Services.AddRateLimiter( + options => + { + options.GlobalLimiter = PartitionedRateLimiter.Create( + httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.User.Identity?.Name ?? + httpContext.Request.Headers.Host.ToString(), + factory: partition => new FixedWindowRateLimiterOptions + { + AutoReplenishment = true, + PermitLimit = 10, + QueueLimit = 0, + Window = TimeSpan.FromMinutes(1) + })); + }); + + builder.AddCustomObservability(); + builder.Services.AddCustomHealthCheck(); + + builder.Services.AddGrpc( + options => + { + options.Interceptors.Add(); + }); + + builder.Services.AddEasyCaching(options => { options.UseInMemory(builder.Configuration, "mem"); }); + + builder.Services.AddValidatorsFromAssemblies(AppDomain.CurrentDomain.GetAssemblies()); + builder.Services.AddCustomMapster(AppDomain.CurrentDomain.GetAssemblies()); + builder.Services.AddProblemDetails(); + + return builder; + } + + + public static WebApplication UserSharedInfrastructure(this WebApplication app) + { + var appOptions = app.Configuration.GetOptions(nameof(AppOptions)); + + app.UseCustomProblemDetails(); + app.UseCustomObservability(); + app.UseCustomHealthCheck(); + + app.UseSerilogRequestLogging( + options => + { + options.EnrichDiagnosticContext = LogEnrichHelper.EnrichFromRequest; + }); + + app.UseCorrelationId(); + app.UseRateLimiter(); + app.MapGet("/", x => x.Response.WriteAsync(appOptions.Name)); + + if (app.Environment.IsDevelopment()) + { + app.UseAspnetOpenApi(); + } + + return app; + } +} diff --git a/2-modular-monolith-architecture-style/src/Api/src/Program.cs b/2-modular-monolith-architecture-style/src/Api/src/Program.cs new file mode 100644 index 0000000..9810f8e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Api/src/Program.cs @@ -0,0 +1,41 @@ +using Api.Extensions; +using Booking.Extensions.Infrastructure; +using BuildingBlocks.Web; +using Flight.Extensions.Infrastructure; +using Identity.Extensions.Infrastructure; +using Passenger.Extensions.Infrastructure; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddMinimalEndpoints(assemblies: AppDomain.CurrentDomain.GetAssemblies()); + +builder.AddSharedInfrastructure(); + +builder.AddFlightModules(); +builder.AddIdentityModules(); +builder.AddPassengerModules(); +builder.AddBookingModules(); + + +var app = builder.Build(); + +// ref: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-7.0#routing-basics +app.UseAuthentication(); +app.UseAuthorization(); +app.MapMinimalEndpoints(); +app.UserSharedInfrastructure(); + +app.UseFlightModules(); +app.UseIdentityModules(); +app.UsePassengerModules(); +app.UseBookingModules(); + + +app.Run(); + +namespace Api +{ + public partial class Program + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Api/src/Properties/launchSettings.json b/2-modular-monolith-architecture-style/src/Api/src/Properties/launchSettings.json new file mode 100644 index 0000000..c1dd2d1 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Api/src/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:17191", + "sslPort": 44352 + } + }, + "profiles": { + "Api": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchUrl": "swagger", + "launchBrowser": true, + "applicationUrl": "https://localhost:5000;http://localhost:5001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Api/src/appsettings.Development.json b/2-modular-monolith-architecture-style/src/Api/src/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Api/src/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Api/src/appsettings.docker.json b/2-modular-monolith-architecture-style/src/Api/src/appsettings.docker.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Api/src/appsettings.docker.json @@ -0,0 +1,2 @@ +{ +} diff --git a/2-modular-monolith-architecture-style/src/Api/src/appsettings.json b/2-modular-monolith-architecture-style/src/Api/src/appsettings.json new file mode 100644 index 0000000..5749172 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Api/src/appsettings.json @@ -0,0 +1,71 @@ +{ + "AppOptions": { + "Name": "Booking-Modular-Monolith" + }, + "LogOptions": { + "Level": "information", + "LogTemplate": "{Timestamp:HH:mm:ss} [{Level:u4}] {Message:lj}{NewLine}{Exception}", + "File": { + "Enabled": false, + "Path": "logs/logs.txt", + "Interval": "day" + } + }, + "PostgresOptions": { + "ConnectionString": { + "Flight": "Server=localhost;Port=5432;Database=flight;User Id=postgres;Password=postgres;Include Error Detail=true", + "Identity": "Server=localhost;Port=5432;Database=identity;User Id=postgres;Password=postgres;Include Error Detail=true", + "Passenger": "Server=localhost;Port=5432;Database=passenger;User Id=postgres;Password=postgres;Include Error Detail=true" + } + }, + "MongoOptions": { + "ConnectionString": "mongodb://localhost:27017", + "DatabaseName": "booking-read" + }, + "EventStoreOptions": { + "ConnectionString": "esdb://localhost:2113?tls=false" + }, + "Jwt": { + "Authority": "http://localhost:6005", + "Audience": "flight-api", + "RequireHttpsMetadata": false, + "MetadataAddress": "http://localhost:6005/.well-known/openid-configuration" + }, + "RabbitMqOptions": { + "HostName": "localhost", + "ExchangeName": "flight", + "UserName": "guest", + "Password": "guest", + "Port": 5672 + }, + "PersistMessageOptions": { + "Interval": 30, + "Enabled": true, + "ConnectionString": "Server=localhost;Port=5432;Database=persist_message;User Id=postgres;Password=postgres;Include Error Detail=true" + }, + "HealthOptions": { + "Enabled": false + }, + "ObservabilityOptions": { + "InstrumentationName": "flight_service", + "OTLPOptions": { + "OTLPGrpExporterEndpoint": "http://localhost:4317" + }, + "AspireDashboardOTLPOptions": { + "OTLPGrpExporterEndpoint": "http://localhost:4319" + }, + "ZipkinOptions": { + "HttpExporterEndpoint": "http://localhost:9411/api/v2/spans" + }, + "JaegerOptions": { + "OTLPGrpcExporterEndpoint": "http://localhost:14317", + "HttpExporterEndpoint": "http://localhost:14268/api/traces" + }, + "UsePrometheusExporter": true, + "UseOTLPExporter": true, + "UseAspireOTLPExporter": true, + "UseGrafanaExporter": false, + "ServiceName": "Flight Service" + }, + "AllowedHosts": "*" +} diff --git a/2-modular-monolith-architecture-style/src/Api/src/appsettings.test.json b/2-modular-monolith-architecture-style/src/Api/src/appsettings.test.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Api/src/appsettings.test.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/2-modular-monolith-architecture-style/src/Api/src/keys/is-signing-key-73D9025BDA857BF270C99C6594EE4246.json b/2-modular-monolith-architecture-style/src/Api/src/keys/is-signing-key-73D9025BDA857BF270C99C6594EE4246.json new file mode 100644 index 0000000..5ca82b6 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Api/src/keys/is-signing-key-73D9025BDA857BF270C99C6594EE4246.json @@ -0,0 +1 @@ +{"Version":1,"Id":"73D9025BDA857BF270C99C6594EE4246","Created":"2024-09-02T18:34:53.8631045Z","Algorithm":"RS256","IsX509Certificate":false,"Data":"CfDJ8MEQ06y_es9CrKY4Ou9Uc7Hf97ujXcMcuawi9V5VrBvxWbAqbdjBlk5zKK_NzrDURTwaFvgOfLUQ__0r-sMvtTXBZLhWhNWJnLn1-KWhtnpvPt5jFTAf2f5mvrjVTTN8E-NTGMGk2yLOYm2TVE3QL7DuLRrazydXLJvIB71O2d_OrAfB7Pq62xrnwLgp4BErb_HphYUExsAEp7jn6uL34QJ5HI6zsb_ct2SHvOl0CzUH2yIjjrY_u-5GAgmqqpUyysGwUh8RpR_CDPCd8ZxgSOiKAlBkp3kQXxf86MF0C-kfBKU-iflrJRSJ1-5R8F5nNPK8FL29I4i7SYCugIkLvqez3wTewPUFv00bTpcfs3V_tmfqBFLyNWm4sppzpU9HGB3elZ0z5bBa_IGbtIrlHC_o4SDSxKKdD4OUZHGLE5X--xWBw5uV9GSgrSH6bcnHZ6rLd3qrt7b82BMr-rVIGyFzGIE6OTLVGkXSL6FdpJ9Lezp06qnZ6DtLFI86lVJGsYIuXR_AIVrz9U8uqCrK6jLwGCk9nR0adPtAUJfgcXGVVTQlvt_YgZv5k5_rYcVl0yLNdgd-BoidXeoqLPJxIJOCohwumVcqTPkf-gB_hjgNk6IEqXZxm0Tzl0d2jpyERDHdHxIvS5o1h4YUfkiMcliRfQMNkLoDxf0H2hFNXRYQKkQ1y6cwS9juq66Uj_-v-vN1etE-hK45ULFCfyppBvb2aTYfTuce5S0ps1t0ZIjvpm7Kvjg3doHEi97N-IqxYaf8r6n8gBcaUwlrfcHaYNPLRyWywX_varEmLm9qGK5KJj2itGNfw0wU0EygIeoV6V_PzqyAkf0JcVTC7lcog37TPdNU2AGzH0S8oXiAQEd49wPs2ZApjiOaiz26efr03I-hD5N91-R2_9ACGjENGPVHMyUtMVV5RPs3-pQMv9f_zweOuLQo7ZfhScqu4HmxGW70amuV4anMxGCQzbi3JWnkTmspptzClJyvE_MJVSTQ6SX04DaS4buSG3wZEc9Qy9SqTj-9CJ7XFGFs8XYmKUj_cIoQF1XuoSnWblsnnEC7EbNRF7y9fG8ZG-Sk3TEEYnalxRrcS-i26wVNdnuUBEmidz_HfsxFxCDKKmx7GHTvHxy72kI84ucMeQeVFjJI3ZDynGn--cL9xBiUbUKM8WDhJ-AgZ76wwh0qAPw5xJ6yHi-15moxySUkvFLjlNkP2Ad5j3_3ab_r6VIunM6zhsq7pSBWIg5povuV5ZwNVZQX0IeLqV9bHug443LaK5a57dTK8wy346AFftV-wc71i4Nt5MIFcOs3lxRPqYij1enbrPYvIV4-N8Sy6aaYj25Qn7VHrGeW72aZPAYY5W-czoPw_Oo6xYGjaPYFFsUSZVg6IQwCzwwAxUoc2gAL33FJQTNvNSnrYBJ5HN-Tqan23Pw_bEus7HHZu2N1daFjqtrl4-oOco46phsppUjH3LGhOPJnFSChr-W8tlk80coJ8IK_AsGludKB09WzId9JBtI5cp3Yu1J7N6nSL7nVTrT6Gw_0hitSoeu5ZLPSS9ooAynAXrvB_s0l0L9aFTRuc5IEhgt4bLzbeqimfQemRlBsNz09JGe04gmOOCmjWD52JHWUiVJQNMavrSGtW9Dy1-Z5h0D_BHzhpTia1S7wx7dSdItJ0-Pm1Au_TNkQGm4ffNFsVDQmNkCYyc8yFnYmZMYYEaPmbw7DvQTs1MHoe7aUzMM0DKcaqboSaxqQK9sxymgElvdoYqOMRWzS7s39UQ1O4TfPngfrWdtN2DogGUtyS-vPfNJpdS6jZvJAj8czgl7PU8buwWyPApE1-FVL32wC6a8dkHvJi4p7fbBjmTfFCnuW8G1KBX3VuToctJvidSjzoSUTX3vgKVni2QW-55Sh7DUYy91FGXGB_ui1yuxEnLmymtbWokcWYkIwcAsl8im70V4oK63ypNSYWea_gaDWMFXT_vANB1iAkr-_zE_ECocOXo93QqSR5UdLmfQFvLiDwjUeovkjFS5C2Z8AjEvHvFkedGWOIK5Bpam-0IEFip3Fvg6RgxwTinFXXa8PiRkcLSlt0J81b85ybrKsDj5WtUA-MFuK2Silyofn9BgD_lh9RU4HPFhVoqey7AuEJjHtGvqz4EnE_05y4A_mKgvJBAs4QiYjCopWtheeOGeeoUa636Ewmu30P66C5mimdAIx36-55xlyJBIM7DFFM6RAGvfAmpyNphjwT0y84B4pOhFEZeOQ2me2sfG-xRJbjjgDhP2SwBBEQ-hCLGeqOD-Xo74FZC4lCTvtn2Sbu1kIw0kz2P_vrq6d6SZwEIrhWYhfRVKTrT8nXj8i48Jdc1d1fyKdRL15USgLhAT-QSNcgVYHRLsVlQx5-b51tGg6Atx6vGCxtXBRSaTwZ3IxbdJs0T62H14K5U81EFu-2Vvf-cMwCm4gCQATxvvAsqToxElou9ZjIVMPt_FQUyAMtJke","DataProtected":true} \ No newline at end of file diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking.csproj b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking.csproj new file mode 100644 index 0000000..f9c7082 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking.csproj @@ -0,0 +1,20 @@ + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Dtos/CreateReservation.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Dtos/CreateReservation.cs new file mode 100644 index 0000000..56c4a89 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Dtos/CreateReservation.cs @@ -0,0 +1,4 @@ +namespace Booking.Booking.Dtos; + +public record BookingResponseDto(Guid Id, string Name, string FlightNumber, Guid AircraftId, decimal Price, + DateTime FlightDate, string SeatNumber, Guid DepartureAirportId, Guid ArriveAirportId, string Description); diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/BookingAlreadyExistException.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/BookingAlreadyExistException.cs new file mode 100644 index 0000000..0149537 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/BookingAlreadyExistException.cs @@ -0,0 +1,10 @@ +namespace Booking.Booking.Exceptions; + +using BuildingBlocks.Exception; + +public class BookingAlreadyExistException : ConflictException +{ + public BookingAlreadyExistException(int? code = default) : base("Booking already exist!", code) + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/FlightNotFoundException.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/FlightNotFoundException.cs new file mode 100644 index 0000000..af4b5e6 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/FlightNotFoundException.cs @@ -0,0 +1,10 @@ +namespace Booking.Booking.Exceptions; + +using BuildingBlocks.Exception; + +public class FlightNotFoundException : NotFoundException +{ + public FlightNotFoundException() : base("Flight doesn't exist!") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidAircraftIdException.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidAircraftIdException.cs new file mode 100644 index 0000000..e56f720 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidAircraftIdException.cs @@ -0,0 +1,11 @@ +namespace Booking.Booking.Exceptions; + +using BuildingBlocks.Exception; + +public class InvalidAircraftIdException : BadRequestException +{ + public InvalidAircraftIdException(Guid aircraftId) + : base($"aircraftId: '{aircraftId}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidArriveAirportIdException.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidArriveAirportIdException.cs new file mode 100644 index 0000000..33ac680 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidArriveAirportIdException.cs @@ -0,0 +1,11 @@ +namespace Booking.Booking.Exceptions; + +using BuildingBlocks.Exception; + +public class InvalidArriveAirportIdException : BadRequestException +{ + public InvalidArriveAirportIdException(Guid arriveAirportId) + : base($"arriveAirportId: '{arriveAirportId}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidDepartureAirportIdException.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidDepartureAirportIdException.cs new file mode 100644 index 0000000..47f0d56 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidDepartureAirportIdException.cs @@ -0,0 +1,11 @@ +namespace Booking.Booking.Exceptions; + +using BuildingBlocks.Exception; + +public class InvalidDepartureAirportIdException : BadRequestException +{ + public InvalidDepartureAirportIdException(Guid departureAirportId) + : base($"departureAirportId: '{departureAirportId}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidFlightDateException.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidFlightDateException.cs new file mode 100644 index 0000000..8b4fd48 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidFlightDateException.cs @@ -0,0 +1,11 @@ +namespace Booking.Booking.Exceptions; + +using BuildingBlocks.Exception; + +public class InvalidFlightDateException : BadRequestException +{ + public InvalidFlightDateException(DateTime flightDate) + : base($"Flight Date: '{flightDate}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidFlightNumberException.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidFlightNumberException.cs new file mode 100644 index 0000000..146a001 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidFlightNumberException.cs @@ -0,0 +1,10 @@ +namespace Booking.Booking.Exceptions; + +using BuildingBlocks.Exception; +public class InvalidFlightNumberException : BadRequestException +{ + public InvalidFlightNumberException(string flightNumber) + : base($"Flight Number: '{flightNumber}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidPassengerNameException.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidPassengerNameException.cs new file mode 100644 index 0000000..9ff87eb --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidPassengerNameException.cs @@ -0,0 +1,11 @@ +namespace Booking.Booking.Exceptions; + +using BuildingBlocks.Exception; + +public class InvalidPassengerNameException : BadRequestException +{ + public InvalidPassengerNameException(string passengerName) + : base($"Passenger Name: '{passengerName}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidPriceException.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidPriceException.cs new file mode 100644 index 0000000..ece6249 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/InvalidPriceException.cs @@ -0,0 +1,12 @@ +namespace Booking.Booking.Exceptions; + +using BuildingBlocks.Exception; + +public class InvalidPriceException : BadRequestException +{ + public InvalidPriceException(decimal price) + : base($"Price: '{price}' must be grater than or equal 0.") + { + } +} + diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/SeatNumberException.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/SeatNumberException.cs new file mode 100644 index 0000000..19678d7 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Exceptions/SeatNumberException.cs @@ -0,0 +1,12 @@ +namespace Booking.Booking.Exceptions; + +using BuildingBlocks.Exception; + +public class SeatNumberException : BadRequestException +{ + public SeatNumberException(string seatNumber) + : base($"Seat Number: '{seatNumber}' is invalid.") + { + } +} + diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Features/BookingMappings.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Features/BookingMappings.cs new file mode 100644 index 0000000..feaa98e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Features/BookingMappings.cs @@ -0,0 +1,23 @@ +using Booking.Booking.Dtos; +using Mapster; + +namespace Booking.Booking.Features; + +using CreatingBook.V1; + +public class BookingMappings : IRegister +{ + public void Register(TypeAdapterConfig config) + { + config.Default.NameMatchingStrategy(NameMatchingStrategy.Flexible); + + config.NewConfig() + .ConstructUsing(x => new BookingResponseDto(x.Id, x.PassengerInfo.Name, x.Trip.FlightNumber, + x.Trip.AircraftId, x.Trip.Price, x.Trip.FlightDate, x.Trip.SeatNumber, x.Trip.DepartureAirportId, x.Trip.ArriveAirportId, + x.Trip.Description)); + + + config.NewConfig() + .ConstructUsing(x => new CreateBooking(x.PassengerId, x.FlightId, x.Description)); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Features/CreatingBook/V1/CreateBooking.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Features/CreatingBook/V1/CreateBooking.cs new file mode 100644 index 0000000..bde40c0 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Features/CreatingBook/V1/CreateBooking.cs @@ -0,0 +1,146 @@ +namespace Booking.Booking.Features.CreatingBook.V1; + +using Ardalis.GuardClauses; +using BuildingBlocks.Core; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using BuildingBlocks.Core.Model; +using BuildingBlocks.EventStoreDB.Repository; +using BuildingBlocks.Web; +using Duende.IdentityServer.EntityFramework.Entities; +using Exceptions; +using Flight; +using FluentValidation; +using Mapster; +using MapsterMapper; +using MassTransit; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Passenger; +using ValueObjects; + +public record CreateBooking(Guid PassengerId, Guid FlightId, string Description) : ICommand +{ + public Guid Id { get; init; } = NewId.NextGuid(); +} + +public record CreateBookingResult(ulong Id); + +public record BookingCreatedDomainEvent(Guid Id, PassengerInfo PassengerInfo, Trip Trip) : Entity, IDomainEvent; + +public record CreateBookingRequestDto(Guid PassengerId, Guid FlightId, string Description); + +public record CreateBookingResponseDto(ulong Id); + +public class CreateBookingEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPost($"{EndpointConfig.BaseApiPath}/booking", async (CreateBookingRequestDto request, + IMediator mediator, IMapper mapper, + CancellationToken cancellationToken) => + { + var command = mapper.Map(request); + + var result = await mediator.Send(command, cancellationToken); + + var response = result.Adapt(); + + return Results.Ok(response); + }) + .RequireAuthorization(nameof(ApiScope)) + .WithName("CreateBooking") + .WithApiVersionSet(builder.NewApiVersionSet("Booking").Build()) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Create Booking") + .WithDescription("Create Booking") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } +} + +public class CreateBookingValidator : AbstractValidator +{ + public CreateBookingValidator() + { + RuleFor(x => x.FlightId).NotNull().WithMessage("FlightId is required!"); + RuleFor(x => x.PassengerId).NotNull().WithMessage("PassengerId is required!"); + } +} + +internal class CreateBookingCommandHandler : ICommandHandler +{ + private readonly IEventStoreDBRepository _eventStoreDbRepository; + private readonly ICurrentUserProvider _currentUserProvider; + private readonly IEventDispatcher _eventDispatcher; + private readonly FlightGrpcService.FlightGrpcServiceClient _flightGrpcServiceClient; + private readonly PassengerGrpcService.PassengerGrpcServiceClient _passengerGrpcServiceClient; + + public CreateBookingCommandHandler(IEventStoreDBRepository eventStoreDbRepository, + ICurrentUserProvider currentUserProvider, + IEventDispatcher eventDispatcher, + FlightGrpcService.FlightGrpcServiceClient flightGrpcServiceClient, + PassengerGrpcService.PassengerGrpcServiceClient passengerGrpcServiceClient) + { + _eventStoreDbRepository = eventStoreDbRepository; + _currentUserProvider = currentUserProvider; + _eventDispatcher = eventDispatcher; + _flightGrpcServiceClient = flightGrpcServiceClient; + _passengerGrpcServiceClient = passengerGrpcServiceClient; + } + + public async Task Handle(CreateBooking command, CancellationToken cancellationToken) + { + Guard.Against.Null(command, nameof(command)); + + var flight = + await _flightGrpcServiceClient.GetByIdAsync(new Flight.GetByIdRequest { Id = command.FlightId.ToString() }, cancellationToken: cancellationToken); + + if (flight is null) + { + throw new FlightNotFoundException(); + } + + var passenger = + await _passengerGrpcServiceClient.GetByIdAsync(new Passenger.GetByIdRequest { Id = command.PassengerId.ToString() }, cancellationToken: cancellationToken); + + var emptySeat = (await _flightGrpcServiceClient + .GetAvailableSeatsAsync(new GetAvailableSeatsRequest { FlightId = command.FlightId.ToString() }, cancellationToken: cancellationToken) + .ResponseAsync) + ?.SeatDtos?.FirstOrDefault(); + + var reservation = await _eventStoreDbRepository.Find(command.Id, cancellationToken); + + if (reservation is not null && !reservation.IsDeleted) + { + throw new BookingAlreadyExistException(); + } + + var aggrigate = Models.Booking.Create(command.Id, PassengerInfo.Of(passenger.PassengerDto?.Name), Trip.Of( + flight.FlightDto.FlightNumber, new Guid(flight.FlightDto.AircraftId), + new Guid(flight.FlightDto.DepartureAirportId), + new Guid(flight.FlightDto.ArriveAirportId), flight.FlightDto.FlightDate.ToDateTime(), + (decimal)flight.FlightDto.Price, command.Description, + emptySeat?.SeatNumber), + false, _currentUserProvider.GetCurrentUserId()); + + await _eventDispatcher.SendAsync(aggrigate.DomainEvents, cancellationToken: cancellationToken); + + await _flightGrpcServiceClient.ReserveSeatAsync(new ReserveSeatRequest + { + FlightId = flight.FlightDto.Id, + SeatNumber = emptySeat?.SeatNumber + }, cancellationToken: cancellationToken); + + var result = await _eventStoreDbRepository.Add( + aggrigate, + cancellationToken); + + return new CreateBookingResult(result); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Models/Booking.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Models/Booking.cs new file mode 100644 index 0000000..354f743 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Models/Booking.cs @@ -0,0 +1,50 @@ +using BuildingBlocks.EventStoreDB.Events; + +namespace Booking.Booking.Models; + +using Features.CreatingBook.V1; +using ValueObjects; + +public record Booking : AggregateEventSourcing +{ + public Trip Trip { get; private set; } + public PassengerInfo PassengerInfo { get; private set; } + + public static Booking Create(Guid id, PassengerInfo passengerInfo, Trip trip, bool isDeleted = false, long? userId = null) + { + var booking = new Booking { Id = id, Trip = trip, PassengerInfo = passengerInfo, IsDeleted = isDeleted }; + + var @event = new BookingCreatedDomainEvent(booking.Id, booking.PassengerInfo, booking.Trip) + { + IsDeleted = booking.IsDeleted, + CreatedAt = DateTime.Now, + CreatedBy = userId + }; + + booking.AddDomainEvent(@event); + booking.Apply(@event); + + return booking; + } + + public override void When(object @event) + { + switch (@event) + { + case BookingCreatedDomainEvent bookingCreated: + { + Apply(bookingCreated); + return; + } + } + } + + private void Apply(BookingCreatedDomainEvent @event) + { + Id = @event.Id; + Trip = @event.Trip; + PassengerInfo = @event.PassengerInfo; + IsDeleted = @event.IsDeleted; + Version++; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Models/BookingReadModel.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Models/BookingReadModel.cs new file mode 100644 index 0000000..57d2018 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/Models/BookingReadModel.cs @@ -0,0 +1,12 @@ +namespace Booking.Booking.Models; + +using ValueObjects; + +public class BookingReadModel +{ + public required Guid Id { get; init; } + public required Guid BookId { get; init; } + public required Trip Trip { get; init; } + public required PassengerInfo PassengerInfo { get; init; } + public required bool IsDeleted { get; init; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/ValueObjects/PassengerInfo.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/ValueObjects/PassengerInfo.cs new file mode 100644 index 0000000..9cbedc6 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/ValueObjects/PassengerInfo.cs @@ -0,0 +1,23 @@ +namespace Booking.Booking.ValueObjects; + +using Exceptions; + +public record PassengerInfo +{ + public string Name { get; } + + private PassengerInfo(string name) + { + Name = name; + } + + public static PassengerInfo Of(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new InvalidPassengerNameException(name); + } + + return new PassengerInfo(name); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/ValueObjects/Trip.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/ValueObjects/Trip.cs new file mode 100644 index 0000000..fb1b0d4 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Booking/ValueObjects/Trip.cs @@ -0,0 +1,69 @@ +namespace Booking.Booking.ValueObjects; + +using Exceptions; + +public record Trip +{ + public string FlightNumber { get; } + public Guid AircraftId { get; } + public Guid DepartureAirportId { get; } + public Guid ArriveAirportId { get; } + public DateTime FlightDate { get; } + public decimal Price { get; } + public string Description { get; } + public string SeatNumber { get; } + + private Trip(string flightNumber, Guid aircraftId, Guid departureAirportId, Guid arriveAirportId, + DateTime flightDate, decimal price, string description, string seatNumber) + { + FlightNumber = flightNumber; + AircraftId = aircraftId; + DepartureAirportId = departureAirportId; + ArriveAirportId = arriveAirportId; + FlightDate = flightDate; + Price = price; + Description = description; + SeatNumber = seatNumber; + } + + public static Trip Of(string flightNumber, Guid aircraftId, Guid departureAirportId, Guid arriveAirportId, + DateTime flightDate, decimal price, string description, string seatNumber) + { + if (string.IsNullOrWhiteSpace(flightNumber)) + { + throw new InvalidFlightNumberException(flightNumber); + } + + if (aircraftId == Guid.Empty) + { + throw new InvalidAircraftIdException(aircraftId); + } + + if (departureAirportId == Guid.Empty) + { + throw new InvalidDepartureAirportIdException(departureAirportId); + } + + if (arriveAirportId == Guid.Empty) + { + throw new InvalidArriveAirportIdException(departureAirportId); + } + + if (flightDate == default) + { + throw new InvalidFlightDateException(flightDate); + } + + if (price < 0) + { + throw new InvalidPriceException(price); + } + + if (string.IsNullOrWhiteSpace(seatNumber)) + { + throw new SeatNumberException(seatNumber); + } + + return new Trip(flightNumber, aircraftId, departureAirportId, arriveAirportId, flightDate, price, description, seatNumber); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/BookingProjection.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/BookingProjection.cs new file mode 100644 index 0000000..ba4d2db --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/BookingProjection.cs @@ -0,0 +1,54 @@ +using Booking.Data; +using BuildingBlocks.EventStoreDB.Events; +using BuildingBlocks.EventStoreDB.Projections; +using MediatR; +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +namespace Booking; + +using Booking.Features.CreatingBook.V1; +using Booking.Models; +using MassTransit; + +public class BookingProjection : IProjectionProcessor +{ + private readonly BookingReadDbContext _bookingReadDbContext; + + public BookingProjection(BookingReadDbContext bookingReadDbContext) + { + _bookingReadDbContext = bookingReadDbContext; + } + + public async Task ProcessEventAsync(StreamEvent streamEvent, CancellationToken cancellationToken = default) + where T : INotification + { + switch (streamEvent.Data) + { + case BookingCreatedDomainEvent bookingCreatedDomainEvent: + await Apply(bookingCreatedDomainEvent, cancellationToken); + break; + } + } + + private async Task Apply(BookingCreatedDomainEvent @event, CancellationToken cancellationToken = default) + { + var reservation = + await _bookingReadDbContext.Booking.AsQueryable().SingleOrDefaultAsync(x => x.Id == @event.Id && !x.IsDeleted, + cancellationToken); + + if (reservation == null) + { + var bookingReadModel = new BookingReadModel + { + Id = NewId.NextGuid(), + Trip = @event.Trip, + BookId = @event.Id, + PassengerInfo = @event.PassengerInfo, + IsDeleted = @event.IsDeleted + }; + + await _bookingReadDbContext.Booking.InsertOneAsync(bookingReadModel, cancellationToken: cancellationToken); + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/BookingRoot.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/BookingRoot.cs new file mode 100644 index 0000000..73eb27c --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/BookingRoot.cs @@ -0,0 +1,6 @@ +namespace Booking; + +public class BookingRoot +{ + +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Configuration/GrpcOptions.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Configuration/GrpcOptions.cs new file mode 100644 index 0000000..4f4adf7 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Configuration/GrpcOptions.cs @@ -0,0 +1,7 @@ +namespace Booking.Configuration; + +public class GrpcOptions +{ + public string FlightAddress { get; set; } + public string PassengerAddress { get; set; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Data/BookingReadDbContext.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Data/BookingReadDbContext.cs new file mode 100644 index 0000000..e35b256 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Data/BookingReadDbContext.cs @@ -0,0 +1,18 @@ +using BuildingBlocks.Mongo; +using Humanizer; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace Booking.Data; + +using Booking.Models; + +public class BookingReadDbContext : MongoDbContext +{ + public BookingReadDbContext(IOptions options) : base(options) + { + Booking = GetCollection(nameof(Booking).Underscore()); + } + + public IMongoCollection Booking { get; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/EventMapper.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/EventMapper.cs new file mode 100644 index 0000000..2996d90 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/EventMapper.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Contracts.EventBus.Messages; +using BuildingBlocks.Core; +using BuildingBlocks.Core.Event; + +namespace Booking; + +using Booking.Features.CreatingBook.V1; + +public sealed class EventMapper : IEventMapper +{ + public IIntegrationEvent? MapToIntegrationEvent(IDomainEvent @event) + { + return @event switch + { + BookingCreatedDomainEvent e => new BookingCreated(e.Id), + _ => null + }; + } + + public IInternalCommand? MapToInternalCommand(IDomainEvent @event) + { + return @event switch + { + _ => null + }; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Extensions/Infrastructure/GrpcClientExtensions.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Extensions/Infrastructure/GrpcClientExtensions.cs new file mode 100644 index 0000000..8c6d501 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Extensions/Infrastructure/GrpcClientExtensions.cs @@ -0,0 +1,33 @@ +using Booking.Configuration; +using BuildingBlocks.Web; +using Flight; +using Microsoft.Extensions.DependencyInjection; +using Passenger; + +namespace Booking.Extensions.Infrastructure; + +using BuildingBlocks.Polly; + +public static class GrpcClientExtensions +{ + public static IServiceCollection AddGrpcClients(this IServiceCollection services) + { + var grpcOptions = services.GetOptions("Grpc"); + + services.AddGrpcClient(o => + { + o.Address = new Uri(grpcOptions.FlightAddress); + }) + .AddGrpcRetryPolicyHandler() + .AddGrpcCircuitBreakerPolicyHandler(); + + services.AddGrpcClient(o => + { + o.Address = new Uri(grpcOptions.PassengerAddress); + }) + .AddGrpcRetryPolicyHandler() + .AddGrpcCircuitBreakerPolicyHandler(); + + return services; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Extensions/Infrastructure/InfrastructureExtensions.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Extensions/Infrastructure/InfrastructureExtensions.cs new file mode 100644 index 0000000..87e6712 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Extensions/Infrastructure/InfrastructureExtensions.cs @@ -0,0 +1,33 @@ +using Booking.Data; +using BuildingBlocks.Core; +using BuildingBlocks.EventStoreDB; +using BuildingBlocks.Mongo; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Booking.Extensions.Infrastructure; + +public static class InfrastructureExtensions +{ + public static WebApplicationBuilder AddBookingModules(this WebApplicationBuilder builder) + { + builder.Services.AddScoped(); + builder.AddMongoDbContext(); + + // ref: https://github.com/oskardudycz/EventSourcing.NetCore/tree/main/Sample/EventStoreDB/ECommerce + builder.Services.AddEventStore(builder.Configuration, typeof(BookingRoot).Assembly) + .AddEventStoreDBSubscriptionToAll(); + + builder.Services.AddGrpcClients(); + + builder.Services.AddCustomMediatR(); + + return builder; + } + + + public static WebApplication UseBookingModules(this WebApplication app) + { + return app; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/Extensions/Infrastructure/MediatRExtensions.cs b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Extensions/Infrastructure/MediatRExtensions.cs new file mode 100644 index 0000000..6b131a8 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/Extensions/Infrastructure/MediatRExtensions.cs @@ -0,0 +1,21 @@ +using BuildingBlocks.Caching; +using BuildingBlocks.Logging; +using BuildingBlocks.Validation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace Booking.Extensions.Infrastructure; + +public static class MediatRExtensions +{ + public static IServiceCollection AddCustomMediatR(this IServiceCollection services) + { + services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(BookingRoot).Assembly)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(InvalidateCachingBehavior<,>)); + + return services; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/GrpcClient/Protos/flight.proto b/2-modular-monolith-architecture-style/src/Modules/Booking/src/GrpcClient/Protos/flight.proto new file mode 100644 index 0000000..aba77c4 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/GrpcClient/Protos/flight.proto @@ -0,0 +1,85 @@ +syntax = "proto3"; + +package flight; +import "google/protobuf/timestamp.proto"; + +service FlightGrpcService { + + rpc GetById (GetByIdRequest) returns (GetFlightByIdResult); + rpc GetAvailableSeats (GetAvailableSeatsRequest) returns (GetAvailableSeatsResult); + rpc ReserveSeat (ReserveSeatRequest) returns (ReserveSeatResult); +} + +message GetByIdRequest { + string Id = 1; +} + +message GetFlightByIdResult{ + FlightResponse FlightDto = 1; +} + +message GetAvailableSeatsResult{ +repeated SeatDtoResponse SeatDtos = 1; +} + +message ReserveSeatResult{ + string Id = 1; +} + +message FlightResponse { + string Id = 1; + string FlightNumber = 2; + string AircraftId = 3; + string DepartureAirportId = 4; + google.protobuf.Timestamp DepartureDate = 5; + google.protobuf.Timestamp ArriveDate = 6; + string ArriveAirportId = 7; + double DurationMinutes = 8; + google.protobuf.Timestamp FlightDate = 9; + FlightStatus Status = 10; + double Price = 11; + string FlightId = 12; +} + +message GetAvailableSeatsRequest { + string FlightId = 1; +} + +message SeatDtoResponse { + string Id = 1; + string SeatNumber = 2; + SeatType Type = 3; + SeatClass Class = 4; + string FlightId = 5; +} + + +message ReserveSeatRequest { + string FlightId = 1; + string SeatNumber = 2; +} + + + enum FlightStatus { + FLIGHT_STATUS_UNKNOWN = 0; + FLIGHT_STATUS_FLYING = 1; + FLIGHT_STATUS_DELAY = 2; + FLIGHT_STATUS_CANCELED = 3; + FLIGHT_STATUS_COMPLETED = 4; + } + + + enum SeatType { + SEAT_TYPE_UNKNOWN = 0; + SEAT_TYPE_WINDOW = 1; + SEAT_TYPE_MIDDLE = 2; + SEAT_TYPE_AISLE = 3; + } + + + enum SeatClass { + SEAT_CLASS_UNKNOWN = 0; + SEAT_CLASS_FIRST_CLASS = 1; + SEAT_CLASS_BUSINESS = 2; + SEAT_CLASS_ECONOMY = 3; + } diff --git a/2-modular-monolith-architecture-style/src/Modules/Booking/src/GrpcClient/Protos/passenger.proto b/2-modular-monolith-architecture-style/src/Modules/Booking/src/GrpcClient/Protos/passenger.proto new file mode 100644 index 0000000..bb98c29 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Booking/src/GrpcClient/Protos/passenger.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +package passenger; + +service PassengerGrpcService { + + rpc GetById (GetByIdRequest) returns (GetPassengerByIdResult); +} + +message GetByIdRequest { + string Id = 1; +} + +message GetPassengerByIdResult { + PassengerResponse PassengerDto = 1; +} + +message PassengerResponse { + string Id = 1; + string Name = 2; + string PassportNumber = 3; + PassengerType PassengerType = 4; + int32 Age = 5; + string Email = 6; +} + + + enum PassengerType { + PASSENGER_TYPE_UNKNOWN = 0; + PASSENGER_TYPE_MALE = 1; + PASSENGER_TYPE_FEMALE = 2; + PASSENGER_TYPE_BABY = 3; + } + diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Dtos/AircraftDto.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Dtos/AircraftDto.cs new file mode 100644 index 0000000..89aeed8 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Dtos/AircraftDto.cs @@ -0,0 +1,3 @@ +namespace Flight.Aircrafts.Dtos; + +public record AircraftDto(long Id, string Name, string Model, int ManufacturingYear); diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/AircraftAlreadyExistException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/AircraftAlreadyExistException.cs new file mode 100644 index 0000000..241dcdb --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/AircraftAlreadyExistException.cs @@ -0,0 +1,11 @@ +namespace Flight.Aircrafts.Exceptions; + +using System.Net; +using BuildingBlocks.Exception; + +public class AircraftAlreadyExistException : AppException +{ + public AircraftAlreadyExistException() : base("Aircraft already exist!", HttpStatusCode.Conflict) + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/InvalidAircraftIdException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/InvalidAircraftIdException.cs new file mode 100644 index 0000000..840f5e2 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/InvalidAircraftIdException.cs @@ -0,0 +1,12 @@ +namespace Flight.Aircrafts.Exceptions; +using System; +using BuildingBlocks.Exception; + + +public class InvalidAircraftIdException : BadRequestException +{ + public InvalidAircraftIdException(Guid aircraftId) + : base($"AircraftId: '{aircraftId}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/InvalidManufacturingYearException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/InvalidManufacturingYearException.cs new file mode 100644 index 0000000..da58cce --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/InvalidManufacturingYearException.cs @@ -0,0 +1,10 @@ +namespace Flight.Aircrafts.Exceptions; +using BuildingBlocks.Exception; + + +public class InvalidManufacturingYearException : BadRequestException +{ + public InvalidManufacturingYearException() : base("ManufacturingYear must be greater than 1900") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/InvalidModelException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/InvalidModelException.cs new file mode 100644 index 0000000..276e733 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/InvalidModelException.cs @@ -0,0 +1,10 @@ +namespace Flight.Aircrafts.Exceptions; +using BuildingBlocks.Exception; + + +public class InvalidModelException : BadRequestException +{ + public InvalidModelException() : base("Model cannot be empty or whitespace.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/InvalidNameException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/InvalidNameException.cs new file mode 100644 index 0000000..d2a7354 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Exceptions/InvalidNameException.cs @@ -0,0 +1,10 @@ +namespace Flight.Aircrafts.Exceptions; +using BuildingBlocks.Exception; + + +public class InvalidNameException : BadRequestException +{ + public InvalidNameException() : base("Name cannot be empty or whitespace.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Features/AircraftMappings.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Features/AircraftMappings.cs new file mode 100644 index 0000000..81be420 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Features/AircraftMappings.cs @@ -0,0 +1,25 @@ +using Flight.Aircrafts.Models; +using Mapster; + +namespace Flight.Aircrafts.Features; + +using CreatingAircraft.V1; +using MassTransit; +using ValueObjects; + +public class AircraftMappings : IRegister +{ + public void Register(TypeAdapterConfig config) + { + config.NewConfig() + .Map(d => d.Id, s => NewId.NextGuid()) + .Map(d => d.AircraftId, s => AircraftId.Of(s.Id)); + + config.NewConfig() + .Map(d => d.Id, s => NewId.NextGuid()) + .Map(d => d.AircraftId, s => AircraftId.Of(s.Id.Value)); + + config.NewConfig() + .ConstructUsing(x => new CreatingAircraft.V1.CreateAircraft(x.Name, x.Model, x.ManufacturingYear)); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Features/CreatingAircraft/V1/CreateAircraft.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Features/CreatingAircraft/V1/CreateAircraft.cs new file mode 100644 index 0000000..ff9b266 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Features/CreatingAircraft/V1/CreateAircraft.cs @@ -0,0 +1,108 @@ +namespace Flight.Aircrafts.Features.CreatingAircraft.V1; + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using BuildingBlocks.Web; +using Data; +using Duende.IdentityServer.EntityFramework.Entities; +using Exceptions; +using Flight.Aircrafts.ValueObjects; +using FluentValidation; +using Mapster; +using MapsterMapper; +using MassTransit; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Models; + +public record CreateAircraft(string Name, string Model, int ManufacturingYear) : ICommand, + IInternalCommand +{ + public Guid Id { get; init; } = NewId.NextGuid(); +} + +public record CreateAircraftResult(AircraftId Id); + +public record AircraftCreatedDomainEvent + (Guid Id, string Name, string Model, int ManufacturingYear, bool IsDeleted) : IDomainEvent; + +public record CreateAircraftRequestDto(string Name, string Model, int ManufacturingYear); + +public record CreateAircraftResponseDto(Guid Id); + +public class CreateAircraftEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPost($"{EndpointConfig.BaseApiPath}/flight/aircraft", async (CreateAircraftRequestDto request, + IMediator mediator, IMapper mapper, + CancellationToken cancellationToken) => + { + var command = mapper.Map(request); + + var result = await mediator.Send(command, cancellationToken); + + var response = result.Adapt(); + + return Results.Ok(response); + }) + .RequireAuthorization(nameof(ApiScope)) + .WithName("CreateAircraft") + .WithApiVersionSet(builder.NewApiVersionSet("Flight").Build()) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Create Aircraft") + .WithDescription("Create Aircraft") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } +} + +public class CreateAircraftValidator : AbstractValidator +{ + public CreateAircraftValidator() + { + RuleFor(x => x.Model).NotEmpty().WithMessage("Model is required"); + RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required"); + RuleFor(x => x.ManufacturingYear).NotEmpty().WithMessage("ManufacturingYear is required"); + } +} + +internal class CreateAircraftHandler : IRequestHandler +{ + private readonly FlightDbContext _flightDbContext; + + public CreateAircraftHandler(FlightDbContext flightDbContext) + { + _flightDbContext = flightDbContext; + } + + public async Task Handle(CreateAircraft request, CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var aircraft = await _flightDbContext.Aircraft.SingleOrDefaultAsync( + a => a.Model.Value == request.Model, cancellationToken); + + if (aircraft is not null) + { + throw new AircraftAlreadyExistException(); + } + + var aircraftEntity = Aircraft.Create(AircraftId.Of(request.Id), Name.Of(request.Name), Model.Of(request.Model), ManufacturingYear.Of(request.ManufacturingYear)); + + var newAircraft = (await _flightDbContext.Aircraft.AddAsync(aircraftEntity, cancellationToken)).Entity; + + return new CreateAircraftResult(newAircraft.Id); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Features/CreatingAircraft/V1/CreateAircraftMongo.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Features/CreatingAircraft/V1/CreateAircraftMongo.cs new file mode 100644 index 0000000..e89ccb0 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Features/CreatingAircraft/V1/CreateAircraftMongo.cs @@ -0,0 +1,52 @@ +namespace Flight.Aircrafts.Features.CreatingAircraft.V1; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using Data; +using Exceptions; +using MapsterMapper; +using MediatR; +using Models; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using ValueObjects; + +public record CreateAircraftMongo(Guid Id, string Name, string Model, int ManufacturingYear, bool IsDeleted = false) : InternalCommand; + +internal class CreateAircraftMongoHandler : ICommandHandler +{ + private readonly FlightReadDbContext _flightReadDbContext; + private readonly IMapper _mapper; + + public CreateAircraftMongoHandler( + FlightReadDbContext flightReadDbContext, + IMapper mapper) + { + _flightReadDbContext = flightReadDbContext; + _mapper = mapper; + } + + public async Task Handle(CreateAircraftMongo request, CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var aircraftReadModel = _mapper.Map(request); + + var aircraft = await _flightReadDbContext.Aircraft.AsQueryable() + .FirstOrDefaultAsync(x => x.AircraftId == aircraftReadModel.AircraftId && + !x.IsDeleted, cancellationToken); + + if (aircraft is not null) + { + throw new AircraftAlreadyExistException(); + } + + await _flightReadDbContext.Aircraft.InsertOneAsync(aircraftReadModel, cancellationToken: cancellationToken); + + return Unit.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Models/Aircraft.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Models/Aircraft.cs new file mode 100644 index 0000000..ab71aeb --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Models/Aircraft.cs @@ -0,0 +1,35 @@ +using BuildingBlocks.Core.Model; + +namespace Flight.Aircrafts.Models; + +using Features.CreatingAircraft.V1; +using ValueObjects; + +public record Aircraft : Aggregate +{ + public Name Name { get; private set; } = default!; + public Model Model { get; private set; } = default!; + public ManufacturingYear ManufacturingYear { get; private set; } = default!; + + public static Aircraft Create(AircraftId id, Name name, Model model, ManufacturingYear manufacturingYear, bool isDeleted = false) + { + var aircraft = new Aircraft + { + Id = id, + Name = name, + Model = model, + ManufacturingYear = manufacturingYear + }; + + var @event = new AircraftCreatedDomainEvent( + aircraft.Id, + aircraft.Name, + aircraft.Model, + aircraft.ManufacturingYear, + isDeleted); + + aircraft.AddDomainEvent(@event); + + return aircraft; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Models/AircraftReadModel.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Models/AircraftReadModel.cs new file mode 100644 index 0000000..d80052e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/Models/AircraftReadModel.cs @@ -0,0 +1,13 @@ +namespace Flight.Aircrafts.Models; + +using System; + +public class AircraftReadModel +{ + public required Guid Id { get; init; } + public required Guid AircraftId { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } + public required int ManufacturingYear { get; init; } + public required bool IsDeleted { get; init; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/ValueObjects/AircraftId.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/ValueObjects/AircraftId.cs new file mode 100644 index 0000000..cc7e241 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/ValueObjects/AircraftId.cs @@ -0,0 +1,29 @@ +namespace Flight.Aircrafts.ValueObjects; + +using System; +using Flight.Aircrafts.Exceptions; + +public record AircraftId +{ + public Guid Value { get; } + + private AircraftId(Guid value) + { + Value = value; + } + + public static AircraftId Of(Guid value) + { + if (value == Guid.Empty) + { + throw new InvalidAircraftIdException(value); + } + + return new AircraftId(value); + } + + public static implicit operator Guid(AircraftId aircraftId) + { + return aircraftId.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/ValueObjects/ManufacturingYear.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/ValueObjects/ManufacturingYear.cs new file mode 100644 index 0000000..3ab80a5 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/ValueObjects/ManufacturingYear.cs @@ -0,0 +1,28 @@ +namespace Flight.Aircrafts.ValueObjects; + +using Exceptions; + +public record ManufacturingYear +{ + public int Value { get; } + + private ManufacturingYear(int value) + { + Value = value; + } + + public static ManufacturingYear Of(int value) + { + if (value < 1900) + { + throw new InvalidManufacturingYearException(); + } + + return new ManufacturingYear(value); + } + + public static implicit operator int(ManufacturingYear manufacturingYear) + { + return manufacturingYear.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/ValueObjects/Model.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/ValueObjects/Model.cs new file mode 100644 index 0000000..eae73f5 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/ValueObjects/Model.cs @@ -0,0 +1,28 @@ +namespace Flight.Aircrafts.ValueObjects; + +using Exceptions; + +public record Model +{ + public string Value { get; } + + private Model(string value) + { + Value = value; + } + + public static Model Of(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidModelException(); + } + + return new Model(value); + } + + public static implicit operator string(Model model) + { + return model.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/ValueObjects/Name.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/ValueObjects/Name.cs new file mode 100644 index 0000000..63a0fa4 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Aircrafts/ValueObjects/Name.cs @@ -0,0 +1,28 @@ +namespace Flight.Aircrafts.ValueObjects; + +using Exceptions; + +public record Name +{ + public string Value { get; } + + private Name(string value) + { + Value = value; + } + + public static Name Of(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidNameException(); + } + + return new Name(value); + } + + public static implicit operator string(Name name) + { + return name.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Dtos/AirportDto.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Dtos/AirportDto.cs new file mode 100644 index 0000000..6ff8797 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Dtos/AirportDto.cs @@ -0,0 +1,3 @@ +namespace Flight.Airports.Dtos; + +public record AirportDto(long Id, string Name, string Address, string Code); diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/AirportAlreadyExistException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/AirportAlreadyExistException.cs new file mode 100644 index 0000000..55cdb27 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/AirportAlreadyExistException.cs @@ -0,0 +1,10 @@ +namespace Flight.Airports.Exceptions; + +using BuildingBlocks.Exception; + +public class AirportAlreadyExistException : ConflictException +{ + public AirportAlreadyExistException(int? code = default) : base("Airport already exist!", code) + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/InvalidAddressException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/InvalidAddressException.cs new file mode 100644 index 0000000..1036a8b --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/InvalidAddressException.cs @@ -0,0 +1,9 @@ +namespace Flight.Airports.Exceptions; +using BuildingBlocks.Exception; + +public class InvalidAddressException : BadRequestException +{ + public InvalidAddressException() : base("Address cannot be empty or whitespace.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/InvalidAirportIdException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/InvalidAirportIdException.cs new file mode 100644 index 0000000..0b4b90e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/InvalidAirportIdException.cs @@ -0,0 +1,11 @@ +namespace Flight.Airports.Exceptions; +using System; +using BuildingBlocks.Exception; + +public class InvalidAirportIdException : BadRequestException +{ + public InvalidAirportIdException(Guid airportId) + : base($"airportId: '{airportId}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/InvalidCodeException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/InvalidCodeException.cs new file mode 100644 index 0000000..9b69b55 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/InvalidCodeException.cs @@ -0,0 +1,10 @@ +namespace Flight.Airports.Exceptions; +using BuildingBlocks.Exception; + + +public class InvalidCodeException : BadRequestException +{ + public InvalidCodeException() : base("Code cannot be empty or whitespace.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/InvalidNameException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/InvalidNameException.cs new file mode 100644 index 0000000..310ffb8 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Exceptions/InvalidNameException.cs @@ -0,0 +1,10 @@ +namespace Flight.Airports.Exceptions; +using BuildingBlocks.Exception; + + +public class InvalidNameException : BadRequestException +{ + public InvalidNameException() : base("Name cannot be empty or whitespace.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Features/AirportMappings.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Features/AirportMappings.cs new file mode 100644 index 0000000..5c7b7cd --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Features/AirportMappings.cs @@ -0,0 +1,23 @@ +namespace Flight.Airports.Features; + +using CreatingAirport.V1; +using Mapster; +using MassTransit; +using Models; + +public class AirportMappings : IRegister +{ + public void Register(TypeAdapterConfig config) + { + config.NewConfig() + .Map(d => d.Id, s => NewId.NextGuid()) + .Map(d => d.AirportId, s => s.Id); + + config.NewConfig() + .Map(d => d.Id, s => NewId.NextGuid()) + .Map(d => d.AirportId, s => s.Id.Value); + + config.NewConfig() + .ConstructUsing(x => new CreateAirport(x.Name, x.Address, x.Code)); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Features/CreatingAirport/V1/CreateAirport.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Features/CreatingAirport/V1/CreateAirport.cs new file mode 100644 index 0000000..580f9c7 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Features/CreatingAirport/V1/CreateAirport.cs @@ -0,0 +1,105 @@ +namespace Flight.Airports.Features.CreatingAirport.V1; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using BuildingBlocks.Web; +using Data; +using Duende.IdentityServer.EntityFramework.Entities; +using Exceptions; +using FluentValidation; +using Mapster; +using MapsterMapper; +using MassTransit; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using ValueObjects; + +public record CreateAirport(string Name, string Address, string Code) : ICommand, IInternalCommand +{ + public Guid Id { get; init; } = NewId.NextGuid(); +} + +public record CreateAirportResult(Guid Id); + +public record AirportCreatedDomainEvent + (Guid Id, string Name, string Address, string Code, bool IsDeleted) : IDomainEvent; + +public record CreateAirportRequestDto(string Name, string Address, string Code); + +public record CreateAirportResponseDto(Guid Id); + +public class CreateAirportEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPost($"{EndpointConfig.BaseApiPath}/flight/airport", async (CreateAirportRequestDto request, + IMediator mediator, IMapper mapper, + CancellationToken cancellationToken) => + { + var command = mapper.Map(request); + + var result = await mediator.Send(command, cancellationToken); + + var response = result.Adapt(); + + return Results.Ok(response); + }) + .RequireAuthorization(nameof(ApiScope)) + .WithName("CreateAirport") + .WithApiVersionSet(builder.NewApiVersionSet("Flight").Build()) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Create Airport") + .WithDescription("Create Airport") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } +} + +public class CreateAirportValidator : AbstractValidator +{ + public CreateAirportValidator() + { + RuleFor(x => x.Code).NotEmpty().WithMessage("Code is required"); + RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required"); + RuleFor(x => x.Address).NotEmpty().WithMessage("Address is required"); + } +} + +internal class CreateAirportHandler : IRequestHandler +{ + private readonly FlightDbContext _flightDbContext; + + public CreateAirportHandler(FlightDbContext flightDbContext) + { + _flightDbContext = flightDbContext; + } + + public async Task Handle(CreateAirport request, CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var airport = + await _flightDbContext.Airports.SingleOrDefaultAsync(x => x.Code.Value == request.Code, cancellationToken); + + if (airport is not null) + { + throw new AirportAlreadyExistException(); + } + + var airportEntity = Models.Airport.Create(AirportId.Of(request.Id), Name.Of(request.Name), Address.Of(request.Address), Code.Of(request.Code)); + + var newAirport = (await _flightDbContext.Airports.AddAsync(airportEntity, cancellationToken)).Entity; + + return new CreateAirportResult(newAirport.Id); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Features/CreatingAirport/V1/CreateAirportMongo.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Features/CreatingAirport/V1/CreateAirportMongo.cs new file mode 100644 index 0000000..ad1df5c --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Features/CreatingAirport/V1/CreateAirportMongo.cs @@ -0,0 +1,51 @@ +namespace Flight.Airports.Features.CreatingAirport.V1; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using Data; +using Exceptions; +using MapsterMapper; +using MediatR; +using Models; +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +public record CreateAirportMongo(Guid Id, string Name, string Address, string Code, bool IsDeleted = false) : InternalCommand; + +internal class CreateAirportMongoHandler : ICommandHandler +{ + private readonly FlightReadDbContext _flightReadDbContext; + private readonly IMapper _mapper; + + public CreateAirportMongoHandler( + FlightReadDbContext flightReadDbContext, + IMapper mapper) + { + _flightReadDbContext = flightReadDbContext; + _mapper = mapper; + } + + public async Task Handle(CreateAirportMongo request, CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var airportReadModel = _mapper.Map(request); + + var aircraft = await _flightReadDbContext.Airport.AsQueryable() + .FirstOrDefaultAsync(x => x.AirportId == airportReadModel.AirportId && + !x.IsDeleted, cancellationToken); + + if (aircraft is not null) + { + throw new AirportAlreadyExistException(); + } + + await _flightReadDbContext.Airport.InsertOneAsync(airportReadModel, cancellationToken: cancellationToken); + + return Unit.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Models/Airport.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Models/Airport.cs new file mode 100644 index 0000000..efe8120 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Models/Airport.cs @@ -0,0 +1,35 @@ +using BuildingBlocks.Core.Model; + +namespace Flight.Airports.Models; + +using Features.CreatingAirport.V1; +using ValueObjects; + +public record Airport : Aggregate +{ + public Name Name { get; private set; } = default!; + public Address Address { get; private set; } = default!; + public Code Code { get; private set; } = default!; + + public static Airport Create(AirportId id, Name name, Address address, Code code, bool isDeleted = false) + { + var airport = new Airport + { + Id = id, + Name = name, + Address = address, + Code = code + }; + + var @event = new AirportCreatedDomainEvent( + airport.Id, + airport.Name, + airport.Address, + airport.Code, + isDeleted); + + airport.AddDomainEvent(@event); + + return airport; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Models/AirportReadModel.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Models/AirportReadModel.cs new file mode 100644 index 0000000..14d5a32 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/Models/AirportReadModel.cs @@ -0,0 +1,13 @@ +namespace Flight.Airports.Models; + +using System; + +public class AirportReadModel +{ + public required Guid Id { get; init; } + public required Guid AirportId { get; init; } + public required string Name { get; init; } + public string Address { get; init; } + public required string Code { get; init; } + public required bool IsDeleted { get; init; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/ValueObjects/Address.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/ValueObjects/Address.cs new file mode 100644 index 0000000..04bbc7c --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/ValueObjects/Address.cs @@ -0,0 +1,28 @@ +namespace Flight.Airports.ValueObjects; + +using Exceptions; + +public class Address +{ + public string Value { get; } + + private Address(string value) + { + Value = value; + } + + public static Address Of(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidAddressException(); + } + + return new Address(value); + } + + public static implicit operator string(Address address) + { + return address.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/ValueObjects/AirportId.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/ValueObjects/AirportId.cs new file mode 100644 index 0000000..d1fd8ae --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/ValueObjects/AirportId.cs @@ -0,0 +1,29 @@ +namespace Flight.Airports.ValueObjects; + +using System; +using Flight.Airports.Exceptions; + +public record AirportId +{ + public Guid Value { get; } + + private AirportId(Guid value) + { + Value = value; + } + + public static AirportId Of(Guid value) + { + if (value == Guid.Empty) + { + throw new InvalidAirportIdException(value); + } + + return new AirportId(value); + } + + public static implicit operator Guid(AirportId airportId) + { + return airportId.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/ValueObjects/Code.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/ValueObjects/Code.cs new file mode 100644 index 0000000..f53c297 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/ValueObjects/Code.cs @@ -0,0 +1,28 @@ +namespace Flight.Airports.ValueObjects; + +using Exceptions; + +public record Code +{ + public string Value { get; } + + private Code(string value) + { + Value = value; + } + + public static Code Of(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidCodeException(); + } + + return new Code(value); + } + + public static implicit operator string(Code code) + { + return code.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/ValueObjects/Name.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/ValueObjects/Name.cs new file mode 100644 index 0000000..c66e500 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Airports/ValueObjects/Name.cs @@ -0,0 +1,28 @@ +namespace Flight.Airports.ValueObjects; + +using Exceptions; + +public record Name +{ + public string Value { get; } + + private Name(string value) + { + Value = value; + } + + public static Name Of(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidNameException(); + } + + return new Name(value); + } + + public static implicit operator string(Name name) + { + return name.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Configurations/AircraftConfiguration.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Configurations/AircraftConfiguration.cs new file mode 100644 index 0000000..cb0adfa --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Configurations/AircraftConfiguration.cs @@ -0,0 +1,56 @@ +using Flight.Aircrafts.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Flight.Data.Configurations; + +using System; +using Aircrafts.ValueObjects; + +public class AircraftConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + + builder.ToTable(nameof(Aircraft)); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).ValueGeneratedNever() + .HasConversion(aircraftId => aircraftId.Value, dbId => AircraftId.Of(dbId)); + + builder.Property(r => r.Version).IsConcurrencyToken(); + + builder.OwnsOne( + x => x.Name, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Aircraft.Name)) + .HasMaxLength(50) + .IsRequired(); + } + ); + + builder.OwnsOne( + x => x.Model, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Aircraft.Model)) + .HasMaxLength(50) + .IsRequired(); + } + ); + + builder.OwnsOne( + x => x.ManufacturingYear, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Aircraft.ManufacturingYear)) + .HasMaxLength(5) + .IsRequired(); + } + ); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Configurations/AirportConfiguration.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Configurations/AirportConfiguration.cs new file mode 100644 index 0000000..6570a2d --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Configurations/AirportConfiguration.cs @@ -0,0 +1,57 @@ +using System; +using Flight.Airports.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Flight.Data.Configurations; + +using Airports.ValueObjects; + +public class AirportConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + + builder.ToTable(nameof(Airport)); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).ValueGeneratedNever() + .HasConversion(airportId => airportId.Value, dbId => AirportId.Of(dbId)); + // // ref: https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=fluent-api + builder.Property(r => r.Version).IsConcurrencyToken(); + + + builder.OwnsOne( + x => x.Name, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Airport.Name)) + .HasMaxLength(50) + .IsRequired(); + } + ); + + builder.OwnsOne( + x => x.Address, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Airport.Address)) + .HasMaxLength(50) + .IsRequired(); + } + ); + + builder.OwnsOne( + x => x.Code, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Airport.Code)) + .HasMaxLength(50) + .IsRequired(); + } + ); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Configurations/FlightConfiguration.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Configurations/FlightConfiguration.cs new file mode 100644 index 0000000..18e75fe --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Configurations/FlightConfiguration.cs @@ -0,0 +1,113 @@ +using Flight.Aircrafts.Models; +using Flight.Airports.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Flight.Data.Configurations; + +using System; +using Flights.Models; +using Flights.ValueObjects; + +public class FlightConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(Flight)); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).ValueGeneratedNever() + .HasConversion(flight => flight.Value, dbId => FlightId.Of(dbId)); + + builder.Property(r => r.Version).IsConcurrencyToken(); + + + builder.OwnsOne( + x => x.FlightNumber, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Flight.FlightNumber)) + .HasMaxLength(50) + .IsRequired(); + } + ); + + builder + .HasOne() + .WithMany() + .HasForeignKey(p => p.AircraftId) + .IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(d => d.DepartureAirportId) + .IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(d => d.ArriveAirportId) + .IsRequired(); + + + builder.OwnsOne( + x => x.DurationMinutes, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Flight.DurationMinutes)) + .HasMaxLength(50) + .IsRequired(); + } + ); + + builder.Property(x => x.Status) + .HasDefaultValue(Flights.Enums.FlightStatus.Unknown) + .HasConversion( + x => x.ToString(), + x => (Flights.Enums.FlightStatus)Enum.Parse(typeof(Flights.Enums.FlightStatus), x)); + + builder.OwnsOne( + x => x.Price, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Flight.Price)) + .HasMaxLength(10) + .IsRequired(); + } + ); + + builder.OwnsOne( + x => x.ArriveDate, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Flight.ArriveDate)) + .IsRequired(); + } + ); + + builder.OwnsOne( + x => x.DepartureDate, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Flight.DepartureDate)) + .IsRequired(); + } + ); + + builder.OwnsOne( + x => x.FlightDate, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Flight.FlightDate)) + .IsRequired(); + } + ); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Configurations/SeatConfiguration.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Configurations/SeatConfiguration.cs new file mode 100644 index 0000000..ff8b995 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Configurations/SeatConfiguration.cs @@ -0,0 +1,50 @@ +using Flight.Seats.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Flight.Data.Configurations; + +using System; +using Seats.ValueObjects; + +public class SeatConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(Seat)); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).ValueGeneratedNever() + .HasConversion(seatId => seatId.Value, dbId => SeatId.Of(dbId)); + + builder.Property(r => r.Version).IsConcurrencyToken(); + + builder.OwnsOne( + x => x.SeatNumber, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Seat.SeatNumber)) + .HasMaxLength(50) + .IsRequired(); + } + ); + + builder + .HasOne() + .WithMany() + .HasForeignKey(p => p.FlightId); + + builder.Property(x => x.Class) + .HasDefaultValue(Seats.Enums.SeatClass.Unknown) + .HasConversion( + x => x.ToString(), + x => (Flight.Seats.Enums.SeatClass)Enum.Parse(typeof(Flight.Seats.Enums.SeatClass), x)); + + builder.Property(x => x.Type) + .HasDefaultValue(Seats.Enums.SeatType.Unknown) + .HasConversion( + x => x.ToString(), + x => (Flight.Seats.Enums.SeatType)Enum.Parse(typeof(Flight.Seats.Enums.SeatType), x)); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/DesignTimeDbContextFactory.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..b9a7a6c --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/DesignTimeDbContextFactory.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Flight.Data +{ + public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory + { + public FlightDbContext CreateDbContext(string[] args) + { + var builder = new DbContextOptionsBuilder(); + + builder.UseNpgsql("Server=localhost;Port=5432;Database=flight;User Id=postgres;Password=postgres;Include Error Detail=true") + .UseSnakeCaseNamingConvention(); + return new FlightDbContext(builder.Options); + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/EfTxFlightBehavior.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/EfTxFlightBehavior.cs new file mode 100644 index 0000000..33df7c6 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/EfTxFlightBehavior.cs @@ -0,0 +1,100 @@ +using System.Text.Json; +using System.Transactions; +using BuildingBlocks.Core; +using BuildingBlocks.PersistMessageProcessor; +using BuildingBlocks.Polly; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Flight.Data; + +public class EfTxFlightBehavior : IPipelineBehavior +where TRequest : notnull, IRequest +where TResponse : notnull +{ + private readonly ILogger> _logger; + private readonly FlightDbContext _flightDbContext; + private readonly IPersistMessageDbContext _persistMessageDbContext; + private readonly IEventDispatcher _eventDispatcher; + + public EfTxFlightBehavior( + ILogger> logger, + FlightDbContext flightDbContext, + IPersistMessageDbContext persistMessageDbContext, + IEventDispatcher eventDispatcher + ) + { + _logger = logger; + _flightDbContext = flightDbContext; + _persistMessageDbContext = persistMessageDbContext; + _eventDispatcher = eventDispatcher; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken + ) + { + _logger.LogInformation( + "{Prefix} Handled command {MediatrRequest}", + GetType().Name, + typeof(TRequest).FullName); + + _logger.LogDebug( + "{Prefix} Handled command {MediatrRequest} with content {RequestContent}", + GetType().Name, + typeof(TRequest).FullName, + JsonSerializer.Serialize(request)); + + var response = await next(); + + _logger.LogInformation( + "{Prefix} Executed the {MediatrRequest} request", + GetType().Name, + typeof(TRequest).FullName); + + while (true) + { + var domainEvents = _flightDbContext.GetDomainEvents(); + + if (domainEvents is null || !domainEvents.Any()) + { + return response; + } + + _logger.LogInformation( + "{Prefix} Open the transaction for {MediatrRequest}", + GetType().Name, + typeof(TRequest).FullName); + + using var scope = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _eventDispatcher.SendAsync( + domainEvents.ToArray(), + typeof(TRequest), + cancellationToken); + + // Save data to database with some retry policy in distributed transaction + await _flightDbContext.RetryOnFailure( + async () => + { + await _flightDbContext.SaveChangesAsync(cancellationToken); + }); + + // Save data to database with some retry policy in distributed transaction + await _persistMessageDbContext.RetryOnFailure( + async () => + { + await _persistMessageDbContext.SaveChangesAsync(cancellationToken); + }); + + scope.Complete(); + + return response; + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/FlightDbContext.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/FlightDbContext.cs new file mode 100644 index 0000000..8f9f77c --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/FlightDbContext.cs @@ -0,0 +1,32 @@ +using BuildingBlocks.EFCore; +using Flight.Aircrafts.Models; +using Flight.Airports.Models; +using Flight.Seats.Models; +using Microsoft.EntityFrameworkCore; + +namespace Flight.Data; + +using BuildingBlocks.Web; +using Microsoft.Extensions.Logging; + +public sealed class FlightDbContext : AppDbContextBase +{ + public FlightDbContext(DbContextOptions options, ICurrentUserProvider? currentUserProvider = null, + ILogger? logger = null) : base( + options, currentUserProvider, logger) + { + } + + public DbSet Flights => Set(); + public DbSet Airports => Set(); + public DbSet Aircraft => Set(); + public DbSet Seats => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.ApplyConfigurationsFromAssembly(typeof(FlightRoot).Assembly); + builder.FilterSoftDeletedProperties(); + builder.ToSnakeCaseTables(); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/FlightReadDbContext.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/FlightReadDbContext.cs new file mode 100644 index 0000000..0c63e27 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/FlightReadDbContext.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Mongo; +using Humanizer; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace Flight.Data; + +using Aircrafts.Models; +using Airports.Models; +using Flights.Models; +using Seats.Models; + +public class FlightReadDbContext : MongoDbContext +{ + public FlightReadDbContext(IOptions options) : base(options) + { + Flight = GetCollection(nameof(Flight).Underscore()); + Aircraft = GetCollection(nameof(Aircraft).Underscore()); + Airport = GetCollection(nameof(Airport).Underscore()); + Seat = GetCollection(nameof(Seat).Underscore()); + } + + public IMongoCollection Flight { get; } + public IMongoCollection Aircraft { get; } + public IMongoCollection Airport { get; } + public IMongoCollection Seat { get; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Migrations/20230611230948_initial.Designer.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Migrations/20230611230948_initial.Designer.cs new file mode 100644 index 0000000..4010517 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Migrations/20230611230948_initial.Designer.cs @@ -0,0 +1,583 @@ +// +using System; +using Flight.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Flight.Data.Migrations +{ + [DbContext(typeof(FlightDbContext))] + [Migration("20230611230948_initial")] + partial class initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Flight.Aircrafts.Models.Aircraft", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("LastModifiedBy") + .HasColumnType("bigint") + .HasColumnName("last_modified_by"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_aircraft"); + + b.ToTable("aircraft", (string)null); + }); + + modelBuilder.Entity("Flight.Airports.Models.Airport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("LastModifiedBy") + .HasColumnType("bigint") + .HasColumnName("last_modified_by"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_airport"); + + b.ToTable("airport", (string)null); + }); + + modelBuilder.Entity("Flight.Flights.Models.Flight", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AircraftId") + .HasColumnType("uuid") + .HasColumnName("aircraft_id"); + + b.Property("ArriveAirportId") + .HasColumnType("uuid") + .HasColumnName("arrive_airport_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasColumnName("created_by"); + + b.Property("DepartureAirportId") + .HasColumnType("uuid") + .HasColumnName("departure_airport_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("LastModifiedBy") + .HasColumnType("bigint") + .HasColumnName("last_modified_by"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Unknown") + .HasColumnName("status"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_flight"); + + b.HasIndex("AircraftId") + .HasDatabaseName("ix_flight_aircraft_id"); + + b.HasIndex("ArriveAirportId") + .HasDatabaseName("ix_flight_arrive_airport_id"); + + b.HasIndex("DepartureAirportId") + .HasDatabaseName("ix_flight_departure_airport_id"); + + b.ToTable("flight", (string)null); + }); + + modelBuilder.Entity("Flight.Seats.Models.Seat", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Class") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Unknown") + .HasColumnName("class"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasColumnName("created_by"); + + b.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("flight_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("LastModifiedBy") + .HasColumnType("bigint") + .HasColumnName("last_modified_by"); + + b.Property("Type") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Unknown") + .HasColumnName("type"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_seat"); + + b.HasIndex("FlightId") + .HasDatabaseName("ix_seat_flight_id"); + + b.ToTable("seat", (string)null); + }); + + modelBuilder.Entity("Flight.Aircrafts.Models.Aircraft", b => + { + b.OwnsOne("Flight.Aircrafts.ValueObjects.ManufacturingYear", "ManufacturingYear", b1 => + { + b1.Property("AircraftId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasMaxLength(5) + .HasColumnType("integer") + .HasColumnName("manufacturing_year"); + + b1.HasKey("AircraftId") + .HasName("pk_aircraft"); + + b1.ToTable("aircraft"); + + b1.WithOwner() + .HasForeignKey("AircraftId") + .HasConstraintName("fk_aircraft_aircraft_id"); + }); + + b.OwnsOne("Flight.Aircrafts.ValueObjects.Model", "Model", b1 => + { + b1.Property("AircraftId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("model"); + + b1.HasKey("AircraftId") + .HasName("pk_aircraft"); + + b1.ToTable("aircraft"); + + b1.WithOwner() + .HasForeignKey("AircraftId") + .HasConstraintName("fk_aircraft_aircraft_id"); + }); + + b.OwnsOne("Flight.Aircrafts.ValueObjects.Name", "Name", b1 => + { + b1.Property("AircraftId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b1.HasKey("AircraftId") + .HasName("pk_aircraft"); + + b1.ToTable("aircraft"); + + b1.WithOwner() + .HasForeignKey("AircraftId") + .HasConstraintName("fk_aircraft_aircraft_id"); + }); + + b.Navigation("ManufacturingYear") + .IsRequired(); + + b.Navigation("Model") + .IsRequired(); + + b.Navigation("Name") + .IsRequired(); + }); + + modelBuilder.Entity("Flight.Airports.Models.Airport", b => + { + b.OwnsOne("Flight.Airports.ValueObjects.Address", "Address", b1 => + { + b1.Property("AirportId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("address"); + + b1.HasKey("AirportId") + .HasName("pk_airport"); + + b1.ToTable("airport"); + + b1.WithOwner() + .HasForeignKey("AirportId") + .HasConstraintName("fk_airport_airport_id"); + }); + + b.OwnsOne("Flight.Airports.ValueObjects.Code", "Code", b1 => + { + b1.Property("AirportId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b1.HasKey("AirportId") + .HasName("pk_airport"); + + b1.ToTable("airport"); + + b1.WithOwner() + .HasForeignKey("AirportId") + .HasConstraintName("fk_airport_airport_id"); + }); + + b.OwnsOne("Flight.Airports.ValueObjects.Name", "Name", b1 => + { + b1.Property("AirportId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b1.HasKey("AirportId") + .HasName("pk_airport"); + + b1.ToTable("airport"); + + b1.WithOwner() + .HasForeignKey("AirportId") + .HasConstraintName("fk_airport_airport_id"); + }); + + b.Navigation("Address") + .IsRequired(); + + b.Navigation("Code") + .IsRequired(); + + b.Navigation("Name") + .IsRequired(); + }); + + modelBuilder.Entity("Flight.Flights.Models.Flight", b => + { + b.HasOne("Flight.Aircrafts.Models.Aircraft", null) + .WithMany() + .HasForeignKey("AircraftId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_flight_aircraft_aircraft_id"); + + b.HasOne("Flight.Airports.Models.Airport", null) + .WithMany() + .HasForeignKey("ArriveAirportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_flight_airport_arrive_airport_id"); + + b.HasOne("Flight.Airports.Models.Airport", null) + .WithMany() + .HasForeignKey("DepartureAirportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_flight_airport_departure_airport_id"); + + b.OwnsOne("Flight.Flights.ValueObjects.ArriveDate", "ArriveDate", b1 => + { + b1.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasColumnType("timestamp with time zone") + .HasColumnName("arrive_date"); + + b1.HasKey("FlightId") + .HasName("pk_flight"); + + b1.ToTable("flight"); + + b1.WithOwner() + .HasForeignKey("FlightId") + .HasConstraintName("fk_flight_flight_id"); + }); + + b.OwnsOne("Flight.Flights.ValueObjects.DepartureDate", "DepartureDate", b1 => + { + b1.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasColumnType("timestamp with time zone") + .HasColumnName("departure_date"); + + b1.HasKey("FlightId") + .HasName("pk_flight"); + + b1.ToTable("flight"); + + b1.WithOwner() + .HasForeignKey("FlightId") + .HasConstraintName("fk_flight_flight_id"); + }); + + b.OwnsOne("Flight.Flights.ValueObjects.DurationMinutes", "DurationMinutes", b1 => + { + b1.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasMaxLength(50) + .HasColumnType("numeric") + .HasColumnName("duration_minutes"); + + b1.HasKey("FlightId") + .HasName("pk_flight"); + + b1.ToTable("flight"); + + b1.WithOwner() + .HasForeignKey("FlightId") + .HasConstraintName("fk_flight_flight_id"); + }); + + b.OwnsOne("Flight.Flights.ValueObjects.FlightDate", "FlightDate", b1 => + { + b1.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasColumnType("timestamp with time zone") + .HasColumnName("flight_date"); + + b1.HasKey("FlightId") + .HasName("pk_flight"); + + b1.ToTable("flight"); + + b1.WithOwner() + .HasForeignKey("FlightId") + .HasConstraintName("fk_flight_flight_id"); + }); + + b.OwnsOne("Flight.Flights.ValueObjects.FlightNumber", "FlightNumber", b1 => + { + b1.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("flight_number"); + + b1.HasKey("FlightId") + .HasName("pk_flight"); + + b1.ToTable("flight"); + + b1.WithOwner() + .HasForeignKey("FlightId") + .HasConstraintName("fk_flight_flight_id"); + }); + + b.OwnsOne("Flight.Flights.ValueObjects.Price", "Price", b1 => + { + b1.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasMaxLength(10) + .HasColumnType("numeric") + .HasColumnName("price"); + + b1.HasKey("FlightId") + .HasName("pk_flight"); + + b1.ToTable("flight"); + + b1.WithOwner() + .HasForeignKey("FlightId") + .HasConstraintName("fk_flight_flight_id"); + }); + + b.Navigation("ArriveDate") + .IsRequired(); + + b.Navigation("DepartureDate") + .IsRequired(); + + b.Navigation("DurationMinutes") + .IsRequired(); + + b.Navigation("FlightDate") + .IsRequired(); + + b.Navigation("FlightNumber") + .IsRequired(); + + b.Navigation("Price") + .IsRequired(); + }); + + modelBuilder.Entity("Flight.Seats.Models.Seat", b => + { + b.HasOne("Flight.Flights.Models.Flight", null) + .WithMany() + .HasForeignKey("FlightId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seat_flight_flight_id"); + + b.OwnsOne("Flight.Seats.ValueObjects.SeatNumber", "SeatNumber", b1 => + { + b1.Property("SeatId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("seat_number"); + + b1.HasKey("SeatId") + .HasName("pk_seat"); + + b1.ToTable("seat"); + + b1.WithOwner() + .HasForeignKey("SeatId") + .HasConstraintName("fk_seat_seat_id"); + }); + + b.Navigation("SeatNumber") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Migrations/20230611230948_initial.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Migrations/20230611230948_initial.cs new file mode 100644 index 0000000..630f49f --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Migrations/20230611230948_initial.cs @@ -0,0 +1,163 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flight.Data.Migrations +{ + /// + public partial class initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "aircraft", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + model = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + manufacturingyear = table.Column(name: "manufacturing_year", type: "integer", maxLength: 5, nullable: false), + createdat = table.Column(name: "created_at", type: "timestamp with time zone", nullable: true), + createdby = table.Column(name: "created_by", type: "bigint", nullable: true), + lastmodified = table.Column(name: "last_modified", type: "timestamp with time zone", nullable: true), + lastmodifiedby = table.Column(name: "last_modified_by", type: "bigint", nullable: true), + isdeleted = table.Column(name: "is_deleted", type: "boolean", nullable: false), + version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_aircraft", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "airport", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + address = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + createdat = table.Column(name: "created_at", type: "timestamp with time zone", nullable: true), + createdby = table.Column(name: "created_by", type: "bigint", nullable: true), + lastmodified = table.Column(name: "last_modified", type: "timestamp with time zone", nullable: true), + lastmodifiedby = table.Column(name: "last_modified_by", type: "bigint", nullable: true), + isdeleted = table.Column(name: "is_deleted", type: "boolean", nullable: false), + version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_airport", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "flight", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + flightnumber = table.Column(name: "flight_number", type: "character varying(50)", maxLength: 50, nullable: false), + aircraftid = table.Column(name: "aircraft_id", type: "uuid", nullable: false), + departureairportid = table.Column(name: "departure_airport_id", type: "uuid", nullable: false), + arriveairportid = table.Column(name: "arrive_airport_id", type: "uuid", nullable: false), + durationminutes = table.Column(name: "duration_minutes", type: "numeric", maxLength: 50, nullable: false), + status = table.Column(type: "text", nullable: false, defaultValue: "Unknown"), + price = table.Column(type: "numeric", maxLength: 10, nullable: false), + arrivedate = table.Column(name: "arrive_date", type: "timestamp with time zone", nullable: false), + departuredate = table.Column(name: "departure_date", type: "timestamp with time zone", nullable: false), + flightdate = table.Column(name: "flight_date", type: "timestamp with time zone", nullable: false), + createdat = table.Column(name: "created_at", type: "timestamp with time zone", nullable: true), + createdby = table.Column(name: "created_by", type: "bigint", nullable: true), + lastmodified = table.Column(name: "last_modified", type: "timestamp with time zone", nullable: true), + lastmodifiedby = table.Column(name: "last_modified_by", type: "bigint", nullable: true), + isdeleted = table.Column(name: "is_deleted", type: "boolean", nullable: false), + version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_flight", x => x.id); + table.ForeignKey( + name: "fk_flight_aircraft_aircraft_id", + column: x => x.aircraftid, + principalTable: "aircraft", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_flight_airport_arrive_airport_id", + column: x => x.arriveairportid, + principalTable: "airport", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_flight_airport_departure_airport_id", + column: x => x.departureairportid, + principalTable: "airport", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "seat", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + seatnumber = table.Column(name: "seat_number", type: "character varying(50)", maxLength: 50, nullable: false), + type = table.Column(type: "text", nullable: false, defaultValue: "Unknown"), + @class = table.Column(name: "class", type: "text", nullable: false, defaultValue: "Unknown"), + flightid = table.Column(name: "flight_id", type: "uuid", nullable: false), + createdat = table.Column(name: "created_at", type: "timestamp with time zone", nullable: true), + createdby = table.Column(name: "created_by", type: "bigint", nullable: true), + lastmodified = table.Column(name: "last_modified", type: "timestamp with time zone", nullable: true), + lastmodifiedby = table.Column(name: "last_modified_by", type: "bigint", nullable: true), + isdeleted = table.Column(name: "is_deleted", type: "boolean", nullable: false), + version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_seat", x => x.id); + table.ForeignKey( + name: "fk_seat_flight_flight_id", + column: x => x.flightid, + principalTable: "flight", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_flight_aircraft_id", + table: "flight", + column: "aircraft_id"); + + migrationBuilder.CreateIndex( + name: "ix_flight_arrive_airport_id", + table: "flight", + column: "arrive_airport_id"); + + migrationBuilder.CreateIndex( + name: "ix_flight_departure_airport_id", + table: "flight", + column: "departure_airport_id"); + + migrationBuilder.CreateIndex( + name: "ix_seat_flight_id", + table: "seat", + column: "flight_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "seat"); + + migrationBuilder.DropTable( + name: "flight"); + + migrationBuilder.DropTable( + name: "aircraft"); + + migrationBuilder.DropTable( + name: "airport"); + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Migrations/FlightDbContextModelSnapshot.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Migrations/FlightDbContextModelSnapshot.cs new file mode 100644 index 0000000..930ce7a --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Migrations/FlightDbContextModelSnapshot.cs @@ -0,0 +1,580 @@ +// +using System; +using Flight.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Flight.Data.Migrations +{ + [DbContext(typeof(FlightDbContext))] + partial class FlightDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Flight.Aircrafts.Models.Aircraft", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("LastModifiedBy") + .HasColumnType("bigint") + .HasColumnName("last_modified_by"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_aircraft"); + + b.ToTable("aircraft", (string)null); + }); + + modelBuilder.Entity("Flight.Airports.Models.Airport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("LastModifiedBy") + .HasColumnType("bigint") + .HasColumnName("last_modified_by"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_airport"); + + b.ToTable("airport", (string)null); + }); + + modelBuilder.Entity("Flight.Flights.Models.Flight", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AircraftId") + .HasColumnType("uuid") + .HasColumnName("aircraft_id"); + + b.Property("ArriveAirportId") + .HasColumnType("uuid") + .HasColumnName("arrive_airport_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasColumnName("created_by"); + + b.Property("DepartureAirportId") + .HasColumnType("uuid") + .HasColumnName("departure_airport_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("LastModifiedBy") + .HasColumnType("bigint") + .HasColumnName("last_modified_by"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Unknown") + .HasColumnName("status"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_flight"); + + b.HasIndex("AircraftId") + .HasDatabaseName("ix_flight_aircraft_id"); + + b.HasIndex("ArriveAirportId") + .HasDatabaseName("ix_flight_arrive_airport_id"); + + b.HasIndex("DepartureAirportId") + .HasDatabaseName("ix_flight_departure_airport_id"); + + b.ToTable("flight", (string)null); + }); + + modelBuilder.Entity("Flight.Seats.Models.Seat", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Class") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Unknown") + .HasColumnName("class"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasColumnName("created_by"); + + b.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("flight_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("LastModifiedBy") + .HasColumnType("bigint") + .HasColumnName("last_modified_by"); + + b.Property("Type") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Unknown") + .HasColumnName("type"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_seat"); + + b.HasIndex("FlightId") + .HasDatabaseName("ix_seat_flight_id"); + + b.ToTable("seat", (string)null); + }); + + modelBuilder.Entity("Flight.Aircrafts.Models.Aircraft", b => + { + b.OwnsOne("Flight.Aircrafts.ValueObjects.ManufacturingYear", "ManufacturingYear", b1 => + { + b1.Property("AircraftId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasMaxLength(5) + .HasColumnType("integer") + .HasColumnName("manufacturing_year"); + + b1.HasKey("AircraftId") + .HasName("pk_aircraft"); + + b1.ToTable("aircraft"); + + b1.WithOwner() + .HasForeignKey("AircraftId") + .HasConstraintName("fk_aircraft_aircraft_id"); + }); + + b.OwnsOne("Flight.Aircrafts.ValueObjects.Model", "Model", b1 => + { + b1.Property("AircraftId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("model"); + + b1.HasKey("AircraftId") + .HasName("pk_aircraft"); + + b1.ToTable("aircraft"); + + b1.WithOwner() + .HasForeignKey("AircraftId") + .HasConstraintName("fk_aircraft_aircraft_id"); + }); + + b.OwnsOne("Flight.Aircrafts.ValueObjects.Name", "Name", b1 => + { + b1.Property("AircraftId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b1.HasKey("AircraftId") + .HasName("pk_aircraft"); + + b1.ToTable("aircraft"); + + b1.WithOwner() + .HasForeignKey("AircraftId") + .HasConstraintName("fk_aircraft_aircraft_id"); + }); + + b.Navigation("ManufacturingYear") + .IsRequired(); + + b.Navigation("Model") + .IsRequired(); + + b.Navigation("Name") + .IsRequired(); + }); + + modelBuilder.Entity("Flight.Airports.Models.Airport", b => + { + b.OwnsOne("Flight.Airports.ValueObjects.Address", "Address", b1 => + { + b1.Property("AirportId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("address"); + + b1.HasKey("AirportId") + .HasName("pk_airport"); + + b1.ToTable("airport"); + + b1.WithOwner() + .HasForeignKey("AirportId") + .HasConstraintName("fk_airport_airport_id"); + }); + + b.OwnsOne("Flight.Airports.ValueObjects.Code", "Code", b1 => + { + b1.Property("AirportId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b1.HasKey("AirportId") + .HasName("pk_airport"); + + b1.ToTable("airport"); + + b1.WithOwner() + .HasForeignKey("AirportId") + .HasConstraintName("fk_airport_airport_id"); + }); + + b.OwnsOne("Flight.Airports.ValueObjects.Name", "Name", b1 => + { + b1.Property("AirportId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b1.HasKey("AirportId") + .HasName("pk_airport"); + + b1.ToTable("airport"); + + b1.WithOwner() + .HasForeignKey("AirportId") + .HasConstraintName("fk_airport_airport_id"); + }); + + b.Navigation("Address") + .IsRequired(); + + b.Navigation("Code") + .IsRequired(); + + b.Navigation("Name") + .IsRequired(); + }); + + modelBuilder.Entity("Flight.Flights.Models.Flight", b => + { + b.HasOne("Flight.Aircrafts.Models.Aircraft", null) + .WithMany() + .HasForeignKey("AircraftId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_flight_aircraft_aircraft_id"); + + b.HasOne("Flight.Airports.Models.Airport", null) + .WithMany() + .HasForeignKey("ArriveAirportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_flight_airport_arrive_airport_id"); + + b.HasOne("Flight.Airports.Models.Airport", null) + .WithMany() + .HasForeignKey("DepartureAirportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_flight_airport_departure_airport_id"); + + b.OwnsOne("Flight.Flights.ValueObjects.ArriveDate", "ArriveDate", b1 => + { + b1.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasColumnType("timestamp with time zone") + .HasColumnName("arrive_date"); + + b1.HasKey("FlightId") + .HasName("pk_flight"); + + b1.ToTable("flight"); + + b1.WithOwner() + .HasForeignKey("FlightId") + .HasConstraintName("fk_flight_flight_id"); + }); + + b.OwnsOne("Flight.Flights.ValueObjects.DepartureDate", "DepartureDate", b1 => + { + b1.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasColumnType("timestamp with time zone") + .HasColumnName("departure_date"); + + b1.HasKey("FlightId") + .HasName("pk_flight"); + + b1.ToTable("flight"); + + b1.WithOwner() + .HasForeignKey("FlightId") + .HasConstraintName("fk_flight_flight_id"); + }); + + b.OwnsOne("Flight.Flights.ValueObjects.DurationMinutes", "DurationMinutes", b1 => + { + b1.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasMaxLength(50) + .HasColumnType("numeric") + .HasColumnName("duration_minutes"); + + b1.HasKey("FlightId") + .HasName("pk_flight"); + + b1.ToTable("flight"); + + b1.WithOwner() + .HasForeignKey("FlightId") + .HasConstraintName("fk_flight_flight_id"); + }); + + b.OwnsOne("Flight.Flights.ValueObjects.FlightDate", "FlightDate", b1 => + { + b1.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasColumnType("timestamp with time zone") + .HasColumnName("flight_date"); + + b1.HasKey("FlightId") + .HasName("pk_flight"); + + b1.ToTable("flight"); + + b1.WithOwner() + .HasForeignKey("FlightId") + .HasConstraintName("fk_flight_flight_id"); + }); + + b.OwnsOne("Flight.Flights.ValueObjects.FlightNumber", "FlightNumber", b1 => + { + b1.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("flight_number"); + + b1.HasKey("FlightId") + .HasName("pk_flight"); + + b1.ToTable("flight"); + + b1.WithOwner() + .HasForeignKey("FlightId") + .HasConstraintName("fk_flight_flight_id"); + }); + + b.OwnsOne("Flight.Flights.ValueObjects.Price", "Price", b1 => + { + b1.Property("FlightId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasMaxLength(10) + .HasColumnType("numeric") + .HasColumnName("price"); + + b1.HasKey("FlightId") + .HasName("pk_flight"); + + b1.ToTable("flight"); + + b1.WithOwner() + .HasForeignKey("FlightId") + .HasConstraintName("fk_flight_flight_id"); + }); + + b.Navigation("ArriveDate") + .IsRequired(); + + b.Navigation("DepartureDate") + .IsRequired(); + + b.Navigation("DurationMinutes") + .IsRequired(); + + b.Navigation("FlightDate") + .IsRequired(); + + b.Navigation("FlightNumber") + .IsRequired(); + + b.Navigation("Price") + .IsRequired(); + }); + + modelBuilder.Entity("Flight.Seats.Models.Seat", b => + { + b.HasOne("Flight.Flights.Models.Flight", null) + .WithMany() + .HasForeignKey("FlightId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seat_flight_flight_id"); + + b.OwnsOne("Flight.Seats.ValueObjects.SeatNumber", "SeatNumber", b1 => + { + b1.Property("SeatId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("seat_number"); + + b1.HasKey("SeatId") + .HasName("pk_seat"); + + b1.ToTable("seat"); + + b1.WithOwner() + .HasForeignKey("SeatId") + .HasConstraintName("fk_seat_seat_id"); + }); + + b.Navigation("SeatNumber") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Seed/FlightDataSeeder.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Seed/FlightDataSeeder.cs new file mode 100644 index 0000000..e2d8cd9 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Seed/FlightDataSeeder.cs @@ -0,0 +1,88 @@ +using BuildingBlocks.EFCore; +using Flight.Aircrafts.Models; +using Flight.Airports.Models; +using Flight.Flights.Models; +using Flight.Seats.Models; +using MapsterMapper; +using Microsoft.EntityFrameworkCore; +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +namespace Flight.Data.Seed; + +public class FlightDataSeeder( + FlightDbContext flightDbContext, + FlightReadDbContext flightReadDbContext, + IMapper mapper +) : IDataSeeder +{ + public async Task SeedAllAsync() + { + var pendingMigrations = await flightDbContext.Database.GetPendingMigrationsAsync(); + + if (!pendingMigrations.Any()) + { + await SeedAirportAsync(); + await SeedAircraftAsync(); + await SeedFlightAsync(); + await SeedSeatAsync(); + } + } + + private async Task SeedAirportAsync() + { + if (!await EntityFrameworkQueryableExtensions.AnyAsync(flightDbContext.Airports)) + { + await flightDbContext.Airports.AddRangeAsync(InitialData.Airports); + await flightDbContext.SaveChangesAsync(); + + if (!await MongoQueryable.AnyAsync(flightReadDbContext.Airport.AsQueryable())) + { + await flightReadDbContext.Airport.InsertManyAsync(mapper.Map>(InitialData.Airports)); + } + } + } + + private async Task SeedAircraftAsync() + { + if (!await EntityFrameworkQueryableExtensions.AnyAsync(flightDbContext.Aircraft)) + { + await flightDbContext.Aircraft.AddRangeAsync(InitialData.Aircrafts); + await flightDbContext.SaveChangesAsync(); + + if (!await MongoQueryable.AnyAsync(flightReadDbContext.Aircraft.AsQueryable())) + { + await flightReadDbContext.Aircraft.InsertManyAsync(mapper.Map>(InitialData.Aircrafts)); + } + } + } + + + private async Task SeedSeatAsync() + { + if (!await EntityFrameworkQueryableExtensions.AnyAsync(flightDbContext.Seats)) + { + await flightDbContext.Seats.AddRangeAsync(InitialData.Seats); + await flightDbContext.SaveChangesAsync(); + + if (!await MongoQueryable.AnyAsync(flightReadDbContext.Seat.AsQueryable())) + { + await flightReadDbContext.Seat.InsertManyAsync(mapper.Map>(InitialData.Seats)); + } + } + } + + private async Task SeedFlightAsync() + { + if (!await EntityFrameworkQueryableExtensions.AnyAsync(flightDbContext.Flights)) + { + await flightDbContext.Flights.AddRangeAsync(InitialData.Flights); + await flightDbContext.SaveChangesAsync(); + + if (!await MongoQueryable.AnyAsync(flightReadDbContext.Flight.AsQueryable())) + { + await flightReadDbContext.Flight.InsertManyAsync(mapper.Map>(InitialData.Flights)); + } + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Seed/InitialData.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Seed/InitialData.cs new file mode 100644 index 0000000..695251e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/Seed/InitialData.cs @@ -0,0 +1,61 @@ +namespace Flight.Data.Seed; + +using System; +using System.Collections.Generic; +using System.Linq; +using Aircrafts.Models; +using Airports.Models; +using Airports.ValueObjects; +using Flight.Aircrafts.ValueObjects; +using Flights.Models; +using Flights.ValueObjects; +using MassTransit; +using Seats.Models; +using Seats.ValueObjects; +using AirportName = Airports.ValueObjects.Name; +using Name = Aircrafts.ValueObjects.Name; + +public static class InitialData +{ + public static List Airports { get; } + public static List Aircrafts { get; } + public static List Seats { get; } + public static List Flights { get; } + + + static InitialData() + { + Airports = new List + { + Airport.Create(AirportId.Of(new Guid("3c5c0000-97c6-fc34-a0cb-08db322230c8")), AirportName.Of("Lisbon International Airport"), Address.Of("LIS"), Code.Of("12988")), + Airport.Create(AirportId.Of(new Guid("3c5c0000-97c6-fc34-fc3c-08db322230c8")), AirportName.Of("Sao Paulo International Airport"), Address.Of("BRZ"), Code.Of("11200")) + }; + + Aircrafts = new List + { + Aircraft.Create(AircraftId.Of(new Guid("3c5c0000-97c6-fc34-fcd3-08db322230c8")), Name.Of("Boeing 737"), Model.Of("B737"), ManufacturingYear.Of(2005)), + Aircraft.Create(AircraftId.Of(new Guid("3c5c0000-97c6-fc34-2e04-08db322230c9")), Name.Of("Airbus 300"), Model.Of("A300"), ManufacturingYear.Of(2000)), + Aircraft.Create(AircraftId.Of(new Guid("3c5c0000-97c6-fc34-2e11-08db322230c9")), Name.Of("Airbus 320"), Model.Of("A320"), ManufacturingYear.Of(2003)) + }; + + + Flights = new List + { + Flight.Create(FlightId.Of(new Guid("3c5c0000-97c6-fc34-2eb9-08db322230c9")), FlightNumber.Of("BD467"), AircraftId.Of(Aircrafts.First().Id.Value), AirportId.Of( Airports.First().Id), DepartureDate.Of(new DateTime(2022, 1, 31, 12, 0, 0)), + ArriveDate.Of(new DateTime(2022, 1, 31, 14, 0, 0)), + AirportId.Of(Airports.Last().Id), DurationMinutes.Of(120m), + FlightDate.Of(new DateTime(2022, 1, 31, 13, 0, 0)), global::Flight.Flights.Enums.FlightStatus.Completed, + Price.Of(8000)) + }; + + Seats = new List + { + Seat.Create(SeatId.Of(NewId.NextGuid()), SeatNumber.Of( "12A"), global::Flight.Seats.Enums.SeatType.Window, global::Flight.Seats.Enums.SeatClass.Economy, FlightId.Of((Guid)Flights.First().Id)), + Seat.Create(SeatId.Of(NewId.NextGuid()), SeatNumber.Of("12B"), global::Flight.Seats.Enums.SeatType.Window, global::Flight.Seats.Enums.SeatClass.Economy, FlightId.Of((Guid)Flights.First().Id)), + Seat.Create(SeatId.Of(NewId.NextGuid()), SeatNumber.Of("12C"), global::Flight.Seats.Enums.SeatType.Middle, global::Flight.Seats.Enums.SeatClass.Economy, FlightId.Of((Guid) Flights.First().Id)), + Seat.Create(SeatId.Of(NewId.NextGuid()), SeatNumber.Of("12D"), global::Flight.Seats.Enums.SeatType.Middle, global::Flight.Seats.Enums.SeatClass.Economy, FlightId.Of((Guid) Flights.First().Id)), + Seat.Create(SeatId.Of(NewId.NextGuid()), SeatNumber.Of("12E"), global::Flight.Seats.Enums.SeatType.Aisle, global::Flight.Seats.Enums.SeatClass.Economy, FlightId.Of((Guid) Flights.First().Id)), + Seat.Create(SeatId.Of(NewId.NextGuid()), SeatNumber.Of("12F"), global::Flight.Seats.Enums.SeatType.Aisle, global::Flight.Seats.Enums.SeatClass.Economy, FlightId.Of((Guid) Flights.First().Id)) + }; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/readme.md b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/readme.md new file mode 100644 index 0000000..f47e679 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Data/readme.md @@ -0,0 +1,2 @@ +dotnet ef migrations add initial --context FlightDbContext -o "Data\Migrations" +dotnet ef database update --context FlightDbContext diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/EventMapper.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/EventMapper.cs new file mode 100644 index 0000000..9035417 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/EventMapper.cs @@ -0,0 +1,51 @@ +using BuildingBlocks.Contracts.EventBus.Messages; +using BuildingBlocks.Core; +using BuildingBlocks.Core.Event; + +namespace Flight; + +using Aircrafts.Features.CreatingAircraft.V1; +using Aircrafts.ValueObjects; +using Airports.Features.CreatingAirport.V1; +using Flights.Features.CreatingFlight.V1; +using Flights.Features.DeletingFlight.V1; +using Flights.Features.UpdatingFlight.V1; +using Seats.Features.CreatingSeat.V1; +using Seats.Features.ReservingSeat.V1; + +// ref: https://www.ledjonbehluli.com/posts/domain_to_integration_event/ +public sealed class EventMapper : IEventMapper +{ + public IIntegrationEvent? MapToIntegrationEvent(IDomainEvent @event) + { + return @event switch + { + FlightCreatedDomainEvent e => new FlightCreated(e.Id), + FlightUpdatedDomainEvent e => new FlightUpdated(e.Id), + FlightDeletedDomainEvent e => new FlightDeleted(e.Id), + AirportCreatedDomainEvent e => new AirportCreated(e.Id), + AircraftCreatedDomainEvent e => new AircraftCreated(e.Id), + SeatCreatedDomainEvent e => new SeatCreated(e.Id), + SeatReservedDomainEvent e => new SeatReserved(e.Id), + _ => null + }; + } + + public IInternalCommand? MapToInternalCommand(IDomainEvent @event) + { + return @event switch + { + FlightCreatedDomainEvent e => new CreateFlightMongo(e.Id, e.FlightNumber, e.AircraftId, e.DepartureDate, e.DepartureAirportId, + e.ArriveDate, e.ArriveAirportId, e.DurationMinutes, e.FlightDate, e.Status, e.Price, e.IsDeleted), + FlightUpdatedDomainEvent e => new UpdateFlightMongo(e.Id, e.FlightNumber, e.AircraftId, e.DepartureDate, e.DepartureAirportId, + e.ArriveDate, e.ArriveAirportId, e.DurationMinutes, e.FlightDate, e.Status, e.Price, e.IsDeleted), + FlightDeletedDomainEvent e => new DeleteFlightMongo(e.Id, e.FlightNumber, e.AircraftId, e.DepartureDate, e.DepartureAirportId, + e.ArriveDate, e.ArriveAirportId, e.DurationMinutes, e.FlightDate, e.Status, e.Price, e.IsDeleted), + AircraftCreatedDomainEvent e => new CreateAircraftMongo(e.Id, e.Name, e.Model, e.ManufacturingYear, e.IsDeleted), + AirportCreatedDomainEvent e => new CreateAirportMongo(e.Id, e.Name, e.Address, e.Code, e.IsDeleted), + SeatCreatedDomainEvent e => new CreateSeatMongo(e.Id, e.SeatNumber, e.Type, e.Class, e.FlightId, e.IsDeleted), + SeatReservedDomainEvent e => new ReserveSeatMongo(e.Id, e.SeatNumber, e.Type, e.Class, e.FlightId, e.IsDeleted), + _ => null + }; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Extensions/Infrastructure/InfrastructureExtensions.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Extensions/Infrastructure/InfrastructureExtensions.cs new file mode 100644 index 0000000..dbbb17f --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Extensions/Infrastructure/InfrastructureExtensions.cs @@ -0,0 +1,35 @@ +using BuildingBlocks.Core; +using BuildingBlocks.EFCore; +using BuildingBlocks.Mongo; +using Flight.Data; +using Flight.Data.Seed; +using Flight.GrpcServer.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Flight.Extensions.Infrastructure; + + +public static class InfrastructureExtensions +{ + public static WebApplicationBuilder AddFlightModules(this WebApplicationBuilder builder) + { + builder.Services.AddScoped(); + builder.AddCustomDbContext(nameof(Flight)); + builder.Services.AddScoped(); + builder.AddMongoDbContext(); + + builder.Services.AddCustomMediatR(); + + return builder; + } + + + public static WebApplication UseFlightModules(this WebApplication app) + { + app.UseMigration(); + app.MapGrpcService(); + + return app; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Extensions/Infrastructure/MediatRExtensions.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Extensions/Infrastructure/MediatRExtensions.cs new file mode 100644 index 0000000..7d4eeb5 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Extensions/Infrastructure/MediatRExtensions.cs @@ -0,0 +1,23 @@ +using BuildingBlocks.Caching; +using BuildingBlocks.Logging; +using BuildingBlocks.Validation; +using Flight.Data; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace Flight.Extensions.Infrastructure; + +public static class MediatRExtensions +{ + public static IServiceCollection AddCustomMediatR(this IServiceCollection services) + { + services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(FlightRoot).Assembly)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); + // services.AddScoped(typeof(IPipelineBehavior<,>), typeof(EfTxFlightBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(InvalidateCachingBehavior<,>)); + + return services; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flight.csproj b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flight.csproj new file mode 100644 index 0000000..6781597 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flight.csproj @@ -0,0 +1,28 @@ + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/FlightRoot.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/FlightRoot.cs new file mode 100644 index 0000000..5fe2291 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/FlightRoot.cs @@ -0,0 +1,6 @@ +namespace Flight; + +public class FlightRoot +{ + +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Dtos/FlightDto.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Dtos/FlightDto.cs new file mode 100644 index 0000000..1da222e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Dtos/FlightDto.cs @@ -0,0 +1,6 @@ +using System; + +namespace Flight.Flights.Dtos; +public record FlightDto(Guid Id, string FlightNumber, Guid AircraftId, Guid DepartureAirportId, + DateTime DepartureDate, DateTime ArriveDate, Guid ArriveAirportId, decimal DurationMinutes, DateTime FlightDate, + Enums.FlightStatus Status, decimal Price); diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Enums/FlightStatus.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Enums/FlightStatus.cs new file mode 100644 index 0000000..14de1d3 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Enums/FlightStatus.cs @@ -0,0 +1,10 @@ +namespace Flight.Flights.Enums; + +public enum FlightStatus +{ + Unknown = 0, + Flying = 1, + Delay = 2, + Canceled = 3, + Completed = 4 +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/FlightAlreadyExistException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/FlightAlreadyExistException.cs new file mode 100644 index 0000000..a3622ef --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/FlightAlreadyExistException.cs @@ -0,0 +1,10 @@ +namespace Flight.Flights.Exceptions; + +using BuildingBlocks.Exception; + +public class FlightAlreadyExistException : ConflictException +{ + public FlightAlreadyExistException(int? code = default) : base("Flight already exist!", code) + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/FlightExceptions.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/FlightExceptions.cs new file mode 100644 index 0000000..0e425c4 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/FlightExceptions.cs @@ -0,0 +1,14 @@ +namespace Flight.Flights.Exceptions; +using System; +using BuildingBlocks.Exception; + +public class FlightExceptions : BadRequestException +{ + public FlightExceptions(DateTime departureDate, DateTime arriveDate) : + base($"Departure date: '{departureDate}' must be before arrive date: '{arriveDate}'.") + { } + + public FlightExceptions(DateTime flightDate) : + base($"Flight date: '{flightDate}' must be between departure and arrive dates.") + { } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/FlightNotFountException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/FlightNotFountException.cs new file mode 100644 index 0000000..8198fbd --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/FlightNotFountException.cs @@ -0,0 +1,10 @@ +using BuildingBlocks.Exception; + +namespace Flight.Flights.Exceptions; + +public class FlightNotFountException : NotFoundException +{ + public FlightNotFountException() : base("Flight not found!") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidArriveDateException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidArriveDateException.cs new file mode 100644 index 0000000..b4cf65b --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidArriveDateException.cs @@ -0,0 +1,11 @@ +namespace Flight.Flights.Exceptions; +using System; +using BuildingBlocks.Exception; + +public class InvalidArriveDateException : BadRequestException +{ + public InvalidArriveDateException(DateTime arriveDate) + : base($"Arrive Date: '{arriveDate}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidDepartureDateException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidDepartureDateException.cs new file mode 100644 index 0000000..6625d57 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidDepartureDateException.cs @@ -0,0 +1,11 @@ +namespace Flight.Flights.Exceptions; +using System; +using BuildingBlocks.Exception; + +public class InvalidDepartureDateException : BadRequestException +{ + public InvalidDepartureDateException(DateTime departureDate) + : base($"Departure Date: '{departureDate}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidDurationException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidDurationException.cs new file mode 100644 index 0000000..7e135bd --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidDurationException.cs @@ -0,0 +1,10 @@ +namespace Flight.Flights.Exceptions; +using BuildingBlocks.Exception; + +public class InvalidDurationException : BadRequestException +{ + public InvalidDurationException() + : base("Duration cannot be negative.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidFlightDateException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidFlightDateException.cs new file mode 100644 index 0000000..d0c4de4 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidFlightDateException.cs @@ -0,0 +1,11 @@ +namespace Flight.Flights.Exceptions; +using System; +using BuildingBlocks.Exception; + +public class InvalidFlightDateException : BadRequestException +{ + public InvalidFlightDateException(DateTime flightDate) + : base($"Flight Date: '{flightDate}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidFlightIdException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidFlightIdException.cs new file mode 100644 index 0000000..05b9222 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidFlightIdException.cs @@ -0,0 +1,11 @@ +namespace Flight.Flights.Exceptions; +using System; +using BuildingBlocks.Exception; + +public class InvalidFlightIdException : BadRequestException +{ + public InvalidFlightIdException(Guid flightId) + : base($"flightId: '{flightId}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidFlightNumberException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidFlightNumberException.cs new file mode 100644 index 0000000..a77b8f1 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidFlightNumberException.cs @@ -0,0 +1,10 @@ +namespace Flight.Flights.Exceptions; +using BuildingBlocks.Exception; + +public class InvalidFlightNumberException : BadRequestException +{ + public InvalidFlightNumberException(string flightNumber) + : base($"Flight Number: '{flightNumber}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidPriceException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidPriceException.cs new file mode 100644 index 0000000..ed37c31 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Exceptions/InvalidPriceException.cs @@ -0,0 +1,11 @@ +namespace Flight.Flights.Exceptions; +using BuildingBlocks.Exception; + + +public class InvalidPriceException : BadRequestException +{ + public InvalidPriceException() + : base($"Price Cannot be negative.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/CreatingFlight/V1/CreateFlight.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/CreatingFlight/V1/CreateFlight.cs new file mode 100644 index 0000000..e0a3d99 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/CreatingFlight/V1/CreateFlight.cs @@ -0,0 +1,127 @@ +namespace Flight.Flights.Features.CreatingFlight.V1; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Aircrafts.ValueObjects; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using BuildingBlocks.Web; +using Data; +using Duende.IdentityServer.EntityFramework.Entities; +using Exceptions; +using Flight.Airports.ValueObjects; +using FluentValidation; +using Mapster; +using MapsterMapper; +using MassTransit; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using ValueObjects; + +public record CreateFlight(string FlightNumber, Guid AircraftId, Guid DepartureAirportId, + DateTime DepartureDate, DateTime ArriveDate, Guid ArriveAirportId, + decimal DurationMinutes, DateTime FlightDate, Enums.FlightStatus Status, + decimal Price) : ICommand, IInternalCommand +{ + public Guid Id { get; init; } = NewId.NextGuid(); +} + +public record CreateFlightResult(Guid Id); + +public record FlightCreatedDomainEvent(Guid Id, string FlightNumber, Guid AircraftId, DateTime DepartureDate, + Guid DepartureAirportId, DateTime ArriveDate, Guid ArriveAirportId, decimal DurationMinutes, + DateTime FlightDate, Enums.FlightStatus Status, decimal Price, bool IsDeleted) : IDomainEvent; + +public record CreateFlightRequestDto(string FlightNumber, Guid AircraftId, Guid DepartureAirportId, + DateTime DepartureDate, DateTime ArriveDate, Guid ArriveAirportId, + decimal DurationMinutes, DateTime FlightDate, Enums.FlightStatus Status, decimal Price); + +public record CreateFlightResponseDto(Guid Id); + +public class CreateFlightEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPost($"{EndpointConfig.BaseApiPath}/flight", async (CreateFlightRequestDto request, + IMediator mediator, IMapper mapper, + CancellationToken cancellationToken) => + { + var command = mapper.Map(request); + + var result = await mediator.Send(command, cancellationToken); + + var response = result.Adapt(); + + return Results.CreatedAtRoute("GetFlightById", new { id = result.Id }, response); + }) + .RequireAuthorization(nameof(ApiScope)) + .WithName("CreateFlight") + .WithApiVersionSet(builder.NewApiVersionSet("Flight").Build()) + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Create Flight") + .WithDescription("Create Flight") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } +} + +public class CreateFlightValidator : AbstractValidator +{ + public CreateFlightValidator() + { + RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than 0"); + + RuleFor(x => x.Status).Must(p => (p.GetType().IsEnum && + p == Enums.FlightStatus.Flying) || + p == Enums.FlightStatus.Canceled || + p == Enums.FlightStatus.Delay || + p == Enums.FlightStatus.Completed) + .WithMessage("Status must be Flying, Delay, Canceled or Completed"); + + RuleFor(x => x.AircraftId).NotEmpty().WithMessage("AircraftId must be not empty"); + RuleFor(x => x.DepartureAirportId).NotEmpty().WithMessage("DepartureAirportId must be not empty"); + RuleFor(x => x.ArriveAirportId).NotEmpty().WithMessage("ArriveAirportId must be not empty"); + RuleFor(x => x.DurationMinutes).GreaterThan(0).WithMessage("DurationMinutes must be greater than 0"); + RuleFor(x => x.FlightDate).NotEmpty().WithMessage("FlightDate must be not empty"); + } +} + +internal class CreateFlightHandler : ICommandHandler +{ + private readonly FlightDbContext _flightDbContext; + + public CreateFlightHandler(FlightDbContext flightDbContext) + { + _flightDbContext = flightDbContext; + } + + public async Task Handle(CreateFlight request, CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var flight = await _flightDbContext.Flights.SingleOrDefaultAsync(x => x.Id == request.Id, + cancellationToken); + + if (flight is not null) + { + throw new FlightAlreadyExistException(); + } + + var flightEntity = Models.Flight.Create(FlightId.Of(request.Id), FlightNumber.Of(request.FlightNumber), AircraftId.Of(request.AircraftId), + AirportId.Of(request.DepartureAirportId), DepartureDate.Of(request.DepartureDate), + ArriveDate.Of(request.ArriveDate), AirportId.Of(request.ArriveAirportId), DurationMinutes.Of(request.DurationMinutes), FlightDate.Of(request.FlightDate), request.Status, + Price.Of(request.Price)); + + var newFlight = (await _flightDbContext.Flights.AddAsync(flightEntity, cancellationToken)).Entity; + + return new CreateFlightResult(newFlight.Id); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/CreatingFlight/V1/CreateFlightMongo.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/CreatingFlight/V1/CreateFlightMongo.cs new file mode 100644 index 0000000..8d283ab --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/CreatingFlight/V1/CreateFlightMongo.cs @@ -0,0 +1,52 @@ +namespace Flight.Flights.Features.CreatingFlight.V1; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using Data; +using Exceptions; +using MapsterMapper; +using MediatR; +using Models; +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +public record CreateFlightMongo(Guid Id, string FlightNumber, Guid AircraftId, DateTime DepartureDate, + Guid DepartureAirportId, DateTime ArriveDate, Guid ArriveAirportId, decimal DurationMinutes, DateTime FlightDate, + Enums.FlightStatus Status, decimal Price, bool IsDeleted = false) : InternalCommand; + +internal class CreateFlightMongoHandler : ICommandHandler +{ + private readonly FlightReadDbContext _flightReadDbContext; + private readonly IMapper _mapper; + + public CreateFlightMongoHandler( + FlightReadDbContext flightReadDbContext, + IMapper mapper) + { + _flightReadDbContext = flightReadDbContext; + _mapper = mapper; + } + + public async Task Handle(CreateFlightMongo request, CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var flightReadModel = _mapper.Map(request); + + var flight = await _flightReadDbContext.Flight.AsQueryable() + .FirstOrDefaultAsync(x => x.FlightId == flightReadModel.FlightId && !x.IsDeleted, cancellationToken); + + if (flight is not null) + { + throw new FlightAlreadyExistException(); + } + + await _flightReadDbContext.Flight.InsertOneAsync(flightReadModel, cancellationToken: cancellationToken); + + return Unit.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/DeletingFlight/V1/DeleteFlight.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/DeletingFlight/V1/DeleteFlight.cs new file mode 100644 index 0000000..3f82d40 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/DeletingFlight/V1/DeleteFlight.cs @@ -0,0 +1,110 @@ +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using BuildingBlocks.Web; +using Duende.IdentityServer.EntityFramework.Entities; +using Flight.Data; +using Flight.Flights.Exceptions; +using FluentValidation; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; + +namespace Flight.Flights.Features.DeletingFlight.V1; + +public record DeleteFlight(Guid Id) : ICommand, IInternalCommand; + +public record DeleteFlightResult(Guid Id); + +public record FlightDeletedDomainEvent( + Guid Id, + string FlightNumber, + Guid AircraftId, + DateTime DepartureDate, + Guid DepartureAirportId, + DateTime ArriveDate, + Guid ArriveAirportId, + decimal DurationMinutes, + DateTime FlightDate, + Enums.FlightStatus Status, + decimal Price, + bool IsDeleted +) : IDomainEvent; + +public class DeleteFlightEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapDelete( + $"{EndpointConfig.BaseApiPath}/flight/{{id}}", + async (Guid id, IMediator mediator, CancellationToken cancellationToken) => + { + await mediator.Send(new DeleteFlight(id), cancellationToken); + + return Results.NoContent(); + }) + .RequireAuthorization(nameof(ApiScope)) + .WithName("DeleteFlight") + .WithApiVersionSet(builder.NewApiVersionSet("Flight").Build()) + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Delete Flight") + .WithDescription("Delete Flight") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } +} + +public class DeleteFlightValidator : AbstractValidator +{ + public DeleteFlightValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} + +internal class DeleteFlightHandler : ICommandHandler +{ + private readonly FlightDbContext _flightDbContext; + + public DeleteFlightHandler(FlightDbContext flightDbContext) + { + _flightDbContext = flightDbContext; + } + + public async Task Handle( + DeleteFlight request, + CancellationToken cancellationToken + ) + { + Guard.Against.Null(request, nameof(request)); + + var flight = await _flightDbContext.Flights.SingleOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (flight is null) + { + throw new FlightNotFountException(); + } + + flight.Delete( + flight.Id, + flight.FlightNumber, + flight.AircraftId, + flight.DepartureAirportId, + flight.DepartureDate, + flight.ArriveDate, + flight.ArriveAirportId, + flight.DurationMinutes, + flight.FlightDate, + flight.Status, + flight.Price); + + var deleteFlight = _flightDbContext.Flights.Update(flight).Entity; + + return new DeleteFlightResult(deleteFlight.Id); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/DeletingFlight/V1/DeleteFlightMongo.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/DeletingFlight/V1/DeleteFlightMongo.cs new file mode 100644 index 0000000..caed0c0 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/DeletingFlight/V1/DeleteFlightMongo.cs @@ -0,0 +1,56 @@ +namespace Flight.Flights.Features.DeletingFlight.V1; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using Data; +using Exceptions; +using MapsterMapper; +using MediatR; +using Models; +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +public record DeleteFlightMongo(Guid Id, string FlightNumber, Guid AircraftId, DateTime DepartureDate, + Guid DepartureAirportId, DateTime ArriveDate, Guid ArriveAirportId, decimal DurationMinutes, DateTime FlightDate, + Enums.FlightStatus Status, decimal Price, bool IsDeleted = false) : InternalCommand; + +internal class DeleteFlightMongoCommandHandler : ICommandHandler +{ + private readonly FlightReadDbContext _flightReadDbContext; + private readonly IMapper _mapper; + + public DeleteFlightMongoCommandHandler( + FlightReadDbContext flightReadDbContext, + IMapper mapper) + { + _flightReadDbContext = flightReadDbContext; + _mapper = mapper; + } + + public async Task Handle(DeleteFlightMongo request, CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var flightReadModel = _mapper.Map(request); + + var flight = await _flightReadDbContext.Flight.AsQueryable() + .FirstOrDefaultAsync(x => x.FlightId == flightReadModel.FlightId && !x.IsDeleted, cancellationToken); + + if (flight is null) + { + throw new FlightNotFountException(); + } + + await _flightReadDbContext.Flight.UpdateOneAsync( + x => x.FlightId == flightReadModel.FlightId, + Builders.Update + .Set(x => x.IsDeleted, flightReadModel.IsDeleted), + cancellationToken: cancellationToken); + + return Unit.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/FlightMappings.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/FlightMappings.cs new file mode 100644 index 0000000..505dd7a --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/FlightMappings.cs @@ -0,0 +1,47 @@ +using Mapster; + +namespace Flight.Flights.Features; + +using CreatingFlight.V1; +using DeletingFlight.V1; +using MassTransit; +using Models; +using UpdatingFlight.V1; +using FlightDto = Dtos.FlightDto; + +public class FlightMappings : IRegister +{ + public void Register(TypeAdapterConfig config) + { + config.NewConfig() + .ConstructUsing(x => new FlightDto(x.Id, x.FlightNumber, x.AircraftId, x.DepartureAirportId, + x.DepartureDate, + x.ArriveDate, x.ArriveAirportId, x.DurationMinutes, x.FlightDate, x.Status, x.Price)); + + config.NewConfig() + .Map(d => d.Id, s => NewId.NextGuid()) + .Map(d => d.FlightId, s => s.Id); + + config.NewConfig() + .Map(d => d.Id, s => NewId.NextGuid()) + .Map(d => d.FlightId, s => s.Id.Value); + + config.NewConfig() + .Map(d => d.Id, s => s.FlightId); + + config.NewConfig() + .Map(d => d.FlightId, s => s.Id); + + config.NewConfig() + .Map(d => d.FlightId, s => s.Id); + + config.NewConfig() + .ConstructUsing(x => new CreateFlight(x.FlightNumber, x.AircraftId, x.DepartureAirportId, + x.DepartureDate, x.ArriveDate, x.ArriveAirportId, x.DurationMinutes, x.FlightDate, x.Status, x.Price)); + + config.NewConfig() + .ConstructUsing(x => new UpdateFlight(x.Id, x.FlightNumber, x.AircraftId, x.DepartureAirportId, x.DepartureDate, + x.ArriveDate, x.ArriveAirportId, x.DurationMinutes, x.FlightDate, x.Status, x.IsDeleted, x.Price)); + + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/GettingAvailableFlights/V1/GetAvailableFlights.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/GettingAvailableFlights/V1/GetAvailableFlights.cs new file mode 100644 index 0000000..73d435e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/GettingAvailableFlights/V1/GetAvailableFlights.cs @@ -0,0 +1,91 @@ +using MongoDB.Driver.Linq; + +namespace Flight.Flights.Features.GettingAvailableFlights.V1; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Caching; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Web; +using Data; +using Dtos; +using Duende.IdentityServer.EntityFramework.Entities; +using Exceptions; +using Mapster; +using MapsterMapper; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using MongoDB.Driver; + +public record GetAvailableFlights : IQuery, ICacheRequest +{ + public string CacheKey => "GetAvailableFlights"; + public DateTime? AbsoluteExpirationRelativeToNow => DateTime.Now.AddHours(1); +} + +public record GetAvailableFlightsResult(IEnumerable FlightDtos); + +public record GetAvailableFlightsResponseDto(IEnumerable FlightDtos); + +public class GetAvailableFlightsEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapGet($"{EndpointConfig.BaseApiPath}/flight/get-available-flights", + async (IMediator mediator, CancellationToken cancellationToken) => + { + var result = await mediator.Send(new GetAvailableFlights(), cancellationToken); + + var response = result.Adapt(); + + return Results.Ok(response); + }) + .RequireAuthorization(nameof(ApiScope)) + .WithName("GetAvailableFlights") + .WithApiVersionSet(builder.NewApiVersionSet("Flight").Build()) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Get Available Flights") + .WithDescription("Get Available Flights") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } +} + +internal class GetAvailableFlightsHandler : IQueryHandler +{ + private readonly IMapper _mapper; + private readonly FlightReadDbContext _flightReadDbContext; + + public GetAvailableFlightsHandler(IMapper mapper, FlightReadDbContext flightReadDbContext) + { + _mapper = mapper; + _flightReadDbContext = flightReadDbContext; + } + + public async Task Handle(GetAvailableFlights request, + CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var flight = (await _flightReadDbContext.Flight.AsQueryable().ToListAsync(cancellationToken)) + .Where(x => !x.IsDeleted); + + if (!flight.Any()) + { + throw new FlightNotFountException(); + } + + var flightDtos = _mapper.Map>(flight); + + return new GetAvailableFlightsResult(flightDtos); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/GettingFlightById/V1/GetFlightById.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/GettingFlightById/V1/GetFlightById.cs new file mode 100644 index 0000000..5f01b14 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/GettingFlightById/V1/GetFlightById.cs @@ -0,0 +1,92 @@ +namespace Flight.Flights.Features.GettingFlightById.V1; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Web; +using Data; +using Dtos; +using Duende.IdentityServer.EntityFramework.Entities; +using Exceptions; +using FluentValidation; +using Mapster; +using MapsterMapper; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +public record GetFlightById(Guid Id) : IQuery; + +public record GetFlightByIdResult(FlightDto FlightDto); + +public record GetFlightByIdResponseDto(FlightDto FlightDto); + +public class GetFlightByIdEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapGet($"{EndpointConfig.BaseApiPath}/flight/{{id}}", + async (Guid id, IMediator mediator, IMapper mapper, CancellationToken cancellationToken) => + { + var result = await mediator.Send(new GetFlightById(id), cancellationToken); + + var response = result.Adapt(); + + return Results.Ok(response); + }) + .RequireAuthorization(nameof(ApiScope)) + .WithName("GetFlightById") + .WithApiVersionSet(builder.NewApiVersionSet("Flight").Build()) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Get Flight By Id") + .WithDescription("Get Flight By Id") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } +} + +public class GetFlightByIdValidator : AbstractValidator +{ + public GetFlightByIdValidator() + { + RuleFor(x => x.Id).NotNull().WithMessage("Id is required!"); + } +} + +internal class GetFlightByIdHandler : IQueryHandler +{ + private readonly IMapper _mapper; + private readonly FlightReadDbContext _flightReadDbContext; + + public GetFlightByIdHandler(IMapper mapper, FlightReadDbContext flightReadDbContext) + { + _mapper = mapper; + _flightReadDbContext = flightReadDbContext; + } + + public async Task Handle(GetFlightById request, CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var flight = await _flightReadDbContext.Flight.AsQueryable().SingleOrDefaultAsync( + x => x.FlightId == request.Id && + !x.IsDeleted, cancellationToken); + + if (flight is null) + { + throw new FlightNotFountException(); + } + + var flightDto = _mapper.Map(flight); + + return new GetFlightByIdResult(flightDto); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/UpdatingFlight/V1/UpdateFlight.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/UpdatingFlight/V1/UpdateFlight.cs new file mode 100644 index 0000000..242f951 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/UpdatingFlight/V1/UpdateFlight.cs @@ -0,0 +1,125 @@ +namespace Flight.Flights.Features.UpdatingFlight.V1; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Aircrafts.ValueObjects; +using Ardalis.GuardClauses; +using BuildingBlocks.Caching; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using BuildingBlocks.Web; +using Data; +using Duende.IdentityServer.EntityFramework.Entities; +using Exceptions; +using Flight.Airports.ValueObjects; +using Flight.Flights.Features.CreatingFlight.V1; +using FluentValidation; +using MapsterMapper; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using ValueObjects; + +public record UpdateFlight(Guid Id, string FlightNumber, Guid AircraftId, Guid DepartureAirportId, + DateTime DepartureDate, DateTime ArriveDate, Guid ArriveAirportId, decimal DurationMinutes, DateTime FlightDate, + Enums.FlightStatus Status, bool IsDeleted, decimal Price) : ICommand, IInternalCommand, + IInvalidateCacheRequest +{ + public string CacheKey => "GetAvailableFlights"; +} + +public record UpdateFlightResult(Guid Id); + +public record FlightUpdatedDomainEvent(Guid Id, string FlightNumber, Guid AircraftId, DateTime DepartureDate, + Guid DepartureAirportId, DateTime ArriveDate, Guid ArriveAirportId, decimal DurationMinutes, + DateTime FlightDate, Enums.FlightStatus Status, decimal Price, bool IsDeleted) : IDomainEvent; + +public record UpdateFlightRequestDto(Guid Id, string FlightNumber, Guid AircraftId, Guid DepartureAirportId, + DateTime DepartureDate, DateTime ArriveDate, + Guid ArriveAirportId, decimal DurationMinutes, DateTime FlightDate, Enums.FlightStatus Status, decimal Price, + bool IsDeleted); + +public class UpdateFlightEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPut($"{EndpointConfig.BaseApiPath}/flight", async (UpdateFlightRequestDto request, + IMediator mediator, + IMapper mapper, CancellationToken cancellationToken) => + { + var command = mapper.Map(request); + + await mediator.Send(command, cancellationToken); + + return Results.NoContent(); + }) + .RequireAuthorization(nameof(ApiScope)) + .WithName("UpdateFlight") + .WithApiVersionSet(builder.NewApiVersionSet("Flight").Build()) + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Update Flight") + .WithDescription("Update Flight") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } +} + +public class UpdateFlightValidator : AbstractValidator +{ + public UpdateFlightValidator() + { + RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than 0"); + + RuleFor(x => x.Status).Must(p => (p.GetType().IsEnum && + p == Enums.FlightStatus.Flying) || + p == Enums.FlightStatus.Canceled || + p == Enums.FlightStatus.Delay || + p == Enums.FlightStatus.Completed) + .WithMessage("Status must be Flying, Delay, Canceled or Completed"); + + RuleFor(x => x.AircraftId).NotEmpty().WithMessage("AircraftId must be not empty"); + RuleFor(x => x.DepartureAirportId).NotEmpty().WithMessage("DepartureAirportId must be not empty"); + RuleFor(x => x.ArriveAirportId).NotEmpty().WithMessage("ArriveAirportId must be not empty"); + RuleFor(x => x.DurationMinutes).GreaterThan(0).WithMessage("DurationMinutes must be greater than 0"); + RuleFor(x => x.FlightDate).NotEmpty().WithMessage("FlightDate must be not empty"); + } +} + +internal class UpdateFlightHandler : ICommandHandler +{ + private readonly FlightDbContext _flightDbContext; + + public UpdateFlightHandler(FlightDbContext flightDbContext) + { + _flightDbContext = flightDbContext; + } + + public async Task Handle(UpdateFlight request, CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var flight = await _flightDbContext.Flights.SingleOrDefaultAsync(x => x.Id == request.Id, + cancellationToken); + + if (flight is null) + { + throw new FlightNotFountException(); + } + + + flight.Update(FlightId.Of(request.Id), FlightNumber.Of(request.FlightNumber), AircraftId.Of(request.AircraftId), AirportId.Of(request.DepartureAirportId), + DepartureDate.Of(request.DepartureDate), + ArriveDate.Of(request.ArriveDate), AirportId.Of(request.ArriveAirportId), DurationMinutes.Of(request.DurationMinutes), FlightDate.Of(request.FlightDate), request.Status, + Price.Of(request.Price), request.IsDeleted); + + var updateFlight = _flightDbContext.Flights.Update(flight).Entity; + + return new UpdateFlightResult(updateFlight.Id); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/UpdatingFlight/V1/UpdateFlightMongo.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/UpdatingFlight/V1/UpdateFlightMongo.cs new file mode 100644 index 0000000..523bfe4 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Features/UpdatingFlight/V1/UpdateFlightMongo.cs @@ -0,0 +1,67 @@ +namespace Flight.Flights.Features.UpdatingFlight.V1; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using Data; +using Exceptions; +using MapsterMapper; +using MediatR; +using Models; +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +public record UpdateFlightMongo(Guid Id, string FlightNumber, Guid AircraftId, DateTime DepartureDate, + Guid DepartureAirportId, DateTime ArriveDate, Guid ArriveAirportId, decimal DurationMinutes, DateTime FlightDate, + Enums.FlightStatus Status, decimal Price, bool IsDeleted = false) : InternalCommand; + + +internal class UpdateFlightMongoCommandHandler : ICommandHandler +{ + private readonly FlightReadDbContext _flightReadDbContext; + private readonly IMapper _mapper; + + public UpdateFlightMongoCommandHandler( + FlightReadDbContext flightReadDbContext, + IMapper mapper) + { + _flightReadDbContext = flightReadDbContext; + _mapper = mapper; + } + + public async Task Handle(UpdateFlightMongo request, CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var flightReadModel = _mapper.Map(request); + + var flight = await _flightReadDbContext.Flight.AsQueryable() + .FirstOrDefaultAsync(x => x.FlightId == flightReadModel.FlightId && !x.IsDeleted, cancellationToken); + + if (flight is null) + { + throw new FlightNotFountException(); + } + + await _flightReadDbContext.Flight.UpdateOneAsync( + x => x.FlightId == flightReadModel.FlightId, + Builders.Update + .Set(x => x.Price, flightReadModel.Price) + .Set(x => x.ArriveDate, flightReadModel.ArriveDate) + .Set(x => x.AircraftId, flightReadModel.AircraftId) + .Set(x => x.DurationMinutes, flightReadModel.DurationMinutes) + .Set(x => x.DepartureDate, flightReadModel.DepartureDate) + .Set(x => x.FlightDate, flightReadModel.FlightDate) + .Set(x => x.FlightNumber, flightReadModel.FlightNumber) + .Set(x => x.IsDeleted, flightReadModel.IsDeleted) + .Set(x => x.Status, flightReadModel.Status) + .Set(x => x.ArriveAirportId, flightReadModel.ArriveAirportId) + .Set(x => x.DepartureAirportId, flightReadModel.DepartureAirportId), + cancellationToken: cancellationToken); + + return Unit.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Models/Flight.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Models/Flight.cs new file mode 100644 index 0000000..4b5b86c --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Models/Flight.cs @@ -0,0 +1,102 @@ +using BuildingBlocks.Core.Model; + +namespace Flight.Flights.Models; + +using Aircrafts.ValueObjects; +using Airports.ValueObjects; +using Features.CreatingFlight.V1; +using Features.DeletingFlight.V1; +using Features.UpdatingFlight.V1; +using ValueObjects; + +public record Flight : Aggregate +{ + public FlightNumber FlightNumber { get; private set; } = default!; + public AircraftId AircraftId { get; private set; } = default!; + public AirportId DepartureAirportId { get; private set; } = default!; + public AirportId ArriveAirportId { get; private set; } = default!; + public DurationMinutes DurationMinutes { get; private set; } = default!; + public Enums.FlightStatus Status { get; private set; } + public Price Price { get; private set; } = default!; + public ArriveDate ArriveDate { get; private set; } = default!; + public DepartureDate DepartureDate { get; private set; } = default!; + public FlightDate FlightDate { get; private set; } = default!; + + public static Flight Create(FlightId id, FlightNumber flightNumber, AircraftId aircraftId, + AirportId departureAirportId, DepartureDate departureDate, ArriveDate arriveDate, + AirportId arriveAirportId, DurationMinutes durationMinutes, FlightDate flightDate, Enums.FlightStatus status, + Price price, bool isDeleted = false) + { + var flight = new Flight + { + Id = id, + FlightNumber = flightNumber, + AircraftId = aircraftId, + DepartureAirportId = departureAirportId, + DepartureDate = departureDate, + ArriveDate = arriveDate, + ArriveAirportId = arriveAirportId, + DurationMinutes = durationMinutes, + FlightDate = flightDate, + Status = status, + Price = price, + IsDeleted = isDeleted, + }; + + var @event = new FlightCreatedDomainEvent(flight.Id, flight.FlightNumber, flight.AircraftId, + flight.DepartureDate, flight.DepartureAirportId, + flight.ArriveDate, flight.ArriveAirportId, flight.DurationMinutes, flight.FlightDate, flight.Status, + flight.Price, flight.IsDeleted); + + flight.AddDomainEvent(@event); + + return flight; + } + + + public void Update(FlightId id, FlightNumber flightNumber, AircraftId aircraftId, + AirportId departureAirportId, DepartureDate departureDate, ArriveDate arriveDate, + AirportId arriveAirportId, DurationMinutes durationMinutes, FlightDate flightDate, Enums.FlightStatus status, + Price price, bool isDeleted = false) + { + this.FlightNumber = flightNumber; + this.AircraftId = aircraftId; + this.DepartureAirportId = departureAirportId; + this.DepartureDate = departureDate; + this.ArriveDate = arriveDate; + this.ArriveAirportId = arriveAirportId; + this.DurationMinutes = durationMinutes; + this.FlightDate = flightDate; + this.Status = status; + this.Price = price; + this.IsDeleted = isDeleted; + + var @event = new FlightUpdatedDomainEvent(id, flightNumber, aircraftId, departureDate, departureAirportId, + arriveDate, arriveAirportId, durationMinutes, flightDate, status, price, isDeleted); + + AddDomainEvent(@event); + } + + public void Delete(FlightId id, FlightNumber flightNumber, AircraftId aircraftId, + AirportId departureAirportId, DepartureDate departureDate, ArriveDate arriveDate, + AirportId arriveAirportId, DurationMinutes durationMinutes, FlightDate flightDate, Enums.FlightStatus status, + Price price, bool isDeleted = true) + { + FlightNumber = flightNumber; + AircraftId = aircraftId; + DepartureAirportId = departureAirportId; + DepartureDate = departureDate; + ArriveDate = arriveDate; + ArriveAirportId = arriveAirportId; + DurationMinutes = durationMinutes; + FlightDate = flightDate; + Status = status; + Price = price; + IsDeleted = isDeleted; + + var @event = new FlightDeletedDomainEvent(id, flightNumber, aircraftId, departureDate, departureAirportId, + arriveDate, arriveAirportId, durationMinutes, flightDate, status, price, isDeleted); + + AddDomainEvent(@event); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Models/FlightReadModel.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Models/FlightReadModel.cs new file mode 100644 index 0000000..74eec28 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/Models/FlightReadModel.cs @@ -0,0 +1,20 @@ +namespace Flight.Flights.Models; + +using System; + +public class FlightReadModel +{ + public required Guid Id { get; init; } + public required Guid FlightId { get; init; } + public required string FlightNumber { get; init; } + public required Guid AircraftId { get; init; } + public required DateTime DepartureDate { get; init; } + public required Guid DepartureAirportId { get; init; } + public required DateTime ArriveDate { get; init; } + public required Guid ArriveAirportId { get; init; } + public required decimal DurationMinutes { get; init; } + public required DateTime FlightDate { get; init; } + public required Enums.FlightStatus Status { get; init; } + public required decimal Price { get; init; } + public required bool IsDeleted { get; init; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/ArriveDate.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/ArriveDate.cs new file mode 100644 index 0000000..72996c5 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/ArriveDate.cs @@ -0,0 +1,28 @@ +namespace Flight.Flights.ValueObjects; +using System; +using Flight.Flights.Exceptions; + +public record ArriveDate +{ + public DateTime Value { get; } + + private ArriveDate(DateTime value) + { + Value = value; + } + + public static ArriveDate Of(DateTime value) + { + if (value == default) + { + throw new InvalidArriveDateException(value); + } + + return new ArriveDate(value); + } + + public static implicit operator DateTime(ArriveDate arriveDate) + { + return arriveDate.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/DepartureDate.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/DepartureDate.cs new file mode 100644 index 0000000..a24e137 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/DepartureDate.cs @@ -0,0 +1,29 @@ +namespace Flight.Flights.ValueObjects; +using System; +using Flight.Flights.Exceptions; + + +public record DepartureDate +{ + public DateTime Value { get; } + + private DepartureDate(DateTime value) + { + Value = value; + } + + public static DepartureDate Of(DateTime value) + { + if (value == default) + { + throw new InvalidDepartureDateException(value); + } + + return new DepartureDate(value); + } + + public static implicit operator DateTime(DepartureDate departureDate) + { + return departureDate.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/DurationMinutes.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/DurationMinutes.cs new file mode 100644 index 0000000..292b345 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/DurationMinutes.cs @@ -0,0 +1,33 @@ +namespace Flight.Flights.ValueObjects; + +using Exceptions; + +public class DurationMinutes +{ + public decimal Value { get; } + + private DurationMinutes(decimal value) + { + Value = value; + } + + public static DurationMinutes Of(decimal value) + { + if (value < 0) + { + throw new InvalidDurationException(); + } + + return new DurationMinutes(value); + } + + public static implicit operator decimal(DurationMinutes duration) + { + return duration.Value; + } + + public static explicit operator DurationMinutes(decimal value) + { + return new DurationMinutes(value); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/FlightDate.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/FlightDate.cs new file mode 100644 index 0000000..0da2949 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/FlightDate.cs @@ -0,0 +1,28 @@ +namespace Flight.Flights.ValueObjects; +using System; +using Flight.Flights.Exceptions; + +public record FlightDate +{ + public DateTime Value { get; } + + private FlightDate(DateTime value) + { + Value = value; + } + + public static FlightDate Of(DateTime value) + { + if (value == default) + { + throw new InvalidFlightDateException(value); + } + + return new FlightDate(value); + } + + public static implicit operator DateTime(FlightDate flightDate) + { + return flightDate.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/FlightId.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/FlightId.cs new file mode 100644 index 0000000..c3e5a46 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/FlightId.cs @@ -0,0 +1,29 @@ +namespace Flight.Flights.ValueObjects; + +using System; +using Exceptions; + +public record FlightId +{ + public Guid Value { get; } + + private FlightId(Guid value) + { + Value = value; + } + + public static FlightId Of(Guid value) + { + if (value == Guid.Empty) + { + throw new InvalidFlightIdException(value); + } + + return new FlightId(value); + } + + public static implicit operator Guid(FlightId flightId) + { + return flightId.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/FlightNumber.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/FlightNumber.cs new file mode 100644 index 0000000..b709d7d --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/FlightNumber.cs @@ -0,0 +1,28 @@ +namespace Flight.Flights.ValueObjects; + +using Exceptions; + +public record FlightNumber +{ + public string Value { get; } + + private FlightNumber(string value) + { + Value = value; + } + + public static FlightNumber Of(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidFlightNumberException(value); + } + + return new FlightNumber(value); + } + + public static implicit operator string(FlightNumber flightNumber) + { + return flightNumber.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/Price.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/Price.cs new file mode 100644 index 0000000..d2fa93e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Flights/ValueObjects/Price.cs @@ -0,0 +1,28 @@ +namespace Flight.Flights.ValueObjects; + +using Flight.Flights.Exceptions; + +public class Price +{ + public decimal Value { get; } + + private Price(decimal value) + { + Value = value; + } + + public static Price Of(decimal value) + { + if (value < 0) + { + throw new InvalidPriceException(); + } + + return new Price(value); + } + + public static implicit operator decimal(Price price) + { + return price.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/GrpcServer/Protos/flight.proto b/2-modular-monolith-architecture-style/src/Modules/Flight/src/GrpcServer/Protos/flight.proto new file mode 100644 index 0000000..aba77c4 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/GrpcServer/Protos/flight.proto @@ -0,0 +1,85 @@ +syntax = "proto3"; + +package flight; +import "google/protobuf/timestamp.proto"; + +service FlightGrpcService { + + rpc GetById (GetByIdRequest) returns (GetFlightByIdResult); + rpc GetAvailableSeats (GetAvailableSeatsRequest) returns (GetAvailableSeatsResult); + rpc ReserveSeat (ReserveSeatRequest) returns (ReserveSeatResult); +} + +message GetByIdRequest { + string Id = 1; +} + +message GetFlightByIdResult{ + FlightResponse FlightDto = 1; +} + +message GetAvailableSeatsResult{ +repeated SeatDtoResponse SeatDtos = 1; +} + +message ReserveSeatResult{ + string Id = 1; +} + +message FlightResponse { + string Id = 1; + string FlightNumber = 2; + string AircraftId = 3; + string DepartureAirportId = 4; + google.protobuf.Timestamp DepartureDate = 5; + google.protobuf.Timestamp ArriveDate = 6; + string ArriveAirportId = 7; + double DurationMinutes = 8; + google.protobuf.Timestamp FlightDate = 9; + FlightStatus Status = 10; + double Price = 11; + string FlightId = 12; +} + +message GetAvailableSeatsRequest { + string FlightId = 1; +} + +message SeatDtoResponse { + string Id = 1; + string SeatNumber = 2; + SeatType Type = 3; + SeatClass Class = 4; + string FlightId = 5; +} + + +message ReserveSeatRequest { + string FlightId = 1; + string SeatNumber = 2; +} + + + enum FlightStatus { + FLIGHT_STATUS_UNKNOWN = 0; + FLIGHT_STATUS_FLYING = 1; + FLIGHT_STATUS_DELAY = 2; + FLIGHT_STATUS_CANCELED = 3; + FLIGHT_STATUS_COMPLETED = 4; + } + + + enum SeatType { + SEAT_TYPE_UNKNOWN = 0; + SEAT_TYPE_WINDOW = 1; + SEAT_TYPE_MIDDLE = 2; + SEAT_TYPE_AISLE = 3; + } + + + enum SeatClass { + SEAT_CLASS_UNKNOWN = 0; + SEAT_CLASS_FIRST_CLASS = 1; + SEAT_CLASS_BUSINESS = 2; + SEAT_CLASS_ECONOMY = 3; + } diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/GrpcServer/Services/FlightGrpcServices.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/GrpcServer/Services/FlightGrpcServices.cs new file mode 100644 index 0000000..f55dc1e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/GrpcServer/Services/FlightGrpcServices.cs @@ -0,0 +1,55 @@ +using System.Threading.Tasks; +using Grpc.Core; +using Mapster; +using MediatR; + +namespace Flight.GrpcServer.Services; + +using System; +using Flights.Features.GettingFlightById.V1; +using Seats.Features.GettingAvailableSeats.V1; +using Seats.Features.ReservingSeat.V1; +using GetAvailableSeatsResult = GetAvailableSeatsResult; +using GetFlightByIdResult = GetFlightByIdResult; +using ReserveSeatResult = ReserveSeatResult; + +public class FlightGrpcServices : FlightGrpcService.FlightGrpcServiceBase +{ + private readonly IMediator _mediator; + + public FlightGrpcServices(IMediator mediator) + { + _mediator = mediator; + } + + public override async Task GetById(GetByIdRequest request, ServerCallContext context) + { + var result = await _mediator.Send(new GetFlightById(new Guid(request.Id))); + return result.Adapt(); + } + + public override async Task GetAvailableSeats(GetAvailableSeatsRequest request, ServerCallContext context) + { + var result = new GetAvailableSeatsResult(); + + var availableSeats = await _mediator.Send(new GetAvailableSeats(new Guid(request.FlightId))); + + if (availableSeats?.SeatDtos == null) + { + return result; + } + + foreach (var availableSeat in availableSeats.SeatDtos) + { + result.SeatDtos.Add(availableSeat.Adapt()); + } + + return result; + } + + public override async Task ReserveSeat(ReserveSeatRequest request, ServerCallContext context) + { + var result = await _mediator.Send(new ReserveSeat(new Guid(request.FlightId), request.SeatNumber)); + return result.Adapt(); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Dtos/SeatDto.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Dtos/SeatDto.cs new file mode 100644 index 0000000..0547616 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Dtos/SeatDto.cs @@ -0,0 +1,5 @@ +namespace Flight.Seats.Dtos; + +using System; + +public record SeatDto(Guid Id, string SeatNumber, Enums.SeatType Type, Enums.SeatClass Class, Guid FlightId); diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Enums/SeatClass.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Enums/SeatClass.cs new file mode 100644 index 0000000..ad49617 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Enums/SeatClass.cs @@ -0,0 +1,9 @@ +namespace Flight.Seats.Enums; + +public enum SeatClass +{ + Unknown = 0, + FirstClass, + Business, + Economy +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Enums/SeatType.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Enums/SeatType.cs new file mode 100644 index 0000000..0bd0684 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Enums/SeatType.cs @@ -0,0 +1,9 @@ +namespace Flight.Seats.Enums; + +public enum SeatType +{ + Unknown = 0, + Window, + Middle, + Aisle +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/AllSeatsFullException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/AllSeatsFullException.cs new file mode 100644 index 0000000..bf58cf3 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/AllSeatsFullException.cs @@ -0,0 +1,10 @@ +namespace Flight.Seats.Exceptions; + +using BuildingBlocks.Exception; + +public class AllSeatsFullException : BadRequestException +{ + public AllSeatsFullException() : base("All seats are full!") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/InvalidSeatIdException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/InvalidSeatIdException.cs new file mode 100644 index 0000000..72abe3e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/InvalidSeatIdException.cs @@ -0,0 +1,12 @@ +namespace Flight.Seats.Exceptions; +using System; +using BuildingBlocks.Exception; + + +public class InvalidSeatIdException : BadRequestException +{ + public InvalidSeatIdException(Guid seatId) + : base($"seatId: '{seatId}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/InvalidSeatNumberException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/InvalidSeatNumberException.cs new file mode 100644 index 0000000..a654ed4 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/InvalidSeatNumberException.cs @@ -0,0 +1,9 @@ +namespace Flight.Seats.Exceptions; +using BuildingBlocks.Exception; + +public class InvalidSeatNumberException : BadRequestException +{ + public InvalidSeatNumberException() : base("SeatNumber Cannot be null or negative") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/SeatAlreadyExistException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/SeatAlreadyExistException.cs new file mode 100644 index 0000000..bdbd615 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/SeatAlreadyExistException.cs @@ -0,0 +1,10 @@ +namespace Flight.Seats.Exceptions; + +using BuildingBlocks.Exception; + +public class SeatAlreadyExistException : ConflictException +{ + public SeatAlreadyExistException(int? code = default) : base("Seat already exist!", code) + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/SeatNumberIncorrectException.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/SeatNumberIncorrectException.cs new file mode 100644 index 0000000..bc2fa5b --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Exceptions/SeatNumberIncorrectException.cs @@ -0,0 +1,10 @@ +namespace Flight.Seats.Exceptions; + +using BuildingBlocks.Exception; + +public class SeatNumberIncorrectException : BadRequestException +{ + public SeatNumberIncorrectException() : base("Seat number is incorrect!") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/CreatingSeat/V1/CreateSeat.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/CreatingSeat/V1/CreateSeat.cs new file mode 100644 index 0000000..1ab80ff --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/CreatingSeat/V1/CreateSeat.cs @@ -0,0 +1,113 @@ +namespace Flight.Seats.Features.CreatingSeat.V1; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using BuildingBlocks.Web; +using Data; +using Duende.IdentityServer.EntityFramework.Entities; +using Exceptions; +using Flights.ValueObjects; +using FluentValidation; +using Mapster; +using MapsterMapper; +using MassTransit; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Models; +using ValueObjects; + +public record CreateSeat + (string SeatNumber, Enums.SeatType Type, Enums.SeatClass Class, Guid FlightId) : ICommand, + IInternalCommand +{ + public Guid Id { get; init; } = NewId.NextGuid(); +} + +public record CreateSeatResult(Guid Id); + +public record SeatCreatedDomainEvent(Guid Id, string SeatNumber, Enums.SeatType Type, Enums.SeatClass Class, + Guid FlightId, bool IsDeleted) : IDomainEvent; + +public record CreateSeatRequestDto(string SeatNumber, Enums.SeatType Type, Enums.SeatClass Class, Guid FlightId); + +public record CreateSeatResponseDto(Guid Id); + +public class CreateSeatEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPost($"{EndpointConfig.BaseApiPath}/flight/seat", CreateSeat) + .RequireAuthorization(nameof(ApiScope)) + .WithName("CreateSeat") + .WithApiVersionSet(builder.NewApiVersionSet("Flight").Build()) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Create Seat") + .WithDescription("Create Seat") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } + + private async Task CreateSeat(CreateSeatRequestDto request, IMediator mediator, IMapper mapper, + CancellationToken cancellationToken) + { + var command = mapper.Map(request); + + var result = await mediator.Send(command, cancellationToken); + + var response = result.Adapt(); + + return Results.Ok(response); + } +} + +public class CreateSeatValidator : AbstractValidator +{ + public CreateSeatValidator() + { + RuleFor(x => x.SeatNumber).NotEmpty().WithMessage("SeatNumber is required"); + RuleFor(x => x.FlightId).NotEmpty().WithMessage("FlightId is required"); + RuleFor(x => x.Class).Must(p => (p.GetType().IsEnum && + p == Enums.SeatClass.FirstClass) || + p == Enums.SeatClass.Business || + p == Enums.SeatClass.Economy) + .WithMessage("Status must be FirstClass, Business or Economy"); + } +} + +internal class CreateSeatCommandHandler : IRequestHandler +{ + private readonly FlightDbContext _flightDbContext; + + public CreateSeatCommandHandler(FlightDbContext flightDbContext) + { + _flightDbContext = flightDbContext; + } + + public async Task Handle(CreateSeat command, CancellationToken cancellationToken) + { + Guard.Against.Null(command, nameof(command)); + + var seat = await _flightDbContext.Seats.SingleOrDefaultAsync(x => x.Id == command.Id, cancellationToken); + + if (seat is not null) + { + throw new SeatAlreadyExistException(); + } + + var seatEntity = Seat.Create(SeatId.Of(command.Id), SeatNumber.Of(command.SeatNumber), command.Type, command.Class, FlightId.Of(command.FlightId)); + + var newSeat = (await _flightDbContext.Seats.AddAsync(seatEntity, cancellationToken)).Entity; + + return new CreateSeatResult(newSeat.Id); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/CreatingSeat/V1/CreateSeatMongo.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/CreatingSeat/V1/CreateSeatMongo.cs new file mode 100644 index 0000000..fedbbfc --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/CreatingSeat/V1/CreateSeatMongo.cs @@ -0,0 +1,52 @@ +namespace Flight.Seats.Features.CreatingSeat.V1; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using Data; +using Exceptions; +using MapsterMapper; +using MediatR; +using Models; +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +public record CreateSeatMongo(Guid Id, string SeatNumber, Enums.SeatType Type, + Enums.SeatClass Class, Guid FlightId, bool IsDeleted = false) : InternalCommand; + +internal class CreateSeatMongoHandler : ICommandHandler +{ + private readonly FlightReadDbContext _flightReadDbContext; + private readonly IMapper _mapper; + + public CreateSeatMongoHandler( + FlightReadDbContext flightReadDbContext, + IMapper mapper) + { + _flightReadDbContext = flightReadDbContext; + _mapper = mapper; + } + + public async Task Handle(CreateSeatMongo request, CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var seatReadModel = _mapper.Map(request); + + var seat = await _flightReadDbContext.Seat.AsQueryable() + .FirstOrDefaultAsync(x => x.SeatId == seatReadModel.SeatId && + !x.IsDeleted, cancellationToken); + + if (seat is not null) + { + throw new SeatAlreadyExistException(); + } + + await _flightReadDbContext.Seat.InsertOneAsync(seatReadModel, cancellationToken: cancellationToken); + + return Unit.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/GettingAvailableSeats/V1/GetAvailableSeats.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/GettingAvailableSeats/V1/GetAvailableSeats.cs new file mode 100644 index 0000000..2470176 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/GettingAvailableSeats/V1/GetAvailableSeats.cs @@ -0,0 +1,96 @@ +using MongoDB.Driver.Linq; + +namespace Flight.Seats.Features.GettingAvailableSeats.V1; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Web; +using Data; +using Dtos; +using Duende.IdentityServer.EntityFramework.Entities; +using Exceptions; +using FluentValidation; +using Mapster; +using MapsterMapper; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using MongoDB.Driver; + +public record GetAvailableSeats(Guid FlightId) : IQuery; + +public record GetAvailableSeatsResult(IEnumerable SeatDtos); + +public record GetAvailableSeatsResponseDto(IEnumerable SeatDtos); + +public class GetAvailableSeatsEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapGet($"{EndpointConfig.BaseApiPath}/flight/get-available-seats/{{id}}", GetAvailableSeats) + .RequireAuthorization(nameof(ApiScope)) + .WithName("GetAvailableSeats") + .WithApiVersionSet(builder.NewApiVersionSet("Flight").Build()) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Get Available Seats") + .WithDescription("Get Available Seats") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } + + private async Task GetAvailableSeats(Guid id, IMediator mediator, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAvailableSeats(id), cancellationToken); + + var response = result.Adapt(); + + return Results.Ok(response); + } +} + +public class GetAvailableSeatsValidator : AbstractValidator +{ + public GetAvailableSeatsValidator() + { + RuleFor(x => x.FlightId).NotNull().WithMessage("FlightId is required!"); + } +} + +internal class GetAvailableSeatsQueryHandler : IRequestHandler +{ + private readonly IMapper _mapper; + private readonly FlightReadDbContext _flightReadDbContext; + + public GetAvailableSeatsQueryHandler(IMapper mapper, FlightReadDbContext flightReadDbContext) + { + _mapper = mapper; + _flightReadDbContext = flightReadDbContext; + } + + + public async Task Handle(GetAvailableSeats query, CancellationToken cancellationToken) + { + Guard.Against.Null(query, nameof(query)); + + var seats = (await _flightReadDbContext.Seat.AsQueryable().ToListAsync(cancellationToken)) + .Where(x => x.FlightId == query.FlightId && !x.IsDeleted); + + if (!seats.Any()) + { + throw new AllSeatsFullException(); + } + + var seatDtos = _mapper.Map>(seats); + + return new GetAvailableSeatsResult(seatDtos); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/ReservingSeat/V1/ReserveSeat.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/ReservingSeat/V1/ReserveSeat.cs new file mode 100644 index 0000000..4123393 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/ReservingSeat/V1/ReserveSeat.cs @@ -0,0 +1,101 @@ +namespace Flight.Seats.Features.ReservingSeat.V1; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using BuildingBlocks.Web; +using Duende.IdentityServer.EntityFramework.Entities; +using Flight.Data; +using Flight.Seats.Exceptions; +using FluentValidation; +using Mapster; +using MapsterMapper; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; + +public record ReserveSeat(Guid FlightId, string SeatNumber) : ICommand, IInternalCommand; + +public record ReserveSeatResult(Guid Id); + +public record SeatReservedDomainEvent(Guid Id, string SeatNumber, Enums.SeatType Type, Enums.SeatClass Class, + Guid FlightId, bool IsDeleted) : IDomainEvent; + +public record ReserveSeatRequestDto(Guid FlightId, string SeatNumber); + +public record ReserveSeatResponseDto(Guid Id); + +public class ReserveSeatEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPost($"{EndpointConfig.BaseApiPath}/flight/reserve-seat", ReserveSeat) + .RequireAuthorization(nameof(ApiScope)) + .WithName("ReserveSeat") + .WithApiVersionSet(builder.NewApiVersionSet("Flight").Build()) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Reserve Seat") + .WithDescription("Reserve Seat") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } + + private async Task ReserveSeat(ReserveSeatRequestDto request, IMediator mediator, IMapper mapper, + CancellationToken cancellationToken) + { + var command = mapper.Map(request); + + var result = await mediator.Send(command, cancellationToken); + + var response = result.Adapt(); + + return Results.Ok(response); + } +} + +public class ReserveSeatValidator : AbstractValidator +{ + public ReserveSeatValidator() + { + RuleFor(x => x.FlightId).NotEmpty().WithMessage("FlightId must not be empty"); + RuleFor(x => x.SeatNumber).NotEmpty().WithMessage("SeatNumber must not be empty"); + } +} + +internal class ReserveSeatCommandHandler : IRequestHandler +{ + private readonly FlightDbContext _flightDbContext; + + public ReserveSeatCommandHandler(FlightDbContext flightDbContext) + { + _flightDbContext = flightDbContext; + } + + public async Task Handle(ReserveSeat command, CancellationToken cancellationToken) + { + Guard.Against.Null(command, nameof(command)); + + var seat = await _flightDbContext.Seats.SingleOrDefaultAsync( + x => x.SeatNumber.Value == command.SeatNumber && + x.FlightId == command.FlightId, cancellationToken); + + if (seat is null) + { + throw new SeatNumberIncorrectException(); + } + + seat.ReserveSeat(); + + var updatedSeat = _flightDbContext.Seats.Update(seat).Entity; + + return new ReserveSeatResult(updatedSeat.Id); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/ReservingSeat/V1/ReserveSeatMongo.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/ReservingSeat/V1/ReserveSeatMongo.cs new file mode 100644 index 0000000..a99e438 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/ReservingSeat/V1/ReserveSeatMongo.cs @@ -0,0 +1,45 @@ +namespace Flight.Seats.Features.ReservingSeat.V1; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using Flight.Data; +using Flight.Seats.Models; +using MapsterMapper; +using MediatR; +using MongoDB.Driver; + +public record ReserveSeatMongo(Guid Id, string SeatNumber, Enums.SeatType Type, + Enums.SeatClass Class, Guid FlightId, bool IsDeleted = false) : InternalCommand; + +internal class ReserveSeatMongoHandler : ICommandHandler +{ + private readonly FlightReadDbContext _flightReadDbContext; + private readonly IMapper _mapper; + + public ReserveSeatMongoHandler( + FlightReadDbContext flightReadDbContext, + IMapper mapper) + { + _flightReadDbContext = flightReadDbContext; + _mapper = mapper; + } + + public async Task Handle(ReserveSeatMongo command, CancellationToken cancellationToken) + { + Guard.Against.Null(command, nameof(command)); + + var seatReadModel = _mapper.Map(command); + + await _flightReadDbContext.Seat.UpdateOneAsync( + x => x.SeatId == seatReadModel.SeatId, + Builders.Update + .Set(x => x.IsDeleted, seatReadModel.IsDeleted), + cancellationToken: cancellationToken); + + return Unit.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/SeatMappings.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/SeatMappings.cs new file mode 100644 index 0000000..65f24fa --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Features/SeatMappings.cs @@ -0,0 +1,35 @@ +using Flight.Seats.Dtos; +using Flight.Seats.Models; +using Mapster; + +namespace Flight.Seats.Features; + +using CreatingSeat.V1; +using MassTransit; +using ReservingSeat.V1; + +public class SeatMappings : IRegister +{ + public void Register(TypeAdapterConfig config) + { + config.NewConfig() + .ConstructUsing(x => new SeatDto(x.Id.Value, x.SeatNumber.Value, x.Type, x.Class, x.FlightId.Value)); + + config.NewConfig() + .Map(d => d.Id, s => NewId.NextGuid()) + .Map(d => d.SeatId, s => s.Id); + + config.NewConfig() + .Map(d => d.Id, s => NewId.NextGuid()) + .Map(d => d.SeatId, s => s.Id.Value); + + config.NewConfig() + .Map(d => d.SeatId, s => s.Id); + + config.NewConfig() + .ConstructUsing(x => new CreateSeat(x.SeatNumber, x.Type, x.Class, x.FlightId)); + + config.NewConfig() + .ConstructUsing(x => new ReserveSeat(x.FlightId, x.SeatNumber)); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Models/Seat.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Models/Seat.cs new file mode 100644 index 0000000..ee817d9 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Models/Seat.cs @@ -0,0 +1,60 @@ +using System; +using BuildingBlocks.Core.Model; + +namespace Flight.Seats.Models; + +using Features.CreatingSeat.V1; +using Features.ReservingSeat.V1; +using Flight.Flights.ValueObjects; +using ValueObjects; + +public record Seat : Aggregate +{ + public SeatNumber SeatNumber { get; private set; } = default!; + public Enums.SeatType Type { get; private set; } + public Enums.SeatClass Class { get; private set; } + public FlightId FlightId { get; private set; } = default!; + + public static Seat Create(SeatId id, SeatNumber seatNumber, Enums.SeatType type, Enums.SeatClass @class, + FlightId flightId, + bool isDeleted = false) + { + var seat = new Seat() + { + Id = id, + Class = @class, + Type = type, + SeatNumber = seatNumber, + FlightId = flightId, + IsDeleted = isDeleted + }; + + var @event = new SeatCreatedDomainEvent( + seat.Id, + seat.SeatNumber, + seat.Type, + seat.Class, + seat.FlightId, + isDeleted); + + seat.AddDomainEvent(@event); + + return seat; + } + + public void ReserveSeat() + { + this.IsDeleted = true; + this.LastModified = DateTime.Now; + + var @event = new SeatReservedDomainEvent( + this.Id, + this.SeatNumber, + this.Type, + this.Class, + this.FlightId, + this.IsDeleted); + + this.AddDomainEvent(@event); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Models/SeatReadModel.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Models/SeatReadModel.cs new file mode 100644 index 0000000..536b62f --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/Models/SeatReadModel.cs @@ -0,0 +1,14 @@ +namespace Flight.Seats.Models; + +using System; + +public class SeatReadModel +{ + public required Guid Id { get; init; } + public required Guid SeatId { get; init; } + public required string SeatNumber { get; init; } + public required Enums.SeatType Type { get; init; } + public required Enums.SeatClass Class { get; init; } + public required Guid FlightId { get; init; } + public required bool IsDeleted { get; init; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/ValueObjects/SeatId.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/ValueObjects/SeatId.cs new file mode 100644 index 0000000..4196b6e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/ValueObjects/SeatId.cs @@ -0,0 +1,29 @@ +namespace Flight.Seats.ValueObjects; + +using System; +using Exceptions; + +public record SeatId +{ + public Guid Value { get; } + + private SeatId(Guid value) + { + Value = value; + } + + public static SeatId Of(Guid value) + { + if (value == Guid.Empty) + { + throw new InvalidSeatIdException(value); + } + + return new SeatId(value); + } + + public static implicit operator Guid(SeatId seatId) + { + return seatId.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/ValueObjects/SeatNumber.cs b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/ValueObjects/SeatNumber.cs new file mode 100644 index 0000000..e6d4b2e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Flight/src/Seats/ValueObjects/SeatNumber.cs @@ -0,0 +1,28 @@ +namespace Flight.Seats.ValueObjects; + +using Exceptions; + +public record SeatNumber +{ + public string Value { get; } + + private SeatNumber(string value) + { + Value = value; + } + + public static SeatNumber Of(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidSeatNumberException(); + } + + return new SeatNumber(value); + } + + public static implicit operator string(SeatNumber seatNumber) + { + return seatNumber.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/AuthOptions.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/AuthOptions.cs new file mode 100644 index 0000000..c7b7605 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/AuthOptions.cs @@ -0,0 +1,6 @@ +namespace Identity.Configurations; + +public class AuthOptions +{ + public string IssuerUri { get; set; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/Config.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/Config.cs new file mode 100644 index 0000000..d1ded48 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/Config.cs @@ -0,0 +1,65 @@ +namespace Identity.Configurations; + +using System.Collections.Generic; +using Duende.IdentityServer; +using Duende.IdentityServer.Models; +using Identity.Constants; + +public static class Config +{ + public static IEnumerable IdentityResources => + new List + { + new IdentityResources.OpenId(), + new IdentityResources.Profile(), + new IdentityResources.Email(), + new IdentityResources.Phone(), + new IdentityResources.Address(), + new(Constants.StandardScopes.Roles, new List {"role"}) + }; + + + public static IEnumerable ApiScopes => + new List + { + new(Constants.StandardScopes.FlightApi), + new(Constants.StandardScopes.PassengerApi), + new(Constants.StandardScopes.BookingApi), + new(Constants.StandardScopes.IdentityApi) + }; + + + public static IList ApiResources => + new List + { + new(Constants.StandardScopes.FlightApi), + new(Constants.StandardScopes.PassengerApi), + new(Constants.StandardScopes.BookingApi), + new(Constants.StandardScopes.IdentityApi) + }; + + public static IEnumerable Clients => + new List + { + new() + { + ClientId = "client", + AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, + ClientSecrets = + { + new Secret("secret".Sha256()) + }, + AllowedScopes = + { + IdentityServerConstants.StandardScopes.OpenId, + IdentityServerConstants.StandardScopes.Profile, + Constants.StandardScopes.FlightApi, + Constants.StandardScopes.PassengerApi, + Constants.StandardScopes.BookingApi, + Constants.StandardScopes.IdentityApi + }, + AccessTokenLifetime = 3600, // authorize the client to access protected resources + IdentityTokenLifetime = 3600 // authenticate the user + } + }; +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/EventMapper.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/EventMapper.cs new file mode 100644 index 0000000..2cce314 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/EventMapper.cs @@ -0,0 +1,23 @@ +namespace Identity.Configurations; + +using BuildingBlocks.Core; +using BuildingBlocks.Core.Event; + +public sealed class EventMapper : IEventMapper +{ + public IIntegrationEvent? MapToIntegrationEvent(IDomainEvent @event) + { + return @event switch + { + _ => null + }; + } + + public IInternalCommand? MapToInternalCommand(IDomainEvent @event) + { + return @event switch + { + _ => null + }; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/IdentityRoot.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/IdentityRoot.cs new file mode 100644 index 0000000..25fb8b1 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/IdentityRoot.cs @@ -0,0 +1,5 @@ +namespace Identity.Configurations; + +public class IdentityRoot +{ +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/UserValidator.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/UserValidator.cs new file mode 100644 index 0000000..ef6c263 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Configurations/UserValidator.cs @@ -0,0 +1,54 @@ +namespace Identity.Configurations; + +using System.Security.Claims; +using System.Threading.Tasks; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Validation; +using Identity.Models; +using Microsoft.AspNetCore.Identity; + +public class UserValidator : IResourceOwnerPasswordValidator +{ + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + + public UserValidator(SignInManager signInManager, + UserManager userManager) + { + _signInManager = signInManager; + _userManager = userManager; + } + + public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context) + { + var user = await _userManager.FindByNameAsync(context.UserName); + + var signIn = await _signInManager.PasswordSignInAsync( + user, + context.Password, + isPersistent: true, + lockoutOnFailure: true); + + if (signIn.Succeeded) + { + var userId = user!.Id.ToString(); + + // context set to success + context.Result = new GrantValidationResult( + subject: userId, + authenticationMethod: "custom", + claims: new Claim[] + { + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Name, user.UserName) + } + ); + + return; + } + + // context set to Failure + context.Result = new GrantValidationResult( + TokenRequestErrors.UnauthorizedClient, "Invalid Credentials"); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/RoleClaimConfiguration.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/RoleClaimConfiguration.cs new file mode 100644 index 0000000..58fabd9 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/RoleClaimConfiguration.cs @@ -0,0 +1,16 @@ +namespace Identity.Data.Configurations; + +using Identity.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class RoleClaimConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(RoleClaim)); + + // // ref: https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=fluent-api + builder.Property(r => r.Version).IsConcurrencyToken(); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/RoleConfiguration.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/RoleConfiguration.cs new file mode 100644 index 0000000..991cdea --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/RoleConfiguration.cs @@ -0,0 +1,16 @@ +namespace Identity.Data.Configurations; + +using Identity.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class RoleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(Role)); + + // // ref: https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=fluent-api + builder.Property(r => r.Version).IsConcurrencyToken(); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserClaimConfiguration.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserClaimConfiguration.cs new file mode 100644 index 0000000..40f2d30 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserClaimConfiguration.cs @@ -0,0 +1,16 @@ +namespace Identity.Data.Configurations; + +using Identity.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class UserClaimConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(UserClaim)); + + // // ref: https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=fluent-api + builder.Property(r => r.Version).IsConcurrencyToken(); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserConfiguration.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..9a66611 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserConfiguration.cs @@ -0,0 +1,16 @@ +namespace Identity.Data.Configurations; + +using Identity.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(User)); + + // // ref: https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=fluent-api + builder.Property(r => r.Version).IsConcurrencyToken(); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserLoginConfiguration.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserLoginConfiguration.cs new file mode 100644 index 0000000..4b556b0 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserLoginConfiguration.cs @@ -0,0 +1,17 @@ +namespace Identity.Data.Configurations; + +using Identity.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class UserLoginConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(UserLogin)); + + // // ref: https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=fluent-api + builder.Property(r => r.Version).IsConcurrencyToken(); + } +} + diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserRoleConfiguration.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserRoleConfiguration.cs new file mode 100644 index 0000000..c0f4bf8 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserRoleConfiguration.cs @@ -0,0 +1,16 @@ +namespace Identity.Data.Configurations; + +using Identity.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class UserRoleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(UserRole)); + + // // ref: https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=fluent-api + builder.Property(r => r.Version).IsConcurrencyToken(); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserTokenConfiguration.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserTokenConfiguration.cs new file mode 100644 index 0000000..380151e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Configurations/UserTokenConfiguration.cs @@ -0,0 +1,16 @@ +namespace Identity.Data.Configurations; + +using Identity.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class UserTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(UserToken)); + + // // ref: https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=fluent-api + builder.Property(r => r.Version).IsConcurrencyToken(); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/DesignTimeDbContextFactory.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..40f5138 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/DesignTimeDbContextFactory.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Identity.Data; + +public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public IdentityContext CreateDbContext(string[] args) + { + var builder = new DbContextOptionsBuilder(); + + builder.UseNpgsql("Server=localhost;Port=5432;Database=identity;User Id=postgres;Password=postgres;Include Error Detail=true") + .UseSnakeCaseNamingConvention(); + return new IdentityContext(builder.Options); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/EfTxIdentityBehavior.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/EfTxIdentityBehavior.cs new file mode 100644 index 0000000..f3d73cc --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/EfTxIdentityBehavior.cs @@ -0,0 +1,100 @@ +using System.Text.Json; +using System.Transactions; +using BuildingBlocks.Core; +using BuildingBlocks.PersistMessageProcessor; +using BuildingBlocks.Polly; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Identity.Data; + +public class EfTxIdentityBehavior : IPipelineBehavior +where TRequest : notnull, IRequest +where TResponse : notnull +{ + private readonly ILogger> _logger; + private readonly IdentityContext _identityContext; + private readonly IPersistMessageDbContext _persistMessageDbContext; + private readonly IEventDispatcher _eventDispatcher; + + public EfTxIdentityBehavior( + ILogger> logger, + IdentityContext identityContext, + IPersistMessageDbContext persistMessageDbContext, + IEventDispatcher eventDispatcher + ) + { + _logger = logger; + _identityContext = identityContext; + _persistMessageDbContext = persistMessageDbContext; + _eventDispatcher = eventDispatcher; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken + ) + { + _logger.LogInformation( + "{Prefix} Handled command {MediatrRequest}", + GetType().Name, + typeof(TRequest).FullName); + + _logger.LogDebug( + "{Prefix} Handled command {MediatrRequest} with content {RequestContent}", + GetType().Name, + typeof(TRequest).FullName, + JsonSerializer.Serialize(request)); + + var response = await next(); + + _logger.LogInformation( + "{Prefix} Executed the {MediatrRequest} request", + GetType().Name, + typeof(TRequest).FullName); + + while (true) + { + var domainEvents = _identityContext.GetDomainEvents(); + + if (domainEvents is null || !domainEvents.Any()) + { + return response; + } + + _logger.LogInformation( + "{Prefix} Open the transaction for {MediatrRequest}", + GetType().Name, + typeof(TRequest).FullName); + + using var scope = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions {IsolationLevel = IsolationLevel.ReadCommitted}, + TransactionScopeAsyncFlowOption.Enabled); + + await _eventDispatcher.SendAsync( + domainEvents.ToArray(), + typeof(TRequest), + cancellationToken); + + // Save data to database with some retry policy in distributed transaction + await _identityContext.RetryOnFailure( + async () => + { + await _identityContext.SaveChangesAsync(cancellationToken); + }); + + // Save data to database with some retry policy in distributed transaction + await _persistMessageDbContext.RetryOnFailure( + async () => + { + await _persistMessageDbContext.SaveChangesAsync(cancellationToken); + }); + + scope.Complete(); + + return response; + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/IdentityContext.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/IdentityContext.cs new file mode 100644 index 0000000..e0f3237 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/IdentityContext.cs @@ -0,0 +1,171 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Data; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using BuildingBlocks.Core.Event; +using BuildingBlocks.Core.Model; +using BuildingBlocks.EFCore; +using Identity.Identity.Models; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Identity.Data; + +using System; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; + +public sealed class IdentityContext : IdentityDbContext, IDbContext +{ + private readonly ILogger? _logger; + private IDbContextTransaction _currentTransaction; + + public IdentityContext(DbContextOptions options, ILogger? logger = null) : base(options) + { + _logger = logger; + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + base.OnModelCreating(builder); + builder.FilterSoftDeletedProperties(); + builder.ToSnakeCaseTables(); + } + + public IExecutionStrategy CreateExecutionStrategy() => Database.CreateExecutionStrategy(); + + public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) + { + if (_currentTransaction != null) + return; + + _currentTransaction = await Database.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken); + } + + public async Task CommitTransactionAsync(CancellationToken cancellationToken = default) + { + try + { + await SaveChangesAsync(cancellationToken); + await _currentTransaction?.CommitAsync(cancellationToken)!; + } + catch + { + await RollbackTransactionAsync(cancellationToken); + throw; + } + finally + { + _currentTransaction?.Dispose(); + _currentTransaction = null; + } + } + + public async Task RollbackTransactionAsync(CancellationToken cancellationToken = default) + { + try + { + await _currentTransaction?.RollbackAsync(cancellationToken)!; + } + finally + { + _currentTransaction?.Dispose(); + _currentTransaction = null; + } + } + + //ref: https://learn.microsoft.com/en-us/ef/core/miscellaneous/connection-resiliency#execution-strategies-and-transactions + public Task ExecuteTransactionalAsync(CancellationToken cancellationToken = default) + { + var strategy = CreateExecutionStrategy(); + return strategy.ExecuteAsync(async () => + { + await using var transaction = + await Database.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken); + try + { + await SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + }); + } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + OnBeforeSaving(); + try + { + return await base.SaveChangesAsync(cancellationToken); + } + //ref: https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=data-annotations#resolving-concurrency-conflicts + catch (DbUpdateConcurrencyException ex) + { + foreach (var entry in ex.Entries) + { + var databaseValues = await entry.GetDatabaseValuesAsync(cancellationToken); + + if (databaseValues == null) + { + _logger.LogError("The record no longer exists in the database, The record has been deleted by another user."); + throw; + } + + // Refresh the original values to bypass next concurrency check + entry.OriginalValues.SetValues(databaseValues); + } + + return await base.SaveChangesAsync(cancellationToken); + } + } + + public IReadOnlyList GetDomainEvents() + { + var domainEntities = ChangeTracker + .Entries() + .Where(x => x.Entity.DomainEvents.Any()) + .Select(x => x.Entity) + .ToList(); + + var domainEvents = domainEntities + .SelectMany(x => x.DomainEvents) + .ToImmutableList(); + + domainEntities.ForEach(entity => entity.ClearDomainEvents()); + + return domainEvents.ToImmutableList(); + } + + private void OnBeforeSaving() + { + try + { + foreach (var entry in ChangeTracker.Entries()) + { + switch (entry.State) + { + case EntityState.Modified: + entry.Entity.Version++; + break; + + case EntityState.Deleted: + entry.Entity.Version++; + break; + } + } + } + catch (Exception ex) + { + throw new Exception("try for find IVersion", ex); + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Migrations/20230331193410_initial.Designer.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Migrations/20230331193410_initial.Designer.cs new file mode 100644 index 0000000..40c2889 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Migrations/20230331193410_initial.Designer.cs @@ -0,0 +1,377 @@ +// +using System; +using Identity.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Identity.Data.Migrations +{ + [DbContext(typeof(IdentityContext))] + [Migration("20230331193410_initial")] + partial class initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Identity.Identity.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_name"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("asp_net_roles", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("asp_net_role_claims", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("FirstName") + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("LastName") + .HasColumnType("text") + .HasColumnName("last_name"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PassPortNumber") + .HasColumnType("text") + .HasColumnName("pass_port_number"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("asp_net_users", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("asp_net_user_claims", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("asp_net_user_logins", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("asp_net_user_roles", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("asp_net_user_tokens", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.RoleClaim", b => + { + b.HasOne("Identity.Identity.Models.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_role_claims_asp_net_roles_role_id"); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserClaim", b => + { + b.HasOne("Identity.Identity.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_claims_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserLogin", b => + { + b.HasOne("Identity.Identity.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_logins_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserRole", b => + { + b.HasOne("Identity.Identity.Models.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_roles_role_id"); + + b.HasOne("Identity.Identity.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserToken", b => + { + b.HasOne("Identity.Identity.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_tokens_asp_net_users_user_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Migrations/20230331193410_initial.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Migrations/20230331193410_initial.cs new file mode 100644 index 0000000..02df8ed --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Migrations/20230331193410_initial.cs @@ -0,0 +1,233 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Identity.Data.Migrations +{ + /// + public partial class initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "asp_net_roles", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + version = table.Column(type: "bigint", nullable: false), + name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + normalizedname = table.Column(name: "normalized_name", type: "character varying(256)", maxLength: 256, nullable: true), + concurrencystamp = table.Column(name: "concurrency_stamp", type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_roles", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "asp_net_users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + firstname = table.Column(name: "first_name", type: "text", nullable: true), + lastname = table.Column(name: "last_name", type: "text", nullable: true), + passportnumber = table.Column(name: "pass_port_number", type: "text", nullable: true), + version = table.Column(type: "bigint", nullable: false), + username = table.Column(name: "user_name", type: "character varying(256)", maxLength: 256, nullable: true), + normalizedusername = table.Column(name: "normalized_user_name", type: "character varying(256)", maxLength: 256, nullable: true), + email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + normalizedemail = table.Column(name: "normalized_email", type: "character varying(256)", maxLength: 256, nullable: true), + emailconfirmed = table.Column(name: "email_confirmed", type: "boolean", nullable: false), + passwordhash = table.Column(name: "password_hash", type: "text", nullable: true), + securitystamp = table.Column(name: "security_stamp", type: "text", nullable: true), + concurrencystamp = table.Column(name: "concurrency_stamp", type: "text", nullable: true), + phonenumber = table.Column(name: "phone_number", type: "text", nullable: true), + phonenumberconfirmed = table.Column(name: "phone_number_confirmed", type: "boolean", nullable: false), + twofactorenabled = table.Column(name: "two_factor_enabled", type: "boolean", nullable: false), + lockoutend = table.Column(name: "lockout_end", type: "timestamp with time zone", nullable: true), + lockoutenabled = table.Column(name: "lockout_enabled", type: "boolean", nullable: false), + accessfailedcount = table.Column(name: "access_failed_count", type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_users", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "asp_net_role_claims", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + version = table.Column(type: "bigint", nullable: false), + roleid = table.Column(name: "role_id", type: "uuid", nullable: false), + claimtype = table.Column(name: "claim_type", type: "text", nullable: true), + claimvalue = table.Column(name: "claim_value", type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_role_claims", x => x.id); + table.ForeignKey( + name: "fk_asp_net_role_claims_asp_net_roles_role_id", + column: x => x.roleid, + principalTable: "asp_net_roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "asp_net_user_claims", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + version = table.Column(type: "bigint", nullable: false), + userid = table.Column(name: "user_id", type: "uuid", nullable: false), + claimtype = table.Column(name: "claim_type", type: "text", nullable: true), + claimvalue = table.Column(name: "claim_value", type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_user_claims", x => x.id); + table.ForeignKey( + name: "fk_asp_net_user_claims_asp_net_users_user_id", + column: x => x.userid, + principalTable: "asp_net_users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "asp_net_user_logins", + columns: table => new + { + loginprovider = table.Column(name: "login_provider", type: "text", nullable: false), + providerkey = table.Column(name: "provider_key", type: "text", nullable: false), + version = table.Column(type: "bigint", nullable: false), + providerdisplayname = table.Column(name: "provider_display_name", type: "text", nullable: true), + userid = table.Column(name: "user_id", type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_user_logins", x => new { x.loginprovider, x.providerkey }); + table.ForeignKey( + name: "fk_asp_net_user_logins_asp_net_users_user_id", + column: x => x.userid, + principalTable: "asp_net_users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "asp_net_user_roles", + columns: table => new + { + userid = table.Column(name: "user_id", type: "uuid", nullable: false), + roleid = table.Column(name: "role_id", type: "uuid", nullable: false), + version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_user_roles", x => new { x.userid, x.roleid }); + table.ForeignKey( + name: "fk_asp_net_user_roles_asp_net_roles_role_id", + column: x => x.roleid, + principalTable: "asp_net_roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_asp_net_user_roles_asp_net_users_user_id", + column: x => x.userid, + principalTable: "asp_net_users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "asp_net_user_tokens", + columns: table => new + { + userid = table.Column(name: "user_id", type: "uuid", nullable: false), + loginprovider = table.Column(name: "login_provider", type: "text", nullable: false), + name = table.Column(type: "text", nullable: false), + version = table.Column(type: "bigint", nullable: false), + value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_user_tokens", x => new { x.userid, x.loginprovider, x.name }); + table.ForeignKey( + name: "fk_asp_net_user_tokens_asp_net_users_user_id", + column: x => x.userid, + principalTable: "asp_net_users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_role_claims_role_id", + table: "asp_net_role_claims", + column: "role_id"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "asp_net_roles", + column: "normalized_name", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_user_claims_user_id", + table: "asp_net_user_claims", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_user_logins_user_id", + table: "asp_net_user_logins", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_user_roles_role_id", + table: "asp_net_user_roles", + column: "role_id"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "asp_net_users", + column: "normalized_email"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "asp_net_users", + column: "normalized_user_name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "asp_net_role_claims"); + + migrationBuilder.DropTable( + name: "asp_net_user_claims"); + + migrationBuilder.DropTable( + name: "asp_net_user_logins"); + + migrationBuilder.DropTable( + name: "asp_net_user_roles"); + + migrationBuilder.DropTable( + name: "asp_net_user_tokens"); + + migrationBuilder.DropTable( + name: "asp_net_roles"); + + migrationBuilder.DropTable( + name: "asp_net_users"); + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Migrations/IdentityContextModelSnapshot.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Migrations/IdentityContextModelSnapshot.cs new file mode 100644 index 0000000..ce8faa3 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Migrations/IdentityContextModelSnapshot.cs @@ -0,0 +1,374 @@ +// +using System; +using Identity.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Identity.Data.Migrations +{ + [DbContext(typeof(IdentityContext))] + partial class IdentityContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Identity.Identity.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_name"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("asp_net_roles", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("asp_net_role_claims", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("FirstName") + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("LastName") + .HasColumnType("text") + .HasColumnName("last_name"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PassPortNumber") + .HasColumnType("text") + .HasColumnName("pass_port_number"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("asp_net_users", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("asp_net_user_claims", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("asp_net_user_logins", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("asp_net_user_roles", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("asp_net_user_tokens", (string)null); + }); + + modelBuilder.Entity("Identity.Identity.Models.RoleClaim", b => + { + b.HasOne("Identity.Identity.Models.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_role_claims_asp_net_roles_role_id"); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserClaim", b => + { + b.HasOne("Identity.Identity.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_claims_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserLogin", b => + { + b.HasOne("Identity.Identity.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_logins_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserRole", b => + { + b.HasOne("Identity.Identity.Models.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_roles_role_id"); + + b.HasOne("Identity.Identity.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Identity.Identity.Models.UserToken", b => + { + b.HasOne("Identity.Identity.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_tokens_asp_net_users_user_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Seed/IdentityDataSeeder.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Seed/IdentityDataSeeder.cs new file mode 100644 index 0000000..d762dc6 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Seed/IdentityDataSeeder.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading.Tasks; +using BuildingBlocks.Contracts.EventBus.Messages; +using BuildingBlocks.Core; +using BuildingBlocks.EFCore; +using Identity.Identity.Constants; +using Identity.Identity.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Identity.Data.Seed; + +using System.Linq; + +public class IdentityDataSeeder : IDataSeeder +{ + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + private readonly IEventDispatcher _eventDispatcher; + private readonly IdentityContext _identityContext; + + public IdentityDataSeeder( + UserManager userManager, + RoleManager roleManager, + IEventDispatcher eventDispatcher, + IdentityContext identityContext + ) + { + _userManager = userManager; + _roleManager = roleManager; + _eventDispatcher = eventDispatcher; + _identityContext = identityContext; + } + + public async Task SeedAllAsync() + { + var pendingMigrations = await _identityContext.Database.GetPendingMigrationsAsync(); + + if (!pendingMigrations.Any()) + { + await SeedRoles(); + await SeedUsers(); + } + } + + private async Task SeedRoles() + { + if (!await _identityContext.Roles.AnyAsync()) + { + if (await _roleManager.RoleExistsAsync(Constants.Role.Admin) == false) + { + await _roleManager.CreateAsync(new Role {Name = Constants.Role.Admin}); + } + + if (await _roleManager.RoleExistsAsync(Constants.Role.User) == false) + { + await _roleManager.CreateAsync(new Role {Name = Constants.Role.User}); + } + } + } + + private async Task SeedUsers() + { + if (!await _identityContext.Users.AnyAsync()) + { + if (await _userManager.FindByNameAsync("samh") == null) + { + var result = await _userManager.CreateAsync(InitialData.Users.First(), "Admin@123456"); + + if (result.Succeeded) + { + await _userManager.AddToRoleAsync(InitialData.Users.First(), Constants.Role.Admin); + + await _eventDispatcher.SendAsync( + new UserCreated( + InitialData.Users.First().Id, + InitialData.Users.First().FirstName + + " " + + InitialData.Users.First().LastName, + InitialData.Users.First().PassPortNumber)); + } + } + + if (await _userManager.FindByNameAsync("meysamh2") == null) + { + var result = await _userManager.CreateAsync(InitialData.Users.Last(), "User@123456"); + + if (result.Succeeded) + { + await _userManager.AddToRoleAsync(InitialData.Users.Last(), Constants.Role.User); + + await _eventDispatcher.SendAsync( + new UserCreated( + InitialData.Users.Last().Id, + InitialData.Users.Last().FirstName + + " " + + InitialData.Users.Last().LastName, + InitialData.Users.Last().PassPortNumber)); + } + } + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Seed/InitialData.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Seed/InitialData.cs new file mode 100644 index 0000000..e380c82 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/Seed/InitialData.cs @@ -0,0 +1,38 @@ +namespace Identity.Data.Seed; + +using System; +using System.Collections.Generic; +using Identity.Models; +using MassTransit; + +public static class InitialData +{ + public static List Users { get; } + + static InitialData() + { + Users = new List + { + new User + { + Id = NewId.NextGuid(), + FirstName = "Sam", + LastName = "H", + UserName = "samh", + PassPortNumber = "123456789", + Email = "sam@test.com", + SecurityStamp = Guid.NewGuid().ToString() + }, + new User + { + Id = NewId.NextGuid(), + FirstName = "Sam2", + LastName = "H2", + UserName = "samh2", + PassPortNumber = "987654321", + Email = "sam2@test.com", + SecurityStamp = Guid.NewGuid().ToString() + } + }; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/readme.md b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/readme.md new file mode 100644 index 0000000..69eb2cb --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Data/readme.md @@ -0,0 +1,2 @@ +dotnet ef migrations add initial --context IdentityContext -o "Data\Migrations" +dotnet ef database update --context IdentityContext diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Extensions/Infrastructure/IdentityServerExtensions.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Extensions/Infrastructure/IdentityServerExtensions.cs new file mode 100644 index 0000000..5605abe --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Extensions/Infrastructure/IdentityServerExtensions.cs @@ -0,0 +1,49 @@ +using BuildingBlocks.Web; +using Identity.Data; +using Identity.Identity.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; + +namespace Identity.Extensions.Infrastructure; + +using Configurations; + +public static class IdentityServerExtensions +{ + public static WebApplicationBuilder AddCustomIdentityServer(this WebApplicationBuilder builder) + { + builder.Services.AddValidateOptions(); + var authOptions = builder.Services.GetOptions(nameof(AuthOptions)); + + builder.Services.AddIdentity(config => + { + config.Password.RequiredLength = 6; + config.Password.RequireDigit = false; + config.Password.RequireNonAlphanumeric = false; + config.Password.RequireUppercase = false; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + var identityServerBuilder = builder.Services.AddIdentityServer(options => + { + options.Events.RaiseErrorEvents = true; + options.Events.RaiseInformationEvents = true; + options.Events.RaiseFailureEvents = true; + options.Events.RaiseSuccessEvents = true; + options.IssuerUri = authOptions.IssuerUri; + }) + .AddInMemoryIdentityResources(Config.IdentityResources) + .AddInMemoryApiResources(Config.ApiResources) + .AddInMemoryApiScopes(Config.ApiScopes) + .AddInMemoryClients(Config.Clients) + .AddAspNetIdentity() + .AddResourceOwnerValidator(); + + //ref: https://documentation.openiddict.com/configuration/encryption-and-signing-credentials.html + identityServerBuilder.AddDeveloperSigningCredential(); + + return builder; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Extensions/Infrastructure/InfrastructureExtensions.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Extensions/Infrastructure/InfrastructureExtensions.cs new file mode 100644 index 0000000..d38d12b --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Extensions/Infrastructure/InfrastructureExtensions.cs @@ -0,0 +1,41 @@ +using BuildingBlocks.Core; +using BuildingBlocks.EFCore; +using Identity.Configurations; +using Identity.Data; +using Identity.Data.Seed; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.DependencyInjection; + +namespace Identity.Extensions.Infrastructure; + + +public static class InfrastructureExtensions +{ + public static WebApplicationBuilder AddIdentityModules(this WebApplicationBuilder builder) + { + builder.Services.AddScoped(); + builder.AddCustomDbContext(nameof(Identity)); + builder.Services.AddScoped(); + builder.AddCustomIdentityServer(); + + builder.Services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + }); + + builder.Services.AddCustomMediatR(); + + return builder; + } + + + public static WebApplication UseIdentityModules(this WebApplication app) + { + app.UseForwardedHeaders(); + app.UseIdentityServer(); + app.UseMigration(); + + return app; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Extensions/Infrastructure/MediatRExtensions.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Extensions/Infrastructure/MediatRExtensions.cs new file mode 100644 index 0000000..1e5145c --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Extensions/Infrastructure/MediatRExtensions.cs @@ -0,0 +1,25 @@ +using BuildingBlocks.Caching; +using BuildingBlocks.Logging; +using BuildingBlocks.Validation; +using Identity.Data; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace Identity.Extensions.Infrastructure; + +using Configurations; + +public static class MediatRExtensions +{ + public static IServiceCollection AddCustomMediatR(this IServiceCollection services) + { + services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(IdentityRoot).Assembly)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); + // services.AddScoped(typeof(IPipelineBehavior<,>), typeof(EfTxIdentityBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(InvalidateCachingBehavior<,>)); + + return services; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity.csproj b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity.csproj new file mode 100644 index 0000000..546c9fa --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity.csproj @@ -0,0 +1,18 @@ + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Constants/Constants.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Constants/Constants.cs new file mode 100644 index 0000000..d87e053 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Constants/Constants.cs @@ -0,0 +1,19 @@ +namespace Identity.Identity.Constants; + +public static class Constants +{ + public static class Role + { + public const string Admin = "admin"; + public const string User = "user"; + } + + public static class StandardScopes + { + public const string Roles = "roles"; + public const string FlightApi = "flight-api"; + public const string PassengerApi = "passenger-api"; + public const string BookingApi = "booking-api"; + public const string IdentityApi = "identity-api"; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Exceptions/RegisterIdentityUserException.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Exceptions/RegisterIdentityUserException.cs new file mode 100644 index 0000000..632290e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Exceptions/RegisterIdentityUserException.cs @@ -0,0 +1,10 @@ +namespace Identity.Identity.Exceptions; + +using BuildingBlocks.Exception; + +public class RegisterIdentityUserException : AppException +{ + public RegisterIdentityUserException(string error) : base(error) + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Features/IdentityMappings.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Features/IdentityMappings.cs new file mode 100644 index 0000000..906485a --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Features/IdentityMappings.cs @@ -0,0 +1,15 @@ +using Mapster; + +namespace Identity.Identity.Features; + +using RegisteringNewUser.V1; + +public class IdentityMappings : IRegister +{ + public void Register(TypeAdapterConfig config) + { + config.NewConfig() + .ConstructUsing(x => new RegisterNewUser(x.FirstName, x.LastName, x.Username, x.Email, + x.Password, x.ConfirmPassword, x.PassportNumber)); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Features/RegisteringNewUser/V1/RegisterNewUser.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Features/RegisteringNewUser/V1/RegisterNewUser.cs new file mode 100644 index 0000000..0e95a69 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Features/RegisteringNewUser/V1/RegisterNewUser.cs @@ -0,0 +1,135 @@ +namespace Identity.Identity.Features.RegisteringNewUser.V1; + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using BuildingBlocks.Contracts.EventBus.Messages; +using BuildingBlocks.Core; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Web; +using Duende.IdentityServer.EntityFramework.Entities; +using Exceptions; +using FluentValidation; +using Mapster; +using MapsterMapper; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Models; + +public record RegisterNewUser(string FirstName, string LastName, string Username, string Email, + string Password, string ConfirmPassword, string PassportNumber) : ICommand; + +public record RegisterNewUserResult(Guid Id, string FirstName, string LastName, string Username, string PassportNumber); + +public record RegisterNewUserRequestDto(string FirstName, string LastName, string Username, string Email, + string Password, string ConfirmPassword, string PassportNumber); + +public record RegisterNewUserResponseDto(Guid Id, string FirstName, string LastName, string Username, + string PassportNumber); + +public class RegisterNewUserEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPost($"{EndpointConfig.BaseApiPath}/identity/register-user", async ( + RegisterNewUserRequestDto request, IMediator mediator, IMapper mapper, + CancellationToken cancellationToken) => + { + var command = mapper.Map(request); + + var result = await mediator.Send(command, cancellationToken); + + var response = result.Adapt(); + + return Results.Ok(response); + }) + // .RequireAuthorization() + .WithName("RegisterUser") + .WithApiVersionSet(builder.NewApiVersionSet("Identity").Build()) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Register User") + .WithDescription("Register User") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } +} + +public class RegisterNewUserValidator : AbstractValidator +{ + public RegisterNewUserValidator() + { + RuleFor(x => x.Password).NotEmpty().WithMessage("Please enter the password"); + RuleFor(x => x.ConfirmPassword).NotEmpty().WithMessage("Please enter the confirmation password"); + + RuleFor(x => x).Custom((x, context) => + { + if (x.Password != x.ConfirmPassword) + { + context.AddFailure(nameof(x.Password), "Passwords should match"); + } + }); + + RuleFor(x => x.Username).NotEmpty().WithMessage("Please enter the username"); + RuleFor(x => x.FirstName).NotEmpty().WithMessage("Please enter the first name"); + RuleFor(x => x.LastName).NotEmpty().WithMessage("Please enter the last name"); + RuleFor(x => x.Email).NotEmpty().WithMessage("Please enter the last email") + .EmailAddress().WithMessage("A valid email is required"); + } +} + +internal class RegisterNewUserHandler : ICommandHandler +{ + private readonly IEventDispatcher _eventDispatcher; + private readonly UserManager _userManager; + + public RegisterNewUserHandler(UserManager userManager, + IEventDispatcher eventDispatcher) + { + _userManager = userManager; + _eventDispatcher = eventDispatcher; + } + + public async Task Handle(RegisterNewUser request, + CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var applicationUser = new User() + { + FirstName = request.FirstName, + LastName = request.LastName, + UserName = request.Username, + Email = request.Email, + PasswordHash = request.Password, + PassPortNumber = request.PassportNumber + }; + + var identityResult = await _userManager.CreateAsync(applicationUser, request.Password); + var roleResult = await _userManager.AddToRoleAsync(applicationUser, Constants.Constants.Role.User); + + if (identityResult.Succeeded == false) + { + throw new RegisterIdentityUserException(string.Join(',', identityResult.Errors.Select(e => e.Description))); + } + + if (roleResult.Succeeded == false) + { + throw new RegisterIdentityUserException(string.Join(',', roleResult.Errors.Select(e => e.Description))); + } + + await _eventDispatcher.SendAsync(new UserCreated(applicationUser.Id, + applicationUser.FirstName + " " + applicationUser.LastName, + applicationUser.PassPortNumber), cancellationToken: cancellationToken); + + return new RegisterNewUserResult(applicationUser.Id, applicationUser.FirstName, applicationUser.LastName, + applicationUser.UserName, applicationUser.PassPortNumber); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/Role.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/Role.cs new file mode 100644 index 0000000..3e14e3d --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/Role.cs @@ -0,0 +1,10 @@ +namespace Identity.Identity.Models; + +using System; +using BuildingBlocks.Core.Model; +using Microsoft.AspNetCore.Identity; + +public class Role : IdentityRole, IVersion +{ + public long Version { get; set; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/RoleClaim.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/RoleClaim.cs new file mode 100644 index 0000000..cc59e12 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/RoleClaim.cs @@ -0,0 +1,10 @@ +namespace Identity.Identity.Models; + +using System; +using BuildingBlocks.Core.Model; +using Microsoft.AspNetCore.Identity; + +public class RoleClaim : IdentityRoleClaim, IVersion +{ + public long Version { get; set; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/User.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/User.cs new file mode 100644 index 0000000..2c1bdb0 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/User.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Identity; + +namespace Identity.Identity.Models; + +using System; +using BuildingBlocks.Core.Model; + +public class User : IdentityUser, IVersion +{ + public required string FirstName { get; init; } + public required string LastName { get; init; } + public required string PassPortNumber { get; init; } + public long Version { get; set; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/UserClaim.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/UserClaim.cs new file mode 100644 index 0000000..c77e433 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/UserClaim.cs @@ -0,0 +1,10 @@ +namespace Identity.Identity.Models; + +using System; +using BuildingBlocks.Core.Model; +using Microsoft.AspNetCore.Identity; + +public class UserClaim : IdentityUserClaim, IVersion +{ + public long Version { get; set; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/UserLogin.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/UserLogin.cs new file mode 100644 index 0000000..4d9fc22 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/UserLogin.cs @@ -0,0 +1,10 @@ +namespace Identity.Identity.Models; + +using System; +using BuildingBlocks.Core.Model; +using Microsoft.AspNetCore.Identity; + +public class UserLogin : IdentityUserLogin, IVersion +{ + public long Version { get; set; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/UserRole.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/UserRole.cs new file mode 100644 index 0000000..3f0adf3 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/UserRole.cs @@ -0,0 +1,10 @@ +namespace Identity.Identity.Models; + +using System; +using BuildingBlocks.Core.Model; +using Microsoft.AspNetCore.Identity; + +public class UserRole : IdentityUserRole, IVersion +{ + public long Version { get; set; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/UserToken.cs b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/UserToken.cs new file mode 100644 index 0000000..ce88cbf --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Identity/src/Identity/Models/UserToken.cs @@ -0,0 +1,10 @@ +namespace Identity.Identity.Models; + +using System; +using BuildingBlocks.Core.Model; +using Microsoft.AspNetCore.Identity; + +public class UserToken : IdentityUserToken, IVersion +{ + public long Version { get; set; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/Configurations/PassengerConfiguration.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/Configurations/PassengerConfiguration.cs new file mode 100644 index 0000000..5871d21 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/Configurations/PassengerConfiguration.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Passenger.Data.Configurations; + +using Passengers.ValueObjects; + +public class PassengerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(Passengers.Models.Passenger)); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).ValueGeneratedNever() + .HasConversion(passengerId => passengerId.Value, dbId => PassengerId.Of(dbId)); + + builder.Property(r => r.Version).IsConcurrencyToken(); + + builder.OwnsOne( + x => x.Name, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Passengers.Models.Passenger.Name)) + .HasMaxLength(50) + .IsRequired(); + } + ); + + builder.OwnsOne( + x => x.PassportNumber, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Passengers.Models.Passenger.PassportNumber)) + .HasMaxLength(10) + .IsRequired(); + } + ); + + builder.OwnsOne( + x => x.Age, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Passengers.Models.Passenger.Age)) + .HasMaxLength(3) + .IsRequired(); + } + ); + + builder.Property(x => x.PassengerType) + .IsRequired() + .HasDefaultValue(Passengers.Enums.PassengerType.Unknown) + .HasConversion( + x => x.ToString(), + x => (Passengers.Enums.PassengerType)Enum.Parse(typeof(Passengers.Enums.PassengerType), x)); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/DesignTimeDbContextFactory.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..4ee9534 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/DesignTimeDbContextFactory.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Passenger.Data; + +public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public PassengerDbContext CreateDbContext(string[] args) + { + var builder = new DbContextOptionsBuilder(); + + builder.UseNpgsql("Server=localhost;Port=5432;Database=passenger;User Id=postgres;Password=postgres;Include Error Detail=true") + .UseSnakeCaseNamingConvention(); + return new PassengerDbContext(builder.Options); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/EfTxPassengerBehavior.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/EfTxPassengerBehavior.cs new file mode 100644 index 0000000..3c15bce --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/EfTxPassengerBehavior.cs @@ -0,0 +1,100 @@ +using System.Text.Json; +using System.Transactions; +using BuildingBlocks.Core; +using BuildingBlocks.PersistMessageProcessor; +using BuildingBlocks.Polly; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Passenger.Data; + +public class EfTxPassengerBehavior : IPipelineBehavior +where TRequest : notnull, IRequest +where TResponse : notnull +{ + private readonly ILogger> _logger; + private readonly PassengerDbContext _passengerDbContext; + private readonly IPersistMessageDbContext _persistMessageDbContext; + private readonly IEventDispatcher _eventDispatcher; + + public EfTxPassengerBehavior( + ILogger> logger, + PassengerDbContext passengerDbContext, + IPersistMessageDbContext persistMessageDbContext, + IEventDispatcher eventDispatcher + ) + { + _logger = logger; + _passengerDbContext = passengerDbContext; + _persistMessageDbContext = persistMessageDbContext; + _eventDispatcher = eventDispatcher; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken + ) + { + _logger.LogInformation( + "{Prefix} Handled command {MediatrRequest}", + GetType().Name, + typeof(TRequest).FullName); + + _logger.LogDebug( + "{Prefix} Handled command {MediatrRequest} with content {RequestContent}", + GetType().Name, + typeof(TRequest).FullName, + JsonSerializer.Serialize(request)); + + var response = await next(); + + _logger.LogInformation( + "{Prefix} Executed the {MediatrRequest} request", + GetType().Name, + typeof(TRequest).FullName); + + while (true) + { + var domainEvents = _passengerDbContext.GetDomainEvents(); + + if (domainEvents is null || !domainEvents.Any()) + { + return response; + } + + _logger.LogInformation( + "{Prefix} Open the transaction for {MediatrRequest}", + GetType().Name, + typeof(TRequest).FullName); + + using var scope = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions {IsolationLevel = IsolationLevel.ReadCommitted}, + TransactionScopeAsyncFlowOption.Enabled); + + await _eventDispatcher.SendAsync( + domainEvents.ToArray(), + typeof(TRequest), + cancellationToken); + + // Save data to database with some retry policy in distributed transaction + await _passengerDbContext.RetryOnFailure( + async () => + { + await _passengerDbContext.SaveChangesAsync(cancellationToken); + }); + + // Save data to database with some retry policy in distributed transaction + await _persistMessageDbContext.RetryOnFailure( + async () => + { + await _persistMessageDbContext.SaveChangesAsync(cancellationToken); + }); + + scope.Complete(); + + return response; + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/Migrations/20230611213031_initial.Designer.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/Migrations/20230611213031_initial.Designer.cs new file mode 100644 index 0000000..d471cb8 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/Migrations/20230611213031_initial.Designer.cs @@ -0,0 +1,150 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Passenger.Data; + +#nullable disable + +namespace Passenger.Data.Migrations +{ + [DbContext(typeof(PassengerDbContext))] + [Migration("20230611213031_initial")] + partial class initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Passenger.Passengers.Models.Passenger", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("LastModifiedBy") + .HasColumnType("bigint") + .HasColumnName("last_modified_by"); + + b.Property("PassengerType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Unknown") + .HasColumnName("passenger_type"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_passenger"); + + b.ToTable("passenger", (string)null); + }); + + modelBuilder.Entity("Passenger.Passengers.Models.Passenger", b => + { + b.OwnsOne("Passenger.Passengers.ValueObjects.Age", "Age", b1 => + { + b1.Property("PassengerId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasMaxLength(3) + .HasColumnType("integer") + .HasColumnName("age"); + + b1.HasKey("PassengerId") + .HasName("pk_passenger"); + + b1.ToTable("passenger"); + + b1.WithOwner() + .HasForeignKey("PassengerId") + .HasConstraintName("fk_passenger_passenger_id"); + }); + + b.OwnsOne("Passenger.Passengers.ValueObjects.Name", "Name", b1 => + { + b1.Property("PassengerId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b1.HasKey("PassengerId") + .HasName("pk_passenger"); + + b1.ToTable("passenger"); + + b1.WithOwner() + .HasForeignKey("PassengerId") + .HasConstraintName("fk_passenger_passenger_id"); + }); + + b.OwnsOne("Passenger.Passengers.ValueObjects.PassportNumber", "PassportNumber", b1 => + { + b1.Property("PassengerId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("passport_number"); + + b1.HasKey("PassengerId") + .HasName("pk_passenger"); + + b1.ToTable("passenger"); + + b1.WithOwner() + .HasForeignKey("PassengerId") + .HasConstraintName("fk_passenger_passenger_id"); + }); + + b.Navigation("Age"); + + b.Navigation("Name") + .IsRequired(); + + b.Navigation("PassportNumber") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/Migrations/20230611213031_initial.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/Migrations/20230611213031_initial.cs new file mode 100644 index 0000000..57c1647 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/Migrations/20230611213031_initial.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Passenger.Data.Migrations +{ + /// + public partial class initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "passenger", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + passportnumber = table.Column(name: "passport_number", type: "character varying(10)", maxLength: 10, nullable: false), + name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + passengertype = table.Column(name: "passenger_type", type: "text", nullable: false, defaultValue: "Unknown"), + age = table.Column(type: "integer", maxLength: 3, nullable: true), + createdat = table.Column(name: "created_at", type: "timestamp with time zone", nullable: true), + createdby = table.Column(name: "created_by", type: "bigint", nullable: true), + lastmodified = table.Column(name: "last_modified", type: "timestamp with time zone", nullable: true), + lastmodifiedby = table.Column(name: "last_modified_by", type: "bigint", nullable: true), + isdeleted = table.Column(name: "is_deleted", type: "boolean", nullable: false), + version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_passenger", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "passenger"); + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/Migrations/PassengerDbContextModelSnapshot.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/Migrations/PassengerDbContextModelSnapshot.cs new file mode 100644 index 0000000..122b184 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/Migrations/PassengerDbContextModelSnapshot.cs @@ -0,0 +1,147 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Passenger.Data; + +#nullable disable + +namespace Passenger.Data.Migrations +{ + [DbContext(typeof(PassengerDbContext))] + partial class PassengerDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Passenger.Passengers.Models.Passenger", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("LastModifiedBy") + .HasColumnType("bigint") + .HasColumnName("last_modified_by"); + + b.Property("PassengerType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Unknown") + .HasColumnName("passenger_type"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_passenger"); + + b.ToTable("passenger", (string)null); + }); + + modelBuilder.Entity("Passenger.Passengers.Models.Passenger", b => + { + b.OwnsOne("Passenger.Passengers.ValueObjects.Age", "Age", b1 => + { + b1.Property("PassengerId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .HasMaxLength(3) + .HasColumnType("integer") + .HasColumnName("age"); + + b1.HasKey("PassengerId") + .HasName("pk_passenger"); + + b1.ToTable("passenger"); + + b1.WithOwner() + .HasForeignKey("PassengerId") + .HasConstraintName("fk_passenger_passenger_id"); + }); + + b.OwnsOne("Passenger.Passengers.ValueObjects.Name", "Name", b1 => + { + b1.Property("PassengerId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b1.HasKey("PassengerId") + .HasName("pk_passenger"); + + b1.ToTable("passenger"); + + b1.WithOwner() + .HasForeignKey("PassengerId") + .HasConstraintName("fk_passenger_passenger_id"); + }); + + b.OwnsOne("Passenger.Passengers.ValueObjects.PassportNumber", "PassportNumber", b1 => + { + b1.Property("PassengerId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("passport_number"); + + b1.HasKey("PassengerId") + .HasName("pk_passenger"); + + b1.ToTable("passenger"); + + b1.WithOwner() + .HasForeignKey("PassengerId") + .HasConstraintName("fk_passenger_passenger_id"); + }); + + b.Navigation("Age"); + + b.Navigation("Name") + .IsRequired(); + + b.Navigation("PassportNumber") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/PassengerDbContext.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/PassengerDbContext.cs new file mode 100644 index 0000000..2aa1227 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/PassengerDbContext.cs @@ -0,0 +1,27 @@ +using System.Reflection; +using BuildingBlocks.EFCore; +using BuildingBlocks.Web; +using Microsoft.EntityFrameworkCore; + +namespace Passenger.Data; + +using Microsoft.Extensions.Logging; + +public sealed class PassengerDbContext : AppDbContextBase +{ + public PassengerDbContext(DbContextOptions options, + ICurrentUserProvider? currentUserProvider = null, ILogger? logger = null) : + base(options, currentUserProvider, logger) + { + } + + public DbSet Passengers => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + base.OnModelCreating(builder); + builder.FilterSoftDeletedProperties(); + builder.ToSnakeCaseTables(); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/PassengerReadDbContext.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/PassengerReadDbContext.cs new file mode 100644 index 0000000..e4dcf14 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/PassengerReadDbContext.cs @@ -0,0 +1,18 @@ +using BuildingBlocks.Mongo; +using Humanizer; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace Passenger.Data; + +using Passengers.Models; + +public class PassengerReadDbContext : MongoDbContext +{ + public PassengerReadDbContext(IOptions options) : base(options) + { + Passenger = GetCollection(nameof(Passenger).Underscore()); + } + + public IMongoCollection Passenger { get; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/readme.md b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/readme.md new file mode 100644 index 0000000..97deb62 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Data/readme.md @@ -0,0 +1,2 @@ +dotnet ef migrations add initial --context PassengerDbContext -o "Data\Migrations" +dotnet ef database update --context PassengerDbContext diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/EventMapper.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/EventMapper.cs new file mode 100644 index 0000000..21a700e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/EventMapper.cs @@ -0,0 +1,33 @@ +using BuildingBlocks.Contracts.EventBus.Messages; +using BuildingBlocks.Core; +using BuildingBlocks.Core.Event; + +namespace Passenger; + +using Identity.Consumers.RegisteringNewUser.V1; +using Passengers.Features.CompletingRegisterPassenger.V1; + +public sealed class EventMapper : IEventMapper +{ + public IIntegrationEvent? MapToIntegrationEvent(IDomainEvent @event) + { + return @event switch + { + PassengerRegistrationCompletedDomainEvent e => new PassengerRegistrationCompleted(e.Id), + PassengerCreatedDomainEvent e => new PassengerCreated(e.Id), + _ => null + }; + } + + public IInternalCommand? MapToInternalCommand(IDomainEvent @event) + { + return @event switch + { + PassengerRegistrationCompletedDomainEvent e => new CompleteRegisterPassengerMongoCommand(e.Id, e.PassportNumber, e.Name, e.PassengerType, + e.Age, e.IsDeleted), + PassengerCreatedDomainEvent e => new CompleteRegisterPassengerMongoCommand(e.Id, e.PassportNumber, e.Name, Passengers.Enums.PassengerType.Unknown, + 0, e.IsDeleted), + _ => null + }; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Exceptions/InvalidPassengerIdException.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Exceptions/InvalidPassengerIdException.cs new file mode 100644 index 0000000..b192a22 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Exceptions/InvalidPassengerIdException.cs @@ -0,0 +1,12 @@ +namespace Passenger.Exceptions; +using System; + +using BuildingBlocks.Exception; + +public class InvalidPassengerIdException : BadRequestException +{ + public InvalidPassengerIdException(Guid passengerId) + : base($"PassengerId: '{passengerId}' is invalid.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Extensions/Infrastructure/InfrastructureExtensions.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Extensions/Infrastructure/InfrastructureExtensions.cs new file mode 100644 index 0000000..611b3e1 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Extensions/Infrastructure/InfrastructureExtensions.cs @@ -0,0 +1,32 @@ +using BuildingBlocks.Core; +using BuildingBlocks.EFCore; +using BuildingBlocks.Mongo; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Passenger.Data; +using Passenger.GrpcServer.Services; + +namespace Passenger.Extensions.Infrastructure; + +public static class InfrastructureExtensions +{ + public static WebApplicationBuilder AddPassengerModules(this WebApplicationBuilder builder) + { + builder.Services.AddScoped(); + builder.AddCustomDbContext(nameof(Passenger)); + builder.AddMongoDbContext(); + + builder.Services.AddCustomMediatR(); + + return builder; + } + + + public static WebApplication UsePassengerModules(this WebApplication app) + { + app.UseMigration(); + app.MapGrpcService(); + + return app; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Extensions/Infrastructure/MediatRExtensions.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Extensions/Infrastructure/MediatRExtensions.cs new file mode 100644 index 0000000..ad6bf1c --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Extensions/Infrastructure/MediatRExtensions.cs @@ -0,0 +1,23 @@ +using BuildingBlocks.Caching; +using BuildingBlocks.Logging; +using BuildingBlocks.Validation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Passenger.Data; + +namespace Passenger.Extensions.Infrastructure; + +public static class MediatRExtensions +{ + public static IServiceCollection AddCustomMediatR(this IServiceCollection services) + { + services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(PassengerRoot).Assembly)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); + // services.AddScoped(typeof(IPipelineBehavior<,>), typeof(EfTxPassengerBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(InvalidateCachingBehavior<,>)); + + return services; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/GrpcServer/Protos/passenger.proto b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/GrpcServer/Protos/passenger.proto new file mode 100644 index 0000000..bb98c29 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/GrpcServer/Protos/passenger.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +package passenger; + +service PassengerGrpcService { + + rpc GetById (GetByIdRequest) returns (GetPassengerByIdResult); +} + +message GetByIdRequest { + string Id = 1; +} + +message GetPassengerByIdResult { + PassengerResponse PassengerDto = 1; +} + +message PassengerResponse { + string Id = 1; + string Name = 2; + string PassportNumber = 3; + PassengerType PassengerType = 4; + int32 Age = 5; + string Email = 6; +} + + + enum PassengerType { + PASSENGER_TYPE_UNKNOWN = 0; + PASSENGER_TYPE_MALE = 1; + PASSENGER_TYPE_FEMALE = 2; + PASSENGER_TYPE_BABY = 3; + } + diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/GrpcServer/Services/PassengerGrpcServices.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/GrpcServer/Services/PassengerGrpcServices.cs new file mode 100644 index 0000000..d66cca5 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/GrpcServer/Services/PassengerGrpcServices.cs @@ -0,0 +1,24 @@ +using Grpc.Core; +using MediatR; + +namespace Passenger.GrpcServer.Services; + +using Mapster; +using Passengers.Features.GettingPassengerById.V1; +using GetPassengerByIdResult = Passenger.GetPassengerByIdResult; + +public class PassengerGrpcServices : PassengerGrpcService.PassengerGrpcServiceBase +{ + private readonly IMediator _mediator; + + public PassengerGrpcServices(IMediator mediator) + { + _mediator = mediator; + } + + public override async Task GetById(GetByIdRequest request, ServerCallContext context) + { + var result = await _mediator.Send(new GetPassengerById(new Guid(request.Id))); + return result?.Adapt(); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Identity/Consumers/RegisteringNewUser/V1/PassengerCreatedDomainEvent.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Identity/Consumers/RegisteringNewUser/V1/PassengerCreatedDomainEvent.cs new file mode 100644 index 0000000..1e96c2d --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Identity/Consumers/RegisteringNewUser/V1/PassengerCreatedDomainEvent.cs @@ -0,0 +1,5 @@ +namespace Passenger.Identity.Consumers.RegisteringNewUser.V1; + +using BuildingBlocks.Core.Event; + +public record PassengerCreatedDomainEvent(Guid Id, string Name, string PassportNumber, bool IsDeleted = false) : IDomainEvent; diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Identity/Consumers/RegisteringNewUser/V1/RegisterNewUser.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Identity/Consumers/RegisteringNewUser/V1/RegisterNewUser.cs new file mode 100644 index 0000000..a674210 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Identity/Consumers/RegisteringNewUser/V1/RegisterNewUser.cs @@ -0,0 +1,59 @@ +namespace Passenger.Identity.Consumers.RegisteringNewUser.V1; + +using Ardalis.GuardClauses; +using BuildingBlocks.Contracts.EventBus.Messages; +using BuildingBlocks.Core; +using BuildingBlocks.Core.Event; +using BuildingBlocks.Web; +using Data; +using Humanizer; +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Passengers.ValueObjects; + +public class RegisterNewUserHandler : IConsumer +{ + private readonly PassengerDbContext _passengerDbContext; + private readonly IEventDispatcher _eventDispatcher; + private readonly ILogger _logger; + private readonly AppOptions _options; + + public RegisterNewUserHandler(PassengerDbContext passengerDbContext, + IEventDispatcher eventDispatcher, + ILogger logger, + IOptions options) + { + _passengerDbContext = passengerDbContext; + _eventDispatcher = eventDispatcher; + _logger = logger; + _options = options.Value; + } + + public async Task Consume(ConsumeContext context) + { + Guard.Against.Null(context.Message, nameof(UserCreated)); + + _logger.LogInformation($"consumer for {nameof(UserCreated).Underscore()} in {_options.Name}"); + + var passengerExist = + await _passengerDbContext.Passengers.AnyAsync(x => x.PassportNumber.Value == context.Message.PassportNumber); + + if (passengerExist) + { + return; + } + + var passenger = Passengers.Models.Passenger.Create(PassengerId.Of(NewId.NextGuid()), Name.Of(context.Message.Name), + PassportNumber.Of(context.Message.PassportNumber)); + + await _passengerDbContext.AddAsync(passenger); + + await _passengerDbContext.SaveChangesAsync(); + + await _eventDispatcher.SendAsync( + new PassengerCreatedDomainEvent(passenger.Id, passenger.Name, passenger.PassportNumber), + typeof(IInternalCommand)); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passenger.csproj b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passenger.csproj new file mode 100644 index 0000000..17488b9 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passenger.csproj @@ -0,0 +1,23 @@ + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/PassengerRoot.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/PassengerRoot.cs new file mode 100644 index 0000000..faf4fe5 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/PassengerRoot.cs @@ -0,0 +1,6 @@ +namespace Passenger; + +public class PassengerRoot +{ + +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Dtos/PassengerDto.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Dtos/PassengerDto.cs new file mode 100644 index 0000000..17fad5c --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Dtos/PassengerDto.cs @@ -0,0 +1,2 @@ +namespace Passenger.Passengers.Dtos; +public record PassengerDto(Guid Id, string Name, string PassportNumber, Enums.PassengerType PassengerType, int Age); diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Enums/PassengerType.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Enums/PassengerType.cs new file mode 100644 index 0000000..4767301 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Enums/PassengerType.cs @@ -0,0 +1,9 @@ +namespace Passenger.Passengers.Enums; + +public enum PassengerType +{ + Unknown = 0, + Male, + Female, + Baby +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/InvalidAgeException.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/InvalidAgeException.cs new file mode 100644 index 0000000..df9a826 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/InvalidAgeException.cs @@ -0,0 +1,9 @@ +namespace Passenger.Passengers.Exceptions; +using BuildingBlocks.Exception; + +public class InvalidAgeException : BadRequestException +{ + public InvalidAgeException() : base("Age Cannot be null or negative") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/InvalidNameException.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/InvalidNameException.cs new file mode 100644 index 0000000..0a44de6 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/InvalidNameException.cs @@ -0,0 +1,10 @@ +namespace Passenger.Passengers.Exceptions; +using BuildingBlocks.Exception; + + +public class InvalidNameException : BadRequestException +{ + public InvalidNameException() : base("Name cannot be empty or whitespace.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/InvalidPassportNumberException.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/InvalidPassportNumberException.cs new file mode 100644 index 0000000..bb6429b --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/InvalidPassportNumberException.cs @@ -0,0 +1,10 @@ +namespace Passenger.Passengers.Exceptions; +using BuildingBlocks.Exception; + + +public class InvalidPassportNumberException : BadRequestException +{ + public InvalidPassportNumberException() : base("Passport number cannot be empty or whitespace.") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/PassengerAlreadyExist.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/PassengerAlreadyExist.cs new file mode 100644 index 0000000..62a46a2 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/PassengerAlreadyExist.cs @@ -0,0 +1,10 @@ +namespace Passenger.Passengers.Exceptions; + +using BuildingBlocks.Exception; + +public class PassengerNotExist : BadRequestException +{ + public PassengerNotExist(string code = default) : base("Please register before!") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/PassengerNotFoundException.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/PassengerNotFoundException.cs new file mode 100644 index 0000000..771089e --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Exceptions/PassengerNotFoundException.cs @@ -0,0 +1,10 @@ +namespace Passenger.Passengers.Exceptions; + +using BuildingBlocks.Exception; + +public class PassengerNotFoundException : NotFoundException +{ + public PassengerNotFoundException(string code = default) : base("Passenger not found!") + { + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Features/CompletingRegisterPassenger/V1/CompleteRegisterPassenger.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Features/CompletingRegisterPassenger/V1/CompleteRegisterPassenger.cs new file mode 100644 index 0000000..5860071 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Features/CompletingRegisterPassenger/V1/CompleteRegisterPassenger.cs @@ -0,0 +1,117 @@ +namespace Passenger.Passengers.Features.CompletingRegisterPassenger.V1; + +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using BuildingBlocks.Web; +using Data; +using Dtos; +using Duende.IdentityServer.EntityFramework.Entities; +using Exceptions; +using FluentValidation; +using Mapster; +using MapsterMapper; +using MassTransit; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Passenger.Passengers.ValueObjects; + +public record CompleteRegisterPassenger + (string PassportNumber, Enums.PassengerType PassengerType, int Age) : ICommand, + IInternalCommand +{ + public Guid Id { get; init; } = NewId.NextGuid(); +} + +public record PassengerRegistrationCompletedDomainEvent(Guid Id, string Name, string PassportNumber, + Enums.PassengerType PassengerType, int Age, bool IsDeleted = false) : IDomainEvent; + +public record CompleteRegisterPassengerResult(PassengerDto PassengerDto); + +public record CompleteRegisterPassengerRequestDto(string PassportNumber, Enums.PassengerType PassengerType, int Age); + +public record CompleteRegisterPassengerResponseDto(PassengerDto PassengerDto); + +public class CompleteRegisterPassengerEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPost($"{EndpointConfig.BaseApiPath}/passenger/complete-registration", async ( + CompleteRegisterPassengerRequestDto request, IMapper mapper, + IMediator mediator, CancellationToken cancellationToken) => + { + var command = mapper.Map(request); + + var result = await mediator.Send(command, cancellationToken); + + var response = result.Adapt(); + + return Results.Ok(response); + }) + .RequireAuthorization(nameof(ApiScope)) + .WithName("CompleteRegisterPassenger") + .WithApiVersionSet(builder.NewApiVersionSet("Passenger").Build()) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Complete Register Passenger") + .WithDescription("Complete Register Passenger") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } +} + +public class CompleteRegisterPassengerValidator : AbstractValidator +{ + public CompleteRegisterPassengerValidator() + { + RuleFor(x => x.PassportNumber).NotNull().WithMessage("The PassportNumber is required!"); + RuleFor(x => x.Age).GreaterThan(0).WithMessage("The Age must be greater than 0!"); + RuleFor(x => x.PassengerType).Must(p => p.GetType().IsEnum && + p == Enums.PassengerType.Baby || + p == Enums.PassengerType.Female || + p == Enums.PassengerType.Male || + p == Enums.PassengerType.Unknown) + .WithMessage("PassengerType must be Male, Female, Baby or Unknown"); + } +} + +internal class CompleteRegisterPassengerCommandHandler : ICommandHandler +{ + private readonly IMapper _mapper; + private readonly PassengerDbContext _passengerDbContext; + + public CompleteRegisterPassengerCommandHandler(IMapper mapper, PassengerDbContext passengerDbContext) + { + _mapper = mapper; + _passengerDbContext = passengerDbContext; + } + + public async Task Handle(CompleteRegisterPassenger request, + CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var passenger = await _passengerDbContext.Passengers.SingleOrDefaultAsync( + x => x.PassportNumber.Value == request.PassportNumber, cancellationToken); + + if (passenger is null) + { + throw new PassengerNotExist(); + } + + passenger.CompleteRegistrationPassenger(passenger.Id, passenger.Name, + passenger.PassportNumber, request.PassengerType, Age.Of(request.Age)); + + var updatePassenger = _passengerDbContext.Passengers.Update(passenger).Entity; + + var passengerDto = _mapper.Map(updatePassenger); + + return new CompleteRegisterPassengerResult(passengerDto); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Features/CompletingRegisterPassenger/V1/CompleteRegisterPassengerMongo.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Features/CompletingRegisterPassenger/V1/CompleteRegisterPassengerMongo.cs new file mode 100644 index 0000000..dcb20e0 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Features/CompletingRegisterPassenger/V1/CompleteRegisterPassengerMongo.cs @@ -0,0 +1,61 @@ +namespace Passenger.Passengers.Features.CompletingRegisterPassenger.V1; + +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Core.Event; +using Data; +using MapsterMapper; +using MediatR; +using Models; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using ValueObjects; + +public record CompleteRegisterPassengerMongoCommand(Guid Id, string PassportNumber, string Name, + Enums.PassengerType PassengerType, int Age, bool IsDeleted = false) : InternalCommand; + + +internal class CompleteRegisterPassengerMongoHandler : ICommandHandler +{ + private readonly PassengerReadDbContext _passengerReadDbContext; + private readonly IMapper _mapper; + + public CompleteRegisterPassengerMongoHandler( + PassengerReadDbContext passengerReadDbContext, + IMapper mapper) + { + _passengerReadDbContext = passengerReadDbContext; + _mapper = mapper; + } + + public async Task Handle(CompleteRegisterPassengerMongoCommand request, CancellationToken cancellationToken) + { + Guard.Against.Null(request, nameof(request)); + + var passengerReadModel = _mapper.Map(request); + + var passenger = await _passengerReadDbContext.Passenger.AsQueryable() + .FirstOrDefaultAsync(x => x.PassengerId == passengerReadModel.PassengerId && !x.IsDeleted, cancellationToken); + + if (passenger is not null) + { + await _passengerReadDbContext.Passenger.UpdateOneAsync( + x => x.PassengerId == PassengerId.Of(passengerReadModel.PassengerId), + Builders.Update + .Set(x => x.PassengerId, PassengerId.Of(passengerReadModel.PassengerId)) + .Set(x => x.Age, passengerReadModel.Age) + .Set(x => x.Name, passengerReadModel.Name) + .Set(x => x.IsDeleted, passengerReadModel.IsDeleted) + .Set(x => x.PassengerType, passengerReadModel.PassengerType) + .Set(x => x.PassportNumber, passengerReadModel.PassportNumber), + cancellationToken: cancellationToken); + } + else + { + await _passengerReadDbContext.Passenger.InsertOneAsync(passengerReadModel, + cancellationToken: cancellationToken); + } + + return Unit.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Features/GettingPassengerById/V1/GetPassengerById.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Features/GettingPassengerById/V1/GetPassengerById.cs new file mode 100644 index 0000000..2b36d5f --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Features/GettingPassengerById/V1/GetPassengerById.cs @@ -0,0 +1,89 @@ +namespace Passenger.Passengers.Features.GettingPassengerById.V1; + +using Ardalis.GuardClauses; +using BuildingBlocks.Core.CQRS; +using BuildingBlocks.Web; +using Duende.IdentityServer.EntityFramework.Entities; +using FluentValidation; +using Mapster; +using MapsterMapper; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using Passenger.Data; +using Passenger.Passengers.Dtos; +using Passenger.Passengers.Exceptions; + +public record GetPassengerById(Guid Id) : IQuery; + +public record GetPassengerByIdResult(PassengerDto PassengerDto); + +public record GetPassengerByIdResponseDto(PassengerDto PassengerDto); + +public class GetPassengerByIdEndpoint : IMinimalEndpoint +{ + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapGet($"{EndpointConfig.BaseApiPath}/passenger/{{id}}", + async (Guid id, IMediator mediator, CancellationToken cancellationToken) => + { + var result = await mediator.Send(new GetPassengerById(id), cancellationToken); + + var response = result.Adapt(); + + return Results.Ok(response); + }) + .RequireAuthorization(nameof(ApiScope)) + .WithName("GetPassengerById") + .WithApiVersionSet(builder.NewApiVersionSet("Passenger").Build()) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Get Passenger By Id") + .WithDescription("Get Passenger By Id") + .WithOpenApi() + .HasApiVersion(1.0); + + return builder; + } +} + +public class GetPassengerByIdValidator : AbstractValidator +{ + public GetPassengerByIdValidator() + { + RuleFor(x => x.Id).NotNull().WithMessage("Id is required!"); + } +} + +internal class GetPassengerByIdHandler : IQueryHandler +{ + private readonly IMapper _mapper; + private readonly PassengerReadDbContext _passengerReadDbContext; + + public GetPassengerByIdHandler(IMapper mapper, PassengerReadDbContext passengerReadDbContext) + { + _mapper = mapper; + _passengerReadDbContext = passengerReadDbContext; + } + + public async Task Handle(GetPassengerById query, CancellationToken cancellationToken) + { + Guard.Against.Null(query, nameof(query)); + + var passenger = + await _passengerReadDbContext.Passenger.AsQueryable() + .SingleOrDefaultAsync(x => x.PassengerId == query.Id && x.IsDeleted == false, cancellationToken); + + if (passenger is null) + { + throw new PassengerNotFoundException(); + } + + var passengerDto = _mapper.Map(passenger); + + return new GetPassengerByIdResult(passengerDto); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Features/PassengerMappings.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Features/PassengerMappings.cs new file mode 100644 index 0000000..f3ac499 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Features/PassengerMappings.cs @@ -0,0 +1,28 @@ +using Mapster; + +namespace Passenger.Passengers.Features; + +using CompletingRegisterPassenger.V1; +using Dtos; +using MassTransit; +using Models; +using ValueObjects; + +public class PassengerMappings : IRegister +{ + public void Register(TypeAdapterConfig config) + { + config.NewConfig() + .Map(d => d.Id, s => NewId.NextGuid()) + .Map(d => d.PassengerId, s => PassengerId.Of(s.Id)); + + config.NewConfig() + .ConstructUsing(x => new CompleteRegisterPassenger(x.PassportNumber, x.PassengerType, x.Age)); + + config.NewConfig() + .ConstructUsing(x => new PassengerDto(x.PassengerId, x.Name, x.PassportNumber, x.PassengerType, x.Age)); + + config.NewConfig() + .ConstructUsing(x => new PassengerDto(x.Id.Value, x.Name.Value, x.PassportNumber.Value, x.PassengerType, x.Age.Value)); + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Models/Passenger.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Models/Passenger.cs new file mode 100644 index 0000000..1b287b9 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Models/Passenger.cs @@ -0,0 +1,45 @@ +using BuildingBlocks.Core.Model; + +namespace Passenger.Passengers.Models; + +using Features.CompletingRegisterPassenger.V1; +using Identity.Consumers.RegisteringNewUser.V1; +using ValueObjects; + +public record Passenger : Aggregate +{ + public PassportNumber PassportNumber { get; private set; } = default!; + public Name Name { get; private set; } = default!; + public Enums.PassengerType PassengerType { get; private set; } + public Age? Age { get; private set; } + + public void CompleteRegistrationPassenger(PassengerId id, Name name, PassportNumber passportNumber, + Enums.PassengerType passengerType, Age age, bool isDeleted = false) + { + this.Id = id; + this.Name = name; + this.PassportNumber = passportNumber; + this.PassengerType = passengerType; + this.Age = age; + this.IsDeleted = isDeleted; + + var @event = new PassengerRegistrationCompletedDomainEvent(this.Id, this.Name, + this.PassportNumber, + this.PassengerType, this.Age, this.IsDeleted); + + this.AddDomainEvent(@event); + } + + + public static Passenger Create(PassengerId id, Name name, PassportNumber passportNumber, bool isDeleted = false) + { + var passenger = new Passenger { Id = id, Name = name, PassportNumber = passportNumber, IsDeleted = isDeleted }; + + var @event = new PassengerCreatedDomainEvent(passenger.Id, passenger.Name, passenger.PassportNumber, + passenger.IsDeleted); + + passenger.AddDomainEvent(@event); + + return passenger; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Models/PassengerReadModel.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Models/PassengerReadModel.cs new file mode 100644 index 0000000..faa64a3 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/Models/PassengerReadModel.cs @@ -0,0 +1,11 @@ +namespace Passenger.Passengers.Models; +public class PassengerReadModel +{ + public required Guid Id { get; init; } + public required Guid PassengerId { get; init; } + public required string PassportNumber { get; init; } + public required string Name { get; init; } + public required Enums.PassengerType PassengerType { get; init; } + public int Age { get; init; } + public required bool IsDeleted { get; init; } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/ValueObjects/Age.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/ValueObjects/Age.cs new file mode 100644 index 0000000..73524cf --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/ValueObjects/Age.cs @@ -0,0 +1,28 @@ +namespace Passenger.Passengers.ValueObjects; + +using Exceptions; + +public record Age +{ + public int Value { get; } + + private Age(int value) + { + Value = value; + } + + public static Age Of(int value) + { + if (value <= 0) + { + throw new InvalidAgeException(); + } + + return new Age(value); + } + + public static implicit operator int(Age age) + { + return age.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/ValueObjects/Name.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/ValueObjects/Name.cs new file mode 100644 index 0000000..41c134f --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/ValueObjects/Name.cs @@ -0,0 +1,28 @@ +namespace Passenger.Passengers.ValueObjects; + +using Passenger.Passengers.Exceptions; + +public record Name +{ + public string Value { get; } + + private Name(string value) + { + Value = value; + } + + public static Name Of(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidNameException(); + } + + return new Name(value); + } + + public static implicit operator string(Name name) + { + return name.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/ValueObjects/PassengerId.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/ValueObjects/PassengerId.cs new file mode 100644 index 0000000..548e6ad --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/ValueObjects/PassengerId.cs @@ -0,0 +1,29 @@ +namespace Passenger.Passengers.ValueObjects; + +using System; +using Passenger.Exceptions; + +public record PassengerId +{ + public Guid Value { get; } + + private PassengerId(Guid value) + { + Value = value; + } + + public static PassengerId Of(Guid value) + { + if (value == Guid.Empty) + { + throw new InvalidPassengerIdException(value); + } + + return new PassengerId(value); + } + + public static implicit operator Guid(PassengerId passengerId) + { + return passengerId.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/ValueObjects/PassportNumber.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/ValueObjects/PassportNumber.cs new file mode 100644 index 0000000..9e4f922 --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/Passengers/ValueObjects/PassportNumber.cs @@ -0,0 +1,33 @@ +namespace Passenger.Passengers.ValueObjects; + +using Passenger.Passengers.Exceptions; + +public record PassportNumber +{ + public string Value { get; } + + public override string ToString() + { + return Value; + } + + private PassportNumber(string value) + { + Value = value; + } + + public static PassportNumber Of(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidPassportNumberException(); + } + + return new PassportNumber(value); + } + + public static implicit operator string(PassportNumber passportNumber) + { + return passportNumber.Value; + } +} diff --git a/2-modular-monolith-architecture-style/src/Modules/Passenger/src/UserCreatedHandler.cs b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/UserCreatedHandler.cs new file mode 100644 index 0000000..700813c --- /dev/null +++ b/2-modular-monolith-architecture-style/src/Modules/Passenger/src/UserCreatedHandler.cs @@ -0,0 +1,13 @@ +// using BuildingBlocks.Contracts.EventBus.Messages; +// using MassTransit; +// +// namespace Passenger; +// +// public class UserCreatedHandler : IConsumer +// { +// public Task Consume(ConsumeContext context) +// { +// Console.WriteLine(context.Message.PassportNumber); +// return Task.CompletedTask; +// } +// } diff --git a/3-microservices-architecture-style/src/Services/Booking/src/Booking/Extensions/Infrastructure/InfrastructureExtensions.cs b/3-microservices-architecture-style/src/Services/Booking/src/Booking/Extensions/Infrastructure/InfrastructureExtensions.cs index 919b6f5..62155a2 100644 --- a/3-microservices-architecture-style/src/Services/Booking/src/Booking/Extensions/Infrastructure/InfrastructureExtensions.cs +++ b/3-microservices-architecture-style/src/Services/Booking/src/Booking/Extensions/Infrastructure/InfrastructureExtensions.cs @@ -59,7 +59,7 @@ public static class InfrastructureExtensions }); builder.Services.AddPersistMessageProcessor(); - builder.Services.AddMongoDbContext(configuration); + builder.AddMongoDbContext(); builder.Services.AddEndpointsApiExplorer(); builder.AddCustomSerilog(env); @@ -72,7 +72,7 @@ public static class InfrastructureExtensions builder.Services.AddProblemDetails(); builder.Services.AddCustomMapster(typeof(BookingRoot).Assembly); builder.Services.AddCustomHealthCheck(); - builder.Services.AddCustomMassTransit(env, typeof(BookingRoot).Assembly); + builder.Services.AddCustomMassTransit(env, TransportType.RabbitMq, typeof(BookingRoot).Assembly); builder.AddCustomObservability(); builder.Services.AddTransient(); diff --git a/3-microservices-architecture-style/src/Services/Flight/src/Flight/Aircrafts/Features/AircraftMappings.cs b/3-microservices-architecture-style/src/Services/Flight/src/Flight/Aircrafts/Features/AircraftMappings.cs index fe58a8b..81be420 100644 --- a/3-microservices-architecture-style/src/Services/Flight/src/Flight/Aircrafts/Features/AircraftMappings.cs +++ b/3-microservices-architecture-style/src/Services/Flight/src/Flight/Aircrafts/Features/AircraftMappings.cs @@ -17,7 +17,7 @@ public class AircraftMappings : IRegister config.NewConfig() .Map(d => d.Id, s => NewId.NextGuid()) - .Map(d => d.AircraftId, s => AircraftId.Of(s.Id)); + .Map(d => d.AircraftId, s => AircraftId.Of(s.Id.Value)); config.NewConfig() .ConstructUsing(x => new CreatingAircraft.V1.CreateAircraft(x.Name, x.Model, x.ManufacturingYear)); diff --git a/3-microservices-architecture-style/src/Services/Flight/src/Flight/Data/Seed/FlightDataSeeder.cs b/3-microservices-architecture-style/src/Services/Flight/src/Flight/Data/Seed/FlightDataSeeder.cs index 3307ed8..e2d8cd9 100644 --- a/3-microservices-architecture-style/src/Services/Flight/src/Flight/Data/Seed/FlightDataSeeder.cs +++ b/3-microservices-architecture-style/src/Services/Flight/src/Flight/Data/Seed/FlightDataSeeder.cs @@ -18,10 +18,15 @@ public class FlightDataSeeder( { public async Task SeedAllAsync() { - await SeedAirportAsync(); - await SeedAircraftAsync(); - await SeedFlightAsync(); - await SeedSeatAsync(); + var pendingMigrations = await flightDbContext.Database.GetPendingMigrationsAsync(); + + if (!pendingMigrations.Any()) + { + await SeedAirportAsync(); + await SeedAircraftAsync(); + await SeedFlightAsync(); + await SeedSeatAsync(); + } } private async Task SeedAirportAsync() diff --git a/3-microservices-architecture-style/src/Services/Flight/src/Flight/Extensions/Infrastructure/InfrastructureExtensions.cs b/3-microservices-architecture-style/src/Services/Flight/src/Flight/Extensions/Infrastructure/InfrastructureExtensions.cs index d0c5f73..cbba0dd 100644 --- a/3-microservices-architecture-style/src/Services/Flight/src/Flight/Extensions/Infrastructure/InfrastructureExtensions.cs +++ b/3-microservices-architecture-style/src/Services/Flight/src/Flight/Extensions/Infrastructure/InfrastructureExtensions.cs @@ -64,9 +64,9 @@ public static class InfrastructureExtensions })); }); - builder.Services.AddCustomDbContext(); + builder.AddCustomDbContext(); builder.Services.AddScoped(); - builder.Services.AddMongoDbContext(configuration); + builder.AddMongoDbContext(); builder.Services.AddPersistMessageProcessor(); builder.Services.AddEndpointsApiExplorer(); @@ -77,7 +77,7 @@ public static class InfrastructureExtensions builder.Services.AddValidatorsFromAssembly(typeof(FlightRoot).Assembly); builder.Services.AddCustomMapster(typeof(FlightRoot).Assembly); builder.Services.AddHttpContextAccessor(); - builder.Services.AddCustomMassTransit(env, typeof(FlightRoot).Assembly); + builder.Services.AddCustomMassTransit(env, TransportType.RabbitMq, typeof(FlightRoot).Assembly); builder.AddCustomObservability(); builder.Services.AddCustomHealthCheck(); diff --git a/3-microservices-architecture-style/src/Services/Flight/src/Flight/Flights/Features/FlightMappings.cs b/3-microservices-architecture-style/src/Services/Flight/src/Flight/Flights/Features/FlightMappings.cs index 9d4bf6f..505dd7a 100644 --- a/3-microservices-architecture-style/src/Services/Flight/src/Flight/Flights/Features/FlightMappings.cs +++ b/3-microservices-architecture-style/src/Services/Flight/src/Flight/Flights/Features/FlightMappings.cs @@ -24,7 +24,7 @@ public class FlightMappings : IRegister config.NewConfig() .Map(d => d.Id, s => NewId.NextGuid()) - .Map(d => d.FlightId, s => s.Id); + .Map(d => d.FlightId, s => s.Id.Value); config.NewConfig() .Map(d => d.Id, s => s.FlightId); diff --git a/3-microservices-architecture-style/src/Services/Identity/src/Identity/Data/Seed/IdentityDataSeeder.cs b/3-microservices-architecture-style/src/Services/Identity/src/Identity/Data/Seed/IdentityDataSeeder.cs index 32badec..29f2494 100644 --- a/3-microservices-architecture-style/src/Services/Identity/src/Identity/Data/Seed/IdentityDataSeeder.cs +++ b/3-microservices-architecture-style/src/Services/Identity/src/Identity/Data/Seed/IdentityDataSeeder.cs @@ -6,6 +6,7 @@ using BuildingBlocks.EFCore; using Identity.Identity.Constants; using Identity.Identity.Models; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; namespace Identity.Data.Seed; @@ -16,20 +17,28 @@ public class IdentityDataSeeder : IDataSeeder private readonly UserManager _userManager; private readonly RoleManager _roleManager; private readonly IEventDispatcher _eventDispatcher; + private readonly IdentityContext _identityContext; public IdentityDataSeeder(UserManager userManager, - RoleManager roleManager, - IEventDispatcher eventDispatcher) + RoleManager roleManager, + IEventDispatcher eventDispatcher, + IdentityContext identityContext) { _userManager = userManager; _roleManager = roleManager; _eventDispatcher = eventDispatcher; + _identityContext = identityContext; } public async Task SeedAllAsync() { - await SeedRoles(); - await SeedUsers(); + var pendingMigrations = await _identityContext.Database.GetPendingMigrationsAsync(); + + if (!pendingMigrations.Any()) + { + await SeedRoles(); + await SeedUsers(); + } } private async Task SeedRoles() diff --git a/3-microservices-architecture-style/src/Services/Identity/src/Identity/Extensions/Infrastructure/InfrastructureExtensions.cs b/3-microservices-architecture-style/src/Services/Identity/src/Identity/Extensions/Infrastructure/InfrastructureExtensions.cs index e58a084..727bc69 100644 --- a/3-microservices-architecture-style/src/Services/Identity/src/Identity/Extensions/Infrastructure/InfrastructureExtensions.cs +++ b/3-microservices-architecture-style/src/Services/Identity/src/Identity/Extensions/Infrastructure/InfrastructureExtensions.cs @@ -62,7 +62,7 @@ public static class InfrastructureExtensions builder.Services.AddEndpointsApiExplorer(); builder.Services.AddControllers(); builder.Services.AddPersistMessageProcessor(); - builder.Services.AddCustomDbContext(); + builder.AddCustomDbContext(); builder.Services.AddScoped(); builder.AddCustomSerilog(env); builder.Services.AddAspnetOpenApi(); @@ -73,7 +73,7 @@ public static class InfrastructureExtensions builder.Services.AddCustomMapster(typeof(IdentityRoot).Assembly); builder.Services.AddCustomHealthCheck(); - builder.Services.AddCustomMassTransit(env, typeof(IdentityRoot).Assembly); + builder.Services.AddCustomMassTransit(env, TransportType.RabbitMq, typeof(IdentityRoot).Assembly); builder.AddCustomObservability(); builder.AddCustomIdentityServer(); diff --git a/3-microservices-architecture-style/src/Services/Passenger/src/Passenger/Extensions/Infrastructure/InfrastructureExtensions.cs b/3-microservices-architecture-style/src/Services/Passenger/src/Passenger/Extensions/Infrastructure/InfrastructureExtensions.cs index a7cbe48..2056f39 100644 --- a/3-microservices-architecture-style/src/Services/Passenger/src/Passenger/Extensions/Infrastructure/InfrastructureExtensions.cs +++ b/3-microservices-architecture-style/src/Services/Passenger/src/Passenger/Extensions/Infrastructure/InfrastructureExtensions.cs @@ -64,8 +64,8 @@ public static class InfrastructureExtensions }); builder.Services.AddPersistMessageProcessor(); - builder.Services.AddCustomDbContext(); - builder.Services.AddMongoDbContext(configuration); + builder.AddCustomDbContext(); + builder.AddMongoDbContext(); builder.AddCustomSerilog(env); builder.Services.AddJwt(); @@ -78,7 +78,7 @@ public static class InfrastructureExtensions builder.Services.AddCustomMapster(typeof(PassengerRoot).Assembly); builder.Services.AddHttpContextAccessor(); builder.Services.AddCustomHealthCheck(); - builder.Services.AddCustomMassTransit(env, typeof(PassengerRoot).Assembly); + builder.Services.AddCustomMassTransit(env, TransportType.RabbitMq, typeof(PassengerRoot).Assembly); builder.AddCustomObservability(); builder.Services.AddGrpc( diff --git a/building-blocks/EFCore/Extensions.cs b/building-blocks/EFCore/Extensions.cs index 17c98d3..60442ad 100644 --- a/building-blocks/EFCore/Extensions.cs +++ b/building-blocks/EFCore/Extensions.cs @@ -1,38 +1,37 @@ using System.Linq.Expressions; -using Ardalis.GuardClauses; using BuildingBlocks.Core.Model; using BuildingBlocks.Web; using Humanizer; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Query; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace BuildingBlocks.EFCore; public static class Extensions { - public static IServiceCollection AddCustomDbContext(this IServiceCollection services) + public static IServiceCollection AddCustomDbContext(this WebApplicationBuilder builder, string connectionName = "") where TContext : DbContext, IDbContext { AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); - services.AddValidateOptions(); + builder.Services.AddValidateOptions(); - services.AddDbContext( + builder.Services.AddDbContext( (sp, options) => { - var postgresOptions = sp.GetRequiredService(); + string? connectionString = string.IsNullOrEmpty(connectionName) ? + sp.GetRequiredService().ConnectionString : + builder.Configuration?.GetSection("PostgresOptions:ConnectionString")[connectionName]; - Guard.Against.Null(options, nameof(postgresOptions)); + ArgumentException.ThrowIfNullOrEmpty(connectionString); options.UseNpgsql( - postgresOptions?.ConnectionString, + connectionString, dbOptions => { dbOptions.MigrationsAssembly(typeof(TContext).Assembly.GetName().Name); @@ -44,10 +43,10 @@ public static class Extensions w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); }); - services.AddScoped(); - services.AddScoped(sp => sp.GetRequiredService()); + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => sp.GetRequiredService()); - return services; + return builder.Services; } diff --git a/building-blocks/Mapster/Extensions.cs b/building-blocks/Mapster/Extensions.cs index 6d71074..81554f8 100644 --- a/building-blocks/Mapster/Extensions.cs +++ b/building-blocks/Mapster/Extensions.cs @@ -7,10 +7,10 @@ namespace BuildingBlocks.Mapster; public static class Extensions { - public static IServiceCollection AddCustomMapster(this IServiceCollection services, Assembly assembly) + public static IServiceCollection AddCustomMapster(this IServiceCollection services, params Assembly[] assemblies) { var typeAdapterConfig = TypeAdapterConfig.GlobalSettings; - typeAdapterConfig.Scan(assembly); + typeAdapterConfig.Scan(assemblies); var mapperConfig = new Mapper(typeAdapterConfig); services.AddSingleton(mapperConfig); diff --git a/building-blocks/MassTransit/Extensions.cs b/building-blocks/MassTransit/Extensions.cs index ae09548..90b5365 100644 --- a/building-blocks/MassTransit/Extensions.cs +++ b/building-blocks/MassTransit/Extensions.cs @@ -11,48 +11,87 @@ using Exception; public static class Extensions { - public static IServiceCollection AddCustomMassTransit(this IServiceCollection services, - IWebHostEnvironment env, params Assembly[] assembly) + public static IServiceCollection AddCustomMassTransit( + this IServiceCollection services, + IWebHostEnvironment env, + TransportType transportType, + params Assembly[] assembly + ) { services.AddValidateOptions(); if (env.IsEnvironment("test")) { - services.AddMassTransitTestHarness(configure => - { - SetupMasstransitConfigurations(services, configure, assembly); - }); + services.AddMassTransitTestHarness( + configure => + { + SetupMasstransitConfigurations(services, configure, transportType, assembly); + }); } else { - services.AddMassTransit(configure => { SetupMasstransitConfigurations(services, configure, assembly); }); + services.AddMassTransit( + configure => + { + SetupMasstransitConfigurations(services, configure, transportType, assembly); + }); } return services; } - private static void SetupMasstransitConfigurations(IServiceCollection services, - IBusRegistrationConfigurator configure, params Assembly[] assembly) + private static void SetupMasstransitConfigurations( + IServiceCollection services, + IBusRegistrationConfigurator configure, + TransportType transportType, + params Assembly[] assembly + ) { configure.AddConsumers(assembly); configure.AddSagaStateMachines(assembly); configure.AddSagas(assembly); configure.AddActivities(assembly); - configure.UsingRabbitMq((context, configurator) => + switch (transportType) { - var rabbitMqOptions = services.GetOptions(nameof(RabbitMqOptions)); + case TransportType.RabbitMq: + configure.UsingRabbitMq( + (context, configurator) => + { + var rabbitMqOptions = + services.GetOptions(nameof(RabbitMqOptions)); - configurator.Host(rabbitMqOptions?.HostName, rabbitMqOptions?.Port ?? 5672, "/", h => - { - h.Username(rabbitMqOptions?.UserName); - h.Password(rabbitMqOptions?.Password); - }); + configurator.Host( + rabbitMqOptions?.HostName, + rabbitMqOptions?.Port ?? 5672, + "/", + h => + { + h.Username(rabbitMqOptions?.UserName); + h.Password(rabbitMqOptions?.Password); + }); - configurator.ConfigureEndpoints(context); + configurator.ConfigureEndpoints(context); - configurator.UseMessageRetry(AddRetryConfiguration); - }); + configurator.UseMessageRetry(AddRetryConfiguration); + }); + + break; + case TransportType.InMemory: + configure.UsingInMemory( + (context, configurator) => + { + configurator.ConfigureEndpoints(context); + configurator.UseMessageRetry(AddRetryConfiguration); + }); + + break; + default: + throw new ArgumentOutOfRangeException( + nameof(transportType), + transportType, + message: null); + } } private static void AddRetryConfiguration(IRetryConfigurator retryConfigurator) @@ -62,6 +101,7 @@ public static class Extensions TimeSpan.FromMilliseconds(200), TimeSpan.FromMinutes(120), TimeSpan.FromMilliseconds(200)) - .Ignore(); // don't retry if we have invalid data and message goes to _error queue masstransit + .Ignore< + ValidationException>(); // don't retry if we have invalid data and message goes to _error queue masstransit } } diff --git a/building-blocks/MassTransit/TransportType.cs b/building-blocks/MassTransit/TransportType.cs new file mode 100644 index 0000000..d8b6e41 --- /dev/null +++ b/building-blocks/MassTransit/TransportType.cs @@ -0,0 +1,7 @@ +namespace BuildingBlocks.MassTransit; + +public enum TransportType +{ + RabbitMq, + InMemory +} diff --git a/building-blocks/Mongo/Extensions.cs b/building-blocks/Mongo/Extensions.cs index 4841fdb..088040c 100644 --- a/building-blocks/Mongo/Extensions.cs +++ b/building-blocks/Mongo/Extensions.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -8,16 +9,16 @@ namespace BuildingBlocks.Mongo public static class Extensions { public static IServiceCollection AddMongoDbContext( - this IServiceCollection services, IConfiguration configuration, Action? configurator = null) - where TContext : MongoDbContext + this WebApplicationBuilder builder, Action? configurator = null) + where TContext : MongoDbContext { - return services.AddMongoDbContext(configuration, configurator); + return builder.Services.AddMongoDbContext(builder.Configuration, configurator); } public static IServiceCollection AddMongoDbContext( this IServiceCollection services, IConfiguration configuration, Action? configurator = null) - where TContextService : IMongoDbContext - where TContextImplementation : MongoDbContext, TContextService + where TContextService : IMongoDbContext + where TContextImplementation : MongoDbContext, TContextService { services.Configure(configuration.GetSection(nameof(MongoOptions))); diff --git a/building-blocks/PersistMessageProcessor/PersistMessageOptions.cs b/building-blocks/PersistMessageProcessor/PersistMessageOptions.cs index 535f9fc..76ada24 100644 --- a/building-blocks/PersistMessageProcessor/PersistMessageOptions.cs +++ b/building-blocks/PersistMessageProcessor/PersistMessageOptions.cs @@ -4,5 +4,5 @@ public class PersistMessageOptions { public int? Interval { get; set; } = 30; public bool Enabled { get; set; } = true; - public string ConnectionString { get; set; } + public string? ConnectionString { get; set; } } diff --git a/monolith-to-cloud-architecture.sln b/monolith-to-cloud-architecture.sln index 5ac1da0..9020b99 100644 --- a/monolith-to-cloud-architecture.sln +++ b/monolith-to-cloud-architecture.sln @@ -73,6 +73,28 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Integration.Test", "3-micro EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BuildingBlocks", "building-blocks\BuildingBlocks.csproj", "{5ED78466-4114-48ED-9A6E-02143984E7A1}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Api", "Api", "{D1907049-C23E-47CB-9DF1-0D9EDB7CE117}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{5F943131-E273-474E-891E-6386C4B10D00}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Flight", "Flight", "{183FF15D-1B24-4FA4-A5E4-505825919113}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Booking", "Booking", "{3CB44FE8-8DC1-49BD-864A-72FB6A8229C5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Passenger", "Passenger", "{254C235E-7E2D-4FEE-9EB4-50E48BDB1295}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{6C250353-B112-42F5-BBE9-FA2A725870FD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flight", "2-modular-monolith-architecture-style\src\Modules\Flight\src\Flight.csproj", "{99914C87-B2FC-4DB2-9BFC-AA2D63B3024F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "2-modular-monolith-architecture-style\src\Api\src\Api.csproj", "{18FAA2C9-5B3B-41D4-83F4-F91B0F2355A0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Booking", "2-modular-monolith-architecture-style\src\Modules\Booking\src\Booking.csproj", "{301AB091-1BBB-4D95-9A54-AA7A8EE928EF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Identity", "2-modular-monolith-architecture-style\src\Modules\Identity\src\Identity.csproj", "{3020E2CD-C6E5-4489-914E-96083705AF0E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Passenger", "2-modular-monolith-architecture-style\src\Modules\Passenger\src\Passenger.csproj", "{1CD81080-9F44-49AA-94F9-EFEBFD8073E4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -113,6 +135,17 @@ Global {B6D11E8B-CAAE-4452-B3AE-A49DA7E68FE0} = {3FD37B50-3C7D-49E9-9456-A3E82675227D} {6D17EFCC-63DB-4E51-8073-25D5E59B2170} = {D2F19D7F-A9DA-47D2-A445-F9ED8D4479C1} {5ED78466-4114-48ED-9A6E-02143984E7A1} = {B85B39B5-B341-4117-8626-C5DD4F375569} + {D1907049-C23E-47CB-9DF1-0D9EDB7CE117} = {570274DD-E84A-4F22-9079-D60B59EC9ED2} + {5F943131-E273-474E-891E-6386C4B10D00} = {570274DD-E84A-4F22-9079-D60B59EC9ED2} + {183FF15D-1B24-4FA4-A5E4-505825919113} = {5F943131-E273-474E-891E-6386C4B10D00} + {3CB44FE8-8DC1-49BD-864A-72FB6A8229C5} = {5F943131-E273-474E-891E-6386C4B10D00} + {254C235E-7E2D-4FEE-9EB4-50E48BDB1295} = {5F943131-E273-474E-891E-6386C4B10D00} + {6C250353-B112-42F5-BBE9-FA2A725870FD} = {5F943131-E273-474E-891E-6386C4B10D00} + {99914C87-B2FC-4DB2-9BFC-AA2D63B3024F} = {183FF15D-1B24-4FA4-A5E4-505825919113} + {18FAA2C9-5B3B-41D4-83F4-F91B0F2355A0} = {D1907049-C23E-47CB-9DF1-0D9EDB7CE117} + {301AB091-1BBB-4D95-9A54-AA7A8EE928EF} = {3CB44FE8-8DC1-49BD-864A-72FB6A8229C5} + {3020E2CD-C6E5-4489-914E-96083705AF0E} = {6C250353-B112-42F5-BBE9-FA2A725870FD} + {1CD81080-9F44-49AA-94F9-EFEBFD8073E4} = {254C235E-7E2D-4FEE-9EB4-50E48BDB1295} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {6538BDF3-A741-46E9-8988-C859ABB2FBB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -179,5 +212,25 @@ Global {5ED78466-4114-48ED-9A6E-02143984E7A1}.Debug|Any CPU.Build.0 = Debug|Any CPU {5ED78466-4114-48ED-9A6E-02143984E7A1}.Release|Any CPU.ActiveCfg = Release|Any CPU {5ED78466-4114-48ED-9A6E-02143984E7A1}.Release|Any CPU.Build.0 = Release|Any CPU + {99914C87-B2FC-4DB2-9BFC-AA2D63B3024F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {99914C87-B2FC-4DB2-9BFC-AA2D63B3024F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {99914C87-B2FC-4DB2-9BFC-AA2D63B3024F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {99914C87-B2FC-4DB2-9BFC-AA2D63B3024F}.Release|Any CPU.Build.0 = Release|Any CPU + {18FAA2C9-5B3B-41D4-83F4-F91B0F2355A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {18FAA2C9-5B3B-41D4-83F4-F91B0F2355A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {18FAA2C9-5B3B-41D4-83F4-F91B0F2355A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {18FAA2C9-5B3B-41D4-83F4-F91B0F2355A0}.Release|Any CPU.Build.0 = Release|Any CPU + {301AB091-1BBB-4D95-9A54-AA7A8EE928EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {301AB091-1BBB-4D95-9A54-AA7A8EE928EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {301AB091-1BBB-4D95-9A54-AA7A8EE928EF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {301AB091-1BBB-4D95-9A54-AA7A8EE928EF}.Release|Any CPU.Build.0 = Release|Any CPU + {3020E2CD-C6E5-4489-914E-96083705AF0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3020E2CD-C6E5-4489-914E-96083705AF0E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3020E2CD-C6E5-4489-914E-96083705AF0E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3020E2CD-C6E5-4489-914E-96083705AF0E}.Release|Any CPU.Build.0 = Release|Any CPU + {1CD81080-9F44-49AA-94F9-EFEBFD8073E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CD81080-9F44-49AA-94F9-EFEBFD8073E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CD81080-9F44-49AA-94F9-EFEBFD8073E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CD81080-9F44-49AA-94F9-EFEBFD8073E4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal