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(); +}