Default Streams With WebApi OData

NuGet package feeds provide metadata about packages, but after searching for new packages to use or updates to existing ones, we also have to be able to download the actual package.

The NuGet OData implementation provides links to download a package by using the default stream concept.

In WCF Data Services, an entity indicates that it has a stream using the aptly named HasStreamAttribute.

1
2
3
4
5
6
[DataServiceKey("Id", "Version")]
[HasStream]
public class DataServicePackage : IEquatable<DataServicePackage>
{
    //snip
}

To build a URI for each instance, a custom implementation of IDataServiceStreamProvider is wired into our DataService.

This is done differently in WebApi which does not include the HasStreamAttribute at all.

Over a year ago, I asked on twitter:

At the time, the answer was no. However, I’m happy to report that recent versions of WebApi do support this feature.

First, we need to tell our EDM model builder that the entity has a default stream:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void MapODataRoutes(HttpConfiguration config)
{
    var builder = new ODataConventionModelBuilder();

    var entity = builder.EntitySet<ODataPackage>("Packages");
    entity.EntityType.HasKey(pkg => pkg.Id);
    entity.EntityType.HasKey(pkg => pkg.Version);

    var entityType = model
      .FindDeclaredType(typeof(ODataPackage).FullName);

    model = builder.GetEdmModel();
    model.SetHasDefaultStream(
       entityType as IEdmEntityType, hasStream: true);

    // snip
}

Next we’ll need to implement a custom ODataSerializerProvider and ODataEdmTypeSerializer to provide a default stream URL for entity instances:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
public class ODataPackageDefaultStreamAwareSerializerProvider
    : DefaultODataSerializerProvider
{
    private readonly ODataEdmTypeSerializer entitySerializer;

    public ODataPackageDefaultStreamAwareSerializerProvider()
    {
        this.entitySerializer =
          new ODataPackageDefaultStreamAwareEntityTypeSerializer(this);
    }

    public override ODataEdmTypeSerializer GetEdmTypeSerializer(
      IEdmTypeReference edmType)
    {
        if (edmType.IsEntity())
        {
            return entitySerializer;
        }

        return base.GetEdmTypeSerializer(edmType);
    }
}

public abstract class DefaultStreamAwareEntityTypeSerializer<T>
    : ODataEntityTypeSerializer where T : class
{
    protected DefaultStreamAwareEntityTypeSerializer(
      ODataSerializerProvider serializerProvider)
        : base(serializerProvider)
    {
    }

    public override ODataEntry CreateEntry(
      SelectExpandNode selectExpandNode,
      EntityInstanceContext entityInstanceContext)
    {
        var entry = base.CreateEntry(selectExpandNode,
          entityInstanceContext);

        var instance = entityInstanceContext.EntityInstance as T;

        if (instance != null)
        {
            var link = BuildLinkForStreamProperty(
              instance, entityInstanceContext);

            entry.MediaResource = new ODataStreamReferenceValue
            {
                ContentType = ContentType,
                ReadLink = new Uri(link)
            };
        }
        return entry;
    }

    protected virtual string ContentType
    {
        get { return "application/octet-stream"; }
    }

    protected abstract string BuildLinkForStreamProperty(
      T entity,
      EntityInstanceContext entityInstanceContext);
}

public class ODataPackageDefaultStreamAwareEntityTypeSerializer : DefaultStreamAwareEntityTypeSerializer<ODataPackage>
{
    public ODataPackageDefaultStreamAwareEntityTypeSerializer(
      ODataSerializerProvider serializerProvider)
      : base(serializerProvider)
    {
    }

    protected override string BuildLinkForStreamProperty(
      ODataPackage package,
      EntityInstanceContext context)
    {
        var url = new UrlHelper(context.Request);
        var routeParams = new { package.Id, package.Version };
        return url.Link(RouteNames.Packages.Download, routeParams);
    }

    protected override string ContentType
    {
        get { return "application/zip"; }
    }
}

The most relevant part above is the override of CreateEntry (line 33) which sets the MediaResource property on the serialized ODataEntry.

Finally the custom serializer is wired into the configuration:

1
2
3
4
config.Formatters.InsertRange(0,
    ODataMediaTypeFormatters.Create(
        new ODataPackageDefaultStreamAwareSerializerProvider(),
        new DefaultODataDeserializerProvider()));

This whole mess of code nets us a modest customization in the Atom output from WebApi OData:

1
2
3
4
5
6
7
<entry>
  <id>http://example/api/odata/Packages(Id='Lucene.Net.Linq',Version='3.2.55')</id>
  <content
    type="application/zip"
    src="http://example/api/packages/Lucene.Net.Linq/3.2.55/content"/>
  <!-- snip -->
</entry>

This enables the NuGet client to download packages.

Comments