diff --git a/.gitignore b/.gitignore index ce89292..5afb6cc 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,6 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# User-specific +.DS_Store diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f118..13deb80 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №1 "Кэширование" + Вариант №37 "Учебный курс" + Выполнена Вольговым Даниилом 6513 + Ссылка на форк diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab..20b85a3 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "http://localhost:5117/api/courses/by-id" } diff --git a/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj new file mode 100644 index 0000000..6edd01b --- /dev/null +++ b/CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj @@ -0,0 +1,23 @@ + + + + + + Exe + net8.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/CloudDevelopment.AppHost/Program.cs b/CloudDevelopment.AppHost/Program.cs new file mode 100644 index 0000000..e6b11e5 --- /dev/null +++ b/CloudDevelopment.AppHost/Program.cs @@ -0,0 +1,16 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("redis") + .WithRedisInsight(containerName: "redis-insight"); + +var courseGeneratorApi = builder.AddProject("course-generator-api") + .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") + .WithReference(redis) + .WaitFor(redis) + .WithHttpEndpoint(name: "api", port: 5117); + +builder.AddProject("client-wasm") + .WaitFor(courseGeneratorApi) + .WithExternalHttpEndpoints(); + +builder.Build().Run(); diff --git a/CloudDevelopment.AppHost/Properties/launchSettings.json b/CloudDevelopment.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..38a3bbb --- /dev/null +++ b/CloudDevelopment.AppHost/Properties/launchSettings.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15044", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19078", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20218" + } + } + } +} diff --git a/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj b/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj new file mode 100644 index 0000000..87b70e2 --- /dev/null +++ b/CloudDevelopment.ServiceDefaults/CloudDevelopment.ServiceDefaults.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + diff --git a/CloudDevelopment.ServiceDefaults/Extensions.cs b/CloudDevelopment.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..86ae232 --- /dev/null +++ b/CloudDevelopment.ServiceDefaults/Extensions.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks(HealthEndpointPath); + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = healthCheck => healthCheck.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241..3c2c273 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -5,6 +5,12 @@ VisualStudioVersion = 17.14.36811.4 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CourseGenerator.Api", "CourseGenerator.Api\CourseGenerator.Api.csproj", "{7A4FEA0A-49BC-4E8C-BF70-686F8353F47B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudDevelopment.AppHost", "CloudDevelopment.AppHost\CloudDevelopment.AppHost.csproj", "{9EF677E1-2CD8-4CE1-8D8D-5BF85E445475}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudDevelopment.ServiceDefaults", "CloudDevelopment.ServiceDefaults\CloudDevelopment.ServiceDefaults.csproj", "{CCF09110-50FF-43D7-9E4A-3CE2089D2354}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +21,18 @@ Global {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU + {7A4FEA0A-49BC-4E8C-BF70-686F8353F47B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A4FEA0A-49BC-4E8C-BF70-686F8353F47B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A4FEA0A-49BC-4E8C-BF70-686F8353F47B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A4FEA0A-49BC-4E8C-BF70-686F8353F47B}.Release|Any CPU.Build.0 = Release|Any CPU + {9EF677E1-2CD8-4CE1-8D8D-5BF85E445475}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9EF677E1-2CD8-4CE1-8D8D-5BF85E445475}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9EF677E1-2CD8-4CE1-8D8D-5BF85E445475}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9EF677E1-2CD8-4CE1-8D8D-5BF85E445475}.Release|Any CPU.Build.0 = Release|Any CPU + {CCF09110-50FF-43D7-9E4A-3CE2089D2354}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CCF09110-50FF-43D7-9E4A-3CE2089D2354}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CCF09110-50FF-43D7-9E4A-3CE2089D2354}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CCF09110-50FF-43D7-9E4A-3CE2089D2354}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CourseGenerator.Api/Controllers/CourseContractsController.cs b/CourseGenerator.Api/Controllers/CourseContractsController.cs new file mode 100644 index 0000000..d34f04b --- /dev/null +++ b/CourseGenerator.Api/Controllers/CourseContractsController.cs @@ -0,0 +1,88 @@ +using System.ComponentModel.DataAnnotations; +using CourseGenerator.Api.Dto; +using CourseGenerator.Api.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace CourseGenerator.Api.Controllers; + +[ApiController] +[Route("api/courses")] +public sealed class CourseContractsController( + ICourseContractsService contractsService, + ICourseContractGenerator contractGenerator) : ControllerBase +{ + /// + /// Генерирует список контрактов курсов с кэшированием результата в Redis. + /// + /// Количество контрактов для генерации (от 1 до 100). + /// Токен отмены запроса. + /// Список сгенерированных контрактов курсов. + /// Контракты успешно получены. + /// Передан недопустимый параметр count. + [HttpGet("generate")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GenerateAsync( + [FromQuery, Range(1, 100)] int count, + CancellationToken cancellationToken) + { + try + { + var contracts = await contractsService.GenerateAsync(count, cancellationToken); + var dto = contracts + .Select(contract => new CourseContractDto( + contract.Id, + contract.CourseName, + contract.TeacherFullName, + contract.StartDate, + contract.EndDate, + contract.MaxStudents, + contract.CurrentStudents, + contract.HasCertificate, + contract.Price, + contract.Rating)) + .ToList(); + + return Ok(dto); + } + catch (ArgumentOutOfRangeException ex) + { + var problem = new ValidationProblemDetails(new Dictionary + { + ["count"] = [ex.Message] + }); + return BadRequest(problem); + } + } + + /// + /// Возвращает один сгенерированный контракт по идентификатору для совместимости с клиентом. + /// + /// Неотрицательный идентификатор объекта. + /// Токен отмены запроса. + /// Сгенерированный контракт. + /// Контракт успешно получен. + /// Передан недопустимый параметр id. + [HttpGet("by-id")] + [ProducesResponseType(typeof(CourseContractDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult GetByIdAsync( + [FromQuery, Range(0, int.MaxValue)] int id, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var contract = contractGenerator.GenerateById(id); + + return Ok(new CourseContractDto( + contract.Id, + contract.CourseName, + contract.TeacherFullName, + contract.StartDate, + contract.EndDate, + contract.MaxStudents, + contract.CurrentStudents, + contract.HasCertificate, + contract.Price, + contract.Rating)); + } +} diff --git a/CourseGenerator.Api/CourseGenerator.Api.csproj b/CourseGenerator.Api/CourseGenerator.Api.csproj new file mode 100644 index 0000000..d5e8615 --- /dev/null +++ b/CourseGenerator.Api/CourseGenerator.Api.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + \ No newline at end of file diff --git a/CourseGenerator.Api/Dto/CourseContractDto.cs b/CourseGenerator.Api/Dto/CourseContractDto.cs new file mode 100644 index 0000000..40ee119 --- /dev/null +++ b/CourseGenerator.Api/Dto/CourseContractDto.cs @@ -0,0 +1,16 @@ +namespace CourseGenerator.Api.Dto; + +/// +/// Контракт на проведение учебного курса. +/// +public sealed record CourseContractDto( + int Id, + string CourseName, + string TeacherFullName, + DateOnly StartDate, + DateOnly EndDate, + int MaxStudents, + int CurrentStudents, + bool HasCertificate, + decimal Price, + int Rating); diff --git a/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs b/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs new file mode 100644 index 0000000..cc53907 --- /dev/null +++ b/CourseGenerator.Api/Interfaces/ICourseContractCacheService.cs @@ -0,0 +1,25 @@ +using CourseGenerator.Api.Models; + +namespace CourseGenerator.Api.Interfaces; + +/// +/// Контракт сервиса кэширования сгенерированных учебных контрактов. +/// +public interface ICourseContractCacheService +{ + /// + /// Возвращает список контрактов из кэша по размеру выборки. + /// + /// Количество контрактов в запрошенной выборке. + /// Токен отмены операции. + /// Список контрактов из кэша или null, если запись не найдена. + Task?> GetAsync(int count, CancellationToken cancellationToken = default); + + /// + /// Сохраняет список контрактов в кэш. + /// + /// Количество контрактов в выборке. + /// Контракты для сохранения. + /// Токен отмены операции. + Task SetAsync(int count, IReadOnlyList contracts, CancellationToken cancellationToken = default); +} diff --git a/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs b/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs new file mode 100644 index 0000000..050869a --- /dev/null +++ b/CourseGenerator.Api/Interfaces/ICourseContractGenerator.cs @@ -0,0 +1,23 @@ +using CourseGenerator.Api.Models; + +namespace CourseGenerator.Api.Interfaces; + +/// +/// Контракт генератора учебных контрактов. +/// +public interface ICourseContractGenerator +{ + /// + /// Генерирует указанное количество учебных контрактов. + /// + /// Количество элементов для генерации. + /// Список сгенерированных контрактов. + IReadOnlyList Generate(int count); + + /// + /// Генерирует один контракт детерминированно по идентификатору. + /// + /// Идентификатор, используемый как seed генерации. + /// Сгенерированный контракт. + CourseContract GenerateById(int id); +} diff --git a/CourseGenerator.Api/Interfaces/ICourseContractsService.cs b/CourseGenerator.Api/Interfaces/ICourseContractsService.cs new file mode 100644 index 0000000..716b0f1 --- /dev/null +++ b/CourseGenerator.Api/Interfaces/ICourseContractsService.cs @@ -0,0 +1,17 @@ +using CourseGenerator.Api.Models; + +namespace CourseGenerator.Api.Interfaces; + +/// +/// Контракт прикладного сервиса генерации контрактов с учетом кэша. +/// +public interface ICourseContractsService +{ + /// + /// Возвращает список контрактов из кэша или генерирует новые. + /// + /// Количество требуемых контрактов. + /// Токен отмены операции. + /// Список контрактов. + Task> GenerateAsync(int count, CancellationToken cancellationToken = default); +} diff --git a/CourseGenerator.Api/Models/CourseContract.cs b/CourseGenerator.Api/Models/CourseContract.cs new file mode 100644 index 0000000..b63500c --- /dev/null +++ b/CourseGenerator.Api/Models/CourseContract.cs @@ -0,0 +1,57 @@ +namespace CourseGenerator.Api.Models; + +/// +/// Контракт на проведение учебного курса. +/// +public sealed record CourseContract +{ + /// + /// Идентификатор контракта. + /// + public int Id { get; init; } + + /// + /// Название курса. + /// + public string CourseName { get; init; } = string.Empty; + + /// + /// ФИО преподавателя. + /// + public string TeacherFullName { get; init; } = string.Empty; + + /// + /// Дата начала курса. + /// + public DateOnly StartDate { get; init; } + + /// + /// Дата окончания курса. + /// + public DateOnly EndDate { get; init; } + + /// + /// Максимальное число студентов. + /// + public int MaxStudents { get; init; } + + /// + /// Текущее число студентов. + /// + public int CurrentStudents { get; init; } + + /// + /// Признак выдачи сертификата по итогам курса. + /// + public bool HasCertificate { get; init; } + + /// + /// Стоимость курса. + /// + public decimal Price { get; init; } + + /// + /// Рейтинг курса. + /// + public int Rating { get; init; } +} \ No newline at end of file diff --git a/CourseGenerator.Api/Program.cs b/CourseGenerator.Api/Program.cs new file mode 100644 index 0000000..960c3aa --- /dev/null +++ b/CourseGenerator.Api/Program.cs @@ -0,0 +1,50 @@ +using System.Reflection; +using CourseGenerator.Api.Interfaces; +using CourseGenerator.Api.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + if (File.Exists(xmlPath)) + { + options.IncludeXmlComments(xmlPath, includeControllerXmlComments: true); + } +}); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.AddRedisDistributedCache(connectionName: "redis"); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseCors(); + +app.MapDefaultEndpoints(); + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/CourseGenerator.Api/Services/CourseContractCacheService.cs b/CourseGenerator.Api/Services/CourseContractCacheService.cs new file mode 100644 index 0000000..7ed129b --- /dev/null +++ b/CourseGenerator.Api/Services/CourseContractCacheService.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using CourseGenerator.Api.Interfaces; +using CourseGenerator.Api.Models; +using Microsoft.Extensions.Caching.Distributed; + +namespace CourseGenerator.Api.Services; + +/// +/// Сервис работы с Redis-кэшем для списков учебных контрактов. +/// +public sealed class CourseContractCacheService(IDistributedCache cache, ILogger logger) : ICourseContractCacheService +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + /// + public async Task?> GetAsync(int count, CancellationToken cancellationToken = default) + { + var key = BuildKey(count); + var cachedPayload = await cache.GetStringAsync(key, cancellationToken); + + if (string.IsNullOrWhiteSpace(cachedPayload)) + { + logger.LogInformation("Cache miss for key {CacheKey}", key); + return null; + } + + logger.LogInformation("Cache hit for key {CacheKey}", key); + + return JsonSerializer.Deserialize>(cachedPayload, SerializerOptions); + } + + /// + public async Task SetAsync(int count, IReadOnlyList contracts, CancellationToken cancellationToken = default) + { + var key = BuildKey(count); + var payload = JsonSerializer.Serialize(contracts, SerializerOptions); + + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) + }; + + await cache.SetStringAsync(key, payload, options, cancellationToken); + logger.LogInformation("Cache updated for key {CacheKey}", key); + } + + private static string BuildKey(int count) => $"courses:count:{count}"; +} diff --git a/CourseGenerator.Api/Services/CourseContractGenerator.cs b/CourseGenerator.Api/Services/CourseContractGenerator.cs new file mode 100644 index 0000000..3fc3b3b --- /dev/null +++ b/CourseGenerator.Api/Services/CourseContractGenerator.cs @@ -0,0 +1,121 @@ +using Bogus; +using Bogus.DataSets; +using CourseGenerator.Api.Interfaces; +using CourseGenerator.Api.Models; + +namespace CourseGenerator.Api.Services; + +/// +/// Генератор тестовых учебных контрактов на основе Bogus. +/// +public sealed class CourseContractGenerator(ILogger logger) : ICourseContractGenerator +{ + private static readonly object FakerLock = new(); + + private static readonly string[] CourseDictionary = + [ + "Основы программирования на C#", + "Проектирование микросервисов", + "Базы данных и SQL", + "Инженерия требований", + "Тестирование программного обеспечения", + "Алгоритмы и структуры данных", + "Распределенные системы", + "Web-разработка на ASP.NET Core", + "DevOps и CI/CD", + "Машинное обучение в разработке ПО" + ]; + + private static readonly string[] MalePatronymicDictionary = + [ + "Иванович", + "Петрович", + "Сергеевич", + "Алексеевич", + "Дмитриевич", + "Андреевич", + "Игоревич", + "Олегович", + "Владимирович", + "Николаевич" + ]; + + private static readonly string[] FemalePatronymicDictionary = + [ + "Ивановна", + "Петровна", + "Сергеевна", + "Алексеевна", + "Дмитриевна", + "Андреевна", + "Игоревна", + "Олеговна", + "Владимировна", + "Николаевна" + ]; + + private static readonly Faker ContractFaker = new Faker("ru") + .RuleFor(contract => contract.Id, _ => 0) + .RuleFor(contract => contract.CourseName, f => f.PickRandom(CourseDictionary)) + .RuleFor(contract => contract.TeacherFullName, f => + { + var gender = f.PickRandom(Name.Gender.Male, Name.Gender.Female); + var firstName = f.Name.FirstName(gender); + var lastName = f.Name.LastName(gender); + var patronymic = gender == Name.Gender.Male + ? f.PickRandom(MalePatronymicDictionary) + : f.PickRandom(FemalePatronymicDictionary); + + return $"{lastName} {firstName} {patronymic}"; + }) + .RuleFor(contract => contract.StartDate, f => DateOnly.FromDateTime(f.Date.Soon(60))) + .RuleFor(contract => contract.EndDate, (f, contract) => contract.StartDate.AddDays(f.Random.Int(1, 180))) + .RuleFor(contract => contract.MaxStudents, f => f.Random.Int(10, 200)) + .RuleFor(contract => contract.CurrentStudents, (f, contract) => f.Random.Int(0, contract.MaxStudents)) + .RuleFor(contract => contract.HasCertificate, f => f.Random.Bool()) + .RuleFor(contract => contract.Price, f => decimal.Round(f.Random.Decimal(1000m, 120000m), 2, MidpointRounding.AwayFromZero)) + .RuleFor(contract => contract.Rating, f => f.Random.Int(1, 5)); + + /// + public IReadOnlyList Generate(int count) + { + logger.LogInformation("Course generation started: {Count}", count); + + if (count <= 0) + { + throw new ArgumentOutOfRangeException(nameof(count), "Count must be greater than zero."); + } + + List generatedContracts; + lock (FakerLock) + { + generatedContracts = ContractFaker.Generate(count); + } + + var courses = generatedContracts + .Select((contract, index) => contract with { Id = index + 1 }) + .ToList(); + logger.LogInformation("Course generation completed: {Count}", courses.Count); + + return courses; + } + + /// + public CourseContract GenerateById(int id) + { + if (id < 0) + { + throw new ArgumentOutOfRangeException(nameof(id), "Id must be non-negative."); + } + + CourseContract contract; + lock (FakerLock) + { + contract = ContractFaker + .UseSeed(id + 1) + .Generate() with { Id = id }; + } + + return contract; + } +} \ No newline at end of file diff --git a/CourseGenerator.Api/Services/CourseContractsService.cs b/CourseGenerator.Api/Services/CourseContractsService.cs new file mode 100644 index 0000000..4c84afc --- /dev/null +++ b/CourseGenerator.Api/Services/CourseContractsService.cs @@ -0,0 +1,44 @@ +using CourseGenerator.Api.Interfaces; +using CourseGenerator.Api.Models; + +namespace CourseGenerator.Api.Services; + +/// +/// Прикладной сервис генерации контрактов с использованием кэша. +/// +public sealed class CourseContractsService( + ICourseContractGenerator generator, + ICourseContractCacheService cache, + ILogger logger) : ICourseContractsService +{ + /// + public async Task> GenerateAsync(int count, CancellationToken cancellationToken = default) + { + if (count is < 1 or > 100) + { + throw new ArgumentOutOfRangeException(nameof(count), "Count must be between 1 and 100."); + } + + var startedAt = DateTimeOffset.UtcNow; + var cachedContracts = await cache.GetAsync(count, cancellationToken); + + if (cachedContracts is not null) + { + logger.LogInformation( + "Request processed from cache: {Count}, DurationMs={DurationMs}", + count, + (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds); + return cachedContracts; + } + + var contracts = generator.Generate(count); + await cache.SetAsync(count, contracts, cancellationToken); + + logger.LogInformation( + "Request processed with generation: {Count}, DurationMs={DurationMs}", + count, + (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds); + + return contracts; + } +} diff --git a/CourseGenerator.Api/appsettings.json b/CourseGenerator.Api/appsettings.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/CourseGenerator.Api/appsettings.json @@ -0,0 +1 @@ +{} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..05a30b8 --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +SHELL := /bin/bash + +DOTNET8_PREFIX ?= /opt/homebrew/opt/dotnet@8 +DOTNET8_BIN := $(DOTNET8_PREFIX)/bin +DOTNET8_ROOT := $(DOTNET8_PREFIX)/libexec + +APPHOST_PROJECT := CloudDevelopment.AppHost/CloudDevelopment.AppHost.csproj +API_PROJECT := CourseGenerator.Api/CourseGenerator.Api.csproj +REDIS_CONTAINER := lab1-redis +REDIS_IMAGE := redis:7-alpine + +.PHONY: help redis-up redis-down restore build run-apphost run-api api-check + +help: + @echo "Targets:" + @echo " make redis-up - start Redis container" + @echo " make redis-down - stop Redis container" + @echo " make restore - restore workloads and NuGet packages" + @echo " make build - build solution in Debug" + @echo " make run-apphost - run Aspire AppHost" + @echo " make run-api - run API standalone on http://localhost:5117" + @echo " make api-check - call API endpoint (standalone mode)" + +redis-up: + @docker ps -a --format '{{.Names}}' | grep -qx '$(REDIS_CONTAINER)' \ + && docker start $(REDIS_CONTAINER) \ + || docker run -d --name $(REDIS_CONTAINER) -p 6379:6379 $(REDIS_IMAGE) + +redis-down: + @docker stop $(REDIS_CONTAINER) + +restore: + @export PATH="$(DOTNET8_BIN):$$PATH"; \ + export DOTNET_ROOT="$(DOTNET8_ROOT)"; \ + dotnet workload restore $(APPHOST_PROJECT); \ + dotnet restore CloudDevelopment.sln + +build: + @export PATH="$(DOTNET8_BIN):$$PATH"; \ + export DOTNET_ROOT="$(DOTNET8_ROOT)"; \ + dotnet build CloudDevelopment.sln -c Debug + +run-apphost: + @export PATH="$(DOTNET8_BIN):$$PATH"; \ + export DOTNET_ROOT="$(DOTNET8_ROOT)"; \ + dotnet run --project $(APPHOST_PROJECT) + +run-api: + @export PATH="$(DOTNET8_BIN):$$PATH"; \ + export DOTNET_ROOT="$(DOTNET8_ROOT)"; \ + ASPNETCORE_ENVIRONMENT=Development dotnet run --project $(API_PROJECT) --urls http://localhost:5117 + +api-check: + @curl "http://localhost:5117/api/courses/generate?count=2" diff --git a/global.json b/global.json new file mode 100644 index 0000000..759e025 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.124", + "rollForward": "disable" + } +}