From 71cf8d63c9e623bf8c94a726d5f02daf59a5f4f2 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Sun, 15 Mar 2026 19:23:06 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=81=20=D0=BF=D0=BE=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=BC=D0=B5=D1=82=D0=BD=D0=BE=D0=B9=20=D0=BE=D0=B1=D0=BB?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=B8,=20=D0=BD=D0=B0=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/Properties/launchSettings.json | 2 +- Client.Wasm/wwwroot/appsettings.json | 2 +- src/AppHost/AppHost.csproj | 21 +++++ src/AppHost/Program.cs | 11 +++ src/AppHost/Properties/launchSettings.json | 17 ++++ src/ServiceDefaults/Extensions.cs | 81 +++++++++++++++++ src/ServiceDefaults/ServiceDefaults.csproj | 20 +++++ src/VehicleApi/Models/Vehicle.cs | 15 ++++ src/VehicleApi/Program.cs | 67 ++++++++++++++ src/VehicleApi/Properties/launchSettings.json | 23 +++++ src/VehicleApi/Services/VehicleGenerator.cs | 27 ++++++ src/VehicleApi/VehicleApi.csproj | 18 ++++ .../VehicleApi.Tests/VehicleApi.Tests.csproj | 24 +++++ .../VehicleApi.Tests/VehicleGeneratorTests.cs | 88 +++++++++++++++++++ 14 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 src/AppHost/AppHost.csproj create mode 100644 src/AppHost/Program.cs create mode 100644 src/AppHost/Properties/launchSettings.json create mode 100644 src/ServiceDefaults/Extensions.cs create mode 100644 src/ServiceDefaults/ServiceDefaults.csproj create mode 100644 src/VehicleApi/Models/Vehicle.cs create mode 100644 src/VehicleApi/Program.cs create mode 100644 src/VehicleApi/Properties/launchSettings.json create mode 100644 src/VehicleApi/Services/VehicleGenerator.cs create mode 100644 src/VehicleApi/VehicleApi.csproj create mode 100644 tests/VehicleApi.Tests/VehicleApi.Tests.csproj create mode 100644 tests/VehicleApi.Tests/VehicleGeneratorTests.cs diff --git a/Client.Wasm/Properties/launchSettings.json b/Client.Wasm/Properties/launchSettings.json index 0d824ea7..8141fbab 100644 --- a/Client.Wasm/Properties/launchSettings.json +++ b/Client.Wasm/Properties/launchSettings.json @@ -38,4 +38,4 @@ } } } -} +} \ No newline at end of file diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 4dda7c04..527aa767 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "https://localhost:7170/land-plot" + "BaseAddress": "https://localhost:5001/api/vehicles" } \ No newline at end of file diff --git a/src/AppHost/AppHost.csproj b/src/AppHost/AppHost.csproj new file mode 100644 index 00000000..20004976 --- /dev/null +++ b/src/AppHost/AppHost.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + enable + enable + true + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/AppHost/Program.cs b/src/AppHost/Program.cs new file mode 100644 index 00000000..b5d037cc --- /dev/null +++ b/src/AppHost/Program.cs @@ -0,0 +1,11 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var cache = builder.AddRedis("cache"); + +var api = builder.AddProject("vehicleapi") + .WithReference(cache); + +var client = builder.AddProject("client") + .WithReference(api); + +builder.Build().Run(); diff --git a/src/AppHost/Properties/launchSettings.json b/src/AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..a37f7f18 --- /dev/null +++ b/src/AppHost/Properties/launchSettings.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17057;http://localhost:15057", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21057", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22057" + } + } + } +} diff --git a/src/ServiceDefaults/Extensions.cs b/src/ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..f66d336a --- /dev/null +++ b/src/ServiceDefaults/Extensions.cs @@ -0,0 +1,81 @@ +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 Microsoft.Extensions.ServiceDiscovery; +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(); + + 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() + .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) + { + app.MapHealthChecks(HealthEndpointPath); + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + return app; + } +} \ No newline at end of file diff --git a/src/ServiceDefaults/ServiceDefaults.csproj b/src/ServiceDefaults/ServiceDefaults.csproj new file mode 100644 index 00000000..66030857 --- /dev/null +++ b/src/ServiceDefaults/ServiceDefaults.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/VehicleApi/Models/Vehicle.cs b/src/VehicleApi/Models/Vehicle.cs new file mode 100644 index 00000000..ef65cdd1 --- /dev/null +++ b/src/VehicleApi/Models/Vehicle.cs @@ -0,0 +1,15 @@ +namespace VehicleApi.Models; + +public record Vehicle +{ + public int Id { get; init; } + public string Vin { get; init; } = string.Empty; + public string Manufacturer { get; init; } = string.Empty; + public string Model { get; init; } = string.Empty; + public int Year { get; init; } + public string BodyType { get; init; } = string.Empty; + public string FuelType { get; init; } = string.Empty; + public string Color { get; init; } = string.Empty; + public double Mileage { get; init; } + public DateOnly LastServiceDate { get; init; } +} diff --git a/src/VehicleApi/Program.cs b/src/VehicleApi/Program.cs new file mode 100644 index 00000000..19fc7f24 --- /dev/null +++ b/src/VehicleApi/Program.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; +using VehicleApi.Models; +using VehicleApi.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Add ServiceDefaults (OpenTelemetry, health checks, service discovery) +builder.AddServiceDefaults(); + +// Add Redis distributed caching +builder.AddRedisDistributedCache("cache"); + +// Add CORS for Blazor client +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +var app = builder.Build(); + +// Enable CORS +app.UseCors(); + +// Map health checks +app.MapDefaultEndpoints(); + +// API endpoint for vehicle data +app.MapGet("/api/vehicles", async (int id, IDistributedCache cache, ILogger logger) => +{ + if (id <= 0) + { + logger.LogWarning("Invalid vehicle ID {Id} requested", id); + return Results.BadRequest("ID must be greater than 0"); + } + + var cacheKey = $"vehicle:{id}"; + var cachedData = await cache.GetAsync(cacheKey); + + if (cachedData != null) + { + logger.LogInformation("Cache hit for vehicle ID {Id}", id); + var vehicle = JsonSerializer.Deserialize(cachedData); + logger.LogInformation("Returning cached vehicle: {@Vehicle}", vehicle); + return Results.Ok(vehicle); + } + + logger.LogInformation("Cache miss for vehicle ID {Id}", id); + var generated = VehicleGenerator.Generate(id); + logger.LogInformation("Generated new vehicle: {@Vehicle}", generated); + + var serialized = JsonSerializer.SerializeToUtf8Bytes(generated); + await cache.SetAsync(cacheKey, serialized, new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) + }); + logger.LogInformation("Vehicle {Id} cached for 10 minutes", id); + + return Results.Ok(generated); +}); + +app.Run(); diff --git a/src/VehicleApi/Properties/launchSettings.json b/src/VehicleApi/Properties/launchSettings.json new file mode 100644 index 00000000..900ff92f --- /dev/null +++ b/src/VehicleApi/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/VehicleApi/Services/VehicleGenerator.cs b/src/VehicleApi/Services/VehicleGenerator.cs new file mode 100644 index 00000000..af3bd41c --- /dev/null +++ b/src/VehicleApi/Services/VehicleGenerator.cs @@ -0,0 +1,27 @@ +using Bogus; +using VehicleApi.Models; + +namespace VehicleApi.Services; + +public static class VehicleGenerator +{ + public static Vehicle Generate(int id) + { + // Seed per-instance (NOT global Randomizer.Seed) + var faker = new Faker() + .UseSeed(id) + .RuleFor(v => v.Id, id) + .RuleFor(v => v.Vin, f => f.Vehicle.Vin()) + .RuleFor(v => v.Manufacturer, f => f.Vehicle.Manufacturer()) + .RuleFor(v => v.Model, f => f.Vehicle.Model()) + .RuleFor(v => v.Year, f => f.Random.Int(1990, DateTime.Now.Year)) + .RuleFor(v => v.BodyType, f => f.Vehicle.Type()) + .RuleFor(v => v.FuelType, f => f.Vehicle.Fuel()) + .RuleFor(v => v.Color, f => f.Commerce.Color()) + .RuleFor(v => v.Mileage, f => f.Random.Double(0, 500000)) + .RuleFor(v => v.LastServiceDate, (f, v) => + DateOnly.FromDateTime(f.Date.Between(new DateTime(v.Year, 1, 1), DateTime.Now))); + + return faker.Generate(); + } +} diff --git a/src/VehicleApi/VehicleApi.csproj b/src/VehicleApi/VehicleApi.csproj new file mode 100644 index 00000000..09abff65 --- /dev/null +++ b/src/VehicleApi/VehicleApi.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/VehicleApi.Tests/VehicleApi.Tests.csproj b/tests/VehicleApi.Tests/VehicleApi.Tests.csproj new file mode 100644 index 00000000..43d11c14 --- /dev/null +++ b/tests/VehicleApi.Tests/VehicleApi.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/VehicleApi.Tests/VehicleGeneratorTests.cs b/tests/VehicleApi.Tests/VehicleGeneratorTests.cs new file mode 100644 index 00000000..b52531da --- /dev/null +++ b/tests/VehicleApi.Tests/VehicleGeneratorTests.cs @@ -0,0 +1,88 @@ +using VehicleApi.Models; +using VehicleApi.Services; +using Xunit; +using VehicleApi.Models; +using VehicleApi.Services; + +namespace VehicleApi.Tests; + +public class VehicleGeneratorTests +{ + [Fact] + public void Generate_SameId_ReturnsSameData() + { + // Arrange & Act + var vehicle1 = VehicleGenerator.Generate(42); + var vehicle2 = VehicleGenerator.Generate(42); + + // Assert + Assert.Equal(vehicle1.Id, vehicle2.Id); + Assert.Equal(vehicle1.Vin, vehicle2.Vin); + Assert.Equal(vehicle1.Manufacturer, vehicle2.Manufacturer); + Assert.Equal(vehicle1.Model, vehicle2.Model); + Assert.Equal(vehicle1.Year, vehicle2.Year); + Assert.Equal(vehicle1.BodyType, vehicle2.BodyType); + Assert.Equal(vehicle1.FuelType, vehicle2.FuelType); + Assert.Equal(vehicle1.Color, vehicle2.Color); + Assert.Equal(vehicle1.Mileage, vehicle2.Mileage); + Assert.Equal(vehicle1.LastServiceDate, vehicle2.LastServiceDate); + } + + [Theory] + [InlineData(1, 2)] + [InlineData(10, 20)] + [InlineData(42, 100)] + public void Generate_DifferentIds_ReturnsDifferentData(int id1, int id2) + { + // Arrange & Act + var vehicle1 = VehicleGenerator.Generate(id1); + var vehicle2 = VehicleGenerator.Generate(id2); + + // Assert + Assert.NotEqual(vehicle1.Vin, vehicle2.Vin); + } + + [Fact] + public void Generate_YearConstraint_IsValid() + { + // Arrange & Act + for (int i = 1; i <= 100; i++) + { + var vehicle = VehicleGenerator.Generate(i); + + // Assert + Assert.True(vehicle.Year >= 1990, $"Vehicle {i} has Year {vehicle.Year} which is less than 1990"); + Assert.True(vehicle.Year <= DateTime.Now.Year, $"Vehicle {i} has Year {vehicle.Year} which exceeds current year {DateTime.Now.Year}"); + } + } + + [Fact] + public void Generate_MileageConstraint_IsValid() + { + // Arrange & Act + for (int i = 1; i <= 100; i++) + { + var vehicle = VehicleGenerator.Generate(i); + + // Assert + Assert.True(vehicle.Mileage >= 0, $"Vehicle {i} has Mileage {vehicle.Mileage} which is less than 0"); + Assert.True(vehicle.Mileage <= 500000, $"Vehicle {i} has Mileage {vehicle.Mileage} which exceeds 500,000"); + } + } + + [Fact] + public void Generate_LastServiceDateConstraint_IsValid() + { + // Arrange & Act + for (int i = 1; i <= 100; i++) + { + var vehicle = VehicleGenerator.Generate(i); + + // Assert + Assert.True(vehicle.LastServiceDate.Year >= vehicle.Year, + $"Vehicle {i} has LastServiceDate year {vehicle.LastServiceDate.Year} which is before the vehicle's manufacturing year {vehicle.Year}"); + Assert.True(vehicle.LastServiceDate <= DateOnly.FromDateTime(DateTime.Now), + $"Vehicle {i} has LastServiceDate {vehicle.LastServiceDate} which is in the future"); + } + } +}