In this series, I've been laying the groundwork for building a custom endpoint visualization graph, as I showed in my first post. This graph shows the different parts of the endpoint routes: literal values, parameters, verb constraints, and endpoints that generate a result:
In this post I show how you can create an endpoint graph like this for your own application, by creating a custom DfaGraphWriter
.
This post uses techniques and classes from the previous posts in the series, so I strongly suggest reading those before continuing.
Adding configuration for the endpoint graph
The first thing we'll look at is how to configure what the final endpoint graph will look like. We'll add configuration for two types of node and four types of edge. The edges are:
- Literal edges: Literal matches for route sections, such as
api
andvalues
in the routeapi/values/{id}
. - Parameters edges: Parameterised sections for routes, such as
{id}
in the routeapi/values/{id}
. - Catch all edges: Edges that correspond to the catch-all route parameter, such as
{**slug}
. - Policy edges: Edges that correspond to a constraint other than the URL. For example, the HTTP verb-based edges in the graph, such as
HTTP: GET
.
and the nodes are:
- Matching node: A node that is associated with an endpoint match, so will generate a response.
- Default node: A node that is not associated with an endpoint match.
Each of these nodes and edges can have any number of Graphviz attributes to control their display. The GraphDisplayOptions
below show the default values I used to generate the graph at the start of this post:
public class GraphDisplayOptions
{
/// <summary>
/// Additional display options for literal edges
/// </summary>
public string LiteralEdge { get; set; } = string.Empty;
/// <summary>
/// Additional display options for parameter edges
/// </summary>
public string ParametersEdge { get; set; } = "arrowhead=diamond color=\"blue\"";
/// <summary>
/// Additional display options for catchall parameter edges
/// </summary>
public string CatchAllEdge { get; set; } = "arrowhead=odot color=\"green\"";
/// <summary>
/// Additional display options for policy edges
/// </summary>
public string PolicyEdge { get; set; } = "color=\"red\" style=dashed arrowhead=open";
/// <summary>
/// Additional display options for node which contains a match
/// </summary>
public string MatchingNode { get; set; } = "shape=box style=filled color=\"brown\" fontcolor=\"white\"";
/// <summary>
/// Additional display options for node without matches
/// </summary>
public string DefaultNode { get; set; } = string.Empty;
}
We can now create our custom graph writer using this object to control the display, and using the ImpromptuInterface "proxy" technique shown in the previous post.
Creating a custom DfaGraphWriter
Our custom graph writer (cunningly called CustomDfaGraphWriter
) is heavily based on the DfaGraphWriter
included in ASP.NET Core. The bulk of this class is the same as the original, with the following changes:
- Inject the
GraphDisplayOptions
into the class to customise the display. - Use the ImpromptuInterface library to work with the internal
DfaMatcherBuilder
andDfaNode
classes, as shown in the previous post. - Customise the
WriteNode
function to use our custom styles. - Add a
Visit
function to work with theIDfaNode
interface, instead of using theVisit()
method on the internalDfaNode
class.
The whole CustomDfaGraphWriter
is shown below, focusing on the main Write()
function. I've kept the implementation almost identical to the original, only updating the parts we have to.
public class CustomDfaGraphWriter
{
// Inject the GraphDisplayOptions
private readonly IServiceProvider _services;
private readonly GraphDisplayOptions _options;
public CustomDfaGraphWriter(IServiceProvider services, GraphDisplayOptions options)
{
_services = services;
_options = options;
}
public void Write(EndpointDataSource dataSource, TextWriter writer)
{
// Use ImpromptuInterface to create the required dependencies as shown in previous post
Type matcherBuilder = typeof(IEndpointSelectorPolicy).Assembly
.GetType("Microsoft.AspNetCore.Routing.Matching.DfaMatcherBuilder");
// Build the list of endpoints used to build the graph
var rawBuilder = _services.GetRequiredService(matcherBuilder);
IDfaMatcherBuilder builder = rawBuilder.ActLike<IDfaMatcherBuilder>();
// This is the same logic as the original graph writer
var endpoints = dataSource.Endpoints;
for (var i = 0; i < endpoints.Count; i++)
{
if (endpoints[i] is RouteEndpoint endpoint && (endpoint.Metadata.GetMetadata<ISuppressMatchingMetadata>()?.SuppressMatching ?? false) == false)
{
builder.AddEndpoint(endpoint);
}
}
// Build the raw tree from the registered routes
var rawTree = builder.BuildDfaTree(includeLabel: true);
IDfaNode tree = rawTree.ActLike<IDfaNode>();
// Store a list of nodes that have already been visited
var visited = new Dictionary<IDfaNode, int>();
// Build the graph by visiting each node, and calling WriteNode on each
writer.WriteLine("digraph DFA {");
Visit(tree, WriteNode);
writer.WriteLine("}");
void WriteNode(IDfaNode node)
{
/* Write the node to the TextWriter */
/* Details shown later in this post*/
}
}
static void Visit(IDfaNode node, Action<IDfaNode> visitor)
{
/* Recursively visit each node in the tree. */
/* Details shown later in this post*/
}
}
I've elided the Visit
and WriteNode
functions here for brevity, but we'll look into them soon. We'll start with the Visit
function, as that flies closest to the original.
Updating the Visit function to work with IDfaNode
As I discussed in my previous post, one of the biggest problems creating a custom DfaGraphWriter
is its use of internal
classes. To work around that I used ImpromptuInterface to create proxy objects that wrap the original:
The original Visit()
method is a method on the DfaNode
class. It recursively visits every node in the endpoint tree, calling a provided Action<>
function for each node.
As
DfaNode
isinternal
, I implemented theVisit
function as a static method onCustomDfaGraphWriter
instead.
Our custom implementation is broadly the same as the original, but we have to do some somewhat arduous conversions between the "raw" DfaNode
s and our IDfaNode
proxies. The updated method is shown below. The method takes two parameters—the node being checked, and an Action<>
to run on each.
static void Visit(IDfaNode node, Action<IDfaNode> visitor)
{
// Does the node of interest have any nodes connected by literal edges?
if (node.Literals?.Values != null)
{
// node.Literals is actually a Dictionary<string, DfaNode>
foreach (var dictValue in node.Literals.Values)
{
// Create a proxy for the child DfaNode node and visit it
IDfaNode value = dictValue.ActLike<IDfaNode>();
Visit(value, visitor);
}
}
// Does the node have a node connected by a parameter edge?
// The reference check breaks any cycles in the graph
if (node.Parameters != null && !ReferenceEquals(node, node.Parameters))
{
// Create a proxy for the DfaNode node and visit it
IDfaNode parameters = node.Parameters.ActLike<IDfaNode>();
Visit(parameters, visitor);
}
// Does the node have a node connected by a catch-all edge?
// The refernece check breaks any cycles in the graph
if (node.CatchAll != null && !ReferenceEquals(node, node.CatchAll))
{
// Create a proxy for the DfaNode node and visit it
IDfaNode catchAll = node.CatchAll.ActLike<IDfaNode>();
Visit(catchAll, visitor);
}
// Does the node have a node connected by a policy edges?
if (node.PolicyEdges?.Values != null)
{
// node.PolicyEdges is actually a Dictionary<object, DfaNode>
foreach (var dictValue in node.PolicyEdges.Values)
{
IDfaNode value = dictValue.ActLike<IDfaNode>();
Visit(value, visitor);
}
}
// Write the node using the provided Action<>
visitor(node);
}
The Visit
function uses a post-order traversal, so it traverses "deep" into a node's child nodes first before writing the node using the visitor
function. This is the same as the original DfaNode.Visit()
function.
We're almost there now. We have a class that builds the endpoint node tree, traverses all the nodes in the tree, and runs a function for each. All that remains is to define the visitor function, WriteNode()
.
Defining a custom WriteNode function
We've finally got to the meaty part, controlling how the endpoint graph is displayed. All of the customisation and effort so far has been to enable us to customise the WriteNode
function.
WriteNode()
is a local function that writes a node to the TextWriter
output, along with any connected edges, using the DOT graph description language.
Our custom WriteNode()
function is, again, almost the same as the original. There are two main differences:
- The original graph writer works with
DfaNode
s, we have to convert to using theIDfaNode
proxy. - The original graph writer uses the same styling for all nodes and edges. We customise the display of nodes and edges based on the configured
GraphDisplayOptions
.
As
WriteNode
is a local function, it can access variables from the enclosing function. This includes thewriter
parameter, used to write the graph to output and thevisited
dictionary of previously written nodes.
The following shows our (heavily commented) custom version of the WriteNode()
method.
void WriteNode(IDfaNode node)
{
// add the node to the visited node dictionary if it isn't already
// generate a zero-based integer label for the node
if (!visited.TryGetValue(node, out var label))
{
label = visited.Count;
visited.Add(node, label);
}
// We can safely index into visited because this is a post-order traversal,
// all of the children of this node are already in the dictionary.
// If this node is linked to any nodes by a literal edge
if (node.Literals != null)
{
foreach (DictionaryEntry dictEntry in node.Literals)
{
// Foreach linked node, get the label for the edge and the linked node
var edgeLabel = (string)dictEntry.Key;
IDfaNode value = dictEntry.Value.ActLike<IDfaNode>();
int nodeLabel = visited[value];
// Write an edge, including our custom styling for literal edges
writer.WriteLine($"{label} -> {nodeLabel} [label=\"/{edgeLabel}\" {_options.LiteralEdge}]");
}
}
// If this node is linked to a nodes by a parameter edge
if (node.Parameters != null)
{
IDfaNode parameters = node.Parameters.ActLike<IDfaNode>();
int nodeLabel = visited[catchAll];
// Write an edge labelled as /* using our custom styling for parameter edges
writer.WriteLine($"{label} -> {nodeLabel} [label=\"/**\" {_options.CatchAllEdge}]");
}
// If this node is linked to a catch-all edge
if (node.CatchAll != null && node.Parameters != node.CatchAll)
{
IDfaNode catchAll = node.CatchAll.ActLike<IDfaNode>();
int nodeLabel = visited[catchAll];
// Write an edge labelled as /** using our custom styling for catch-all edges
writer.WriteLine($"{label} -> {nodelLabel} [label=\"/**\" {_options.CatchAllEdge}]");
}
// If this node is linked to any Policy Edges
if (node.PolicyEdges != null)
{
foreach (DictionaryEntry dictEntry in node.PolicyEdges)
{
// Foreach linked node, get the label for the edge and the linked node
var edgeLabel = (object)dictEntry.Key;
IDfaNode value = dictEntry.Value.ActLike<IDfaNode>();
int nodeLabel = visited[value];
// Write an edge, including our custom styling for policy edges
writer.WriteLine($"{label} -> {nodeLabel} [label=\"{key}\" {_options.PolicyEdge}]");
}
}
// Does this node have any associated matches, indicating it generates a response?
var matchCount = node?.Matches?.Count ?? 0;
var extras = matchCount > 0
? _options.MatchingNode // If we have matches, use the styling for response-generating nodes...
: _options.DefaultNode; // ...otherwise use the default style
// Write the node to the graph output
writer.WriteLine($"{label} [label=\"{node.Label}\" {extras}]");
}
Tracing the flow of these interactions can be a little confusing, because of the way we write the nodes from the "leaf" nodes back to the root of the tree. For example if we look at the output for the basic app shown at the start of this post, you can see the "leaf" endpoints are all written first: the healthz
health check endpoint and the terminal match generating endpoints with the longest route:
digraph DFA {
1 [label="/healthz/" shape=box style=filled color="brown" fontcolor="white"]
2 [label="/api/Values/{...}/ HTTP: GET" shape=box style=filled color="brown" fontcolor="white"]
3 [label="/api/Values/{...}/ HTTP: PUT" shape=box style=filled color="brown" fontcolor="white"]
4 [label="/api/Values/{...}/ HTTP: DELETE" shape=box style=filled color="brown" fontcolor="white"]
5 [label="/api/Values/{...}/ HTTP: *" shape=box style=filled color="brown" fontcolor="white"]
6 -> 2 [label="HTTP: GET" color="red" style=dashed arrowhead=open]
6 -> 3 [label="HTTP: PUT" color="red" style=dashed arrowhead=open]
6 -> 4 [label="HTTP: DELETE" color="red" style=dashed arrowhead=open]
6 -> 5 [label="HTTP: *" color="red" style=dashed arrowhead=open]
6 [label="/api/Values/{...}/"]
7 [label="/api/Values/ HTTP: GET" shape=box style=filled color="brown" fontcolor="white"]
8 [label="/api/Values/ HTTP: POST" shape=box style=filled color="brown" fontcolor="white"]
9 [label="/api/Values/ HTTP: *" shape=box style=filled color="brown" fontcolor="white"]
10 -> 6 [label="/*" arrowhead=diamond color="blue"]
10 -> 7 [label="HTTP: GET" color="red" style=dashed arrowhead=open]
10 -> 8 [label="HTTP: POST" color="red" style=dashed arrowhead=open]
10 -> 9 [label="HTTP: *" color="red" style=dashed arrowhead=open]
10 [label="/api/Values/"]
11 -> 10 [label="/Values"]
11 [label="/api/"]
12 -> 1 [label="/healthz"]
12 -> 11 [label="/api"]
12 [label="/"]
}
Even though the leaf nodes are written to the graph output first, the Graphviz visualizer will generally draw the graph with the leaf nodes at the bottom, and the edges pointing down. You can visualize the graph online at https://dreampuf.github.io/GraphvizOnline/:
If you want to change how the graph is rendered you can customize the GraphDisplayOptions
. If you use the "test" approach I described in a previous post, you can pass these options in directly when generating the graph. If you're using the "middleware" approach, you can register the GraphDisplayOptions
using the IOptions<>
system instead, and control the display using the configuration system.
Summary
In this post I showed how to create a custom DfaGraphWriter
to control how an application's endpoint graph is generated. To interoperate with the internal
classes we used ImpromptuInterface, as described in the previous post, to create proxies we can interact with. We then had to write a custom Visit()
function to work with the IDfaNode
proxies. Finally, we created a custom WriteNode
function that uses custom settings defined in a GraphDisplayOptions
object to display each type of node and edge differently.
Top comments (0)