From f070c6861edc2f81de5c37158f238e81d00fb756 Mon Sep 17 00:00:00 2001 From: OluwaVader Date: Sat, 28 Feb 2026 22:36:57 +0100 Subject: [PATCH 1/2] switch from HotResolverFactory approach to listening to for .net startup --- src/SwaggerDiff.Tool/HostResolver.cs | 228 +++++++++++++++++++-------- 1 file changed, 164 insertions(+), 64 deletions(-) diff --git a/src/SwaggerDiff.Tool/HostResolver.cs b/src/SwaggerDiff.Tool/HostResolver.cs index e2276df..86613c0 100644 --- a/src/SwaggerDiff.Tool/HostResolver.cs +++ b/src/SwaggerDiff.Tool/HostResolver.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Reflection; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; @@ -8,99 +9,188 @@ namespace SwaggerDiff.Tool; /// -/// Resolves an from an assembly's entry point -/// using the same HostFactoryResolver pattern as Swashbuckle CLI and dotnet-ef. -/// The host is started with a so no ports are bound. +/// Resolves an from an assembly's entry point by subscribing +/// to events emitted by the hosting infrastructure. +/// +/// When WebApplication.CreateBuilder().Build() (or HostBuilder.Build()) executes, +/// the framework publishes HostBuilding and HostBuilt events on a +/// "Microsoft.Extensions.Hosting" diagnostic source. We intercept these to: +/// +/// Replace the real server with so no ports are bound. +/// Remove background registrations to avoid side-effects. +/// Capture the fully-built and return its service provider. +/// +/// +/// This approach avoids depending on the internal HostFactoryResolver class, which is +/// not reliably accessible via reflection across different runtime/SDK versions and +/// dotnet exec dependency contexts. /// internal static class HostResolver { public static IServiceProvider? GetServiceProvider(Assembly assembly) { - // Use HostFactoryResolver pattern via diagnostic listener — works for .NET 6+ minimal APIs - var hostFactoryResolver = typeof(IHost).Assembly - .GetType("Microsoft.Extensions.Hosting.HostFactoryResolver"); + var result = ResolveViaDiagnosticListener(assembly); + if (result != null) + return result; - if (hostFactoryResolver == null) - { - AnsiConsole.MarkupLine("[yellow]Warning:[/] HostFactoryResolver not found. Trying fallback..."); - return GetServiceProviderFallback(assembly); - } - - var resolveMethod = hostFactoryResolver.GetMethod( - "ResolveHostFactory", - BindingFlags.Public | BindingFlags.Static); + // Fallback: try convention-based approach with Startup class (pre-.NET 6 apps) + AnsiConsole.MarkupLine("[yellow]Warning:[/] DiagnosticListener approach did not resolve host. Trying Startup-based fallback..."); + return GetServiceProviderFallback(assembly); + } - if (resolveMethod == null) + private static IServiceProvider? ResolveViaDiagnosticListener(Assembly assembly) + { + var entryPoint = assembly.EntryPoint; + if (entryPoint == null) { - AnsiConsole.MarkupLine("[yellow]Warning:[/] ResolveHostFactory method not found. Trying fallback..."); - return GetServiceProviderFallback(assembly); + AnsiConsole.MarkupLine("[red]Error:[/] Assembly has no entry point."); + return null; } - Action configureHostBuilder = hostBuilder => - { - if (hostBuilder is IHostBuilder builder) + IHost? capturedHost = null; + var hostBuiltSignal = new ManualResetEventSlim(false); + IDisposable? hostingSubscription = null; + + // Subscribe to all DiagnosticListeners; when the hosting listener appears, subscribe to its events + var outerSubscription = DiagnosticListener.AllListeners.Subscribe( + new DelegateObserver(listener => { - builder.ConfigureServices((_, services) => + if (listener.Name == "Microsoft.Extensions.Hosting") { - // Replace the real server with a no-op to avoid binding ports - services.AddSingleton(); - - // Remove all IHostedService registrations except GenericWebHostService - // (which is required for the middleware pipeline / DI to be fully configured) - for (var i = services.Count - 1; i >= 0; i--) - { - var registration = services[i]; - if (registration.ServiceType == typeof(IHostedService) - && registration.ImplementationType?.FullName != - "Microsoft.AspNetCore.Hosting.GenericWebHostService") + hostingSubscription = listener.Subscribe( + new DelegateObserver>(kvp => { - services.RemoveAt(i); - } - } - }); + switch (kvp.Key) + { + case "HostBuilding": + // Inject NoOpServer and strip hosted services before Build() completes + ConfigureHostBuilder(kvp.Value!); + break; + + case "HostBuilt" when kvp.Value is IHost host: + capturedHost = host; + hostBuiltSignal.Set(); + break; + } + })); + } + })); + + // Run the target app's entry point on a background thread. + // app.Run() will block that thread, but since it's a background thread it won't + // prevent this process from exiting. + Exception? entryPointException = null; + var thread = new Thread(() => + { + try + { + var parameters = entryPoint.GetParameters(); + var assemblyName = assembly.GetName()?.FullName ?? assembly.GetName()?.Name; + object?[] args = parameters.Length == 0 + ? Array.Empty() + : [new[] { $"--applicationName={assemblyName}" }]; + entryPoint.Invoke(null, args); } - }; - - Action entrypointCompleted = _ => { }; + catch (TargetInvocationException tie) + { + // HostAbortedException / OperationCanceledException are expected when the host shuts down + if (tie.InnerException is not OperationCanceledException) + { + entryPointException = tie.InnerException ?? tie; + hostBuiltSignal.Set(); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + entryPointException = ex; + hostBuiltSignal.Set(); + } + }) { IsBackground = true }; + thread.Start(); - // ResolveHostFactory(assembly, waitTimeout, stopApplication, configureHostBuilder, entrypointCompleted) - var factory = resolveMethod.Invoke(null, new object?[] + // Wait for the HostBuilt event (or an error / timeout) + if (!hostBuiltSignal.Wait(TimeSpan.FromSeconds(30))) { - assembly, - TimeSpan.FromSeconds(30), - false, // stopApplication = false — we need the host to start for full DI - configureHostBuilder, - entrypointCompleted - }) as Func; + AnsiConsole.MarkupLine("[yellow]Warning:[/] Timed out waiting for application host to build (30 s)."); + outerSubscription.Dispose(); + hostingSubscription?.Dispose(); + return null; + } + + outerSubscription.Dispose(); + hostingSubscription?.Dispose(); - if (factory == null) - return GetServiceProviderFallback(assembly); + if (capturedHost == null) + { + if (entryPointException != null) + AnsiConsole.MarkupLine($"[red]Error:[/] Entry point failed: {entryPointException.Message.EscapeMarkup()}"); + return null; + } - var assemblyName = assembly.GetName()?.FullName ?? assembly.GetName()?.Name; - var host = factory([$"--applicationName={assemblyName}"]) as IHost; + // The entry point thread will call app.Run() → host.StartAsync(). + // Wait for ApplicationStarted so services that initialise during startup are ready. + try + { + var lifetime = capturedHost.Services.GetRequiredService(); + var started = new ManualResetEventSlim(false); + using var reg = lifetime.ApplicationStarted.Register(() => started.Set()); - if (host == null) - return GetServiceProviderFallback(assembly); + if (!started.IsSet) + started.Wait(TimeSpan.FromSeconds(30)); + } + catch + { + // If we can't wait for startup, the DI container should still be usable + } - // Wait for ApplicationStarted to ensure services are fully initialized - var lifetime = host.Services.GetRequiredService(); - var tcs = new TaskCompletionSource(); - using var reg = lifetime.ApplicationStarted.Register(() => tcs.TrySetResult(null)); + return capturedHost.Services; + } - // Start the host (with NoOpServer, this won't bind any ports) - host.StartAsync().GetAwaiter().GetResult(); + /// + /// Configures the host builder to use and removes background services. + /// Handles both IHostBuilder (legacy) and HostApplicationBuilder (.NET 7+ minimal hosting). + /// + private static void ConfigureHostBuilder(object hostBuilder) + { + // Legacy IHostBuilder path (e.g. Host.CreateDefaultBuilder().ConfigureWebHostDefaults(...)) + if (hostBuilder is IHostBuilder legacyBuilder) + { + legacyBuilder.ConfigureServices((_, services) => + { + services.AddSingleton(); + RemoveHostedServices(services); + }); + return; + } - if (!tcs.Task.Wait(TimeSpan.FromSeconds(30))) + // HostApplicationBuilder / WebApplicationBuilder (.NET 7+ minimal hosting model). + // We can't reference the type directly (it's in the target app's framework), + // so access the Services property via reflection. + var servicesProperty = hostBuilder.GetType().GetProperty("Services"); + if (servicesProperty?.GetValue(hostBuilder) is IServiceCollection services) { - AnsiConsole.MarkupLine("[yellow]Warning:[/] Timed out waiting for application to start."); + services.AddSingleton(); + RemoveHostedServices(services); } + } - return host.Services; + private static void RemoveHostedServices(IServiceCollection services) + { + for (var i = services.Count - 1; i >= 0; i--) + { + var registration = services[i]; + if (registration.ServiceType == typeof(IHostedService) + && registration.ImplementationType?.FullName != + "Microsoft.AspNetCore.Hosting.GenericWebHostService") + { + services.RemoveAt(i); + } + } } private static IServiceProvider? GetServiceProviderFallback(Assembly assembly) { - // Fallback: try convention-based approach with Startup class + // Convention-based approach for apps with a Startup class try { var assemblyName = assembly.GetName().Name; @@ -119,3 +209,13 @@ internal static class HostResolver } } } + +/// +/// Minimal that delegates to an . +/// +internal sealed class DelegateObserver(Action onNext) : IObserver +{ + public void OnNext(T value) => onNext(value); + public void OnError(Exception error) { } + public void OnCompleted() { } +} From 99887ad90942a3501c6574f00fa38b61cd3f5dc9 Mon Sep 17 00:00:00 2001 From: OluwaVader Date: Sat, 28 Feb 2026 22:37:47 +0100 Subject: [PATCH 2/2] add dry run setup to allow exclusions of external dependencies failure during snapshot loading --- README.md | 57 +++++++++++++++---- .../SwaggerDiff.AspNetCore.csproj | 2 +- src/SwaggerDiff.AspNetCore/SwaggerDiffEnv.cs | 39 +++++++++++++ .../Commands/SnapshotCommand.cs | 39 +++++++++++-- 4 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 src/SwaggerDiff.AspNetCore/SwaggerDiffEnv.cs diff --git a/README.md b/README.md index 76627ff..399819f 100644 --- a/README.md +++ b/README.md @@ -145,25 +145,25 @@ dotnet tool install -g SwaggerDiff.Tool ### Commands -#### `swagger-diff snapshot` +#### `swaggerdiff snapshot` Generate a new OpenAPI snapshot. The simplest usage — run from your project directory: ```bash # Auto-discovers the .csproj, builds it, and generates a snapshot -swagger-diff snapshot +swaggerdiff snapshot ``` With explicit project and configuration: ```bash -swagger-diff snapshot --project ./src/MyApi/MyApi.csproj -c Release --output Docs/Versions +swaggerdiff snapshot --project ./src/MyApi/MyApi.csproj -c Release --output Docs/Versions ``` Or point directly at a pre-built assembly: ```bash -swagger-diff snapshot --assembly ./bin/Release/net8.0/MyApi.dll +swaggerdiff snapshot --assembly ./bin/Release/net8.0/MyApi.dll ``` | Option | Default | Description | @@ -178,17 +178,44 @@ swagger-diff snapshot --assembly ./bin/Release/net8.0/MyApi.dll The command will: 1. **Build the project** (unless `--no-build` or `--assembly` is used), then resolve the output DLL via MSBuild. -2. **Load the assembly** and build the host using the same `HostFactoryResolver` pattern that `dotnet ef` and the Swashbuckle CLI use — your `Program.cs` entry point runs, but a `NoOpServer` replaces Kestrel so no ports are bound and hosted services are stripped out. +2. **Load the assembly** and build the host — your `Program.cs` entry point runs, but a `NoOpServer` replaces Kestrel so no ports are bound and hosted services are stripped out. 3. **Resolve `ISwaggerProvider`** from the DI container and serialize the OpenAPI document. 4. **Compare** with the latest existing snapshot (normalizing away the `info.version` field). 5. If the API surface has changed, **write a new timestamped file** (e.g. `doc_20250612143022.json`). If nothing changed, print "No API changes detected" and exit cleanly. -#### `swagger-diff list` +### Dry-run mode — skipping external dependencies + +When the CLI tool loads your application to generate a snapshot, your `Program.cs` entry point runs in full. This means any startup code that connects to external services (secret vaults, databases, message brokers, etc.) will execute and may fail if those services are unreachable. + +To handle this, the tool automatically sets a `SWAGGERDIFF_DRYRUN` environment variable. The library provides a convenient static helper to check it: + +```csharp +using SwaggerDiff.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +if (!SwaggerDiffEnv.IsDryRun) +{ + // These only run during normal application startup — not during snapshot generation + builder.ConfigureSecretVault(); + builder.Services.AddDbContext(...); + builder.Services.ConfigureMessageBroker(); +} + +// Swagger registration always runs — this is what the tool needs +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerDiff(); +``` + +`SwaggerDiffEnv.IsDryRun` returns `true` only when the application is being loaded by the `swaggerdiff` CLI tool. During normal `dotnet run`, it returns `false` and all your startup code runs as usual. + +#### `swaggerdiff list` List available snapshots: ```bash -swagger-diff list --dir Docs/Versions +swaggerdiff list --dir Docs/Versions ``` | Option | Default | Description | @@ -197,11 +224,11 @@ swagger-diff list --dir Docs/Versions ### How assembly loading works -The CLI uses a **two-stage subprocess** pattern (identical to how the Swashbuckle CLI works): +The CLI uses a **two-stage subprocess** pattern (similar to how the Swashbuckle CLI works): -1. **Stage 1** (`snapshot`): Builds the project (if needed), resolves the output DLL via `dotnet msbuild --getProperty:TargetPath`, then re-invokes itself via `dotnet exec --depsfile .deps.json --runtimeconfig .runtimeconfig.json .dll _snapshot ...`. This ensures the tool runs inside the target app's dependency graph. +1. **Stage 1** (`snapshot`): Builds the project (if needed), resolves the output DLL via `dotnet msbuild --getProperty:TargetPath`, then re-invokes itself via `dotnet exec --depsfile .deps.json --additional-deps .deps.json --runtimeconfig .runtimeconfig.json .dll _snapshot ...`. This ensures the tool runs inside the target app's dependency graph while retaining access to its own dependencies. -2. **Stage 2** (`_snapshot`): Now running with the correct dependencies, it loads the assembly via `AssemblyLoadContext`, resolves `HostFactoryResolver` via reflection, builds the host with `NoOpServer` injected, and extracts the swagger document. +2. **Stage 2** (`_snapshot`): Now running with the correct dependencies, it loads the assembly via `AssemblyLoadContext`, subscribes to `DiagnosticListener` events to intercept the host as it builds, injects `NoOpServer`, and extracts the swagger document from the DI container. --- @@ -209,8 +236,16 @@ The CLI uses a **two-stage subprocess** pattern (identical to how the Swashbuckl ```csharp // Program.cs +using SwaggerDiff.AspNetCore; +using SwaggerDiff.AspNetCore.Extensions; + var builder = WebApplication.CreateBuilder(args); +if (!SwaggerDiffEnv.IsDryRun) +{ + builder.ConfigureSecretVault(); +} + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerDiff(); @@ -228,7 +263,7 @@ app.Run(); ```bash # Generate a snapshot (builds the project automatically) -swagger-diff snapshot --output ./Docs/Versions +swaggerdiff snapshot --output ./Docs/Versions # Copy snapshots to build output so the UI can find them cp -r Docs/ bin/Debug/net8.0/Docs/ diff --git a/src/SwaggerDiff.AspNetCore/SwaggerDiff.AspNetCore.csproj b/src/SwaggerDiff.AspNetCore/SwaggerDiff.AspNetCore.csproj index 155548e..5f78368 100644 --- a/src/SwaggerDiff.AspNetCore/SwaggerDiff.AspNetCore.csproj +++ b/src/SwaggerDiff.AspNetCore/SwaggerDiff.AspNetCore.csproj @@ -10,7 +10,7 @@ 1.0.0 OluwaVader In-app OpenAPI diff viewer for ASP.NET Core APIs. Embeds a diff viewer UI and wires up minimal API endpoints — no controllers required. - swagger;openapi;diff;aspnetcore;api;oasdiff + swagger;openapi;diff;aspnetcore;api;oasdiff;api-versioning;openapi-diff https://github.com/OluwaVader/SwaggerDiff.AspNetCore https://github.com/OluwaVader/SwaggerDiff.AspNetCore git diff --git a/src/SwaggerDiff.AspNetCore/SwaggerDiffEnv.cs b/src/SwaggerDiff.AspNetCore/SwaggerDiffEnv.cs new file mode 100644 index 0000000..398db8c --- /dev/null +++ b/src/SwaggerDiff.AspNetCore/SwaggerDiffEnv.cs @@ -0,0 +1,39 @@ +namespace SwaggerDiff.AspNetCore; + +/// +/// Provides environment detection helpers for use in your application's startup code. +/// +/// When the swaggerdiff snapshot CLI tool generates a snapshot, it boots your application's +/// host to resolve the Swagger/OpenAPI document. During this process external dependencies +/// (secret vaults, databases, message brokers, etc.) may be unreachable or unnecessary. +/// +/// +/// Wrap expensive or environment-dependent startup code so it is skipped during snapshot generation: +/// +/// if (!SwaggerDiffEnv.IsDryRun) +/// { +/// builder.ConfigureSecretVault(); +/// builder.Services.AddDbContext<AppDbContext>(...); +/// } +/// +/// +/// +public static class SwaggerDiffEnv +{ + /// + /// The environment variable set by the swaggerdiff CLI tool when it boots + /// the target application to generate a snapshot. + /// + public const string DryRunVariable = "SWAGGERDIFF_DRYRUN"; + + /// + /// Returns true when the application is being loaded by the swaggerdiff + /// CLI tool for snapshot generation. Use this to skip external configuration providers, + /// database connections, or other side-effects that are not needed for OpenAPI generation. + /// + public static bool IsDryRun => + string.Equals( + Environment.GetEnvironmentVariable(DryRunVariable), + "true", + StringComparison.OrdinalIgnoreCase); +} diff --git a/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs b/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs index 994c977..8b57f4c 100644 --- a/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs +++ b/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs @@ -88,14 +88,38 @@ public override int Execute(CommandContext context, Settings settings, Cancellat return 1; } - // 2. Re-invoke as: dotnet exec --depsfile ... --runtimeconfig ... .dll _snapshot ... + // 2. Re-invoke as: dotnet exec --depsfile ... --additional-deps ... --runtimeconfig ... .dll _snapshot ... + // We pass --additional-deps so the runtime knows about the tool's own dependencies + // (e.g. Spectre.Console.Cli) which aren't in the target app's deps.json. + // We pass --additionalprobingpath so the runtime can locate those assemblies + // in the NuGet global packages cache. var toolDll = typeof(SnapshotCommand).Assembly.Location; + var toolDir = Path.GetDirectoryName(toolDll)!; + var toolDepsFile = Path.Combine(toolDir, + Path.GetFileNameWithoutExtension(toolDll) + ".deps.json"); - var processArgs = string.Join(" ", - [ + var nugetPackages = Environment.GetEnvironmentVariable("NUGET_PACKAGES") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); + + var args = new List + { "exec", "--depsfile", EscapePath(depsFile), - "--runtimeconfig", EscapePath(runtimeConfig), + "--runtimeconfig", EscapePath(runtimeConfig) + }; + + // Merge the tool's dependency graph so Spectre.Console.Cli etc. can be resolved + if (File.Exists(toolDepsFile)) + { + args.AddRange(["--additional-deps", EscapePath(toolDepsFile)]); + } + + // Tell the runtime where to find both tool and NuGet-cached assemblies + args.AddRange(["--additionalprobingpath", EscapePath(nugetPackages)]); + args.AddRange(["--additionalprobingpath", EscapePath(toolDir)]); + + args.AddRange( + [ EscapePath(toolDll), "_snapshot", "--assembly", EscapePath(assemblyPath), @@ -103,6 +127,8 @@ public override int Execute(CommandContext context, Settings settings, Cancellat "--doc-name", settings.DocName ]); + var processArgs = string.Join(" ", args); + var process = new Process { StartInfo = new ProcessStartInfo @@ -116,6 +142,11 @@ public override int Execute(CommandContext context, Settings settings, Cancellat } }; + // Signal to the target app that it's being loaded for snapshot generation. + // The SwaggerDiff.AspNetCore library exposes SwaggerDiffEnv.IsDryRun so users + // can skip expensive startup code (Vault, DB, message brokers, etc.). + process.StartInfo.Environment["SWAGGERDIFF_DRYRUN"] = "true"; + process.Start(); var stdout = process.StandardOutput.ReadToEnd();