blog post image
Andrew Lock avatar

Andrew Lock

~9 min read

Creating a custom endpoint visualization graph

Visualizing ASP.NET Core 3.0 endpoints using GraphvizOnline - Part 4

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:

A ValuesController endpoint routing application with different styling

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 and values in the route api/values/{id}.
  • Parameters edges: Parameterised sections for routes, such as {id} in the route api/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 and DfaNode classes, as shown in the previous post.
  • Customise the WriteNode function to use our custom styles.
  • Add a Visit function to work with the IDfaNode interface, instead of using the Visit() method on the internal DfaNode 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:

Using ImpromptuInterface to add a wrapper proxy

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 is internal, I implemented the Visit function as a static method on CustomDfaGraphWriter instead.

Our custom implementation is broadly the same as the original, but we have to do some somewhat arduous conversions between the "raw" DfaNodes 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 DfaNodes, we have to convert to using the IDfaNode 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 the writer parameter, used to write the graph to output and the visited 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/:

A ValuesController endpoint routing application with different styling

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.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?