asp.netrestnestedopenapiasp.net-mvc-routing

ASP.NET: route matching template for traversing a tree structure


I have a tree structure of nodes, where each node has 0-n subnodes. Each node has a set of properties. I would like to expose that structure in a webservice.

let's say my route template for accessing a node is

[HttpGet("nodes/{nodeId:int}")]

and for accessing it's subnodes, its

[HttpGet("nodes/{nodeId:int}/nodes")]

What i'd love is a way to specify a route template that matches the {nodeId:int}/nodes part 1-n times, and maps to an action parameter of type IEnumerable<int>

I know i can use a catch-all template ({**path}) and do the parsing myself, but i'm wondering if there's a better way.

I'm open to other suggestions on how to expose this structure in a RESTful way. Important: those nodeIds are not unique across the service. Just within the parent.

Bonus question: Is there something in the OpenAPI standard that handles paths like that? After all, i'd like to describe the endpoint in a way that might even be understood by tooling, client generation, etc.


Solution

  • The only solution that comes to my mind is to write custom model binder. And I don't think ASP.NET supports dynamic paths, like want, to match any number pairs "{nodeId}/nodes".

    Keeping all that in mind, I would write custom model binder, that would also validate the path. Here's implementation:

    public class NodePathBinder : IModelBinder
    {
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            var value = bindingContext.ValueProvider.GetValue("nodePath").FirstValue;
            if (string.IsNullOrEmpty(value))
            {
                bindingContext.Result = ModelBindingResult.Success(new List<int>());
                return Task.CompletedTask;
            }
    
            var segments = value.Split('/', StringSplitOptions.RemoveEmptyEntries);
    
            var nodesPartsValid = segments
                .Where((s, i) => i % 2 == 1) // validate the "nodes" parts
                .All(x => x == "nodes");
    
            if(!nodesPartsValid || !TryGetIds(segments, out var ids))
            {
                throw new InvalidOperationException();
            }
    
            bindingContext.Result = ModelBindingResult.Success(ids);
            return Task.CompletedTask;
        }
    
        private static bool TryGetIds(string[] segments, out List<int> ids)
        {
            try
            {
                ids = segments
                    .Where((s, i) => i % 2 == 0) // filter out the "nodes"
                    .Select(int.Parse)
                    .ToList();
                return true;
            }
            catch
            {
                ids = new List<int>(0);
                return false;
            }
        }
    }
    

    Then you would need to define attribute to use in endpoint definitions:

    public class FromNodePathAttribute : ModelBinderAttribute
    {
        public FromNodePathAttribute() : base(typeof(NodePathBinder)) { }
    }
    

    And then you could use it in an endpoint, with following path definition:

    [HttpGet("/nodetest/{*nodePath}")]
    public IActionResult MyAction([FromNodePath] List<int> nodePath)
    {
        return Ok(nodePath);
    }