From 0199639b6878fe63a6b0b2d4d83725ab9f36c869 Mon Sep 17 00:00:00 2001 From: Timon Back Date: Mon, 1 Jan 2024 21:08:18 +0100 Subject: [PATCH] feat(core): document primitive types using envelope class and AsyncApiPayload marker GH-377 --- .../annotation/AsyncOperation.java | 2 + .../channels/payload/AsyncApiPayload.java | 34 +++++++++++++ .../schemas/DefaultSchemasService.java | 41 +++++++++++++++- .../schemas/DefaultSchemasServiceTest.java | 48 +++++++++++++++++++ .../test/resources/schemas/api-payload.json | 8 ++++ 5 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 springwolf-core/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/payload/AsyncApiPayload.java create mode 100644 springwolf-core/src/test/resources/schemas/api-payload.json diff --git a/springwolf-core/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/annotation/AsyncOperation.java b/springwolf-core/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/annotation/AsyncOperation.java index fb65aa2ae..64dd5bb56 100644 --- a/springwolf-core/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/annotation/AsyncOperation.java +++ b/springwolf-core/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/annotation/AsyncOperation.java @@ -41,6 +41,8 @@ String[] servers() default {}; /** + * Overwrite the Springwolf auto-detected payload type. + *

* Mapped to {@link OperationData#getPayloadType()} */ Class payloadType() default Object.class; diff --git a/springwolf-core/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/payload/AsyncApiPayload.java b/springwolf-core/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/payload/AsyncApiPayload.java new file mode 100644 index 000000000..436d34c7a --- /dev/null +++ b/springwolf-core/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/payload/AsyncApiPayload.java @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.asyncapi.scanners.channels.payload; + +import io.github.stavshamir.springwolf.asyncapi.scanners.channels.operationdata.annotation.AsyncListener; +import io.github.stavshamir.springwolf.asyncapi.scanners.channels.operationdata.annotation.AsyncOperation; +import io.github.stavshamir.springwolf.asyncapi.scanners.channels.operationdata.annotation.AsyncPublisher; + +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; + +/** + * Marker interface to indicate the actual payload class in envelop wrapper classes. + *

+ * Since primitive data types cannot be documented directly (like String, Integer, etc.), an envelope class is needed: + *

{@code
+ * public class StringEnvelop {
+ *   @AsyncApiPayload
+ *   @Schema(description = "Payload description using @Schema annotation", maxLength = 10)
+ *   String payload;
+ * }
+ * }
+ *

+ * In case the signature of the listener/publisher method can not be changed to use the envelope class, + * the envelope class can be set as {@link AsyncOperation#payloadType()} + * in the {@link AsyncListener} and/or {@link AsyncPublisher} annotation + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({FIELD}) +@Inherited +public @interface AsyncApiPayload {} diff --git a/springwolf-core/src/main/java/io/github/stavshamir/springwolf/schemas/DefaultSchemasService.java b/springwolf-core/src/main/java/io/github/stavshamir/springwolf/schemas/DefaultSchemasService.java index df89736b8..899a5839e 100644 --- a/springwolf-core/src/main/java/io/github/stavshamir/springwolf/schemas/DefaultSchemasService.java +++ b/springwolf-core/src/main/java/io/github/stavshamir/springwolf/schemas/DefaultSchemasService.java @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 package io.github.stavshamir.springwolf.schemas; +import io.github.stavshamir.springwolf.asyncapi.scanners.channels.payload.AsyncApiPayload; import io.github.stavshamir.springwolf.asyncapi.types.channel.operation.message.header.AsyncHeaders; import io.github.stavshamir.springwolf.configuration.properties.SpringwolfConfigProperties; import io.github.stavshamir.springwolf.schemas.example.ExampleGenerator; @@ -12,8 +13,11 @@ import io.swagger.v3.oas.models.media.StringSchema; import lombok.extern.slf4j.Slf4j; +import java.lang.reflect.Field; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -61,11 +65,17 @@ public String register(AsyncHeaders headers) { public String register(Class type) { log.debug("Registering schema for {}", type.getSimpleName()); - Map schemas = runWithFqnSetting((unused) -> converter.readAll(type)); + Map schemas = new LinkedHashMap<>(runWithFqnSetting((unused) -> converter.readAll(type))); + String schemaName = getSchemaName(type, schemas); + preProcessSchemas(schemas, schemaName, type); this.definitions.putAll(schemas); schemas.values().forEach(this::postProcessSchema); + return schemaName; + } + + private String getSchemaName(Class type, Map schemas) { if (schemas.isEmpty() && type.equals(String.class)) { return registerString(); } @@ -74,7 +84,8 @@ public String register(Class type) { return new ArrayList<>(schemas.keySet()).get(0); } - Set resolvedPayloadModelName = converter.read(type).keySet(); + Set resolvedPayloadModelName = + runWithFqnSetting((unused) -> converter.read(type).keySet()); if (!resolvedPayloadModelName.isEmpty()) { return new ArrayList<>(resolvedPayloadModelName).get(0); } @@ -82,6 +93,32 @@ public String register(Class type) { return type.getSimpleName(); } + private void preProcessSchemas(Map schemas, String schemaName, Class type) { + processAsyncApiPayloadAnnotation(schemas, schemaName, type); + } + + private void processAsyncApiPayloadAnnotation(Map schemas, String schemaName, Class type) { + List withPayloadAnnotatedFields = Arrays.stream(type.getDeclaredFields()) + .filter(field -> field.isAnnotationPresent(AsyncApiPayload.class)) + .toList(); + + if (withPayloadAnnotatedFields.size() == 1) { + Schema envelopSchema = schemas.get(schemaName); + if (envelopSchema != null) { + String fieldName = withPayloadAnnotatedFields.get(0).getName(); + Schema actualSchema = (Schema) envelopSchema.getProperties().get(fieldName); + if (actualSchema != null) { + schemas.put(schemaName, actualSchema); + } + } + + } else if (withPayloadAnnotatedFields.size() > 1) { + log.warn(("Found more than one field with @AsyncApiPayload annotation in class %s. " + + "Falling back and ignoring annotation.") + .formatted(type.getName())); + } + } + private String registerString() { String schemaName = "String"; StringSchema schema = new StringSchema(); diff --git a/springwolf-core/src/test/java/io/github/stavshamir/springwolf/schemas/DefaultSchemasServiceTest.java b/springwolf-core/src/test/java/io/github/stavshamir/springwolf/schemas/DefaultSchemasServiceTest.java index 604c132f4..da042c4f5 100644 --- a/springwolf-core/src/test/java/io/github/stavshamir/springwolf/schemas/DefaultSchemasServiceTest.java +++ b/springwolf-core/src/test/java/io/github/stavshamir/springwolf/schemas/DefaultSchemasServiceTest.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import io.github.stavshamir.springwolf.asyncapi.scanners.channels.payload.AsyncApiPayload; import io.github.stavshamir.springwolf.configuration.properties.SpringwolfConfigProperties; import io.github.stavshamir.springwolf.schemas.example.ExampleGenerator; import io.github.stavshamir.springwolf.schemas.example.ExampleJsonGenerator; @@ -291,4 +292,51 @@ public class ImplementationTwo { private Boolean secondTwo; } } + + @Nested + class AsyncApiPayloadTest { + @Test + void stringEnvelopTest() throws IOException { + schemasService.register(StringEnvelop.class); + + String actualDefinitions = objectMapper.writer(printer).writeValueAsString(schemasService.getDefinitions()); + String expected = jsonResource("/schemas/api-payload.json"); + + System.out.println("Got: " + actualDefinitions); + assertEquals(expected, actualDefinitions); + + assertThat(actualDefinitions).doesNotContain("otherField"); + } + + @Test + void illegalEnvelopTest() throws IOException { + schemasService.register(EnvelopWithMultipleAsyncApiPayloadAnnotations.class); + + String actualDefinitions = objectMapper.writer(printer).writeValueAsString(schemasService.getDefinitions()); + + // fallback to EnvelopWithMultipleAsyncApiPayloadAnnotations, which contains the field + assertThat(actualDefinitions).contains("otherField"); + } + + @Data + @NoArgsConstructor + public class StringEnvelop { + Integer otherField; + + @AsyncApiPayload + @Schema(description = "The payload in the envelop", maxLength = 10) + String payload; + } + + @Data + @NoArgsConstructor + public class EnvelopWithMultipleAsyncApiPayloadAnnotations { + @AsyncApiPayload + Integer otherField; + + @AsyncApiPayload + @Schema(description = "The payload in the envelop", maxLength = 10) + String payload; + } + } } diff --git a/springwolf-core/src/test/resources/schemas/api-payload.json b/springwolf-core/src/test/resources/schemas/api-payload.json new file mode 100644 index 000000000..47090f384 --- /dev/null +++ b/springwolf-core/src/test/resources/schemas/api-payload.json @@ -0,0 +1,8 @@ +{ + "StringEnvelop" : { + "maxLength" : 10, + "type" : "string", + "description" : "The payload in the envelop", + "example" : "string" + } +} \ No newline at end of file