Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,42 +16,45 @@
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Optional;
import java.util.stream.Stream;

import org.eclipse.esmf.aspectmodel.VersionNumber;
import org.eclipse.esmf.aspectmodel.generator.JsonGenerator;
import org.eclipse.esmf.aspectmodel.generator.XsdToJsonTypeMapping;
import org.eclipse.esmf.aspectmodel.generator.jsonschema.AspectModelJsonSchemaGenerator;
import org.eclipse.esmf.aspectmodel.generator.jsonschema.AspectModelJsonSchemaVisitor;
import org.eclipse.esmf.aspectmodel.generator.jsonschema.JsonSchemaGenerationConfig;
import org.eclipse.esmf.aspectmodel.generator.jsonschema.JsonSchemaGenerationConfigBuilder;
import org.eclipse.esmf.aspectmodel.urn.AspectModelUrn;
import org.eclipse.esmf.metamodel.Aspect;
import org.eclipse.esmf.metamodel.Event;
import org.eclipse.esmf.metamodel.Property;

import com.apicatalog.jsonld.StringUtils;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.apache.commons.io.IOUtils;
import org.apache.jena.rdf.model.Resource;
import org.apache.jena.rdf.model.ResourceFactory;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class AspectModelAsyncApiGenerator extends JsonGenerator<Aspect, AsyncApiSchemaGenerationConfig, JsonNode, AsyncApiSchemaArtifact> {
public static final AsyncApiSchemaGenerationConfig DEFAULT_CONFIG = AsyncApiSchemaGenerationConfigBuilder.builder().build();

private static final String APPLICATION_JSON = "application/json";
private static final String CHANNELS = "#/channels";
private static final String COMPONENTS_SCHEMAS_PATH = "#/components/schemas";
private static final String COMPONENTS_MESSAGES = "#/components/messages";
private static final String CHANNELS_REF = "#/channels";
private static final String COMPONENTS_SCHEMAS_REF = "#/components/schemas";
private static final String COMPONENTS_MESSAGES_REF = "#/components/messages";
public static final String SCHEMAS = "schemas";
public static final String COMPONENTS = "components";
private static final String MESSAGES_FIELD = "messages";
private static final String ACTION_RECEIVE = "receive";
private static final String ACTION_SEND = "send";
private static final String V30 = "3.0.0";

private static final String TITLE_FIELD = "title";
private static final String DESCRIPTION_FIELD = "description";
private static final String TYPE_FIELD = "type";

private static final JsonNodeFactory FACTORY = JsonNodeFactory.instance;
private static final Logger LOG = LoggerFactory.getLogger( AspectModelAsyncApiGenerator.class );
Expand Down Expand Up @@ -82,13 +85,15 @@ public Stream<AsyncApiSchemaArtifact> generate() {
final ObjectNode info = (ObjectNode) rootNode.get( "info" );
info.put( TITLE_FIELD, aspect().getPreferredName( config.locale() ) + " MQTT API" );
info.put( "version", apiVersion );
info.put( DESCRIPTION_FIELD, getDescription( aspect().getDescription( config.locale() ) ) );
info.put( AspectModelJsonSchemaGenerator.SAMM_EXTENSION, aspect().urn().toString() );
Optional.ofNullable( aspect().getDescription( config.locale() ) )
.ifPresent( description -> info.put( DESCRIPTION_FIELD, description ) );

rootNode.set( "channels", buildChannelNode( aspect() ) );

rootNode.set( "channels", getChannelNode( aspect(), config ) );
if ( !aspect().getEvents().isEmpty() || !aspect().getOperations().isEmpty() ) {
setOperations( aspect(), rootNode );
setComponents( aspect(), rootNode, config.locale() );
setComponents( aspect(), rootNode );
}
result = new AsyncApiSchemaArtifact( aspect().getName(), rootNode );
} catch ( final Exception exception ) {
Expand All @@ -98,176 +103,155 @@ public Stream<AsyncApiSchemaArtifact> generate() {
return Stream.of( result );
}

private void setComponents( final Aspect aspect, final ObjectNode rootNode, final Locale locale ) {
final ObjectNode componentsNode = FACTORY.objectNode();
final ObjectNode messagesNode = FACTORY.objectNode();
final ObjectNode schemasNode = FACTORY.objectNode();

aspect.getEvents().forEach( event -> generateComponentsMessageAndSchemaEvent( messagesNode, schemasNode, event, locale ) );
aspect.getOperations().forEach( operation -> {
private void setOperations( final Aspect aspect, final ObjectNode rootNode ) {
final ObjectNode operationsNode = FACTORY.objectNode();

operation.getInput().forEach( input -> generateComponentsMessageAndSchemaOperation( messagesNode, schemasNode, input, locale ) );
aspect.getEvents().forEach( event ->
addOperation( operationsNode, aspect.getName(), event.getName(), ACTION_RECEIVE ) );

if ( operation.getOutput().isPresent() ) {
generateComponentsMessageAndSchemaOperation( messagesNode, schemasNode, operation.getOutput().get(), locale );
}
aspect.getOperations().forEach( operation -> {
operation.getInput().forEach( input ->
addOperation( operationsNode, aspect.getName(), input.getName(), ACTION_RECEIVE ) );
Copy link
Copy Markdown
Contributor Author

@andreas-wirth andreas-wirth Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This generates an Operation for each input, which is not desirable. Instead, generate only one Operation with an object wrapping all inputs. Also consider adding suffixes to the generated messages like "Request" and "Response"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for noticing this, Andreas.

I added a question with example for this case, in related ticket, but now I see that you have mentioned it here.

Just to confirm - you are right that when we have an Operation with 1 or more inputs, all of these should be handled under 1 AsyncAPI Operation as inputs.

Having multiple AsyncAPI operations for each individual input in the Aspect Model Operation is wrong because, in the model we would have defined specific Operation, which takes number of input parameters, that correspond to specific case or are required to be able to provide the declared in the aspect model operation response.
However, splitting the inputs in standalone AsyncAPI operations will break this set up, because it will provide # number of possible AsyncAPI calls that would be expected to return same response, which will be impossible due to the missing other input parameters.

With regards to the "Request" and "Response" - this would be very handy for better readability and understanding of the generated AsyncAPI, when one is going through it.

Thanks again and I hope that this would also be addressed in this fix :)

operation.getOutput().ifPresent( output ->
addOperation( operationsNode, aspect.getName(), output.getName(), ACTION_SEND ) );
} );

componentsNode.set( "messages", messagesNode );
componentsNode.set( "schemas", schemasNode );

rootNode.set( "components", componentsNode );
rootNode.set( "operations", operationsNode );
}

private void generateComponentsMessageAndSchemaEvent( final ObjectNode messagesNode, final ObjectNode schemasNode, final Event event,
final Locale locale ) {
final ObjectNode messageNode = FACTORY.objectNode();
messageNode.put( "name", event.getName() );
messageNode.put( TITLE_FIELD, event.getPreferredName( locale ) );
messageNode.put( "summary", event.getDescription( locale ) );
messageNode.put( "contentType", APPLICATION_JSON );

final ObjectNode payloadNode = FACTORY.objectNode();
payloadNode.put( "$ref", generateRef( COMPONENTS_SCHEMAS_PATH, event.getName() ) );

messageNode.set( "payload", payloadNode );

messagesNode.set( event.getName(), messageNode );

final ObjectNode schemaNode = FACTORY.objectNode();
if ( !event.getProperties().isEmpty() ) {
schemaNode.put( TYPE_FIELD, "object" );

final ObjectNode propertiesNode = FACTORY.objectNode();
event.getProperties().forEach( property -> {
final ObjectNode propertyNode = FACTORY.objectNode();
propertyNode.put( TITLE_FIELD, property.getName() );
propertyNode.set( TYPE_FIELD, getType( ResourceFactory.createResource( property.getDataType().get().getUrn() ) ).toJsonNode() );
propertyNode.put( DESCRIPTION_FIELD, property.getDescription( locale ) );

propertiesNode.set( property.getName(), propertyNode );
} );
private void addOperation( final ObjectNode operationsNode, final String aspectName,
final String operationName, final String action ) {
final ObjectNode operationNode = FACTORY.objectNode();
operationNode.put( "action", action );
operationNode.set( "channel", FACTORY.objectNode().put( "$ref", ref( CHANNELS_REF, aspectName ) ) );

schemaNode.set( "properties", propertiesNode );
}
final ArrayNode messagesArray = operationNode.putArray( MESSAGES_FIELD );
messagesArray.add( FACTORY.objectNode().put( "$ref",
String.format( "%s/%s/messages/%s", CHANNELS_REF, aspectName, operationName ) ) );

schemasNode.set( event.getName(), schemaNode );
operationsNode.set( operationName, operationNode );
}

private void generateComponentsMessageAndSchemaOperation( final ObjectNode messagesNode, final ObjectNode schemasNode,
final Property property, final Locale locale ) {
final ObjectNode messageNode = FACTORY.objectNode();
messageNode.put( "name", property.getName() );
messageNode.put( TITLE_FIELD, property.getPreferredName( locale ) );
messageNode.put( "summary", property.getDescription( locale ) );
messageNode.put( "contentType", APPLICATION_JSON );

final ObjectNode payloadNode = FACTORY.objectNode();
payloadNode.put( "$ref", generateRef( COMPONENTS_SCHEMAS_PATH, property.getName() ) );

messageNode.set( "payload", payloadNode );
private ObjectNode buildChannelNode( final Aspect aspect ) {
final ObjectNode channelsNode = FACTORY.objectNode();
final ObjectNode pathNode = FACTORY.objectNode();

messagesNode.set( property.getName(), messageNode );
final AspectModelUrn urn = aspect.urn();
pathNode.put( "address", config.channelAddress() != null && !config.channelAddress().isBlank()
? config.channelAddress()
: String.format( "/%s/%s/%s", urn.getNamespaceMainPart(), urn.getVersion(), aspect.getName() ) );
pathNode.put( DESCRIPTION_FIELD, "Channel for updating " + aspect.getName() + " Aspect." );

final ObjectNode schemaNode = FACTORY.objectNode();
schemaNode.set( TYPE_FIELD, getType( ResourceFactory.createResource( property.getDataType().get().getUrn() ) ).toJsonNode() );
schemaNode.put( DESCRIPTION_FIELD, getDescription( property.getDescription( locale ) ) );
final ObjectNode parametersNode = FACTORY.objectNode();
parametersNode.put( "namespace", urn.getNamespaceMainPart() );
parametersNode.put( "version", urn.getVersion() );
parametersNode.put( "aspect-name", aspect.getName() );
pathNode.set( "parameters", parametersNode );

schemasNode.set( property.getName(), schemaNode );
}
pathNode.set( MESSAGES_FIELD, buildChannelMessages( aspect ) );

private String getDescription( final String description ) {
return description == null ? "" : description;
channelsNode.set( aspect.getName(), pathNode );
return channelsNode;
}

private void setOperations( final Aspect aspect, final ObjectNode rootNode ) {
final ObjectNode operationsNode = FACTORY.objectNode();
final String aspectName = aspect.getName();
aspect.getEvents().forEach( event -> generateOperation( operationsNode, aspectName, event.getName(), ACTION_RECEIVE ) );
private ObjectNode buildChannelMessages( final Aspect aspect ) {
final ObjectNode messagesNode = FACTORY.objectNode();
aspect.getEvents().forEach( event ->
messagesNode.set( event.getName(),
FACTORY.objectNode().put( "$ref", ref( COMPONENTS_MESSAGES_REF, event.getName() ) ) ) );
aspect.getOperations().forEach( operation -> {

operation.getInput().forEach( input -> generateOperation( operationsNode, aspectName, input.getName(), ACTION_RECEIVE ) );

if ( operation.getOutput().isPresent() ) {
generateOperation( operationsNode, aspectName, operation.getOutput().get().getName(), ACTION_SEND );
}
operation.getInput().forEach( input ->
messagesNode.set( input.getName(),
FACTORY.objectNode().put( "$ref", ref( COMPONENTS_MESSAGES_REF, input.getName() ) ) ) );
operation.getOutput().ifPresent( output ->
messagesNode.set( output.getName(),
FACTORY.objectNode().put( "$ref", ref( COMPONENTS_MESSAGES_REF, output.getName() ) ) ) );
} );
return messagesNode;
}

rootNode.set( "operations", operationsNode );
private JsonSchemaGenerationConfig createJsonSchemaConfig() {
return JsonSchemaGenerationConfigBuilder.builder()
.locale( config.locale() )
.generateForOpenApi( true )
.generateCommentForSeeAttributes( false )
.useExtendedTypes( false )
.build();
}

private void generateOperation( final ObjectNode operationsNode, final String aspectName, final String operationName,
final String action ) {
final ObjectNode operationNode = FACTORY.objectNode();
private void setComponents( final Aspect aspect, final ObjectNode rootNode ) {
final ObjectNode componentsNode = FACTORY.objectNode();
final ObjectNode messagesNode = FACTORY.objectNode();
final ObjectNode schemasNode = FACTORY.objectNode();

operationNode.put( "action", action );
final AspectModelJsonSchemaVisitor schemaVisitor = new AspectModelJsonSchemaVisitor( createJsonSchemaConfig() );

final ObjectNode channelNode = FACTORY.objectNode();
channelNode.put( "$ref", String.format( "%s/%s", CHANNELS, aspectName ) );
aspect.getEvents().forEach( event ->
addEventComponent( messagesNode, schemasNode, event, schemaVisitor ) );

operationNode.set( "channel", channelNode );
aspect.getOperations().forEach( operation -> {
operation.getInput().forEach( input ->
addOperationPropertyComponent( messagesNode, schemasNode, input, schemaVisitor ) );
operation.getOutput().ifPresent( output ->
addOperationPropertyComponent( messagesNode, schemasNode, output, schemaVisitor ) );
} );

final ArrayNode messagesNode = operationNode.putArray( "messages" );
final ObjectNode objectNode = FACTORY.objectNode();
objectNode.put( "$ref", String.format( "%s/%s/messages/%s", CHANNELS, aspectName, operationName ) );
messagesNode.add( objectNode );
mergeVisitorSchemas( schemaVisitor, schemasNode );

operationsNode.set( operationName, operationNode );
componentsNode.set( MESSAGES_FIELD, messagesNode );
componentsNode.set( SCHEMAS, schemasNode );
rootNode.set( COMPONENTS, componentsNode );
}

private ObjectNode getChannelNode( final Aspect aspect, final AsyncApiSchemaGenerationConfig config ) {
final ObjectNode endpointPathsNode = FACTORY.objectNode();
final ObjectNode pathNode = FACTORY.objectNode();
private void addEventComponent( final ObjectNode messagesNode, final ObjectNode schemasNode,
final Event event, final AspectModelJsonSchemaVisitor schemaVisitor ) {
final Locale locale = config.locale();

endpointPathsNode.set( aspect.getName(), pathNode );
final ObjectNode messageNode = FACTORY.objectNode();
messageNode.put( "name", event.getName() );
messageNode.put( TITLE_FIELD, event.getPreferredName( locale ) );
messageNode.put( "contentType", APPLICATION_JSON );
messageNode.set( "payload", FACTORY.objectNode().put( "$ref", ref( COMPONENTS_SCHEMAS_REF, event.getName() ) ) );
messagesNode.set( event.getName(), messageNode );

setChannelNodeMeta( pathNode, aspect, config );
setNodeMessages( pathNode, aspect );
Optional.ofNullable( event.getDescription( locale ) )
.ifPresent( description -> messageNode.put( "summary", description ) );

return endpointPathsNode;
final ObjectNode eventSchemaNode = FACTORY.objectNode();
if ( !event.getProperties().isEmpty() ) {
schemaVisitor.visitHasProperties( event, eventSchemaNode );
}
schemasNode.set( event.getName(), eventSchemaNode );
}

private void setChannelNodeMeta( final ObjectNode channelNode, final Aspect aspect, final AsyncApiSchemaGenerationConfig config ) {
final AspectModelUrn aspectModelUrn = aspect.urn();
private void addOperationPropertyComponent( final ObjectNode messagesNode, final ObjectNode schemasNode,
final Property property, final AspectModelJsonSchemaVisitor schemaVisitor ) {
final Locale locale = config.locale();

channelNode.put( "address", StringUtils.isNotBlank( config.channelAddress() )
? config.channelAddress()
: String.format( "/%s/%s/%s", aspectModelUrn.getNamespaceMainPart(), aspectModelUrn.getVersion(), aspect.getName() ) );
channelNode.put( DESCRIPTION_FIELD, "This channel for updating " + aspect.getName() + " Aspect." );
final ObjectNode messageNode = FACTORY.objectNode();
messageNode.put( "name", property.getName() );
messageNode.put( TITLE_FIELD, property.getPreferredName( locale ) );
messageNode.put( "contentType", APPLICATION_JSON );
messageNode.set( "payload", FACTORY.objectNode().put( "$ref", ref( COMPONENTS_SCHEMAS_REF, property.getName() ) ) );
messagesNode.set( property.getName(), messageNode );

final ObjectNode parametersNode = FACTORY.objectNode();
parametersNode.put( "namespace", aspectModelUrn.getNamespaceMainPart() );
parametersNode.put( "version", aspectModelUrn.getVersion() );
parametersNode.put( "aspect-name", aspect.getName() );
Optional.ofNullable( property.getDescription( locale ) )
.ifPresent( description -> messageNode.put( "summary", description ) );

channelNode.set( "parameters", parametersNode );
final JsonNode schema = property.accept( schemaVisitor, FACTORY.objectNode() );
schemasNode.set( property.getName(), schema );
}

private void setNodeMessages( final ObjectNode rootNode, final Aspect aspect ) {
final ObjectNode messagesNode = FACTORY.objectNode();
if ( !aspect.getEvents().isEmpty() || !aspect.getOperations().isEmpty() ) {
aspect.getEvents().forEach( event -> generateNodeMessageRef( messagesNode, event.getName() ) );
aspect.getOperations().forEach( operation -> {

operation.getInput().forEach( input -> generateNodeMessageRef( messagesNode, input.getName() ) );

if ( operation.getOutput().isPresent() ) {
generateNodeMessageRef( messagesNode, operation.getOutput().get().getName() );
}
} );
private void mergeVisitorSchemas( final AspectModelJsonSchemaVisitor schemaVisitor, final ObjectNode schemasNode ) {
final ObjectNode visitorRoot = schemaVisitor.getRootNode();
if ( visitorRoot.has( COMPONENTS ) && visitorRoot.get( COMPONENTS ).has( SCHEMAS ) ) {
visitorRoot.get( COMPONENTS ).get( SCHEMAS ).properties()
.forEach( field -> schemasNode.set( field.getKey(), field.getValue() ) );
}

rootNode.set( "messages", messagesNode );
}

private void generateNodeMessageRef( final ObjectNode parentNode, final String messageName ) {
final ObjectNode refNode = FACTORY.objectNode();
refNode.put( "$ref", generateRef( COMPONENTS_MESSAGES, messageName ) );
parentNode.set( messageName, refNode );
}

private String generateRef( final String path, final String name ) {
return String.format( "%s/%s", path, name );
private static String ref( final String basePath, final String name ) {
return String.format( "%s/%s", basePath, name );
}

private String getApiVersion( final Aspect aspect, final boolean useSemanticVersion ) {
Expand All @@ -281,8 +265,4 @@ private ObjectNode getRootJsonNode() throws IOException {
.replace( "${AsyncApiVer}", V30 );
return (ObjectNode) objectMapper.readTree( string );
}

private XsdToJsonTypeMapping.JsonType getType( final Resource type ) {
return XsdToJsonTypeMapping.TYPE_MAP.getOrDefault( type, XsdToJsonTypeMapping.JsonType.STRING );
}
}
Loading
Loading