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
12 changes: 10 additions & 2 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,16 @@ jobs:
snapshot = json.load(handle)
assert snapshot['framework'] == 'aspnetcore'
assert snapshot['runtime']['name'] == 'dotnet'
assert snapshot['summary'] == {'documented': 1, 'runtime': 2, 'matched': 1, 'runtimeOnly': 1, 'documentedOnly': 0}
assert snapshot['runtimeOnly'] == [{'method': 'POST', 'path': '/internal'}]
assert snapshot['summary'] == {'documented': 1, 'runtime': 3, 'matched': 1, 'runtimeOnly': 2, 'documentedOnly': 0}
assert {(item['method'], item['path']) for item in snapshot['runtimeOnly']} == {('GET', '/internal/health'), ('POST', '/internal/reindex')}
validation = snapshot['validation']
assert validation['status'] == 'fail'
assert validation['summary'] == {'total': 2, 'errors': 1, 'warnings': 0, 'info': 1}
assert validation['findings'][0]['severity'] == 'error'
assert validation['findings'][0]['location']['method'] == 'POST'
assert validation['findings'][0]['location']['path'] == '/internal/reindex'
assert validation['findings'][1]['disposition'] == 'acknowledged'
assert validation['findings'][1]['location']['path'] == '/internal/health'
PY
test "$(curl --silent --output /dev/null --write-out '%{http_code}' -X POST http://127.0.0.1:5087/docs/__flexdoc/runtime)" = "405"

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Notable FlexDoc product releases are recorded here. Detailed release notes remai

## Unreleased

- Spring MVC and ASP.NET Core emit the same runtime-contract validation object as Node. A complete-discovery undocumented route fails `flexdoc validate` by method and path. Acknowledgement keeps that route registered as informational and does not remove it from the runtime record. Snapshot membership uses the same wire identity as validation, so differing path-parameter names are one operation.

## 3.5.0 — Node disagreement loop

`@prauga/flexdoc-client` and `@prauga/flexdoc-backend` **3.5.0**, with `@prauga/flexdoc-cli` **0.8.0**. Native adapter versions are unchanged.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
using System.Text.RegularExpressions;

namespace Prauga.FlexDoc.AspNetCore;

/// <summary>
/// Ports the Node runtime-contract validator. Path-parameter names are ignored for route identity.
/// Acknowledged undocumented routes stay registered and are reported as informational findings.
/// </summary>
internal static class AspNetContractValidation
{
internal static Dictionary<string, object?> Validate(
IReadOnlyList<AspNetRuntimeIntelligence.RuntimeRoute> documentedRoutes,
IReadOnlyList<AspNetRuntimeIntelligence.RuntimeRoute> runtimeRoutes,
bool discoveryComplete,
IReadOnlyList<AspNetRuntimeIntelligence.RuntimeRoute> acknowledgedUndocumented)
{
var documentedKeys = documentedRoutes.Select(ExactShapeKey).ToHashSet(StringComparer.Ordinal);
var runtimeKeys = runtimeRoutes.Select(ExactShapeKey).ToHashSet(StringComparer.Ordinal);
var acknowledgedKeys = acknowledgedUndocumented.Select(ExactShapeKey).ToHashSet(StringComparer.Ordinal);
var findings = new List<Dictionary<string, object?>>();
var documentedMethods = GroupedMethods(documentedRoutes);
var runtimeMethods = GroupedMethods(runtimeRoutes);
var methodMismatchShapes = new HashSet<string>(StringComparer.Ordinal);

foreach (var (shape, expectedMethods) in documentedMethods)
{
if (!runtimeMethods.TryGetValue(shape, out var observedMethods)) continue;
if (expectedMethods.Any(observedMethods.Contains)) continue;
methodMismatchShapes.Add(shape);
var path = RepresentativePath(documentedRoutes, shape);
findings.Add(Finding(
"runtime.method-mismatch",
null,
path,
discoveryComplete ? "error" : "warning",
expectedMethods[0],
$"Runtime route {path} is registered for different HTTP methods than OpenAPI documents.",
expectedMethods,
observedMethods,
null));
}

foreach (var route in runtimeRoutes)
{
var shape = RouteShape(route.Path);
if (methodMismatchShapes.Contains(shape) || documentedKeys.Contains(ExactShapeKey(route))) continue;
var acknowledged = acknowledgedKeys.Contains(ExactShapeKey(route));
findings.Add(Finding(
"runtime.operation-undocumented",
route.Method,
route.Path,
acknowledged ? "info" : discoveryComplete ? "error" : "warning",
route.Method,
$"Runtime implements {route.Method} {route.Path}, but OpenAPI does not document that operation.",
"Operation is represented in OpenAPI",
acknowledged
? "Operation exists only in the running backend and is acknowledged"
: "Operation exists only in the running backend",
acknowledged ? "acknowledged" : null));
}

foreach (var route in documentedRoutes)
{
var shape = RouteShape(route.Path);
if (methodMismatchShapes.Contains(shape) || runtimeKeys.Contains(ExactShapeKey(route))) continue;
findings.Add(Finding(
"runtime.operation-unobserved",
route.Method,
route.Path,
discoveryComplete ? "error" : "info",
route.Method,
discoveryComplete
? $"OpenAPI documents {route.Method} {route.Path}, but the running backend does not expose that operation."
: $"OpenAPI documents {route.Method} {route.Path}, but it was not observed during partial runtime discovery.",
"Operation is exposed by the running backend",
discoveryComplete ? "No matching runtime operation exists" : "No matching operation was observed during partial discovery",
null));
}

findings.Sort(static (left, right) =>
{
var severity = SeverityRank(Text(left, "severity")).CompareTo(SeverityRank(Text(right, "severity")));
if (severity != 0) return severity;
var path = string.Compare(Location(left, "path"), Location(right, "path"), StringComparison.Ordinal);
if (path != 0) return path;
var method = string.Compare(Location(left, "method"), Location(right, "method"), StringComparison.Ordinal);
if (method != 0) return method;
return string.Compare(Text(left, "code"), Text(right, "code"), StringComparison.Ordinal);
});

var errors = findings.Count(finding => Text(finding, "severity") == "error");
var warnings = findings.Count(finding => Text(finding, "severity") == "warning");
var info = findings.Count - errors - warnings;
var status = errors > 0 ? "fail" : warnings > 0 ? "warn" : !discoveryComplete ? "partial" : "pass";
return new Dictionary<string, object?>
{
["status"] = status,
["complete"] = discoveryComplete,
["findings"] = findings,
["summary"] = new Dictionary<string, object?>
{
["total"] = findings.Count,
["errors"] = errors,
["warnings"] = warnings,
["info"] = info,
},
};
}

private static Dictionary<string, object?> Finding(
string code,
string? idMethod,
string path,
string severity,
string? locationMethod,
string message,
object expected,
object observed,
string? disposition)
{
var location = new Dictionary<string, object?> { ["kind"] = "operation", ["path"] = path };
if (locationMethod is not null) location["method"] = locationMethod;
var finding = new Dictionary<string, object?>
{
["id"] = $"{code}:{(idMethod is null ? "" : idMethod + ":")}{RouteShape(path)}",
["code"] = code,
["severity"] = severity,
["location"] = location,
["message"] = message,
["expected"] = expected,
["observed"] = observed,
};
if (disposition is not null) finding["disposition"] = disposition;
return finding;
}

private static string Text(Dictionary<string, object?> finding, string key) => (string)finding[key]!;

private static string Location(Dictionary<string, object?> finding, string key)
{
var location = (Dictionary<string, object?>)finding["location"]!;
return location.TryGetValue(key, out var value) && value is string text ? text : "";
}

private static int SeverityRank(string severity) => severity switch
{
"error" => 0,
"warning" => 1,
_ => 2,
};

private static Dictionary<string, List<string>> GroupedMethods(IReadOnlyList<AspNetRuntimeIntelligence.RuntimeRoute> routes)
{
var grouped = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
foreach (var route in routes)
{
if (!grouped.TryGetValue(RouteShape(route.Path), out var methods))
{
methods = new SortedSet<string>(StringComparer.Ordinal);
grouped[RouteShape(route.Path)] = methods;
}
methods.Add(route.Method.ToUpperInvariant());
}
return grouped.ToDictionary(
entry => entry.Key,
entry => entry.Value.ToList(),
StringComparer.Ordinal);
}

private static string RepresentativePath(IReadOnlyList<AspNetRuntimeIntelligence.RuntimeRoute> routes, string shape)
{
foreach (var route in routes)
{
if (RouteShape(route.Path) == shape) return route.Path;
}
return shape;
}

private static string ExactShapeKey(AspNetRuntimeIntelligence.RuntimeRoute route)
=> $"{route.Method.ToUpperInvariant()} {RouteShape(route.Path)}";

private static string RouteShape(string path) => Regex.Replace(path, @"\{[^/{}]+\}", "{}");
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,16 @@ internal static JsonElement OpenApiDocumentElement(object value)
JsonElement openApiDocument,
HttpContext context,
string docsPath,
string specUrl)
string specUrl,
IReadOnlyList<RuntimeRoute>? acknowledgedUndocumented = null)
{
var discovery = DiscoverRoutes(endpoints, docsPath, specUrl);
var documented = DocumentedRoutes(openApiDocument);
var documentedKeys = documented.Select(RouteKey).ToHashSet(StringComparer.Ordinal);
var runtimeKeys = discovery.Routes.Select(RouteKey).ToHashSet(StringComparer.Ordinal);
var runtimeOnly = discovery.Routes.Where(route => !documentedKeys.Contains(RouteKey(route))).ToArray();
var documentedOnly = documented.Where(route => !runtimeKeys.Contains(RouteKey(route))).ToArray();
var matched = discovery.Routes.Count(route => documentedKeys.Contains(RouteKey(route)));
var documentedKeys = documented.Select(ShapeKey).ToHashSet(StringComparer.Ordinal);
var runtimeKeys = discovery.Routes.Select(ShapeKey).ToHashSet(StringComparer.Ordinal);
var runtimeOnly = discovery.Routes.Where(route => !documentedKeys.Contains(ShapeKey(route))).ToArray();
var documentedOnly = documented.Where(route => !runtimeKeys.Contains(ShapeKey(route))).ToArray();
var matched = discovery.Routes.Count(route => documentedKeys.Contains(ShapeKey(route)));
var frameworkVersion = typeof(RouteEndpoint).Assembly.GetName().Version?.ToString();

var snapshot = new Dictionary<string, object?>
Expand Down Expand Up @@ -76,9 +77,28 @@ internal static JsonElement OpenApiDocumentElement(object value)
var environmentName = StandardEnvironmentName();
if (environmentName is not null)
snapshot["environment"] = new Dictionary<string, object?> { ["name"] = environmentName };
snapshot["validation"] = AspNetContractValidation.Validate(
documented,
discovery.Routes,
discovery.Complete,
acknowledgedUndocumented ?? Array.Empty<RuntimeRoute>());
return snapshot;
}

internal static IReadOnlyList<RuntimeRoute> NormalizeAcknowledged(IEnumerable<FlexDocAcknowledgedRoute>? configured)
{
if (configured is null) return Array.Empty<RuntimeRoute>();
var routes = new List<RuntimeRoute>();
foreach (var route in configured)
{
if (route is null || string.IsNullOrWhiteSpace(route.Method) || string.IsNullOrWhiteSpace(route.Path)) continue;
var method = route.Method.Trim().ToUpperInvariant();
if (!HttpMethods.Contains(method)) continue;
routes.Add(new RuntimeRoute(method, NormalizePath(route.Path)));
}
return UniqueSorted(routes);
}

internal static (IReadOnlyList<RuntimeRoute> Routes, bool Complete) DiscoverRoutes(
IEnumerable<Endpoint> endpoints,
string docsPath,
Expand Down Expand Up @@ -192,6 +212,10 @@ private static IReadOnlyList<RuntimeRoute> WithoutImplicitHead(IReadOnlyList<Run

private static string RouteKey(RuntimeRoute route) => $"{route.Method} {route.Path}";

/// <summary>Wire identity. Parameter names such as <c>{id}</c> and <c>{petId}</c> describe the same operation.</summary>
private static string ShapeKey(RuntimeRoute route)
=> $"{route.Method} {System.Text.RegularExpressions.Regex.Replace(route.Path, @"\{[^/{}]+\}", "{}")}";

private static Dictionary<string, string> RouteObject(RuntimeRoute route) => new()
{
["method"] = route.Method,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,16 @@ public static RouteGroupBuilder MapFlexDoc(
// route; reverse proxies and application middleware may still enforce less.
.WithMetadata(new DisableRequestSizeLimitAttribute());
if (runtimeDocument is JsonElement document)
{
var acknowledged = AspNetRuntimeIntelligence.NormalizeAcknowledged(options.AcknowledgedUndocumented);
group.MapGet("/__flexdoc/runtime", context => WriteRuntime(
context,
endpoints,
document,
path,
options.SpecUrl));
options.SpecUrl,
acknowledged));
}
return group;
}

Expand Down Expand Up @@ -93,7 +97,8 @@ private static async Task WriteRuntime(
IEndpointRouteBuilder endpoints,
JsonElement openApiDocument,
string path,
string specUrl)
string specUrl,
IReadOnlyList<AspNetRuntimeIntelligence.RuntimeRoute> acknowledgedUndocumented)
{
try
{
Expand All @@ -102,7 +107,8 @@ private static async Task WriteRuntime(
openApiDocument,
context,
path,
specUrl);
specUrl,
acknowledgedUndocumented);
var body = JsonSerializer.SerializeToUtf8Bytes(snapshot);
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = "application/json; charset=utf-8";
Expand Down
15 changes: 14 additions & 1 deletion adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/FlexDocOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,25 @@ public FlexDocOptions() { }
/// <summary>Native ASP.NET Core executor. A null executor keeps host execution unavailable and the execute route unregistered.</summary>
public FlexDocHostExecution? HostExecution { get; set; }

/// <summary>Explicit opt-in for live ASP.NET Core route discovery and OpenAPI presence drift.</summary>
/// <summary>Explicit opt-in for live ASP.NET Core route discovery and the same contract-validation object Node emits.</summary>
public bool RuntimeIntelligence { get; set; }

/// <summary>Runtime routes accepted as intentionally absent from OpenAPI. They stay registered.</summary>
public IReadOnlyList<FlexDocAcknowledgedRoute> AcknowledgedUndocumented { get; set; } = Array.Empty<FlexDocAcknowledgedRoute>();

/// <summary>
/// Server-only OpenAPI document used for Runtime Intelligence comparison. Required when RuntimeIntelligence is enabled.
/// Accepts a serializable OpenAPI object, JsonElement/JsonDocument, or JSON string and is never sent to the renderer.
/// </summary>
public object? RuntimeOpenApiDocument { get; set; }
}

/// <summary>One runtime route the operator has accepted as undocumented.</summary>
public sealed class FlexDocAcknowledgedRoute
{
/// <summary>HTTP method.</summary>
public string Method { get; set; } = "";

/// <summary>Route path.</summary>
public string Path { get; set; } = "";
}
Loading
Loading