Java tutorial
/* * #%L * Alfresco Share WAR * %% * Copyright (C) 2005 - 2016 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of * the paid license agreement will prevail. Otherwise, the software is * provided under the following open source license terms: * * Alfresco is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Alfresco is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with Alfresco. If not, see <http://www.gnu.org/licenses/>. * #L% */ package org.alfresco.web.cmm; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.Writer; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import javax.servlet.http.HttpServletResponse; import org.alfresco.error.AlfrescoRuntimeException; import org.alfresco.web.scripts.DictionaryQuery; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.dom4j.DocumentException; import org.dom4j.Element; import org.json.simple.JSONArray; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; import org.springframework.extensions.surf.ModuleDeploymentService; import org.springframework.extensions.surf.RequestContext; import org.springframework.extensions.surf.ServletUtil; import org.springframework.extensions.surf.exception.ConnectorServiceException; import org.springframework.extensions.surf.exception.ModelObjectPersisterException; import org.springframework.extensions.surf.support.ThreadLocalRequestContext; import org.springframework.extensions.surf.types.ExtensionModule; import org.springframework.extensions.surf.types.ModuleDeployment; import org.springframework.extensions.surf.uri.UriUtils; import org.springframework.extensions.surf.util.StringBuilderWriter; import org.springframework.extensions.surf.util.URLEncoder; import org.springframework.extensions.webscripts.Cache; import org.springframework.extensions.webscripts.DeclarativeWebScript; import org.springframework.extensions.webscripts.Description; import org.springframework.extensions.webscripts.Status; import org.springframework.extensions.webscripts.WebScriptException; import org.springframework.extensions.webscripts.WebScriptRequest; import org.springframework.extensions.webscripts.connector.Connector; import org.springframework.extensions.webscripts.connector.ConnectorContext; import org.springframework.extensions.webscripts.connector.HttpMethod; import org.springframework.extensions.webscripts.connector.Response; import org.springframework.extensions.webscripts.processor.FTLTemplateProcessor; /** * Base class for CMM WebScript requests to perform a number of service related functions: * <p> * Each model has associated Share form configuration which is activated or deactivated * based on the current API state for that model. The dynamic form configuration allows * the user to make immediate use of the model without server restarts. * <p> * Each CRUD HTTP method WebScript extends this service and it is responsible for the bulk * of the work for operations. It will proxy through API calls to the repo via the given * operation ID mapping to a templated repository API URL. The caller is responsible for * providing the appropriate bag of arguments to the templated URL and also the data JSON * as expected by the repository API. Errors and status codes from the repo API are * proxied back to the caller. * <p> * Besides proxying the API calls, the main purpose of this service is to provide business * logic hook points before and after the repository operations for Share. This allows say * local Data Dictionary modifications and updates based on the success of a repository API. * * @author Kevin Roast */ public abstract class CMMService extends DeclarativeWebScript { private static final Log logger = LogFactory.getLog(CMMService.class); /** JSON string constants */ private static final String JSON_APPEARANCE = "appearance"; private static final String JSON_LABEL = "label"; private static final String JSON_STYLECLASS = "styleclass"; private static final String JSON_STYLE = "style"; private static final String JSON_MAXLENGTH = "maxlength"; private static final String JSON_READ_ONLY = "read-only"; private static final String JSON_HIDDEN = "hidden"; private static final String JSON_FORCE = "force"; private static final String JSON_ANY = "any"; private static final String JSON_FOR_MODE = "for-mode"; private static final String JSON_CONTROLTYPE = "controltype"; private static final String JSON_ELEMENTCONFIG = "elementconfig"; private static final String JSON_ID = "id"; private static final String JSON_COLUMN = "column"; private static final String JSON_PSEUDONYM = "pseudonym"; private static final String JSON_PROPERTIES = "properties"; private static final String JSON_TITLE = "title"; private static final String JSON_PREFIXEDNAME = "prefixedName"; private static final String JSON_ENTRY = "entry"; private static final String JSON_ACTIVE = "ACTIVE"; private static final String JSON_STATUS = "status"; private static final String JSON_ARGUMENTS = "arguments"; private static final String JSON_DATA = "data"; private static final String JSON_OPERATION = "operation"; private static final String JSON_TYPES = "types"; /** template output string constants */ private static final String TEMPLATE_SET = "set"; private static final String TEMPLATE_LABEL = "label"; private static final String TEMPLATE_APPEARANCE = "appearance"; private static final String TEMPLATE_PASSWORD = "password"; private static final String TEMPLATE_STYLE = "style"; private static final String TEMPLATE_STYLECLASS = "styleclass"; private static final String TEMPLATE_MAXLENGTH = "maxLength"; private static final String TEMPLATE_READONLY = "readonly"; private static final String TEMPLATE_FORCE = "force"; private static final String TEMPLATE_MODE = "mode"; private static final String TEMPLATE_PARAMS = "params"; private static final String TEMPLATE_ID = "id"; private static final String TEMPLATE_FIELDS = "fields"; private static final String TEMPLATE_SETS = "sets"; private static final String TEMPLATE_PROPERTIES = "properties"; private static final String TEMPLATE_TITLE = "title"; private static final String TEMPLATE_FORM = "form"; private static final String TEMPLATE_NAME = "name"; private static final String TEMPLATE_ENTITIES = "entities"; private static final String TEMPLATE_ASPECTS = "aspects"; private static final String TEMPLATE_SUBTYPES = "subtypes"; private static final String TEMPLATE_TYPES = "types"; private static final String TEMPLATE_MODULE_NAME = "moduleName"; private static final String TEMPLATE_TEMPLATE = "template"; /** control types */ private static final String CONTROLTYPE_DEFAULT = "default"; private static final String CONTROLTYPE_PASSWORD = "password"; private static final String CONTROLTYPE_RICHTEXT = "richtext"; private static final String CONTROLTYPE_TEXTAREA = "textarea"; private static final String CONTROLTYPE_CONTENT = "content"; private static final String CONTROLTYPE_TEXTFIELD = "textfield"; private static final String CONTROLTYPE_HIDDEN = "hidden"; private static final String CONTROLTYPE_SIZE = "size"; private static final String CONTROLTYPE_MIMETYPE = "mimetype"; private static final String CONTROLTYPE_TAGGABLE = "taggable"; private static final String CONTROLTYPE_CATEGORIES = "categories"; /** well known DD types */ private static final String CM_FOLDER = "cm:folder"; private static final String CM_CONTENT = "cm:content"; /** Prefix used for all CMM related modules - the suffix is the model ID */ private static final String MODULE_PREFIX = "CMM_"; /** path to the FreeMarker template used to render the module configuration for a model */ private static final String MODULE_TEMPLATE_PATH = "/org/alfresco/cmm/components/module-configuration.ftl"; /** simple default JSON response for services when result value is proxied from the repository */ protected static final String DEFAULT_OK_RESULT = "{\"success\":true}"; /** Repository Operations available from client API */ private static final String OP_DELETE_PROPERTY = "deleteProperty"; private static final String OP_EDIT_PROPERTY = "editProperty"; private static final String OP_CREATE_PROPERTY = "createProperty"; private static final String OP_DELETE_PROPERTY_GROUP = "deletePropertyGroup"; private static final String OP_EDIT_PROPERTY_GROUP = "editPropertyGroup"; private static final String OP_CREATE_PROPERTY_GROUP = "createPropertyGroup"; private static final String OP_DELETE_TYPE = "deleteType"; private static final String OP_EDIT_TYPE = "editType"; private static final String OP_CREATE_TYPE = "createType"; private static final String OP_DELETE_MODEL = "deleteModel"; private static final String OP_DEACTIVATE_MODEL = "deactivateModel"; private static final String OP_ACTIVATE_MODEL = "activateModel"; private static final String OP_EDIT_MODEL = "editModel"; private static final String OP_CREATE_MODEL = "createModel"; /** * Mapping of client-side operation name to repository API templated URL * The caller is responsible for passing the named arguments to the service in the JSON params. This service * will then apply the template arguments to the URL and then proxy over any associated JSON data blob. */ protected static Map<String, String> operationMapping = new HashMap<String, String>() { { put(OP_CREATE_MODEL, "/-default-/private/alfresco/versions/1/cmm"); put(OP_EDIT_MODEL, "/-default-/private/alfresco/versions/1/cmm/{name}"); put(OP_ACTIVATE_MODEL, "/-default-/private/alfresco/versions/1/cmm/{name}?select=status"); put(OP_DEACTIVATE_MODEL, "/-default-/private/alfresco/versions/1/cmm/{name}?select=status"); put(OP_DELETE_MODEL, "/-default-/private/alfresco/versions/1/cmm/{name}"); put(OP_CREATE_TYPE, "/-default-/private/alfresco/versions/1/cmm/{name}/types"); put(OP_EDIT_TYPE, "/-default-/private/alfresco/versions/1/cmm/{name}/types/{typeName}"); put(OP_DELETE_TYPE, "/-default-/private/alfresco/versions/1/cmm/{name}/types/{typeName}"); put(OP_CREATE_PROPERTY_GROUP, "/-default-/private/alfresco/versions/1/cmm/{name}/aspects"); put(OP_EDIT_PROPERTY_GROUP, "/-default-/private/alfresco/versions/1/cmm/{name}/aspects/{aspectName}"); put(OP_DELETE_PROPERTY_GROUP, "/-default-/private/alfresco/versions/1/cmm/{name}/aspects/{aspectName}"); put(OP_CREATE_PROPERTY, "/-default-/private/alfresco/versions/1/cmm/{name}/{entityClass}/{entityName}?select=props"); put(OP_EDIT_PROPERTY, "/-default-/private/alfresco/versions/1/cmm/{name}/{entityClass}/{entityName}?select=props&update={propertyName}"); put(OP_DELETE_PROPERTY, "/-default-/private/alfresco/versions/1/cmm/{name}/{entityClass}/{entityName}?select=props&delete={propertyName}"); } }; /** * <p>A @link ModuleDeploymentService} is required as it is used to refresh the configured module list.</p> */ protected ModuleDeploymentService moduleDeploymentService; /** * @param moduleDeploymentService ModuleDeploymentService */ public void setModuleDeploymentService(ModuleDeploymentService moduleDeploymentService) { this.moduleDeploymentService = moduleDeploymentService; } protected DictionaryQuery dictionary; /** * Dictionary Query bean reference * * @param dictionary DictionaryQuery */ public void setDictionary(DictionaryQuery dictionary) { this.dictionary = dictionary; } protected FTLTemplateProcessor templateProcessor; /** * @param templateProcessor FTLTemplateProcessor */ public void setTemplateProcessor(FTLTemplateProcessor templateProcessor) { this.templateProcessor = templateProcessor; } public final static Cache CACHE_NEVER = new Cache(new Description.RequiredCache() { @Override public boolean getNeverCache() { return true; } @Override public boolean getIsPublic() { return false; } @Override public boolean getMustRevalidate() { return true; } }); /** * Model operation service call. Provide a proxy through to the given repo API and provides a hook * for client business logic pertinent that may be required for each operation. * * @param status * @param modelName * @param json * @throws IOException */ protected String serviceModelOperation(Status status, String modelName, JSONObject json) throws IOException { final String opId = (String) json.get(JSON_OPERATION); // repository API mapping operation - collect arguments, data blob - http method is as called JSONObject data = (JSONObject) json.get(JSON_DATA); // map operation to URL and apply arguments String url = operationMapping.get(opId); if (url == null) { throw new IllegalArgumentException("Specified API operation does not map to a known URL: " + opId); } final Map<String, String> args = new HashMap<>(); JSONObject arguments = (JSONObject) json.get(JSON_ARGUMENTS); if (arguments != null) { for (String key : (Set<String>) arguments.keySet()) { args.put(key, URLEncoder.encode((String) arguments.get(key))); } } url = UriUtils.replaceUriTokens(url, args); if (logger.isDebugEnabled()) logger.debug("Executing service operation: " + opId + " with URL: " + url + " method: " + this.getDescription().getMethod() + " - using data:\n" + (data != null ? data.toJSONString() : "null")); // pre operation business logic Map<String, String> updatedForms = null; Response preResponse = null; switch (opId) { case OP_DELETE_MODEL: case OP_DEACTIVATE_MODEL: { // get model ready to remove it from dictionary if deactive is successful JSONObject model = getModel(modelName); String prefix = (String) model.get("namespacePrefix"); preResponse = getConnector().call( "/api/dictionary?model=" + URLEncoder.encode(prefix) + ":" + URLEncoder.encode(modelName)); break; } case OP_EDIT_MODEL: { // if a model has form definitions, they may need updating to ensure a modified Model Prefix is applied // to the widget IDs within the forms - we use the "prefix:field" approach for widget IDs for form elements JSONObject model = getModel(modelName); String oldPrefix = (String) model.get("namespacePrefix"); String newPrefix = (String) data.get("namespacePrefix"); // if the prefix has changed then the IDs of the widgets in the form definitions will now be incorrect if (!newPrefix.equals(oldPrefix)) { ExtensionModule module = getExtensionModule(modelName); if (module != null) { // retrieve existing form definitions from extension configuration updatedForms = getFormDefinitions(module); if (updatedForms.size() != 0) { for (String formId : updatedForms.keySet()) { // modify the form JSON string - we want to replace "oldprefix:fieldid" with "newprefix:fieldid" to // ensure the widget IDs in the form will match the expected namespace ID of the custom model String form = updatedForms.get(formId); updatedForms.put(formId, form.replace("\"id\":\"" + oldPrefix + ":", "\"id\":\"" + newPrefix + ":")); } } } } break; } } // prepare proxied JSON body data and make the call Response res; if (data != null) { // make the request with the given data payload res = getAPIConnector().call(url, new ConnectorContext(HttpMethod.valueOf(this.getDescription().getMethod())), new ByteArrayInputStream(data.toJSONString().getBytes("UTF-8"))); } else { // no body required for this request res = getAPIConnector().call(url, new ConnectorContext(HttpMethod.valueOf(this.getDescription().getMethod()))); } if (logger.isDebugEnabled()) logger.debug("Response: " + res.getStatus().getCode() + "\n" + res.getResponse()); int statusCode = res.getStatus().getCode(); if (statusCode >= 200 && statusCode < 300) { // if we get here successfully, then perform post operation business logic switch (opId) { case OP_ACTIVATE_MODEL: { if (logger.isDebugEnabled()) logger.debug("ACTIVATE model config id: " + modelName); updateDictionaryForModel(modelName); buildExtensionModule(status, modelName, null, true); break; } case OP_DEACTIVATE_MODEL: { if (logger.isDebugEnabled()) logger.debug("DEACTIVATE model config id: " + modelName); // update dictionary - remove classes relating to this namespace if (preResponse != null && preResponse.getStatus().getCode() == Status.STATUS_OK) { this.dictionary.updateRemoveClasses(preResponse.getResponse()); } else { if (logger.isWarnEnabled()) logger.warn("Unable to update Share local Data Dictionary as Repository API call failed."); } buildExtensionModule(status, modelName, null, false); break; } case OP_CREATE_MODEL: { // NOTE: no need to update Dictionary - new model begins lifecycle as deactivated break; } case OP_EDIT_MODEL: { // NOTE: no need to update Dictionary - only deactivated models can be edited // updating to ensure form definitions are updated after a Model Prefix change if (updatedForms != null && updatedForms.size() != 0) { buildExtensionModule(status, modelName, new FormOperation(FormOperationEnum.Create, updatedForms), false); } break; } case OP_DELETE_MODEL: { if (logger.isDebugEnabled()) logger.debug("Deleting extension and form definitions for model: " + modelName); // Delete the model - so delete the entire module definition and related configurations deleteExtensionModule(status, modelName); // NOTE: no need to update Dictionary - only inactive models can be deleted and therefore already processed break; } case OP_CREATE_TYPE: case OP_EDIT_TYPE: { // update the dictionary is the model is currently active if (isModelActive(getModel(modelName))) { updateDictionaryForModel(modelName); buildExtensionModule(status, modelName, null, true); } break; } case OP_DELETE_TYPE: case OP_DELETE_PROPERTY_GROUP: { // NOTE: no need to update Dictionary - only inactive models can have types or aspects deleted! break; } case OP_CREATE_PROPERTY_GROUP: case OP_EDIT_PROPERTY_GROUP: { // update the dictionary is the model is currently active if (isModelActive(getModel(modelName))) { buildExtensionModule(status, modelName, null, true); updateDictionaryForModel(modelName); } break; } case OP_CREATE_PROPERTY: case OP_DELETE_PROPERTY: { if (isModelActive(getModel(modelName))) { // TODO: could update Dictionary if the granularity of properties are ever used...? buildExtensionModule(status, modelName, null, true); } break; } } } status.setCode(statusCode); return res.getResponse(); } /** * Update the Share local Data Dictionary based on the current state of the given model. The model * is retrieved and merged into the local data dictionary - adding or updating classes as required. * * @param modelName Name of the model to update dictionary for */ private void updateDictionaryForModel(final String modelName) { // update dictionary if (logger.isDebugEnabled()) logger.debug("Updating dictionary for model: " + modelName); JSONObject model = getModel(modelName); String prefix = (String) model.get("namespacePrefix"); Response res = getConnector() .call("/api/dictionary?model=" + URLEncoder.encode(prefix) + ":" + URLEncoder.encode(modelName)); if (logger.isDebugEnabled()) logger.debug("Dictionary get response " + res.getStatus().getCode() + "\n" + res.getResponse()); if (res.getStatus().getCode() == Status.STATUS_OK) { this.dictionary.updateAddClasses(res.getResponse()); } } /** * Return the JSON object for the meta description of the given model * @param modelName Model to retrieve meta for * @return JSON meta: * { * "author":"Kevin Roast", * "name":"DemoModel", * "description":"a demo model", * "namespaceUri":"http://www.mycompany.com/model/demo/1.0", * "namespacePrefix":"demo", * "status":"DRAFT" * } */ protected JSONObject getModel(String modelName) { Response res = getAPIConnector() .call("/-default-/private/alfresco/versions/1/cmm/" + URLEncoder.encode(modelName)); if (res.getStatus().getCode() == Status.STATUS_OK) { return ((JSONObject) getJsonBody(res).get(JSON_ENTRY)); } else { throw new AlfrescoRuntimeException( "Unable to retrieve model information: " + modelName + " (" + res.getStatus().getCode() + ")"); } } /** * @return the extension module ID for a given modelName */ protected String buildModuleId(String modelName) { return MODULE_PREFIX + modelName; } /** * @param model JSON model object * @return true if the given model is active, false if deactivated */ private boolean isModelActive(JSONObject model) { return model.get(JSON_STATUS).equals(JSON_ACTIVE); } protected void buildExtensionModule(Status status, String modelName, FormOperation formOp) { // is the model active? boolean active = isModelActive(getModel(modelName)); buildExtensionModule(status, modelName, formOp, active); } protected void buildExtensionModule(Status status, String modelName, FormOperation formOp, JSONObject model) { // is the model active? boolean active = isModelActive(model); buildExtensionModule(status, modelName, formOp, active); } /** * Construct the Surf Extension Module for a given model. * <p> * A Freemarker template is used to build the final extension module config from a hiearchy of template objects. See the * various TEMPLATE_ constants and module-configuration.ftl for the template model object names and template structure. * <p> * Each model maps to an extension and associated Share Forms and Share Document Library configuration output. If the model * is active then a number of Share Forms may be generated from persisted JSON form layouts. The template model transforms * the generic JSON form structure to the esoteric Share Form XML configuration. * * @param status WebScript status object - used to set error codes * @param modelName Model name to construct extension config for * @param formOp Optional form operation to apply to current Forms before extension module is generated * @param active Model active/deactive status */ protected void buildExtensionModule(Status status, String modelName, FormOperation formOp, boolean active) { final String moduleId = buildModuleId(modelName); // construct the model used to render the module template configuration TWrapper model = new TWrapper(8); model.put(TEMPLATE_MODULE_NAME, moduleId); List<Object> typeList = new ArrayList<>(); model.put(TEMPLATE_TYPES, typeList); List<Object> subtypesList = new ArrayList<>(); model.put(TEMPLATE_SUBTYPES, subtypesList); List<Object> aspectsList = new ArrayList<>(); model.put(TEMPLATE_ASPECTS, aspectsList); List<Object> entitiesList = new ArrayList<>(); model.put(TEMPLATE_ENTITIES, entitiesList); // retrieve form configuration if present already for this module to update new module definition Map<String, String> formDefs = new HashMap<>(); ExtensionModule module = getExtensionModule(modelName); if (module != null) { // retrieve existing form definitions from extension configuration e.g. formDefs = getFormDefinitions(module); } // perform optional form CrUD operation if (formOp != null) { formOp.perform(formDefs); } // add form definitions to template model map for (String entityId : formDefs.keySet()) { TWrapper wrapper = new TWrapper(4); wrapper.put(TEMPLATE_NAME, entityId).put(TEMPLATE_FORM, formDefs.get(entityId)); entitiesList.add(wrapper); } // if the model is active, we want to generate the Share config for types/aspects/forms if (active) { // get all types and aspects for the model and process them Response response = getAPIConnector().call( "/-default-/private/alfresco/versions/1/cmm/" + URLEncoder.encode(modelName) + "?select=all"); if (response.getStatus().getCode() == Status.STATUS_OK) { JSONObject jsonData = getJsonBody(response); // process types final JSONArray types = (JSONArray) ((JSONObject) jsonData.get(JSON_ENTRY)).get(JSON_TYPES); // walk the types and use form definitions to generate the form config objects // and also generate the sub-types list Map<String, List<TWrapper>> subtypeMap = new HashMap<>(); for (final Object t : types) { final JSONObject type = (JSONObject) t; String typeName = (String) type.get(JSON_PREFIXEDNAME); // generate form wrapper objects for this type TWrapper formWrappers = processFormWidgets(formDefs, type); // form definition present for this type? if (formWrappers.size() != 0) { // add type wrapper for template output TWrapper typeWrapper = new TWrapper(8); typeWrapper.put(TEMPLATE_NAME, typeName).put(TEMPLATE_TITLE, (String) type.get(JSON_TITLE)); typeList.add(typeWrapper); // add all form wrapper objects for the type typeWrapper.putAll(formWrappers); // for each type, firstly ensure is subtype of cm:content, // then walk the parent hiearchy and add this type as a subtype of each parent type up to and including cm:content if (this.dictionary.isSubType(typeName, CM_CONTENT) || this.dictionary.isSubType(typeName, CM_FOLDER)) { String parentType = typeName; do { // walk hiearchy to prepare for next loop iteration parentType = this.dictionary.getParent(parentType); List<TWrapper> subtypes = subtypeMap.get(parentType); if (subtypes == null) { subtypes = new ArrayList<>(4); subtypeMap.put(parentType, subtypes); } // check for existing - hierachies of types can repeat the same type from other hierachy boolean found = false; for (TWrapper st : subtypes) { if (st.get(TEMPLATE_NAME).equals(typeName)) { found = true; break; } } // add subtype wrapper for template output if (!found) { TWrapper subtypeWrapper = new TWrapper(4); subtypeWrapper.put(TEMPLATE_NAME, typeName).put(TEMPLATE_TITLE, this.dictionary.getTitle(typeName)); subtypes.add(subtypeWrapper); } } while (!(CM_CONTENT.equals(parentType) || CM_FOLDER.equals(parentType))); } } } // convert map to List for templates - each parent type then has an associated list of sub-type wrappers for (final String type : subtypeMap.keySet()) { TWrapper stypeWrapper = new TWrapper(4); stypeWrapper.put(TEMPLATE_NAME, type).put(TEMPLATE_SUBTYPES, subtypeMap.get(type)); subtypesList.add(stypeWrapper); } // process aspects final JSONArray aspects = (JSONArray) ((JSONObject) jsonData.get(JSON_ENTRY)).get(TEMPLATE_ASPECTS); for (final Object a : aspects) { final JSONObject aspect = (JSONObject) a; final String aspectName = (String) aspect.get(JSON_PREFIXEDNAME); // generate form wrapper objects for this aspect TWrapper formWrappers = processFormWidgets(formDefs, aspect); // add aspect wrapper for template output TWrapper aspectWrapper = new TWrapper(8); aspectWrapper.put(TEMPLATE_NAME, aspectName).put(TEMPLATE_TITLE, (String) aspect.get(JSON_TITLE)); aspectsList.add(aspectWrapper); // add all form wrapper objects for the type aspectWrapper.putAll(formWrappers); } } else { throw new AlfrescoRuntimeException( "Unable to retrieve types and aspects for model id: " + modelName); } } // render the template to generate the final module configuration and persist it Writer out = new StringBuilderWriter(4096); try { this.templateProcessor.process(MODULE_TEMPLATE_PATH, model, out); if (logger.isDebugEnabled()) logger.debug("Attempting to save module config:\r\n" + out.toString()); if (module == null) { this.moduleDeploymentService.addModuleToExtension(out.toString()); } else { this.moduleDeploymentService.updateModuleToExtension(out.toString()); } if (logger.isDebugEnabled()) logger.debug("addModuleToExtension() completed."); } catch (WebScriptException | DocumentException | ModelObjectPersisterException err) { // template error - probably developer exception so report in log logger.error("Failed to execute template to construct module configuration.", err); errorResponse(status, err.getMessage()); } } /** * Read, process and transform the JSON entity that represents the generic Aikau Form widget tree. * The elements are nested within panels with varying numbers of column. Each widget within the column * has a number of configuration parameters. * <p> * Consume the JSON entity and transform the generic tree into a template model for rendering Share Forms * configuration for properties and rendering sets of associated fields. * <p> * See module-configuration.ftl * * @param forms List of current Form state * @param entity JSON object containing Form widget hiearchy * * @return Template wrapper objects containing the hierarchical model ready for template rendering */ protected TWrapper processFormWidgets(Map<String, String> forms, JSONObject entity) { TWrapper formPropertyWrappers = new TWrapper(8); String entityName = (String) entity.get(TEMPLATE_NAME); String formDef = forms.get(entityName); if (formDef != null) { // form definition present for this type - transform it into Share Forms Runtime configuration try { Object o = new JSONParser().parse(formDef); if (o instanceof JSONArray) { JSONArray formElements = (JSONArray) o; if (formElements.size() != 0) { // construct the wrapper collections to hold our properties, sets and field wrappers List<TWrapper> properties = new ArrayList<>(); formPropertyWrappers.put(TEMPLATE_PROPERTIES, properties); List<TWrapper> sets = new ArrayList<>(); formPropertyWrappers.put(TEMPLATE_SETS, sets); List<TWrapper> fields = new ArrayList<>(); formPropertyWrappers.put(TEMPLATE_FIELDS, fields); // used to ensure a single Set of fields i.e. one per property id Map<String, TWrapper> fieldMap = new HashMap<>(); // process well known component names and output wrappers for (Object item : formElements) { // avoid garbage - there should not be any arrays etc. at root if (!(item instanceof JSONObject)) { throw new IllegalStateException("Unexpected item in form structure: " + formDef); } // prepare state - set by lookup table against the various column layout options int numCols = 0; String columnSetTemplate = null; final String name = (String) ((JSONObject) item).get(JSON_PSEUDONYM); switch (name) { case "cmm/editor/layout/1cols": { numCols = 1; break; } case "cmm/editor/layout/2cols": { numCols = 2; columnSetTemplate = "/org/alfresco/components/form/2-column-set.ftl"; break; } case "cmm/editor/layout/2colswideleft": { numCols = 2; columnSetTemplate = "/org/alfresco/components/form/2-column-wide-left-set.ftl"; break; } case "cmm/editor/layout/3cols": { numCols = 3; columnSetTemplate = "/org/alfresco/components/form/3-column-set.ftl"; break; } } if (numCols != 0) { // process properties containing within the column child object List<TWrapper> colProperties = new ArrayList<>(); JSONArray column = (JSONArray) ((JSONObject) item).get(JSON_COLUMN); if (column != null) { // process widget list within each column - form fields automatically wrap // at the appropriate column index when rendered by the Forms Runtime template for (Object w : column) { // process widget list - wraps automatically at column index JSONObject widget = ((JSONObject) w); String pseudonym = (String) widget.get(JSON_PSEUDONYM); String id = (String) widget.get(JSON_ID); if (logger.isDebugEnabled()) logger.debug("Processing widget: " + id + " of type: " + pseudonym); // generate a template wrapper for the property widget Form config TWrapper controlProperties = new TWrapper(4).put(TEMPLATE_NAME, id); colProperties.add(controlProperties); final JSONObject config = (JSONObject) widget.get(JSON_ELEMENTCONFIG); if (config != null) { if (logger.isDebugEnabled()) logger.debug("Found 'elementconfig' for widget - processing..."); // generate wrappers for control params and field properties Map<String, Object> controlParams = new HashMap<>(4); TWrapper fieldWrapper = new TWrapper(4).put(TEMPLATE_ID, id) .put(TEMPLATE_PARAMS, controlParams); fieldMap.put(id, fieldWrapper); // map element config to Forms Config values // this is fiddly - the simple list of properties is remapped to attributes on // both the control property and on the associated field mapping for it String controlType = (String) config.get(JSON_CONTROLTYPE); String mode = (String) config.get(JSON_FOR_MODE); if (mode != null && !mode.equals(JSON_ANY)) controlProperties.put(TEMPLATE_MODE, mode); // deal with annoying checkbox = string when not used, but boolean when clicked nonsense if (config.get(JSON_FORCE) instanceof Boolean) { Boolean force = (Boolean) config.get(JSON_FORCE); if (Boolean.TRUE == force) controlProperties.put(TEMPLATE_FORCE, true); } if (config.get(JSON_HIDDEN) instanceof Boolean) { Boolean hidden = (Boolean) config.get(JSON_HIDDEN); if (Boolean.TRUE == hidden) controlType = CONTROLTYPE_HIDDEN; } if (config.get(JSON_READ_ONLY) instanceof Boolean) { Boolean readOnly = (Boolean) config.get(JSON_READ_ONLY); if (Boolean.TRUE == readOnly) fieldWrapper.put(TEMPLATE_READONLY, true); } Number maxLength = (Number) config.get(JSON_MAXLENGTH); if (maxLength != null) controlParams.put(TEMPLATE_MAXLENGTH, maxLength); String style = (String) config.get(JSON_STYLE); if (style != null && style.length() != 0) controlParams.put(TEMPLATE_STYLE, style); String styleClass = (String) config.get(JSON_STYLECLASS); if (styleClass != null && styleClass.length() != 0) controlParams.put(TEMPLATE_STYLECLASS, styleClass); // control type for field wrapper - each control type maps to a wrapper template and params as per Share Forms config String template = null; if (controlType != null) { switch (controlType) { case CONTROLTYPE_TEXTFIELD: template = "/org/alfresco/components/form/controls/textfield.ftl"; break; case CONTROLTYPE_TEXTAREA: template = "/org/alfresco/components/form/controls/textarea.ftl"; break; case CONTROLTYPE_CONTENT: template = "/org/alfresco/components/form/controls/content.ftl"; break; case CONTROLTYPE_RICHTEXT: template = "/org/alfresco/components/form/controls/richtext.ftl"; break; case CONTROLTYPE_PASSWORD: template = "/org/alfresco/components/form/controls/textfield.ftl"; controlParams.put(TEMPLATE_PASSWORD, "true"); break; case CONTROLTYPE_HIDDEN: template = "/org/alfresco/components/form/controls/hidden.ftl"; break; case CONTROLTYPE_SIZE: template = "/org/alfresco/components/form/controls/size.ftl"; break; case CONTROLTYPE_MIMETYPE: template = "/org/alfresco/components/form/controls/mimetype.ftl"; break; case CONTROLTYPE_TAGGABLE: controlParams.put("compactMode", "true"); controlParams.put("params", "aspect=cm:taggable"); controlParams.put("createNewItemUri", "/api/tag/workspace/SpacesStore"); controlParams.put("createNewItemIcon", "tag"); break; case CONTROLTYPE_CATEGORIES: controlParams.put("compactMode", "true"); break; case CONTROLTYPE_DEFAULT: break; default: if (logger.isDebugEnabled()) logger.debug( "WARNING: unknown control type for template mapping: " + controlType); } if (template != null) { fieldWrapper.put(TEMPLATE_TEMPLATE, template); if (logger.isDebugEnabled()) logger.debug("Widget control template: " + template); } } } } } // output a layout set - if number of columns > 1 then output a set template to render columns // Example: // <set template="/org/alfresco/components/form/2-column-set.ftl" appearance="title" label-id="CMM Reference Model" id="refmodel"/> // also see web-framework-commons/.../form.lib.ftl final JSONObject config = (JSONObject) ((JSONObject) item).get(JSON_ELEMENTCONFIG); String panelLabel = (String) config.get(JSON_LABEL); boolean hasLabel = (panelLabel != null && panelLabel.length() != 0); final String setId = entity.get(JSON_PREFIXEDNAME) + "_cmm_set" + sets.size(); TWrapper setWrapper = new TWrapper(8); setWrapper .put(TEMPLATE_APPEARANCE, hasLabel ? config.get(JSON_APPEARANCE) : "whitespace") .put(TEMPLATE_ID, setId); if (numCols > 1) setWrapper.put(TEMPLATE_TEMPLATE, columnSetTemplate); if (hasLabel) setWrapper.put(TEMPLATE_LABEL, config.get(JSON_LABEL)); sets.add(setWrapper); // bind properties via fields to the column set // Example: // <field set="refmodel" id="cmm:simple_string" /> for (TWrapper property : colProperties) { String id = (String) property.get(TEMPLATE_NAME); TWrapper fieldWrapper = fieldMap.get(id); if (fieldWrapper == null) { fieldWrapper = new TWrapper(4).put(TEMPLATE_ID, id); fieldMap.put(id, fieldWrapper); } fieldWrapper.put(TEMPLATE_SET, setId); if (logger.isDebugEnabled()) logger.debug("Field mapping of: " + id + " mapped to set:" + setId); } // add all the properties gathered for this column set properties.addAll(colProperties); } // end num cols != check } // end form elements processing loop // add all fields from the map to the list structure used by the template fields.addAll(fieldMap.values()); } } } catch (ParseException e) { logger.warn("Unable to parse Form definition for entity: " + entityName + "\n" + formDef + "\n" + e.getMessage()); } } return formPropertyWrappers; } /** * Delete extension module for a given model * * @param status WebScript status object - used to set error codes * @param modelName Model name to delete extension config for */ protected void deleteExtensionModule(Status status, String modelName) { if (logger.isDebugEnabled()) logger.debug("Attempting to delete module: " + buildModuleId(modelName)); try { this.moduleDeploymentService.deleteModuleFromExtension(buildModuleId(modelName)); } catch (DocumentException | ModelObjectPersisterException err) { // template error - probably developer exception so report in log logger.error("Failed to execute template to construct module configuration.", err); errorResponse(status, err.getMessage()); } if (logger.isDebugEnabled()) logger.debug("deleteModuleFromExtension() completed."); } /** * @param modelName Model name to get extension module for * @return ExtensionModule */ protected ExtensionModule getExtensionModule(final String modelName) { final String moduleId = buildModuleId(modelName); ExtensionModule module = null; for (ModuleDeployment m : this.moduleDeploymentService.getDeployedModules()) { if (moduleId.equals(m.getId())) { module = m.getExtensionModule(); if (logger.isDebugEnabled()) logger.debug("Found existing module for ID: " + moduleId); } } if (module == null) { // no module yet - lazy create on module save if (logger.isDebugEnabled()) logger.debug("No module found for ID: " + moduleId); } return module; } /** * @param modelName Model to get the Form Definitions map for * @return the Form Definitions map for a given model */ protected Map<String, String> getFormDefinitions(String modelName) { return getFormDefinitions(getExtensionModule(modelName)); } protected Map<String, String> getFormDefinitions(ExtensionModule module) { Map<String, String> forms = new HashMap<>(); if (module != null) { List<Element> configs = module.getConfigurations(); for (Element config : configs) { for (Element form : (List<Element>) config.selectNodes("config/form-definition")) { String formId = form.attributeValue(JSON_ID); String formJSON = form.getText(); forms.put(formId, formJSON); } } } return forms; } protected Connector getConnector() { final RequestContext rc = ThreadLocalRequestContext.getRequestContext(); try { return rc.getServiceRegistry().getConnectorService().getConnector("alfresco", rc.getUserId(), ServletUtil.getSession()); } catch (ConnectorServiceException e) { throw new AlfrescoRuntimeException("Connector exception.", e); } } protected Connector getAPIConnector() { final RequestContext rc = ThreadLocalRequestContext.getRequestContext(); try { return rc.getServiceRegistry().getConnectorService().getConnector("alfresco-api", rc.getUserId(), ServletUtil.getSession()); } catch (ConnectorServiceException e) { throw new AlfrescoRuntimeException("Connector exception.", e); } } protected JSONObject getJsonBody(final WebScriptRequest req) { try { JSONObject jsonData = null; final String content = req.getContent().getContent(); if (content != null && content.length() != 0) { Object o = new JSONParser().parse(content); if (o instanceof JSONObject) { jsonData = (JSONObject) o; } } return jsonData; } catch (ParseException | IOException e) { throw new AlfrescoRuntimeException("Failed to retrieve or parse JSON body.", e); } } protected JSONObject getJsonBody(final Response res) { try { JSONObject jsonData = null; final String content = res.getResponse(); if (content != null && content.length() != 0) { Object o = new JSONParser().parse(content); if (o instanceof JSONObject) { jsonData = (JSONObject) o; } } return jsonData; } catch (ParseException e) { throw new AlfrescoRuntimeException("Failed to retrieve or parse JSON body.", e); } } protected void errorResponse(Status status, String msg) { status.setCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); status.setMessage(msg); status.setRedirect(true); } /** * Enum that represents the operations that can be performed on a Form definition for an entity */ enum FormOperationEnum { Create, Update, Delete } /** * Wrapper class that encapsulates a CRUD operation for a Form definition */ class FormOperation { private final FormOperationEnum op; private final String entityId; private final String form; private final Map<String, String> forms; FormOperation(FormOperationEnum op, String entityId, String form) { this.op = op; if (entityId == null || entityId.length() == 0) { throw new IllegalArgumentException("EntityID is mandatory."); } this.entityId = entityId; this.form = form; this.forms = null; } FormOperation(FormOperationEnum op, Map<String, String> forms) { this.op = op; if (forms == null) { throw new IllegalArgumentException("Forms map is mandatory."); } this.entityId = null; this.form = null; this.forms = forms; } /** * Perform the given operation on the given forms map onto the given output list * @param forms Map of entity Ids to forms defs */ void perform(Map<String, String> forms) { switch (this.op) { case Create: { forms.putAll(this.forms); break; } case Update: { forms.put(this.entityId, this.form); break; } case Delete: { forms.remove(this.entityId); break; } } } } /** * Simple wrapper class for a template Map object - to avoid verbose Map generics code. */ public static class TWrapper extends HashMap<String, Object> implements Map<String, Object> { public TWrapper(int size) { super(size); } public TWrapper put(String key, Object value) { super.put(key, value); return this; } public TWrapper putAll(Object... args) { for (int i = 0; i < args.length; i += 2) { super.put((String) args[i], args[i + 1]); } return this; } } }