Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ body:
id: version
attributes:
label: Imp version
placeholder: 0.1.0
placeholder: 0.2.0
validations:
required: true
- type: textarea
Expand Down
16 changes: 16 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 15 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
<WarningsAsErrors>$(WarningsAsErrors);NU1901;NU1902;NU1903;NU1904</WarningsAsErrors>
</PropertyGroup>
</Project>
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions Samples/TodoApp.Tests/TodoApp.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<MicrosoftTestingExtensionsCodeCoverageVersion>18.10.0</MicrosoftTestingExtensionsCodeCoverageVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
34 changes: 34 additions & 0 deletions eng/verify-package.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail

if [[ $# -ne 2 ]]; then
echo "Usage: $0 <package-version> <artifacts-directory>" >&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 '<dependency ' <<< "$nuspec"; then
echo "Imp should not carry ordinary NuGet package dependencies:" >&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
4 changes: 3 additions & 1 deletion src/Imp.Tests/Imp.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<Project Sdk="MSTest.Sdk/4.3.3">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<MicrosoftTestingExtensionsCodeCoverageVersion>18.10.0</MicrosoftTestingExtensionsCodeCoverageVersion>
</PropertyGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="../Imp/Imp.csproj" />
<EmbeddedResource Include="PageTemplates/*.htm" />
</ItemGroup>
</Project>
63 changes: 63 additions & 0 deletions src/Imp.Tests/IntegrationPages.cs
Original file line number Diff line number Diff line change
@@ -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);
74 changes: 74 additions & 0 deletions src/Imp.Tests/KestrelIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -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<NotFound>()
.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<IServer>();
var address = server.Features.Get<IServerAddressesFeature>()!.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"),
"<p id=\"template-result\">template:Ada</p>"
);
Assert.AreEqual("injected:service", await client.GetStringAsync("/injected"));

using var postResponse = await client.PostAsync(
"/postback",
new FormUrlEncodedContent(new Dictionary<string, string> { ["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();
}
}
}
3 changes: 3 additions & 0 deletions src/Imp.Tests/PageTemplates/IntegrationTemplate.htm
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<PageTemplate>
<p id="template-result"><Dynamic.Greeting /></p>
</PageTemplate>
10 changes: 3 additions & 7 deletions src/Imp/Imp.csproj
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<PackageId>KitchenPC.Imp</PackageId>
<Version>0.1.0</Version>
<Version>0.2.0</Version>
<Title>Imp</Title>
<Authors>KitchenPC</Authors>
<Company>KitchenPC</Company>
Expand All @@ -23,11 +23,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="log4net" Version="3.3.2" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Owin" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Server.IISIntegration" Version="2.2.1" />
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.2.0" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
Expand Down
25 changes: 17 additions & 8 deletions src/Imp/ImpMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -19,8 +18,8 @@ public class ImpMiddleware
private static readonly ConcurrentDictionary<Type, CompiledPage> PageCache =
new ConcurrentDictionary<Type, CompiledPage>();
private readonly ITemplateManager _templateManager;
private readonly RequestDelegate _next;
private readonly ImpConfiguration _config;
private readonly ILogger<ImpMiddleware> _logger;

public string PageAssemblyName => _config.pageAssembly?.FullName; //TODO: Should just return Assembly type
public Type NotFoundType => _config.notFoundPageType;
Expand All @@ -33,9 +32,17 @@ public class ImpMiddleware
public AuthenticateLogonCallback Authenticate => _config.authenticate;

public ImpMiddleware(RequestDelegate next, ImpConfiguration config)
: this(next, config, NullLogger<ImpMiddleware>.Instance) { }

public ImpMiddleware(
RequestDelegate next,
ImpConfiguration config,
ILogger<ImpMiddleware> logger
)
{
_next = next;
_ = next ?? throw new ArgumentNullException(nameof(next));
_config = config;
_logger = logger ?? NullLogger<ImpMiddleware>.Instance;

//TODO: Should be able to configure this in Configuration file
_templateManager = new ResourceTemplateManager(this) { Assembly = config.pageAssembly };
Expand All @@ -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
Expand Down Expand Up @@ -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);

Expand Down