Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 46 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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<AppDbContext>(...);
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 |
Expand All @@ -197,20 +224,28 @@ 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 <app>.deps.json --runtimeconfig <app>.runtimeconfig.json <tool>.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 <app>.deps.json --additional-deps <tool>.deps.json --runtimeconfig <app>.runtimeconfig.json <tool>.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.

---

## Full example

```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();
Expand All @@ -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/
Expand Down
2 changes: 1 addition & 1 deletion src/SwaggerDiff.AspNetCore/SwaggerDiff.AspNetCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<Version>1.0.0</Version>
<Authors>OluwaVader</Authors>
<Description>In-app OpenAPI diff viewer for ASP.NET Core APIs. Embeds a diff viewer UI and wires up minimal API endpoints — no controllers required.</Description>
<PackageTags>swagger;openapi;diff;aspnetcore;api;oasdiff</PackageTags>
<PackageTags>swagger;openapi;diff;aspnetcore;api;oasdiff;api-versioning;openapi-diff</PackageTags>
<PackageProjectUrl>https://github.com/OluwaVader/SwaggerDiff.AspNetCore</PackageProjectUrl>
<RepositoryUrl>https://github.com/OluwaVader/SwaggerDiff.AspNetCore</RepositoryUrl>
<RepositoryType>git</RepositoryType>
Expand Down
39 changes: 39 additions & 0 deletions src/SwaggerDiff.AspNetCore/SwaggerDiffEnv.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace SwaggerDiff.AspNetCore;

/// <summary>
/// Provides environment detection helpers for use in your application's startup code.
/// <para>
/// When the <c>swaggerdiff snapshot</c> 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.
/// </para>
/// <example>
/// Wrap expensive or environment-dependent startup code so it is skipped during snapshot generation:
/// <code>
/// if (!SwaggerDiffEnv.IsDryRun)
/// {
/// builder.ConfigureSecretVault();
/// builder.Services.AddDbContext&lt;AppDbContext&gt;(...);
/// }
/// </code>
/// </example>
/// </summary>
public static class SwaggerDiffEnv
{
/// <summary>
/// The environment variable set by the <c>swaggerdiff</c> CLI tool when it boots
/// the target application to generate a snapshot.
/// </summary>
public const string DryRunVariable = "SWAGGERDIFF_DRYRUN";

/// <summary>
/// Returns <c>true</c> when the application is being loaded by the <c>swaggerdiff</c>
/// 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.
/// </summary>
public static bool IsDryRun =>
string.Equals(
Environment.GetEnvironmentVariable(DryRunVariable),
"true",
StringComparison.OrdinalIgnoreCase);
}
39 changes: 35 additions & 4 deletions src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,21 +88,47 @@ public override int Execute(CommandContext context, Settings settings, Cancellat
return 1;
}

// 2. Re-invoke as: dotnet exec --depsfile ... --runtimeconfig ... <tool>.dll _snapshot ...
// 2. Re-invoke as: dotnet exec --depsfile ... --additional-deps ... --runtimeconfig ... <tool>.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<string>
{
"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),
"--output", EscapePath(outputDir),
"--doc-name", settings.DocName
]);

var processArgs = string.Join(" ", args);

var process = new Process
{
StartInfo = new ProcessStartInfo
Expand All @@ -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();
Expand Down
Loading