From 02934b1fd903a0b3d87ca9b1fbe49f21c0b22a0a Mon Sep 17 00:00:00 2001 From: elar1s Date: Thu, 25 Sep 2025 22:21:41 +0300 Subject: [PATCH] feat:Initial commit --- .gitignore | 16 ++ LCT2025.sln | 16 ++ StoreService/Controllers/OrdersController.cs | 76 ++++++++ StoreService/Database/ApplicationContext.cs | 127 +++++++++++++ .../Database/Entities/StoreCategory.cs | 17 ++ .../Database/Entities/StoreDiscount.cs | 36 ++++ .../Database/Entities/StoreDiscountItem.cs | 19 ++ StoreService/Database/Entities/StoreItem.cs | 25 +++ StoreService/Database/Entities/StoreOrder.cs | 20 ++ .../Database/Entities/StoreOrderItem.cs | 21 +++ .../Entities/StoreOrderItemDiscount.cs | 19 ++ StoreService/Extensions/DatabaseExtensions.cs | 21 +++ .../Extensions/DomainServicesExtensions.cs | 16 ++ StoreService/Extensions/LoggingExtensions.cs | 24 +++ .../Extensions/RepositoryExtensions.cs | 18 ++ .../Middleware/ErrorHandlingMiddleware.cs | 79 ++++++++ StoreService/Models/CreateOrderRequest.cs | 19 ++ StoreService/Models/OrderDto.cs | 15 ++ StoreService/Models/OrderItemDto.cs | 13 ++ StoreService/Models/OrderModels.cs | 3 + StoreService/Program.cs | 63 +++++++ StoreService/Properties/launchSettings.json | 23 +++ .../Repositories/GenericRepository.cs | 79 ++++++++ .../Repositories/IGenericRepository.cs | 36 ++++ StoreService/Repositories/IUnitOfWork.cs | 32 ++++ StoreService/Repositories/UnitOfWork.cs | 99 ++++++++++ StoreService/Services/IOrderService.cs | 17 ++ StoreService/Services/OrderService.cs | 172 ++++++++++++++++++ StoreService/StoreService.csproj | 42 +++++ StoreService/StoreService.http | 6 + StoreService/appsettings.Development.json | 8 + StoreService/appsettings.json | 47 +++++ ...CoreApp,Version=v9.0.AssemblyAttributes.cs | 4 + .../Debug/net9.0/StoreService.AssemblyInfo.cs | 22 +++ .../net9.0/StoreService.GlobalUsings.g.cs | 17 ++ 35 files changed, 1267 insertions(+) create mode 100644 .gitignore create mode 100644 LCT2025.sln create mode 100644 StoreService/Controllers/OrdersController.cs create mode 100644 StoreService/Database/ApplicationContext.cs create mode 100644 StoreService/Database/Entities/StoreCategory.cs create mode 100644 StoreService/Database/Entities/StoreDiscount.cs create mode 100644 StoreService/Database/Entities/StoreDiscountItem.cs create mode 100644 StoreService/Database/Entities/StoreItem.cs create mode 100644 StoreService/Database/Entities/StoreOrder.cs create mode 100644 StoreService/Database/Entities/StoreOrderItem.cs create mode 100644 StoreService/Database/Entities/StoreOrderItemDiscount.cs create mode 100644 StoreService/Extensions/DatabaseExtensions.cs create mode 100644 StoreService/Extensions/DomainServicesExtensions.cs create mode 100644 StoreService/Extensions/LoggingExtensions.cs create mode 100644 StoreService/Extensions/RepositoryExtensions.cs create mode 100644 StoreService/Middleware/ErrorHandlingMiddleware.cs create mode 100644 StoreService/Models/CreateOrderRequest.cs create mode 100644 StoreService/Models/OrderDto.cs create mode 100644 StoreService/Models/OrderItemDto.cs create mode 100644 StoreService/Models/OrderModels.cs create mode 100644 StoreService/Program.cs create mode 100644 StoreService/Properties/launchSettings.json create mode 100644 StoreService/Repositories/GenericRepository.cs create mode 100644 StoreService/Repositories/IGenericRepository.cs create mode 100644 StoreService/Repositories/IUnitOfWork.cs create mode 100644 StoreService/Repositories/UnitOfWork.cs create mode 100644 StoreService/Services/IOrderService.cs create mode 100644 StoreService/Services/OrderService.cs create mode 100644 StoreService/StoreService.csproj create mode 100644 StoreService/StoreService.http create mode 100644 StoreService/appsettings.Development.json create mode 100644 StoreService/appsettings.json create mode 100644 StoreService/obj/Debug/net9.0/.NETCoreApp,Version=v9.0.AssemblyAttributes.cs create mode 100644 StoreService/obj/Debug/net9.0/StoreService.AssemblyInfo.cs create mode 100644 StoreService/obj/Debug/net9.0/StoreService.GlobalUsings.g.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8187f71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +/StoreService/obj/project.assets.json +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/projectSettingsUpdater.xml +/modules.xml +/.idea.LCT2025.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +*.xml +*.cache \ No newline at end of file diff --git a/LCT2025.sln b/LCT2025.sln new file mode 100644 index 0000000..2bfd4ed --- /dev/null +++ b/LCT2025.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StoreService", "StoreService\StoreService.csproj", "{5DC91A89-84D9-4C5E-9539-43757A2474D5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5DC91A89-84D9-4C5E-9539-43757A2474D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DC91A89-84D9-4C5E-9539-43757A2474D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DC91A89-84D9-4C5E-9539-43757A2474D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DC91A89-84D9-4C5E-9539-43757A2474D5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/StoreService/Controllers/OrdersController.cs b/StoreService/Controllers/OrdersController.cs new file mode 100644 index 0000000..c9048a9 --- /dev/null +++ b/StoreService/Controllers/OrdersController.cs @@ -0,0 +1,76 @@ +using Microsoft.AspNetCore.Mvc; +using StoreService.Models; +using StoreService.Services; + +namespace StoreService.Controllers; + +/// +/// Endpoints for order lifecycle: create, query, pay and redeem. +/// +[ApiController] +[Route("api/[controller]")] +public class OrdersController : ControllerBase +{ + #region Fields + private readonly IOrderService _orderService; + #endregion + + #region Ctor + public OrdersController(IOrderService orderService) + { + _orderService = orderService; + } + #endregion + + #region Endpoints + /// + /// Creates a new order for specified user and store items. Prices are calculated with active discounts. + /// + [HttpPost] + [ProducesResponseType(typeof(OrderDto), StatusCodes.Status201Created)] + public async Task Create([FromBody] CreateOrderRequest request, CancellationToken ct) + { + var order = await _orderService.CreateOrderAsync(request, ct); + return CreatedAtAction(nameof(Get), new { id = order.Id }, order); + } + + /// + /// Retrieves an order by id. + /// + [HttpGet("{id:long}")] + [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Get([FromRoute] long id, CancellationToken ct) + { + var order = await _orderService.GetOrderAsync(id, ct); + return order == null ? NotFound() : Ok(order); + } + + /// + /// Pays (confirms) an order. Sets the paid date. Idempotent except it fails if already paid. + /// + [HttpPost("{id:long}/pay")] + [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Pay([FromRoute] long id, CancellationToken ct) + { + var order = await _orderService.PayOrderAsync(id, ct); + return Ok(order); + } + + /// + /// Marks items as redeemed (granted to user inventory) after payment. + /// + [HttpPost("{id:long}/redeem")] + [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Redeem([FromRoute] long id, CancellationToken ct) + { + var order = await _orderService.RedeemOrderItemsAsync(id, ct); + return Ok(order); + } + #endregion +} + diff --git a/StoreService/Database/ApplicationContext.cs b/StoreService/Database/ApplicationContext.cs new file mode 100644 index 0000000..1d248c1 --- /dev/null +++ b/StoreService/Database/ApplicationContext.cs @@ -0,0 +1,127 @@ +using Microsoft.EntityFrameworkCore; +using StoreService.Database.Entities; + +namespace StoreService.Database; + +/// +/// Entity Framework Core database context for the Store microservice. +/// Defines DbSets and configures entity relationships & constraints. +/// +public class ApplicationContext : DbContext +{ + #region Ctor + public ApplicationContext(DbContextOptions options) : base(options) + { + } + #endregion + + #region DbSets + public DbSet StoreCategories => Set(); + public DbSet StoreItems => Set(); + public DbSet StoreDiscounts => Set(); + public DbSet StoreDiscountItems => Set(); + public DbSet StoreOrders => Set(); + public DbSet StoreOrderItems => Set(); + public DbSet StoreOrderItemDiscounts => Set(); + #endregion + + #region ModelCreating + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // store_category + modelBuilder.Entity(b => + { + b.ToTable("store_category"); + b.HasKey(x => x.Id); + b.Property(x => x.Title).HasMaxLength(256).IsRequired(); + }); + + // store_item + modelBuilder.Entity(b => + { + b.ToTable("store_item"); + b.HasKey(x => x.Id); + b.Property(x => x.ManaBuyPrice).IsRequired(); + b.Property(x => x.ManaSellPrice).IsRequired(); + b.Property(x => x.InventoryLimit).IsRequired(); + b.Property(x => x.UnlimitedPurchase).HasDefaultValue(false); + b.HasOne(x => x.Category) + .WithMany(c => c.Items) + .HasForeignKey(x => x.StoreCategoryId) + .OnDelete(DeleteBehavior.Restrict); + }); + + // store_discount + modelBuilder.Entity(b => + { + b.ToTable("store_discount"); + b.HasKey(x => x.Id); + b.Property(x => x.Percentage).HasPrecision(5, 2); // up to 100.00 + b.Property(x => x.FromDate).IsRequired(); + b.Property(x => x.UntilDate).IsRequired(); + b.Property(x => x.IsCanceled).HasDefaultValue(false); + }); + + // store_discount_item (many-to-many manual mapping) + modelBuilder.Entity(b => + { + b.ToTable("store_discount_item"); + b.HasKey(x => x.Id); + b.HasOne(x => x.Discount) + .WithMany(d => d.DiscountItems) + .HasForeignKey(x => x.StoreDiscountId) + .OnDelete(DeleteBehavior.Cascade); + b.HasOne(x => x.StoreItem) + .WithMany(i => i.DiscountItems) + .HasForeignKey(x => x.StoreItemId) + .OnDelete(DeleteBehavior.Cascade); + b.HasIndex(x => new { x.StoreDiscountId, x.StoreItemId }).IsUnique(); + }); + + // store_order + modelBuilder.Entity(b => + { + b.ToTable("store_order"); + b.HasKey(x => x.Id); + b.Property(x => x.UserId).IsRequired(); + b.Property(x => x.CostUpdateDate).IsRequired(); + b.Property(x => x.ItemsRedeemed).HasDefaultValue(false); + }); + + // store_order_item + modelBuilder.Entity(b => + { + b.ToTable("store_order_item"); + b.HasKey(x => x.Id); + b.Property(x => x.CalculatedPrice).IsRequired(); + b.HasOne(x => x.Order) + .WithMany(o => o.OrderItems) + .HasForeignKey(x => x.StoreOrderId) + .OnDelete(DeleteBehavior.Cascade); + b.HasOne(x => x.StoreItem) + .WithMany(i => i.OrderItems) + .HasForeignKey(x => x.StoreItemId) + .OnDelete(DeleteBehavior.Restrict); + }); + + // store_order_item_discount + modelBuilder.Entity(b => + { + b.ToTable("store_order_item_discount"); + b.HasKey(x => x.Id); + b.HasOne(x => x.OrderItem) + .WithMany(oi => oi.AppliedDiscounts) + .HasForeignKey(x => x.StoreOrderItemId) + .OnDelete(DeleteBehavior.Cascade); + b.HasOne(x => x.Discount) + .WithMany(d => d.OrderItemDiscounts) + .HasForeignKey(x => x.StoreDiscountId) + .OnDelete(DeleteBehavior.Restrict); + b.HasIndex(x => new { x.StoreOrderItemId, x.StoreDiscountId }).IsUnique(); + }); + } + #endregion +} + diff --git a/StoreService/Database/Entities/StoreCategory.cs b/StoreService/Database/Entities/StoreCategory.cs new file mode 100644 index 0000000..76bafb4 --- /dev/null +++ b/StoreService/Database/Entities/StoreCategory.cs @@ -0,0 +1,17 @@ +namespace StoreService.Database.Entities; + +/// +/// Category grouping store items. +/// +public class StoreCategory +{ + #region Properties + public long Id { get; set; } + public string Title { get; set; } = string.Empty; + #endregion + + #region Navigation + public ICollection Items { get; set; } = new List(); + #endregion +} + diff --git a/StoreService/Database/Entities/StoreDiscount.cs b/StoreService/Database/Entities/StoreDiscount.cs new file mode 100644 index 0000000..79c9c53 --- /dev/null +++ b/StoreService/Database/Entities/StoreDiscount.cs @@ -0,0 +1,36 @@ +using System.Diagnostics.CodeAnalysis; + +namespace StoreService.Database.Entities; + +/// +/// Percentage discount that can apply to one or more store items within a time window. +/// +public class StoreDiscount +{ + #region Properties + public long Id { get; set; } + public decimal Percentage { get; set; } // 0 - 100 (%) + public string? Description { get; set; } + public DateTime FromDate { get; set; } + public DateTime UntilDate { get; set; } + public bool IsCanceled { get; set; } + #endregion + + #region Navigation + public ICollection DiscountItems { get; set; } = new List(); + public ICollection OrderItemDiscounts { get; set; } = new List(); + #endregion + + #region Helpers + /// + /// Checks whether discount is active at provided moment (default: now) ignoring cancellation flag (if canceled returns false). + /// + public bool IsActive(DateTime? at = null) + { + if (IsCanceled) return false; + var moment = at ?? DateTime.UtcNow; + return moment >= FromDate && moment <= UntilDate; + } + #endregion +} + diff --git a/StoreService/Database/Entities/StoreDiscountItem.cs b/StoreService/Database/Entities/StoreDiscountItem.cs new file mode 100644 index 0000000..cec4cd4 --- /dev/null +++ b/StoreService/Database/Entities/StoreDiscountItem.cs @@ -0,0 +1,19 @@ +namespace StoreService.Database.Entities; + +/// +/// Join entity linking discounts to store items. +/// +public class StoreDiscountItem +{ + #region Properties + public long Id { get; set; } + public long StoreDiscountId { get; set; } + public long StoreItemId { get; set; } + #endregion + + #region Navigation + public StoreDiscount? Discount { get; set; } + public StoreItem? StoreItem { get; set; } + #endregion +} + diff --git a/StoreService/Database/Entities/StoreItem.cs b/StoreService/Database/Entities/StoreItem.cs new file mode 100644 index 0000000..d85c152 --- /dev/null +++ b/StoreService/Database/Entities/StoreItem.cs @@ -0,0 +1,25 @@ +namespace StoreService.Database.Entities; + +/// +/// Store item with pricing and purchase rules. +/// +public class StoreItem +{ + #region Properties + public long Id { get; set; } + public long ItemId { get; set; } // FK to external Item service (not modeled here) + public long StoreCategoryId { get; set; } // FK to StoreCategory + public long? RankId { get; set; } // Minimum rank required to buy (external system) + public int ManaBuyPrice { get; set; } + public int ManaSellPrice { get; set; } + public bool UnlimitedPurchase { get; set; } + public int InventoryLimit { get; set; } + #endregion + + #region Navigation + public StoreCategory? Category { get; set; } + public ICollection DiscountItems { get; set; } = new List(); + public ICollection OrderItems { get; set; } = new List(); + #endregion +} + diff --git a/StoreService/Database/Entities/StoreOrder.cs b/StoreService/Database/Entities/StoreOrder.cs new file mode 100644 index 0000000..b3e7234 --- /dev/null +++ b/StoreService/Database/Entities/StoreOrder.cs @@ -0,0 +1,20 @@ +namespace StoreService.Database.Entities; + +/// +/// Represents a purchase order created by a user. +/// +public class StoreOrder +{ + #region Properties + public long Id { get; set; } + public long UserId { get; set; } + public DateTime CostUpdateDate { get; set; } = DateTime.UtcNow; // updated when prices recalculated + public DateTime? PaidDate { get; set; } // when payment succeeded + public bool ItemsRedeemed { get; set; } // becomes true once items granted to inventory + #endregion + + #region Navigation + public ICollection OrderItems { get; set; } = new List(); + #endregion +} + diff --git a/StoreService/Database/Entities/StoreOrderItem.cs b/StoreService/Database/Entities/StoreOrderItem.cs new file mode 100644 index 0000000..2ba005a --- /dev/null +++ b/StoreService/Database/Entities/StoreOrderItem.cs @@ -0,0 +1,21 @@ +namespace StoreService.Database.Entities; + +/// +/// Item line inside an order with captured calculated price for history. +/// +public class StoreOrderItem +{ + #region Properties + public long Id { get; set; } + public long StoreOrderId { get; set; } + public long StoreItemId { get; set; } + public int CalculatedPrice { get; set; } + #endregion + + #region Navigation + public StoreOrder? Order { get; set; } + public StoreItem? StoreItem { get; set; } + public ICollection AppliedDiscounts { get; set; } = new List(); + #endregion +} + diff --git a/StoreService/Database/Entities/StoreOrderItemDiscount.cs b/StoreService/Database/Entities/StoreOrderItemDiscount.cs new file mode 100644 index 0000000..8d7a0b7 --- /dev/null +++ b/StoreService/Database/Entities/StoreOrderItemDiscount.cs @@ -0,0 +1,19 @@ +namespace StoreService.Database.Entities; + +/// +/// Captures which discounts were applied to order items at purchase time. +/// +public class StoreOrderItemDiscount +{ + #region Properties + public long Id { get; set; } + public long StoreOrderItemId { get; set; } + public long? StoreDiscountId { get; set; } // can be null if discount later removed but kept for history + #endregion + + #region Navigation + public StoreOrderItem? OrderItem { get; set; } + public StoreDiscount? Discount { get; set; } + #endregion +} + diff --git a/StoreService/Extensions/DatabaseExtensions.cs b/StoreService/Extensions/DatabaseExtensions.cs new file mode 100644 index 0000000..ff4b652 --- /dev/null +++ b/StoreService/Extensions/DatabaseExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using StoreService.Database; + +namespace StoreService.Extensions; + +/// +/// Database related DI registrations. +/// +public static class DatabaseExtensions +{ + /// + /// Registers the EF Core DbContext (PostgreSQL). + /// + public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(options => + options.UseNpgsql(configuration.GetConnectionString("StoreDb"))); + return services; + } +} + diff --git a/StoreService/Extensions/DomainServicesExtensions.cs b/StoreService/Extensions/DomainServicesExtensions.cs new file mode 100644 index 0000000..0a152f3 --- /dev/null +++ b/StoreService/Extensions/DomainServicesExtensions.cs @@ -0,0 +1,16 @@ +using StoreService.Services; + +namespace StoreService.Extensions; + +/// +/// Domain service layer DI registrations. +/// +public static class DomainServicesExtensions +{ + public static IServiceCollection AddDomainServices(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} + diff --git a/StoreService/Extensions/LoggingExtensions.cs b/StoreService/Extensions/LoggingExtensions.cs new file mode 100644 index 0000000..f10cfdd --- /dev/null +++ b/StoreService/Extensions/LoggingExtensions.cs @@ -0,0 +1,24 @@ +using Serilog; + +namespace StoreService.Extensions; + +/// +/// Logging related extensions (Serilog configuration). +/// +public static class LoggingExtensions +{ + /// + /// Adds Serilog configuration for the host using appsettings.json (Serilog section). + /// + public static IHostBuilder AddSerilogLogging(this IHostBuilder hostBuilder) + { + hostBuilder.UseSerilog((ctx, services, cfg) => + { + cfg.ReadFrom.Configuration(ctx.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext(); + }); + return hostBuilder; + } +} + diff --git a/StoreService/Extensions/RepositoryExtensions.cs b/StoreService/Extensions/RepositoryExtensions.cs new file mode 100644 index 0000000..46d21b7 --- /dev/null +++ b/StoreService/Extensions/RepositoryExtensions.cs @@ -0,0 +1,18 @@ +using StoreService.Repositories; + +namespace StoreService.Extensions; + +/// +/// Repository & UnitOfWork registrations. +/// +public static class RepositoryExtensions +{ + public static IServiceCollection AddRepositories(this IServiceCollection services) + { + // Open generic registration for repositories + services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>)); + // Unit of work (will receive repositories via constructor injection) + services.AddScoped(); + return services; + } +} diff --git a/StoreService/Middleware/ErrorHandlingMiddleware.cs b/StoreService/Middleware/ErrorHandlingMiddleware.cs new file mode 100644 index 0000000..f4714d5 --- /dev/null +++ b/StoreService/Middleware/ErrorHandlingMiddleware.cs @@ -0,0 +1,79 @@ +using System.Net; +using System.Text.Json; +using System.ComponentModel.DataAnnotations; + +namespace StoreService.Middleware; + +/// +/// Global error handling middleware capturing unhandled exceptions and returning structured JSON errors. +/// +public class ErrorHandlingMiddleware +{ + #region Fields + private readonly RequestDelegate _next; + private readonly ILogger _logger; + #endregion + + #region Ctor + public ErrorHandlingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + #endregion + + #region Invoke + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex); + } + } + #endregion + + #region Helpers + private async Task HandleExceptionAsync(HttpContext context, Exception ex) + { + var status = ex switch + { + ValidationException => (int)HttpStatusCode.BadRequest, + ArgumentException => (int)HttpStatusCode.BadRequest, + KeyNotFoundException => (int)HttpStatusCode.NotFound, + InvalidOperationException => StatusCodes.Status409Conflict, + _ => (int)HttpStatusCode.InternalServerError + }; + + var errorId = Guid.NewGuid().ToString(); + if (status >= 500) + { + _logger.LogError(ex, "Unhandled server exception {ErrorId}", errorId); + } + else + { + _logger.LogWarning(ex, "Handled domain exception {ErrorId} -> {Status}", errorId, status); + } + + var env = context.RequestServices.GetRequiredService(); + + var problem = new + { + traceId = context.TraceIdentifier, + errorId, + status, + message = ex.Message, + details = env.IsDevelopment() ? ex.StackTrace : null + }; + + context.Response.ContentType = "application/json"; + context.Response.StatusCode = status; + await context.Response.WriteAsync(JsonSerializer.Serialize(problem)); + } + #endregion +} + + diff --git a/StoreService/Models/CreateOrderRequest.cs b/StoreService/Models/CreateOrderRequest.cs new file mode 100644 index 0000000..aa2bcc4 --- /dev/null +++ b/StoreService/Models/CreateOrderRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace StoreService.Models; + +/// +/// Request body to create a new order consisting of store item identifiers. +/// +public class CreateOrderRequest +{ + /// Identifier of the user creating the order. + [Required] + public long UserId { get; set; } + + /// Collection of store item ids to include (unique). Duplicates are ignored. + [Required] + [MinLength(1)] + public List StoreItemIds { get; set; } = new(); +} + diff --git a/StoreService/Models/OrderDto.cs b/StoreService/Models/OrderDto.cs new file mode 100644 index 0000000..5a1f39b --- /dev/null +++ b/StoreService/Models/OrderDto.cs @@ -0,0 +1,15 @@ +namespace StoreService.Models; + +/// +/// Result DTO representing an order summary. +/// +public class OrderDto +{ + public long Id { get; set; } + public long UserId { get; set; } + public DateTime CostUpdateDate { get; set; } + public DateTime? PaidDate { get; set; } + public bool ItemsRedeemed { get; set; } + public List Items { get; set; } = new(); +} + diff --git a/StoreService/Models/OrderItemDto.cs b/StoreService/Models/OrderItemDto.cs new file mode 100644 index 0000000..b0ebe0f --- /dev/null +++ b/StoreService/Models/OrderItemDto.cs @@ -0,0 +1,13 @@ +namespace StoreService.Models; + +/// +/// Order line DTO. +/// +public class OrderItemDto +{ + public long Id { get; set; } + public long StoreItemId { get; set; } + public int CalculatedPrice { get; set; } + public List AppliedDiscountIds { get; set; } = new(); +} + diff --git a/StoreService/Models/OrderModels.cs b/StoreService/Models/OrderModels.cs new file mode 100644 index 0000000..8fe486f --- /dev/null +++ b/StoreService/Models/OrderModels.cs @@ -0,0 +1,3 @@ +// Legacy aggregated models file kept intentionally empty after splitting into individual files. +// CreateOrderRequest, OrderDto, and OrderItemDto now reside in separate files. + diff --git a/StoreService/Program.cs b/StoreService/Program.cs new file mode 100644 index 0000000..798f8e2 --- /dev/null +++ b/StoreService/Program.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Serilog; +using StoreService.Database; +using StoreService.Middleware; +using StoreService.Repositories; +using StoreService.Services; +using StoreService.Extensions; // added for DI extension +using Newtonsoft.Json; // added + +var builder = WebApplication.CreateBuilder(args); + +#region Serilog +// Use new logging extension +builder.Host.AddSerilogLogging(); +#endregion + +#region Services +builder.Services + .AddControllers() + .AddNewtonsoftJson(options => + { + options.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore; + options.SerializerSettings.Formatting = Formatting.None; + options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; + }); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFilename); + if (File.Exists(xmlPath)) + { + options.IncludeXmlComments(xmlPath, includeControllerXmlComments: true); + } +}); + +// Granular DI registration chain +builder.Services + .AddDatabase(builder.Configuration) + .AddRepositories() + .AddDomainServices(); +#endregion + +var app = builder.Build(); + +#region Middleware +app.UseSerilogRequestLogging(); +app.UseGlobalErrorHandling(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); +#endregion + +app.Run(); \ No newline at end of file diff --git a/StoreService/Properties/launchSettings.json b/StoreService/Properties/launchSettings.json new file mode 100644 index 0000000..78c5fb2 --- /dev/null +++ b/StoreService/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5141", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7238;http://localhost:5141", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/StoreService/Repositories/GenericRepository.cs b/StoreService/Repositories/GenericRepository.cs new file mode 100644 index 0000000..29ed26c --- /dev/null +++ b/StoreService/Repositories/GenericRepository.cs @@ -0,0 +1,79 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using StoreService.Database; + +namespace StoreService.Repositories; + +/// +/// Generic repository implementation wrapping EF Core DbSet. +/// +/// Entity type. +public class GenericRepository : IGenericRepository where TEntity : class +{ + #region Fields + private readonly ApplicationContext _context; + private readonly DbSet _dbSet; + #endregion + + #region Ctor + public GenericRepository(ApplicationContext context) + { + _context = context; + _dbSet = context.Set(); + } + #endregion + + #region Query + public virtual IQueryable Get( + Expression>? filter = null, + Func, IOrderedQueryable>? orderBy = null, + params Expression>[] includes) + { + IQueryable query = _dbSet; + + if (filter != null) + query = query.Where(filter); + + foreach (var include in includes) + query = query.Include(include); + + return orderBy != null ? orderBy(query) : query; + } + + public virtual async Task FirstOrDefaultAsync(Expression> predicate, params Expression>[] includes) + { + IQueryable query = _dbSet; + foreach (var include in includes) + query = query.Include(include); + return await query.FirstOrDefaultAsync(predicate); + } + + public virtual TEntity? GetById(object id) => _dbSet.Find(id); + + public virtual async Task GetByIdAsync(object id) => await _dbSet.FindAsync(id); + #endregion + + #region Mutations + public virtual void Add(TEntity entity) => _dbSet.Add(entity); + public virtual async Task AddAsync(TEntity entity) => await _dbSet.AddAsync(entity); + public virtual void AddRange(IEnumerable entities) => _dbSet.AddRange(entities); + + public virtual void Update(TEntity entity) => _dbSet.Update(entity); + + public virtual void Delete(object id) + { + var entity = _dbSet.Find(id) ?? throw new KeyNotFoundException($"Entity {typeof(TEntity).Name} with id '{id}' not found"); + Delete(entity); + } + + public virtual void Delete(TEntity entity) + { + if (_context.Entry(entity).State == EntityState.Detached) + _dbSet.Attach(entity); + _dbSet.Remove(entity); + } + + public virtual void DeleteRange(IEnumerable entities) => _dbSet.RemoveRange(entities); + #endregion +} + diff --git a/StoreService/Repositories/IGenericRepository.cs b/StoreService/Repositories/IGenericRepository.cs new file mode 100644 index 0000000..d8c1693 --- /dev/null +++ b/StoreService/Repositories/IGenericRepository.cs @@ -0,0 +1,36 @@ +using System.Linq.Expressions; + +namespace StoreService.Repositories; + +/// +/// Generic repository abstraction for simple CRUD & query operations. +/// +/// Entity type. +public interface IGenericRepository where TEntity : class +{ + #region Query + IQueryable Get( + Expression>? filter = null, + Func, IOrderedQueryable>? orderBy = null, + params Expression>[] includes); + + Task FirstOrDefaultAsync(Expression> predicate, + params Expression>[] includes); + + TEntity? GetById(object id); + Task GetByIdAsync(object id); + #endregion + + #region Mutations + void Add(TEntity entity); + Task AddAsync(TEntity entity); + void AddRange(IEnumerable entities); + + void Update(TEntity entity); + + void Delete(object id); + void Delete(TEntity entity); + void DeleteRange(IEnumerable entities); + #endregion +} + diff --git a/StoreService/Repositories/IUnitOfWork.cs b/StoreService/Repositories/IUnitOfWork.cs new file mode 100644 index 0000000..1789baf --- /dev/null +++ b/StoreService/Repositories/IUnitOfWork.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Storage; +using StoreService.Database.Entities; + +namespace StoreService.Repositories; + +/// +/// Unit of work pattern abstraction encapsulating repositories and transactions. +/// +public interface IUnitOfWork : IAsyncDisposable, IDisposable +{ + #region Repositories + IGenericRepository StoreCategories { get; } + IGenericRepository StoreItems { get; } + IGenericRepository StoreDiscounts { get; } + IGenericRepository StoreDiscountItems { get; } + IGenericRepository StoreOrders { get; } + IGenericRepository StoreOrderItems { get; } + IGenericRepository StoreOrderItemDiscounts { get; } + #endregion + + #region Save + bool SaveChanges(); + Task SaveChangesAsync(CancellationToken ct = default); + #endregion + + #region Transactions + Task BeginTransactionAsync(CancellationToken ct = default); + Task CommitTransactionAsync(CancellationToken ct = default); + Task RollbackTransactionAsync(CancellationToken ct = default); + #endregion +} + diff --git a/StoreService/Repositories/UnitOfWork.cs b/StoreService/Repositories/UnitOfWork.cs new file mode 100644 index 0000000..7b3a12b --- /dev/null +++ b/StoreService/Repositories/UnitOfWork.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore.Storage; +using StoreService.Database; +using StoreService.Database.Entities; + +namespace StoreService.Repositories; + +/// +/// Coordinates repository access and database transactions. +/// +public class UnitOfWork : IUnitOfWork +{ + #region Fields + private readonly ApplicationContext _context; + private IDbContextTransaction? _transaction; + #endregion + + #region Ctor + public UnitOfWork( + ApplicationContext context, + IGenericRepository storeCategories, + IGenericRepository storeItems, + IGenericRepository storeDiscounts, + IGenericRepository storeDiscountItems, + IGenericRepository storeOrders, + IGenericRepository storeOrderItems, + IGenericRepository storeOrderItemDiscounts) + { + _context = context; + StoreCategories = storeCategories; + StoreItems = storeItems; + StoreDiscounts = storeDiscounts; + StoreDiscountItems = storeDiscountItems; + StoreOrders = storeOrders; + StoreOrderItems = storeOrderItems; + StoreOrderItemDiscounts = storeOrderItemDiscounts; + } + #endregion + + #region Repositories + public IGenericRepository StoreCategories { get; } + public IGenericRepository StoreItems { get; } + public IGenericRepository StoreDiscounts { get; } + public IGenericRepository StoreDiscountItems { get; } + public IGenericRepository StoreOrders { get; } + public IGenericRepository StoreOrderItems { get; } + public IGenericRepository StoreOrderItemDiscounts { get; } + #endregion + + #region Save + public bool SaveChanges() => _context.SaveChanges() > 0; + + public async Task SaveChangesAsync(CancellationToken ct = default) => (await _context.SaveChangesAsync(ct)) > 0; + #endregion + + #region Transactions + public async Task BeginTransactionAsync(CancellationToken ct = default) + { + if (_transaction != null) throw new InvalidOperationException("Transaction already started"); + _transaction = await _context.Database.BeginTransactionAsync(ct); + } + + public async Task CommitTransactionAsync(CancellationToken ct = default) + { + if (_transaction == null) throw new InvalidOperationException("No transaction started"); + try + { + await _transaction.CommitAsync(ct); + } + catch + { + await RollbackTransactionAsync(ct); + throw; + } + finally + { + await _transaction.DisposeAsync(); + _transaction = null; + } + } + + public async Task RollbackTransactionAsync(CancellationToken ct = default) + { + if (_transaction == null) return; + await _transaction.RollbackAsync(ct); + await _transaction.DisposeAsync(); + _transaction = null; + } + #endregion + + #region Dispose + public void Dispose() => _transaction?.Dispose(); + + public async ValueTask DisposeAsync() + { + if (_transaction != null) + await _transaction.DisposeAsync(); + } + #endregion +} diff --git a/StoreService/Services/IOrderService.cs b/StoreService/Services/IOrderService.cs new file mode 100644 index 0000000..4db00f9 --- /dev/null +++ b/StoreService/Services/IOrderService.cs @@ -0,0 +1,17 @@ +using StoreService.Models; + +namespace StoreService.Services; + +/// +/// Service responsible for order lifecycle (create, pay, redeem) including price calculation. +/// +public interface IOrderService +{ + #region Methods + Task CreateOrderAsync(CreateOrderRequest request, CancellationToken ct = default); + Task GetOrderAsync(long id, CancellationToken ct = default); + Task PayOrderAsync(long id, CancellationToken ct = default); + Task RedeemOrderItemsAsync(long id, CancellationToken ct = default); + #endregion +} + diff --git a/StoreService/Services/OrderService.cs b/StoreService/Services/OrderService.cs new file mode 100644 index 0000000..9bc0849 --- /dev/null +++ b/StoreService/Services/OrderService.cs @@ -0,0 +1,172 @@ +using Microsoft.EntityFrameworkCore; +using StoreService.Database.Entities; +using StoreService.Models; +using StoreService.Repositories; + +namespace StoreService.Services; + +/// +/// Implements order creation, payment, redemption and price calculation logic. +/// +public class OrderService : IOrderService +{ + #region Fields + private readonly IUnitOfWork _uow; + private readonly ILogger _logger; + #endregion + + #region Ctor + public OrderService(IUnitOfWork uow, ILogger logger) + { + _uow = uow; + _logger = logger; + } + #endregion + + #region Public Methods + /// + public async Task CreateOrderAsync(CreateOrderRequest request, CancellationToken ct = default) + { + if (request.StoreItemIds.Count == 0) + throw new ArgumentException("No store items specified", nameof(request.StoreItemIds)); + + // Ensure uniqueness + var uniqueIds = request.StoreItemIds.Distinct().ToList(); + + // Load items with potential discount items and discounts for calculation. + var items = await _uow.StoreItems + .Get(i => uniqueIds.Contains(i.Id), includes: i => i.DiscountItems) + .Include(i => i.DiscountItems) // ensure collection loaded + .ToListAsync(ct); + + if (items.Count != uniqueIds.Count) + { + var missing = uniqueIds.Except(items.Select(i => i.Id)).ToArray(); + throw new KeyNotFoundException($"Store items not found: {string.Join(", ", missing)}"); + } + + // Preload discounts referenced by items for efficient calculation. + var discountIds = items.SelectMany(i => i.DiscountItems.Select(di => di.StoreDiscountId)).Distinct().ToList(); + var discounts = await _uow.StoreDiscounts + .Get(d => discountIds.Contains(d.Id)) + .ToListAsync(ct); + + var now = DateTime.UtcNow; + + var order = new StoreOrder + { + UserId = request.UserId, + CostUpdateDate = now, + OrderItems = new List() + }; + + foreach (var item in items) + { + var applicableDiscounts = discounts + .Where(d => item.DiscountItems.Any(di => di.StoreDiscountId == d.Id) && d.IsActive(now)) + .ToList(); + + var calculatedPrice = CalculatePrice(item.ManaBuyPrice, applicableDiscounts); + + var orderItem = new StoreOrderItem + { + StoreItemId = item.Id, + CalculatedPrice = calculatedPrice, + AppliedDiscounts = applicableDiscounts.Select(d => new StoreOrderItemDiscount + { + StoreDiscountId = d.Id + }).ToList() + }; + + order.OrderItems.Add(orderItem); + } + + await _uow.StoreOrders.AddAsync(order); + await _uow.SaveChangesAsync(ct); + + _logger.LogInformation("Created order {OrderId} for user {UserId}", order.Id, order.UserId); + + return MapOrder(order); + } + + /// + public async Task GetOrderAsync(long id, CancellationToken ct = default) + { + var order = await _uow.StoreOrders + .Get(o => o.Id == id, includes: o => o.OrderItems) + .Include(o => o.OrderItems) + .ThenInclude(oi => oi.AppliedDiscounts) + .FirstOrDefaultAsync(ct); + return order == null ? null : MapOrder(order); + } + + /// + public async Task PayOrderAsync(long id, CancellationToken ct = default) + { + var order = await _uow.StoreOrders + .Get(o => o.Id == id, includes: o => o.OrderItems) + .FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException($"Order {id} not found"); + + if (order.PaidDate != null) + throw new InvalidOperationException("Order already paid"); + + order.PaidDate = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + _logger.Information("Order {OrderId} paid", id); + return await GetOrderRequiredAsync(id, ct); + } + + /// + public async Task RedeemOrderItemsAsync(long id, CancellationToken ct = default) + { + var order = await _uow.StoreOrders.Get(o => o.Id == id).FirstOrDefaultAsync(ct) ?? + throw new KeyNotFoundException($"Order {id} not found"); + + if (order.PaidDate == null) + throw new InvalidOperationException("Order not paid yet"); + if (order.ItemsRedeemed) + throw new InvalidOperationException("Order items already redeemed"); + + // TODO: integrate with external inventory service. For now we just toggle flag. + order.ItemsRedeemed = true; + await _uow.SaveChangesAsync(ct); + _logger.Information("Order {OrderId} items redeemed", id); + return await GetOrderRequiredAsync(id, ct); + } + #endregion + + #region Helpers + private int CalculatePrice(int basePrice, IEnumerable discounts) + { + // Assumption: sum discount percentages, cap at 90% to avoid free unless explicitly 100%. Documented decision. + var totalPercent = discounts.Sum(d => d.Percentage); + if (totalPercent > 100m) totalPercent = 100m; // absolute cap + var discounted = basePrice - (int)Math.Floor(basePrice * (decimal)totalPercent / 100m); + return Math.Max(0, discounted); + } + + private async Task GetOrderRequiredAsync(long id, CancellationToken ct) + { + var dto = await GetOrderAsync(id, ct); + if (dto == null) throw new KeyNotFoundException($"Order {id} not found after update"); + return dto; + } + + private static OrderDto MapOrder(StoreOrder order) => new() + { + Id = order.Id, + UserId = order.UserId, + CostUpdateDate = order.CostUpdateDate, + PaidDate = order.PaidDate, + ItemsRedeemed = order.ItemsRedeemed, + Items = order.OrderItems.Select(oi => new OrderItemDto + { + Id = oi.Id, + StoreItemId = oi.StoreItemId, + CalculatedPrice = oi.CalculatedPrice, + AppliedDiscountIds = oi.AppliedDiscounts.Select(ad => ad.StoreDiscountId ?? 0).ToList() + }).ToList() + }; + #endregion +} + diff --git a/StoreService/StoreService.csproj b/StoreService/StoreService.csproj new file mode 100644 index 0000000..81a80ec --- /dev/null +++ b/StoreService/StoreService.csproj @@ -0,0 +1,42 @@ + + + + net9.0 + enable + enable + true + 1591 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StoreService/StoreService.http b/StoreService/StoreService.http new file mode 100644 index 0000000..df06b0c --- /dev/null +++ b/StoreService/StoreService.http @@ -0,0 +1,6 @@ +@StoreService_HostAddress = http://localhost:5141 + +GET {{StoreService_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/StoreService/appsettings.Development.json b/StoreService/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/StoreService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/StoreService/appsettings.json b/StoreService/appsettings.json new file mode 100644 index 0000000..9c7216f --- /dev/null +++ b/StoreService/appsettings.json @@ -0,0 +1,47 @@ +{ + "ConnectionStrings": { + "StoreDb": "Host=localhost;Port=5432;Database=store_db;Username=store_user;Password=store_password" + }, + "Serilog": { + "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File", "Serilog.Sinks.Debug", "Serilog.Sinks.OpenSearch" ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System": "Warning" + } + }, + "Enrich": [ "FromLogContext", "WithThreadId", "WithEnvironmentName", "WithProcessId" ], + "WriteTo": [ + { "Name": "Console" }, + { "Name": "Debug" }, + { + "Name": "File", + "Args": { + "path": "Logs/log-.txt", + "rollingInterval": "Day", + "retainedFileCountLimit": 14, + "shared": true + } + }, + { + "Name": "OpenSearch", + "Args": { + "nodeUris": "http://localhost:9200", + "indexFormat": "store-service-logs-{0:yyyy.MM.dd}", + "autoRegisterTemplate": true, + "numberOfShards": 1, + "numberOfReplicas": 0 + } + } + ] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/StoreService/obj/Debug/net9.0/.NETCoreApp,Version=v9.0.AssemblyAttributes.cs b/StoreService/obj/Debug/net9.0/.NETCoreApp,Version=v9.0.AssemblyAttributes.cs new file mode 100644 index 0000000..feda5e9 --- /dev/null +++ b/StoreService/obj/Debug/net9.0/.NETCoreApp,Version=v9.0.AssemblyAttributes.cs @@ -0,0 +1,4 @@ +// +using System; +using System.Reflection; +[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0")] diff --git a/StoreService/obj/Debug/net9.0/StoreService.AssemblyInfo.cs b/StoreService/obj/Debug/net9.0/StoreService.AssemblyInfo.cs new file mode 100644 index 0000000..71902a9 --- /dev/null +++ b/StoreService/obj/Debug/net9.0/StoreService.AssemblyInfo.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +using System; +using System.Reflection; + +[assembly: System.Reflection.AssemblyCompanyAttribute("StoreService")] +[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] +[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")] +[assembly: System.Reflection.AssemblyProductAttribute("StoreService")] +[assembly: System.Reflection.AssemblyTitleAttribute("StoreService")] +[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] + +// Generated by the MSBuild WriteCodeFragment class. + diff --git a/StoreService/obj/Debug/net9.0/StoreService.GlobalUsings.g.cs b/StoreService/obj/Debug/net9.0/StoreService.GlobalUsings.g.cs new file mode 100644 index 0000000..025530a --- /dev/null +++ b/StoreService/obj/Debug/net9.0/StoreService.GlobalUsings.g.cs @@ -0,0 +1,17 @@ +// +global using global::Microsoft.AspNetCore.Builder; +global using global::Microsoft.AspNetCore.Hosting; +global using global::Microsoft.AspNetCore.Http; +global using global::Microsoft.AspNetCore.Routing; +global using global::Microsoft.Extensions.Configuration; +global using global::Microsoft.Extensions.DependencyInjection; +global using global::Microsoft.Extensions.Hosting; +global using global::Microsoft.Extensions.Logging; +global using global::System; +global using global::System.Collections.Generic; +global using global::System.IO; +global using global::System.Linq; +global using global::System.Net.Http; +global using global::System.Net.Http.Json; +global using global::System.Threading; +global using global::System.Threading.Tasks;