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; + } + } +}