One of my motivations for learning Spring-Roo was to be able to build quick client specific applications that spoke to an Alfresco backend via Webscripts. This is my second Roo app, and this one deviates a bit from the database backed CRUD style web app that Roo is built to generate.
This web app provides a user-interface for blog authors to post blogs to an Alfresco CMS. Alfresco does have a Web Content Management interface that provides this facility. However, I was looking for a way to customize the interface for different editorial teams, at the same time sharing the underlying Alfresco CMS. The solution I came up with was to build each team their own user interface - obviously, this approach is scalable only if it were possible to build cookie cutter web applications relatively painlessly and then extend them as needed - this is where (the choice of) Roo comes in.
This Roo web application exposes two entities - a Post and a Blog. A Blog contains metadata about the blog itself, such as its author, byline, profile, etc. A Post contains the title, friendly URL, content, etc. of an individual blog post. The actual data resides in an Alfresco CMS, which exposes it using Webscripts which return JSON. The Roo app talks to the Webscripts using HTTP GET and POST requests. Something like this...
I initially thought of using Spring-Surf, but after a quick look at the tutorial and being unable to run it locally using Jetty, I decided against it. Spring-Surf has a nice remoting mechanism built in, but its primary objective seems to be building read-only web sites, and would perhaps be a better fit for people who are planning to serve (part or all of) their websites from Alfresco.
What Roo gives me here is the ability to quickly generate a web application (the CRUD pages based on the entity definitions, web and security scaffolding), which I then customize. Since Roo works against a local database by default, and because I wanted to modify the default security workflow, the customizations are quite extensive, and Roo wasn't exactly a RAD tool in this case. However, it still beats having to build something like this from scratch (and since I am not much of a UI guy, the end result wouldn't have looked half as pretty).
Since I am borrowing heavily from what I learned building my previous Roo app, in the interests of keeping this post down to a reasonable size, I am going to gloss over some details and just point you to a post that describes the process in more detail. So broadly, these are the steps:
- Code Generation
- Cosmetic Customizations
- Connect to Alfresco
- Security Customizations
- Application Customization - Security
- Persistence Customizations
- Application Customizations - Blog
- Application Customizations - Post
- Add Rich Text Editor
Code Generation
Here is my log.roo file that defines the entities (and generates the entity classes), the web controllers and the security scaffolding.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | // Spring Roo 1.0.2.RELEASE [rev 638] log opened at 2010-09-15 11:44:03
project --topLevelPackage com.mycompany.alfresco.client
persistence setup --provider HIBERNATE --database HYPERSONIC_IN_MEMORY
// create an enum of allowed publishing states
enum type --class ~.domain.PubStates
enum constant --name Draft
enum constant --name Review
enum constant --name Published
enum constant --name Scheduled
enum constant --name Archived
// create a Post entity
entity --class ~.domain.Post --identifierField uuid \
--identifierType java.lang.String
field string --fieldName title
field string --fieldName summary
field string --fieldName content
field string --fieldName furl
field enum --fieldName pubState --type ~.domain.PubStates
// create a Blog entity
entity --class ~.domain.Blog --identifierField uuid \
--identifierType java.lang.String
field string --fieldName owner
field string --fieldName blogname
field string --fieldName byline --sizeMax 250
field string --fieldName profile
// build the controller layer
controller all --package ~.web
// build the security scaffolding
security setup
// generate the .classpath file
perform eclipse
quit
|
At this point, I have a fully functional web application with CRUD screens and menus. Unlike typical Roo usage, I don't plan on using Roo's bidirectional RAD feature. My use of Roo is just to eliminate the up-front work in creating the application skeleton, allowing me to work on the more interesting aspects.
Note that I am using Hibernate persistence with an in-memory Hypersonic database. This is only for initial code generation - I plan on replacing it with a custom layer that talks directly to Alfresco via Webscripts. Another thing to note is that I specifically request that id fields not be generated, by providing my own UUID field (Alfresco uses a UUID to identify content nodes).
Cosmetic Customizations
The easiest way to make your app look "custom" is to change the color pallete, sort of like painting your house. I just replaced the set of green(ish) colors in the Roo generated app with a set of blue(ish) colors. I found this color picker widget very helpful in figuring out what colors to use - once you have the mapping, its simple to do a global search and replace in the standard.css and alt.css files.
The one other change I did is to replace the banner graphic and favicon with custom ones for this application. See my post on basic Roo customizations for more information.
Connect with Alfresco
For the Webscripts on the Alfresco side, rather than follow the prescribed approach of manually designing the JSON views using FTL (Freemarker Template Language), I decided to use the Jackson JSON library on both sides of the link to minimize the chances of error.
On the Roo side, I added the dependencies for HttpClient and Jackson in the POM, then created a class that wraps a Apache commons HttpClient and exposes convenience methods that take in a service name and a Map of parameters. These arguments are converted to a Alfresco service URL and the returned JSON is converted to a Map of data. Here is the code for the WebscriptClient.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 | // Source: src/main/java/com/mycompany/alfresco/client/remote/WebscriptClient.java
package com.mycompany.alfresco.client.remote;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.List;
import java.util.Map;
import javax.annotation.PostConstruct;
import org.apache.commons.httpclient.DefaultHttpMethodRetryHandler;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpMethod;
import org.apache.commons.httpclient.HttpStatus;
import org.apache.commons.httpclient.MultiThreadedHttpConnectionManager;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.httpclient.methods.PostMethod;
import org.apache.commons.httpclient.params.HttpMethodParams;
import org.apache.commons.io.IOUtils;
import org.apache.log4j.Logger;
import org.codehaus.jackson.map.ObjectMapper;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.stereotype.Service;
/**
* Wrapper over a HTTP Client to make formatted GET and POST requests.
*/
@Service("webscriptClient")
public class WebscriptClient {
private final Logger logger = Logger.getLogger(getClass());
private int connectTimeout;
private int readTimeout;
private int retries;
private String alfrescoServiceUrl;
private HttpClient httpClient;
private ObjectMapper objectMapper;
@Required public void setConnectTimeout(int connectTimeout) {
this.connectTimeout = connectTimeout;
}
@Required public void setReadTimeout(int readTimeout) {
this.readTimeout = readTimeout;
}
@Required public void setRetries(int retries) {
this.retries = retries;
}
@Required public void setAlfrescoServiceUrl(String alfrescoServiceUrl) {
this.alfrescoServiceUrl = alfrescoServiceUrl;
}
public WebscriptClient() {
httpClient = new HttpClient(new MultiThreadedHttpConnectionManager());
objectMapper = new ObjectMapper();
}
@PostConstruct protected void init() {
httpClient.getHttpConnectionManager().getParams().setConnectionTimeout(
connectTimeout);
}
public List<Map<String,Object>> getList(String url) throws Exception {
StringBuilder buf = new StringBuilder();
buf.append(alfrescoServiceUrl).append("/").append(url);
String fullUrl = buf.toString();
GetMethod getMethod = new GetMethod(fullUrl);
configureMethod(getMethod);
InputStream in = null;
try {
int status = httpClient.executeMethod(getMethod);
if (status != HttpStatus.SC_OK) {
throw new Exception("GET " + fullUrl +
" failed, status code=" + status);
}
in = getMethod.getResponseBodyAsStream();
List<Map<String,Object>> data = objectMapper.readValue(in, List.class);
return data;
} catch (IOException e) {
logger.warn(e);
throw new Exception("GET " + fullUrl + " failed", e);
} finally {
IOUtils.closeQuietly(in);
getMethod.releaseConnection();
}
}
public Map<String,Object> get(String url) throws Exception {
StringBuilder buf = new StringBuilder();
buf.append(alfrescoServiceUrl).append("/").append(url);
String fullUrl = buf.toString();
GetMethod getMethod = new GetMethod(fullUrl);
configureMethod(getMethod);
InputStream in = null;
try {
int status = httpClient.executeMethod(getMethod);
if (status != HttpStatus.SC_OK) {
throw new Exception("GET " + fullUrl +
" failed, status code=" + status);
}
in = getMethod.getResponseBodyAsStream();
Map<String,Object> data = objectMapper.readValue(in, Map.class);
return data;
} catch (IOException e) {
logger.warn(e);
throw new Exception("GET " + fullUrl + " failed", e);
} finally {
IOUtils.closeQuietly(in);
getMethod.releaseConnection();
}
}
public int post(String url, Map<String,String> params)
throws Exception {
StringBuilder buf = new StringBuilder();
buf.append(alfrescoServiceUrl).
append("/").append(url);
String fullUrl = buf.toString();
PostMethod postMethod = new PostMethod(fullUrl);
for (String name : params.keySet()) {
postMethod.addParameter(name, encode(params.get(name)));
}
configureMethod(postMethod);
try {
int status = httpClient.executeMethod(postMethod);
if (status != HttpStatus.SC_OK) {
throw new Exception("POST " + fullUrl +
" failed, status code=" + status);
}
return status;
} catch (IOException e) {
logger.warn(e);
throw new Exception("POST " + fullUrl + " failed", e);
} finally {
postMethod.releaseConnection();
}
}
private String toQueryString(Map<String, String> params) {
StringBuilder buf = new StringBuilder();
int i = 0;
for (String name : params.keySet()) {
String value = params.get(name);
try {
value = URLEncoder.encode(value, "UTF-8");
} catch (UnsupportedEncodingException e) { /* NOOP */ }
if (i > 0) {
buf.append("&");
}
buf.append(name).append("=").append(value);
i++;
}
return buf.toString();
}
private void configureMethod(HttpMethod method) {
if (readTimeout > 0) {
method.getParams().setSoTimeout(readTimeout);
}
if (retries > 0) {
method.getParams().setParameter(HttpMethodParams.RETRY_HANDLER,
new DefaultHttpMethodRetryHandler(retries, false));
}
}
private String encode(String unencoded) {
try {
return URLEncoder.encode(unencoded, "UTF-8");
} catch (Exception e) {
// very unlikely to come here, warn and return original
logger.warn("Can't URL Encode string: " + unencoded);
return unencoded;
}
}
}
|
Security Customization
Roo uses Spring Security (formerly Acegi) to implement an interceptor style of security. However, my app would be better off having everything behind login to begin with, because it needs to behave differently based on the user's role. Here is the snippet from my applicationContext-security that implements this functionality.
1 2 3 4 5 6 7 8 9 10 | <http auto-config="true" use-expressions="true">
...
<!-- Configure these elements to secure URIs in your application -->
<intercept-url pattern="/" access="isAuthenticated()"/>
<intercept-url pattern="/post/**" access="isAuthenticated()"/>
<intercept-url pattern="/blog/**" access="isAuthenticated()" />
<intercept-url pattern="/resources/**" access="permitAll" />
<intercept-url pattern="/static/**" access="permitAll" />
<intercept-url pattern="/**" access="permitAll" />
</http>
|
To support remote authentication via Alfresco, I needed to build a Webscript that takes the user name and password nad returns a role (or an empty string if authentication fails). Webscripts are fairly simple and repetitive to build, so I won't describe them here.
On the client side, the (generated) security configuration exposes an authentication manager object which wraps an authentication provider. So similar to my previous attempt at custom security in Roo, I built a custom Authentication Provider that sends an HTTP GET request to the Webscript using the WebScript client described above.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | // Source: src/main/java/com/mycompany/alfresco/client/security/WebscriptAuthenticationProvider.java
package com.mycompany.alfresco.client.security;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang.StringUtils;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.GrantedAuthorityImpl;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import com.mycompany.alfresco.client.remote.WebscriptClient;
/**
* Authentication Provider that retrieves a user from a backend Alfresco
* system via a HTTP call.
*/
public class WebscriptAuthenticationProvider extends
AbstractUserDetailsAuthenticationProvider {
private WebscriptClient client;
public void setWebscriptClient(WebscriptClient client) {
this.client = client;
}
@Override protected void additionalAuthenticationChecks(
UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
return;
}
@Override protected UserDetails retrieveUser(
String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
String password = (String) authentication.getCredentials();
if (StringUtils.isEmpty(password)) {
throw new BadCredentialsException("Please enter password");
}
List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
StringBuilder qbuf = new StringBuilder();
qbuf.append("mycompany/authenticate.json?user=").
append(username).
append("&pass=").
append(password);
try {
Map<String,Object> data = client.get(qbuf.toString());
String role = (String) data.get("role");
if (StringUtils.isEmpty(role)) {
throw new BadCredentialsException("Invalid username/password");
}
authorities.add(new GrantedAuthorityImpl(role));
} catch (Exception e) {
throw new BadCredentialsException(e.getMessage());
}
return new User(username, password, true, true, true, true, authorities);
}
}
|
We then replace the Roo generated authentication provider with our own in the applicationContext-security.xml file. Here is the relevant snippet.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | <!-- Configure Authentication mechanism -->
<beans:bean id="webscriptClient"
class="com.mycompany.alfresco.client.remote.WebscriptClient">
<beans:property name="connectTimeout" value="5000"/>
<beans:property name="readTimeout" value="1000"/>
<beans:property name="retries" value="3"/>
<beans:property name="alfrescoServiceUrl"
value="http://localhost:8080/alfresco/service"/>
</beans:bean>
<beans:bean id="webscriptAuthenticationProvider"
class="com.mycompany.alfresco.client.security.WebscriptAuthenticationProvider">
<beans:property name="webscriptClient" ref="webscriptClient"/>
</beans:bean>
<authentication-manager alias="authenticationManager">
<authentication-provider ref="webscriptAuthenticationProvider"/>
</authentication-manager>
|
So here is what the application looks like on startup. Unlike the Roo generated webapp, the landing page is a login page.
Application Customization - Security
The reason for starting with the login screen is because I want to restrict the user to documents that only they have rights to. For example, some with "blogger" role can only see (and modify) documents that they have authored and which are still in Draft state. They also have a profile which they can edit. Editors, on the other hand, have control over documents that are in states other than Draft, and they don't have a profile to edit.
All of these are just simple JSP changes in menu.jspx, with some extra logic to filter out links based on the current user (sec:authorize tags). The screenshots below show the home page (after login) for a blogger and for an editor.
Also, since I don't need multi-language support, and I don't want the user to switch between standard and alternate themes (as cool as both features are), I removed the links from default.jspx. I also added code in here (a sec:authentication tag) to show the currently logged in user.
Persistence Customization
Like the Roo generated security scaffolding exposes an Authentication Provider, the persistence scaffolding exposes an Entity Manager Factory. So I decided to follow a similar strategy and build a custom Entity Manager Factory. My custom Entity Manager Factory is a factory for my custom Entity Manager and is shown below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | // Source: src/main/java/com/mycompany/alfresco/client/remote/WebscriptEntityManagerFactory.java
package com.mycompany.alfresco.client.remote;
import java.util.Map;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import org.springframework.beans.factory.annotation.Required;
/**
* Custom Entity Manager Factory that returns a custom Entity Manager.
*/
public class WebscriptEntityManagerFactory implements EntityManagerFactory {
private WebscriptClient webscriptClient;
@Required public void setWebscriptClient(WebscriptClient webscriptClient) {
this.webscriptClient = webscriptClient;
}
@Override public boolean isOpen() {
return true;
}
@Override public EntityManager createEntityManager() {
return new WebscriptEntityManager(webscriptClient);
}
@Override public EntityManager createEntityManager(Map map) {
return createEntityManager();
}
@Override public void close() {}
}
|
The Entity Manager is passed in a reference to the WebscriptClient, which it uses to run various methods such as find(), persist(), remove(), etc. All these calls are basically HTTP GET or POST calls to Webscripts on the Alfresco CMS.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | // Source: src/main/java/com/mycompany/alfresco/client/remote/WebscriptEntityManager.java
package com.mycompany.alfresco.client.remote;
import java.util.Map;
import javax.persistence.EntityManager;
import javax.persistence.EntityTransaction;
import javax.persistence.FlushModeType;
import javax.persistence.LockModeType;
import javax.persistence.Query;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* Custom Entity Manager that will delegate to a specific Webscript hosted
* by a backend Alfresco instance.
*/
public class WebscriptEntityManager implements EntityManager {
private final Log logger = LogFactory.getLog(getClass());
private WebscriptClient client;
public WebscriptEntityManager(WebscriptClient client) {
this.client = client;
}
@Override public void clear() { /* NOOP */ }
@Override public void close() { /* NOOP */ }
@Override public void flush() { /* NOOP */ }
@Override public void joinTransaction() { /* NOOP */ }
@Override public void lock(Object entity, LockModeType lockMode) { /* NOOP */ }
@Override public void setFlushMode(FlushModeType flushMode) { /* NOOP */ }
/**
* Create a WebscriptQuery object from the passed in string. The
* passed in string is the path and query string part of the actual
* GET request that will be sent to the Alfresco Webscripts tier.
* @param qlString the query string to use.
* @return a reference to a WebscriptQuery object.
*/
@Override public Query createQuery(String qlString) {
return new WebscriptQuery(qlString, client);
}
@Override public Query createNamedQuery(String name) {
throw new UnsupportedOperationException("Method not implemented");
}
@Override public Query createNativeQuery(String sqlString) {
throw new UnsupportedOperationException("Method not implemented");
}
@Override public Query createNativeQuery(String sqlString, Class resultClass) {
throw new UnsupportedOperationException("Method not implemented");
}
@Override public Query createNativeQuery(String sqlString,
String resultSetMapping) {
throw new UnsupportedOperationException("Method not implemented");
}
@Override public boolean contains(Object entity) {
return false;
}
@Override public Object getDelegate() {
return client;
}
@Override public FlushModeType getFlushMode() {
return FlushModeType.AUTO;
}
@Override public EntityTransaction getTransaction() {
return new WebscriptEntityTransaction();
}
@Override public boolean isOpen() {
return true;
}
/**
* Convenience method to return a single result of the specified
* class. Can return a null if the no result is found for the primary key.
* @param entityClass the class of the result desired.
* @param primaryKey the UUID for the node.
* @return an instance of the specified class matching the UUID, or null.
*/
@Override public <T> T find(Class<T> entityClass, Object primaryKey) {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
String classname = entityClass.getSimpleName();
StringBuilder qbuf = new StringBuilder();
qbuf.append("mycompany/").
append(StringUtils.lowerCase(classname)).
append("_get.json").
append("?user=").
append(username).
append("&uuid=").
append(primaryKey);
Map<String,Object> result = (Map<String,Object>) createQuery(qbuf.toString()).getSingleResult();
return ConversionUtils.mapToEntity(entityClass, result);
}
@Override public <T> T getReference(Class<T> entityClass, Object primaryKey) {
return find(entityClass, primaryKey);
}
/**
* Save and reload.
*/
@Override public <T> T merge(T entity) {
if (entity != null) {
persist(entity);
return find(getEntityClass(entity),
ConversionUtils.getPrimaryKey(entity));
}
return null;
}
/**
* Sends a HTTP POST request to the named webscript instance to save
* the object into Alfresco.
*/
@Override public void persist(Object entity) {
Class<?> entityClass = getEntityClass(entity);
StringBuilder qbuf = new StringBuilder();
String classname = entityClass.getSimpleName();
qbuf.append("mycompany/").
append(StringUtils.lowerCase(classname)).
append("_update.json");
WebscriptQuery query = (WebscriptQuery) createQuery(qbuf.toString());
String username = SecurityContextHolder.getContext().getAuthentication().getName();
query.setParameter("user", username);
Map<String,String> fields = ConversionUtils.entityToMap(entity);
for (String name : fields.keySet()) {
query.setParameter(name, fields.get(name));
}
query.executeUpdate();
}
/**
* HTTP GET with UUID, and overwrite the entity with what we get
*/
@Override public void refresh(Object entity) {
entity = find(getEntityClass(entity),
ConversionUtils.getPrimaryKey(entity));
}
/**
* Send HTTP DELETE request to remove the entity from Alfresco.
*/
@Override public void remove(Object entity) {
String primaryKey = ConversionUtils.getPrimaryKey(entity);
Class<?> entityClass = getEntityClass(entity);
StringBuilder qbuf = new StringBuilder();
String classname = entityClass.getSimpleName();
qbuf.append("mycompany/").
append(StringUtils.lowerCase(classname)).
append("_delete.json");
WebscriptQuery query = (WebscriptQuery) createQuery(qbuf.toString());
String username = SecurityContextHolder.getContext().getAuthentication().getName();
query.setParameter("user", username);
query.setParameter("uuid", primaryKey);
query.executeUpdate();
}
private <T> Class<T> getEntityClass(T entity) {
return (Class<T>) entity.getClass();
}
}
|
The WebscriptQuery is a custom Query implementation that provides methods to return a single result or list of results, and to execute a DML call. As before, they call specific methods on the WebscriptClient which proxy to the Webscripts on the Alfresco service.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 | // Source: src/main/java/com/mycompany/alfresco/client/remote/WebscriptQuery.java
package com.mycompany.alfresco.client.remote;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.persistence.FlushModeType;
import javax.persistence.PersistenceException;
import javax.persistence.Query;
import javax.persistence.TemporalType;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
public class WebscriptQuery implements Query {
private final Log logger = LogFactory.getLog(getClass());
private WebscriptClient client;
private String queryString;
private int maxResult;
private Map<String,String> parameters;
public WebscriptQuery(String queryString, WebscriptClient client) {
this.queryString = queryString;
this.client = client;
maxResult = 1;
parameters = new HashMap<String,String>();
}
@Override public int executeUpdate() {
try {
return client.post(queryString, parameters);
} catch (Exception e) {
throw new PersistenceException(e);
}
}
@Override public List getResultList() {
try {
return client.getList(queryString);
} catch (Exception e) {
throw new PersistenceException(e);
}
}
@Override public Object getSingleResult() {
try {
return client.get(queryString);
} catch (Exception e) {
throw new PersistenceException(e);
}
}
@Override public Query setMaxResults(int maxResult) {
this.maxResult = maxResult;
return this;
}
@Override public Query setParameter(String name, Object value) {
this.parameters.put(name, String.valueOf(value));
return this;
}
// the following methods are effectively no-ops. They are used as
// convenience chaining API by client
@Override public Query setFirstResult(int startPosition) { return this; }
@Override public Query setFlushMode(FlushModeType flushMode) { return this; }
@Override public Query setHint(String hintName, Object value) { return this; }
@Override public Query setParameter(int position, Object value) { return this; }
@Override public Query setParameter(String name, Date value,
TemporalType temporalType) { return this; }
@Override public Query setParameter(String name, Calendar value,
TemporalType temporalType) { return this; }
@Override public Query setParameter(int position, Date value,
TemporalType temporalType) { return this; }
@Override public Query setParameter(int position, Calendar value,
TemporalType temporalType) { return this; }
}
|
There is also the EntityTransaction implementation that is customized to our setup. Its really a no-op, but we need to define it so we don't get null pointer exceptions from JPA.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | // Source: src/main/java/com/mycompany/alfresco/client/remote/WebscriptEntityTransaction.java
package com.mycompany.alfresco.client.remote;
import javax.persistence.EntityTransaction;
/**
* The Alfresco backend handles everything in a transactional manner,
* so this particular transaction object is a no-op transacton object.
*/
public class WebscriptEntityTransaction implements EntityTransaction {
private boolean active = false;
private boolean rollbackOnly = false;
@Override public void begin() { this.active = true; }
@Override public void commit() { this.active = false; }
@Override public void rollback() { this.active = false; }
@Override public boolean isActive() { return active; }
@Override public void setRollbackOnly() { this.rollbackOnly = true; }
@Override public boolean getRollbackOnly() { return rollbackOnly; }
}
|
Finally, we have some application specific code (ie, code that "knows" about the Post and Blog objects. I tried to do it with reflection but it appears that AspectJ renames the fields under the covers, so I had to do instanceof tests here.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 | // Source: src/main/java/com/mycompany/alfresco/client/remote/ConversionUtils.java
package com.mycompany.alfresco.client.remote;
import java.util.HashMap;
import java.util.Map;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import com.mycompany.alfresco.client.domain.Blog;
import com.mycompany.alfresco.client.domain.Post;
import com.mycompany.alfresco.client.domain.PubStates;
public class ConversionUtils {
private static final Log logger = LogFactory.getLog(ConversionUtils.class);
public static <T> T mapToEntity(Class<T> entityClass,
Map<String,Object> result) {
if (entityClass.getName().endsWith("Blog")) {
Blog blog = new Blog();
blog.setBlogname((String) result.get("blogname"));
blog.setByline((String) result.get("byline"));
blog.setOwner((String) result.get("owner"));
blog.setProfile((String) result.get("profile"));
// on the Alfresco side, the node UUID comes from getId()
// the webscript uses the owner value to figure out the blog
blog.setUuid((String) result.get("owner"));
return (T) blog;
} else if (entityClass.getName().endsWith("Post")) {
Post post = new Post();
post.setTitle((String) result.get("title"));
post.setSummary((String) result.get("description"));
post.setFurl((String) result.get("furl"));
post.setPubState(PubStates.valueOf((String) result.get("pubState")));
post.setContent((String) result.get("content"));
// on the Alfresco side, the node UUID comes from getId()
post.setUuid((String) result.get("id"));
// but on the Roo side, it comes from UUID
if (StringUtils.isEmpty(post.getUuid())) {
post.setUuid((String) result.get("uuid"));
}
return (T) post;
} else {
return null;
}
}
public static Map<String,String> entityToMap(Object entity) {
Map<String,String> fields = new HashMap<String,String>();
if (entity instanceof Blog) {
fields.put("blogname", ((Blog) entity).getBlogname());
fields.put("byline", ((Blog) entity).getByline());
fields.put("owner", ((Blog) entity).getOwner());
fields.put("profile", ((Blog) entity).getProfile());
fields.put("uuid", ((Blog) entity).getUuid());
} else if (entity instanceof Post) {
fields.put("title", ((Post) entity).getTitle());
fields.put("description", ((Post) entity).getSummary());
fields.put("furl", ((Post) entity).getFurl());
fields.put("pubState", ((Post) entity).getPubState().name());
fields.put("content", ((Post) entity).getContent());
fields.put("uuid", ((Post) entity).getUuid());
}
return fields;
}
public static String getPrimaryKey(Object entity) {
if (entity instanceof Post) {
return ((Post) entity).getUuid();
} else if (entity instanceof Blog) {
return ((Blog) entity).getOwner();
} else {
return null;
}
}
}
|
And finally, we wire it all together in the applicationContext.xml (replacing the configuration for the JPA Entity Manager that we set up using the "persistence setup" command in Roo).
1 2 3 4 5 6 7 8 9 10 | <bean id="transactionManager"
class="org.springframework.orm.jpa.JpaTransactionManager">
<property name="entityManagerFactory" ref="entityManagerFactory"/>
</bean>
<tx:annotation-driven mode="aspectj"
transaction-manager="transactionManager"/>
<bean id="entityManagerFactory"
class="com.mycompany.alfresco.client.remote.WebscriptEntityManagerFactory">
<property name="webscriptClient" ref="webscriptClient"/>
</bean>
|
On the Alfresco side, we provide the following Webscripts to implement the CRUD operations in terms of the Alfresco Node API. For each entity, we have a count, get and find Webscript, returning the count of records, a single record (by primary key), a list of records respectively. In addition, each entity also has an update and delete Webscript that update (or insert) and delete a record from the Alfresco CMS.
In our case, the currently logged in user is passed into all the Webscripts from Spring Security's SecurityContextHolder, since our results always need to be filtered by the user (or his role). In addition, since we split documents by publish state, we need to pass that in as well. For the blog record, there is only ever going to be a single blog (and associated profile) per user, so I didn't implement some of the Webscripts for blog that were not needed.
Name | HTTP Method | Description | Called from |
authenticate | GET | Accepts username and password from Roo and returns the user's role. Throws an Autnetication Exception if invalid username/password. | WebscriptAuthenticationProvider |
blog_get | GET | Returns the blog information for the current user. | WebscriptEntityManager |
blog_update | POST | Updates the blog record for the current user. | WebscriptEntityManager |
post_count | GET | Returns the number of Draft posts for the current user if user has blogger role. If user has editor role, then returns the number of posts for the specified publish state. | Post |
post_get | GET | Returns a post for the provided node UUID. | WebscriptEntityManager |
post_find | GET | Returns a list of Draft posts for the current user if user has blogger role. If user has editor role, returns a list of posts for the specific publish state. | Post |
post_update | POST | Updates the current post by UUID if available, else creates a new post entry in Alfresco. | WebscriptEntityManager |
post_delete | POST | Deletes the post specified by the node UUID. | WebscriptEntityManager |
Application Customization - Blog
Roo treats all entities the same way, as it should. However, for my app, there is only ever going to be one Blog entry per user, so the listing page made no sense for me. I also wanted the URL for the "Create New Blog" link (/blog/form) to be used for editing the single blog entry. So I overrode the BlogController_Roo_Controller.aj createForm() method (by commenting it out and adding a corresponding method) in BlogController.java.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | // Source: src/main/java/com/mycompany/alfresco/client/web/BlogController.java
package com.mycompany.alfresco.client.web;
import org.springframework.roo.addon.web.mvc.controller.RooWebScaffold;
import com.mycompany.alfresco.client.domain.Blog;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
@RooWebScaffold(path = "blog", automaticallyMaintainView = false,
formBackingObject = Blog.class)
@RequestMapping("/blog/**")
@Controller
public class BlogController {
@RequestMapping(value = "/blog/form", method = RequestMethod.GET)
public String createForm(ModelMap modelMap) {
String user =
SecurityContextHolder.getContext().getAuthentication().getName();
modelMap.addAttribute("blog", Blog.findBlog(user));
return "blog/create";
}
}
|
Application Customization - Post
The Post_Roo_Entity.aj file has methods countPosts(), findAllPosts() and findPostEntries() whose signatures did not work for me, since I needed to pass in the user and (optionally) publish state parameters as well, so I created these new methods in my Post.java class.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | // Source: src/main/java/com/mycompany/alfresco/client/domain/Post.java
package com.mycompany.alfresco.client.domain;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import javax.persistence.Entity;
import javax.persistence.Enumerated;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.roo.addon.entity.RooEntity;
import org.springframework.roo.addon.javabean.RooJavaBean;
import org.springframework.roo.addon.tostring.RooToString;
import com.mycompany.alfresco.client.remote.ConversionUtils;
@SuppressWarnings("unchecked")
@Entity
@RooJavaBean
@RooToString
@RooEntity(identifierField = "uuid", identifierType = String.class)
public class Post {
private final static Log logger = LogFactory.getLog(Post.class);
private String title;
private String summary;
private String furl;
@Enumerated
private PubStates pubState;
private String content;
// same method names as those in the AspectJ code, but different sigs
public static int countPosts(String username, String pubState) {
String classname = Post.class.getSimpleName();
StringBuilder qbuf = new StringBuilder();
qbuf.append("mycompany/").
append(StringUtils.lowerCase(classname)).
append("_count.json").
append("?user=").
append(username);
if (StringUtils.isNotEmpty(pubState)) {
qbuf.append("&pubState=").
append(pubState);
}
Map<String,Object> result =
(Map<String,Object>) Post.entityManager().createQuery(
qbuf.toString()).getSingleResult();
return (Integer) result.get("count");
}
public static List<Post> findAllPosts(String username, String pubState) {
String classname = Post.class.getSimpleName();
StringBuilder qbuf = new StringBuilder();
qbuf.append("mycompany/").
append(StringUtils.lowerCase(classname)).
append("_find.json").
append("?user=").
append(username);
if (StringUtils.isNotEmpty(pubState)) {
qbuf.append("&pubState=").
append(pubState);
}
List<Map<String,Object>> results =
(List<Map<String,Object>>) Post.entityManager().createQuery(
qbuf.toString()).getResultList();
List<Post> posts = new ArrayList<Post>();
for (Map<String,Object> result : results) {
posts.add(ConversionUtils.mapToEntity(Post.class, result));
}
return posts;
}
public static List<Post> findPostEntries(String username,
String pubState, int firstResult, int maxResults) {
List<Post> posts = findAllPosts(username, pubState);
if (firstResult < 0 || firstResult > posts.size()) {
return Collections.emptyList();
}
if (firstResult + maxResults > posts.size()) {
maxResults = posts.size();
}
return posts.subList(firstResult, firstResult + maxResults);
}
}
|
On the controller, I overrode (by commenting out) the generated (AspectJ) list() method to pass in the optional parameter pubState, and call these new methods I just added to the Post class.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | // Source: src/main/java/com/mycompany/alfresco/client/web/PostController.java
package com.mycompany.alfresco.client.web;
import org.apache.commons.lang.StringUtils;
import org.springframework.roo.addon.web.mvc.controller.RooWebScaffold;
import com.mycompany.alfresco.client.domain.Post;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
@RooWebScaffold(path = "post", automaticallyMaintainView = false,
formBackingObject = Post.class)
@RequestMapping("/post/**")
@Controller
public class PostController {
@RequestMapping(value = "/post", method = RequestMethod.GET)
public String list(
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size,
@RequestParam(value = "pubState", required = false) String pubState,
ModelMap modelMap) {
if (page == null || page <= 0) {
page = 1;
}
if (size == null || size <= 0) {
size = 5;
}
if (StringUtils.isEmpty(pubState)) {
pubState = "inbox";
}
String user =
SecurityContextHolder.getContext().getAuthentication().getName();
int count = Post.countPosts(user, pubState);
int maxPages = Post.countPosts(user, pubState) / size;
if (count % size != 0) {
maxPages = maxPages + 1;
}
if (page > maxPages) {
page = maxPages;
}
int start = (page - 1) * size;
int end = Math.min(start + size, count);
modelMap.addAttribute("maxPages", maxPages);
modelMap.addAttribute("posts",
Post.findPostEntries(user, pubState, start, end - start));
return "post/list";
}
}
|
I also modified the post/list.jspx method to produce a listing format that contained the title and summary (similar to search results) to provide bloggers and editors with a nicer experience than using the default Roo list format. The actual changes are quite trivial (merely rearranging components), so I don't show it here. But to show you how it looks, here is a screenshot of the Post listing page for a blogger.
Add Rich Text Editor
One of the things that I have hear often from users is how much nicer their experience is with a web application if they can enter text using a Rich Text Editor, especially for content which is ultimately going to be rendered as HTML. In my case, the post content and the blog profile are good candidates, so I decided it was time to figure out how to do this.
My first attempt was to try and embed FCKEditor, which did not go too well, since the FCK:Editor tag basically expands into an IFRAME which pulls in the editor HTML from a URL which the default Spring DispatcherServlet attempts to serve, resulting in a 404. I could not find a way to either (a) configure the tag to produce a different URL or (b) configure Spring to treat the IFRAME URL as an exception, although, to be honest, I did not try too hard once I realized that Roo uses the Dojo as its Javascript library, and there is already a Rich Text Editor widget in the Dijit (Dojo Widget) library.
According to this article listing various RTEs, if the numbers are anything to go by, FCKEditor is at #2 and dijit.Editor at #18, so perhaps the dijit.Editor is not such a great choice after all. It definitely does not seem to be as feature rich as FCKEditor, although you can add a few plugins to the default set to make it somewhat comparable. Integrating dijit.Editor was quite painless though, I just modified the create.jspx and update.jspx, based on the information on this documentation page for dijit.Editor.
You also need some extra Javascript code described here to actually pass the editor contents into the server layer, by declaring a hidden field and populating it with the editor contents on form submission.
The additional code shown below needs to be applied to all the .jspx files that expose a dijit.Editor component. In my case, it was the blog/create.jspx and the post/create.jspx and post/update.jspx. In addition, you need to give your form an id and declare a hidden field that to hold the editor contents on form submit - the snippet below is from my post/create.jspx file.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | <div ...>
<jsp:output omit-xml-declaration="yes"/>
<!-- modified block to add require calls for dijit.Editor and plugins -->
<script type="text/javascript">
dojo.require('dijit.TitlePane');
dojo.require('dijit.form.FilteringSelect');
dojo.require('dijit.form.SimpleTextarea');
dojo.require('dijit.Editor');
dojo.require('dijit._editor.plugins.FontChoice');
dojo.require('dijit._editor.plugins.TextColor');
dojo.require('dijit._editor.plugins.LinkDialog');
dojo.require('dijit._editor.plugins.ViewSource');
dojo.addOnLoad(function() {
dojo.connect(dojo.byId('_form_post_create'),
'onsubmit', function() {
dojo.byId('_hidden_content_id').value =
dijit.byId('_content_id').getValue(false);
}});
</script>
...
<form:form id="_form_post_create" action="${form_url}"
method="POST" modelAttribute="post">
...
<div id="roo_post_content">
<label for="_content_id">Content:</label>
<form:textarea cssStyle="width:250px" id="_content_id"
path="content"/>
<script type="text/javascript">
Spring.addDecoration(new Spring.ElementDecoration({
elementId : '_content_id',
widgetType: 'dijit.Editor',
widgetAttrs: {
extraPlugins: ['foreColor','hiliteColor','createLink',
'insertImage','viewsource']
}}));
</script>
<br/>
<form:errors cssClass="errors" id="_content_error_id"
path="content"/>
</div>
<br/>
...
<form:hidden id="_hidden_content_id" path="content"/>
</form:form>
</div>
</div>
|
I could not get the ViewSource plugin to work (which I think is needed in this context, to allow authors to drop down to HTML if there is no RTE control to do something specific) - perhaps because this plugin is in Dojo 1.4 and the Dojo library in the Spring-Javascript library used by Roo may be older. In any case, despite the convenience, I think it would make sense to use FCKEditor instead, so perhaps I will try that later.
Also, since we are allowing users to enter rich text, we need to render it accordingly on the blog/show.jspx and post/show.jspx. All it involves is to enable the escapeXml attribute on the c:out call. Here is the snippet from my post/show.jspx file.
1 2 3 4 5 6 7 8 | ...
<div id="roo_post_content">
<label for="_content_id">Content:</label>
<div class="box" id="_content_id">
<c:out value="${post.content}" escapeXml="false"/>
</div>
</div>
...
|
Here are some screenshots of the rich text editor on the post edit form and the rendering of the post on the show form.
Closing Thoughts
The app that I described here is not a "typical" Roo app, in the sense that the final app is very different from the one generated using commands in the Roo shell. Apart from the application level customizations, the major work involved here was the custom persistence layer. I don't know enough about Roo to build a reusable persistence layer - I have asked on the forum, but there has been no reply, so it is possible that my question was too vague or that its hard to explain (and by extension, hard to implement). In the absence of a reusable persistence layer, the code can be copied over (or JAR'ed up into a library) to other Roo apps that have similar persistence needs.