Chapter 2. Exploring the CMIS domain model

This chapter covers

  • Establishing communications with a CMIS service
  • Using the features of a repository
  • Navigating the folder hierarchy
  • Retrieving a document with its content stream and properties (metadata)

In chapter 1, you received a high-level introduction to CMIS as a specification. Every object that lives in a CMIS repository is an instance of an object type. In this chapter, we’ll explore the basic object types that make up the CMIS domain model as well as some of the key concepts that bind them all together into a useful system. Along the way, you’ll write some Java/Groovy code (using the Workbench that was introduced in chapter 1) to illustrate key concepts.

Although it’s a bit of a cliché, a picture is still worth a thousand words, so we’ll start this chapter with an illustration of the object types we’ll be talking about. Sometimes a clear image in your mind can help you organize related ideas as they arrive. Figure 2.1 shows the interrelationships between all of the high-level object types we’ll cover in this chapter. Ordered from the highest level and progressing downward (left to right in the figure) are the CMIS service, the binding chosen between the service and the CMIS client, repository, folder, and finally, document. Refer back to this diagram as you move through the sections of this chapter to refresh your understanding of their respective roles.

Figure 2.1. CMIS high-level object types (all of which we’ll discuss in this chapter)

By the time you’ve finished this chapter, you’ll have a clear picture of what the object types in figure 2.1 are, what they do in the context of a CMIS server, and how they relate to each other. We’ll be revisiting this diagram as we move through the individual sections of the chapter to remind you of where you are in the big picture, but try to remember this image as we move on to the service.

2.1. The CMIS service

Of all of the items in figure 2.1, the CMIS service is unique in that it’s not a persisted object like all of the other items; rather, it’s a running program to service your requests. Think of the CMIS service as an interface to all of the CMIS objects you’ll be dealing with (see figure 2.2). If a real-world analogy helps, think of it as a concierge at a hotel. This is probably a hotel somewhere in Europe, though, because this particular CMIS concierge must always speak two languages, and in some cases can even speak three. This is because CMIS servers must implement two bindings (three in CMIS 1.1).

Figure 2.2. The CMIS service is an interface to all of the CMIS repositories and the objects that they contain.

This section will familiarize you with the CMIS service and how it’s the key to this whole picture.

2.1.1. The role of the CMIS service

At the highest level, the CMIS service is responsible for these three functions:

  • Allow a client to discover what repositories are present for this particular CMIS service.
  • Provide all the details about the capabilities of these repositories.
  • For each of the repositories, publish the interfaces for the nine subservices that are exposed for every CMIS repository (see the following note).
The nine subservices of CMIS

We’ll cover all of these subservices in detail in later chapters, but in case you can’t wait, here’s a quick list:

  • Repository services (discussed in this chapter)Example: getRepositoryInfo
  • Navigation servicesExample: getFolderTree
  • Object servicesExample: getObject
  • Multifiling servicesExample: addObjectToFolder
  • Discovery servicesExample: query
  • Versioning servicesExample: checkOut
  • Relationship servicesExample: getObjectRelationships
  • Policy servicesExample: applyPolicy
  • ACL (access control lists) servicesExample: applyACL

Don’t worry too much about these nine subservices yet, because from a client perspective they’re somewhat arbitrary groupings of the functionality. We’ll introduce you to them gradually as we move through the basic exercises in this book. By the time you’re done with this chapter, you’ll be familiar with the first three in the list. By the time we’re done with part 1 of the book, you’ll have used most of them.

2.1.2. Bindings: what does a CMIS service look like?

Recall that our concierge must speak at least two languages. These two languages are analogous to the two protocol bindings (Web Services and AtomPub) that all CMIS servers must speak. If you’re a CMIS client, you can speak either of these languages (bindings) and always know that the hotel desk will be able to understand you. In a perfect world with lots of unicorns and rainbows, we’d have been able to require only one protocol, and every possible client would be able to speak it. In that same perfect place, our European concierge would only ever have to speak one language. But the reality is that many different types of processes exist on many platforms that need to talk to CMIS, and some protocols are easier for some to manage than others.

In the case of CMIS 1.0, we have the Web Services and the RESTful AtomPub bindings. What about that third language that’s sometimes used? Well, CMIS 1.1 adds a new optional binding called the Browser binding. This optional binding or protocol is similar to the AtomPub binding in a lot of ways, except that it’s designed to be easy to access from JavaScript in a browser. We’ll cover more differences later in the book, but this will suffice until we get to chapter 11, when we’ll go into greater detail about the innards of all of the bindings. Figure 2.3 shows multiple clients talking to one CMIS service, each using one of the CMIS 1.1 supported bindings.

Figure 2.3. Three bindings expose the same functionality for clients with different needs.

Let’s get back to the questions we were trying to answer. What does a CMIS service look like? Regardless of the binding, it looks like a simple HTTP URL. In the case of the Web Services binding, this URL is the address of the WSDL (Web Services Description Language) document for the web service. In the case of the AtomPub and Browser bindings, it’s the address of the service document (XML or JSON). When a client retrieves these documents, they have the keys they need to start talking to CMIS in earnest.

2.2. Repository—the CMIS database

If you were asked to distill a CMIS repository down to its most simple role, you could safely get away with thinking of it as a database. More specifically, it’s a database that knows a lot about the semantics of unstructured content and even more specifically about content management. It’s a hierarchical store of content and the metadata describing not only the content itself but its organization and relationships to other content within the same repository.

As you can see in figure 2.4, multiple repositories can optionally be exposed by a given CMIS service. When you connected to the repository in chapter 1, you clicked Load Repositories and then chose the only repository presented—a repository with an ID of A1. Behind the scenes, the server was responding to a getRepository call and returning the list of available repositories.

Figure 2.4. The repository is where all of the objects are stored.

A helpful analogy to use for the repository is that of a disk drive in a typical desktop computer. A server (which would be the CMIS service in this analogy) can host many disk drives, just like a CMIS server can support multiple repositories. Each of these drives may be formatted with different filesystems (different metadata, in CMIS terminology), and each has its own root directory, which may optionally contain other folders and files.

2.2.1. Repository info and capabilities

In chapter 1, you connected to the repository and went straight to the root folder for the example. Normally, however, when you first talk to a CMIS service, you may want to know a bit about what its capabilities are so that your client code can expose the menus and commands that match the repository.

Specification reference: getRepositoryInfo

For a more formal discussion of getRepositoryInfo, check out section 2.2.2.2, getRepositoryInfo, in the CMIS 1.0 specification. (See appendix E for references.)

For this exercise, you’ll need to go back to the CMIS Workbench session you set up in chapter 1. Once you’re connected, look at the buttons across the top of the application (shown in figure 2.5).

Figure 2.5. Repository Info button in the Workbench

You’ll see at the top left that the second one is labeled Repository Info. If you click this button, the CMIS Workbench will display the information returned for the CMIS getRepositoryInfo call. Figure 2.6 shows this information. The ACL capabilities are omitted here because we’ll talk about those in detail in chapter 12.

Figure 2.6. CMIS Repository Info display in CMIS Workbench

As you can see in figure 2.6, this call returns a wealth of information, including the following:

  • Information about the server vendor
  • The supported CMIS version
  • The ID of the root folder (very important)
  • Details on support for certain navigational operations
  • Details on supported filing operations
  • Details on supported versioning operations
  • Details on supported query functions and advanced query features

We’ll discuss all of these items in more detail in later chapters. All you need to know for now is that this response contains everything that a client needs to start talking to a CMIS server.

2.2.2. Capabilities across different repository vendors

As you look over the capabilities that your test InMemory server is reporting, you can start to see how CMIS manages to smoothly communicate with so many different repository implementations. CMIS needs to be able to accommodate repositories that have advanced features while at the same time enabling repositories with minimal features to play. This optional capabilities information is the most coarse-grained level of this type of information, and you’ll see more of this throughout the specification as we explore further in upcoming chapters.

Spec reference: optional capabilities

For a detailed list of all of the optional capabilities, as well as their definitions, see section 2.1.1.1 of the CMIS 1.0 specification. (See appendix E for references.)

Say you were building a folder-browsing client and you wanted to be able to pull down the entire folder tree hierarchy in one round trip to the server, for efficiency reasons. Your client would then want to check to see if the repository capability getFolderTree was supported. If so, it would have the most efficient code path, and if not, it could degrade to iteratively crawling the hierarchy to collect the needed information.

2.2.3. Try it—retrieve the repository info

Let’s look at the code you need to get at the repository info. You’ll continue to use the CMIS Workbench for this exercise. Your code will list the repository info and the capabilities of the repository you’re connected to in the Workbench.

In the code exercise in chapter 1, you used Groovy for the example. A nice thing about the Groovy interpreter is that pure Java syntax is valid as well. To illustrate this, the code in the examples for this chapter will be in Java form. Feel free to use the form you feel more comfortable with, or switch back and forth if you like variety. Keep in mind that the project you’ll build in part 2 of the book will be written mainly in Java.

For this exercise, return to the Groovy console window in the CMIS Workbench and then copy this code into your code pane.

Listing 2.1. getRepositoryInfo code example

Figure 2.7 shows the output in the Groovy console.

Figure 2.7. Groovy console output for the getRepositoryInfo code example

As you can see, the OpenCMIS API makes parsing this information trivial. If you were doing this without Chemistry, you’d need to parse the raw XML response into your own structure of values either manually or with a library like JAXB (Java Architecture for XML Binding). For a discussion of what bindings are available and what the XML schema looks like for each, have a look at chapter 11.

2.3. Folders

In this section, we’ll cover CMIS folders at the highest level: what they do, what they look like, and how they’re related to each other and to documents.

2.3.1. The role of folders

Folders in CMIS are much like folders in filesystems that you’re already using from day to day. Every CMIS repository must have at least one folder, the root folder, as you can see in figure 2.8. When you retrieve the repository info, you’ll see there’s always a root folder ID present. This is the starting point that clients must always use if they’re doing folder navigation.

Figure 2.8. Folder shown with relationship to repository and document

The important rule to remember with CMIS folders is that every folder must have one, and only one, parent folder. The only exception is the root folder. You can think of the root folder’s parent as the repository that hosts it, even though technically CMIS root folders are parentless—that’s the only attribute (aside from their place at the top of the folder hierarchy) that makes them unique among all of the other folders. All folders (like their filesystem equivalents) have an associated path, as do all CMIS objects that are contained in folders. (We’ll talk more about the path properties of CMIS objects in part 2 of the book.)

Also note that every base CMIS object type has a unique ID defined by the specification. For folders the ID is cmis:folder. When you see the name of an object type with the cmis: prefix, you’ll know that this is an object type that’s defined in the CMIS specification’s object model. We’ll talk a lot more about the base object types when we get to chapter 4.

Spec reference: folders

To see the full normative definition of CMIS folders, including all of their attributes, see section 2.1.5 of the CMIS 1.0 specification. (See appendix E for references.)

CMIS Workbench has a simple, built-in folder navigation feature as well. If you recall from your exercises in chapter 1, when you first connect to a repository, you see the folders and documents contained in the root folder displayed in the left-most pane. But it only shows a flat list at one level. If you want to see it presented as a hierarchy, you’ll have to move on to the next section, where you’ll write some code to display the entire folder hierarchy from your InMemory server.

2.3.2. Try it—folder navigation

For listing 2.2, we’ll go back to the CMIS Workbench Groovy console view again. This time you’ll use the CMIS folder’s getDescendants function. After making the call, you’ll recursively iterate through the results, dumping them to the console output window using spaces to indent each level you traverse.

Listing 2.2. getDescendants code example

The output for this exercise is shown in figure 2.9.

Figure 2.9. Groovy console output—dumping the folder and document hierarchy

Note that in addition to the getDescendants function you used, CMIS contains a full suite of other navigation-related functions for you to explore. We’ll touch on all of these navigation functions in more detail in later chapters, but the full list is as follows:

  • getChildren()—Gets only the direct containees of a folder
  • getDescendants()—Gets the containees of a folder and all of their children to a specified depth
  • getFolderTree()—Gets the set of descendant folder objects contained in the specified folder
  • getFolderParent()—Gets the parent folder object for the specified folder
  • getObjectParents()—Gets the parent folder(s) for the specified nonfolder object

2.4. Documents

Moving right along in our tour of the domain model, we’ve arrived at document. Figure 2.10 gives you a quick high-level picture of where we are now and how documents fit into the larger picture.

Figure 2.10. Documents can be contained in folders or unfiled children of a repository. Unfiled documents are retrieved from the repository’s “unfiled documents” collection.

In CMIS, documents are where the rubber meets the road. Without them, there wouldn’t be much point in having a document management system, would there? This section will get you familiar with the CMIS document type at an introductory level. We’ll also introduce the subject of properties, which are present on all of the other CMIS object types, like folders, but are used more extensively on documents. This is why we waited until now to spring them on you. After we’ve covered the basics, we’ll pop back into the CMIS Workbench to write some more code, and then create, file, and retrieve documents and their properties. Here we go!

2.4.1. The role of documents

To properly explain the role of documents, we’ll switch to a different perspective. Figure 2.11 shows an object model view that describes the base cmis:object common to all of the objects you’ll see in CMIS. As an extension to this base type, you see cmis:document (which is the CMIS ID for this object type) with its content stream indicated as a contained subobject. Keep in mind that there’s a lot more to cmis:document than just being an additional content stream. We’ll cover all of those details in later chapters, but this is all you need to be aware of for now.

Figure 2.11. CMIS object model view: these properties are common to all object types, but only document has a content stream.

A word about cmis:object

In this book (as well as in the 1.1 specification), you’ll see some mention of cmis:object as if there were a base class for all of the five base CMIS object types. Technically speaking, the specification doesn’t call out the existence of such a base class. But the CMIS Technical Committee has made an effort to keep a certain key set of properties common to all CMIS objects (see section 2.4.2) so that in object-oriented (OO) language bindings, they could be modeled as if they were from a common parent (object). Whether you choose to think of all of the base objects as sharing these properties, or inheriting them, the end result is the same.

Spec reference: CMIS object models

If you’d like to see a much more detailed model type view of all of the CMIS object types, see section 2.1 (Data Model) in the CMIS 1.1 specification. (See appendix E for references).

2.4.2. Properties

As you can see in figure 2.11, all CMIS objects have properties. We’ll get into much more detail about types in chapter 4, but one of the things that distinguishes one object type from another is the specific properties that are defined for that type. But before we can talk about the properties on documents, we first need to take a short diversion and talk about the properties that are common to all CMIS object types.

Properties common to all CMIS 1.0 object types

These are the properties that you’ll find on all CMIS object types, regardless of their base type. For a given repository, there may be many more custom properties in addition to these:

  • cmis:name (String)—The name of this object.
  • cmis:objectId (ID)—The opaque identifier for this object. It’s unique among all other objects in this repository.
  • cmis:baseTypeId (ID)—The opaque identifier for the base type of this object. We’ll cover types in chapter 4.
  • cmis:objectTypeId (ID)—The opaque identifier for this object’s type.
  • cmis:createdBy (String)—The name of the user that created this object in this repository.
  • cmis:creationDate (DateTime)—The date and time when this object was created.
  • cmis:lastModifiedBy (String)—The name of the user who last modified this object.
  • cmis:lastModificationDate (DateTime)—The date and time this object was last modified.
  • cmis:changeToken (String)—An opaque token used to identify a point in the lifecycle of this object. We’ll talk more about these tokens in chapter 8.
Why are these identifiers opaque?

You probably noticed that the identifiers in the list of common object types aren’t only identifiers, they’re opaque identifiers. When something is described as opaque, it means it should be treated as if you can’t tell what’s in it.

For example, if we showed you an identifier that looked like “jeff-potts-tulsa-1.2,” you might try to make some sense of that string. You might assume the identifier is talking about something having to do with a person named “Jeff Potts” who has a relationship to a city named “Tulsa” and that maybe this is version 1.2 of that object. You might even write some code that implements those assumptions. But in CMIS, when you see that something is opaque, you must avoid the temptation to write code that depends on an understanding of how that particular identifier is constructed, because the repository is free to change how it implements opaque identifiers at any time.

Properties common to all CMIS 1.0 documents

These are all of the properties that are both unique to and present on all CMIS 1.0 documents (remember that all of the properties common to all objects are also common to documents):

  • cmis:isImmutable (Boolean)—Indicates the CMIS service will throw an exception on an attempt to modify this object.
  • cmis:isLatestVersion (Boolean)—Indicates whether this object is the latest version of its version series. We’ll talk more about versions in chapter 3.
  • cmis:isMajorVersion (Boolean)—Indicates whether this object is a major version (true) or minor (false).
  • cmis:isLatestMajorVersion (Boolean)—Indicates whether this document is the latest major version. The latest major version has special significance in some repositories.
  • cmis:versionLabel (String)—The string rendering of the document’s version information. For example, 1.5 would indicate major version 1 and minor version 5.
  • cmis:versionSeriesId (ID)—The opaque identifier of this object’s version series. We’ll look more at version series objects in chapter 3.
  • cmis:isVersionSeriesCheckedOut (Boolean)—Indicates whether this document is currently in a checked-out state.
  • cmis:versionSeriesCheckedOutBy (String)—The name of the user that performed the checkout operation on this document.
  • cmis:versionSeriesCheckedOutId (ID)—An opaque identifier of the Private Working Copy (PWC) for this object’s version series. More on PWC objects in chapter 3.
  • cmis:checkinComment (String)—The comment associated with this version of the document.
  • cmis:contentStreamLength (Integer)—The length of this document’s associated content stream, if one is present.
  • cmis:contentStreamMimeType (String)—The MIME type of the content stream associated with this document.
  • cmis:contentStreamFileName (String)—The name of the file stored in this document’s content stream, if present.
  • cmis:contentStreamId (ID)—The opaque identifier of this document’s content stream, if present.

You may notice that all of these additional properties deal with versioning and content stream information. In later chapters, when we explore the other types of base CMIS object types, you’ll see that they each have their own set of object-type-specific properties.

A few more basic rules about properties

A CMIS property may hold zero, one, or more typed data value(s), and each property may be single- or multivalued. Single-valued properties contain (drum roll here) a single data value, and multivalued properties contain an ordered list of data values of the same type. The ordering in a multivalued property should be preserved by the repository, but this isn’t guaranteed.

Any property (single- or multivalued) can be in a not-set state, but the CMIS specification doesn’t support a null property value.

If a multivalued property is set, it must contain a non-empty list of individual values. Each individual value in the list must have a value (that is, it can’t be not set), and each of those values must be of the same type, conforming to its multivalued property’s type. In other words, a multivalued property is either set or not set in its entirety.

Individual values of multivalued properties must be set to hold a position in the list of values. Empty lists of values are not allowed, nor are sparse lists. For example, you may not have a sparse string list property with values {"a," "b," null, "c"}, but a string list with values {"a," "b," ""} would be OK, because for strings an empty string is a set value distinct from null.

Base property data types

All CMIS properties are typed and must be one of the eight base property data types listed in the specification. Table 2.1 shows these base property types and their corresponding OpenCMIS interface names. All of the OpenCMIS property interfaces are in the org.apache.chemistry.opencmis.commons.data package, and all inherit the org.apache.chemistry.opencmis.client.api.Property interface.

Table 2.1. Eight base property data types supported by CMIS and OpenCMIS

CMIS property

Java data type

OpenCMIS interface

string java.lang.String PropertyString
boolean java.lang.Boolean PropertyBoolean
integer java.math.BigInteger PropertyInteger
decimal java.math.BigDecimal PropertyDecimal
datetime java.util.GregorianCalendar PropertyDateTime
id java.lang.String PropertyId
html java.lang.String PropertyHtml
uri java.lang.String PropertyUri
Rules to be aware of when dealing with html, id, and uri properties

  • An html property value can be a fragment and need not be valid. For example, the following string isn’t completely valid from an HTML standpoint, but it’s allowed to be stored in an html property: <html><body>My body is truncated.
  • A uri value may or may not be checked by the repository.
  • An id value doesn’t need to be a valid ID in the repository.
Custom properties

Although we’ll cover this in much more detail in chapter 4, it’s worth mentioning that the types we’ve shown you so far are only the properties that are defined by CMIS for all documents. These properties are common to any ECM system. The flexible thing about ECM systems and about CMIS is that there can be many different types of documents with any number of custom properties defined on them. When we get into part 2 of the book and start building a custom CMIS music management application, we’ll define custom properties that are specific to music MIME types. You’ll see some of the powerful things you can do with these properties when we talk about Query in chapter 5.

2.4.3. Try it—list a document’s properties

It’s time now to go back to the Groovy console in CMIS Workbench to write some code. This time you’ll find the first document object in the root folder and list all of its system properties.

Listing 2.3. List the system (cmis:xxx) properties for the first document we find.
import org.apache.chemistry.opencmis.commons.*
import org.apache.chemistry.opencmis.commons.data.*
import org.apache.chemistry.opencmis.commons.enums.*
import org.apache.chemistry.opencmis.client.api.*

// obtain the root folder object
Folder rootFolder = session.getRootFolder();
foundCount = 0;

for (t in rootFolder.getChildren()) {
  // until we find an object that is a doc type or subtype
  if (t instanceof Document) {
    println("name:" + t.getName());
    foundCount += 1;
    List<Property<?>> props = t.getProperties();

    // list all of the system properties that is those
    // that begin with the cmis: prefix we listed earlier
    for (p in props) {
      if (p.getId().startsWith("cmis:")) {
        println("  " + p.getDefinition().getId()
            + "=" + p.getValuesAsString());
      }
    }
  }
  if (foundCount > 0) {
    break;   // we can stop after the first one is found
  }
}

Copy the code from listing 2.3 into your Groovy console and give it a run. Figure 2.12 shows the output from CMIS Workbench when it’s connected to the OpenCMIS InMemory Repository with the default sample data loaded. The output from the run is always displayed in the lower window.

Figure 2.12. Output from default data in the document property exercise

Using the Groovy console in Workbench

Don’t forget that every time you use the session object in the Groovy console, you’re sharing the session object from the CMIS Workbench session. If the CMIS Workbench isn’t connected to a live server, your session object in the console isn’t going to do you much good.

2.4.4. Content streams

Now that we’ve covered all of the properties of a document, we can finally get to the document itself. As you can see in figure 2.13, there can be either 0 or 1 associated content streams with every CMIS document. This is what’s sometimes referred to as the payload of the document. It might be a binary or text file of any MIME type and of any size, depending on your repository limitations. This is one of a handful of things that make a document special in CMIS and, more generally, special in all ECM systems.

Figure 2.13. A content stream of 0 or 1 per document is accessible via CMIS.

2.4.5. Try it—retrieve a document’s content stream

In this exercise, you’ll retrieve a text document from your test InMemory server and inspect its contents. Because the InMemory server starts up with some test data, you’ll search for the first text document that you find in the root folder, and then retrieve its content stream, as shown in listing 2.4 (the helper method that gets the contents of a stream is taken from the “OpenCMIS Client API Developer’s Guide” at http://chemistry.apache.org/java/developing/guide.html). Finally, so you have something to show for all of this, you’ll display the first line of the document’s stream text to the console.

Listing 2.4. Retrieving a document’s content stream and stream properties

The output of this code is shown in figure 2.14.

Figure 2.14. Output of code for retrieving a document’s content stream

2.5. The item object type (version 1.1)

You’re probably thinking, “Hey, where did this CMIS item object type come from anyway? I don’t remember seeing this in the main diagram.” That’s because CMIS item (cmis:item) is new to CMIS version 1.1, so we decided to leave it until you understood the document basics. It turns out that many CMIS repositories have object types whose instances are fileable, like documents, but that are much less heavyweight. For example, they might not have any content streams associated with them, and they might not be versionable either. Don’t worry, we’ll talk about versioning in chapter 3.

In CMIS 1.1, we created a brand-new, top-level type named item that would be the base type for all objects that have properties but aren’t documents. At the most basic level, you can think of an item as a fileable collection of properties or even a complex object type. For example, suppose you want to store some configuration information for your application in the CMIS repository. You might choose to persist the application configuration as a set of key-value pairs that would be defined as properties on an object type that extends cmis:item.

Spec reference: CMIS item

For a detailed list of CMIS item’s properties and attributes, see section 2.1.8 of the CMIS 1.1 specification. (See appendix E for references.)

2.6. Summary

In this chapter, you were introduced to the key high-level concepts in a CMIS system: the service, repository, folder (cmis:folder), document (cmis:document), and item (cmis:item), and each one’s respective properties. We even sprinkled in a little taste of the bindings. You used the OpenCMIS API to discover a repository’s capabilities, browse its folder hierarchy, and retrieve its document’s properties and content streams. Along the way, you were given your first peek at the object model for CMIS and you saw how all CMIS object types share a common set of properties. In later chapters, we’ll fill out these images you now have in your head with more details. These concepts will be your guideposts as you progress through the rest of part 1. By the time you have completed the next three chapters, you should have a good general understanding of CMIS, enough to dive into part 2 and build a useful (and we hope fun) application.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.188.190.175