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 d1fe7ab3..87e4c8ee 100644
--- a/Client.Wasm/wwwroot/appsettings.json
+++ b/Client.Wasm/wwwroot/appsettings.json
@@ -6,5 +6,6 @@
}
},
"AllowedHosts": "*",
- "BaseAddress": ""
+ "BaseAddress": "https://localhost:5001/api/vehicles"
}
+
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");
+ }
+ }
+}