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.
- Enter
- 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.
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
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.
- 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, theContent-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.
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’).
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
- Append
$value
to theLocation
header from the above section – http://localhost:5000/Assets(‘c1b877f’)/$value. - From the Postman app, create a new
GET
request. - Click the Send button to retrieve the MR.
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.
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.
0 comments