diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/annotations/AsyncOperation.java b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/annotations/AsyncOperation.java index 71a606f42..d7e99c8dc 100644 --- a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/annotations/AsyncOperation.java +++ b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/annotations/AsyncOperation.java @@ -55,6 +55,22 @@ String description() default ""; String value() default ""; + + /** + * The format of the header value according to AsyncAPI specification. + *

+ * Common formats include: + *

+ * + * @see AsyncAPI Data Type Format + * @return the format string, empty by default + */ + String format() default ""; } } } diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/scanners/common/annotation/AsyncAnnotationUtil.java b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/scanners/common/annotation/AsyncAnnotationUtil.java index e8763f5e7..6a434b1fb 100644 --- a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/scanners/common/annotation/AsyncAnnotationUtil.java +++ b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/scanners/common/annotation/AsyncAnnotationUtil.java @@ -66,8 +66,10 @@ public static SchemaObject getAsyncHeaders(AsyncOperation op, StringValueResolve SchemaObject property = new SchemaObject(); property.setType(SchemaType.STRING); + property.setTitle(propertyName); property.setDescription(getDescription(headersValues, stringValueResolver)); + property.setFormat(getFormat(headersValues, stringValueResolver)); List values = getHeaderValues(headersValues, stringValueResolver); if (!values.isEmpty()) { property.setExamples(new ArrayList<>(values)); @@ -84,7 +86,7 @@ private static List getHeaderValues( return value.stream() .map(AsyncOperation.Headers.Header::value) .filter(StringUtils::hasText) - .map(stringValueResolver::resolveStringValue) + .flatMap(text -> Optional.ofNullable(stringValueResolver.resolveStringValue(text)).stream()) .sorted() .toList(); } @@ -93,8 +95,19 @@ private static String getDescription( List value, StringValueResolver stringValueResolver) { return value.stream() .map(AsyncOperation.Headers.Header::description) - .map(stringValueResolver::resolveStringValue) .filter(StringUtils::hasText) + .flatMap(text -> Optional.ofNullable(stringValueResolver.resolveStringValue(text)).stream()) + .sorted() + .findFirst() + .orElse(null); + } + + private static String getFormat( + List value, StringValueResolver stringValueResolver) { + return value.stream() + .map(AsyncOperation.Headers.Header::format) + .filter(StringUtils::hasText) + .flatMap(text -> Optional.ofNullable(stringValueResolver.resolveStringValue(text)).stream()) .sorted() .findFirst() .orElse(null); diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/schemas/SwaggerSchemaUtil.java b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/schemas/SwaggerSchemaUtil.java index fb17b35ea..b8311c328 100644 --- a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/schemas/SwaggerSchemaUtil.java +++ b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/schemas/SwaggerSchemaUtil.java @@ -274,7 +274,7 @@ private Schema mapSchemaObjectToSwagger(SchemaObject asyncApiSchema) { .orElse(null)); swaggerSchema.setTypes(asyncApiSchema.getType()); } - // swaggerSchema.setFormat(asyncApiSchema.getFormat()); + swaggerSchema.setFormat(asyncApiSchema.getFormat()); swaggerSchema.setDescription(asyncApiSchema.getDescription()); swaggerSchema.setExamples(asyncApiSchema.getExamples()); swaggerSchema.setEnum(asyncApiSchema.getEnumValues()); diff --git a/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/scanners/common/annotation/AsyncAnnotationUtilTest.java b/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/scanners/common/annotation/AsyncAnnotationUtilTest.java index d15f8d9a0..3388cdf7b 100644 --- a/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/scanners/common/annotation/AsyncAnnotationUtilTest.java +++ b/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/scanners/common/annotation/AsyncAnnotationUtilTest.java @@ -35,6 +35,11 @@ class AsyncAnnotationUtilTest { StringValueResolver stringValueResolver = mock(StringValueResolver.class); + { + when(stringValueResolver.resolveStringValue(any())) + .thenAnswer(invocation -> invocation.getArgument(0).toString() + "Resolved"); + } + @ParameterizedTest @ValueSource(classes = {ClassWithOperationBindingProcessor.class, ClassWithAbstractOperationBindingProcessor.class}) void getAsyncHeaders(Class classWithOperationBindingProcessor) throws Exception { @@ -42,9 +47,6 @@ void getAsyncHeaders(Class classWithOperationBindingProcessor) throws Excepti Method m = classWithOperationBindingProcessor.getDeclaredMethod("methodWithAnnotation", String.class); AsyncOperation operation = m.getAnnotation(AsyncListener.class).operation(); - when(stringValueResolver.resolveStringValue(any())) - .thenAnswer(invocation -> invocation.getArgument(0).toString() + "Resolved"); - // when SchemaObject headers = AsyncAnnotationUtil.getAsyncHeaders(operation, stringValueResolver); @@ -58,6 +60,7 @@ void getAsyncHeaders(Class classWithOperationBindingProcessor) throws Excepti assertThat(headerResolved.getType()).containsExactly("string"); assertThat(headerResolved.getExamples().get(0)).isEqualTo("valueResolved"); assertThat(headerResolved.getDescription()).isEqualTo("descriptionResolved"); + assertThat(headerResolved.getFormat()).isEqualTo("int32Resolved"); assertThat(headers.getProperties().containsKey("headerWithoutValueResolved")) .as(headers.getProperties() + " does not contain key 'headerWithoutValueResolved'") @@ -68,6 +71,7 @@ void getAsyncHeaders(Class classWithOperationBindingProcessor) throws Excepti assertThat(headerWithoutValueResolved.getExamples()).isNull(); assertThat(headerWithoutValueResolved.getEnumValues()).isNull(); assertThat(headerWithoutValueResolved.getDescription()).isEqualTo("descriptionResolved"); + assertThat(headerWithoutValueResolved.getFormat()).isNull(); } @Test @@ -76,10 +80,6 @@ void getAsyncHeadersWithEmptyHeaders() throws Exception { Method m = ClassWithHeaders.class.getDeclaredMethod("emptyHeaders", String.class); AsyncOperation operation = m.getAnnotation(AsyncListener.class).operation(); - StringValueResolver stringValueResolver = mock(StringValueResolver.class); - when(stringValueResolver.resolveStringValue(any())) - .thenAnswer(invocation -> invocation.getArgument(0).toString() + "Resolved"); - // when SchemaObject headers = AsyncAnnotationUtil.getAsyncHeaders(operation, stringValueResolver); @@ -93,10 +93,6 @@ void getAsyncHeadersWithoutSchemaName() throws Exception { Method m = ClassWithHeaders.class.getDeclaredMethod("withoutSchemaName", String.class); AsyncOperation operation = m.getAnnotation(AsyncListener.class).operation(); - StringValueResolver stringValueResolver = mock(StringValueResolver.class); - when(stringValueResolver.resolveStringValue(any())) - .thenAnswer(invocation -> invocation.getArgument(0).toString() + "Resolved"); - // when SchemaObject headers = AsyncAnnotationUtil.getAsyncHeaders(operation, stringValueResolver); @@ -104,13 +100,14 @@ void getAsyncHeadersWithoutSchemaName() throws Exception { assertThat(headers) .isEqualTo(SchemaObject.builder() .type(SchemaType.OBJECT) - .title("Headers-501004016") + .title("Headers-1585401221") .properties(Map.of( "headerResolved", SchemaObject.builder() .type(SchemaType.STRING) .title("headerResolved") .description("descriptionResolved") + .format(null) .enumValues(List.of("valueResolved")) .examples(List.of("valueResolved")) .build())) @@ -123,9 +120,32 @@ void getAsyncHeadersWithoutValue() throws Exception { Method m = ClassWithHeaders.class.getDeclaredMethod("withoutValue", String.class); AsyncOperation operation = m.getAnnotation(AsyncListener.class).operation(); - StringValueResolver stringValueResolver = mock(StringValueResolver.class); - when(stringValueResolver.resolveStringValue(any())) - .thenAnswer(invocation -> invocation.getArgument(0).toString() + "Resolved"); + // when + SchemaObject headers = AsyncAnnotationUtil.getAsyncHeaders(operation, stringValueResolver); + + // then + assertThat(headers) + .isEqualTo(SchemaObject.builder() + .type(SchemaType.OBJECT) + .title("Headers-1612438838") + .properties(Map.of( + "headerResolved", + SchemaObject.builder() + .type(SchemaType.STRING) + .title("headerResolved") + .description("descriptionResolved") + .format(null) + .enumValues(null) + .examples(null) + .build())) + .build()); + } + + @Test + void getAsyncHeadersWithFormat() throws Exception { + // given + Method m = ClassWithHeaders.class.getDeclaredMethod("withFormat", String.class); + AsyncOperation operation = m.getAnnotation(AsyncListener.class).operation(); // when SchemaObject headers = AsyncAnnotationUtil.getAsyncHeaders(operation, stringValueResolver); @@ -134,11 +154,12 @@ void getAsyncHeadersWithoutValue() throws Exception { assertThat(headers) .isEqualTo(SchemaObject.builder() .type(SchemaType.OBJECT) - .title("Headers-472917891") + .title("Headers-1701213112") .properties(Map.of( "headerResolved", SchemaObject.builder() .type(SchemaType.STRING) + .format("int32Resolved") .title("headerResolved") .description("descriptionResolved") .enumValues(null) @@ -147,6 +168,20 @@ void getAsyncHeadersWithoutValue() throws Exception { .build()); } + @Test + void getAsyncHeadersWithEmptyFormat() throws Exception { + // given + Method m = ClassWithHeaders.class.getDeclaredMethod("withoutFormat", String.class); + AsyncOperation operation = m.getAnnotation(AsyncListener.class).operation(); + + // when + SchemaObject headers = AsyncAnnotationUtil.getAsyncHeaders(operation, stringValueResolver); + + // then + SchemaObject headerProperty = (SchemaObject) headers.getProperties().get("headerResolved"); + assertThat(headerProperty.getFormat()).isNull(); + } + @Test void generatedHeaderSchemaNameShouldBeUnique() throws Exception { // given @@ -156,10 +191,6 @@ void generatedHeaderSchemaNameShouldBeUnique() throws Exception { Method m2 = ClassWithHeaders.class.getDeclaredMethod("differentHeadersWithoutSchemaName", String.class); AsyncOperation operation2 = m2.getAnnotation(AsyncListener.class).operation(); - StringValueResolver stringValueResolver = mock(StringValueResolver.class); - when(stringValueResolver.resolveStringValue(any())) - .thenAnswer(invocation -> invocation.getArgument(0).toString() + "Resolved"); - // when SchemaObject headers1 = AsyncAnnotationUtil.getAsyncHeaders(operation1, stringValueResolver); SchemaObject headers2 = AsyncAnnotationUtil.getAsyncHeaders(operation2, stringValueResolver); @@ -286,8 +317,6 @@ void getServers() throws Exception { Method m = ClassWithOperationBindingProcessor.class.getDeclaredMethod("methodWithAnnotation", String.class); AsyncOperation operation = m.getAnnotation(AsyncListener.class).operation(); - StringValueResolver stringValueResolver = mock(StringValueResolver.class); - // when when(stringValueResolver.resolveStringValue("${test.property.server1}")).thenReturn("server1"); @@ -351,7 +380,8 @@ private static class ClassWithOperationBindingProcessor { @AsyncOperation.Headers.Header( name = "header", value = "value", - description = "description"), + description = "description", + format = "int32"), @AsyncOperation.Headers.Header( name = "headerWithoutValue", description = "description") @@ -398,7 +428,8 @@ private static class ClassWithAbstractOperationBindingProcessor { @AsyncOperation.Headers.Header( name = "header", value = "value", - description = "description"), + description = "description", + format = "int32"), @AsyncOperation.Headers.Header( name = "headerWithoutValue", description = "description") @@ -465,6 +496,35 @@ private void withoutSchemaName(String payload) {} @TestOperationBindingProcessor.TestOperationBinding() private void withoutValue(String payload) {} + @AsyncListener( + operation = + @AsyncOperation( + channelName = "${test.property.test-channel}", + headers = + @AsyncOperation.Headers( + values = { + @AsyncOperation.Headers.Header( + name = "header", + description = "description", + format = "int32") + }))) + @TestOperationBindingProcessor.TestOperationBinding() + private void withFormat(String payload) {} + + @AsyncListener( + operation = + @AsyncOperation( + channelName = "${test.property.test-channel}", + headers = + @AsyncOperation.Headers( + values = { + @AsyncOperation.Headers.Header( + name = "header", + description = "description") + }))) + @TestOperationBindingProcessor.TestOperationBinding() + private void withoutFormat(String payload) {} + @AsyncListener( operation = @AsyncOperation( diff --git a/springwolf-examples/springwolf-kafka-example/src/main/java/io/github/springwolf/examples/kafka/producers/AnotherProducer.java b/springwolf-examples/springwolf-kafka-example/src/main/java/io/github/springwolf/examples/kafka/producers/AnotherProducer.java index a19e26cdf..5be7fb4e9 100644 --- a/springwolf-examples/springwolf-kafka-example/src/main/java/io/github/springwolf/examples/kafka/producers/AnotherProducer.java +++ b/springwolf-examples/springwolf-kafka-example/src/main/java/io/github/springwolf/examples/kafka/producers/AnotherProducer.java @@ -1,18 +1,40 @@ // SPDX-License-Identifier: Apache-2.0 package io.github.springwolf.examples.kafka.producers; +import io.github.springwolf.bindings.kafka.annotations.KafkaAsyncOperationBinding; +import io.github.springwolf.core.asyncapi.annotations.AsyncOperation; +import io.github.springwolf.core.asyncapi.annotations.AsyncPublisher; import io.github.springwolf.examples.kafka.configuration.KafkaConfiguration; import io.github.springwolf.examples.kafka.dtos.AnotherPayloadDto; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; +import static org.springframework.kafka.support.mapping.AbstractJavaTypeMapper.DEFAULT_CLASSID_FIELD_NAME; + @Component public class AnotherProducer { @Autowired private KafkaTemplate kafkaTemplate; + @AsyncPublisher( + operation = + @AsyncOperation( + channelName = "another-topic", + headers = + @AsyncOperation.Headers( + schemaName = "SpringKafkaDefaultHeaders-AnotherTopic", + values = { + @AsyncOperation.Headers.Header( + name = DEFAULT_CLASSID_FIELD_NAME, + description = "Type ID"), + @AsyncOperation.Headers.Header( + name = "my_uuid_field", + description = "Event identifier", + format = "uuid") + }))) + @KafkaAsyncOperationBinding public void sendMessage(AnotherPayloadDto msg) { kafkaTemplate.send(KafkaConfiguration.PRODUCER_TOPIC, msg); } diff --git a/springwolf-examples/springwolf-kafka-example/src/test/resources/asyncapi.json b/springwolf-examples/springwolf-kafka-example/src/test/resources/asyncapi.json index f1eda6a7b..60518b6bb 100644 --- a/springwolf-examples/springwolf-kafka-example/src/test/resources/asyncapi.json +++ b/springwolf-examples/springwolf-kafka-example/src/test/resources/asyncapi.json @@ -496,6 +496,43 @@ "type": "object" } }, + "SpringKafkaDefaultHeaders-AnotherTopic": { + "title": "SpringKafkaDefaultHeaders-AnotherTopic", + "type": "object", + "properties": { + "__TypeId__": { + "type": "string", + "description": "Type ID" + }, + "my_uuid_field": { + "type": "string", + "description": "Event identifier", + "format": "uuid" + } + }, + "examples": [ + { + "__TypeId__": "string", + "my_uuid_field": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "x-json-schema": { + "$schema": "https://json-schema.org/draft-04/schema#", + "properties": { + "__TypeId__": { + "description": "Type ID", + "type": "string" + }, + "my_uuid_field": { + "description": "Event identifier", + "format": "uuid", + "type": "string" + } + }, + "title": "SpringKafkaDefaultHeaders-AnotherTopic", + "type": "object" + } + }, "SpringKafkaDefaultHeaders-ExamplePayloadDto-546532105": { "title": "SpringKafkaDefaultHeaders-ExamplePayloadDto-546532105", "type": "object", @@ -512,6 +549,7 @@ }, "kafka_offset": { "type": "integer", + "format": "int32", "examples": [ 0 ] @@ -548,6 +586,7 @@ "type": "string" }, "kafka_offset": { + "format": "int32", "type": "integer" }, "kafka_receivedMessageKey": { @@ -1904,7 +1943,7 @@ }, "io.github.springwolf.examples.kafka.dtos.AnotherPayloadDto": { "headers": { - "$ref": "#/components/schemas/SpringKafkaDefaultHeaders-AnotherPayloadDto" + "$ref": "#/components/schemas/SpringKafkaDefaultHeaders-AnotherTopic" }, "payload": { "schemaFormat": "application/vnd.aai.asyncapi+json;version=3.0.0", @@ -2132,6 +2171,24 @@ } ] }, + "another-topic_send_sendMessage": { + "action": "send", + "channel": { + "$ref": "#/channels/another-topic" + }, + "title": "another-topic_send", + "description": "Auto-generated description", + "bindings": { + "kafka": { + "bindingVersion": "0.5.0" + } + }, + "messages": [ + { + "$ref": "#/channels/another-topic/messages/io.github.springwolf.examples.kafka.dtos.AnotherPayloadDto" + } + ] + }, "avro-topic_receive_receiveExampleAvroPayload": { "action": "receive", "channel": { diff --git a/springwolf-examples/springwolf-kafka-example/src/test/resources/asyncapi.yaml b/springwolf-examples/springwolf-kafka-example/src/test/resources/asyncapi.yaml index b0c056945..7af9075df 100644 --- a/springwolf-examples/springwolf-kafka-example/src/test/resources/asyncapi.yaml +++ b/springwolf-examples/springwolf-kafka-example/src/test/resources/asyncapi.yaml @@ -339,6 +339,32 @@ components: type: string title: SpringKafkaDefaultHeaders-AnotherPayloadDto type: object + SpringKafkaDefaultHeaders-AnotherTopic: + title: SpringKafkaDefaultHeaders-AnotherTopic + type: object + properties: + __TypeId__: + type: string + description: Type ID + my_uuid_field: + type: string + description: Event identifier + format: uuid + examples: + - __TypeId__: string + my_uuid_field: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + x-json-schema: + $schema: https://json-schema.org/draft-04/schema# + properties: + __TypeId__: + description: Type ID + type: string + my_uuid_field: + description: Event identifier + format: uuid + type: string + title: SpringKafkaDefaultHeaders-AnotherTopic + type: object SpringKafkaDefaultHeaders-ExamplePayloadDto-546532105: title: SpringKafkaDefaultHeaders-ExamplePayloadDto-546532105 type: object @@ -352,6 +378,7 @@ components: - io.github.springwolf.examples.kafka.dtos.ExamplePayloadDto kafka_offset: type: integer + format: int32 examples: - 0 kafka_receivedMessageKey: @@ -376,6 +403,7 @@ components: - io.github.springwolf.examples.kafka.dtos.ExamplePayloadDto type: string kafka_offset: + format: int32 type: integer kafka_receivedMessageKey: type: string @@ -1367,7 +1395,7 @@ components: bindingVersion: 0.5.0 io.github.springwolf.examples.kafka.dtos.AnotherPayloadDto: headers: - $ref: "#/components/schemas/SpringKafkaDefaultHeaders-AnotherPayloadDto" + $ref: "#/components/schemas/SpringKafkaDefaultHeaders-AnotherTopic" payload: schemaFormat: application/vnd.aai.asyncapi+json;version=3.0.0 schema: @@ -1518,6 +1546,17 @@ operations: bindingVersion: 0.5.0 messages: - $ref: "#/channels/another-topic/messages/io.github.springwolf.examples.kafka.dtos.AnotherPayloadDto" + another-topic_send_sendMessage: + action: send + channel: + $ref: "#/channels/another-topic" + title: another-topic_send + description: Auto-generated description + bindings: + kafka: + bindingVersion: 0.5.0 + messages: + - $ref: "#/channels/another-topic/messages/io.github.springwolf.examples.kafka.dtos.AnotherPayloadDto" avro-topic_receive_receiveExampleAvroPayload: action: receive channel: