Skip to content

Commit

Permalink
feat(generator): support icons (#1118)
Browse files Browse the repository at this point in the history
  • Loading branch information
chillleader authored Sep 7, 2023
1 parent 3602c41 commit ef560d1
Show file tree
Hide file tree
Showing 11 changed files with 208 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
import io.camunda.connector.generator.core.OutboundElementTemplateGenerator;
import io.camunda.connector.generator.dsl.OutboundElementTemplate;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.apache.maven.artifact.Artifact;
Expand Down Expand Up @@ -52,7 +54,7 @@ public class ElementTemplateGeneratorMojo extends AbstractMojo {
private String outputDirectory;

private static final ObjectMapper mapper = new ObjectMapper();
private final OutboundElementTemplateGenerator generator = new OutboundElementTemplateGenerator();
private OutboundElementTemplateGenerator generator;

private static final String COMPILED_CLASSES_DIR = "target" + File.separator + "classes";

Expand All @@ -70,6 +72,15 @@ public void execute() throws MojoFailureException {
project.getFile().getParent() + File.separator + COMPILED_CLASSES_DIR;
classpathUrls.add(new File(compiledClassesPath).toURI().toURL());

var resourcesDirectory = getResourcesDirectory();
var testResourcesDirectory = getTestResourcesDirectory();
if (resourcesDirectory != null) {
classpathUrls.add(resourcesDirectory);
}
if (testResourcesDirectory != null) {
classpathUrls.add(testResourcesDirectory);
}

for (String dependency : includeDependencies) {
Artifact dependencyArtifact = (Artifact) project.getArtifactMap().get(dependency);
if (dependencyArtifact == null) {
Expand All @@ -86,6 +97,10 @@ public void execute() throws MojoFailureException {
new URLClassLoader(
classpathUrls.toArray(new URL[0]), Thread.currentThread().getContextClassLoader())) {

// ensures that resources and classes from the project are loaded by the classloader
Thread.currentThread().setContextClassLoader(classLoader);
generator = new OutboundElementTemplateGenerator();

for (String className : connectorClasses) {
getLog().info("Generating element template for " + className);
Class<?> clazz = classLoader.loadClass(className);
Expand Down Expand Up @@ -127,4 +142,18 @@ private String transformConnectorNameToTemplateFileName(String connectorName) {
connectorName = connectorName.toLowerCase();
return connectorName + ".json";
}

private URL getResourcesDirectory() throws MalformedURLException {
if (project.getBuild().getResources().size() > 0) {
return Path.of(project.getBuild().getResources().get(0).getDirectory()).toUri().toURL();
}
return null;
}

private URL getTestResourcesDirectory() throws MalformedURLException {
if (project.getBuild().getTestResources().size() > 0) {
return Path.of(project.getBuild().getTestResources().get(0).getDirectory()).toUri().toURL();
}
return null;
}
}
26 changes: 26 additions & 0 deletions connector-sdk/element-template-generator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,32 @@ to execute the Connector. The following properties are added by default:
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.

## Icons

A custom element template icon can be defined by using the `@ElementTemplate` annotation:

```java
@ElementTemplate(
id = "myConnector",
name = "My Connector",
version = 1,
icon = "my-connector.svg")
public class MyConnectorFunction { }
```

You can use SVG or PNG graphics for the icon, although SVG is recommended. The icons get rendered
18x18 pixels in the element on the modeling canvas, and 32x32 pixels in the properties panel.

The icon file must be available as a resource in the classpath. By default, it is expected to be in the
`src/main/resources` directory.

When running in a multi-module Maven environment using the
[Maven Plugin](../element-template-generator-maven-plugin), the resources of a connector module are
not visible to the Template Generator's default class loader. To mitigate this, the Maven Plugin
adds the individual connector resources to the custom class loader that can be consumed by the
Template Generator either via `Thread.currentThread().getContextClassLoader()` or directly via
constructor injection.

## Element Template DSL

This module defines a DSL for building element templates programmatically. The starting point is the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@
*/
PropertyGroup[] propertyGroups() default {};

/**
* Icon for the connector. Will be displayed in Camunda Modeler along with the connector name.
* Should be a classpath resource path. The classpath resource should either be an SVG or PNG
* image.
*
* <p>It is recommended to use squared SVG graphics. The icons get rendered 18x18 pixels in the
* element on the modeling canvas, and 32x32 pixels in the properties panel.
*/
String icon() default "";

@interface PropertyGroup {
String id();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,32 @@
import io.camunda.connector.generator.core.util.ReflectionUtil;
import io.camunda.connector.generator.core.util.TemplatePropertiesUtil;
import io.camunda.connector.generator.dsl.CommonProperties;
import io.camunda.connector.generator.dsl.ElementTemplateIcon;
import io.camunda.connector.generator.dsl.OutboundElementTemplate;
import io.camunda.connector.generator.dsl.PropertyBinding.ZeebeInput;
import io.camunda.connector.generator.dsl.PropertyBinding.ZeebeTaskHeader;
import io.camunda.connector.generator.dsl.PropertyBuilder;
import io.camunda.connector.generator.dsl.PropertyGroup;
import io.camunda.connector.generator.dsl.PropertyGroup.PropertyGroupBuilder;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;

public class OutboundElementTemplateGenerator
implements ElementTemplateGenerator<OutboundElementTemplate> {

private final ClassLoader classLoader;

public OutboundElementTemplateGenerator(ClassLoader classLoader) {
this.classLoader = classLoader;
}

public OutboundElementTemplateGenerator() {
this(Thread.currentThread().getContextClassLoader());
}

@Override
public OutboundElementTemplate generate(Class<?> connectorDefinition) {
var connector =
Expand Down Expand Up @@ -117,16 +130,51 @@ public OutboundElementTemplate generate(Class<?> connectorDefinition) {
var nonGroupedProperties =
properties.stream().filter(property -> property.build().getGroup() == null).toList();

var icon = template.icon().isBlank() ? null : resolveIcon(template.icon());

return OutboundElementTemplate.builder()
.id(template.id())
.type(connector.type())
.name(template.name())
.version(template.version())
.icon(icon)
.documentationRef(
template.documentationRef().isEmpty() ? null : template.documentationRef())
.description(template.description().isEmpty() ? null : template.description())
.properties(nonGroupedProperties.stream().map(PropertyBuilder::build).toList())
.propertyGroups(mergedGroups)
.build();
}

public ElementTemplateIcon resolveIcon(String iconDefinition) {
if (iconDefinition.startsWith("https://")) {
return new ElementTemplateIcon(iconDefinition);
}
try {
return new ElementTemplateIcon(resolveIconFile(iconDefinition));
} catch (Exception e) {
throw new IllegalArgumentException(
"Invalid icon definition: " + iconDefinition + ", " + e.getMessage(), e);
}
}

private String resolveIconFile(String path) throws IOException {
var resource = classLoader.getResource(path);
if (resource == null) {
throw new IllegalArgumentException("Icon file not found: " + path);
}
String base64Data;
try (var stream = resource.openStream()) {
var bytes = stream.readAllBytes();
base64Data = Base64.getEncoder().encodeToString(bytes);
}

if (path.endsWith(".svg")) {
return "data:image/svg+xml;base64," + base64Data;
} else if (path.endsWith(".png")) {
return "data:image/png;base64," + base64Data;
} else {
throw new IllegalArgumentException("Unsupported icon file: " + path);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* 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.dsl;

import com.fasterxml.jackson.annotation.JsonProperty;

public record ElementTemplateIcon(@JsonProperty String contents) {}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ public record OutboundElementTemplate(
@JsonProperty String documentationRef,
@JsonProperty String description,
@JsonProperty List<PropertyGroup> groups,
@JsonProperty List<Property> properties)
@JsonProperty List<Property> properties,
@JsonProperty ElementTemplateIcon icon)
implements ElementTemplateBase {

public OutboundElementTemplate {
Expand All @@ -68,6 +69,9 @@ public record OutboundElementTemplate(
if (!errors.isEmpty()) {
throw new IllegalArgumentException(String.join(", ", errors));
}
if (icon != null && !icon.contents().matches("^(data):.*")) {
throw new IllegalArgumentException("icon contents must be base64 encoded");
}
}

@JsonProperty
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public class OutboundElementTemplateBuilder {
private String id;
private String name;
private int version;
private ElementTemplateIcon icon;
private String documentationRef;
private String description;
private final List<PropertyGroup> groups = new ArrayList<>();
Expand Down Expand Up @@ -65,6 +66,11 @@ public OutboundElementTemplateBuilder version(int version) {
return this;
}

public OutboundElementTemplateBuilder icon(ElementTemplateIcon icon) {
this.icon = icon;
return this;
}

public OutboundElementTemplateBuilder documentationRef(String documentationRef) {
this.documentationRef = documentationRef;
return this;
Expand Down Expand Up @@ -104,7 +110,7 @@ public OutboundElementTemplate build() {
throw new IllegalStateException("type is not assigned");
}
return new OutboundElementTemplate(
id, name, version, documentationRef, description, groups, properties);
id, name, version, documentationRef, description, groups, properties, icon);
}

private boolean isTypeAssigned() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/
package io.camunda.connector.generator.core;

import static java.nio.file.Files.readAllBytes;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
Expand All @@ -32,6 +33,9 @@
import io.camunda.connector.generator.dsl.PropertyConstraints.Pattern;
import io.camunda.connector.generator.dsl.StringProperty;
import io.camunda.connector.generator.dsl.TextProperty;
import java.io.IOException;
import java.nio.file.Paths;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -367,4 +371,36 @@ void validationPresent_notEmpty_objectProperty() {
assertThat(notEmptyProperty.getConstraints().maxLength()).isNull();
}
}

@Nested
class Icons {

@Test
void svgIcon_classpathFile() throws IOException {
var expectedIcon =
OutboundTemplateGeneratorTest.class.getClassLoader().getResource("my-connector-icon.svg");
var expectedIconString =
"data:image/svg+xml;base64,"
+ Base64.getEncoder().encodeToString(readAllBytes(Paths.get(expectedIcon.getFile())));

var template = generator.generate(MyConnectorFunction.MinimallyAnnotatedWithSvgIcon.class);
var icon = template.icon();

assertThat(icon.contents()).isEqualTo(expectedIconString);
}

@Test
void pngIcon_classpathFile() throws IOException {
var expectedIcon =
OutboundTemplateGeneratorTest.class.getClassLoader().getResource("my-connector-icon.png");
var expectedIconString =
"data:image/png;base64,"
+ Base64.getEncoder().encodeToString(readAllBytes(Paths.get(expectedIcon.getFile())));

var template = generator.generate(MyConnectorFunction.MinimallyAnnotatedWithPngIcon.class);
var icon = template.icon();

assertThat(icon.contents()).isEqualTo(expectedIconString);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,26 @@ public static class FullyAnnotated extends MyConnectorFunction {}
name = MyConnectorFunction.NAME,
inputDataClass = MyConnectorInput.class)
public static class MinimallyAnnotated extends MyConnectorFunction {}

@OutboundConnector(
name = "my-connector",
type = "my-connector-type",
inputVariables = {})
@ElementTemplate(
id = MyConnectorFunction.ID,
name = MyConnectorFunction.NAME,
inputDataClass = MyConnectorInput.class,
icon = "my-connector-icon.svg")
public static class MinimallyAnnotatedWithSvgIcon extends MyConnectorFunction {}

@OutboundConnector(
name = "my-connector",
type = "my-connector-type",
inputVariables = {})
@ElementTemplate(
id = MyConnectorFunction.ID,
name = MyConnectorFunction.NAME,
inputDataClass = MyConnectorInput.class,
icon = "my-connector-icon.png")
public static class MinimallyAnnotatedWithPngIcon extends MyConnectorFunction {}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit ef560d1

Please sign in to comment.