diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index ce3466e..990c364 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -39,6 +39,7 @@ jobs:
run: |
dotnet pack src/HttpMock/HttpMock.csproj --configuration Release --no-build -p:PackageVersion=${{ steps.version.outputs.VERSION }} --output ./nupkgs
dotnet pack src/HttpMock.Verify.NUnit/HttpMock.Verify.NUnit.csproj --configuration Release --no-build -p:PackageVersion=${{ steps.version.outputs.VERSION }} --output ./nupkgs
+ dotnet pack src/HttpMock.Aspire.Hosting/HttpMock.Aspire.Hosting.csproj --configuration Release --no-build -p:PackageVersion=${{ steps.version.outputs.VERSION }} --output ./nupkgs
- name: Push to NuGet.org
run: dotnet nuget push ./nupkgs/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
diff --git a/.nuget/NuGet.Config b/.nuget/NuGet.Config
deleted file mode 100644
index 6a318ad..0000000
--- a/.nuget/NuGet.Config
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.nuget/NuGet.exe b/.nuget/NuGet.exe
deleted file mode 100644
index 6bb79fe..0000000
Binary files a/.nuget/NuGet.exe and /dev/null differ
diff --git a/.nuget/NuGet.targets b/.nuget/NuGet.targets
deleted file mode 100644
index d0ebc75..0000000
--- a/.nuget/NuGet.targets
+++ /dev/null
@@ -1,136 +0,0 @@
-
-
-
- $(MSBuildProjectDirectory)\..\
-
-
- false
-
-
- false
-
-
- true
-
-
- false
-
-
-
-
-
-
-
-
-
-
- $([System.IO.Path]::Combine($(SolutionDir), ".nuget"))
- $([System.IO.Path]::Combine($(ProjectDir), "packages.config"))
-
-
-
-
- $(SolutionDir).nuget
- packages.config
-
-
-
-
- $(NuGetToolsPath)\NuGet.exe
- @(PackageSource)
-
- "$(NuGetExePath)"
- mono --runtime=v4.0.30319 $(NuGetExePath)
-
- $(TargetDir.Trim('\\'))
-
- -RequireConsent
- -NonInteractive
-
- "$(SolutionDir) "
- "$(SolutionDir)"
-
-
- $(NuGetCommand) install "$(PackagesConfig)" -source "$(PackageSources)" $(NonInteractiveSwitch) $(RequireConsentSwitch) -solutionDir $(PaddedSolutionDir)
- $(NuGetCommand) pack "$(ProjectPath)" -Properties "Configuration=$(Configuration);Platform=$(Platform)" $(NonInteractiveSwitch) -OutputDirectory "$(PackageOutputDir)" -symbols
-
-
-
- RestorePackages;
- $(BuildDependsOn);
-
-
-
-
- $(BuildDependsOn);
- BuildPackage;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.nuget/nuget.exe b/.nuget/nuget.exe
deleted file mode 100644
index 5e246fb..0000000
Binary files a/.nuget/nuget.exe and /dev/null differ
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..6f9c966
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,51 @@
+# Contributing to HttpMock
+
+Thank you for taking the time to contribute! Please read the guidelines below before opening a pull request.
+
+## Building
+
+```bash
+dotnet build HttpMock.sln
+```
+
+## Running the tests
+
+```bash
+dotnet test HttpMock.sln
+```
+
+## Project structure
+
+| Folder | Purpose |
+|--------|---------|
+| `src/HttpMock` | Core library |
+| `src/HttpMock.Aspire.Hosting` | .NET Aspire hosting integration |
+| `src/HttpMock.Verify.NUnit` | NUnit verification helpers |
+| `examples/` | Runnable example applications |
+
+## Test project naming conventions
+
+Test projects must follow this naming scheme:
+
+| Test type | Suffix | Example |
+|-----------|--------|---------|
+| Unit tests | `*.Unit.Tests` | `HttpMock.Unit.Tests` |
+| Integration tests | `*.Integration.Tests` | `HttpMock.Integration.Tests` |
+
+Both suffixes apply to any feature area. For example, tests for the Aspire hosting package should be named:
+
+- `HttpMock.Aspire.Hosting.Unit.Tests`
+- `HttpMock.Aspire.Hosting.Integration.Tests`
+
+### Rationale
+
+The consistent suffixes make it easy to:
+
+- Filter test runs by type (`--filter FullyQualifiedName~Integration.Tests`).
+- Identify the scope of a project at a glance.
+- Apply CI policies (e.g. run unit tests on every push, integration tests only on PRs).
+
+## Reporting issues
+
+When reporting a bug, please provide a failing test that demonstrates the problem.
+
diff --git a/HttpMock.sln b/HttpMock.sln
index 4c3684c..efb87da 100644
--- a/HttpMock.sln
+++ b/HttpMock.sln
@@ -9,54 +9,123 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HttpMock.Integration.Tests"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HttpMock.Unit.Tests", "src\HttpMock.Unit.Tests\HttpMock.Unit.Tests.csproj", "{92735A21-16E8-46BC-859D-AB96F137C457}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".nuget", ".nuget", "{BF7536BA-5DEE-48E0-BD16-45E3C36B1979}"
- ProjectSection(SolutionItems) = preProject
- .nuget\NuGet.Config = .nuget\NuGet.Config
- .nuget\NuGet.exe = .nuget\NuGet.exe
- .nuget\NuGet.targets = .nuget\NuGet.targets
- EndProjectSection
-EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HttpMock.Verify.NUnit", "src\HttpMock.Verify.NUnit\HttpMock.Verify.NUnit.csproj", "{1B61C8E3-7A43-479B-B7FE-D374DFEAA5E7}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HttpMock.Aspire.Hosting", "src\HttpMock.Aspire.Hosting\HttpMock.Aspire.Hosting.csproj", "{263B28F3-85D5-4357-9043-11210B1799A1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HttpMock.Aspire.Hosting.Unit.Tests", "src\HttpMock.Aspire.Hosting.Unit.Tests\HttpMock.Aspire.Hosting.Unit.Tests.csproj", "{9B2EB8C3-7C3C-4BBC-8058-A9B2BA4A2E09}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HttpMock.Aspire.Hosting.Integration.Tests.AppHost", "src\HttpMock.Aspire.Hosting.Integration.Tests.AppHost\HttpMock.Aspire.Hosting.Integration.Tests.AppHost.csproj", "{35191166-4086-4A21-840B-D6894E24D9FF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HttpMock.Aspire.Hosting.Integration.Tests", "src\HttpMock.Aspire.Hosting.Integration.Tests\HttpMock.Aspire.Hosting.Integration.Tests.csproj", "{5A9E205D-8837-4AB6-B449-15B14E13E6E0}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x86 = Debug|x86
+ Debug|x64 = Debug|x64
Release|Any CPU = Release|Any CPU
Release|x86 = Release|x86
+ Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7D7A1C68-6D1C-4AD5-BD2E-CA2D7B76AE1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D7A1C68-6D1C-4AD5-BD2E-CA2D7B76AE1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D7A1C68-6D1C-4AD5-BD2E-CA2D7B76AE1C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7D7A1C68-6D1C-4AD5-BD2E-CA2D7B76AE1C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7D7A1C68-6D1C-4AD5-BD2E-CA2D7B76AE1C}.Debug|x64.Build.0 = Debug|Any CPU
{7D7A1C68-6D1C-4AD5-BD2E-CA2D7B76AE1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7D7A1C68-6D1C-4AD5-BD2E-CA2D7B76AE1C}.Release|Any CPU.Build.0 = Release|Any CPU
{7D7A1C68-6D1C-4AD5-BD2E-CA2D7B76AE1C}.Release|x86.ActiveCfg = Release|Any CPU
{7D7A1C68-6D1C-4AD5-BD2E-CA2D7B76AE1C}.Release|x86.Build.0 = Release|Any CPU
+ {7D7A1C68-6D1C-4AD5-BD2E-CA2D7B76AE1C}.Release|x64.ActiveCfg = Release|Any CPU
+ {7D7A1C68-6D1C-4AD5-BD2E-CA2D7B76AE1C}.Release|x64.Build.0 = Release|Any CPU
{3E61549A-1FAC-416B-BA50-7265E6FC1D03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3E61549A-1FAC-416B-BA50-7265E6FC1D03}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3E61549A-1FAC-416B-BA50-7265E6FC1D03}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3E61549A-1FAC-416B-BA50-7265E6FC1D03}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3E61549A-1FAC-416B-BA50-7265E6FC1D03}.Debug|x64.Build.0 = Debug|Any CPU
{3E61549A-1FAC-416B-BA50-7265E6FC1D03}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3E61549A-1FAC-416B-BA50-7265E6FC1D03}.Release|Any CPU.Build.0 = Release|Any CPU
{3E61549A-1FAC-416B-BA50-7265E6FC1D03}.Release|x86.ActiveCfg = Release|Any CPU
{3E61549A-1FAC-416B-BA50-7265E6FC1D03}.Release|x86.Build.0 = Release|Any CPU
+ {3E61549A-1FAC-416B-BA50-7265E6FC1D03}.Release|x64.ActiveCfg = Release|Any CPU
+ {3E61549A-1FAC-416B-BA50-7265E6FC1D03}.Release|x64.Build.0 = Release|Any CPU
{92735A21-16E8-46BC-859D-AB96F137C457}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{92735A21-16E8-46BC-859D-AB96F137C457}.Debug|Any CPU.Build.0 = Debug|Any CPU
{92735A21-16E8-46BC-859D-AB96F137C457}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {92735A21-16E8-46BC-859D-AB96F137C457}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {92735A21-16E8-46BC-859D-AB96F137C457}.Debug|x64.Build.0 = Debug|Any CPU
{92735A21-16E8-46BC-859D-AB96F137C457}.Release|Any CPU.ActiveCfg = Release|Any CPU
{92735A21-16E8-46BC-859D-AB96F137C457}.Release|Any CPU.Build.0 = Release|Any CPU
{92735A21-16E8-46BC-859D-AB96F137C457}.Release|x86.ActiveCfg = Release|Any CPU
{92735A21-16E8-46BC-859D-AB96F137C457}.Release|x86.Build.0 = Release|Any CPU
+ {92735A21-16E8-46BC-859D-AB96F137C457}.Release|x64.ActiveCfg = Release|Any CPU
+ {92735A21-16E8-46BC-859D-AB96F137C457}.Release|x64.Build.0 = Release|Any CPU
{1B61C8E3-7A43-479B-B7FE-D374DFEAA5E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1B61C8E3-7A43-479B-B7FE-D374DFEAA5E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1B61C8E3-7A43-479B-B7FE-D374DFEAA5E7}.Debug|x86.ActiveCfg = Debug|Any CPU
{1B61C8E3-7A43-479B-B7FE-D374DFEAA5E7}.Debug|x86.Build.0 = Debug|Any CPU
+ {1B61C8E3-7A43-479B-B7FE-D374DFEAA5E7}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1B61C8E3-7A43-479B-B7FE-D374DFEAA5E7}.Debug|x64.Build.0 = Debug|Any CPU
{1B61C8E3-7A43-479B-B7FE-D374DFEAA5E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1B61C8E3-7A43-479B-B7FE-D374DFEAA5E7}.Release|Any CPU.Build.0 = Release|Any CPU
{1B61C8E3-7A43-479B-B7FE-D374DFEAA5E7}.Release|x86.ActiveCfg = Release|Any CPU
{1B61C8E3-7A43-479B-B7FE-D374DFEAA5E7}.Release|x86.Build.0 = Release|Any CPU
+ {1B61C8E3-7A43-479B-B7FE-D374DFEAA5E7}.Release|x64.ActiveCfg = Release|Any CPU
+ {1B61C8E3-7A43-479B-B7FE-D374DFEAA5E7}.Release|x64.Build.0 = Release|Any CPU
+ {263B28F3-85D5-4357-9043-11210B1799A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {263B28F3-85D5-4357-9043-11210B1799A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {263B28F3-85D5-4357-9043-11210B1799A1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {263B28F3-85D5-4357-9043-11210B1799A1}.Debug|x86.Build.0 = Debug|Any CPU
+ {263B28F3-85D5-4357-9043-11210B1799A1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {263B28F3-85D5-4357-9043-11210B1799A1}.Debug|x64.Build.0 = Debug|Any CPU
+ {263B28F3-85D5-4357-9043-11210B1799A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {263B28F3-85D5-4357-9043-11210B1799A1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {263B28F3-85D5-4357-9043-11210B1799A1}.Release|x86.ActiveCfg = Release|Any CPU
+ {263B28F3-85D5-4357-9043-11210B1799A1}.Release|x86.Build.0 = Release|Any CPU
+ {263B28F3-85D5-4357-9043-11210B1799A1}.Release|x64.ActiveCfg = Release|Any CPU
+ {263B28F3-85D5-4357-9043-11210B1799A1}.Release|x64.Build.0 = Release|Any CPU
+ {9B2EB8C3-7C3C-4BBC-8058-A9B2BA4A2E09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9B2EB8C3-7C3C-4BBC-8058-A9B2BA4A2E09}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9B2EB8C3-7C3C-4BBC-8058-A9B2BA4A2E09}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9B2EB8C3-7C3C-4BBC-8058-A9B2BA4A2E09}.Debug|x86.Build.0 = Debug|Any CPU
+ {9B2EB8C3-7C3C-4BBC-8058-A9B2BA4A2E09}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9B2EB8C3-7C3C-4BBC-8058-A9B2BA4A2E09}.Debug|x64.Build.0 = Debug|Any CPU
+ {9B2EB8C3-7C3C-4BBC-8058-A9B2BA4A2E09}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9B2EB8C3-7C3C-4BBC-8058-A9B2BA4A2E09}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9B2EB8C3-7C3C-4BBC-8058-A9B2BA4A2E09}.Release|x86.ActiveCfg = Release|Any CPU
+ {9B2EB8C3-7C3C-4BBC-8058-A9B2BA4A2E09}.Release|x86.Build.0 = Release|Any CPU
+ {9B2EB8C3-7C3C-4BBC-8058-A9B2BA4A2E09}.Release|x64.ActiveCfg = Release|Any CPU
+ {9B2EB8C3-7C3C-4BBC-8058-A9B2BA4A2E09}.Release|x64.Build.0 = Release|Any CPU
+ {35191166-4086-4A21-840B-D6894E24D9FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {35191166-4086-4A21-840B-D6894E24D9FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {35191166-4086-4A21-840B-D6894E24D9FF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {35191166-4086-4A21-840B-D6894E24D9FF}.Debug|x86.Build.0 = Debug|Any CPU
+ {35191166-4086-4A21-840B-D6894E24D9FF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {35191166-4086-4A21-840B-D6894E24D9FF}.Debug|x64.Build.0 = Debug|Any CPU
+ {35191166-4086-4A21-840B-D6894E24D9FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {35191166-4086-4A21-840B-D6894E24D9FF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {35191166-4086-4A21-840B-D6894E24D9FF}.Release|x86.ActiveCfg = Release|Any CPU
+ {35191166-4086-4A21-840B-D6894E24D9FF}.Release|x86.Build.0 = Release|Any CPU
+ {35191166-4086-4A21-840B-D6894E24D9FF}.Release|x64.ActiveCfg = Release|Any CPU
+ {35191166-4086-4A21-840B-D6894E24D9FF}.Release|x64.Build.0 = Release|Any CPU
+ {5A9E205D-8837-4AB6-B449-15B14E13E6E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5A9E205D-8837-4AB6-B449-15B14E13E6E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5A9E205D-8837-4AB6-B449-15B14E13E6E0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5A9E205D-8837-4AB6-B449-15B14E13E6E0}.Debug|x86.Build.0 = Debug|Any CPU
+ {5A9E205D-8837-4AB6-B449-15B14E13E6E0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5A9E205D-8837-4AB6-B449-15B14E13E6E0}.Debug|x64.Build.0 = Debug|Any CPU
+ {5A9E205D-8837-4AB6-B449-15B14E13E6E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5A9E205D-8837-4AB6-B449-15B14E13E6E0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5A9E205D-8837-4AB6-B449-15B14E13E6E0}.Release|x86.ActiveCfg = Release|Any CPU
+ {5A9E205D-8837-4AB6-B449-15B14E13E6E0}.Release|x86.Build.0 = Release|Any CPU
+ {5A9E205D-8837-4AB6-B449-15B14E13E6E0}.Release|x64.ActiveCfg = Release|Any CPU
+ {5A9E205D-8837-4AB6-B449-15B14E13E6E0}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ EndGlobalSection
EndGlobal
diff --git a/Integration.Tests b/Integration.Tests
new file mode 100644
index 0000000..e69de29
diff --git a/Integration.Tests.AppHost b/Integration.Tests.AppHost
new file mode 100644
index 0000000..e69de29
diff --git a/README.md b/README.md
index d9c609d..882431d 100644
--- a/README.md
+++ b/README.md
@@ -365,25 +365,65 @@ using var tracerProvider = Sdk.CreateTracerProviderBuilder()
No additional NuGet packages are required in HttpMock itself — `ActivitySource` is built into .NET.
-## Examples
+## Finding a Free Port
-### .NET Aspire Integration
+`HttpMockRepository.FindFreePort()` returns a free TCP port on the loopback interface by asking the OS to assign one (via `TcpListener` on port 0). Use it whenever you need a collision-free port for a stub server:
-The [Aspire example](examples/AspireExample/) demonstrates using HttpMock to mock downstream APIs in a .NET Aspire distributed application. The test suite starts an HttpMock server once, builds the Aspire app host pointing at it, and uses `WithNewContext()` to swap stubs between tests:
+```csharp
+var port = HttpMockRepository.FindFreePort();
+var server = HttpMockRepository.At($"http://localhost:{port}");
+```
+
+
+## .NET Aspire Integration
+
+Install the companion package to register an HttpMock server as a first-class [.NET Aspire](https://learn.microsoft.com/dotnet/aspire/get-started/aspire-overview) resource. The server appears on the Aspire dashboard, starts automatically before dependent projects, and injects its URL into them via the standard Aspire connection-string mechanism — no manual port allocation or configuration overrides needed.
+
+```
+dotnet add package HttpMock.Aspire.Hosting
+```
+
+### App host
```csharp
-// OneTimeSetUp — create the app and mock server once for all tests
-_mockServer = HttpMockRepository.At($"http://localhost:{FindAvailablePort()}");
+using HttpMock.Aspire.Hosting;
+
+var builder = DistributedApplication.CreateBuilder(args);
+var mockWeatherApi = builder.AddHttpMock("external-weather-api");
+
+builder.AddProject("weatherapi")
+ .WithReference(mockWeatherApi);
+
+builder.Build().Run();
+```
+
+`WithReference` injects `ConnectionStrings__external-weather-api = http://localhost:` into `weatherapi` automatically.
+
+### Test project
+
+```csharp
+// OneTimeSetUp — start the Aspire application once for all tests
var appHost = await DistributedApplicationTestingBuilder
- .CreateAsync();
-appHost.Configuration["ConnectionStrings:ExternalWeatherApi"] = mockUrl;
+ .CreateAsync();
_app = await appHost.BuildAsync();
await _app.StartAsync();
+
+await _app.ResourceNotifications
+ .WaitForResourceAsync("external-weather-api", KnownResourceStates.Running);
+
+// Retrieve the live mock server
+var resource = _app.Services
+ .GetRequiredService()
+ .Resources
+ .OfType()
+ .Single(r => r.Name == "external-weather-api");
+
+_mockServer = resource.MockServer!;
_httpClient = _app.CreateHttpClient("weatherapi");
-// Each test — swap stubs without rebuilding the app
+// Each test — clear previous stubs and register new ones
_mockServer.WithNewContext()
.Stub(x => x.Get("/api/weather"))
.Return(jsonPayload)
@@ -393,7 +433,7 @@ _mockServer.WithNewContext()
var response = await _httpClient.GetAsync("/weatherforecast");
```
-See the [full example README](examples/AspireExample/README.md) for project structure, prerequisites, and test details.
+See the [Aspire example](examples/AspireExample/) and the [integration tests](src/HttpMock.Aspire.Hosting.IntegrationTests/) for a fully worked end-to-end example.
## Reporting Issues.
When reporting issues, please provide a failing test.
diff --git a/Unit.Tests b/Unit.Tests
new file mode 100644
index 0000000..e69de29
diff --git a/examples/AspireExample/AspireExample.AppHost/AppHost.cs b/examples/AspireExample/AspireExample.AppHost/AppHost.cs
index a556893..af89568 100644
--- a/examples/AspireExample/AspireExample.AppHost/AppHost.cs
+++ b/examples/AspireExample/AspireExample.AppHost/AppHost.cs
@@ -1,10 +1,10 @@
+using HttpMock.Aspire.Hosting;
+
var builder = DistributedApplication.CreateBuilder(args);
-// In production, this would be a real external weather API.
-// In tests, we replace it with an HttpMock server using a connection string override.
-var externalWeatherApi = builder.AddConnectionString("ExternalWeatherApi");
+var externalWeatherApi = builder.AddHttpMock("ExternalWeatherApi");
-var weatherApi = builder.AddProject("weatherapi")
+builder.AddProject("weatherapi")
.WithReference(externalWeatherApi);
builder.Build().Run();
diff --git a/examples/AspireExample/AspireExample.AppHost/AspireExample.AppHost.csproj b/examples/AspireExample/AspireExample.AppHost/AspireExample.AppHost.csproj
index 3f7173d..f85beb2 100644
--- a/examples/AspireExample/AspireExample.AppHost/AspireExample.AppHost.csproj
+++ b/examples/AspireExample/AspireExample.AppHost/AspireExample.AppHost.csproj
@@ -1,7 +1,9 @@
-
-
+
+
+
diff --git a/examples/AspireExample/AspireExample.Tests/AspireExample.Tests.csproj b/examples/AspireExample/AspireExample.Tests/AspireExample.Tests.csproj
index 6809fd5..80f550f 100644
--- a/examples/AspireExample/AspireExample.Tests/AspireExample.Tests.csproj
+++ b/examples/AspireExample/AspireExample.Tests/AspireExample.Tests.csproj
@@ -19,15 +19,17 @@
+
+
-
-
-
+
+
+
diff --git a/examples/AspireExample/AspireExample.Tests/IntegrationTest1.cs b/examples/AspireExample/AspireExample.Tests/IntegrationTest1.cs
index fa65cc3..db0074f 100644
--- a/examples/AspireExample/AspireExample.Tests/IntegrationTest1.cs
+++ b/examples/AspireExample/AspireExample.Tests/IntegrationTest1.cs
@@ -1,16 +1,16 @@
using System.Text.Json;
using Aspire.Hosting;
-using HttpMock;
namespace AspireExample.Tests;
///
-/// Demonstrates how to use HttpMock with .NET Aspire to mock a downstream API
+/// Demonstrates how to use the HttpMock Aspire resource to mock a downstream API
/// during integration testing. The WeatherApi service calls an external weather
-/// API; these tests replace that external dependency with an HttpMock stub server.
+/// API; these tests replace that external dependency with an HttpMock stub server
+/// registered via .
///
-/// The Aspire application and HttpMock server are created once for the entire
-/// test suite in . Each test calls
+/// The Aspire application is created once for the entire test suite in
+/// . Each test calls
/// to clear previous stubs and register
/// fresh ones, following the same pattern used in the main HttpMock integration tests.
///
@@ -21,32 +21,34 @@ public class WeatherApiTests
private DistributedApplication _app = null!;
private IHttpServer _mockServer = null!;
private HttpClient _httpClient = null!;
- private string _mockUrl = null!;
[OneTimeSetUp]
public async Task OneTimeSetUp()
{
- // Start an HttpMock server on an available port – this stays alive for all tests.
- var mockPort = FindAvailablePort();
- _mockUrl = $"http://localhost:{mockPort}";
- _mockServer = HttpMockRepository.At(_mockUrl);
-
- // Build and start the Aspire app host once, pointing the downstream API
- // connection string at the HttpMock server.
using var cts = new CancellationTokenSource(DefaultTimeout);
var cancellationToken = cts.Token;
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync(cancellationToken);
- appHost.Configuration["ConnectionStrings:ExternalWeatherApi"] = _mockUrl;
-
_app = await appHost.BuildAsync(cancellationToken)
.WaitAsync(DefaultTimeout, cancellationToken);
await _app.StartAsync(cancellationToken)
.WaitAsync(DefaultTimeout, cancellationToken);
+ // Retrieve the HttpMock resource that was started by the Aspire lifecycle.
+ var mockResource = _app.Services
+ .GetRequiredService()
+ .Resources
+ .OfType()
+ .Single(r => r.Name == "ExternalWeatherApi");
+
+ _mockServer = mockResource.MockServer
+ ?? throw new InvalidOperationException(
+ $"HttpMockResource '{mockResource.Name}' has no MockServer. " +
+ "Ensure the app has started before accessing MockServer.");
+
_httpClient = _app.CreateHttpClient("weatherapi");
await _app.ResourceNotifications
@@ -63,8 +65,6 @@ public async Task OneTimeTearDown()
{
await _app.DisposeAsync();
}
-
- _mockServer?.Dispose();
}
[Test]
@@ -145,14 +145,4 @@ public async Task GetWeatherForecast_WithDelayedResponse_StillReturnsData()
Assert.That(forecasts!.Length, Is.EqualTo(1));
Assert.That(forecasts[0].GetProperty("summary").GetString(), Is.EqualTo("Hot"));
}
-
- private static int FindAvailablePort()
- {
- using var listener = new System.Net.Sockets.TcpListener(
- System.Net.IPAddress.Loopback, 0);
- listener.Start();
- var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
- listener.Stop();
- return port;
- }
}
diff --git a/examples/AspireExample/README.md b/examples/AspireExample/README.md
index 964a06e..590287b 100644
--- a/examples/AspireExample/README.md
+++ b/examples/AspireExample/README.md
@@ -25,51 +25,54 @@ A `WeatherApi` service exposes a `/weatherforecast` endpoint that internally cal
| Project | Description |
|---------|-------------|
-| `AspireExample.AppHost` | Aspire orchestrator. Registers the `WeatherApi` and wires up the external API connection string. |
+| `AspireExample.AppHost` | Aspire orchestrator. Registers the `WeatherApi` and an `HttpMockResource` via `AddHttpMock`. |
| `AspireExample.WeatherApi` | Minimal API that calls a downstream weather service via `HttpClient`. The base address is read from the `ExternalWeatherApi` connection string. |
| `AspireExample.ServiceDefaults` | Shared Aspire service defaults (OpenTelemetry, health checks, resilience, service discovery). |
-| `AspireExample.Tests` | NUnit integration tests using `Aspire.Hosting.Testing` and `HttpMock`. |
+| `AspireExample.Tests` | NUnit integration tests using `Aspire.Hosting.Testing` and `HttpMock.Aspire.Hosting`. |
-## How It Works
+## How It Works — First-Class Aspire Resource (`HttpMock.Aspire.Hosting`)
-### 1. The WeatherApi reads the downstream URL from configuration
+The `HttpMock.Aspire.Hosting` package lets you register an HttpMock stub server as a proper Aspire resource. Aspire starts the server automatically, shows it on the dashboard, and injects its URL into dependent projects — no manual port allocation required.
+
+### 1. The AppHost registers the mock as a first-class resource
```csharp
-builder.Services.AddHttpClient("ExternalWeatherApi", client =>
-{
- client.BaseAddress = new Uri(
- builder.Configuration.GetConnectionString("ExternalWeatherApi")
- ?? "http://localhost:5200");
-});
-```
+using HttpMock.Aspire.Hosting;
-### 2. The AppHost registers it as a connection string reference
+var builder = DistributedApplication.CreateBuilder(args);
-```csharp
-var externalWeatherApi = builder.AddConnectionString("ExternalWeatherApi");
+var externalWeatherApi = builder.AddHttpMock("ExternalWeatherApi");
builder.AddProject("weatherapi")
.WithReference(externalWeatherApi);
+
+builder.Build().Run();
```
-### 3. Tests create the app once and reuse it across the suite
+`WithReference` automatically injects `ConnectionStrings__ExternalWeatherApi = http://localhost:` into the WeatherApi process — exactly what it reads via `GetConnectionString("ExternalWeatherApi")`.
-The Aspire application and HttpMock server are created once in `[OneTimeSetUp]`.
-Each test calls `WithNewContext()` to clear previous stubs and register fresh ones —
-exactly the same pattern used in HttpMock's own integration tests.
+### 2. The WeatherApi reads the downstream URL from configuration (unchanged)
```csharp
-// OneTimeSetUp — runs once for the entire test fixture
-var mockUrl = $"http://localhost:{FindAvailablePort()}";
-_mockServer = HttpMockRepository.At(mockUrl);
+builder.Services.AddHttpClient("ExternalWeatherApi", client =>
+{
+ client.BaseAddress = new Uri(
+ builder.Configuration.GetConnectionString("ExternalWeatherApi")
+ ?? "http://localhost:5200");
+});
+```
-var appHost = await DistributedApplicationTestingBuilder
- .CreateAsync();
-appHost.Configuration["ConnectionStrings:ExternalWeatherApi"] = mockUrl;
+### 3. Tests configure stubs via the `MockServer` property
-_app = await appHost.BuildAsync();
-await _app.StartAsync();
-_httpClient = _app.CreateHttpClient("weatherapi");
+```csharp
+// OneTimeSetUp — get the resource that Aspire already started
+var mockResource = _app.Services
+ .GetRequiredService()
+ .Resources
+ .OfType()
+ .Single(r => r.Name == "ExternalWeatherApi");
+
+_mockServer = mockResource.MockServer!;
// Each test — swap stubs without rebuilding the app
_mockServer.WithNewContext()
@@ -81,6 +84,8 @@ _mockServer.WithNewContext()
var response = await _httpClient.GetAsync("/weatherforecast");
```
+---
+
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) or later
diff --git a/src/HttpMock.Aspire.Hosting.Integration.Tests.AppHost/AppHost.cs b/src/HttpMock.Aspire.Hosting.Integration.Tests.AppHost/AppHost.cs
new file mode 100644
index 0000000..465edb4
--- /dev/null
+++ b/src/HttpMock.Aspire.Hosting.Integration.Tests.AppHost/AppHost.cs
@@ -0,0 +1,10 @@
+using HttpMock.Aspire.Hosting;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+// Register a mock server named "mock-api". Aspire starts the server before any
+// dependent project launches and makes its URL available as:
+// ConnectionStrings__mock-api = http://localhost:
+builder.AddHttpMock("mock-api");
+
+builder.Build().Run();
diff --git a/src/HttpMock.Aspire.Hosting.Integration.Tests.AppHost/HttpMock.Aspire.Hosting.Integration.Tests.AppHost.csproj b/src/HttpMock.Aspire.Hosting.Integration.Tests.AppHost/HttpMock.Aspire.Hosting.Integration.Tests.AppHost.csproj
new file mode 100644
index 0000000..69f3d96
--- /dev/null
+++ b/src/HttpMock.Aspire.Hosting.Integration.Tests.AppHost/HttpMock.Aspire.Hosting.Integration.Tests.AppHost.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ httpmock-aspire-hosting-integration-tests
+
+
+
+
+
+
+
+
diff --git a/src/HttpMock.Aspire.Hosting.Integration.Tests/HttpMock.Aspire.Hosting.Integration.Tests.csproj b/src/HttpMock.Aspire.Hosting.Integration.Tests/HttpMock.Aspire.Hosting.Integration.Tests.csproj
new file mode 100644
index 0000000..019761b
--- /dev/null
+++ b/src/HttpMock.Aspire.Hosting.Integration.Tests/HttpMock.Aspire.Hosting.Integration.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ false
+ true
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/HttpMock.Aspire.Hosting.Integration.Tests/HttpMockResourceIntegrationTests.cs b/src/HttpMock.Aspire.Hosting.Integration.Tests/HttpMockResourceIntegrationTests.cs
new file mode 100644
index 0000000..f28146d
--- /dev/null
+++ b/src/HttpMock.Aspire.Hosting.Integration.Tests/HttpMockResourceIntegrationTests.cs
@@ -0,0 +1,195 @@
+using System.Net;
+using System.Text;
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Testing;
+using HttpMock;
+using HttpMock.Aspire.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using NUnit.Framework;
+
+namespace HttpMock.Aspire.Hosting.Integration.Tests;
+
+///
+/// End-to-end integration tests that demonstrate how to use the
+/// API with .NET Aspire.
+///
+/// Pattern:
+///
+/// -
+/// The AppHost registers builder.AddHttpMock("mock-api").
+/// Aspire starts the server automatically when the application launches.
+///
+/// -
+/// Tests retrieve the by name from the
+/// and configure stubs via
+/// .
+///
+/// -
+/// Each test calls to clear any
+/// leftover stubs from previous tests.
+///
+///
+///
+[TestFixture]
+public class HttpMockResourceIntegrationTests
+{
+ private static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(60);
+
+ private DistributedApplication _app = null!;
+ private IHttpServer _mockServer = null!;
+ private HttpClient _httpClient = null!;
+
+ [OneTimeSetUp]
+ public async Task OneTimeSetUp()
+ {
+ using var cts = new CancellationTokenSource(StartupTimeout);
+ var cancellationToken = cts.Token;
+
+ // Build a real Aspire DistributedApplication from the companion AppHost project.
+ // The AppHost registers: builder.AddHttpMock("mock-api")
+ var appHost = await DistributedApplicationTestingBuilder
+ .CreateAsync(cancellationToken);
+
+ _app = await appHost.BuildAsync(cancellationToken);
+ await _app.StartAsync(cancellationToken);
+
+ // Wait until the mock resource reports Running so the server is definitely ready.
+ await _app.ResourceNotifications
+ .WaitForResourceAsync("mock-api", KnownResourceStates.Running, cancellationToken)
+ .WaitAsync(StartupTimeout, cancellationToken);
+
+ // Retrieve the resource to access its live MockServer and URL.
+ var resource = _app.Services
+ .GetRequiredService()
+ .Resources
+ .OfType()
+ .Single(r => r.Name == "mock-api");
+
+ _mockServer = resource.MockServer
+ ?? throw new InvalidOperationException(
+ $"HttpMockResource '{resource.Name}' has no MockServer. " +
+ "Ensure the application has started before accessing MockServer.");
+
+ _httpClient = new HttpClient { BaseAddress = new Uri(resource.Url!) };
+ }
+
+ [OneTimeTearDown]
+ public async Task OneTimeTearDown()
+ {
+ _httpClient?.Dispose();
+
+ if (_app != null)
+ {
+ await _app.DisposeAsync();
+ }
+ }
+
+ // ── Basic verb/status stubs ──────────────────────────────────────────────
+
+ [Test]
+ public async Task Stub_Get_Returns200WithBody()
+ {
+ _mockServer.WithNewContext()
+ .Stub(x => x.Get("/hello"))
+ .Return("world")
+ .OK();
+
+ using var response = await _httpClient.GetAsync("/hello");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.That(body, Is.EqualTo("world"));
+ }
+
+ [Test]
+ public async Task Stub_Get_ReturnsJson()
+ {
+ const string json = """{"temperature":22,"summary":"Warm"}""";
+
+ _mockServer.WithNewContext()
+ .Stub(x => x.Get("/api/weather"))
+ .Return(json)
+ .AsContentType("application/json")
+ .OK();
+
+ using var response = await _httpClient.GetAsync("/api/weather");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ Assert.That(response.Content.Headers.ContentType?.MediaType,
+ Is.EqualTo("application/json"));
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.That(body, Is.EqualTo(json));
+ }
+
+ [Test]
+ public async Task Stub_Get_ReturnsNotFound()
+ {
+ _mockServer.WithNewContext()
+ .Stub(x => x.Get("/missing"))
+ .NotFound();
+
+ using var response = await _httpClient.GetAsync("/missing");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
+ }
+
+ [Test]
+ public async Task Stub_Post_ReturnsCreated()
+ {
+ _mockServer.WithNewContext()
+ .Stub(x => x.Post("/api/items"))
+ .Return("""{"id":42}""")
+ .AsContentType("application/json")
+ .WithStatus(HttpStatusCode.Created);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "/api/items")
+ {
+ Content = new StringContent(
+ """{"name":"Widget"}""", Encoding.UTF8, "application/json")
+ };
+ using var response = await _httpClient.SendAsync(request);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.That(body, Does.Contain("42"));
+ }
+
+ // ── Context isolation ────────────────────────────────────────────────────
+
+ [Test]
+ public async Task WithNewContext_ClearsPreviousStubs()
+ {
+ // First context: stub returns 200
+ _mockServer.WithNewContext()
+ .Stub(x => x.Get("/endpoint"))
+ .Return("first")
+ .OK();
+
+ using var first = await _httpClient.GetAsync("/endpoint");
+ Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+
+ // New context: no stubs registered — should return 404
+ _mockServer.WithNewContext();
+ using var second = await _httpClient.GetAsync("/endpoint");
+ Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
+ }
+
+ // ── Aspire resource model ────────────────────────────────────────────────
+
+ [Test]
+ public async Task ConnectionStringExpression_ReturnsRunningServerUrl()
+ {
+ var resource = _app.Services
+ .GetRequiredService()
+ .Resources
+ .OfType()
+ .Single(r => r.Name == "mock-api");
+
+ var connStr = await resource.ConnectionStringExpression
+ .GetValueAsync(CancellationToken.None);
+
+ Assert.That(connStr, Is.EqualTo(resource.Url));
+ Assert.That(connStr, Does.StartWith("http://localhost:"));
+ }
+}
diff --git a/src/HttpMock.Aspire.Hosting.Unit.Tests/HttpMock.Aspire.Hosting.Unit.Tests.csproj b/src/HttpMock.Aspire.Hosting.Unit.Tests/HttpMock.Aspire.Hosting.Unit.Tests.csproj
new file mode 100644
index 0000000..487fab8
--- /dev/null
+++ b/src/HttpMock.Aspire.Hosting.Unit.Tests/HttpMock.Aspire.Hosting.Unit.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ false
+ true
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/HttpMock.Aspire.Hosting.Unit.Tests/HttpMockResourceTests.cs b/src/HttpMock.Aspire.Hosting.Unit.Tests/HttpMockResourceTests.cs
new file mode 100644
index 0000000..06ff906
--- /dev/null
+++ b/src/HttpMock.Aspire.Hosting.Unit.Tests/HttpMockResourceTests.cs
@@ -0,0 +1,143 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Eventing;
+using HttpMock;
+using HttpMock.Aspire.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using NUnit.Framework;
+
+namespace HttpMock.Aspire.Hosting.Unit.Tests;
+
+[TestFixture]
+public class HttpMockResourceTests
+{
+ // ── Unit tests (no running app) ──────────────────────────────────────────
+
+ [Test]
+ public void ConnectionStringExpression_IsNotNull_WhenUrlNotSet()
+ {
+ var resource = new HttpMockResource("test");
+
+ Assert.That(resource.ConnectionStringExpression, Is.Not.Null);
+ }
+
+ [Test]
+ public async Task ConnectionStringExpression_ReturnsUrl_AfterUrlSet()
+ {
+ var resource = new HttpMockResource("test");
+ resource.Url = "http://localhost:54321";
+
+ var value = await resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None);
+
+ Assert.That(value, Is.EqualTo("http://localhost:54321"));
+ }
+
+ [Test]
+ public void Dispose_ClearsMockServerProperty()
+ {
+ var resource = new HttpMockResource("test");
+ resource.MockServer = HttpMockRepository.At($"http://localhost:{HttpMockRepository.FindFreePort()}");
+
+ resource.Dispose();
+
+ Assert.That(resource.MockServer, Is.Null);
+ }
+
+ [Test]
+ public void AddHttpMock_ResourceHasCorrectName()
+ {
+ var builder = DistributedApplication.CreateBuilder(
+ new DistributedApplicationOptions
+ {
+ DisableDashboard = true,
+ AssemblyName = typeof(HttpMockResourceTests).Assembly.GetName().Name
+ });
+
+ var resourceBuilder = builder.AddHttpMock("my-mock");
+
+ Assert.That(resourceBuilder.Resource.Name, Is.EqualTo("my-mock"));
+ }
+
+ // ── Subscriber integration test (in-process, no DCP required) ────────────
+
+ [Test]
+ public async Task Subscriber_StartsServerForHttpMockResource()
+ {
+ var resource = new HttpMockResource("test-mock");
+ var model = new DistributedApplicationModel([resource]);
+
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ await using var sp = services.BuildServiceProvider();
+ var notificationService = sp.GetRequiredService();
+
+ using var subscriber = new HttpMockEventingSubscriber(notificationService);
+ var eventing = new CapturingEventing();
+
+ var context = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run);
+ await subscriber.SubscribeAsync(eventing, context);
+
+ // Fire BeforeStartEvent directly — no DCP needed
+ var evt = new BeforeStartEvent(sp, model);
+ await eventing.PublishAsync(evt, CancellationToken.None);
+
+ Assert.That(resource.Url, Does.StartWith("http://localhost:"));
+ Assert.That(resource.MockServer, Is.Not.Null);
+ Assert.That(resource.MockServer!.IsAvailable(), Is.True);
+
+ var connStr = await resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None);
+ Assert.That(connStr, Is.EqualTo(resource.Url));
+ }
+
+ // ── Test helpers ─────────────────────────────────────────────────────────
+
+ /// Minimal with no-op token sources.
+ private sealed class NoOpHostLifetime : IHostApplicationLifetime
+ {
+ public CancellationToken ApplicationStarted => CancellationToken.None;
+ public CancellationToken ApplicationStopping => CancellationToken.None;
+ public CancellationToken ApplicationStopped => CancellationToken.None;
+ public void StopApplication() { }
+ }
+
+ ///
+ /// Minimal that captures subscriptions and
+ /// allows tests to fire events directly without a running Aspire host.
+ ///
+ private sealed class CapturingEventing : IDistributedApplicationEventing
+ {
+ private readonly Dictionary>> _subs = [];
+
+ public DistributedApplicationEventSubscription Subscribe(Func callback)
+ where T : IDistributedApplicationEvent
+ {
+ if (!_subs.TryGetValue(typeof(T), out var list))
+ _subs[typeof(T)] = list = [];
+
+ Task Wrapper(IDistributedApplicationEvent e, CancellationToken ct) => callback((T)e, ct);
+ list.Add(Wrapper);
+ return new DistributedApplicationEventSubscription(Wrapper);
+ }
+
+ DistributedApplicationEventSubscription IDistributedApplicationEventing.Subscribe(IResource resource, Func callback)
+ => ((IDistributedApplicationEventing)this).Subscribe(callback);
+
+ public void Unsubscribe(DistributedApplicationEventSubscription subscription) { }
+
+ public async Task PublishAsync(T evt, CancellationToken cancellationToken = default)
+ where T : IDistributedApplicationEvent
+ {
+ if (_subs.TryGetValue(typeof(T), out var callbacks))
+ foreach (var cb in callbacks)
+ await cb(evt, cancellationToken);
+ }
+
+ public Task PublishAsync(T evt, EventDispatchBehavior behavior, CancellationToken cancellationToken = default)
+ where T : IDistributedApplicationEvent
+ => PublishAsync(evt, cancellationToken);
+ }
+}
diff --git a/src/HttpMock.Aspire.Hosting/HttpMock.Aspire.Hosting.csproj b/src/HttpMock.Aspire.Hosting/HttpMock.Aspire.Hosting.csproj
new file mode 100644
index 0000000..c99fa74
--- /dev/null
+++ b/src/HttpMock.Aspire.Hosting/HttpMock.Aspire.Hosting.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net10.0
+ HttpMock.Aspire.Hosting
+ HttpMock.Aspire.Hosting
+ HttpMock.Aspire.Hosting
+ 3.0.0
+ Hibri Marzook
+ A .NET Aspire hosting integration for HttpMock. Register an HttpMock stub server as a first-class Aspire resource so it appears on the Aspire dashboard and participates in connection-string injection.
+ https://github.com/hibri/HttpMock
+ https://github.com/hibri/HttpMock
+ git
+ httpmock testing stubs http aspire
+ MIT
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>HttpMock.Aspire.Hosting.Tests
+
+
+
+
diff --git a/src/HttpMock.Aspire.Hosting/HttpMockEventingSubscriber.cs b/src/HttpMock.Aspire.Hosting/HttpMockEventingSubscriber.cs
new file mode 100644
index 0000000..724cee7
--- /dev/null
+++ b/src/HttpMock.Aspire.Hosting/HttpMockEventingSubscriber.cs
@@ -0,0 +1,65 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Eventing;
+using Aspire.Hosting.Lifecycle;
+using HttpMock;
+
+namespace HttpMock.Aspire.Hosting;
+
+///
+/// Aspire eventing subscriber that starts an for every
+/// registered in the app model before the distributed
+/// application starts, and disposes each server when the
+/// is torn down.
+///
+public sealed class HttpMockEventingSubscriber : IDistributedApplicationEventingSubscriber, IDisposable
+{
+ private readonly ResourceNotificationService _notificationService;
+ private readonly List _startedResources = [];
+
+ public HttpMockEventingSubscriber(ResourceNotificationService notificationService)
+ {
+ _notificationService = notificationService;
+ }
+
+ ///
+ public Task SubscribeAsync(
+ IDistributedApplicationEventing eventing,
+ DistributedApplicationExecutionContext executionContext,
+ CancellationToken cancellationToken = default)
+ {
+ eventing.Subscribe(async (evt, ct) =>
+ {
+ foreach (var resource in evt.Model.Resources.OfType())
+ {
+ var port = HttpMockRepository.FindFreePort();
+ var url = $"http://localhost:{port}";
+
+ resource.Url = url;
+ resource.MockServer = new HttpServer(new Uri(url));
+ resource.MockServer.Start();
+
+ _startedResources.Add(resource);
+
+ await _notificationService.PublishUpdateAsync(resource, s => s with
+ {
+ State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success),
+ Urls = [new UrlSnapshot("http", url, IsInternal: false)],
+ });
+ }
+ });
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public void Dispose()
+ {
+ foreach (var resource in _startedResources)
+ {
+ resource.Dispose();
+ }
+
+ _startedResources.Clear();
+ }
+}
diff --git a/src/HttpMock.Aspire.Hosting/HttpMockResource.cs b/src/HttpMock.Aspire.Hosting/HttpMockResource.cs
new file mode 100644
index 0000000..7bda2f2
--- /dev/null
+++ b/src/HttpMock.Aspire.Hosting/HttpMockResource.cs
@@ -0,0 +1,45 @@
+using Aspire.Hosting.ApplicationModel;
+using HttpMock;
+
+namespace HttpMock.Aspire.Hosting;
+
+///
+/// Represents an HttpMock stub server registered as a first-class .NET Aspire resource.
+///
+///
+/// Use to register this resource in
+/// the Aspire app host. The resource implements so
+/// that Aspire automatically injects the mock server URL into any dependent project as the
+/// ConnectionStrings__<name> environment variable.
+///
+public sealed class HttpMockResource : Resource, IResourceWithConnectionString, IDisposable
+{
+ ///
+ /// Gets the URL of the running mock server (e.g. http://localhost:54321).
+ /// Set by the Aspire lifecycle before any dependent resource starts.
+ ///
+ public string? Url { get; set; }
+
+ ///
+ /// Gets the live instance once the resource has started.
+ /// Use this in tests to register stubs:
+ ///
+ /// resource.MockServer.WithNewContext().Stub(x => x.Get("/path")).Return("body").OK();
+ ///
+ ///
+ public IHttpServer? MockServer { get; set; }
+
+ ///
+ public HttpMockResource(string name) : base(name) { }
+
+ ///
+ public ReferenceExpression ConnectionStringExpression =>
+ ReferenceExpression.Create($"{Url ?? string.Empty}");
+
+ ///
+ public void Dispose()
+ {
+ MockServer?.Dispose();
+ MockServer = null;
+ }
+}
diff --git a/src/HttpMock.Aspire.Hosting/HttpMockResourceBuilderExtensions.cs b/src/HttpMock.Aspire.Hosting/HttpMockResourceBuilderExtensions.cs
new file mode 100644
index 0000000..b4504f3
--- /dev/null
+++ b/src/HttpMock.Aspire.Hosting/HttpMockResourceBuilderExtensions.cs
@@ -0,0 +1,43 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Lifecycle;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HttpMock.Aspire.Hosting;
+
+///
+/// Fluent extension methods for registering instances in a
+/// .NET Aspire app host.
+///
+public static class HttpMockResourceBuilderExtensions
+{
+ ///
+ /// Adds an HttpMock stub server as a first-class Aspire resource.
+ ///
+ /// The distributed application builder.
+ ///
+ /// The logical name of the resource. Aspire uses this name to inject the mock server's URL
+ /// into dependent projects as ConnectionStrings__<name>.
+ ///
+ /// An for further configuration.
+ ///
+ ///
+ /// // App host
+ /// var mockApi = builder.AddHttpMock("external-weather-api");
+ /// builder.AddProject<Projects.WeatherApi>("weatherapi")
+ /// .WithReference(mockApi);
+ ///
+ /// // Test
+ /// var mock = _app.Resources.OfType<HttpMockResource>().Single(r => r.Name == "external-weather-api");
+ /// mock.MockServer.WithNewContext().Stub(x => x.Get("/api/weather")).Return(json).OK();
+ ///
+ ///
+ public static IResourceBuilder AddHttpMock(
+ this IDistributedApplicationBuilder builder,
+ string name)
+ {
+ builder.Services.TryAddEventingSubscriber();
+ var resource = new HttpMockResource(name);
+ return builder.AddResource(resource);
+ }
+}
diff --git a/src/HttpMock.Integration.Tests/HostHelper.cs b/src/HttpMock.Integration.Tests/HostHelper.cs
index 36f4938..3591804 100644
--- a/src/HttpMock.Integration.Tests/HostHelper.cs
+++ b/src/HttpMock.Integration.Tests/HostHelper.cs
@@ -12,7 +12,7 @@ public static string GenerateAHostUrlForAStubServerWith(string basePath)
public static string GenerateAHostUrlForAStubServer()
{
- return String.Format("http://localhost:{0}", PortHelper.FindLocalAvailablePortForTesting());
+ return String.Format("http://localhost:{0}", HttpMockRepository.FindFreePort());
}
}
}
\ No newline at end of file
diff --git a/src/HttpMock.Integration.Tests/HttpServerTests.cs b/src/HttpMock.Integration.Tests/HttpServerTests.cs
index b1b5117..ea7ffdb 100644
--- a/src/HttpMock.Integration.Tests/HttpServerTests.cs
+++ b/src/HttpMock.Integration.Tests/HttpServerTests.cs
@@ -10,7 +10,7 @@ public class HttpServerTests
public void IsAvailableReturnsFalseIfStartNotCalled()
{
IHttpServer httpServer = new HttpServer(new Uri(String.Format("http://localhost:{0}",
- PortHelper.FindLocalAvailablePortForTesting())));
+ HttpMockRepository.FindFreePort())));
Assert.That(httpServer.IsAvailable(), Is.EqualTo(false));
}
}
diff --git a/src/HttpMock.Integration.Tests/PortHelper.cs b/src/HttpMock.Integration.Tests/PortHelper.cs
deleted file mode 100644
index d04fba5..0000000
--- a/src/HttpMock.Integration.Tests/PortHelper.cs
+++ /dev/null
@@ -1,74 +0,0 @@
-using System;
-using System.Linq;
-using System.Net;
-using System.Net.NetworkInformation;
-using System.Runtime.InteropServices;
-using System.Diagnostics;
-using System.IO;
-
-namespace HttpMock.Integration.Tests
-{
- internal static class PortHelper
- {
- internal static int FindLocalAvailablePortForTesting ()
- {
- const int minPort = 1024;
-
- var random = new Random ();
- var maxPort = 64000;
- var randomPort = random.Next (minPort, maxPort);
-
-
- while (IsPortInUse (randomPort)) {
- randomPort = random.Next (minPort, maxPort);
- }
-
- return randomPort;
- }
-
- private static string FindLsof()
- {
- var paths = Environment.GetEnvironmentVariable("PATH")?.Split(':') ?? new string[0];
- foreach (var dir in paths)
- {
- var full = Path.Combine(dir, "lsof");
- if (File.Exists(full))
- return full;
- }
- return "lsof";
- }
-
- private static bool IsPortInUse (int randomPort)
- {
-
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) {
- var process = new Process () {
- StartInfo = new ProcessStartInfo (FindLsof(), "-Pni") {
- RedirectStandardOutput = true,
- UseShellExecute = false
- }
- };
-
- using (process) {
-
- process.Start ();
-
- var output = process.StandardOutput.ReadToEnd ();
-
- var lines = output.Split (new string[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries);
-
- return lines.Any (s => s.EndsWith (string.Format ("{0} (LISTEN)", randomPort)));
- }
-
-
- } else {
-
- var properties = IPGlobalProperties.GetIPGlobalProperties ();
- return properties.GetActiveTcpConnections ().Any (a => a.LocalEndPoint.Port == randomPort) && properties.GetActiveTcpListeners ().Any (a => a.Port == randomPort);
- }
- }
-
-
- }
-
-}
\ No newline at end of file
diff --git a/src/HttpMock/HttpMockRepository.cs b/src/HttpMock/HttpMockRepository.cs
index d71f482..3dd68cd 100644
--- a/src/HttpMock/HttpMockRepository.cs
+++ b/src/HttpMock/HttpMockRepository.cs
@@ -1,23 +1,46 @@
-using System;
-using Microsoft.Extensions.Logging;
-
-namespace HttpMock
-{
- public static class HttpMockRepository
- {
- private static readonly HttpServerFactory _httpServerFactory = new HttpServerFactory();
-
- public static IHttpServer At(string uri, ILoggerFactory loggerFactory = null) {
- if (uri.Trim().EndsWith("/")) {
- throw new ArgumentException(
- String.Format("Do not use a trailing slash for the server URI please: {0}", uri), "uri");
- }
- return At(new Uri(uri), loggerFactory);
- }
-
- public static IHttpServer At(Uri uri, ILoggerFactory loggerFactory = null)
- {
- return _httpServerFactory.Get(uri, loggerFactory).WithNewContext();
- }
- }
-}
\ No newline at end of file
+using System;
+using System.Net;
+using System.Net.Sockets;
+using Microsoft.Extensions.Logging;
+
+namespace HttpMock
+{
+ public static class HttpMockRepository
+ {
+ private static readonly HttpServerFactory _httpServerFactory = new HttpServerFactory();
+
+ public static IHttpServer At(string uri, ILoggerFactory loggerFactory = null)
+ {
+ if (uri.Trim().EndsWith("/"))
+ {
+ throw new ArgumentException(
+ String.Format("Do not use a trailing slash for the server URI please: {0}", uri), "uri");
+ }
+
+ return At(new Uri(uri), loggerFactory);
+ }
+
+ public static IHttpServer At(Uri uri, ILoggerFactory loggerFactory = null)
+ {
+ return _httpServerFactory.Get(uri, loggerFactory).WithNewContext();
+ }
+
+ ///
+ /// Asks the operating system for a free TCP port on the loopback interface.
+ ///
+ ///
+ /// The port is reserved by on port 0 and released
+ /// immediately. There is a small TOCTOU window between the release and the
+ /// caller binding to the port; this is an accepted limitation for test/mock usage.
+ ///
+ /// An available port number.
+ public static int FindFreePort()
+ {
+ using var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+ }
+}