mirror of
https://github.com/fiodarsazanavets/aspire-13-examples.git
synced 2026-06-20 12:23:14 +00:00
581 lines
17 KiB
C#
581 lines
17 KiB
C#
using Azure.Data.Tables;
|
||
using Azure.Storage.Blobs;
|
||
using CsvHelper;
|
||
using CsvHelper.Configuration;
|
||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.Data.SqlClient;
|
||
using MongoDB.Driver;
|
||
using OnlineShop.ApiService;
|
||
using OnlineShop.ApiService.Model;
|
||
using OnlineShop.ServiceDefaults.Dtos;
|
||
using RabbitMQ.Client;
|
||
using System.Data;
|
||
using System.Globalization;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using static MongoDB.Driver.WriteConcern;
|
||
|
||
var builder = WebApplication.CreateBuilder(args);
|
||
|
||
builder.AddServiceDefaults();
|
||
|
||
builder.Services.AddProblemDetails();
|
||
|
||
builder.Services.AddOpenApi();
|
||
|
||
builder.Services.AddSignalR();
|
||
|
||
builder.Services.AddHttpClient(
|
||
"OidcBackchannel", o => o.BaseAddress = new("http://idp"));
|
||
|
||
builder.Services.AddAuthentication(options =>
|
||
{
|
||
options.DefaultAuthenticateScheme =
|
||
JwtBearerDefaults.AuthenticationScheme;
|
||
options.DefaultChallengeScheme =
|
||
JwtBearerDefaults.AuthenticationScheme;
|
||
|
||
})
|
||
.AddJwtBearer()
|
||
.ConfigureApiJwt();
|
||
|
||
builder.AddSqlServerClient("sqldb");
|
||
builder.AddMongoDBClient("mongodb");
|
||
builder.AddAzureTableServiceClient("tables");
|
||
builder.AddAzureBlobServiceClient("blobs");
|
||
builder.AddRabbitMQClient("rabbitmq");
|
||
|
||
builder.Services.AddSingleton<LocationUpdater>();
|
||
builder.Services.AddHostedService(
|
||
sp => sp
|
||
.GetRequiredService<LocationUpdater>());
|
||
|
||
var app = builder.Build();
|
||
|
||
using (var scope = app.Services.CreateScope())
|
||
{
|
||
var connection = scope.ServiceProvider.GetRequiredService<SqlConnection>();
|
||
connection.Open();
|
||
|
||
var createDbCommand = new SqlCommand(@"
|
||
IF NOT EXISTS (SELECT *
|
||
FROM sys.databases
|
||
WHERE name = 'Shop')
|
||
BEGIN
|
||
CREATE DATABASE Shop;
|
||
END;", connection);
|
||
|
||
createDbCommand.ExecuteNonQuery();
|
||
|
||
var createTableCommand = new SqlCommand(@"
|
||
USE Shop;
|
||
|
||
-- Products table
|
||
IF NOT EXISTS (
|
||
SELECT *
|
||
FROM sysobjects
|
||
WHERE name='Products' AND xtype='U'
|
||
)
|
||
BEGIN
|
||
CREATE TABLE Products (
|
||
Id INT PRIMARY KEY IDENTITY,
|
||
Title VARCHAR(100) NOT NULL,
|
||
Summary NVARCHAR(2100) NOT NULL,
|
||
Price DECIMAL(18,2) NOT NULL,
|
||
DateAdded DATE NOT NULL
|
||
);
|
||
END
|
||
|
||
-- Orders table
|
||
IF NOT EXISTS (
|
||
SELECT *
|
||
FROM sysobjects
|
||
WHERE name='Orders' AND xtype='U'
|
||
)
|
||
BEGIN
|
||
CREATE TABLE Orders (
|
||
Id INT IDENTITY PRIMARY KEY,
|
||
TotalAmount DECIMAL(18,2) NOT NULL
|
||
);
|
||
END
|
||
|
||
-- OrderItems table
|
||
IF NOT EXISTS (
|
||
SELECT *
|
||
FROM sysobjects
|
||
WHERE name='OrderItems' AND xtype='U'
|
||
)
|
||
BEGIN
|
||
CREATE TABLE OrderItems (
|
||
OrderId INT NOT NULL,
|
||
ProductId INT NOT NULL,
|
||
Quantity INT NOT NULL,
|
||
|
||
CONSTRAINT PK_OrderItems PRIMARY KEY (OrderId, ProductId),
|
||
|
||
CONSTRAINT FK_OrderItems_Orders
|
||
FOREIGN KEY (OrderId) REFERENCES Orders(Id),
|
||
|
||
CONSTRAINT FK_OrderItems_Products
|
||
FOREIGN KEY (ProductId) REFERENCES Products(Id)
|
||
);
|
||
END
|
||
", connection);
|
||
|
||
createTableCommand.ExecuteNonQuery();
|
||
|
||
var checkDataCommand =
|
||
new SqlCommand(
|
||
"SELECT COUNT(*) FROM Products",
|
||
connection);
|
||
|
||
var count = (int)checkDataCommand
|
||
.ExecuteScalar();
|
||
|
||
if (count == 0)
|
||
{
|
||
var insertCommand =
|
||
new SqlCommand("""
|
||
INSERT INTO Products (Title, Summary, Price, DateAdded)
|
||
VALUES
|
||
(
|
||
'Wireless Optical Mouse',
|
||
N'Ergonomic wireless optical mouse with adjustable DPI and long battery life, suitable for everyday office and home use.',
|
||
24.99,
|
||
'2025-01-05'
|
||
),
|
||
(
|
||
'Mechanical Gaming Keyboard',
|
||
N'RGB backlit mechanical keyboard with blue switches, anti-ghosting keys, and durable aluminum frame.',
|
||
129.99,
|
||
'2025-01-06'
|
||
),
|
||
(
|
||
'27-inch 4K Monitor',
|
||
N'27-inch UHD 4K monitor with IPS panel, 3840x2160 resolution, HDR support, and ultra-thin bezels.',
|
||
399.00,
|
||
'2025-01-07'
|
||
),
|
||
(
|
||
'USB-C Docking Station',
|
||
N'Multi-port USB-C docking station with HDMI, DisplayPort, Ethernet, USB 3.0 ports, and 100W power delivery.',
|
||
179.50,
|
||
'2025-01-08'
|
||
),
|
||
(
|
||
'External SSD 1TB',
|
||
N'Portable 1TB external SSD with USB 3.2 Gen 2 support, delivering fast read/write speeds in a compact design.',
|
||
149.99,
|
||
'2025-01-09'
|
||
),
|
||
(
|
||
'Noise-Cancelling Headphones',
|
||
N'Over-ear wireless headphones with active noise cancellation, high-fidelity sound, and 30-hour battery life.',
|
||
249.00,
|
||
'2025-01-10'
|
||
),
|
||
(
|
||
'Webcam Full HD 1080p',
|
||
N'Full HD 1080p webcam with built-in microphone, autofocus, and low-light correction for video conferencing.',
|
||
69.99,
|
||
'2025-01-11'
|
||
),
|
||
(
|
||
'Gaming Laptop Backpack',
|
||
N'Water-resistant backpack designed for gaming laptops up to 17 inches, featuring padded compartments and USB charging port.',
|
||
59.95,
|
||
'2025-01-12'
|
||
),
|
||
(
|
||
'Wi-Fi 6 Router',
|
||
N'Dual-band Wi-Fi 6 router offering high-speed wireless connectivity, improved range, and support for multiple devices.',
|
||
199.00,
|
||
'2025-01-13'
|
||
),
|
||
(
|
||
'Portable Laser Printer',
|
||
N'Compact monochrome laser printer suitable for small offices, offering fast printing speeds and wireless connectivity.',
|
||
289.99,
|
||
'2025-01-14'
|
||
);
|
||
""",
|
||
connection);
|
||
|
||
insertCommand.ExecuteNonQuery();
|
||
}
|
||
|
||
var mongoClient = scope.ServiceProvider
|
||
.GetRequiredService<IMongoClient>();
|
||
|
||
var database = mongoClient
|
||
.GetDatabase("ShopDB");
|
||
|
||
var collection = database
|
||
.GetCollection<ProductReviewsDocument>("ProductReviews");
|
||
|
||
var docCount = collection.CountDocuments(FilterDefinition<ProductReviewsDocument>.Empty);
|
||
|
||
if (docCount == 0)
|
||
{
|
||
var productReviews = new List<ProductReviewsDocument>();
|
||
|
||
foreach (var productId in Enumerable.Range(1, 10))
|
||
{
|
||
var reviews = new List<ProductReview>();
|
||
|
||
var numberOfReviews = Random.Shared.Next(1, 5);
|
||
|
||
for (var i = 0; i < numberOfReviews; i++)
|
||
{
|
||
reviews.Add(new ProductReview
|
||
{
|
||
UserId = $"user-{Random.Shared.Next(1, 100)}",
|
||
Rating = Random.Shared.Next(3, 6), // 3–5
|
||
Comment = "Great product, works exactly as expected.",
|
||
CreatedAt = DateTime.UtcNow.AddDays(-Random.Shared.Next(1, 30)),
|
||
VerifiedPurchase = Random.Shared.Next(0, 2) == 1,
|
||
Status = ReviewStatus.Approved
|
||
});
|
||
}
|
||
|
||
var averageRating = reviews.Average(r => r.Rating);
|
||
|
||
productReviews.Add(new ProductReviewsDocument
|
||
{
|
||
ProductId = productId, // matches SQL Products.Id
|
||
AverageRating = Math.Round(averageRating, 2),
|
||
TotalReviews = reviews.Count,
|
||
Reviews = reviews
|
||
});
|
||
}
|
||
|
||
collection.InsertMany(productReviews);
|
||
}
|
||
|
||
var tableServiceClient =
|
||
scope.ServiceProvider.GetRequiredService<TableServiceClient>();
|
||
|
||
var tableClient = tableServiceClient
|
||
.GetTableClient("ProductMetadata");
|
||
|
||
await tableClient.CreateIfNotExistsAsync();
|
||
|
||
var existing = tableClient
|
||
.Query<ProductMetadataEntity>(x => x.PartitionKey == "Product")
|
||
.Take(1)
|
||
.Any();
|
||
|
||
if (!existing)
|
||
{
|
||
var entities = new List<ProductMetadataEntity>();
|
||
|
||
foreach (var productId in Enumerable.Range(1, 10))
|
||
{
|
||
entities.Add(new ProductMetadataEntity
|
||
{
|
||
PartitionKey = "Product",
|
||
RowKey = productId.ToString(),
|
||
|
||
ReviewsEnabled = true,
|
||
Featured = productId % 2 == 0,
|
||
MaxReviewsPerUser = 1
|
||
});
|
||
}
|
||
|
||
foreach (var entity in entities)
|
||
{
|
||
await tableClient.AddEntityAsync(entity);
|
||
}
|
||
}
|
||
|
||
var blobServiceClient =
|
||
scope.ServiceProvider
|
||
.GetRequiredService<BlobServiceClient>();
|
||
|
||
var containerClient = blobServiceClient
|
||
.GetBlobContainerClient("products");
|
||
|
||
// Create the container if it doesn't exist
|
||
await containerClient.CreateIfNotExistsAsync();
|
||
|
||
var blobClient = containerClient
|
||
.GetBlobClient("products-specs.csv");
|
||
|
||
// Check if the blob already exists
|
||
if (await blobClient.ExistsAsync())
|
||
{
|
||
return;
|
||
}
|
||
|
||
var productSpecs = new List<ProductSpecCsvRow>();
|
||
|
||
foreach (var productId in Enumerable.Range(1, 10))
|
||
{
|
||
productSpecs.Add(new ProductSpecCsvRow
|
||
{
|
||
ProductId = productId,
|
||
ReviewsEnabled = true,
|
||
Featured = productId % 2 == 0,
|
||
MaxReviewsPerUser = 1,
|
||
|
||
Category = productId % 2 == 0 ? "Laptop" : "Peripheral",
|
||
WarrantyMonths = productId % 2 == 0 ? 24 : 12
|
||
});
|
||
}
|
||
|
||
using (var memoryStream = new MemoryStream())
|
||
using (var writer = new StreamWriter(memoryStream, Encoding.UTF8, leaveOpen: true))
|
||
using (var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)))
|
||
{
|
||
csv.WriteRecords(productSpecs);
|
||
writer.Flush();
|
||
|
||
memoryStream.Position = 0;
|
||
await blobClient.UploadAsync(memoryStream, overwrite: true);
|
||
}
|
||
}
|
||
|
||
app.UseExceptionHandler();
|
||
|
||
if (app.Environment.IsDevelopment())
|
||
{
|
||
app.MapOpenApi();
|
||
}
|
||
|
||
app.MapGet("/", () => "API service is running.");
|
||
|
||
app.MapGet("/products",
|
||
([FromServices] SqlConnection connection) =>
|
||
{
|
||
connection.Open();
|
||
|
||
var command = new SqlCommand(@"
|
||
USE Shop;
|
||
SELECT
|
||
Id,
|
||
Title,
|
||
Summary,
|
||
Price
|
||
FROM Products", connection);
|
||
var products = new List<ProductDto>();
|
||
|
||
using (var reader = command.ExecuteReader())
|
||
{
|
||
while (reader.Read())
|
||
{
|
||
products.Add(new ProductDto(
|
||
Id: reader.GetInt32(0),
|
||
Title: reader.GetString(1),
|
||
Summary: reader.GetString(2),
|
||
Price: reader.GetDecimal(3)
|
||
));
|
||
}
|
||
|
||
return products.ToArray();
|
||
}
|
||
});
|
||
|
||
app.MapGet("/product-reviews",
|
||
([FromServices] IMongoClient mongoClient) =>
|
||
{
|
||
var database = mongoClient
|
||
.GetDatabase("ShopDB");
|
||
|
||
var collection = database
|
||
.GetCollection<ProductReviewsDocument>(
|
||
"ProductReviews");
|
||
|
||
var productReviews = collection
|
||
.Find(FilterDefinition<ProductReviewsDocument>.Empty)
|
||
.ToList();
|
||
|
||
return productReviews.ToArray();
|
||
});
|
||
|
||
app.MapGet("/product-metadata",
|
||
async (TableServiceClient tableServiceClient) =>
|
||
{
|
||
var tableClient = tableServiceClient
|
||
.GetTableClient("ProductMetadata");
|
||
|
||
var metadata = new List<ProductMetadataEntity>();
|
||
|
||
var entities = tableClient
|
||
.QueryAsync<ProductMetadataEntity>(
|
||
x => x.PartitionKey == "Product");
|
||
|
||
await foreach (var entity in entities)
|
||
{
|
||
metadata.Add(entity);
|
||
}
|
||
|
||
return metadata.ToArray();
|
||
});
|
||
|
||
app.MapGet("/product-specs", async (
|
||
BlobServiceClient blobServiceClient) =>
|
||
{
|
||
var containerClient = blobServiceClient
|
||
.GetBlobContainerClient("products");
|
||
|
||
var blobClient = containerClient
|
||
.GetBlobClient("product-specs.csv");
|
||
|
||
if (!await blobClient.ExistsAsync())
|
||
{
|
||
return Array.Empty<ProductSpecCsvRow>();
|
||
}
|
||
|
||
List<ProductSpecCsvRow> productSpecs;
|
||
|
||
// Download the CSV from the blob
|
||
var downloadResponse = await blobClient.DownloadAsync();
|
||
|
||
using (var stream = downloadResponse.Value.Content)
|
||
using (var reader = new StreamReader(stream))
|
||
using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
|
||
{
|
||
productSpecs = csv
|
||
.GetRecords<ProductSpecCsvRow>()
|
||
.ToList();
|
||
}
|
||
|
||
return productSpecs.ToArray();
|
||
});
|
||
|
||
app.MapPost("/api/orders", async (
|
||
Dictionary<int, int> basket,
|
||
[FromServices] SqlConnection dbConnection,
|
||
[FromServices] IConnection rabbitConnection) =>
|
||
{
|
||
if (basket is null || basket.Count == 0)
|
||
return Results.BadRequest("Basket is empty.");
|
||
|
||
var items = basket
|
||
.Where(kvp => kvp.Value > 0)
|
||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||
|
||
if (items.Count == 0)
|
||
return Results.BadRequest("Basket contains no items with quantity > 0.");
|
||
|
||
if (dbConnection.State != ConnectionState.Open)
|
||
await dbConnection.OpenAsync();
|
||
|
||
int orderId;
|
||
decimal totalAmount;
|
||
|
||
await using var tx =
|
||
(SqlTransaction)await dbConnection.BeginTransactionAsync(IsolationLevel.ReadCommitted);
|
||
|
||
try
|
||
{
|
||
var productIds = items.Keys.ToList();
|
||
var inParams = string.Join(", ", productIds.Select((_, i) => $"@p{i}"));
|
||
|
||
var priceLookup = new Dictionary<int, decimal>();
|
||
|
||
await using (var cmd = new SqlCommand($@"
|
||
SELECT Id, Price
|
||
FROM Products
|
||
WHERE Id IN ({inParams});",
|
||
dbConnection, tx))
|
||
{
|
||
for (int i = 0; i < productIds.Count; i++)
|
||
cmd.Parameters.AddWithValue($"@p{i}", productIds[i]);
|
||
|
||
await using var reader = await cmd.ExecuteReaderAsync();
|
||
while (await reader.ReadAsync())
|
||
priceLookup[reader.GetInt32(0)] = reader.GetDecimal(1);
|
||
}
|
||
|
||
totalAmount = items.Sum(i => priceLookup[i.Key] * i.Value);
|
||
|
||
await using (var cmd = new SqlCommand(@"
|
||
INSERT INTO Orders (TotalAmount)
|
||
VALUES (@totalAmount);
|
||
SELECT CAST(SCOPE_IDENTITY() AS INT);",
|
||
dbConnection, tx))
|
||
{
|
||
var p = cmd.Parameters.Add("@totalAmount", SqlDbType.Decimal);
|
||
p.Precision = 18;
|
||
p.Scale = 2;
|
||
p.Value = totalAmount;
|
||
|
||
orderId = (int)(await cmd.ExecuteScalarAsync() ?? 0);
|
||
}
|
||
|
||
if (orderId <= 0)
|
||
return Results.Problem("Failed to create order.");
|
||
|
||
await using (var cmd = new SqlCommand(@"
|
||
INSERT INTO OrderItems (OrderId, ProductId, Quantity)
|
||
VALUES (@orderId, @productId, @quantity);",
|
||
dbConnection, tx))
|
||
{
|
||
cmd.Parameters.Add("@orderId", SqlDbType.Int).Value = orderId;
|
||
var pProductId = cmd.Parameters.Add("@productId", SqlDbType.Int);
|
||
var pQuantity = cmd.Parameters.Add("@quantity", SqlDbType.Int);
|
||
|
||
foreach (var (productId, qty) in items)
|
||
{
|
||
pProductId.Value = productId;
|
||
pQuantity.Value = qty;
|
||
await cmd.ExecuteNonQueryAsync();
|
||
}
|
||
}
|
||
|
||
await tx.CommitAsync();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await tx.RollbackAsync();
|
||
return Results.Problem($"Order creation failed: {ex.Message}");
|
||
}
|
||
|
||
try
|
||
{
|
||
await using var channel = await rabbitConnection.CreateChannelAsync();
|
||
|
||
const string queueName = "orders.created";
|
||
|
||
await channel.QueueDeclareAsync(
|
||
queue: queueName,
|
||
durable: true,
|
||
exclusive: false,
|
||
autoDelete: false,
|
||
arguments: null);
|
||
|
||
var message = JsonSerializer.Serialize(new { orderId });
|
||
var body = Encoding.UTF8.GetBytes(message);
|
||
|
||
var props = new BasicProperties
|
||
{
|
||
Persistent = true
|
||
};
|
||
|
||
await channel.BasicPublishAsync(
|
||
exchange: "",
|
||
routingKey: queueName,
|
||
mandatory: false,
|
||
basicProperties: props,
|
||
body: body);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem(
|
||
$"Order was created (Id={orderId}) but RabbitMQ publish failed: {ex.Message}");
|
||
}
|
||
|
||
return Results.Created($"/api/orders/{orderId}", new
|
||
{
|
||
OrderId = orderId,
|
||
TotalAmount = totalAmount
|
||
});
|
||
});
|
||
|
||
app.MapDefaultEndpoints();
|
||
|
||
app.MapHub<LocationHub>("/locationHub");
|
||
|
||
app.Run(); |