From d14084a9e9545a93486d192c462a519339d1ca7d Mon Sep 17 00:00:00 2001 From: Mattias Karlsson Date: Wed, 14 Oct 2026 10:41:32 +0200 Subject: [PATCH] feat: add MockHttp global tool with inspect UI and live activity Host Routes.json on Kestrel so real HttpClients can mock an external API, with path-or-host matching, /inspect request-header details, and a Spectre live console. --- .gitattributes | 6 +- README.md | 11 + build/records.cs | 4 + cake.cs | 8 +- global.json | 2 +- ...Devlead.Testing.MockHttp.Tool.Tests.csproj | 43 +++ .../Fixtures/HeaderMismatch/Example/Index.txt | 1 + .../Fixtures/HeaderMismatch/Routes.json | 24 ++ ...AfterGetIncludesHitAndHeaders.verified.txt | 87 ++++++ .../InspectTests.cs | 92 ++++++ .../MockHttpTestHost.cs | 45 +++ .../RouteTableTests.cs | 94 ++++++ ....EnableDisable_methods=DELETE.verified.txt | 13 + ...sts.EnableDisable_methods=GET.verified.txt | 13 + ...isable_methods=PUT,DELETE,GET.verified.txt | 35 +++ ...bleDisable_methods=PUT,DELETE.verified.txt | 24 ++ ...isable_methods=PUT,GET,DELETE.verified.txt | 35 +++ ...EnableDisable_methods=PUT,GET.verified.txt | 24 ++ ...sts.EnableDisable_methods=PUT.verified.txt | 13 + .../RouteTests.GetIndex.verified.txt | 22 ++ ...ts.GetSecretWithAuthorization.verified.txt | 22 ++ ...GetSecretWithoutAuthorization.verified.txt | 11 + ...sts.HeaderMatchReturnsContent.verified.txt | 22 ++ ...ts.HeaderMismatchIsBadRequest.verified.txt | 11 + .../RouteTests.MatchHostHit.verified.txt | 22 ++ .../RouteTests.MatchHostMiss.verified.txt | 11 + ...teTests.UnknownPathIsNotFound.verified.txt | 11 + .../RouteTests.cs | 112 +++++++ .../ServeSettingsTests.cs | 42 +++ .../VerifyModule.cs | 10 + .../Commands/ServeCommand.ListenUrl.cs | 43 +++ .../Commands/ServeCommand.cs | 60 ++++ .../Commands/ServeSettings.cs | 38 +++ .../Devlead.Testing.MockHttp.Tool.csproj | 46 +++ .../Hosting/MockHttpWebHost.Inspect.cs | 52 ++++ .../Hosting/MockHttpWebHost.cs | 39 +++ .../Inspect/Models/ActivityEntry.cs | 16 + .../Inspect/Models/InspectCounts.cs | 8 + .../Inspect/Models/InspectSnapshot.cs | 11 + .../Inspect/Models/MockHttpListenUrl.cs | 3 + .../Inspect/Models/RouteSnapshot.cs | 9 + .../Inspect/Models/ToggleRouteRequest.cs | 3 + .../Inspect/RequestActivity.cs | 48 +++ .../Inspect/inspect.html | 277 ++++++++++++++++++ src/Devlead.Testing.MockHttp.Tool/Program.cs | 23 ++ src/Devlead.Testing.MockHttp.Tool/README.md | 48 +++ .../Routing/Models/RouteDocument.cs | 7 + .../Routing/Models/RouteEnableRequest.cs | 12 + .../Routing/Models/RouteMethod.cs | 3 + .../Routing/Models/RouteRequest.cs | 9 + .../Routing/Models/RouteResponse.cs | 16 + .../Routing/ResourceFiles.cs | 62 ++++ .../Routing/RouteTable.Entry.cs | 22 ++ .../Routing/RouteTable.Headers.cs | 49 ++++ .../Routing/RouteTable.Inspect.cs | 44 +++ .../Routing/RouteTable.Invoke.cs | 124 ++++++++ .../Routing/RouteTable.Match.cs | 52 ++++ .../Routing/RouteTable.cs | 82 ++++++ .../Ui/StatusLiveView.Build.cs | 52 ++++ .../Ui/StatusLiveView.cs | 59 ++++ src/Devlead.Testing.MockHttp.slnx | 2 + .../Devlead.Testing.MockHttp.csproj | 1 + src/Directory.Packages.props | 8 +- 63 files changed, 2189 insertions(+), 9 deletions(-) create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/Devlead.Testing.MockHttp.Tool.Tests.csproj create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/Fixtures/HeaderMismatch/Example/Index.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/Fixtures/HeaderMismatch/Routes.json create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/InspectTests.InspectApiAfterGetIncludesHitAndHeaders.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/InspectTests.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/MockHttpTestHost.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTableTests.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=DELETE.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=GET.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,DELETE,GET.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,DELETE.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,GET,DELETE.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,GET.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.GetIndex.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.GetSecretWithAuthorization.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.GetSecretWithoutAuthorization.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.HeaderMatchReturnsContent.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.HeaderMismatchIsBadRequest.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.MatchHostHit.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.MatchHostMiss.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.UnknownPathIsNotFound.verified.txt create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/ServeSettingsTests.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool.Tests/VerifyModule.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Commands/ServeCommand.ListenUrl.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Commands/ServeCommand.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Commands/ServeSettings.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Devlead.Testing.MockHttp.Tool.csproj create mode 100644 src/Devlead.Testing.MockHttp.Tool/Hosting/MockHttpWebHost.Inspect.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Hosting/MockHttpWebHost.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Inspect/Models/ActivityEntry.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Inspect/Models/InspectCounts.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Inspect/Models/InspectSnapshot.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Inspect/Models/MockHttpListenUrl.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Inspect/Models/RouteSnapshot.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Inspect/Models/ToggleRouteRequest.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Inspect/RequestActivity.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Inspect/inspect.html create mode 100644 src/Devlead.Testing.MockHttp.Tool/Program.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/README.md create mode 100644 src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteDocument.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteEnableRequest.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteMethod.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteRequest.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteResponse.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Routing/ResourceFiles.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Entry.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Headers.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Inspect.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Invoke.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Match.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Ui/StatusLiveView.Build.cs create mode 100644 src/Devlead.Testing.MockHttp.Tool/Ui/StatusLiveView.cs diff --git a/.gitattributes b/.gitattributes index eee2264..023f926 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,7 @@ # Verify -*.verified.txt text eol=lf working-tree-encoding=UTF-8 \ No newline at end of file +*.verified.txt text eol=lf working-tree-encoding=UTF-8 + +# Mock HTTP bodies (Content-MD5 is computed from file bytes) +src/Devlead.Testing.MockHttp.Tests/Resources/** text eol=lf +src/Devlead.Testing.MockHttp.Tool.Tests/Fixtures/** text eol=lf \ No newline at end of file diff --git a/README.md b/README.md index d73b624..2d7041f 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,17 @@ public class MyServiceTests This approach ensures that your service logic is tested in isolation, without making actual network requests, by simulating HTTP interactions using the mock HTTP client. The `Constants` type helps manage the configuration of these interactions, providing a centralized way to define and access resource settings. +## Local mock HTTP tool + +`Devlead.Testing.MockHttp.Tool` is a .NET global tool that hosts the same `Routes.json` contract on Kestrel instead of replacing `HttpClient`. Install and serve a resources directory: + +```bash +dotnet tool install -g Devlead.Testing.MockHttp.Tool +dotnet mockhttp serve ./Resources --port 5080 +``` + +By default the tool matches method, path, and query so a client can use `http://127.0.0.1:5080` as its base address. Pass `--match-host` to also require the request `Host` to match the host in `AbsoluteUri`. The console shows live route and activity tables; `http://127.0.0.1:5080/inspect` has request/response header details. See the [tool README](src/Devlead.Testing.MockHttp.Tool/README.md) for details. + ## Example project A real world example can be found in the [Blobify](https://github.com/devlead/Blobify) project. diff --git a/build/records.cs b/build/records.cs index 102816b..0bce6a6 100644 --- a/build/records.cs +++ b/build/records.cs @@ -17,6 +17,10 @@ DirectoryPath OutputPath { private const string IntegrationTest = "integrationtest", Output = "output"; + public FilePath ToolProjectPath => ProjectRoot.CombineWithFilePath("Devlead.Testing.MockHttp.Tool/Devlead.Testing.MockHttp.Tool.csproj"); + + public FilePath[] PackableProjectPaths => [ProjectPath, ToolProjectPath]; + public DirectoryPath NuGetOutputPath { get; } = OutputPath.Combine("nuget"); public DirectoryPath BinaryOutputPath { get; } = OutputPath.Combine("bin"); public DirectoryPath IntegrationTestPath { get; } = OutputPath.Combine(IntegrationTest); diff --git a/cake.cs b/cake.cs index 4c55c83..6615467 100644 --- a/cake.cs +++ b/cake.cs @@ -58,7 +58,6 @@ .WithProperty("Company", "devlead") .WithProperty("PackageLicenseExpression", "MIT") .WithProperty("PackageTags", "testing;http") - .WithProperty("PackageDescription", ".NET Library for mocking HTTP client requests") .WithProperty("RepositoryUrl", "https://github.com/devlead/Devlead.Testing.MockHttp.git") .WithProperty("ContinuousIntegrationBuild", GitHubActions.IsRunningOnGitHubActions ? "true" : "false") .WithProperty("EmbedUntrackedSources", "true"), @@ -127,9 +126,10 @@ ) ) .Then("Pack") - .Does( - static (context, data) => context.DotNetPack( - data.ProjectPath.FullPath, + .DoesForEach( + static (data, _) => data.PackableProjectPaths, + static (data, project, context) => context.DotNetPack( + project.FullPath, new DotNetPackSettings { NoBuild = true, NoRestore = true, diff --git a/global.json b/global.json index 5b0245b..60ff44e 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { "rollForward": "latestFeature", - "version": "11.0.100-rc.1.26425.128" + "version": "11.0.100-rc.2.26504.105" } } \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/Devlead.Testing.MockHttp.Tool.Tests.csproj b/src/Devlead.Testing.MockHttp.Tool.Tests/Devlead.Testing.MockHttp.Tool.Tests.csproj new file mode 100644 index 0000000..3ecbfe4 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/Devlead.Testing.MockHttp.Tool.Tests.csproj @@ -0,0 +1,43 @@ + + + + net11.0 + latest + enable + enable + false + NU1008 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/Fixtures/HeaderMismatch/Example/Index.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/Fixtures/HeaderMismatch/Example/Index.txt new file mode 100644 index 0000000..b45ef6f --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/Fixtures/HeaderMismatch/Example/Index.txt @@ -0,0 +1 @@ +Hello, World! \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/Fixtures/HeaderMismatch/Routes.json b/src/Devlead.Testing.MockHttp.Tool.Tests/Fixtures/HeaderMismatch/Routes.json new file mode 100644 index 0000000..a90b6db --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/Fixtures/HeaderMismatch/Routes.json @@ -0,0 +1,24 @@ +[ + { + "Request": { + "Methods": [ + { + "Method": "GET" + } + ], + "AbsoluteUri": "https://example.com/headers.txt" + }, + "Responses": [ + { + "RequestHeaders": { + "X-Test": [ + "yes" + ] + }, + "ContentResource": "Example.Index.txt", + "ContentType": "text/plain", + "StatusCode": 200 + } + ] + } +] diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/InspectTests.InspectApiAfterGetIncludesHitAndHeaders.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/InspectTests.InspectApiAfterGetIncludesHitAndHeaders.verified.txt new file mode 100644 index 0000000..a5c1f61 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/InspectTests.InspectApiAfterGetIncludesHitAndHeaders.verified.txt @@ -0,0 +1,87 @@ +{ + version: {Scrubbed}, + listenUrl: http://localhost, + inspectUrl: http://localhost/inspect, + resourcesDirectory: {Scrubbed}, + matchHost: false, + counts: { + routes: 5, + enabled: 3, + requests: 1, + unauthorized: 0, + notFound: 0 + }, + routes: [ + { + method: GET, + absoluteUri: https://example.com/login/secret.json, + host: example.com, + pathAndQuery: /login/secret.json, + enabled: true, + hits: 0 + }, + { + method: GET, + absoluteUri: https://example.com/index.txt, + host: example.com, + pathAndQuery: /index.txt, + enabled: true, + hits: 1 + }, + { + method: PUT, + absoluteUri: https://example.com/new.txt, + host: example.com, + pathAndQuery: /new.txt, + enabled: true, + hits: 0 + }, + { + method: GET, + absoluteUri: https://example.com/new.txt, + host: example.com, + pathAndQuery: /new.txt, + enabled: false, + hits: 0 + }, + { + method: DELETE, + absoluteUri: https://example.com/new.txt, + host: example.com, + pathAndQuery: /new.txt, + enabled: false, + hits: 0 + } + ], + activity: [ + { + id: {Scrubbed}, + utc: {Scrubbed}, + method: GET, + pathAndQuery: /index.txt, + host: localhost, + status: 200, + matchedAbsoluteUri: https://example.com/index.txt, + enableDisable: false, + requestHeaders: { + X-Test: [ + inspect + ], + Host: [ + localhost + ] + }, + responseHeaders: { + Content-Type: [ + text/plain + ], + Content-MD5: [ + ZajifYh5KDgxtmS9i38K1A== + ] + }, + contentType: text/plain, + contentLength: 13, + bodyPreview: Hello, World! + } + ] +} \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/InspectTests.cs b/src/Devlead.Testing.MockHttp.Tool.Tests/InspectTests.cs new file mode 100644 index 0000000..3fbbd86 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/InspectTests.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace Devlead.Testing.MockHttp.Tool.Tests; + +public class InspectTests +{ + [Test] + public async Task InspectPageReturnsHtml() + { + await using var host = await MockHttpTestHost.StartAsync(); + + var response = await host.Client.GetAsync("/inspect"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(response.Content.Headers.ContentType?.MediaType, Is.EqualTo("text/html")); + var html = await response.Content.ReadAsStringAsync(); + Assert.That(html, Does.Contain("MockHttp inspect")); + Assert.That(html, Does.Contain("Request headers")); + Assert.That(html, Does.Contain("Response headers")); + } + + [Test] + public async Task InspectApiAfterGetIncludesHitAndHeaders() + { + await using var host = await MockHttpTestHost.StartAsync(); + host.Client.DefaultRequestHeaders.Add("X-Test", "inspect"); + + await host.Client.GetAsync("/index.txt"); + var json = await host.Client.GetStringAsync("/inspect/api"); + + await VerifyJson(json) + .ScrubMember("version") + .ScrubMember("resourcesDirectory") + .ScrubMember("id") + .ScrubMember("utc"); + } + + [Test] + public async Task DisabledRouteGetDoesNotIncrementHits() + { + await using var host = await MockHttpTestHost.StartAsync(); + + var response = await host.Client.GetAsync("/new.txt"); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + + var snapshot = await host.Client.GetFromJsonAsync("/inspect/api"); + var route = snapshot.GetProperty("routes").EnumerateArray().Single(item => + item.GetProperty("method").GetString() == "GET" + && item.GetProperty("pathAndQuery").GetString() == "/new.txt"); + Assert.That(route.GetProperty("hits").GetInt64(), Is.EqualTo(0)); + } + + [Test] + public async Task ToggleDisableThenGetIsNotFound() + { + await using var host = await MockHttpTestHost.StartAsync(); + + var toggle = await host.Client.PostAsJsonAsync( + "/inspect/api/routes/toggle", + new { method = "GET", absoluteUri = "https://example.com/index.txt", enabled = false }); + Assert.That(toggle.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + + var response = await host.Client.GetAsync("/index.txt"); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task ClearActivityRemovesHits() + { + await using var host = await MockHttpTestHost.StartAsync(); + await host.Client.GetAsync("/index.txt"); + + var clear = await host.Client.DeleteAsync("/inspect/api/activity"); + Assert.That(clear.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + + var snapshot = await host.Client.GetFromJsonAsync("/inspect/api"); + Assert.That(snapshot.GetProperty("activity").GetArrayLength(), Is.EqualTo(0)); + Assert.That(snapshot.GetProperty("counts").GetProperty("requests").GetInt32(), Is.EqualTo(0)); + } + + [Test] + public async Task InspectIsNotMockedAsNotFound() + { + await using var host = await MockHttpTestHost.StartAsync(); + + var response = await host.Client.GetAsync("/inspect"); + + Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.NotFound)); + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/MockHttpTestHost.cs b/src/Devlead.Testing.MockHttp.Tool.Tests/MockHttpTestHost.cs new file mode 100644 index 0000000..8e4aac1 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/MockHttpTestHost.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Devlead.Testing.MockHttp.Tool.Tests; + +internal sealed class MockHttpTestHost : IAsyncDisposable +{ + private MockHttpTestHost(WebApplication app, HttpClient client) + { + App = app; + Client = client; + } + + public WebApplication App { get; } + + public HttpClient Client { get; } + + public static async Task StartAsync(string? resourcesDirectory = null, bool matchHost = false) + { + var table = RouteTable.Load(resourcesDirectory ?? TestPaths.LibraryResources, matchHost); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddSingleton(table); + builder.Services.AddSingleton(new MockHttpListenUrl("http://localhost")); + var app = builder.Build(); + MockHttpWebHost.Map(app); + await app.StartAsync(); + return new MockHttpTestHost(app, app.GetTestClient()); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await App.DisposeAsync(); + } +} + +internal static class TestPaths +{ + public static string LibraryResources { get; } = Path.Combine(AppContext.BaseDirectory, "Resources"); + + public static string HeaderMismatch { get; } = Path.Combine(AppContext.BaseDirectory, "Fixtures", "HeaderMismatch"); +} diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTableTests.cs b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTableTests.cs new file mode 100644 index 0000000..0cb6594 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTableTests.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Http; + +namespace Devlead.Testing.MockHttp.Tool.Tests; + +public class RouteTableTests +{ + [Test] + public void Load_MissingContentResource_Throws() + { + using var directory = TemporaryRoutes.Create( + """ + [ + { + "Request": { + "Methods": [{ "Method": "GET" }], + "AbsoluteUri": "https://example.com/x" + }, + "Responses": [ + { + "ContentResource": "Missing.File.txt", + "ContentType": "text/plain", + "StatusCode": 200 + } + ] + } + ] + """); + + var ex = Assert.Throws(() => RouteTable.Load(directory.Path, false)); + Assert.That(ex!.Message, Does.Contain("Missing.File.txt")); + Assert.That(ex.Message, Does.Contain("https://example.com/x")); + } + + [Test] + public void Load_ContentResourceOutsideDirectory_Throws() + { + using var directory = TemporaryRoutes.Create( + """ + [ + { + "Request": { + "Methods": [{ "Method": "GET" }], + "AbsoluteUri": "https://example.com/x" + }, + "Responses": [ + { + "ContentResource": "..Outside.txt", + "ContentType": "text/plain", + "StatusCode": 200 + } + ] + } + ] + """); + + var ex = Assert.Throws(() => RouteTable.Load(directory.Path, false)); + Assert.That(ex!.Message, Does.Contain("..Outside.txt")); + Assert.That(ex.Message, Does.Contain("https://example.com/x")); + } + + [Test] + public void HostMatches_OmittedPortUsesSchemeDefault() + { + var httpsDefault = new Uri("https://example.com/index.txt"); + var httpOn443 = new Uri("http://example.com:443/index.txt"); + var host = new HostString("example.com"); + + Assert.That(RouteTable.HostMatches(httpsDefault, host), Is.True); + Assert.That(RouteTable.HostMatches(httpOn443, host), Is.False); + } +} + +file sealed class TemporaryRoutes : IDisposable +{ + private TemporaryRoutes(string path) => Path = path; + + public string Path { get; } + + public static TemporaryRoutes Create(string routesJson) + { + var path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(path); + File.WriteAllText(System.IO.Path.Combine(path, "Routes.json"), routesJson); + return new TemporaryRoutes(path); + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, true); + } + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=DELETE.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=DELETE.verified.txt new file mode 100644 index 0000000..c1162de --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=DELETE.verified.txt @@ -0,0 +1,13 @@ +[ + { + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: NotFound, + ReasonPhrase: Not Found, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: false + } +] \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=GET.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=GET.verified.txt new file mode 100644 index 0000000..c1162de --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=GET.verified.txt @@ -0,0 +1,13 @@ +[ + { + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: NotFound, + ReasonPhrase: Not Found, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: false + } +] \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,DELETE,GET.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,DELETE,GET.verified.txt new file mode 100644 index 0000000..bf834d2 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,DELETE,GET.verified.txt @@ -0,0 +1,35 @@ +[ + { + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: Created, + ReasonPhrase: Created, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true + }, + { + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: NoContent, + ReasonPhrase: No Content, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true + }, + { + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: NotFound, + ReasonPhrase: Not Found, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: false + } +] \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,DELETE.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,DELETE.verified.txt new file mode 100644 index 0000000..b26c6bc --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,DELETE.verified.txt @@ -0,0 +1,24 @@ +[ + { + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: Created, + ReasonPhrase: Created, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true + }, + { + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: NoContent, + ReasonPhrase: No Content, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true + } +] \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,GET,DELETE.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,GET,DELETE.verified.txt new file mode 100644 index 0000000..f2505b2 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,GET,DELETE.verified.txt @@ -0,0 +1,35 @@ +[ + { + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: Created, + ReasonPhrase: Created, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true + }, + { + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: OK, + ReasonPhrase: OK, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true + }, + { + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: NoContent, + ReasonPhrase: No Content, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true + } +] \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,GET.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,GET.verified.txt new file mode 100644 index 0000000..1a6da4e --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT,GET.verified.txt @@ -0,0 +1,24 @@ +[ + { + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: Created, + ReasonPhrase: Created, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true + }, + { + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: OK, + ReasonPhrase: OK, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true + } +] \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT.verified.txt new file mode 100644 index 0000000..0420963 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.EnableDisable_methods=PUT.verified.txt @@ -0,0 +1,13 @@ +[ + { + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: Created, + ReasonPhrase: Created, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true + } +] \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.GetIndex.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.GetIndex.verified.txt new file mode 100644 index 0000000..125f39b --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.GetIndex.verified.txt @@ -0,0 +1,22 @@ +{ + Version: 1.1, + Content: { + Headers: [ + { + Content-Type: [ + text/plain + ] + }, + { + Content-MD5: [ + ZajifYh5KDgxtmS9i38K1A== + ] + } + ] + }, + StatusCode: OK, + ReasonPhrase: OK, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true +} \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.GetSecretWithAuthorization.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.GetSecretWithAuthorization.verified.txt new file mode 100644 index 0000000..ab4eb92 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.GetSecretWithAuthorization.verified.txt @@ -0,0 +1,22 @@ +{ + Version: 1.1, + Content: { + Headers: [ + { + Content-Type: [ + application/json + ] + }, + { + Content-MD5: [ + wsHdBlIc0NmV7pIsc33m6w== + ] + } + ] + }, + StatusCode: OK, + ReasonPhrase: OK, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true +} \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.GetSecretWithoutAuthorization.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.GetSecretWithoutAuthorization.verified.txt new file mode 100644 index 0000000..0750dfc --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.GetSecretWithoutAuthorization.verified.txt @@ -0,0 +1,11 @@ +{ + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: Unauthorized, + ReasonPhrase: Unauthorized, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: false +} \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.HeaderMatchReturnsContent.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.HeaderMatchReturnsContent.verified.txt new file mode 100644 index 0000000..125f39b --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.HeaderMatchReturnsContent.verified.txt @@ -0,0 +1,22 @@ +{ + Version: 1.1, + Content: { + Headers: [ + { + Content-Type: [ + text/plain + ] + }, + { + Content-MD5: [ + ZajifYh5KDgxtmS9i38K1A== + ] + } + ] + }, + StatusCode: OK, + ReasonPhrase: OK, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true +} \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.HeaderMismatchIsBadRequest.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.HeaderMismatchIsBadRequest.verified.txt new file mode 100644 index 0000000..4662019 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.HeaderMismatchIsBadRequest.verified.txt @@ -0,0 +1,11 @@ +{ + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: BadRequest, + ReasonPhrase: Bad Request, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: false +} \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.MatchHostHit.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.MatchHostHit.verified.txt new file mode 100644 index 0000000..125f39b --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.MatchHostHit.verified.txt @@ -0,0 +1,22 @@ +{ + Version: 1.1, + Content: { + Headers: [ + { + Content-Type: [ + text/plain + ] + }, + { + Content-MD5: [ + ZajifYh5KDgxtmS9i38K1A== + ] + } + ] + }, + StatusCode: OK, + ReasonPhrase: OK, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: true +} \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.MatchHostMiss.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.MatchHostMiss.verified.txt new file mode 100644 index 0000000..da4b6bf --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.MatchHostMiss.verified.txt @@ -0,0 +1,11 @@ +{ + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: NotFound, + ReasonPhrase: Not Found, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: false +} \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.UnknownPathIsNotFound.verified.txt b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.UnknownPathIsNotFound.verified.txt new file mode 100644 index 0000000..da4b6bf --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.UnknownPathIsNotFound.verified.txt @@ -0,0 +1,11 @@ +{ + Version: 1.1, + Content: { + Headers: [] + }, + StatusCode: NotFound, + ReasonPhrase: Not Found, + Headers: [], + TrailingHeaders: [], + IsSuccessStatusCode: false +} \ No newline at end of file diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.cs b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.cs new file mode 100644 index 0000000..10db6a0 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/RouteTests.cs @@ -0,0 +1,112 @@ +using System.Net.Http.Headers; + +namespace Devlead.Testing.MockHttp.Tool.Tests; + +public class RouteTests +{ + [Test] + public async Task GetIndex() + { + await using var host = await MockHttpTestHost.StartAsync(); + + var response = await host.Client.GetAsync("/index.txt"); + + await Verify(response); + } + + [Test] + public async Task GetSecretWithoutAuthorization() + { + await using var host = await MockHttpTestHost.StartAsync(); + + var response = await host.Client.GetAsync("/login/secret.json"); + + await Verify(response); + } + + [Test] + public async Task GetSecretWithAuthorization() + { + await using var host = await MockHttpTestHost.StartAsync(); + host.Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "AccessToken"); + + var response = await host.Client.GetAsync("/login/secret.json"); + + await Verify(response); + } + + [Test] + public async Task UnknownPathIsNotFound() + { + await using var host = await MockHttpTestHost.StartAsync(); + + var response = await host.Client.GetAsync("/missing.txt"); + + await Verify(response); + } + + [Test] + public async Task HeaderMismatchIsBadRequest() + { + await using var host = await MockHttpTestHost.StartAsync(TestPaths.HeaderMismatch); + + var response = await host.Client.GetAsync("/headers.txt"); + + await Verify(response); + } + + [Test] + public async Task HeaderMatchReturnsContent() + { + await using var host = await MockHttpTestHost.StartAsync(TestPaths.HeaderMismatch); + host.Client.DefaultRequestHeaders.Add("X-Test", "yes"); + + var response = await host.Client.GetAsync("/headers.txt"); + + await Verify(response); + } + + [TestCase("GET")] + [TestCase("DELETE")] + [TestCase("PUT")] + [TestCase("PUT", "GET")] + [TestCase("PUT", "DELETE")] + [TestCase("PUT", "GET", "DELETE")] + [TestCase("PUT", "DELETE", "GET")] + public async Task EnableDisable(params string[] methods) + { + await using var host = await MockHttpTestHost.StartAsync(); + + var responses = new List(); + foreach (var method in methods) + { + responses.Add( + await host.Client.SendAsync( + new HttpRequestMessage(new HttpMethod(method), "/new.txt"))); + } + + await Verify(responses).UseParameters(string.Join(",", methods)); + } + + [Test] + public async Task MatchHostHit() + { + await using var host = await MockHttpTestHost.StartAsync(matchHost: true); + host.Client.DefaultRequestHeaders.Host = "example.com"; + + var response = await host.Client.GetAsync("/index.txt"); + + await Verify(response); + } + + [Test] + public async Task MatchHostMiss() + { + await using var host = await MockHttpTestHost.StartAsync(matchHost: true); + host.Client.DefaultRequestHeaders.Host = "other.com"; + + var response = await host.Client.GetAsync("/index.txt"); + + await Verify(response); + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/ServeSettingsTests.cs b/src/Devlead.Testing.MockHttp.Tool.Tests/ServeSettingsTests.cs new file mode 100644 index 0000000..9581812 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/ServeSettingsTests.cs @@ -0,0 +1,42 @@ +using Devlead.Testing.MockHttp.Tool.Commands; + +namespace Devlead.Testing.MockHttp.Tool.Tests; + +public class ServeSettingsTests +{ + [Test] + public void ResolveListenUrl_DefaultPort() + { + var url = ServeCommand.ResolveListenUrl(new ServeSettings()); + + Assert.That(url, Is.EqualTo("http://127.0.0.1:5080")); + } + + [Test] + public void ResolveListenUrl_CustomPort() + { + var url = ServeCommand.ResolveListenUrl(new ServeSettings { Port = 6001 }); + + Assert.That(url, Is.EqualTo("http://127.0.0.1:6001")); + } + + [Test] + public void ResolveListenUrl_UrlOverridesPort() + { + var url = ServeCommand.ResolveListenUrl( + new ServeSettings + { + Port = 6001, + Url = "http://127.0.0.1:7000/ignored" + }); + + Assert.That(url, Is.EqualTo("http://127.0.0.1:7000")); + } + + [Test] + public void ResolveListenUrl_InvalidUrlThrows() + { + Assert.Throws( + () => ServeCommand.ResolveListenUrl(new ServeSettings { Url = "not-a-url" })); + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool.Tests/VerifyModule.cs b/src/Devlead.Testing.MockHttp.Tool.Tests/VerifyModule.cs new file mode 100644 index 0000000..9cdcaf3 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool.Tests/VerifyModule.cs @@ -0,0 +1,10 @@ +using System.Runtime.CompilerServices; + +internal static class VerifyModule +{ + [ModuleInitializer] + public static void Init() + { + VerifierSettings.IgnoreMember(x => x.RequestMessage); + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Commands/ServeCommand.ListenUrl.cs b/src/Devlead.Testing.MockHttp.Tool/Commands/ServeCommand.ListenUrl.cs new file mode 100644 index 0000000..c37799f --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Commands/ServeCommand.ListenUrl.cs @@ -0,0 +1,43 @@ +using System.Net; + +namespace Devlead.Testing.MockHttp.Tool.Commands; + +/// +public sealed partial class ServeCommand +{ + /// + /// Resolves the Kestrel listen URL from or . + /// + /// The command settings. + /// The listen authority, for example http://127.0.0.1:5080. + internal static string ResolveListenUrl(ServeSettings settings) + { + if (!string.IsNullOrWhiteSpace(settings.Url)) + { + if (!Uri.TryCreate(settings.Url, UriKind.Absolute, out var uri)) + { + throw new ArgumentException("The --url value must be an absolute URI.", nameof(settings)); + } + + if (!IsLoopbackHost(uri.Host)) + { + AnsiConsole.MarkupLine( + "[yellow]Warning:[/] listening on a non-loopback address. The mock API has no authentication beyond Routes.json."); + } + + return uri.GetLeftPart(UriPartial.Authority); + } + + var port = settings.Port is > 0 ? settings.Port.Value : 5080; + return $"http://127.0.0.1:{port}"; + } + + /// + /// Returns when is loopback or localhost. + /// + /// The URI host. + /// when the host is loopback. + private static bool IsLoopbackHost(string host) => + string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || + (IPAddress.TryParse(host, out var address) && IPAddress.IsLoopback(address)); +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Commands/ServeCommand.cs b/src/Devlead.Testing.MockHttp.Tool/Commands/ServeCommand.cs new file mode 100644 index 0000000..adb5767 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Commands/ServeCommand.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Logging; +using Spectre.Console.Cli; + +namespace Devlead.Testing.MockHttp.Tool.Commands; + +/// +/// Loads Routes.json and hosts the mock HTTP API until the process is cancelled. +/// +public sealed partial class ServeCommand : AsyncCommand +{ + /// + public override async Task ExecuteAsync(CommandContext context, ServeSettings settings, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(settings.Resources)) + { + AnsiConsole.MarkupLine("[red]A resources directory is required.[/]"); + return 1; + } + + var resourcesDirectory = Path.GetFullPath(settings.Resources); + if (!Directory.Exists(resourcesDirectory)) + { + AnsiConsole.MarkupLine($"[red]Resources directory not found:[/] {resourcesDirectory}"); + return 1; + } + + var listenUrl = ResolveListenUrl(settings); + var table = RouteTable.Load(resourcesDirectory, settings.MatchHost); + + var builder = MockHttpWebHost.CreateBuilder(table, listenUrl); + builder.Logging.ClearProviders(); + var app = builder.Build(); + MockHttpWebHost.Map(app); + + using var stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, app.Lifetime.ApplicationStopping); + ConsoleCancelEventHandler? onCancel = (_, args) => + { + args.Cancel = true; + stop.Cancel(); + }; + System.Console.CancelKeyPress += onCancel; + + await app.StartAsync(stop.Token).ConfigureAwait(false); + + try + { + await StatusLiveView.RunAsync(table, listenUrl, stop.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stop.IsCancellationRequested) + { + } + finally + { + System.Console.CancelKeyPress -= onCancel; + await app.StopAsync(CancellationToken.None).ConfigureAwait(false); + } + + return 0; + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Commands/ServeSettings.cs b/src/Devlead.Testing.MockHttp.Tool/Commands/ServeSettings.cs new file mode 100644 index 0000000..0d9f593 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Commands/ServeSettings.cs @@ -0,0 +1,38 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace Devlead.Testing.MockHttp.Tool.Commands; + +/// +/// Command-line settings for the local mock HTTP host. +/// +public sealed class ServeSettings : CommandSettings +{ + /// + /// Gets the directory that contains Routes.json and response files. + /// + [CommandArgument(0, "")] + [Description("Directory that contains Routes.json and response files.")] + public string Resources { get; init; } = string.Empty; + + /// + /// Gets the TCP port to listen on when is not set. + /// + [CommandOption("--port")] + [Description("Port to listen on. Default is 5080. Ignored when --url is set.")] + public int? Port { get; init; } + + /// + /// Gets an absolute listen URL. When set, it overrides . + /// + [CommandOption("--url")] + [Description("Absolute listen URL, for example http://127.0.0.1:5080. Overrides --port.")] + public string? Url { get; init; } + + /// + /// Gets a value indicating whether inbound requests must match the host from AbsoluteUri. + /// + [CommandOption("--match-host")] + [Description("Require the request Host to match the host in Routes.json AbsoluteUri. Default is path and query only.")] + public bool MatchHost { get; init; } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Devlead.Testing.MockHttp.Tool.csproj b/src/Devlead.Testing.MockHttp.Tool/Devlead.Testing.MockHttp.Tool.csproj new file mode 100644 index 0000000..fc899df --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Devlead.Testing.MockHttp.Tool.csproj @@ -0,0 +1,46 @@ + + + + Exe + net11.0 + enable + enable + true + dotnet-mockhttp + README.md + icon/devlead.testing.mockhttp.png + true + false + false + .NET global tool that hosts Routes.json as a local mock HTTP API + Devlead.Testing.MockHttp.Tool + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Devlead.Testing.MockHttp.Tool/Hosting/MockHttpWebHost.Inspect.cs b/src/Devlead.Testing.MockHttp.Tool/Hosting/MockHttpWebHost.Inspect.cs new file mode 100644 index 0000000..a2e5fee --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Hosting/MockHttpWebHost.Inspect.cs @@ -0,0 +1,52 @@ +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Devlead.Testing.MockHttp.Tool.Hosting; + +/// +internal static partial class MockHttpWebHost +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private static readonly string ApplicationVersion = + typeof(MockHttpWebHost).Assembly.GetCustomAttribute()?.InformationalVersion + is { Length: > 0 } informational + ? informational.Split('+', 2)[0] + : typeof(MockHttpWebHost).Assembly.GetName().Version?.ToString() ?? string.Empty; + + private static IResult InspectPage() + { + using var stream = typeof(MockHttpWebHost).Assembly + .GetManifestResourceStream("Devlead.Testing.MockHttp.Tool.Inspect.inspect.html") + ?? throw new InvalidOperationException("The inspect page resource is missing."); + using var reader = new StreamReader(stream, Encoding.UTF8); + return Results.Content(reader.ReadToEnd(), "text/html", Encoding.UTF8); + } + + private static IResult InspectState(RouteTable table, MockHttpListenUrl listen) => + Results.Json(table.Snapshot(listen.Value, ApplicationVersion), JsonOptions); + + private static IResult InspectToggle(ToggleRouteRequest body, RouteTable table) + { + if (string.IsNullOrWhiteSpace(body.Method) || string.IsNullOrWhiteSpace(body.AbsoluteUri)) + { + return Results.BadRequest(); + } + + return table.TryToggle(body.Method, body.AbsoluteUri, body.Enabled) + ? Results.NoContent() + : Results.NotFound(); + } + + private static IResult InspectClearActivity(RouteTable table) + { + table.Activity.Clear(); + return Results.NoContent(); + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Hosting/MockHttpWebHost.cs b/src/Devlead.Testing.MockHttp.Tool/Hosting/MockHttpWebHost.cs new file mode 100644 index 0000000..d0aa83d --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Hosting/MockHttpWebHost.cs @@ -0,0 +1,39 @@ +namespace Devlead.Testing.MockHttp.Tool.Hosting; + +/// +/// Builds and maps the mock HTTP application. +/// +internal static partial class MockHttpWebHost +{ + /// + /// Creates a Kestrel-backed builder that listens on . + /// + /// The in-memory route table. + /// The listen authority, for example http://127.0.0.1:5080. + /// The configured web application builder. + public static WebApplicationBuilder CreateBuilder(RouteTable table, string listenUrl) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls(listenUrl); + builder.Services.AddSingleton(table); + builder.Services.AddSingleton(new MockHttpListenUrl(listenUrl)); + return builder; + } + + /// + /// Maps inspect endpoints, then the mock HTTP fallback. + /// + /// The web application. + public static void Map(WebApplication app) + { + app.MapGet("/inspect", InspectPage); + app.MapGet("/inspect/api", InspectState); + app.MapPost("/inspect/api/routes/toggle", InspectToggle); + app.MapDelete("/inspect/api/activity", InspectClearActivity); + + var table = app.Services.GetRequiredService(); + app.UseWhen( + context => !context.Request.Path.StartsWithSegments("/inspect"), + branch => branch.Run(table.InvokeAsync)); + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/ActivityEntry.cs b/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/ActivityEntry.cs new file mode 100644 index 0000000..da6dd0e --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/ActivityEntry.cs @@ -0,0 +1,16 @@ +namespace Devlead.Testing.MockHttp.Tool.Inspect.Models; + +internal sealed record ActivityEntry( + string Id, + DateTimeOffset Utc, + string Method, + string PathAndQuery, + string Host, + int Status, + string MatchedAbsoluteUri, + bool EnableDisable, + IReadOnlyDictionary RequestHeaders, + IReadOnlyDictionary ResponseHeaders, + string? ContentType, + int ContentLength, + string? BodyPreview); diff --git a/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/InspectCounts.cs b/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/InspectCounts.cs new file mode 100644 index 0000000..817276f --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/InspectCounts.cs @@ -0,0 +1,8 @@ +namespace Devlead.Testing.MockHttp.Tool.Inspect.Models; + +internal sealed record InspectCounts( + int Routes, + int Enabled, + int Requests, + int Unauthorized, + int NotFound); diff --git a/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/InspectSnapshot.cs b/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/InspectSnapshot.cs new file mode 100644 index 0000000..5f54523 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/InspectSnapshot.cs @@ -0,0 +1,11 @@ +namespace Devlead.Testing.MockHttp.Tool.Inspect.Models; + +internal sealed record InspectSnapshot( + string Version, + string ListenUrl, + string InspectUrl, + string ResourcesDirectory, + bool MatchHost, + InspectCounts Counts, + IReadOnlyList Routes, + IReadOnlyList Activity); diff --git a/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/MockHttpListenUrl.cs b/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/MockHttpListenUrl.cs new file mode 100644 index 0000000..46bd197 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/MockHttpListenUrl.cs @@ -0,0 +1,3 @@ +namespace Devlead.Testing.MockHttp.Tool.Inspect.Models; + +internal sealed record MockHttpListenUrl(string Value); diff --git a/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/RouteSnapshot.cs b/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/RouteSnapshot.cs new file mode 100644 index 0000000..af86061 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/RouteSnapshot.cs @@ -0,0 +1,9 @@ +namespace Devlead.Testing.MockHttp.Tool.Inspect.Models; + +internal sealed record RouteSnapshot( + string Method, + string AbsoluteUri, + string Host, + string PathAndQuery, + bool Enabled, + long Hits); diff --git a/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/ToggleRouteRequest.cs b/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/ToggleRouteRequest.cs new file mode 100644 index 0000000..08c1c9b --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Inspect/Models/ToggleRouteRequest.cs @@ -0,0 +1,3 @@ +namespace Devlead.Testing.MockHttp.Tool.Inspect.Models; + +internal sealed record ToggleRouteRequest(string Method, string AbsoluteUri, bool? Enabled = null); diff --git a/src/Devlead.Testing.MockHttp.Tool/Inspect/RequestActivity.cs b/src/Devlead.Testing.MockHttp.Tool/Inspect/RequestActivity.cs new file mode 100644 index 0000000..be23c3b --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Inspect/RequestActivity.cs @@ -0,0 +1,48 @@ +using System.Collections.Concurrent; + +namespace Devlead.Testing.MockHttp.Tool.Inspect; + +internal sealed class RequestActivity +{ + private const int Limit = 200; + private readonly ConcurrentQueue _entries = new(); + private int _count; + private int _unauthorized; + private int _notFound; + + public int Requests => _count; + public int Unauthorized => _unauthorized; + public int NotFound => _notFound; + + public IReadOnlyList Snapshot() => _entries.Reverse().ToArray(); + + public void Clear() + { + while (_entries.TryDequeue(out _)) + { + } + + Interlocked.Exchange(ref _count, 0); + Interlocked.Exchange(ref _unauthorized, 0); + Interlocked.Exchange(ref _notFound, 0); + } + + public void Add(ActivityEntry entry) + { + _entries.Enqueue(entry); + Interlocked.Increment(ref _count); + if (entry.Status == StatusCodes.Status401Unauthorized) + { + Interlocked.Increment(ref _unauthorized); + } + + if (entry.Status == StatusCodes.Status404NotFound) + { + Interlocked.Increment(ref _notFound); + } + + while (_entries.Count > Limit && _entries.TryDequeue(out _)) + { + } + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Inspect/inspect.html b/src/Devlead.Testing.MockHttp.Tool/Inspect/inspect.html new file mode 100644 index 0000000..f6356c4 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Inspect/inspect.html @@ -0,0 +1,277 @@ + + + + + MockHttp inspect + + + +
+

MockHttp inspect

+ + +
+
+
+
+
+

Routes

+
+
+ + + + + + +
MethodPathHostEnabledHits
+
+
+
+
+

Route

+ +
+
+
Method
Select a route.
+
Absolute URI
+
Enabled
+
Hits
+
+
+
+
+
+
+

Activity

+ +
+
+ + + + + + +
UTCMethodPathStatusMatched
+
+
+
+

Headers

+
+
General
Select a request.
+
Matched
+
+

Request headers

+
+

Response headers

+
+

Body preview

+

+      
+
+
+
+ Listen + + + Inspect + +
+ + + diff --git a/src/Devlead.Testing.MockHttp.Tool/Program.cs b/src/Devlead.Testing.MockHttp.Tool/Program.cs new file mode 100644 index 0000000..c28e11d --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Program.cs @@ -0,0 +1,23 @@ +using Devlead.Console.Extensions; +using Devlead.Testing.MockHttp.Tool.Commands; + +/// +/// Command-line host for the local mock HTTP API. +/// +public partial class Program +{ + /// + /// Registers the default serve command. + /// + /// The Devlead.Console application configuration. + static partial void ConfigureApp(AppServiceConfig appServiceConfig) + { + appServiceConfig + .AddCommand("serve") + .WithDescription("Host Routes.json as a local mock HTTP API.") + .WithExample(["serve", "Resources", "--port", "5080"]); + + appServiceConfig.SetDefaultCommand(); + appServiceConfig.SetApplicationName("dotnet-mockhttp"); + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/README.md b/src/Devlead.Testing.MockHttp.Tool/README.md new file mode 100644 index 0000000..ed74ef5 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/README.md @@ -0,0 +1,48 @@ +# Devlead.Testing.MockHttp.Tool + +.NET global tool that hosts a `Routes.json` file and response resources as a local mock HTTP API. + +## Installation + +```bash +dotnet tool install -g Devlead.Testing.MockHttp.Tool +``` + +## Usage + +```bash +dotnet mockhttp serve [--port 5080] [--url http://127.0.0.1:5080] [--match-host] +``` + +`serve` is the default command, so `` can be passed directly. + +- `` is a directory that contains `Routes.json` and the response files it references. +- `--port` binds `http://127.0.0.1:` (default 5080). +- `--url` overrides `--port` and uses the URL authority only. +- `--match-host` requires the request `Host` to match the host in each route `AbsoluteUri`. By default only HTTP method, path, and query are matched so a client can use the mock as its `BaseAddress`. + +Point an `HttpClient` at the listen URL: + +```bash +dotnet mockhttp serve ./Resources --port 5080 +``` + +```csharp +var client = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5080") }; +var response = await client.GetAsync("/index.txt"); +``` + +```bash +curl http://127.0.0.1:5080/index.txt +curl -i -H "Authorization: Bearer AccessToken" http://127.0.0.1:5080/login/secret.json +``` + +Those requests match `Routes.json` entries whose `AbsoluteUri` values are `https://example.com/index.txt` and `https://example.com/login/secret.json`. + +While the host is running, the console shows a live table of configured routes and recent requests. Open `http://127.0.0.1:5080/inspect` for the inspect page: route enable/disable, request activity, and DevTools-style request/response headers. `GET /inspect/api` returns the same snapshot as JSON. + +## Routes.json and resources + +The JSON contract is the same as [Devlead.Testing.MockHttp](https://github.com/devlead/Devlead.Testing.MockHttp): methods, `AbsoluteUri`, optional authorization, request-header-specific responses, status codes, and `EnableRequests` / `DisableRequests`. + +`ContentResource` values such as `Example.Login.Secret.json` resolve to `Example/Login/Secret.json` under the resources directory. diff --git a/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteDocument.cs b/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteDocument.cs new file mode 100644 index 0000000..7f0d16f --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteDocument.cs @@ -0,0 +1,7 @@ +namespace Devlead.Testing.MockHttp.Tool.Routing.Models; + +internal sealed record RouteDocument( + RouteRequest Request, + RouteResponse[] Responses, + Dictionary? Authorization = null +); diff --git a/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteEnableRequest.cs b/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteEnableRequest.cs new file mode 100644 index 0000000..a499b97 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteEnableRequest.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Devlead.Testing.MockHttp.Tool.Routing.Models; + +internal sealed record RouteEnableRequest( + [property: JsonPropertyName("Method")] + string StringMethod, + string AbsoluteUri +) +{ + internal HttpMethod Method { get; } = new(StringMethod); +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteMethod.cs b/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteMethod.cs new file mode 100644 index 0000000..ae8d0a5 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteMethod.cs @@ -0,0 +1,3 @@ +namespace Devlead.Testing.MockHttp.Tool.Routing.Models; + +internal sealed record RouteMethod(string Method); diff --git a/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteRequest.cs b/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteRequest.cs new file mode 100644 index 0000000..3a67d94 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteRequest.cs @@ -0,0 +1,9 @@ +namespace Devlead.Testing.MockHttp.Tool.Routing.Models; + +internal sealed record RouteRequest( + RouteMethod[] Methods, + string AbsoluteUri +) +{ + public bool Disabled { get; set; } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteResponse.cs b/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteResponse.cs new file mode 100644 index 0000000..96c3171 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Routing/Models/RouteResponse.cs @@ -0,0 +1,16 @@ +using System.Net; + +namespace Devlead.Testing.MockHttp.Tool.Routing.Models; + +internal sealed record RouteResponse( + string? ContentResource, + string? ContentType, + HttpStatusCode StatusCode +) +{ + public Dictionary RequestHeaders { get; init; } = []; + public Dictionary ContentHeaders { get; init; } = []; + public RouteEnableRequest[] EnableRequests { get; init; } = []; + public RouteEnableRequest[] DisableRequests { get; init; } = []; + internal byte[]? Content { get; set; } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Routing/ResourceFiles.cs b/src/Devlead.Testing.MockHttp.Tool/Routing/ResourceFiles.cs new file mode 100644 index 0000000..c236672 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Routing/ResourceFiles.cs @@ -0,0 +1,62 @@ +namespace Devlead.Testing.MockHttp.Tool.Routing; + +internal static class ResourceFiles +{ + public static byte[] GetBytes(string resourcesDirectory, string contentResource, string? absoluteUri = null) + { + var fullPath = ResolvePath(resourcesDirectory, contentResource, absoluteUri); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException(Describe(contentResource, absoluteUri, "was not found"), fullPath); + } + + return File.ReadAllBytes(fullPath); + } + + internal static string ResolvePath(string resourcesDirectory, string contentResource, string? absoluteUri = null) + { + if (Path.IsPathRooted(contentResource)) + { + throw new InvalidOperationException(Describe(contentResource, absoluteUri, "must be relative to the resources directory")); + } + + var relativePath = ToRelativePath(contentResource); + if (Path.IsPathRooted(relativePath)) + { + throw new InvalidOperationException(Describe(contentResource, absoluteUri, "must be relative to the resources directory")); + } + + var root = Path.GetFullPath(resourcesDirectory); + var fullPath = Path.GetFullPath(Path.Combine(root, relativePath)); + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + var prefix = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + + if (!fullPath.StartsWith(prefix, comparison) && !string.Equals(fullPath, root, comparison)) + { + throw new InvalidOperationException(Describe(contentResource, absoluteUri, "must stay inside the resources directory")); + } + + return fullPath; + } + + internal static string ToRelativePath(string contentResource) + { + var lastDot = contentResource.LastIndexOf('.'); + if (lastDot <= 0) + { + return contentResource.Replace('.', Path.DirectorySeparatorChar); + } + + return contentResource[..lastDot].Replace('.', Path.DirectorySeparatorChar) + contentResource[lastDot..]; + } + + private static string Describe(string contentResource, string? absoluteUri, string suffix) + { + return absoluteUri is { Length: > 0 } + ? $"ContentResource {contentResource} for {absoluteUri} {suffix}." + : $"ContentResource {contentResource} {suffix}."; + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Entry.cs b/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Entry.cs new file mode 100644 index 0000000..9a4f14b --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Entry.cs @@ -0,0 +1,22 @@ +namespace Devlead.Testing.MockHttp.Tool.Routing; + +/// +internal sealed partial class RouteTable +{ + private sealed class RouteEntry + { + public RouteEntry(HttpMethod method, RouteDocument document) + { + Method = method; + Document = document; + AbsoluteUri = new Uri(document.Request.AbsoluteUri, UriKind.Absolute); + PathAndQuery = AbsoluteUri.PathAndQuery; + } + + public HttpMethod Method { get; } + public RouteDocument Document { get; } + public Uri AbsoluteUri { get; } + public string PathAndQuery { get; } + public long Hits; + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Headers.cs b/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Headers.cs new file mode 100644 index 0000000..5e9830e --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Headers.cs @@ -0,0 +1,49 @@ +using System.Text; + +namespace Devlead.Testing.MockHttp.Tool.Routing; + +/// +internal sealed partial class RouteTable +{ + private static bool TryGetHeaderValues(HttpRequest request, string key, out string[] values) + { + if (request.Headers.TryGetValue(key, out var headerValues)) + { + values = headerValues.ToArray()!; + return true; + } + + values = []; + return false; + } + + private static IReadOnlyDictionary CaptureHeaders(IHeaderDictionary headers) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var header in headers) + { + result[header.Key] = header.Value.ToArray()!; + } + + return result; + } + + private static string? BodyPreview(byte[]? content, string? contentType) + { + if (content is not { Length: > 0 } || contentType is null) + { + return null; + } + + if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) + && !contentType.Contains("text", StringComparison.OrdinalIgnoreCase) + && !contentType.Contains("xml", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var length = Math.Min(content.Length, 2048); + var text = Encoding.UTF8.GetString(content.AsSpan(0, length)); + return content.Length > 2048 ? text + "…" : text; + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Inspect.cs b/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Inspect.cs new file mode 100644 index 0000000..f30c9ef --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Inspect.cs @@ -0,0 +1,44 @@ +namespace Devlead.Testing.MockHttp.Tool.Routing; + +/// +internal sealed partial class RouteTable +{ + public InspectSnapshot Snapshot(string listenUrl, string version) + { + var routes = _entries + .Select(entry => new RouteSnapshot( + entry.Method.Method, + entry.Document.Request.AbsoluteUri, + entry.AbsoluteUri.Host, + entry.PathAndQuery, + !entry.Document.Request.Disabled, + Interlocked.Read(ref entry.Hits))) + .ToArray(); + + return new InspectSnapshot( + version, + listenUrl, + $"{listenUrl.TrimEnd('/')}/inspect", + _resourcesDirectory, + _matchHost, + new InspectCounts( + routes.Length, + routes.Count(route => route.Enabled), + _activity.Requests, + _activity.Unauthorized, + _activity.NotFound), + routes, + _activity.Snapshot()); + } + + public bool TryToggle(string method, string absoluteUri, bool? enabled) + { + if (!_byAbsoluteUri.TryGetValue((new HttpMethod(method), absoluteUri), out var entry)) + { + return false; + } + + entry.Document.Request.Disabled = enabled is { } value ? !value : !entry.Document.Request.Disabled; + return true; + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Invoke.cs b/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Invoke.cs new file mode 100644 index 0000000..7eb1ea5 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Invoke.cs @@ -0,0 +1,124 @@ +using System.Security.Cryptography; + +namespace Devlead.Testing.MockHttp.Tool.Routing; + +/// +internal sealed partial class RouteTable +{ + public async Task InvokeAsync(HttpContext context) + { + var requestHeaders = CaptureHeaders(context.Request.Headers); + var method = new HttpMethod(context.Request.Method); + var pathAndQuery = $"{context.Request.Path}{context.Request.QueryString}"; + var host = context.Request.Host.Value ?? string.Empty; + var matched = "-"; + var enableDisable = false; + byte[]? content = null; + string? contentType = null; + + try + { + var entry = FindEntry(method, pathAndQuery, context.Request.Host); + if (entry is null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + matched = entry.Document.Request.AbsoluteUri; + + if (entry.Document.Request.Disabled) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + Interlocked.Increment(ref entry.Hits); + + if (entry.Document.Authorization is { } authorization) + { + foreach (var header in authorization) + { + if (!TryGetHeaderValues(context.Request, header.Key, out var values) + || !values.All(value => header.Value.Contains(value))) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + } + } + + var response = entry.Document.Responses.FirstOrDefault( + candidate => candidate.RequestHeaders.All( + header => + TryGetHeaderValues(context.Request, header.Key, out var values) + && values.All(value => header.Value.Contains(value)))); + + if (response is null) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + foreach (var enableRequest in response.EnableRequests) + { + if (_byAbsoluteUri.TryGetValue((enableRequest.Method, enableRequest.AbsoluteUri), out var target)) + { + target.Document.Request.Disabled = false; + enableDisable = true; + } + } + + foreach (var disableRequest in response.DisableRequests) + { + if (_byAbsoluteUri.TryGetValue((disableRequest.Method, disableRequest.AbsoluteUri), out var target)) + { + target.Document.Request.Disabled = true; + enableDisable = true; + } + } + + context.Response.StatusCode = (int)response.StatusCode; + + foreach (var (key, value) in response.ContentHeaders) + { + context.Response.Headers[key] = value; + } + + if (!string.IsNullOrWhiteSpace(response.ContentResource)) + { + content = response.Content + ?? throw new InvalidOperationException( + $"Response content for {response.ContentResource} was not loaded."); + + if (!string.IsNullOrWhiteSpace(response.ContentType)) + { + context.Response.ContentType = response.ContentType; + contentType = response.ContentType; + } + + context.Response.Headers.ContentMD5 = Convert.ToBase64String(MD5.HashData(content)); + await context.Response.Body.WriteAsync(content).ConfigureAwait(false); + } + } + finally + { + contentType ??= context.Response.ContentType; + _activity.Add( + new ActivityEntry( + Guid.NewGuid().ToString("n")[..12], + DateTimeOffset.UtcNow, + method.Method, + pathAndQuery, + host, + context.Response.StatusCode, + matched, + enableDisable, + requestHeaders, + CaptureHeaders(context.Response.Headers), + contentType, + content?.Length ?? 0, + BodyPreview(content, contentType))); + } + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Match.cs b/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Match.cs new file mode 100644 index 0000000..9ceeb69 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.Match.cs @@ -0,0 +1,52 @@ +namespace Devlead.Testing.MockHttp.Tool.Routing; + +/// +internal sealed partial class RouteTable +{ + private RouteEntry? FindEntry(HttpMethod method, string pathAndQuery, HostString host) + { + foreach (var entry in _entries) + { + if (!entry.Method.Equals(method)) + { + continue; + } + + if (!string.Equals(entry.PathAndQuery, pathAndQuery, StringComparison.Ordinal)) + { + continue; + } + + if (_matchHost && !HostMatches(entry.AbsoluteUri, host)) + { + continue; + } + + return entry; + } + + return null; + } + + internal static bool HostMatches(Uri absoluteUri, HostString requestHost) + { + if (!string.Equals(absoluteUri.Host, requestHost.Host, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var routePort = absoluteUri.IsDefaultPort + ? SchemeDefaultPort(absoluteUri.Scheme) + : absoluteUri.Port; + + if (requestHost.Port is null) + { + return routePort == SchemeDefaultPort(absoluteUri.Scheme); + } + + return requestHost.Port == routePort; + } + + private static int SchemeDefaultPort(string scheme) => + string.Equals(scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ? 443 : 80; +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.cs b/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.cs new file mode 100644 index 0000000..306f0c1 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Routing/RouteTable.cs @@ -0,0 +1,82 @@ +using System.Collections.Concurrent; +using System.Text.Json; + +namespace Devlead.Testing.MockHttp.Tool.Routing; + +/// +/// Instance-scoped route table loaded from a resources directory. +/// +internal sealed partial class RouteTable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly string _resourcesDirectory; + private readonly bool _matchHost; + private readonly List _entries; + private readonly ConcurrentDictionary<(HttpMethod Method, string AbsoluteUri), RouteEntry> _byAbsoluteUri; + private readonly RequestActivity _activity = new(); + + public string ResourcesDirectory => _resourcesDirectory; + public bool MatchHost => _matchHost; + public RequestActivity Activity => _activity; + + private RouteTable( + string resourcesDirectory, + bool matchHost, + List entries, + ConcurrentDictionary<(HttpMethod Method, string AbsoluteUri), RouteEntry> byAbsoluteUri) + { + _resourcesDirectory = resourcesDirectory; + _matchHost = matchHost; + _entries = entries; + _byAbsoluteUri = byAbsoluteUri; + } + + /// + /// Loads Routes.json and response files from . + /// + /// Directory that contains Routes.json. + /// When , require the request host to match AbsoluteUri. + /// The loaded route table. + public static RouteTable Load(string resourcesDirectory, bool matchHost) + { + var routesPath = Path.Combine(resourcesDirectory, "Routes.json"); + if (!File.Exists(routesPath)) + { + throw new FileNotFoundException("Routes.json was not found in the resources directory.", routesPath); + } + + var documents = JsonSerializer.Deserialize(File.ReadAllText(routesPath), JsonOptions) + ?? throw new InvalidOperationException("Routes.json did not contain any routes."); + + var entries = new List(); + var byAbsoluteUri = new ConcurrentDictionary<(HttpMethod Method, string AbsoluteUri), RouteEntry>(); + + foreach (var document in documents) + { + foreach (var response in document.Responses) + { + if (!string.IsNullOrWhiteSpace(response.ContentResource)) + { + response.Content = ResourceFiles.GetBytes( + resourcesDirectory, + response.ContentResource, + document.Request.AbsoluteUri); + } + } + + foreach (var method in document.Request.Methods) + { + var httpMethod = new HttpMethod(method.Method); + var entry = new RouteEntry(httpMethod, document); + entries.Add(entry); + byAbsoluteUri.TryAdd((httpMethod, document.Request.AbsoluteUri), entry); + } + } + + return new RouteTable(resourcesDirectory, matchHost, entries, byAbsoluteUri); + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Ui/StatusLiveView.Build.cs b/src/Devlead.Testing.MockHttp.Tool/Ui/StatusLiveView.Build.cs new file mode 100644 index 0000000..3fba9b7 --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Ui/StatusLiveView.Build.cs @@ -0,0 +1,52 @@ +using Spectre.Console.Rendering; + +namespace Devlead.Testing.MockHttp.Tool.Ui; + +/// +internal static partial class StatusLiveView +{ + internal static IRenderable Build(RouteTable table, string listenUrl, string inspectUrl) + { + var snapshot = table.Snapshot(listenUrl, string.Empty); + var counts = snapshot.Counts; + + var header = new Markup( + $"[bold]Listening[/] [green]{Markup.Escape(listenUrl)}[/] [bold]Inspect[/] [link]{Markup.Escape(inspectUrl)}[/]\n" + + $"[grey]{Markup.Escape(snapshot.ResourcesDirectory)}[/] match-host: {(snapshot.MatchHost ? "[green]on[/]" : "[grey]off[/]")}\n" + + $"Routes {counts.Routes} Enabled {counts.Enabled} Requests {counts.Requests} 401 {counts.Unauthorized} 404 {counts.NotFound}"); + + var routes = new Table().Title("Routes").Expand(); + routes.AddColumn("Method"); + routes.AddColumn("Path"); + routes.AddColumn("Host"); + routes.AddColumn("Enabled"); + routes.AddColumn("Hits"); + foreach (var route in snapshot.Routes.Take(20)) + { + routes.AddRow( + Markup.Escape(route.Method), + Markup.Escape(route.PathAndQuery), + Markup.Escape(route.Host), + route.Enabled ? "[green]yes[/]" : "[red]no[/]", + route.Hits.ToString()); + } + + var activity = new Table().Title("Activity").Expand(); + activity.AddColumn("UTC"); + activity.AddColumn("Method"); + activity.AddColumn("Path"); + activity.AddColumn("Status"); + activity.AddColumn("Matched"); + foreach (var entry in snapshot.Activity.Take(15)) + { + activity.AddRow( + Markup.Escape(entry.Utc.ToString("HH:mm:ss")), + Markup.Escape(entry.Method), + Markup.Escape(entry.PathAndQuery), + entry.Status.ToString(), + Markup.Escape(entry.MatchedAbsoluteUri)); + } + + return new Rows(header, routes, activity); + } +} diff --git a/src/Devlead.Testing.MockHttp.Tool/Ui/StatusLiveView.cs b/src/Devlead.Testing.MockHttp.Tool/Ui/StatusLiveView.cs new file mode 100644 index 0000000..93dd41d --- /dev/null +++ b/src/Devlead.Testing.MockHttp.Tool/Ui/StatusLiveView.cs @@ -0,0 +1,59 @@ +namespace Devlead.Testing.MockHttp.Tool.Ui; + +/// +/// Renders a live Spectre.Console view of listen URL, routes, and recent traffic. +/// +internal static partial class StatusLiveView +{ + /// + /// Renders the live status view until is cancelled. + /// + public static async Task RunAsync( + RouteTable table, + string listenUrl, + CancellationToken cancellationToken) + { + var inspectUrl = $"{listenUrl.TrimEnd('/')}/inspect"; + var view = Build(table, listenUrl, inspectUrl); + if (System.Console.IsOutputRedirected || !Environment.UserInteractive) + { + AnsiConsole.Write(view); + await WaitUntilCancelled(cancellationToken).ConfigureAwait(false); + return; + } + + try + { + await AnsiConsole.Live(view) + .AutoClear(false) + .StartAsync(async ctx => + { + while (!cancellationToken.IsCancellationRequested) + { + ctx.UpdateTarget(Build(table, listenUrl, inspectUrl)); + await WaitUntilCancelled(cancellationToken, TimeSpan.FromMilliseconds(500)).ConfigureAwait(false); + } + }) + .ConfigureAwait(false); + } + catch (IOException) + { + AnsiConsole.Write(Build(table, listenUrl, inspectUrl)); + await WaitUntilCancelled(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private static async Task WaitUntilCancelled(CancellationToken cancellationToken, TimeSpan? delay = null) + { + try + { + await Task.Delay(delay ?? Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } +} diff --git a/src/Devlead.Testing.MockHttp.slnx b/src/Devlead.Testing.MockHttp.slnx index 22aebad..ff71294 100644 --- a/src/Devlead.Testing.MockHttp.slnx +++ b/src/Devlead.Testing.MockHttp.slnx @@ -1,4 +1,6 @@ + + diff --git a/src/Devlead.Testing.MockHttp/Devlead.Testing.MockHttp.csproj b/src/Devlead.Testing.MockHttp/Devlead.Testing.MockHttp.csproj index 881243c..163b119 100644 --- a/src/Devlead.Testing.MockHttp/Devlead.Testing.MockHttp.csproj +++ b/src/Devlead.Testing.MockHttp/Devlead.Testing.MockHttp.csproj @@ -7,6 +7,7 @@ README.md icon/devlead.testing.mockhttp.png true + .NET Library for mocking HTTP client requests Devlead false diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 9063777..ed99f5d 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -8,12 +8,14 @@ + + - - - + + +