org.modeshape.web.jcr.rest.handler.ItemHandler Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of modeshape-web-jcr-rest
Show all versions of modeshape-web-jcr-rest
ModeShape REST support library
/*
* ModeShape (http://www.modeshape.org)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.modeshape.web.jcr.rest.handler;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.jcr.Binary;
import javax.jcr.Item;
import javax.jcr.ItemNotFoundException;
import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.PathNotFoundException;
import javax.jcr.Property;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.Value;
import javax.jcr.ValueFactory;
import javax.jcr.nodetype.NodeType;
import javax.jcr.version.VersionManager;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.NotAuthorizedException;
import javax.ws.rs.NotFoundException;
import org.codehaus.jettison.json.JSONArray;
import org.codehaus.jettison.json.JSONException;
import org.codehaus.jettison.json.JSONObject;
import org.modeshape.common.annotation.Immutable;
import org.modeshape.common.util.Base64;
import org.modeshape.jcr.api.JcrConstants;
import org.modeshape.web.jcr.rest.RestHelper;
/**
* Resource handler that implements REST methods for items.
*/
@Immutable
public abstract class ItemHandler extends AbstractHandler {
protected static final String CHILD_NODE_HOLDER = "children";
private static final String PRIMARY_TYPE_PROPERTY = JcrConstants.JCR_PRIMARY_TYPE;
private static final String MIXIN_TYPES_PROPERTY = JcrConstants.JCR_MIXIN_TYPES;
private static final String PROPERTIES_HOLDER = "properties";
/**
* Adds the node described by {@code jsonNode} with name {@code nodeName} to the existing node {@code parentNode}.
*
* @param parentNode the parent of the node to be added
* @param nodeName the name of the node to be added
* @param jsonNode the JSON-encoded representation of the node or nodes to be added.
* @return the JSON-encoded representation of the node or nodes that were added. This will differ from {@code requestContent}
* in that auto-created and protected properties (e.g., jcr:uuid) will be populated.
* @throws JSONException if there is an error encoding the node
* @throws RepositoryException if any other error occurs
*/
protected Node addNode( Node parentNode,
String nodeName,
JSONObject jsonNode ) throws RepositoryException, JSONException {
Node newNode;
JSONObject properties = getProperties(jsonNode);
if (properties.has(PRIMARY_TYPE_PROPERTY)) {
String primaryType = properties.getString(PRIMARY_TYPE_PROPERTY);
newNode = parentNode.addNode(nodeName, primaryType);
} else {
newNode = parentNode.addNode(nodeName);
}
if (properties.has(MIXIN_TYPES_PROPERTY)) {
// Be sure to set this property first, before the other properties in case the other properties
// are defined only on one of the mixin types ...
updateMixins(newNode, properties.get(MIXIN_TYPES_PROPERTY));
}
for (Iterator> iter = properties.keys(); iter.hasNext();) {
String key = (String)iter.next();
if (PRIMARY_TYPE_PROPERTY.equals(key) || MIXIN_TYPES_PROPERTY.equals(key)) {
continue;
}
setPropertyOnNode(newNode, key, properties.get(key));
}
if (hasChildren(jsonNode)) {
List children = getChildren(jsonNode);
for (JSONChild child : children) {
addNode(newNode, child.getName(), child.getBody());
}
}
return newNode;
}
protected List getChildren( JSONObject jsonNode ) throws JSONException {
List children;
try {
JSONObject childrenObject = jsonNode.getJSONObject(CHILD_NODE_HOLDER);
children = new ArrayList<>(childrenObject.length());
for (Iterator> iterator = childrenObject.keys(); iterator.hasNext();) {
String childName = iterator.next().toString();
//it is not possible to have SNS in the object form, so the index will always be 1
children.add(new JSONChild(childName, childrenObject.getJSONObject(childName), 1));
}
return children;
} catch (JSONException e) {
JSONArray childrenArray = jsonNode.getJSONArray(CHILD_NODE_HOLDER);
children = new ArrayList<>(childrenArray.length());
Map visitedNames = new HashMap<>(childrenArray.length());
for (int i = 0; i < childrenArray.length(); i++) {
JSONObject child = childrenArray.getJSONObject(i);
if (child.length() == 0) {
continue;
}
if (child.length() > 1) {
logger.warn("The child object {0} has more than 1 elements, only the first one will be taken into account",
child);
}
String childName = child.keys().next().toString();
int sns = visitedNames.containsKey(childName) ? visitedNames.get(childName) + 1 : 1;
visitedNames.put(childName, sns);
children.add(new JSONChild(childName, child.getJSONObject(childName), sns));
}
return children;
}
}
protected boolean hasChildren( JSONObject jsonNode ) {
return jsonNode.has(CHILD_NODE_HOLDER);
}
protected JSONObject getProperties( JSONObject jsonNode ) throws JSONException {
return jsonNode.has(PROPERTIES_HOLDER) ? jsonNode.getJSONObject(PROPERTIES_HOLDER) : new JSONObject();
}
private Value createBinaryValue( String base64EncodedValue,
ValueFactory valueFactory ) throws RepositoryException {
InputStream stream = null;
try {
byte[] binaryValue = Base64.decode(base64EncodedValue);
stream = new ByteArrayInputStream(binaryValue);
Binary binary = valueFactory.createBinary(stream);
return valueFactory.createValue(binary);
} catch (IOException ioe) {
throw new RepositoryException(ioe);
} finally {
try {
if (stream != null) {
stream.close();
}
} catch (IOException e) {
logger.debug(e, "Error while closing binary stream");
}
}
}
/**
* Sets the named property on the given node. This method expects {@code value} to be either a JSON string or a JSON array of
* JSON strings. If {@code value} is a JSON array, {@code Node#setProperty(String, String[]) the multi-valued property setter}
* will be used.
*
* @param node the node on which the property is to be set
* @param propName the name of the property to set
* @param value the JSON-encoded values to be set
* @throws RepositoryException if there is an error setting the property
* @throws JSONException if {@code value} cannot be decoded
*/
protected void setPropertyOnNode( Node node,
String propName,
Object value ) throws RepositoryException, JSONException {
// Are the property values encoded ?
boolean encoded = propName.endsWith(BASE64_ENCODING_SUFFIX);
if (encoded) {
int newLength = propName.length() - BASE64_ENCODING_SUFFIX.length();
propName = newLength > 0 ? propName.substring(0, newLength) : "";
}
Object values = convertToJcrValues(node, value, encoded);
if (values == null) {
// remove the property
node.setProperty(propName, (Value) null);
} else if (values instanceof Value) {
node.setProperty(propName, (Value) values);
} else {
node.setProperty(propName, (Value[]) values);
}
}
private Set updateMixins( Node node,
Object mixinsJsonValue ) throws JSONException, RepositoryException {
Object valuesObject = convertToJcrValues(node, mixinsJsonValue, false);
Value[] values = null;
if (valuesObject == null) {
values = new Value[0];
} else if (valuesObject instanceof Value[]) {
values = (Value[])valuesObject;
} else {
values = new Value[]{(Value)valuesObject};
}
Set jsonMixins = new HashSet(values.length);
for (Value theValue : values) {
jsonMixins.add(theValue.getString());
}
Set mixinsToRemove = new HashSet();
for (NodeType nodeType : node.getMixinNodeTypes()) {
mixinsToRemove.add(nodeType.getName());
}
Set mixinsToAdd = new HashSet(jsonMixins);
mixinsToAdd.removeAll(mixinsToRemove);
mixinsToRemove.removeAll(jsonMixins);
for (String nodeType : mixinsToAdd) {
node.addMixin(nodeType);
}
// return the list of mixins to be removed, as that needs to be processed last due to type validation
return mixinsToRemove;
}
private Object convertToJcrValues( Node node,
Object value,
boolean encoded ) throws RepositoryException, JSONException {
if (value == JSONObject.NULL || (value instanceof JSONArray && ((JSONArray)value).length() == 0)) {
// for any null value of empty json array, return an empty array which will mean the property will be removed
return null;
}
org.modeshape.jcr.api.ValueFactory valueFactory = (org.modeshape.jcr.api.ValueFactory)node.getSession().getValueFactory();
if (value instanceof JSONArray) {
JSONArray jsonValues = (JSONArray)value;
Value[] values = new Value[jsonValues.length()];
for (int i = 0; i < jsonValues.length(); i++) {
if (encoded) {
values[i] = createBinaryValue(jsonValues.getString(i), valueFactory);
} else {
values[i] = RestHelper.jsonValueToJCRValue(jsonValues.get(i), valueFactory);
}
}
return values;
}
return encoded ? createBinaryValue(value.toString(), valueFactory) : RestHelper.jsonValueToJCRValue(value, valueFactory);
}
/**
* Deletes the item at {@code path}.
*
* @param request the servlet request; may not be null or unauthenticated
* @param rawRepositoryName the URL-encoded repository name
* @param rawWorkspaceName the URL-encoded workspace name
* @param path the path to the item
* @throws NotFoundException if no item exists at {@code path}
* @throws NotAuthorizedException if the user does not have the access required to delete the item at this path
* @throws RepositoryException if any other error occurs
*/
public void deleteItem( HttpServletRequest request,
String rawRepositoryName,
String rawWorkspaceName,
String path ) throws NotFoundException, NotAuthorizedException, RepositoryException {
assert rawRepositoryName != null;
assert rawWorkspaceName != null;
assert path != null;
Session session = getSession(request, rawRepositoryName, rawWorkspaceName);
doDelete(path, session);
session.save();
}
protected void doDelete( String path,
Session session ) throws RepositoryException {
Item item;
try {
item = session.getItem(path);
} catch (PathNotFoundException pnfe) {
throw new NotFoundException(pnfe.getMessage(), pnfe);
}
item.remove();
}
/**
* Updates the existing item based upon the supplied JSON content.
*
* @param item the node or property to be updated
* @param jsonItem the JSON of the item(s) to be updated
* @return the node that was updated; never null
* @throws JSONException if there is an error encoding the node
* @throws RepositoryException if any other error occurs
*/
protected Item updateItem( Item item,
JSONObject jsonItem ) throws RepositoryException, JSONException {
if (item instanceof Node) {
return updateNode((Node)item, jsonItem);
}
return updateProperty((Property)item, jsonItem);
}
private Property updateProperty( Property property,
JSONObject jsonItem ) throws RepositoryException, JSONException {
String propertyName = property.getName();
String jsonPropertyName = jsonItem.has(propertyName) ? propertyName : propertyName + BASE64_ENCODING_SUFFIX;
Node node = property.getParent();
setPropertyOnNode(node, jsonPropertyName, jsonItem.get(jsonPropertyName));
return property;
}
protected Node updateNode( Node node,
JSONObject jsonItem ) throws RepositoryException, JSONException {
VersionableChanges changes = new VersionableChanges(node.getSession());
try {
node = updateNode(node, jsonItem, changes);
changes.checkin();
} catch (RepositoryException | JSONException | RuntimeException e) {
changes.abort();
throw e;
}
return node;
}
/**
* Updates the existing node with the properties (and optionally children) as described by {@code jsonNode}.
*
* @param node the node to be updated
* @param jsonNode the JSON-encoded representation of the node or nodes to be updated.
* @param changes the versionable changes; may not be null
* @return the Node that was updated; never null
* @throws JSONException if there is an error encoding the node
* @throws RepositoryException if any other error occurs
*/
protected Node updateNode( Node node,
JSONObject jsonNode,
VersionableChanges changes ) throws RepositoryException, JSONException {
// If the JSON object has a properties holder, then this is likely a subgraph ...
JSONObject properties = jsonNode;
if (jsonNode.has(PROPERTIES_HOLDER)) {
properties = jsonNode.getJSONObject(PROPERTIES_HOLDER);
}
changes.checkout(node);
// Change the primary type first ...
if (properties.has(PRIMARY_TYPE_PROPERTY)) {
String primaryType = properties.getString(PRIMARY_TYPE_PROPERTY);
primaryType = primaryType.trim();
if (primaryType.length() != 0 && !node.getPrimaryNodeType().getName().equals(primaryType)) {
node.setPrimaryType(primaryType);
}
}
Set mixinsToRemove = new HashSet();
if (properties.has(MIXIN_TYPES_PROPERTY)) {
// Next add new mixins, but don't remove old ones yet, because that needs to happen only after all the children
// and properties have been processed
mixinsToRemove = updateMixins(node, properties.get(MIXIN_TYPES_PROPERTY));
}
// Now set all the other properties ...
for (Iterator> iter = properties.keys(); iter.hasNext();) {
String key = (String)iter.next();
if (PRIMARY_TYPE_PROPERTY.equals(key) || MIXIN_TYPES_PROPERTY.equals(key) || CHILD_NODE_HOLDER.equals(key)) {
continue;
}
setPropertyOnNode(node, key, properties.get(key));
}
// If the JSON object has a children holder, then we need to update the list of children and child nodes ...
if (hasChildren(jsonNode)) {
updateChildren(node, jsonNode, changes);
}
// after all the children and properties have been processed, remove mixins because that will trigger validation
for (String mixinToRemove : mixinsToRemove) {
node.removeMixin(mixinToRemove);
}
return node;
}
private void updateChildren( Node node,
JSONObject jsonNode,
VersionableChanges changes ) throws JSONException, RepositoryException {
Session session = node.getSession();
// Get the existing children ...
Map existingChildNames = new LinkedHashMap<>();
List existingChildrenToUpdate = new ArrayList<>();
NodeIterator childIter = node.getNodes();
while (childIter.hasNext()) {
Node child = childIter.nextNode();
String childName = nameOf(child);
existingChildNames.put(childName, child);
existingChildrenToUpdate.add(childName);
}
//keep track of the old/new order of children to be able to perform reorderings
List newChildrenToUpdate = new ArrayList<>();
List children = getChildren(jsonNode);
for (JSONChild jsonChild : children) {
String childName = jsonChild.getNameWithSNS();
JSONObject child = jsonChild.getBody();
// Find the existing node ...
if (node.hasNode(childName)) {
// The node exists, so get it and update it ...
Node childNode = node.getNode(childName);
String childNodeName = nameOf(childNode);
newChildrenToUpdate.add(childNodeName);
updateNode(childNode, child, changes);
existingChildNames.remove(childNodeName);
} else {
//try to see if the child name is actually an identifier
try {
Node childNode = session.getNodeByIdentifier(childName);
String childNodeName = nameOf(childNode);
if (childNode.getParent().getIdentifier().equals(node.getIdentifier())) {
//this is an existing child of the current node, referenced via an identifier
newChildrenToUpdate.add(childNodeName);
updateNode(childNode, child, changes);
existingChildNames.remove(childNodeName);
} else {
//this is a child belonging to another node
if (childNode.isNodeType("mix:shareable")) {
//if it's a shared node, we can't clone it because clone is not a session-scoped operation
logger.warn("The node {0} with the id {1} is a shared node belonging to another parent. It cannot be changed via the update operation",
childNode.getPath(), childNode.getIdentifier());
} else {
//move the node into this parent
session.move(childNode.getPath(), node.getPath() + "/" + childNodeName);
}
}
} catch (ItemNotFoundException e) {
//the child name is not a valid identifier, so treat it as a new child
addNode(node, childName, child);
}
}
}
// Remove the children in reverse order (starting with the last child to be removed) ...
LinkedList childNodes = new LinkedList(existingChildNames.values());
while (!childNodes.isEmpty()) {
Node child = childNodes.removeLast();
existingChildrenToUpdate.remove(child.getIdentifier());
child.remove();
}
// Do any necessary reorderings
if (newChildrenToUpdate.equals(existingChildrenToUpdate)) {
//no order changes exist
return;
}
for (int i = 0; i < newChildrenToUpdate.size() - 1; i++) {
String startNodeName = newChildrenToUpdate.get(i);
int startNodeOriginalPosition = existingChildrenToUpdate.indexOf(startNodeName);
assert startNodeOriginalPosition != -1;
for (int j = i + 1; j < newChildrenToUpdate.size(); j++) {
String nodeName = newChildrenToUpdate.get(j);
int nodeOriginalPosition = existingChildrenToUpdate.indexOf(nodeName);
assert nodeOriginalPosition != -1;
if (startNodeOriginalPosition > nodeOriginalPosition) {
//the start node should be moved *before* this node
node.orderBefore(startNodeName, nodeName);
}
}
}
}
private String nameOf( Node node ) throws RepositoryException {
int index = node.getIndex();
String childName = node.getName();
return index == 1 ? childName : childName + "[" + index + "]";
}
protected static class JSONChild {
private final String name;
private final JSONObject body;
private final int snsIdx;
protected JSONChild( String name, JSONObject body, int snsIdx ) {
this.name = name;
this.body = body;
this.snsIdx = snsIdx;
}
public String getName() {
return name;
}
public String getNameWithSNS() {
return snsIdx > 1 ? name + "[" + snsIdx + "]" : name;
}
public JSONObject getBody() {
return body;
}
@Override
public String toString() {
final StringBuilder sb = new StringBuilder("JSONChild{");
sb.append("name='").append(getNameWithSNS()).append('\'');
sb.append(", body=").append(body);
sb.append('}');
return sb.toString();
}
}
protected static class VersionableChanges {
private final Set changedVersionableNodes = new HashSet();
private final Session session;
private final VersionManager versionManager;
protected VersionableChanges( Session session ) throws RepositoryException {
this.session = session;
assert this.session != null;
this.versionManager = session.getWorkspace().getVersionManager();
}
public void checkout( Node node ) throws RepositoryException {
boolean versionable = node.isNodeType("mix:versionable");
if (versionable) {
String path = node.getPath();
versionManager.checkout(path);
this.changedVersionableNodes.add(path);
}
}
public void checkin() throws RepositoryException {
if (this.changedVersionableNodes.isEmpty()) {
return;
}
session.save();
RepositoryException first = null;
for (String path : this.changedVersionableNodes) {
try {
if (versionManager.isCheckedOut(path)) {
versionManager.checkin(path);
}
} catch (RepositoryException e) {
if (first == null) {
first = e;
}
}
}
if (first != null) {
throw first;
}
}
public void abort() throws RepositoryException {
if (this.changedVersionableNodes.isEmpty()) {
return;
}
// Throw out all the changes ...
session.refresh(false);
RepositoryException first = null;
for (String path : this.changedVersionableNodes) {
try {
if (versionManager.isCheckedOut(path)) {
versionManager.checkin(path);
}
} catch (RepositoryException e) {
if (first == null) {
first = e;
}
}
}
if (first != null) {
throw first;
}
}
}
}