From 94bad2912b2f9b3c38188d789cd54310502fe552 Mon Sep 17 00:00:00 2001
From: Alexa <53471801+Alexa-RR@users.noreply.github.com>
Date: Mon, 15 Jun 2026 20:31:41 +0300
Subject: [PATCH] Add endpoint contract tool and GitHub workflow
---
.../workflows.disabled/endpoint-contract.yml | 78 +++
.../DorkNet.EndpointContract.csproj | 14 +
tools/DorkNet.EndpointContract/Program.cs | 509 ++++++++++++++++++
3 files changed, 601 insertions(+)
create mode 100644 .github/workflows.disabled/endpoint-contract.yml
create mode 100644 tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj
create mode 100644 tools/DorkNet.EndpointContract/Program.cs
diff --git a/.github/workflows.disabled/endpoint-contract.yml b/.github/workflows.disabled/endpoint-contract.yml
new file mode 100644
index 0000000..1fc41ea
--- /dev/null
+++ b/.github/workflows.disabled/endpoint-contract.yml
@@ -0,0 +1,78 @@
+# Disabled template. GitHub ignores files outside .github/workflows/.
+# Move this file to .github/workflows/endpoint-contract.yml when it should run.
+name: Endpoint Contract
+
+on:
+ workflow_dispatch:
+
+jobs:
+ endpoint-contract:
+ runs-on: ubuntu-latest
+
+ env:
+ ASPNETCORE_ENVIRONMENT: Development
+ ASPNETCORE_URLS: http://127.0.0.1:18080
+ Database__Provider: sqlite
+ Database__SqlitePath: ${{ runner.temp }}/dorknet-endpoint-contract.db
+ DORKNET_JWT_SECRET: endpoint-contract-secret-000000000000000000000000000000000000000000
+ S3__Endpoint: ""
+ S3__AccessKey: ""
+ S3__SecretKey: ""
+ PROTOBUF_PROTOC: /usr/bin/protoc
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.0.x
+
+ - name: Install protobuf compiler
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y protobuf-compiler
+
+ - name: Build server
+ run: dotnet build DorkNet.Server/DorkNet.Server.csproj
+
+ - name: Build endpoint contract runner
+ run: dotnet build tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-restore
+
+ - name: Start server
+ run: |
+ dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
+ echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
+
+ - name: Wait for server readiness
+ run: |
+ for attempt in {1..60}; do
+ if curl --fail --silent http://127.0.0.1:18080/healthz; then
+ exit 0
+ fi
+ sleep 2
+ done
+ cat "$RUNNER_TEMP/dorknet-server.log"
+ exit 1
+
+ - name: Run endpoint contract
+ run: |
+ dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
+ --server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \
+ --base-url http://127.0.0.1:18080 \
+ --report "$RUNNER_TEMP/endpoint-contract-report.json"
+
+ - name: Upload endpoint contract artifacts
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: endpoint-contract
+ path: |
+ ${{ runner.temp }}/endpoint-contract-report.json
+ ${{ runner.temp }}/dorknet-server.log
+
+ - name: Stop server
+ if: always()
+ run: |
+ if [ -f "$RUNNER_TEMP/dorknet-server.pid" ]; then
+ kill "$(cat "$RUNNER_TEMP/dorknet-server.pid")" || true
+ fi
diff --git a/tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj b/tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj
new file mode 100644
index 0000000..10a76b3
--- /dev/null
+++ b/tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/tools/DorkNet.EndpointContract/Program.cs b/tools/DorkNet.EndpointContract/Program.cs
new file mode 100644
index 0000000..9c8bed1
--- /dev/null
+++ b/tools/DorkNet.EndpointContract/Program.cs
@@ -0,0 +1,509 @@
+using System.Net;
+using System.Reflection;
+using System.Runtime.Loader;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Routing;
+
+var options = EndpointContractOptions.Parse(args);
+var serverAssembly = LoadServerAssembly(options.ServerAssemblyPath);
+var routes = DiscoverRoutes(serverAssembly).OrderBy(r => r.Method).ThenBy(r => r.SamplePath).ToArray();
+var results = new List();
+var failures = new List();
+
+ValidateRouteShapes(routes, failures);
+
+Console.WriteLine($"Using server assembly: {serverAssembly.Location}");
+Console.WriteLine($"Server assembly timestamp (UTC): {File.GetLastWriteTimeUtc(serverAssembly.Location):O}");
+Console.WriteLine($"Discovered {routes.Length} HTTP endpoint contracts.");
+
+if (options.BaseUrl is { } baseUrl)
+{
+ using var http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") };
+ http.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
+
+ foreach (var route in routes)
+ {
+ var result = await ProbeAsync(http, route, options);
+ results.Add(result);
+ if (!result.Passed)
+ failures.Add($"{route.Source}: {route.Method} {route.SamplePath} {result.Expected} but got {result.Observed}");
+ }
+}
+else
+{
+ foreach (var route in routes)
+ {
+ results.Add(new EndpointProbeResult(
+ route.Method,
+ route.SamplePath,
+ route.Source,
+ route.RequiresAuthorization,
+ "route-shape",
+ null,
+ null,
+ true));
+ }
+}
+
+if (options.ReportPath is { } reportPath)
+ WriteReport(reportPath, serverAssembly, routes.Length, results, failures);
+
+foreach (var result in results)
+{
+ var status = result.StatusCode is int code ? code.ToString() : "-";
+ Console.WriteLine($"{(result.Passed ? "PASS" : "FAIL"),-4} {result.Method,-6} {status,-3} {result.Path,-72} {result.Source}");
+}
+
+if (failures.Count == 0)
+ return 0;
+
+Console.Error.WriteLine();
+Console.Error.WriteLine("Endpoint contract failed:");
+foreach (var failure in failures)
+ Console.Error.WriteLine($"- {failure}");
+return 1;
+
+static void ValidateRouteShapes(IEnumerable routes, List failures)
+{
+ 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 route token in {route.SamplePath}");
+
+ if (!route.SamplePath.StartsWith('/'))
+ failures.Add($"{route.Source}: sample path must be absolute: {route.SamplePath}");
+ }
+}
+
+static async Task ProbeAsync(
+ HttpClient http,
+ EndpointContract route,
+ EndpointContractOptions options)
+{
+ using var request = new HttpRequestMessage(ToHttpMethod(route.Method), route.SamplePath.TrimStart('/'));
+ request.Headers.Accept.ParseAdd("application/json");
+ request.Headers.TryAddWithoutValidation("X-DorkNet-Version", options.VersionHeader);
+ request.Headers.UserAgent.ParseAdd("DorkNetEndpointContract/1.0");
+
+ if (NeedsRequestBody(request.Method))
+ request.Content = CreateContent(route);
+
+ try
+ {
+ using var response = await http.SendAsync(request);
+ var statusCode = (int)response.StatusCode;
+ var passed = ResponseMatchesContract(route, response.StatusCode);
+ return new EndpointProbeResult(
+ route.Method,
+ route.SamplePath,
+ route.Source,
+ route.RequiresAuthorization,
+ ExpectedContract(route),
+ statusCode,
+ null,
+ passed);
+ }
+ catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
+ {
+ return new EndpointProbeResult(
+ route.Method,
+ route.SamplePath,
+ route.Source,
+ route.RequiresAuthorization,
+ ExpectedContract(route),
+ null,
+ ex.Message,
+ false);
+ }
+}
+
+static bool ResponseMatchesContract(EndpointContract route, HttpStatusCode statusCode)
+{
+ if (route.RequiresAuthorization)
+ return statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
+
+ return (int)statusCode < 500
+ && statusCode != HttpStatusCode.MethodNotAllowed;
+}
+
+static string ExpectedContract(EndpointContract route)
+{
+ return route.RequiresAuthorization
+ ? "unauthenticated protected endpoint should return 401 or 403"
+ : "public endpoint should return a non-5xx, non-405 response";
+}
+
+static HttpMethod ToHttpMethod(string method)
+{
+ return string.Equals(method, "ANY", StringComparison.OrdinalIgnoreCase)
+ ? HttpMethod.Get
+ : new HttpMethod(method);
+}
+
+static bool NeedsRequestBody(HttpMethod method)
+{
+ return method == HttpMethod.Post
+ || method == HttpMethod.Put
+ || method.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase);
+}
+
+static HttpContent CreateContent(EndpointContract route)
+{
+ var consumes = route.Consumes.Select(c => c.ToLowerInvariant()).ToArray();
+ if (consumes.Any(c => c.Contains("application/x-www-form-urlencoded")))
+ {
+ return new FormUrlEncodedContent(new Dictionary
+ {
+ ["grant_type"] = "password",
+ ["username"] = "endpoint-contract",
+ ["password"] = "endpoint-contract",
+ ["device_id"] = "endpoint-contract-device",
+ ["platform"] = "0",
+ ["platform_id"] = "endpoint-contract-platform",
+ });
+ }
+
+ if (consumes.Any(c => c.Contains("multipart/form-data")))
+ {
+ var content = new MultipartFormDataContent();
+ content.Add(new StringContent("endpoint-contract"), "name");
+ content.Add(new ByteArrayContent([]), "file", "endpoint-contract.bin");
+ return content;
+ }
+
+ if (consumes.Any(c => c.Contains("text/plain")))
+ return new StringContent("", Encoding.UTF8, "text/plain");
+
+ return new StringContent("{}", Encoding.UTF8, "application/json");
+}
+
+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" };
+
+ foreach (var httpMethod in attrMethods)
+ {
+ foreach (var controllerTemplate in controllerRouteTemplates)
+ {
+ var template = CombineTemplates(controllerTemplate, attr.Template);
+ template = ReplaceRouteTokens(template, controllerName, method.Name);
+ yield return new EndpointContract(
+ httpMethod.ToUpperInvariant(),
+ template,
+ ToSamplePath(template),
+ $"{controller.FullName}.{method.Name}",
+ RequiresAuthorization(controller, method),
+ ConsumesContentTypes(controller, method));
+ }
+ }
+ }
+ }
+ }
+}
+
+static bool RequiresAuthorization(Type controller, MethodInfo method)
+{
+ var attributes = controller.GetCustomAttributes(inherit: true)
+ .Concat(method.GetCustomAttributes(inherit: true))
+ .ToArray();
+
+ if (attributes.OfType().Any())
+ return false;
+
+ return attributes.OfType().Any();
+}
+
+static string[] ConsumesContentTypes(Type controller, MethodInfo method)
+{
+ var consumes = method.GetCustomAttributes(inherit: true)
+ .Concat(controller.GetCustomAttributes(inherit: true))
+ .OfType()
+ .SelectMany(a => a.ContentTypes)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ return consumes.Length == 0
+ ? ["application/json"]
+ : consumes;
+}
+
+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 name = match.Groups["name"].Value.ToLowerInvariant();
+ var constraint = match.Groups["constraint"].Value.ToLowerInvariant();
+
+ if (constraint.Contains("long") || constraint.Contains("int") || name.EndsWith("id"))
+ return "1";
+ if (constraint.Contains("bool"))
+ return "true";
+ if (constraint.Contains("guid"))
+ return "00000000-0000-0000-0000-000000000001";
+ if (name.Contains("roomname"))
+ return "DormRoom";
+ if (name.Contains("locale"))
+ return "en";
+ return "sample";
+ });
+
+ path = Regex.Replace(path, "/+", "/");
+ return path.StartsWith('/') ? path : "/" + path;
+}
+
+static Assembly LoadServerAssembly(string? configuredPath)
+{
+ if (!string.IsNullOrWhiteSpace(configuredPath))
+ return LoadAssemblyFromPath(Path.GetFullPath(configuredPath));
+
+ 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 --server-assembly.",
+ string.Join(Environment.NewLine, candidates));
+ }
+
+ if (existingCandidates.Length > 1)
+ {
+ throw new InvalidOperationException(
+ "Multiple DorkNet.Server assemblies were found. Pass --server-assembly so endpoint checks validate 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 endpoint contract tool output path.");
+}
+
+static void WriteReport(
+ string reportPath,
+ Assembly serverAssembly,
+ int routeCount,
+ IReadOnlyCollection results,
+ IReadOnlyCollection failures)
+{
+ var report = new EndpointContractReport(
+ serverAssembly.Location,
+ File.GetLastWriteTimeUtc(serverAssembly.Location),
+ routeCount,
+ results.Count(r => r.Passed),
+ results.Count(r => !r.Passed),
+ failures,
+ results);
+
+ var fullPath = Path.GetFullPath(reportPath);
+ Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
+ File.WriteAllText(fullPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
+}
+
+internal sealed record EndpointContract(
+ string Method,
+ string Template,
+ string SamplePath,
+ string Source,
+ bool RequiresAuthorization,
+ string[] Consumes);
+
+internal sealed record EndpointProbeResult(
+ string Method,
+ string Path,
+ string Source,
+ bool RequiresAuthorization,
+ string Expected,
+ int? StatusCode,
+ string? Error,
+ bool Passed)
+{
+ public string Observed => StatusCode is int code ? code.ToString() : Error ?? "no response";
+}
+
+internal sealed record EndpointContractReport(
+ string ServerAssembly,
+ DateTime ServerAssemblyTimestampUtc,
+ int RouteCount,
+ int Passed,
+ int Failed,
+ IReadOnlyCollection Failures,
+ IReadOnlyCollection Results);
+
+internal sealed class EndpointContractOptions
+{
+ public string? ServerAssemblyPath { get; private init; }
+ public string? BaseUrl { get; private init; }
+ public string? ReportPath { get; private init; }
+ public string VersionHeader { get; private init; } = "december_2020_12_18";
+ public int TimeoutSeconds { get; private init; } = 10;
+
+ public static EndpointContractOptions Parse(string[] args)
+ {
+ string? serverAssemblyPath = null;
+ string? baseUrl = null;
+ string? reportPath = null;
+ var versionHeader = "december_2020_12_18";
+ var timeoutSeconds = 10;
+
+ for (var i = 0; i < args.Length; i++)
+ {
+ var arg = args[i];
+ switch (arg)
+ {
+ case "--server-assembly":
+ serverAssemblyPath = NextValue(args, ref i, arg);
+ break;
+ case "--base-url":
+ baseUrl = NextValue(args, ref i, arg);
+ break;
+ case "--report":
+ reportPath = NextValue(args, ref i, arg);
+ break;
+ case "--version":
+ versionHeader = NextValue(args, ref i, arg);
+ break;
+ case "--timeout-seconds":
+ timeoutSeconds = int.Parse(NextValue(args, ref i, arg));
+ break;
+ case "--help":
+ case "-h":
+ Console.WriteLine("Usage: DorkNet.EndpointContract [--server-assembly PATH] [--base-url URL] [--report PATH]");
+ Environment.Exit(0);
+ break;
+ default:
+ throw new ArgumentException($"Unknown argument: {arg}");
+ }
+ }
+
+ return new EndpointContractOptions
+ {
+ ServerAssemblyPath = serverAssemblyPath,
+ BaseUrl = baseUrl,
+ ReportPath = reportPath,
+ VersionHeader = versionHeader,
+ TimeoutSeconds = timeoutSeconds,
+ };
+ }
+
+ private static string NextValue(string[] args, ref int index, string option)
+ {
+ if (index + 1 >= args.Length)
+ throw new ArgumentException($"{option} requires a value.");
+
+ index++;
+ return args[index];
+ }
+}
+
+partial class Program
+{
+ [GeneratedRegex(@"\{[*]?(?[^}:=\?]+)(?:[^}=\?]+)?(?\?)?(?=[^}]+)?\}")]
+ private static partial Regex RouteParameterRegex();
+}