Event-Driven Architecture: Automatic DTO Generation From Event Documentation

Written by dstepanov | Published 2022/09/22
Tech Story Tags: microservices | software-architecture | software-development | spring | code-generator | gradle | swagger | hackernoon-top-story | hackernoon-es | hackernoon-hi | hackernoon-zh | hackernoon-vi | hackernoon-fr | hackernoon-pt | hackernoon-ja

TLDRAsyncAPI is an open-source initiative that seeks to improve the current state of Event-Driven Architecture (EDA) AsyncApi has several tools that allow you to generate documentation from code. In this article, I would like to tell you how I solved the following task, namely the generation of DTOs using the JSON documentation that springwolf generates.via the TL;DR App

One very important thing in the software development process that is often overlooked in the early stages of a project is API documentation. One of the solutions to this problem is frameworks for the automatic generation of documentation.

In the case of dividing the project into microservices and using Event-Driven architecture, the interaction between services is built using events transmitted through the message broker.

To generate documentation in the case of an Event-Driven architecture, there is AsyncApi. AsyncAPI is an open-source initiative that seeks to improve the current state of Event-Driven Architecture (EDA). AsyncApi has several Java tools that allow you to generate documentation from code. In this article, I described how to set up one of these springwolf tools.

In this article, I would like to tell you how I solved the following task, namely the generation of DTOs using the JSON documentation that springwolf generates.

Problem

The documentation structure that spring wolf generates looks like this:

{
  "service": {
    "serviceVersion": "2.0.0",
    "info": {
      //block with service info
    },
    "servers": {
      "kafka": {
        //describe of kafka connection
      }
    },
    "channels": {
      "kafka-channel": {
        "subscribe": {
          //...
          "message": {
            "oneOf": [
              {
                "name": "pckg.test.TestEvent",
                "title": "TestEvent",
                "payload": {
                  "$ref": "#/components/schemas/TestEvent"
                }
              }
            ]
          }
        },
        //...
      }
    },
    "components": {
      "schemas": {
        "TestEvent": {
          //jsonschema of component
        }
      }
    }
  }
}

Since jsonschema is used to describe the components in the documentation, I decided to use the jsonschema2pojo library to solve this problem. However, in the process of trying to implement my plan, I ran into several problems:

  • you need to additionally parse the JSON document to extract objects that describe the components. Since jsonschema2pojo takes jsonschema objects as input, they are in the components block.
  • jsonschema2pojo does not work well with polymorphism and does not handle standard references from oneOf block that are in AsyncAPI. The description of inheritance requires special fields in the schema (extends.javaType), which cannot be added to the AsyncAPI documentation simply.
  • since the generated classes in our case should be used to deserialize messages from the broker, it is necessary to add Jackson annotations describing descriptors and subtypes.

All these problems led me to the need to implement my wrapper over jsonschema2pojo, which will extract the necessary information from the documentation, support polymorphism, and add Jackson annotations. The result is a Gradle plugin with which you can generate DTO classes for your project using the springwolf API. Next, I will try to demonstrate how to annotate classes for documentation and how to use the Springwolfdoc2dto plugin.

Documentation setup

Here I would like to consider the specifics of when generation for non-primitive types such as Enum and Map. And also describe the necessary actions for polymorphism.

Let's look at the following message:

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class TestEvent implements Serializable {

    private String id;
    private LocalDateTime occuredOn;
    private TestEvent.ValueType valueType;
    private Map<String, Boolean> flags;
    private String value;

    public enum ValueType {

        STRING("STRING"),
        BOOLEAN("BOOLEAN"),
        INTEGER("INTEGER"),
        DOUBLE("DOUBLE");
        
        private final String value;

        public ValueType(String value) {
            this.value = value;
        }
    }
}

The jsonschema for such a message would look like this:

{
  "service": {
    //...
    "components": {
      "schemas": {
        "TestEvent": {
          "type": "object",
          "properties": {
            "id": {
              "type": "string",
              "exampleSetFlag": false
            },
            "occuredOn": {
              "type": "string",
              "format": "date-time",
              "exampleSetFlag": false
            },
            "valueType": {
              "type": "string",
              "exampleSetFlag": false,
              "enum": [
                "STRING",
                "BOOLEAN",
                "INTEGER",
                "DOUBLE"
              ]
            },
            "flags": {
              "type": "object",
              "additionalProperties": {
                "type": "boolean",
                "exampleSetFlag": false
              },
              "exampleSetFlag": false
            },
            "value": {
              "type": "string",
              "exampleSetFlag": false
            }
          },
          "example": {
            "id": "string",
            "occuredOn": "2015-07-20T15:49:04",
            "valueType": "STRING",
            "flags": {
              "additionalProp1": true,
              "additionalProp2": true,
              "additionalProp3": true
            }
          },
          "exampleSetFlag": true
        }
      }
    }
  }
}

When generating DTO classes, we will get the following class structure. You can see that Enum is processed as in the original version, however, the collection of type Map<String, Boolean> has turned into a separate class Flags and the entire value of the collection itself will fall into the Flags.additionalProperties field.

package pckg.test;

// import

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({
    "id",
    "occuredOn",
    "valueType",
    "flags",
    "value"
})
@Generated("jsonschema2pojo")
public class TestEvent implements Serializable
{
    @JsonProperty("id")
    private String id;
    @JsonProperty("occuredOn")
    private LocalDateTime occuredOn;
    @JsonProperty("valueType")
    private TestEvent.ValueType valueType;
    @JsonProperty("flags")
    private Flags flags;
    @JsonProperty("value")
    private String value;
    @JsonIgnore
    private Map<String, Object> additionalProperties = new LinkedHashMap<String, Object>();
    private final static long serialVersionUID = 7311052418845777748L;

    // Getters ans Setters

    @Generated("jsonschema2pojo")
    public enum ValueType {

        STRING("STRING"),
        BOOLEAN("BOOLEAN"),
        INTEGER("INTEGER"),
        DOUBLE("DOUBLE");
        private final String value;
        private final static Map<String, TestEvent.ValueType> CONSTANTS = new HashMap<String, TestEvent.ValueType>();

        static {
            for (TestEvent.ValueType c: values()) {
                CONSTANTS.put(c.value, c);
            }
        }

        ValueType(String value) {
            this.value = value;
        }

        @Override
        public String toString() {
            return this.value;
        }

        @JsonValue
        public String value() {
            return this.value;
        }

        @JsonCreator
        public static TestEvent.ValueType fromValue(String value) {
            TestEvent.ValueType constant = CONSTANTS.get(value);
            if (constant == null) {
                throw new IllegalArgumentException(value);
            } else {
                return constant;
            }
        }
    }
}


@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({

})
@Generated("jsonschema2pojo")
public class Flags implements Serializable
{

    @JsonIgnore
    private Map<String, Boolean> additionalProperties = new LinkedHashMap<String, Boolean>();
    private final static long serialVersionUID = 7471055390730117740L;

    //getters and setters

}

Polymorphism

And now let's look at how to provide a polymorphism option. This is relevant when we want to send several message subtypes to one broker topic and implement our listener for each subtype.

To do this, we need to add a parent class to the list of providers and add the @Schema annotation from swagger to it.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
@Setter(AccessLevel.PROTECTED)
@EqualsAndHashCode
@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.EXISTING_PROPERTY,
    property = "type",
    visible = true,
    defaultImpl = ChangedEvent.class
)
@JsonSubTypes(value = {
    @JsonSubTypes.Type(name = ChangedEvent.type, value = ChangedEvent.class),
    @JsonSubTypes.Type(name = DeletedEvent.type, value = DeletedEvent.class)
})
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(oneOf = {ChangedEvent.class, DeletedEvent.class},
discriminatorProperty = "type",
discriminatorMapping = {
    @DiscriminatorMapping(value = ChangedEvent.type, schema = ChangedEvent.class),
    @DiscriminatorMapping(value = DeletedEvent.type, schema = DeletedEvent.class),
})
public abstract class DomainEvent {
    @Schema(required = true, nullable = false)
    private String id;
    
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime occuredOn = LocalDateTime.now();
    
    public abstract String getType();
}

/**
* Subtype ChangedEvent
*/
public class ChangedEvent
    extends DomainEvent
    implements Serializable
{
    public static final String type = "CHANGED_EVENT";
    private String valueId;
    private String value;
}

/**
* Subtype DeletedEvent
*/
public class DeletedEvent
    extends DomainEvent
    implements Serializable
{
    public static final String type = "DELETED_EVENT";
    private String valueId;
 }

In this case, the description of the components in the documentation will change as follows:

"components": {
    "schemas": {
        "ChangedEvent": {
            "type": "object",
            "properties": {
                "id": {
                    "type": "string",
                    "exampleSetFlag": false
                },
                "occuredOn": {
                    "type": "string",
                    "format": "date-time",
                    "exampleSetFlag": false
                },
                "value": {
                    "type": "string",
                    "exampleSetFlag": false
                },
                "valueId": {
                    "type": "string",
                    "exampleSetFlag": false
                },
                "type": {
                     "type": "string",
                     "exampleSetFlag": false
                }
            },
            "example": {
                "id": "string",
                "occuredOn": "2015-07-20T15:49:04",
                "value": "string",
                "valueId": "string",
                "type": "CHANGED_EVENT"
            },
            "exampleSetFlag": true
        },
        "DeletedEvent": {
            "type": "object",
            "properties": {
                "id": {
                    "type": "string",
                    "exampleSetFlag": false
                },
                "occuredOn": {
                    "type": "string",
                    "format": "date-time",
                    "exampleSetFlag": false
                },
                "valueId": {
                    "type": "string",
                    "exampleSetFlag": false
                },
                "type": {
                    "type": "string",
                    "exampleSetFlag": false
                }
            },
            "example": {
                "id": "string",
                "occuredOn": "2015-07-20T15:49:04",
                "valueId": "string",
                "type": "DELETED_EVENT"
            },
            "exampleSetFlag": true
        },
        "DomainEvent": {
            "type": "object",
            "properties": {
                "id": {
                    "type": "string",
                    "exampleSetFlag": false
                },
                "occuredOn": {
                    "type": "string",
                    "format": "date-time",
                    "exampleSetFlag": false
                },
                "type": {
                    "type": "string",
                    "exampleSetFlag": false
                }
            },
            "example": {
                "id": "string",
                "occuredOn": "2015-07-20T15:49:04",
                "type": "string"
            },
            "discriminator": {
                "propertyName": "type",
                "mapping": {
                    "CHANGED_EVENT": "#/components/schemas/ChangedEvent",
                    "DELETED_EVENT": "#/components/schemas/DeletedEvent"
                }
            },
            "exampleSetFlag": true,
            "oneOf": [
                {
                    "$ref": "#/components/schemas/ChangedEvent",
                    "exampleSetFlag": false
                },
                {
                    "$ref": "#/components/schemas/DeletedEvent",
                    "exampleSetFlag": false
                }
            ]
        }
    }
}

After that, the plugin will take into account the links from the oneOf block and the described discriminators. As a result, we get the following class structure.

package pckg.test;

// import

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({
    "id",
    "occuredOn",
    "type"
})
@Generated("jsonschema2pojo")
@JsonTypeInfo(property = "type", use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true)
@JsonSubTypes({
    @JsonSubTypes.Type(name = "CHANGED_EVENT", value = ChangedEvent.class),
    @JsonSubTypes.Type(name = "DELETED_EVENT", value = DeletedEvent.class)
})
public class DomainEvent implements Serializable
{

    @JsonProperty("id")
    protected String id;
    @JsonProperty("occuredOn")
    protected LocalDateTime occuredOn;
    @JsonProperty("type")
    protected String type;
    @JsonIgnore
    protected Map<String, Object> additionalProperties = new LinkedHashMap<String, Object>();
    protected final static long serialVersionUID = 4691666114019791903L;

    //getters and setters

}

// import

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({
    "id",
    "occuredOn",
    "valueId",
    "type"
})
@Generated("jsonschema2pojo")
public class DeletedEvent
    extends DomainEvent
    implements Serializable
{

    @JsonProperty("id")
    private String id;
    @JsonProperty("occuredOn")
    private LocalDateTime occuredOn;
    @JsonProperty("valueId")
    private String valueId;
    @JsonProperty("type")
    private String type;
    @JsonIgnore
    private Map<String, Object> additionalProperties = new LinkedHashMap<String, Object>();
    private final static long serialVersionUID = 7326381459761013337L;

    // getters and setters

}


package pckg.test;

//import

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({
    "id",
    "occuredOn",
    "value",
    "type"
})
@Generated("jsonschema2pojo")
public class ChangedEvent
    extends DomainEvent
    implements Serializable
{
    @JsonProperty("id")
    private String id;
    @JsonProperty("occuredOn")
    private LocalDateTime occuredOn;
    @JsonProperty("value")
    private String value;
    @JsonProperty("type")
    private String type;
    @JsonIgnore
    private Map<String, Object> additionalProperties = new LinkedHashMap<String, Object>();
    private final static long serialVersionUID = 5446866391322866265L;

    //getters and setters

}


Plugin setup

To connect the plugin, you need to add it to the gradle.build file and specify the parameters:

  • folder was to generate DTO

  • package of new classes

  • springwolf documentation URL

  • the root name in the documentation, usually the name of the service

plugins {
    id 'io.github.stepanovd.springwolf2dto' version '1.0.1-alpha'
}

springWolfDoc2DTO{
    url = 'http://localhost:8080/springwolf/docs'
    targetPackage = 'example.package'
    documentationTitle = 'my-service'
    targetDirectory = project.layout.getBuildDirectory().dir("generated-sources")
}

Run task using bash command:

./gradle -q generateDTO

Conclusion

In this article, I described how you can use the springwolfdocs2dto plugin to generate new DTO classes based on the AsyncApi documentation. At the same time, new classes will be according to original inheritance and contain Jackson annotations for correct deserialization. I hope you find this plugin useful to you.


Written by dstepanov | 10+ years of experience in software development including architecture, design, implementation, and maintenance.
Published by HackerNoon on 2022/09/22