Part I - OpenText Content Server Development - An Alternative Approach

Over the last year I've changed how I approach development with OpenText Content Server. The change came after 12 years of conventional approaches, so you may understand it wasn't an easy change to make. However, it has made my job much easier. This blog post will be part of a multipart series that summarizes my motivation for the change, the research I've done, and the framework that came out of it.

Content Server uses a procedural programming approach in the majority of its implementation. What this means is that most implementations have two parts: data and operations on that data. Data represents some type of information (e.g., in the form of an Assoc, Integer, RecArray, Record, etc.) and these are passed into functions and procedures to operate on it. A developer must know what a piece of data represents (e.g., a node, user, workflow, permission, category, etc.), what functions are available, and where they are located in order to use them. Furthermore, it's often required a developer convert between representations of data in order to satisfy the parameter requirements of a function.

For example, say you have the DataID of a node and wish to programatically rename it. You must first fetch the related DAPINODE (via the DAPI library), do some error handling, fetch the related LLNode (via the $LLIAPI.LLNodeSubsystem subsystem), and then finally call the NodeRename() function:

Integer DataID = …

DAPINODE node = DAPI.GetNodeByID( prgCtx.DapiSess(), DAPI.BY_DATAID, DataID )

if IsDefined(node) && IsNotError(node)
	Object llnode = $LLIAPI.LLNodeSubsystem.GetItem( node.pSubtype )

	Assoc results = llnode.NodeRename(node, "My Renamed Node")

	if results.OK
		// all good
	else
		// handle error
	end

else
	// handle error
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

This is a lot of code and requires a deep understanding of where various components are located, how they are related, and how they can be linked together to perform the operation.

Similarly, if you wish to create an open link for a node you must get the WebNode record representation of the node, the related WebNode object, the open WebNodeCMD object, and then finally the URL from the url() function:

Record nodeRec = $WebNode.WebNodeUtils.NodeToWebNode( node )
Object webNode = $WebNode.WebNodes.GetItem( nodeRec.SUBTYPE )
Object cmd = webNode.cmd('open')
String link = cmd.url( request, nodeRec )
1
2
3
4

It seems rather verbose to require all this information from various locations just to generate a URL. It also violates the don't repeat yourself (opens new window) (DRY) principle since it's the same steps anytime you wish to generate a URL for a different node.

Another example are workflow GeneralCallbackScripts. A callback script receives a number of IDs (workID, subWorkID, taskID, etc.) that have little meaning on their own. You can get more data via function calls (e.g., $WFMain.WAPIPkg.LoadWorkData() or prgCtx.WSession().LoadTaskStatus(), etc.), but these return more data structures that require an even deeper understanding of what they mean before further functions can be called on it.

This pattern is found often, and I'd argue it's the hardest thing to learn in Content Server development. What does a piece of data mean? What functions are available to operate on it? Where do I find these functions? How do I get data into a form that complies with the input parameters of a function? What are the intermediate steps to get my desired result?

I'm of the opinion that much of this can be simplified with an object-oriented programming (OOP) approach. For those unfamiliar with OOP, Wikipedia has a good definition (opens new window):

Object-oriented programming (OOP) is a programming paradigm that represents concepts as "objects" that have data fields (attributes that describe the object) and associated procedures known as methods.

In other words, an object is something that encloses the representation of data and its associated functions into a single representation. Sounds good! Further on:

Objects can be thought of as encapsulating their data within a set of functions designed to ensure that the data are used appropriately, and to assist in that use. The object's methods typically include checks and safeguards specific to the data types the object contains. An object can also offer simple-to-use, standardized methods for performing particular operations on its data, while concealing the specifics of how those tasks are accomplished.

The last line summarizes my motivation and goal: I want to be able to rename a node or get a URL with a minimum number of intermediate steps. I don't want to know anything about DAPINODE, the various subsystems, the WebNodes view, the WebNodes object, or the WebNodeCMD object to accomplish these tasks.

Fortunately, OScript supports basic OOP concepts. Not everything is possible, but the fundamentals such as classes, objects, inheritance, overriding, encapsulation, and polymorphism (with a little coercion) is possible. There is even a way to do super calls. Content Server already uses an OOP approach in some areas (e.g., categories and attributes), but it isn't widely used.

Just to be clear: I'm not suggesting a rewrite of core Content Server functionality using OOP. Instead, I believe many complex and verbose patterns can be simplified by abstracting their implementation into an object, which provides a simpler interface to the developer. But also, OOP is a great tool when it comes to developing new modules and features.

Let me illustrate with an example from my RHCore library. In the library is a class named RHNode, which represents a node in Content Server (I tend to prefix my stuff with "RH" to minimize naming conflicts and confusion with other modules). It is subclassed from RHObject, which is my base class (similar to how java.lang.Object is the base class in Java). An RHNode object can be constructed from a DataID, a DAPINODE, Record (DTree or WebNodes), or nickname:

// node can be a DataID, DAPINODE, Record, or nickname
Frame mynode = $RHCore.RHNode.New( prgCtx, node )
1
2

This returns an "object" (in the OOP-sense, using the Content Server Frame datatype, and not to be confused with the Object datatype in Content Server) representing my node. Inside that object is all the data pertaining to the node (e.g., name, dataid, create date, owner, parent, etc.) plus methods to operate on it. For example, it includes a rename() method:

Assoc results = mynode.rename("My Renamed Node")

if results.OK
	// all good
else
	// handle error
end
1
2
3
4
5
6
7

It also includes a method to create URLs:

String url = mynode.url(request, 'open')
1

These examples work without having to know anything about a DAPINODE, the WebNodes representation of the node, or the various subsystems. That's all abstracted away and managed behind the scenes by RHNode. Of course, accessors such as dapinode(), nodeRec(), llnode(), webNode(), etc. are available if you need them.

System attributes (e.g., Name, DataID, ParentID, CreateDate, ExtendedData, etc.) are accessible with the valueForKey method:

String nodeName = mynode.valueForKey('name')
Integer parentid = mynode.valueForKey('parentid')
1
2

However, some commonly used attributes have their own method (these are just convenient shortcuts):

String nodeName = mynode.name()
Assoc extendeddata = mynode.extendeddata()
1
2

Here is where it gets interesting. RHNode has a number of methods for fetching related information and calculated values that would otherwise require multiple lines of code. A few examples:

  • the subTypeName() method returns the subtype name of the node (e.g., "Folder", "Document", etc.);
  • the size() method returns the size of the node in a human-readable format (e.g. 25KB, 5 items, 3 Sub-Projects, etc.);
  • the isEditable() method returns true if the current user has edit permission on the node;
  • the gif() method returns the path (relative to the support directory) of the node's icon;
  • the versionAsText() method returns the contents of the document (as long as it has a text-based mime type);
  • the categoryvalues() method returns the node's category values in a simplified Assoc that is easy to traverse;
  • the children() method returns the child objects as an Iterable (a collection of values that can be looped, but more on the Iterable class another time); and
  • the parent() method returns the parent as an RHNode, or Undefined if it's not accessible.

The last example means you can fetch the name of the parent like this:

Frame parentNode = mynode.parent()
String parentName = parentNode.name()
1
2

Or, chain them together into a single line:

String parentName = mynode.parent().name()
1

Or get the name of the parent's parent:

String grandparentName = mynode.parent().parent().name()
1

An astute reader may have noticed the problem here: If a call in the chain returns Undefined it will cause the thread to crash on the subsequent call. Since OScript doesn't support exception handling we are forced to handle this differently.

The concept of key-value coding was made aware to me by Apple when I started developing for the iPhone. From their documentation (opens new window):

A key is a string that identifies a specific property of an object. Typically, a key corresponds to the name of an accessor method or instance variable in the receiving object.

To resolve a key we use the valueForKey method (defined in the RHObject base class), which I introduced earlier for resolving system attributes. However, this method also works with accessor methods. Accessor methods are methods that do not accept parameters and do not alter the state of the object. In other words, the valueForKey() method can be used with all the methods listed above. For example, each of the following pairs return the same value:

node.parent()
node.valueForKey('parent')

node.size()
node.valueForKey('size')

node.categoryvalues()
node.valueForKey('categoryvalues')
1
2
3
4
5
6
7
8

Reading on in the documentation:

A key path is a string of dot separated keys that is used to specify a sequence of object properties to traverse. The property of the first key in the sequence is relative to the receiver, and each subsequent key is evaluated relative to the value of the previous property.

To resolve a key path we use the valueForKeyPath() method, which RHNode also inherits from RHObject. For example:

String grandparentName = node.valueForKeyPath("parent.parent.name")
1

By default, the valueForKeyPath() method returns Undefined if a key path cannot be fully resolved. This can be changed with an optional second parameter:

String grandparentName = node.valueForKeyPath("parent.parent.name", "<< Unknown Name >>")
1

As an aside: The valueForKeyPath() function is also available as a standalone function and can be used to traverse any structured data type in Content Server without having to assert if a feature exists before attempting to access it. It's very handy.

That said, the valueForKeyPath() method permits some otherwise complicated operations to be performed in a single line of code. For example, say you have a node and on the parent is a category (named "My Category") with a multi-valued attribute (named "My Attribute") and you wish to fetch the second value. This is simply:

Dynamic value = node.valueForKeyPath("parent.categoryvalues.'My Category'.'My Attribute'.2")
1

Another class in the same genre is RHUser. RHUser wraps the details of a user or group and abstracts away many of the complicated UAPI and $LLIApi.UsersPkg function calls to manipulate users, groups, memberships, and roles. It can be constructed directly:

Frame rhuser = $RHCore.RHUser.New( prgCtx, 1000 )
1

Or, it can also be retrieved from an RHNode:

Frame rhuser = node.user() // fetches the node owner as an RHUser
Frame rhgroup = node.group() // fetches the node group as an RHUser
1
2

Once you have an RHUser instance you can do some interesting things:

Boolean isUser = rhuser.isUser() // return true
Boolean isUser = rhgroup.isUser() // return false

String username = rhuser.name() // returns "Admin"

// get the Personal Workspace as an RHNode
Frame pws = rhuser.personalworkspace()

// returns all direct memberships of this user as an iterator
Frame groups = rhuser.memberships()

// returns all direct & indirect memberships of this user as an iterator
Frame allgroups = rhuser.allmemberships()

// returns all nodes where the user is the owner
Frame nodes = rhuser.ownedNodes()

// fetch the last login date from the audit logs
Date lastLogin = rhuser.lastLogin()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

RHUser also has a method to return the display name as configured in the admin.index page:

String displayName = rhuser.displayName()
1

Of course, RHUser is also key path compliant, which means you can use a key path to retrieve the display name of a node's owner in a single line:

String ownerDisplayName = node.valueForKeyPath('user.displayName')
1

This only scratches the surface of what's possible. I've written similar wrappers for permissions, workflows, workflow tasks, and audit information, and will soon be adding support for Records Management. Not only is OOP useful for abstracting away Content Server data and functions, but it can also be used as the basis for other frameworks or tools. Take my zip and download wrapper class as an example, which simplifies the generation of zip files:

Frame myZip = $RHCore.ZIP.New(prgCtx, 'myzipfile.zip') // create an object to which I can add documents
myZip.add( node  ) // node can be an RHNode, DataID, DAPINODE, nodeRec, or nickname of a document
myZip.add( node2 )
…
String downloadURL = myZip.zip() // zip up the files and return a download URL for myzipfile.zip
1
2
3
4
5

Using an object-oriented programming approach has completely changed the way I develop for Content Server. It's become the basis of my RHCore development framework, and is proving to be extremely effective in my projects. I'm doing much more with less code and fewer bugs.

In my next blog post I will expand on this framework. I will start with the model component, which allows a developer to create a complex relational database schema with a fully functional object-based API for querying, adding, updating, and deleting records (with referential integrity) in only a few lines of code and without a single line of SQL. Stay tuned.