Add code samples for deployment

This commit is contained in:
fiodarsazanavets
2026-02-04 13:00:03 +00:00
parent becb5d2871
commit 9312555269
138 changed files with 66565 additions and 1 deletions
@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
namespace OnlineShop.ApiService
{
public static class AuthExtensions
{
public static void ConfigureApiJwt(this AuthenticationBuilder authentication)
{
// Named options
authentication.Services
.AddOptions<JwtBearerOptions>(
JwtBearerDefaults.AuthenticationScheme)
.Configure<
IConfiguration,
IHttpClientFactory,
IHostEnvironment>(Configure);
// Unnamed options
authentication.Services.AddOptions<JwtBearerOptions>()
.Configure<
IConfiguration,
IHttpClientFactory,
IHostEnvironment>(Configure);
static void Configure(
JwtBearerOptions options,
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
IHostEnvironment hostEnvironment)
{
var backchannelHttpClient =
httpClientFactory.CreateClient(
"OidcBackchannel");
options.Backchannel = backchannelHttpClient;
options.Authority =
backchannelHttpClient
.GetIdpAuthorityUri().ToString();
options.RequireHttpsMetadata =
!hostEnvironment.IsDevelopment();
options.MapInboundClaims = false;
options.TokenValidationParameters =
new TokenValidationParameters
{
ValidateAudience = false
};
}
}
}
}
@@ -0,0 +1,113 @@
using Microsoft.EntityFrameworkCore;
using OnlineShop.ApiService.Model;
namespace OnlineShop.ApiService;
public class DataSeederService(IServiceProvider serviceProvider) : BackgroundService
{
private readonly IServiceProvider _serviceProvider = serviceProvider;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var scope = _serviceProvider.CreateScope();
using var context = scope.ServiceProvider.GetRequiredService<ProductsDbContext>();
await RunMigrationAsync(context, stoppingToken);
SeedData(context);
}
private static async Task RunMigrationAsync(
ProductsDbContext context,
CancellationToken cancellationToken)
{
var strategy = context.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await context.Database.MigrateAsync(cancellationToken);
});
}
private static void SeedData(ProductsDbContext context)
{
if (!context.Products.Any())
{
var products = new List<Product>
{
new Product
{
Title = "Wireless Optical Mouse",
Summary = "Ergonomic wireless optical mouse with adjustable DPI and long battery life, suitable for everyday office and home use.",
Price = 24.99m,
DateAdded = new DateTime(2025, 1, 5)
},
new Product
{
Title = "Mechanical Gaming Keyboard",
Summary = "RGB backlit mechanical keyboard with blue switches, anti-ghosting keys, and durable aluminum frame.",
Price = 129.99m,
DateAdded = new DateTime(2025, 1, 6)
},
new Product
{
Title = "27-inch 4K Monitor",
Summary = "27-inch UHD 4K monitor with IPS panel, 3840x2160 resolution, HDR support, and ultra-thin bezels.",
Price = 399.00m,
DateAdded = new DateTime(2025, 1, 7)
},
new Product
{
Title = "USB-C Docking Station",
Summary = "Multi-port USB-C docking station with HDMI, DisplayPort, Ethernet, USB 3.0 ports, and 100W power delivery.",
Price = 179.50m,
DateAdded = new DateTime(2025, 1, 8)
},
new Product
{
Title = "External SSD 1TB",
Summary = "Portable 1TB external SSD with USB 3.2 Gen 2 support, delivering fast read/write speeds in a compact design.",
Price = 149.99m,
DateAdded = new DateTime(2025, 1, 9)
},
new Product
{
Title = "Noise-Cancelling Headphones",
Summary = "Over-ear wireless headphones with active noise cancellation, high-fidelity sound, and 30-hour battery life.",
Price = 249.00m,
DateAdded = new DateTime(2025, 1, 10)
},
new Product
{
Title = "Webcam Full HD 1080p",
Summary = "Full HD 1080p webcam with built-in microphone, autofocus, and low-light correction for video conferencing.",
Price = 69.99m,
DateAdded = new DateTime(2025, 1, 11)
},
new Product
{
Title = "Gaming Laptop Backpack",
Summary = "Water-resistant backpack designed for gaming laptops up to 17 inches, featuring padded compartments and USB charging port.",
Price = 59.95m,
DateAdded = new DateTime(2025, 1, 12)
},
new Product
{
Title = "Wi-Fi 6 Router",
Summary = "Dual-band Wi-Fi 6 router offering high-speed wireless connectivity, improved range, and support for multiple devices.",
Price = 199.00m,
DateAdded = new DateTime(2025, 1, 13)
},
new Product
{
Title = "Portable Laser Printer",
Summary = "Compact monochrome laser printer suitable for small offices, offering fast printing speeds and wireless connectivity.",
Price = 289.99m,
DateAdded = new DateTime(2025, 1, 14)
}
};
context.Products.AddRange(products);
context.SaveChanges();
}
}
}
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.SignalR;
namespace OnlineShop.ApiService;
public class LocationHub : Hub
{
public async Task UpdateLocation(
double latitude,
double longitude)
{
await Clients.All.SendAsync(
"ReceiveLocationUpdate",
latitude,
longitude);
}
}
@@ -0,0 +1,58 @@
using Azure.Storage.Queues;
using Azure.Storage.Queues.Models;
using Microsoft.AspNetCore.SignalR;
using System.Text.Json;
namespace OnlineShop.ApiService;
public sealed class LocationUpdater(
IHubContext<LocationHub> locationHub,
QueueServiceClient queueServiceClient) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var queueClient = queueServiceClient
.GetQueueClient("orders-created");
await queueClient.CreateIfNotExistsAsync();
while (!stoppingToken.IsCancellationRequested)
{
QueueMessage[] messages = queueClient
.ReceiveMessages(maxMessages: 10);
foreach (var message in messages)
{
var body =
JsonSerializer.Deserialize<OrderCreatedMessage>(message.MessageText);
await SetInitialDeliveryLocation(
body.OrderId,
stoppingToken);
}
}
}
private async Task SetInitialDeliveryLocation(
int orderId,
CancellationToken cancellationToken)
{
await Task.Delay(3000, cancellationToken);
await UpdateLocation(orderId, 51.5074, -0.1276, cancellationToken);
}
private async Task UpdateLocation(
int orderId,
double latitude,
double longitude,
CancellationToken cancellationToken)
{
await locationHub.Clients.All.SendAsync(
"ReceiveLocationUpdate",
latitude,
longitude,
cancellationToken);
}
private sealed record OrderCreatedMessage(int OrderId);
}
@@ -0,0 +1,122 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OnlineShop.ApiService;
#nullable disable
namespace OnlineShop.ApiService.Migrations
{
[DbContext(typeof(ProductsDbContext))]
[Migration("20260204103202_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("OnlineShop.ApiService.Model.Order", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<double>("TotalAmount")
.HasColumnType("float");
b.HasKey("Id");
b.ToTable("Orders");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.OrderItem", b =>
{
b.Property<int>("OrderId")
.HasColumnType("int");
b.Property<int>("ProductId")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.HasKey("OrderId", "ProductId");
b.HasIndex("ProductId");
b.ToTable("OrderItems");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.Product", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("DateAdded")
.HasColumnType("datetime2");
b.Property<decimal>("Price")
.HasColumnType("decimal(18,2)");
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(2100)
.HasColumnType("nvarchar(2100)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.ToTable("Products");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.OrderItem", b =>
{
b.HasOne("OnlineShop.ApiService.Model.Order", "Order")
.WithMany("Items")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlineShop.ApiService.Model.Product", "Product")
.WithMany("OrderItems")
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Order");
b.Navigation("Product");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.Order", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.Product", b =>
{
b.Navigation("OrderItems");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,87 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlineShop.ApiService.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Orders",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
TotalAmount = table.Column<double>(type: "float", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Orders", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Products",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Title = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Summary = table.Column<string>(type: "nvarchar(2100)", maxLength: 2100, nullable: false),
Price = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
DateAdded = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Products", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OrderItems",
columns: table => new
{
OrderId = table.Column<int>(type: "int", nullable: false),
ProductId = table.Column<int>(type: "int", nullable: false),
Quantity = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OrderItems", x => new { x.OrderId, x.ProductId });
table.ForeignKey(
name: "FK_OrderItems_Orders_OrderId",
column: x => x.OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_OrderItems_Products_ProductId",
column: x => x.ProductId,
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_OrderItems_ProductId",
table: "OrderItems",
column: "ProductId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OrderItems");
migrationBuilder.DropTable(
name: "Orders");
migrationBuilder.DropTable(
name: "Products");
}
}
}
@@ -0,0 +1,125 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OnlineShop.ApiService;
#nullable disable
namespace OnlineShop.ApiService.Migrations
{
[DbContext(typeof(ProductsDbContext))]
[Migration("20260204103406_AddOrderAddress")]
partial class AddOrderAddress
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("OnlineShop.ApiService.Model.Order", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<double>("TotalAmount")
.HasColumnType("float");
b.HasKey("Id");
b.ToTable("Orders");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.OrderItem", b =>
{
b.Property<int>("OrderId")
.HasColumnType("int");
b.Property<int>("ProductId")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.HasKey("OrderId", "ProductId");
b.HasIndex("ProductId");
b.ToTable("OrderItems");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.Product", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("DateAdded")
.HasColumnType("datetime2");
b.Property<decimal>("Price")
.HasColumnType("decimal(18,2)");
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(2100)
.HasColumnType("nvarchar(2100)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.ToTable("Products");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.OrderItem", b =>
{
b.HasOne("OnlineShop.ApiService.Model.Order", "Order")
.WithMany("Items")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlineShop.ApiService.Model.Product", "Product")
.WithMany("OrderItems")
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Order");
b.Navigation("Product");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.Order", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.Product", b =>
{
b.Navigation("OrderItems");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlineShop.ApiService.Migrations
{
/// <inheritdoc />
public partial class AddOrderAddress : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Address",
table: "Orders",
type: "nvarchar(max)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Address",
table: "Orders");
}
}
}
@@ -0,0 +1,122 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OnlineShop.ApiService;
#nullable disable
namespace OnlineShop.ApiService.Migrations
{
[DbContext(typeof(ProductsDbContext))]
partial class ProductsDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("OnlineShop.ApiService.Model.Order", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<double>("TotalAmount")
.HasColumnType("float");
b.HasKey("Id");
b.ToTable("Orders");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.OrderItem", b =>
{
b.Property<int>("OrderId")
.HasColumnType("int");
b.Property<int>("ProductId")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.HasKey("OrderId", "ProductId");
b.HasIndex("ProductId");
b.ToTable("OrderItems");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.Product", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("DateAdded")
.HasColumnType("datetime2");
b.Property<decimal>("Price")
.HasColumnType("decimal(18,2)");
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(2100)
.HasColumnType("nvarchar(2100)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.ToTable("Products");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.OrderItem", b =>
{
b.HasOne("OnlineShop.ApiService.Model.Order", "Order")
.WithMany("Items")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlineShop.ApiService.Model.Product", "Product")
.WithMany("OrderItems")
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Order");
b.Navigation("Product");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.Order", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("OnlineShop.ApiService.Model.Product", b =>
{
b.Navigation("OrderItems");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace OnlineShop.ApiService.Model;
public class Order
{
[Key]
public int Id { get; set; }
public double TotalAmount { get; set; }
public string? Address { get; set; }
public ICollection<OrderItem> Items { get; set; } = [];
}
@@ -0,0 +1,11 @@
namespace OnlineShop.ApiService.Model;
public class OrderItem
{
public int OrderId { get; set; }
public int ProductId { get; set; }
public int Quantity { get; set; }
public Order Order { get; set; } = default!;
public Product Product { get; set; } = default!;
}
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace OnlineShop.ApiService.Model;
public class Product
{
[Key]
public int Id { get; set; }
[MaxLength(100)]
public string Title { get; set; } = string.Empty;
[MaxLength(2100)]
public string Summary { get; set; } = string.Empty;
public decimal Price { get; set; }
public DateTime DateAdded { get; set; }
public ICollection<OrderItem> OrderItems { get; set; } = [];
}
@@ -0,0 +1,19 @@
using Azure;
using Azure.Data.Tables;
namespace OnlineShop.ApiService.Model;
public sealed class ProductMetadataEntity : ITableEntity
{
// Required by Table Storage
public string PartitionKey { get; set; } = default!;
public string RowKey { get; set; } = default!;
public DateTimeOffset? Timestamp { get; set; }
public ETag ETag { get; set; }
// Business metadata
public bool ReviewsEnabled { get; set; }
public bool Featured { get; set; }
public int MaxReviewsPerUser { get; set; }
}
@@ -0,0 +1,42 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace OnlineShop.ApiService.Model;
public sealed class ProductReviewsDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; init; } = default!;
// Matches Product.Id from SQL Server
public int ProductId { get; init; }
public double AverageRating { get; set; }
public int TotalReviews { get; set; }
public List<ProductReview> Reviews { get; init; } = new();
}
public sealed class ProductReview
{
public string UserId { get; init; } = default!;
public int Rating { get; init; }
public string Comment { get; init; } = default!;
public DateTime CreatedAt { get; init; }
public bool VerifiedPurchase { get; init; }
public ReviewStatus Status { get; init; } = ReviewStatus.Approved;
}
public enum ReviewStatus
{
Pending = 0,
Approved = 1,
Rejected = 2
}
@@ -0,0 +1,12 @@
namespace OnlineShop.ApiService.Model;
public sealed class ProductSpecCsvRow
{
public int ProductId { get; set; }
public string Category { get; set; } = default!;
public int WarrantyMonths { get; set; }
public bool ReviewsEnabled { get; set; }
public bool Featured { get; set; }
public int MaxReviewsPerUser { get; set; }
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OnlineShop.ServiceDefaults\OnlineShop.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Azure.Data.Tables" Version="13.1.0" />
<PackageReference Include="Aspire.Azure.Storage.Blobs" Version="13.1.0" />
<PackageReference Include="Aspire.Azure.Storage.Queues" Version="13.1.0" />
<PackageReference Include="Aspire.Microsoft.EntityFrameworkCore.SqlServer" Version="13.1.0" />
<PackageReference Include="Aspire.MongoDB.Driver" Version="13.1.0" />
<PackageReference Include="Aspire.StackExchange.Redis.DistributedCaching" Version="13.1.0" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
@ApiService_HostAddress = http://localhost:5579
GET {{ApiService_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using OnlineShop.ApiService.Model;
namespace OnlineShop.ApiService;
public class ProductsDbContext : DbContext
{
public ProductsDbContext(
DbContextOptions<ProductsDbContext> options) : base(options)
{
}
public DbSet<Product> Products { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<OrderItem>(entity =>
{
entity.HasKey(x => new { x.OrderId, x.ProductId });
entity.HasOne(x => x.Order)
.WithMany(o => o.Items)
.HasForeignKey(x => x.OrderId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Product)
.WithMany(p => p.OrderItems)
.HasForeignKey(x => x.ProductId)
.OnDelete(DeleteBehavior.Restrict);
});
}
}
@@ -0,0 +1,422 @@
using Azure.Data.Tables;
using Azure.Storage.Blobs;
using Azure.Storage.Queues;
using CsvHelper;
using CsvHelper.Configuration;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using MongoDB.Driver;
using OnlineShop.ApiService;
using OnlineShop.ApiService.Model;
using OnlineShop.ServiceDefaults.Dtos;
using StackExchange.Redis;
using System.Data;
using System.Globalization;
using System.Text;
using System.Text.Json;
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.AddSqlServerDbContext<ProductsDbContext>("sqldb");
builder.AddMongoDBClient("mongodb");
builder.AddAzureTableServiceClient("tables");
builder.AddAzureBlobServiceClient("blobs");
builder.AddAzureQueueServiceClient("queues");
builder.AddRedisDistributedCache(connectionName: "cache");
builder.Services.AddSingleton<LocationUpdater>();
builder.Services.AddHostedService(
sp => sp
.GetRequiredService<LocationUpdater>());
builder.Services.AddSingleton<DataSeederService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<DataSeederService>());
builder.Services.AddAuthorization();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
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), // 35
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.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", () => "API service is running.");
app.MapGet("/products",
async ([FromServices] ProductsDbContext db,
[FromServices] IDistributedCache cache,
CancellationToken ct) =>
{
const string cacheKey = "Products";
var cached = await cache.GetStringAsync(cacheKey, ct);
if (cached is not null)
{
var cachedProducts =
JsonSerializer.Deserialize<ProductDto[]>(cached);
return Results.Ok(cachedProducts ?? Array.Empty<ProductDto>());
}
var products = await db.Products
.AsNoTracking()
.Select(p => new ProductDto(
Id: p.Id,
Title: p.Title,
Summary: p.Summary,
Price: p.Price
))
.ToArrayAsync(ct);
var serializedProducts = JsonSerializer.Serialize(products);
await cache.SetStringAsync(
cacheKey,
serializedProducts,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
},
ct);
return Results.Ok(products);
});
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] ProductsDbContext dbContext,
[FromServices] QueueServiceClient queueServiceClient,
[FromServices] IConnectionMultiplexer redis,
CancellationToken ct) =>
{
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.");
IDatabase redisDb = redis.GetDatabase();
List<string> lockKeys = [];
try
{
foreach (var productId in items.Keys)
{
string lockKey = $"product_lock_{productId}";
bool lockAcquired = await redisDb.LockTakeAsync(
lockKey,
Environment.MachineName,
TimeSpan.FromSeconds(10));
if (!lockAcquired)
return Results.StatusCode(423);
lockKeys.Add(lockKey);
}
int orderId;
decimal totalAmount;
await using var tx = await dbContext.Database
.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
try
{
var productIds = items.Keys.ToArray();
var priceLookup = await dbContext.Products
.AsNoTracking()
.Where(p => productIds.Contains(p.Id))
.Select(p => new { p.Id, p.Price })
.ToDictionaryAsync(x => x.Id, x => x.Price, ct);
var missing = productIds.Where(id => !priceLookup.ContainsKey(id)).ToArray();
if (missing.Length > 0)
return Results.BadRequest($"Unknown product ids: {string.Join(", ", missing)}");
totalAmount = items.Sum(i => priceLookup[i.Key] * i.Value);
var order = new OnlineShop.ApiService.Model.Order
{
TotalAmount = (double)totalAmount,
Items = items.Select(i => new OrderItem
{
ProductId = i.Key,
Quantity = i.Value
}).ToList()
};
dbContext.Orders.Add(order);
await dbContext.SaveChangesAsync(ct);
orderId = order.Id;
await tx.CommitAsync(ct);
}
catch (Exception ex)
{
await tx.RollbackAsync(ct);
return Results.Problem($"Order creation failed: {ex.Message}");
}
var queueClient = queueServiceClient.GetQueueClient("orders-created");
await queueClient.CreateIfNotExistsAsync(cancellationToken: ct);
var payload = JsonSerializer.Serialize(new { orderId });
await queueClient.SendMessageAsync(payload, cancellationToken: ct);
return Results.Created($"/api/orders/{orderId}", new
{
OrderId = orderId,
TotalAmount = totalAmount
});
}
finally
{
foreach (var lockKey in lockKeys)
{
await redisDb.LockReleaseAsync(lockKey, Environment.MachineName);
}
}
});
app.MapDefaultEndpoints();
app.MapHub<LocationHub>("/locationHub");
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5579",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7465;http://localhost:5579",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,5 @@
namespace OnlineShop.ApiService;
public class RedisResourceBuilderExtensions
{
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1 @@
.azure
@@ -0,0 +1,109 @@
using Microsoft.Extensions.Hosting;
using OnlineShop.AppHost;
using OnlineShop.AppHost.Extensions;
using OnlineShop.MailDev.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
var maildev = builder.AddMailDev("maildev");
var idp = builder.AddKeycloakContainer(
"idp", tag: "23.0")
.ImportRealms("Keycloak")
.WithExternalHttpEndpoints();
var cache = builder.AddRedis("cache").WithRedisCommander().WithClearCacheCommand();
var mongo = builder.AddMongoDB("mongo").WithLifetime(ContainerLifetime.Persistent);
var mongodb = mongo.AddDatabase("mongodb");
var storage = builder.AddAzureStorage("storage")
.RunAsEmulator();
var tables = storage
.AddTables("tables");
var blobs = storage
.AddBlobs("blobs");
var queues = storage
.AddQueues("queues");
var ollama = builder.AddOllama("ollama")
.WithLifetime(ContainerLifetime.Persistent)
.WithDataVolume()
.WithOpenWebUI();
var phi35 = ollama.AddModel("phi35", "phi3.5");
var apiService = builder.AddProject<Projects.OnlineShop_ApiService>("apiservice")
.WithHttpHealthCheck("/health")
.WithReplicas(3)
.WithReference(cache)
.WaitFor(cache)
.WithReference(queues)
.WaitFor(queues)
.WithReference(blobs)
.WaitFor(blobs)
.WithReference(tables)
.WaitFor(tables)
.WithReference(mongodb)
.WaitFor(mongodb)
.WithReference(idp)
.WaitFor(idp);
if (builder.ExecutionContext.IsRunMode)
{
var sql = builder.AddSqlServer("sql").WithLifetime(ContainerLifetime.Persistent);
var sqldb = sql.AddDatabase("sqldb");
apiService
.WaitFor(sqldb)
.WithReference(sqldb);
}
else
{
var sql = builder.AddAzureSqlServer("sql");
var sqldb = sql.AddDatabase("sqldb");
apiService
.WaitFor(sqldb)
.WithReference(sqldb);
}
var webFrontend = builder
.AddProject<Projects.OnlineShop_Web>("webfrontend")
.WithExternalHttpEndpoints()
.WithHttpHealthCheck("/health")
.WithReference(phi35)
.WithReference(apiService)
.WaitFor(apiService)
.WithReference(idp, env: "Identity__ClientSecret")
.WaitFor(idp)
.WithReference(cache)
.WaitFor(cache);
if (builder.ExecutionContext.IsRunMode)
{
var webAppHttp = webFrontend.GetEndpoint("http");
var webAppHttps = webFrontend.GetEndpoint("https");
idp.WithEnvironment("WEBAPP_HTTP", () =>
$"{webAppHttp.Scheme}://{webAppHttp.Host}:{webAppHttp.Port}");
if (webAppHttps.Exists)
{
idp.WithEnvironment("WEBAPP_HTTP_CONTAINERHOST",
webAppHttps);
idp.WithEnvironment("WEBAPP_HTTPS", () =>
$"{webAppHttps.Scheme}://{webAppHttps.Host}:{webAppHttps.Port}");
}
else
{
idp.WithEnvironment("WEBAPP_HTTP_CONTAINERHOST",
webAppHttp);
}
}
builder.Build().Run();
@@ -0,0 +1,86 @@
namespace OnlineShop.AppHost.Extensions;
internal static class KeycloakHostingExtensions
{
public static IResourceBuilder<TResource> WithReference<TResource>(
this IResourceBuilder<TResource> builder,
IResourceBuilder<KeycloakResource> keycloakBuilder,
string env) where TResource : IResourceWithEnvironment
{
builder.WithReference(keycloakBuilder);
builder.WithEnvironment(
env, keycloakBuilder.Resource.ClientSecret);
return builder;
}
public static IResourceBuilder<KeycloakResource> AddKeycloakContainer(
this IDistributedApplicationBuilder builder,
string name,
int? port = null,
string? tag = null)
{
var keycloakContainer = new KeycloakResource(name)
{
ClientSecret = "some_secret"
};
var keycloak = builder.AddResource(keycloakContainer)
.WithAnnotation(new ContainerImageAnnotation
{
Registry = "quay.io",
Image = "keycloak/keycloak",
Tag = tag ?? "latest"
})
.WithHttpEndpoint(port: port, targetPort: 8080)
.WithEnvironment("KEYCLOAK_ADMIN", "admin")
.WithEnvironment("KEYCLOAK_ADMIN_PASSWORD", "admin")
.WithEnvironment("WEBAPP_CLIENT_SECRET", keycloakContainer.ClientSecret);
if (builder.ExecutionContext.IsRunMode)
{
keycloak.WithArgs("start-dev");
}
else
{
keycloak.WithArgs("start");
}
return keycloak;
}
public static IResourceBuilder<KeycloakResource>
ImportRealms(this IResourceBuilder<KeycloakResource>
builder, string source)
{
builder
.WithBindMount(source,
"/opt/keycloak/data/import")
.WithAnnotation(
new CommandLineArgsCallbackAnnotation(
args =>
{
args.Clear();
if (builder.ApplicationBuilder
.ExecutionContext.IsRunMode)
{
args.Add("start-dev");
}
else
{
args.Add("start");
}
args.Add("--import-realm");
}));
return builder;
}
}
internal class KeycloakResource(string name) :
ContainerResource(name),
IResourceWithServiceDiscovery
{
public string? ClientSecret { get; set; }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
<Project Sdk="Aspire.AppHost.Sdk/13.0.1">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>2f2ef062-99af-422f-b6a5-1094759553e7</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Azure.Sql" Version="13.1.0" />
<PackageReference Include="Aspire.Hosting.Azure.Storage" Version="13.1.0" />
<PackageReference Include="Aspire.Hosting.MongoDB" Version="13.1.0" />
<PackageReference Include="Aspire.Hosting.Redis" Version="13.0.1" />
<PackageReference Include="Aspire.Hosting.SqlServer" Version="13.1.0" />
<PackageReference Include="CommunityToolkit.Aspire.Hosting.Ollama" Version="13.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OnlineShop.ApiService\OnlineShop.ApiService.csproj" />
<ProjectReference Include="..\OnlineShop.Web\OnlineShop.Web.csproj" />
<ProjectReference Include="..\OnlineShop.MailDev.Hosting\OnlineShop.MailDev.Hosting.csproj" IsAspireProjectResource="false" />
</ItemGroup>
<ItemGroup>
<None Update="Keycloak\import.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,31 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17233;http://localhost:15066",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21152",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23107",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22167"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15066",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19114",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18044",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20232"
}
}
}
}
@@ -0,0 +1,47 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using StackExchange.Redis;
namespace OnlineShop.AppHost;
public static class RedisResourceBuilderExtensions
{
public static IResourceBuilder<RedisResource> WithClearCacheCommand(
this IResourceBuilder<RedisResource> builder)
{
var options = new CommandOptions
{
IconName = "Broom",
IconVariant = IconVariant.Filled,
UpdateState = ctx =>
ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy
? ResourceCommandState.Enabled
: ResourceCommandState.Disabled
};
builder.WithCommand(
name: "clear-cache",
displayName: "Clear cache",
executeCommand: _ => RunAsync(builder),
commandOptions: options);
return builder;
}
private static async Task<ExecuteCommandResult> RunAsync(
IResourceBuilder<RedisResource> builder)
{
var connStr = await builder.Resource.GetConnectionStringAsync()
?? throw new InvalidOperationException(
"Could not resolve Redis connection string.");
await using var mux = await ConnectionMultiplexer.ConnectAsync(connStr);
var db = mux.GetDatabase();
// Clear everything across all DBs. Prefer FLUSHDB if you only want the current DB.
await db.ExecuteAsync("FLUSHALL");
return CommandResults.Success();
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
@@ -0,0 +1,24 @@
{
"projectPath": ".",
"outputPath": "aspirate-output",
"containerImageTags": [
"latest"
],
"containerBuilder": "docker",
"outputFormat": "kustomize",
"privateRegistryEmail": "aspir8@aka.ms",
"includeDashboard": true,
"secrets": {
"salt": "ghSmQrXQCnqlsFka",
"hash": "NKO3okzJ/ACklIpZ2XlQRSVmEX\u002BUC0NXhdzTV6q5taQ=",
"secrets": {
"cache-password": {
"value": "ghSmQrXQCnqlsFkaXXPbeupBZdXjkEEysTW\u002BvqL8ldolTn0Ocd5qrqAbXH3BR0Gu9iU="
},
"mongo-password": {
"value": "ghSmQrXQCnqlsFkaDGo/r1nuvCiYeFpjL7ueGeLEuYRxe344WeFWlr09K0/8fFSd6i0="
}
}
},
"processAllComponents": true
}
@@ -0,0 +1,11 @@
{
"TemplatePath": null,
"ContainerSettings": {
"Registry": null,
"RepositoryPrefix": null,
"Tags": [
"latest"
],
"Builder": "docker"
}
}
@@ -0,0 +1,8 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
name: onlineshop-apphost
services:
app:
language: dotnet
project: ./OnlineShop.AppHost.csproj
host: containerapp
@@ -0,0 +1,117 @@
api-version: 2024-02-02-preview
location: {{ .Env.AZURE_LOCATION }}
identity:
type: UserAssigned
userAssignedIdentities:
? "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}"
: {}
properties:
environmentId: {{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }}
configuration:
activeRevisionsMode: single
runtime:
dotnet:
autoConfigureDataProtection: true
ingress:
external: false
targetPort: {{ targetPortOrDefault 8080 }}
transport: http
allowInsecure: true
registries:
- server: {{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }}
identity: {{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}
secrets:
- name: cache-password
value: '{{ securedParameter "cache_password" }}'
- name: connectionstrings--blobs
value: '{{ .Env.STORAGE_BLOBENDPOINT }}'
- name: connectionstrings--cache
value: cache:6379,password={{ securedParameter "cache_password" }}
- name: connectionstrings--mongodb
value: mongodb://admin:{{ uriEncode (securedParameter "mongo_password" )}}@mongo:27017/mongodb?authSource=admin&authMechanism=SCRAM-SHA-256
- name: connectionstrings--queues
value: '{{ .Env.STORAGE_QUEUEENDPOINT }}'
- name: connectionstrings--sqldb
value: Server=tcp:{{ .Env.SQL_SQLSERVERFQDN }},1433;Encrypt=True;Authentication="Active Directory Default";Database=sqldb
- name: connectionstrings--tables
value: '{{ .Env.STORAGE_TABLEENDPOINT }}'
- name: mongodb-password
value: '{{ securedParameter "mongo_password" }}'
template:
containers:
- image: {{ .Image }}
name: apiservice
env:
- name: AZURE_CLIENT_ID
value: {{ .Env.MANAGED_IDENTITY_CLIENT_ID }}
- name: ASPNETCORE_FORWARDEDHEADERS_ENABLED
value: "true"
- name: BLOBS_URI
value: '{{ .Env.STORAGE_BLOBENDPOINT }}'
- name: CACHE_HOST
value: cache
- name: CACHE_PORT
value: "6379"
- name: CACHE_URI
value: redis://:{{ uriEncode (securedParameter "cache_password" )}}@cache:6379
- name: HTTP_PORTS
value: '{{ targetPortOrDefault 0 }}'
- name: IDP_HTTP
value: http://idp.{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN }}
- name: MONGODB_AUTHENTICATIONDATABASE
value: admin
- name: MONGODB_AUTHENTICATIONMECHANISM
value: SCRAM-SHA-256
- name: MONGODB_DATABASENAME
value: mongodb
- name: MONGODB_HOST
value: mongo
- name: MONGODB_PORT
value: "27017"
- name: MONGODB_URI
value: mongodb://admin:{{ uriEncode (securedParameter "mongo_password" )}}@mongo:27017/mongodb?authSource=admin&authMechanism=SCRAM-SHA-256
- name: MONGODB_USERNAME
value: admin
- name: OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES
value: "true"
- name: OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES
value: "true"
- name: OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY
value: in_memory
- name: QUEUES_URI
value: '{{ .Env.STORAGE_QUEUEENDPOINT }}'
- name: SQLDB_DATABASENAME
value: sqldb
- name: SQLDB_HOST
value: '{{ .Env.SQL_SQLSERVERFQDN }}'
- name: SQLDB_JDBCCONNECTIONSTRING
value: jdbc:sqlserver://{{ .Env.SQL_SQLSERVERFQDN }}:1433;database=sqldb;encrypt=true;trustServerCertificate=false
- name: SQLDB_PORT
value: "1433"
- name: SQLDB_URI
value: mssql://{{ .Env.SQL_SQLSERVERFQDN }}:1433/sqldb
- name: TABLES_URI
value: '{{ .Env.STORAGE_TABLEENDPOINT }}'
- name: services__idp__http__0
value: http://idp.{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN }}
- name: CACHE_PASSWORD
secretRef: cache-password
- name: ConnectionStrings__blobs
secretRef: connectionstrings--blobs
- name: ConnectionStrings__cache
secretRef: connectionstrings--cache
- name: ConnectionStrings__mongodb
secretRef: connectionstrings--mongodb
- name: ConnectionStrings__queues
secretRef: connectionstrings--queues
- name: ConnectionStrings__sqldb
secretRef: connectionstrings--sqldb
- name: ConnectionStrings__tables
secretRef: connectionstrings--tables
- name: MONGODB_PASSWORD
secretRef: mongodb-password
scale:
minReplicas: 1
tags:
azd-service-name: apiservice
aspire-resource-name: apiservice
@@ -0,0 +1,7 @@
operations:
- type: FileShareUpload
description: Upload files for idp
config:
storageAccount: ${AZURE_VOLUMES_STORAGE_ACCOUNT}
fileShareName: ${SERVICE_IDP_FILE_SHARE_BM0_NAME}
path: C:\Repos\aspire-13-examples\AppWithInfrastructure\OnlineShop\OnlineShop.AppHost\Keycloak
@@ -0,0 +1,43 @@
api-version: 2024-02-02-preview
location: {{ .Env.AZURE_LOCATION }}
identity:
type: UserAssigned
userAssignedIdentities:
? "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}"
: {}
properties:
environmentId: {{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }}
configuration:
activeRevisionsMode: single
runtime:
dotnet:
autoConfigureDataProtection: true
ingress:
external: false
targetPort: 6379
transport: tcp
allowInsecure: false
registries:
- server: {{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }}
identity: {{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}
secrets:
- name: redis-password
value: '{{ securedParameter "cache_password" }}'
template:
containers:
- image: {{ .Image }}
name: cache
args:
- -c
- redis-server --requirepass $REDIS_PASSWORD
command: [/bin/sh]
env:
- name: AZURE_CLIENT_ID
value: {{ .Env.MANAGED_IDENTITY_CLIENT_ID }}
- name: REDIS_PASSWORD
secretRef: redis-password
scale:
minReplicas: 1
tags:
azd-service-name: cache
aspire-resource-name: cache
@@ -0,0 +1,50 @@
api-version: 2024-02-02-preview
location: {{ .Env.AZURE_LOCATION }}
identity:
type: UserAssigned
userAssignedIdentities:
? "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}"
: {}
properties:
environmentId: {{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }}
configuration:
activeRevisionsMode: single
runtime:
dotnet:
autoConfigureDataProtection: true
ingress:
external: true
targetPort: 8080
transport: http
allowInsecure: false
registries:
- server: {{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }}
identity: {{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}
template:
volumes:
- name: idp-bm0
storageType: AzureFile
storageName: {{ .Env.SERVICE_IDP_VOLUME_BM0_NAME }}
containers:
- image: {{ .Image }}
name: idp
args:
- start
- --import-realm
env:
- name: AZURE_CLIENT_ID
value: {{ .Env.MANAGED_IDENTITY_CLIENT_ID }}
- name: KEYCLOAK_ADMIN
value: admin
- name: KEYCLOAK_ADMIN_PASSWORD
value: admin
- name: WEBAPP_CLIENT_SECRET
value: some_secret
volumeMounts:
- volumeName: idp-bm0
mountPath: /opt/keycloak/data/import
scale:
minReplicas: 1
tags:
azd-service-name: idp
aspire-resource-name: idp
@@ -0,0 +1,37 @@
api-version: 2024-02-02-preview
location: {{ .Env.AZURE_LOCATION }}
identity:
type: UserAssigned
userAssignedIdentities:
? "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}"
: {}
properties:
environmentId: {{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }}
configuration:
activeRevisionsMode: single
runtime:
dotnet:
autoConfigureDataProtection: true
ingress:
additionalPortMappings:
- targetPort: 1025
external: false
external: false
targetPort: 1080
transport: http
allowInsecure: true
registries:
- server: {{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }}
identity: {{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}
template:
containers:
- image: {{ .Image }}
name: maildev
env:
- name: AZURE_CLIENT_ID
value: {{ .Env.MANAGED_IDENTITY_CLIENT_ID }}
scale:
minReplicas: 1
tags:
azd-service-name: maildev
aspire-resource-name: maildev
@@ -0,0 +1,102 @@
targetScope = 'subscription'
@minLength(1)
@maxLength(64)
@description('Name of the environment that can be used as part of naming resource convention, the name of the resource group for your application will use this name, prefixed with rg-')
param environmentName string
@minLength(1)
@description('The location used for all deployed resources')
param location string
@description('Id of the user or app to assign application roles')
param principalId string = ''
@metadata({azd: {
type: 'generate'
config: {length:22,noSpecial:true}
}
})
@secure()
param cache_password string
@metadata({azd: {
type: 'generate'
config: {length:22,noSpecial:true}
}
})
@secure()
param mongo_password string
var tags = {
'azd-env-name': environmentName
}
resource rg 'Microsoft.Resources/resourceGroups@2022-09-01' = {
name: 'rg-${environmentName}'
location: location
tags: tags
}
module resources 'resources.bicep' = {
scope: rg
name: 'resources'
params: {
location: location
tags: tags
principalId: principalId
}
}
module sql 'sql/sql.module.bicep' = {
name: 'sql'
scope: rg
params: {
location: location
}
}
module sql_roles 'sql-roles/sql-roles.module.bicep' = {
name: 'sql-roles'
scope: rg
params: {
location: location
principalId: resources.outputs.MANAGED_IDENTITY_PRINCIPAL_ID
principalName: resources.outputs.MANAGED_IDENTITY_NAME
principalType: 'ServicePrincipal'
sql_outputs_name: sql.outputs.name
sql_outputs_sqlserveradminname: sql.outputs.sqlServerAdminName
}
}
module storage 'storage/storage.module.bicep' = {
name: 'storage'
scope: rg
params: {
location: location
}
}
module storage_roles 'storage-roles/storage-roles.module.bicep' = {
name: 'storage-roles'
scope: rg
params: {
location: location
principalId: resources.outputs.MANAGED_IDENTITY_PRINCIPAL_ID
principalType: 'ServicePrincipal'
storage_outputs_name: storage.outputs.name
}
}
output MANAGED_IDENTITY_CLIENT_ID string = resources.outputs.MANAGED_IDENTITY_CLIENT_ID
output MANAGED_IDENTITY_NAME string = resources.outputs.MANAGED_IDENTITY_NAME
output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = resources.outputs.AZURE_LOG_ANALYTICS_WORKSPACE_NAME
output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT
output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = resources.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID
output AZURE_CONTAINER_REGISTRY_NAME string = resources.outputs.AZURE_CONTAINER_REGISTRY_NAME
output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_NAME
output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID
output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN
output SERVICE_IDP_VOLUME_BM0_NAME string = resources.outputs.SERVICE_IDP_VOLUME_BM0_NAME
output SERVICE_IDP_FILE_SHARE_BM0_NAME string = resources.outputs.SERVICE_IDP_FILE_SHARE_BM0_NAME
output SERVICE_OLLAMA_VOLUME_ONLINESHOPAPPHOSTA2D2D5FE79OLLAMAOLLAMA_NAME string = resources.outputs.SERVICE_OLLAMA_VOLUME_ONLINESHOPAPPHOSTA2D2D5FE79OLLAMAOLLAMA_NAME
output AZURE_VOLUMES_STORAGE_ACCOUNT string = resources.outputs.AZURE_VOLUMES_STORAGE_ACCOUNT
output SQL_SQLSERVERFQDN string = sql.outputs.sqlServerFqdn
output STORAGE_BLOBENDPOINT string = storage.outputs.blobEndpoint
output STORAGE_QUEUEENDPOINT string = storage.outputs.queueEndpoint
output STORAGE_TABLEENDPOINT string = storage.outputs.tableEndpoint
@@ -0,0 +1,22 @@
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"principalId": {
"value": "${AZURE_PRINCIPAL_ID}"
},
"cache_password": {
"value": "${AZURE_CACHE_PASSWORD}"
},
"mongo_password": {
"value": "${AZURE_MONGO_PASSWORD}"
},
"environmentName": {
"value": "${AZURE_ENV_NAME}"
},
"location": {
"value": "${AZURE_LOCATION}"
}
}
}
@@ -0,0 +1,41 @@
api-version: 2024-02-02-preview
location: {{ .Env.AZURE_LOCATION }}
identity:
type: UserAssigned
userAssignedIdentities:
? "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}"
: {}
properties:
environmentId: {{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }}
configuration:
activeRevisionsMode: single
runtime:
dotnet:
autoConfigureDataProtection: true
ingress:
external: false
targetPort: 27017
transport: tcp
allowInsecure: false
registries:
- server: {{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }}
identity: {{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}
secrets:
- name: mongo-initdb-root-password
value: '{{ securedParameter "mongo_password" }}'
template:
containers:
- image: {{ .Image }}
name: mongo
env:
- name: AZURE_CLIENT_ID
value: {{ .Env.MANAGED_IDENTITY_CLIENT_ID }}
- name: MONGO_INITDB_ROOT_USERNAME
value: admin
- name: MONGO_INITDB_ROOT_PASSWORD
secretRef: mongo-initdb-root-password
scale:
minReplicas: 1
tags:
azd-service-name: mongo
aspire-resource-name: mongo
@@ -0,0 +1,41 @@
api-version: 2024-02-02-preview
location: {{ .Env.AZURE_LOCATION }}
identity:
type: UserAssigned
userAssignedIdentities:
? "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}"
: {}
properties:
environmentId: {{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }}
configuration:
activeRevisionsMode: single
runtime:
dotnet:
autoConfigureDataProtection: true
ingress:
external: false
targetPort: 11434
transport: http
allowInsecure: true
registries:
- server: {{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }}
identity: {{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}
template:
volumes:
- name: ollama-onlineshopapphosta2d2d5fe79ollamaollama
storageType: AzureFile
storageName: {{ .Env.SERVICE_OLLAMA_VOLUME_ONLINESHOPAPPHOSTA2D2D5FE79OLLAMAOLLAMA_NAME }}
containers:
- image: {{ .Image }}
name: ollama
env:
- name: AZURE_CLIENT_ID
value: {{ .Env.MANAGED_IDENTITY_CLIENT_ID }}
volumeMounts:
- volumeName: ollama-onlineshopapphosta2d2d5fe79ollamaollama
mountPath: /root/.ollama
scale:
minReplicas: 1
tags:
azd-service-name: ollama
aspire-resource-name: ollama
@@ -0,0 +1,157 @@
@description('The location used for all deployed resources')
param location string = resourceGroup().location
@description('Id of the user or app to assign application roles')
param principalId string = ''
@description('Tags that will be applied to all resources')
param tags object = {}
var resourceToken = uniqueString(resourceGroup().id)
resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
name: 'mi-${resourceToken}'
location: location
tags: tags
}
resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
name: replace('acr-${resourceToken}', '-', '')
location: location
sku: {
name: 'Basic'
}
tags: tags
}
resource caeMiRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(containerRegistry.id, managedIdentity.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d'))
scope: containerRegistry
properties: {
principalId: managedIdentity.properties.principalId
principalType: 'ServicePrincipal'
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')
}
}
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = {
name: 'law-${resourceToken}'
location: location
properties: {
sku: {
name: 'PerGB2018'
}
}
tags: tags
}
resource storageVolume 'Microsoft.Storage/storageAccounts@2022-05-01' = {
name: 'vol${resourceToken}'
location: location
kind: 'StorageV2'
sku: {
name: 'Standard_LRS'
}
properties: {
largeFileSharesState: 'Enabled'
}
}
resource storageVolumeFileService 'Microsoft.Storage/storageAccounts/fileServices@2022-05-01' = {
parent: storageVolume
name: 'default'
}
resource volumesAccountRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(storageVolume.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd'))
scope: storageVolume
properties: {
principalId: principalId
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd')
}
}
resource idpBm0FileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2022-05-01' = {
parent: storageVolumeFileService
name: take('${toLower('idp')}-${toLower('bm0')}', 60)
properties: {
shareQuota: 1024
enabledProtocols: 'SMB'
}
}
resource ollamaOnlineshopapphostA2d2d5fe79OllamaOllamaFileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2022-05-01' = {
parent: storageVolumeFileService
name: take('${toLower('ollama')}-${toLower('onlineshopapphosta2d2d5fe79ollamaollama')}', 60)
properties: {
shareQuota: 1024
enabledProtocols: 'SMB'
}
}
resource containerAppEnvironment 'Microsoft.App/managedEnvironments@2024-02-02-preview' = {
name: 'cae-${resourceToken}'
location: location
properties: {
workloadProfiles: [{
workloadProfileType: 'Consumption'
name: 'consumption'
}]
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: logAnalyticsWorkspace.properties.customerId
sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey
}
}
}
tags: tags
resource aspireDashboard 'dotNetComponents' = {
name: 'aspire-dashboard'
properties: {
componentType: 'AspireDashboard'
}
}
}
resource idpBm0Store 'Microsoft.App/managedEnvironments/storages@2023-05-01' = {
parent: containerAppEnvironment
name: take('${toLower('idp')}-${toLower('bm0')}', 32)
properties: {
azureFile: {
shareName: idpBm0FileShare.name
accountName: storageVolume.name
accountKey: storageVolume.listKeys().keys[0].value
accessMode: 'ReadWrite'
}
}
}
resource ollamaOnlineshopapphostA2d2d5fe79OllamaOllamaStore 'Microsoft.App/managedEnvironments/storages@2023-05-01' = {
parent: containerAppEnvironment
name: take('${toLower('ollama')}-${toLower('onlineshopapphosta2d2d5fe79ollamaollama')}', 32)
properties: {
azureFile: {
shareName: ollamaOnlineshopapphostA2d2d5fe79OllamaOllamaFileShare.name
accountName: storageVolume.name
accountKey: storageVolume.listKeys().keys[0].value
accessMode: 'ReadWrite'
}
}
}
output MANAGED_IDENTITY_CLIENT_ID string = managedIdentity.properties.clientId
output MANAGED_IDENTITY_NAME string = managedIdentity.name
output MANAGED_IDENTITY_PRINCIPAL_ID string = managedIdentity.properties.principalId
output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = logAnalyticsWorkspace.name
output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = logAnalyticsWorkspace.id
output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.properties.loginServer
output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = managedIdentity.id
output AZURE_CONTAINER_REGISTRY_NAME string = containerRegistry.name
output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = containerAppEnvironment.name
output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = containerAppEnvironment.id
output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = containerAppEnvironment.properties.defaultDomain
output SERVICE_IDP_VOLUME_BM0_NAME string = idpBm0Store.name
output SERVICE_IDP_FILE_SHARE_BM0_NAME string = idpBm0FileShare.name
output SERVICE_OLLAMA_VOLUME_ONLINESHOPAPPHOSTA2D2D5FE79OLLAMAOLLAMA_NAME string = ollamaOnlineshopapphostA2d2d5fe79OllamaOllamaStore.name
output AZURE_VOLUMES_STORAGE_ACCOUNT string = storageVolume.name
@@ -0,0 +1,63 @@
@description('The location for the resource(s) to be deployed.')
param location string = resourceGroup().location
param sql_outputs_name string
param sql_outputs_sqlserveradminname string
param principalId string
param principalName string
param principalType string
resource sql 'Microsoft.Sql/servers@2023-08-01' existing = {
name: sql_outputs_name
}
resource sqlServerAdmin 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = {
name: sql_outputs_sqlserveradminname
}
resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = {
name: principalName
}
resource script_sql_sqldb 'Microsoft.Resources/deploymentScripts@2023-08-01' = {
name: take('script-${uniqueString('sql', principalName, 'sqldb', resourceGroup().id)}', 24)
location: location
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${sqlServerAdmin.id}': { }
}
}
kind: 'AzurePowerShell'
properties: {
azPowerShellVersion: '14.0'
retentionInterval: 'PT1H'
environmentVariables: [
{
name: 'DBNAME'
value: 'sqldb'
}
{
name: 'DBSERVER'
value: sql.properties.fullyQualifiedDomainName
}
{
name: 'PRINCIPALTYPE'
value: principalType
}
{
name: 'PRINCIPALNAME'
value: principalName
}
{
name: 'ID'
value: mi.properties.clientId
}
]
scriptContent: '\$sqlServerFqdn = "\$env:DBSERVER"\r\n\$sqlDatabaseName = "\$env:DBNAME"\r\n\$principalName = "\$env:PRINCIPALNAME"\r\n\$id = "\$env:ID"\r\n\r\n# Install SqlServer module - using specific version to avoid breaking changes in 22.4.5.1 (see https://github.com/dotnet/aspire/issues/9926)\r\nInstall-Module -Name SqlServer -RequiredVersion 22.3.0 -Force -AllowClobber -Scope CurrentUser\r\nImport-Module SqlServer\r\n\r\n\$sqlCmd = @"\r\nDECLARE @name SYSNAME = \'\$principalName\';\r\nDECLARE @id UNIQUEIDENTIFIER = \'\$id\';\r\n\r\n-- Convert the guid to the right type\r\nDECLARE @castId NVARCHAR(MAX) = CONVERT(VARCHAR(MAX), CONVERT (VARBINARY(16), @id), 1);\r\n\r\n-- Construct command: CREATE USER [@name] WITH SID = @castId, TYPE = E;\r\nDECLARE @cmd NVARCHAR(MAX) = N\'CREATE USER [\' + @name + \'] WITH SID = \' + @castId + \', TYPE = E;\'\r\nEXEC (@cmd);\r\n\r\n-- Assign roles to the new user\r\nDECLARE @role1 NVARCHAR(MAX) = N\'ALTER ROLE db_owner ADD MEMBER [\' + @name + \']\';\r\nEXEC (@role1);\r\n\r\n"@\r\n# Note: the string terminator must not have whitespace before it, therefore it is not indented.\r\n\r\nWrite-Host \$sqlCmd\r\n\r\n\$connectionString = "Server=tcp:\${sqlServerFqdn},1433;Initial Catalog=\${sqlDatabaseName};Authentication=Active Directory Default;"\r\n\r\nInvoke-Sqlcmd -ConnectionString \$connectionString -Query \$sqlCmd'
}
}
@@ -0,0 +1,55 @@
@description('The location for the resource(s) to be deployed.')
param location string = resourceGroup().location
resource sqlServerAdminManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = {
name: take('sql-admin-${uniqueString(resourceGroup().id)}', 63)
location: location
}
resource sql 'Microsoft.Sql/servers@2023-08-01' = {
name: take('sql-${uniqueString(resourceGroup().id)}', 63)
location: location
properties: {
administrators: {
administratorType: 'ActiveDirectory'
login: sqlServerAdminManagedIdentity.name
sid: sqlServerAdminManagedIdentity.properties.principalId
tenantId: subscription().tenantId
azureADOnlyAuthentication: true
}
minimalTlsVersion: '1.2'
publicNetworkAccess: 'Enabled'
version: '12.0'
}
tags: {
'aspire-resource-name': 'sql'
}
}
resource sqlFirewallRule_AllowAllAzureIps 'Microsoft.Sql/servers/firewallRules@2023-08-01' = {
name: 'AllowAllAzureIps'
properties: {
endIpAddress: '0.0.0.0'
startIpAddress: '0.0.0.0'
}
parent: sql
}
resource sqldb 'Microsoft.Sql/servers/databases@2023-08-01' = {
name: 'sqldb'
location: location
properties: {
freeLimitExhaustionBehavior: 'AutoPause'
useFreeLimit: true
}
sku: {
name: 'GP_S_Gen5_2'
}
parent: sql
}
output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName
output name string = sql.name
output sqlServerAdminName string = sql.properties.administrators.login
@@ -0,0 +1,42 @@
@description('The location for the resource(s) to be deployed.')
param location string = resourceGroup().location
param storage_outputs_name string
param principalType string
param principalId string
resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = {
name: storage_outputs_name
}
resource storage_StorageBlobDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'))
properties: {
principalId: principalId
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')
principalType: principalType
}
scope: storage
}
resource storage_StorageTableDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3'))
properties: {
principalId: principalId
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3')
principalType: principalType
}
scope: storage
}
resource storage_StorageQueueDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88'))
properties: {
principalId: principalId
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88')
principalType: principalType
}
scope: storage
}
@@ -0,0 +1,30 @@
@description('The location for the resource(s) to be deployed.')
param location string = resourceGroup().location
resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {
name: take('storage${uniqueString(resourceGroup().id)}', 24)
kind: 'StorageV2'
location: location
sku: {
name: 'Standard_GRS'
}
properties: {
accessTier: 'Hot'
allowSharedKeyAccess: false
minimumTlsVersion: 'TLS1_2'
networkAcls: {
defaultAction: 'Allow'
}
}
tags: {
'aspire-resource-name': 'storage'
}
}
output blobEndpoint string = storage.properties.primaryEndpoints.blob
output queueEndpoint string = storage.properties.primaryEndpoints.queue
output tableEndpoint string = storage.properties.primaryEndpoints.table
output name string = storage.name
@@ -0,0 +1,85 @@
api-version: 2024-02-02-preview
location: {{ .Env.AZURE_LOCATION }}
identity:
type: UserAssigned
userAssignedIdentities:
? "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}"
: {}
properties:
environmentId: {{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }}
configuration:
activeRevisionsMode: single
runtime:
dotnet:
autoConfigureDataProtection: true
ingress:
external: true
targetPort: {{ targetPortOrDefault 8080 }}
transport: http
allowInsecure: false
registries:
- server: {{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }}
identity: {{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}
secrets:
- name: cache-password
value: '{{ securedParameter "cache_password" }}'
- name: connectionstrings--cache
value: cache:6379,password={{ securedParameter "cache_password" }}
- name: connectionstrings--phi35
value: Endpoint=http://ollama.internal.{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN }}:80;Model=phi3.5
template:
containers:
- image: {{ .Image }}
name: webfrontend
env:
- name: AZURE_CLIENT_ID
value: {{ .Env.MANAGED_IDENTITY_CLIENT_ID }}
- name: APISERVICE_HTTP
value: http://apiservice.internal.{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN }}
- name: APISERVICE_HTTPS
value: https://apiservice.internal.{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN }}
- name: ASPNETCORE_FORWARDEDHEADERS_ENABLED
value: "true"
- name: CACHE_HOST
value: cache
- name: CACHE_PORT
value: "6379"
- name: CACHE_URI
value: redis://:{{ uriEncode (securedParameter "cache_password" )}}@cache:6379
- name: HTTP_PORTS
value: '{{ targetPortOrDefault 0 }}'
- name: IDP_HTTP
value: http://idp.{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN }}
- name: Identity__ClientSecret
value: some_secret
- name: OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES
value: "true"
- name: OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES
value: "true"
- name: OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY
value: in_memory
- name: PHI35_HOST
value: ollama.internal.{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN }}
- name: PHI35_MODEL
value: phi3.5
- name: PHI35_PORT
value: "80"
- name: PHI35_URI
value: http://ollama.internal.{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN }}:80
- name: services__apiservice__http__0
value: http://apiservice.internal.{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN }}
- name: services__apiservice__https__0
value: https://apiservice.internal.{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN }}
- name: services__idp__http__0
value: http://idp.{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN }}
- name: CACHE_PASSWORD
secretRef: cache-password
- name: ConnectionStrings__cache
secretRef: connectionstrings--cache
- name: ConnectionStrings__phi35
secretRef: connectionstrings--phi35
scale:
minReplicas: 1
tags:
azd-service-name: webfrontend
aspire-resource-name: webfrontend
@@ -0,0 +1,299 @@
{
"$schema": "https://json.schemastore.org/aspire-8.0.json",
"resources": {
"maildev": {
"type": "container.v0",
"connectionString": "smtp://{maildev.bindings.smtp.host}:{maildev.bindings.smtp.port}",
"image": "docker.io/maildev/maildev:2.1.0",
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"targetPort": 1080
},
"smtp": {
"scheme": "tcp",
"protocol": "tcp",
"transport": "tcp",
"targetPort": 1025
}
}
},
"idp": {
"type": "container.v0",
"image": "quay.io/keycloak/keycloak:23.0",
"args": [
"start",
"--import-realm"
],
"bindMounts": [
{
"source": "Keycloak",
"target": "/opt/keycloak/data/import",
"readOnly": false
}
],
"env": {
"KEYCLOAK_ADMIN": "admin",
"KEYCLOAK_ADMIN_PASSWORD": "admin",
"WEBAPP_CLIENT_SECRET": "some_secret"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"targetPort": 8080,
"external": true
}
}
},
"cache": {
"type": "container.v0",
"connectionString": "{cache.bindings.tcp.host}:{cache.bindings.tcp.port},password={cache-password.value}",
"image": "docker.io/library/redis:8.2",
"entrypoint": "/bin/sh",
"args": [
"-c",
"redis-server --requirepass $REDIS_PASSWORD"
],
"env": {
"REDIS_PASSWORD": "{cache-password.value}"
},
"bindings": {
"tcp": {
"scheme": "tcp",
"protocol": "tcp",
"transport": "tcp",
"targetPort": 6379
}
}
},
"mongo": {
"type": "container.v0",
"connectionString": "mongodb://admin:{mongo-password-uri-encoded.value}@{mongo.bindings.tcp.host}:{mongo.bindings.tcp.port}?authSource=admin\u0026authMechanism=SCRAM-SHA-256",
"image": "docker.io/library/mongo:8.2",
"env": {
"MONGO_INITDB_ROOT_USERNAME": "admin",
"MONGO_INITDB_ROOT_PASSWORD": "{mongo-password.value}"
},
"bindings": {
"tcp": {
"scheme": "tcp",
"protocol": "tcp",
"transport": "tcp",
"targetPort": 27017
}
}
},
"mongodb": {
"type": "value.v0",
"connectionString": "mongodb://admin:{mongo-password-uri-encoded.value}@{mongo.bindings.tcp.host}:{mongo.bindings.tcp.port}/mongodb?authSource=admin\u0026authMechanism=SCRAM-SHA-256"
},
"storage": {
"type": "azure.bicep.v0",
"path": "storage.module.bicep"
},
"tables": {
"type": "value.v0",
"connectionString": "{storage.outputs.tableEndpoint}"
},
"blobs": {
"type": "value.v0",
"connectionString": "{storage.outputs.blobEndpoint}"
},
"queues": {
"type": "value.v0",
"connectionString": "{storage.outputs.queueEndpoint}"
},
"ollama": {
"type": "container.v0",
"connectionString": "Endpoint={ollama.bindings.http.scheme}://{ollama.bindings.http.host}:{ollama.bindings.http.port}",
"image": "docker.io/ollama/ollama:0.13.0",
"volumes": [
{
"name": "onlineshop.apphost-a2d2d5fe79-ollama-ollama",
"target": "/root/.ollama",
"readOnly": false
}
],
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"targetPort": 11434
}
}
},
"phi35": {
"type": "value.v0",
"connectionString": "{ollama.connectionString};Model=phi3.5"
},
"apiservice": {
"type": "project.v0",
"path": "../OnlineShop.ApiService/OnlineShop.ApiService.csproj",
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
"HTTP_PORTS": "{apiservice.bindings.http.targetPort}",
"ConnectionStrings__cache": "{cache.connectionString}",
"CACHE_HOST": "{cache.bindings.tcp.host}",
"CACHE_PORT": "{cache.bindings.tcp.port}",
"CACHE_PASSWORD": "{cache-password.value}",
"CACHE_URI": "redis://:{cache-password-uri-encoded.value}@{cache.bindings.tcp.host}:{cache.bindings.tcp.port}",
"ConnectionStrings__queues": "{queues.connectionString}",
"QUEUES_URI": "{storage.outputs.queueEndpoint}",
"ConnectionStrings__blobs": "{blobs.connectionString}",
"BLOBS_URI": "{storage.outputs.blobEndpoint}",
"ConnectionStrings__tables": "{tables.connectionString}",
"TABLES_URI": "{storage.outputs.tableEndpoint}",
"ConnectionStrings__mongodb": "{mongodb.connectionString}",
"MONGODB_HOST": "{mongo.bindings.tcp.host}",
"MONGODB_PORT": "{mongo.bindings.tcp.port}",
"MONGODB_USERNAME": "admin",
"MONGODB_PASSWORD": "{mongo-password.value}",
"MONGODB_AUTHENTICATIONDATABASE": "admin",
"MONGODB_AUTHENTICATIONMECHANISM": "SCRAM-SHA-256",
"MONGODB_URI": "mongodb://admin:{mongo-password-uri-encoded.value}@{mongo.bindings.tcp.host}:{mongo.bindings.tcp.port}/mongodb?authSource=admin\u0026authMechanism=SCRAM-SHA-256",
"MONGODB_DATABASENAME": "mongodb",
"IDP_HTTP": "{idp.bindings.http.url}",
"services__idp__http__0": "{idp.bindings.http.url}",
"ConnectionStrings__sqldb": "{sqldb.connectionString}",
"SQLDB_HOST": "{sql.outputs.sqlServerFqdn}",
"SQLDB_PORT": "1433",
"SQLDB_URI": "mssql://{sql.outputs.sqlServerFqdn}:1433/sqldb",
"SQLDB_JDBCCONNECTIONSTRING": "jdbc:sqlserver://{sql.outputs.sqlServerFqdn}:1433;database=sqldb;encrypt=true;trustServerCertificate=false",
"SQLDB_DATABASENAME": "sqldb"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http"
},
"https": {
"scheme": "https",
"protocol": "tcp",
"transport": "http"
}
}
},
"sql": {
"type": "azure.bicep.v0",
"connectionString": "Server=tcp:{sql.outputs.sqlServerFqdn},1433;Encrypt=True;Authentication=\u0022Active Directory Default\u0022",
"path": "sql.module.bicep"
},
"sqldb": {
"type": "value.v0",
"connectionString": "{sql.connectionString};Database=sqldb"
},
"webfrontend": {
"type": "project.v0",
"path": "../OnlineShop.Web/OnlineShop.Web.csproj",
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
"HTTP_PORTS": "{webfrontend.bindings.http.targetPort}",
"ConnectionStrings__phi35": "{phi35.connectionString}",
"PHI35_HOST": "{ollama.bindings.http.host}",
"PHI35_PORT": "{ollama.bindings.http.port}",
"PHI35_URI": "{ollama.bindings.http.scheme}://{ollama.bindings.http.host}:{ollama.bindings.http.port}",
"PHI35_MODEL": "phi3.5",
"APISERVICE_HTTP": "{apiservice.bindings.http.url}",
"services__apiservice__http__0": "{apiservice.bindings.http.url}",
"APISERVICE_HTTPS": "{apiservice.bindings.https.url}",
"services__apiservice__https__0": "{apiservice.bindings.https.url}",
"IDP_HTTP": "{idp.bindings.http.url}",
"services__idp__http__0": "{idp.bindings.http.url}",
"Identity__ClientSecret": "some_secret",
"ConnectionStrings__cache": "{cache.connectionString}",
"CACHE_HOST": "{cache.bindings.tcp.host}",
"CACHE_PORT": "{cache.bindings.tcp.port}",
"CACHE_PASSWORD": "{cache-password.value}",
"CACHE_URI": "redis://:{cache-password-uri-encoded.value}@{cache.bindings.tcp.host}:{cache.bindings.tcp.port}"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"external": true
},
"https": {
"scheme": "https",
"protocol": "tcp",
"transport": "http",
"external": true
}
}
},
"storage-roles": {
"type": "azure.bicep.v0",
"path": "storage-roles.module.bicep",
"params": {
"storage_outputs_name": "{storage.outputs.name}",
"principalType": "",
"principalId": ""
}
},
"sql-roles": {
"type": "azure.bicep.v0",
"path": "sql-roles.module.bicep",
"params": {
"sql_outputs_name": "{sql.outputs.name}",
"sql_outputs_sqlserveradminname": "{sql.outputs.sqlServerAdminName}",
"principalId": "",
"principalName": "",
"principalType": ""
}
},
"cache-password": {
"type": "parameter.v0",
"value": "{cache-password.inputs.value}",
"inputs": {
"value": {
"type": "string",
"secret": true,
"default": {
"generate": {
"minLength": 22,
"special": false
}
}
}
}
},
"cache-password-uri-encoded": {
"type": "annotated.string",
"value": "{cache-password.value}",
"filter": "uri"
},
"mongo-password": {
"type": "parameter.v0",
"value": "{mongo-password.inputs.value}",
"inputs": {
"value": {
"type": "string",
"secret": true,
"default": {
"generate": {
"minLength": 22,
"special": false
}
}
}
}
},
"mongo-password-uri-encoded": {
"type": "annotated.string",
"value": "{mongo-password.value}",
"filter": "uri"
}
}
}
@@ -0,0 +1,73 @@
# Next Steps after `azd init`
## Table of Contents
1. [Next Steps](#next-steps)
2. [What was added](#what-was-added)
3. [Billing](#billing)
4. [Troubleshooting](#troubleshooting)
## Next Steps
### Provision infrastructure and deploy application code
Run `azd up` to provision your infrastructure and deploy to Azure in one step (or run `azd provision` then `azd deploy` to accomplish the tasks separately). Visit the service endpoints listed to see your application up-and-running!
To troubleshoot any issues, see [troubleshooting](#troubleshooting).
### Configure CI/CD pipeline
Run `azd pipeline config -e <environment name>` to configure the deployment pipeline to connect securely to Azure. An environment name is specified here to configure the pipeline with a different environment for isolation purposes. Run `azd env list` and `azd env set` to reselect the default environment after this step.
- Deploying with `GitHub Actions`: Select `GitHub` when prompted for a provider. If your project lacks the `azure-dev.yml` file, accept the prompt to add it and proceed with pipeline configuration.
- Deploying with `Azure DevOps Pipeline`: Select `Azure DevOps` when prompted for a provider. If your project lacks the `azure-dev.yml` file, accept the prompt to add it and proceed with pipeline configuration.
## What was added
### Infrastructure configuration
To describe the infrastructure and application, an `azure.yaml` was added with the following directory structure:
```yaml
- azure.yaml # azd project configuration
```
This file contains a single service, which references your project's App Host. When needed, `azd` generates the required infrastructure as code in memory and uses it.
If you would like to see or modify the infrastructure that `azd` uses, run `azd infra gen` to generate it to disk.
If you do this, some additional directories will be created:
```yaml
- infra/ # Infrastructure as Code (bicep) files
- main.bicep # main deployment module
- resources.bicep # resources shared across your application's services
```
In addition, for each project resource referenced by your app host, a `containerApp.tmpl.yaml` file will be created in a directory named `manifests` next the project file. This file contains the infrastructure as code for running the project on Azure Container Apps.
*Note*: Once you have generated your infrastructure to disk, those files are the source of truth for azd. Any changes made to `azure.yaml` or your App Host will not be reflected in the infrastructure until you regenerate it with `azd infra gen` again. It will prompt you before overwriting files. You can pass `--force` to force `azd infra gen` to overwrite the files without prompting.
## Billing
Visit the *Cost Management + Billing* page in Azure Portal to track current spend. For more information about how you're billed, and how you can monitor the costs incurred in your Azure subscriptions, visit [billing overview](https://learn.microsoft.com/azure/developer/intro/azure-developer-billing).
## Troubleshooting
Q: I visited the service endpoint listed, and I'm seeing a blank page, a generic welcome page, or an error page.
A: Your service may have failed to start, or it may be missing some configuration settings. To investigate further:
1. Run `azd show`. Click on the link under "View in Azure Portal" to open the resource group in Azure Portal.
2. Navigate to the specific Container App service that is failing to deploy.
3. Click on the failing revision under "Revisions with Issues".
4. Review "Status details" for more information about the type of failure.
5. Observe the log outputs from Console log stream and System log stream to identify any errors.
6. If logs are written to disk, use *Console* in the navigation to connect to a shell within the running container.
For more troubleshooting information, visit [Container Apps troubleshooting](https://learn.microsoft.com/azure/container-apps/troubleshooting).
### Additional information
For additional information about setting up your `azd` project, visit our official [docs](https://learn.microsoft.com/azure/developer/azure-developer-cli/make-azd-compatible?pivots=azd-convert).
@@ -0,0 +1,63 @@
@description('The location for the resource(s) to be deployed.')
param location string = resourceGroup().location
param sql_outputs_name string
param sql_outputs_sqlserveradminname string
param principalId string
param principalName string
param principalType string
resource sql 'Microsoft.Sql/servers@2023-08-01' existing = {
name: sql_outputs_name
}
resource sqlServerAdmin 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = {
name: sql_outputs_sqlserveradminname
}
resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = {
name: principalName
}
resource script_sql_sqldb 'Microsoft.Resources/deploymentScripts@2023-08-01' = {
name: take('script-${uniqueString('sql', principalName, 'sqldb', resourceGroup().id)}', 24)
location: location
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${sqlServerAdmin.id}': { }
}
}
kind: 'AzurePowerShell'
properties: {
azPowerShellVersion: '14.0'
retentionInterval: 'PT1H'
environmentVariables: [
{
name: 'DBNAME'
value: 'sqldb'
}
{
name: 'DBSERVER'
value: sql.properties.fullyQualifiedDomainName
}
{
name: 'PRINCIPALTYPE'
value: principalType
}
{
name: 'PRINCIPALNAME'
value: principalName
}
{
name: 'ID'
value: mi.properties.clientId
}
]
scriptContent: '\$sqlServerFqdn = "\$env:DBSERVER"\r\n\$sqlDatabaseName = "\$env:DBNAME"\r\n\$principalName = "\$env:PRINCIPALNAME"\r\n\$id = "\$env:ID"\r\n\r\n# Install SqlServer module - using specific version to avoid breaking changes in 22.4.5.1 (see https://github.com/dotnet/aspire/issues/9926)\r\nInstall-Module -Name SqlServer -RequiredVersion 22.3.0 -Force -AllowClobber -Scope CurrentUser\r\nImport-Module SqlServer\r\n\r\n\$sqlCmd = @"\r\nDECLARE @name SYSNAME = \'\$principalName\';\r\nDECLARE @id UNIQUEIDENTIFIER = \'\$id\';\r\n\r\n-- Convert the guid to the right type\r\nDECLARE @castId NVARCHAR(MAX) = CONVERT(VARCHAR(MAX), CONVERT (VARBINARY(16), @id), 1);\r\n\r\n-- Construct command: CREATE USER [@name] WITH SID = @castId, TYPE = E;\r\nDECLARE @cmd NVARCHAR(MAX) = N\'CREATE USER [\' + @name + \'] WITH SID = \' + @castId + \', TYPE = E;\'\r\nEXEC (@cmd);\r\n\r\n-- Assign roles to the new user\r\nDECLARE @role1 NVARCHAR(MAX) = N\'ALTER ROLE db_owner ADD MEMBER [\' + @name + \']\';\r\nEXEC (@role1);\r\n\r\n"@\r\n# Note: the string terminator must not have whitespace before it, therefore it is not indented.\r\n\r\nWrite-Host \$sqlCmd\r\n\r\n\$connectionString = "Server=tcp:\${sqlServerFqdn},1433;Initial Catalog=\${sqlDatabaseName};Authentication=Active Directory Default;"\r\n\r\nInvoke-Sqlcmd -ConnectionString \$connectionString -Query \$sqlCmd'
}
}
@@ -0,0 +1,55 @@
@description('The location for the resource(s) to be deployed.')
param location string = resourceGroup().location
resource sqlServerAdminManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = {
name: take('sql-admin-${uniqueString(resourceGroup().id)}', 63)
location: location
}
resource sql 'Microsoft.Sql/servers@2023-08-01' = {
name: take('sql-${uniqueString(resourceGroup().id)}', 63)
location: location
properties: {
administrators: {
administratorType: 'ActiveDirectory'
login: sqlServerAdminManagedIdentity.name
sid: sqlServerAdminManagedIdentity.properties.principalId
tenantId: subscription().tenantId
azureADOnlyAuthentication: true
}
minimalTlsVersion: '1.2'
publicNetworkAccess: 'Enabled'
version: '12.0'
}
tags: {
'aspire-resource-name': 'sql'
}
}
resource sqlFirewallRule_AllowAllAzureIps 'Microsoft.Sql/servers/firewallRules@2023-08-01' = {
name: 'AllowAllAzureIps'
properties: {
endIpAddress: '0.0.0.0'
startIpAddress: '0.0.0.0'
}
parent: sql
}
resource sqldb 'Microsoft.Sql/servers/databases@2023-08-01' = {
name: 'sqldb'
location: location
properties: {
freeLimitExhaustionBehavior: 'AutoPause'
useFreeLimit: true
}
sku: {
name: 'GP_S_Gen5_2'
}
parent: sql
}
output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName
output name string = sql.name
output sqlServerAdminName string = sql.properties.administrators.login
@@ -0,0 +1,42 @@
@description('The location for the resource(s) to be deployed.')
param location string = resourceGroup().location
param storage_outputs_name string
param principalType string
param principalId string
resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = {
name: storage_outputs_name
}
resource storage_StorageBlobDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'))
properties: {
principalId: principalId
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')
principalType: principalType
}
scope: storage
}
resource storage_StorageTableDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3'))
properties: {
principalId: principalId
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3')
principalType: principalType
}
scope: storage
}
resource storage_StorageQueueDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88'))
properties: {
principalId: principalId
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88')
principalType: principalType
}
scope: storage
}
@@ -0,0 +1,30 @@
@description('The location for the resource(s) to be deployed.')
param location string = resourceGroup().location
resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {
name: take('storage${uniqueString(resourceGroup().id)}', 24)
kind: 'StorageV2'
location: location
sku: {
name: 'Standard_GRS'
}
properties: {
accessTier: 'Hot'
allowSharedKeyAccess: false
minimumTlsVersion: 'TLS1_2'
networkAcls: {
defaultAction: 'Allow'
}
}
tags: {
'aspire-resource-name': 'storage'
}
}
output blobEndpoint string = storage.properties.primaryEndpoints.blob
output queueEndpoint string = storage.properties.primaryEndpoints.queue
output tableEndpoint string = storage.properties.primaryEndpoints.table
output name string = storage.name
@@ -0,0 +1,10 @@
namespace OnlineShop.MailDev.Hosting;
internal static class MailDevContainerImageTags
{
internal const string Registry = "docker.io";
internal const string Image = "maildev/maildev";
internal const string Tag = "2.1.0";
}
@@ -0,0 +1,21 @@
using Aspire.Hosting.ApplicationModel;
namespace OnlineShop.MailDev.Hosting;
public sealed class MailDevResource(string name) :
ContainerResource(name), IResourceWithConnectionString
{
internal const string SmtpEndpointName = "smtp";
internal const string HttpEndpointName = "http";
private EndpointReference? _smtpReference;
public EndpointReference SmtpEndpoint =>
_smtpReference ??= new(this, SmtpEndpointName);
public ReferenceExpression ConnectionStringExpression =>
ReferenceExpression.Create(
$"smtp://{SmtpEndpoint.Property(EndpointProperty.Host)}:{SmtpEndpoint.Property(EndpointProperty.Port)}"
);
}
@@ -0,0 +1,31 @@
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
namespace OnlineShop.MailDev.Hosting;
public static class MailDevResourceBuilderExtensions
{
public static IResourceBuilder<MailDevResource> AddMailDev(
this IDistributedApplicationBuilder builder,
string name,
int? httpPort = null,
int? smtpPort = null)
{
MailDevResource resource = new(name);
return builder.AddResource(resource)
.WithImage(MailDevContainerImageTags.Image)
.WithImageRegistry(MailDevContainerImageTags.Registry)
.WithImageTag(MailDevContainerImageTags.Tag)
.WithHttpEndpoint(
targetPort: 1080,
port: httpPort,
name: MailDevResource.HttpEndpointName)
.WithEndpoint(
targetPort: 1025,
port: smtpPort,
name: MailDevResource.SmtpEndpointName);
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting" Version="13.0.1" />
</ItemGroup>
</Project>
@@ -0,0 +1,8 @@
namespace OnlineShop.ServiceDefaults.Dtos;
public record ProductDto(
int Id,
string Title,
string Summary,
decimal Price
);
@@ -0,0 +1,136 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.ServiceDiscovery;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace Microsoft.Extensions.Hosting;
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution.
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
public static class Extensions
{
private const string HealthEndpointPath = "/health";
private const string AlivenessEndpointPath = "/alive";
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery();
});
// Uncomment the following to restrict the allowed schemes for service discovery.
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
// {
// options.AllowedSchemes = ["https"];
// });
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(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 =>
// Exclude health check requests from tracing
tracing.Filter = context =>
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
)
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
//.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
//{
// builder.Services.AddOpenTelemetry()
// .UseAzureMonitor();
//}
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
// Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks(HealthEndpointPath);
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
return app;
}
public static Uri GetIdpAuthorityUri(this HttpClient httpClient)
{
var idpBaseUri = httpClient.BaseAddress
?? throw new InvalidOperationException(
$"HttpClient instance does not have a BaseAddress configured.");
return new Uri(idpBaseUri, "realms/OnlineShop/");
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,46 @@
using AngleSharp;
using AngleSharp.Html.Dom;
using AngleSharp.Io;
using System.Net.Http.Headers;
namespace OnlineShop.Tests.Helpers;
public class HtmlHelpers
{
public static async Task<IHtmlDocument>
GetDocumentAsync(HttpResponseMessage response)
{
var content = await response
.Content.ReadAsStringAsync();
var document = await BrowsingContext.New()
.OpenAsync(
ResponseFactory, CancellationToken.None);
return (IHtmlDocument)document;
void ResponseFactory(VirtualResponse htmlResponse)
{
htmlResponse
.Address(response.RequestMessage.RequestUri)
.Status(response.StatusCode);
MapHeaders(response.Headers);
MapHeaders(response.Content.Headers);
htmlResponse.Content(content);
void MapHeaders(HttpHeaders headers)
{
foreach (var header in headers)
{
foreach (var value in header.Value)
{
htmlResponse.Header(
header.Key, value);
}
}
}
}
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.4.1-beta.506" />
<PackageReference Include="Aspire.Hosting.Testing" Version="13.1.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OnlineShop.AppHost\OnlineShop.AppHost.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,54 @@
using Aspire.Hosting.Testing;
using OnlineShop.Tests.Helpers;
using System.Net;
namespace OnlineShop.Tests;
public class WebTests
{
[Fact]
public async Task GetWebResourceRootReturnsOkStatusCode()
{
// Arrange
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.OnlineShop_Web>();
await using var app = await appHost.BuildAsync();
await app.StartAsync();
// Act
var httpClient = app.CreateHttpClient("webfrontend");
var response = await httpClient.GetAsync("/");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetProductsReturnsRightContent()
{
// Arrange
var appHost = await
DistributedApplicationTestingBuilder
.CreateAsync<Projects.OnlineShop_Web>();
await using var app = await appHost.BuildAsync();
await app.StartAsync();
// Act
var httpClient = app.CreateHttpClient("webfrontend");
var response = await httpClient
.GetAsync("/");
var responseBody = await HtmlHelpers
.GetDocumentAsync(response);
var titleElement = responseBody
.QuerySelector("h1");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(titleElement);
Assert.Equal(
"Products",
titleElement.InnerHtml);
}
}
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
namespace OnlineShop.Web;
public static class AuthExtensions
{
public static void ConfigureWebAppOpenIdConnect(this AuthenticationBuilder authentication)
{
// Named options
authentication.Services
.AddOptions<OpenIdConnectOptions>(
OpenIdConnectDefaults.AuthenticationScheme)
.Configure<
IConfiguration,
IHttpClientFactory,
IHostEnvironment>(Configure);
// Unnamed options
authentication.Services.AddOptions<OpenIdConnectOptions>()
.Configure<
IConfiguration,
IHttpClientFactory,
IHostEnvironment>(Configure);
static void Configure(
OpenIdConnectOptions options,
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
IHostEnvironment hostEnvironment)
{
var backchannelHttpClient =
httpClientFactory.CreateClient(
"OidcBackchannel");
options.Backchannel = backchannelHttpClient;
options.Authority =
backchannelHttpClient
.GetIdpAuthorityUri().ToString();
options.ClientId = "webapp";
options.ClientSecret =
Environment
.GetEnvironmentVariable(
"Identity__ClientSecret");
options.ResponseType =
OpenIdConnectResponseType.Code;
options.SaveTokens = true;
options.RequireHttpsMetadata =
!hostEnvironment.IsDevelopment();
options.MapInboundClaims = false;
}
}
}
@@ -0,0 +1,30 @@
using Microsoft.SemanticKernel.ChatCompletion;
namespace OnlineShop.Web;
public class ChatHistoryService : IChatHistoryService
{
private readonly Dictionary<string, ChatHistory> _chatHistories = new();
public void AddUserMessage(string connectionId, string message)
{
if (!_chatHistories.TryGetValue(connectionId, out ChatHistory? value))
{
value = [];
_chatHistories[connectionId] = value;
}
value.AddUserMessage(message);
}
public ChatHistory GetChatHistory(string connectionId)
{
return _chatHistories[connectionId];
}
public void RemoveHistory(string connectionId)
{
_chatHistories.Remove(connectionId);
}
}
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using System.Runtime.CompilerServices;
namespace OnlineShop.Web;
public class ChatHub : Hub
{
private readonly Kernel _kernel;
private readonly IChatCompletionService _chat;
private readonly IChatHistoryService _chatHistoryService;
public ChatHub(
Kernel kernel,
IChatCompletionService chat,
IChatHistoryService chatHistoryService)
{
_kernel = kernel;
_chat = chat;
_chatHistoryService = chatHistoryService;
}
public async IAsyncEnumerable<string> StreamAnswer(
string prompt,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
_chatHistoryService.AddUserMessage(Context.ConnectionId, prompt);
var settings = new PromptExecutionSettings
{
};
await foreach (var delta in _chat.GetStreamingChatMessageContentsAsync(
_chatHistoryService.GetChatHistory(
Context.ConnectionId), settings, _kernel, cancellationToken))
{
if (!string.IsNullOrEmpty(delta.Content))
{
yield return delta.Content;
}
}
}
public override async Task OnConnectedAsync()
{
Console.WriteLine($"[ChatHub] Connected: {Context.ConnectionId}");
await base.OnConnectedAsync();
}
public override Task OnDisconnectedAsync(Exception? exception)
{
Console.WriteLine($"[ChatHub] Disconnected: {Context.ConnectionId} {exception?.Message}");
_chatHistoryService.RemoveHistory(Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
}
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["OnlineShop.Web.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" />
<script src="https://cdn.jsdelivr.net/npm/ol@v10.3.1/dist/ol.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@v10.3.1/ol.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/9.0.6/signalr.min.js" integrity="sha512-kkMt8UThSmWcdXLYFaGZ/U6vyWSNLZMUWQ5SMeF80pGqrEkH5ei9D/3MbVQpB8p7D5C3A4vlX7BpsWTT2BfB6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
</script>
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script src="js/site.js"></script>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>
@@ -0,0 +1,23 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
@@ -0,0 +1,96 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
@@ -0,0 +1,29 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">OnlineShop</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="tracking" Match="NavLinkMatch.All">
<span class="bi bi-list-nested" aria-hidden="true"></span> Track Your Order
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="chat" Match="NavLinkMatch.All">
<span class="bi bi-list-nested" aria-hidden="true"></span> Chat
</NavLink>
</div>
</nav>
</div>
@@ -0,0 +1,102 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}
@@ -0,0 +1,42 @@
@page "/chat"
@rendermode InteractiveServer
@inject IJSRuntime JS
<div class="container py-4">
<span id="status" class="badge text-bg-secondary">disconnected</span>
<form id="promptForm" class="mt-3">
<textarea id="prompt" class="form-control" rows="4" placeholder="Ask…"></textarea>
<div class="mt-2 d-flex gap-2">
<button id="sendBtn" type="submit" class="btn btn-primary">Send</button>
<button id="stopBtn" type="button" class="btn btn-outline-secondary" disabled>Stop</button>
<button id="clearBtn" type="button" class="btn btn-outline-danger ms-auto">Clear</button>
<button id="copyBtn" type="button" class="btn btn-outline-secondary">Copy</button>
</div>
</form>
<div class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Response</strong>
<span id="tokenCount" class="small text-muted">0 chars</span>
</div>
<div class="card-body">
<div id="output" style="white-space:pre-wrap; min-height:8rem;"></div>
</div>
</div>
</div>
@code {
private IJSObjectReference? _module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_module =
await JS.InvokeAsync<IJSObjectReference>("import", "./js/chat.js");
await _module.InvokeVoidAsync("init");
}
}
}
@@ -0,0 +1,38 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@requestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
public HttpContext? HttpContext { get; set; }
private string? requestId;
private bool ShowRequestId => !string.IsNullOrEmpty(requestId);
protected override void OnInitialized()
{
requestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
}
@@ -0,0 +1,218 @@
@page "/"
@using Microsoft.AspNetCore.Components.Authorization
@using OnlineShop.ServiceDefaults.Dtos
@attribute [StreamRendering(true)]
@attribute [OutputCache(Duration = 5)]
@inject ProductsApiClient ProductsApi
@inject NavigationManager Nav
<PageTitle>Products</PageTitle>
<h1>Products</h1>
@if (products == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Price (USD)</th>
<th>Summary</th>
<th style="width: 280px;">Basket</th>
</tr>
</thead>
<tbody>
@foreach (var product in products)
{
var qty = GetQuantity(product.Id);
<tr>
<td>@product.Title</td>
<td>@product.Price</td>
<td>@product.Summary</td>
<td>
<AuthorizeView>
<Authorized>
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<button class="btn btn-sm btn-primary"
@onclick="() => AddToBasket(product.Id)">
Add to Basket
</button>
<button class="btn btn-sm btn-outline-danger"
@onclick="() => RemoveFromBasket(product.Id)"
disabled="@(qty == 0)">
Remove From Basket
</button>
<span class="btn btn-sm btn-secondary disabled"
aria-disabled="true"
style="pointer-events:none;">
Quantity ordered: @qty
</span>
</div>
</Authorized>
<NotAuthorized>
<span class="text-muted">
Sign in to order
</span>
</NotAuthorized>
</AuthorizeView>
</td>
</tr>
}
</tbody>
</table>
<AuthorizeView>
<Authorized>
@if (TotalQuantity > 0)
{
<div class="mt-3 p-3 border rounded" style="display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<div>
<strong>Total price:</strong> @TotalPrice.ToString("0.00") USD
<span class="text-muted">(@TotalQuantity item@(TotalQuantity == 1 ? "" : "s"))</span>
</div>
<button class="btn btn-success"
@onclick="PlaceOrderAsync"
disabled="@isPlacingOrder">
@(isPlacingOrder ? "Placing..." : "Place order")
</button>
</div>
@if (!string.IsNullOrWhiteSpace(orderStatus))
{
<p class="mt-2">@orderStatus</p>
}
}
</Authorized>
</AuthorizeView>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="m-0">Products</h1>
<AuthorizeView>
<Authorized>
<form method="post"
action="@($"/logout?returnUrl={Uri.EscapeDataString(Nav.Uri)}")">
<button type="submit" class="btn btn-outline-secondary btn-sm">
Sign out
</button>
</form>
</Authorized>
<NotAuthorized>
<a class="btn btn-outline-primary btn-sm"
href="@($"/login?returnUrl={Uri.EscapeDataString(Nav.Uri)}")">
Sign in
</a>
</NotAuthorized>
</AuthorizeView>
</div>
}
@code {
private ProductDto[]? products;
private readonly Dictionary<int, int> basket = new();
private bool isPlacingOrder;
private string? orderStatus;
protected override async Task OnInitializedAsync()
{
products = await ProductsApi.GetProductsAsync();
}
private int GetQuantity(int productId)
=> basket.TryGetValue(productId, out var qty) ? qty : 0;
private void AddToBasket(int productId)
{
basket.TryGetValue(productId, out var qty);
basket[productId] = qty + 1;
orderStatus = null;
StateHasChanged();
}
private void RemoveFromBasket(int productId)
{
if (!basket.TryGetValue(productId, out var qty) || qty <= 0)
return;
qty--;
if (qty <= 0)
basket.Remove(productId);
else
basket[productId] = qty;
orderStatus = null;
StateHasChanged();
}
private int TotalQuantity => basket.Values.Sum();
private decimal TotalPrice
{
get
{
if (products == null || basket.Count == 0)
return 0m;
decimal total = 0m;
foreach (var (productId, qty) in basket)
{
var product = products.FirstOrDefault(p => p.Id == productId);
if (product is null) continue;
total += (decimal)product.Price * qty;
}
return total;
}
}
private async Task PlaceOrderAsync()
{
if (TotalQuantity <= 0)
return;
isPlacingOrder = true;
orderStatus = null;
try
{
var payload = new Dictionary<int, int>(basket);
var response = await ProductsApi.MakeOrder(payload);
if (response.IsSuccessStatusCode)
{
orderStatus = "Order placed successfully.";
basket.Clear();
}
else
{
orderStatus = $"Failed to place order. Status: {(int)response.StatusCode} {response.ReasonPhrase}";
}
}
catch (Exception ex)
{
orderStatus = $"Failed to place order: {ex.Message}";
}
finally
{
isPlacingOrder = false;
StateHasChanged();
}
}
}
@@ -0,0 +1,49 @@
@page "/tracking"
@using Microsoft.AspNetCore.SignalR.Client
@using OnlineShop.Web.Extensions
@inject IJSRuntime JSRuntime
@inject IHttpMessageHandlerFactory ClientFactory
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
<div id="map" style="width: 100%; height: 400px;"></div>
@code {
private HubConnection? hubConnection;
protected override async Task OnInitializedAsync()
{
hubConnection = new HubConnectionBuilder()
.WithUrl("https://apiservice/locationHub",
ClientFactory)
.Build();
}
protected override async Task
OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JSRuntime
.InvokeVoidAsync("initializeMap");
hubConnection?.On<double, double>(
"ReceiveLocationUpdate",
async (lat, lon) =>
{
await InvokeAsync(async () =>
{
await JSRuntime.InvokeVoidAsync(
"addMarker",
lat,
lon);
StateHasChanged();
});
});
await hubConnection?.StartAsync();
}
}
}
@@ -0,0 +1,9 @@
@using Microsoft.AspNetCore.Components.Authorization
<CascadingAuthenticationState>
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
</CascadingAuthenticationState>
@@ -0,0 +1,11 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.OutputCaching
@using Microsoft.JSInterop
@using OnlineShop.Web
@using OnlineShop.Web.Components
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.SignalR.Client;
namespace OnlineShop.Web.Extensions;
public static class HubConnectionExtensions
{
public static IHubConnectionBuilder WithUrl(
this IHubConnectionBuilder builder,
string url,
IHttpMessageHandlerFactory clientFactory)
{
return builder.WithUrl(
url, options =>
{
options.HttpMessageHandlerFactory =
_ => clientFactory.CreateHandler();
});
}
}
@@ -0,0 +1,10 @@
using Microsoft.SemanticKernel.ChatCompletion;
namespace OnlineShop.Web;
public interface IChatHistoryService
{
void AddUserMessage(string connectionId, string message);
ChatHistory GetChatHistory(string connectionId);
void RemoveHistory(string connectionId);
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.StackExchange.Redis.OutputCaching" Version="13.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.2" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.70.0-alpha" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OnlineShop.ServiceDefaults\OnlineShop.ServiceDefaults.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Components.Authorization;
using OnlineShop.ServiceDefaults.Dtos;
using System.Net.Http.Headers;
namespace OnlineShop.Web;
public class ProductsApiClient(HttpClient httpClient, AuthenticationStateProvider authStateProvider)
{
public async Task<ProductDto[]> GetProductsAsync(int maxItems = 10, CancellationToken cancellationToken = default)
{
List<ProductDto>? products = null;
await foreach (var product in httpClient.GetFromJsonAsAsyncEnumerable<ProductDto>("/products", cancellationToken))
{
if (products?.Count >= maxItems)
{
break;
}
if (product is not null)
{
products ??= [];
products.Add(product);
}
}
return products?.ToArray() ?? [];
}
public async Task<HttpResponseMessage> MakeOrder(Dictionary<int, int> basket)
{
var authState = await authStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identity?.IsAuthenticated == true)
{
var token = user.FindFirst("access_token")?.Value;
if (!string.IsNullOrWhiteSpace(token))
{
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
}
}
return await httpClient.PostAsJsonAsync("/api/orders", basket);
}
}
@@ -0,0 +1,132 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using OnlineShop.Web;
using OnlineShop.Web.Components;
using System.Data.Common;
var builder = WebApplication.CreateBuilder(args);
builder.AddRedisOutputCache("cache");
builder.AddServiceDefaults();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddOutputCache();
builder.Services.AddHttpClient(
"OidcBackchannel", o => o.BaseAddress = new("http://idp"));
builder.Services.AddHttpClient<ProductsApiClient>(client =>
{
client.BaseAddress = new("https+http://apiservice");
});
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme =
CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme =
OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(
CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect()
.ConfigureWebAppOpenIdConnect();
builder.Services.AddHttpClient();
builder.Services.AddHttpClient("ollama", c =>
{
c.BaseAddress = new Uri("http://ollama");
})
.AddServiceDiscovery();
var phiConnectionString = builder.Configuration.GetConnectionString("phi35");
DbConnectionStringBuilder csBuilder = new()
{
ConnectionString = phiConnectionString
};
if (!csBuilder.TryGetValue("Endpoint", out var ollamaEndpoint))
{
throw new InvalidDataException(
"Ollama connection string is not properly configured.");
}
builder.Services.AddSingleton(sp =>
{
IKernelBuilder kb = Kernel.CreateBuilder();
#pragma warning disable SKEXP0070
kb.AddOllamaChatCompletion(
modelId: "phi3.5",
endpoint: new Uri((string)ollamaEndpoint)
);
#pragma warning restore SKEXP0070
return kb.Build();
});
builder.Services.AddSingleton<IChatHistoryService, ChatHistoryService>();
builder.Services.AddSingleton(sp =>
sp.GetRequiredService<Kernel>()
.GetRequiredService<IChatCompletionService>());
builder.Services.AddSignalR();
builder.Services.AddSignalR()
.AddHubOptions<ChatHub>(o => o.EnableDetailedErrors = true);
builder.Services.AddAuthorization();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAntiforgery();
app.UseOutputCache();
app.MapStaticAssets();
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<ChatHub>("/chat");
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapDefaultEndpoints();
app.MapGet("/login", async (HttpContext ctx, string? returnUrl) =>
{
returnUrl ??= "/";
await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme,
new AuthenticationProperties { RedirectUri = returnUrl });
});
app.MapPost("/logout", async (HttpContext ctx, string? returnUrl) =>
{
returnUrl ??= "/";
// sign out of the local cookie and the OIDC provider
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme,
new AuthenticationProperties { RedirectUri = returnUrl });
});
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5240",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7199;http://localhost:5240",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,56 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
a, .btn-link {
color: #006bb7;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e52400;
}
.validation-message {
color: #e52400;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,107 @@
export async function init() {
if (typeof window.signalR === 'undefined') {
console.error('[chat.js] SignalR client not loaded. Check script tag order.');
return;
}
const statusEl = document.getElementById('status');
const form = document.getElementById('promptForm');
const promptEl = document.getElementById('prompt');
const sendBtn = document.getElementById('sendBtn');
const stopBtn = document.getElementById('stopBtn');
const clearBtn = document.getElementById('clearBtn');
const copyBtn = document.getElementById('copyBtn');
const outEl = document.getElementById('output');
const countEl = document.getElementById('tokenCount');
function setStatus(text, cls = 'text-bg-secondary') {
if (!statusEl) return;
statusEl.className = `badge ${cls}`;
statusEl.textContent = text;
}
function setBusy(isBusy) {
if (sendBtn) sendBtn.disabled = isBusy;
if (stopBtn) stopBtn.disabled = !isBusy;
if (promptEl) promptEl.disabled = isBusy;
}
function updateCharCount() {
if (countEl && outEl) countEl.textContent = `${outEl.textContent.length} chars`;
}
const hubUrl = new URL('chat', document.baseURI).toString();
let connection = new signalR.HubConnectionBuilder()
.withUrl(hubUrl, { withCredentials: true })
.withAutomaticReconnect()
.configureLogging(signalR.LogLevel.Information)
.build();
connection.onreconnecting(() => setStatus('reconnecting…', 'text-bg-warning'));
connection.onreconnected(() => setStatus('connected', 'text-bg-success'));
connection.onclose(() => setStatus('disconnected', 'text-bg-secondary'));
try {
await connection.start();
setStatus('connected', 'text-bg-success');
console.log('[chat.js] connection started');
} catch (err) {
console.error('[chat.js] connection start failed:', err);
setStatus('disconnected', 'text-bg-danger');
return;
}
let subscription = null;
async function startStream(prompt) {
if (!prompt) return;
outEl.textContent = '';
updateCharCount();
setBusy(true);
const stream = connection.stream('StreamAnswer', prompt);
subscription = stream.subscribe({
next: chunk => {
for (const ch of chunk) outEl.textContent += ch;
updateCharCount();
if (outEl.parentElement) {
outEl.parentElement.scrollTop = outEl.parentElement.scrollHeight;
}
},
complete: () => setBusy(false),
error: err => {
setBusy(false);
outEl.textContent += `\n\n[error] ${err?.message ?? err}`;
updateCharCount();
}
});
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
await startStream(promptEl.value.trim());
});
stopBtn.addEventListener('click', () => {
subscription?.dispose();
subscription = null;
setBusy(false);
});
clearBtn.addEventListener('click', () => {
outEl.textContent = '';
updateCharCount();
promptEl.focus();
});
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(outEl.textContent);
copyBtn.textContent = 'Copied!';
setTimeout(() => (copyBtn.textContent = 'Copy'), 1000);
} catch { /* ignore */ }
});
}
@@ -0,0 +1,54 @@
var map = null;
window.initializeMap = () => {
map = new ol.Map({
target: 'map',
layers: [
new ol.layer.Tile({
source: new ol.source.OSM()
})
],
view: new ol.View({
center: ol.proj.fromLonLat([-0.1276, 51.5074]),
zoom: 12
})
});
}
window.markerLayer = null;
window.addMarker = (lat, lon) => {
if (window.markerLayer) {
window.map.removeLayer(window.markerLayer);
}
const marker = new ol.Feature({
geometry: new ol.geom.Point(
ol.proj.fromLonLat([lon, lat]))
});
const markerStyle = new ol.style.Style({
image: new ol.style.Circle({
radius: 10, // Radius of the circle in pixels
fill: new ol.style.Fill({
color: 'rgba(255, 0, 0, 0.8)'
}), // Fill color (red with 80% opacity)
stroke: new ol.style.Stroke({
color: 'black',
width: 2
}) // Optional stroke (black border)
})
});
marker.setStyle(markerStyle);
const vectorSource = new ol.source.Vector({
features: [marker]
});
window.markerLayer = new ol.layer.Vector({
source: vectorSource
});
window.map.addLayer(window.markerLayer);
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More