Views: 11599
Number of votes: 8
Average rating:

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:

entry

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:

attendee_accessrights 

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) }; } }
}
The repositry descriptor above is configured to serve the Attendee type, and the Roots property has been set to the EntryPoint in the provider.
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.
 
To display the content in the assets pane, I have created a component. In the component, a reference to a dojo component must be defined. For this, I’ve used the built-in HierarchialList component, which is a base dojo component for listing content. If you need to create a custom one, it could be a good idea to check out the uncompressed js file. It is located at \modules\_protected\CMS\EPiServer.Cms.Shell.UI.zip\7.X\ClientResources\epi-cms\widget\HierarchicalList.js.uncompressed.js.
The title and description has been hard coded in this example. In order to use localization files, use the LanguagePath property.
 
[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.

attendee_overview

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

Nov 14, 2014

johan.petersson
(By johan.petersson, 11/14/2014 9:56:27 PM)

Awesome Per!

This article series should be linked from the documentation pages.

Lars Smeby
(By Lars Smeby, 5/10/2016 1:04:28 PM)

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

Bjørn Terje Svennes
(By Bjørn Terje Svennes, 7/30/2016 3:17:17 PM)

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

Cathinka Walberg
(By Cathinka Walberg, 5/3/2017 3:01:24 PM)

Hi Bjørn Terje,

Did you find a solution to your problem?

--Cathinka

Andreas  Johansson
(By Andreas Johansson, 5/30/2017 12:24:37 AM)

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

var result = _identityMappingService.Get(personUri)


result is always null for (I don't know why yet) and I can't do an

_identityMappingService.MapContent(MappedIdentity.ConstructExternalIdentifier(ProviderKey, person.Email), ConvertToAttendee(person))

to create a MappedIdentity cause that throws an exception about that mapping already exists. BUT I can do 

var result = _identityMappingService.Get(personUri, true)

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.)

    internal MappedIdentity MapContent(Uri externalIdentifier, ContentReference contentLink, Guid contentGuid)
    {
      return this.Database.ExecuteTransaction((Func) (() =>
      {
        using (SqlCommand sqlCommand = (SqlCommand) this.CreateCommand("netMappedIdentityMapContent"))
        {
          sqlCommand.Parameters.Add((object) this.CreateParameter("Provider", (object) externalIdentifier.Host));
          sqlCommand.Parameters.Add((object) this.CreateParameter("ProviderUniqueId", (object) IdentityMappingDB.RemoveBeginningSlash(externalIdentifier.PathAndQuery + externalIdentifier.Fragment)));
          sqlCommand.Parameters.Add((object) this.CreateParameter("ExistingContentId", (object) contentLink.ID));
          sqlCommand.Parameters.Add((object) this.CreateParameter("ExistingCustomProvider", string.IsNullOrEmpty(contentLink.ProviderName) ? (object) DBNull.Value : (object) true));
          sqlCommand.Parameters.Add((object) this.CreateParameter("ContentGuid", (object) contentGuid));
          sqlCommand.Parameters.Add((object) this.CreateReturnParameter());
          sqlCommand.ExecuteNonQuery();
          if (this.GetReturnValue((DbCommand) sqlCommand) != 0)
            throw new InvalidOperationException("There is already a mapping for externalIdentifier: " + (object) externalIdentifier);
          return new MappedIdentity()
          {
            ContentLink = ContentReferenceExtensions.ToReferenceWithoutVersion(contentLink),
            ContentGuid = contentGuid,
            ExternalIdentifier = externalIdentifier
          };
        }
      }));
    }

    internal IEnumerable List(IEnumerable externalIdentitifiers, bool createMissingMappings)
    {
      if (!Enumerable.Any(externalIdentitifiers))
        return Enumerable.Empty();
      return this.Database.ExecuteTransaction>((Func>) (() =>
      {
        using (SqlCommand cmd = (SqlCommand) this.CreateCommand("netMappedIdentityGetOrCreate"))
        {
          cmd.Parameters.Add((object) this.CreateParameter("CreateIfMissing", (object) (bool) (createMissingMappings ? 1 : 0)));
          List uriPartsTable = this.CreateUriPartsTable(externalIdentitifiers);
          SqlParameter sqlParameter = cmd.Parameters.AddWithValue("@ExternalIds", (object) uriPartsTable);
          int num = 30;
          sqlParameter.SqlDbType = (SqlDbType) num;
          string str = "UriPartsTable";
          sqlParameter.TypeName = str;
          return this.ExecuteListUriCommand(cmd);
        }
      }));
    }

Please login to comment.