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);