diff --git a/cms-core/pom.xml b/cms-core/pom.xml index c12c589e9e..428206b368 100644 --- a/cms-core/pom.xml +++ b/cms-core/pom.xml @@ -436,6 +436,12 @@ handlebars + + + com.jayway.jsonpath + json-path + + org.glassfish.jersey.containers diff --git a/cms-core/src/main/java/com/gentics/contentnode/devtools/AbstractSynchronizer.java b/cms-core/src/main/java/com/gentics/contentnode/devtools/AbstractSynchronizer.java index bfde4df53f..63c86fd330 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/devtools/AbstractSynchronizer.java +++ b/cms-core/src/main/java/com/gentics/contentnode/devtools/AbstractSynchronizer.java @@ -561,6 +561,8 @@ protected String getProposedFilename(Part part) { // special case for HandlebarsPartType if (part.getPartTypeId() == 43) { return "part." + part.getKeyname() + ".hbs"; + } else if (part.getPartTypeId() == Part.JSON) { + return "part." + part.getKeyname() + ".json"; } switch (Property.Type.get(part.getPartTypeId())) { case STRING: diff --git a/cms-core/src/main/java/com/gentics/contentnode/devtools/ConstructSynchronizer.java b/cms-core/src/main/java/com/gentics/contentnode/devtools/ConstructSynchronizer.java index 8a30871675..7796d006e1 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/devtools/ConstructSynchronizer.java +++ b/cms-core/src/main/java/com/gentics/contentnode/devtools/ConstructSynchronizer.java @@ -447,6 +447,9 @@ protected Part transform(PartModel from, Part to) throws NodeException { to.setExternalEditorUrl(from.getExternalEditorUrl()); switch (to.getPartTypeId()) { + case Part.JSON: + to.setInfoText(from.getHtmlClass()); + // fallthrough case Part.TEXT: case Part.TEXTHMTL: case Part.HTML: @@ -455,11 +458,6 @@ protected Part transform(PartModel from, Part to) throws NodeException { case Part.HTMLLONG: to.setInfoInt(from.getRegexId()); break; - default: - break; - } - - switch (to.getPartTypeId()) { case Part.LIST: case Part.LISTORDERED: case Part.LISTUNORDERED: diff --git a/cms-core/src/main/java/com/gentics/contentnode/factory/object/ContentRepositoryFactory.java b/cms-core/src/main/java/com/gentics/contentnode/factory/object/ContentRepositoryFactory.java index 1f9a3b55b6..62af04c2f9 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/factory/object/ContentRepositoryFactory.java +++ b/cms-core/src/main/java/com/gentics/contentnode/factory/object/ContentRepositoryFactory.java @@ -1532,6 +1532,10 @@ private static class FactoryTagmapEntry extends TagmapEntry { @Updateable protected String micronodeFilter; + @DataField("json_schema_filter") + @Updateable + protected String jsonSchemaFilter; + /** * Create an empty instance * @param info info @@ -1678,6 +1682,11 @@ public String getMicronodeFilter() { return micronodeFilter; } + @Override + public String getJSONSchemaFilter() { + return jsonSchemaFilter; + } + @Override public void delete(boolean force) throws InsufficientPrivilegesException, NodeException { @@ -1887,6 +1896,14 @@ public void setMicronodeFilter(String micronodeFilter) throws ReadOnlyException } } + @Override + public void setJSONSchemaFilter(String jsonSchemaFilter) throws ReadOnlyException { + if (!StringUtils.isEqual(this.jsonSchemaFilter, jsonSchemaFilter)) { + this.jsonSchemaFilter = jsonSchemaFilter; + this.modified = true; + } + } + @Override public boolean save() throws InsufficientPrivilegesException, NodeException { @@ -2224,7 +2241,7 @@ private static class FactoryCrFragmentEntry extends AbstractContentObject implem protected int crFragmentId; @RestModel(update = { "tagname", "mapname", "obj_type", "attribute_type", "multivalue", "optimized", "filesystem", "target_type", - "foreignlink_attribute", "foreignlink_attribute_rule", "category", "displayfield", "segmentfield", "urlfield", "no_index", "elasticsearch", "micronode_filter" }) + "foreignlink_attribute", "foreignlink_attribute_rule", "category", "displayfield", "segmentfield", "urlfield", "no_index", "elasticsearch", "micronode_filter", "json_schema_filter" }) protected ContentRepositoryFragmentEntryModel model; protected AttributeType attributeType; @@ -2385,6 +2402,11 @@ public String getMicronodeFilter() { return model.getMicronodeFilter(); } + @Override + public String getJsonSchemaFilter() { + return model.getJSONSchemaFilter(); + } + @Override public ContentRepositoryFragmentEntryModel getModel() { return model; @@ -2500,6 +2522,14 @@ public void setMicronodeFilter(String micronodeFilter) throws ReadOnlyException } } + @Override + public void setJSONSchemaFilter(String jsonSchemaFilter) throws ReadOnlyException { + if (!StringUtils.isEqual(model.getJSONSchemaFilter(), jsonSchemaFilter)) { + model.setJSONSchemaFilter(jsonSchemaFilter); + this.modified = true; + } + } + @Override public void setFilesystem(boolean filesystem) throws ReadOnlyException { if (isFilesystem() != filesystem) { diff --git a/cms-core/src/main/java/com/gentics/contentnode/factory/object/PartFactory.java b/cms-core/src/main/java/com/gentics/contentnode/factory/object/PartFactory.java index 317df39e28..078fb3cdbd 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/factory/object/PartFactory.java +++ b/cms-core/src/main/java/com/gentics/contentnode/factory/object/PartFactory.java @@ -16,12 +16,19 @@ import java.util.List; import java.util.Map; import java.util.Vector; +import java.util.stream.IntStream; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.gentics.api.lib.etc.ObjectTransformer; import com.gentics.api.lib.exception.NodeException; import com.gentics.api.lib.exception.ReadOnlyException; import com.gentics.api.lib.i18n.I18nString; import com.gentics.contentnode.db.DBUtils; +import com.gentics.contentnode.etc.Function; import com.gentics.contentnode.events.Events; import com.gentics.contentnode.events.TransactionalTriggerEvent; import com.gentics.contentnode.factory.C; @@ -45,9 +52,13 @@ import com.gentics.contentnode.object.Value; import com.gentics.contentnode.object.ValueContainer; import com.gentics.contentnode.rest.exceptions.InsufficientPrivilegesException; +import com.gentics.contentnode.rest.util.MiscUtils; import com.gentics.lib.db.SQLExecutor; import com.gentics.lib.etc.StringUtils; import com.gentics.lib.i18n.CNI18nString; +import com.gentics.mesh.json.JsonUtil; + +import io.vertx.core.json.JsonArray; /** * An objectfactory which can create {@link Part} objects, based on the @@ -55,6 +66,12 @@ */ @DBTables({ @DBTable(clazz = Part.class, name = "part") }) public class PartFactory extends AbstractFactory { + + /** + * Error log + */ + public final static String LOG_JSON_VALIDATION_ERROR = "JSON Validation error"; + /** * SQL Statement to select a part */ @@ -644,6 +661,8 @@ public boolean save() throws InsufficientPrivilegesException, if (!isNew) { origPart = t.getObject(Part.class, getId()); } + // validate + validatePart(this, getDefaultValue(), reason -> supplyInvalidJSONException(getKeyname(), reason, this)); // save the construct, if necessary if (isModified) { @@ -835,6 +854,54 @@ public void flush() throws NodeException { } } + /** + * Perform the validation of a part, where applicable. + * + * @param tag + * @throws NodeException + */ + public static void validatePart(Part part, Object value, Function exceptionSupplier) throws NodeException { + if (part.getPartTypeId() == Part.JSON) { + if (value == null || !(value instanceof Value val)) { + // Nothing to validate + return; + } + String stringValue = val.getValueText(); + if (StringUtils.isEmpty(stringValue)) { + // Nothing to validate + return; + } + ObjectMapper objectMapper = MiscUtils.newObjectMapper(); + try { + JsonNode jsonNode = objectMapper.readTree(stringValue); + if (!StringUtils.isEmpty(part.getInfoText())) { + JsonNode jsonSchemaContent; + jsonSchemaContent = objectMapper.readTree(part.getInfoText()); + JsonNode[] allowedSchemas = null; + if (jsonSchemaContent.isArray()) { + ArrayNode jsonSchemas = (ArrayNode)jsonSchemaContent; + + allowedSchemas = IntStream.range(0, jsonSchemas.size()).mapToObj(jsonSchemas::get) + .filter(JsonNode::isObject).map(ObjectNode.class::cast).toArray(size -> new JsonNode[size]); + } else { + allowedSchemas = new JsonNode[] { jsonSchemaContent }; + } + if (allowedSchemas != null && Arrays.asList(allowedSchemas).stream().noneMatch(schema1 -> JsonUtil.validate(schema1, jsonNode) == Boolean.TRUE)) { + throw exceptionSupplier.apply("the JSON contents does not match any of allowed schemas"); + } + } + } catch (JsonProcessingException e) { + exceptionSupplier.apply("Error while parsing JSON"); + } + } + } + + private static ObjectModificationException supplyInvalidJSONException(String property, String reason, Part part) { + return new ObjectModificationException(property, LOG_JSON_VALIDATION_ERROR + " for " + + "part {" + part.getKeyname() + "}." + + " Reason: " + reason, "json_validation_failed"); + } + /** * Internal helper class for implementation of dummy values (for parttypes that do not have an own value) */ diff --git a/cms-core/src/main/java/com/gentics/contentnode/factory/object/TagFactory.java b/cms-core/src/main/java/com/gentics/contentnode/factory/object/TagFactory.java index 182a670046..8ef733657f 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/factory/object/TagFactory.java +++ b/cms-core/src/main/java/com/gentics/contentnode/factory/object/TagFactory.java @@ -55,6 +55,7 @@ import com.gentics.contentnode.object.ContentTag; import com.gentics.contentnode.object.EditableValueList; import com.gentics.contentnode.object.LocalizableNodeObject; +import com.gentics.contentnode.object.NamedNodeObject; import com.gentics.contentnode.object.Node; import com.gentics.contentnode.object.NodeObject; import com.gentics.contentnode.object.NodeObjectInfo; @@ -67,6 +68,7 @@ import com.gentics.contentnode.object.Template; import com.gentics.contentnode.object.TemplateTag; import com.gentics.contentnode.object.Value; +import com.gentics.contentnode.object.ValueContainer; import com.gentics.contentnode.object.ValueList; import com.gentics.contentnode.object.parttype.PartType; import com.gentics.contentnode.publish.wrapper.PublishablePage; @@ -156,6 +158,10 @@ public class TagFactory extends AbstractFactory { */ public final static String SYNC_RUNNING_ATTRIBUTENAME = "objtag.sync_running"; + /** + * Error log + */ + public final static String LOG_JSON_VALIDATION_ERROR = "JSON Validation error"; /** * Implementation class for a ContentTag */ @@ -459,6 +465,7 @@ public boolean saveBatch(BatchUpdater batchUpdater, Operator before, Operator af if (valueIds != null) { valueIdsToRemove.addAll(valueIds); } + validateTagObject(this); for (Value value : values) { isModified |= value.saveBatch(batchUpdater, () -> value.setContainer(this), null); valueIdsToRemove.remove(value.getId()); @@ -825,6 +832,7 @@ public boolean save() throws InsufficientPrivilegesException, NodeException { if (valueIds != null) { valueIdsToRemove.addAll(valueIds); } + validateTagObject(this); for (Value value : values) { value.setContainer(this); isModified |= value.save(); @@ -1553,6 +1561,7 @@ public boolean save() throws InsufficientPrivilegesException, NodeException { if (valueIds != null) { valueIdsToRemove.addAll(valueIds); } + validateTagObject(this); for (Value value : values) { value.setContainer(this); isModified |= value.save(); @@ -1931,6 +1940,21 @@ private static void saveTemplateTagObject(EditableFactoryTemplateTag tag) throws } } + /** + * Perform the validation of a value a tag, where applicable. + * + * @param tag + * @throws NodeException + */ + private static void validateTagObject(T tag) throws NodeException { + for (Part part: tag.getConstruct().getParts()) { + PartFactory.validatePart(part, tag.get(part.getKeyname()), reason -> new ObjectModificationException(tag.getName(), LOG_JSON_VALIDATION_ERROR + " for " + + "tag {" + tag.getId() + " " + tag.getName() + "}" + + ", part {" + part.getKeyname() + "}." + + " Reason: " + reason, "json_validation_failed")); + } + } + /** * Save the given objecttag object * @param tag tag diff --git a/cms-core/src/main/java/com/gentics/contentnode/migration/MigrationPartMapper.java b/cms-core/src/main/java/com/gentics/contentnode/migration/MigrationPartMapper.java index 88c2030ea9..5393161e3e 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/migration/MigrationPartMapper.java +++ b/cms-core/src/main/java/com/gentics/contentnode/migration/MigrationPartMapper.java @@ -11,6 +11,7 @@ import static com.gentics.contentnode.object.Part.HTMLCUSTOMFORM; import static com.gentics.contentnode.object.Part.HTMLLONG; import static com.gentics.contentnode.object.Part.JAVAEDITOR; +import static com.gentics.contentnode.object.Part.JSON; import static com.gentics.contentnode.object.Part.LIST; import static com.gentics.contentnode.object.Part.LISTORDERED; import static com.gentics.contentnode.object.Part.LISTUNORDERED; @@ -194,6 +195,7 @@ private static boolean hasPartMappableValue(Part part) { case TEXTHTMLLONG: case TEXTCUSTOMFORM: case HTML: + case JSON: case LIST: case LISTORDERED: case LISTUNORDERED: diff --git a/cms-core/src/main/java/com/gentics/contentnode/object/ContentRepository.java b/cms-core/src/main/java/com/gentics/contentnode/object/ContentRepository.java index ab6daee03a..032062d6f1 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/object/ContentRepository.java +++ b/cms-core/src/main/java/com/gentics/contentnode/object/ContentRepository.java @@ -1125,6 +1125,8 @@ protected String[] getCheckedAttributes(AttributeType attrType) throws NodeExcep default: throw new NodeException(); } + case json: + return new String[] { "tagname", "mapname", "object", "attributeType", "multivalue", "segmentfield", "displayfield", "urlfield", "noIndex", "elasticsearch", "jsonSchemaFilter" }; case micronode: return new String[] { "tagname", "mapname", "object", "attributeType", "multivalue", "segmentfield", "displayfield", "urlfield", "micronodeFilter", "elasticsearch" }; case link: diff --git a/cms-core/src/main/java/com/gentics/contentnode/object/Part.java b/cms-core/src/main/java/com/gentics/contentnode/object/Part.java index ef03139901..c5cb7241da 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/object/Part.java +++ b/cms-core/src/main/java/com/gentics/contentnode/object/Part.java @@ -7,6 +7,8 @@ import java.net.URI; +import org.apache.commons.lang3.StringUtils; + import com.gentics.api.lib.etc.ObjectTransformer; import com.gentics.api.lib.exception.NodeException; import com.gentics.api.lib.exception.ReadOnlyException; @@ -70,6 +72,10 @@ public abstract class Part extends AbstractContentObject implements I18nNamedNod public static final int FILEUPLOAD = 38; public static final int FOLDERUPLOAD = 39; public static final int NODE = 40; + public static final int FORM = 41; + public static final int CMSFORM = 42; + public static final int HANDLEBARS = 43; + public static final int JSON = 44; /** * The ttype of the part object. @@ -106,6 +112,9 @@ public abstract class Part extends AbstractContentObject implements I18nNamedNod to.setExternalEditorUrl(from.getExternalEditorUrl()); switch (to.getPartTypeId()) { + case Part.JSON: + to.setInfoText(from.getJSONSchema()); + // fallthrough case Part.TEXT: case Part.TEXTHMTL: case Part.HTML: @@ -116,11 +125,6 @@ public abstract class Part extends AbstractContentObject implements I18nNamedNod to.setInfoInt(from.getRegex().getId()); } break; - default: - break; - } - - switch (to.getPartTypeId()) { case Part.LIST: case Part.LISTORDERED: case Part.LISTUNORDERED: @@ -185,6 +189,9 @@ public abstract class Part extends AbstractContentObject implements I18nNamedNod to.setTypeId(from.getPartTypeId()); switch (from.getPartTypeId()) { + case Part.JSON: + to.setJSONSchema(from.getInfoText()); + // fallthrough case Part.TEXT: case Part.TEXTHMTL: case Part.HTML: @@ -243,6 +250,9 @@ public abstract class Part extends AbstractContentObject implements I18nNamedNod to.setExternalEditorUrl(from.getExternalEditorUrl()); switch (from.getPartTypeId()) { + case Part.JSON: + to.setHtmlClass(from.getInfoText()); + // fallthrough case Part.TEXT: case Part.TEXTHMTL: case Part.HTML: @@ -251,11 +261,6 @@ public abstract class Part extends AbstractContentObject implements I18nNamedNod case Part.HTMLLONG: to.setRegexId(from.getInfoInt()); break; - default: - break; - } - - switch (from.getPartTypeId()) { case Part.LIST: case Part.LISTORDERED: case Part.LISTUNORDERED: diff --git a/cms-core/src/main/java/com/gentics/contentnode/object/TagmapEntry.java b/cms-core/src/main/java/com/gentics/contentnode/object/TagmapEntry.java index 9fa01f7f0d..ab38f5ac17 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/object/TagmapEntry.java +++ b/cms-core/src/main/java/com/gentics/contentnode/object/TagmapEntry.java @@ -65,6 +65,7 @@ public abstract class TagmapEntry extends AbstractContentObject implements Named } } entry.setMicronodeFilter(nodeEntry.getMicronodeFilter()); + entry.setJSONSchemaFilter(nodeEntry.getJSONSchemaFilter()); } CrFragment fragment = nodeEntry.getContentRepositoryFragment(); if (fragment != null) { @@ -128,6 +129,9 @@ public abstract class TagmapEntry extends AbstractContentObject implements Named if (entry.getMicronodeFilter() != null) { nodeEntry.setMicronodeFilter(entry.getMicronodeFilter()); } + if (entry.getJSONSchemaFilter() != null) { + nodeEntry.setJSONSchemaFilter(entry.getJSONSchemaFilter()); + } if (entry.getNoIndex() != null) { nodeEntry.setNoIndex(entry.getNoIndex()); } @@ -166,6 +170,7 @@ public abstract class TagmapEntry extends AbstractContentObject implements Named resolvableProperties.put("urlfield", new NodeObjectProperty<>((o, key) -> o.isUrlfield())); resolvableProperties.put("fragmentName", new NodeObjectProperty<>((o, key) -> o.getContentRepositoryFragmentName())); resolvableProperties.put("micronodeFilter", new NodeObjectProperty<>((o, key) -> o.getMicronodeFilter())); + resolvableProperties.put("jsonSchemaFilter", new NodeObjectProperty<>((o, key) -> o.getJSONSchemaFilter())); resolvableProperties.put("elasticsearch", new NodeObjectProperty<>((o, key) -> o.getElasticsearch())); resolvableProperties.put("noIndex", new NodeObjectProperty<>((o, key) -> o.isNoIndex())); } @@ -549,6 +554,23 @@ public void setMicronodeFilter(String micronodeFilter) throws ReadOnlyException failReadOnly(); } + /** + * Get the JSON schema filter for entries of type {@link AttributeType#json} + * @return filter + */ + @FieldGetter("json_schema_filter") + public abstract String getJSONSchemaFilter(); + + /** + * Set the JSON schema filter for entries of type {@link AttributeType#json} + * @param jsonSchemaFilter filter + * @throws ReadOnlyException + */ + @FieldSetter("json_schema_filter") + public void setJSONSchemaFilter(String jsonSchemaFilter) throws ReadOnlyException { + failReadOnly(); + } + @Override public String getName() { return getMapname(); @@ -563,6 +585,9 @@ public String toString() { case foreignlink: return String.format("%d: %s -> %s (%s from %d.%s, mul: %b, stat: %b, opt: %b, fs: %b, cat: %s)", getObject(), getTagname(), getMapname(), getAttributetype(), getTargetType(), getForeignlinkAttribute(), isMultivalue(), isStatic(), isOptimized(), isFilesystem(), getCategory()); + case json: + return String.format("%d: %s -> %s (mul: %b, stat: %b, opt: %b, fs: %b, cat: %s, schema: %s)", getObject(), getTagname(), getMapname(), + isMultivalue(), isStatic(), isOptimized(), isFilesystem(), getCategory(), getJSONSchemaFilter()); default: return String.format("%d: %s -> %s (%s, mul: %b, stat: %b, opt: %b, fs: %b, cat: %s)", getObject(), getTagname(), getMapname(), getAttributetype(), isMultivalue(), isStatic(), isOptimized(), isFilesystem(), getCategory()); @@ -621,7 +646,12 @@ public static enum AttributeType { /** * Micronode */ - micronode(12, ContentRepositoryModel.Type.mesh); + micronode(12, ContentRepositoryModel.Type.mesh), + + /** + * JSON + */ + json(13, ContentRepositoryModel.Type.mesh); /** * numerical type diff --git a/cms-core/src/main/java/com/gentics/contentnode/object/cr/CrFragmentEntry.java b/cms-core/src/main/java/com/gentics/contentnode/object/cr/CrFragmentEntry.java index ae58031c2a..841035fa49 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/object/cr/CrFragmentEntry.java +++ b/cms-core/src/main/java/com/gentics/contentnode/object/cr/CrFragmentEntry.java @@ -337,6 +337,23 @@ default void setMicronodeFilter(String micronodeFilter) throws ReadOnlyException throw new ObjectReadOnlyException(this); } + /** + * Get the JSON schema filter (comma separated values) for the entries of a type ({@link AttributeType#json} * + * @return + */ + @FieldGetter("json_schema_filter") + String getJsonSchemaFilter(); + + /** + * Set the JSON schema filter for entries of type {@link AttributeType#json} + * @param jsonSchemaFilter comma separated JSON schema values + * @throws ReadOnlyException + */ + @FieldSetter("json_schema_filter") + default void setJSONSchemaFilter(String jsonSchemaFilter) throws ReadOnlyException { + throw new ObjectReadOnlyException(this); + } + @Override default String getName() { return getMapname(); diff --git a/cms-core/src/main/java/com/gentics/contentnode/object/cr/CrFragmentEntryWrapper.java b/cms-core/src/main/java/com/gentics/contentnode/object/cr/CrFragmentEntryWrapper.java index fab15ca469..4ce417a14c 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/object/cr/CrFragmentEntryWrapper.java +++ b/cms-core/src/main/java/com/gentics/contentnode/object/cr/CrFragmentEntryWrapper.java @@ -142,6 +142,11 @@ public String getMicronodeFilter() { return wrapped.getMicronodeFilter(); } + @Override + public String getJSONSchemaFilter() { + return wrapped.getJsonSchemaFilter(); + } + @Override public Integer getId() { return wrapped.getId(); diff --git a/cms-core/src/main/java/com/gentics/contentnode/object/parttype/JSONPartType.java b/cms-core/src/main/java/com/gentics/contentnode/object/parttype/JSONPartType.java new file mode 100644 index 0000000000..0f2e213c07 --- /dev/null +++ b/cms-core/src/main/java/com/gentics/contentnode/object/parttype/JSONPartType.java @@ -0,0 +1,89 @@ +package com.gentics.contentnode.object.parttype; + +import java.util.HashSet; +import java.util.Set; +import java.util.stream.IntStream; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.gentics.api.lib.exception.NodeException; +import com.gentics.contentnode.object.Value; +import com.gentics.contentnode.rest.model.Property; +import com.gentics.contentnode.rest.model.Property.Type; +import com.gentics.contentnode.rest.util.MiscUtils; + +/** + * A parttype for storing JSON content. Parttype ID = 44. + */ +public class JSONPartType extends TextPartType { + + private static final long serialVersionUID = -4534399369711092989L; + + protected JsonNode json; + + protected ArrayNode arrayNode; + + protected ObjectNode objectNode; + + public JSONPartType(Value value) throws NodeException { + super(value, TextPartType.REPLACENL_EXTENDEDNL2BR); + } + + @Override + public Type getPropertyType() { + return Property.Type.RICHTEXT; + } + + @Override + public void setText(String text) throws NodeException { + super.setText(text); + try { + json = null; + arrayNode = null; + objectNode = null; + + json = MiscUtils.newObjectMapper().readTree(text); + if (json instanceof ArrayNode array) { + arrayNode = array; + } + if (json instanceof ObjectNode object) { + objectNode = object; + } + } catch (JsonProcessingException e) { + throw new NodeException("Invalid JSON"); + } + } + + @Override + public Set getResolvableKeys() { + Set resolvableKeys = new HashSet<>(); + if (arrayNode != null) { + IntStream.range(0, arrayNode.size()).forEach(i -> resolvableKeys.add(Integer.toString(i))); + } else if (objectNode != null) { + objectNode.fieldNames().forEachRemaining(resolvableKeys::add); + } + return resolvableKeys; + } + + @Override + public Object get(String key) { + if (arrayNode != null) { + try { + int i = Integer.parseInt(key); + if (arrayNode.size() > i) { + return arrayNode.get(i); + } else { + return null; + } + } catch (NumberFormatException e) { + return null; + } + } else if (objectNode != null) { + return objectNode.get(key); + } else { + return null; + } + } +} diff --git a/cms-core/src/main/java/com/gentics/contentnode/object/parttype/handlebars/HandlebarsPartType.java b/cms-core/src/main/java/com/gentics/contentnode/object/parttype/handlebars/HandlebarsPartType.java index 1470886c77..5aae09ed4e 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/object/parttype/handlebars/HandlebarsPartType.java +++ b/cms-core/src/main/java/com/gentics/contentnode/object/parttype/handlebars/HandlebarsPartType.java @@ -84,7 +84,7 @@ public String render(RenderResult result, String template) throws NodeException StringTemplateSource source = new StringTemplateSource(templateName, getText()); Template handlebarsTemplate = handlebars.compile(source); Context context = Context.newBuilder(null) - .resolver(MapValueResolver.INSTANCE, JavaBeanValueResolver.INSTANCE) + .resolver(MapValueResolver.INSTANCE, JavaBeanValueResolver.INSTANCE, JsonContentValueResolver.INSTANCE) .combine("cms", new ResolvableMapWrapper(cmsResolver)) .build(); String rendered = handlebarsTemplate.apply(context); diff --git a/cms-core/src/main/java/com/gentics/contentnode/object/parttype/handlebars/HelperSource.java b/cms-core/src/main/java/com/gentics/contentnode/object/parttype/handlebars/HelperSource.java index 665bf029eb..3a60e22b88 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/object/parttype/handlebars/HelperSource.java +++ b/cms-core/src/main/java/com/gentics/contentnode/object/parttype/handlebars/HelperSource.java @@ -7,12 +7,14 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.ListUtils; import org.apache.commons.lang3.StringUtils; +import com.fasterxml.jackson.databind.JsonNode; import com.gentics.api.lib.datasource.Datasource; import com.gentics.api.lib.etc.ObjectTransformer; import com.gentics.api.lib.exception.NodeException; @@ -27,6 +29,7 @@ import com.gentics.contentnode.object.ImageFile; import com.gentics.contentnode.object.Node; import com.gentics.contentnode.object.Tag; +import com.gentics.contentnode.object.Value; import com.gentics.contentnode.object.parttype.ImageURLPartType; import com.gentics.contentnode.object.parttype.NodePartType; import com.gentics.contentnode.render.GisRendering; @@ -42,6 +45,10 @@ import com.gentics.contentnode.resolving.ResolvableMapWrapper.RenderContext; import com.gentics.lib.render.Renderable; import com.github.jknack.handlebars.Options; +import com.github.jknack.handlebars.helper.HelperFunction; +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.ParseContext; +import com.jayway.jsonpath.spi.json.JacksonJsonProvider; /** * Source for helpers used when rendering a {@link HandlebarsPartType} @@ -131,6 +138,40 @@ public static String gtx_edit(Object renderable, Options options) throws NodeExc } } + /** + * Fetch the internals of a JSON content according to a given JsonPath. + * + * @param renderable JSON string or object + * @param jsonPathString a jsonpath + * @param options + * @return + */ + @HelperFunction("gtx_json_path") + public static Object jsonPath(Object renderable, String jsonPathString, Options options) { + if (renderable instanceof ResolvableMapWrapper mw) { + renderable = mw.getWrapped(); + } + if (renderable instanceof Value v) { + renderable = v.getValueText(); + } else if (renderable instanceof JsonNode json) { + renderable = json.toString(); + } + JsonPath jsonPath = JsonPath.compile(jsonPathString); + ParseContext parseContext = JsonPath.using(new JacksonJsonProvider()); + Object parsed = parseContext.parse(Objects.toString(renderable)).read(jsonPath); + // TODO make consistent over any number of results, including 0? + if (parsed instanceof List list) { + if (list.size() == 1) { + return list.get(0); + } else if (list.size() > 1) { + return parsed; + } else { + return null; + } + } + return parsed; + } + /** * Sort helper * @param objects objects to be sorted diff --git a/cms-core/src/main/java/com/gentics/contentnode/object/parttype/handlebars/JsonContentValueResolver.java b/cms-core/src/main/java/com/gentics/contentnode/object/parttype/handlebars/JsonContentValueResolver.java new file mode 100644 index 0000000000..45f9b60315 --- /dev/null +++ b/cms-core/src/main/java/com/gentics/contentnode/object/parttype/handlebars/JsonContentValueResolver.java @@ -0,0 +1,58 @@ +package com.gentics.contentnode.object.parttype.handlebars; + +import java.util.Collections; +import java.util.Map.Entry; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.apache.commons.collections4.keyvalue.UnmodifiableMapEntry; + +import com.github.jknack.handlebars.ValueResolver; + +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; + +/** + * A singleton of {@link ValueResolver} of {@link JsonObject} or {@link JsonArray}. + */ +public enum JsonContentValueResolver implements ValueResolver { + + INSTANCE; + + @Override + public Object resolve(Object context, String name) { + if (context instanceof JsonArray ja) { + if ("length".equals(name)) { + return ja.size(); + } else { + try { + int index = Integer.parseInt(name); + return ja.getValue(index); + } catch (NumberFormatException e) { + } + } + } else if (context instanceof JsonObject jo) { + return jo.getValue(name); + } + return UNRESOLVED; + } + + @Override + public Object resolve(Object context) { + if (context instanceof JsonObject || context instanceof JsonArray) { + return context; + } + return UNRESOLVED; + } + + @Override + public Set> propertySet(Object context) { + if (context instanceof JsonArray ja) { + return IntStream.range(0, ja.size()).mapToObj(i -> new UnmodifiableMapEntry<>(Integer.toString(i), ja.getValue(i))).collect(Collectors.toSet()); + } else if (context instanceof JsonObject jo) { + return jo.getMap().entrySet(); + } + return Collections.emptySet(); + } +} diff --git a/cms-core/src/main/java/com/gentics/contentnode/publish/cr/MeshRoleRenderer.java b/cms-core/src/main/java/com/gentics/contentnode/publish/cr/MeshRoleRenderer.java index 60888b4ae5..cde671a3b4 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/publish/cr/MeshRoleRenderer.java +++ b/cms-core/src/main/java/com/gentics/contentnode/publish/cr/MeshRoleRenderer.java @@ -1,17 +1,26 @@ package com.gentics.contentnode.publish.cr; +import java.util.ArrayList; +import java.util.List; import java.util.stream.Collectors; +import java.util.stream.IntStream; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.gentics.api.lib.exception.NodeException; import com.gentics.contentnode.etc.BiFunction; import com.gentics.contentnode.object.Tag; import com.gentics.contentnode.object.Value; import com.gentics.contentnode.object.ValueList; +import com.gentics.contentnode.object.parttype.JSONPartType; import com.gentics.contentnode.object.parttype.PartType; import com.gentics.contentnode.object.parttype.SelectPartType; import com.gentics.contentnode.publish.CnMapPublisher; import com.gentics.contentnode.render.RenderResult; import com.gentics.contentnode.render.RenderType; +import com.gentics.contentnode.rest.util.MiscUtils; import com.gentics.lib.content.GenticsContentAttribute; /** @@ -74,12 +83,19 @@ public Object getRenderedTransformedValue(RenderType renderType, RenderResult re ValueList values = tag.getValues(); for (Value v : values) { PartType partType = v.getPartType(); - if (partType instanceof SelectPartType) { - return ((SelectPartType) partType).getSelection().stream().map(e -> e.getValue()).collect(Collectors.toList()); + if (partType instanceof SelectPartType selectPt) { + return selectPt.getSelection().stream().map(e -> e.getValue()).collect(Collectors.toList()); + } + if (partType instanceof JSONPartType jsonPt) { + return tryTransformJSON(jsonPt.getText()); } } } - return TagmapEntryRenderer.super.getRenderedTransformedValue(renderType, renderResult, linkTransformer); + Object transformedValue = TagmapEntryRenderer.super.getRenderedTransformedValue(renderType, renderResult, linkTransformer); + if (transformedValue instanceof String stringTransformedValue) { + transformedValue = tryTransformJSON(stringTransformedValue); + } + return transformedValue; } @Override @@ -87,4 +103,29 @@ public boolean canSkip() { // the MeshRoleRenderer must not be skipped. Otherwise the MeshPublisher would set the default permissions. return false; } + + /** + * Try getting a JSON content out of string value. + * + * @param string + * @return + */ + protected Object tryTransformJSON(String string) { + try { + JsonNode json = MiscUtils.newObjectMapper().readTree(string); + if (json != null) { + if (json.isArray()) { + ArrayNode jsonArray = (ArrayNode) json; + return IntStream.range(0, jsonArray.size()).mapToObj(jsonArray::get).map(JsonNode::asText).collect(Collectors.toList()); + } else if (json.isObject()) { + ObjectNode jsonObject = (ObjectNode) json; + List values = new ArrayList(); + jsonObject.fieldNames().forEachRemaining(values::add); + return values; + } + } + } catch (JsonProcessingException ignored) { + } + return string; + } } diff --git a/cms-core/src/main/java/com/gentics/contentnode/publish/cr/TagmapEntryRenderer.java b/cms-core/src/main/java/com/gentics/contentnode/publish/cr/TagmapEntryRenderer.java index b80f31813b..cfdee8d771 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/publish/cr/TagmapEntryRenderer.java +++ b/cms-core/src/main/java/com/gentics/contentnode/publish/cr/TagmapEntryRenderer.java @@ -5,8 +5,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.IntStream; import com.gentics.api.lib.etc.ObjectTransformer; import com.gentics.api.lib.exception.NodeException; @@ -25,6 +27,9 @@ import com.gentics.contentnode.render.RendererFactory; import com.gentics.contentnode.render.TemplateRenderer; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; + /** * Interface for a published tagmap entry */ @@ -87,7 +92,11 @@ default boolean isLinkAttribute() { */ default Object transformValue(Object value, BiFunction linkTransformer) { // when this is a mulitvalue, every single value must be transformed - if (value instanceof Collection) { + if (value instanceof JsonArray ja) { + return IntStream.range(0, ja.size()).mapToObj(ja::getValue).map(Objects::toString).collect(Collectors.toList()); + } else if (value instanceof JsonObject jo) { + return new ArrayList<>(jo.getMap().entrySet()); + } else if (value instanceof Collection) { return ((Collection) value).stream().map(v -> transformValue(v, linkTransformer)).collect(Collectors.toList()); } else if (value instanceof Object[]) { return Arrays.asList((Object[]) value).stream().map(v -> transformValue(v, linkTransformer)).collect(Collectors.toList()); diff --git a/cms-core/src/main/java/com/gentics/contentnode/publish/mesh/MeshMicronodePublisher.java b/cms-core/src/main/java/com/gentics/contentnode/publish/mesh/MeshMicronodePublisher.java index 0189a2e77b..4b041d882f 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/publish/mesh/MeshMicronodePublisher.java +++ b/cms-core/src/main/java/com/gentics/contentnode/publish/mesh/MeshMicronodePublisher.java @@ -62,6 +62,7 @@ import com.gentics.mesh.core.rest.microschema.impl.MicroschemaUpdateRequest; import com.gentics.mesh.core.rest.node.field.Field; import com.gentics.mesh.core.rest.node.field.impl.BooleanFieldImpl; +import com.gentics.mesh.core.rest.node.field.impl.JsonFieldImpl; import com.gentics.mesh.core.rest.node.field.impl.NodeFieldImpl; import com.gentics.mesh.core.rest.node.field.impl.NumberFieldImpl; import com.gentics.mesh.core.rest.node.field.impl.StringFieldImpl; @@ -70,6 +71,7 @@ import com.gentics.mesh.core.rest.schema.MicroschemaModel; import com.gentics.mesh.core.rest.schema.MicroschemaReference; import com.gentics.mesh.core.rest.schema.impl.BooleanFieldSchemaImpl; +import com.gentics.mesh.core.rest.schema.impl.JsonFieldSchemaImpl; import com.gentics.mesh.core.rest.schema.impl.ListFieldSchemaImpl; import com.gentics.mesh.core.rest.schema.impl.MicroschemaReferenceImpl; import com.gentics.mesh.core.rest.schema.impl.NodeFieldSchemaImpl; @@ -666,6 +668,9 @@ protected Observable toFieldSchema(Part part) throws NodeException case 40: // Node fieldSchemas.add(new NumberFieldSchemaImpl()); break; + case 44: // Node + fieldSchemas.add(new JsonFieldSchemaImpl()); + break; } if (fieldSchemas.isEmpty()) { @@ -850,6 +855,12 @@ protected Observable> toField(int nodeId, Value value, Opera fieldList.add(ImmutablePair.of(value.getPart().getKeyname(), new NumberFieldImpl().setNumber(node.getId()))); } break; + case 44: + fieldList.add(ImmutablePair.of(value.getPart().getKeyname(), new JsonFieldImpl().setJson( + StringUtils.isNotBlank(value.getValueText()) + ? JsonUtil.toJsonNode(value.getValueText(), true) + : null))); + break; } if (fieldList.isEmpty()) { diff --git a/cms-core/src/main/java/com/gentics/contentnode/publish/mesh/MeshPublisher.java b/cms-core/src/main/java/com/gentics/contentnode/publish/mesh/MeshPublisher.java index 3a749ff83d..531bf91e8b 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/publish/mesh/MeshPublisher.java +++ b/cms-core/src/main/java/com/gentics/contentnode/publish/mesh/MeshPublisher.java @@ -2,7 +2,6 @@ import static com.gentics.contentnode.publish.mesh.MeshPublishUtils.ifNotFound; import static com.gentics.contentnode.publish.mesh.MeshPublishUtils.isRecoverable; -import static com.gentics.mesh.util.URIUtils.encodeSegment; import java.io.IOException; import java.io.InputStream; @@ -15,7 +14,6 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.Deque; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -26,7 +24,6 @@ import java.util.Optional; import java.util.Queue; import java.util.Set; -import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -36,6 +33,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; +import java.util.stream.IntStream; import java.util.stream.Stream; import org.apache.commons.collections.CollectionUtils; @@ -49,7 +47,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.gentics.api.lib.etc.ObjectTransformer; import com.gentics.api.lib.exception.NodeException; import com.gentics.api.lib.i18n.I18nString; @@ -57,7 +54,6 @@ import com.gentics.contentnode.etc.BiConsumer; import com.gentics.contentnode.etc.BiFunction; import com.gentics.contentnode.etc.Consumer; -import com.gentics.contentnode.etc.ContentNodeHelper; import com.gentics.contentnode.etc.Feature; import com.gentics.contentnode.etc.LangTrx; import com.gentics.contentnode.etc.NodePreferences; @@ -65,7 +61,6 @@ import com.gentics.contentnode.etc.ServiceLoaderUtil; import com.gentics.contentnode.events.Dependency; import com.gentics.contentnode.exception.RestMappedException; -import com.gentics.contentnode.factory.AnyChannelTrx; import com.gentics.contentnode.factory.ChannelTrx; import com.gentics.contentnode.factory.ContentLanguageTrx; import com.gentics.contentnode.factory.HandleDependenciesTrx; @@ -93,7 +88,6 @@ import com.gentics.contentnode.object.ContentTag; import com.gentics.contentnode.object.Datasource; import com.gentics.contentnode.object.DatasourceEntry; -import com.gentics.contentnode.object.Disinheritable; import com.gentics.contentnode.object.DummyObject; import com.gentics.contentnode.object.File; import com.gentics.contentnode.object.Folder; @@ -153,7 +147,6 @@ import com.gentics.mesh.core.rest.branch.info.BranchSchemaInfo; import com.gentics.mesh.core.rest.common.FieldTypes; import com.gentics.mesh.core.rest.common.ObjectPermissionGrantRequest; -import com.gentics.mesh.core.rest.common.RestModel; import com.gentics.mesh.core.rest.job.JobListResponse; import com.gentics.mesh.core.rest.job.JobStatus; import com.gentics.mesh.core.rest.micronode.MicronodeResponse; @@ -173,12 +166,14 @@ import com.gentics.mesh.core.rest.node.field.impl.BinaryFieldImpl; import com.gentics.mesh.core.rest.node.field.impl.BooleanFieldImpl; import com.gentics.mesh.core.rest.node.field.impl.DateFieldImpl; +import com.gentics.mesh.core.rest.node.field.impl.JsonFieldImpl; import com.gentics.mesh.core.rest.node.field.impl.NodeFieldImpl; import com.gentics.mesh.core.rest.node.field.impl.NumberFieldImpl; import com.gentics.mesh.core.rest.node.field.impl.StringFieldImpl; import com.gentics.mesh.core.rest.node.field.list.FieldList; import com.gentics.mesh.core.rest.node.field.list.impl.BooleanFieldListImpl; import com.gentics.mesh.core.rest.node.field.list.impl.DateFieldListImpl; +import com.gentics.mesh.core.rest.node.field.list.impl.JsonFieldListImpl; import com.gentics.mesh.core.rest.node.field.list.impl.MicronodeFieldListImpl; import com.gentics.mesh.core.rest.node.field.list.impl.NodeFieldListImpl; import com.gentics.mesh.core.rest.node.field.list.impl.NodeFieldListItemImpl; @@ -198,6 +193,7 @@ import com.gentics.mesh.core.rest.schema.impl.BinaryFieldSchemaImpl; import com.gentics.mesh.core.rest.schema.impl.BooleanFieldSchemaImpl; import com.gentics.mesh.core.rest.schema.impl.DateFieldSchemaImpl; +import com.gentics.mesh.core.rest.schema.impl.JsonFieldSchemaImpl; import com.gentics.mesh.core.rest.schema.impl.ListFieldSchemaImpl; import com.gentics.mesh.core.rest.schema.impl.MicronodeFieldSchemaImpl; import com.gentics.mesh.core.rest.schema.impl.NodeFieldSchemaImpl; @@ -3205,6 +3201,27 @@ protected void addFieldSchema(FieldSchemaContainer container, TagmapEntry entry) fieldSchema = new DateFieldSchemaImpl(); } break; + case json: + if (entry.isMultivalue()) { + fieldSchema = new ListFieldSchemaImpl().setListType(FieldTypes.JSON.toString()); + } else { + JsonNode[] allowedSchemas = null; + if (StringUtils.isEmpty(entry.getJSONSchemaFilter())) { + allowedSchemas = new JsonNode[0]; + } else { + JsonNode jsonSchemaContent = JsonUtil.toJsonNode(entry.getJSONSchemaFilter(), true); + if (jsonSchemaContent != null) { + if (jsonSchemaContent.isArray()) { + ArrayNode jsonSchemas = (ArrayNode) jsonSchemaContent; + allowedSchemas = IntStream.range(0, jsonSchemas.size()).mapToObj(jsonSchemas::get).toArray(size -> new JsonNode[size]); + } else { + allowedSchemas = new JsonNode[] { jsonSchemaContent }; + } + } + } + fieldSchema = new JsonFieldSchemaImpl().setAllowedSchemas(allowedSchemas); + } + break; case bool: if (entry.isMultivalue()) { fieldSchema = new ListFieldSchemaImpl().setListType(FieldTypes.BOOLEAN.toString()); @@ -3701,6 +3718,22 @@ public void handleRenderedEntries(boolean preview, int nodeId, int objectId, int } break; } + case json: + { + if (entry.isMultivalue()) { + FieldList field = new JsonFieldListImpl(); + fields.put(entry.getMapname(), field); + for (Object o : ObjectTransformer.getCollection(value, Collections.emptyList())) { + String jsonString = ObjectTransformer.getString(o, null); + if (jsonString != null) { + field.add(JsonUtil.toJsonNode(jsonString, true)); + } + } + } else { + fields.put(entry.getMapname(), new JsonFieldImpl().setJson(JsonUtil.toJsonNode(ObjectTransformer.getString(value, null), true))); + } + break; + } case date: { if (entry.isMultivalue()) { diff --git a/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/FileResourceImpl.java b/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/FileResourceImpl.java index 8d173dc68e..e1d4185ab6 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/FileResourceImpl.java +++ b/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/FileResourceImpl.java @@ -52,6 +52,7 @@ import com.gentics.contentnode.factory.Wastebin; import com.gentics.contentnode.factory.WastebinFilter; import com.gentics.contentnode.factory.object.FileFactory; +import com.gentics.contentnode.factory.object.ObjectModificationException; import com.gentics.contentnode.i18n.I18NHelper; import com.gentics.contentnode.msg.NodeMessage; import com.gentics.contentnode.object.ContentFile; @@ -1674,6 +1675,9 @@ public GenericResponse save(@PathParam("id") Integer id, FileSaveRequest request } catch (InsufficientPrivilegesException e) { InsufficientPrivilegesMapper.log(e); return new GenericResponse(new Message(Type.CRITICAL, e.getLocalizedMessage()), new ResponseInfo(ResponseCode.PERMISSION, e.getMessage())); + } catch (ObjectModificationException e) { + return new GenericResponse(new Message(Type.CRITICAL, e.getLocalizedMessage()), + new ResponseInfo(ResponseCode.INVALIDDATA, e.getMessage(), e.getProperty())); } catch (NodeException e) { logger.error("Error while saving file " + id, e); I18nString message = new CNI18nString("rest.general.error"); @@ -1717,6 +1721,9 @@ public GenericResponse save(@PathParam("id") Integer id, MultiPart multiPart) { return new GenericResponse(new Message(Type.CRITICAL, e.getLocalizedMessage()), new ResponseInfo(ResponseCode.PERMISSION, e.getMessage())); } catch (EntityNotFoundException e) { return new GenericResponse(new Message(Type.CRITICAL, e.getLocalizedMessage()), new ResponseInfo(ResponseCode.NOTFOUND, e.getMessage())); + } catch (ObjectModificationException e) { + return new GenericResponse(new Message(Type.CRITICAL, e.getLocalizedMessage()), + new ResponseInfo(ResponseCode.INVALIDDATA, e.getMessage(), e.getProperty())); } catch (NodeException e) { logger.error("Error while saving file " + id, e); I18nString message = new CNI18nString("rest.general.error"); diff --git a/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/FolderResourceImpl.java b/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/FolderResourceImpl.java index dc77de5f8c..d93d82c70d 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/FolderResourceImpl.java +++ b/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/FolderResourceImpl.java @@ -27,17 +27,6 @@ import java.util.function.Function; import java.util.stream.Collectors; -import jakarta.ws.rs.BeanParam; -import jakarta.ws.rs.DefaultValue; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.WebApplicationException; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Response.Status; - import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.MapUtils; import org.apache.commons.lang3.BooleanUtils; @@ -82,7 +71,6 @@ import com.gentics.contentnode.object.ImageFile; import com.gentics.contentnode.object.Node; import com.gentics.contentnode.object.NodeObject; -import com.gentics.contentnode.object.NodeObject.GlobalId; import com.gentics.contentnode.object.ObjectTag; import com.gentics.contentnode.object.ObjectTagDefinition; import com.gentics.contentnode.object.Page; @@ -168,6 +156,17 @@ import com.gentics.lib.etc.StringUtils; import com.gentics.lib.i18n.CNI18nString; +import jakarta.ws.rs.BeanParam; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; + /** * Resource for loading and manipulating folders in GCN * @author norbert diff --git a/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/ImageResourceImpl.java b/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/ImageResourceImpl.java index 830b7bd235..92cf87e80f 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/ImageResourceImpl.java +++ b/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/ImageResourceImpl.java @@ -62,6 +62,7 @@ import com.gentics.contentnode.factory.Wastebin; import com.gentics.contentnode.factory.WastebinFilter; import com.gentics.contentnode.factory.object.FileFactory; +import com.gentics.contentnode.factory.object.ObjectModificationException; import com.gentics.contentnode.i18n.I18NHelper; import com.gentics.contentnode.msg.NodeMessage; import com.gentics.contentnode.object.ContentFile; @@ -741,6 +742,9 @@ public GenericResponse save(@PathParam("id") Integer id, ImageSaveRequest reques } catch (InsufficientPrivilegesException e) { InsufficientPrivilegesMapper.log(e); return new GenericResponse(new Message(Type.CRITICAL, e.getLocalizedMessage()), new ResponseInfo(ResponseCode.PERMISSION, e.getMessage())); + } catch (ObjectModificationException e) { + return new GenericResponse(new Message(Type.CRITICAL, e.getLocalizedMessage()), + new ResponseInfo(ResponseCode.INVALIDDATA, e.getMessage(), e.getProperty())); } catch (NodeException e) { logger.error("Error while saving image " + id, e); I18nString message = new CNI18nString("rest.general.error"); diff --git a/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/PageResourceImpl.java b/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/PageResourceImpl.java index 951630ec38..661bf3ecea 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/PageResourceImpl.java +++ b/cms-core/src/main/java/com/gentics/contentnode/rest/resource/impl/PageResourceImpl.java @@ -67,6 +67,7 @@ import com.gentics.contentnode.factory.TransactionManager.ReturnValueExecutable; import com.gentics.contentnode.factory.Wastebin; import com.gentics.contentnode.factory.WastebinFilter; +import com.gentics.contentnode.factory.object.ObjectModificationException; import com.gentics.contentnode.factory.object.PageFactory; import com.gentics.contentnode.factory.url.DynamicUrlFactory; import com.gentics.contentnode.factory.url.StaticUrlFactory; @@ -1248,6 +1249,9 @@ public GenericResponse save(@PathParam("id") String id, PageSaveRequest request) } catch (ReadOnlyException e) { return new GenericResponse(new Message(Type.CRITICAL, e.getLocalizedMessage()), new ResponseInfo(ResponseCode.PERMISSION, e.getMessage())); + } catch (ObjectModificationException e) { + return new GenericResponse(new Message(Type.CRITICAL, e.getLocalizedMessage()), + new ResponseInfo(ResponseCode.INVALIDDATA, e.getMessage(), e.getProperty())); } catch (NodeException e) { try { t.rollback(false); diff --git a/cms-core/src/main/java/com/gentics/contentnode/rest/util/PageValidator.java b/cms-core/src/main/java/com/gentics/contentnode/rest/util/PageValidator.java index e2b453e1f7..5633414347 100644 --- a/cms-core/src/main/java/com/gentics/contentnode/rest/util/PageValidator.java +++ b/cms-core/src/main/java/com/gentics/contentnode/rest/util/PageValidator.java @@ -128,7 +128,7 @@ protected void processTag(com.gentics.contentnode.rest.model.Tag tag, Transactio } String stringValue = tagProperty.getStringValue(); - if (tagProperty.getStringValue() == null) { + if (stringValue == null) { continue; } diff --git a/cms-core/src/main/resources/changelog/content.CHANGELOG b/cms-core/src/main/resources/changelog/content.CHANGELOG index a427d9dedb..6252a9a301 100644 --- a/cms-core/src/main/resources/changelog/content.CHANGELOG +++ b/cms-core/src/main/resources/changelog/content.CHANGELOG @@ -824,3 +824,6 @@ 2026-03-27 CH-1741 NOP SQL ALTER TABLE node ADD COLUMN default_form_folder_id INT NOT NULL default 0; 2026-05-08 CH-1745 NOP SQL ALTER TABLE form ADD external_hash VARCHAR(255) after external_id; ## add column for external hash 2026-05-21 CH-1747 NOP SQL CREATE TABLE form_translations (formtype VARCHAR(255) NOT NULL, translations MEDIUMTEXT NOT NULL, PRIMARY KEY (formtype)); ## Add table for form translations +2026-04-21 CH-1742 SP SQL INSERT INTO type (id, name, description, auto, javaclass) VALUES (44, 'JSON', 'JSON content', 0, 'com.gentics.contentnode.object.parttype.JSONPartType'); ## New JSON part type +2026-04-28 CH-1743 SP SQL ALTER TABLE tagmap ADD COLUMN json_schema_filter MEDIUMTEXT AFTER micronode_filter; +2026-04-28 CH-1744 SP SQL ALTER TABLE cr_fragment_entry ADD COLUMN json_schema_filter MEDIUMTEXT AFTER micronode_filter; diff --git a/cms-core/src/main/resources/contentnode_de_DE.properties b/cms-core/src/main/resources/contentnode_de_DE.properties index 092403de94..f98a93c55a 100644 --- a/cms-core/src/main/resources/contentnode_de_DE.properties +++ b/cms-core/src/main/resources/contentnode_de_DE.properties @@ -383,6 +383,7 @@ category=Kategorie syntax=Syntax time=Uhrzeit the_publishing_directory=Aufgrund doppelter Dateinamen konnte das Publizierungsverzeichnis nicht ge\u00E4ndert werden. +json_validation_failed=Der angegebene JSON-Inhalt konnte nicht erfolgreich validiert werden. a\ page\ with\ this\ filename\ already\ exists=Es existiert bereits eine Seite mit diesem Dateinamen\:
{0} tagmap\ entry=Tagmap Eintrag add\ entry=Eintrag hinzuf\u00FCgen diff --git a/cms-core/src/main/resources/contentnode_en_EN.properties b/cms-core/src/main/resources/contentnode_en_EN.properties index c57af0be7d..c7a5d5b789 100644 --- a/cms-core/src/main/resources/contentnode_en_EN.properties +++ b/cms-core/src/main/resources/contentnode_en_EN.properties @@ -370,6 +370,7 @@ syntax=Syntax marks\ this\ object\ property\ as\ being\ inherited\ from\ folders\ and\ templates\ to\ pages\ and\ files.=Marks this Object Property as being inherited from Folders and Templates to Pages and Files. time=Time the_publishing_directory=The publishing directory couldn't be changed as identical filenames exist in the new directory. +json_validation_failed=The provided JSON content has failed the validation for its tag. a\ page\ with\ this\ filename\ already\ exists=A page with this filename already exists\:
{0} tagmap\ entry=Tagmap Entry channelsync.push.readonly=Some objects could not be pushed to the master either because the permissions were insufficient or the objects were locked by other users\: diff --git a/cms-core/src/test/java/com/gentics/contentnode/tests/devtools/CrFragmentSyncTest.java b/cms-core/src/test/java/com/gentics/contentnode/tests/devtools/CrFragmentSyncTest.java index ac42ad3f49..a522eeb6e9 100644 --- a/cms-core/src/test/java/com/gentics/contentnode/tests/devtools/CrFragmentSyncTest.java +++ b/cms-core/src/test/java/com/gentics/contentnode/tests/devtools/CrFragmentSyncTest.java @@ -78,6 +78,7 @@ public void testEntryMicronodeFilter() throws NodeException { fr.setMapname("target"); fr.setTagname("source"); fr.setMicronodeFilter("micronodefilter bla"); + fr.setJSONSchemaFilter("{\"whatever\":\"whoever\"}"); fr.setNoIndex(noIndex); }, false)); })); @@ -98,7 +99,8 @@ public void testEntryMicronodeFilter() throws NodeException { .hasFieldOrPropertyWithValue("mapname", "target") .hasFieldOrPropertyWithValue("tagname", "source") .hasFieldOrPropertyWithValue("noIndex", noIndex) - .hasFieldOrPropertyWithValue("micronodeFilter", "micronodefilter bla"); + .hasFieldOrPropertyWithValue("micronodeFilter", "micronodefilter bla") + .hasFieldOrPropertyWithValue("jsonSchemaFilter", "{\"whatever\":\"whoever\"}"); }); } } diff --git a/cms-core/src/test/java/com/gentics/contentnode/tests/parttype/handlebars/HandlebarsPartTypeResolvingTest.java b/cms-core/src/test/java/com/gentics/contentnode/tests/parttype/handlebars/HandlebarsPartTypeResolvingTest.java index 92f8490e90..c32cb014fe 100644 --- a/cms-core/src/test/java/com/gentics/contentnode/tests/parttype/handlebars/HandlebarsPartTypeResolvingTest.java +++ b/cms-core/src/test/java/com/gentics/contentnode/tests/parttype/handlebars/HandlebarsPartTypeResolvingTest.java @@ -62,12 +62,15 @@ */ @RunWith(value = Parameterized.class) public class HandlebarsPartTypeResolvingTest { + + protected static final String HANDLEBARS_CONTENT_ITERATE_KEYS = "{{#each cms.tag.parts.otherpart}}{{@key}}{{#unless @last}}, {{/unless}}{{/each}}"; + public final static String TAG_NAME = "testtag"; @ClassRule public static DBTestContext testContext = new DBTestContext(); - private static Node node; + protected static Node node; @Parameters(name = "{index}: template {0}") public static Collection data() { @@ -153,7 +156,7 @@ public void setup() throws NodeException { }).build(); construct = update(construct, c -> { - getPartType(HandlebarsPartType.class, c, "hb").setText("{{#each cms.tag.parts.otherpart}}{{@key}}{{#unless @last}}, {{/unless}}{{/each}}"); + getPartType(HandlebarsPartType.class, c, "hb").setText(HANDLEBARS_CONTENT_ITERATE_KEYS); }).build(); template = create(Template.class, t -> { diff --git a/cms-core/src/test/java/com/gentics/contentnode/tests/parttype/json/JSONPartTypeResolvingTest.java b/cms-core/src/test/java/com/gentics/contentnode/tests/parttype/json/JSONPartTypeResolvingTest.java new file mode 100644 index 0000000000..bca8627919 --- /dev/null +++ b/cms-core/src/test/java/com/gentics/contentnode/tests/parttype/json/JSONPartTypeResolvingTest.java @@ -0,0 +1,130 @@ +package com.gentics.contentnode.tests.parttype.json; + +import static com.gentics.contentnode.tests.utils.Builder.create; +import static com.gentics.contentnode.tests.utils.Builder.update; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.getPartType; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.getPartTypeId; + +import java.util.Arrays; +import java.util.Collection; + +import org.junit.Before; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import com.gentics.api.lib.exception.NodeException; +import com.gentics.contentnode.object.Construct; +import com.gentics.contentnode.object.Page; +import com.gentics.contentnode.object.Part; +import com.gentics.contentnode.object.Template; +import com.gentics.contentnode.object.TemplateTag; +import com.gentics.contentnode.object.Value; +import com.gentics.contentnode.object.parttype.JSONPartType; +import com.gentics.contentnode.object.parttype.handlebars.HandlebarsPartType; +import com.gentics.contentnode.tests.parttype.handlebars.HandlebarsPartTypeResolvingTest; + +/** + * Test cases for resolving attributes of other part types + */ +@RunWith(value = Parameterized.class) +public class JSONPartTypeResolvingTest extends HandlebarsPartTypeResolvingTest { + + protected static final String JSON_INPUT = "{\"whatever\":{\"whoever\": \"wherever\"}, \"whoever\":[{\"whatever\": \"wherever\"}], \"wherever\": [\"what\", \"who\", \"where\"]}"; + + @Parameter(2) + public String defaultValue; + + @Parameter(3) + public String handlebars; + + @Before + @Override + public void setup() throws NodeException { + construct = create(Construct.class, c -> { + c.setAutoEnable(true); + c.setKeyword("construct"); + c.setName("construct", 1); + + c.getParts().add(create(Part.class, p -> { + p.setPartTypeId(getPartTypeId(HandlebarsPartType.class)); + p.setEditable(0); + p.setHidden(false); + p.setKeyname("hb"); + p.setName("Handlebars", 1); + p.setDefaultValue(create(Value.class, v -> {}).doNotSave().build()); + }).doNotSave().build()); + + c.getParts().add(create(Part.class, p -> { + p.setPartTypeId(getPartTypeId(testedClass)); + p.setEditable(0); + p.setHidden(true); + p.setKeyname("otherpart"); + p.setName("Other Part", 1); + p.setDefaultValue(create(Value.class, v -> v.setValueText(defaultValue)).doNotSave().build()); + }).doNotSave().build()); + }).build(); + + construct = update(construct, c -> { + getPartType(HandlebarsPartType.class, c, "hb").setText(handlebars); + }).build(); + + template = create(Template.class, t -> { + t.setFolderId(node.getFolder().getId()); + t.setMlId(1); + t.setName("Template"); + t.setSource(String.format("", TAG_NAME)); + + t.getTemplateTags().put(TAG_NAME, create(TemplateTag.class, tag -> { + tag.setConstructId(construct.getId()); + tag.setEnabled(true); + tag.setName(TAG_NAME); + tag.setPublic(false); + }).doNotSave().build()); + }).unlock().build(); + + page = create(Page.class, p -> { + p.setFolder(node, node.getFolder()); + p.setTemplateId(template.getId()); + p.setName("Page"); + }).unlock().build(); + } + + @Parameters(name = "{index}: keys {1} source {2} content {3}") + public static Collection data() { + Collection data = Arrays.asList( + new Object[] { JSONPartType.class, "whatever, whoever", + "{\"whatever\":\"wherever\", \"whoever\":[\"me\", \"notme\"]}", + HANDLEBARS_CONTENT_ITERATE_KEYS }, + new Object[] { JSONPartType.class, "0, 1", + "[\"me\", \"notme\"]", + HANDLEBARS_CONTENT_ITERATE_KEYS }, + new Object[] { JSONPartType.class, "", + "{}", + HANDLEBARS_CONTENT_ITERATE_KEYS }, + new Object[] { JSONPartType.class, "", + "[]", + HANDLEBARS_CONTENT_ITERATE_KEYS }, + new Object[] { JSONPartType.class, "", + "", + HANDLEBARS_CONTENT_ITERATE_KEYS }, + new Object[] { JSONPartType.class, "wherever, wherever, who", + JSON_INPUT, + "{{cms.tag.parts.otherpart.whatever.whoever}}, {{cms.tag.parts.otherpart.whoever.0.whatever}}, {{cms.tag.parts.otherpart.wherever.1}}" }, + new Object[] { JSONPartType.class, "wherever, wherever, who", + JSON_INPUT, + "{{cms.tag.parts.otherpart.whatever.whoever}}, {{cms.tag.parts.otherpart.whoever.[0].whatever}}, {{cms.tag.parts.otherpart.wherever.[1]}}" }, + new Object[] { JSONPartType.class, "1, 3", + JSON_INPUT, + "{{cms.tag.parts.otherpart.whoever.length}}, {{cms.tag.parts.otherpart.wherever.length}}" }, + new Object[] { JSONPartType.class, "whoever:whoever=wherever , :{\"whatever\":\"wherever\"} , 0:what 1:who 2:where ", + JSON_INPUT, + "{{#each cms.tag.parts.otherpart.whatever}}{{@key}}:{{{this}}} {{/each}}, {{#each cms.tag.parts.otherpart.whoever}}{{@key}}:{{{this}}} {{/each}}, {{#each cms.tag.parts.otherpart.wherever}}{{@key}}:{{{this}}} {{/each}}" }, + new Object[] { JSONPartType.class, "wherever, wherever, who", + JSON_INPUT, + "{{gtx_json_path cms.tag.parts.otherpart \"$[*]['whoever']\"}}, {{gtx_json_path cms.tag.parts.otherpart \"$.whoever[0].whatever\"}}, {{gtx_json_path cms.tag.parts.otherpart \"$.wherever[1]\"}}" } + ); + return data; + } +} diff --git a/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/AbstractMeshPublishRoleTest.java b/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/AbstractMeshPublishRoleTest.java new file mode 100644 index 0000000000..98896738a5 --- /dev/null +++ b/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/AbstractMeshPublishRoleTest.java @@ -0,0 +1,1001 @@ +package com.gentics.contentnode.tests.publish.mesh; + +import static com.gentics.contentnode.tests.utils.ContentNodeMeshCRUtils.assertMeshProject; +import static com.gentics.contentnode.tests.utils.ContentNodeMeshCRUtils.cleanMesh; +import static com.gentics.contentnode.tests.utils.ContentNodeMeshCRUtils.crResource; +import static com.gentics.contentnode.tests.utils.ContentNodeMeshCRUtils.createMeshCR; +import static com.gentics.contentnode.tests.utils.ContentNodeMeshCRUtils.isDefaultBranch; +import static com.gentics.contentnode.tests.utils.ContentNodeRESTUtils.assertResponseOK; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.create; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createFile; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createFolder; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createNode; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createObjectPropertyDefinition; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createPage; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createTemplate; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.getPartType; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.getPartTypeId; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.update; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.fail; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; + +import com.gentics.api.lib.exception.NodeException; +import com.gentics.contentnode.etc.Function; +import com.gentics.contentnode.exception.RestMappedException; +import com.gentics.contentnode.factory.Trx; +import com.gentics.contentnode.object.Construct; +import com.gentics.contentnode.object.ContentRepository; +import com.gentics.contentnode.object.Datasource; +import com.gentics.contentnode.object.Datasource.SourceType; +import com.gentics.contentnode.object.DatasourceEntry; +import com.gentics.contentnode.object.File; +import com.gentics.contentnode.object.Folder; +import com.gentics.contentnode.object.Node; +import com.gentics.contentnode.object.NodeObject; +import com.gentics.contentnode.object.ObjectTagContainer; +import com.gentics.contentnode.object.ObjectTagDefinition; +import com.gentics.contentnode.object.Page; +import com.gentics.contentnode.object.Part; +import com.gentics.contentnode.object.Template; +import com.gentics.contentnode.object.Value; +import com.gentics.contentnode.object.parttype.MultiSelectPartType; +import com.gentics.contentnode.publish.PublishQueue; +import com.gentics.contentnode.publish.PublishQueue.Action; +import com.gentics.contentnode.publish.mesh.MeshPublisher; +import com.gentics.contentnode.rest.model.ContentRepositoryModel.Status; +import com.gentics.contentnode.rest.model.request.MeshRolesRequest; +import com.gentics.contentnode.rest.model.response.ContentRepositoryResponse; +import com.gentics.contentnode.rest.model.response.MeshRolesResponse; +import com.gentics.contentnode.rest.util.MiscUtils; +import com.gentics.contentnode.tests.utils.ContentNodeRESTUtils; +import com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.PublishTarget; +import com.gentics.contentnode.tests.utils.ExceptionChecker; +import com.gentics.contentnode.tests.utils.Expected; +import com.gentics.contentnode.testutils.DBTestContext; +import com.gentics.contentnode.testutils.mesh.MeshContext; +import com.gentics.contentnode.testutils.mesh.MeshTestRule; +import com.gentics.mesh.core.rest.branch.BranchResponse; +import com.gentics.mesh.core.rest.common.AbstractGenericRestResponse; +import com.gentics.mesh.core.rest.common.ObjectPermissionGrantRequest; +import com.gentics.mesh.core.rest.common.Permission; +import com.gentics.mesh.core.rest.node.NodeResponse; +import com.gentics.mesh.core.rest.project.ProjectResponse; +import com.gentics.mesh.core.rest.role.RoleCreateRequest; +import com.gentics.mesh.core.rest.role.RoleListResponse; +import com.gentics.mesh.core.rest.role.RoleReference; +import com.gentics.mesh.core.rest.role.RoleResponse; +import com.gentics.mesh.parameter.RolePermissionParameters; +import com.gentics.mesh.parameter.client.RolePermissionParametersImpl; +import com.gentics.mesh.rest.client.MeshRequest; + +/** + * Test cases for setting permissions on roles + */ +public abstract class AbstractMeshPublishRoleTest { + /** + * Name of the mesh project + */ + public final static String MESH_PROJECT_NAME = "testproject"; + + /** + * Mesh roles + */ + public final static List ROLES = Arrays.asList("role_a", "role_b", "role_c"); + + protected final static String TPL_OBJECT_TAG_KEYWORD = "vtlroles"; + protected final static String TPL_PART_KEYWORD = "vtl"; + protected final static String TPL_ROLES_FIELD = "roles"; + + @ClassRule + public static DBTestContext context = new DBTestContext(); + + @ClassRule + public static MeshContext mesh = new MeshContext(); + + protected static Node node; + + protected static Integer crId; + + protected static Template template; + + protected static Datasource rolesDs; + + protected static Construct rolesConstruct; + + protected static ObjectTagDefinition rolesProperty; + + @Rule + public MeshTestRule meshTestRule = new MeshTestRule(mesh); + + @Rule + public ExceptionChecker exceptionChecker = new ExceptionChecker(); + + @BeforeClass + public static void setupOnce() throws Exception { + ROLES.forEach(role -> { + mesh.client().createRole(new RoleCreateRequest().setName(role)).blockingAwait(); + }); + node = Trx.supply(() -> createNode("node", "Node", PublishTarget.CONTENTREPOSITORY)); + crId = createMeshCR(mesh, MESH_PROJECT_NAME); + + Trx.operate(() -> update(node, n -> { + n.setContentrepositoryId(crId); + })); + + template = Trx.supply(() -> createTemplate(node.getFolder(), "Template")); + + rolesDs = Trx.supply(() -> create(Datasource.class, ds -> { + ds.setSourceType(SourceType.staticDS); + ds.setName("Mesh Roles"); + })); + + rolesConstruct = Trx.supply(() -> create(Construct.class, construct -> { + construct.setAutoEnable(true); + construct.setKeyword("roles"); + construct.setName("roles", 1); + construct.getNodes().add(node); + + construct.getParts().add(create(Part.class, part -> { + part.setEditable(1); + part.setHidden(false); + part.setKeyname("roles"); + part.setName("roles", 1); + part.setPartTypeId(getPartTypeId(MultiSelectPartType.class)); + part.setDefaultValue(create(Value.class, value -> {}, false)); + part.setInfoInt(rolesDs.getId()); + }, false)); + })); + } + + @Before + public void setup() throws Exception { + cleanMesh(mesh.client()); + + Trx.operate(t -> { + update(t.getObject(ContentRepository.class, crId, true), cr -> { + cr.setPermissionProperty("object.roles"); + cr.setDefaultPermission("role_b"); + }); + }); + + Trx.operate(t -> { + for (Folder folder : node.getFolder().getChildFolders()) { + t.getObject(folder, true).delete(true); + } + }); + + Trx.operate(t -> { + update(rolesDs, ds -> { + ds.getEntries().clear(); + }); + }); + } + + /** + * Test synchronizing all available roles + * @throws Exception + */ + @Test + public void testSyncAll() throws Exception { + ContentRepositoryResponse response = crResource.repair(Integer.toString(crId), 0); + ContentNodeRESTUtils.assertResponseOK(response); + if (response.getContentRepository().getCheckStatus() != Status.ok) { + fail(response.getContentRepository().getCheckResult()); + } + setAllRoles(); + + assertMeshProject(mesh.client(), MESH_PROJECT_NAME); + + rolesDs = Trx.execute(ds -> ds.reload(), rolesDs); + + Trx.operate(() -> { + List expectedRoles = getExpectedEntries(); + List roles = rolesDs.getEntries(); + assertThat(roles).as("Roles Datasource entries").usingElementComparatorOnFields("key", "value").containsExactlyElementsOf(expectedRoles); + }); + } + + /** + * Test synchronizing some of the available roles + * @throws Exception + */ + @Test + public void testSetSomeRoles() throws Exception { + doSetTest("role_a", "role_c"); + } + + /** + * Test Changing set roles + * @throws Exception + */ + @Test + public void testChangeRoles() throws Exception { + doSetTest("role_a", "role_c"); + doSetTest("role_b", "anonymous"); + doSetTest("role_b", "role_c"); + doSetTest(); + } + + /** + * Test setting invalid (unknown) roles + * @throws Exception + */ + @Test + @Expected(ex = RestMappedException.class, message = "Die Rollen [invalid] sind ungültig und können nicht gesetzt werden.") + public void testSetInvalidRoles() throws Exception { + crResource.setRoles(Integer.toString(crId), new MeshRolesRequest().setRoles(Arrays.asList("invalid"))); + } + + /** + * Test getting available roles + * @throws Exception + */ + @Test + public void testGetAvailableRoles() throws Exception { + MeshRolesResponse getResponse = crResource.getAvailableRoles(Integer.toString(crId)); + assertResponseOK(getResponse); + + assertThat(getResponse.getRoles()).as("Available roles").containsOnly("anonymous", "role_a", "role_b", "role_c"); + } + + /** + * Test that permissions on project are set + * @throws Exception + */ + @Test + public void testProjectPermissions() throws Exception { + setAllRoles(); + + Trx.operate(() -> update(node.getFolder(), f -> { + setRoles(f, "anonymous", "role_b"); + })); + + Trx.operate(() -> PublishQueue.dirtObject(node.getFolder(), Action.MODIFY, 0)); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertProjectPermission(MESH_PROJECT_NAME, Permission.READ, "anonymous", "role_b"); + List branches = mesh.client().findBranches(MESH_PROJECT_NAME).blockingGet().getData(); + Optional optionalDefaultBranch = branches.stream().filter(isDefaultBranch()).findFirst(); + assertThat(optionalDefaultBranch).isPresent(); + assertBranchPermission(MESH_PROJECT_NAME, optionalDefaultBranch.get().getUuid(), Permission.READ, "anonymous", "role_b"); + } + + /** + * Test that permissions on project are set, even if not yet created on Mesh. + * @throws Exception + */ + @Test + public void testProjectPermissionsAutoCreate() throws Exception { + try (MeshPublisher mp = new MeshPublisher(MiscUtils.load(ContentRepository.class, crId.toString()), true)) { + Trx.operate(() -> { + update(rolesDs, ds -> { + mp.setRoles(Collections.singleton(rolesDs), Arrays.asList("role_auto")); + }); + update(node.getFolder(), f -> { + setRoles(f, "role_auto"); + }); + }); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertProjectPermission(MESH_PROJECT_NAME, Permission.READ, "role_auto"); + List branches = mesh.client().findBranches(MESH_PROJECT_NAME).blockingGet().getData(); + Optional optionalDefaultBranch = branches.stream().filter(isDefaultBranch()).findFirst(); + assertThat(optionalDefaultBranch).isPresent(); + assertBranchPermission(MESH_PROJECT_NAME, optionalDefaultBranch.get().getUuid(), Permission.READ, "role_auto"); + } + } + /** + * Test permissions on root node + * @throws Exception + */ + @Test + public void testProjectRootNodePermissions() throws Exception { + setAllRoles(); + + Trx.operate(() -> update(node.getFolder(), f -> { + setRoles(f, "anonymous", "role_b"); + })); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + String rootNodeUuid = mesh.client().findProjectByName(MESH_PROJECT_NAME).blockingGet().getRootNode().getUuid(); + assertRoles(param -> mesh.client().findNodeByUuid(MESH_PROJECT_NAME, rootNodeUuid, param), Permission.READ_PUBLISHED, "anonymous", "role_b"); + } + + + /** + * Test permissions on root node when role is not yet created on Mesh. + * @throws Exception + */ + @Test + public void testProjectRootNodePermissionsAutoCreate() throws Exception { + try (MeshPublisher mp = new MeshPublisher(MiscUtils.load(ContentRepository.class, crId.toString()), true)) { + Trx.operate(() -> { + update(rolesDs, ds -> { + mp.setRoles(Collections.singleton(rolesDs), Arrays.asList("role_auto")); + }); + update(node.getFolder(), f -> { + setRoles(f, "role_auto"); + }); + }); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + String rootNodeUuid = mesh.client().findProjectByName(MESH_PROJECT_NAME).blockingGet().getRootNode().getUuid(); + assertRoles(param -> mesh.client().findNodeByUuid(MESH_PROJECT_NAME, rootNodeUuid, param), Permission.READ_PUBLISHED, "role_auto"); + } + } + + /** + * Test permissions on folder + * @throws Exception + */ + @Test + public void testFolderPermissions() throws Exception { + setAllRoles(); + + Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { + setRoles(f, "anonymous", "role_c"); + })); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "anonymous", "role_c"); + } + + /** + * Test permissions on folder when role is not yet created on Mesh. + * @throws Exception + */ + @Test + public void testFolderPermissionsAutoCreate() throws Exception { + try (MeshPublisher mp = new MeshPublisher(MiscUtils.load(ContentRepository.class, crId.toString()), true)) { + Folder folder = Trx.supply(() -> { + update(rolesDs, ds -> { + mp.setRoles(Collections.singleton(rolesDs), Arrays.asList("role_auto")); + }); + + return update(createFolder(node.getFolder(), "Folder"), f -> { + setRoles(f, "role_auto"); + }); + }); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "role_auto"); + } + } + + /** + * Test permissions on folder after attribute dirting + * @throws Exception + */ + @Test + public void testFolderPermissionsAfterAttributeDirting() throws Exception { + setAllRoles(); + + Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { + setRoles(f, "anonymous", "role_c"); + })); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "anonymous", "role_c"); + + // dirt "name" on all folders + Trx.operate(() -> PublishQueue.dirtFolders(new int[] { node.getId() }, null, 0, 0, Action.DEPENDENCY, + "name")); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "anonymous", "role_c"); + } + + /** + * Test default permission on folder + * @throws Exception + */ + @Test + public void testDefaultFolderPermission() throws Exception { + setAllRoles(); + + Folder folder = Trx.supply(() -> createFolder(node.getFolder(), "Folder")); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "role_b"); + + } + + /** + * Test permissions on page + * @throws Exception + */ + @Test + public void testPagePermissions() throws Exception { + setAllRoles(); + + Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { + setRoles(f, "role_c", "role_a"); + })); + + Page page = Trx.supply(() -> update(createPage(folder, template, "Page"), Page::publish)); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_a", "role_c"); + } + + /** + * Test permissions on page, when the project root node has additional permissions set + * @throws Exception + */ + @Test + public void testPagePermissionsWithModifiedRootNodePermissions() throws Exception { + setAllRoles(); + + // run publish process (will create project and node for cms node) + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + // set "read" permission for role_a on project root node and cms node + ProjectResponse projectResponse = mesh.client().findProjectByName(MESH_PROJECT_NAME).blockingGet(); + mesh.client().grantNodeRolePermissions(MESH_PROJECT_NAME, projectResponse.getRootNode().getUuid(), + new ObjectPermissionGrantRequest().setRead(Arrays.asList(new RoleReference().setName("role_a")))) + .blockingAwait(); + for (NodeResponse nodeResponse : mesh.client().findNodeChildren(MESH_PROJECT_NAME, projectResponse.getRootNode().getUuid()).blockingGet().getData()) { + mesh.client().grantNodeRolePermissions(MESH_PROJECT_NAME,nodeResponse.getUuid(), + new ObjectPermissionGrantRequest().setRead(Arrays.asList(new RoleReference().setName("role_a")))) + .blockingAwait(); + } + + Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { + setRoles(f, "role_c", "role_a"); + })); + + Page page = Trx.supply(() -> update(createPage(folder, template, "Page"), Page::publish)); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_a", "role_c"); + } + + /** + * Test permissions on page when role is not yet created on Mesh. + * @throws Exception + */ + @Test + public void testPagePermissionsAutoCreate() throws Exception { + try (MeshPublisher mp = new MeshPublisher(MiscUtils.load(ContentRepository.class, crId.toString()), true)) { + Folder folder = Trx.supply(() -> { + update(rolesDs, ds -> { + mp.setRoles(Collections.singleton(rolesDs), Arrays.asList("role_auto")); + }); + + return update(createFolder(node.getFolder(), "Folder"), f -> { + setRoles(f, "role_auto"); + }); + }); + + Page page = Trx.supply(() -> update(createPage(folder, template, "Page"), Page::publish)); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_auto"); + } + } + + /** + * Test permissions on page after attribute was dirted + * @throws Exception + */ + @Test + public void testPagePermissionsAfterAttributeDirting() throws Exception { + setAllRoles(); + + Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { + setRoles(f, "role_c", "role_a"); + })); + + Page page = Trx.supply(() -> update(createPage(folder, template, "Page"), Page::publish)); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_a", "role_c"); + + // dirt "name" on all pages + Trx.operate(() -> PublishQueue.dirtPublishedPages(new int[] { node.getId() }, null, 0, 0, Action.DEPENDENCY, + "name")); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_a", "role_c"); + } + + /** + * Test default permissions on page + * @throws Exception + */ + @Test + public void testDefaultPagePermissions() throws Exception { + setAllRoles(); + + Folder folder = Trx.supply(() -> createFolder(node.getFolder(), "Folder")); + + Page page = Trx.supply(() -> update(createPage(folder, template, "Page"), Page::publish)); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_b"); + } + + /** + * Test permissions on file + * @throws Exception + */ + @Test + public void testFilePermissions() throws Exception { + setAllRoles(); + + Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { + setRoles(f, "role_c", "role_a", "role_b"); + })); + + File file = Trx.supply(() -> createFile(folder, "file.txt", "File contents".getBytes())); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_a", "role_b", "role_c"); + } + + /** + * Test permissions on file when role is not yet created on Mesh. + * @throws Exception + */ + @Test + public void testFilePermissionsAutoCreate() throws Exception { + try (MeshPublisher mp = new MeshPublisher(MiscUtils.load(ContentRepository.class, crId.toString()), true)) { + Folder folder = Trx.supply(() -> { + update(rolesDs, ds -> { + mp.setRoles(Collections.singleton(rolesDs), Arrays.asList("role_auto")); + }); + + return update(createFolder(node.getFolder(), "Folder"), f -> { + setRoles(f, "role_auto"); + }); + }); + + File file = Trx.supply(() -> createFile(folder, "file.txt", "File contents".getBytes())); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_auto"); + } + } + + /** + * Test permissions on file after attribute dirting + * @throws Exception + */ + @Test + public void testFilePermissionsAfterAttributeDirting() throws Exception { + setAllRoles(); + + Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { + setRoles(f, "role_c", "role_a", "role_b"); + })); + + File file = Trx.supply(() -> createFile(folder, "file.txt", "File contents".getBytes())); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_a", "role_b", "role_c"); + + // dirt "name" on all files + Trx.operate(() -> PublishQueue.dirtImagesAndFiles(new int[] { node.getId() }, null, 0, 0, Action.DEPENDENCY, + "name")); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_a", "role_b", "role_c"); + } + + /** + * Test default permissions on file + * @throws Exception + */ + @Test + public void testDefaultFilePermissions() throws Exception { + setAllRoles(); + + Folder folder = Trx.supply(() -> createFolder(node.getFolder(), "Folder")); + + File file = Trx.supply(() -> createFile(folder, "file.txt", "File contents".getBytes())); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_b"); + } + + /** + * Test the permissions on a folder when using values from a Velocity context. + * @throws Exception + */ + @Test + public void testFolderPermissionsWithoutDs() throws Exception { + Folder folder = Trx.supply(() -> createFolder(node.getFolder(), "Folder")); + + setVelocityPermissionProperty(); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + // Assert that the page has the default permission set. + assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "role_b"); + + Trx.operate(() -> update(folder, f -> setTemplateRoles(f, "role_a", "role_c"))); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "role_a", "role_c"); + } + + /** + * Test the permissions on a page when using values from a Velocity context. + * @throws Exception + */ + @Test + public void testPagePermissionsWithoutDs() throws Exception { + Folder folder = Trx.supply(() -> createFolder(node.getFolder(), "Folder")); + Page page = Trx.supply(() -> update(createPage(folder, template, "testpage"), Page::publish)); + + setVelocityPermissionProperty(); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + // Assert that the page has the default permission set. + assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_b"); + + update(folder, f -> setTemplateRoles(f, "role_a", "role_c")); + update(page, Page::publish); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_a", "role_c"); + } + + /** + * Test the permissions on a file when using values from a Velocity context. + * @throws Exception + */ + @Test + public void testFilePermissionsWithoutDs() throws Exception { + Folder folder = Trx.supply(() -> createFolder(node.getFolder(), "Folder")); + File file = Trx.supply(() -> createFile(folder, "file.txt", "File contents".getBytes())); + + setVelocityPermissionProperty(); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + // Assert that the page has the default permission set. + assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_b"); + + Trx.operate(() -> update(folder, f -> setTemplateRoles(f, "role_a", "role_c"))); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_a", "role_c"); + } + + + /** + * Test that permissions on project are set + * @throws Exception + */ + @Test + public void testProjectPermissionsWithoutDs() throws Exception { + setVelocityPermissionProperty(); + setTemplateRoles(node.getFolder(), "anonymous", "role_b"); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + assertProjectPermission(MESH_PROJECT_NAME, Permission.READ, "anonymous", "role_b"); + List branches = mesh.client().findBranches(MESH_PROJECT_NAME).blockingGet().getData(); + Optional optionalDefaultBranch = branches.stream().filter(isDefaultBranch()).findFirst(); + assertThat(optionalDefaultBranch).isPresent(); + assertBranchPermission(MESH_PROJECT_NAME, optionalDefaultBranch.get().getUuid(), Permission.READ, "anonymous", "role_b"); + } + + /** + * Test permissions on root node + * @throws Exception + */ + @Test + public void testProjectRootNodePermissionsWithoutDs() throws Exception { + setVelocityPermissionProperty(); + setTemplateRoles(node.getFolder(), "anonymous", "role_b"); + + try (Trx trx = new Trx()) { + context.publish(false); + trx.success(); + } + + String rootNodeUuid = mesh.client().findProjectByName(MESH_PROJECT_NAME).blockingGet().getRootNode().getUuid(); + assertRoles(param -> mesh.client().findNodeByUuid(MESH_PROJECT_NAME, rootNodeUuid, param), Permission.READ_PUBLISHED, "anonymous", "role_b"); + } + + /** + * Get the expected datasource entries for the roles datasource (only keys and values filled) + * @return list of expected datasource entries + * @throws NodeException + */ + protected List getExpectedEntries() throws NodeException { + RoleListResponse rolesResponse = mesh.client().findRoles().blockingGet(); + return getExpectedEntries( + rolesResponse.getData().stream().map(RoleResponse::getName).filter(role -> !"admin".equals(role)).collect(Collectors.toList())); + } + + /** + * Get the expected datasource entries for the given list of roles + * @param roles role list + * @return list of expected datasource entries + * @throws NodeException + */ + protected List getExpectedEntries(List roles) throws NodeException { + return getExpectedEntries(roles.toArray(new String[roles.size()])); + } + + /** + * Get the expected datasource entries for the given list of roles + * @param roles role list + * @return list of expected datasource entries + * @throws NodeException + */ + protected List getExpectedEntries(String...roles) throws NodeException { + List entries = new ArrayList<>(); + for (String role : roles) { + entries.add(create(DatasourceEntry.class, entry -> { + entry.setKey(role); + entry.setValue(role); + }, false)); + } + + entries.sort((e1, e2) -> { + return e1.getKey().compareTo(e2.getKey()); + }); + return entries; + } + + /** + * The the {@link ContentRepository#setPermissionProperty(String) permission property} of the Mesh Contentrepository + * to the {@code roles} field in the Velocity context of the object tag. + */ + protected abstract void setVelocityPermissionProperty() throws NodeException; + + /** + * Set the given roles via the velocity object tag. + * + * @param container The container to set the roles for. + * @param roles The roles to set. + */ + protected abstract void setTemplateRoles(ObjectTagContainer container, String... roles) throws NodeException; + + /** + * Set the given roles into the roles object property of the container. + * This method expects an open transaction and that the container is editable + * @param container editable container + * @param roles roles to be set + * @throws NodeException + */ + protected void setRoles(ObjectTagContainer container, String... roles) throws NodeException { + Set roleNames = new HashSet<>(Arrays.asList(roles)); + AtomicInteger rolesCount = new AtomicInteger(); + Datasource ds = rolesDs.reload(); + String valueText = ds.getEntries().stream().filter(e -> roleNames.contains(e.getKey())).map(e -> { + rolesCount.incrementAndGet(); + return Integer.toString(e.getDsid()); + }).collect(Collectors.joining("|-|")); + + assertThat(rolesCount.get()).as("Roles set to object property").isEqualTo(roles.length); + getPartType(MultiSelectPartType.class, container.getObjectTag("roles"), "roles").getValueObject().setValueText(valueText); + container.getObjectTag("roles").setEnabled(true); + } + + /** + * Assert that exactly the given roles have the permission on the project + * @param projectName project name + * @param permission queried permission + * @param roles roles + * @throws NodeException + */ + protected void assertProjectPermission(String projectName, Permission permission, String... roles) throws NodeException { + assertRoles(param -> mesh.client().findProjectByName(projectName, param), permission, roles); + } + + /** + * Assert that exactly the given roles have the permission on the branch + * @param projectName project name + * @param branchUuid branch uuid + * @param permission queried permission + * @param roles roles + * @throws NodeException + */ + protected void assertBranchPermission(String projectName, String branchUuid, Permission permission, String... roles) throws NodeException { + assertRoles(param -> mesh.client().findBranchByUuid(projectName, branchUuid, param), permission, roles); + } + + /** + * Assert that exactly the given roles have the permission on the object + * @param projectName project name + * @param object object + * @param permission queried permission + * @param roles roles + * @throws NodeException + */ + protected void assertNodePermission(String projectName, NodeObject object, Permission permission, String... roles) throws NodeException { + Trx.operate(() -> { + assertRoles(param -> mesh.client().findNodeByUuid(projectName, MeshPublisher.getMeshUuid(object), param), permission, roles); + }); + } + + /** + * Assert that the object with given path has the permission set on exactly the given roles + * @param request function that returns the MeshRequest to get the object with permission + * @param permission queried permission + * @param roles roles + * @throws NodeException + */ + protected void assertRoles(Function> request, Permission permission, String... roles) throws NodeException { + Set roleNames = new HashSet<>(Arrays.asList(roles)); + Set neededRoles = new HashSet<>(roleNames); + RoleListResponse rolesResponse = mesh.client().findRoles().blockingGet(); + for (RoleResponse role : rolesResponse.getData()) { + AbstractGenericRestResponse response = request.apply(new RolePermissionParametersImpl().setRoleUuid(role.getUuid())).blockingGet(); + + assertThat(response.getRolePerms().get(permission)).as(String.format("Flag for %s on %s", permission, role.getName())).isEqualTo(roleNames.contains(role.getName())); + if (response.getRolePerms().get(permission) && !"admin".equals(role.getName())) { + // All the other permissions are reset. + response.getRolePerms().asMap().entrySet().stream().filter(p -> !permission.equals(p.getKey())).filter(p -> p.getValue()).findAny().ifPresent(p -> { + fail(String.format("The role `%s` should contain no perms other than `%s`, but has `%s`", role.getName(), permission.getName(), p.getKey().getName())); + }); + } + neededRoles.remove(role.getName()); + } + + assertThat(neededRoles) + .as("Roles left to check") + .isEmpty(); + } + + /** + * Set all available roles to the datasource + * @throws Exception + */ + protected void setAllRoles() throws Exception { + MeshRolesResponse getResponse = crResource.getAvailableRoles(Integer.toString(crId)); + ContentNodeRESTUtils.assertResponseOK(getResponse); + + List roles = getResponse.getRoles(); + ContentNodeRESTUtils.assertResponseOK(crResource.setRoles(Integer.toString(crId), new MeshRolesRequest().setRoles(roles))); + } + + /** + * Set the given roles and do some assertions + * @param roles roles to set + * @throws Exception + */ + protected void doSetTest(String...roles) throws Exception { + assertResponseOK(crResource.setRoles(Integer.toString(crId), new MeshRolesRequest().setRoles(Arrays.asList(roles)))); + + MeshRolesResponse rolesResponse = crResource.getRoles(Integer.toString(crId)); + assertResponseOK(rolesResponse); + assertThat(rolesResponse.getRoles()).as("Set roles").containsOnly(roles); + + rolesDs = Trx.execute(ds -> ds.reload(), rolesDs); + Trx.operate(() -> { + List expectedRoles = getExpectedEntries(roles); + List rolesEntries = rolesDs.getEntries(); + assertThat(rolesEntries).as("Roles Datasource entries").usingElementComparatorOnFields("key", "value").containsExactlyElementsOf(expectedRoles); + }); + } +} diff --git a/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/MeshMicronodePublishTest.java b/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/MeshMicronodePublishTest.java index 75989519a4..724592e3b2 100644 --- a/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/MeshMicronodePublishTest.java +++ b/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/MeshMicronodePublishTest.java @@ -76,6 +76,7 @@ import com.gentics.contentnode.object.parttype.ImageHeightPartType; import com.gentics.contentnode.object.parttype.ImageURLPartType; import com.gentics.contentnode.object.parttype.ImageWidthPartType; +import com.gentics.contentnode.object.parttype.JSONPartType; import com.gentics.contentnode.object.parttype.MultiSelectPartType; import com.gentics.contentnode.object.parttype.NodePartType; import com.gentics.contentnode.object.parttype.OrderedListPartType; @@ -117,12 +118,12 @@ public class MeshMicronodePublishTest { /** * Maximum typeId */ - public final static int MAX_TYPE_ID = 40; + public final static int MAX_TYPE_ID = 44; /** * Unused typeIds */ - public final static List UNUSED_TYPE_IDS = Arrays.asList(5, 7, 12, 14, 28); + public final static List UNUSED_TYPE_IDS = Arrays.asList(5, 7, 12, 14, 28, 41, 42, 43); /** * Valueless typeIds @@ -270,6 +271,9 @@ public class MeshMicronodePublishTest { TAG_CONSUMER.put(40, tag -> { getPartType(NodePartType.class, tag, PART_NAME).setNode(node); }); + TAG_CONSUMER.put(44, json -> { + getPartType(JSONPartType.class, json, PART_NAME).setText("{\"whatever\":\"wherever\"}"); + }); } /** @@ -322,7 +326,7 @@ public static void setupOnce() throws Exception { @Parameters(name = "{index}: parttype {0}, entry {1}") public static Collection data() throws NodeException { Collection data = new ArrayList<>(); - for (int typeId = MIN_TYPE_ID; typeId <= MAX_TYPE_ID; typeId++) { + for (int typeId = MAX_TYPE_ID; typeId <= MAX_TYPE_ID; typeId++) { if (UNUSED_TYPE_IDS.contains(typeId)) { continue; } @@ -560,6 +564,8 @@ protected List getExpectedFields() { case 30: // Select (multiple) case 32: // Datasource return Arrays.asList(new ListFieldSchemaImpl().setListType(FieldTypes.STRING.toString()).setName("part").setLabel("Test Part")); + case 44: // JSON + return Arrays.asList(new ListFieldSchemaImpl().setListType(FieldTypes.JSON.toString()).setName("part").setLabel("Test Part")); default: return Arrays.asList(new StringFieldSchemaImpl().setName("part").setLabel("Test Part")); } diff --git a/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/MeshPublishJSONRolesTest.java b/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/MeshPublishJSONRolesTest.java new file mode 100644 index 0000000000..59d0f73456 --- /dev/null +++ b/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/MeshPublishJSONRolesTest.java @@ -0,0 +1,99 @@ +package com.gentics.contentnode.tests.publish.mesh; + +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.create; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createObjectPropertyDefinition; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.getPartType; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.getPartTypeId; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.update; + +import org.junit.BeforeClass; +import org.junit.experimental.categories.Category; + +import com.gentics.api.lib.exception.NodeException; +import com.gentics.contentnode.etc.Feature; +import com.gentics.contentnode.factory.Trx; +import com.gentics.contentnode.object.Construct; +import com.gentics.contentnode.object.ContentRepository; +import com.gentics.contentnode.object.Folder; +import com.gentics.contentnode.object.ObjectTag; +import com.gentics.contentnode.object.ObjectTagContainer; +import com.gentics.contentnode.object.ObjectTagDefinition; +import com.gentics.contentnode.object.Part; +import com.gentics.contentnode.object.Value; +import com.gentics.contentnode.object.parttype.JSONPartType; +import com.gentics.contentnode.tests.category.MeshTest; +import com.gentics.contentnode.testutils.GCNFeature; + +import io.vertx.core.json.JsonArray; + +/** + * Test cases for setting permissions on roles + */ +@GCNFeature(set = { Feature.MESH_CONTENTREPOSITORY, Feature.ATTRIBUTE_DIRTING }) +@Category(MeshTest.class) +public class MeshPublishJSONRolesTest extends AbstractMeshPublishRoleTest { + + protected static ObjectTagDefinition templateRolesProperty; + + @BeforeClass + public static void setupOnce() throws Exception { + AbstractMeshPublishRoleTest.setupOnce(); + + Construct jsonRolesConstruct = Trx.supply(() -> create(Construct.class, construct -> { + construct.setAutoEnable(true); + construct.setKeyword("roleConstruct"); + construct.setName("roles", 1); + construct.getNodes().add(node); + + construct.getParts().add(create(Part.class, part -> { + part.setEditable(1); + part.setHidden(false); + part.setKeyname(TPL_PART_KEYWORD); + part.setName("roles", 1); + part.setPartTypeId(getPartTypeId(JSONPartType.class)); + part.setDefaultValue(create(Value.class, value -> {}, false)); + part.setInfoText("{\"type\":\"array\",\"items\":{\"type\":\"string\"}}"); + }, false)); + })); + rolesProperty = createObjectPropertyDefinition(Folder.TYPE_FOLDER, rolesConstruct.getId(), "Roles", "roles"); + templateRolesProperty = createObjectPropertyDefinition(Folder.TYPE_FOLDER, jsonRolesConstruct.getId(), "VTLRoles", TPL_OBJECT_TAG_KEYWORD); + } + + /** + * The the {@link ContentRepository#setPermissionProperty(String) permission property} of the Mesh Contentrepository + * to the {@code roles} field in the Velocity context of the object tag. + */ + @Override + protected void setVelocityPermissionProperty() throws NodeException { + Trx.operate(t -> { + ContentRepository cr = t.getObject(ContentRepository.class, crId, true); + + cr.setPermissionProperty(String.format("object.%s.parts.%s", TPL_OBJECT_TAG_KEYWORD, TPL_PART_KEYWORD)); + cr.save(); + }); + } + + /** + * Set the given roles via the velocity object tag. + * + * @param container The container to set the roles for. + * @param roles The roles to set. + */ + @Override + protected void setTemplateRoles(ObjectTagContainer container, String... roles) throws NodeException { + ObjectTag objTag = container.getObjectTag(TPL_OBJECT_TAG_KEYWORD); + JsonArray rolesArray = new JsonArray(); + + for (String role : roles) { + rolesArray.add(role); + } + + update(objTag, tag -> { + tag.setEnabled(true); + + getPartType(JSONPartType.class, tag, TPL_PART_KEYWORD) + .getValueObject() + .setValueText(rolesArray.encode()); + }); + } +} diff --git a/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/MeshPublishRolesTest.java b/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/MeshPublishRolesTest.java index 8780cf6455..0d7675f949 100644 --- a/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/MeshPublishRolesTest.java +++ b/cms-core/src/test/java/com/gentics/contentnode/tests/publish/mesh/MeshPublishRolesTest.java @@ -1,134 +1,36 @@ package com.gentics.contentnode.tests.publish.mesh; -import static com.gentics.contentnode.tests.utils.ContentNodeMeshCRUtils.assertMeshProject; -import static com.gentics.contentnode.tests.utils.ContentNodeMeshCRUtils.cleanMesh; -import static com.gentics.contentnode.tests.utils.ContentNodeMeshCRUtils.crResource; -import static com.gentics.contentnode.tests.utils.ContentNodeMeshCRUtils.createMeshCR; -import static com.gentics.contentnode.tests.utils.ContentNodeMeshCRUtils.isDefaultBranch; -import static com.gentics.contentnode.tests.utils.ContentNodeRESTUtils.assertResponseOK; -import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.create; -import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createFile; -import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createFolder; -import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createNode; import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createObjectPropertyDefinition; -import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createPage; -import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createTemplate; import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.getPartType; -import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.getPartTypeId; import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.update; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.Assert.fail; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Collectors; - -import org.junit.Before; import org.junit.BeforeClass; -import org.junit.ClassRule; import org.junit.Rule; -import org.junit.Test; import org.junit.experimental.categories.Category; import com.gentics.api.lib.exception.NodeException; import com.gentics.contentnode.etc.Feature; -import com.gentics.contentnode.etc.Function; -import com.gentics.contentnode.exception.RestMappedException; import com.gentics.contentnode.factory.Trx; -import com.gentics.contentnode.object.Construct; import com.gentics.contentnode.object.ContentRepository; -import com.gentics.contentnode.object.Datasource; -import com.gentics.contentnode.object.Datasource.SourceType; -import com.gentics.contentnode.object.DatasourceEntry; -import com.gentics.contentnode.object.File; import com.gentics.contentnode.object.Folder; -import com.gentics.contentnode.object.Node; -import com.gentics.contentnode.object.NodeObject; import com.gentics.contentnode.object.ObjectTag; import com.gentics.contentnode.object.ObjectTagContainer; import com.gentics.contentnode.object.ObjectTagDefinition; -import com.gentics.contentnode.object.Page; -import com.gentics.contentnode.object.Part; -import com.gentics.contentnode.object.Template; -import com.gentics.contentnode.object.Value; import com.gentics.contentnode.object.parttype.LongHTMLPartType; -import com.gentics.contentnode.object.parttype.MultiSelectPartType; -import com.gentics.contentnode.publish.PublishQueue; -import com.gentics.contentnode.publish.PublishQueue.Action; -import com.gentics.contentnode.publish.mesh.MeshPublisher; -import com.gentics.contentnode.rest.model.ContentRepositoryModel.Status; -import com.gentics.contentnode.rest.model.request.MeshRolesRequest; -import com.gentics.contentnode.rest.model.response.ContentRepositoryResponse; -import com.gentics.contentnode.rest.model.response.MeshRolesResponse; -import com.gentics.contentnode.rest.util.MiscUtils; import com.gentics.contentnode.tests.category.MeshTest; -import com.gentics.contentnode.tests.utils.ContentNodeRESTUtils; import com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils; -import com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.PublishTarget; import com.gentics.contentnode.tests.utils.ExceptionChecker; -import com.gentics.contentnode.tests.utils.Expected; -import com.gentics.contentnode.testutils.DBTestContext; import com.gentics.contentnode.testutils.GCNFeature; -import com.gentics.contentnode.testutils.mesh.MeshContext; import com.gentics.contentnode.testutils.mesh.MeshTestRule; -import com.gentics.mesh.core.rest.branch.BranchResponse; -import com.gentics.mesh.core.rest.common.AbstractGenericRestResponse; -import com.gentics.mesh.core.rest.common.ObjectPermissionGrantRequest; -import com.gentics.mesh.core.rest.common.Permission; -import com.gentics.mesh.core.rest.node.NodeResponse; -import com.gentics.mesh.core.rest.project.ProjectResponse; -import com.gentics.mesh.core.rest.role.RoleCreateRequest; -import com.gentics.mesh.core.rest.role.RoleListResponse; -import com.gentics.mesh.core.rest.role.RoleReference; -import com.gentics.mesh.core.rest.role.RoleResponse; -import com.gentics.mesh.parameter.RolePermissionParameters; -import com.gentics.mesh.parameter.client.RolePermissionParametersImpl; -import com.gentics.mesh.rest.client.MeshRequest; /** * Test cases for setting permissions on roles */ @GCNFeature(set = { Feature.MESH_CONTENTREPOSITORY, Feature.ATTRIBUTE_DIRTING }) @Category(MeshTest.class) -public class MeshPublishRolesTest { - /** - * Name of the mesh project - */ - public final static String MESH_PROJECT_NAME = "testproject"; - - /** - * Mesh roles - */ - public final static List ROLES = Arrays.asList("role_a", "role_b", "role_c"); - - private final static String VTL_OBJECT_TAG_KEYWORD = "vtlroles"; - private final static String VTL_PART_KEYWORD = "vtl"; - private final static String VTL_ROLES_FIELD = "roles"; - - @ClassRule - public static DBTestContext context = new DBTestContext(); - - @ClassRule - public static MeshContext mesh = new MeshContext(); - - private static Node node; - - private static Integer crId; +public class MeshPublishRolesTest extends AbstractMeshPublishRoleTest { - private static Template template; - - private static Datasource rolesDs; - - private static Construct rolesConstruct; - - private static ObjectTagDefinition rolesProperty; - private static ObjectTagDefinition velocityRolesProperty; + protected static ObjectTagDefinition templateRolesProperty; @Rule public MeshTestRule meshTestRule = new MeshTestRule(mesh); @@ -138,758 +40,23 @@ public class MeshPublishRolesTest { @BeforeClass public static void setupOnce() throws Exception { - ROLES.forEach(role -> { - mesh.client().createRole(new RoleCreateRequest().setName(role)).blockingAwait(); - }); - node = Trx.supply(() -> createNode("node", "Node", PublishTarget.CONTENTREPOSITORY)); - crId = createMeshCR(mesh, MESH_PROJECT_NAME); - - Trx.operate(() -> update(node, n -> { - n.setContentrepositoryId(crId); - })); - - template = Trx.supply(() -> createTemplate(node.getFolder(), "Template")); - - rolesDs = Trx.supply(() -> create(Datasource.class, ds -> { - ds.setSourceType(SourceType.staticDS); - ds.setName("Mesh Roles"); - })); - - rolesConstruct = Trx.supply(() -> create(Construct.class, construct -> { - construct.setAutoEnable(true); - construct.setKeyword("roles"); - construct.setName("roles", 1); - construct.getNodes().add(node); - - construct.getParts().add(create(Part.class, part -> { - part.setEditable(1); - part.setHidden(false); - part.setKeyname("roles"); - part.setName("roles", 1); - part.setPartTypeId(getPartTypeId(MultiSelectPartType.class)); - part.setDefaultValue(create(Value.class, value -> {}, false)); - part.setInfoInt(rolesDs.getId()); - }, false)); - })); - - int velocityRolesConstructId = Trx.supply(t -> ContentNodeTestDataUtils.createVelocityConstruct(node, "roleConstruct", VTL_PART_KEYWORD)); + AbstractMeshPublishRoleTest.setupOnce(); + int velocityRolesConstructId = Trx.supply(t -> ContentNodeTestDataUtils.createVelocityConstruct(node, "roleConstruct", TPL_PART_KEYWORD)); rolesProperty = createObjectPropertyDefinition(Folder.TYPE_FOLDER, rolesConstruct.getId(), "Roles", "roles"); - velocityRolesProperty = createObjectPropertyDefinition(Folder.TYPE_FOLDER, velocityRolesConstructId, "VTLRoles", VTL_OBJECT_TAG_KEYWORD); - } - - @Before - public void setup() throws Exception { - cleanMesh(mesh.client()); - - Trx.operate(t -> { - update(t.getObject(ContentRepository.class, crId, true), cr -> { - cr.setPermissionProperty("object.roles"); - cr.setDefaultPermission("role_b"); - }); - }); - - Trx.operate(t -> { - for (Folder folder : node.getFolder().getChildFolders()) { - t.getObject(folder, true).delete(true); - } - }); - - Trx.operate(t -> { - update(rolesDs, ds -> { - ds.getEntries().clear(); - }); - }); - } - - /** - * Test synchronizing all available roles - * @throws Exception - */ - @Test - public void testSyncAll() throws Exception { - ContentRepositoryResponse response = crResource.repair(Integer.toString(crId), 0); - ContentNodeRESTUtils.assertResponseOK(response); - if (response.getContentRepository().getCheckStatus() != Status.ok) { - fail(response.getContentRepository().getCheckResult()); - } - setAllRoles(); - - assertMeshProject(mesh.client(), MESH_PROJECT_NAME); - - rolesDs = Trx.execute(ds -> ds.reload(), rolesDs); - - Trx.operate(() -> { - List expectedRoles = getExpectedEntries(); - List roles = rolesDs.getEntries(); - assertThat(roles).as("Roles Datasource entries").usingElementComparatorOnFields("key", "value").containsExactlyElementsOf(expectedRoles); - }); - } - - /** - * Test synchronizing some of the available roles - * @throws Exception - */ - @Test - public void testSetSomeRoles() throws Exception { - doSetTest("role_a", "role_c"); - } - - /** - * Test Changing set roles - * @throws Exception - */ - @Test - public void testChangeRoles() throws Exception { - doSetTest("role_a", "role_c"); - doSetTest("role_b", "anonymous"); - doSetTest("role_b", "role_c"); - doSetTest(); - } - - /** - * Test setting invalid (unknown) roles - * @throws Exception - */ - @Test - @Expected(ex = RestMappedException.class, message = "Die Rollen [invalid] sind ungültig und können nicht gesetzt werden.") - public void testSetInvalidRoles() throws Exception { - crResource.setRoles(Integer.toString(crId), new MeshRolesRequest().setRoles(Arrays.asList("invalid"))); - } - - /** - * Test getting available roles - * @throws Exception - */ - @Test - public void testGetAvailableRoles() throws Exception { - MeshRolesResponse getResponse = crResource.getAvailableRoles(Integer.toString(crId)); - assertResponseOK(getResponse); - - assertThat(getResponse.getRoles()).as("Available roles").containsOnly("anonymous", "role_a", "role_b", "role_c"); - } - - /** - * Test that permissions on project are set - * @throws Exception - */ - @Test - public void testProjectPermissions() throws Exception { - setAllRoles(); - - Trx.operate(() -> update(node.getFolder(), f -> { - setRoles(f, "anonymous", "role_b"); - })); - - Trx.operate(() -> PublishQueue.dirtObject(node.getFolder(), Action.MODIFY, 0)); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertProjectPermission(MESH_PROJECT_NAME, Permission.READ, "anonymous", "role_b"); - List branches = mesh.client().findBranches(MESH_PROJECT_NAME).blockingGet().getData(); - Optional optionalDefaultBranch = branches.stream().filter(isDefaultBranch()).findFirst(); - assertThat(optionalDefaultBranch).isPresent(); - assertBranchPermission(MESH_PROJECT_NAME, optionalDefaultBranch.get().getUuid(), Permission.READ, "anonymous", "role_b"); - } - - /** - * Test that permissions on project are set, even if not yet created on Mesh. - * @throws Exception - */ - @Test - public void testProjectPermissionsAutoCreate() throws Exception { - try (MeshPublisher mp = new MeshPublisher(MiscUtils.load(ContentRepository.class, crId.toString()), true)) { - Trx.operate(() -> { - update(rolesDs, ds -> { - mp.setRoles(Collections.singleton(rolesDs), Arrays.asList("role_auto")); - }); - update(node.getFolder(), f -> { - setRoles(f, "role_auto"); - }); - }); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertProjectPermission(MESH_PROJECT_NAME, Permission.READ, "role_auto"); - List branches = mesh.client().findBranches(MESH_PROJECT_NAME).blockingGet().getData(); - Optional optionalDefaultBranch = branches.stream().filter(isDefaultBranch()).findFirst(); - assertThat(optionalDefaultBranch).isPresent(); - assertBranchPermission(MESH_PROJECT_NAME, optionalDefaultBranch.get().getUuid(), Permission.READ, "role_auto"); - } - } - /** - * Test permissions on root node - * @throws Exception - */ - @Test - public void testProjectRootNodePermissions() throws Exception { - setAllRoles(); - - Trx.operate(() -> update(node.getFolder(), f -> { - setRoles(f, "anonymous", "role_b"); - })); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - String rootNodeUuid = mesh.client().findProjectByName(MESH_PROJECT_NAME).blockingGet().getRootNode().getUuid(); - assertRoles(param -> mesh.client().findNodeByUuid(MESH_PROJECT_NAME, rootNodeUuid, param), Permission.READ_PUBLISHED, "anonymous", "role_b"); - } - - - /** - * Test permissions on root node when role is not yet created on Mesh. - * @throws Exception - */ - @Test - public void testProjectRootNodePermissionsAutoCreate() throws Exception { - try (MeshPublisher mp = new MeshPublisher(MiscUtils.load(ContentRepository.class, crId.toString()), true)) { - Trx.operate(() -> { - update(rolesDs, ds -> { - mp.setRoles(Collections.singleton(rolesDs), Arrays.asList("role_auto")); - }); - update(node.getFolder(), f -> { - setRoles(f, "role_auto"); - }); - }); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - String rootNodeUuid = mesh.client().findProjectByName(MESH_PROJECT_NAME).blockingGet().getRootNode().getUuid(); - assertRoles(param -> mesh.client().findNodeByUuid(MESH_PROJECT_NAME, rootNodeUuid, param), Permission.READ_PUBLISHED, "role_auto"); - } - } - - /** - * Test permissions on folder - * @throws Exception - */ - @Test - public void testFolderPermissions() throws Exception { - setAllRoles(); - - Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { - setRoles(f, "anonymous", "role_c"); - })); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "anonymous", "role_c"); - } - - /** - * Test permissions on folder when role is not yet created on Mesh. - * @throws Exception - */ - @Test - public void testFolderPermissionsAutoCreate() throws Exception { - try (MeshPublisher mp = new MeshPublisher(MiscUtils.load(ContentRepository.class, crId.toString()), true)) { - Folder folder = Trx.supply(() -> { - update(rolesDs, ds -> { - mp.setRoles(Collections.singleton(rolesDs), Arrays.asList("role_auto")); - }); - - return update(createFolder(node.getFolder(), "Folder"), f -> { - setRoles(f, "role_auto"); - }); - }); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "role_auto"); - } - } - - /** - * Test permissions on folder after attribute dirting - * @throws Exception - */ - @Test - public void testFolderPermissionsAfterAttributeDirting() throws Exception { - setAllRoles(); - - Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { - setRoles(f, "anonymous", "role_c"); - })); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "anonymous", "role_c"); - - // dirt "name" on all folders - Trx.operate(() -> PublishQueue.dirtFolders(new int[] { node.getId() }, null, 0, 0, Action.DEPENDENCY, - "name")); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "anonymous", "role_c"); - } - - /** - * Test default permission on folder - * @throws Exception - */ - @Test - public void testDefaultFolderPermission() throws Exception { - setAllRoles(); - - Folder folder = Trx.supply(() -> createFolder(node.getFolder(), "Folder")); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "role_b"); - - } - - /** - * Test permissions on page - * @throws Exception - */ - @Test - public void testPagePermissions() throws Exception { - setAllRoles(); - - Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { - setRoles(f, "role_c", "role_a"); - })); - - Page page = Trx.supply(() -> update(createPage(folder, template, "Page"), Page::publish)); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_a", "role_c"); - } - - /** - * Test permissions on page, when the project root node has additional permissions set - * @throws Exception - */ - @Test - public void testPagePermissionsWithModifiedRootNodePermissions() throws Exception { - setAllRoles(); - - // run publish process (will create project and node for cms node) - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - // set "read" permission for role_a on project root node and cms node - ProjectResponse projectResponse = mesh.client().findProjectByName(MESH_PROJECT_NAME).blockingGet(); - mesh.client().grantNodeRolePermissions(MESH_PROJECT_NAME, projectResponse.getRootNode().getUuid(), - new ObjectPermissionGrantRequest().setRead(Arrays.asList(new RoleReference().setName("role_a")))) - .blockingAwait(); - for (NodeResponse nodeResponse : mesh.client().findNodeChildren(MESH_PROJECT_NAME, projectResponse.getRootNode().getUuid()).blockingGet().getData()) { - mesh.client().grantNodeRolePermissions(MESH_PROJECT_NAME,nodeResponse.getUuid(), - new ObjectPermissionGrantRequest().setRead(Arrays.asList(new RoleReference().setName("role_a")))) - .blockingAwait(); - } - - Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { - setRoles(f, "role_c", "role_a"); - })); - - Page page = Trx.supply(() -> update(createPage(folder, template, "Page"), Page::publish)); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_a", "role_c"); - } - - /** - * Test permissions on page when role is not yet created on Mesh. - * @throws Exception - */ - @Test - public void testPagePermissionsAutoCreate() throws Exception { - try (MeshPublisher mp = new MeshPublisher(MiscUtils.load(ContentRepository.class, crId.toString()), true)) { - Folder folder = Trx.supply(() -> { - update(rolesDs, ds -> { - mp.setRoles(Collections.singleton(rolesDs), Arrays.asList("role_auto")); - }); - - return update(createFolder(node.getFolder(), "Folder"), f -> { - setRoles(f, "role_auto"); - }); - }); - - Page page = Trx.supply(() -> update(createPage(folder, template, "Page"), Page::publish)); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_auto"); - } - } - - /** - * Test permissions on page after attribute was dirted - * @throws Exception - */ - @Test - public void testPagePermissionsAfterAttributeDirting() throws Exception { - setAllRoles(); - - Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { - setRoles(f, "role_c", "role_a"); - })); - - Page page = Trx.supply(() -> update(createPage(folder, template, "Page"), Page::publish)); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_a", "role_c"); - - // dirt "name" on all pages - Trx.operate(() -> PublishQueue.dirtPublishedPages(new int[] { node.getId() }, null, 0, 0, Action.DEPENDENCY, - "name")); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_a", "role_c"); - } - - /** - * Test default permissions on page - * @throws Exception - */ - @Test - public void testDefaultPagePermissions() throws Exception { - setAllRoles(); - - Folder folder = Trx.supply(() -> createFolder(node.getFolder(), "Folder")); - - Page page = Trx.supply(() -> update(createPage(folder, template, "Page"), Page::publish)); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_b"); - } - - /** - * Test permissions on file - * @throws Exception - */ - @Test - public void testFilePermissions() throws Exception { - setAllRoles(); - - Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { - setRoles(f, "role_c", "role_a", "role_b"); - })); - - File file = Trx.supply(() -> createFile(folder, "file.txt", "File contents".getBytes())); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_a", "role_b", "role_c"); - } - - /** - * Test permissions on file when role is not yet created on Mesh. - * @throws Exception - */ - @Test - public void testFilePermissionsAutoCreate() throws Exception { - try (MeshPublisher mp = new MeshPublisher(MiscUtils.load(ContentRepository.class, crId.toString()), true)) { - Folder folder = Trx.supply(() -> { - update(rolesDs, ds -> { - mp.setRoles(Collections.singleton(rolesDs), Arrays.asList("role_auto")); - }); - - return update(createFolder(node.getFolder(), "Folder"), f -> { - setRoles(f, "role_auto"); - }); - }); - - File file = Trx.supply(() -> createFile(folder, "file.txt", "File contents".getBytes())); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_auto"); - } - } - - /** - * Test permissions on file after attribute dirting - * @throws Exception - */ - @Test - public void testFilePermissionsAfterAttributeDirting() throws Exception { - setAllRoles(); - - Folder folder = Trx.supply(() -> update(createFolder(node.getFolder(), "Folder"), f -> { - setRoles(f, "role_c", "role_a", "role_b"); - })); - - File file = Trx.supply(() -> createFile(folder, "file.txt", "File contents".getBytes())); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_a", "role_b", "role_c"); - - // dirt "name" on all files - Trx.operate(() -> PublishQueue.dirtImagesAndFiles(new int[] { node.getId() }, null, 0, 0, Action.DEPENDENCY, - "name")); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_a", "role_b", "role_c"); - } - - /** - * Test default permissions on file - * @throws Exception - */ - @Test - public void testDefaultFilePermissions() throws Exception { - setAllRoles(); - - Folder folder = Trx.supply(() -> createFolder(node.getFolder(), "Folder")); - - File file = Trx.supply(() -> createFile(folder, "file.txt", "File contents".getBytes())); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_b"); - } - - /** - * Test the permissions on a folder when using values from a Velocity context. - * @throws Exception - */ - @Test - public void testFolderPermissionsWithoutDs() throws Exception { - Folder folder = Trx.supply(() -> createFolder(node.getFolder(), "Folder")); - - setVelocityPermissionProperty(); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - // Assert that the page has the default permission set. - assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "role_b"); - - Trx.operate(() -> update(folder, f -> setVelocityRoles(f, "role_a", "role_c"))); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, folder, Permission.READ_PUBLISHED, "role_a", "role_c"); - } - - /** - * Test the permissions on a page when using values from a Velocity context. - * @throws Exception - */ - @Test - public void testPagePermissionsWithoutDs() throws Exception { - Folder folder = Trx.supply(() -> createFolder(node.getFolder(), "Folder")); - Page page = Trx.supply(() -> update(createPage(folder, template, "testpage"), Page::publish)); - - setVelocityPermissionProperty(); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - // Assert that the page has the default permission set. - assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_b"); - - update(folder, f -> setVelocityRoles(f, "role_a", "role_c")); - update(page, Page::publish); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, page, Permission.READ_PUBLISHED, "role_a", "role_c"); - } - - /** - * Test the permissions on a file when using values from a Velocity context. - * @throws Exception - */ - @Test - public void testFilePermissionsWithoutDs() throws Exception { - Folder folder = Trx.supply(() -> createFolder(node.getFolder(), "Folder")); - File file = Trx.supply(() -> createFile(folder, "file.txt", "File contents".getBytes())); - - setVelocityPermissionProperty(); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - // Assert that the page has the default permission set. - assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_b"); - - Trx.operate(() -> update(folder, f -> setVelocityRoles(f, "role_a", "role_c"))); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertNodePermission(MESH_PROJECT_NAME, file, Permission.READ_PUBLISHED, "role_a", "role_c"); - } - - - /** - * Test that permissions on project are set - * @throws Exception - */ - @Test - public void testProjectPermissionsWithoutDs() throws Exception { - setVelocityPermissionProperty(); - setVelocityRoles(node.getFolder(), "anonymous", "role_b"); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - assertProjectPermission(MESH_PROJECT_NAME, Permission.READ, "anonymous", "role_b"); - List branches = mesh.client().findBranches(MESH_PROJECT_NAME).blockingGet().getData(); - Optional optionalDefaultBranch = branches.stream().filter(isDefaultBranch()).findFirst(); - assertThat(optionalDefaultBranch).isPresent(); - assertBranchPermission(MESH_PROJECT_NAME, optionalDefaultBranch.get().getUuid(), Permission.READ, "anonymous", "role_b"); - } - - /** - * Test permissions on root node - * @throws Exception - */ - @Test - public void testProjectRootNodePermissionsWithoutDs() throws Exception { - setVelocityPermissionProperty(); - setVelocityRoles(node.getFolder(), "anonymous", "role_b"); - - try (Trx trx = new Trx()) { - context.publish(false); - trx.success(); - } - - String rootNodeUuid = mesh.client().findProjectByName(MESH_PROJECT_NAME).blockingGet().getRootNode().getUuid(); - assertRoles(param -> mesh.client().findNodeByUuid(MESH_PROJECT_NAME, rootNodeUuid, param), Permission.READ_PUBLISHED, "anonymous", "role_b"); - } - - /** - * Get the expected datasource entries for the roles datasource (only keys and values filled) - * @return list of expected datasource entries - * @throws NodeException - */ - protected List getExpectedEntries() throws NodeException { - RoleListResponse rolesResponse = mesh.client().findRoles().blockingGet(); - return getExpectedEntries( - rolesResponse.getData().stream().map(RoleResponse::getName).filter(role -> !"admin".equals(role)).collect(Collectors.toList())); - } - - /** - * Get the expected datasource entries for the given list of roles - * @param roles role list - * @return list of expected datasource entries - * @throws NodeException - */ - protected List getExpectedEntries(List roles) throws NodeException { - return getExpectedEntries(roles.toArray(new String[roles.size()])); - } - - /** - * Get the expected datasource entries for the given list of roles - * @param roles role list - * @return list of expected datasource entries - * @throws NodeException - */ - protected List getExpectedEntries(String...roles) throws NodeException { - List entries = new ArrayList<>(); - for (String role : roles) { - entries.add(create(DatasourceEntry.class, entry -> { - entry.setKey(role); - entry.setValue(role); - }, false)); - } - - entries.sort((e1, e2) -> { - return e1.getKey().compareTo(e2.getKey()); - }); - return entries; + templateRolesProperty = createObjectPropertyDefinition(Folder.TYPE_FOLDER, velocityRolesConstructId, "VTLRoles", TPL_OBJECT_TAG_KEYWORD); } /** * The the {@link ContentRepository#setPermissionProperty(String) permission property} of the Mesh Contentrepository * to the {@code roles} field in the Velocity context of the object tag. */ + @Override protected void setVelocityPermissionProperty() throws NodeException { Trx.operate(t -> { ContentRepository cr = t.getObject(ContentRepository.class, crId, true); - cr.setPermissionProperty(String.format("object.%s.parts.%s.%s", VTL_OBJECT_TAG_KEYWORD, VTL_PART_KEYWORD, VTL_ROLES_FIELD)); + cr.setPermissionProperty(String.format("object.%s.parts.%s.%s", TPL_OBJECT_TAG_KEYWORD, TPL_PART_KEYWORD, TPL_ROLES_FIELD)); cr.save(); }); } @@ -900,12 +67,13 @@ protected void setVelocityPermissionProperty() throws NodeException { * @param container The container to set the roles for. * @param roles The roles to set. */ - protected void setVelocityRoles(ObjectTagContainer container, String... roles) throws NodeException { - ObjectTag objTag = container.getObjectTag(VTL_OBJECT_TAG_KEYWORD); - StringBuilder valueText = new StringBuilder(String.format("#set($%s = [])\n", VTL_ROLES_FIELD)); + @Override + protected void setTemplateRoles(ObjectTagContainer container, String... roles) throws NodeException { + ObjectTag objTag = container.getObjectTag(TPL_OBJECT_TAG_KEYWORD); + StringBuilder valueText = new StringBuilder(String.format("#set($%s = [])\n", TPL_ROLES_FIELD)); for (String role : roles) { - valueText.append(String.format("$%s.add(\"%s\")\n", VTL_ROLES_FIELD, role)); + valueText.append(String.format("$%s.add(\"%s\")\n", TPL_ROLES_FIELD, role)); } update(objTag, tag -> { @@ -916,123 +84,4 @@ protected void setVelocityRoles(ObjectTagContainer container, String... roles) t .setValueText(valueText.toString()); }); } - - /** - * Set the given roles into the roles object property of the container. - * This method expects an open transaction and that the container is editable - * @param container editable container - * @param roles roles to be set - * @throws NodeException - */ - protected void setRoles(ObjectTagContainer container, String... roles) throws NodeException { - Set roleNames = new HashSet<>(Arrays.asList(roles)); - AtomicInteger rolesCount = new AtomicInteger(); - Datasource ds = rolesDs.reload(); - String valueText = ds.getEntries().stream().filter(e -> roleNames.contains(e.getKey())).map(e -> { - rolesCount.incrementAndGet(); - return Integer.toString(e.getDsid()); - }).collect(Collectors.joining("|-|")); - - assertThat(rolesCount.get()).as("Roles set to object property").isEqualTo(roles.length); - getPartType(MultiSelectPartType.class, container.getObjectTag("roles"), "roles").getValueObject().setValueText(valueText); - container.getObjectTag("roles").setEnabled(true); - } - - /** - * Assert that exactly the given roles have the permission on the project - * @param projectName project name - * @param permission queried permission - * @param roles roles - * @throws NodeException - */ - protected void assertProjectPermission(String projectName, Permission permission, String... roles) throws NodeException { - assertRoles(param -> mesh.client().findProjectByName(projectName, param), permission, roles); - } - - /** - * Assert that exactly the given roles have the permission on the branch - * @param projectName project name - * @param branchUuid branch uuid - * @param permission queried permission - * @param roles roles - * @throws NodeException - */ - protected void assertBranchPermission(String projectName, String branchUuid, Permission permission, String... roles) throws NodeException { - assertRoles(param -> mesh.client().findBranchByUuid(projectName, branchUuid, param), permission, roles); - } - - /** - * Assert that exactly the given roles have the permission on the object - * @param projectName project name - * @param object object - * @param permission queried permission - * @param roles roles - * @throws NodeException - */ - protected void assertNodePermission(String projectName, NodeObject object, Permission permission, String... roles) throws NodeException { - Trx.operate(() -> { - assertRoles(param -> mesh.client().findNodeByUuid(projectName, MeshPublisher.getMeshUuid(object), param), permission, roles); - }); - } - - /** - * Assert that the object with given path has the permission set on exactly the given roles - * @param request function that returns the MeshRequest to get the object with permission - * @param permission queried permission - * @param roles roles - * @throws NodeException - */ - protected void assertRoles(Function> request, Permission permission, String... roles) throws NodeException { - Set roleNames = new HashSet<>(Arrays.asList(roles)); - Set neededRoles = new HashSet<>(roleNames); - RoleListResponse rolesResponse = mesh.client().findRoles().blockingGet(); - for (RoleResponse role : rolesResponse.getData()) { - AbstractGenericRestResponse response = request.apply(new RolePermissionParametersImpl().setRoleUuid(role.getUuid())).blockingGet(); - - assertThat(response.getRolePerms().get(permission)).as(String.format("Flag for %s on %s", permission, role.getName())).isEqualTo(roleNames.contains(role.getName())); - if (response.getRolePerms().get(permission) && !"admin".equals(role.getName())) { - // All the other permissions are reset. - response.getRolePerms().asMap().entrySet().stream().filter(p -> !permission.equals(p.getKey())).filter(p -> p.getValue()).findAny().ifPresent(p -> { - fail(String.format("The role `%s` should contain no perms other than `%s`, but has `%s`", role.getName(), permission.getName(), p.getKey().getName())); - }); - } - neededRoles.remove(role.getName()); - } - - assertThat(neededRoles) - .as("Roles left to check") - .isEmpty(); - } - - /** - * Set all available roles to the datasource - * @throws Exception - */ - protected void setAllRoles() throws Exception { - MeshRolesResponse getResponse = crResource.getAvailableRoles(Integer.toString(crId)); - ContentNodeRESTUtils.assertResponseOK(getResponse); - - List roles = getResponse.getRoles(); - ContentNodeRESTUtils.assertResponseOK(crResource.setRoles(Integer.toString(crId), new MeshRolesRequest().setRoles(roles))); - } - - /** - * Set the given roles and do some assertions - * @param roles roles to set - * @throws Exception - */ - protected void doSetTest(String...roles) throws Exception { - assertResponseOK(crResource.setRoles(Integer.toString(crId), new MeshRolesRequest().setRoles(Arrays.asList(roles)))); - - MeshRolesResponse rolesResponse = crResource.getRoles(Integer.toString(crId)); - assertResponseOK(rolesResponse); - assertThat(rolesResponse.getRoles()).as("Set roles").containsOnly(roles); - - rolesDs = Trx.execute(ds -> ds.reload(), rolesDs); - Trx.operate(() -> { - List expectedRoles = getExpectedEntries(roles); - List rolesEntries = rolesDs.getEntries(); - assertThat(rolesEntries).as("Roles Datasource entries").usingElementComparatorOnFields("key", "value").containsExactlyElementsOf(expectedRoles); - }); - } } diff --git a/cms-core/src/test/java/com/gentics/contentnode/tests/rest/AbstractJSONPropertyTest.java b/cms-core/src/test/java/com/gentics/contentnode/tests/rest/AbstractJSONPropertyTest.java new file mode 100644 index 0000000000..4cc8915f44 --- /dev/null +++ b/cms-core/src/test/java/com/gentics/contentnode/tests/rest/AbstractJSONPropertyTest.java @@ -0,0 +1,62 @@ +package com.gentics.contentnode.tests.rest; + +import static com.gentics.contentnode.factory.Trx.supply; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createConstruct; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createNode; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createTemplate; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import java.util.Map.Entry; + +import org.junit.BeforeClass; +import org.junit.ClassRule; + +import com.gentics.api.lib.exception.NodeException; +import com.gentics.contentnode.object.Node; +import com.gentics.contentnode.object.Template; +import com.gentics.contentnode.object.parttype.JSONPartType; +import com.gentics.contentnode.testutils.DBTestContext; + +public abstract class AbstractJSONPropertyTest { + + protected static final String PART_KEYWORD = "json"; + protected static final String CONSTRUCT_KEYWORD = "construct"; + protected static final String TAG_KEYWORD = "json"; + protected static final String OBJPROP_KEYWORD = "object." + TAG_KEYWORD; + protected static final String RANDOM_JSON = "{\"whatever\":\"wherever\"}"; + + protected static Node node; + protected static Template template; + protected static Integer constructId; + + @ClassRule + public static DBTestContext testContext = new DBTestContext(); + + public static Collection data() { + Collection data = new ArrayList<>(); + for (Entry schemaRestriction : Map.of( + "", RANDOM_JSON, + "{\"type\":\"array\",\"items\":{\"type\":\"string\"}}", "[\"one\", \"two\", \"three\"]", + "{\"type\":\"object\",\"properties\":{\"firstName\":{\"type\":\"string\"},\"lastName\":{\"type\":\"string\"},\"middleName\":{\"type\":\"string\"}},\"required\":[\"firstName\",\"lastName\"]}", "{\"firstName\":\"Mickey\", \"lastName\":\"Mouse\"}" + ).entrySet()) { + data.add(new Object[] { schemaRestriction.getKey(), schemaRestriction.getValue() }); + } + return data; + } + + @BeforeClass + public static void setupOnce() throws NodeException { + testContext.getContext().getTransaction().commit(); + + // basic setup + node = supply(() -> createNode()); + constructId = supply(() -> createConstruct(node, JSONPartType.class, CONSTRUCT_KEYWORD, PART_KEYWORD)); + template = supply(() -> { + Template template = createTemplate(node.getFolder(), "Template"); + template.save(); + return template; + }); + } +} diff --git a/cms-core/src/test/java/com/gentics/contentnode/tests/rest/JSONContentTagTest.java b/cms-core/src/test/java/com/gentics/contentnode/tests/rest/JSONContentTagTest.java new file mode 100644 index 0000000000..f8596bcde0 --- /dev/null +++ b/cms-core/src/test/java/com/gentics/contentnode/tests/rest/JSONContentTagTest.java @@ -0,0 +1,227 @@ +package com.gentics.contentnode.tests.rest; + +import static com.gentics.contentnode.factory.Trx.consume; +import static com.gentics.contentnode.factory.Trx.operate; +import static com.gentics.contentnode.factory.Trx.supply; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.clear; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createTemplateTag; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.getPartTypeId; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.update; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collection; + +import org.apache.commons.lang3.StringUtils; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import com.gentics.api.lib.exception.NodeException; +import com.gentics.contentnode.factory.Transaction; +import com.gentics.contentnode.factory.TransactionManager; +import com.gentics.contentnode.factory.object.ObjectModificationException; +import com.gentics.contentnode.object.Construct; +import com.gentics.contentnode.object.LocalizableNodeObject; +import com.gentics.contentnode.object.Page; +import com.gentics.contentnode.object.Part; +import com.gentics.contentnode.object.TemplateTag; +import com.gentics.contentnode.object.Value; +import com.gentics.contentnode.object.parttype.JSONPartType; +import com.gentics.contentnode.tests.utils.TestedType; + +@RunWith(value = Parameterized.class) +public class JSONContentTagTest extends AbstractJSONPropertyTest { + + protected static TemplateTag templateTag; + + @Parameters(name = "{index}: restriction {0}") + public static Collection data() { + return AbstractJSONPropertyTest.data(); + } + + @BeforeClass + public static void setupOnce() throws NodeException { + AbstractJSONPropertyTest.setupOnce(); + + templateTag = supply(() -> createTemplateTag(constructId, TAG_KEYWORD, true, false)); + template = update(template, template -> { + template.getTemplateTags().put(templateTag.getName(), templateTag); + }); + } + + @Parameter(0) + public String jsonSchemaRestriction; + + @Parameter(1) + public String correctAnswer; + + @Before + public void setup() throws NodeException { + operate(trx -> { + Construct construct = trx.getObject(Construct.class, constructId, true); + for (Part part: construct.getParts()) { + if (part.getKeyname().equals(PART_KEYWORD)) { + part.setInfoText(jsonSchemaRestriction); + break; + } + } + construct.save(); + trx.commit(false); + }); + operate(() -> clear(node)); + } + + @Test + public void testContentRight() throws NodeException { + // create tested object + LocalizableNodeObject testedObject = supply(() -> TestedType.page.create(node.getFolder(), template)); + + testedObject = update(((Page) testedObject), update -> { + Value value = (Value) update.getContentTag(TAG_KEYWORD).getValues().get(PART_KEYWORD); + value.setValueText(correctAnswer); + value.save(); + }); + // assert all tags available and filled + consume(o -> { + assertThat(((Value) ((Page) o).getContentTag(TAG_KEYWORD).getValues().get(PART_KEYWORD)).getValueText()).isEqualTo(correctAnswer); + }, testedObject); + } + + @Test(expected = ObjectModificationException.class) + public void testContentWrong() throws NodeException { + // create tested object + LocalizableNodeObject testedObject = supply(() -> TestedType.page.create(node.getFolder(), template)); + + testedObject = update(((Page) testedObject), update -> { + Value value = (Value) update.getContentTag(TAG_KEYWORD).getValues().get(PART_KEYWORD); + value.setValueText(RANDOM_JSON); + value.save(); + }); + // assert all tags available and filled + consume(o -> { + if (StringUtils.isNotBlank(jsonSchemaRestriction)) { + assertThat(((Value) ((Page) o).getContentTag(TAG_KEYWORD).getValues().get(PART_KEYWORD)).getValueText()).isNotEqualTo(RANDOM_JSON); + } else { + assertThat(((Value) ((Page) o).getContentTag(TAG_KEYWORD).getValues().get(PART_KEYWORD)).getValueText()).isEqualTo(RANDOM_JSON); + // Test passed + throw new ObjectModificationException(PART_KEYWORD, PART_KEYWORD, PART_KEYWORD); + } + }, testedObject); + } + + @Test + public void testConstructDefaultValueRight() throws NodeException { + Construct construct1 = supply(() -> { + Transaction t = TransactionManager.getCurrentTransaction(); + + Construct construct = t.createObject(Construct.class); + construct.setAutoEnable(true); + construct.setKeyword("construct_with_" + TAG_KEYWORD); + construct.setName("construct_with_" + TAG_KEYWORD, 1); + if (node != null) { + construct.getNodes().add(node); + } + + Part part = t.createObject(Part.class); + part.setEditable(1); + part.setHidden(false); + part.setKeyname(TAG_KEYWORD); + part.setName(TAG_KEYWORD, 1); + part.setPartTypeId(getPartTypeId(JSONPartType.class)); + + Value value = t.createObject(Value.class); + value.setValueText(correctAnswer); + part.setDefaultValue(value); + + construct.getParts().add(part); + + construct.save(); + t.commit(false); + return construct; + }); + consume(o -> { + assertThat((o.getParts().get(0).getDefaultValue()).getValueText()).isEqualTo(correctAnswer); + }, construct1); + } + + + @Test(expected = ObjectModificationException.class) + public void testConstructDefaultValueWrong() throws NodeException { + Construct construct1 = supply(() -> { + Transaction t = TransactionManager.getCurrentTransaction(); + + Construct construct = t.createObject(Construct.class); + construct.setAutoEnable(true); + construct.setKeyword("construct_with_" + TAG_KEYWORD); + construct.setName("construct_with_" + TAG_KEYWORD, 1); + if (node != null) { + construct.getNodes().add(node); + } + + Part part = t.createObject(Part.class); + part.setInfoText(jsonSchemaRestriction); + part.setEditable(1); + part.setHidden(false); + part.setKeyname(TAG_KEYWORD); + part.setName(TAG_KEYWORD, 1); + part.setPartTypeId(getPartTypeId(JSONPartType.class)); + + Value value = t.createObject(Value.class); + value.setValueText(RANDOM_JSON); + part.setDefaultValue(value); + + construct.getParts().add(part); + + construct.save(); + t.commit(false); + return construct; + }); + consume(o -> { + if (StringUtils.isNotBlank(jsonSchemaRestriction)) { + assertThat((o.getParts().get(0).getDefaultValue()).getValueText()).isNotEqualTo(RANDOM_JSON); + } else { + assertThat((o.getParts().get(0).getDefaultValue()).getValueText()).isEqualTo(RANDOM_JSON); + // Test passed + throw new ObjectModificationException(PART_KEYWORD, PART_KEYWORD, PART_KEYWORD); + } + }, construct1); + } + + @Test + public void testTemplateRight() throws NodeException { + template = update(template, template -> { + Value value = (Value) template.getTemplateTags().get(TAG_KEYWORD).getTagValues().get(PART_KEYWORD); + value.setValueText(correctAnswer); + value.save(); + }); + + // assert all tags available and filled + consume(o -> { + assertThat(((Value) o.getTemplateTag(TAG_KEYWORD).getValues().get(PART_KEYWORD)).getValueText()).isEqualTo(correctAnswer); + }, template); + } + + @Test(expected = ObjectModificationException.class) + public void testTemplateWrong() throws NodeException { + template = update(template, template -> { + Value value = (Value) template.getTemplateTags().get(TAG_KEYWORD).getTagValues().get(PART_KEYWORD); + value.setValueText(RANDOM_JSON); + value.save(); + }); + + // assert all tags available and filled + consume(o -> { + if (StringUtils.isNotBlank(jsonSchemaRestriction)) { + assertThat(((Value) o.getTemplateTag(TAG_KEYWORD).getValues().get(PART_KEYWORD)).getValueText()).isNotEqualTo(RANDOM_JSON); + } else { + assertThat(((Value) o.getTemplateTag(TAG_KEYWORD).getValues().get(PART_KEYWORD)).getValueText()).isEqualTo(RANDOM_JSON); + // Test passed + throw new ObjectModificationException(PART_KEYWORD, PART_KEYWORD, PART_KEYWORD); + } + }, template); + } +} diff --git a/cms-core/src/test/java/com/gentics/contentnode/tests/rest/JSONObjectPropertyTest.java b/cms-core/src/test/java/com/gentics/contentnode/tests/rest/JSONObjectPropertyTest.java new file mode 100644 index 0000000000..15a681ebf2 --- /dev/null +++ b/cms-core/src/test/java/com/gentics/contentnode/tests/rest/JSONObjectPropertyTest.java @@ -0,0 +1,207 @@ +package com.gentics.contentnode.tests.rest; + +import static com.gentics.contentnode.factory.Trx.consume; +import static com.gentics.contentnode.factory.Trx.operate; +import static com.gentics.contentnode.factory.Trx.supply; +import static com.gentics.contentnode.tests.assertj.GCNAssertions.assertThat; +import static com.gentics.contentnode.tests.utils.Builder.update; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.clear; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.createObjectPropertyDefinition; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.getPartType; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import com.gentics.api.lib.exception.NodeException; +import com.gentics.contentnode.factory.object.ObjectModificationException; +import com.gentics.contentnode.object.Construct; +import com.gentics.contentnode.object.ContentFile; +import com.gentics.contentnode.object.Folder; +import com.gentics.contentnode.object.LocalizableNodeObject; +import com.gentics.contentnode.object.ObjectTag; +import com.gentics.contentnode.object.ObjectTagContainer; +import com.gentics.contentnode.object.ObjectTagDefinition; +import com.gentics.contentnode.object.Page; +import com.gentics.contentnode.object.Part; +import com.gentics.contentnode.object.parttype.JSONPartType; +import com.gentics.contentnode.rest.model.Property; +import com.gentics.contentnode.rest.model.Tag; +import com.gentics.contentnode.rest.model.Tag.Type; +import com.gentics.contentnode.rest.model.request.FileSaveRequest; +import com.gentics.contentnode.rest.model.request.FolderSaveRequest; +import com.gentics.contentnode.rest.model.request.ImageSaveRequest; +import com.gentics.contentnode.rest.model.request.PageSaveRequest; +import com.gentics.contentnode.rest.model.response.GenericResponse; +import com.gentics.contentnode.rest.model.response.ResponseCode; +import com.gentics.contentnode.tests.utils.TestedType; + +/** + * Test updating JSON tag part for different restriction / target usecases + */ +@RunWith(value = Parameterized.class) +public class JSONObjectPropertyTest extends AbstractJSONPropertyTest { + + protected static List properties = new ArrayList<>(); + + @Parameters(name = "{index}: type {0} restriction {1}") + public static Collection data() { + Collection data = new ArrayList<>(); + Collection abstractData = AbstractJSONPropertyTest.data(); + for (TestedType type : TestedType.values()) { + abstractData.forEach(dataItem -> { + data.add(new Object[] { type, dataItem[0], dataItem[1] }); + }); + } + return data; + } + + @BeforeClass + public static void setupOnce() throws NodeException { + AbstractJSONPropertyTest.setupOnce(); + + for (int type : Arrays.asList(Folder.TYPE_FOLDER, Page.TYPE_PAGE, ContentFile.TYPE_FILE, ContentFile.TYPE_IMAGE)) { + properties.add(supply(() -> createObjectPropertyDefinition(type, constructId, TAG_KEYWORD, OBJPROP_KEYWORD))); + } + } + + @Parameter(0) + public TestedType type; + + @Parameter(1) + public String jsonSchemaRestriction; + + @Parameter(2) + public String correctAnswer; + + @Before + public void setup() throws NodeException { + operate(trx -> { + Construct construct = trx.getObject(Construct.class, constructId, true); + for (Part part: construct.getParts()) { + if (part.getKeyname().equals(PART_KEYWORD)) { + part.setInfoText(jsonSchemaRestriction); + break; + } + } + construct.save(); + trx.commit(false); + }); + operate(() -> clear(node)); + + for (ObjectTagDefinition def : properties) { + update(def, update -> { + update.getNodes().clear(); + update.getNodes().add(node); + }).build(); + } + } + + @Test + public void testCoreRight() throws NodeException { + testInput(correctAnswer, false); + } + + @Test(expected = ObjectModificationException.class) + public void testCoreWrong() throws NodeException { + testInput(RANDOM_JSON, false); + + // The no-restriction case should pass here + if (correctAnswer.equals(RANDOM_JSON)) { + throw new ObjectModificationException(PART_KEYWORD, PART_KEYWORD, PART_KEYWORD); + } + } + + @Test + public void testRestRight() throws NodeException { + GenericResponse response = (GenericResponse) testInput(correctAnswer, true); + assertThat(response.getResponseInfo().getResponseCode()).as("Response code").isEqualTo(ResponseCode.OK); + } + + @Test + public void testRestWrong() throws NodeException { + GenericResponse response = (GenericResponse) testInput(RANDOM_JSON, true); + assertThat(response.getResponseInfo().getResponseCode()).as("Response code").isEqualTo(correctAnswer.equals(RANDOM_JSON) ? ResponseCode.OK : ResponseCode.INVALIDDATA); + } + + protected Object testInput(String input, boolean useRest) throws NodeException { + // create tested object + LocalizableNodeObject testedObject = supply(() -> type.create(node.getFolder(), template)); + + if (useRest) { + return supply(() -> { + Property prop = new Property(); + prop.setType(com.gentics.contentnode.rest.model.Property.Type.RICHTEXT); + prop.setStringValue(input); + Tag tag = new Tag(); + tag.setType(Type.OBJECTTAG); + tag.setName(TAG_KEYWORD); + tag.setActive(true); + tag.setProperties(Map.of(PART_KEYWORD, prop)); + Object request; + switch(type) { + case file: + com.gentics.contentnode.rest.model.File file = new com.gentics.contentnode.rest.model.File(); + file.setId(testedObject.getId()); + file.setTags(Map.of(OBJPROP_KEYWORD, tag)); + FileSaveRequest fileRequest = new FileSaveRequest(); + fileRequest.setFile(file); + request = fileRequest; + break; + case folder: + com.gentics.contentnode.rest.model.Folder folder = new com.gentics.contentnode.rest.model.Folder(); + folder.setId(testedObject.getId()); + folder.setTags(Map.of(OBJPROP_KEYWORD, tag)); + FolderSaveRequest folderRequest = new FolderSaveRequest(); + folderRequest.setFolder(folder); + request = folderRequest; + break; + case image: + com.gentics.contentnode.rest.model.Image image = new com.gentics.contentnode.rest.model.Image(); + image.setId(testedObject.getId()); + image.setTags(Map.of(OBJPROP_KEYWORD, tag)); + ImageSaveRequest imageRequest = new ImageSaveRequest(); + imageRequest.setImage(image); + request = imageRequest; + break; + case page: + com.gentics.contentnode.rest.model.Page page = new com.gentics.contentnode.rest.model.Page(); + page.setId(testedObject.getId()); + page.setTags(Map.of(OBJPROP_KEYWORD, tag)); + PageSaveRequest pageRequest = new PageSaveRequest(); + pageRequest.setPage(page); + request = pageRequest; + break; + default: + throw new IllegalStateException("Unsupported type"); + } + return type.save(testedObject, request); + }); + } else { + // fill object tags + ObjectTagContainer container = update(((ObjectTagContainer) testedObject), update -> { + ObjectTag tag = update.getObjectTag(TAG_KEYWORD); + getPartType(JSONPartType.class, tag, PART_KEYWORD).setText(input); + tag.setEnabled(true); + }).build(); + + // assert all object tags available and filled + consume(o -> { + assertThat(o.getObjectTag(TAG_KEYWORD)).isNotNull().hasPartWithText(JSONPartType.class, PART_KEYWORD, input); + }, container); + + return container; + } + } +} diff --git a/cms-core/src/test/java/com/gentics/contentnode/tests/rest/tag/TagModelTest.java b/cms-core/src/test/java/com/gentics/contentnode/tests/rest/tag/TagModelTest.java index 65f6a80db5..422fa7012f 100644 --- a/cms-core/src/test/java/com/gentics/contentnode/tests/rest/tag/TagModelTest.java +++ b/cms-core/src/test/java/com/gentics/contentnode/tests/rest/tag/TagModelTest.java @@ -61,6 +61,7 @@ import com.gentics.contentnode.object.parttype.ImageHeightPartType; import com.gentics.contentnode.object.parttype.ImageURLPartType; import com.gentics.contentnode.object.parttype.ImageWidthPartType; +import com.gentics.contentnode.object.parttype.JSONPartType; import com.gentics.contentnode.object.parttype.JavaEditorPartType; import com.gentics.contentnode.object.parttype.LongHTMLPartType; import com.gentics.contentnode.object.parttype.LongHTMLTextPartType; @@ -116,6 +117,8 @@ public class TagModelTest { private final static String TEST_TEXT = "This is the test text"; + private final static String TEST_JSON = "{\"testKey\": \"testValue\",\"whatever\":\"wherever\"}"; + private final static String formUuid = UUID.randomUUID().toString(); private static Map, Integer> constructMap = new HashMap<>(); @@ -138,6 +141,13 @@ public class TagModelTest { assertAllElseNull(property, "stringValue"); }; + private final static Consumer jsonTextAsserter = property -> { + assertThat(property).as("Property") + .hasFieldOrPropertyWithValue("type", Type.RICHTEXT) + .hasFieldOrPropertyWithValue("stringValue", TEST_JSON); + assertAllElseNull(property, "stringValue"); + }; + private final static Consumer pageUrlAsserter = property -> { assertThat(property).as("Property") .hasFieldOrPropertyWithValue("type", Type.PAGE) @@ -418,6 +428,7 @@ public enum TestDefinition { LongHTMLTextPartType(LongHTMLTextPartType.class, (Consumer) partType -> partType.setText(TEST_TEXT), richTextAsserter), LongHTMLPartType(LongHTMLPartType.class, (Consumer) partType -> partType.setText(TEST_TEXT), richTextAsserter), JavaEditorPartType(JavaEditorPartType.class, (Consumer) partType -> partType.setText(TEST_TEXT), richTextAsserter), + JSONEditorPartType(JSONPartType.class, (Consumer) partType -> partType.setText(TEST_JSON), jsonTextAsserter), DHTMLPartType(DHTMLPartType.class, (Consumer) partType -> partType.setText(TEST_TEXT), richTextAsserter), PageURLPartTypeInternal(PageURLPartType.class, (Consumer) partType -> { partType.setTargetPage(targetPage); diff --git a/cms-core/src/test/java/com/gentics/contentnode/tests/utils/ContentNodeTestDataUtils.java b/cms-core/src/test/java/com/gentics/contentnode/tests/utils/ContentNodeTestDataUtils.java index a6a7c75e3f..27723e8254 100644 --- a/cms-core/src/test/java/com/gentics/contentnode/tests/utils/ContentNodeTestDataUtils.java +++ b/cms-core/src/test/java/com/gentics/contentnode/tests/utils/ContentNodeTestDataUtils.java @@ -1,6 +1,8 @@ package com.gentics.contentnode.tests.utils; +import static com.gentics.contentnode.tests.utils.Builder.create; import static com.gentics.contentnode.tests.utils.ContentNodeRESTUtils.getTemplateResource; +import static com.gentics.contentnode.tests.utils.ContentNodeTestDataUtils.getPartTypeId; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -102,6 +104,7 @@ import com.gentics.contentnode.object.parttype.TemplateTagPartType; import com.gentics.contentnode.object.parttype.TextPartType; import com.gentics.contentnode.object.parttype.VelocityPartType; +import com.gentics.contentnode.object.parttype.handlebars.HandlebarsPartType; import com.gentics.contentnode.perm.PermissionStore; import com.gentics.contentnode.rest.model.request.FileSaveRequest; import com.gentics.contentnode.rest.model.request.FolderSaveRequest; @@ -300,6 +303,39 @@ public static int createVelocityConstruct(Node node, String constructKeyword, St return ObjectTransformer.getInt(construct.getId(), 0); } + /** + * Create a construct containing a Handlebars part + * @param node node + * @param constructKeyword construct keyword + * @param partKeyword part keyword for the velocity part + * @return construct id + * @throws NodeException + */ + public static int createHandlebarsConstruct(Node node, String constructKeyword, String partKeyword) throws NodeException { + Transaction t = TransactionManager.getCurrentTransaction(); + Construct construct = t.createObject(Construct.class); + construct.setAutoEnable(true); + construct.setKeyword(constructKeyword); + construct.setName(constructKeyword, 1); + if (node != null) { + construct.getNodes().add(node); + } + + Part hbsPart = t.createObject(Part.class); + hbsPart.setEditable(1); + hbsPart.setHidden(false); + hbsPart.setKeyname(partKeyword); + hbsPart.setName(partKeyword, 1); + hbsPart.setPartTypeId(getPartTypeId(HandlebarsPartType.class)); + t.createObject(Value.class).setPart(hbsPart); + construct.getParts().add(hbsPart); + + construct.save(); + t.commit(false); + + return ObjectTransformer.getInt(construct.getId(), 0); + } + /** * Create a default breadcrumb construct * @param node node diff --git a/cms-core/src/test/java/com/gentics/contentnode/tests/utils/TestedType.java b/cms-core/src/test/java/com/gentics/contentnode/tests/utils/TestedType.java index 136653f9f6..f6b5b50232 100644 --- a/cms-core/src/test/java/com/gentics/contentnode/tests/utils/TestedType.java +++ b/cms-core/src/test/java/com/gentics/contentnode/tests/utils/TestedType.java @@ -12,8 +12,12 @@ import com.gentics.contentnode.object.NodeObject; import com.gentics.contentnode.object.Page; import com.gentics.contentnode.object.Template; +import com.gentics.contentnode.rest.model.request.FileSaveRequest; import com.gentics.contentnode.rest.model.request.FolderMoveRequest; +import com.gentics.contentnode.rest.model.request.FolderSaveRequest; +import com.gentics.contentnode.rest.model.request.ImageSaveRequest; import com.gentics.contentnode.rest.model.request.ObjectMoveRequest; +import com.gentics.contentnode.rest.model.request.PageSaveRequest; import com.gentics.contentnode.rest.model.response.GenericResponse; import com.gentics.contentnode.rest.resource.impl.FileResourceImpl; import com.gentics.contentnode.rest.resource.impl.FolderResourceImpl; @@ -142,6 +146,28 @@ public GenericResponse restoreFromWastebin(NodeObject object) throws NodeExcepti } } + /** + * Save the object + * + * @param object + * @param request + * @return + */ + public GenericResponse save(LocalizableNodeObject object, Object request) { + switch(this) { + case file: + return new FileResourceImpl().save(object.getId(), (FileSaveRequest) request); + case folder: + return new FolderResourceImpl().save(object.getId().toString(), (FolderSaveRequest) request); + case image: + return new ImageResourceImpl().save(object.getId(), (ImageSaveRequest) request); + case page: + return new PageResourceImpl().save(object.getId().toString(), (PageSaveRequest) request); + default: + return null; + } + } + /** * Publish the object, if it is a page * @param object object to publish diff --git a/cms-core/src/test/java/com/gentics/contentnode/testutils/mesh/MeshContext.java b/cms-core/src/test/java/com/gentics/contentnode/testutils/mesh/MeshContext.java index 740c7186e9..c82b6e4cfd 100644 --- a/cms-core/src/test/java/com/gentics/contentnode/testutils/mesh/MeshContext.java +++ b/cms-core/src/test/java/com/gentics/contentnode/testutils/mesh/MeshContext.java @@ -45,7 +45,7 @@ public class MeshContext extends GenericContainer { /** * Currently tested Mesh Version */ - public final static String TESTED_MESH_VERSION = "3.2.12"; + public final static String TESTED_MESH_VERSION = "3.3.0"; public static final String MESH_DATABASE_SUFFIX = "_mesh"; diff --git a/cms-core/src/test/resources/com/gentics/contentnode/tests/publish/mesh/micronode_44.json b/cms-core/src/test/resources/com/gentics/contentnode/tests/publish/mesh/micronode_44.json new file mode 100644 index 0000000000..02becd62ae --- /dev/null +++ b/cms-core/src/test/resources/com/gentics/contentnode/tests/publish/mesh/micronode_44.json @@ -0,0 +1 @@ +[{"uuid":"#MICRONODE_UUID#","microschema":{"name":"test_construct","uuid":"#MICROSCHEMA_UUID#","version":"1.0","versionUuid":"#VERSION_UUID#"},"fields":{"part":{"whatever":"wherever"}}}] \ No newline at end of file diff --git a/cms-oss-changelog/src/changelog/entries/2026/04/8751.GPU-2187.enhancement b/cms-oss-changelog/src/changelog/entries/2026/04/8751.GPU-2187.enhancement new file mode 100644 index 0000000000..6e18598023 --- /dev/null +++ b/cms-oss-changelog/src/changelog/entries/2026/04/8751.GPU-2187.enhancement @@ -0,0 +1 @@ +A new content field type, `JSON`, has been introduced, offering support for storing and navigation on a JSON object/array content. \ No newline at end of file diff --git a/cms-oss-doc/src/main/source/feature_mesh_contentrepository.textile b/cms-oss-doc/src/main/source/feature_mesh_contentrepository.textile index 1629b00d3d..08f35ebd6f 100644 --- a/cms-oss-doc/src/main/source/feature_mesh_contentrepository.textile +++ b/cms-oss-doc/src/main/source/feature_mesh_contentrepository.textile @@ -178,6 +178,7 @@ The fields in the schemas are defined by the tagmap entries of the _Mesh Content | Date | date | | Boolean | boolean | | "Micronode":#micronode-fields | micronode | +| "JSON":#json-fields | json | NOTE: When a new Mesh ContentRepository is created, it will have some default tagmap entries. Some of them are Reserved and cannot be modified or removed, because they are absolutely necessary. @@ -218,6 +219,53 @@ Tagmap entries of type "Micronode" will publish the editable data contained in t field of type Micronode. When using e.g. @page.tags@, the field must be multivalue and will contain all tags of the page as list of micronodes. +h5. JSON Fields + +These tagmap entries will validate its contents against "JavaScript Object Notation":https://www.json.org/json-en.html . Additionally, one may limit the allowed content with JSON schemas. As the simplest example of allowance limits, the following JSON schemas depict the person data, and a simple array of strings: + + +{ + "type" : "object", + "properties" : { + "firstName" : { + "type" : "string" + }, + "lastName" : { + "type" : "string" + } + }, + "required" : [ + "firstName", + "lastName" + ] + +} + + +{ + "type" : "array", + "items" : { + "type" : "string" + } +} + + +So the following contents will pass the corresponding schema filters: + + +{ + "firstName" : "John", + "lastName" : "Doe" +} + + +[ + "a string", + "another string" +] + +For looking up into the full possibilities of JSON schemas filtering one may inspect the link:https://json-schema.org/[official JSON Schema guide]. + h6. Transformation of tagtypes When a Mesh ContentRepository contains at least one tagmap entry of type "Micronode", all constructs that meet all of the following requirements will be transformed into microschemas: diff --git a/cms-oss-doc/src/main/source/gcn_part_types.textile b/cms-oss-doc/src/main/source/gcn_part_types.textile index 00c816066a..4437a88073 100644 --- a/cms-oss-doc/src/main/source/gcn_part_types.textile +++ b/cms-oss-doc/src/main/source/gcn_part_types.textile @@ -22,6 +22,7 @@ h3. Overview | 40 | Node | The node part is a drop-down menu with the nodes available to the user. | | 42 | Form | The CMS Form part lets the user select a form from the CMS. See "Gentics CMS Forms":feature_forms.html for details. **NOTE:** Parts of this type can only be used in the new Editor User Interface. | | 43 | Handlebars | The Handlebars part type renders its content with the Handlebars rendering engine. See "Rendering with Handlebars":handlebars.html for details. | +| 44 | JSON | The JSON part type offers storing, validation and navigation/resolving over structured data in the "JavaScript Object Notation":https://www.json.org/json-en.html content. | NOTE: Inline editable parts - which are transformed to editables in Aloha Editor - should only use the part type @HTML@. @@ -53,6 +54,55 @@ Example: #if( $cms.page.object.myprop.toString() == "test" ) +h3. JSON Part Type + +This part type is designed to work with content of the "JavaScript Object Notation":https://www.json.org/json-en.html . The tag content of this type is validated against JSON syntax. + +Optionally, one may limit the allowed content with JSON schemas. As the simplest example of such allowance limits, the following JSON schemas depict the person data, and a simple array of strings: + + +{ + "type" : "object", + "properties" : { + "firstName" : { + "type" : "string" + }, + "lastName" : { + "type" : "string" + } + }, + "required" : [ + "firstName", + "lastName" + ] + +} + + +{ + "type" : "array", + "items" : { + "type" : "string" + } +} + + +So the following contents will pass the corresponding schema filters: + + +{ + "firstName" : "John", + "lastName" : "Doe" +} + + +[ + "a string", + "another string" +] + +For looking up into the full possibilities of JSON schemas filtering one may inspect the link:https://json-schema.org/[official JSON Schema guide]. + h3. Custom Part Type Implementations Custom part types can be created using the AbstractExtensiblePartType class. diff --git a/cms-oss-doc/src/main/source/handlebars.textile b/cms-oss-doc/src/main/source/handlebars.textile index afbf4d4016..5615073609 100644 --- a/cms-oss-doc/src/main/source/handlebars.textile +++ b/cms-oss-doc/src/main/source/handlebars.textile @@ -33,6 +33,8 @@ The context of a rendered Handlebars part will always contain the base object +c INFO: To find out all possible attributes that can be resolved from the base object "cms", you can use something like +{{#each cms}}{{ @key }}:{{ this }}
{{/each}}+. This obviously does not only work for "cms", but for every object in the context. +INFO: The inner content of "JSON":gcn_part_type.html part type can also be resolved, including array indices. For instance, resolving +{{cms.tag.parts.otherpart.whoever.0.whatever}}+ on +{"whatever":{"whoever": "wherever"}, "whoever":[{"whatever": "wherever"}]+ results in +wherever+ being rendered. + WARNING: Unlike the Velocity Part Type, the Handlebars Part Type does not support "shortcuts" when resolving. So for example to resolve a specific part in the current tag, it is not allowed to omit the "parts" keyword in handlebars: +{{ cms.tag.parts.html }}+ will resolve to the part "html" in the current tag, whereas +{{ cms.tag.html }}+ will *not* work. Hence, if you need to, say, render a copyright of a referenced image, set withing an image's object property, the Velocity's definition +{{cms.tag.parts.img.object.copyright}}+ turns into longer variants for the Handlebars: @@ -65,6 +67,7 @@ h4. Additional Helpers | "gtx_gis":#gtx_gis | Include a resized image into the output | ++ | | "gtx_form":#gtx_form | Include a form | +{{gtx_form cms.tag.parts.form type="java" }}+ | | "gtx_i18n":#gtx_i18n | Include a translation in the language of the currently rendered page | +{{gtx_i18n de="Deutsch" en="English" default="Other language" }}+ | +| "gtx_json_path":#gtx_json_path | Navigate over a "JSON":gcn_part_type.html part type using "JSONPath":https://goessner.net/articles/JsonPath/ | +{{gtx_json_path cms.tag.parts.json "$.objarray[0].objfield"}}+ | h5. gtx_render @@ -266,6 +269,42 @@ Finally it will fall back to "default". If no translation is found, it will render nothing. +h5. gtx_json_path + +Helps navigating over a "JSON":gcn_part_type.html part type using "JSONPath":https://goessner.net/articles/JsonPath/ notation input. + +General usage is: + + +{{gtx_json_path [object] [JSONPath-string]}} + + +Where the parameters are + +|_. Name |_. Description |_. Default | +| +object+ | JSON part type object to resolve from | _mandatory_ | +| +JSONPath-string+ | JSONPath notated string | _mandatory_ | + +An example of rendering a JSON part type "otherpart" of a current tag, having content + + +{"whatever":{"whoever": "wherever"}, "whoever":[{"whatever": "wherever"}], "wherever": ["what", "who", "where"]} + + +using the following calls + + +{{gtx_json_path cms.tag.parts.otherpart "$[*]['whoever']"}}, {{gtx_json_path cms.tag.parts.otherpart "$.whoever[0].whatever"}}, {{gtx_json_path cms.tag.parts.otherpart "$.wherever[1]"}} + + +will result in + + +"wherever, wherever, who" + + +Please refer to the "implementing library documentation":https://github.com/json-path/JsonPath/blob/master/README.md for the whole possibilities of JSONPath. + h4. Custom helpers For implementation of custom logic, it is possible to create helpers as Javascript files in devtool packages. diff --git a/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/ContentRepositoryFragmentEntryModel.java b/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/ContentRepositoryFragmentEntryModel.java index 6c18e48fb5..2a1870c06f 100644 --- a/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/ContentRepositoryFragmentEntryModel.java +++ b/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/ContentRepositoryFragmentEntryModel.java @@ -5,6 +5,7 @@ import jakarta.xml.bind.annotation.XmlRootElement; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; /** @@ -62,6 +63,8 @@ public class ContentRepositoryFragmentEntryModel implements Serializable { private String micronodeFilter; + private String jsonSchemaFilter; + /** * Local ID * @return local ID @@ -365,4 +368,22 @@ public String getMicronodeFilter() { public void setMicronodeFilter(String micronodeFilter) { this.micronodeFilter = micronodeFilter; } + + /** + * Get the JSON schema filter (for entries of type JSON) + * @return filter + */ + @JsonProperty("jsonSchemaFilter") + public String getJSONSchemaFilter() { + return jsonSchemaFilter; + } + + /** + * Set the JSON schema filter + * @param jsonSchemaFilter filter + */ + @JsonProperty("jsonSchemaFilter") + public void setJSONSchemaFilter(String jsonSchemaFilter) { + this.jsonSchemaFilter = jsonSchemaFilter; + } } diff --git a/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/Part.java b/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/Part.java index d723dd4312..4f2729e0e5 100644 --- a/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/Part.java +++ b/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/Part.java @@ -7,6 +7,7 @@ import jakarta.xml.bind.annotation.XmlRootElement; +import com.fasterxml.jackson.annotation.JsonProperty; import com.gentics.contentnode.rest.model.Property.Type; /** @@ -84,6 +85,11 @@ public class Part implements Serializable { */ private RegexModel regex; + /** + * JSON schema + */ + private String jsonSchema; + /** * Flag to hide part in tag editor */ @@ -537,4 +543,24 @@ public Part setName(String name, String language) { this.nameI18n.put(language, name); return this; } + + /** + * Get the JSON schema + * @return + */ + @JsonProperty("jsonSchema") + public String getJSONSchema() { + return jsonSchema; + } + + /** + * Set the JSON schema + * @param jsonSchema + * @return + */ + @JsonProperty("jsonSchema") + public Part setJSONSchema(String jsonSchema) { + this.jsonSchema = jsonSchema; + return this; + } } diff --git a/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/Property.java b/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/Property.java index 6a7f4e1cce..3647ec5cbe 100644 --- a/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/Property.java +++ b/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/Property.java @@ -53,6 +53,7 @@ public static Type get(int typeId) { case 27: // DHTML Editor case 36: // HTML (custom form) case 43: // Handlebars + case 44: // JSON return RICHTEXT; case 4: // URL (page) diff --git a/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/TagmapEntryModel.java b/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/TagmapEntryModel.java index 22443c1b15..6c99abb0e9 100644 --- a/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/TagmapEntryModel.java +++ b/cms-restapi/src/main/java/com/gentics/contentnode/rest/model/TagmapEntryModel.java @@ -4,6 +4,7 @@ import jakarta.xml.bind.annotation.XmlRootElement; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; @XmlRootElement @@ -53,6 +54,8 @@ public class TagmapEntryModel implements Serializable { protected String micronodeFilter; + protected String jsonSchemaFilter; + protected String fragmentName; /** @@ -381,6 +384,24 @@ public void setMicronodeFilter(String micronodeFilter) { this.micronodeFilter = micronodeFilter; } + /** + * Get the JSONSchema filter (for entries of type "JSON") + * @return filter + */ + @JsonProperty("jsonSchemaFilter") + public String getJSONSchemaFilter() { + return jsonSchemaFilter; + } + + /** + * Set the JSON schema filter + * @param jsonSchemaFilter filter + */ + @JsonProperty("jsonSchemaFilter") + public void setJSONSchemaFilter(String jsonSchemaFilter) { + this.jsonSchemaFilter = jsonSchemaFilter; + } + /** * Name of the CR Fragment, this entry belongs to. Null, if the entry directly belongs to the ContentRepository. * @return fragment name or null diff --git a/cms-ui/apps/admin-ui/public/i18n/de.json b/cms-ui/apps/admin-ui/public/i18n/de.json index e0886de97c..8013df9c5d 100644 --- a/cms-ui/apps/admin-ui/public/i18n/de.json +++ b/cms-ui/apps/admin-ui/public/i18n/de.json @@ -649,6 +649,7 @@ "noIndex": "Keine Indexierung", "elasticsearch": "Suchindex-Einstellungen", "micronodefilter": "Tagtyp-Filter", + "jsonschemafilter": "JSON-Schema-Filter", "fragmentname": "Fragmentname", "objecttype_folder": "Ordner", "objecttype_page": "Seite", @@ -663,6 +664,7 @@ "attributetype_date": "Datum", "attributetype_boolean": "Boolean", "attributetype_micronode": "Micronode", + "attributetype_json": "JSON", "title_reference_settings": "Referenz", "title_elasticsearch_settings": "Elasticsearch", "title_misc_settings": "Sonstige" @@ -1077,6 +1079,7 @@ "fieldType_node": "Node", "fieldType_number": "Nummer", "fieldType_string": "Text", + "fieldType_json": "JSON", "user_name": "Benutzername", "admin_permissions": "Administrator Berechtigungen", "force_password_reset": "Benutzer muss Passwort beim nächsten Login ändern", @@ -1368,6 +1371,7 @@ "mandatory": "Notwendig", "type": "Typ", "defaultProperty": "Standard-Befüllung", + "jsonSchema": "Erlaubte JSON schema(s)", "regex": "Validator (RegEx)", "hideInEditor": "Im Editor versteckt", "options": "OptionenName", @@ -1441,6 +1445,7 @@ "tagPartType_41": "Formular Liste", "tagPartType_42": "Formular", "tagPartType_43": "Handlebars", + "tagPartType_44": "JSON", "validator_name_number_natural": "Zahl (natürlich)", "validator_name_email_address": "E-Mail Adresse", "validator_name_must_not_be_emty": "Nicht leer", diff --git a/cms-ui/apps/admin-ui/public/i18n/en.json b/cms-ui/apps/admin-ui/public/i18n/en.json index b3039a8c80..f08379953e 100644 --- a/cms-ui/apps/admin-ui/public/i18n/en.json +++ b/cms-ui/apps/admin-ui/public/i18n/en.json @@ -649,6 +649,7 @@ "noIndex": "No indexing", "elasticsearch": "Search index settings", "micronodefilter": "Tagtype filter", + "jsonschemafilter": "JSON schema filter", "fragmentname": "Fragment name", "objecttype_folder": "Folder", "objecttype_page": "Page", @@ -663,6 +664,7 @@ "attributetype_date": "Date", "attributetype_boolean": "Boolean", "attributetype_micronode": "Micronode", + "attributetype_json": "JSON", "title_reference_settings": "Reference", "title_elasticsearch_settings": "Elasticsearch", "title_misc_settings": "Miscellaneous" @@ -1077,6 +1079,7 @@ "fieldType_node": "Node", "fieldType_number": "Number", "fieldType_string": "String", + "fieldType_json": "JSON", "user_name": "Username", "admin_permissions": "Administrator permissions", "force_password_reset": "Force user to change password on login", @@ -1368,6 +1371,7 @@ "mandatory": "Mandatory", "type": "Type", "defaultProperty": "Default properties", + "jsonSchema": "Allowed JSON schema(s)", "regex": "Validator (RegEx)", "hideInEditor": "Hide in editor", "options": "Options", @@ -1441,6 +1445,7 @@ "tagPartType_41": "Form List", "tagPartType_42": "Form", "tagPartType_43": "Handlebars", + "tagPartType_44": "JSON", "validator_name_number_natural": "Number (natural)", "validator_name_email_address": "E-mail address", "validator_name_must_not_be_emty": "May not be empty", diff --git a/cms-ui/apps/admin-ui/src/app/common/models/tag-map.ts b/cms-ui/apps/admin-ui/src/app/common/models/tag-map.ts index b49620ee00..007217c800 100644 --- a/cms-ui/apps/admin-ui/src/app/common/models/tag-map.ts +++ b/cms-ui/apps/admin-ui/src/app/common/models/tag-map.ts @@ -37,6 +37,10 @@ export const TAGMAP_ENTRY_ATTRIBUTES: { id: TagmapEntryAttributeTypes; label: st id: TagmapEntryAttributeTypes.MICRONODE, label: 'tagmap_entry.attributetype_micronode', }, + { + id: TagmapEntryAttributeTypes.JSON, + label: 'tagmap_entry.attributetype_json', + }, ]; export const TAGMAP_ENTRY_ATTRIBUTES_MAP = TAGMAP_ENTRY_ATTRIBUTES.reduce((acc, entry) => { diff --git a/cms-ui/apps/admin-ui/src/app/common/utils/form-utils/form-validation-helpers.ts b/cms-ui/apps/admin-ui/src/app/common/utils/form-utils/form-validation-helpers.ts index b271d8c7e4..0d7bfba79f 100644 --- a/cms-ui/apps/admin-ui/src/app/common/utils/form-utils/form-validation-helpers.ts +++ b/cms-ui/apps/admin-ui/src/app/common/utils/form-utils/form-validation-helpers.ts @@ -1,5 +1,5 @@ import { AbstractControl, ValidatorFn } from '@angular/forms'; -import { isEqual } from'lodash-es' +import { isEqual } from 'lodash-es'; import { combineLatest, Observable } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; @@ -78,7 +78,7 @@ export function createBlacklistValidator(blacklist: any[] | (() => any[])): Vali }; } } - } + }; } type CompareFn = (a: any, b: any) => boolean; @@ -112,7 +112,7 @@ export function createWhitelistValidator(whitelist: any[] | (() => any[]), compa } return null; - } + }; } type InvalidLanguagesCallback = (invalidLanguages: string[]) => any; @@ -170,7 +170,7 @@ export function createI18nRequiredValidator(requiredLanguages: string[] | (() => } return hasError ? err : null; - } + }; } export function createNotNullValidator(): ValidatorFn { @@ -180,5 +180,5 @@ export function createNotNullValidator(): ValidatorFn { } return { null: true }; - } + }; } diff --git a/cms-ui/apps/admin-ui/src/app/features/construct/components/construct-editor/construct-editor.component.ts b/cms-ui/apps/admin-ui/src/app/features/construct/components/construct-editor/construct-editor.component.ts index dae437f215..93097c8ef9 100644 --- a/cms-ui/apps/admin-ui/src/app/features/construct/components/construct-editor/construct-editor.component.ts +++ b/cms-ui/apps/admin-ui/src/app/features/construct/components/construct-editor/construct-editor.component.ts @@ -21,7 +21,7 @@ function normalizeTagPart(rawPart: TagPart): TagPart { }; } - if (part.typeId === TagPartType.HtmlLong || part.typeId === TagPartType.Handlebars) { + if (part.typeId === TagPartType.HtmlLong || part.typeId === TagPartType.Handlebars || part.typeId === TagPartType.Json) { return { ...part, defaultProperty: part.defaultProperty || null, @@ -88,7 +88,7 @@ function tagPartValidator(parts: typeof CONTROL_INVALID_VALUE | ((TagPart | type templateUrl: './construct-editor.component.html', styleUrls: ['./construct-editor.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false + standalone: false, }) export class ConstructEditorComponent extends BaseEntityEditorComponent implements OnInit { diff --git a/cms-ui/apps/admin-ui/src/app/features/construct/components/construct-part-properties/construct-part-properties.component.html b/cms-ui/apps/admin-ui/src/app/features/construct/components/construct-part-properties/construct-part-properties.component.html index 787282ad37..7cbc5b30e0 100644 --- a/cms-ui/apps/admin-ui/src/app/features/construct/components/construct-part-properties/construct-part-properties.component.html +++ b/cms-ui/apps/admin-ui/src/app/features/construct/components/construct-part-properties/construct-part-properties.component.html @@ -142,6 +142,14 @@ formControlName="defaultProperty" > } + + @if (form.controls.jsonSchema.enabled) { + + } diff --git a/cms-ui/apps/admin-ui/src/app/features/construct/components/construct-part-properties/construct-part-properties.component.ts b/cms-ui/apps/admin-ui/src/app/features/construct/components/construct-part-properties/construct-part-properties.component.ts index 8cbd9b2139..53df8565ba 100644 --- a/cms-ui/apps/admin-ui/src/app/features/construct/components/construct-part-properties/construct-part-properties.component.ts +++ b/cms-ui/apps/admin-ui/src/app/features/construct/components/construct-part-properties/construct-part-properties.component.ts @@ -25,6 +25,7 @@ import { TagPartValidatorConfigs, TagPartValidatorId, TagPropertyType, + MarkupLanguageType, } from '@gentics/cms-models'; import { BaseFormPropertiesComponent, FormProperties, generateFormProvider, generateValidatorProvider, setControlsEnabled } from '@gentics/ui-core'; import { @@ -73,6 +74,9 @@ export interface TagPartPropertiesFormData { /** FROM TAG EDITOR */ defaultProperty: TagPartProperty; + + /** JSON schema */ + jsonSchema: string; } export enum ConstructPartPropertiesMode { @@ -96,6 +100,7 @@ export const VIABLE_CONSTRUCT_PART_TYPES: TagPartType[] = [ TagPartType.SelectMultiple, TagPartType.Velocity, TagPartType.Handlebars, + TagPartType.Json, ]; export const REMOVED_CONSTRUCT_PART_TYPES: TagPartType[] = [ @@ -263,6 +268,8 @@ export class ConstructPartPropertiesComponent // Tag-Editor defaultProperty: new FormControl(null), + /** JSON schema (for JSON type only) */ + jsonSchema: new FormControl(null), // ///// TYPE-DEPENDANT: // ///// ONLY for HTML/Text inputs @@ -294,6 +301,7 @@ export class ConstructPartPropertiesComponent let selectSettingsEnabled = false; let overviewSettingsEnabled = false; let defaultPropertyEnabled = false; + let jsonSchemaEnabled = false; switch (value?.typeId) { case TagPartType.SelectSingle: @@ -302,6 +310,9 @@ export class ConstructPartPropertiesComponent selectSettingsEnabled = true; break; + case TagPartType.Json: + jsonSchemaEnabled = true; + // eslint-disable-next-line no-fallthrough case TagPartType.DataSource: defaultPropertyEnabled = true; break; @@ -344,6 +355,7 @@ export class ConstructPartPropertiesComponent setControlsEnabled(this.form, ['selectSettings'], selectSettingsEnabled, options); setControlsEnabled(this.form, ['overviewSettings'], overviewSettingsEnabled, options); setControlsEnabled(this.form, ['defaultProperty'], defaultPropertyEnabled, options); + setControlsEnabled(this.form, ['jsonSchema'], jsonSchemaEnabled, options); } protected assembleValue(formData: TagPartPropertiesFormData): TagPartPropertiesFormData { @@ -359,7 +371,9 @@ export class ConstructPartPropertiesComponent output.name = this.value?.name; output.type = this.value?.type; } - + if (formData.typeId === TagPartType.Json) { + (output as TagPartPropertiesFormData).markupLanguageId = MarkupLanguageType.JSON; + } return output as TagPartPropertiesFormData; } } diff --git a/cms-ui/apps/admin-ui/src/app/features/construct/components/json-part-fill/json-part-fill.component.ts b/cms-ui/apps/admin-ui/src/app/features/construct/components/json-part-fill/json-part-fill.component.ts index 30c795ad49..278232f33e 100644 --- a/cms-ui/apps/admin-ui/src/app/features/construct/components/json-part-fill/json-part-fill.component.ts +++ b/cms-ui/apps/admin-ui/src/app/features/construct/components/json-part-fill/json-part-fill.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleCha import { AbstractControl, UntypedFormControl } from '@angular/forms'; import { TagPartProperty, TagPropertyType } from '@gentics/cms-models'; import { BaseFormElementComponent, generateFormProvider } from '@gentics/ui-core'; -import { isEqual } from'lodash-es' +import { isEqual } from 'lodash-es'; import { combineLatest } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs/operators'; @@ -31,8 +31,8 @@ const validateTagPartProperty: (type: TagPropertyType) => any = (type) => { } return null; - } -} + }; +}; @Component({ selector: 'gtx-json-part-fill', @@ -40,7 +40,7 @@ const validateTagPartProperty: (type: TagPropertyType) => any = (type) => { styleUrls: ['./json-part-fill.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, providers: [generateFormProvider(JsonPartFillComponent)], - standalone: false + standalone: false, }) export class JsonPartFillComponent extends BaseFormElementComponent implements OnInit, OnChanges { @@ -57,7 +57,7 @@ export class JsonPartFillComponent extends BaseFormElementComponent status === 'VALID' ? value : null), distinctUntilChanged(isEqual), - ).subscribe(value => { + ).subscribe((value) => { if (value != null) { const parsed = JSON.parse(value); this.triggerChange(parsed); diff --git a/cms-ui/apps/admin-ui/src/app/features/construct/components/list-part-fill/list-part-fill.component.ts b/cms-ui/apps/admin-ui/src/app/features/construct/components/list-part-fill/list-part-fill.component.ts index 1da14b4ecb..01aeff401a 100644 --- a/cms-ui/apps/admin-ui/src/app/features/construct/components/list-part-fill/list-part-fill.component.ts +++ b/cms-ui/apps/admin-ui/src/app/features/construct/components/list-part-fill/list-part-fill.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ListTagPartProperty, OrderedUnorderedListTagPartProperty, TagPropertyType } from '@gentics/cms-models'; import { BaseFormElementComponent, generateFormProvider } from '@gentics/ui-core'; -import { pick } from'lodash-es' +import { pick } from 'lodash-es'; @Component({ selector: 'gtx-list-part-fill', @@ -9,7 +9,7 @@ import { pick } from'lodash-es' styleUrls: ['./list-part-fill.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, providers: [generateFormProvider(ListPartFillComponent)], - standalone: false + standalone: false, }) export class ListPartFillComponent extends BaseFormElementComponent { diff --git a/cms-ui/apps/admin-ui/src/app/features/mesh-browser/components/mesh-browser-editor/mesh-browser-editor.component.ts b/cms-ui/apps/admin-ui/src/app/features/mesh-browser/components/mesh-browser-editor/mesh-browser-editor.component.ts index 9937bd12c6..e2b11e6bf0 100644 --- a/cms-ui/apps/admin-ui/src/app/features/mesh-browser/components/mesh-browser-editor/mesh-browser-editor.component.ts +++ b/cms-ui/apps/admin-ui/src/app/features/mesh-browser/components/mesh-browser-editor/mesh-browser-editor.component.ts @@ -137,6 +137,10 @@ export class MeshBrowserEditorComponent implements OnChanges { value = this.getImagePath(field.name); break; } + case FieldType.JSON: { + value = JSON.stringify(value); + break; + } } return { diff --git a/cms-ui/apps/admin-ui/src/app/mesh/components/schema-field-properties/schema-field-properties.component.html b/cms-ui/apps/admin-ui/src/app/mesh/components/schema-field-properties/schema-field-properties.component.html index 922efff05c..ccae5cb670 100644 --- a/cms-ui/apps/admin-ui/src/app/mesh/components/schema-field-properties/schema-field-properties.component.html +++ b/cms-ui/apps/admin-ui/src/app/mesh/components/schema-field-properties/schema-field-properties.component.html @@ -71,6 +71,14 @@ @if (form.get('allow').enabled) {
@switch (effectiveType) { + @case (FieldType.JSON) { + + } @case (FieldType.NODE) { > implements OnInit { @@ -85,6 +85,7 @@ export class CreateUpdateTagmapEntryModalComponent extends BaseModal{{ 'tagmap_entry.title_misc_settings' | gtxI18n }}
05.08.2026 - 3.2.13 + f-gpu-2210-SNAPSHOT 3.1.10