Skip to content

Commit 1af2faf

Browse files
committed
Add SQL Server support and image handling features
Migrated database from SQLite to SQL Server, adding support for binary image storage (`ImageData`) and base64-encoded image serialization (`ImageDataBase64`). Updated `Product` entity with timestamps (`CreatedDate`, `ModifiedDate`) and enhanced `OnModelCreating` for SQL Server-specific configurations. Introduced new API endpoints for product image retrieval (`/api/Product/{id}/image`), product count, and debug information. Added support for uploading images via `PUT /api/Product/{id}/image`. Enhanced Blazor frontend with responsive layouts, improved product grid design, and debug tools (`ProductsDebug.razor`). Updated `ProductService` to handle image URLs and API interactions. Added database setup scripts (`Setup.sql`, `LoadImages.sql`) and a PowerShell script (`LoadImages.ps1`) for image loading. Updated `README.md` with setup instructions, troubleshooting tips, and architecture details. Enabled CORS for Blazor frontend, configured static file serving, and initialized the database on startup. Improved maintainability, scalability, and user experience.
1 parent c79ac17 commit 1af2faf

19 files changed

Lines changed: 1411 additions & 229 deletions

src/DataEntities/Product.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Text.Json.Serialization;
1+
using System.ComponentModel.DataAnnotations.Schema;
2+
using System.Text.Json.Serialization;
23

34
namespace DataEntities;
45

@@ -18,6 +19,25 @@ public class Product
1819

1920
[JsonPropertyName("imageUrl")]
2021
public string? ImageUrl { get; set; }
22+
23+
// Binary image data stored in database
24+
[JsonIgnore]
25+
public byte[]? ImageData { get; set; }
26+
27+
// Base64 encoded image for JSON serialization
28+
[NotMapped]
29+
[JsonPropertyName("imageDataBase64")]
30+
public string? ImageDataBase64
31+
{
32+
get => ImageData != null ? Convert.ToBase64String(ImageData) : null;
33+
set => ImageData = value != null ? Convert.FromBase64String(value) : null;
34+
}
35+
36+
[JsonPropertyName("createdDate")]
37+
public DateTime CreatedDate { get; set; }
38+
39+
[JsonPropertyName("modifiedDate")]
40+
public DateTime ModifiedDate { get; set; }
2141
}
2242

2343

src/Products/Data/ProductDataContext.cs

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,49 +6,89 @@ namespace Products.Data;
66
public class ProductDataContext : DbContext
77
{
88
public ProductDataContext(DbContextOptions<ProductDataContext> options)
9-
: base(options)
9+
: base(options)
1010
{
1111
}
1212

1313
public DbSet<Product> Product { get; set; } = default!;
14+
15+
protected override void OnModelCreating(ModelBuilder modelBuilder)
16+
{
17+
base.OnModelCreating(modelBuilder);
18+
19+
// Configure Product entity for SQL Server
20+
modelBuilder.Entity<Product>(entity =>
21+
{
22+
entity.ToTable("Products", "dbo");
23+
entity.HasKey(e => e.Id);
24+
entity.Property(e => e.Id).ValueGeneratedOnAdd();
25+
entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
26+
entity.Property(e => e.Description).HasMaxLength(1000);
27+
entity.Property(e => e.Price).HasColumnType("decimal(18,2)");
28+
entity.Property(e => e.ImageUrl).HasMaxLength(500);
29+
entity.Property(e => e.ImageData).HasColumnType("varbinary(max)");
30+
entity.Property(e => e.CreatedDate).HasDefaultValueSql("GETUTCDATE()");
31+
entity.Property(e => e.ModifiedDate).HasDefaultValueSql("GETUTCDATE()");
32+
33+
entity.HasIndex(e => e.Name).HasDatabaseName("IX_Products_Name");
34+
});
35+
}
1436
}
1537

1638
public static class Extensions
1739
{
18-
public static void CreateDbIfNotExists(this IHost host)
40+
public static async Task InitializeDatabaseAsync(this IHost host)
1941
{
2042
using var scope = host.Services.CreateScope();
21-
2243
var services = scope.ServiceProvider;
2344
var context = services.GetRequiredService<ProductDataContext>();
24-
context.Database.EnsureCreated();
25-
DbInitializer.Initialize(context);
45+
var logger = services.GetRequiredService<ILogger<ProductDataContext>>();
46+
47+
try
48+
{
49+
// Ensure database exists (for LocalDB)
50+
await context.Database.EnsureCreatedAsync();
51+
logger.LogInformation("Database connection successful.");
52+
53+
// Seed data if empty
54+
if (!await context.Product.AnyAsync())
55+
{
56+
await DbInitializer.InitializeAsync(context, logger);
57+
}
58+
}
59+
catch (Exception ex)
60+
{
61+
logger.LogError(ex, "An error occurred while initializing the database.");
62+
throw;
63+
}
2664
}
2765
}
2866

29-
3067
public static class DbInitializer
3168
{
32-
public static void Initialize(ProductDataContext context)
69+
public static async Task InitializeAsync(ProductDataContext context, ILogger logger)
3370
{
34-
if (context.Product.Any())
35-
return;
71+
logger.LogInformation("Seeding initial product data...");
3672

3773
var products = new List<Product>
38-
{
39-
new Product { Name = "Solar Powered Flashlight", Description = "A fantastic product for outdoor enthusiasts", Price = 19.99m, ImageUrl = "product1.png" },
40-
new Product { Name = "Hiking Poles", Description = "Ideal for camping and hiking trips", Price = 24.99m, ImageUrl = "product2.png" },
41-
new Product { Name = "Outdoor Rain Jacket", Description = "This product will keep you warm and dry in all weathers", Price = 49.99m, ImageUrl = "product3.png" },
42-
new Product { Name = "Survival Kit", Description = "A must-have for any outdoor adventurer", Price = 99.99m, ImageUrl = "product4.png" },
43-
new Product { Name = "Outdoor Backpack", Description = "This backpack is perfect for carrying all your outdoor essentials", Price = 39.99m, ImageUrl = "product5.png" },
44-
new Product { Name = "Camping Cookware", Description = "This cookware set is ideal for cooking outdoors", Price = 29.99m, ImageUrl = "product6.png" },
45-
new Product { Name = "Camping Stove", Description = "This stove is perfect for cooking outdoors", Price = 49.99m, ImageUrl = "product7.png" },
46-
new Product { Name = "Camping Lantern", Description = "This lantern is perfect for lighting up your campsite", Price = 19.99m, ImageUrl = "product8.png" },
47-
new Product { Name = "Camping Tent", Description = "This tent is perfect for camping trips", Price = 99.99m, ImageUrl = "product9.png" },
74+
{
75+
// Note: ImageUrl is set to null - images will be loaded into ImageData via LoadImages.ps1 script
76+
// This enables Scenario 2: Database image serving via /api/Product/{id}/image
77+
new Product { Name = "Solar Powered Flashlight", Description = "A fantastic product for outdoor enthusiasts", Price = 19.99m, ImageUrl = null, CreatedDate = DateTime.UtcNow, ModifiedDate = DateTime.UtcNow },
78+
new Product { Name = "Hiking Poles", Description = "Ideal for camping and hiking trips", Price = 24.99m, ImageUrl = null, CreatedDate = DateTime.UtcNow, ModifiedDate = DateTime.UtcNow },
79+
new Product { Name = "Outdoor Rain Jacket", Description = "This product will keep you warm and dry in all weathers", Price = 49.99m, ImageUrl = null, CreatedDate = DateTime.UtcNow, ModifiedDate = DateTime.UtcNow },
80+
new Product { Name = "Survival Kit", Description = "A must-have for any outdoor adventurer", Price = 99.99m, ImageUrl = null, CreatedDate = DateTime.UtcNow, ModifiedDate = DateTime.UtcNow },
81+
new Product { Name = "Outdoor Backpack", Description = "This backpack is perfect for carrying all your outdoor essentials", Price = 39.99m, ImageUrl = null, CreatedDate = DateTime.UtcNow, ModifiedDate = DateTime.UtcNow },
82+
new Product { Name = "Camping Cookware", Description = "This cookware set is ideal for cooking outdoors", Price = 29.99m, ImageUrl = null, CreatedDate = DateTime.UtcNow, ModifiedDate = DateTime.UtcNow },
83+
new Product { Name = "Camping Stove", Description = "This stove is perfect for cooking outdoors", Price = 49.99m, ImageUrl = null, CreatedDate = DateTime.UtcNow, ModifiedDate = DateTime.UtcNow },
84+
new Product { Name = "Camping Lantern", Description = "This lantern is perfect for lighting up your campsite", Price = 19.99m, ImageUrl = null, CreatedDate = DateTime.UtcNow, ModifiedDate = DateTime.UtcNow },
85+
new Product { Name = "Camping Tent", Description = "This tent is perfect for camping trips", Price = 99.99m, ImageUrl = null, CreatedDate = DateTime.UtcNow, ModifiedDate = DateTime.UtcNow }
4886
};
4987

50-
context.AddRange(products);
88+
context.Product.AddRange(products);
89+
await context.SaveChangesAsync();
5190

52-
context.SaveChanges();
91+
logger.LogInformation("Seeded {Count} products successfully.", products.Count);
92+
logger.LogInformation("Run LoadImages.ps1 script to load images into ImageData column.");
5393
}
5494
}

src/Products/Endpoints/ProductEndpoints.cs

Lines changed: 112 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,70 +11,154 @@ public static class ProductEndpoints
1111
public static void MapProductEndpoints(this IEndpointRouteBuilder routes)
1212
{
1313
var group = routes.MapGroup("/api/Product");
14+
1415
// Count all products currently in store
1516
group.MapGet("/count", async (ProductDataContext db) =>
1617
{
1718
var count = await db.Product.CountAsync();
18-
return Results.Ok(count);
19+
return Results.Ok(count);
1920
});
21+
2022
// GET all products
2123
group.MapGet("/", async (ProductDataContext db) =>
2224
{
23-
return await db.Product.ToListAsync();
25+
return await db.Product
26+
.OrderBy(p => p.Id)
27+
.ToListAsync();
2428
})
2529
.WithName("GetAllProducts")
26-
.Produces<List<Product>>(StatusCodes.Status200OK);
30+
.Produces<List<Product>>(StatusCodes.Status200OK);
2731

2832
// GET product by ID
2933
group.MapGet("/{productId:int}", async (int productId, ProductDataContext db) =>
3034
{
31-
var product = await db.Product.FindAsync(productId);
32-
return product is not null ? Results.Ok(product) : Results.NotFound();
35+
var product = await db.Product.FindAsync(productId);
36+
return product is not null ? Results.Ok(product) : Results.NotFound();
3337
})
3438
.WithName("GetProductById")
35-
.Produces<Product>(StatusCodes.Status200OK)
39+
.Produces<Product>(StatusCodes.Status200OK)
40+
.Produces(StatusCodes.Status404NotFound);
41+
42+
// GET product image as PNG
43+
group.MapGet("/{productId:int}/image", async (int productId, ProductDataContext db) =>
44+
{
45+
var product = await db.Product.FindAsync(productId);
46+
47+
if (product is null)
48+
return Results.NotFound();
49+
50+
if (product.ImageData is null || product.ImageData.Length == 0)
51+
return Results.NotFound(new { message = "No image data available" });
52+
53+
return Results.File(product.ImageData, "image/png");
54+
})
55+
.WithName("GetProductImage")
56+
.Produces(StatusCodes.Status200OK, contentType: "image/png")
3657
.Produces(StatusCodes.Status404NotFound);
3758

38-
// POST to create a new product
39-
group.MapPost("/", async (Product product, ProductDataContext db) =>
59+
// Debug endpoint: Check image configuration
60+
group.MapGet("/debug/images", async (ProductDataContext db) =>
4061
{
62+
var products = await db.Product
63+
.Select(p => new
64+
{
65+
p.Id,
66+
p.Name,
67+
p.ImageUrl,
68+
HasImageData = p.ImageData != null && p.ImageData.Length > 0,
69+
ImageDataSize = p.ImageData != null ? p.ImageData.Length : 0
70+
})
71+
.ToListAsync();
72+
73+
return Results.Ok(new
74+
{
75+
TotalProducts = products.Count,
76+
ProductsWithImageUrl = products.Count(p => !string.IsNullOrEmpty(p.ImageUrl)),
77+
ProductsWithImageData = products.Count(p => p.HasImageData),
78+
Products = products
79+
});
80+
})
81+
.WithName("DebugImages")
82+
.Produces(StatusCodes.Status200OK);
83+
84+
// POST to create a new product
85+
group.MapPost("/", async (Product product, ProductDataContext db) =>
86+
{
87+
product.CreatedDate = DateTime.UtcNow;
88+
product.ModifiedDate = DateTime.UtcNow;
89+
4190
db.Product.Add(product);
4291
await db.SaveChangesAsync();
43-
return Results.Created($"/api/Product/{product.Id}", product);
44-
})
45-
.WithName("CreateProduct")
46-
.Produces<Product>(StatusCodes.Status201Created);
92+
return Results.Created($"/api/Product/{product.Id}", product);
93+
})
94+
.WithName("CreateProduct")
95+
.Produces<Product>(StatusCodes.Status201Created);
4796

48-
// PUT to update a product
97+
// PUT to update a product
4998
group.MapPut("/{productId:int}", async (int productId, Product updatedProduct, ProductDataContext db) =>
5099
{
51-
var product = await db.Product.FindAsync(productId);
52-
if (product is null) return Results.NotFound();
100+
var product = await db.Product.FindAsync(productId);
101+
if (product is null) return Results.NotFound();
53102

54-
product.Name = updatedProduct.Name;
103+
product.Name = updatedProduct.Name;
55104
product.Description = updatedProduct.Description;
56-
product.Price = updatedProduct.Price;
57-
product.ImageUrl = updatedProduct.ImageUrl;
105+
product.Price = updatedProduct.Price;
106+
product.ImageUrl = updatedProduct.ImageUrl;
107+
108+
// Update image data if provided
109+
if (updatedProduct.ImageData != null)
110+
{
111+
product.ImageData = updatedProduct.ImageData;
112+
}
113+
114+
product.ModifiedDate = DateTime.UtcNow;
58115

59-
await db.SaveChangesAsync();
60-
return Results.NoContent();
116+
await db.SaveChangesAsync();
117+
return Results.NoContent();
61118
})
62-
.WithName("UpdateProduct")
119+
.WithName("UpdateProduct")
63120
.Produces(StatusCodes.Status204NoContent)
64121
.Produces(StatusCodes.Status404NotFound);
65122

66-
// DELETE to remove a product
67-
group.MapDelete("/{productId:int}", async (int productId, ProductDataContext db) =>
123+
// PUT to upload product image
124+
group.MapPut("/{productId:int}/image", async (int productId, IFormFile file, ProductDataContext db) =>
68125
{
69-
var product = await db.Product.FindAsync(productId);
126+
var product = await db.Product.FindAsync(productId);
127+
if (product is null) return Results.NotFound();
128+
129+
if (file.Length == 0)
130+
return Results.BadRequest(new { message = "Empty file" });
131+
132+
// Validate file type
133+
if (!file.ContentType.StartsWith("image/"))
134+
return Results.BadRequest(new { message = "File must be an image" });
135+
136+
using var memoryStream = new MemoryStream();
137+
await file.CopyToAsync(memoryStream);
138+
product.ImageData = memoryStream.ToArray();
139+
product.ModifiedDate = DateTime.UtcNow;
140+
141+
await db.SaveChangesAsync();
142+
return Results.NoContent();
143+
})
144+
.WithName("UploadProductImage")
145+
.Produces(StatusCodes.Status204NoContent)
146+
.Produces(StatusCodes.Status400BadRequest)
147+
.Produces(StatusCodes.Status404NotFound)
148+
.DisableAntiforgery();
149+
150+
// DELETE to remove a product
151+
group.MapDelete("/{productId:int}", async (int productId, ProductDataContext db) =>
152+
{
153+
var product = await db.Product.FindAsync(productId);
70154
if (product is null) return Results.NotFound();
71155

72-
db.Product.Remove(product);
73-
await db.SaveChangesAsync();
74-
return Results.NoContent();
156+
db.Product.Remove(product);
157+
await db.SaveChangesAsync();
158+
return Results.NoContent();
75159
})
76160
.WithName("DeleteProduct")
77-
.Produces(StatusCodes.Status204NoContent)
161+
.Produces(StatusCodes.Status204NoContent)
78162
.Produces(StatusCodes.Status404NotFound);
79163
}
80164
}

src/Products/Products.csproj

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<TargetFramework>net9.0</TargetFramework>
44
<Nullable>enable</Nullable>
55
<ImplicitUsings>enable</ImplicitUsings>
6-
<InvariantGlobalization>true</InvariantGlobalization>
6+
<InvariantGlobalization>false</InvariantGlobalization>
77
</PropertyGroup>
88
<ItemGroup>
99
<None Remove="Database.db" />
@@ -13,8 +13,7 @@
1313
<ItemGroup>
1414
<PackageReference Include="System.Text.Json" Version="9.0.6" />
1515
<PackageReference Include="System.Formats.Asn1" Version="9.0.6" />
16-
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.6" />
17-
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
16+
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.6" />
1817
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.6">
1918
<PrivateAssets>all</PrivateAssets>
2019
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

src/Products/Program.cs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,23 @@
66

77
builder.AddServiceDefaults();
88

9+
// Configure SQL Server connection
10+
var connectionString = builder.Configuration.GetConnectionString("ProductsDb")
11+
?? "Server=(localdb)\\MSSQLLocalDB;Database=TestDB;Integrated Security=true;TrustServerCertificate=True;";
12+
913
builder.Services.AddDbContext<ProductDataContext>(options =>
10-
options.UseInMemoryDatabase("inmemproducts"));
14+
options.UseSqlServer(connectionString));
15+
16+
// Add CORS policy for Blazor Server frontend
17+
builder.Services.AddCors(options =>
18+
{
19+
options.AddPolicy("AllowBlazorClient", policy =>
20+
{
21+
policy.AllowAnyOrigin()
22+
.AllowAnyMethod()
23+
.AllowAnyHeader();
24+
});
25+
});
1126

1227
// Add services to the container.
1328
var app = builder.Build();
@@ -17,11 +32,16 @@
1732
// Configure the HTTP request pipeline.
1833
app.UseHttpsRedirection();
1934

20-
app.MapProductEndpoints();
35+
// Enable CORS before other middleware
36+
app.UseCors("AllowBlazorClient");
2137

38+
// Static files must be configured before routing
2239
app.UseStaticFiles();
2340

24-
app.CreateDbIfNotExists();
41+
app.MapProductEndpoints();
42+
43+
// Initialize database and seed data if needed
44+
await app.InitializeDatabaseAsync();
2545

2646
app.Run();
2747

0 commit comments

Comments
 (0)