Expand implementation and view model mapping

What is Expand

Round-trips are the death of performance in many cases, and this is no different for API's.

The web does scale out well - so there is certainly the option to make lots of simultaneous requests, but this does not take care of the problem of addressing those related resources - if the you need to fetch back a resource's representation before you can construct additional requests to fetch other resources, you still are faced with the issues of latency.

OData provides a mechanism for implementing this via a URL containing the $expand query parameter. API's implemented for products such as Atlassians Jira (popular defect tracker) include an "expand" parameter which achieves the same thing, but uses a slightly different approach.

The API being presented here is not an OData compliant service - but certainly the Expand concept was a useful one we wanted to adopt.

Deep expansion

In addition to single-level expansion:

GET /api//scriptpackage/{id}?$expand=Children

Which might return:

{
"Id": "1911ea14-3ede-46ca-bb1b-a0a80019f6cf",
"ProjectId": "53cc97cd-7514-4465-b352-a0a80019f180",
"Name": "Script Library",
"OrderNumber": 2,
"Expands": [
"Children",
"Parent",
"Project",
"Scripts"
],
"Self": "https://localhost/api/scriptpackage/1911ea14-3ede-46ca-bb1b-a0a80019f6cf"
}

We wanted to support deeper expansion - so that something like:

GET /api/project/{id}/scriptpackages?$expand=Children.Children,Children.Scripts

Would return a script package (folder) with all it's child packages, those child packages children and those child packages scripts (test cases).

{
"Id": "1911ea14-3ede-46ca-bb1b-a0a80019f6cf",
"ProjectId": "53cc97cd-7514-4465-b352-a0a80019f180",
"Name": "Script Library",
"OrderNumber": 2,
"Expands": [
"Parent",
"Project",
"Scripts"
],
"Children": [
{
"Id": "1fe2686a-eb17-485a-96b4-a0a80019f6cf",
"ParentId": "1911ea14-3ede-46ca-bb1b-a0a80019f6cf",
"Name": "Sprint 1",
"OrderNumber": 0,
"Expands": [
"Parent",
"Project"
],
"Scripts": [...],
"Children":[...],
...
},
...
}

Notice that we advertise the available expansions as a property of the resource - this is a feature of the Atlassian Jira API we adopted (and this list changes based on what expansions have already been applied).

Building a mapper

To allow expansion to be done correctly and at any depth, we needed to hand over construction of our view models to a third party - thus enters the view model mapper:

public interface IViewModelMapper
{
void RegisterSearchResultConstructor(
Func intermediateConstructor)
where TFrom : class
where TTo : AbstractModel
where TIntermediate : class;
void RegisterDefaultConstructors(Type[] from, Type to);
void RegisterDefaultConstructor();
void RegisterConstructor(Func constructor);
void RemoveConstructor();
void RegisterExpander(string expansionName, string resourceName,
Func expansion);
void RegisterExpander(Type fromType, Type toType, string expansionName,
string resourceName, Func expansion);
void RegisterEntityTypeAndIdToResourceResolver(Func urlFunc,
params string[] entityTypes);
void RemoveExpand(string expansionName);
TTo Map(TFrom from, params string[] expansions)
where TTo : AbstractModel;
object MapSearchResult(object from, string[] expansions);
object Map(object instance, Type targetType, params string[] expansions);
IEnumerable GetExpandersFor(Type fromType, Type toType);
string ResolveUrlForEntityTypeResource(string entityType, Guid id);
}

The implementation of this interface comprises a service where you can register:

  • Constructors - which are able to take a DTO/domain class/Tuple/whatever and construct a view model from it.
  • Expanders - a named expansion attached to a constructor
  • Map methods for mapping an instance to a view model, with a set of expansions to apply
  • Handling of special cases such as translating the results of a search to a suitable form for then translating into a view model

Given the plugin architecture used within the application, this provided the ability for plugins to add new Expand options to existing resources - so for example if a customer has the automated testing plugin enabled, then the script packages (folder) will also support expansions for the "AutomatedTests" collection of automated tests within that package.

As an example how we register an expander - here is code to register the expansion for a collection of steps associated with a script.

...

mapper.RegisterExpander(
"Steps", null, (input, expands) => RenderSteps(expands, input));
}

IList RenderSteps(string[] expands, EditScriptDto input)
{
ExpandsUtility.AssertEmpty("Steps", expands);

return (input.Steps ?? Enumerable.Empty())
.Select(StepModel.CreateFrom)
.OrderBy(model => model.OrderNumber).ToList();
}

In this case we are using Expand to avoid the cost of expanding a large collection (the steps for a testscript/test case) which is part of the Script aggregate (believe it or not, there are testers out there writing tests scripts with 300+ steps...).

Controllers

Within our API controllers we just call the mapping method and pass in the expands parameter:

var script = _scriptReportingService.GetScript(id);
var wrapped = _viewModelMapper.Map(script, Expands);
return Request.CreateResponse(HttpStatusCode.OK, wrapped);

The Expands property in this case just exposed a property associated with the current request.

protected virtual string[] Expands
{
get { return (string[]) (Request.Properties["expand"] ?? new string[] {}); }
}

And this request property was captured by a simple DelegatingHandler that would parse the query string for various OData parameters - this approach made it a bit easier for other delegating handlers to have access to this information prior to the controller's methods being invoked.

OData support in ASP.Net Web API

For those who have been working with the various releases of ASP.Net WEB API since it was originally targeting WCF, there have been quite a few breaking changes along the way, including OData - which was introduced initially as a basic [Queryable] attribute that could be added to controller methods, and then later on, removed entirely pending a new OData re-implementation.

Recently the Web API team have announced greatly improved support for OData in the WebAPI - allowing the construction of entirely OData compliant services, as a preview release on nuget - the [Queryable] attribute is also back.

I believe this now includes support for $expand, which was previously missing, but I haven't yet had a chance to play with the latest release to confirm this - but I'm not entirely sure if this would have worked for our approach at any rate.

Next

Next, in part 3 of this series we take a look at how we generated API documentation.

Written on August 20, 2012