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
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ jobs:
dotnet tool restore

- name: Build (Release)
run: dotnet build PatternKit.sln --configuration Release --no-restore /p:ContinuousIntegrationBuild=true
run: dotnet build PatternKit.slnx --configuration Release --no-restore /p:ContinuousIntegrationBuild=true

- name: Test with coverage
run: |
dotnet test PatternKit.sln \
dotnet test PatternKit.slnx \
--configuration Release \
--collect:"XPlat Code Coverage" \
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura \
Expand Down Expand Up @@ -139,7 +139,7 @@ jobs:

- name: Build (Release)
run: >
dotnet build PatternKit.sln
dotnet build PatternKit.slnx
--configuration Release
--no-restore
/p:ContinuousIntegrationBuild=true
Expand All @@ -150,7 +150,7 @@ jobs:

- name: Test with coverage (Release)
run: |
dotnet test PatternKit.sln \
dotnet test PatternKit.slnx \
--configuration Release \
--collect:"XPlat Code Coverage" \
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura \
Expand All @@ -160,7 +160,7 @@ jobs:

- name: Pack (all packable projects)
run: >
dotnet pack PatternKit.sln
dotnet pack PatternKit.slnx
--configuration Release
--no-build
--output ./artifacts
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ jobs:
languages: csharp

- name: Restore
run: dotnet restore PatternKit.sln --use-lock-file
run: dotnet restore PatternKit.slnx --use-lock-file

- name: Build
run: dotnet build PatternKit.sln --configuration Release --no-restore
run: dotnet build PatternKit.slnx --configuration Release --no-restore

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ riderModule.iml
.idea/
_site/
api/
*.user
*.user
TestResults/
43 changes: 41 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,12 +1,51 @@
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFrameworks>netstandard2.0;netstandard2.1;net8.0;net9.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<LangVersion>preview</LangVersion>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<Deterministic>true</Deterministic>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>

<PropertyGroup>
<Authors>Jerrett Davis and contributors</Authors>
<Company>JDH Productions LLC.</Company>
<Product>PatternKit</Product>
<Description>PatternKit is a collection of design patterns implemented in a fluent API style for .NET, enabling developers to easily integrate common design patterns into their applications with readable and maintainable code.</Description>
<PackageTags>design-patterns;fluent;fluent-api;dotnet;</PackageTags>

<PackageLicenseExpression>MIT</PackageLicenseExpression>

<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://github.com/JerrettDavis/PatternKit</RepositoryUrl>
<PackageProjectUrl>https://github.com/JerrettDavis/PatternKit</PackageProjectUrl>

<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageIcon>tiny.png</PackageIcon>

<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>

<ContinuousIntegrationBuild Condition="'$(GITHUB_ACTIONS)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>

<PropertyGroup>
<IsPackable>false</IsPackable>
<IsPackable Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests'))">false</IsPackable>
<IsPackable Condition="$([System.String]::Copy('$(MSBuildProjectName)').StartsWith('TinyBDD'))">true</IsPackable>
</PropertyGroup>

<ItemGroup>
<None Include="$(MSBuildThisFileDirectory)README.md" Pack="true" PackagePath="\"/>

<None Include="$(MSBuildThisFileDirectory)docs/images/tiny.png"
Pack="true"
PackagePath="tiny.png"/>
</ItemGroup>
</Project>
11 changes: 0 additions & 11 deletions PatternKit.Core/PatternKit.Core.csproj

This file was deleted.

35 changes: 0 additions & 35 deletions PatternKit.sln

This file was deleted.

18 changes: 18 additions & 0 deletions PatternKit.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Solution>
<Folder Name="/Solution Items/">
<File Path=".gitignore" />
<File Path="Directory.Build.props" />
<File Path="README.md" />
</Folder>
<Folder Name="/src/">
<Project Path="src/PatternKit.Core/PatternKit.Core.csproj" />
<Project Path="src/PatternKit.Examples/PatternKit.Examples.csproj" />
<Project Path="src/PatternKit.Generators/PatternKit.Generators.csproj" />
<Project Path="src\PatternKit.Generators.Abstractions\PatternKit.Generators.Abstractions.csproj" Type="Classic C#" />
</Folder>
<Folder Name="/test/">
<Project Path="test/PatternKit.Tests/PatternKit.Tests.csproj" />
<Project Path="test\PatternKit.Examples.Tests\PatternKit.Examples.Tests.csproj" Type="Classic C#" />
<Project Path="test\PatternKit.Generators.Tests\PatternKit.Generators.Tests.csproj" Type="Classic C#" />
</Folder>
</Solution>
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ if (parse.Execute("123", out var n))

PatternKit will grow to cover **Creational**, **Structural**, and **Behavioral** patterns with fluent, discoverable APIs:

| Category | Patterns ✓ = implemented |
| -------------- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Creational** | Factory (planned) • Builder (planned) • Prototype (planned) • Singleton (planned) |
| **Structural** | Adapter (planned) • Bridge (planned) • Composite (planned) • Decorator (planned) • Facade (planned) • Flyweight (planned) • Proxy (planned) |
| **Behavioral** | Strategy ✓ • TryStrategy ✓ • Chain of Responsibility (planned) • Command (planned) • Iterator (planned) • Mediator (planned) • Memento (planned) • Observer (planned) • State (planned) • Template Method (planned) • Visitor (planned) |
| Category | Patterns ✓ = implemented |
| -------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Creational** | Factory (planned) • Builder (planned) • Prototype (planned) • Singleton (planned) |
| **Structural** | Adapter (planned) • Bridge (planned) • Composite (planned) • Decorator (planned) • Facade (planned) • Flyweight (planned) • Proxy (planned) |
| **Behavioral** | Strategy ✓ • TryStrategy ✓ • ActionStrategy ✓ • Chain of Responsibility (planned) • Command (planned) • Iterator (planned) • Mediator (planned) • Memento (planned) • Observer (planned) • State (planned) • Template Method (planned) • Visitor (planned) |

Each pattern will ship with:

Expand Down Expand Up @@ -163,3 +163,4 @@ PatternKit is inspired by:
* Fluent APIs from **ASP.NET Core**, **System.Linq**, and modern libraries
* The desire to make patterns **readable**, **performant**, and **fun** to use in 2025+


3 changes: 3 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ignore:
- "**/*.Tests/**"
- "**/*Tests*/**"
162 changes: 162 additions & 0 deletions docs/examples/auth-logging-chain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Auth & Logging with `ActionChain<HttpRequest>`

A tiny example that shows how to use **PatternKit.Behavioral.Chain** to build request-logging and auth checks with **no `if`/`else`** and **first-match-wins** semantics.

* **Goal:** log a request id when present, short-circuit unauthorized `/admin/*` requests, and (otherwise) log the method/path.
* **Key idea:** branchless pipelines using `When(...).ThenContinue(...)`, `When(...).ThenStop(...)`, and a `Finally(...)` tail.

---

## The demo pipeline

```csharp
using PatternKit.Behavioral.Chain;

public readonly record struct HttpRequest(string Method, string Path, IReadOnlyDictionary<string, string> Headers);
public readonly record struct HttpResponse(int Status, string Body);

public static class AuthLoggingDemo
{
public static List<string> Run()
{
var log = new List<string>();

var chain = ActionChain<HttpRequest>.Create()
// 1) request id (continue)
.When(static (in r) => r.Headers.ContainsKey("X-Request-Id"))
.ThenContinue(r => log.Add($"reqid={r.Headers["X-Request-Id"]}"))

// 2) admin requires auth (stop)
.When(static (in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal)
&& !r.Headers.ContainsKey("Authorization"))
.ThenStop(_ => log.Add("deny: missing auth"))

// 3) tail – runs only if the chain did not stop
.Finally((in r, next) =>
{
log.Add($"{r.Method} {r.Path}");
next(r); // terminal next is a no-op
})
.Build();

// simulate
chain.Execute(new HttpRequest("GET", "/health", new Dictionary<string,string>()));
chain.Execute(new HttpRequest("GET", "/admin/metrics", new Dictionary<string,string>()));

return log;
}
}
```

### What it logs (strict-stop semantics)

`Run()` returns:

```
GET /health
deny: missing auth
```

Why not a third line (`GET /admin/metrics`)? Because **`.ThenStop(...)` halts the pipeline** and the `Finally(...)` tail does **not** run after a stop. That’s by design—great for auth short-circuits.

---

## Mental model

* **First match wins**: the first `When(...)` whose predicate is `true` executes its `Then...` and the others are skipped.
* **`.ThenContinue(...)`**: perform side effects and continue evaluating later steps (and eventually `Finally`).
* **`.ThenStop(...)`**: perform side effects and **end the pipeline immediately** (no `Finally`).
* **`Finally(...)`**: tail step that runs **only if the chain didn’t stop**.

---

## Variant: “Always log method/path”

If you want method/path to be logged **even when denied**, move that logging up front with `Use(...)`:

```csharp
var chain = ActionChain<HttpRequest>.Create()
.Use((in r, next) => { log.Add($"{r.Method} {r.Path}"); next(r); })
.When(static (in r) => r.Headers.ContainsKey("X-Request-Id"))
.ThenContinue(r => log.Add($"reqid={r.Headers["X-Request-Id"]}"))
.When(static (in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal)
&& !r.Headers.ContainsKey("Authorization"))
.ThenStop(_ => log.Add("deny: missing auth"))
.Build();
```

Now the simulated run yields:

```
GET /health
GET /admin/metrics
deny: missing auth
```

(Logging happens before the deny short-circuit.)

---

## TinyBDD smoke tests

Here’s a compact set that locks in the strict-stop behavior (no method/path after deny):

```csharp
using TinyBDD;
using TinyBDD.Xunit;
using Xunit;
using Xunit.Abstractions;

[Feature("Auth & Logging demo (ActionChain<HttpRequest>)")]
public sealed class AuthLoggingDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output)
{
[Scenario("Run() logs health, then denies admin without trailing method/path")]
[Fact]
public async Task Demo_Run_Smoke()
{
await Given("the demo Run() helper", () => (Func<List<string>>)AuthLoggingDemo.Run)
.When("running it", run => run())
.Then("first line is GET /health", log => log.ElementAtOrDefault(0) == "GET /health")
.And("second line is deny", log => log.ElementAtOrDefault(1) == "deny: missing auth")
.And("stops after deny (no third line)", log => log.Count == 2)
.AssertPassed();
}

[Scenario("Order with both: X-Request-Id then deny (no method/path)")]
[Fact]
public async Task RequestId_Then_Deny()
{
var log = new List<string>();
var chain = ActionChain<HttpRequest>.Create()
.When(static (in r) => r.Headers.ContainsKey("X-Request-Id"))
.ThenContinue(r => log.Add($"reqid={r.Headers["X-Request-Id"]}"))
.When(static (in r) => r.Path.StartsWith("/admin") && !r.Headers.ContainsKey("Authorization"))
.ThenStop(_ => log.Add("deny: missing auth"))
.Finally((in r, next) => { log.Add($"{r.Method} {r.Path}"); next(r); })
.Build();

await Given("the chain and log", () => (chain, log))
.When("GET /admin/x with X-Request-Id and no auth", t =>
{
var (c, l) = t;
c.Execute(new HttpRequest("GET", "/admin/x", new Dictionary<string,string>{{"X-Request-Id","rid-7"}}));
return l;
})
.Then("reqid first", l => l.ElementAtOrDefault(0) == "reqid=rid-7")
.And("deny second", l => l.ElementAtOrDefault(1) == "deny: missing auth")
.And("no method/path after stop", l => l.Count == 2)
.AssertPassed();
}
}
```

> Tip: use `ElementAtOrDefault` in tests to avoid index exceptions and get clearer failure messages.

---

## When to use this pattern

* **Middleware-like** concerns where some steps are pure side effects (logging, metrics, request-id) and others **short-circuit** (auth, feature gates).
* Places where you want **declarative ordering** and **no nested conditionals**—add/remove rules without touching the rest.

That’s it—simple, predictable, and production-friendly.
Loading