August 8th, 2023

Working with media resources in OData – Part 1

John Gathogo
Senior Software Engineer

OData enables you to define data feeds that serve binary large object (BLOB) data. In OData lingo, this binary data is referred to as a media resource. A media resource (MR) is an unstructured piece of data or stream, e.g., a document, image, or video. It is requested from the data service separately from the entry in the feed to which it belongs, called a media link entry. A media link entry (MLE) is a special type of entry which links to an MR and includes additional metadata about it. Typical metadata that an MLE may maintain about the related MR include description, file name, file size, dimensions, date uploaded, content type, etc.

This blog post demonstrates how to implement an OData service that serves media resources and how clients may interact with the service.

Create an ASP.NET Core application

  • Start Visual Studio 2022 and select Create a new project.
  • In the Create a new project dialog:
    • Enter Empty in the Search for templates text box.
    • Select ASP.NET Core Empty project template and select Next.

Screenshot of creating aspnetcore empty project using vs 2022 targeting net 6.0 framework

  • Name the project ODataMRSample and select Next.
  • In the Additional information dialog:
    • Select .NET 6.0 (Long Term Support).
    • Uncheck Configure for HTTPS checkbox.
    • Select Create.

Screenshot of creating aspnetcore project using vs 2022 targeting net core 6.0 framework additional info

Install required packages

Run the following command on the Visual Studio Package Manager Console to install the Microsoft.AspNetCore.OData nuget package:

Install-Package Microsoft.AspNetCore.OData

Add data models

Add a folder named Models to the project. Then, add the following Asset class representing the data model to the Models folder:
namespace ODataMRSample.Models
{
    [MediaType]
    public class Asset
    {
        public string Id { get; set; } = string.Empty;
        [NotMapped]
        public string Path { get; set; } = string.Empty;
        public IDictionary<string, object?> Properties { get; set; } = new Dictionary<string, object?>();
    }
}

In the above block of code, we define a class named Asset and decorate it with the MediaType attribute. The MediaType attribute is used to indicate to the Edm model builder that this class should be represented as a media type in the Edm model.

By including a property of type IDictionary<string, object>, the model builder will also represent this class as an open type in the Edm model. We will use the dictionary to store the metadata for the media resources.

Build the Edm model and configure the service

Replace the contents of the Program.cs file with the following code:

using Microsoft.AspNetCore.OData;
using Microsoft.OData.ModelBuilder;
using ODataMRSample.Models;

var builder = WebApplication.CreateBuilder(args);

var modelBuilder = new ODataConventionModelBuilder();
modelBuilder.EntitySet<Asset>("Assets");

builder.Services.AddControllers().AddOData(
    options => options.EnableQueryFeatures().AddRouteComponents(
        model: modelBuilder.GetEdmModel()));

var app = builder.Build();

app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapControllers());

app.Run();

In the above block of code, we define an entity set named Assets. We also register Asset as an entity type. Next, we configure our OData service and pass our Edm model to the AddRouteComponents method.

At this point, we can run our service and inspect the service metadata.

Press F5 to build and run the application.

After the application has launched, take note of the endpoint that the application is listening on – http://localhost:5000. Please note that the port may differ since Visual Studio assigns a random port for the web server when a web project is created.

The service metadata is available at the $metadata endpoint – http://localhost:5000/$metadata. The endpoint returns a document containing a description of the feeds, types, properties, and relationships exposed by the OData service:

<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
    <edmx:DataServices>
        <Schema Namespace="ODataMRSample.Models" xmlns="http://docs.oasis-open.org/odata/ns/edm">
            <EntityType Name="Asset" OpenType="true" HasStream="true">
                <Key>
                    <PropertyRef Name="Id" />
                </Key>
                <Property Name="Id" Type="Edm.String" Nullable="false" />
            </EntityType>
        </Schema>
        <Schema Namespace="Default" xmlns="http://docs.oasis-open.org/odata/ns/edm">
            <EntityContainer Name="Container">
                <EntitySet Name="Assets" EntityType="ODataMRSample.Models.Asset" />
            </EntityContainer>
        </Schema>
    </edmx:DataServices>
</edmx:Edmx>

You will notice that the Asset entity type has two attributes, namely, OpenType and HasStream both set to true.

Add OData controller

In this section, we implement the logic needed to accept and serve media resources from our OData service.

MLEs are created by issuing a POST request against the collection that the MLE should be created in, with the request body containing the MR and Content-Type header indicating its media type. When processing the POST request, the service should create both the MR and the MLE and return the URI of the MLE in the Location response header. The MLE is typically initialized with server-generated values and information extracted from the MR. The MLE can optionally be included in the response body to update the client with the server-generated data.

Add a folder named Controllers to the project. Then, add the following AssetsController class to the Controllers folder:

namespace ODataMRSample.Controllers
{
    using System.Net;
    using System.Net.Mime;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.OData.Deltas;
    using Microsoft.AspNetCore.OData.Routing.Controllers;
    using Microsoft.Extensions.Primitives;
    using ODataMRSample.Models;

    public class AssetsController : ODataController
    {
        private const string ContentTypeHeader = "Content-Type";
        private const string ContentDispositionHeader = "Content-Disposition";
        private static readonly List<Asset> assets = new List<Asset>();
    }
}

The AssetsController class derives from the ODataController, a base class for OData controllers that supports writing and reading using the OData formats. The controller name also corresponds with the Assets entity set defined in the Edm model.

For this simple service, we are going to use an in-memory list as our data store.

Posting a media resource

Before implementing the controller action for processing the POST request containing the MR, let’s implement the following 2 helper methods in the AssetsController class:

public string? GetContentType()
{
    if (!string.IsNullOrEmpty(Request.ContentType))
    {
        return Request.ContentType;
    }

    if (Request.Headers.TryGetValue("Content-Type", out StringValues contentType))
    {
        return contentType.ToString();
    }

    return null;
}
public (string? ContentDisposition, string? FileName) GetContentDisposition()
{
    if (Request.Headers.TryGetValue("Content-Disposition", out StringValues contentDispositionHeader))
    {
        string contentDispositionHeaderValue = contentDispositionHeader.ToString();
        string? fileName = null;

        if (!string.IsNullOrEmpty(contentDispositionHeaderValue))
        {
            ContentDisposition contentDisposition = new ContentDisposition(contentDispositionHeaderValue);
            fileName = contentDisposition.FileName;
        }

        return (contentDispositionHeaderValue, fileName);
    }

    return (null, null);
}

The GetContentType helper method returns the request content type.

On the other hand, the GetContentDisposition helper method returns a tuple comprising of the Content-Disposition header and file name from the request headers collection.

Add a folder named Media to the project. This is where we will save the media resources.

Next, we implement the Post controller action that processes the MR in the request body:

public async Task<ActionResult> Post()
{
    var assetId = Guid.NewGuid().ToString("N").Substring(0, 7);
    var contentType = GetContentType();

    (string? contentDisposition, string? fileName) = GetContentDisposition();

    fileName = $"Media\\{fileName ?? assetId}";

    using (var fileStream = new FileStream(fileName, FileMode.Create))
    {
        await Request.Body.CopyToAsync(fileStream);
    }

    var asset = new Asset { Id = assetId, Path = fileName };

    asset.Properties.Add("Content-Type", contentType);
    asset.Properties.Add("Content-Disposition", contentDisposition);

    assets.Add(asset);

    return Created(asset);
}

In the above block of code, we generate a random string to serve as the asset ID. Next we extract the value of the Content-Type header from the request. We also extract the value of the Content-Disposition header and resolve the file name where possible.

In our implementation, we are saving the MR to disk and then initializing an Asset object with the relevant values and dynamic properties. Finally, we stash the object in our in-memory data store and then call the Created method which generates a 201 Created response with a Location header.

Requesting the media resource of a media link entry using $value

To request the MR represented by an MLE, the client appends $value to the path of the MLE URL. For instance, where the MLE URL is http://localhost:5000/Assets(‘foobar’), the address for requesting the MR would be http://localhost:5000/Assets(‘foobar’)/$value.

The implementation for the controller action to handle the request for the MR is as follows:

[HttpGet("Assets({key})/$value")]
public async Task GetMediaResourceAsync(string key)
{
    var asset = assets.SingleOrDefault(d => d.Id == key);

    if (asset == null)
    {
        Response.StatusCode = (int)HttpStatusCode.BadRequest;
        return;
    }

    Response.StatusCode = (int)HttpStatusCode.OK;
    Response.Headers.Add("Content-Type", asset.Properties["ContentType"] as string);
    Response.Headers.Add("Content-Disposition", asset.Properties["ContentDisposition"] as string);

    using (var fileStream = new FileStream(asset.Path, FileMode.Open))
    {
        await fileStream.CopyToAsync(Response.Body);
    }
}

In the above block of code, we retrieve the Asset object that matches the specified key from the in-memory data store. Next, we retrieve the Content-Type and Content-Disposition values from the dynamic properties container property and set the respective response headers. Finally, we read the file identified by the Path property from the disk and write it into the response body.

Trying it out

To test out the posting and retrieval of media resources from the OData service, we are going to use the Postman API client.

Posting a media resource

  • Press F5 to build and run the application.
  • Next, run the Postman app and create a new POST request.
  • On the Postman address bar, enter http://localhost:5000/Assets.
  • On the Body tab, choose binary as the data type for the request body.

Image media resource postman post binary data

  • After choosing binary, a button labelled Select File should appear.
  • Click the Select File button to browse for a media file to upload to the OData service.
  • On the Headers tab, optionally set an appropriate Content-Disposition, e.g., inline;filename=green.png for a media file named green.png. In the strictest sense, the Content-Disposition header is a HTTP response header used to indicate if the content is expected to be displayed inline in the browser or as an attachment, that is downloaded and saved locally. In our contrived sample service, we’re including the header in our request so it’s later returned with the response when the MR is requested.
  • Click the Send button to post the MR.

Image media resource postman post media file 2

From the above screenshot, the MR was successfully posted – 201 Created HTTP status code. The media file should be in the Media folder of your application. The response body is a JSON object representing the MLE linked to the posted MR. On the Headers tab of the response window, the Location header has the value http://localhost:5000/Assets(‘c1b877f’).

If you do not have Postman installed, you could use the curl command line tool to post the MR to the service:

curl -i -X POST -H "Content-Type: image/png" -H "Content-Disposition: inline;filename=green.png" --data-binary @"PATH\TO\green.png" http://localhost:5000/Assets

Retrieving a media resource

Image media resource postman get media file

The media file posted earlier, in this case a square that is filled with color green, is successfully retrieved and rendered on the response window.

Finishing up the implementation

For completeness, we implement logic to support the following:

  • patching a media link entry,
  • retrieving a media link entry, and
  • updating a media resource.

Patching a media link entry

The controller action to support patching the MLE looks as follows:

public ActionResult Patch(string key, [FromBody] Delta<Asset> delta)
{
    var asset = assets.SingleOrDefault(d => d.Id == key);

    if (asset == null)
    {
        return NotFound();
    }

    delta.Patch(asset);

    return Ok();
}

A PATCH request to the MLE URL should update the properties in the request body:

PATCH http://localhost:5000/Assets('c1b877f')

Payload:

{
    "Description": "Square",
    "Color": "Green"
}

The Asset entity is an open type. Any additional properties in the payload will be stashed in the dynamic properties container. In the above case, Description and Color are dynamic properties. The MLE created when the MR was uploaded to the service did not include those properties.

Retrieving a media link entry

To support fetching the MLE, we add the following controller action:

public ActionResult Get(string key)
{
    var asset = assets.SingleOrDefault(d => d.Id == key);

    if (asset == null)
    {
        return NotFound();
    }

    return Ok(asset);
}

A GET request to the MLE URL should return the metadata for the linked MR:

GET http://localhost:5000/Assets('c1b877f')

Response:

{
    "@odata.context": "http://localhost:5000/$metadata#Assets/$entity",
    "Id": "c1b877f",
    "Path": "Media\\green.png",
    "ContentType": "image/png",
    "ContentDisposition": "inline;filename=green.png",
    "Description": "Square",
    "Color": "Green"
}

Updating a media resource

We update the MR by replacing it. By convention, the endpoint for updating the MR consists of the MLE URL prepended with /$value.

To support updating of the MR, we add the following controller action:

[HttpPut("Assets({key})/$value")]
public async Task<ActionResult> SetMediaResourceAsync(string key)
{
    var asset = assets.SingleOrDefault(d => d.Id == key);
    if (asset == null)
    {
        return NotFound();
    }

    var contentType = GetContentType();
    (string? contentDisposition, string? fileName) = GetContentDisposition();

    if (contentType == null)
    {
        Response.StatusCode = (int)HttpStatusCode.BadRequest;
        return BadRequest();
    }

    fileName = $"Media\\{fileName ?? asset.Id}";

    using (var fileStream = new FileStream(fileName, FileMode.Create))
    {
        await Request.Body.CopyToAsync(fileStream);
    }

    asset.Path = fileName;
    asset.Properties["Content-Type"] = contentType;
    asset.Properties["Content-Disposition"] = contentDisposition;        

    return Ok();
}

The controller action is decorated with HttpPut attribute to support HTTP PUT semantics for updating the MR. In addition, we are using attribute routing to set the path template to map to the controller action. We retrieve the asset by key, then persist the MR to disk and finally update the relevant properties with the respective MR metadata.

Image media resource postman put media file

If you do not have Postman installed, you could use the curl command line tool to update the MR:

curl -i -X PUT -H "Content-Type: image/png" -H "Content-Disposition: inline;filename=red.png" --data-binary @"PATH\TO\red.png" http://localhost:5000/Assets('c1b877f')/$value

If you send a GET request to the MR endpoint, http://localhost:5000/Assets(‘c1b877f’)/$value, the updated MR should be rendered.

Summary

It is my hope that this blog post helps you understand how the ASP.NET Core OData library supports working with media resources.

If you have any questions, comments, or if you run into any issues, feel free to reach out on this blog post or report the issue on GitHub repo for ASP.NET Core OData.

Find the source code for this blog post here.

Author

John Gathogo
Senior Software Engineer

0 comments

Discussion are closed.