Content Providers 101 – Part I: Introduction, Initialization, UI & Identity Mapping
A couple of weeks ago at an EPiServer Techforum in Norway, I did a demo on Content Providers. A few people have been asking about the code I wrote, so I decided to write this blog post series. While content providers is not a new feature, it has become a lot more manageable in newer versions of EPiServer. Especially when being able to create custom content types and having tools such as the identity mapping service, which will be used in this example.
External content
In order to create a content provider you’ll need some actual content to provide (duh!). In the following example, I'll create a content provider that will import objects from a PersonService. The PersonService really just reads and writes information to a tab delimited file, with entries that contains basic information about a person. Keep in mind that the service could have retrieved the content from anywhere, and not just from a text file.
An entry looks like this:
The service converts this information into Person objects that will later be converted to Attendee objects, and used as content in EPiServer. The content will be displayed as a flat structure in a new tab in the assets pane.
Here’s how the Person class is defined:
public class Person
{
public string Email { get; set; }
public string Title { get; set; }
public string Company { get; set; }
public string Name { get; set; }
}
The PersonService contains methods for retrieving, updating, adding, deleting and searching for objects.
public interface IPersonService
{
Person GetPersonByEmail(string email);
IEnumerable<Person> GetAll();
void UpdatePerson(string originalEmail, string newEmail, string title, string name, string company);
void CreatePerson(Person person);
IEnumerable<Person> Search(string searchQuery);
void Delete(string email);
}
Custom IContent
As mentioned, I’ll convert the person objects into an Attendee object. The reason I’ve named it Attendees is because the list I used during the demo was based on the attendees at the EPiServer Techforum. I’ve changed the attendee names for this example though.
[ContentType(GUID = "0D4A8F04-8337-4A59-882E-F39617E5D434")]
public class Attendee : ContentBase
{
[EmailAddress]
[Required]
public virtual string Email { get; set; }
public virtual string Title { get; set; }
public virtual string Company { get; set; }
}
This is a custom IContent type. By inheriting ContentBase, all the necessary properties to create IContent, like Name, StartPublish, StopPublish, ContentLink and so on, are implemented.
Instead of creating a new content type, I could have converted the person objects into standard content types as well, like a page type or a block type.
In order to convert a person to an attendee, we’ll need to populate quite a few properties, including properties found in ContentBase, like the ContentLink. When populating the ContentLink, which is a ContentReference, you need an int property. However, the person objects does not contain any suitable int properties. This is where the IdentityMappingService becomes very useful. It can create one for us! In this case, it’s being mapped to the person’s email, and will also contain a mapped GUID. Below is an example on how to do this. I’ve added plenty of inline comments, so hopefully it will make sense.
public Attendee ConvertToAttendee(Person person)
{
ContentType type = ContentTypeRepository.Load(typeof(Attendee));
Attendee attendee =
ContentFactory.CreateContent(type, new BuildingContext(type)
{
// as this is a flat structure, we set the parent to the provider's EntryPoint
// by setting this in the Buildingcontext, access rights will also be inherited
Parent = DataFactory.Instance.Get<ContentFolder>(EntryPoint),
}) as Attendee;
// make sure the content will be visible for all users
attendee.Status = VersionStatus.Published;
attendee.IsPendingPublish = false;
attendee.StartPublish = DateTime.Now.Subtract(TimeSpan.FromDays(14));
// This part is a bit tricky. IdentityMappingService is used in order to create the ContentReference and content GUID.
// The only unique property on the person object is the e-mail, so that will be used as the identifier.
// First, create an external identifier based on the person's e-mail
Uri externalId = MappedIdentity.ConstructExternalIdentifier(ProviderKey, person.Email);
// then, invoke IdentityMappingService's Get with the externalId.
// Make sure Get is invoked with the second parameter ('createMissingMapping') set to true. This will create a new mapping if no existing mapping is found
MappedIdentity mappedContent = IdentityMappingService.Service.Get(externalId, true);
attendee.ContentLink = mappedContent.ContentLink;
attendee.ContentGuid = mappedContent.ContentGuid;
// and then the properties from the person objects
attendee.Title = person.Title;
attendee.Name = person.Name;
attendee.Company = person.Company;
attendee.Email = person.Email;
// make the content read only
attendee.MakeReadOnly();
return attendee;
}
protected Injected<IdentityMappingService> IdentityMappingService { get; set; }
The Provider
With the convertion of Person objects in place, the content provider can be built. To create a provider, create a new class and inherit from ContentProvider. There are many methods that can be overridden in order to implement the content provider of your dreams functionality that is required for your provider. Below I’ve implemented LoadContent, which will be invoked whenever loading content from the provider. This is an abstract method and requires implementation. I’ve also implemented LoadChildrenReferencesAndTypes which is invoked whenever children should be listed from a node, such as when opening a node in edit mode or when GetChildren is invoked from an IContentRepository.
public class AttendeeProvider : ContentProvider
{
public const string Key = "attendees";
private List<Attendee> _attendees = new List<Attendee>();
// This will be invoked when trying to load a single attendee from the providers. Such as when displaying the attendee on a page or in edit mode.
protected override IContent LoadContent(ContentReference contentLink, ILanguageSelector languageSelector)
{
// In order to return the attendee, the contentLink must be mapped to an e-mail so that the person object can be found using the PersonService
MappedIdentity mappedIdentity = IdentityMappingService.Service.Get(contentLink);
// the email is found in the ExternalIdentifier that was created earlier. Note that Segments[1] is used due to the fact that the ExternalIdentifier is of type Uri.
// It contains two segments. Segments[0] contains the content provider key, and Segments[1] contains the unique path, which is the e-mail in this case.
string email = mappedIdentity.ExternalIdentifier.Segments[1];
return ConvertToAttendee(PersonService.GetPersonByEmail(email));
}
// this will pass back content reference for all the children for a specific node. In this case, it will be a flat structure,
// so this will only be loaded with the provider's EntryPoint set as the contentLink
protected override IList<GetChildrenReferenceResult> LoadChildrenReferencesAndTypes(
ContentReference contentLink, string languageID, out bool languageSpecific)
{
// the attendees are not language specific, so this is ignored.
languageSpecific = false;
// get all Person objects
var people = PersonService.GetAll();
// create and return GetChildrenReferenceResults. The ContentReference (ContentLink) is fetched using the IdentityMapingService.
return people.Select(p =>
new GetChildrenReferenceResult()
{
ContentLink =
IdentityMappingService.Service.Get(MappedIdentity.ConstructExternalIdentifier(ProviderKey,
p.Email)).ContentLink,
ModelType = typeof (Attendee)
}).ToList();
}
}
The provider automatically caches items, meaning that LoadContent will not be invoked for every request. The cache settings can be overridden, so you can control this yourself if needed.
Register the provider
The registration of the provider is done with an initializable module. Here is how the initialization is implemented:
public void Initialize(InitializationEngine context)
{
var attendeeProvider = new AttendeeProvider();
// add configuration settings for entry point and capabilites
var providerValues = new NameValueCollection();
providerValues.Add(ContentProviderElement.EntryPointString, AttendeeProvider.GetEntryPoint("attendees").ContentLink.ToString());
providerValues.Add(ContentProviderElement.CapabilitiesString, "Create,Edit,Delete,Search");
// initialize and register the provider
attendeeProvider.Initialize(AttendeeProvider.Key, providerValues);
var providerManager = context.Locate.Advanced.GetInstance<IContentProviderManager>();
providerManager.ProviderMap.AddProvider(attendeeProvider);
}
The provider's entry point is set and capabilities configured.
When working with content providers, the entry point must be a content node without any children. The GetEntryPoint method takes care of this by creating a folder with the given name beneath the root node:
public static ContentFolder GetEntryPoint(string name)
{
var contentRepository = ServiceLocator.Current.GetInstance<IContentRepository>();
var folder = contentRepository.GetBySegment(ContentReference.RootPage, name, LanguageSelector.AutoDetect()) as ContentFolder;
if (folder == null)
{
folder = contentRepository.GetDefault<ContentFolder>(ContentReference.RootPage);
folder.Name = name;
contentRepository.Save(folder, SaveAction.Publish, AccessLevel.NoAccess);
}
return folder;
}
Tip: Use GetBySegment to find a child node with a matching name. Performance wise this is better than invoking GetChildren and looping through each child for a possible match.
An easy way to check if the content is being loaded at the correct location is to use the “Set Access Rights” admin plugin:
Display the content in the UI
I want to make the attendees appear in a new tab in the assets pane. In order to do this, two things are needed: a content repository descriptor and a component.
The former is used to describe the attendee repository, which will be used by the component.
[ServiceConfiguration(typeof(IContentRepositoryDescriptor))]
public class AttendeeRepositoryDescriptor : ContentRepositoryDescriptorBase
{
protected Injected<IContentProviderManager> ContentProviderManager { get; set; }
public override string Key { get { return AttendeeProvider.Key; } }
public override string Name { get { return "Attendees"; } }
public override IEnumerable<ContentReference> Roots { get { return new[] { ContentProviderManager.Service.GetProvider(AttendeeProvider.Key).EntryPoint }; } }
public override IEnumerable<Type> ContainedTypes { get { return new[] { typeof(Attendee) }; } }
public override IEnumerable<Type> MainNavigationTypes { get { return new[] { typeof(ContentFolder) }; } }
public override IEnumerable<Type> CreatableTypes { get { return new[] { typeof(Attendee) }; } }
}
If needed, you could return multiple types and roots, meaning that you could create repository descriptors for various items types - not just limited to a certain type.
[Component]
public class AttendeeComponent : ComponentDefinitionBase
{
public AttendeeComponent(): base("epi-cms.widget.HierarchicalList")
{
Categories = new string[] { "content" };
Title = "Attendees";
Description = "All the attendees at the techforum! Displayed neatly in the assets pane";
SortOrder = 1000;
PlugInAreas = new[] { PlugInArea.AssetsDefaultGroup };
Settings.Add(new Setting("repositoryKey", AttendeeProvider.Key));
}
}
Tip: When working with components, you might run into an issue where new components are not being displayed. This could be a caching issue. Using the reset views button is a quick way to fix this. You’ll find it on the My Settings page, beneath the display option tab.
Now we can finally see some data in the UI. A simple view will make it render nicely on the site as well when dragged into a content area.
We should be able to write some data to the provider as well. This is done in Content Providers 101 Part II: From read-only to writeable
Awesome Per!
This article series should be linked from the documentation pages.
Hi,
Great post!
How will this scale? If the attendees.txt contains, say, 10 000 records, will this approach still work, and will the asset pane handle 10 000 items?
Lars Smeby
I've just created a content provider that reads 35 000 user objects from an external database. My biggest concern is the performance issue of the IdentityMappingService. The call to Get seems to take quite some time. I've tried splitting the users into content folders that contain persons with the same first character of the last name. But once the parent folder og the alphabet folders is expanded in the asset pane it starts loading all the children of the folders (the users). I haven't found a way of suspending this last load untill a alphabet folder is expanded (setting IsLeafFolder on the GetChildrenReferenceResult instance to true on the alphabet folders stops anything from loading at any time).
Do you have any suggestions concerning performance and optimalization?
- Bjørn Terje Svennes
Hi Bjørn Terje,
Did you find a solution to your problem?
--Cathinka
Hi, thanks for the great article. I stumbled accross a part I don't understand and thought that mayby someone could help me get a better understanding of the ContentProvider. In the LoadChildrenReferencesAndTypes() method we get the mappedIdentity for setting the GetChildrenReferenceResult.ContentLink. With this code
result is always null for (I don't know why yet) and I can't do an
to create a MappedIdentity cause that throws an exception about that mapping already exists. BUT I can do
with the true flag being "createMissingMapping". What is that method doing to create and return a mapped identity that MapContent(uri, IContent) doesn't do? (I've done some DotPeak/ILSpy and seen that it all boils up to these two methods in the end but I don't get that I first can't create a mapped identity because it already exists but it returns null so I must create a mapped identity.)
Can you please provide source code link ?