Skip to content

Commit

Permalink
feat(generator): support validation annotations and provide docs (#1110)
Browse files Browse the repository at this point in the history
  • Loading branch information
chillleader authored Sep 6, 2023
1 parent 2b26922 commit 55200af
Show file tree
Hide file tree
Showing 8 changed files with 485 additions and 10 deletions.
251 changes: 251 additions & 0 deletions connector-sdk/element-template-generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
# Element Template Generator

This is a tool to generate [element templates](https://docs.camunda.io/docs/components/connectors/custom-built-connectors/connector-templates/)
based on the Connector Java code.

**Note:** only outbound Connector templates are currently supported.

## Prerequisites

To make use of the Template Generator, your Connector must:
- Define data models for Connector inputs by means of a Java class that is used to deserialize the input JSON;
- Generally rely on the `bindVariables` method of the `OutboundConnectorContext` (and not the low-level `getVariables` method);
- Be annotated with the `@ElementTemplate` annotation defined in this module.

The points above define the minimum requirements.
You can customize and extend the functionality by using more annotations (see below).

## Usage

For most use cases, we recommend using the [Maven plugin](../element-template-generator-maven-plugin) to invoke the Template Generator.

The Generator can be invoked directly from Java code as well. To do so, create an instance of the
`OutboundElementTemplateGenerator` class and invoke its `generate` method.

```java
OutboundElementTemplateGenerator generator = new OutboundElementTemplateGenerator();
OutboundElementTemplate template = generator.generate(MyConnectorFunction.class);
```

The resulting object can be serialized using Jackson (pre-configured) or any other JSON library.

## Property types

The Template Generator works out-of-the-box for most data models. If you only use the `@ElementTemplate` annotation
without any additional configuration, it will convert the Connector input data model to an Element Template
using the default rules.

| Java field type | Generated template property type |
|-----------------------------------------|----------------------------------|
| `String` | `String` |
| Number primitives and boxed types | `String` |
| `Boolean` | `Boolean` |
| Enums | `Dropdown` |
| Collections, Maps, `Object`, `JsonNode` | `String` with `feel: required` |

Everything else gets converted to a `String` by default.

The property type can be customized by using the `@TemplateProperty` annotation:

```java
@TemplateProperty(type = "Text")
private String value;
```

Now the property will be of type `Text` instead of `String`.

## Property names and labels

By default, the property name and label are derived from the Java field name.
Property ID will be the same as the field name, and the label will be the field name with the first letter capitalized
and spaces inserted between words. For example, the field `myField` will be converted to a property with ID `myField`
and label `My field`.

You can customize the property name and label by using the `@TemplateProperty` annotation:

```java
@TemplateProperty(id = "myField", label = "My field")
private String value;
```

## Nested properties

The Template Generator supports nested properties. For example, if your Connector input data model looks like this:

```java
public class MyConnectorInput {
private String name;
private MyNestedInput nested;
}

public class MyNestedInput {
private String value;
}
```

The generated Element Template will contain two properties:

```json
{
"properties": [
{
"id": "name",
"label": "Name",
"binding": {
"name": "name",
"type": "zeebe:input"
},
"type": "String"
},
{
"id": "nested.value",
"label": "Value",
"binding": {
"name": "nested.value",
"type": "zeebe:input"
},
"type": "String"
}
]
}
```

As shown in the example, the property ID is composed of the field names of the nested properties
separated by a dot.
This behavior is enabled by default to prevent name clashes. You can disable it by setting
the `addNestedPath` property
of the `TemplateProperty` annotation to `false`, like this:

```java
@TemplateProperty(addNestedPath = false)
private MyNestedInput nested;
```

## Sealed hierarchies

Sealed hierarchies are common for defining Connector inputs with multiple variants. For example, the
Out-of-the-Box [HTTP Connector](https://github.com/camunda/connectors/tree/main/connectors/http/rest)
uses a sealed hierarchy for different authentication methods. Refer to the simplified example below.

```java
public abstract sealed class Authentication
permits BasicAuthentication,
BearerAuthentication,
CustomAuthentication,
NoAuthentication,
OAuthAuthentication {}

public final class BasicAuthentication extends Authentication {
@FEEL @NotEmpty private String username;
@FEEL @NotEmpty private String password;
}

public final class BearerAuthentication extends Authentication {
@FEEL @NotEmpty private String token;
}
```

This technique can also be applied to define connectors with multiple operations if the inputs for the
operations are different or only partially overlapping. Another example of this is the
[AWS DynamoDB Connector](https://github.com/camunda/connectors/tree/main/connectors/aws/aws-dynamodb).

The Template Generator supports sealed hierarchies by default. For each sealed hierarchy, it generates
an additional discriminator property of type `Dropdown` that gets mapped to a `type` variable in the resulting JSON.

The discriminator property can be configured by using the `@TemplateDiscriminatorProperty`.
It should be placed on the class level of the sealed hierarchy root class.

```java
@TemplateDiscriminatorProperty(name = "authenticationType", label = "Authentication type")
public abstract sealed class Authentication
permits BasicAuthentication,
BearerAuthentication,
CustomAuthentication,
NoAuthentication,
OAuthAuthentication {}
```

Here, `name` defines the property ID and variable name of the discriminator property.

The sealed variants can be configured by using the `@TemplateSubType` annotation.
It should be placed on the class level of the sealed variant classes.

```java
@TemplateSubType(id = "basic", label = "Basic authentication")
public final class BasicAuthentication extends Authentication {
@FEEL @NotEmpty private String username;
@FEEL @NotEmpty private String password;
}
```

If you are relying on Jackson to deserialize the polymorphic type, make sure to align the
discriminator property name and subtype IDs with the Jackson configuration.

Note that the [nested properties rules](#nested-properties) also apply to the discriminator property
and the sealed variants. The discriminator property is implicitly considered part of the nested
type.

## Property groups

By default, if no group is defined by `@TemplateProperty`, all properties are added to the default group.
Unlike defining properties with no group at all, using a default fallback group allows to render
the properties in a better way in the Modeler.

You can configure group IDs for specific properties by using the `@TemplateProperty` annotation,
and the group labels can be customized in the `@ElementTemplate` annotation:

```java
@ElementTemplate(
id = "myConnector",
name = "My Connector",
version = 1,
propertyGroups = {
@PropertyGroup(id = "group2", label = "Group Two"),
@PropertyGroup(id = "group1", label = "Group One")
})
public class MyConnectorFunction { }
```

The order of the groups is also determined by the order of the `@PropertyGroup` annotations.
In the example above, the `Group Two` will be rendered before `Group One`.

## Property validation

The Template Generator allows to define validation constraints for properties.
Validation constraints can be defined using the standard Bean Validation annotations.

```java
@NotEmpty
private String value;
```

The property above will receive a `notEmpty` constraint in the generated element template.

The following Bean Validation annotations are supported:
- `@NotEmpty`
- `@NotBlank` for strings, results in a `notEmpty` constraint
- `@NotNull` for objects, results in a `notEmpty` constraint
- `@Size` for strings, results in a `minLength` and `maxLength` constraint
- `@Pattern`

## Additional default properties

The Template Generator adds additional default properties to the generated Element Template. These
properties are not part of the Connector input data model, but are required by the Connector Runtime
to execute the Connector. The following properties are added by default:

- `errorExpression` - Expression that is evaluated to determine if the Connector invocation failed.
- `resultVariable` - Name of the variable that is used to store the Connector invocation result.
- `resultExpression` - Expression that is evaluated to determine the Connector invocation result.

## Property binding

Every generated property is bound to a Zeebe input (`zeebe:input` mapping). The binding name is derived from the
field name. Other bindings, like task headers, are currently not supported by the `@TemplateProperty` annotation.

## Element Template DSL

This module defines a DSL for building element templates programmatically. The starting point is the
`OutboundElementTemplate` class. You can use the DSL directly to build the template and then invoke
the `build` method. The resulting `ElementTemplate` object can be serialized to JSON using Jackson
(pre-configured) or any other JSON library (would require custom configuration).
5 changes: 5 additions & 0 deletions connector-sdk/element-template-generator/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,10 @@
<artifactId>jsonassert</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>

</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH
* under one or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information regarding copyright
* ownership. Camunda licenses this file to you under the Apache License,
* Version 2.0; you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.camunda.connector.generator.core.processor;

import io.camunda.connector.generator.dsl.PropertyBuilder;
import java.lang.reflect.Field;

public interface FieldProcessor {

void process(Field field, PropertyBuilder propertyBuilder);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH
* under one or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information regarding copyright
* ownership. Camunda licenses this file to you under the Apache License,
* Version 2.0; you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.camunda.connector.generator.core.processor;

import io.camunda.connector.generator.dsl.PropertyBuilder;
import io.camunda.connector.generator.dsl.PropertyConstraints;
import io.camunda.connector.generator.dsl.PropertyConstraints.PropertyConstraintsBuilder;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.lang.reflect.Field;
import org.apache.commons.lang3.tuple.Pair;

/** Jakarta Bean Validation API annotations processor */
public class JakartaValidationFieldProcessor implements FieldProcessor {

@Override
public void process(Field field, PropertyBuilder propertyBuilder) {
PropertyConstraintsBuilder constraintsBuilder = PropertyConstraints.builder();

if (hasNotEmptyConstraint(field)) {
constraintsBuilder.notEmpty(true);
}

var minSize = hasMinSizeAnnotation(field);
if (minSize != null) {
constraintsBuilder.minLength(minSize);
}
var maxSize = hasMaxSizeAnnotation(field);
if (maxSize != null) {
constraintsBuilder.maxLength(maxSize);
}

var pattern = hasPatternAnnotation(field);
if (pattern != null) {
constraintsBuilder.pattern(
new PropertyConstraints.Pattern(pattern.getLeft(), pattern.getRight()));
}

var constraints = constraintsBuilder.build();
if (!isConstraintEmpty(constraints)) {
propertyBuilder.constraints(constraints);
}
}

private boolean hasNotEmptyConstraint(Field field) {
return field.isAnnotationPresent(NotBlank.class)
|| field.isAnnotationPresent(NotEmpty.class)
|| field.isAnnotationPresent(NotNull.class);
}

private Integer hasMinSizeAnnotation(Field field) {
var sizeAnnotation = field.getAnnotation(Size.class);
if (sizeAnnotation != null && sizeAnnotation.min() != Integer.MIN_VALUE) {
return sizeAnnotation.min();
}
return null;
}

private Integer hasMaxSizeAnnotation(Field field) {
var sizeAnnotation = field.getAnnotation(Size.class);
if (sizeAnnotation != null && sizeAnnotation.max() != Integer.MAX_VALUE) {
return sizeAnnotation.max();
}
return null;
}

private Pair<String, String> hasPatternAnnotation(Field field) {
var patternAnnotation = field.getAnnotation(Pattern.class);
if (patternAnnotation != null) {
return Pair.of(patternAnnotation.regexp(), patternAnnotation.message());
}
return null;
}

private boolean isConstraintEmpty(PropertyConstraints constraints) {
return constraints.pattern() == null
&& constraints.minLength() == null
&& constraints.maxLength() == null
&& constraints.notEmpty() == null;
}
}
Loading

0 comments on commit 55200af

Please sign in to comment.