Repository: kestra-io/kestra
Issue: Handle property Duration on documentation 
Labels: documentation
backend

For now, displayed like that : 
![image](https://user-images.githubusercontent.com/2064609/97891196-c245fd80-1d2e-11eb-9cc4-12180b6a68eb.png)


Issue comments:
where is this documentation plugin located ? there is no such information in the core.
Edit : I think I found one plugin where there is such information.

Pull request review comments:

Patch that resolved the issue:
diff --git a/core/src/main/java/io/kestra/core/docs/ClassPluginDocumentation.java b/core/src/main/java/io/kestra/core/docs/ClassPluginDocumentation.java
index b2dc65e370..3d976edf69 100644
--- a/core/src/main/java/io/kestra/core/docs/ClassPluginDocumentation.java
+++ b/core/src/main/java/io/kestra/core/docs/ClassPluginDocumentation.java
@@ -1,9 +1,9 @@
 package io.kestra.core.docs;
 
 import com.google.common.base.CaseFormat;
+import io.kestra.core.plugins.RegisteredPlugin;
 import lombok.*;
 import org.apache.commons.lang3.ArrayUtils;
-import io.kestra.core.plugins.RegisteredPlugin;
 
 import java.util.*;
 import java.util.stream.Collectors;
@@ -24,6 +24,7 @@ public class ClassPluginDocumentation<T> {
     private String docDescription;
     private String docBody;
     private List<ExampleDoc> docExamples;
+    private Map<String, Object> defs = new TreeMap<>();
     private Map<String, Object> inputs = new TreeMap<>();
     private Map<String, Object> outputs = new TreeMap<>();
     private Map<String, Object> propertiesSchema;
@@ -45,6 +46,15 @@ private ClassPluginDocumentation(RegisteredPlugin plugin, Class<? extends T> cls
         this.propertiesSchema = JSON_SCHEMA_GENERATOR.properties(baseCls, cls);
         this.outputsSchema = JSON_SCHEMA_GENERATOR.outputs(baseCls, cls);
 
+        if (this.propertiesSchema.containsKey("$defs")) {
+            this.defs.putAll((Map<String, Object>) this.propertiesSchema.get("$defs"));
+            this.propertiesSchema.remove("$defs");
+        }
+        if (this.outputsSchema.containsKey("$defs")) {
+            this.defs.putAll((Map<String, Object>) this.outputsSchema.get("$defs"));
+            this.outputsSchema.remove("$defs");
+        }
+
         this.docDescription = this.propertiesSchema.containsKey("title") ? (String) this.propertiesSchema.get("title") : null;;
         this.docBody = this.propertiesSchema.containsKey("description") ? (String) this.propertiesSchema.get("description") : null;
 
@@ -109,7 +119,9 @@ private static String flattenKey(String current, String parent) {
 
     @SuppressWarnings("unchecked")
     private static Map<String, Object> properties(Map<String, Object> props) {
-        return (Map<String, Object>) props.get("properties");
+        Map<String, Object> properties = (Map<String, Object>) props.get("properties");
+
+        return properties != null ? properties : new HashMap<>();
     }
 
     @SuppressWarnings("unchecked")
diff --git a/core/src/main/java/io/kestra/core/docs/DocumentationGenerator.java b/core/src/main/java/io/kestra/core/docs/DocumentationGenerator.java
index 2010b96e29..ee55511c3f 100644
--- a/core/src/main/java/io/kestra/core/docs/DocumentationGenerator.java
+++ b/core/src/main/java/io/kestra/core/docs/DocumentationGenerator.java
@@ -5,18 +5,19 @@
 import com.github.jknack.handlebars.Template;
 import com.github.jknack.handlebars.helper.*;
 import com.google.common.base.Charsets;
-import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableMap;
-import lombok.SneakyThrows;
-import org.apache.commons.io.IOUtils;
 import io.kestra.core.models.conditions.Condition;
 import io.kestra.core.models.tasks.Task;
 import io.kestra.core.models.triggers.AbstractTrigger;
 import io.kestra.core.plugins.RegisteredPlugin;
 import io.kestra.core.runners.handlebars.helpers.DateHelper;
 import io.kestra.core.runners.handlebars.helpers.JsonHelper;
+import io.kestra.core.runners.handlebars.helpers.OtherStringsHelper;
 import io.kestra.core.serializers.JacksonMapper;
 import io.kestra.core.utils.Slugify;
+import lombok.SneakyThrows;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
 
 import java.io.IOException;
 import java.io.InputStream;
@@ -33,6 +34,20 @@ abstract public class DocumentationGenerator {
         .registerHelpers(EachHelper.class)
         .registerHelpers(LogHelper.class)
         .registerHelpers(StringHelpers.class)
+        .registerHelpers(OtherStringsHelper.class)
+        .registerHelper("definitionName", (context, options) -> {
+            String s = StringUtils.substringAfterLast(context.toString(), ".");
+            if (s.contains("-")) {
+                String s1 = StringUtils.substringAfter(s, "-");
+
+                try {
+                    Integer.parseInt(s1);
+                } catch (NumberFormatException e) {
+                    s = s1;
+                }
+            }
+            return s;
+        })
         .registerHelpers(UnlessHelper.class)
         .registerHelpers(WithHelper.class)
         .registerHelpers(DateHelper.class)
diff --git a/core/src/main/java/io/kestra/core/docs/JsonSchemaGenerator.java b/core/src/main/java/io/kestra/core/docs/JsonSchemaGenerator.java
index 8c05417d17..31912a4b72 100644
--- a/core/src/main/java/io/kestra/core/docs/JsonSchemaGenerator.java
+++ b/core/src/main/java/io/kestra/core/docs/JsonSchemaGenerator.java
@@ -1,26 +1,31 @@
 package io.kestra.core.docs;
 
+import com.fasterxml.classmate.ResolvedType;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
 import com.github.victools.jsonschema.generator.*;
+import com.github.victools.jsonschema.generator.impl.DefinitionKey;
+import com.github.victools.jsonschema.generator.naming.DefaultSchemaDefinitionNamingStrategy;
 import com.github.victools.jsonschema.module.jackson.JacksonModule;
 import com.github.victools.jsonschema.module.javax.validation.JavaxValidationModule;
 import com.github.victools.jsonschema.module.javax.validation.JavaxValidationOption;
 import com.github.victools.jsonschema.module.swagger2.Swagger2Module;
 import com.google.common.collect.ImmutableMap;
-import io.swagger.v3.oas.annotations.media.Schema;
 import io.kestra.core.models.annotations.Plugin;
 import io.kestra.core.models.annotations.PluginProperty;
 import io.kestra.core.models.tasks.Output;
 import io.kestra.core.serializers.JacksonMapper;
+import io.micronaut.core.annotation.Nullable;
+import io.swagger.v3.oas.annotations.media.Schema;
 
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
 import java.lang.reflect.ParameterizedType;
+import java.time.Duration;
 import java.util.*;
 import java.util.stream.Collectors;
-import io.micronaut.core.annotation.Nullable;
 
 public class JsonSchemaGenerator {
     public <T> Map<String, Object> properties(Class<T> base, Class<? extends T> cls) {
@@ -68,7 +73,9 @@ private <T> Map<String, Object> generate(Class<? extends T> cls, @Nullable Class
                 JavaxValidationOption.INCLUDE_PATTERN_EXPRESSIONS
             ))
             .with(new Swagger2Module())
-            .with(Option.INLINE_ALL_SCHEMAS)
+            .with(Option.DEFINITIONS_FOR_ALL_OBJECTS)
+            .with(Option.DEFINITION_FOR_MAIN_SCHEMA)
+            .with(Option.PLAIN_DEFINITION_KEYS)
             .with(Option.ALLOF_CLEANUP_AT_THE_END);
 
         // base is passed, we don't return base properties
@@ -78,6 +85,42 @@ private <T> Map<String, Object> generate(Class<? extends T> cls, @Nullable Class
                 .withIgnoreCheck(fieldScope -> fieldScope.getDeclaringType().getTypeName().equals(base.getName()));
         }
 
+        // def name
+        configBuilder.forTypesInGeneral()
+            .withDefinitionNamingStrategy(new DefaultSchemaDefinitionNamingStrategy() {
+                @Override
+                public String getDefinitionNameForKey(DefinitionKey key, SchemaGenerationContext context) {
+                    TypeContext typeContext = context.getTypeContext();
+                    ResolvedType type = key.getType();
+                    return typeContext.getFullTypeDescription(type);
+                }
+
+                @Override
+                public String adjustNullableName(DefinitionKey key, String definitionName, SchemaGenerationContext context) {
+                    return definitionName;
+                }
+            });
+
+        // inline some type
+        configBuilder.forTypesInGeneral()
+            .withCustomDefinitionProvider(new CustomDefinitionProviderV2() {
+                @Override
+                public CustomDefinition provideCustomSchemaDefinition(ResolvedType javaType, SchemaGenerationContext context) {
+                    if (javaType.isInstanceOf(Map.class) || javaType.isInstanceOf(Enum.class)) {
+                        ObjectNode definition = context.createStandardDefinition(javaType, this);
+                        return new CustomDefinition(definition, true);
+                    } else if (javaType.isInstanceOf(Duration.class)) {
+                        ObjectNode definitionReference = context
+                            .createDefinitionReference(context.getTypeContext().resolve(String.class))
+                            .put("format", "duration");
+
+                        return new CustomDefinition(definitionReference, true);
+                    } else {
+                        return null;
+                    }
+                }
+            });
+
         // default value
         configBuilder.forFields().withDefaultResolver(target -> {
             if (target.getOverriddenType() != null) {
@@ -149,51 +192,41 @@ private <T> Map<String, Object> generate(Class<? extends T> cls, @Nullable Class
                 }
             }
 
-            return JacksonMapper.toMap(flattenAllOf(objectNode));
+            return JacksonMapper.toMap(extractMainRef(objectNode));
         } catch (IllegalArgumentException e) {
             throw new IllegalArgumentException("Unable to generate jsonschema for '" + cls.getName() + "'", e);
         }
     }
 
 
-    private ObjectNode flattenAllOf(ObjectNode objectNode) {
-        JsonNode allOf = objectNode.get("allOf");
+    private ObjectNode extractMainRef(ObjectNode objectNode) {
+        TextNode ref = (TextNode) objectNode.get("$ref");
+        ObjectNode defs = (ObjectNode) objectNode.get("$defs");
 
-        if (allOf == null) {
-            return objectNode;
+        if (ref == null) {
+            throw new IllegalArgumentException("Missing $ref");
         }
+        String mainClassName = ref.asText().substring(ref.asText().lastIndexOf("/") + 1);
 
-        ArrayList<JsonNode> schema = new ArrayList<>();
-        allOf.forEach(schema::add);
+        if (mainClassName.endsWith("-2")) {
+            mainClassName = mainClassName.substring(0, mainClassName.length() - 2);
+            JsonNode mainClassDef = defs.get(mainClassName + "-1");
 
-        // fields
-        JsonNode props = splitAllOf(schema,  false);
-        objectNode.remove("allOf");
+            objectNode.set("properties", mainClassDef.get("properties"));
 
-        props.fields().forEachRemaining(entry -> {
-            objectNode.set(entry.getKey(), entry.getValue());
-        });
+            defs.remove(mainClassName + "-1");
+            defs.remove(mainClassName + "-2");
+        } else {
+            JsonNode mainClassDef = defs.get(mainClassName);
+            defs.remove(mainClassName);
+            objectNode.set("properties", mainClassDef.get("properties"));
+        }
 
-        // const
-        JsonNode type = splitAllOf(schema,  true);
-        type.get("properties").fields().forEachRemaining(entry -> {
-            ((ObjectNode) objectNode.get("properties")).set(entry.getKey(), entry.getValue());
-        });
+        objectNode.remove("$ref");
 
         return objectNode;
     }
 
-    private static JsonNode splitAllOf(ArrayList<JsonNode> schema, boolean typeConstant) {
-        return schema
-            .stream()
-            .filter(jsonNode -> (jsonNode.has("properties") &&
-                jsonNode.get("properties").has("type") &&
-                jsonNode.get("properties").get("type").has("const")) == typeConstant
-            )
-            .findFirst()
-            .orElseThrow();
-    }
-
     private Object buildDefaultInstance(Class<?> cls) {
         try {
             Method builderMethod = cls.getMethod("builder");
@@ -207,7 +240,6 @@ private Object buildDefaultInstance(Class<?> cls) {
         }
     }
 
-
     private Object defaultValue(Object instance, Class<?> cls, String fieldName) {
         try {
             Method field = cls.getMethod("get" + fieldName.substring(0, 1).toUpperCase(Locale.ROOT) + fieldName.substring(1));
diff --git a/core/src/main/java/io/kestra/core/runners/VariableRenderer.java b/core/src/main/java/io/kestra/core/runners/VariableRenderer.java
index 0b15c9dc02..f5e235da86 100644
--- a/core/src/main/java/io/kestra/core/runners/VariableRenderer.java
+++ b/core/src/main/java/io/kestra/core/runners/VariableRenderer.java
@@ -29,6 +29,7 @@ public VariableRenderer(ApplicationContext applicationContext) {
             .registerHelpers(EachHelper.class)
             .registerHelpers(LogHelper.class)
             .registerHelpers(StringHelpers.class)
+            .registerHelpers(OtherStringsHelper.class)
             .registerHelpers(UnlessHelper.class)
             .registerHelpers(WithHelper.class)
             .registerHelpers(DateHelper.class)
diff --git a/core/src/main/java/io/kestra/core/runners/handlebars/helpers/OtherStringsHelper.java b/core/src/main/java/io/kestra/core/runners/handlebars/helpers/OtherStringsHelper.java
new file mode 100644
index 0000000000..1f71c76fcc
--- /dev/null
+++ b/core/src/main/java/io/kestra/core/runners/handlebars/helpers/OtherStringsHelper.java
@@ -0,0 +1,47 @@
+package io.kestra.core.runners.handlebars.helpers;
+
+import com.github.jknack.handlebars.Helper;
+import com.github.jknack.handlebars.Options;
+import org.apache.commons.lang3.StringUtils;
+
+import static org.apache.commons.lang3.Validate.validIndex;
+
+public enum OtherStringsHelper implements Helper<Object> {
+    substringBefore {
+        @Override
+        public CharSequence apply(final Object value, final Options options) {
+            validIndex(options.params, 0, "Required valid separator: ");
+            String seps = options.param(0);
+
+            return StringUtils.substringBefore(value.toString(), seps);
+        }
+    },
+    substringAfter {
+        @Override
+        public CharSequence apply(final Object value, final Options options) {
+            validIndex(options.params, 0, "Required valid separator: ");
+            String seps = options.param(0);
+
+            return StringUtils.substringAfter(value.toString(), seps);
+        }
+    },
+    substringBeforeLast {
+        @Override
+        public CharSequence apply(final Object value, final Options options) {
+            validIndex(options.params, 0, "Required valid separator: ");
+            String seps = options.param(0);
+
+            return StringUtils.substringBeforeLast(value.toString(), seps);
+        }
+    },
+    substringAfterLast {
+        @Override
+        public CharSequence apply(final Object value, final Options options) {
+            validIndex(options.params, 0, "Required valid separator: ");
+            String seps = options.param(0);
+
+            return StringUtils.substringAfterLast(value.toString(), seps);
+        }
+    },
+}
+
diff --git a/core/src/main/resources/docs/task.hbs b/core/src/main/resources/docs/task.hbs
index dd3d10e198..7eb8826ee7 100644
--- a/core/src/main/resources/docs/task.hbs
+++ b/core/src/main/resources/docs/task.hbs
@@ -28,14 +28,22 @@ icon: {{ icon }}
 
 {{~/inline~}}
 
+{{~#*inline "type"~}}
+{{~#if $ref~}}
+[{{~ definitionName (substring $ref 8) ~}}](#{{~ lower (definitionName (substring $ref 8)) ~}})
+{{~else~}}
+{{~ type ~}}
+{{~/if}}
+{{~/inline~}}
+
 {{~#*inline "fieldType" ~}}
     {{~#if $deprecated }}
 🔒 *Deprecated*
     {{~/if}}
 
-* **Type:** =={{ type }}==
+* **Type:** =={{> type }}==
     {{~#if items }}
-* **SubType:** =={{ items.type }}==
+* **SubType:** =={{> type items }}==
     {{~/if~}}
     {{~#if additionalProperties }}
 * **SubType:** =={{ additionalProperties.type }}==
@@ -83,6 +91,10 @@ icon: {{ icon }}
     * **Maximum:** `&lt; {{ exclusiveMaximum }}`
 {{/if~}}
 
+{{~#if format ~}}
+    * **Format:** `{{ format }}`
+{{/if~}}
+
 
 {{~/inline~}}
 {{!-- {{                 Main doc                 }} --}}
@@ -115,7 +127,7 @@ type: "{{cls}}"
 
 
 {{!-- {{                 Inputs                 }} --}}
-## Inputs
+## Properties
 {{#if inputs}}
 {{#each inputs as | input fullName | ~}}
 ### `{{fullName}}`
@@ -135,5 +147,22 @@ type: "{{cls}}"
 {{> fieldType }}
 {{> fieldDetail }}
 {{> body }}
+{{/each ~}}
+{{/if}}
+
+{{!-- {{              Definitions                }} --}}
+{{#if defs}}
+## Definitions
+{{#each defs  as | def fullName | ~}}
+{{#if def.properties}}
+### `{{ definitionName fullName }}`
+
+{{#each def.properties  as | output name | ~}}
+#### `{{ name }}`
+{{> fieldType }}
+{{> fieldDetail }}
+{{> body }}
+{{/each}}
+{{/if}}
 {{/each}}
 {{/if}}
diff --git a/ui/src/components/graph/TreeNode.vue b/ui/src/components/graph/TreeNode.vue
index ca6d808a7b..77004869dd 100644
--- a/ui/src/components/graph/TreeNode.vue
+++ b/ui/src/components/graph/TreeNode.vue
@@ -32,7 +32,7 @@
                         v-if="task.description"
                         class="node-action"
                     >
-                        <markdown-tooltip :description="task.description" :id="hash" :modal="true" :title="task.id"/>
+                        <markdown-tooltip :description="task.description" :id="hash" :modal="true" :title="task.id" />
                     </b-button>
 
                     <sub-flow-link
diff --git a/ui/src/components/plugins/Plugin.vue b/ui/src/components/plugins/Plugin.vue
index 56e176f1a4..07471a031d 100644
--- a/ui/src/components/plugins/Plugin.vue
+++ b/ui/src/components/plugins/Plugin.vue
@@ -91,11 +91,20 @@
             background: $success;
             color: $white;
             font-size: $font-size-sm;
+            padding: 2px 8px 2px 8px;
+
+            * {
+                color: $white !important;
+            }
         }
 
         h2 {
             margin-top: $spacer * 2;
+            border-bottom: 1px solid $gray-500;
+            font-weight: bold;
+            color: $gray-700
         }
+
         h3 {
             code {
                 display: inline-block;
@@ -103,6 +112,14 @@
                 font-weight: 400;
             }
         }
+
+        h4 {
+            code {
+                display: inline-block;
+                font-size: $font-size-base * 1.00;
+                font-weight: 400;
+            }
+        }
     }
 
 </style>
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/PluginController.java b/webserver/src/main/java/io/kestra/webserver/controllers/PluginController.java
index debc392f99..7dfcf3157f 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/PluginController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/PluginController.java
@@ -73,7 +73,8 @@ public Doc pluginDocumentation(String cls) throws HttpStatusException, IOExcepti
             DocumentationGenerator.render(classPluginDocumentation),
             new Schema(
                 classPluginDocumentation.getPropertiesSchema(),
-                classPluginDocumentation.getOutputsSchema()
+                classPluginDocumentation.getOutputsSchema(),
+                classPluginDocumentation.getDefs()
             )
         );
     }
@@ -139,7 +140,6 @@ public static Plugin of(RegisteredPlugin registeredPlugin) {
                 ))
                 .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
 
-
             plugin.tasks = className(filter(registeredPlugin.getTasks()).toArray(Class[]::new));
             plugin.triggers = className(filter(registeredPlugin.getTriggers()).toArray(Class[]::new));
             plugin.conditions = className(filter(registeredPlugin.getConditions()).toArray(Class[]::new));
@@ -190,5 +190,6 @@ public static class PluginIcon {
     public static class Schema {
         private Map<String, Object> properties;
         private Map<String, Object> outputs;
+        private Map<String, Object> definitions;
     }
 }


Test patch:
diff --git a/core/src/test/java/io/kestra/core/docs/ClassPluginDocumentationTest.java b/core/src/test/java/io/kestra/core/docs/ClassPluginDocumentationTest.java
index c32d69f6e5..afae871f70 100644
--- a/core/src/test/java/io/kestra/core/docs/ClassPluginDocumentationTest.java
+++ b/core/src/test/java/io/kestra/core/docs/ClassPluginDocumentationTest.java
@@ -32,7 +32,7 @@ void tasks() throws URISyntaxException {
 
         assertThat(doc.getDocExamples().size(), is(2));
         assertThat(doc.getIcon(), is(notNullValue()));
-        assertThat(doc.getInputs().size(), is(5));
+        assertThat(doc.getInputs().size(), is(2));
 
         // simple
         assertThat(((Map<String, String>) doc.getInputs().get("format")).get("type"), is("string"));
@@ -41,22 +41,34 @@ void tasks() throws URISyntaxException {
         assertThat(((Map<String, String>) doc.getInputs().get("format")).get("description"), containsString("of this input"));
 
         // enum
-        assertThat(((List<String>) ((Map<String, Object>) doc.getInputs().get("childInput.childEnum")).get("enum")).size(), is(2));
-        assertThat(((List<String>) ((Map<String, Object>) doc.getInputs().get("childInput.childEnum")).get("enum")), containsInAnyOrder("VALUE_1", "VALUE_2"));
+        Map<String, Object> enumProperties = (Map<String, Object>) ((Map<String, Object>) ((Map<String, Object>) doc.getDefs().get("io.kestra.plugin.templates.ExampleTask-PropertyChildInput")).get("properties")).get("childEnum");
+        assertThat(((List<String>) enumProperties.get("enum")).size(), is(2));
+        assertThat(((List<String>) enumProperties.get("enum")), containsInAnyOrder("VALUE_1", "VALUE_2"));
+
+        Map<String, Object> childInput = (Map<String, Object>) ((Map<String, Object>) doc.getDefs().get("io.kestra.plugin.templates.ExampleTask-PropertyChildInput")).get("properties");
 
         // array
-        assertThat((String) ((Map<String, Object>) doc.getInputs().get("childInput.list")).get("type"), is("array"));
-        assertThat((String) ((Map<String, Object>) doc.getInputs().get("childInput.list")).get("title"), is("List of string"));
-        assertThat((Integer) ((Map<String, Object>) doc.getInputs().get("childInput.list")).get("minItems"), is(1));
-        assertThat(((Map<String, String>) ((Map<String, Object>) doc.getInputs().get("childInput.list")).get("items")).get("type"), is("string"));
+        Map<String, Object> childInputList = (Map<String, Object>) childInput.get("list");
+        assertThat((String) (childInputList).get("type"), is("array"));
+        assertThat((String) (childInputList).get("title"), is("List of string"));
+        assertThat((Integer) (childInputList).get("minItems"), is(1));
+        assertThat(((Map<String, String>) (childInputList).get("items")).get("type"), is("string"));
 
         // map
-        assertThat((String) ((Map<String, Object>) doc.getInputs().get("childInput.map")).get("type"), is("object"));
-        assertThat((Boolean) ((Map<String, Object>) doc.getInputs().get("childInput.map")).get("$dynamic"), is(true));
-        assertThat(((Map<String, String>) ((Map<String, Object>) doc.getInputs().get("childInput.map")).get("additionalProperties")).get("type"), is("number"));
+        Map<String, Object> childInputMap = (Map<String, Object>) childInput.get("map");
+        assertThat((String) (childInputMap).get("type"), is("object"));
+        assertThat((Boolean) (childInputMap).get("$dynamic"), is(true));
+        assertThat(((Map<String, String>) (childInputMap).get("additionalProperties")).get("type"), is("number"));
+
+        // output
+        Map<String, Object> childOutput = (Map<String, Object>) ((Map<String, Object>) doc.getDefs().get("io.kestra.plugin.templates.AbstractTask-OutputChild")).get("properties");
+        assertThat(((Map<String, String>) childOutput.get("value")).get("type"), is("string"));
+        assertThat(((Map<String, Object>) childOutput.get("outputChildMap")).get("type"), is("object"));
+        assertThat(((Map<String, String>)((Map<String, Object>) childOutput.get("outputChildMap")).get("additionalProperties")).get("$ref"), containsString("OutputMap"));
 
-        // map with object
-        assertThat(((Map<String, String>) ((Map<String, Object>) doc.getOutputs().get("childInput.outputChildMap")).get("additionalProperties")).get("type"), is("object"));
-        assertThat((((Map<String, Map<String, Map<String, String>>>) ((Map<String, Object>) doc.getOutputs().get("childInput.outputChildMap")).get("additionalProperties")).get("properties")).get("code").get("type"), is("integer"));
+        // output ref
+        Map<String, Object> outputMap = ((Map<String, Object>) ((Map<String, Object>) doc.getDefs().get("io.kestra.plugin.templates.AbstractTask-OutputMap")).get("properties"));
+        assertThat(outputMap.size(), is(2));
+        assertThat(((Map<String, Object>) outputMap.get("code")).get("type"), is("integer"));
     }
 }
diff --git a/core/src/test/java/io/kestra/core/docs/DocumentationGeneratorTest.java b/core/src/test/java/io/kestra/core/docs/DocumentationGeneratorTest.java
index 301e944a32..45a5cf16c8 100644
--- a/core/src/test/java/io/kestra/core/docs/DocumentationGeneratorTest.java
+++ b/core/src/test/java/io/kestra/core/docs/DocumentationGeneratorTest.java
@@ -30,6 +30,7 @@ void tasks() throws URISyntaxException, IOException {
 
         String render = DocumentationGenerator.render(doc);
 
+        System.out.println(render);
         assertThat(render, containsString("ExampleTask"));
         assertThat(render, containsString("`VALUE_1`"));
         assertThat(render, containsString("`VALUE_2`"));
diff --git a/core/src/test/java/io/kestra/core/docs/JsonSchemaGeneratorTest.java b/core/src/test/java/io/kestra/core/docs/JsonSchemaGeneratorTest.java
index b2fb8f3859..2a4f570be6 100644
--- a/core/src/test/java/io/kestra/core/docs/JsonSchemaGeneratorTest.java
+++ b/core/src/test/java/io/kestra/core/docs/JsonSchemaGeneratorTest.java
@@ -33,7 +33,7 @@ void tasks() throws URISyntaxException {
         Class<? extends Task> cls = scan.get(0).getTasks().get(0);
 
         Map<String, Object> generate = jsonSchemaGenerator.properties(Task.class, cls);
-        assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).size(), is(3));
+        assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).size(), is(2));
 
         Map<String, Object> format = properties(generate).get("format");
         assertThat(format.get("default"), is("{}"));
@@ -48,7 +48,7 @@ void bash() {
         Map<String, Object> generate = jsonSchemaGenerator.properties(Task.class, Bash.class);
 
         assertThat(generate, is(not(nullValue())));
-        assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).size(), is(10));
+        assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).size(), is(9));
 
         generate = jsonSchemaGenerator.outputs(Task.class, Bash.class);
 
diff --git a/core/src/test/java/io/kestra/core/runners/VariableRendererTest.java b/core/src/test/java/io/kestra/core/runners/VariableRendererTest.java
index 2d41321ca3..ad7b62e126 100644
--- a/core/src/test/java/io/kestra/core/runners/VariableRendererTest.java
+++ b/core/src/test/java/io/kestra/core/runners/VariableRendererTest.java
@@ -89,7 +89,6 @@ void firstDefined() throws IllegalVariableEvaluationException {
         });
     }
 
-
     @Test
     void firstDefinedEval() throws IllegalVariableEvaluationException {
         ImmutableMap<String, Object> vars = ImmutableMap.of(
@@ -125,4 +124,28 @@ void get() throws IllegalVariableEvaluationException {
             variableRenderer.render("{{ get missing }}", vars);
         });
     }
+
+    @Test
+    void substringBefore() throws IllegalVariableEvaluationException {
+        String render = variableRenderer.render("{{ substringBefore 'a.b.c' '.' }}", Map.of());
+        assertThat(render, is("a"));
+    }
+
+    @Test
+    void substringAfter() throws IllegalVariableEvaluationException {
+        String render = variableRenderer.render("{{ substringAfter 'a.b.c' '.' }}", Map.of());
+        assertThat(render, is("b.c"));
+    }
+
+    @Test
+    void substringBeforeLast() throws IllegalVariableEvaluationException {
+        String render = variableRenderer.render("{{ substringBeforeLast 'a.b.c' '.' }}", Map.of());
+        assertThat(render, is("a.b"));
+    }
+
+    @Test
+    void substringAfterLast() throws IllegalVariableEvaluationException {
+        String render = variableRenderer.render("{{ substringAfterLast 'a.b.c' '.' }}", Map.of());
+        assertThat(render, is("c"));
+    }
 }