Skip to content

Instantly share code, notes, and snippets.

@deanebarker
Last active August 7, 2020 13:53
Show Gist options
  • Save deanebarker/56d57000da22da0c9cd010707f61f01d to your computer and use it in GitHub Desktop.
Save deanebarker/56d57000da22da0c9cd010707f61f01d to your computer and use it in GitHub Desktop.
A demo of using Episerver to manage "pure" (non-web) content.

Managing Non-Web Content in Episerver

This code is a supplement to a blog post on episerver.com: Episerver Content Has Always Been Headless

This code represents a proof-of-concept and sample code for manipulating non-visual/non-web content in Episerver. The content object definition (Person.cs) and controller (PersonController.cs) effectively form a very limited, bespoke headless endpoint for a specific type of content.

This is not a new feature. It's not even new code. This functionality has been in the product for years.

IMPORTANT NOTE:

Episerver has a Content Delivery API. Use that instead of this.

This code exists merely to prove how easy it is to manipulate "pure" content in Episerver, and to demonstrate how Episerver content doesn't need to be traditional "web" content. In no way is this being presented as the correct way to work with Episerver content headlessly.

That said...

In data terms, Episerver content is based on a hierarchy of classes. The traditional "web" content is actually built on a foundation of more pure content representations. ContentData is the abstract representation of content. The BasicContent class extends from this and allows you to create, manipulate, persist, and delete content objects that have no URL and provide no web rendering.

(For web content, ContentData extends into PageData and BlockData. There are also classes for MediaData and ProductData.)

The Person object in the sample below can be persisted to and retrieved from the repository. It implements IVersionable, so it will accumulate versions as you edit. It must exist as the child of a parent object (page, folder, root, whatever -- defined in the ROOT_ID constant), and it can have children of its own.

The Person object has two properties:

  1. Name is inherited from ContentData
  2. Age has been added directly to Person

Any property type in the Episerver library is available. From a modeling perspective, classes extending from BasicContent perform like any other objects.

These objects will never appear in Edit Mode, nor will they have URL -- and they shouldn't, because they're not web content.

They're just...well, content.

(You could effectively "hide" content under pages this way. If you implemented, say, blog comments as BasicContent, for example, you could make them children of the page they were entered on, which would make them easy to find from code, and they would delete with an "owning" page, but they would never appear in Edit Mode.)

These objects can be managed in Content Manager, since that UI is designed in part to service content outside the web channel. You can create a custom view for the content type, then create, edit, and delete them from the Content Manager UI. Additionally, the content is fully available from C# via IContentRepository or IContentLoader.

Again, I want to stress -- this is not a demonstration of Episerver's headless capabilities (see our Content Delivery API). This is simply to prove that Episerver is very good at managing content, and any web-specific functions and features are layered on top of that foundation.

Installation

These two files should "just work" if compiled into an Episerver installation, with two small changes:

  1. Change the ROOT_ID constant to represent the parent of where the new content should be created
  2. Ensure the content object represented by ROOT_ID allows the Person type as child objects via its AvailableContentTypes attribute

When functioning, the following URL patterns will become available (assuming no prior conflicts):

  • /person/new?name=[name]&age=[age]
  • /person/edit/[id]?name=[name]&age=[age]
  • /person/delete[id]
  • /person/delete (deletes all Person objects under ROOT_ID)
  • /person/show/[id]
  • /person/show (shows all Person objects under ROOT_ID)

Some notes:

  • There is absolutely no authentication on this. If you deploy this as-written, you deserve what you're gonna get.
  • The format of the return value is created in the GetSimplePerson method. This is a completely invented format. It's assumed you have your own format you want to return. Go nuts.
  • All actions are implemented as GET requests to make it easy to demo and test. Most of these (anything other than Show) should be re-implemented as POST.

The code is relatively solid and has been reviewed, but this is not something you should deploy to production.

Finally -- and I know I've said this before -- this not representative of Episerver's headless API or capabilities. This is very specific code meant to illustrate a very specific thing.

Deane Barker
August 2020

This file exists just to name the gist...
using EPiServer.Core;
using EPiServer.DataAnnotations;
using System;
namespace Episerver.Labs
{
[ContentType(GUID = "AEECADF2-3E89-4117-ADEB-F8D43565D3F4")]
public class Person : BasicContent, IContent, IVersionable
{
// Name is inherited from BasicContent
// This is a custom property
public virtual int Age { get; set; }
// These are required from IVersionable
public VersionStatus Status { get; set; }
public bool IsPendingPublish { get; set; }
public DateTime? StartPublish { get; set; }
public DateTime? StopPublish { get; set; }
}
}
using EPiServer;
using EPiServer.Core;
using EPiServer.DataAccess;
using EPiServer.Security;
using EPiServer.ServiceLocation;
using System.Linq;
using System.Net;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
namespace Episerver.Labs
{
public class PersonController : Controller
{
private const int ROOT_ID = 60; // Where you want to save new records
private static readonly ContentReference peopleRoot = new ContentReference(ROOT_ID); // We assume this exists and that it can create objects of this type
private static readonly IContentVersionRepository versionRepo = ServiceLocator.Current.GetInstance<IContentVersionRepository>();
private static readonly IContentRepository contentRepo = ServiceLocator.Current.GetInstance<IContentRepository>(); // Use ContentRepository when you want to CREATE/EDIT/DELETE content
private static readonly IContentLoader contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>(); // Use ContentLoader when you just want to READ content -- it's faster
// To make code less-verbose
private readonly AccessLevel noAuthMode = AccessLevel.NoAccess;
private readonly JsonRequestBehavior allowGet = JsonRequestBehavior.AllowGet;
// Creates one under a static parent
public JsonResult New(string name, int? age)
{
var person = contentRepo.GetDefault<Person>(peopleRoot);
// If they don't pass in a name arg, we abandon...
person.Name = name ?? throw new HttpException((int)HttpStatusCode.BadRequest, "You must pass in a \"name\" querystring value.", null);
person.Age = age.GetValueOrDefault(0);
_ = contentRepo.Save(person, SaveAction.Publish, noAuthMode);
return Json(GetSimplePerson(person), allowGet);
}
// Edits one
[Route("edit/{id}")]
public JsonResult Edit(int id, string name, int? age)
{
// We get the stored object, THEN create a close we can modify, so we can compare
Person person;
try
{
person = contentLoader.Get<Person>(new ContentReference(id));
}
catch (ContentNotFoundException)
{
throw new HttpException((int)HttpStatusCode.NotFound, "Not Found");
}
var personEdits = (Person)person.CreateWritableClone();
// If they don't pass data in, we'll use the original data
personEdits.Name = name ?? person.Name;
personEdits.Age = age.GetValueOrDefault(person.Age);
// Whatever you want to do to determine equivalence...
// Maybe you don't even care? [cue Oprah]: "You get a version! And you get a version! ..."
if (personEdits.Name != person.Name || personEdits.Property.Any(p => p.IsModified))
{
_ = contentRepo.Save(personEdits, SaveAction.Publish, noAuthMode);
}
return Json(GetSimplePerson(personEdits), allowGet);
}
// Gets one or an array of all
[Route("get/{id?}")]
public JsonResult Show(int? id)
{
if (id.HasValue)
{
// One
try
{
var person = contentLoader.Get<Person>(new ContentReference(id.Value));
return Json(GetSimplePerson(person), allowGet);
}
catch (ContentNotFoundException)
{
throw new HttpException((int)HttpStatusCode.NotFound, "Not Found");
}
}
else
{
// All
var children = contentLoader.GetChildren<Person>(peopleRoot);
return Json(children.Select(p => GetSimplePerson(p)), allowGet);
}
}
// Deletes one or all
[Route("delete/{id?}")]
public EmptyResult Delete(int? id)
{
if (id.HasValue)
{
// One
try
{
var person = contentLoader.Get<Person>(new ContentReference(id.Value));
contentRepo.Delete(person.ContentLink, true, noAuthMode);
}
catch (ContentNotFoundException)
{
throw new HttpException((int)HttpStatusCode.NotFound, "Not Found");
}
}
else
{
// All
contentRepo.DeleteChildren(peopleRoot, true, noAuthMode);
}
Response.StatusCode = (int)HttpStatusCode.NoContent; // 204: No Content
return new EmptyResult();
}
// Helper; returns a simplied object representation of the content
private object GetSimplePerson(Person person)
{
var versions = versionRepo.List(person.ContentLink).OrderByDescending(v => v.Saved); // Why send back versions? To prove the content is being versioned via IVersionable. You wouldnt do this in real life.
// Note: I literally made this format up...it represents no philosophy nor convention...send back whatever you like...
return new
{
name = person.Name,
age = person.Age,
meta = new
{
id = person.ContentLink.ID,
modified = person.Changed,
modified_by = person.ChangedBy,
current_version = versions.First().ContentLink.WorkID,
version_count = versions.Count(),
watch_out_for = "Keyser Söze"
}
};
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment