/* * This software is distributed under the terms of the FSF * Gnu Lesser General Public License (see lgpl.txt). * * This program is distributed WITHOUT ANY WARRANTY. See the * GNU General Public License for more details. */ package com.scooterframework.web.route; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Properties; import com.scooterframework.common.logging.LogUtil; import com.scooterframework.common.util.Converters; import com.scooterframework.common.util.PropertyFileUtil; import com.scooterframework.common.util.StringUtil; import com.scooterframework.common.util.WordUtil; import com.scooterframework.orm.sqldataexpress.config.DatabaseConfig; import com.scooterframework.orm.sqldataexpress.object.PrimaryKey; import com.scooterframework.orm.sqldataexpress.util.SqlExpressUtil; /** * Resource class * * @author (Fei) John Chen */ public class Resource { private LogUtil log = LogUtil.getLogger(this.getClass().getName()); /** * Constant which indicates that this is a plural resource. */ public static final int PLURAL = 0; /** * Constant which indicates that this is a single resource. */ public static final int SINGLE = 1; private static String[] standardRestfulActionNames = {RouteConstants.ROUTE_ACTION_LIST_RESOURCES, RouteConstants.ROUTE_ACTION_ADD_RESOURCE, RouteConstants.ROUTE_ACTION_CREATE_RESOURCE, RouteConstants.ROUTE_ACTION_READ_RESOURCE, RouteConstants.ROUTE_ACTION_EDIT_RESOURCE, RouteConstants.ROUTE_ACTION_UPDATE_RESOURCE, RouteConstants.ROUTE_ACTION_DELETE_RESOURCE}; protected String name; protected int type; protected String controller; protected String controllerClass; protected String singular; protected String namespace; protected String pathPrefix; protected String pathAlias; protected String actionAlias; protected String only; protected String except; protected String member; protected String collection; protected String add; protected String requirements; protected String parents; private String model; private String connName; private String implicitPathPrefix; private boolean strictParent = false; private boolean autoRoute; private Properties actionAliasProperties; private List<RestRoute> routes = new ArrayList<RestRoute>(); /** * Creates a resource instance of either single or plural type. * * @param name Name of the resource * @param type Either SINGLE or PLURAL */ public Resource(String name, int type) { this(name, type, new Properties()); } /** * Creates a resource instance, but uses <tt>noRoute</tt> parameter to * indicate if routes of the resource are allowed to be created. * * @param name Name of the resource * @param noRoutes True if no routes are allowed to create */ public Resource(String name, boolean noRoutes) { this(name, PLURAL, new Properties(), noRoutes, false); } /** * Creates a resource instance of either single or plural type with a * specific property <tt>p</tt>. * * @param name Name of the resource * @param type Either SINGLE or PLURAL * @param p properties of the resource */ public Resource(String name, int type, Properties p) { this(name, type, p, false, false); } Resource(String name, int type, Properties p, boolean noRoutes, boolean autoRoute) { this.name = name; this.type = type; this.autoRoute = autoRoute; if (name == null || "".equals(name)) throw new IllegalArgumentException("Name cannot be null or empty " + "when instantiating a Resource."); if (type != SINGLE && type != PLURAL) throw new IllegalArgumentException("Invalid resource type \"" + type + "\". Only \"" + PLURAL + "\" (plural) and \"" + SINGLE + "\" (single) are allowed."); populateProperties(p); if (type == SINGLE) { if (except == null || except.indexOf(RouteConstants.ROUTE_ACTION_LIST_RESOURCES) == -1) { except += " " + RouteConstants.ROUTE_ACTION_LIST_RESOURCES; } } if (!noRoutes) createRoutes(); } public String getName() { return name; } public int getType() { return type; } public boolean isSingle() { return (SINGLE == type); } public String getController() { return (controller != null)?controller:name; } public String getControllerClass() { return controllerClass; } public String getSingular() { return singular; } public String getNamespace() { return namespace; } public String getPathPrefix() { return pathPrefix; } public String getPathAlias() { return pathAlias; } //Note: It is important that the returned url must be prefixed with a //slash, because other code depend on it. public String getUnprifixedURL() { return "/" + ((pathAlias != null)?pathAlias:name); } public String getScreenURLPattern() { String url = getUnprifixedURL(); if (pathPrefix != null) { url = pathPrefix + url; } else { if (implicitPathPrefix != null) { url = implicitPathPrefix + url; } } return url; } /** * Returns url string. * * @param fieldValues name/value pairs to be used to resolve dynamic url. * @return a url */ public String getScreenURL(Map<String, String> fieldValues) { return Route.resolveURL(getScreenURLPattern(), fieldValues); } public String getActionAlias() { return actionAlias; } public String getOnly() { return only; } public String getExcept() { return except; } public String getMember() { return member; } public String getCollection() { return collection; } public String getAdd() { return add; } public String getRequirements() { return requirements; } public String getParents() { return parents; } public boolean isStrictParent() { return strictParent; } public String getModel() { return model; } public List<RestRoute> getRoutes() { return routes; } /** * Returns a rest route of the underlying resource. The <tt>target</tt> * must a model name if a member route is desired, or a resource name if a * collection route is wanted. * * @param action action name * @param target target name * @return resource */ public RestRoute getRestRoute(String action, String target) { RestRoute rr = null; String routeName = createRestRouteName(action, target); Iterator<RestRoute> it = routes.iterator(); while(it.hasNext()) { RestRoute tmp = it.next(); if (tmp.getName().equals(routeName)) { rr = tmp; break; } } return rr; } /** * Returns a string representation of the object. * @return String */ public String toString() { StringBuilder returnString = new StringBuilder(); String SEPARATOR = ", "; returnString.append("name = " + name).append(SEPARATOR); returnString.append("type = " + type).append(SEPARATOR); returnString.append("controller = " + controller).append(SEPARATOR); returnString.append("controllerClass = " + controllerClass).append(SEPARATOR); returnString.append("singular = " + singular).append(SEPARATOR); returnString.append("namespace = " + namespace).append(SEPARATOR); returnString.append("pathPrefix = " + pathPrefix).append(SEPARATOR); returnString.append("pathAlias = " + pathAlias).append(SEPARATOR); returnString.append("actionAlias = " + actionAlias).append(SEPARATOR); returnString.append("only = " + only).append(SEPARATOR); returnString.append("except = " + except).append(SEPARATOR); returnString.append("member = " + member).append(SEPARATOR); returnString.append("collection = " + collection).append(SEPARATOR); returnString.append("add = " + add).append(SEPARATOR); returnString.append("requirements = " + requirements).append(SEPARATOR); returnString.append("parents = " + parents).append(SEPARATOR); returnString.append("strictParent = " + strictParent).append(SEPARATOR); returnString.append("implicitPathPrefix = " + implicitPathPrefix).append(SEPARATOR); returnString.append("model = " + model); return returnString.toString(); } /** * Returns a format string for resource id. For example, for a resource * named "<tt>users</tt>", the resource id is "<tt>user_id</tt>"; * * @param resource a resource * @return a format string for resource id */ public static String getNesteeIdFormat(Resource resource) { return resource.getModel() + "_id"; } protected void populateProperties(Properties p) { controller = p.getProperty(RouteConstants.ROUTE_KEY_CONTROLLER); controllerClass = p.getProperty(RouteConstants.ROUTE_KEY_CONTROLLER_CLASS); singular = p.getProperty(RouteConstants.ROUTE_KEY_SINGULAR); namespace = p.getProperty(RouteConstants.ROUTE_KEY_NAMESPACE); pathPrefix = p.getProperty(RouteConstants.ROUTE_KEY_PATH_PREFIX); pathAlias = p.getProperty(RouteConstants.ROUTE_KEY_PATH_ALIAS); actionAlias = p.getProperty(RouteConstants.ROUTE_KEY_ACTION_ALIAS); actionAlias = StringUtil.remove(actionAlias, RouteConstants.PROPERTY_SYMBOL_GROUP); if (actionAlias != null) { actionAliasProperties = Converters.convertStringToProperties(actionAlias, RouteConstants.PROPERTY_SYMBOL_GROUP_ITEM_ASSIGN, RouteConstants.PROPERTY_SYMBOL_GROUP_ITEMS_DELIMITER); } only = p.getProperty(RouteConstants.ROUTE_KEY_ONLY); only = StringUtil.remove(only, RouteConstants.PROPERTY_SYMBOL_ARRAY); except = p.getProperty(RouteConstants.ROUTE_KEY_EXCEPT); except = StringUtil.remove(except, RouteConstants.PROPERTY_SYMBOL_ARRAY); member = p.getProperty(RouteConstants.ROUTE_KEY_MEMBER); member = StringUtil.remove(member, RouteConstants.PROPERTY_SYMBOL_GROUP); collection = p.getProperty(RouteConstants.ROUTE_KEY_COLLECTION); collection = StringUtil.remove(collection, RouteConstants.PROPERTY_SYMBOL_GROUP); add = p.getProperty(RouteConstants.ROUTE_KEY_ADD); add = StringUtil.remove(add, RouteConstants.PROPERTY_SYMBOL_GROUP); requirements = p.getProperty(RouteConstants.ROUTE_KEY_REQUIREMENTS); parents = p.getProperty(RouteConstants.ROUTE_KEY_PARENTS); parents = StringUtil.remove(parents, RouteConstants.PROPERTY_SYMBOL_ARRAY); if (parents != null && parents.indexOf(RouteConstants.PROPERTY_SYMBOL_STRICT_PARENT) != -1) { if (!parents.startsWith(RouteConstants.PROPERTY_SYMBOL_STRICT_PARENT)) { throw new IllegalArgumentException("Resource definition error for \"" + getName() + "\": " + RouteConstants.PROPERTY_SYMBOL_STRICT_PARENT + " must be the first word for parents key."); } else { parents = parents.substring(RouteConstants.PROPERTY_SYMBOL_STRICT_PARENT.length()).trim(); strictParent = true; } } //populate derived fields if (singular != null) { model = singular; } else { model = (DatabaseConfig.getInstance().usePluralTableName())?WordUtil.singularize(name):name; } } protected void createRoutes() { // //parse allowed actions // String[] allowedActionNames = standardRestfulActionNames; if (only != null) { allowedActionNames = Converters.convertStringToStringArray(only, RouteConstants.PROPERTY_SYMBOL_ARRAY_ITEMS_DELIMITER); } else if (except != null) { allowedActionNames = updateActionNamesByExcept(except); } if (allowedActionNames == null || allowedActionNames.length ==0) return; // //Start to create all kinds of routes // // //create nested routes // if (parents != null) { String[] parentsArray = Converters.convertStringToStringArray(parents, RouteConstants.PROPERTY_SYMBOL_ARRAY_ITEMS_DELIMITER); if (parentsArray != null && parentsArray.length > 0) { //create all 7 restful nested routes in another method. int total = parentsArray.length; if (isStrictParent() && total > 1) { throw new IllegalArgumentException("Resource definition error for \"" + getName() + "\": There can be only one parent in strict case."); } String nestedPrefix = ""; for (int j = 0; j < total; j++) { String[] parentResourceNames = Converters.convertStringToStringArray(parentsArray[j], RouteConstants.PROPERTY_SYMBOL_PARENTS_CONNECTION); Resource[] parentResource = getParentResource(parentResourceNames); nestedPrefix = getNestedURLPrefixString(parentResource); String indexURLNested = getNestedURLStringForList(nestedPrefix); String curdURLNested = getNestedURLStringForCRUD(nestedPrefix); String addURLNested = getNestedURLStringForADD(nestedPrefix); String editURLNested = getNestedURLStringForEDIT(nestedPrefix); String[] parentModels = getParentModels(parentResource); int countOfActions = allowedActionNames.length; for (int i = 0; i < countOfActions; i++) { String action = allowedActionNames[i]; if (RouteConstants.ROUTE_ACTION_LIST_RESOURCES.equals(action)) { routes.add(createRestRoute(action, getTarget(parentModels, name), indexURLNested, RouteConstants.ROUTE_HTTP_METHOD_GET)); } else if (RouteConstants.ROUTE_ACTION_ADD_RESOURCE.equals(action)) { routes.add(createRestRoute(action, getTarget(parentModels, model), addURLNested, RouteConstants.ROUTE_HTTP_METHOD_GET)); } else if (RouteConstants.ROUTE_ACTION_CREATE_RESOURCE.equals(action)) { routes.add(createRestRoute(action, getTarget(parentModels, model), indexURLNested, RouteConstants.ROUTE_HTTP_METHOD_POST)); } else if (RouteConstants.ROUTE_ACTION_READ_RESOURCE.equals(action)) { routes.add(createRestRoute(action, getTarget(parentModels, model), curdURLNested, RouteConstants.ROUTE_HTTP_METHOD_GET)); } else if (RouteConstants.ROUTE_ACTION_EDIT_RESOURCE.equals(action)) { routes.add(createRestRoute(action, getTarget(parentModels, model), editURLNested, RouteConstants.ROUTE_HTTP_METHOD_GET)); } else if (RouteConstants.ROUTE_ACTION_UPDATE_RESOURCE.equals(action)) { routes.add(createRestRoute(action, getTarget(parentModels, model), curdURLNested, RouteConstants.ROUTE_HTTP_METHOD_PUT)); } else if (RouteConstants.ROUTE_ACTION_DELETE_RESOURCE.equals(action)) { routes.add(createRestRoute(action, getTarget(parentModels, model), curdURLNested, RouteConstants.ROUTE_HTTP_METHOD_DELETE)); } } } if (isStrictParent()) implicitPathPrefix = nestedPrefix; } } if (strictParent) return; // //create standard REST routes // String indexURL = getURLStringForList(); String curdURL = getURLStringForCRUD(indexURL); String addURL = getURLStringForADD(indexURL); String editURL = getURLStringForEDIT(indexURL); // //create member routes // Properties memberProperties = PropertyFileUtil.parseNestedPropertiesFromLine(member, RouteConstants.PROPERTY_SYMBOL_GROUP_ITEM_ASSIGN, RouteConstants.PROPERTY_SYMBOL_GROUP_ITEMS_DELIMITER); if (memberProperties != null) { for (Map.Entry<Object, Object> entry : memberProperties.entrySet()) { String action = (String)entry.getKey(); String methods = (String)entry.getValue(); methods = StringUtil.remove(methods, RouteConstants.PROPERTY_SYMBOL_ARRAY); routes.add(createRestRoute(action, model, curdURL + "/" + action, methods)); } } // //create collection routes // Properties collectionProperties = PropertyFileUtil.parseNestedPropertiesFromLine(collection, RouteConstants.PROPERTY_SYMBOL_GROUP_ITEM_ASSIGN, RouteConstants.PROPERTY_SYMBOL_GROUP_ITEMS_DELIMITER); if (collectionProperties != null) { for (Map.Entry<Object, Object> entry : collectionProperties.entrySet()) { String action = (String)entry.getKey(); String methods = (String)entry.getValue(); methods = StringUtil.remove(methods, RouteConstants.PROPERTY_SYMBOL_ARRAY); routes.add(createRestRoute(action, name, indexURL + "/" + action, methods)); } } // //create add routes // Properties addProperties = PropertyFileUtil.parseNestedPropertiesFromLine(add, RouteConstants.PROPERTY_SYMBOL_GROUP_ITEM_ASSIGN, RouteConstants.PROPERTY_SYMBOL_GROUP_ITEMS_DELIMITER); if (addProperties != null) { for (Map.Entry<Object, Object> entry : addProperties.entrySet()) { String action = (String)entry.getKey(); String methods = (String)entry.getValue(); methods = StringUtil.remove(methods, RouteConstants.PROPERTY_SYMBOL_ARRAY); routes.add(createRestRoute(action, model, indexURL + "/" + action, methods)); } } // //create the seven standard rest routes // int count = allowedActionNames.length; for (int i = 0; i < count; i++) { String action = allowedActionNames[i]; if (RouteConstants.ROUTE_ACTION_LIST_RESOURCES.equals(action)) { routes.add(createRestRoute(action, name, indexURL, RouteConstants.ROUTE_HTTP_METHOD_GET)); } else if (RouteConstants.ROUTE_ACTION_ADD_RESOURCE.equals(action)) { routes.add(createRestRoute(action, model, addURL, RouteConstants.ROUTE_HTTP_METHOD_GET)); } else if (RouteConstants.ROUTE_ACTION_CREATE_RESOURCE.equals(action)) { routes.add(createRestRoute(action, model, indexURL, RouteConstants.ROUTE_HTTP_METHOD_POST)); } else if (RouteConstants.ROUTE_ACTION_READ_RESOURCE.equals(action)) { routes.add(createRestRoute(action, model, curdURL, RouteConstants.ROUTE_HTTP_METHOD_GET)); } else if (RouteConstants.ROUTE_ACTION_EDIT_RESOURCE.equals(action)) { routes.add(createRestRoute(action, model, editURL, RouteConstants.ROUTE_HTTP_METHOD_GET)); } else if (RouteConstants.ROUTE_ACTION_UPDATE_RESOURCE.equals(action)) { routes.add(createRestRoute(action, model, curdURL, RouteConstants.ROUTE_HTTP_METHOD_PUT)); } else if (RouteConstants.ROUTE_ACTION_DELETE_RESOURCE.equals(action)) { routes.add(createRestRoute(action, model, curdURL, RouteConstants.ROUTE_HTTP_METHOD_DELETE)); } } } private String[] updateActionNamesByExcept(String except) { int length = standardRestfulActionNames.length; String allowedActions = ""; for (int i = 0; i < length; i++) { String action = standardRestfulActionNames[i]; if (except.indexOf(action) == -1) { allowedActions += action + " "; } } return Converters.convertStringToStringArray(allowedActions, " "); } private RestRoute createRestRoute(String action, String target, String url, String methodsAllowed) { Route.validateMethods(methodsAllowed); Properties p = new Properties(); p.put(RouteConstants.ROUTE_KEY_URL, url); p.put(RouteConstants.ROUTE_KEY_CONTROLLER, getController()); p.put(RouteConstants.ROUTE_KEY_ACTION, action); p.put(RouteConstants.ROUTE_KEY_ALLOWED_METHODS, methodsAllowed); if (getSingular() != null) { p.put(RouteConstants.ROUTE_KEY_SINGULAR, getSingular()); } if (getNamespace() != null) { p.put(RouteConstants.ROUTE_KEY_NAMESPACE, getNamespace()); } if (getControllerClass() != null) { p.put(RouteConstants.ROUTE_KEY_CONTROLLER_CLASS, getControllerClass()); } if (getRequirements() != null) { p.put(RouteConstants.ROUTE_KEY_REQUIREMENTS, requirements); } String routeName = createRestRouteName(action, target); RestRoute rr = new RestRoute(routeName, p); rr.setResourceName(this.name); rr.setModel(this.model); if(rr.getCacheable() == null) rr.cacheable = getDefaultCacheable(methodsAllowed); return rr; } private String getDefaultCacheable(String httpMethod) { return (RouteConstants.ROUTE_HTTP_METHOD_GET.equals(httpMethod)) ? "true" : "false"; } private String getURLStringForList() { return getScreenURLPattern(); } private String getURLStringForCRUD(String indexURL) { return (!isSingle())?indexURL + "/" + ((autoRoute)?getDynamicPrimaryKeyRepresentation():RouteConstants.ROUTE_DEFAULT_ID): indexURL; } private String getURLStringForADD(String indexURL) { return indexURL + "/" + getActionString("add"); } private String getURLStringForEDIT(String indexURL) { return getURLStringForCRUD(indexURL) + "/" + getActionString("edit"); } private Resource[] getParentResource(String[] parentResourceNames) { if (parentResourceNames == null) return null; int length = parentResourceNames.length; Resource[] resourceArray = new Resource[length]; for (int i = 0; i < length; i++) { resourceArray[i] = getParentResource(parentResourceNames[i]); } return resourceArray; } private Resource getParentResource(String parentResourceName) { Resource resource = MatchMaker.getInstance().getResource(parentResourceName); if (resource == null) { resource = new Resource(parentResourceName, true); //throw new IllegalArgumentException("\"" + parentResourceName + "\"" + // " is not defined as a resource, but is used as a" + // " parent resource for \"" + getName() + "\"."); } return resource; } private String getNestedURLPrefixString(Resource[] parents) { if (pathPrefix != null) return getURLStringForList(); String nestedPath = ""; int length = parents.length; for (int i = length-1; i >= 0; i--) { Resource parent = parents[i]; String parentPath = (i == length-1)? parent.getScreenURLPattern() + "/" + "$" + getNesteeIdFormat(parent) : parent.getUnprifixedURL() + "/" + "$" + getNesteeIdFormat(parent); nestedPath += parentPath + "/"; } nestedPath = StringUtil.replace(nestedPath, "//", "/"); nestedPath = StringUtil.removeLastToken(nestedPath, "/"); return nestedPath; } private String getNestedURLStringForList(String nestedPrefix) { String nestedPath = nestedPrefix; nestedPath += getUnprifixedURL(); return nestedPath; } private String getNestedURLStringForCRUD(String nestedPrefix) { return (!isSingle())?getNestedURLStringForList(nestedPrefix) + "/" + ((autoRoute)?getDynamicPrimaryKeyRepresentation():RouteConstants.ROUTE_DEFAULT_ID): getNestedURLStringForList(nestedPrefix); } private String getNestedURLStringForADD(String nestedPrefix) { return getNestedURLStringForList(nestedPrefix) + "/" + getActionString("add"); } private String getNestedURLStringForEDIT(String nestedPrefix) { return getNestedURLStringForCRUD(nestedPrefix) + "/" + getActionString("edit"); } private String[] getParentModels(Resource[] parentResource) { int length = parentResource.length; String[] models = new String[length]; for (int i = 0; i < length; i++) { models[i] = parentResource[i].getModel(); } return models; } private String getActionString(String action) { return (actionAliasProperties != null)?actionAliasProperties.getProperty(action, action):action; } private String getTarget(String[] parents, String target) { if (parents == null) return target; int length = parents.length; String nestedTarget = ""; for (int i = length-1; i >= 0; i--) { nestedTarget += parents[i] + "-"; } nestedTarget = StringUtil.removeLastToken(nestedTarget, "-"); nestedTarget += "-" + target; return nestedTarget; } private String createRestRouteName(String action, String target) { return action + " " + target; } /** * <p>Uses the <tt>model</tt> field to retrieve and construct a dynamic * primary key representation for a record in route definition.</p> * * <p>The dynamic primary key representation should be like "$itemid", * or "$orderid-$itemid" if the primary key is a composite key consisting * of <tt>orderid</tt> and <tt>itemid</tt> fields. The separator between * key fields are defined by {@link com.scooterframework.web.route.RouteConstants#PRIMARY_KEY_SEPARATOR}.</p> * * <p>In the event that the <tt>model</tt> is not a table name, the * regular "$id" is used to represent dynamic primary key string.</p> */ private String getDynamicPrimaryKeyRepresentation() { String tableName = (DatabaseConfig.getInstance().usePluralTableName())?WordUtil.tableize(model):model; List<String> columns = null; try { if (connName == null) connName = DatabaseConfig.getInstance().getDefaultDatabaseConnectionName(); PrimaryKey pk = SqlExpressUtil.lookupPrimaryKey(connName, tableName); if (pk == null) { log.info("There is no primary key detected for table \"" + tableName + "\" as it may be just a resource, " + "not a real table, maybe a view. \"" + RouteConstants.ROUTE_DEFAULT_ID + "\" will be used to represent dynamic id for " + model + " in the route definition instead."); } else { columns = pk.getColumns(); } } catch(Exception ex) { log.error(ex); } String s = ""; if (columns != null && columns.size() > 0) { Iterator<String> it = columns.iterator(); while(it.hasNext()) { s += "$" + it.next().toLowerCase() + RouteConstants.PRIMARY_KEY_SEPARATOR; } s = StringUtil.removeLastToken(s, RouteConstants.PRIMARY_KEY_SEPARATOR); } else { s = RouteConstants.ROUTE_DEFAULT_ID; } return s; } }