From 2df65c432d23da14c3ff7c945f1ab90b0aa7f7b8 Mon Sep 17 00:00:00 2001 From: OluwaVader Date: Sat, 28 Feb 2026 23:32:54 +0100 Subject: [PATCH 1/2] support auto discovery across solution and support for multi project listing --- README.md | 37 +- .../Commands/SnapshotCommand.cs | 347 +++++++++++++----- src/SwaggerDiff.Tool/Program.cs | 4 +- 3 files changed, 284 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 399819f..30fdc6c 100644 --- a/README.md +++ b/README.md @@ -168,12 +168,14 @@ swaggerdiff snapshot --assembly ./bin/Release/net8.0/MyApi.dll | Option | Default | Description | |--------|---------|-------------| -| `--project` | auto-discover | Path to a `.csproj` file. If omitted, finds the single `.csproj` in the current directory | -| `--assembly` | — | Direct path to a built DLL. Overrides `--project` and skips the build step | +| `--project` | auto-discover | Path to one or more `.csproj` files. Repeat for multiple projects | +| `--assembly` | — | Direct path to a built DLL. Overrides `--project` and skips the build step (single project only) | | `-c`, `--configuration` | `Debug` | Build configuration (used with `--project`) | | `--no-build` | `false` | Skip the build step (assumes the project was already built) | -| `--output` | `Docs/Versions` | Directory where snapshots are written | +| `--output` | `Docs/Versions` | Directory where snapshots are written (relative to each project directory) | | `--doc-name` | `v1` | Swagger document name passed to `ISwaggerProvider.GetSwagger()` | +| `--exclude` | — | Project names to exclude from auto-discovery (without `.csproj`). Repeat for multiple | +| `--exclude-dir` | — | Directory names to exclude from auto-discovery. Repeat for multiple | The command will: @@ -183,6 +185,35 @@ The command will: 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. +### Multiple projects + +When run from a solution directory (or any directory without a single `.csproj`), the tool automatically discovers all ASP.NET Core web projects by scanning up to 2 levels deep for `.csproj` files with `Sdk="Microsoft.NET.Sdk.Web"`. + +```bash +# From the solution root — discovers and snapshots all web API projects +swaggerdiff snapshot + +# Explicit multi-project +swaggerdiff snapshot \ + --project ./src/AdminApi/AdminApi.csproj \ + --project ./src/TenantApi/TenantApi.csproj + +# Auto-discover but skip specific projects or directories +swaggerdiff snapshot --exclude MyApi.Tests --exclude-dir tests +``` + +Each project's snapshots are written to `Docs/Versions/` relative to that project's own directory: + +``` +src/ + ServiceOneApi/ + Docs/Versions/doc_20250612143022.json + ServiceTwoApi/ + Docs/Versions/doc_20250612143022.json +``` + +The tool skips `bin/`, `obj/`, `.git/`, and other well-known non-project directories automatically. + ### 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. diff --git a/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs b/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs index 8b57f4c..0ba2488 100644 --- a/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs +++ b/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs @@ -7,20 +7,28 @@ namespace SwaggerDiff.Tool.Commands; /// /// Stage 1: User-facing snapshot command. -/// Resolves the target assembly (from --project, --assembly, or auto-discovery), -/// optionally builds it, then re-invokes itself via dotnet exec with the target app's +/// Resolves target assemblies (from --project, --assembly, or auto-discovery of ASP.NET Core web projects), +/// optionally builds them, then re-invokes itself via dotnet exec with each target app's /// deps.json and runtimeconfig.json so that all assembly dependencies resolve correctly. /// internal sealed class SnapshotCommand : Command { + /// + /// Well-known directories that are always skipped during auto-discovery. + /// + private static readonly HashSet SkippedDirectories = new(StringComparer.OrdinalIgnoreCase) + { + "bin", "obj", ".git", ".idea", ".vs", "node_modules", "TestResults", "artifacts" + }; + public sealed class Settings : CommandSettings { [CommandOption("--project ")] - [Description("Path to the .csproj file. Defaults to the single .csproj in the current directory.")] - public string? Project { get; set; } + [Description("Path to one or more .csproj files. Repeat for multiple projects.")] + public string[]? Project { get; set; } [CommandOption("--assembly ")] - [Description("Direct path to a built assembly DLL. Overrides --project (skips build).")] + [Description("Direct path to a built assembly DLL. Overrides --project (skips build). Single project only.")] public string? Assembly { get; set; } [CommandOption("-c|--configuration ")] @@ -34,7 +42,7 @@ public sealed class Settings : CommandSettings public bool NoBuild { get; set; } [CommandOption("--output ")] - [Description("Output directory for snapshots.")] + [Description("Output directory for snapshots (relative to each project directory).")] [DefaultValue("Docs/Versions")] public string Output { get; set; } = "Docs/Versions"; @@ -43,6 +51,14 @@ public sealed class Settings : CommandSettings [DefaultValue("v1")] public string DocName { get; set; } = "v1"; + [CommandOption("--exclude ")] + [Description("Project names to exclude from auto-discovery (without .csproj extension). Repeat for multiple.")] + public string[]? Exclude { get; set; } + + [CommandOption("--exclude-dir ")] + [Description("Directory names to exclude from auto-discovery. Repeat for multiple.")] + public string[]? ExcludeDir { get; set; } + public override ValidationResult Validate() { if (!string.IsNullOrWhiteSpace(Assembly)) @@ -52,11 +68,14 @@ public override ValidationResult Validate() return ValidationResult.Error($"Assembly not found: {fullPath}"); } - if (!string.IsNullOrWhiteSpace(Project)) + if (Project != null) { - var fullPath = Path.GetFullPath(Project); - if (!File.Exists(fullPath)) - return ValidationResult.Error($"Project file not found: {fullPath}"); + foreach (var project in Project) + { + var fullPath = Path.GetFullPath(project); + if (!File.Exists(fullPath)) + return ValidationResult.Error($"Project file not found: {fullPath}"); + } } return ValidationResult.Success(); @@ -65,13 +84,220 @@ public override ValidationResult Validate() public override int Execute(CommandContext context, Settings settings, CancellationToken cancellationToken) { - // 1. Resolve the assembly path - var assemblyPath = ResolveAssemblyPath(settings); - if (assemblyPath == null) + // Single assembly mode — unchanged behavior + if (!string.IsNullOrWhiteSpace(settings.Assembly)) + { + AnsiConsole.MarkupLine($"[grey]Using assembly:[/] {settings.Assembly}"); + var assemblyPath = Path.GetFullPath(settings.Assembly); + var assemblyDir = Path.GetDirectoryName(assemblyPath)!; + var outputDir = Path.GetFullPath(settings.Output); + return RunSnapshotSubprocess(assemblyPath, assemblyDir, outputDir, settings.DocName); + } + + // Resolve one or more projects + var projects = ResolveProjects(settings); + if (projects == null || projects.Count == 0) return 1; - var outputDir = Path.GetFullPath(settings.Output); - var assemblyDir = Path.GetDirectoryName(assemblyPath)!; + var succeeded = 0; + var failed = 0; + + foreach (var projectPath in projects) + { + var projectName = Path.GetFileNameWithoutExtension(projectPath); + var projectDir = Path.GetDirectoryName(projectPath)!; + + if (projects.Count > 1) + AnsiConsole.MarkupLine($"\n[bold]── {projectName.EscapeMarkup()} ──[/]"); + + // Build and resolve the assembly DLL + var assemblyPath = BuildAndResolveAssembly(projectPath, settings); + if (assemblyPath == null) + { + failed++; + continue; + } + + // Output directory is relative to the project's directory + var outputDir = Path.Combine(projectDir, settings.Output); + var assemblyDir = Path.GetDirectoryName(assemblyPath)!; + + var exitCode = RunSnapshotSubprocess(assemblyPath, assemblyDir, outputDir, settings.DocName); + if (exitCode == 0) + succeeded++; + else + failed++; + } + + // Print summary for multi-project runs + if (projects.Count > 1) + { + AnsiConsole.WriteLine(); + if (failed == 0) + AnsiConsole.MarkupLine($"[green]Snapshots complete:[/] {succeeded}/{projects.Count} succeeded"); + else + AnsiConsole.MarkupLine($"[yellow]Snapshots complete:[/] {succeeded}/{projects.Count} succeeded, {failed} failed"); + } + + return failed > 0 ? 1 : 0; + } + + /// + /// Resolves the list of projects to process from explicit flags or auto-discovery. + /// + private static List? ResolveProjects(Settings settings) + { + // Explicit --project flags + if (settings.Project is { Length: > 0 }) + { + var resolved = settings.Project.Select(Path.GetFullPath).ToList(); + AnsiConsole.MarkupLine($"[grey]Using {resolved.Count} specified project(s)[/]"); + return resolved; + } + + // Auto-discovery: single .csproj in CWD → current behavior + var cwd = Directory.GetCurrentDirectory(); + var localProjects = Directory.GetFiles(cwd, "*.csproj"); + + if (localProjects.Length == 1) + { + AnsiConsole.MarkupLine($"[grey]Using project:[/] {localProjects[0]}"); + return [localProjects[0]]; + } + + // Auto-discovery: scan up to 2 levels deep for ASP.NET Core web projects + var discovered = DiscoverWebProjects(cwd, settings.Exclude, settings.ExcludeDir); + + if (discovered.Count == 0) + { + AnsiConsole.MarkupLine("[red]Error:[/] No ASP.NET Core web projects found."); + AnsiConsole.MarkupLine("[grey]Searched for projects with Sdk=\"Microsoft.NET.Sdk.Web\" up to 2 levels deep.[/]"); + AnsiConsole.MarkupLine("[grey]Use --project to specify project paths explicitly.[/]"); + return null; + } + + AnsiConsole.MarkupLine($"[grey]Discovered {discovered.Count} web project(s):[/]"); + foreach (var p in discovered) + AnsiConsole.MarkupLine($"[grey] {Path.GetRelativePath(cwd, p)}[/]"); + + return discovered; + } + + /// + /// Scans the current directory and up to 2 levels of subdirectories for ASP.NET Core web projects. + /// Filters by Sdk="Microsoft.NET.Sdk.Web" and applies exclude rules. + /// + private static List DiscoverWebProjects(string rootDir, string[]? excludeNames, string[]? excludeDirs) + { + var results = new List(); + var excludeNameSet = new HashSet(excludeNames ?? [], StringComparer.OrdinalIgnoreCase); + var excludeDirSet = new HashSet(excludeDirs ?? [], StringComparer.OrdinalIgnoreCase); + + SearchDirectory(rootDir, 0); + results.Sort(StringComparer.OrdinalIgnoreCase); + return results; + + void SearchDirectory(string dir, int depth) + { + if (depth > 2) return; + + // Skip well-known non-project directories + var dirName = Path.GetFileName(dir); + if (depth > 0 && SkippedDirectories.Contains(dirName)) + return; + + // Skip user-excluded directories + if (depth > 0 && excludeDirSet.Contains(dirName)) + return; + + // Check .csproj files in this directory + foreach (var csproj in Directory.GetFiles(dir, "*.csproj")) + { + var projectName = Path.GetFileNameWithoutExtension(csproj); + + // Skip excluded project names + if (excludeNameSet.Contains(projectName)) + continue; + + // Check if it's a web project + if (IsWebProject(csproj)) + results.Add(csproj); + } + + // Recurse into subdirectories + try + { + foreach (var subDir in Directory.GetDirectories(dir)) + SearchDirectory(subDir, depth + 1); + } + catch (UnauthorizedAccessException) + { + // Skip directories we can't read + } + } + } + + /// + /// Checks whether a .csproj file uses the ASP.NET Core Web SDK. + /// + private static bool IsWebProject(string csprojPath) + { + try + { + // Read just the first few lines — the Sdk attribute is always on line 1 + using var reader = new StreamReader(csprojPath); + for (var i = 0; i < 3 && reader.ReadLine() is { } line; i++) + { + if (line.Contains("Microsoft.NET.Sdk.Web", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + catch + { + return false; + } + } + + /// + /// Builds a project (unless --no-build) and resolves its output assembly DLL path. + /// + private static string? BuildAndResolveAssembly(string projectPath, Settings settings) + { + AnsiConsole.MarkupLine($"[grey]Using project:[/] {projectPath}"); + + if (!settings.NoBuild) + { + AnsiConsole.MarkupLine($"[grey]Building[/] ({settings.Configuration})..."); + var buildResult = RunProcess("dotnet", + $"build {EscapePath(projectPath)} --configuration {settings.Configuration} --nologo -v q"); + if (buildResult != 0) + { + AnsiConsole.MarkupLine("[red]Error:[/] Build failed."); + return null; + } + } + + var targetPath = GetMsBuildProperty(projectPath, "TargetPath", settings.Configuration); + if (string.IsNullOrWhiteSpace(targetPath) || !File.Exists(targetPath)) + { + AnsiConsole.MarkupLine("[red]Error:[/] Could not resolve assembly path from project. Try using --assembly directly."); + return null; + } + + return targetPath; + } + + // ───────────────────────────────────────────────────────────────── + // Subprocess execution (Stage 2) + // ───────────────────────────────────────────────────────────────── + + /// + /// Launches the Stage 2 subprocess via dotnet exec with the target app's dependency context. + /// + private static int RunSnapshotSubprocess(string assemblyPath, string assemblyDir, string outputDir, string docName) + { var assemblyName = Path.GetFileNameWithoutExtension(assemblyPath); var depsFile = Path.Combine(assemblyDir, $"{assemblyName}.deps.json"); var runtimeConfig = Path.Combine(assemblyDir, $"{assemblyName}.runtimeconfig.json"); @@ -88,11 +314,7 @@ public override int Execute(CommandContext context, Settings settings, Cancellat return 1; } - // 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. + // Resolve tool paths for --additional-deps and --additionalprobingpath var toolDll = typeof(SnapshotCommand).Assembly.Location; var toolDir = Path.GetDirectoryName(toolDll)!; var toolDepsFile = Path.Combine(toolDir, @@ -108,13 +330,9 @@ public override int Execute(CommandContext context, Settings settings, Cancellat "--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)]); @@ -123,8 +341,8 @@ public override int Execute(CommandContext context, Settings settings, Cancellat EscapePath(toolDll), "_snapshot", "--assembly", EscapePath(assemblyPath), - "--output", EscapePath(outputDir), - "--doc-name", settings.DocName + "--output", EscapePath(Path.GetFullPath(outputDir)), + "--doc-name", docName ]); var processArgs = string.Join(" ", args); @@ -143,8 +361,6 @@ 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(); @@ -162,73 +378,10 @@ public override int Execute(CommandContext context, Settings settings, Cancellat return process.ExitCode; } - /// - /// Resolves the assembly DLL path from --assembly, --project, or auto-discovery. - /// Builds the project first unless --no-build or --assembly is specified. - /// - private static string? ResolveAssemblyPath(Settings settings) - { - // Direct assembly path — skip everything - if (!string.IsNullOrWhiteSpace(settings.Assembly)) - { - AnsiConsole.MarkupLine($"[grey]Using assembly:[/] {settings.Assembly}"); - return Path.GetFullPath(settings.Assembly); - } - - // Resolve the .csproj path - var projectPath = ResolveProjectPath(settings.Project); - if (projectPath == null) - return null; - - AnsiConsole.MarkupLine($"[grey]Using project:[/] {projectPath}"); - - // Build unless --no-build - if (!settings.NoBuild) - { - AnsiConsole.MarkupLine($"[grey]Building[/] ({settings.Configuration})..."); - var buildResult = RunProcess("dotnet", $"build {EscapePath(projectPath)} --configuration {settings.Configuration} --nologo -v q"); - if (buildResult != 0) - { - AnsiConsole.MarkupLine("[red]Error:[/] Build failed."); - return null; - } - } - - // Resolve the output DLL path via MSBuild - var targetPath = GetMsBuildProperty(projectPath, "TargetPath", settings.Configuration); - if (string.IsNullOrWhiteSpace(targetPath) || !File.Exists(targetPath)) - { - AnsiConsole.MarkupLine("[red]Error:[/] Could not resolve assembly path from project. Try using --assembly directly."); - return null; - } - - return targetPath; - } - - /// - /// Resolves the .csproj file path from an explicit --project value or auto-discovers - /// the single .csproj in the current directory. - /// - private static string? ResolveProjectPath(string? explicitProject) - { - if (!string.IsNullOrWhiteSpace(explicitProject)) - return Path.GetFullPath(explicitProject); - - // Auto-discover: find .csproj files in the current directory - var csprojFiles = Directory.GetFiles(Directory.GetCurrentDirectory(), "*.csproj"); + // ───────────────────────────────────────────────────────────────── + // Utilities + // ───────────────────────────────────────────────────────────────── - return csprojFiles.Length switch - { - 0 => Error("No .csproj file found in the current directory. Use --project or --assembly."), - 1 => csprojFiles[0], - _ => Error($"Multiple .csproj files found. Use --project to specify which one:\n" - + string.Join("\n", csprojFiles.Select(f => $" {Path.GetFileName(f)}"))) - }; - } - - /// - /// Reads an MSBuild property from a project file using dotnet msbuild --getProperty. - /// private static string? GetMsBuildProperty(string projectPath, string property, string configuration) { var process = new Process @@ -280,12 +433,6 @@ private static int RunProcess(string fileName, string arguments) return process.ExitCode; } - private static string? Error(string message) - { - AnsiConsole.MarkupLine($"[red]Error:[/] {message.EscapeMarkup()}"); - return null; - } - private static string EscapePath(string path) => path.Contains(' ') ? $"\"{path}\"" : path; } diff --git a/src/SwaggerDiff.Tool/Program.cs b/src/SwaggerDiff.Tool/Program.cs index a3ea533..9ab80be 100644 --- a/src/SwaggerDiff.Tool/Program.cs +++ b/src/SwaggerDiff.Tool/Program.cs @@ -8,9 +8,11 @@ config.SetApplicationName("swaggerdiff"); config.AddCommand("snapshot") - .WithDescription("Generate a new OpenAPI snapshot from a built assembly.") + .WithDescription("Generate OpenAPI snapshots from built assemblies. Auto-discovers ASP.NET Core web projects when run from a solution directory.") .WithExample("snapshot") .WithExample("snapshot", "--project", "./src/MyApi/MyApi.csproj") + .WithExample("snapshot", "--project", "./src/Api1/Api1.csproj", "--project", "./src/Api2/Api2.csproj") + .WithExample("snapshot", "--exclude", "MyApi.Tests", "--exclude-dir", "tests") .WithExample("snapshot", "-c", "Release", "--output", "Docs/Versions") .WithExample("snapshot", "--assembly", "./bin/Release/net8.0/MyApi.dll"); From 3c94efc97285ddb59f8b1f9d904e72fca6b61dfc Mon Sep 17 00:00:00 2001 From: OluwaVader Date: Sat, 28 Feb 2026 23:36:25 +0100 Subject: [PATCH 2/2] support anonymous calls for versioning --- .../Extensions/SwaggerDiffExtensions.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/SwaggerDiff.AspNetCore/Extensions/SwaggerDiffExtensions.cs b/src/SwaggerDiff.AspNetCore/Extensions/SwaggerDiffExtensions.cs index 765f0be..1eab00f 100644 --- a/src/SwaggerDiff.AspNetCore/Extensions/SwaggerDiffExtensions.cs +++ b/src/SwaggerDiff.AspNetCore/Extensions/SwaggerDiffExtensions.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -46,7 +47,7 @@ public static WebApplication UseSwaggerDiff(this WebApplication app) { var versions = service.GetAvailableVersions(); return Results.Ok(new { isSuccess = true, data = versions }); - }).ExcludeFromDescription(); + }).ExcludeFromDescription().AllowAnonymous(); app.MapPost("/api-docs/compare", async (ApiDiffRequest request, SwaggerDiffService service) => { @@ -55,7 +56,7 @@ public static WebApplication UseSwaggerDiff(this WebApplication app) return result == null ? Results.Ok(new { isSuccess = false, message = "Failed to retrieve the diff." }) : Results.Ok(new { isSuccess = true, data = result }); - }).ExcludeFromDescription(); + }).ExcludeFromDescription().AllowAnonymous(); // Serve the Swagger Diff UI app.Map(options.RoutePrefix, swaggerDiffApp =>