Recently I’ve been working on an API for fabrik (a portfolio and blogging service). Since no GUI clients exist yet for the API (because I haven’t developed any) I thought it would be useful to support a standard publishing protocol, especially since ASP.NET Web API makes this so easy with content negotiation.
In the past I’ve used MetaWeblog API. Unfortunately MetaWeblog API is a RPC based protocol (XML-RPC to be exact). XML-RPC requires that we construct special XML requests to invoke specific functions on the server. In the case of the MetaWeblog API, we have the following basic entry-points:
metaWeblog.newPost (blogid, username, password, struct, publish)
metaWeblog.editPost (postid, username, password, struct, publish)
metaWeblog.getPost (postid, username, password)
Since I’m building a REST API, I needed a protocol that adheres to REST principles. Fortunately, AtomPub meets these requirements.
The Atom Publishing Protocol (APP) is based on HTTP and is used for publications and posting on Web resources. The Atom Publishing Protocol (APP) together with the Atom Syndication Format (ASF) provides interaction with content, especially at blogs and RSS.
You’re probably already familiar with the Atom Syndication Format, since it is a popular alternative to RSS for providing “content feeds” on web sites (like the feed on this blog).
To support AtomPub using ASP.NET Web API we have the following requirements:
- Return Atom representations of our resources.
- Provide HTTP endpoints for working with Atom entries.
- Provide a way to discover the AtomPub service.
The AtomPub protocol defines the following operations:
- Listing Collection Members => GET to collection URI
- Creating a Resource => POST to collection URI
- Editing a Resource => PUT to Member URI
- Deleting a Resource => DELETE to Member URI
As you can see, the default ASP.NET Web API Controller template almost supports this out of the box!
Listing Collection Members
To return our resources as Atom Feeds/Entries we can create a custom MediaTypeFormatter
:
public class AtomPubMediaFormatter : MediaTypeFormatter
{
private const string AtomMediaType = "application/atom+xml";
public AtomPubMediaFormatter()
{
SupportedMediaTypes.Add(new MediaTypeHeaderValue(AtomMediaType));
this.AddQueryStringMapping("format", "atom", AtomMediaType);
}
public override bool CanWriteType(Type type)
{
return type.Implements<IPublication>() || type.Implements<IPublicationFeed>();
}
public override Task WriteToStreamAsync(Type type, object value, Stream writeStream, HttpContent content, TransportContext transportContext)
{
return TaskHelpers.RunSynchronously(() =>
{
if (value is IPublicationFeed)
{
WriteAtomFeed((IPublicationFeed)value, writeStream);
}
else
{
WriteAtomEntry((IPublication)value, writeStream);
}
});
}
private void WriteAtomFeed(IPublicationFeed feed, Stream writeStream)
{
var atomEntries = feed.Items.Select(i => i.Syndicate());
var atomFeed = new SyndicationFeed
{
Title = new TextSyndicationContent(feed.Title),
Items = atomEntries
};
atomFeed.Authors.Add(new SyndicationPerson { Name = feed.Author });
var formatter = new Atom10FeedFormatter(atomFeed);
using (var writer = XmlWriter.Create(writeStream))
{
formatter.WriteTo(writer);
}
}
private void WriteAtomEntry(IPublication publication, Stream writeStream)
{
var entry = publication.Syndicate();
var formatter = new Atom10ItemFormatter(entry);
using (var writer = XmlWriter.Create(writeStream))
{
formatter.WriteTo(writer);
}
}
}
The above formatter is mapped to the application/atom+xml
media type and handles both Atom feeds and entries.
For Atom Feeds, your resources should implement IPublicationFeed
:
/// <summary>
/// An interface for publication feeds.
/// </summary>
public interface IPublicationFeed
{
string Title { get; }
string Author { get; }
IEnumerable<IPublication> Items { get; }
}
For Atom Entries, you should implement IPublication
:
/// <summary>
/// An interface for publications that can be returned as Atom entries.
/// </summary>
public interface IPublication
{
string Id { get; }
string Title { get; }
string Summary { get; }
string Content { get; }
string ContentType { get; }
DateTime LastUpdated { get; }
DateTime? PublishDate { get; }
IEnumerable<string> Categories { get; }
IEnumerable<Link> Links { get; }
}
These interfaces provide all the information required by the Atom specification. Some optional attributes such as Slug and Draft are not currently supported.
Note that IPublication
includes a collection of relation links. The AtomPub specification requires that each Atom entry includes an edit
link. I’m using a response enricher to add these in the example project.
Provide HTTP endpoints for working with Atom entries
To support creating (POST), editing (PUT) and deleting (DELETE) resources we can use the same formatter, overriding ReadFromStreamAsync
:
public override bool CanReadType(Type type)
{
return type.Implements<IPublicationCommand>();
}
public override Task<object> ReadFromStreamAsync(Type type, Stream readStream, HttpContent content, IFormatterLogger formatterLogger)
{
Ensure.Argument.NotNull(type, "type");
Ensure.Argument.NotNull(readStream, "readStream");
return TaskHelpers.RunSynchronously<object>(() =>
{
HttpContentHeaders contentHeaders = content == null ? null : content.Headers;
// If content length is 0 then return default value for this type
if (contentHeaders != null && contentHeaders.ContentLength == 0)
{
return GetDefaultValueForType(type);
}
try
{
using (var reader = XmlReader.Create(readStream))
{
var formatter = new Atom10ItemFormatter();
formatter.ReadFrom(reader);
var command = Activator.CreateInstance(type);
((IPublicationCommand)command)
.ReadSyndicationItem(formatter.Item);
return command;
}
}
catch (Exception e)
{
if (formatterLogger == null)
{
throw;
}
formatterLogger.LogError(String.Empty, e);
return GetDefaultValueForType(type);
}
});
}
This requires that your POST and PUT methods have a parameter that implements IPublicationCommand
. It is important to not change the type of the created command instance as parameter binding will fail (which is why we don’t return a casted instance of the command).
/// <summary>
/// An interface for commands that can create or update publications.
/// </summary>
public interface IPublicationCommand
{
string Title { get; set; }
string Summary { get; set; }
string Content { get; set; }
string ContentType { get; set; }
DateTime? PublishDate { get; set; }
string[] Categories { get; set; }
}
The following Web API Controller serves as an endpoint for our AtomPub server:
public class PostsController : ApiController
{
private static readonly List<Post> posts = new List<Post>();
// GET api/posts
public PostFeed Get()
{
var feed = new PostFeed
{
Title = "My Post Feed",
Author = "John Doe",
Posts = posts.Select(p =>
new PostModel(p)).OrderByDescending(p => p.PublishDate).ToArray()
};
return feed;
}
// GET api/posts/5
public PostModel Get(int id)
{
return new PostModel(GetPost(id));
}
// POST api/posts
public HttpResponseMessage Post(AddPostCommand command)
{
var post = new Post
{
Id = GetNextId(),
Title = command.Title,
Slug = command.Slug ?? command.Title.ToSlug(),
Summary = command.Summary,
ContentType = command.ContentType,
Content = command.Content,
Tags = command.Tags,
PublishDate = command.PublishDate ?? DateTime.UtcNow
};
posts.Add(post);
var response = Request.CreateResponse(HttpStatusCode.Created, new PostModel(post));
response.Headers.Location = new Uri(Url.Link("DefaultApi", new { controller = "posts", id = post.Id }));
return response;
}
// PUT api/posts/5
public HttpResponseMessage Put(int id, UpdatePostCommand command)
{
var post = GetPost(id);
post.Title = command.Title;
post.Slug = command.Slug ?? post.Slug;
post.Summary = command.Summary;
post.ContentType = command.ContentType;
post.Content = command.Content;
post.Tags = command.Tags;
post.PublishDate = command.PublishDate ?? post.PublishDate;
return new HttpResponseMessage(HttpStatusCode.OK);
}
// DELETE api/posts/5
public HttpResponseMessage Delete(int id)
{
var post = GetPost(id);
posts.Remove(post);
return new HttpResponseMessage(HttpStatusCode.OK);
}
private Post GetPost(int id)
{
var post = posts.SingleOrDefault(p => p.Id == id);
if (post == null)
{
throw new HttpResponseException(new HttpResponseMessage(HttpStatusCode.NotFound));
}
return post;
}
private int GetNextId()
{
return posts.Count > 0 ? posts.Max(p => p.Id) + 1 : 1;
}
}
Provide a way to discover the AtomPub service
So that our Atom Publication Service can be discovered we need to provide a service document. In the example project you can see how I’ve done this using a Web API controller (/api/services
). However, you could just as easily create an XML document to do the same job:
<?xml version="1.0" encoding="utf-8"?>
<app:service xmlns:a10="http://www.w3.org/2005/Atom" xmlns:app="http://www.w3.org/2007/app">
<app:workspace xml:base="http://localhost:56592/">
<a10:title type="text">My Site</a10:title>
<app:collection href="http://localhost:56592/api/posts">
<a10:title type="text">Blog</a10:title>
<app:accept>application/atom+xml;type=entry</app:accept>
<app:categories />
</app:collection>
</app:workspace>
</app:service>
I’m still in two minds about whether to return the service document using a controller or using a media type formatter. Since I have no intention of abstracting the ServiceDocument
creation (since this will vary too much per use case) I don’t really see the point of returning this in other formats.
End of Part 1
I’ve decided to split this topic up into a series of posts, mainly because the implementation is not finished. You’ll find all the source code on GitHub so if you want to help, I still need to:
- Add support for slugs (slugs are sent as a HTTP header. Weird right?!)
- Support the “draft” extension
- Add category support
- Add media support
In the next post I’ll cover how you can publish content using an AtomPub client.