From b8ade932b89d6ef2617ad4b506d9096aefa9692a Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Fri, 28 Aug 2026 11:29:30 -0700 Subject: [PATCH] Modernize Imp for current .NET LTS releases --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/dependabot.yml | 16 ++++ .github/workflows/build.yml | 9 ++- .github/workflows/publish.yml | 16 +++- Directory.Build.props | 7 ++ README.md | 2 + Samples/TodoApp.Tests/TodoApp.Tests.csproj | 1 + eng/verify-package.sh | 34 +++++++++ src/Imp.Tests/Imp.Tests.csproj | 4 +- src/Imp.Tests/IntegrationPages.cs | 63 ++++++++++++++++ src/Imp.Tests/KestrelIntegrationTests.cs | 74 +++++++++++++++++++ .../PageTemplates/IntegrationTemplate.htm | 3 + src/Imp/Imp.csproj | 10 +-- src/Imp/ImpMiddleware.cs | 25 +++++-- 14 files changed, 246 insertions(+), 20 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 Directory.Build.props create mode 100755 eng/verify-package.sh create mode 100644 src/Imp.Tests/IntegrationPages.cs create mode 100644 src/Imp.Tests/KestrelIntegrationTests.cs create mode 100644 src/Imp.Tests/PageTemplates/IntegrationTemplate.htm diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 53d4882..a726fea 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -25,7 +25,7 @@ body: id: version attributes: label: Imp version - placeholder: 0.1.0 + placeholder: 0.2.0 validations: required: true - type: textarea diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9574d36 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: nuget + directory: "/" + schedule: + interval: weekly + groups: + test-dependencies: + patterns: + - "MSTest.*" + - "Microsoft.Testing.*" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6dcde9f..f8b1e1f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,9 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.x + dotnet-version: | + 8.0.x + 10.0.x - name: Restore dependencies run: dotnet restore Imp.slnx @@ -45,7 +47,10 @@ jobs: --configuration Release --no-build --output artifacts - -p:PackageVersion=0.1.0-ci.${{ github.run_number }} + -p:PackageVersion=0.2.0-ci.${{ github.run_number }} + + - name: Verify package consumers + run: ./eng/verify-package.sh 0.2.0-ci.${{ github.run_number }} artifacts - name: Upload package uses: actions/upload-artifact@v7 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 742329e..54a7d73 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,7 +37,18 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.x + dotnet-version: | + 8.0.x + 10.0.x + + - name: Verify project version + shell: bash + run: | + project_version="$(dotnet msbuild src/Imp/Imp.csproj -getProperty:Version -nologo)" + if [[ "$project_version" != "${{ steps.version.outputs.value }}" ]]; then + echo "Tag version '${{ steps.version.outputs.value }}' does not match project version '$project_version'." >&2 + exit 1 + fi - name: Restore dependencies run: dotnet restore Imp.slnx @@ -53,6 +64,9 @@ jobs: --output artifacts -p:PackageVersion=${{ steps.version.outputs.value }} + - name: Verify package consumers + run: ./eng/verify-package.sh ${{ steps.version.outputs.value }} artifacts + - name: Upload release package uses: actions/upload-artifact@v7 with: diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..e7fe5d8 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + true + all + $(WarningsAsErrors);NU1901;NU1902;NU1903;NU1904 + + diff --git a/README.md b/README.md index f12e152..dcf1bfd 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ Imp (In Memory Pages) is a lightweight page framework built on ASP.NET Core middleware. It maps request paths to .NET classes, creates pages through dependency injection, binds query-string values to page properties, and renders responses directly or through embedded XML templates. +Imp supports the actively maintained .NET 8 and .NET 10 LTS releases. + ## The idea in one example Given this page class: diff --git a/Samples/TodoApp.Tests/TodoApp.Tests.csproj b/Samples/TodoApp.Tests/TodoApp.Tests.csproj index d89a7e7..db9cd06 100644 --- a/Samples/TodoApp.Tests/TodoApp.Tests.csproj +++ b/Samples/TodoApp.Tests/TodoApp.Tests.csproj @@ -3,6 +3,7 @@ net10.0 enable enable + 18.10.0 diff --git a/eng/verify-package.sh b/eng/verify-package.sh new file mode 100755 index 0000000..4169e8a --- /dev/null +++ b/eng/verify-package.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 64 +fi + +package_version="$1" +artifact_directory="$(cd "$2" && pwd)" +package="$artifact_directory/KitchenPC.Imp.$package_version.nupkg" + +if [[ ! -f "$package" ]]; then + echo "Package not found: $package" >&2 + exit 1 +fi + +nuspec="$(unzip -p "$package" '*.nuspec')" +if grep -q '&2 + echo "$nuspec" >&2 + exit 1 +fi + +temporary_directory="$(mktemp -d)" +trap 'rm -rf "$temporary_directory"' EXIT + +for framework in net8.0 net10.0; do + project_directory="$temporary_directory/$framework" + dotnet new web --output "$project_directory" --no-restore + dotnet add "$project_directory" package KitchenPC.Imp --version "$package_version" --no-restore + dotnet restore "$project_directory" -p:TargetFramework="$framework" --source "$artifact_directory" --source https://api.nuget.org/v3/index.json + dotnet build "$project_directory" --configuration Release --no-restore -p:TargetFramework="$framework" +done diff --git a/src/Imp.Tests/Imp.Tests.csproj b/src/Imp.Tests/Imp.Tests.csproj index 9e727d2..c8b4966 100644 --- a/src/Imp.Tests/Imp.Tests.csproj +++ b/src/Imp.Tests/Imp.Tests.csproj @@ -1,12 +1,14 @@ - net10.0 + net8.0;net10.0 enable disable + 18.10.0 + diff --git a/src/Imp.Tests/IntegrationPages.cs b/src/Imp.Tests/IntegrationPages.cs new file mode 100644 index 0000000..3600016 --- /dev/null +++ b/src/Imp.Tests/IntegrationPages.cs @@ -0,0 +1,63 @@ +using System.IO; +using Imp.TemplateManagers; +using Microsoft.AspNetCore.Http; + +namespace Imp.Tests.IntegrationPages; + +public sealed class Direct : BasePage +{ + public string Name { get; set; } = "World"; + + public override Task Render(HttpResponse response) => + response.WriteAsync($"direct:{Name}"); +} + +[PageTemplate("Imp.Tests.PageTemplates.IntegrationTemplate.htm")] +public sealed class Template : BasePage +{ + public string Name { get; set; } = "World"; + + public Task Greeting(TextWriter output, DynamicContentArgs _) => + output.WriteAsync($"template:{Name}"); +} + +public sealed class Injected(IntegrationDependency dependency) : BasePage +{ + public override Task Render(HttpResponse response) => + response.WriteAsync($"injected:{dependency.Value}"); +} + +public sealed class Postback : BasePage, IAsyncPostable +{ + private string value = "not-posted"; + + public async Task PostbackAsync(HttpResponse response) + { + var form = await Request.ReadFormAsync(); + value = form["value"].ToString(); + } + + public override Task Render(HttpResponse response) => response.WriteAsync($"postback:{value}"); +} + +[SecurePage] +public sealed class Secure : BasePage +{ + public override Task Render(HttpResponse response) => response.WriteAsync("secure:rendered"); +} + +public sealed class Fallback : BasePage +{ + public override Task Render(HttpResponse response) => response.WriteAsync("fallback:rendered"); +} + +public sealed class NotFound : BasePage +{ + public override Task Render(HttpResponse response) + { + response.StatusCode = StatusCodes.Status404NotFound; + return response.WriteAsync("not-found:rendered"); + } +} + +public sealed record IntegrationDependency(string Value); diff --git a/src/Imp.Tests/KestrelIntegrationTests.cs b/src/Imp.Tests/KestrelIntegrationTests.cs new file mode 100644 index 0000000..aee2d40 --- /dev/null +++ b/src/Imp.Tests/KestrelIntegrationTests.cs @@ -0,0 +1,74 @@ +using System.Net; +using Imp.Tests.IntegrationPages; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Imp.Tests; + +[TestClass] +public sealed class KestrelIntegrationTests +{ + [TestMethod] + public async Task MiddlewareRunsEndToEndOnKestrel() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseKestrel().UseUrls("http://127.0.0.1:0"); + builder.Services.AddSingleton(new IntegrationDependency("service")); + + await using var app = builder.Build(); + app.UseImp(config => + config + .PageAssembly(typeof(Direct).Assembly) + .RootPageNamespace("Imp.Tests.IntegrationPages") + .NotFoundPageType() + .OnNotFound(request => + request.Path == "/custom-route" ? typeof(Fallback) : null + ) + .Authenticate((context, _) => + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return false; + }) + ); + + await app.StartAsync(); + try + { + var server = app.Services.GetRequiredService(); + var address = server.Features.Get()!.Addresses.Single(); + using var client = new HttpClient { BaseAddress = new Uri(address) }; + + Assert.AreEqual("direct:Mike", await client.GetStringAsync("/direct?Name=Mike")); + StringAssert.Contains( + await client.GetStringAsync("/template?Name=Ada"), + "

template:Ada

" + ); + Assert.AreEqual("injected:service", await client.GetStringAsync("/injected")); + + using var postResponse = await client.PostAsync( + "/postback", + new FormUrlEncodedContent(new Dictionary { ["value"] = "saved" }) + ); + Assert.AreEqual(HttpStatusCode.OK, postResponse.StatusCode); + Assert.AreEqual("postback:saved", await postResponse.Content.ReadAsStringAsync()); + + using var secureResponse = await client.GetAsync("/secure"); + Assert.AreEqual(HttpStatusCode.Unauthorized, secureResponse.StatusCode); + Assert.AreEqual(string.Empty, await secureResponse.Content.ReadAsStringAsync()); + + Assert.AreEqual("fallback:rendered", await client.GetStringAsync("/custom-route")); + + using var notFoundResponse = await client.GetAsync("/missing"); + Assert.AreEqual(HttpStatusCode.NotFound, notFoundResponse.StatusCode); + Assert.AreEqual("not-found:rendered", await notFoundResponse.Content.ReadAsStringAsync()); + } + finally + { + await app.StopAsync(); + } + } +} diff --git a/src/Imp.Tests/PageTemplates/IntegrationTemplate.htm b/src/Imp.Tests/PageTemplates/IntegrationTemplate.htm new file mode 100644 index 0000000..84b9da4 --- /dev/null +++ b/src/Imp.Tests/PageTemplates/IntegrationTemplate.htm @@ -0,0 +1,3 @@ + +

+
diff --git a/src/Imp/Imp.csproj b/src/Imp/Imp.csproj index 57e0daf..82f6d58 100644 --- a/src/Imp/Imp.csproj +++ b/src/Imp/Imp.csproj @@ -1,8 +1,8 @@ - netstandard2.0 + net8.0;net10.0 KitchenPC.Imp - 0.1.0 + 0.2.0 Imp KitchenPC KitchenPC @@ -23,11 +23,7 @@ - - - - - + diff --git a/src/Imp/ImpMiddleware.cs b/src/Imp/ImpMiddleware.cs index 600a153..36d2ffc 100644 --- a/src/Imp/ImpMiddleware.cs +++ b/src/Imp/ImpMiddleware.cs @@ -2,15 +2,14 @@ using System.Collections.Concurrent; using System.Threading.Tasks; using Imp.TemplateManagers; -using log4net; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Imp { public class ImpMiddleware { - private static readonly ILog log = LogManager.GetLogger(typeof(ImpMiddleware)); - public delegate bool AuthenticateLogonCallback(HttpContext context, BasePage page); public delegate string CdnResolutionEvent(string url); public delegate Type NotFoundCallback(HttpRequest request); @@ -19,8 +18,8 @@ public class ImpMiddleware private static readonly ConcurrentDictionary PageCache = new ConcurrentDictionary(); private readonly ITemplateManager _templateManager; - private readonly RequestDelegate _next; private readonly ImpConfiguration _config; + private readonly ILogger _logger; public string PageAssemblyName => _config.pageAssembly?.FullName; //TODO: Should just return Assembly type public Type NotFoundType => _config.notFoundPageType; @@ -33,9 +32,17 @@ public class ImpMiddleware public AuthenticateLogonCallback Authenticate => _config.authenticate; public ImpMiddleware(RequestDelegate next, ImpConfiguration config) + : this(next, config, NullLogger.Instance) { } + + public ImpMiddleware( + RequestDelegate next, + ImpConfiguration config, + ILogger logger + ) { - _next = next; + _ = next ?? throw new ArgumentNullException(nameof(next)); _config = config; + _logger = logger ?? NullLogger.Instance; //TODO: Should be able to configure this in Configuration file _templateManager = new ResourceTemplateManager(this) { Assembly = config.pageAssembly }; @@ -50,7 +57,7 @@ private async Task RenderHtml(HttpContext httpContext) var request = new Request(this); var page = request.CreatePageObject(httpContext.Request, httpContext.RequestServices); - log.InfoFormat("Creating Page Type: {0}", page.GetType().FullName); + _logger.LogInformation("Creating page type {PageType}", page.GetType().FullName); page.SetHandler(this); //If secure page, authenticate first @@ -105,8 +112,10 @@ private async Task RenderHtml(HttpContext httpContext) public async Task InvokeAsync(HttpContext httpContext) { - log.Info( - $"Request for {httpContext.Request.Path} received ({httpContext.Request.ContentLength ?? 0} bytes)" + _logger.LogInformation( + "Request for {RequestPath} received ({ContentLength} bytes)", + httpContext.Request.Path, + httpContext.Request.ContentLength ?? 0 ); await RenderHtml(httpContext);