Event-driven architecture: automatic generation of DTOs from event documentation

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 lies in frameworks for automatic documentation generation.

In the case of dividing the project into microservices and using the Event-Driven architecture, the interaction between the services is built using events passed 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 aims 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 configure one of these spring wolf tools.

In this article, I would like to tell you how I solved the following task i.e. generating DTO using JSON documentation generated by Springwolf.

Problem

The documentation structure generated by Spring Wolf 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 components in documentation, I decided to use jsonschema2pojo library to solve this problem. However, while trying to implement my plan, I encountered several problems:

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

All of these issues led me to the need to implement my wrapper on 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’ll try to show how to annotate classes for documentation and how to use the Springwolfdoc2dto plugin.

Documentation Setup

Here I would like to look at the specifics of generating non-primitive types such as Enum and Map. And also describe the actions needed 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 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 the DTO classes, we will get the following class structure. You can see that Enum is treated as in the original version, however, the collection of type Map became a separate Flags class and the total 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 additionalProperties = new LinkedHashMap();
    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 CONSTANTS = new HashMap();

        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 additionalProperties = new LinkedHashMap();
    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 multiple message subtypes to a 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 to it from swagger.

@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 additionalProperties = new LinkedHashMap();
    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 additionalProperties = new LinkedHashMap();
    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 additionalProperties = new LinkedHashMap();
    private final static long serialVersionUID = 5446866391322866265L;

    //getters and setters

}

Plugin configuration

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

  • folder had to generate DTO

  • package of new classes

  • springwolf documentation url

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

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 the task using the 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 AsyncApi documentation. At the same time, the new classes will conform to the original inheritance and contain Jackson annotations for proper deserialization. Hope you find this plugin useful for you.

LOADING
. . . comments & After!

Sam D. Gomez