diff --git a/DorkNet.sln b/DorkNet.sln
index 01601e1..b8818b0 100644
--- a/DorkNet.sln
+++ b/DorkNet.sln
@@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MigrateSqliteToPostgres", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.AdminMobile", "DorkNet.AdminMobile\DorkNet.AdminMobile.csproj", "{C2265F00-DFC5-4352-A94C-B887E3C0E5CA}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.ApiRouteCoverage", "tools\DorkNet.ApiRouteCoverage\DorkNet.ApiRouteCoverage.csproj", "{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -71,11 +73,24 @@ Global
{C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x64.Build.0 = Release|Any CPU
{C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x86.ActiveCfg = Release|Any CPU
{C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x86.Build.0 = Release|Any CPU
+ {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x64.Build.0 = Debug|Any CPU
+ {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x86.Build.0 = Debug|Any CPU
+ {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x64.ActiveCfg = Release|Any CPU
+ {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x64.Build.0 = Release|Any CPU
+ {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x86.ActiveCfg = Release|Any CPU
+ {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{70C60098-1B11-4C13-943B-A8A056429EAE} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
+ {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
EndGlobalSection
EndGlobal
diff --git a/tools/DorkNet.ApiRouteCoverage/DorkNet.ApiRouteCoverage.csproj b/tools/DorkNet.ApiRouteCoverage/DorkNet.ApiRouteCoverage.csproj
new file mode 100644
index 0000000..10a76b3
--- /dev/null
+++ b/tools/DorkNet.ApiRouteCoverage/DorkNet.ApiRouteCoverage.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/tools/DorkNet.ApiRouteCoverage/Program.cs b/tools/DorkNet.ApiRouteCoverage/Program.cs
new file mode 100644
index 0000000..5710454
--- /dev/null
+++ b/tools/DorkNet.ApiRouteCoverage/Program.cs
@@ -0,0 +1,216 @@
+using System.Reflection;
+using System.Runtime.Loader;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Routing;
+
+var serverAssembly = LoadServerAssembly(args);
+var serverAssemblyWriteTime = File.GetLastWriteTimeUtc(serverAssembly.Location);
+Console.WriteLine($"Using server assembly: {serverAssembly.Location}");
+Console.WriteLine($"Server assembly timestamp (UTC): {serverAssemblyWriteTime:O}");
+
+var routes = DiscoverRoutes(serverAssembly).OrderBy(r => r.Method).ThenBy(r => r.Template).ToArray();
+var failures = new List();
+
+if (routes.Length == 0)
+ failures.Add("No HTTP controller routes were discovered.");
+
+foreach (var route in routes)
+{
+ if (string.IsNullOrWhiteSpace(route.Template))
+ failures.Add($"{route.Source}: empty route template");
+
+ if (route.SamplePath.Contains('{') || route.SamplePath.Contains('}'))
+ failures.Add($"{route.Source}: unresolved route parameter in {route.SamplePath}");
+
+ if (route.SamplePath.Contains('[') || route.SamplePath.Contains(']'))
+ failures.Add($"{route.Source}: unresolved token in {route.SamplePath}");
+
+ if (!route.SamplePath.StartsWith('/'))
+ failures.Add($"{route.Source}: sample path must be absolute: {route.SamplePath}");
+}
+
+Console.WriteLine($"Discovered {routes.Length} HTTP route test cases.");
+foreach (var route in routes)
+ Console.WriteLine($"{route.Method,-6} {route.SamplePath,-72} {route.Source}");
+
+if (failures.Count == 0)
+ return 0;
+
+Console.Error.WriteLine();
+Console.Error.WriteLine("Route coverage failed:");
+foreach (var failure in failures)
+ Console.Error.WriteLine($"- {failure}");
+return 1;
+
+static Assembly LoadServerAssembly(string[] args)
+{
+ if (args.Length > 0)
+ return LoadAssemblyFromPath(Path.GetFullPath(args[0]));
+
+ var repositoryRoot = FindRepositoryRoot();
+ var candidates = new[]
+ {
+ Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net9.0", "DorkNet.Server.dll"),
+ Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll")
+ };
+
+ var existingCandidates = candidates.Where(File.Exists).ToArray();
+ if (existingCandidates.Length == 0)
+ {
+ throw new FileNotFoundException(
+ "Could not find a built DorkNet.Server assembly. Build DorkNet.Server first or pass the DLL path as the first argument.",
+ string.Join(Environment.NewLine, candidates));
+ }
+
+ if (existingCandidates.Length > 1)
+ {
+ throw new InvalidOperationException(
+ "Multiple DorkNet.Server assemblies were found. Pass the DLL path as the first argument so route coverage validates the intended build."
+ + Environment.NewLine
+ + string.Join(Environment.NewLine, existingCandidates));
+ }
+
+ return LoadAssemblyFromPath(existingCandidates[0]);
+}
+
+static Assembly LoadAssemblyFromPath(string assemblyPath)
+{
+ var assemblyDirectory = Path.GetDirectoryName(assemblyPath)
+ ?? throw new DirectoryNotFoundException($"Could not resolve assembly directory for {assemblyPath}.");
+
+ AssemblyLoadContext.Default.Resolving += (_, assemblyName) =>
+ {
+ var dependencyPath = Path.Combine(assemblyDirectory, assemblyName.Name + ".dll");
+ return File.Exists(dependencyPath)
+ ? AssemblyLoadContext.Default.LoadFromAssemblyPath(dependencyPath)
+ : null;
+ };
+
+ return AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath);
+}
+
+static string FindRepositoryRoot()
+{
+ var current = new DirectoryInfo(AppContext.BaseDirectory);
+ while (current is not null)
+ {
+ if (File.Exists(Path.Combine(current.FullName, "DorkNet.Server", "DorkNet.Server.csproj")))
+ return current.FullName;
+
+ current = current.Parent;
+ }
+
+ throw new DirectoryNotFoundException("Could not locate the repository root from the route coverage tool output path.");
+}
+
+static IEnumerable DiscoverRoutes(Assembly serverAssembly)
+{
+ var controllerBaseType = typeof(ControllerBase);
+ foreach (var controller in serverAssembly.GetTypes()
+ .Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t)))
+ {
+ var controllerName = ControllerName(controller);
+ var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true)
+ .OfType()
+ .Select(a => a.Template)
+ .Where(t => !string.IsNullOrWhiteSpace(t))
+ .Cast()
+ .DefaultIfEmpty("")
+ .ToArray();
+
+ foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
+ {
+ var httpAttrs = method.GetCustomAttributes(inherit: true)
+ .OfType()
+ .ToArray();
+ if (httpAttrs.Length == 0)
+ continue;
+
+ foreach (var attr in httpAttrs)
+ {
+ var attrMethods = attr.HttpMethods.Any()
+ ? attr.HttpMethods
+ : new[] { "ANY" };
+ var actionName = method.Name;
+ foreach (var httpMethod in attrMethods)
+ {
+ foreach (var controllerTemplate in controllerRouteTemplates)
+ {
+ var template = CombineTemplates(controllerTemplate, attr.Template);
+ template = ReplaceRouteTokens(template, controllerName, actionName);
+ yield return new RouteCase(
+ httpMethod.ToUpperInvariant(),
+ template,
+ ToSamplePath(template),
+ $"{controller.FullName}.{method.Name}");
+ }
+ }
+ }
+ }
+ }
+}
+
+static string ControllerName(Type controller)
+{
+ const string suffix = "Controller";
+ return controller.Name.EndsWith(suffix, StringComparison.Ordinal)
+ ? controller.Name[..^suffix.Length]
+ : controller.Name;
+}
+
+static string CombineTemplates(string? controllerTemplate, string? actionTemplate)
+{
+ controllerTemplate ??= "";
+ actionTemplate ??= "";
+
+ if (actionTemplate.StartsWith("~/", StringComparison.Ordinal))
+ return "/" + actionTemplate[2..].TrimStart('/');
+
+ if (actionTemplate.StartsWith("/", StringComparison.Ordinal))
+ return "/" + actionTemplate.TrimStart('/');
+
+ var left = controllerTemplate.Trim('/');
+ var right = actionTemplate.Trim('/');
+
+ if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right))
+ return "/";
+ if (string.IsNullOrEmpty(left))
+ return "/" + right;
+ if (string.IsNullOrEmpty(right))
+ return "/" + left;
+ return "/" + left + "/" + right;
+}
+
+static string ReplaceRouteTokens(string template, string controllerName, string actionName)
+{
+ return template
+ .Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase)
+ .Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase);
+}
+
+static string ToSamplePath(string template)
+{
+ var path = RouteParameterRegex().Replace(template, match =>
+ {
+ var constraint = match.Groups["constraint"].Value.ToLowerInvariant();
+ if (constraint.Contains("long") || constraint.Contains("int"))
+ return "1";
+ if (constraint.Contains("bool"))
+ return "true";
+ if (constraint.Contains("guid"))
+ return "00000000-0000-0000-0000-000000000001";
+ return "sample";
+ });
+
+ path = Regex.Replace(path, "/+", "/");
+ return path.StartsWith('/') ? path : "/" + path;
+}
+
+internal sealed record RouteCase(string Method, string Template, string SamplePath, string Source);
+
+partial class Program
+{
+ [GeneratedRegex(@"\{[*]?(?[^}:=\?]+)(?:[^}=\?]+)?(?\?)?(?=[^}]+)?\}")]
+ private static partial Regex RouteParameterRegex();
+}