In a previous post I discussed the approach we opted for at Linn when deciding how to evolve our HTTP APIs without breaking existing clients.

In this post I describe how we implemented that approach using NancyFx (a web framework for .NET inspired by Sinatra).

Jumping In

Most of the work is handled by implementing Nancy’s IResponseProcessor interface.

public interface IResponseProcessor
{
	IEnumerable<Tuple<string, MediaRange>> ExtensionMappings
	{
		get;
	}

	ProcessorMatch CanProcess(
		MediaRange requestedMediaRange,
		dynamic model, NancyContext context);

	Response Process(
		MediaRange requestedMediaRange,
		dynamic model, NancyContext context);
}

Firstly we define the media types that this processor will respond to.

private static readonly IEnumerable<MediaRange> SupportedRanges = new[]
{
	MediaRange.FromString("application/json"),
	MediaRange.FromString("application/vnd.linn.user+json"),
    MediaRange.FromString(
		"application/vnd.linn.user+json; version=1")
};
  • application/json to support regular JSON requests
  • application/vnd.linn.user+json to support requests which do not specify a version
  • application/vnd.linn.user+json; version=1 to support requests for version 1 of the media type

We then implement the CanProcess method by evaluating the requested media type against those we support, and by looking at the type of the response model.

var modelResult = model is User ?
	MatchResult.ExactMatch : MatchResult.NoMatch;
	
var requestedContentTypeResult = SupportedRanges.Any(
		r => r.MatchesWithParameters(requestedMediaRange)) ?
			MatchResult.ExactMatch : MatchResult.NoMatch;

return new ProcessorMatch
{
	ModelResult = modelResult,
	RequestedContentTypeResult = requestedContentTypeResult
};

When I first implemented this I ran into an issue where Nancy was stripping out the parameters from requestedMediaRange before it was handed to the response processor. This was fixed with a PR which also introduced the MediaRange.MatchesWithParameters method used above. The changes are included in Nancy 0.21+.

The Process method is implemented by passing the model to a factory class which returns a DTO (we suffix such classes with ‘Resource’) created specifically for our vendor specific media type.

return new JsonResponse(
	this.userResourceProvider.Create(user),
	this.serializer) { ContentType = MediaType };

Putting it Together

Here is the IResponseProcessor in its entirety:

public class UserResourceProcessor : IResponseProcessor
{
	private readonly IUserResourceProvider userResourceProvider;

	private readonly ISerializer serializer;

	private static readonly IEnumerable<MediaRange>
	SupportedRanges = new[]
	{
    	MediaRange.FromString("application/json"),
        MediaRange.FromString("application/vnd.linn.user+json"),
        MediaRange.FromString(
			"application/vnd.linn.user+json; version=1")
	};

	private const string MediaType =
		"application/vnd.linn.user+json; version=1";

	public UserResourceProcessor(
		IEnumerable<ISerializer> serializers,
		IUserResourceProvider userResourceProvider)
	{
		this.serializer = serializers.FirstOrDefault(
			x => x.CanSerialize("application/json"));
		this.userResourceProvider = userResourceProvider;
	}

	public IEnumerable<Tuple<string, MediaRange>>
	ExtensionMappings
	{
		get { yield break; }
	}

	public ProcessorMatch CanProcess(
		MediaRange requestedMediaRange,
		dynamic model,
		NancyContext context)
	{
		var modelResult = model is User ?
			MatchResult.ExactMatch : MatchResult.NoMatch;
		var requestedContentTypeResult = SupportedRanges.Any(
			r => r.MatchesWithParameters(requestedMediaRange)) ?
			MatchResult.ExactMatch : MatchResult.NoMatch;

		return new ProcessorMatch
		{
			ModelResult = modelResult,
			RequestedContentTypeResult =
				requestedContentTypeResult
		};
	}

	public Response Process(
		MediaRange requestedMediaRange,
		dynamic model,
		NancyContext context)
	{
		var user = model as User;

		return new JsonResponse(
			this.userResourceProvider.Create(user),
			this.serializer) { ContentType = MediaType };
	}
}

Nancy automatically detects implementations of IResponseProcessor at runtime as part of its content negotiation support. To take advantage of this, we must return a response as follows:

return this.Negotiate.WithModel(user);

And with that, requests such as:

GET /users/42 HTTP/1.1
Accept: application/json
GET /users/42 HTTP/1.1
Accept: application/vnd.linn.user+json
GET /users/42 HTTP/1.1
Accept: application/vnd.linn.user+json; version=1

Receive a response like this:

HTTP/1.1 200 OK
Content-Type: application/vnd.linn.user+json; version=1

{
	"firstName" : "Joe",
	"lastName": "Bloggs"
}

Introducing a New Version

When a new version of the media type is introduced (and we must continue to support the old one), we implement a new DTO class to represent the new media type, and implement an IResponseProcessor as before:

public class UserResourceVersion2Processor : IResponseProcessor
{
	private readonly IUserResourceVersion2Provider
		userResourceProvider;

	private readonly ISerializer serializer;

	private static readonly IEnumerable<MediaRange>
	SupportedRanges = new[]
	{
    	MediaRange.FromString("application/json"),
        MediaRange.FromString("application/vnd.linn.user+json"),
        MediaRange.FromString(
			"application/vnd.linn.user+json; version=2")
	};

	private const string MediaType =
		"application/vnd.linn.user+json; version=2";

	public UserResourceVersion2Processor(
		IEnumerable<ISerializer> serializers,
		IUserResourceVersion2Provider userResourceProvider)
	{
		this.serializer = serializers.FirstOrDefault(
			x => x.CanSerialize("application/json"));
		this.userResourceProvider = userResourceProvider;
	}

	public IEnumerable<Tuple<string, MediaRange>>
	ExtensionMappings
	{
		get { yield break; }
	}

	public ProcessorMatch CanProcess(
		MediaRange requestedMediaRange,
		dynamic model,
		NancyContext context)
	{
		var modelResult = model is User ?
			MatchResult.ExactMatch : MatchResult.NoMatch;
		var requestedContentTypeResult = SupportedRanges.Any(
			r => r.MatchesWithParameters(requestedMediaRange)) ?
			MatchResult.ExactMatch : MatchResult.NoMatch;

		return new ProcessorMatch
		{
			ModelResult = modelResult,
			RequestedContentTypeResult =
				requestedContentTypeResult
		};
	}

	public Response Process(
		MediaRange requestedMediaRange,
		dynamic model,
		NancyContext context)
	{
		var user = model as User;
		return new JsonResponse(
			this.userResourceProvider.Create(user),
			this.serializer) { ContentType = MediaType };
	}
}

Finally, we remove the parameterless vendor specific media type from the original processor, since we want requests which do not specify version to receive the most recent version if the media type. We also remove application/json as we want our new IResponseProcessor to handle that instead.

public class UserResourceProcessor : IResponseProcessor
{
	private readonly IUserResourceProvider
		userResourceProvider;

	private readonly ISerializer serializer;

	private const string MediaType =
		"application/vnd.linn.user+json; version=1";

	public UserResourceProcessor(
		IEnumerable<ISerializer> serializers,
		IUserResourceProvider userResourceProvider)
	{
		this.serializer = serializers.FirstOrDefault(
			x => x.CanSerialize("application/json"));
		this.userResourceProvider = userResourceProvider;
	}

	public IEnumerable<Tuple<string, MediaRange>>
	ExtensionMappings
	{
		get { yield break; }
	}

	public ProcessorMatch CanProcess(
		MediaRange requestedMediaRange,
		dynamic model,
		NancyContext context)
	{
		var modelResult = model is User ?
			MatchResult.ExactMatch : MatchResult.NoMatch;
		var requestedContentTypeResult =
        		MediaRange.FromString(MediaType)
				.MatchesWithParameters(requestedMediaRange) ?
				MatchResult.ExactMatch : MatchResult.NoMatch;

		return new ProcessorMatch
		{
			ModelResult = modelResult,
			RequestedContentTypeResult =
				requestedContentTypeResult
		};
	}

	public Response Process(
		MediaRange requestedMediaRange,
		dynamic model,
		NancyContext context)
	{
		var user = model as User;
		return new JsonResponse(
			this.userResourceProvider.Create(user),
			this.serializer) { ContentType = MediaType };
	}
}

With those changes in place, the following requests:

GET /users/42 HTTP/1.1
Accept: application/json
GET /users/42 HTTP/1.1
Accept: application/vnd.linn.user+json
GET /users/42 HTTP/1.1
Accept: application/vnd.linn.user+json; version=2

All receive a response like this:

HTTP/1.1 200 OK
Content-Type: application/vnd.linn.user+json; version=2

{
	"name": {
		"first" : "Joe",
		"last": "Bloggs"
	}
}

But a request against the original media type version will receive the same body as before.

Once we no longer need to support the original version, we can simply delete the corresponding processor and DTO.

And that’s all there is to it. If anyone has any suggestions on how this could be improved in any way, please feel free to comment below.