From c26be5998172476543e79187999615dc2022598b Mon Sep 17 00:00:00 2001 From: Maxim Gorbunkov Date: Wed, 11 Feb 2015 13:50:50 +0000 Subject: [PATCH] Generic service invocation API #PL-3952 --- .../cuba/portal/restapi/Convertor.java | 4 + .../portal/restapi/DataServiceController.java | 116 +++++++++++++++- .../cuba/portal/restapi/JSONConvertor.java | 45 +++++++ .../cuba/portal/restapi/MyJSONObject.java | 17 ++- .../restapi/RestServicePermissions.java | 125 ++++++++++++++++++ .../cuba/portal/restapi/XMLConvertor.java | 61 +++++++-- .../cuba/portal/restapi/rest-services.xsd | 33 +++++ modules/portal/src/cuba-portal-app.properties | 1 + modules/portal/src/cuba-rest-services.xml | 12 ++ 9 files changed, 402 insertions(+), 12 deletions(-) create mode 100644 modules/portal/src/com/haulmont/cuba/portal/restapi/RestServicePermissions.java create mode 100644 modules/portal/src/com/haulmont/cuba/portal/restapi/rest-services.xsd create mode 100644 modules/portal/src/cuba-rest-services.xml diff --git a/modules/portal/src/com/haulmont/cuba/portal/restapi/Convertor.java b/modules/portal/src/com/haulmont/cuba/portal/restapi/Convertor.java index 057eb3a772..2d08e67ac8 100644 --- a/modules/portal/src/com/haulmont/cuba/portal/restapi/Convertor.java +++ b/modules/portal/src/com/haulmont/cuba/portal/restapi/Convertor.java @@ -10,6 +10,7 @@ import com.haulmont.cuba.core.entity.Entity; import com.haulmont.cuba.core.global.View; import javax.activation.MimeType; +import javax.annotation.Nullable; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.lang.reflect.InvocationTargetException; @@ -33,6 +34,9 @@ public interface Convertor { Object process(Set entities, String requestURI) throws InvocationTargetException, NoSuchMethodException, IllegalAccessException; + Object processServiceMethodResult(Object result, String requestURI, @Nullable String viewName) + throws NoSuchMethodException, IllegalAccessException, InvocationTargetException; + CommitRequest parseCommitRequest(String content); void write(HttpServletResponse response, Object o) throws IOException; diff --git a/modules/portal/src/com/haulmont/cuba/portal/restapi/DataServiceController.java b/modules/portal/src/com/haulmont/cuba/portal/restapi/DataServiceController.java index 6fb52bdfd4..8e9c346590 100644 --- a/modules/portal/src/com/haulmont/cuba/portal/restapi/DataServiceController.java +++ b/modules/portal/src/com/haulmont/cuba/portal/restapi/DataServiceController.java @@ -11,7 +11,6 @@ import com.haulmont.chile.core.datatypes.impl.*; import com.haulmont.chile.core.model.MetaClass; import com.haulmont.cuba.core.app.DataService; import com.haulmont.cuba.core.app.DomainDescriptionService; -import com.haulmont.cuba.core.entity.BaseGenericIdEntity; import com.haulmont.cuba.core.entity.Entity; import com.haulmont.cuba.core.global.*; import com.haulmont.cuba.core.sys.AbstractViewRepository; @@ -22,15 +21,18 @@ import org.apache.commons.lang.exception.ExceptionUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.stereotype.Controller; +import org.springframework.util.ClassUtils; import org.springframework.web.bind.annotation.*; import javax.activation.MimeType; +import javax.annotation.Nullable; import javax.inject.Inject; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.StringReader; import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.text.ParseException; import java.util.*; @@ -67,6 +69,9 @@ public class DataServiceController { @Inject protected Authentication authentication; + @Inject + protected RestServicePermissions restServicePermissions; + @RequestMapping(value = "/api/find.{type}", method = RequestMethod.GET) public void find(@PathVariable String type, @RequestParam(value = "e") String entityRef, @@ -306,6 +311,115 @@ public class DataServiceController { } } + @RequestMapping(value = "/api/service.{type}", method = RequestMethod.GET) + public void serviceByGet(@PathVariable(value = "type") String type, + @RequestParam(value = "s") String sessionId, + @RequestParam(value = "service") String serviceName, + @RequestParam(value = "method") String methodName, + @RequestParam(value = "view", required = false) String view, + HttpServletRequest request, + HttpServletResponse response) throws IOException { + if (!authentication.begin(sessionId)) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + if (!restServicePermissions.isPermitted(serviceName, methodName)) { + response.sendError(HttpServletResponse.SC_FORBIDDEN); + return; + } + try { + response.addHeader("Access-Control-Allow-Origin", "*"); + + Map parameterMap = request.getParameterMap(); + List paramValuesString = new ArrayList<>(); + List> paramTypes = new ArrayList<>(); + + int idx = 0; + while (true) { + String[] _values = parameterMap.get("param" + idx); + if (_values == null) break; + if (_values.length > 1) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Multiple values for param" + idx); + return; + } + paramValuesString.add(_values[0]); + + String[] _types = parameterMap.get("param" + idx + "_type"); + if (_types != null) { + if (_types.length > 1) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Multiple values for param" + idx + "_type"); + return; + } + paramTypes.add(idx, ClassUtils.forName(_types[0], null)); + } else if (!paramTypes.isEmpty()) { + //types should be defined for all parameters or for none of them + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Parameter type for param" + idx +" is not defined"); + return; + } + idx++; + } + + Object service = AppBeans.get(serviceName); + Method serviceMethod; + if (paramTypes.isEmpty()) { + //trying to guess which method to invoke + Method[] methods = service.getClass().getMethods(); + List appropriateMethods = new ArrayList<>(); + for (Method method : methods) { + if (methodName.equals(method.getName()) && method.getParameterTypes().length == paramValuesString.size()) { + appropriateMethods.add(method); + } + } + if (appropriateMethods.size() == 1) { + serviceMethod = appropriateMethods.get(0); + } else if (appropriateMethods.size() > 1) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "There are multiple methods with given argument numbers. Please define parameter types in request"); + return; + } else { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Method not found"); + return; + } + } else { + try { + serviceMethod = service.getClass().getMethod(methodName, paramTypes.toArray(new Class[paramTypes.size()])); + } catch (NoSuchMethodException e) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Method not found"); + return; + } + } + + List paramValues = new ArrayList<>(); + Class[] types = serviceMethod.getParameterTypes(); + for (int i = 0; i < types.length; i++) { + Class aClass = types[i]; + paramValues.add(toObject(aClass, paramValuesString.get(i))); + } + + Object result = serviceMethod.invoke(service, paramValues.toArray()); + + Convertor convertor = conversionFactory.getConvertor(type); + Object converted = convertor.processServiceMethodResult(result, request.getRequestURI(), view); + convertor.write(response, converted); + } catch (Throwable e) { + sendError(request, response, e); + } finally { + authentication.end(); + } + } + + private Object toObject(Class clazz, String value) { + if (Boolean.class == clazz || Boolean.TYPE == clazz) return Boolean.parseBoolean(value); + if (Byte.class == clazz || Byte.TYPE == clazz) return Byte.parseByte(value); + if (Short.class == clazz || Short.TYPE == clazz) return Short.parseShort(value); + if (Integer.class == clazz || Integer.TYPE == clazz) return Integer.parseInt(value); + if (Long.class == clazz || Long.TYPE == clazz) return Long.parseLong(value); + if (Float.class == clazz || Float.TYPE == clazz) return Float.parseFloat(value); + if (Double.class == clazz || Double.TYPE == clazz) return Double.parseDouble(value); + if (UUID.class == clazz) return UUID.fromString(value); + if (String.class == clazz) return value; + throw new IllegalArgumentException("Parameters of type " + clazz.getName() + " are not supported"); + } + private void sendError(HttpServletRequest request, HttpServletResponse response, Throwable e) throws IOException { log.error("Error processing request: " + request.getRequestURI() + "?" + request.getQueryString(), e); diff --git a/modules/portal/src/com/haulmont/cuba/portal/restapi/JSONConvertor.java b/modules/portal/src/com/haulmont/cuba/portal/restapi/JSONConvertor.java index 569bdc2256..0b41d5ba97 100644 --- a/modules/portal/src/com/haulmont/cuba/portal/restapi/JSONConvertor.java +++ b/modules/portal/src/com/haulmont/cuba/portal/restapi/JSONConvertor.java @@ -21,6 +21,7 @@ package com.haulmont.cuba.portal.restapi; +import com.google.common.base.Strings; import com.haulmont.chile.core.datatypes.impl.StringDatatype; import com.haulmont.chile.core.model.MetaClass; import com.haulmont.chile.core.model.MetaProperty; @@ -36,6 +37,8 @@ import org.json.JSONObject; import javax.activation.MimeType; import javax.activation.MimeTypeParseException; +import javax.annotation.Nullable; +import javax.persistence.Id; import javax.servlet.http.HttpServletResponse; import java.beans.IntrospectionException; import java.beans.PropertyDescriptor; @@ -116,6 +119,48 @@ public class JSONConvertor implements Convertor { return result; } + @Override + public Object processServiceMethodResult(Object result, String requestURI, @Nullable String viewName) + throws NoSuchMethodException, IllegalAccessException, InvocationTargetException { + MyJSONObject root = new MyJSONObject(); + if (result instanceof Entity) { + Entity entity = (Entity) result; + ViewRepository viewRepository = AppBeans.get(ViewRepository.class); + if (Strings.isNullOrEmpty(viewName)) viewName = View.LOCAL; + View view = viewRepository.getView(entity.getMetaClass(), viewName); + MyJSONObject entityObject = process(entity, entity.getMetaClass(), "", view); + root.set("result", entityObject); + } else if (result instanceof Collection) { + if (!checkCollectionItemTypes((Collection) result, Entity.class)) + throw new IllegalArgumentException("Items that are not instances of Entity found in service method result"); + ArrayList list = new ArrayList((Collection) result); + MetaClass metaClass; + if (!list.isEmpty()) + metaClass = ((Entity) list.get(0)).getMetaClass(); + else + metaClass = AppBeans.get(Metadata.class).getClasses().iterator().next(); + ViewRepository viewRepository = AppBeans.get(ViewRepository.class); + if (Strings.isNullOrEmpty(viewName)) viewName = View.LOCAL; + View view = viewRepository.getView(metaClass, viewName); + MyJSONObject.Array processed = process(list, metaClass, requestURI, view); + root.set("result", processed); + } else { + root.set("result", result); + } + return root; + } + + /** + * Checks that all collection items are instances of given class + */ + protected boolean checkCollectionItemTypes(Collection collection, Class itemClass) { + for (Object collectionItem : collection) { + if (!itemClass.isAssignableFrom(collectionItem.getClass())) + return false; + } + return true; + } + private MetaClass getMetaClass(Entity entity) { return metadata.getSession().getClassNN(entity.getClass()); } diff --git a/modules/portal/src/com/haulmont/cuba/portal/restapi/MyJSONObject.java b/modules/portal/src/com/haulmont/cuba/portal/restapi/MyJSONObject.java index 7b3f8cbb54..e3a870f17c 100644 --- a/modules/portal/src/com/haulmont/cuba/portal/restapi/MyJSONObject.java +++ b/modules/portal/src/com/haulmont/cuba/portal/restapi/MyJSONObject.java @@ -21,6 +21,8 @@ package com.haulmont.cuba.portal.restapi; +import com.google.common.base.Strings; + import java.io.PrintWriter; import java.util.*; @@ -33,8 +35,12 @@ public class MyJSONObject implements MyJSON { private final boolean _ref; private final Map _values; + public MyJSONObject() { + this(null, false); + } + public MyJSONObject(Object id, boolean ref) { - _id = id.toString(); + _id = id != null ? id.toString() : null; _ref = ref; _values = new LinkedHashMap(); } @@ -52,14 +58,19 @@ public class MyJSONObject implements MyJSON { public StringBuilder asString(int indent) { StringBuilder buf = new StringBuilder().append(OBJECT_START); - buf.append(encodeField(_ref ? REF_MARKER : ID_MARKER, ior(), 0)); + if (!Strings.isNullOrEmpty(_id)) { + buf.append(encodeField(_ref ? REF_MARKER : ID_MARKER, ior(), 0)); + buf.append(FIELD_SEPARATOR).append(NEWLINE); + } if (_ref) { return buf.append(OBJECT_END); } StringBuilder tab = newIndent(indent+1); + int i = 0; for (Map.Entry e : _values.entrySet()) { - buf.append(FIELD_SEPARATOR).append(NEWLINE); buf.append(tab).append(encodeField(e.getKey(), e.getValue(), indent+1)); + if (i++ < _values.entrySet().size() - 1) + buf.append(FIELD_SEPARATOR).append(NEWLINE); } buf.append(NEWLINE) .append(newIndent(indent)) diff --git a/modules/portal/src/com/haulmont/cuba/portal/restapi/RestServicePermissions.java b/modules/portal/src/com/haulmont/cuba/portal/restapi/RestServicePermissions.java new file mode 100644 index 0000000000..e8d0b27e23 --- /dev/null +++ b/modules/portal/src/com/haulmont/cuba/portal/restapi/RestServicePermissions.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2008-2015 Haulmont. All rights reserved. + * Use is subject to license terms, see http://www.cuba-platform.com/license for details. + */ + +package com.haulmont.cuba.portal.restapi; + +import com.haulmont.bali.util.Dom4j; +import com.haulmont.cuba.core.global.Resources; +import com.haulmont.cuba.core.sys.AppContext; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.text.StrTokenizer; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.dom4j.Element; +import org.springframework.core.io.Resource; + +import javax.annotation.ManagedBean; +import javax.inject.Inject; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * Class holds an information about services that allowed to be invoked with REST API. + * Configuration is loaded from {@code *-rest-services.xml} files. + * + * @author gorbunkov + * @version $Id$ + */ +@ManagedBean(RestServicePermissions.NAME) +public class RestServicePermissions { + + public static final String NAME = "cuba_RestServicePermissions"; + + public static final String CUBA_REST_SERVICES_CONFIG_PROP_NAME = "cuba.restServicesConfig"; + + @Inject + protected Resources resources; + + protected static Log log = LogFactory.getLog(RestServicePermissions.class); + + protected Map> serviceMethods = new ConcurrentHashMap<>(); + + protected volatile boolean initialized; + + protected ReadWriteLock lock = new ReentrantReadWriteLock(); + + /** + * Checks whether method of service is allowed to be invoked with REST API + */ + public boolean isPermitted(String serviceName, String methodName) { + lock.readLock().lock(); + try { + checkInitialized(); + Set methods = serviceMethods.get(serviceName); + return methods != null && methods.contains(methodName); + } finally { + lock.readLock().unlock(); + } + } + + protected void checkInitialized() { + if (!initialized) { + lock.readLock().unlock(); + lock.writeLock().lock(); + try { + if (!initialized) { + init(); + initialized = true; + } + } finally { + lock.readLock().lock(); + lock.writeLock().unlock(); + } + } + } + + protected void init() { + String configName = AppContext.getProperty(CUBA_REST_SERVICES_CONFIG_PROP_NAME); + StrTokenizer tokenizer = new StrTokenizer(configName); + for (String location : tokenizer.getTokenArray()) { + Resource resource = resources.getResource(location); + if (resource.exists()) { + InputStream stream = null; + try { + stream = resource.getInputStream(); + loadConfig(Dom4j.readDocument(stream).getRootElement()); + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + IOUtils.closeQuietly(stream); + } + } else { + log.warn("Resource " + location + " not found, ignore it"); + } + } + } + + protected void loadConfig(Element rootElem) { + for (Element serviceElem : Dom4j.elements(rootElem, "service")) { + String serviceName = serviceElem.attributeValue("name"); + + for (Element methodElem : Dom4j.elements(serviceElem, "method")) { + String methodName = methodElem.attributeValue("name"); + addServiceMethod(serviceName, methodName); + } + } + } + + protected void addServiceMethod(String serviceName, String methodName) { + Set methods = serviceMethods.get(serviceName); + if (methods == null) { + methods = new HashSet<>(); + serviceMethods.put(serviceName, methods); + } + methods.add(methodName); + } + +} diff --git a/modules/portal/src/com/haulmont/cuba/portal/restapi/XMLConvertor.java b/modules/portal/src/com/haulmont/cuba/portal/restapi/XMLConvertor.java index 92aafef9e3..49fc7281e0 100644 --- a/modules/portal/src/com/haulmont/cuba/portal/restapi/XMLConvertor.java +++ b/modules/portal/src/com/haulmont/cuba/portal/restapi/XMLConvertor.java @@ -21,6 +21,7 @@ package com.haulmont.cuba.portal.restapi; +import com.google.common.base.Strings; import com.haulmont.chile.core.datatypes.impl.StringDatatype; import com.haulmont.chile.core.model.MetaClass; import com.haulmont.chile.core.model.MetaProperty; @@ -40,6 +41,7 @@ import org.w3c.dom.ls.LSParserFilter; import org.w3c.dom.traversal.NodeFilter; import javax.activation.MimeType; +import javax.annotation.Nullable; import javax.persistence.Embedded; import javax.persistence.Id; import javax.persistence.Version; @@ -69,6 +71,7 @@ public class XMLConvertor implements Convertor { public static final String MIME_STR = "text/xml;charset=UTF-8"; public static final String ELEMENT_INSTANCE = "instance"; + public static final String ELEMENT_RESULT = "result"; public static final String ELEMENT_URI = "uri"; public static final String ELEMENT_REF = "ref"; public static final String ELEMENT_NULL_REF = "null"; @@ -159,6 +162,48 @@ public class XMLConvertor implements Convertor { } } + @Override + public Object processServiceMethodResult(@Nullable Object result, String requestURI, String viewName) + throws NoSuchMethodException, IllegalAccessException, InvocationTargetException { + Element root = newDocument(ELEMENT_RESULT); + if (result instanceof Entity) { + Entity entity = (Entity) result; + if (Strings.isNullOrEmpty(viewName)) viewName = View.LOCAL; + ViewRepository viewRepository = AppBeans.get(ViewRepository.class); + View view = viewRepository.getView(entity.getMetaClass(), viewName); + Document convertedEntity = process(entity, entity.getMetaClass(), requestURI, view); + Node importedNode = root.getOwnerDocument().importNode(convertedEntity.getDocumentElement(), true); + root.appendChild(importedNode); + } else if (result instanceof Collection) { + if (!checkCollectionItemTypes((Collection) result, Entity.class)) + throw new IllegalArgumentException("Items that are not instances of Entity class found in service method result"); + ArrayList list = new ArrayList((Collection) result); + MetaClass metaClass; + if (!list.isEmpty()) + metaClass = ((Entity) list.get(0)).getMetaClass(); + else + metaClass = AppBeans.get(Metadata.class).getClasses().iterator().next(); + + ViewRepository viewRepository = AppBeans.get(ViewRepository.class); + if (Strings.isNullOrEmpty(viewName)) viewName = View.LOCAL; + View view = viewRepository.getView(metaClass, viewName); + Document processed = process(list, metaClass, requestURI, view); + Node importedNode = root.getOwnerDocument().importNode(processed.getDocumentElement(), true); + root.appendChild(importedNode); + } else { + root.setTextContent(result != null ? result.toString() : NULL_VALUE); + } + return root.getOwnerDocument(); + } + + protected boolean checkCollectionItemTypes(Collection collection, Class itemClass) { + for (Object collectionItem : collection) { + if (!itemClass.isAssignableFrom(collectionItem.getClass())) + return false; + } + return true; + } + @Override public void write(HttpServletResponse response, Object o) throws IOException { Document doc = (Document) o; @@ -421,14 +466,14 @@ public class XMLConvertor implements Convertor { Document doc = builder.newDocument(); Element root = doc.createElement(rootTag); doc.appendChild(root); - String[] nvpairs = new String[]{ - "xmlns:xsi", XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI, -// "xsi:noNamespaceSchemaLocation", INSTANCE_XSD, - ATTR_VERSION, "1.0", - }; - for (int i = 0; i < nvpairs.length; i += 2) { - root.setAttribute(nvpairs[i], nvpairs[i + 1]); - } +// String[] nvpairs = new String[]{ +// "xmlns:xsi", XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI, +//// "xsi:noNamespaceSchemaLocation", INSTANCE_XSD, +// ATTR_VERSION, "1.0", +// }; +// for (int i = 0; i < nvpairs.length; i += 2) { +// root.setAttribute(nvpairs[i], nvpairs[i + 1]); +// } return root; } diff --git a/modules/portal/src/com/haulmont/cuba/portal/restapi/rest-services.xsd b/modules/portal/src/com/haulmont/cuba/portal/restapi/rest-services.xsd new file mode 100644 index 0000000000..8df39cbc8a --- /dev/null +++ b/modules/portal/src/com/haulmont/cuba/portal/restapi/rest-services.xsd @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/modules/portal/src/cuba-portal-app.properties b/modules/portal/src/cuba-portal-app.properties index 442ecea95f..4b234be9e4 100644 --- a/modules/portal/src/cuba-portal-app.properties +++ b/modules/portal/src/cuba-portal-app.properties @@ -41,6 +41,7 @@ cuba.dispatcherSpringContextConfig=cuba-portal-dispatcher-spring.xml cuba.viewsConfig=cuba-views.xml cuba.persistenceConfig=cuba-persistence.xml cuba.metadataConfig=cuba-metadata.xml +cuba.restServicesConfig=cuba-rest-services.xml cuba.mainMessagePack=com.haulmont.cuba.core diff --git a/modules/portal/src/cuba-rest-services.xml b/modules/portal/src/cuba-rest-services.xml new file mode 100644 index 0000000000..0a5883c64e --- /dev/null +++ b/modules/portal/src/cuba-rest-services.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file