Overview
As a follow up to my previous blog entry in which I demonstrated how to build a JSON Serializer for Documentum, I thought it would be interesting to build a service that allows you to enter a DQL query and have the results returned as a array of JSON objects. The Object Broker that was developed in my previous post in this series, was a service which was RESTFUL in nature and leveraged the JSON Serializer for Documentum to expose object metadata to consumer applications.
The goal of this post is to demonstrate how the JSON Serializer for Documentum can be further leveraged to provide a DQL query service that can be invoked asynchronously. It allows you to perform DQL queries from any application that supports JSON and web requests. Similar to the Object Broker Service, the Query Service allows you to perform object queries without requiring the application consuming the data to have any knowledge or reference to the EMC Documentum libraries (e.g. DFC or DFS Client). The solution is RESTFUL in nature, thus making it very lightweight and re-usable.
The sample demonstration solution is a pure HTML and JavaScript solution to demonstrate the lightweight nature of the data service. The web page contains a TEXTAREA control for the query and an Execute button that when clicked makes the asynchronous request for the query results.
The following figure shows what the user sees upon clicking the Execute button, but before the results are returned to the user.
The following screen shows what the user sees once the results are received by the client.
Since the request is made asynchronously, the screen prints don’t do this demonstration justice. The key benefit is that the web page is never “refreshed”. The results (JSONArray containing multiple JSONObject’s) are displayed by dynamically creating a table using JavaScript and DOM once the asynchronous response is received.
Technical Objectives
The primary objective is to build a lightweight solution to return an array of JSON Objects for the specified DQL query. With this goal in mind, we will:
- Build a server-side interface (Servlet) that returns a JSON Array containing the results of the query specified in the query argument
- Use only basic DFC classes, methods, and operations since many enterprise systems are still based on the EMC Documentum 5.x platform (thus enabling the service to be used more broadly)
- Build a sample HTML web page that demonstrates how the asynchronous call is made to the server to execute the DQL query. It also demonstrates how to process the JSON Array via JavaScript scripting to display the DQL query results
Extending the Repository Service
The first step is to add a method to the RepositoryService class created in the first part of this series. This method accepts a single argument queryString which contains the DQL query to execute. It returns an ArrayList containing IDfTypedObject for each row in the query results. I used generics, but if you are using an older version of Java prior to 1.5 (when generic support was added), simply remove the type safety statements.
public ArrayList<IDfTypedObject> getQueryResults(String queryString) {
ArrayList<IDfTypedObject> results = new ArrayList<IDfTypedObject> ();
IDfSessionManager sessionMgr = null;
IDfSession session = null;
IDfCollection col = null;
try {
// Get session manager
sessionMgr = this.getSessionManager();
if (sessionMgr == null) {
return results;
}
// Get repository session
session = sessionMgr.getSession(this.getRepositoryName());
// Get query object
IDfClientX clientX = this.getClientX();
IDfQuery query = clientX.getQuery();
// Initialize query object
query.setDQL(queryString);
// Execute query
col = query.execute(session, IDfQuery.DF_EXECREAD_QUERY);
// Iterate through results and add IDfTYpedObject for each row
// ArrayList that is returned
while (col.next()) {
IDfTypedObject curObj= col.getTypedObject();
results.add(curObj);
}
} catch (DfException e) {
e.printStackTrace();
}
finally {
// Close collection
try {
col.close();
} catch (DfException e) {}
if (session != null && sessionMgr != null) {
sessionMgr.release(session);
}
}
return results;
}
Extending the JSON Documentum Serializer
As I was developing this Query Service, I thought it would be useful to extend the JSON Serializer for Documentum (TypedObjectJsonSerializer class) that I developed in the previous post in this series to support attribute metadata (e.g. attribute data type, attribute repeating flag). Previously, it simply returned an array containing the names of all attributes. I enhanced it to now provide the attribute data type and true/false if the attribute is repeating. This moderately increases the size of the server response, but since it is JSON, it should be manageable.
Below is the complete listing of the revised TypedObjectJsonSerializer class:
/* The Class TypedObjectJsonSerializer serializes a EMC Documentum IDfTypedObject to a JSON object
*/
public class TypedObjectJsonSerializer {
/**
* Instantiates a new typed object json serializer.
*/
public TypedObjectJsonSerializer() {
}
public String serializeToString(IDfTypedObject obj) {
JSONObject jsonObj = this.serialize(obj);
if (jsonObj != null) {
return jsonObj.toString();
}
return "";
}
public JSONObject serialize(IDfPersistentObject obj) {
IDfTypedObject typedObj = (IDfTypedObject) obj;
JSONObject json = this.serialize(typedObj);
try {
// If json object returned
if (json != null) {
json.put("r_object_id", obj.getObjectId().toString());
json.put("r_object_type", obj.getType().getName());
} // if json not null
} catch (DfException e) {
e.printStackTrace();
} catch (JSONException e) {
e.printStackTrace();
}
return json;
}
/**
* Serialize IDfTypedObject to JSON object.
*
* @param obj Object to serialize
*
* @return JSON object
*/
public JSONObject serialize(IDfTypedObject obj) {
// Create JSON object
JSONObject json = new JSONObject();
JSONArray attributes = new JSONArray();
try {
// Get attribute count
int attrCount = obj.getAttrCount();
// Iterate through all attributes
for (int curIndex = 0; curIndex < attrCount; curIndex++) {
// Get current attribute
IDfAttr curAttr = obj.getAttr(curIndex);
// If attribute not null
if (curAttr != null) {
int curType = curAttr.getDataType();
String curName = curAttr.getName();
boolean curIsRepeating = false;
String curDataType = "";
// Append attribute-type specific value to JSON object
switch (curType) {
// If boolean
case IDfAttr.DM_BOOLEAN:
curDataType = "boolean";
curIsRepeating = curAttr.isRepeating();
// If attribute is repeating
if (curAttr.isRepeating()) {
JSONArray boolArray = this.getRepeatingBooleans(obj,curAttr);
json.put(curName, boolArray);
}
else {
boolean boolValue = this.getBooleanValue(obj,curAttr);
json.put(curName, boolValue);
}
break;
// If string
case IDfAttr.DM_STRING:
curDataType = "string";
curIsRepeating = curAttr.isRepeating();
// If attribute is repeating
if (curAttr.isRepeating()) {
JSONArray strArray = this.getRepeatingStrings(obj,curAttr);
json.put(curName, strArray);
}
else {
String strValue = this.getStringValue(obj,curAttr);
json.put(curName, strValue);
}
break;
// If id
case IDfAttr.DM_ID:
curDataType = "id";
curIsRepeating = curAttr.isRepeating();
// If attribute is repeating
if (curAttr.isRepeating()) {
JSONArray idArray = this.getRepeatingIds(obj,curAttr);
json.put(curName, idArray);
}
else {
String idValue = this.getIdValue(obj,curAttr);
json.put(curName, idValue);
}
break;
// If integer
case IDfAttr.DM_INTEGER:
curDataType = "integer";
curIsRepeating = curAttr.isRepeating();
// If attribute is repeating
if (curAttr.isRepeating()) {
JSONArray intArray = this.getRepeatingIntegers(obj,curAttr);
json.put(curName, intArray);
}
else {
int intValue = this.getIntegerValue(obj,curAttr);
json.put(curName, intValue);
}
break;
// If time
case IDfAttr.DM_TIME:
curDataType = "time";
curIsRepeating = curAttr.isRepeating();
// If attribute is repeating
if (curAttr.isRepeating()) {
JSONArray timeArray = this.getRepeatingDates(obj,curAttr);
json.put(curName, timeArray);
}
else {
String dateValue = this.getDateValue(obj,curAttr);
json.put(curName, dateValue);
}
break;
// If double
case IDfAttr.DM_DOUBLE:
curDataType = "double";
curIsRepeating = curAttr.isRepeating();
if (curAttr.isRepeating()) {
JSONArray dblArray = this.getRepeatingDoubles(obj,curAttr);
json.put(curName, dblArray);
}
else {
double dblValue = this.getDoubleValue(obj,curAttr);
json.put(curName, dblValue);
}
break;
case IDfAttr.DM_UNDEFINED:
curDataType = "unknown";
curIsRepeating = false;
json.put(curName, "");
} // switch
// Add attribute
JSONObject curAttribute = new JSONObject();
curAttribute.put("name", curName);
curAttribute.put("type", curDataType);
curAttribute.put("repeating", curIsRepeating);
attributes.put(curAttribute);
} // if
} // for loop
json.put("attributes", attributes);
} catch (DfException e) {
e.printStackTrace();
} catch (JSONException e) {
e.printStackTrace();
}
// Return JSON object
return json;
}
/**
* Gets the repeating doubles.
*
* @param obj the object
* @param attr the attr
*
* @return the repeating doubles
*
* @throws DfException the df exception
* @throws JSONException the JSON exception
*/
private JSONArray getRepeatingDoubles(IDfTypedObject obj, IDfAttr attr) throws DfException, JSONException {
JSONArray array = new JSONArray();
if (obj == null || attr == null) {
return array;
}
// Get attribute name
String attrName = attr.getName();
// Iterate through all attribute values
for (int index = 0; index < obj.getValueCount(attrName); index++) {
array.put(obj.getRepeatingDouble(attrName, index));
}
return array;
}
/**
* Gets the repeating dates.
*
* @param obj the object
* @param attr the attr
*
* @return the repeating dates
*
* @throws DfException the df exception
*/
private JSONArray getRepeatingDates(IDfTypedObject obj, IDfAttr attr) throws DfException {
JSONArray array = new JSONArray();
if (obj == null || attr == null) {
return array;
}
// Get attribute name
String attrName = attr.getName();
// Iterate through all attribute values
for (int index = 0; index < obj.getValueCount(attrName); index++) {
IDfTime time = obj.getRepeatingTime(attrName, index);
if (time != null && !time.isNullDate()) {
array.put(time.asString(IDfTime.DF_TIME_PATTERN18));
}
else {
array.put("");
}
}
return array;
}
/**
* Gets the repeating integers.
*
* @param obj the object
* @param attr the attr
*
* @return the repeating integers
*
* @throws DfException the df exception
*/
private JSONArray getRepeatingIntegers(IDfTypedObject obj, IDfAttr attr) throws DfException {
JSONArray array = new JSONArray();
if (obj == null || attr == null) {
return array;
}
// Get attribute name
String attrName = attr.getName();
// Iterate through all attribute values
for (int index = 0; index < obj.getValueCount(attrName); index++) {
array.put(obj.getRepeatingInt(attrName, index));
}
return array;
}
/**
* Gets the repeating ids.
*
* @param obj the object
* @param attr the attr
*
* @return the repeating ids
*
* @throws DfException the df exception
*/
private JSONArray getRepeatingIds(IDfTypedObject obj, IDfAttr attr) throws DfException {
JSONArray array = new JSONArray();
if (obj == null || attr == null) {
return array;
}
// Get attribute name
String attrName = attr.getName();
// Iterate through all attribute values
for (int index = 0; index < obj.getValueCount(attrName); index++) {
IDfId objId = obj.getRepeatingId(attrName, index);
String curValue = null;
if (objId != null) {
curValue = objId.toString();
}
array.put(curValue);
}
return array;
}
/**
* Gets the repeating booleans.
*
* @param obj the object
* @param attr the attr
*
* @return the repeating booleans
*
* @throws DfException the df exception
*/
private JSONArray getRepeatingBooleans(IDfTypedObject obj, IDfAttr attr) throws DfException {
JSONArray array = new JSONArray();
if (obj == null || attr == null) {
return array;
}
// Get attribute name
String attrName = attr.getName();
// Iterate through all attribute values
for (int index = 0; index < obj.getValueCount(attrName); index++) {
array.put(obj.getRepeatingBoolean(attrName, index));
}
return array;
}
/**
* Gets the repeating strings.
*
* @param obj the object
* @param attr the attr
*
* @return the repeating strings
*
* @throws DfException the df exception
*/
private JSONArray getRepeatingStrings(IDfTypedObject obj, IDfAttr attr) throws DfException {
JSONArray array = new JSONArray();
if (obj == null || attr == null) {
return array;
}
// Get attribute name
String attrName = attr.getName();
// Iterate through all attribute values
for (int index = 0; index < obj.getValueCount(attrName); index++) {
array.put(obj.getRepeatingString(attrName, index));
}
return array;
}
/**
* Gets the date value.
*
* @param obj the object
*
* @return the date value
* @throws DfException
*/
private String getDateValue(IDfTypedObject obj, IDfAttr attr) throws DfException {
if (obj == null || attr == null) {
return null;
}
// Get attribute name
String attrName = attr.getName();
IDfTime time = obj.getTime(attrName);
if (time != null && !time.isNullDate()) {
return time.asString(IDfTime.DF_TIME_PATTERN18);
}
else {
return null;
}
}
/**
* Gets the double value.
*
* @param obj the object
*
* @return the double value
* @throws DfException
*/
private double getDoubleValue(IDfTypedObject obj, IDfAttr attr) throws DfException {
if (obj == null || attr == null) {
return 0;
}
// Get attribute name
String attrName = attr.getName();
return obj.getDouble(attrName);
}
/**
* Gets the integer value.
*
* @param obj the object
*
* @return the integer value
* @throws DfException
*/
private int getIntegerValue(IDfTypedObject obj, IDfAttr attr) throws DfException {
if (obj == null || attr == null) {
return 0;
}
// Get attribute name
String attrName = attr.getName();
return obj.getInt(attrName);
}
/**
* Gets the id value.
*
* @param obj the object
*
* @return the id value
* @throws DfException
*/
private String getIdValue(IDfTypedObject obj, IDfAttr attr) throws DfException {
if (obj == null || attr == null) {
return null;
}
// Get attribute name
String attrName = attr.getName();
IDfId objId = obj.getId(attrName);
if (objId == null) {
return null;
}
return objId.toString();
}
/**
* Gets the string value.
*
* @param obj the object
*
* @return the string value
* @throws DfException
*/
private String getStringValue(IDfTypedObject obj, IDfAttr attr) throws DfException {
if (obj == null || attr == null) {
return null;
}
// Get attribute name
String attrName = attr.getName();
return obj.getString(attrName);
}
/**
* Gets the boolean value.
*
* @param obj the object
*
* @return the boolean value
* @throws DfException
*/
private boolean getBooleanValue(IDfTypedObject obj, IDfAttr attr) throws DfException {
if (obj == null || attr == null) {
return false;
}
// Get attribute name
String attrName = attr.getName();
return obj.getBoolean(attrName);
}
}
Building the Servlet
The Servlet is fairly straightforward. The key characteristics of the Servlet are:
- The Servlet supports both the POST and GET operations to provide maximum flexibility, although POST is strongly recommended to avoid to query string limitations and the escaping of the query on the client side.
- The Servlet uses the singleton pattern, to only instantiate a single instance of the RepositoryService class. Upon instantiation the singleton class can be used for each subsequent request. This avoids having to connect to the repository for each asynchronous request.
- The Servlet expects a single argument, query
- The Servlet returns either an empty string or the string representation of the array of JSON objects
- The Servlet reads the repository credentials from the RepositoryName, RepositoryUser, and RepositoryPassword configuration parameters in the web.xml
Similar to the Object Broker Servlet, the processRequest method does most of the work. The processRequest method simply retrieves the query results from the RepositoryService using the new getQueryResults method. The ArrayList returned by the getQueryResults is iterated through and a JSONObject is created for each row. A JSONArray containing the JSONObject objects is then serialized to a String and returned to the consumer application.
The full listing of the QueryServlet is shown below:
public class QueryServlet extends HttpServlet {
public RepositoryService m_repositorySvc = null;
/**
* Gets the repository service.
*
* @return the repository service
*/
public RepositoryService getRepositoryService() {
if (this.m_repositorySvc == null) {
// Get repository credentials from web.xml
ServletContext context = this.getServletContext();
String repositoryName = context.getInitParameter("RepositoryName");
String userName = context.getInitParameter("RepositoryUser");
String userPassword = context.getInitParameter("RepositoryPassword");
this.m_repositorySvc = new RepositoryService(repositoryName, userName, userPassword);
}
return this.m_repositorySvc;
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
// Get json response
String jsonResponse = this.processRequest(req, resp);
// Write response
PrintWriter out = resp.getWriter();
out.println(jsonResponse);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
// Get json response
String jsonResponse = this.processRequest(req, resp);
// Write response
PrintWriter out = resp.getWriter();
out.println(jsonResponse);
}
/**
* Process request.
*
* @param req the req
* @param resp the resp
*
* @return the string
*/
private String processRequest(HttpServletRequest req, HttpServletResponse resp) {
String jsonStr = "";
String queryString= req.getParameter("query");
JSONArray jsonArray = new JSONArray();
// Initialize repository service
RepositoryService repositorySvc = this.getRepositoryService();
// If not initialized or connected return null
if (repositorySvc == null || !repositorySvc.isValid()) {
return jsonStr;
}
// Create serializer
TypedObjectJsonSerializer serializer = new TypedObjectJsonSerializer();
// Iterate through all query results
ArrayList<IDfTypedObject> results = repositorySvc.getQueryResults(queryString);
if (results != null && results.size() >0) {
Iterator<IDfTypedObject> objIter = results.iterator();
while (objIter.hasNext()) {
IDfTypedObject curObj = objIter.next();
JSONObject jsonObj = serializer.serialize(curObj);
jsonArray.put(jsonObj);
}
}
jsonStr = jsonArray.toString();
return jsonStr;
}
}
Building the HTML Page
The final step is to demonstrate how to use the new service using standard HTML and JavaScript scripting.
As in the previous post in this series, XmlHttpRequest, which is supported by most modern browsers including Internet Explorer and Firefox, is used to make the asynchronous request.
An asynchronous request is made in the onExecute event handler which is fired when the Execute button is clicked. The query specified in the queryText TEXTAREA control is passed to the asynchronous request.
The server response is handled by the onServerResponse function. The server response is checked to make sure no error occurred and that the server response is complete. The eval JavaScript function is used to build the JavaScript array containing the query result objects from the JSON string. The query results are passed to the showQueryResults function which dynamically generates an HTML table containing the query results. The JavaScript code is fairly straightforward and uses DOM Scripting to build the HTML table.
The complete listing of this HTML page is shown below:
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1">
<title>Query Service</title>
<script type="text/javascript">
var req;
// Initialize XmlHttpRequest object
function initializeXmlHttpRequest() {
if (window.ActiveXObject) {
req=new ActiveXObject('Microsoft.XMLHTTP');
}
else {
req=new XMLHttpRequest();
}
}
function showQueryResults(results) {
if (results.length <= 0) {
return;
}
var resultTable=document.createElement("table");
resultTable.border=1;
resultTable.cellSpacing=0;
resultTable.cellPadding=0;
for(i=0;i<results.length;i++) {
// Get current row
var curObj = results[i];
// Add row
var curRow = resultTable.insertRow(i);
// Add cell for each attribute
for(j=0; j < curObj.attributes.length; j++) {
var curAttr = curObj.attributes[j];
var curName = curAttr.name;
var curCell = curRow.insertCell(j);
// If repeating
if (curAttr.repeating) {
var values = curObj[curName];
var valueStr = "";
for (v=0; v < values.length; v++) {
valueStr = valueStr + values[v] + "<br/>";
}
if (valueStr.length ==0) {
valueStr = " ";
}
curCell.innerHTML = valueStr;
}
else {
var curValue = curObj[curName];
if (curValue == null || curValue.length == 0) {
curCell.innerHTML = " ";
}
else {
curCell.innerHTML = curValue;
}
}
}
}
// Add header
var thead = resultTable.createTHead();
var headerRow = thead.insertRow(-1);
headerRow.setAttribute("bgColor","#3399FF");
var curObj = results[0];
for(j=0; j < curObj.attributes.length; j++) {
var curAttr = curObj.attributes[j];
var curName = curAttr.name;
var curCell = headerRow.insertCell(j);
curCell.align = "center";
curCell.style.color = "white";
curCell.style.fontWeight = "bold";
curCell.innerHTML = curName;
}
var dumpControl = document.getElementById("queryDump");
dumpControl.innerHTML = "";
dumpControl.appendChild(resultTable);
//alert(dumpControl.innerHTML);
}
// Event handler for asynchronous request
function onServerResponse() {
try {
// If not finished, then return
if(req.readyState!=4) {
return;
}
// If an error occurred notify user and return
if(req.status != 200) {
alert('An error occurred retrieving query data from repository.');
return;
}
// Get server response
var responseData = req.responseText;
// Store JSON object for global access
var obj = eval('(' + responseData + ')');
// Build table
showQueryResults(obj);
}
catch(err) {
alert(err.message);
}
}
// Get post data
function getPostData() {
var data = "query=" + queryForm.queryText.value;
return data;
}
// Event handler for on click execute button
function onExecute() {
var queryControl = document.getElementById("queryText");
if (queryControl == null) {
alert('Query field is missing or null.');
return;
}
// Get user-entered object id
var query = queryControl.value;
// Ensure valid object id specified (must be 16 characters in documentum)
if (query.length == 0) {
return;
}
// Update object dump div tag to indicate that data is being retrieved
var dumpControl = document.getElementById("queryDump");
if (dumpControl != null) {
dumpControl.innerHTML = "<span style='color:blue;font-style:italic'>Executing query: " + query + "</span>";
}
// Initialize XmlHttpRequest object
initializeXmlHttpRequest();
if (req == null) {
alert ('Unable to initialize XmlHttpRequest object.');
return;
}
// Build request
if (req!=null) {
req.onreadystatechange=onServerResponse;
// Set window status
window.status='Executing query...';
var postData = getPostData();
// Open server request
req.open('POST','/TestWebSite/queryservice',true);
req.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
req.setRequestHeader("Content-length", postData.length);
// Send data
req.send(postData);
}
return;
}
// Event handler for on click cancel button
function onCancel() {
history.back(-1);
}
</script>
</head>
<body>
<form id="queryForm">
<table width="400px">
<tbody>
<tr>
<td>
<span style="font-weight: bold">Query:</span>
</td>
<td>
<textarea rows="10" cols="50" id="queryText"></textarea>
</td>
</tr>
<tr>
<td colspan="2">
<input type="button" value="Cancel" onclick="onCancel()"/>
<input type="button" value="Execute" onclick="onExecute()"/>
</td>
</tr>
</tbody>
</table>
<div id="queryDumpLabel" style="font-weight:bold;margin-bottom:1em;">JSON Query Results</div>
<div id="queryDump" ></div>
</form>
</body>
</html>
Conclusion
This solution demonstrated how to asynchronously retrieve DQL query results from the client-side, how to serialize the query results to JSON, and finally how to process/access the server response in JavaScript scripting. It could easily be consumed in most modern languages. Please refer to http://www.json.org for a list of libraries that support JSON.
As I mentioned in my previous post, this demonstration sample is only to show how the service can be consumed, but the possibilities for leveraging the new Query Service are endless. The DQL query can dynamically be built in JavaScript functions based on selected nodes, form field values, etc. The query results can then be used to build tables, list items, tree nodes, or any other dynamic HTML generated through JavaScript and DOM scripting.
As always, this code is more of a proof of concept and not production grade code. I recommend the following:
- Evaluate whether the security approach is acceptable (storing credentials for account in the web.xml file).
- Make sure the account being used has limited access (read-only) to the repository.
- Add checks to ensure no updates or malicious queries are allowed. This require simple parsing of the DQL query in the RepositoryService class
- Possibly use security and filtering to only enable the service to be accessed by certain hosts through IP Filtering or Digital Signatures
- Potentially modify the RepositoryService and Servlet to use a ticketed login, but this assumes the consuming application is able to obtain a repository session and generate a login ticket
- Potentially modify the RepositoryService and Servlet to store the repository session in a JSP session variable
- If you are integrating this interface into an existing WDK application, you can modify the RepositoryService and Servlet to retrieve the current repository session from SessionManagerHttpBinding
- The error handling needs to be more robust.
- Although I did some unit testing, additional testing is required, as this is more of a proof-of-concept.
- If large volumes of data are used, perhaps it would be useful to add a flag to suppress the attribute metadata (data type, repeating, etc). It really depends on how the data will be consumed.
I would be interested in knowing if anyone finds this useful, as well as hearing about possible ideas and extensions for this solution. Please feel free to contact me or comment on this blog entry. If this is useful, I will post additional blog entries related to this topic.
If the demand and interest is there, perhaps this can be turned into an Open Source project.
Related Recommended Books