diff --git a/src/Services/Coupon/Coupon.API/Controllers/CouponController.cs b/src/Services/Coupon/Coupon.API/Controllers/CouponController.cs new file mode 100644 index 000000000..d4db1625f --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Controllers/CouponController.cs @@ -0,0 +1,28 @@ +namespace Coupon.API.Controllers +{ + using System.Net; + using System.Threading.Tasks; + using Coupon.API.Dtos; + using Coupon.API.Infrastructure.Models; + using Coupon.API.Infrastructure.Repositories; + using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + + [Authorize] + [ApiController] + [Route("api/v1/[controller]")] + public class CouponController : ControllerBase + { + private readonly ICouponRepository _couponRepository; + private readonly IMapper _mapper; + + public CouponController(ICouponRepository couponRepository, IMapper mapper) + { + _couponRepository = couponRepository; + _mapper = mapper; + } + + // Add the GetCouponByCodeAsync method + } +} diff --git a/src/Services/Coupon/Coupon.API/Coupon.API.csproj b/src/Services/Coupon/Coupon.API/Coupon.API.csproj new file mode 100644 index 000000000..22ede9547 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Coupon.API.csproj @@ -0,0 +1,40 @@ + + + + netcoreapp3.1 + 1d5bc948-90f1-4906-a1f8-8edaa1ed9e2e + Linux + ..\..\..\.. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Coupon/Coupon.API/CouponSettings.cs b/src/Services/Coupon/Coupon.API/CouponSettings.cs new file mode 100644 index 000000000..f46ac768d --- /dev/null +++ b/src/Services/Coupon/Coupon.API/CouponSettings.cs @@ -0,0 +1,15 @@ +namespace Coupon.API +{ + public class CouponSettings + { + public string ConnectionString { get; set; } + + public string CouponMongoDatabase { get; set; } + + public string EventBusConnection { get; set; } + + public bool UseCustomizationData { get; set; } + + public bool AzureStorageEnabled { get; set; } + } +} diff --git a/src/Services/Coupon/Coupon.API/Dockerfile b/src/Services/Coupon/Coupon.API/Dockerfile new file mode 100644 index 000000000..345ef3143 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Dockerfile @@ -0,0 +1,25 @@ +#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. + +FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build +WORKDIR /src +COPY ["src/Services/Coupon/Coupon.API/Coupon.API.csproj", "src/Services/Coupon/Coupon.API/"] +COPY ["src/BuildingBlocks/EventBus/EventBusRabbitMQ/EventBusRabbitMQ.csproj", "src/BuildingBlocks/EventBus/EventBusRabbitMQ/"] +COPY ["src/BuildingBlocks/EventBus/EventBus/EventBus.csproj", "src/BuildingBlocks/EventBus/EventBus/"] +COPY ["src/BuildingBlocks/EventBus/EventBusServiceBus/EventBusServiceBus.csproj", "src/BuildingBlocks/EventBus/EventBusServiceBus/"] +RUN dotnet restore "src/Services/Coupon/Coupon.API/Coupon.API.csproj" +COPY . . +WORKDIR "/src/src/Services/Coupon/Coupon.API" +RUN dotnet build "Coupon.API.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "Coupon.API.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Coupon.API.dll"] \ No newline at end of file diff --git a/src/Services/Coupon/Coupon.API/Dtos/CouponDto.cs b/src/Services/Coupon/Coupon.API/Dtos/CouponDto.cs new file mode 100644 index 000000000..7893e8eaa --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Dtos/CouponDto.cs @@ -0,0 +1,9 @@ +namespace Coupon.API.Dtos +{ + public class CouponDto + { + public int Discount { get; set; } + + public string Code { get; set; } + } +} diff --git a/src/Services/Coupon/Coupon.API/Dtos/IMapper.cs b/src/Services/Coupon/Coupon.API/Dtos/IMapper.cs new file mode 100644 index 000000000..bad856fd5 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Dtos/IMapper.cs @@ -0,0 +1,7 @@ +namespace Coupon.API.Dtos +{ + public interface IMapper + { + TResult Translate(TEntity entity); + } +} diff --git a/src/Services/Coupon/Coupon.API/Dtos/Mapper.cs b/src/Services/Coupon/Coupon.API/Dtos/Mapper.cs new file mode 100644 index 000000000..4a471c1da --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Dtos/Mapper.cs @@ -0,0 +1,16 @@ +namespace Coupon.API.Dtos +{ + using Coupon.API.Infrastructure.Models; + + public class Mapper : IMapper + { + public CouponDto Translate(Coupon entity) + { + return new CouponDto + { + Code = entity.Code, + Discount = entity.Discount + }; + } + } +} diff --git a/src/Services/Coupon/Coupon.API/Extensions/IHostBuilderExtensions.cs b/src/Services/Coupon/Coupon.API/Extensions/IHostBuilderExtensions.cs new file mode 100644 index 000000000..68a894417 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Extensions/IHostBuilderExtensions.cs @@ -0,0 +1,49 @@ +using System; +using System.Data.SqlClient; +using Coupon.API.IntegrationEvents.EventHandlers; +using Coupon.API.IntegrationEvents.Events; +using Microsoft.eShopOnContainers.BuildingBlocks.EventBus.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Polly; + +namespace Coupon.API.Extensions +{ + public static class IHostBuilderExtensions + { + public static IHost SeedDatabaseStrategy(this IHost host, Action seeder) + { + using (var scope = host.Services.CreateScope()) + { + var context = scope.ServiceProvider.GetService(); + + var policy = Policy.Handle() + .WaitAndRetry(new TimeSpan[] + { + TimeSpan.FromSeconds(3), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(8), + }); + + policy.Execute(() => + { + seeder.Invoke(context); + }); + } + + return host; + } + + public static IHost SubscribersIntegrationEvents(this IHost host) + { + using (var scope = host.Services.CreateScope()) + { + var eventBus = scope.ServiceProvider.GetRequiredService(); + + eventBus.Subscribe(); + } + + return host; + } + } +} diff --git a/src/Services/Coupon/Coupon.API/Extensions/IServiceCollectionExtensions.cs b/src/Services/Coupon/Coupon.API/Extensions/IServiceCollectionExtensions.cs new file mode 100644 index 000000000..7df2a21a0 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Extensions/IServiceCollectionExtensions.cs @@ -0,0 +1,227 @@ +namespace Coupon.API.Extensions +{ + using System; + using System.Collections.Generic; + using Autofac; + using Coupon.API.Dtos; + using Coupon.API.Filters; + using Coupon.API.Infrastructure.Models; + using Coupon.API.Infrastructure.Repositories; + using Microsoft.AspNetCore.Authentication.JwtBearer; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Azure.ServiceBus; + using Microsoft.eShopOnContainers.BuildingBlocks.EventBus; + using Microsoft.eShopOnContainers.BuildingBlocks.EventBus.Abstractions; + using Microsoft.eShopOnContainers.BuildingBlocks.EventBusRabbitMQ; + using Microsoft.eShopOnContainers.BuildingBlocks.EventBusServiceBus; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Diagnostics.HealthChecks; + using Microsoft.Extensions.Logging; + using Microsoft.OpenApi.Models; + using RabbitMQ.Client; + + public static class IServiceCollectionExtensions + { + public static IServiceCollection AddCouponRegister(this IServiceCollection services, IConfiguration configuration) + { + services.AddTransient() + .AddTransient(service => + { + var connection = new ServiceBusConnectionStringBuilder(configuration["EventBusConnection"]); + + return new DefaultServiceBusPersisterConnection(connection, service.GetService>()); + }) + .AddTransient(service => + { + var factory = new ConnectionFactory() + { + HostName = configuration["EventBusConnection"], + DispatchConsumersAsync = true + }; + + var retryCount = 5; + + if (!string.IsNullOrEmpty(configuration["EventBusRetryCount"])) + { + retryCount = int.Parse(configuration["EventBusRetryCount"]); + } + + return new DefaultRabbitMQPersistentConnection(factory, service.GetService>(), retryCount); + }) + .AddTransient() + .AddTransient() + .AddTransient, Mapper>(); + + return services; + } + + public static IServiceCollection AddSwagger(this IServiceCollection services, IConfiguration configuration) + { + services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "eShopOnContainers - Coupon HTTP API", + Version = "v1", + Description = "The Coupon Service HTTP API" + }); + + options.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows() + { + Implicit = new OpenApiOAuthFlow() + { + AuthorizationUrl = new Uri($"{configuration.GetValue("IdentityUrlExternal")}/connect/authorize"), + TokenUrl = new Uri($"{configuration.GetValue("IdentityUrlExternal")}/connect/token"), + Scopes = new Dictionary() + { + { "coupon", "Coupon API" } + } + } + } + }); + + options.OperationFilter(); + }); + + return services; + } + + public static IServiceCollection AddCustomSettings(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration); + services.Configure(options => + { + options.InvalidModelStateResponseFactory = context => + { + var problemDetails = new ValidationProblemDetails(context.ModelState) + { + Instance = context.HttpContext.Request.Path, + Status = StatusCodes.Status400BadRequest, + Detail = "Please refer to the errors property for additional details." + }; + + return new BadRequestObjectResult(problemDetails) + { + ContentTypes = { "application/problem+json", "application/problem+xml" } + }; + }; + }); + + return services; + } + + public static IServiceCollection AddEventBus(this IServiceCollection services, IConfiguration configuration) + { + var subscriptionClientName = configuration["SubscriptionClientName"]; + + if (configuration.GetValue("AzureServiceBusEnabled")) + { + services.AddSingleton(sp => + { + var serviceBusPersisterConnection = sp.GetRequiredService(); + var iLifetimeScope = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + var eventBusSubcriptionsManager = sp.GetRequiredService(); + + return new EventBusServiceBus(serviceBusPersisterConnection, logger, eventBusSubcriptionsManager, subscriptionClientName, iLifetimeScope); + }); + } + else + { + services.AddSingleton(sp => + { + var rabbitMQPersistentConnection = sp.GetRequiredService(); + var iLifetimeScope = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + var eventBusSubcriptionsManager = sp.GetRequiredService(); + + var retryCount = 5; + + if (!string.IsNullOrEmpty(configuration["EventBusRetryCount"])) + { + retryCount = int.Parse(configuration["EventBusRetryCount"]); + } + + return new EventBusRabbitMQ(rabbitMQPersistentConnection, logger, iLifetimeScope, eventBusSubcriptionsManager, subscriptionClientName, retryCount); + }); + } + + return services; + } + + public static IServiceCollection AddCustomHealthCheck(this IServiceCollection services, IConfiguration configuration) + { + var accountName = configuration.GetValue("AzureStorageAccountName"); + var accountKey = configuration.GetValue("AzureStorageAccountKey"); + + var hcBuilder = services.AddHealthChecks(); + + hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy()) + .AddMongoDb( + configuration["ConnectionString"], + name: "CouponCollection-check", + tags: new string[] { "couponcollection" }); + + if (configuration.GetValue("AzureServiceBusEnabled")) + { + hcBuilder.AddAzureServiceBusTopic( + configuration["EventBusConnection"], + topicName: "eshop_event_bus", + name: "coupon-servicebus-check", + tags: new string[] { "servicebus" }); + } + else + { + hcBuilder.AddRabbitMQ( + $"amqp://{configuration["EventBusConnection"]}", + name: "coupon-rabbitmqbus-check", + tags: new string[] { "rabbitmqbus" }); + } + + return services; + } + + public static IServiceCollection AddCustomPolicies(this IServiceCollection services) + { + services.AddCors(options => + { + options.AddPolicy("CorsPolicy", + builder => builder.SetIsOriginAllowed((host) => true) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials()); + }); + + return services; + } + + public static IServiceCollection AddAppInsights(this IServiceCollection services, IConfiguration configuration) + { + services.AddApplicationInsightsTelemetry(configuration); + services.AddApplicationInsightsKubernetesEnricher(); + + return services; + } + + public static IServiceCollection AddCustomAuthentication(this IServiceCollection services, IConfiguration configuration) + { + return services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }).AddJwtBearer(options => + { + options.Authority = configuration["IdentityUrl"]; + options.RequireHttpsMetadata = false; + options.Audience = "coupon"; + }).Services; + } + + public static IServiceCollection AddCustomAuthorization(this IServiceCollection services) => services.AddAuthorization(); + } +} diff --git a/src/Services/Coupon/Coupon.API/Filters/AuthorizeCheckOperationFilter.cs b/src/Services/Coupon/Coupon.API/Filters/AuthorizeCheckOperationFilter.cs new file mode 100644 index 000000000..726b537d4 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Filters/AuthorizeCheckOperationFilter.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Coupon.API.Filters +{ + public class AuthorizeCheckOperationFilter : IOperationFilter + { + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var hasAuthorize = context.MethodInfo.DeclaringType.GetCustomAttributes(true).OfType().Any() || + context.MethodInfo.GetCustomAttributes(true).OfType().Any(); + + if (!hasAuthorize) return; + + operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" }); + operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" }); + + var oAuthScheme = new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "oauth2" } + }; + + operation.Security = new List + { + new OpenApiSecurityRequirement + { + [oAuthScheme] = new [] { "CouponApi" } + } + }; + } + } +} diff --git a/src/Services/Coupon/Coupon.API/Filters/ValidateModelAttribute.cs b/src/Services/Coupon/Coupon.API/Filters/ValidateModelAttribute.cs new file mode 100644 index 000000000..59cc10917 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Filters/ValidateModelAttribute.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Coupon.API.Filters +{ + public class ValidateModelAttribute : ActionFilterAttribute + { + public override void OnActionExecuting(ActionExecutingContext context) + { + if (!context.ModelState.IsValid) + { + context.Result = new BadRequestObjectResult(context.ModelState); + } + } + } +} diff --git a/src/Services/Coupon/Coupon.API/Infrastructure/CouponSeed.cs b/src/Services/Coupon/Coupon.API/Infrastructure/CouponSeed.cs new file mode 100644 index 000000000..39c531381 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Infrastructure/CouponSeed.cs @@ -0,0 +1,52 @@ +namespace Coupon.API.Infrastructure +{ + using System.Collections.Generic; + using System.Threading.Tasks; + using Coupon.API.Infrastructure.Models; + using Coupon.API.Infrastructure.Repositories; + + public class CouponSeed + { + public async Task SeedAsync(CouponContext context) + { + if (context.Coupons.EstimatedDocumentCount() == 0) + { + var coupons = new List + { + new Coupon + { + Code = "DISC-5", + Discount = 5 + }, + new Coupon + { + Code = "DISC-10", + Discount = 10 + }, + new Coupon + { + Code = "DISC-15", + Discount = 15 + }, + new Coupon + { + Code = "DISC-20", + Discount = 20 + }, + new Coupon + { + Code = "DISC-25", + Discount = 25 + }, + new Coupon + { + Code = "DISC-30", + Discount = 30 + } + }; + + await context.Coupons.InsertManyAsync(coupons); + } + } + } +} diff --git a/src/Services/Coupon/Coupon.API/Infrastructure/Models/Coupon.cs b/src/Services/Coupon/Coupon.API/Infrastructure/Models/Coupon.cs new file mode 100644 index 000000000..13d628dbc --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Infrastructure/Models/Coupon.cs @@ -0,0 +1,20 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Coupon.API.Infrastructure.Models +{ + public class Coupon + { + [BsonIgnoreIfDefault] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } + + public int Discount { get; set; } + + public string Code { get; set; } + + public bool Consumed { get; set; } + + public int OrderId { get; set; } + } +} diff --git a/src/Services/Coupon/Coupon.API/Infrastructure/Repositories/CouponContext.cs b/src/Services/Coupon/Coupon.API/Infrastructure/Repositories/CouponContext.cs new file mode 100644 index 000000000..945dfe4f0 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Infrastructure/Repositories/CouponContext.cs @@ -0,0 +1,25 @@ +namespace Coupon.API.Infrastructure.Repositories +{ + using Coupon.API.Infrastructure.Models; + using Microsoft.Extensions.Options; + using MongoDB.Driver; + + public class CouponContext + { + private readonly IMongoDatabase _database = null; + + public CouponContext(IOptions settings) + { + var client = new MongoClient(settings.Value.ConnectionString); + + if (client is null) + { + throw new MongoConfigurationException("Cannot connect to the database. The connection string is not valid or the database is not accessible"); + } + + _database = client.GetDatabase(settings.Value.CouponMongoDatabase); + } + + public IMongoCollection Coupons => _database.GetCollection("CouponCollection"); + } +} diff --git a/src/Services/Coupon/Coupon.API/Infrastructure/Repositories/CouponRepository.cs b/src/Services/Coupon/Coupon.API/Infrastructure/Repositories/CouponRepository.cs new file mode 100644 index 000000000..62bd8a3fa --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Infrastructure/Repositories/CouponRepository.cs @@ -0,0 +1,42 @@ +namespace Coupon.API.Infrastructure.Repositories +{ + using System.Threading.Tasks; + using Coupon.API.Infrastructure.Models; + using MongoDB.Driver; + + public class CouponRepository : ICouponRepository + { + private readonly CouponContext _couponContext; + + public CouponRepository(CouponContext couponContext) + { + _couponContext = couponContext; + } + + public async Task UpdateCouponConsumedByCodeAsync(string code, int orderId) + { + var filter = Builders.Filter.Eq("Code", code); + var update = Builders.Update + .Set(coupon => coupon.Consumed, true) + .Set(coupon => coupon.OrderId, orderId); + + await _couponContext.Coupons.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = false }); + } + + public async Task UpdateCouponReleasedByOrderIdAsync(int orderId) + { + var filter = Builders.Filter.Eq("OrderId", orderId); + var update = Builders.Update + .Set(coupon => coupon.Consumed, false) + .Set(coupon => coupon.OrderId, 0); + + await _couponContext.Coupons.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = false }); + } + + public async Task FindCouponByCodeAsync(string code) + { + var filter = Builders.Filter.Eq("Code", code); + return await _couponContext.Coupons.Find(filter).FirstOrDefaultAsync(); + } + } +} diff --git a/src/Services/Coupon/Coupon.API/Infrastructure/Repositories/ICouponRepository.cs b/src/Services/Coupon/Coupon.API/Infrastructure/Repositories/ICouponRepository.cs new file mode 100644 index 000000000..0dbac0693 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Infrastructure/Repositories/ICouponRepository.cs @@ -0,0 +1,14 @@ +namespace Coupon.API.Infrastructure.Repositories +{ + using System.Threading.Tasks; + using Coupon.API.Infrastructure.Models; + + public interface ICouponRepository + { + Task FindCouponByCodeAsync(string code); + + Task UpdateCouponConsumedByCodeAsync(string code, int orderId); + + Task UpdateCouponReleasedByOrderIdAsync(int orderId); + } +} diff --git a/src/Services/Coupon/Coupon.API/IntegrationEvents/EventHandlers/OrderStatusChangedToAwaitingCouponValidationIntegrationEventHandler.cs b/src/Services/Coupon/Coupon.API/IntegrationEvents/EventHandlers/OrderStatusChangedToAwaitingCouponValidationIntegrationEventHandler.cs new file mode 100644 index 000000000..d7f76acab --- /dev/null +++ b/src/Services/Coupon/Coupon.API/IntegrationEvents/EventHandlers/OrderStatusChangedToAwaitingCouponValidationIntegrationEventHandler.cs @@ -0,0 +1,56 @@ +using System.Threading.Tasks; +using Coupon.API.Infrastructure.Repositories; +using Coupon.API.IntegrationEvents.Events; +using Microsoft.eShopOnContainers.BuildingBlocks.EventBus.Abstractions; +using Microsoft.eShopOnContainers.BuildingBlocks.EventBus.Events; +using Serilog; +using Serilog.Context; + +namespace Coupon.API.IntegrationEvents.EventHandlers +{ + public class OrderStatusChangedToAwaitingCouponValidationIntegrationEventHandler : IIntegrationEventHandler + { + private readonly ICouponRepository _couponRepository; + private readonly IEventBus _eventBus; + + public OrderStatusChangedToAwaitingCouponValidationIntegrationEventHandler(ICouponRepository couponRepository, IEventBus eventBus) + { + _couponRepository = couponRepository; + _eventBus = eventBus; + } + + public async Task Handle(OrderStatusChangedToAwaitingCouponValidationIntegrationEvent @event) + { + await Task.Delay(3000); + + using (LogContext.PushProperty("IntegrationEventContext", $"{@event.Id}-Coupon.API")) + { + Log.Information("----- Handling integration event: {IntegrationEventId} at {AppName} - ({@IntegrationEvent})", @event.Id, "Coupon.API", @event); + + var couponIntegrationEvent = await ProcessIntegrationEventAsync(@event); + + Log.Information("----- Publishing integration event: {IntegrationEventId} from {AppName} - ({@IntegrationEvent})", couponIntegrationEvent.Id, "Coupon.API", couponIntegrationEvent); + + _eventBus.Publish(couponIntegrationEvent); + } + } + + private async Task ProcessIntegrationEventAsync(OrderStatusChangedToAwaitingCouponValidationIntegrationEvent integrationEvent) + { + var coupon = await _couponRepository.FindCouponByCodeAsync(integrationEvent.Code); + + Log.Information("----- Coupon \"{CouponCode}\": {@Coupon}", integrationEvent.Code, coupon); + + if (coupon == null || coupon.Consumed) + { + return new OrderCouponRejectedIntegrationEvent(integrationEvent.OrderId, coupon.Code); + } + + Log.Information("Consumed coupon: {DiscountCode}", integrationEvent.Code); + + await _couponRepository.UpdateCouponConsumedByCodeAsync(integrationEvent.Code, integrationEvent.OrderId); + + return new OrderCouponConfirmedIntegrationEvent(integrationEvent.OrderId, coupon.Discount); + } + } +} diff --git a/src/Services/Coupon/Coupon.API/IntegrationEvents/EventHandlers/OrderStatusChangedToCancelledIntegrationEventHandler.cs b/src/Services/Coupon/Coupon.API/IntegrationEvents/EventHandlers/OrderStatusChangedToCancelledIntegrationEventHandler.cs new file mode 100644 index 000000000..c6b931e40 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/IntegrationEvents/EventHandlers/OrderStatusChangedToCancelledIntegrationEventHandler.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; +using Coupon.API.Infrastructure.Repositories; +using Coupon.API.IntegrationEvents.Events; +using Microsoft.eShopOnContainers.BuildingBlocks.EventBus.Abstractions; + +namespace Coupon.API.IntegrationEvents.EventHandlers +{ + public class OrderStatusChangedToCancelledIntegrationEventHandler : IIntegrationEventHandler + { + private readonly ICouponRepository _couponRepository; + + public OrderStatusChangedToCancelledIntegrationEventHandler(ICouponRepository couponRepository) + { + _couponRepository = couponRepository; + } + + public async Task Handle(OrderStatusChangedToCancelledIntegrationEvent @event) + { + await _couponRepository.UpdateCouponReleasedByOrderIdAsync(@event.OrderId); + } + } +} diff --git a/src/Services/Coupon/Coupon.API/IntegrationEvents/Events/OrderCouponConfirmedIntegrationEvent.cs b/src/Services/Coupon/Coupon.API/IntegrationEvents/Events/OrderCouponConfirmedIntegrationEvent.cs new file mode 100644 index 000000000..64c75e528 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/IntegrationEvents/Events/OrderCouponConfirmedIntegrationEvent.cs @@ -0,0 +1,17 @@ +using Microsoft.eShopOnContainers.BuildingBlocks.EventBus.Events; + +namespace Coupon.API.IntegrationEvents.Events +{ + public class OrderCouponConfirmedIntegrationEvent : IntegrationEvent + { + public int OrderId { get; } + + public int Discount { get; } + + public OrderCouponConfirmedIntegrationEvent(int orderId, int discount) + { + OrderId = orderId; + Discount = discount; + } + } +} diff --git a/src/Services/Coupon/Coupon.API/IntegrationEvents/Events/OrderCouponRejectedIntegrationEvent.cs b/src/Services/Coupon/Coupon.API/IntegrationEvents/Events/OrderCouponRejectedIntegrationEvent.cs new file mode 100644 index 000000000..dc2272f0c --- /dev/null +++ b/src/Services/Coupon/Coupon.API/IntegrationEvents/Events/OrderCouponRejectedIntegrationEvent.cs @@ -0,0 +1,17 @@ +using Microsoft.eShopOnContainers.BuildingBlocks.EventBus.Events; + +namespace Coupon.API.IntegrationEvents.Events +{ + public class OrderCouponRejectedIntegrationEvent : IntegrationEvent + { + public int OrderId { get; } + + public string Code { get; } + + public OrderCouponRejectedIntegrationEvent(int orderId, string code) + { + OrderId = orderId; + Code = code; + } + } +} diff --git a/src/Services/Coupon/Coupon.API/IntegrationEvents/Events/OrderStatusChangedToAwaitingCouponValidationIntegrationEvent.cs b/src/Services/Coupon/Coupon.API/IntegrationEvents/Events/OrderStatusChangedToAwaitingCouponValidationIntegrationEvent.cs new file mode 100644 index 000000000..93749501b --- /dev/null +++ b/src/Services/Coupon/Coupon.API/IntegrationEvents/Events/OrderStatusChangedToAwaitingCouponValidationIntegrationEvent.cs @@ -0,0 +1,20 @@ +using Microsoft.eShopOnContainers.BuildingBlocks.EventBus.Events; +using Newtonsoft.Json; + +namespace Coupon.API.IntegrationEvents.Events +{ + public class OrderStatusChangedToAwaitingCouponValidationIntegrationEvent : IntegrationEvent + { + [JsonProperty] + public int OrderId { get; private set; } + + [JsonProperty] + public string OrderStatus { get; private set; } + + [JsonProperty] + public string BuyerName { get; private set; } + + [JsonProperty] + public string Code { get; private set; } + } +} diff --git a/src/Services/Coupon/Coupon.API/IntegrationEvents/Events/OrderStatusChangedToCancelledIntegrationEvent.cs b/src/Services/Coupon/Coupon.API/IntegrationEvents/Events/OrderStatusChangedToCancelledIntegrationEvent.cs new file mode 100644 index 000000000..1c1d72518 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/IntegrationEvents/Events/OrderStatusChangedToCancelledIntegrationEvent.cs @@ -0,0 +1,24 @@ +using Microsoft.eShopOnContainers.BuildingBlocks.EventBus.Events; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Coupon.API.IntegrationEvents.Events +{ + public class OrderStatusChangedToCancelledIntegrationEvent : IntegrationEvent + { + [JsonProperty] + public int OrderId { get; private set; } + + [JsonProperty] + public string OrderStatus { get; private set; } + + [JsonProperty] + public string BuyerName { get; private set; } + + [JsonProperty] + public string DiscountCode { get; private set; } + } +} diff --git a/src/Services/Coupon/Coupon.API/Program.cs b/src/Services/Coupon/Coupon.API/Program.cs new file mode 100644 index 000000000..e00001af0 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Program.cs @@ -0,0 +1,53 @@ +using System.IO; +using Autofac.Extensions.DependencyInjection; +using Coupon.API.Extensions; +using Coupon.API.Infrastructure; +using Coupon.API.Infrastructure.Repositories; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Serilog; +using Serilog.Events; + +namespace Coupon.API +{ + public class Program + { + public static void Main(string[] args) => + CreateHostBuilder(args) + .Build() + .SeedDatabaseStrategy(context => new CouponSeed().SeedAsync(context).Wait()) + .SubscribersIntegrationEvents() + .Run(); + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .UseServiceProviderFactory(new AutofacServiceProviderFactory()) + .ConfigureAppConfiguration((host, builder) => + { + builder.SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{host.HostingEnvironment.EnvironmentName}.json", optional: false, reloadOnChange: true) + .AddEnvironmentVariables(); + + var config = builder.Build(); + + if (config.GetValue("UseVault", false)) + { + builder.AddAzureKeyVault($"https://{config["Vault:Name"]}.vault.azure.net/", config["Vault:ClientId"], config["Vault:ClientSecret"]); + } + }) + .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()) + .UseSerilog((host, builder) => + { + builder.MinimumLevel.Verbose() + .MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .Enrich.WithProperty("ApplicationContext", host.HostingEnvironment.ApplicationName) + .Enrich.FromLogContext() + .WriteTo.Console() + .WriteTo.Seq(string.IsNullOrWhiteSpace(host.Configuration["Serilog:SeqServerUrl"]) ? "http://seq" : host.Configuration["Serilog:SeqServerUrl"]) + .WriteTo.Http(string.IsNullOrWhiteSpace(host.Configuration["Serilog:LogstashUrl"]) ? "http://logstash:8080" : host.Configuration["Serilog:LogstashUrl"]) + .ReadFrom.Configuration(host.Configuration); + }); + } +} diff --git a/src/Services/Coupon/Coupon.API/Properties/launchSettings.json b/src/Services/Coupon/Coupon.API/Properties/launchSettings.json new file mode 100644 index 000000000..2e0f02cd8 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:50436/", + "sslPort": 44354 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Coupon.API": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + } + } +} \ No newline at end of file diff --git a/src/Services/Coupon/Coupon.API/Startup.cs b/src/Services/Coupon/Coupon.API/Startup.cs new file mode 100644 index 000000000..ae3daabb5 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/Startup.cs @@ -0,0 +1,85 @@ +using Coupon.API.Extensions; +using Coupon.API.Filters; +using Coupon.API.IntegrationEvents.EventHandlers; +using Coupon.API.IntegrationEvents.Events; +using HealthChecks.UI.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.eShopOnContainers.BuildingBlocks.EventBus.Abstractions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace Coupon.API +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers(options => options.Filters.Add()); + + services.AddCustomSettings(Configuration) + .AddCouponRegister(Configuration) + .AddCustomPolicies() + .AddAppInsights(Configuration) + .AddEventBus(Configuration) + .AddCustomAuthentication(Configuration) + .AddCustomAuthorization() + .AddSwagger(Configuration); + + services.AddTransient, OrderStatusChangedToAwaitingCouponValidationIntegrationEventHandler>(); + services.AddTransient, OrderStatusChangedToCancelledIntegrationEventHandler>(); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + var pathBase = Configuration["PATH_BASE"]; + + if (!string.IsNullOrEmpty(pathBase)) + { + app.UsePathBase(pathBase); + } + + app.UseSwagger() + .UseSwaggerUI(options => + { + options.SwaggerEndpoint($"{ (!string.IsNullOrEmpty(pathBase) ? pathBase : string.Empty) }/swagger/v1/swagger.json", "Coupon.API V1"); + options.OAuthClientId("couponswaggerui"); + options.OAuthAppName("eShop-Learn.Coupon.API Swagger UI"); + }) + .UseCors("CorsPolicy") + .UseRouting() + .UseAuthentication() + .UseAuthorization() + .UseEndpoints(endpoints => + { + endpoints.MapControllers(); + // Add the endpoints.MapHealthChecks code + }); + + ConfigureEventBus(app); + } + + private void ConfigureEventBus(IApplicationBuilder app) + { + var eventBus = app.ApplicationServices.GetRequiredService(); + + eventBus.Subscribe>(); + eventBus.Subscribe>(); + } + } +} diff --git a/src/Services/Coupon/Coupon.API/appsettings.Development.json b/src/Services/Coupon/Coupon.API/appsettings.Development.json new file mode 100644 index 000000000..a00a59a10 --- /dev/null +++ b/src/Services/Coupon/Coupon.API/appsettings.Development.json @@ -0,0 +1,18 @@ +{ + "ConnectionString": "mongodb://localhost:27017", + "CouponMongoDatabase": "CouponDb", + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Warning", + "Microsoft.eShopOnContainers": "Debug", + "System": "Warning" + } + } + }, + "IdentityUrlExternal": "http://localhost:5105", + "IdentityUrl": "http://localhost:5105", + "AzureServiceBusEnabled": false, + "EventBusConnection": "localhost" +} diff --git a/src/Services/Coupon/Coupon.API/appsettings.json b/src/Services/Coupon/Coupon.API/appsettings.json new file mode 100644 index 000000000..d93e3cd7f --- /dev/null +++ b/src/Services/Coupon/Coupon.API/appsettings.json @@ -0,0 +1,28 @@ +{ + "ConnectionString": null, + "CouponMongoDatabase": "CouponDb", + "UseCustomizationData": false, + "Serilog": { + "SeqServerUrl": null, + "LogstashUrl": null, + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.eShopOnContainers": "Information", + "System": "Warning" + } + } + }, + "SubscriptionClientName": "Coupon", + "ApplicationInsights": { + "InstrumentationKey": "" + }, + "EventBusRetryCount": 5, + "UseVault": false, + "Vault": { + "Name": "eshop", + "ClientId": "your-client-id", + "ClientSecret": "your-client-secret" + } +} diff --git a/src/eShopOnContainers-ServicesAndWebApps.sln b/src/eShopOnContainers-ServicesAndWebApps.sln index 446614fc0..1f5d5a437 100644 --- a/src/eShopOnContainers-ServicesAndWebApps.sln +++ b/src/eShopOnContainers-ServicesAndWebApps.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29020.237 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.32014.148 MinimumVisualStudioVersion = 10.0.40219.1 Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{FEA0C318-FFED-4D39-8781-265718CA43DD}" EndProject @@ -124,6 +124,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{373D8AA1 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EventBus.Tests", "BuildingBlocks\EventBus\EventBus.Tests\EventBus.Tests.csproj", "{95D735BE-2899-4495-BE3F-2600E93B4E3C}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Coupon", "Coupon", "{6E72EEF6-E86F-4880-B40E-60BA9349C422}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Coupon.API", "Services\Coupon\Coupon.API\Coupon.API.csproj", "{76D98902-FB50-45F3-917F-023B23233174}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Ad-Hoc|Any CPU = Ad-Hoc|Any CPU @@ -1530,6 +1534,54 @@ Global {95D735BE-2899-4495-BE3F-2600E93B4E3C}.Release|x64.Build.0 = Release|Any CPU {95D735BE-2899-4495-BE3F-2600E93B4E3C}.Release|x86.ActiveCfg = Release|Any CPU {95D735BE-2899-4495-BE3F-2600E93B4E3C}.Release|x86.Build.0 = Release|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Ad-Hoc|ARM.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Ad-Hoc|ARM.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Ad-Hoc|x64.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Ad-Hoc|x64.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Ad-Hoc|x86.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Ad-Hoc|x86.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.AppStore|Any CPU.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.AppStore|ARM.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.AppStore|ARM.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.AppStore|iPhone.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.AppStore|iPhone.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.AppStore|x64.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.AppStore|x64.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.AppStore|x86.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.AppStore|x86.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Debug|Any CPU.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Debug|ARM.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Debug|ARM.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Debug|iPhone.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Debug|x64.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Debug|x64.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Debug|x86.ActiveCfg = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Debug|x86.Build.0 = Debug|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Release|Any CPU.ActiveCfg = Release|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Release|Any CPU.Build.0 = Release|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Release|ARM.ActiveCfg = Release|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Release|ARM.Build.0 = Release|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Release|iPhone.ActiveCfg = Release|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Release|iPhone.Build.0 = Release|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Release|x64.ActiveCfg = Release|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Release|x64.Build.0 = Release|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Release|x86.ActiveCfg = Release|Any CPU + {76D98902-FB50-45F3-917F-023B23233174}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1588,6 +1640,8 @@ Global {B62E859F-825E-4C8B-93EC-5966EACFD026} = {798BFC44-2CCD-45FA-B37A-5173B03C2B30} {373D8AA1-36BE-49EC-89F0-6CB736666285} = {807BB76E-B2BB-47A2-A57B-3D1B20FF5E7F} {95D735BE-2899-4495-BE3F-2600E93B4E3C} = {373D8AA1-36BE-49EC-89F0-6CB736666285} + {6E72EEF6-E86F-4880-B40E-60BA9349C422} = {91CF7717-08AB-4E65-B10E-0B426F01E2E8} + {76D98902-FB50-45F3-917F-023B23233174} = {6E72EEF6-E86F-4880-B40E-60BA9349C422} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {25728519-5F0F-4973-8A64-0A81EB4EA8D9}