August 30, 2012

RavenDB identity strategy in ASP.NET Web API

The recommended identity strategy in RavenDB is to use string identifiers for your entities. You can use integers but (in my own experience) this can make using some of the lower level APIs quite difficult.

Unfortunately string identifiers in the format “doctype/id” don’t work so well with Web API routing.

The default Web API route looks like this:

config.Routes.MapHttpRoute(
    name: "DefaultApi",
    routeTemplate: "api/{controller}/{id}",
    defaults: new { id = RouteParameter.Optional }
);

So to load a blog post with id “posts/10” from our PostsController we would need to make a request to api/posts/posts/10 which doesn’t work as ASP.NET splits the Id into Uri segments.

You can get round this in MVC by using a wildcard parameter but wildcard parameters are not supported out of the box in Web API (although Tugberk did blog recently how to achieve this).

A better solution, and one that is recommended by the folks at RavenDB, is to expose integer identifiers to the client and use strings on the server.

This solves the routing problem and since RavenDB’s Load<T> function supports integers, it doesn’t cause that much additional work on the server:

public Post Get(int id)
{
    var post = Session.Load<Post>(id);
    return post;
}

However, if we’re using integer identifiers in our Uri templates, we should use integers in all our resource representations.

Since I’m using AutoMapper to map my domain objects to view models, we can use Type Converters to translate the identifiers automatically:

public class IntTypeConverter : TypeConverter<string, int>
{
    protected override int ConvertCore(string source)
    {
        if (source == null)
            throw new AutoMapperMappingException("Cannot convert null string to non-nullable return type.");

        if (source.Contains("/"))
            return source.ToIntId();

        return Int32.Parse(source);
    }
}

public class NullIntTypeConverter : TypeConverter<string, int?>
{
    protected override int? ConvertCore(string source)
    {
        if (source == null)
            return null;

        if (source.Contains("/"))
            return source.ToIntId();

        int result;
        return Int32.TryParse(source, out result) 
            ? (int?)result
            : null;
    }
}

The converters assume that if the string contains a “/”, it must be a document identifier. The actual conversion is then done using a simple extension method:

public static int ToIntId(this string id)
{
    Ensure.Argument.NotNullOrEmpty(id);
    return int.Parse(id.Substring(id.LastIndexOf('/') + 1));
}

To add the converter to the AutoMapper configuration:

// For mapping ravendb string ids to integer
Mapper.CreateMap<string, int>().ConvertUsing(new IntTypeConverter());
Mapper.CreateMap<string, int?>().ConvertUsing(new NullIntTypeConverter());

This takes care of mapping strings to integers and nullable integers.

Our updated action method looks like:

public PostModel Get(int id)
{
    var post = Session.Load<Post>(id);
    return Mapper.Map<PostModel>(post);
}

There will be cases where you need a string identifier. This can be done using a bit of string concatenation, but a better approach is have RavenDB do this for you:

public static string GetStringId<T>(this IDocumentSession session, object id)
{
    return session.Advanced.DocumentStore.Conventions
		.DefaultFindFullDocumentKeyFromNonStringIdentifier(id, typeof(T), false);
}

So if we wanted to query all Posts in a category:

public IEnumerable<PostModel> Get(int categoryId) 
{
	var posts = Session.Query<Post>()
					.Where(p => p.CategoryId == Session.GetStringId<Category>(categoryId))
					.ToList();

	return Mapper.Map<IEnumerable<PostModel>>(posts);
}

So there you have it, string identifiers for the server and integers for the client.

© 2022 Ben Foster