From 50f8ffba7d3741329b22741e731116d05344fe12 Mon Sep 17 00:00:00 2001 From: biswajitmailid Date: Wed, 16 Sep 2026 00:48:21 +0200 Subject: [PATCH 1/2] Upgrade to .NET 10 / C# 14 Target net10.0 in both projects and pin the SDK with global.json. Delta.EF 6.0.0 -> 9.0.1 and all Microsoft.* packages -> 10.0.12; migrate HyperCache.sln to the .slnx format. API - Move UseCors ahead of UseDelta so Delta's 304 short-circuit still carries Access-Control-Allow-Origin for the cross-origin Blazor client. - Add OpenAPI + Scalar reference UI (/openapi/v1.json, /scalar) in Development. - Replace the anonymous paged response with a PagedResponse record produced by a C# 14 extension block (QueryableExtensions.ToPagedAsync). - Make the startup seed row count configurable (Seed:Count, C# 14 `field` validation), generate rows lazily, and use Random.Shared. - Drop the redundant HasConversion() on the rowversion column (no model drift; no migration needed). - Fix the placeholder connection string and point HyperCache.http at the real endpoints, including an If-None-Match round-trip. Web - Adopt .NET 10 fingerprinted static assets (OverrideHtmlAssetPlaceholders, preload/importmap placeholders in index.html). - Replace template leftovers: real landing page, GitHub link, remove unused sample-data/weather.json; make ApiConstant static. Docs - README: .NET 10 / VS 2026, real Delta wiring (there is no AddDelta), correct dotnet ef and launch-profile instructions, configuration, API table with Scalar screenshot, how Delta detects changes, C# 14 features used, benchmark provenance note, badges. - LICENSE placeholder filled in; CLAUDE.md added to .gitignore. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + HyperCache.Web/Constants/ApiConstant.cs | 6 +- HyperCache.Web/HyperCache.Web.csproj | 7 +- HyperCache.Web/Layout/MainLayout.razor | 2 +- HyperCache.Web/Pages/Home.razor | 22 ++- HyperCache.Web/wwwroot/index.html | 8 +- .../wwwroot/sample-data/weather.json | 27 --- HyperCache.sln | 31 --- HyperCache.slnx | 5 + .../Controllers/CustomPropertiesController.cs | 35 ++-- HyperCache/Data/AppDbContext.cs | 9 +- HyperCache/Data/DataSeeder.cs | 56 +++--- HyperCache/Data/SeedOptions.cs | 22 +++ HyperCache/Extensions/QueryableExtensions.cs | 27 +++ HyperCache/HyperCache.Api.csproj | 13 +- HyperCache/HyperCache.http | 25 ++- HyperCache/Models/PagedResponse.cs | 11 ++ HyperCache/Program.cs | 18 +- HyperCache/appsettings.json | 5 +- LICENSE.txt | 2 +- README.md | 179 +++++++++++++----- Screenshots/Scalar.jpg | Bin 0 -> 47795 bytes global.json | 6 + 23 files changed, 323 insertions(+), 196 deletions(-) delete mode 100644 HyperCache.Web/wwwroot/sample-data/weather.json delete mode 100644 HyperCache.sln create mode 100644 HyperCache.slnx create mode 100644 HyperCache/Data/SeedOptions.cs create mode 100644 HyperCache/Extensions/QueryableExtensions.cs create mode 100644 HyperCache/Models/PagedResponse.cs create mode 100644 Screenshots/Scalar.jpg create mode 100644 global.json diff --git a/.gitignore b/.gitignore index 429cdbe..6f0845c 100644 --- a/.gitignore +++ b/.gitignore @@ -362,3 +362,6 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd /HyperCache/appsettings.Development.json + +# Claude Code guidance file (local only) +CLAUDE.md diff --git a/HyperCache.Web/Constants/ApiConstant.cs b/HyperCache.Web/Constants/ApiConstant.cs index f3030b6..ddaa25c 100644 --- a/HyperCache.Web/Constants/ApiConstant.cs +++ b/HyperCache.Web/Constants/ApiConstant.cs @@ -1,10 +1,10 @@ -namespace HyperCache.Web.Constants; +namespace HyperCache.Web.Constants; -public class ApiConstant +public static class ApiConstant { public const string BaseUrl = "https://localhost:7148"; public const string CustomProperties = "api/customproperties"; public const string CustomPropertiesPaged = "api/customproperties/paged"; public const string CustomPropertiesSearch = "api/customproperties/search"; public const string CustomPropertiesAll = "api/customproperties/all"; -} \ No newline at end of file +} diff --git a/HyperCache.Web/HyperCache.Web.csproj b/HyperCache.Web/HyperCache.Web.csproj index 2ede3a0..b51965c 100644 --- a/HyperCache.Web/HyperCache.Web.csproj +++ b/HyperCache.Web/HyperCache.Web.csproj @@ -1,14 +1,15 @@ - net9.0 + net10.0 enable enable + true - - + + diff --git a/HyperCache.Web/Layout/MainLayout.razor b/HyperCache.Web/Layout/MainLayout.razor index 76eb725..9ee6c7f 100644 --- a/HyperCache.Web/Layout/MainLayout.razor +++ b/HyperCache.Web/Layout/MainLayout.razor @@ -6,7 +6,7 @@
diff --git a/HyperCache.Web/Pages/Home.razor b/HyperCache.Web/Pages/Home.razor index 9001e0b..a3831c9 100644 --- a/HyperCache.Web/Pages/Home.razor +++ b/HyperCache.Web/Pages/Home.razor @@ -1,7 +1,21 @@ -@page "/" +@page "/" -Home +HyperCache -

Hello, world!

+

HyperCache

-Welcome to your new app. +

+ A demo of HTTP caching with the Delta middleware + on .NET 10, Entity Framework Core 10 and SQL Server. +

+ +

+ The API behind this page holds one million rows. Every GET carries an ETag derived from + the database's change stamp, so repeating a request costs a 304 Not Modified instead of a query. + Open the browser's network tab, page back and forth, and watch the repeat requests come back in milliseconds. +

+ + diff --git a/HyperCache.Web/wwwroot/index.html b/HyperCache.Web/wwwroot/index.html index a7a1a3c..5d17423 100644 --- a/HyperCache.Web/wwwroot/index.html +++ b/HyperCache.Web/wwwroot/index.html @@ -1,17 +1,17 @@ - HyperCache + + -
@@ -20,13 +20,11 @@
-
An unhandled error has occurred. Reload 🗙
- + - diff --git a/HyperCache.Web/wwwroot/sample-data/weather.json b/HyperCache.Web/wwwroot/sample-data/weather.json deleted file mode 100644 index b745973..0000000 --- a/HyperCache.Web/wwwroot/sample-data/weather.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2022-01-06", - "temperatureC": 1, - "summary": "Freezing" - }, - { - "date": "2022-01-07", - "temperatureC": 14, - "summary": "Bracing" - }, - { - "date": "2022-01-08", - "temperatureC": -13, - "summary": "Freezing" - }, - { - "date": "2022-01-09", - "temperatureC": -16, - "summary": "Balmy" - }, - { - "date": "2022-01-10", - "temperatureC": -2, - "summary": "Chilly" - } -] diff --git a/HyperCache.sln b/HyperCache.sln deleted file mode 100644 index a77aafa..0000000 --- a/HyperCache.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.13.35617.110 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HyperCache.Api", "HyperCache\HyperCache.Api.csproj", "{25E73FD1-AA64-49EF-8076-BBF122492549}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HyperCache.Web", "HyperCache.Web\HyperCache.Web.csproj", "{26CC1505-16F6-4B6B-ADE4-5CAE532CBAEE}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {25E73FD1-AA64-49EF-8076-BBF122492549}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {25E73FD1-AA64-49EF-8076-BBF122492549}.Debug|Any CPU.Build.0 = Debug|Any CPU - {25E73FD1-AA64-49EF-8076-BBF122492549}.Release|Any CPU.ActiveCfg = Release|Any CPU - {25E73FD1-AA64-49EF-8076-BBF122492549}.Release|Any CPU.Build.0 = Release|Any CPU - {26CC1505-16F6-4B6B-ADE4-5CAE532CBAEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {26CC1505-16F6-4B6B-ADE4-5CAE532CBAEE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {26CC1505-16F6-4B6B-ADE4-5CAE532CBAEE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {26CC1505-16F6-4B6B-ADE4-5CAE532CBAEE}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {1210998D-1B2B-4245-8114-A8DD5B0A0F3D} - EndGlobalSection -EndGlobal diff --git a/HyperCache.slnx b/HyperCache.slnx new file mode 100644 index 0000000..242bf1c --- /dev/null +++ b/HyperCache.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/HyperCache/Controllers/CustomPropertiesController.cs b/HyperCache/Controllers/CustomPropertiesController.cs index 94b77d0..f2cefd7 100644 --- a/HyperCache/Controllers/CustomPropertiesController.cs +++ b/HyperCache/Controllers/CustomPropertiesController.cs @@ -1,4 +1,5 @@ using HyperCache.Api.Data; +using HyperCache.Api.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -10,17 +11,12 @@ public class CustomPropertiesController(AppDbContext context) : ControllerBase { [HttpGet("paged")] - public async Task GetPagedProperties([FromQuery] int page = 1, [FromQuery] int pageSize = 20) + public async Task GetPagedProperties([FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default) { if (page < 1 || pageSize < 1) return BadRequest("Page and PageSize must be greater than 0."); - var totalCount = await context.CustomProperties.CountAsync(); - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - - var properties = await context.CustomProperties - .Skip((page - 1) * pageSize) - .Take(pageSize) + var response = await context.CustomProperties .Select(p => new { p.Id, @@ -30,27 +26,18 @@ public async Task GetPagedProperties([FromQuery] int page = 1, [F p.CreatedBy, p.ModifiedBy }) - .ToListAsync(); - - var response = new - { - Items = properties, - CurrentPage = page, - TotalPages = totalPages, - HasPreviousPage = page > 1, - HasNextPage = page < totalPages - }; + .ToPagedAsync(page, pageSize, cancellationToken); return Ok(response); } [HttpGet("{id}")] - public async Task GetPropertyDetails(string id) + public async Task GetPropertyDetails(string id, CancellationToken cancellationToken) { if (!Guid.TryParse(id, out var propertyId)) return BadRequest("Invalid GUID format."); - var customProperty = await context.CustomProperties.FindAsync(propertyId); + var customProperty = await context.CustomProperties.FindAsync([propertyId], cancellationToken); if (customProperty == null) return NotFound(); @@ -58,22 +45,22 @@ public async Task GetPropertyDetails(string id) } [HttpGet("all")] - public async Task Get() + public async Task Get(CancellationToken cancellationToken) { - var customProperties = await context.CustomProperties.ToListAsync(); + var customProperties = await context.CustomProperties.ToListAsync(cancellationToken); return Ok(customProperties); } [HttpGet("search")] - public async Task Search([FromQuery] string keyword) + public async Task Search([FromQuery] string keyword, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(keyword)) return BadRequest("Keyword can't be empty!"); var customProperties = await context.CustomProperties .Where(x => x.Name.Contains(keyword)) - .ToListAsync(); + .ToListAsync(cancellationToken); return Ok(customProperties); } -} \ No newline at end of file +} diff --git a/HyperCache/Data/AppDbContext.cs b/HyperCache/Data/AppDbContext.cs index 68837ea..c1cacb7 100644 --- a/HyperCache/Data/AppDbContext.cs +++ b/HyperCache/Data/AppDbContext.cs @@ -1,4 +1,4 @@ -using HyperCache.Api.Models; +using HyperCache.Api.Models; using Microsoft.EntityFrameworkCore; namespace HyperCache.Api.Data; @@ -14,11 +14,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) var customProperty = modelBuilder.Entity(); customProperty.HasKey(cp => cp.Id); - // Configures RowVersion for Delta Package + // SQL Server rowversion column. Every write bumps @@DBTS, which is what Delta reads to build its ETag. customProperty .Property(cp => cp.RowVersion) - .IsRowVersion() - .HasConversion(); + .IsRowVersion(); // Configure required properties for the CustomProperty entity. customProperty.Property(cp => cp.Name).IsRequired(); @@ -27,4 +26,4 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) customProperty.Property(cp => cp.ModifiedBy).IsRequired(); customProperty.Property(cp => cp.ParentTable).IsRequired(); } -} \ No newline at end of file +} diff --git a/HyperCache/Data/DataSeeder.cs b/HyperCache/Data/DataSeeder.cs index 96c3398..32c8ad7 100644 --- a/HyperCache/Data/DataSeeder.cs +++ b/HyperCache/Data/DataSeeder.cs @@ -1,60 +1,56 @@ -using HyperCache.Api.Models; +using HyperCache.Api.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace HyperCache.Api.Data; -public class DataSeeder(AppDbContext context) +public class DataSeeder(AppDbContext context, IOptions options) { + // Sample parent table names and user names, picked with Random.GetItems (arrays convert to ReadOnlySpan implicitly). + private static readonly string[] ParentTableNames = ["Orders", "Products", "Users", "Invoices"]; + private static readonly string[] Users = ["Alice", "Bob", "Charlie", "Diana"]; + /// /// Seeds the CustomProperties table with sample data if it is empty. + /// The row count comes from (default 1,000,000). /// - public async Task SeedCustomPropertiesAsync() + public async Task SeedCustomPropertiesAsync(CancellationToken cancellationToken = default) { - if (await context.CustomProperties.AnyAsync()) + if (await context.CustomProperties.AnyAsync(cancellationToken)) return; // Skip seeding if data already exists. - var customProperties = GenerateCustomProperties(1_000_000); - foreach (var batch in customProperties.Chunk(10_000)) + // Rows are generated lazily and inserted in batches so the full set is never held in memory. + foreach (var batch in GenerateCustomProperties(options.Value.Count).Chunk(10_000)) { - await context.CustomProperties.AddRangeAsync(batch); - await context.SaveChangesAsync(); + await context.CustomProperties.AddRangeAsync(batch, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); } } - /// - /// Generates a list of custom properties with random data. + /// Generates custom properties with random data, one at a time. /// /// The number of custom properties to generate. - /// A list of CustomProperty objects. - private List GenerateCustomProperties(int count) + private static IEnumerable GenerateCustomProperties(int count) { - var random = new Random(); - var customProperties = new List(); + var random = Random.Shared; - // Sample parent table names and user names. - var parentTableNames = new[] { "Orders", "Products", "Users", "Invoices" }; - var users = new[] { "Alice", "Bob", "Charlie", "Diana" }; - - // Create and add a new custom property with random values. - for (int i = 0; i < count; i++) + for (var i = 0; i < count; i++) { - var parentId = Guid.NewGuid(); - var parentTable = parentTableNames[random.Next(parentTableNames.Length)]; - customProperties.Add(new CustomProperty + var parentTable = random.GetItems(ParentTableNames, 1)[0]; + yield return new CustomProperty { Id = Guid.NewGuid(), - ParentId = parentId, + ParentId = Guid.NewGuid(), Name = $"{parentTable}_Property_{i}", Value = $"Value_{random.Next(1, 1000)}", CreatedOn = DateTime.UtcNow.AddDays(-random.Next(1, 365)), ModifiedOn = DateTime.UtcNow.AddMinutes(-random.Next(1, 10000)), - CreatedBy = users[random.Next(users.Length)], - ModifiedBy = users[random.Next(users.Length)], + CreatedBy = random.GetItems(Users, 1)[0], + ModifiedBy = random.GetItems(Users, 1)[0], ParentTable = parentTable - }); + }; } - - return customProperties; } -} \ No newline at end of file +} diff --git a/HyperCache/Data/SeedOptions.cs b/HyperCache/Data/SeedOptions.cs new file mode 100644 index 0000000..9001d65 --- /dev/null +++ b/HyperCache/Data/SeedOptions.cs @@ -0,0 +1,22 @@ +namespace HyperCache.Api.Data; + +/// +/// Controls the one-time seeding that runs at startup. Bound from the "Seed" configuration section, +/// so the row count can be lowered for local runs and tests via Seed:Count / Seed__Count. +/// +public sealed class SeedOptions +{ + public const string SectionName = "Seed"; + + /// + /// Number of rows to insert when the CustomProperties table is empty. Defaults to 1,000,000. + /// + public int Count + { + get; + // C# 14 `field` keyword: validate in the setter without declaring a backing field by hand. + set => field = value > 0 + ? value + : throw new ArgumentOutOfRangeException(nameof(Count), value, "Seed:Count must be greater than 0."); + } = 1_000_000; +} diff --git a/HyperCache/Extensions/QueryableExtensions.cs b/HyperCache/Extensions/QueryableExtensions.cs new file mode 100644 index 0000000..f4541d7 --- /dev/null +++ b/HyperCache/Extensions/QueryableExtensions.cs @@ -0,0 +1,27 @@ +using HyperCache.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace HyperCache.Api.Extensions; + +public static class QueryableExtensions +{ + // C# 14 extension members: the receiver is declared once for the whole block. + extension(IQueryable source) + { + /// + /// Runs a COUNT plus a Skip/Take page query and wraps the results with paging metadata. + /// + public async Task> ToPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default) + { + var totalCount = await source.CountAsync(cancellationToken); + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + + var items = await source + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return new PagedResponse(items, page, totalPages); + } + } +} diff --git a/HyperCache/HyperCache.Api.csproj b/HyperCache/HyperCache.Api.csproj index a14b0ba..449e2d8 100644 --- a/HyperCache/HyperCache.Api.csproj +++ b/HyperCache/HyperCache.Api.csproj @@ -1,20 +1,21 @@ - net9.0 + net10.0 enable enable - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/HyperCache/HyperCache.http b/HyperCache/HyperCache.http index 7fe63a1..8f81821 100644 --- a/HyperCache/HyperCache.http +++ b/HyperCache/HyperCache.http @@ -1,6 +1,25 @@ -@HyperCache_HostAddress = http://localhost:5012 +@HyperCache_HostAddress = https://localhost:7148 -GET {{HyperCache_HostAddress}}/weatherforecast/ +### Paged list. Note the ETag header on the response. +GET {{HyperCache_HostAddress}}/api/customproperties/paged?page=1&pageSize=20 Accept: application/json -### +### Same request with the ETag from above: Delta answers 304 Not Modified without hitting the table. +GET {{HyperCache_HostAddress}}/api/customproperties/paged?page=1&pageSize=20 +Accept: application/json +If-None-Match: "" + +### Search by name +GET {{HyperCache_HostAddress}}/api/customproperties/search?keyword=Orders_Property_1 +Accept: application/json + +### Single item (use an Id from the paged response) +GET {{HyperCache_HostAddress}}/api/customproperties/00000000-0000-0000-0000-000000000000 +Accept: application/json + +### Everything (large payload; the request the README benchmarks) +GET {{HyperCache_HostAddress}}/api/customproperties/all +Accept: application/json + +### OpenAPI document (Development only). Browsable UI at {{HyperCache_HostAddress}}/scalar +GET {{HyperCache_HostAddress}}/openapi/v1.json diff --git a/HyperCache/Models/PagedResponse.cs b/HyperCache/Models/PagedResponse.cs new file mode 100644 index 0000000..aae5515 --- /dev/null +++ b/HyperCache/Models/PagedResponse.cs @@ -0,0 +1,11 @@ +namespace HyperCache.Api.Models; + +/// +/// Page of results plus navigation metadata. The JSON shape is mirrored by +/// HyperCache.Web/Dtos/PagedResponseDto.cs; keep the two in sync. +/// +public sealed record PagedResponse(IReadOnlyList Items, int CurrentPage, int TotalPages) +{ + public bool HasPreviousPage => CurrentPage > 1; + public bool HasNextPage => CurrentPage < TotalPages; +} diff --git a/HyperCache/Program.cs b/HyperCache/Program.cs index 86db680..1dc61b0 100644 --- a/HyperCache/Program.cs +++ b/HyperCache/Program.cs @@ -1,20 +1,27 @@ using Delta; using HyperCache.Api.Data; using Microsoft.EntityFrameworkCore; +using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.Configure(builder.Configuration.GetSection(SeedOptions.SectionName)); builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddCors(); +builder.Services.AddOpenApi(); var app = builder.Build(); +// CORS must run before Delta: Delta short-circuits with 304 Not Modified, and that response +// still needs Access-Control-Allow-Origin for the cross-origin Blazor client. +app.UseCors(x => x.AllowAnyOrigin()); + // Applies the Delta middleware to the specified AppDbContext for all incoming requests. // This enables Delta's features such as tracking and managing changes in the specified database context Globally. app.UseDelta(); @@ -31,9 +38,18 @@ await seeder.SeedCustomPropertiesAsync(); } -app.UseCors(x => x.AllowAnyOrigin()); +if (app.Environment.IsDevelopment()) +{ + // OpenAPI document at /openapi/v1.json and a browsable reference UI at /scalar. + app.MapOpenApi(); + app.MapScalarApiReference(); +} + app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run(); + +// Exposes the implicit Program class to WebApplicationFactory in the test project. +public partial class Program; diff --git a/HyperCache/appsettings.json b/HyperCache/appsettings.json index a7ef236..5e70c9c 100644 --- a/HyperCache/appsettings.json +++ b/HyperCache/appsettings.json @@ -6,7 +6,10 @@ } }, "ConnectionStrings": { - "DefaultConnection": "Server=;Database=HyperCacheDB;User ID=sa;Password=************;Trusted_Connection=True;TrustServerCertificate=True;TrustServerCertificate=True;MultipleActiveResultSets=true" + "DefaultConnection": "Server=;Database=HyperCacheDB;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=true" + }, + "Seed": { + "Count": 1000000 }, "AllowedHosts": "*" } diff --git a/LICENSE.txt b/LICENSE.txt index 8aa2645..3606b35 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) [year] [fullname] +Copyright (c) 2024 Biswajit Panday Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6583a04..842a530 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,36 @@ # HyperCache -HyperCache is a lightweight and efficient caching solution built on the simple implementation of the Delta NuGet Package ([Delta](https://www.nuget.org/packages/Delta)). This project demonstrates the integration of modern technologies, including .NET 9.0, Entity Framework Core 9.0, Blazor, MSSQL, and Code-First database design patterns. +[![CI](https://github.com/biswajitpanday/HyperCache/actions/workflows/ci.yml/badge.svg)](https://github.com/biswajitpanday/HyperCache/actions/workflows/ci.yml) +![.NET 10](https://img.shields.io/badge/.NET-10.0-512BD4) +![C# 14](https://img.shields.io/badge/C%23-14-239120) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt) + +HyperCache is a lightweight and efficient caching solution built on the simple implementation of the Delta NuGet Package ([Delta.EF](https://www.nuget.org/packages/Delta.EF)). This project demonstrates the integration of modern technologies, including .NET 10, C# 14, Entity Framework Core 10, Blazor WebAssembly, MSSQL, and Code-First database design patterns. ## Project Overview -HyperCache is developed in **Microsoft Visual Studio Enterprise 2022 (64-bit) - Preview** and aims to provide a seamless, high-performance caching mechanism for .NET applications. By leveraging Entity Framework Core's Code-First approach and Blazor's modern UI framework, the project provides a robust and scalable solution for handling caching with SQL Server. +HyperCache is developed with the **.NET 10 SDK** and **Visual Studio 2026** and aims to provide a seamless, high-performance caching mechanism for .NET applications. By leveraging Entity Framework Core's Code-First approach and Blazor's modern UI framework, the project provides a robust and scalable solution for handling caching with SQL Server. + +The solution has three projects: + +| Project | Purpose | +|---|---| +| `HyperCache/` (`HyperCache.Api`) | ASP.NET Core Web API + EF Core 10 + Delta middleware. Seeds 1,000,000 rows on first start. | +| `HyperCache.Web/` | Standalone Blazor WebAssembly client that calls the API. | +| `HyperCache.Api.Tests/` | xUnit smoke tests that boot the API in-process against a throw-away SQL Server database. | ## Key Features -- **.NET 9.0**: Utilizes the latest advancements in the .NET framework for improved performance and productivity. -- **Entity Framework Core 9.0**: Implements Code-First database design for flexibility and maintainability. -- **Blazor**: Builds modern and interactive UI components. -- **Delta NuGet Package**: Provides a simple and effective caching implementation. +- **.NET 10 (LTS) / C# 14**: Utilizes the latest advancements in the .NET framework and language. +- **Entity Framework Core 10**: Implements Code-First database design for flexibility and maintainability. +- **Blazor WebAssembly**: Builds modern and interactive UI components, with .NET 10 fingerprinted static assets. +- **Delta.EF**: Provides a simple and effective HTTP caching implementation (ETag / `304 Not Modified`). +- **OpenAPI + Scalar**: Self-describing API with a browsable reference UI at `/scalar` in Development. - **MSSQL**: Ensures data persistence with SQL Server. - **Scalable Architecture**: Designed for ease of use and scalability in enterprise environments. ## Performance Comparison Table (1M Rows) +> Measured on the original .NET 9 build of this project. After the .NET 10 upgrade the behaviour was re-verified on a developer laptop: `GET /api/customproperties/all` over 1,000,000 rows took ~7 s uncached and ~16 ms with a matching `If-None-Match` (`304 Not Modified`). + | Metric | Before Implementation (1M Rows) | After Implementation (1M Rows) | |----------------------------|----------------------|----------------------| | **Total Requests** | 10 | 14 (Demonstration Purpose) | @@ -36,6 +52,12 @@ HyperCache is developed in **Microsoft Visual Studio Enterprise 2022 (64-bit) - ### After Implementation: ![After Implementation - 1](Screenshots/after%20Implementation%20-%201.png) +## Prerequisites +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (the repo's `global.json` pins the 10.0.4xx band) +- SQL Server (any edition; tested with SQL Server 2025 Developer) +- Optional: Visual Studio 2026 (18.0+) or any editor +- Optional: `dotnet tool install --global dotnet-ef` for migrations + ## Installation Follow these steps to set up the HyperCache project: @@ -45,43 +67,84 @@ Follow these steps to set up the HyperCache project: cd HyperCache ``` -2. Open the project in **Visual Studio Enterprise 2022**. - -3. Restore the required NuGet packages: - - Delta - - Entity Framework Core 9.0 etc. - - NuGet package restoration can be performed by running: +2. Restore the NuGet packages: ```bash dotnet restore ``` -4. Configure the connection string in the `appsettings.json` file to point to your MSSQL instance: +3. Point the API at your SQL Server. Create `HyperCache/appsettings.Development.json` (git-ignored) or edit `HyperCache/appsettings.json`: ```json - "ConnectionStrings": { - "DefaultConnection": "Server=YOUR_SERVER_NAME;Database=HyperCacheDb;Trusted_Connection=True;" + { + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Database=HyperCacheDB;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=true" + } } ``` + For SQL authentication replace `Trusted_Connection=True` with `User ID=...;Password=...`. -5. Apply the database migrations: +4. Apply the database migrations (run from the API project folder): ```bash + cd HyperCache dotnet ef database update + cd .. + ``` + +5. Run the API. Use the `https` profile — the Blazor client is hard-wired to `https://localhost:7148`: + ```bash + dotnet run --project HyperCache/HyperCache.Api.csproj --launch-profile https ``` + On first start against an empty database the API seeds **1,000,000** rows before it begins listening; expect a few minutes. See [Configuration](#configuration) to lower that for local work. -6. Build and run the project: +6. Run the Blazor client in a second terminal and open `https://localhost:7222`: ```bash - dotnet run + dotnet run --project HyperCache.Web/HyperCache.Web.csproj --launch-profile https ``` - -4. **Build and run the Blazor UI**: Use Blazor components for interactive user interfaces. -## How to Configure Delta Package +## Configuration -To properly configure and use the Delta package in HyperCache, follow these steps: +| Setting | Where | Default | Notes | +|---|---|---|---| +| `ConnectionStrings:DefaultConnection` | `appsettings*.json` / env `ConnectionStrings__DefaultConnection` | placeholder | SQL Server connection string. | +| `Seed:Count` | `appsettings*.json` / env `Seed__Count` | `1000000` | Rows inserted when the `CustomProperties` table is empty. Seeding is skipped if the table already has rows; use `dotnet ef database drop` to start over. | -### Step 1: Modify `OnModelCreating` in the `DbContext` +Example of a fast local start: +```bash +Seed__Count=20000 dotnet run --project HyperCache/HyperCache.Api.csproj --launch-profile https +``` + +## API + +| Endpoint | Description | +|---|---| +| `GET /api/customproperties/paged?page=1&pageSize=20` | Paged list with `CurrentPage`, `TotalPages`, `HasPreviousPage`, `HasNextPage`. | +| `GET /api/customproperties/all` | Every row (the request used in the benchmarks above). | +| `GET /api/customproperties/search?keyword=Orders` | Name contains `keyword`. | +| `GET /api/customproperties/{id}` | Single row by GUID; `400` for a malformed id, `404` if unknown. | +| `GET /openapi/v1.json` | OpenAPI 3.1 document (Development only). | +| `GET /scalar` | Browsable API reference UI (Development only). | + +`HyperCache/HyperCache.http` contains ready-made requests for all of these, including the `If-None-Match` round-trip that shows Delta returning `304`. + +### API Reference (Scalar) +![Scalar API reference](Screenshots/Scalar.jpg) + +## How Delta Is Wired Up -In your `AppDbContext.cs` file, configure the `RowVersion` column for delta tracking: +Delta is a middleware, not a service. Three pieces make it work in this project: + +### 1. A `rowversion` column on the entity (`Models/CustomProperty.cs`) + +```csharp +public class CustomProperty +{ + public Guid Id { get; set; } + // ... + [Timestamp] // SQL Server rowversion; every write bumps @@DBTS + public byte[] RowVersion { get; set; } = []; +} +``` + +### 2. The column mapped as a row version (`Data/AppDbContext.cs`) ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -91,56 +154,70 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) var customProperty = modelBuilder.Entity(); customProperty.HasKey(cp => cp.Id); - // Configures RowVersion for Delta Package customProperty .Property(cp => cp.RowVersion) - .IsRowVersion() - .HasConversion(); + .IsRowVersion(); } ``` -### Step 2: Register Delta Middleware in `Program.cs` +The `AddDeltaRowVersion` migration adds this column to the existing table. -Ensure the Delta package is registered before mapping controllers: +### 3. The middleware, registered after CORS (`Program.cs`) ```csharp -var builder = WebApplication.CreateBuilder(args); - builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); -builder.Services.AddControllers(); - -// Register Delta middleware -builder.Services.AddDelta(); - var app = builder.Build(); -app.UseHttpsRedirection(); -app.UseAuthorization(); +// CORS first: Delta short-circuits with 304 and that response still needs the CORS headers +// for the cross-origin Blazor client. +app.UseCors(x => x.AllowAnyOrigin()); -// Enable Delta middleware +// Every GET gets an ETag; a matching If-None-Match is answered with 304 before any controller runs. app.UseDelta(); -app.MapControllers(); +// Or scope it to specific paths: +// app.UseDelta(shouldExecute: ctx => ctx.Request.Path.ToString().Contains("CustomProperties")); +app.MapControllers(); app.Run(); ``` -### Step 3: Define Entity Model with `RowVersion` +### How Delta detects changes -Ensure the `CustomProperty` entity includes a `RowVersion` column: +Delta derives the ETag from a database-wide "last change" stamp, so it never has to look at the rows a request would return. On SQL Server it uses, in order of preference: -```csharp -public class CustomProperty -{ - public int Id { get; set; } +1. `sys.dm_db_log_stats` (requires `VIEW SERVER STATE`), +2. `change_tracking_current_version()` if change tracking is enabled on the database, +3. `@@DBTS`, the database's current `rowversion` value. - [Timestamp] // Required for EF Core concurrency tracking - public byte[] RowVersion { get; set; } -} +This project relies on option 3, which is why the `rowversion` column matters: any insert/update to `CustomProperties` advances `@@DBTS`, invalidating every cached ETag. Enabling SQL Server change tracking (`ALTER TABLE ... ENABLE CHANGE_TRACKING`) is optional and not required for the demo. + +## Running the Tests + +The tests boot the real API in-process (`WebApplicationFactory`) and need a reachable SQL Server because Delta reads `@@DBTS`. On each run they drop and re-create a database named `HyperCacheDB_Test` and seed it with 200 rows. + +```bash +dotnet test HyperCache.slnx ``` +The same suite runs in GitHub Actions on every push and pull request (`.github/workflows/ci.yml`), against a SQL Server 2022 service container. + +The connection is taken from `HYPERCACHE_TEST_CONNECTION` if set, otherwise from the API's `ConnectionStrings:DefaultConnection` with the database name swapped to `HyperCacheDB_Test`. + +Covered: paged response shape and `ETag`, `If-None-Match` → `304`, search (including the empty-keyword `400`), details (`400` / `404` / `200`), and the OpenAPI document. + +## C# 14 Features Used + +| Feature | Where | +|---|---| +| Extension members (`extension(IQueryable source) { ... }`) | `HyperCache/Extensions/QueryableExtensions.cs` — `ToPagedAsync` used by the controller | +| `field` keyword | `HyperCache/Data/SeedOptions.cs` — setter validation with no hand-written backing field | +| Collection expressions, primary constructors, `required` members | throughout | + +Features such as null-conditional assignment, `nameof` on unbound generics, and partial constructors have no natural home in a codebase this small and are deliberately not shoe-horned in. + ## Summary of Delta Package Implementation ### **Performance Gains** @@ -188,7 +265,7 @@ Contributions are welcome! If you want to improve the project, follow these step 4. Submit a pull request. ## License -This project is licensed under the [MIT License](LICENSE). +This project is licensed under the [MIT License](LICENSE.txt). ## Contact For any queries or suggestions, feel free to contact the repository owner: diff --git a/Screenshots/Scalar.jpg b/Screenshots/Scalar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..075f241a4a905eb9227d0dfbde9e832a574d4dc7 GIT binary patch literal 47795 zcmeFZ30RZKwkR646$OV@L1wU3goH_vF^VWQU_uil14Eb^5io=>2V@claA33{fHEmU z2q6Rn66Sz{KnsEbVM-W;$P@^JLQoL65%=ER`|a*?_B-dkbIyHt7ax`SSFKvLYSpU$ zuVK~N=-YS)_#SEuF$Qee0sw3g`~fybxAZ|STyVSuH#dg-Wb`fLSAZaVcLD&wdHVap zO$-lP+n^5%z57<;Yo3#{pZC|_KM4Z5{GPAW0e~*mKf(F8z`L%v_&Ey@<^})9d`kMAd!Y>HYR|IJ#x8J2N|1Rz9?fVr!Mu2k$ z=YjuP*Vpis zdNR<5vhQUboCiV_A$~fUuFVcTV2`_r#UolDcQ$cJDF(z8?pyz7WX%?8auZ*%TyrQ_Ws+X2mk$PW{X#Z~07H&ZTyjrQ<3ZctRW0&z@@kGfQ_5QWlh zw(1YF-vAs|navOKTe056FImF|Gd1ThEg%R~wQ!GCrcCQOli$^x0QkEn1<~T;pGMqP zZhHL==MlWDkm`(|`IMy?sgrInJs!jI)Bd8EQ($Qnr# zU7G_+gFlpzuRR$Q_Ps>-P@&Vk0hnvt61&-K=S`+w*A4cjRhqGxP_VA<2*c4$ggPM` zft#5g^eWdJDAyc>EHoXd>XTq^3jYGFg5F+U$#?7e73z*k6iT2~K)#oZ%e}nuUGu*7 z@kK>(Cm(1%6teMwwe#0~YAgxw3f)0+?c^?BlEkZ=Md#b@yL1}IMjn3bPZMLNGsvA0 z?#5{mxoK#czT>eB^W>yVmAxLaj?X+idU9zmpeGVy@|)F6gJ#KRM$Q=NEWEMYxjxlj z+a|>>FeYBC&ko$B?9qVWth?5gTgyBHL;5_>mWy06I8KwURSQ7|pZYjG5>YGbdZ?u~ zpeOKcW0A1C{~$IPe~ehTiv#zXsr24USpdp~VLuRbB|Hd)F)EGt~rs1Z1ZcY&JU zb;#U4QOZX_K?HjpU1Oh^@(zgp`{XOV;t|PmQsXzqOssjIq}v`V?8z|cpX!Gp(z|1!qz9PYfPm>Y$= zB|*FKUWc=cksE->%#(4JemzxnI?weE`*-s_e=RGHbyGr8h5caZp1R0{jysQS+sSx^ z^ib&&{O;s7dRdmTR08=p%a2)X;iPXpZ6B`IPTN6{JQ$guT_~miN-+JtNwapKcKFWe zbs3+xySL?4#8O`Z-xkb}ZSuAA#W3~huDMLD1yf`Nw%zxapqa1-BXBAho*FY{Xb~t3 z5{6+~4U|aD6HK8ado$^AEo5CKF#c#2X%{jQwTIP_q9fNc6y!|JwxJiy@PKS6E4SQ7 z_Jhsp_n6D-qdB>C$95D*iaB}Ykr5Oi%z9a>DejAa8H3`uKObY;TjbY_3|`hDXjOgw zs{HNJpB&$2G`!5WKpkO4bL*#DMiQg5HULb-7wnS$zN?F{*vCdAG>#t}MfgFlkw_$K zKC2E1{vedmxjK1WVI_q+?FpLh%t`ej1ec^X7;E4(d(mohRw?q++#M(=Q{n0qf@GNn zhub9My_@AkBxXLB@XFrC(tso*=G!idG#HTV-ZGNJX0v|wvP#)E&DEi_-d$SS=4FDb z&$x;V_>g%g^>vD98V(;CzV^y_IT;a?ZJ`2hi2O9Mhfm6cfThmp0gb6Xe=b_PXxLq%%xz057(Aa@MD zf*;zkXEWFM^H)+&?AuzSgDQ>41Lp=41(>$#aJ)`$DQqYEI;c3*tQzdNt` z74kLf=J%#wA^$@Dmo)yr(GAHfzp0(c``?t!gtD42#2lPm(jUTO-t1m-ljZzP5semp zr>oF_aNpnwPRhx`>w11$4<}cC8hed>9B21D?mFPNhYW0WbjInGCHE>FMY4Ic!YvEGJ#mgR!xQkDA=vA}#EPhX2BETa3)3S(g1W*h2(e;Kn(6=SGD^xD}o zNaj`qk-k_-YO;Az=J9XxVo#gh_Y<7}1u$Jwz{N$6;O@alq=PN&EI(v5;knE^ux+&d zV|b*l;UNO35t65zM_+zmcV*bacYgR+Dz)}icDQq462sQb9!z()D-^lbBjd+MdVl;p z0q{FKMP_-V&OhO{M0uW!L{f+qaaRTsHUOuro4uBdcXVczX1k=>gQ$v7HE1DqJU=M0 zx!zRf^mFF#zq5OpLhf5jR=vhA71Xjn6MmodKRdnE|N8plQAhF&JMfD^_@u=Kz<-}) z_VVHe;J})>E4yxFbpv1+zxqTd8sMP~dzINuu?aB~K_6ONrWY!gKTY?2S{lK|y*ieF zd%x$Jc6pdwZCR!Nl;UhL zwiy^1*iM{rO+<5chZHBalhq`6+tP(D*gjc1nr+`{t422;g{6|s_a5-UX+I9Q{AuMO z9jsmswCSlPmlS3jMibnjmzq+30-52O?sM;WqSt#? z%S?-gTGjqCstmAw*uD+=ON0{Qbs9W(f@0vdOEFVFW8mDx78<3at<;dk9W89Dh?KUe zuq0%~?z_9zF<#wf{g;m4m5)+@qMUbuecaWc>bq(S>RMM$96oIUB1}MYvfvsKqw^ev zJNNicl-0}DU4&bCb&t;d&(@jWbEjxBH?^*?lG#=s-mttLDZhMfA=MzcxeK)JCBc;%~I zWmF$<*HEV~{5BabrVyt;-~6hSuUHI%Q2>0kGpW4#a_5I3B2mhxIwITZ zr}y(?@N_n$MoqKxvrgmup|!J&a_AnU3`iSd*;$5oeBs!WiUNFXt;E=kzjT2kjZs;g&t;>M^yqhb-KW>Q*Y`!K z06=U)dK#`gc^Cuxr{7D$Qz?-jq+CWqZ#)5w_M%+?Tk};5a)#e)n$~~ba?a|<8ubTV z>LTyAZ?V^yuf6b-;8Aot8cY58$?vt{zbk$458hbU+T`m9tP$6cd; za$aZpvvn?`hoO;-*C-uWhJs$bbpjr5ia-jRx}AIFB?2o7TTC2|zkT&7-9Itg8H@ArG0p*6#a=YFhAOMa z!+rf=_7CkW+L2T^jga5g4m}y=Rjd$EcP!z^`!PkJ_WL1QFTHKy7cR4y0ZY00U@d<) zylzc75NND7U2j_Vu|*?z-C%7~Q>GjD%)DkQBIJ8=>m`@KxC({UM-X%%$=!FgLp(HOWqGf3P#N zVnY7xDE6`kw+E#LvNH{lI*Bt830OXv>2i+~lV*qJhT7^~5;C`SX7$@u5k~TF=WwD* zx>XSgw_2JGHu##kgjD`ivvT_9q6uTDlsm86Ky$www;K+1s-etD0O+1~h>Wgx;YZ>q^Bx@xG=B-UjIjZ>no_r_5! zH=189;%b7iu+67uGoF%+{W{5wmF@((k&b+Tel!oiV4^msrhuDir!DK6!a@$VII5H_ z3|VhQG*vp+aOGz&qPawA_ayy{%c?Zyvl6!N=*P^tw`1oERcds4`o|&&G)R5b3gW_HMeOR54&`bOOfyMy(XYk>sH+u zf9KoQyw8O+cd_?tuaSAQ<5_cINsYAXs$q?}IV|OVZ^;i;lz z(I1x+SUqV>ASx>4qPwn{%1FJdT9qHm>5#wLq>JZ#cy&Fuvj`o7K@~X=b<36N{XBuS z}#^!kLAIhz6UXGaS9SInKvXH2>9X;OUmMoR8UO=TvT%W+c(2S2V|J$j1KJdXO{t z!m{}VxgpZ?YFk$}qJo7?=G&$QPeQcr(2aaXnj>oE=iSLXVI~$qg^todFD@U9NF`YL z2EBvuOdmmB4ch5xvput0ak;A@o#79}=Ts-_Bhzy8!=>W#qp~*u8ic0{1B@bABvPb7*86yX$KyQ!AF3d?NoXC|4ZM!`<{Sy)Hm7DB;^Cr;k@Gfnxg zg8!3~fM>jIv|Zh+*!kwQyxKC{z+@){8pF+hfIkUSgPnx?v^HCbwu)V6eL1-SaMvl5 z{h_SC*Mck}%gxQ39mq+HzO`J%QQK=)D-qVVB`@Uc0wNZ|wv5zkM!JE0EfN}1gyP;i z-1)U4Nw*!P1dmzFYbjXFeP5EIrpL9h2{lV<8l=Zurc$|3zcLb(3h90~aJ3?&xOuns zr-k;@8kd=@o)j@q6SoCV?KVRmm8ve^9w{dNVhj@#@_6qvEpGc5^OEI~T^dAT$H$b& zxbicISFAmu-T>RQ5l|(hb(b^cqup-GP$K&JQY9IK)noTi z;dog{?gZIMmu!@`gxA~voYV9VfW0lU3KRje(g@z~dL?pM9JZh--v@|8H4?Hcy(5q$ z-LB*#sb+3>_wMd{<27BIg?oDb20%(pF9s2M>O=jfL~@Bbum?E;LrpwV zl)6NC#)&o6<=k0#-d4l*SIdGUc`m{qBAg51G3ku4<7^8Xu62u7bgK(%?Qj`Iispia z6NQsNs6+&E>=9I>{#_@eSzdG{8NeL!SmEylm8Dn0G}~=Fc(p*W(Jfx6(Oc`zc-Zk;vPYg7 zf*_{a`#G_B?|5~}bGGQ^xnG-gQi|m2RKs|&A;P~zh7GxhX+3qflea>gthNXq9Vaq5 z;D@iBBvjOXEl_`3%8KDN`!#n#eK|4=%HLKFhLk@W^FsJljec$R<=Wx$BAgfcX+Pdx z+}`9T#TP)A`M6WPecrhnfNWWXFlk$qviVj1Dk%AJP_}W=NBkL1^$l56(S_t+*E0R1 z%6en(EcMWkvr0^o5BMY=o`$x?-sZNvDq#mM#WsnP2U99706c z^BM_DUG9>|Y)bjceOGb)(Yg3grw6rld=omXo~ zu#avt+>}5ore{EmR_B1nXUlA3{WR8n!D(AST4s{}s*6Wl!s)@J(wVukbdMjP!x(r~ zw^UG^6yXpur-ENtBh&bpw|96M7b#m*Ecl}KzxC%~Ly%LA&Be%DBWJFkGhBWW5F-C{ zfyK7ML6aKMDTfxRK(?MMT@MP>b6?eq7ms6l^4EL&!bGOT)M6X51t)FwJ))c-^k`H@ zaJvPZkgfg6JM^sbv-bhbrY`gq_0oV-C9&deXew?I+)j`hy>hr^dSq?eAjIa7*p`y;P0$S`xX~SKIit z+gBY~Mz+{Aq16&G)vGfPXDF!$3N`@oW^O}p(-WcB4?FqSW%*8aXTGT;qQ{9;K3&3| zRUOP66K{m>sa@Yko7w=lu*F@w`gLr*JZ2^xFI7|#oxEVk@>JXFUl#60B-&lyJ%L?p zaJFP=!#4o+i^NhKrv2<2G#5d(^tJ8=#RhoSH-EV-1`em;R`g2B&NkusNR@1T9$I`K z>SfNyl!#(>L%g+Q&jz5h1r>au(^i_1Go#8^v3IlR7}D#UHmCY@-DTkdgbXoocD3LQ zav6sfx0Hs)HgTzAQE4QQPjEKK)7qzgaTF9~ww2l634YTVqR1 z;+@Sw@ZQIGpbGxFYakEGYD(&PQRH08n{mRIO!X!yUsGz<@TxAVK2FIUJVox3o^~H< z6ak6;pdj^qCcO~uoBlDfv8+;JP-*J)6sRA<3Nd(QW7svPHQlxDNsLeVUhk_3%70(@ z53MuLWjL1A?G9(fCAuzN&+PP9oq+kjCwC#pd+DG!cuXp!Mu}4yYIW5QFy-=0b&3tD z@^sz+kYdg7@D7WX7}CA@4S=%!+=rv932pN2{DYV*-}1mb|R%(_X+S#^Zey z&c>r#S_1ddjTDU4&2;syhCDRafU5KKv>FR@KP_!}y*ooTEE?8A;~EksjNba(rG-%2 z;g`(!s2X6EsUbzt4cekBS}tC~aYRr;utqkjrjcqh6E768^0@70lR$^?zWuBFZJPWC zg_9T=5F^33)jrxgSE%=9ul#XrwCMXGQkP6W=TN!a0O=~v_&Sr1LMPBku>FJQTLX`G zB|>V;Rvz7CMWJh4ortO}#V%hB9sa$w+qSTdG%UD-EiRtyKif@=pmdSoelhqtV6*y< zPginQ&%IsR_Es%evSe0_y3Xgg+1ev$jdXCxX9UF--OM<)81l%b^<|39^)9}JB|m~> zf_d=mf3qr&gVg3_$O(%V;QOxOM&@DVDas8i-^gl+G!rJ zET>nl=yfu4Qk8g`^670A1y;>x@sm5U3#IeL6lB#rZJ3K^yu&iyd7ls6cfMjRjm_=| z)L@nJgM~}0Z1a7|dj>>PEnzp@j4b)m)nKvQAtx~P#jAPT zlyAMY88H*!{d|reZlY}k)rQuPFsiX2D=jii6Wpm#Suc0M{C9e48*u@uyj*-5s&iYN(8 z%ew?4j8Z&{CAqCZ9DZ=zt@3jLE@gdvqB`G`&8z}!&xaVJ(D}&>^8>FBCZsm+dnQD< z61sO~1K>0x{;VvrQ8*yH23K&@vOvX?gpLGOEkyYwCboKe0n2`|Pb}8f9{`^&o%X1x zN`tpkq2<3&ERaN{b__%ntw|IkaE2SVJl|GDU&}P)BY9VGdLbT8Sa?;&Zg>oqBXx!N zNGqwYwD0G_C>wi^>1f{Lut2aT5h}t`S%$&zGIILkncz=nYh?iMS6)ntg}bq}S>g^I zWom>xLvAUOcT}Y-%PL@HoH5apV)e38y+2m!dUq|GKpriaV(aC5BN}zzXu$JQS0R@{ z*3;YCUre1g;0KjB^p|HjV(hIVPe;33A-lKlo+DQ_|3t4XaH>(iV}7P45)@c2I;jUf zNmhE%b{Phwm6XgsFZrk6tXnN2NZs8-uRL9b1NhIYwek)ar=dFPp@DDx^@_$aF!RS ziLx?1m5%VW$C&7aVWX@^tJ}QZZ;$_@qG{KXcMQ_X>cx=(60jYSP6WOx z2kJD|-4E$2^lyS^>l$15#^k0W6Kt+|y}M%3x12Oe6oZ}&_U2l~9P}Mcsp&mb!Kv82 zTTM3f>`_?;aoDw}$ap-rh677UUlLa+opUyWS<(siW}UC)+4ElnAC_2F^U)*PI=;RZ z^uhs$M+#>1K~3^nuNixO34srxl5~lZFt7srWSX~4^5G!|afk0E(2wQg9bZ=Z`B{G8 zQrm(mdnk%}trYbS3d+iPz)H4d)Gc_THx?Rt(8a|$P%uqXMlVnhGv~Y9;hZ=T+HRlM zud+&uJl&qgQZcQn-nXYAWwqDLf067TKrjb^T05H%1WP0`0jpkGAOg){%7_w)G}arT zxT4QT*j}m&8b3Ju!E@*EN~ZqP;rlVK5BR>3zoj%&m}Fw!Pr+LBaH@kW7M!a`x8HDx zCBEjsEXwn{?PgiGmlt~DKJa8y!hE!(0_b+>E=nXB_bz^MW?A(Ei5mr3AU}elxTFzX z>6H^{;Pvo8?<#|nl`EyiC2+*z+rC(B?Iuzeo4V=Kj`{vCWb=NlDk*Z&t*>#Ggn}SI zviFxq@vM6fA7((YS81MNe=`Nl+Vy9ewVUyn=E--#SeDdO(J4xkcvWtduTQ&AQnQTs z(eJmE5j*v%hU4@$eM-30(gK5AmrUkR)aixov-RDwH%?PUckB`~m z*ypYC6EcjvKXh`(`Um?k&!r`B)GR0z$&Xz;MxG^P`y69xxp!sq?0kk_#>y6j;owxL z_%e-zcE>9S^YmW14SCTQLLEcqP5{KG?Al|&OfsrS7@rVpZF~&@83U?y6Efv}9+`XL zz?Nql%-wN2ZAO~=IX(*Jd&a1GO?!Uc=BCP^k+6m=Tj=MvPdK1U!!_rdXE@ce8H14% zow2l8qTeb91Nv|)cf9aQX8MH8C&BPY{dq@yi0gVTHN=pQ=IENDg7huYUU$vGL(#lY z)3;M{JtV1)JKd~ZR;$BnKQd;euC3dhwcB%XoAc`QRZtvhPMYU=DJ^;3#`o4s`F7=7 zVRyuWyVcY{RuniMgiIt%=%gH9VY@v0GcEZ?_zolWYMoWZobEFH^htIQ~9b@>yHD9TtZ%j)wVESYn2|77&$~VFScM z^pq+q8s}#qKB1?@a8-VE++^Qd_ZTwTBICQ{JZR39gk_?nA|YE;uu)Z5ni!}h?r}Xr z|0yZ-dc=-7EN+!r3XMH|?}c$JG%*^UPMn*1H}IYfF*@cR?a*OSTd}s2L_^NSzr9f- z;?RMpH87OhjJHmztp2phSv*qvmq*nQBNMiKmMA~zS$&m z_pijx{+`&p%Bm6U)7s9E%~<4l>1LrH{#6uR)V=S`y6`u-5FwTIYS!(J@MiBr&;L=W z{sQ^uKX!n>e(M;;Z2qb?Hv<}wNR@&{#gRD?2w!VVp$2Y(8h|f757awT`@T{|blcMk z_M+2vSPZDU8wX9NBbP6C3kgNW$z1sy`qSxHGR9A@i-Lpu|A2ztxu2)CT})Q}_OwNO z>wPl7GmpphFV~2qjlff*x}~Beq7$uQ>Wy)F+)sDQv(t@PK2hn!*&fauVz5jwxpWD{ z|J->)$9GC}`2-P~@ng1R9}94$X-&&EMZ)w9@8f#mzlM* z22#)rcJN*`JFzX?Hm9A@7TlSII7LMlI5S4RT%3AY5z=RRGcmjqZNN|?dBKS8C}mVC z86GsE5J}XXw*x;$zw>>!=Y5rt-cSd%Dl^snX;%$|fhm}WxVTkRRbA@N<@D3S_ZjC^ zlnLx}r9%D08wTIF*?vx+-xd^DHfdi^w}KXUx)P8BT}(Pq=f$zdRaGf6<$YH!5J6g8 zq-9Ar+#;P+sAfh|>?T21?eF{8e3I$g_Vh0FD2yElZEKvuS}sR<63Fu-nU!vmBg|;! z99;rY3e^p@*^&EW{xF9><;foF?g? zJQ}e2h~3#V2KUq;*f!~wJ$~WM)x6R9$jhwJjeUAF*UUHGblD0~MV4#_>UKq*mF<9p zzi_5+VMYD)x_#{0y?e~x3_A=D{Hdn>r!oqvvOjD95{sTy_AP;bz_Tr8&u5p>ILH2NQ^RZf~E-kD-0O*KY)Bx3gVsyqu*@`X>#pN1gJ!l^iH4dgT+1_(=WkL4&JZ zlS#83WIrGHn|`;9rAE+Pm{gR7Ma@|3bJzDLN`pZX z4Uls99v^env;3ghd!L`ZU)!-61Lj6bS3vv-Cvb?tk3`fZpzFgrI^K}DD76`Ht0Z@V z#MJo7Z!!i!d|DaG6m5m-Buq0QHRRSI4$xFYAFBUr3!Nps<5ItW!Q9nGv~rbywq_oR zK!i#V^3Q&sn3U8nV7}QWx4-qwxq}_@+A@!ye2c&T?*H4^K%@&_3I8$erh5rmZrHNI*8@FV%@Nm$BYj=!ye)6D0YVCf4O9ys}z zYAX29YL)cMbWvNvr`+s0g%{dFS~GL6{Wg|Hc>-Yy`oWt7(q@jQGq_QiZs|@gwdSSwWW2tS`9^bZm0+W& zTR0Hq9-?`b-5G0zV!EF)7hJKjl7RR?yg$oS916?${L6an?QfEF0NDIp!>(;{`<9`< zi52;7`{T_PKlz3we&b)o|9R0Bxn0*4D4yZK(-9TTE6J3l3m>;`-hBPT5wYtZN8B0L zf7qJ1A*b?3rB7dy&D*rJG~Be={UenBJZyKAV?b=vo2f&c{mC6=#?$O^7D}|c$bXHJ8BvBP5=z369RbW6(z?M2<*Pcq~c%bmC21N;R zTfnCG?bmWr)8=uG)V?O8{ zOji=5-A?o}+e73@xh?Cexset8z^uoe59M(r%i!Tl;xFRsDC2LqplOs%8>ArATKt7m zcR}%^HaAK%u$D4z&i(nywtt5IOiC=shC-Y-kCPBT?|Q081(RfwDI=})tv6> zH`&$MZ0_zG&U2gWZt{9UV+-1{+L%E!%|}i{BhQ{n4LzV@j2~Z+L?+IfJyYM>Kt-Ja zC%y@={)*oA@6n3iWZe0iV*a}0>^B7)vaAp0IxU@frhcn|f>tHLF@-f{h`&S1`-=#- z=dW3O*#Nw|vsv7$tk8I)Ad;|5*N_G~}QLjfU0w(I-J7ya; zcCG6#T{PFfRNhlrss47i;h1n5B?>QB=L>asPiwtB6Ba+}vU;|412EueFeSdm>E}DH zb9^=c7whJ>VZtq&bMSKkjHZr~{-iO*r>%aLaPqyPp!O4(7IQsZ?vUemZFkS}8F6p2 z@x~hfOec*9TFkF#$#Buhwqn5ihj$g!7@L~Dc|N}O*kuDSt0TV{z5b#0O^LuzMLK@P zXajIfb`fmWpA``uXa+X?x*|+s=wJGkUrx7M048s^DwxZANOf?YmO) zdy6R!fzp%iJ@XlQ?kS>O+}V-d$INcek@2ptW#c!?dULINC>AZ6`GUJP`Gx{32}F$J z_QnOr4S+(Uz}SbI=tX~@IV)Ipn7+NbSz>FSnkQ$xfxy`D4JzUq&_*#90~yi?$j;GT zp*xxKZ{*e1cc^{K{)P+a!?`bl5F{AEN#@LCZdc5yo{|dRH&#Kqj%=`ShTHKewyq(> zIoeG3OSRv}Ijyvcps($Fde&c1@1At7O;v&g)gy#3UknXR+3|okfK}mkG?!2cgTYUg4FT(=+xqH!(+GvK~DlF5T(){(iTHH zuU(stZ!OagbA3|zO^RPvD+C6hOeC%1n{2yQB2Wyev^Gc;{eU^t-VVI_aUN)bffJRL7Tixnxa&L)6w~syiw*cY z!So~Q%3^QjW=Z+NimJ_8Fz)7fS)VB^`-PC zjTMLZ3putJX3FN`!oC>euz8 zK+G|Pz#bYU$TD3F=boY2<434in68ia^0;s3z;4GM854ex0WEDI-6=^PhmPV_|foXLJnA?+)TX{~Y?p-8#qR->Z!yW%*>cP4)) zJKODT1A+x|%9Fw7wy%Ud#J>sk_BX7p#EXy+nzK+QjXgK_xzO>HGd2h-`v@8tummTA zI?#S|Ar+2_cONHcvH#kdO{J+R^uSt?(FIcwXbw@SC9PL`Ea})n_fY9@!+lR;W6TU? zRI|JgZV?x&TCiHE8-MvzQQ}|q?wdUSLDR219&8>LytcQgWD*VI}KFVS|*u6d{(DxDs!o?{q6&`39t!@+Puw3qT2=jO}bJq6|Q;JB+QawGdnP%((Ud0 zl2Gz&s#!kM*S!#Y-htcsfyJN;Kd7t2H*-;Bc4U}mYjI~*!OU5nOHdH;Z85XkrU~I~rcJ90IxE>?XLFa%r<;`q z@$l5i&H|1^JCYvqDJlx5z3?L7=XUy(5f*K$-_+C@sI4X(`;wVXaI#>UN;cc2dKCd| zXp@EP+BgbU(oDArI~>(QAWCr5qdgBm@bg?%o_(xp3eMSwhb#mR!SP3?nk>BCVHz=n zgwa%2vk7S7EY1xJjg)p*gk#D2`tMcu= z4|zpu-WNT)&EZcq?oToxYXjH4)^{d^2g;{y0B#)l6EuOhmw)~O+#k^RFS+-(HQ(AA zqqU$+zGm_LcXV}Osg%h508{vU*8iUYPh%^cbw>s+{ejT`0{Wl75A)}||HpIw)#|@o zD*TV^x!Dmon zmZUc0t$@tUBF6tJ_*F#z63)Lg=U=1azw^~0`VU4|`(%i^Rm>i)@{k=OOyI$wk3aY6 z`Uc=__R8~phyLi3Bb&FEx&1f0)jvY{p9%YJa&7IaBcuMZzqD|DQts40Tt)xCq0tw< z-M_yb-q{$^kvCK1;zQ2;KFQ2ZDo%d;=YX3NKmS1o3XY&yH#QwrY#vSj0mlDSP{y}A z-JY?ke@Wcv_#;;T*=f0b8NGs0Ny1?biNQ;94-q7BAmqydRoS^23Y=Aq?HB9|4I23O z@@2xPv*P{h9^t>r^z=;@4o7TfbVbm+EX>3oh-Af6Wa@G4h!7pU6EK*A$_RD{LZL_UG#oVY#z=bZeaTde<&K zV&YDG*vhkYU;F`v&tsCLio4C)Re^;|nY7@U0OMclCfTL^)km6iF7->Vlr8gejf|X|f`fHZ3dXl{DP0*Cn~-CN7LKPJdc8*66YWuTWopswiekqK z(+!)dKJ16nLhKu|8BN*VJ#g*=F8*0S;cyW=uBC(=2v5hiYc3?oaE=9(y=0{ck87`5 zHZh1cDot8H_Yj93BfiAD+N!&-aQ3BODQz?Q2Eh0Syn{cm(l~~wqG!BoqS)KG+?l6( zv#hM{#}x(PCG9a0n*nAKKG3M?RGS`syQKDfg|A~m@Z=|U^A_%*z(eBtVyrdAtd6ip z=vmfiMuzmo4qu{Sio*wsHw?uLaIMIwmtsfTFE}fY;jYOs;qrZ#*}Uzg`9^dvGz|ST zWk<^Phw~#zANCy-qR-4m&zP0tJ2I%@0vBarjole-?(DNkIy)_sj?=WZH%EC|+h05C zR8ply=TDd%=i{kRx*E5$fXBz#hPX4kfwa`r1_IjN?%Efj_PeniWkr5K<4CC_yCV|{ zJ1a3!(_XTV4<4!gwYIF^G7Ux-5;e`2(J>vuy=?7BpnXpr#Rrz8FFiPr79(Zn zT^tjrvj1SEBDE$|uAHV4tP^hHJith)E0QHip|S;=6v3j?@Fthtnjf`dM$T{yPKl60 zG+K&`<(U0tr-8PewMl!{s0>pyt-_lGCkQNOqYYB5A58|`13dVnLH1t;`|FZtPzwF+ z(d)jL=80?ZCepR3-GjQC z9AUb2RM@Av=}~)X*WOE3;}qwVV+5dof#roo1Mcz<}Um;PlC~o~xqvfS2Tib6#zqLbI5tt8kPQGPf!{B(;@0r}bn{)7vSBVOBzU62F&G z$jc#_*jvp)@Q~2!`mS1L<#f=h>&L5ht?XyBA1xT`V0s{D^k;xJ3Vgl{L2+GaKa1?_aXSEUcrC$O^TJuuExQ}{6spPZQhxXv9X_(llY33!*Uf0>E!(QhUB1#^BY}Ic zuef*qRp8&2QUVe2Ld{4DGg5Hkt3&8N4u9QltJ0)>o4!_U`|*!3|Eo^u@t@!KTYH$1 zXp>B;voo5Z>rKd#Dd6bZ&&haKq_P|X@V4@vGpy4(Zf{v~{laM)d@Dj&>dAzHvFyP_ zU-XHoq9C{1p}IbMo%iWq7)#Gg!guMX7#|W-#xe+GTVzJMlel3-w!Uuyhrck(5xG6u z7B+0A!nKOPz!o|JwL7W0FY57R#E>4)ovh97m8;a6EqpH|iF5G_QnBqI^i*{Da-#^D zN76zM`LL`pWX<{1Y5k)c0J)ip!A3F;Xc0qoH=E>GA}KK6mL!`qx-CE*MLwK+a?+pU z!e+52Nr5$_oyMX%-h*tYCP_$F-_CAKLGeYvTH$x`20{T z8(o0W)d5c1*kD#WlCRDGG_v#Jrt|GT>ZLz{iCD>xh1FR2f4xfb_{*m2??1-;>ondU zbM@PN-haovKf(-na{AJ5R|uZ`5z2oaCaGzOS$cZy)6+rq>f_se7B0w(UfN<8t|760 z#UVE7^;Er2_VL`#_{Q48Yfd)3o)}|w!`H=HFZ@*Nhh5GPRtq?zcg`whB?=}z(jwTF z`yeZgbd1mFB1c_zz~}SXQA+W`SCWXk*m|DVv)1fVwL*2C2if>wk^eigvDi_SX>P08 z$TF#MOLTmc#HZ}DwGAvym5Cr_%;~F;=8!|F7*iOLO|sg}k$i`JUU*i?dGvn!5FIQ! z-JYHeEe!O-Mm(FJw|yhC#V+(xbE9P7Y0ye%XGS+R$T-qS_Ebx_J2*T2?4IT*bE-tx zIV*Y5lM6=RObOoBiceYbQR?+gqk%qjpIC5bR07IP3cDAaBB@zXSoo#zqkO6UjZJ)G zqsZ7WVz_Y&3xuMW83JpLt+HoKi;ka;JI*1@FC;OaJAaiyuGO^`PII2{ra-Fk7#|l~ zydM;qkBM1qt%5)d+B91af2$uL*4(%|)Z*OVr2Un1QNaKHObhoBrwj2x-v5Vbq-gET zBVosYp9H?>8)b=@ove2MTy7UZG&QO|#;?0M?wYr&7Pd-_*maxGjfU=;bvE6HbnK6A zcC!i2Z0?2iS0y1RNV={_l&AXSi%rdbP1@LP9h=YCH44)^ugN?bQXhkl=DN>BBkJN@fZ2m+xTskOOsti=+QII5}0m1lXnR?mo3+a zbELI13SSn%bqZHqb1xV)GumZg0+$$p1mKI|)jc9ErtsWs zIAy3M+kyAv2!A2N%$p<5#JF^{f|9EZ{KDWmY$(){rf7P?JITsSQFFbsA#vF!{=UfB zNSS#T2b15Bz3{p{Rgrod04_P-|2@&Dn~uP(n;U=#?dU8A z8i>{Yy;+wJWqAsLlAF6LbDq>J^L#oW-mJ_>wOY4{I@qlM=ZFVuOy)-kgEEh&MZFbR zdCSu_0B1um{!LV@afu8(yq_ZxwgLKE?7_EFh?5Eq4hx!7G%SfluMOsJo z;A9}kH#d=Rh2Zx%SRb;K4DvsXxs>HVZNx&=1h>$MySiVdkz#S)Tqxw@>^AHLVgQ`o z69SvV@N&rlhXiOS2n&yJK2~bss)2b`-m}m^yESF`ro8VE8{BQDXjtG)I)B+B*3AOB z+AMa)$=URa^+~4=DP^y>{hjPXNgT^NtEe4IE-xS^dz|A6Wv6r+@?K_j?4kM;=i#9K zBNauu^}EU*GxT>&NHi772JxBzY@Axc%ycxnjfArtwYuIgLkoMVA@G)#Lpqi|K~#bi zxKD>3;GPnPmYEUn{8~;y&(7gRY?wqdRZ&^3GZhR-rH+EYdr(o{+lHq}xGFQ*6q<6q z<`g0+Pudy9sJ9XbA;?r{=S5|6YeLeeP4^L-1+}c`*J>zdhX85R6K}sBHD#%4 z;Wxu5{cyNPxWTUj8AyJDCpOfI2JwU0(J88x`N=bOo!X72k1pm3U@-#%|5|n+`3!_; zYR`eEL>o4-V>b9cg};MV{YL})Z{4_zwf#o+yAOb;|JqvhjAiO?x}j&z-Msq`Qv9z` z%l_GVzV#={+V!UZKzj_QnhGU(7*cGSs1&cE&La_(?HZfkegHg3&=y|&j&LpQAoT-a z?~q`?j;yZcO{<0zm1&JaQfA=E%{%I^uX6Jzq02`o_vL~3VdrK^Td|4kvF6SP6qZ7o zv>yj&f*s!AWu(`gtV`uG!y$T8J~!39Y%XdelI<=#ty|z{^jfeNuJ+VamAuryY8q4l zuc<`1sQ}BK6OboP@e`w?Wwt%Y{64pU_M1Y-=4hL~4d*7e8mb}}PH=W?sjd^xyxw%G zPl0A>4)#~icLvqB$~6V} z=_C+vm{4tYZ#Avz9toDY1aj=u$G5zf$>r2Bpvy$Ain+dOlVvP&5t4dxY<_WM!C>1+ zl(}21{uZdPMJ%CrHV?mC@I3BakMaQ9d-{nqtX3ymYGRT@ThM;(kNWgACkI>@lS1eK zf@Y0^5XeqVnCB-)%M0q&75(^*I|&t2yL(s(W{1p9|9GV%5|8xxi+sb{VXa zG|7opkzy*2DcugNvbAncI;a;PktJRkE3YtZ?#?zI+Y3x5O;mA9dkR2+R# zN=CPB=TQUw%~OHruflt%h=7$kTP+jg8UPnIb*MWx*mC~JqnVz3nt5(TK0h`#wyUNo zFS%XxL(0^yEoW5Fo7GTw{?_xkS8tgpj+3hsPQ*wACl(Foay7xWp3r^|VK0m6B~Q;K z0H2K$B4!~OHmCTZu0Col1&ywfrMZtm#hR3Gg@@c|a7h$i*827cFa+O_9NQ&#$8mIb zI@?OiTfC-Xk6<1B z)IkW!rt>P6_<}LUy%?Qjr!`8<`YVZ(VeS=IQA|%%Vs)AQ$*s)vH4dIzCzPOBc~!kX zdO9evH~8)B6!42&2q$KobVblr`&xjj2esCVsFg46BZ~7j%`#s&^FUh(`WD!s1rBdd zpNJ~fsr)C36~Fb5pYiS#+=*Ot1vyOLpZEX(h{=%t;P+>*&;7w;{(asbt$|OOrwyfu zIiXG&$iOMkyqn|t^6?R4eH&#hF~09~=nb`G8)IsXRl9WdGk;w*oHfR`!aJS}flzoczUy+Y*?xH=u!B51$6QSa zOQtWV=bb}QR>TEQT$2|G7v4-SOGxV^Ed3eCq1&5A_D z{4MQ){tTV^sUfu?#*a1j38(uF^^NeOTm6Yo(&ze-Q@cCJ0zQJ_W;}iLSeJR=C<{^~ zCAFL}nvx;nr#Z@lH+P~hml8#}^Z~2Uyi=JJS|cnm5bft^D?6*dL`C`xMG281r`DB} zpIi!x04p4-vks@Z&DMPxe?pb+4=F1UL`2vWdWbR`u^zo~2NYtzph7nXR0VX?pUJkP zow=Jj(6c`KXl8}w8$aRwOKPZC%kco9Wy0-wx_*OW$SV{=-Ku@v-q6f!CInnZ=4=F& z2SJ$XQ^xztQl}6vl;r}=w4!n|k875D8$`~@cQ)&z+xW}X1oU|xDYZIX>Ik87g>|%g z*xgalG$NrKgi~`xJR-|i>s4~&`8*|jn@#1oNr>qC2Q>vO6=+D(J~7f-M2ii!SH5r4 zoSzeyz&a4pYe(H{?nhGywyV9<(6K%C9C$Lx9vf zM5*4~9rWq7g;4!~t5<`V5))-6fF2Mr zVh+zD?}3(Q*Fwq(M->q%T)wMK&}!}xi;EHvogR`(q8xa`RD&Jv*NYw;!a2>)OBo#UMD%Ap;uEQ1ADgEhk!N zg~17|4iL=l`N=-rzUAamTd#mVe!J%_F~FLpH*f$+)t3Qj+r_2A8lTs5ny;m|IPXjt zZs;Whc6#T7pl(pLJ<=R!52dhO*GX@?08|U{tG`Go~wT$6zCl?oorRUS!w03 z-Pz!J`iE@1k9^unHcDRvBu=XU)8|AGO)K9Je3!Qj_BQk*DcwK$?Z7-ioISg)`N`%; ztSzW<_YVXA$s3S=_?&;wjh)}bL1kUBkERny2`#61?U;TsdV!ejBbDu10KP?yE{RcsQ^8%(soVzT|*^EgW6hqo;RiY!F{_o}g3~Ai6 zG5hL|zh3|MUHEI8|98I&e~s|p=J*%XmH!rtHvbty`LhS4ojyGgNt>G#1!K?hK~42$ z{nLwF@auP{c0W>z-2R+z?6$lty5{z7}G={jk0|pa!K|%wZ=MFRYjm?^1X?$3J#gxq z&NGHHRHdAe{5eh5!9eROUHcD!AHvRw*`F?yKsF4vIa5B_YayNfq`)Arul!$46>jY0 zD9mL*HWHXh(o@ytYS2*A05))6At6S0luB zQ_jB{SEI6Jw!Id7lUlX`qVto^J6HWx=LGGVDV8iCA}C;Cs;#q7xh@_xohH@qgn5&F zRKerQ>=vU(%e!E{SSf{7Pt*rtG@as8kDk8!>U&=CK$n$iDYCSJ9W})f_7RF`1(%($ z!>M=~F(5qa@W2Ov6&;5W=qMOLrir_J`!?h~ZEDplof_Rw?a?`uxb~>->(aW%EMhX# zcvXZ`4=Z8pPY9%_*80vVM|5lcHR^-~dx>)v+CSBk(r8lp#)l`0b@{3}(@lf0* zh|dlW%Ix6tZoX_oU=U~rs!@wA3=f+dd570usaC-sOXNc`+nMm24*Wx$Gg3uU2nx(G zB+BFHu$LQtX=m~t>+-N&9Hk5>VHyQz$`f;aJ^=EI_ZLaK@7kxze(;6Bbe|w&e{*me z;U-uwpVk-P{k2_BhLc@-9PYGHcd$&|j%ud0$IIDpvD00IlFGVbG$g2RUYiL^=m4jm6zY{zNMl6@Y7e<6>5F0M z!!DEs;jn@X2uANKDjp&|0xZ9_L3)*GS2xL?t+|PlpkOM71wGp}oDoF91Wb68OFUj9bO0R@7 znd+b{e~vs$%{APF?sX5V{c62%JH+I>UxWJ-pE(-S=R0+UmP6cdme$eC!uRJ^B652{ zaU7uOHv>UyI#CNFUS1x+Zfsb#rf$jy08o?(lHU}jqh@XSP0oZm>UF_oXKlKzQBF?I zVEESTlLzB~L;Q{YN8#!>aQpZ;g#=bOUdaG4w67ak^#P!lap#Roz0wfT?3cG@)JrEg zs0_C{W4b_%59#1{%ui6|{9Gemy1r{2eRbIWW!KsV5~c*J!M=i+0WQ1K>ml;8<1! z=UDt~jajn&fB;$1Q_N;GKnTpST|T?coT0Td3(HFu*NOay(=UTfPfXPf8Mi`WdI#E~ zNo2LnH$A)JJ=9|(LQHCR%xoT~3J!Z+yn$F9M6xDl%0jaZh|SlyVUPO{)#s954wu{H zhW3IILOC`$!miT7i5#-DEc^wkEnY^ZWwdkej!9$VJZJgUBx?hMo;otIqQ81IU8XHQ zKPBPfNs4sK;rlDbW=XgXBFnotC{SFZ8D~A;dIy!(4pp{Mku=qMLu;CD!;3iQnRaO> zlxshG_DaLWeefOS%+0KlB$ehcQt1n7xXe=C4y^k3fD1RuE{Mu70R}LS- zOB;UoQ{~i+=Z`aAWaBC(2fLk-2}7PYg8;^zNdZ}&vkQFY zHovl1zib{mgl5Vba4|;clq5I9;s@*j)6*|aWnT&;Il`5>eY0~Vu-ZXYNM^MT)esAD z(Op>adc6E}4{gqyIO~iB5()ri#uiX?A8Y;`xp3F5sju;wk%R4Nx6h zHEPU5a=C$!$LCJ<@{J+O^HST-vHb*YxZ$a%Z#(LGZ63G@aJT~!OzVj9Nie>QQhF6y z%1tI<`tWNx^@mL@J=x!ZPdMiv9n!26$Vh3bcvjLpW_wfT!+ZtluSAR1%-2hIcdS&@ zocmXu)ql$U|4yHQJA%l{%5$ZkH1Telx3vQdB->)Q{|C-fEbr_q-a5eP}2 zvnFD~#J73E!2Rwg;Z@i(h;ZtP*7@q(5pfg=9`0w$nBHj8bnCPqDp%ENalxuKXz>PJ zZwR$SwSYkVdOzO~`0RC7?B}bcvNw?I-^G5us>k5f1o=UU6$P98jEpG%Q|XQ^yE(Z1 z&N4EqW$>I3VjR9(fX+isNmF_nayz4Ha@UZ1isbe0w-_ZIi{?3#FqEGGpq z(o&Rr+w8Vf)@#@5fpU@CXaWljMRqjPX-cN>%WchJf?wt9c*mx;th>Dn)@-NUV)}}j znCAD2ku2HX1P6%i5wt!CRFRw1Ud-OrIxBZ)k%qNg73M#%l;@L>G!x6SUd-k7@AH3? z*Y4@0fNjd84=BtSRa<~9wgM(w6^ykRwKN_dPx3*_XB>zNSWvY9?W31A!c+4f@!L92 z^b;x{>DACpdpE@P@}HzGkrxC)TT0qbGVNgL-cI=>B@4#=Th}(q23zlpR1@WSxnLO8 z$_vJOz?5J29hooRnhg{cNm_;0mwA<*{96K^UOwawSWHC0Zh#5JU9Qn(p3k1;^;(?~YmPX%oa_GGtD%7HNK#kd zgJN3JoAFWEiTkP$aiR}^jeY}5XM^}6(?nuuO*toQWtK~D6?g7dpGD#!SB58~m2z>UP6zJ#AT)}0yk|7)IH&KY{fwk@^tU1}{2vialXM31sQpDW^#Be5s zoB`fdrGAzjP2Qh^zeNEDO1;TFtf``~0MXa4a)?SBD>*yuhdZqr-YGTNa zSILxJFa{49zoa$y#kAHqYsTX!J{CwjvKmJ`1wBQfgF2R8EhTwQ!kUjCQ@Z9VC+HSpUuIYu7tCZboS538ro~s4X5UTS`T)2o zgs(eDY4e>gA2qKhs347Afzu1MoGqD;6t#3v?9+)Qw zTIwl4QXiV@>le0L);}*{$cYwr{!%?zKUiK<$Ifi8CKcw7;DG_2s^=+L<*C|XX2Tt z^qE8j7qe6KjZ}mxE~BPB1ISW!xm=Mqx6)u2UeJVx-*a*COK(gJd#Q-X8Bvitm6s`x zm5|bMLq>P3G=&AQFEe291XCyqjuRtOw1i&&zx_P<2;CN69o#R)bgcy{TTR3e_?^1< z`^us+!7ng6m^82PpG|$<-67cE^jB0_*M`2+!OW@C`@bwUx)6SpPe7Z4*!?8Uvkx(S zqe%AY;=N6siXMy-(Y<#?Pkv=HKLE5iG3@tGAV<(yezAS5m4gf%Efnnomcm$=qsr){ z>wN`NsR~X`E{3-c^a|LeHB|f_OG%0f5R2cPBQ#uc`G{Hc3cJ!?eHJ3mpXq&@bM(54 zdtbGfPFQc0om?kl)NHnQ;fAZ*3h6LEt3vP>jqNrSB{9n>?IJ?uFJQsq zmMS(a;0|H;1^yYG3dZ?PO?@{n&|qMDVv$ZWKGU!@%-DNddTohjg?Nr+J~td0JJT4S z7FYO8OqP;1b-{Vtow6g%7CQ{H>v5M4t9w}+c>65TPdgWnG zJ+NKLddlsdOHJ&0*VqV(t@bG&{_x zKxC6@s6!+3i&nlZJCC?L5kK*xbIxsbhIpqqIIG3R%K8A)ce?p(c91c0UJ7fMR91it zO-FM3kKgNU97!x{JmTCUt|tADg4(GQj+$rkt24*#eS7_~cW1geV`Y6t*B{`p>1=}) zhQ3`7ea3@3{c4SDDghK12*mN&Li93KKf=KR(#0*l)yhs==A>tibX&GxK%A66%fAvZ&sFzh5K%Nbp5*Tx=>_xQEp@8o%>sJoi!Q%U}IYRnWY z=>)Yc3GQ@hXWVjE?M+-Y6UD(0xpdT>wv^`UY5uME|Eh z#Gkyqi#$yjt~t7W>CtE5>6tFuKUdMlx~9G+G@mVyIiqhoUDGHBl_6uR_bj6Qh$s7 zF{Mk=@t$XXq^k5s-TixCfXb(0D}0ndvh0<8fA`e8e=nD>0GWT6*6(5g{zpZEQ4I^* zB&|n(ybA#Awm4p688{eu539T%-(g}ZJ7Xg{tLRbqjIwq!Q&uiRGrF$1n+lyqq4iH~ z&4JV7Fh;ZO4evY1rsT2-aAHn^>@z6rez_WY`)EPowwbJGuw%Duqn7ixmib2{C(*WE z&NwVa3JvR3-pixeHRqkA>}9W}RdZ_`>>iPnA>KzEvBk01eVb)j@4n_j6x47RVFp|k zxz6d#Io_JT^t|;*CS_nqY(U5K2E#q;AKAB{2=_5+jJ0Jeg*?lT&Mn@LD6oSuVKo#vI&FH| z3OzG7GuQg#idDrryQxG*wG{$twx&^I?h1X-mfQ2IDNP6DA;c&CX#;WZwrJbm5oiLp z?P;3RWxIe=4DWI{PhV{c)_j_8=cH$pN;V3aX%3NU+!<~xL87@pq_}j?@JvqX#5%-g z;=ORyVJxx!l;;5)`Ko_eKIME>9|9}3WY}lX3~Cp;zC%4ubUt|X`LbXmx!;~DU{FivNiAxqNq2~yr+Crqn9P?If1FNzne<2`~tI& zR%jX$;^>qrRYi)CZPX^|YBSlz%iowb`kP0q_LP)~b-*4Nr}M|C+ExV`Ya>=gTbDYR zr3Nl1kchql!?O|n&2#fC52gCGgC_NzWe>e!n_}|Z(YEQ#1i3JOve8K9n-G8NkXf2* z9Z~5^sukQ@^lb{>stxADp3Pq;klU*Ucr!36wupt|Fzez2%?%TykFFPnw|W%sPB6L% zc6M?M32B>Gi`jEgBBSi<;8=$18E_F$8c`-qy$}-GA{U4sS@dM`?w7Ph6?DU+MO5P- zdF}B!CCWVFJCE{iu}r~e$6H~3{)B5!qE)Jc<&)7}ht=yWP1XZ*-V1+?Eg2|9P~}Uz zr-8nxqwlC^?865*C5eT4oz~)NtS@B`oMCyY!2(3|F9@yygd;q9PDJJNu+8D8HMTcq z`)7ZybSA1;Bi0!DQnV^FB@)76-2a#B6txcOsn;B9(5mKMiJ>MXB5wbiAt*LiZ@cs>ReWsc+#ut#ZC`Yh8$f4u8pxB}3Zm1FgT4^ilJ~PurN7iO7vW)=3Ud zDhVJoY`A>PAHq_5}G+h@zffLC+#CGI>slicdnAY&PRJ&IJ za9>wQa(r+i2!BT^%b|sqK2#!qnp}9ZC;IrS@Wpu6HPl4k=2FrmHq#lmbSqyLaJu)DNtKc6Gm47LeHGHt zYAcTB{oXP`1~^(B$kvD3F(G|A{h&c97v*DLJ||RX%G}6prQ0#AEVxuvH81%Tr$+P# z0OHpjWiK64gMv%RE2d*|s#GQUl;@+vhm&u##Ry7PLmKM#yS<%)7JB&XIx4Dlk#wB) z9f>zFTg&v5nm}TtM$|f1EY(42YEFawxk#2m7)zDoL^FKGl3t3qbSUwR0(dl<=h)mw z3gGrpAeo*Zj$JQT^k8h!8noOZR))L5%XLCI!{Wze_MY{+U!%A<>lo5?>`B}#r?0e{ zO@Xk&G37L#6UFEmMPHbOmUTwbkYvN2f(f|%H@VGD^9wsY=cLryuXPs9Q@{wfWywU! zT3HA-O;@w%Wf@}d>V8-+FeR2)u1!abrfjNRwK8jZx%9NVAsWRJlWdd3dY#&F?T%fO z=T%gqG9m$BNVtP1mmzNtLU$70(#GSZGN4)SvZNkU`Dw>a9UCCldTV4LSFVc*jfg}gN4=*Kot=DSP`D;VjT1GGpa8uovfS9tB*}|XaUi8IZ11+Y<7IbpK<5yVAb;Vrk#)!^E#^qdX=Gl zr-uZt>0~NtsXgMoOpAOoJb~rdV1I=SiF_XA+`=DeWLG`~1RN^2cdQOK%Tu>3TC;wX z$KeVhy{d}(-vy?oWUEUT@k1LESGujgBf0yzk2f2#m-QY3Q&F+55f64$R*`ajkiw%Y zU$Jo%pdy~xZt8MW?m-!~hUi9B>wOMqhT6-M*sq6gK{T(V)4e)h>^bpGaX>6Uxzx#u&DG#jEZ z8La*O;REQHZ?+}|s~#DYG0*oD=acF2$gHx1B5N08e@3Fi5s3A-Yc+L}{DCx+7KR>7VictSVwWY@k?U zm{BBZd)1zqQAw45Otcj5x!5JAEpjVwSeYGiNBd%bJm#}mmU!lUNu6 zQ2**FlOT)L-ip-DO7>+Y+@i_J`jF16vi=0w7U`wdGQRW0D?@N5-V<--J&Sds`{&cD zJPu`8YnkYjHNL+4Mr-Jam<&S9<+@4S67Ta5M_ybrp38t=(~PUj@DDtWsfJaCp{Y4` z*za84JR8*+NqUp(e|nnDSrt+o=_ZZ5v%umG5hEOZO1(h_`VUp5$(( z7HLq-Qwr!9Yo^B@3bhAw#F>sPv*W>ZiB_#+4ptZC8c{kMmAOe%a0)25-1EGcveK#2 z+wE$C%jrF#*v-#tXlMwf<&qkVp{inNInZiR-vqKgYx2#)_BpXa#_ONNY~Yb=ncr`o zFA;=ad_-hc8-u&~$z+Ka1-zj)L)uYcVWEcCDesm6fm?)RbQXlpcOR}aoh{%zKaO#n z#s%lqyFLm{+djt=C-(SLVoC4sGClxWzZL_(h|2&rC0@+i)HMFAlP{#Oic9$5Ha&R1 zQ>}|nME`xt#W+I9gnZUcts96brH;PN%%05Nc+hIB5Rst23n`hazW~2%V_^%q?J3Ow z;xrz;j$Hn%_ygeN{VjzLfZE8{yWF>}4sV?K00M~wPI;@j9ie8pc68g& z=+^DsvP$0>%*AVxZ+-wsu+ytmRq9ZZ&CVkaypj%1nBHJ`4)OV%f|*WSB{y*adWWkw zu4SSu-sQGqG|FDV(dQ0?^khS|9em^Zk@_ntMpMmx>1gF=8XF*D#n8Ya5>p34&@9sf z>`%V6LU3Pagx*=GX{jAs+>DV`c!-1O2QgV4!9ek3gzl|*;NE5Y`}HMVFOEVm5}Hml zQEL!e+^c`*X$>`uG?RNVvUbqy6}Vt7BFjw*eVMZVNVEYBYL~R=7H_75HdCJv^gku` z^$ig0o5Uu6d6d;LdAsMrA5ZqB#ph?i8)iG9wg<1S?dTEW7}j(JOZfmgj|xQHq$nSg zKN8{SINg9vJTBQm-m0tb%zkeVEu{c`Q=`v7otsoN&PuuIE^mOEdYU;Vn* zwyZeukjFv%PFkX1;0p>YgB5rcm`oI}0(N{?-j9@=b6;G3;mjwEVAXu`Iyt2cc)bYVmZXIeFYFb8X{M9iXt^iK+#%6#5Yf~S+hX9@<&r} zcwC~Vss%bz`Qf;YW<}=lJ&VuUKUI{me1?bENJ45_OhZhu+|RU!fNZ16WGEsN2cNE3 zeQO-dRXICC>2gKxk4={Q3|pYbcP-)4O|gD47UhM^gE8zm?oHdrz?B|C(LjG@xxZ{gq1w2!={NSaJiWRG*m=0}&v(Ty{y_nxJHb>Eqh&OD-3J}|c;n~7cu!4Aat&?W z;k1GCwA(GSyffU|ml2ox=BXnt&C_?JUr3~Gq-7kYNvvoY9Ea(D45?k6745&%vz{{E zIDH|)y5l}K!TV!KZ*i2T13olyx<3uW-dtTd;rubA!v7}z=ZeJ=#h=r_KHB!#Ob2n_ ztN$`&+fem={np}pgwY28esk0fsQ3X;mHp8>;p2UUh2g_Z7qZrl*B_6)e|G+6FnqDd zBk=70@!gwi&uc$M@(VPW!@LwCDJB{qc;{JK*T<^-t@hUz9)uM5Z;w?AtRro|rQ2DL zXw9SlmckyU;LcE(jSmhI|0K}fH#TvZVuVSRaemI}Y88JzLRa#*`LOJa)eK|2RZX^jqC#CVc>ivbH9Y{?_mgU-l9ug$*`{_?8FE{uPi$>#R&D)yHu3{NEQ$q_$d9ExBQExp zuzcoip68g%rV8({eXUtjQK@B_ag zWLs^P_Sj11=kCrtN%>_ucA@j!vLUCEK=Od(<)x%RK#Q(Vz7bM|;h=u2prhS<7LYe3 z5d*xn!Bqhh82WOyNHUk?l2b`lt>C=+xrkLW8CX};oE_|bb;x@}ACDxHvQ5WhvT5_e zE15dNLN>{FXWNR|;@HW>L-ud9a0eUdW+9JK@ficDP3I=aq*{{6xv^gxZSK z=ZPGOo8&#aMS`R;8I#sUG zqIpkj zgtCqt`K(Zu{Y`F_K<81m{GoWFvNQ!Pyr^jA{xIz-zD#uh)fUK{8XWb_L_=(D=%Og| zE1oU1_ScoKjo!D9in9|8UAWOKyo@uq4^HJDnWh5N! z_Qn&!pFY|dE!P#k*R;Lyhk!xj<1!DHCZhJ7U6?qw)_C8^#Azq&kv@v~7^V{ArrfETI>ccntsSHuz^+ZLGaot12@pEaJ8ZP{Hv=&C>O`UPX2T>M0%rt^RhLLh1ZebX+M zw{Uf3#rM+d4XbkIYGyAL)*mw2Q<3W<$8{vUJ%17I6w+z@^j~6Y{H~|_%f(Ug#ErPR zWWo8(CVbZ)<3avoOD%hqQ^_+C7IRX!7bdp=j}L69zH1jVW`1%%d#W{=JE3XYee;uh z0PrLyiBu(~{ru>j)A(#V*33^#*!ju*Go#K~OzbxtvG;o24${{+{cjwt=l+oZKKgw= z)kFKZdFG$0`(t70?kj*Hov`w&>V0R?ao4T*5!0 zs|}knhQZ(VU;OTn8xV%kwqbLBR_&n~LVc-5mg+jmap>EfISJdX)NmkSn%~o-(dK*~ zG~JVgpG3J8PDF)m?=HJOD7ai+xf(~2&d))3%CQ$NKnpyEx;%J|01OpEF>Hp3SCO&W zIK8Z2EeT_W4X0BG^=;O1?-b%T)Ax%P9DO{(o1mrJCvS*1tHXO5VyzVwx~8(c$_>n; z@6{0ZHIUE^h&72$Nqe2wV~N_m#P}6fWBDPsFG%CLds|yqm7mA1D^v-cc>Gk}G?xP0 zKVWW$D4`S>juhfWE$SxP%eu#U2WsBUlUW)wQ7W_T`*m{qj|XcFzkBkl*yiLqa5j!& zoxMH>G{#avZ?uf1xsCy zia)FDqvJE;x&>CEdy(5^+0}Ie_LDgMT|Tz@`Exw5yr=47cYU+u$nep6+zml9 z1smp%pzZ~Ciy=JLVxh2@<1X@6BIb@qd#rteORmu%^ooGq*nq8v(=dCTaiLjzl7vx2 zge&DH&NM|?1`q9rQDo-%l!x=<6w<|5?#SZY!p?`ljIKj(@>aJ}P%s$sr<7=Ys5cKC zKcrFwgP0&|gUsGOXumm&$lVLiju8V0%&vPoR~A6DNb zojR#RLCPb6bRlK{Swpchj>|}oJ;F&vt5sna+WqXh0-Wv;`<^4FY-+q^hV$ zz|2-Fm<#hfEXC8gs9Zw<)9M-|Kg4qhz5Ro*;#V4^GfGXsEqa|JhVpE(JuD3U0czCP zT%>;B0Kl&?F2pHvXDJiz;iL?iCzsD(wbY@-8netKU%~fIW+nj_B zQ=T!{TN|nevqCp|8@7)Q%cN}qdUnz(V!G@~;nC$-9tCNy0!t3yL>m>@Ts%IGGx10y zKF!lIkh<9qkFK3LS!UWBkExRlXJt>VRf_qNCtmK+HH44`Rb=EY-ivdwJ93qEMqeHH zQ}I6NX^MCTugHP|ZPo9MJ}myDKH;zaJAqN~{4&Gh%n>cA(&731 z5bWWkCWHEO0Yj%J=VDr;)#D(K^T+Z@OuS0kgENM$6-&{IMTM<;gQy!4pzm>@47oPu z;DQQPy-pA3GS#3ihGok1dpm(VUDm>4-r?0VSq4$_6Hq+2(xMI(?sm-B+2;vpjGejw z7i2(FyO5U&!5C6Ovv-uzC3tF}xh@tK*WPB!T6=6j+}Ksll%j;XargwaDlW|-B-f_- za-8j8dnoOYwhcby_P)zoC35(tHXT`DEZWVUpp|gGqt<&B}$W`;40o%s>2C`C2eJA z=6IiH9jv-7aeejFi@X9pufA9*8%6}a9K4YURBgRr)EUrf!iry9Pi0oyvYD#p(RE{} zfJak?&)|u=P*nCni^PN|nl$od(xsqU&)-mDQTr-R9 z-qu@L3?hNwl}Wpo{t`Mt0adr(%I4k3bJs_sLrt6N9%`o zsc&tcX?}Y#TTyc)TCBhMUh=ixM~@#zf)8qrqEy6C#yNMyh{cAkjRz`IAFM0?dNWIm za-1lGztcJ3>e8H4PpU4rILpgRYbx#gc$=hz!IQ2Q0rLVnwBBe?6VZO3jcrkkjaw{< zo^F}HwCPI)jOk@LjC-^%9PdcR(=;&f5XbgWOZSlNTc6hp-Vy#efxw{`fE`OSqvBq2 zN1E_sU%9?=7Z7yi_BpY~@Y0NukCxcB?r@U(7nK^CVobCJ06^*Pr)z+jsRyIrV~l_C z(@~2U&=JFA{_=<6BvzZs2i6d?BY`>ko`D)|wo$JLx-vtg+OsjnA-nGb_`|n0t;13F zYLMI%0^BbdPba~>)in2+Df;)nQwX1XScY*Z7_mgdi5Buwcxa#`34td&Sy|PJ62YlD zdrlo9--<|s2=lyP;`P&bZxIaahC#1+Bt9=)uU8tdjM?TJ1hYUn0-}bwkI@=utz@zD zT=O60_Ag*dyZ)me+Qtqqro6|>R%3dL&Es+TQ;_^=_>jOH>jF#6TjV6>=Ly{+PbqeT zRXiWtv#d7Zya#QvS&9~wl*HQBHl25;TxuS_)H_AMh8OulA%&i?-nKdroiu4_?$W@s z>xZr@CFFO#BvdeW)1BKbP|!lG3mn1Kx7M+-p+371<2|1 za+Dmfw&J)`a?8Qh_uc4cwZ%9O54Hvk7LRGvror^b@(oj7Tobrx%P6uNL9c(UXO)rE z5rH7gprv}~-ae`X|9LgZEj_t2tE}DVssEMfiLxLIa8C|HpbxW(c<=ss-!Z1G-1^jd z!IFOXjlKrXmkD`O+=6LSsJ>J=^NWy%6n6}4F49Y^9sW1W0EpC>iAdJP*{5LWR+zyG zC<7lgJt2fyi7H!Z<^wom)&6KhR9ocpcHg-7HhAm0BDL!hJK5OT&Lh|oukEmQQ>qOI zL^c-|`V`pbS`8t>Ze2|X&|a-BlR3B;U$&rWI$`9j@J_^lx12tqRk7{)iD~-WA5}R( zDbgGVVxZ>eJW-iFM6Y0;J}2)TK&!Hk56KgAB+ETjylZNG^X-vq$Qr(ME^M?1yrODH zfoj>P^+vhmjdDpjua{GiC+??lJ?yr!ZdD6gzyal&E=yC6pN7GeI`(q?Srkta-(X58`BY~y?Jg2Bw zB~k&>@B9)?JVkQ>+o&qj#x#mdBi`-odN$?1m3fQkH)B~h`k3ki%k^hSgNt!6ga*3QigySSiyylfHnvUK0rR1^tWUy=FnR0+xC`=@vRP&WVU z#;;-|#b05YrC0u0ASltPhaq47dIWY@J9*~2IB%OOlZs`*oa!G&I{J}#Tx^m)^&2SA zzosD1pXB_;MV>z?@x{kvrvdvrVWhzc6*V&uJN4rt!QXy_Q?G=E)V{3C09(Ft;+OI6 zNMz(|7XR*N|D1>X^AQvi9H`b%DBAx(t;vdVGSj!;=Q6+(RK}80KaW#ylJBvjvzMQ9 z93d_tg}ypCur+HAXpXNhh6h_xK@zH&ZT zg_L(;t)eGDy?bjUu(}vzpIx7to>Xsb=razGz4EsL|KFVG6ckjuIKQ_2a>>kn5FnfM zV0l~gzvutYau~~qDmiHO0k94@@W-{Gd0C9^tG2DYL1vBj4G=d>E&EbCbD^727<&W< Pt;yY3UIZNfFz|l>dp|?{ literal 0 HcmV?d00001 diff --git a/global.json b/global.json new file mode 100644 index 0000000..f3e8020 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.401", + "rollForward": "latestPatch" + } +} From 1e3f622d93ce32032cb60bef7c6b6a012e2032e7 Mon Sep 17 00:00:00 2001 From: biswajitmailid Date: Wed, 16 Sep 2026 00:43:54 +0200 Subject: [PATCH 2/2] Add API smoke tests and GitHub Actions CI HyperCache.Api.Tests boots the API in-process with WebApplicationFactory against a throw-away SQL Server database (HyperCacheDB_Test, recreated from migrations and seeded with 200 rows on every run). The fixture takes HYPERCACHE_TEST_CONNECTION or derives the test database from the API's configured connection string, and refuses to drop any database whose name does not end in _Test. Covers: paged response shape and ETag, If-None-Match -> 304, search (including the empty-keyword 400), details 400/404/200, and the OpenAPI document. CI runs restore/build/test on ubuntu with an mssql/server:2022 service container and uploads TRX results. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 64 +++++++++++++ .../CustomPropertiesEndpointTests.cs | 93 +++++++++++++++++++ .../HyperCache.Api.Tests.csproj | 25 +++++ HyperCache.Api.Tests/HyperCacheFactory.cs | 91 ++++++++++++++++++ README.md | 2 +- 5 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 HyperCache.Api.Tests/CustomPropertiesEndpointTests.cs create mode 100644 HyperCache.Api.Tests/HyperCache.Api.Tests.csproj create mode 100644 HyperCache.Api.Tests/HyperCacheFactory.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7ed72e8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET (version from global.json) + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + # The smoke tests boot the API in-process and need a real SQL Server (Delta reads @@DBTS). + # The SA password is generated fresh for every run and never written to the repo or the log. + - name: Start SQL Server + run: | + set -euo pipefail + SA_PASSWORD="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9')Aa1!" + echo "::add-mask::$SA_PASSWORD" + + docker run -d --name sqlserver \ + -e ACCEPT_EULA=Y \ + -e "MSSQL_SA_PASSWORD=$SA_PASSWORD" \ + -p 1433:1433 \ + mcr.microsoft.com/mssql/server:2022-latest + + for i in $(seq 1 30); do + if docker exec sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -Q "SELECT 1" > /dev/null 2>&1; then + echo "SQL Server is ready after $((i * 2))s" + break + fi + if [ "$i" -eq 30 ]; then + echo "SQL Server did not become ready in time" >&2 + docker logs sqlserver >&2 + exit 1 + fi + sleep 2 + done + + echo "HYPERCACHE_TEST_CONNECTION=Server=localhost,1433;Database=HyperCacheDB_Test;User ID=sa;Password=$SA_PASSWORD;TrustServerCertificate=True;MultipleActiveResultSets=true" >> "$GITHUB_ENV" + + - name: Restore + run: dotnet restore HyperCache.slnx + + - name: Build + run: dotnet build HyperCache.slnx --configuration Release --no-restore + + - name: Test + run: dotnet test HyperCache.slnx --configuration Release --no-build --logger "trx;LogFileName=test-results.trx" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: "**/TestResults/*.trx" diff --git a/HyperCache.Api.Tests/CustomPropertiesEndpointTests.cs b/HyperCache.Api.Tests/CustomPropertiesEndpointTests.cs new file mode 100644 index 0000000..12386b0 --- /dev/null +++ b/HyperCache.Api.Tests/CustomPropertiesEndpointTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Net.Http.Json; + +namespace HyperCache.Api.Tests; + +public sealed class CustomPropertiesEndpointTests(HyperCacheFactory factory) : IClassFixture +{ + private const string PagedUrl = "/api/customproperties/paged?page=1&pageSize=5"; + + private sealed record PagedResponse(List Items, int CurrentPage, int TotalPages, bool HasPreviousPage, bool HasNextPage); + private sealed record Item(Guid Id, string Name, string Value, string ParentTable, string CreatedBy, string ModifiedBy); + + [Fact] + public async Task Paged_returns_page_with_etag() + { + using var client = factory.CreateClient(); + + var response = await client.GetAsync(PagedUrl); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Headers.ETag); + + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(5, body.Items.Count); + Assert.Equal(1, body.CurrentPage); + Assert.Equal(HyperCacheFactory.SeedCount / 5, body.TotalPages); + Assert.False(body.HasPreviousPage); + Assert.True(body.HasNextPage); + } + + [Fact] + public async Task Paged_with_matching_etag_returns_304() + { + using var client = factory.CreateClient(); + + var first = await client.GetAsync(PagedUrl); + var etag = first.Headers.ETag; + Assert.NotNull(etag); + + using var request = new HttpRequestMessage(HttpMethod.Get, PagedUrl); + request.Headers.IfNoneMatch.Add(etag); + var second = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotModified, second.StatusCode); + } + + [Fact] + public async Task Search_returns_matches_and_rejects_empty_keyword() + { + using var client = factory.CreateClient(); + + var ok = await client.GetAsync("/api/customproperties/search?keyword=_Property_1"); + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + var items = await ok.Content.ReadFromJsonAsync>(); + Assert.NotNull(items); + Assert.NotEmpty(items); + Assert.All(items, i => Assert.Contains("_Property_1", i.Name)); + + var bad = await client.GetAsync("/api/customproperties/search?keyword="); + Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode); + } + + [Fact] + public async Task Details_validates_id_and_returns_404_for_unknown() + { + using var client = factory.CreateClient(); + + var bad = await client.GetAsync("/api/customproperties/not-a-guid"); + Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode); + + var missing = await client.GetAsync($"/api/customproperties/{Guid.NewGuid()}"); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + + var page = await client.GetFromJsonAsync(PagedUrl); + Assert.NotNull(page); + var found = await client.GetAsync($"/api/customproperties/{page.Items[0].Id}"); + Assert.Equal(HttpStatusCode.OK, found.StatusCode); + } + + [Fact] + public async Task OpenApi_document_is_served_in_development() + { + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/openapi/v1.json"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + // Route templates keep the controller's casing in the document: /api/CustomProperties/paged + Assert.Contains("/api/customproperties/paged", json, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/HyperCache.Api.Tests/HyperCache.Api.Tests.csproj b/HyperCache.Api.Tests/HyperCache.Api.Tests.csproj new file mode 100644 index 0000000..deefeef --- /dev/null +++ b/HyperCache.Api.Tests/HyperCache.Api.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/HyperCache.Api.Tests/HyperCacheFactory.cs b/HyperCache.Api.Tests/HyperCacheFactory.cs new file mode 100644 index 0000000..a0e6350 --- /dev/null +++ b/HyperCache.Api.Tests/HyperCacheFactory.cs @@ -0,0 +1,91 @@ +using HyperCache.Api.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace HyperCache.Api.Tests; + +/// +/// Boots the real API in-process against a throw-away SQL Server database. +/// Delta reads @@DBTS, so these tests need a reachable SQL Server; there is no in-memory substitute. +/// +/// Connection string resolution: +/// 1. HYPERCACHE_TEST_CONNECTION environment variable, if set. +/// 2. Otherwise the API's own ConnectionStrings:DefaultConnection (appsettings*.json) with the +/// database name replaced by . +/// The database is dropped and re-created from migrations on every run. +/// +public sealed class HyperCacheFactory : WebApplicationFactory +{ + public const string TestDatabaseName = "HyperCacheDB_Test"; + public const string ConnectionEnvVar = "HYPERCACHE_TEST_CONNECTION"; + public const int SeedCount = 200; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment(Environments.Development); + builder.UseSetting("Seed:Count", SeedCount.ToString()); + + builder.ConfigureAppConfiguration((context, config) => + { + var connectionString = ResolveConnectionString(context.Configuration); + + // Runs while the host is being built, i.e. before Program's seeder executes. + RecreateDatabase(connectionString); + + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = connectionString + }); + }); + } + + private static string ResolveConnectionString(IConfiguration appConfiguration) + { + var fromEnv = Environment.GetEnvironmentVariable(ConnectionEnvVar); + if (!string.IsNullOrWhiteSpace(fromEnv)) + return fromEnv; + + var fromApp = appConfiguration.GetConnectionString("DefaultConnection"); + if (string.IsNullOrWhiteSpace(fromApp) || fromApp.Contains("")) + { + throw new InvalidOperationException( + $"No usable SQL Server connection string. Set the {ConnectionEnvVar} environment variable " + + "or configure ConnectionStrings:DefaultConnection in HyperCache/appsettings.Development.json."); + } + + return new SqlConnectionStringBuilder(fromApp) { InitialCatalog = TestDatabaseName }.ConnectionString; + } + + private static void RecreateDatabase(string connectionString) + { + // This drops the database. Refuse anything that doesn't look like a test database so a + // mis-set HYPERCACHE_TEST_CONNECTION can never wipe HyperCacheDB. + var database = new SqlConnectionStringBuilder(connectionString).InitialCatalog; + if (!database.EndsWith("_Test", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Refusing to drop database '{database}': test connection strings must target a database whose name ends in '_Test'."); + } + + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString) + .Options; + + using var db = new AppDbContext(options); + try + { + db.Database.EnsureDeleted(); + db.Database.Migrate(); + } + catch (SqlException ex) + { + throw new InvalidOperationException( + $"Could not reach SQL Server to create the test database '{TestDatabaseName}'. " + + $"Set {ConnectionEnvVar} to a reachable server. Underlying error: {ex.Message}", ex); + } + } +} diff --git a/README.md b/README.md index 842a530..b40cbf6 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ The tests boot the real API in-process (`WebApplicationFactory`) and need a reac dotnet test HyperCache.slnx ``` -The same suite runs in GitHub Actions on every push and pull request (`.github/workflows/ci.yml`), against a SQL Server 2022 service container. +The same suite runs in GitHub Actions on every push and pull request (`.github/workflows/ci.yml`), against a SQL Server 2022 container started in the job with a password generated per run. The connection is taken from `HYPERCACHE_TEST_CONNECTION` if set, otherwise from the API's `ConnectionStrings:DefaultConnection` with the database name swapped to `HyperCacheDB_Test`.