From 856eb08bc6c9d9199d44c9413c297e3a70caef5f Mon Sep 17 00:00:00 2001 From: Carlos Tasada Date: Thu, 5 Oct 2023 08:35:46 +0200 Subject: [PATCH] Add AWS SNS Plugin Although AsyncAPI allows to add more details for the binding, the underlying library jAsyncApi has no support for further binding information. Therefore, only the sns binding without properties is supported. Manual configuration is supported through the usual `@AsyncPublisher` annotation in combination with the `@SnsAsyncOperationBinding` annotation. The code is mostly copied and adjusted based on the other plugins. --- .github/workflows/springwolf-plugins.yml | 2 +- README.md | 4 +- RELEASING.md | 3 +- build.gradle | 1 + settings.gradle | 3 + .../springwolf-sns-example/.env | 1 + .../springwolf-sns-example/README.md | 6 + .../springwolf-sns-example/build.gradle | 80 +++++++++ .../springwolf-sns-example/docker-compose.yml | 36 ++++ .../sns/SpringwolfSnsExampleApplication.java | 13 ++ .../sns/consumers/ExampleConsumer.java | 37 ++++ .../example/sns/dtos/AnotherPayloadDto.java | 23 +++ .../example/sns/dtos/ExamplePayloadDto.java | 30 ++++ .../sns/producers/AnotherProducer.java | 29 +++ .../src/main/resources/application.properties | 35 ++++ .../example/sns/ApiIntegrationTest.java | 48 +++++ ...iIntegrationWithDockerIntegrationTest.java | 68 +++++++ .../sns/SnsTestContainerExtension.java | 57 ++++++ ...fSnsExampleApplicationIntegrationTest.java | 29 +++ .../src/test/resources/asyncapi.json | 166 ++++++++++++++++++ .../springwolf-sns-plugin/README.md | 42 +++++ .../springwolf-sns-plugin/build.gradle | 81 +++++++++ .../controller/SpringwolfSnsController.java | 70 ++++++++ .../SnsMessageBindingProcessor.java | 36 ++++ .../SnsOperationBindingProcessor.java | 16 ++ .../annotation/SnsAsyncOperationBinding.java | 21 +++ .../SpringwolfSnsConfigConstants.java | 16 ++ .../SpringwolfSnsConfigProperties.java | 54 ++++++ .../producer/SpringwolfSnsProducer.java | 38 ++++ .../ObjectMapperTestConfiguration.java | 17 ++ ...sProducerConfigurationIntegrationTest.java | 97 ++++++++++ .../producer/SpringwolfSnsProducerTest.java | 40 +++++ .../src/app/shared/mock/mock-server.ts | 3 + 33 files changed, 1199 insertions(+), 3 deletions(-) create mode 100644 springwolf-examples/springwolf-sns-example/.env create mode 100644 springwolf-examples/springwolf-sns-example/README.md create mode 100644 springwolf-examples/springwolf-sns-example/build.gradle create mode 100644 springwolf-examples/springwolf-sns-example/docker-compose.yml create mode 100644 springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/SpringwolfSnsExampleApplication.java create mode 100644 springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/consumers/ExampleConsumer.java create mode 100644 springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/dtos/AnotherPayloadDto.java create mode 100644 springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/dtos/ExamplePayloadDto.java create mode 100644 springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/producers/AnotherProducer.java create mode 100644 springwolf-examples/springwolf-sns-example/src/main/resources/application.properties create mode 100644 springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/ApiIntegrationTest.java create mode 100644 springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/ApiIntegrationWithDockerIntegrationTest.java create mode 100644 springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/SnsTestContainerExtension.java create mode 100644 springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/SpringwolfSnsExampleApplicationIntegrationTest.java create mode 100644 springwolf-examples/springwolf-sns-example/src/test/resources/asyncapi.json create mode 100644 springwolf-plugins/springwolf-sns-plugin/README.md create mode 100644 springwolf-plugins/springwolf-sns-plugin/build.gradle create mode 100644 springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/controller/SpringwolfSnsController.java create mode 100644 springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/SnsMessageBindingProcessor.java create mode 100644 springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/SnsOperationBindingProcessor.java create mode 100644 springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/annotation/SnsAsyncOperationBinding.java create mode 100644 springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/configuration/properties/SpringwolfSnsConfigConstants.java create mode 100644 springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/configuration/properties/SpringwolfSnsConfigProperties.java create mode 100644 springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/producer/SpringwolfSnsProducer.java create mode 100644 springwolf-plugins/springwolf-sns-plugin/src/test/java/io/github/stavshamir/springwolf/configuration/ObjectMapperTestConfiguration.java create mode 100644 springwolf-plugins/springwolf-sns-plugin/src/test/java/io/github/stavshamir/springwolf/configuration/SpringwolfSnsProducerConfigurationIntegrationTest.java create mode 100644 springwolf-plugins/springwolf-sns-plugin/src/test/java/io/github/stavshamir/springwolf/producer/SpringwolfSnsProducerTest.java diff --git a/.github/workflows/springwolf-plugins.yml b/.github/workflows/springwolf-plugins.yml index 85d868277..eafe868c0 100644 --- a/.github/workflows/springwolf-plugins.yml +++ b/.github/workflows/springwolf-plugins.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - plugin: [ "amqp", "cloud-stream", "kafka", "sqs" ] + plugin: [ "amqp", "cloud-stream", "kafka", "sns", "sqs" ] env: plugin: springwolf-plugins/springwolf-${{ matrix.plugin }}-plugin diff --git a/README.md b/README.md index 5646be329..788181067 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ ![springwolf-amqp](https://github.com/springwolf/springwolf-core/workflows/springwolf-amqp/badge.svg) ![springwolf-cloud-stream](https://github.com/springwolf/springwolf-core/workflows/springwolf-cloud-stream/badge.svg) ![springwolf-kafka](https://github.com/springwolf/springwolf-core/workflows/springwolf-kafka/badge.svg) +![springwolf-sns](https://github.com/springwolf/springwolf-core/workflows/springwolf-sns/badge.svg) ![springwolf-sqs](https://github.com/springwolf/springwolf-core/workflows/springwolf-sqs/badge.svg) ![springwolf-common-model-converters](https://github.com/springwolf/springwolf-core/actions/workflows/springwolf-common-model-converters.yml/badge.svg) @@ -38,7 +39,7 @@ The documentation and quickstart is available on [springwolf.dev](https://www.sp ### Why You Should Use It Springwolf exploits the fact that you already fully described your consumer endpoint (with listener annotations, such as -`@KafkaListner`, `@RabbitListener`, `@SqsListener`, etc.) and generates the documentation based on this information. +`@KafkaListener`, `@RabbitListener`, `@SqsListener`, etc.) and generates the documentation based on this information. #### Share API Schema Definition @@ -60,6 +61,7 @@ More details in the documentation. |-------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | [Core](https://github.com/springwolf/springwolf-core/tree/master/springwolf-core) | | ![Maven Central](https://img.shields.io/maven-central/v/io.github.springwolf/springwolf-core?color=green&label=springwolf-core&style=plastic) | ![Sonatype Nexus (Snapshots)](https://img.shields.io/nexus/s/io.github.springwolf/springwolf-core?label=springwolf-core&server=https%3A%2F%2Fs01.oss.sonatype.org&style=plastic) | | [AMQP](https://github.com/springwolf/springwolf-core/tree/master/springwolf-plugins/springwolf-amqp-plugin) | [AMQP Example](https://github.com/springwolf/springwolf-core/tree/master/springwolf-examples/springwolf-amqp-example) | ![Maven Central](https://img.shields.io/maven-central/v/io.github.springwolf/springwolf-amqp?color=green&label=springwolf-amqp&style=plastic) | ![Sonatype Nexus (Snapshots)](https://img.shields.io/nexus/s/io.github.springwolf/springwolf-amqp?label=springwolf-amqp&server=https%3A%2F%2Fs01.oss.sonatype.org&style=plastic) | +| [AWS SNS](https://github.com/springwolf/springwolf-core/tree/master/springwolf-plugins/springwolf-sns-plugin) | [AWS SNS Example](https://github.com/springwolf/springwolf-core/tree/master/springwolf-examples/springwolf-sns-example) | ![Maven Central](https://img.shields.io/maven-central/v/io.github.springwolf/springwolf-sns?color=green&label=springwolf-sqs&style=plastic) | ![Sonatype Nexus (Snapshots)](https://img.shields.io/nexus/s/io.github.springwolf/springwolf-sns?label=springwolf-sns&server=https%3A%2F%2Fs01.oss.sonatype.org&style=plastic) | | [AWS SQS](https://github.com/springwolf/springwolf-core/tree/master/springwolf-plugins/springwolf-sqs-plugin) | [AWS SQS Example](https://github.com/springwolf/springwolf-core/tree/master/springwolf-examples/springwolf-sqs-example) | ![Maven Central](https://img.shields.io/maven-central/v/io.github.springwolf/springwolf-sqs?color=green&label=springwolf-sqs&style=plastic) | ![Sonatype Nexus (Snapshots)](https://img.shields.io/nexus/s/io.github.springwolf/springwolf-sqs?label=springwolf-sqs&server=https%3A%2F%2Fs01.oss.sonatype.org&style=plastic) | | [Cloud Stream](https://github.com/springwolf/springwolf-core/tree/master/springwolf-plugins/springwolf-cloud-stream-plugin) | [Cloud Stream Example](https://github.com/springwolf/springwolf-core/tree/master/springwolf-examples/springwolf-cloud-stream-example) | ![Maven Central](https://img.shields.io/maven-central/v/io.github.springwolf/springwolf-cloud-stream?color=green&label=springwolf-cloud-stream&style=plastic) | ![Sonatype Nexus (Snapshots)](https://img.shields.io/nexus/s/io.github.springwolf/springwolf-cloud-stream?label=springwolf-cloud-stream&server=https%3A%2F%2Fs01.oss.sonatype.org&style=plastic) | | [Kafka](https://github.com/springwolf/springwolf-core/tree/master/springwolf-plugins/springwolf-kafka-plugin) | [Kafka Example](https://github.com/springwolf/springwolf-core/tree/master/springwolf-examples/springwolf-kafka-example) | ![Maven Central](https://img.shields.io/maven-central/v/io.github.springwolf/springwolf-kafka?color=green&label=springwolf-kafka&style=plastic) | ![Sonatype Nexus (Snapshots)](https://img.shields.io/nexus/s/io.github.springwolf/springwolf-kafka?label=springwolf-kafka&server=https%3A%2F%2Fs01.oss.sonatype.org&style=plastic) | diff --git a/RELEASING.md b/RELEASING.md index 38502e17d..ed0cb67c7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,7 +7,8 @@ The following list describe the steps necessary to release a new version. 1. AMQP: https://amqp.demo.springwolf.dev/ 2. CloudStream https://cloud-stream.demo.springwolf.dev/ 3. Kafka: https://kafka.demo.springwolf.dev/ - 4. SQS: https://sqs.demo.springwolf.dev/ + 4. SNS: https://sns.demo.springwolf.dev/ + 5. SQS: https://sqs.demo.springwolf.dev/ 3. Remove the `-SNAPHSOT` postfix in `.env`, create a new branch `release/0.X.X` (version number), commit & push 4. Run GitHub `Publish releases` pipeline from the newly created release branch 5. Release version in nexus diff --git a/build.gradle b/build.gradle index d29595b4b..99746e2f8 100644 --- a/build.gradle +++ b/build.gradle @@ -68,6 +68,7 @@ allprojects { project.name == 'springwolf-amqp' || project.name == 'springwolf-cloud-stream' || project.name == 'springwolf-kafka' || + project.name == 'springwolf-sns' || project.name == 'springwolf-sqs' || project.name == 'springwolf-ui' || project.name == 'springwolf-common-model-converters') diff --git a/settings.gradle b/settings.gradle index cc62680bf..21effccd9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,10 +5,12 @@ include( 'springwolf-plugins:springwolf-amqp-plugin', 'springwolf-plugins:springwolf-cloud-stream-plugin', 'springwolf-plugins:springwolf-kafka-plugin', + 'springwolf-plugins:springwolf-sns-plugin', 'springwolf-plugins:springwolf-sqs-plugin', 'springwolf-examples:springwolf-amqp-example', 'springwolf-examples:springwolf-cloud-stream-example', 'springwolf-examples:springwolf-kafka-example', + 'springwolf-examples:springwolf-sns-example', 'springwolf-examples:springwolf-sqs-example', 'springwolf-ui', 'springwolf-add-ons:springwolf-common-model-converters' @@ -17,5 +19,6 @@ include( project(':springwolf-plugins:springwolf-amqp-plugin').name = 'springwolf-amqp' project(':springwolf-plugins:springwolf-cloud-stream-plugin').name = 'springwolf-cloud-stream' project(':springwolf-plugins:springwolf-kafka-plugin').name = 'springwolf-kafka' +project(':springwolf-plugins:springwolf-sns-plugin').name = 'springwolf-sns' project(':springwolf-plugins:springwolf-sqs-plugin').name = 'springwolf-sqs' diff --git a/springwolf-examples/springwolf-sns-example/.env b/springwolf-examples/springwolf-sns-example/.env new file mode 100644 index 000000000..3e6c13682 --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/.env @@ -0,0 +1 @@ +SPRINGWOLF_VERSION=0.16.0-SNAPSHOT diff --git a/springwolf-examples/springwolf-sns-example/README.md b/springwolf-examples/springwolf-sns-example/README.md new file mode 100644 index 000000000..fc5e472fa --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/README.md @@ -0,0 +1,6 @@ +## Usage + +### Run with docker compose (recommended) +1. Copy the `docker-compose.yml` file to your machine. +2. Run `$ docker-compose up`. +3. Visit `localhost:8080/springwolf/asyncapi-ui.html` or try the API: `$ curl localhost:8080/springwolf/docs`. diff --git a/springwolf-examples/springwolf-sns-example/build.gradle b/springwolf-examples/springwolf-sns-example/build.gradle new file mode 100644 index 000000000..dc0a2e894 --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/build.gradle @@ -0,0 +1,80 @@ +plugins { + id 'java' + + id 'org.springframework.boot' + id 'io.spring.dependency-management' + id 'ca.cutterslade.analyze' + + id 'com.bmuschko.docker-spring-boot-application' version '9.3.4' +} + +dependencyManagement { + imports { + mavenBom "io.awspring.cloud:spring-cloud-aws-dependencies:3.0.2" + } +} + +dependencies { + implementation project(":springwolf-core") + implementation project(":springwolf-plugins:springwolf-sns") + + annotationProcessor project(":springwolf-plugins:springwolf-sns") + runtimeOnly project(":springwolf-ui") + + runtimeOnly "org.springframework.boot:spring-boot-starter-web" + + implementation "org.slf4j:slf4j-api:${slf4jApiVersion}" + implementation "io.swagger.core.v3:swagger-annotations:${swaggerVersion}" + + implementation 'io.awspring.cloud:spring-cloud-aws-sns' + implementation 'io.awspring.cloud:spring-cloud-aws-starter-sns' + permitUnusedDeclared 'io.awspring.cloud:spring-cloud-aws-starter-sns' +// implementation 'io.awspring.cloud:spring-cloud-aws-messaging' +// implementation 'io.awspring.cloud:spring-cloud-aws-autoconfigure' + implementation "org.springframework.boot:spring-boot-autoconfigure" + implementation "org.springframework.boot:spring-boot" + implementation "org.springframework:spring-context" + implementation "org.springframework:spring-messaging" + + testRuntimeOnly "org.junit.jupiter:junit-jupiter:${junitJupiterVersion}" + + testImplementation "com.vaadin.external.google:android-json:${androidJsonVersion}" + + testImplementation "org.junit.jupiter:junit-jupiter-api:${junitJupiterVersion}" + + testImplementation "org.mockito:mockito-core:${mockitoCoreVersion}" + + testImplementation "org.springframework.boot:spring-boot-test" + testImplementation "org.springframework:spring-beans" + testImplementation "org.springframework:spring-web" + testImplementation "org.springframework:spring-test" + + testImplementation "org.testcontainers:testcontainers:${testcontainersVersion}" + testImplementation "org.testcontainers:junit-jupiter:${testcontainersVersion}" + testImplementation "org.testcontainers:localstack:${testcontainersVersion}" +} + +docker { + springBootApplication { + maintainer = 'shamir.stav@gmail.com' + baseImage = 'eclipse-temurin:17-jre-focal' + ports = [8080] + images = ["stavshamir/springwolf-sns-example:${project.version}"] + } + + registryCredentials { + username = project.findProperty('DOCKERHUB_USERNAME') ?: '' + password = project.findProperty('DOCKERHUB_TOKEN') ?: '' + } +} + +test { + dependsOn dockerBuildImage + dependsOn spotlessApply // Automatically fix code formatting if possible + + useJUnitPlatform() + + testLogging { + exceptionFormat = 'full' + } +} diff --git a/springwolf-examples/springwolf-sns-example/docker-compose.yml b/springwolf-examples/springwolf-sns-example/docker-compose.yml new file mode 100644 index 000000000..b648eff3e --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/docker-compose.yml @@ -0,0 +1,36 @@ +version: '3' +services: + app: + image: stavshamir/springwolf-sns-example:${SPRINGWOLF_VERSION} + links: + - localstack + environment: + - SPRING_CLOUD_AWS_ENDPOINT=http://localstack:4566 + ports: + - "8080:8080" + depends_on: + - localstack + + localstack: + image: localstack/localstack:2.2.0 + environment: + - DEBUG=${DEBUG-} + - AWS_REGION=eu-central-1 + - SERVICES=sqs,sns + ports: + - "4566:4566" # LocalStack Gateway +# - "4510-4559:4510-4559" # external services port range + volumes: + - "${TMPDIR:-/tmp/localstack}:/tmp/localstack" + - "/var/run/docker.sock:/var/run/docker.sock" + localstack_setup: + image: localstack/localstack:2.2.0 + links: + - localstack + depends_on: + - localstack + restart: "no" + entrypoint: [ "bash", "-c", " + awslocal --endpoint-url=http://localstack:4566 sns create-topic --name another-topic --region eu-central-1; + awslocal --endpoint-url=http://localstack:4566 sns create-topic --name example-topic --region eu-central-1; + " ] \ No newline at end of file diff --git a/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/SpringwolfSnsExampleApplication.java b/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/SpringwolfSnsExampleApplication.java new file mode 100644 index 000000000..0acdbd03a --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/SpringwolfSnsExampleApplication.java @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.example.sns; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringwolfSnsExampleApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringwolfSnsExampleApplication.class, args); + } +} diff --git a/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/consumers/ExampleConsumer.java b/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/consumers/ExampleConsumer.java new file mode 100644 index 000000000..d00c9ddaa --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/consumers/ExampleConsumer.java @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.example.sns.consumers; + +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.SnsAsyncOperationBinding; +import io.github.stavshamir.springwolf.example.sns.dtos.AnotherPayloadDto; +import io.github.stavshamir.springwolf.example.sns.dtos.ExamplePayloadDto; +import io.github.stavshamir.springwolf.example.sns.producers.AnotherProducer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ExampleConsumer { + private final AnotherProducer anotherProducer; + + @AsyncListener(operation = @AsyncOperation(channelName = "example-topic")) + @SnsAsyncOperationBinding + public void receiveExamplePayload(ExamplePayloadDto payload) { + log.info("Received new message in example-topic: {}", payload.toString()); + + AnotherPayloadDto example = new AnotherPayloadDto(); + example.setExample(payload); + example.setFoo("foo"); + + anotherProducer.sendMessage(example); + } + + @AsyncListener(operation = @AsyncOperation(channelName = "another-topic")) + @SnsAsyncOperationBinding + public void receiveAnotherPayload(AnotherPayloadDto payload) { + log.info("Received new message in another-topic: {}", payload.toString()); + } +} diff --git a/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/dtos/AnotherPayloadDto.java b/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/dtos/AnotherPayloadDto.java new file mode 100644 index 000000000..3a88a2ea1 --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/dtos/AnotherPayloadDto.java @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.example.sns.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +@Schema(description = "Another payload model") +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AnotherPayloadDto { + + @Schema(description = "Foo field", example = "bar", requiredMode = NOT_REQUIRED) + private String foo; + + @Schema(description = "Example field", requiredMode = REQUIRED) + private ExamplePayloadDto example; +} diff --git a/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/dtos/ExamplePayloadDto.java b/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/dtos/ExamplePayloadDto.java new file mode 100644 index 000000000..8a1e8bf92 --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/dtos/ExamplePayloadDto.java @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.example.sns.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +@Schema(description = "Example payload model") +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ExamplePayloadDto { + @Schema(description = "Some string field", example = "some string value", requiredMode = REQUIRED) + private String someString; + + @Schema(description = "Some long field", example = "5") + private long someLong; + + @Schema(description = "Some enum field", example = "FOO2", requiredMode = REQUIRED) + private ExampleEnum someEnum; + + public enum ExampleEnum { + FOO1, + FOO2, + FOO3 + } +} diff --git a/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/producers/AnotherProducer.java b/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/producers/AnotherProducer.java new file mode 100644 index 000000000..4d500477a --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/src/main/java/io/github/stavshamir/springwolf/example/sns/producers/AnotherProducer.java @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.example.sns.producers; + +import io.awspring.cloud.sns.core.SnsTemplate; +import io.github.stavshamir.springwolf.asyncapi.scanners.channels.operationdata.annotation.AsyncOperation; +import io.github.stavshamir.springwolf.asyncapi.scanners.channels.operationdata.annotation.AsyncPublisher; +import io.github.stavshamir.springwolf.asyncapi.scanners.channels.operationdata.annotation.SnsAsyncOperationBinding; +import io.github.stavshamir.springwolf.example.sns.dtos.AnotherPayloadDto; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AnotherProducer { + private final SnsTemplate template; + + public static final String TOPIC = "another-topic"; + + @AsyncPublisher( + operation = + @AsyncOperation( + channelName = TOPIC, + description = "Custom, optional description defined in the AsyncPublisher annotation")) + @SnsAsyncOperationBinding + public void sendMessage(AnotherPayloadDto msg) { + template.send(TOPIC, MessageBuilder.withPayload(msg).build()); + } +} diff --git a/springwolf-examples/springwolf-sns-example/src/main/resources/application.properties b/springwolf-examples/springwolf-sns-example/src/main/resources/application.properties new file mode 100644 index 000000000..63644df29 --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/src/main/resources/application.properties @@ -0,0 +1,35 @@ +######### +# Spring configuration +spring.application.name=Springwolf example project - SNS + + +######### +# Spring AWS configuration +spring.cloud.aws.endpoint=http://localhost:4566 +spring.cloud.aws.region.static=eu-central-1 +spring.cloud.aws.stack.auto=false +spring.cloud.aws.stack.enabled=false +spring.cloud.aws.credentials.secretKey=ABC +spring.cloud.aws.credentials.accessKey=XYZ + + +######### +# Springwolf configuration +springwolf.enabled=true +springwolf.docket.base-package=io.github.stavshamir.springwolf.example.sns +springwolf.docket.info.title=${spring.application.name} +springwolf.docket.info.version=1.0.0 +springwolf.docket.info.description=Springwolf example project to demonstrate springwolfs abilities +springwolf.docket.info.terms-of-service=http://asyncapi.org/terms +springwolf.docket.info.contact.name=springwolf +springwolf.docket.info.contact.email=example@example.com +springwolf.docket.info.contact.url=https://github.com/springwolf/springwolf-core +springwolf.docket.info.license.name=Apache License 2.0 +springwolf.docket.servers.sns.protocol=sns +springwolf.docket.servers.sns.url=http://localhost:4566 + +springwolf.plugin.sns.publishing.enabled=true + + +# For debugging purposes +logging.level.io.github.stavshamir.springwolf=DEBUG diff --git a/springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/ApiIntegrationTest.java b/springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/ApiIntegrationTest.java new file mode 100644 index 000000000..f558cc26d --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/ApiIntegrationTest.java @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.example.sns; + +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest( + classes = {SpringwolfSnsExampleApplication.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ExtendWith({SnsTestContainerExtension.class}) +class ApiIntegrationTest { + + @DynamicPropertySource + static void setUpTestContainers(DynamicPropertyRegistry registry) { + SnsTestContainerExtension.overrideConfiguration(registry); + } + + @Autowired + private TestRestTemplate restTemplate; + + @Value("${server.port}") + public Integer serverPort; + + @Test + void asyncApiResourceArtifactTest() throws JSONException, IOException { + String url = "/springwolf/docs"; + String actual = restTemplate.getForObject(url, String.class); + System.out.println("Got: " + actual); + + InputStream s = this.getClass().getResourceAsStream("/asyncapi.json"); + String expected = new String(s.readAllBytes(), StandardCharsets.UTF_8); + + assertEquals(expected, actual); + } +} diff --git a/springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/ApiIntegrationWithDockerIntegrationTest.java b/springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/ApiIntegrationWithDockerIntegrationTest.java new file mode 100644 index 000000000..dc27a5426 --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/ApiIntegrationWithDockerIntegrationTest.java @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.example.sns; + +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestTemplate; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * While the assertion of this test is identical to ApiIntegrationTests, + * the setup uses a full docker-compose context with a real sns instance. + */ +@Testcontainers +// @Ignore("Uncomment this line if you have issues running this test on your local machine.") +public class ApiIntegrationWithDockerIntegrationTest { + + private static final RestTemplate restTemplate = new RestTemplate(); + private static final String APP_NAME = "app_1"; + private static final int APP_PORT = 8080; + + private static final Map ENV = new HashMap<>(); + + static { + try (InputStream input = new FileInputStream(".env")) { + var properties = new Properties(); + properties.load(input); + properties.forEach((key, value) -> ENV.put(String.valueOf(key), String.valueOf(value))); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Container + public DockerComposeContainer environment = new DockerComposeContainer<>(new File("docker-compose.yml")) + .withExposedService(APP_NAME, APP_PORT) + .withEnv(ENV); + + private String baseUrl() { + String host = environment.getServiceHost(APP_NAME, APP_PORT); + int port = environment.getServicePort(APP_NAME, APP_PORT); + return String.format("http://%s:%d", host, port); + } + + @Test + void asyncapiDocsShouldReturnTheCorrectJsonResponse() throws IOException, JSONException { + String url = baseUrl() + "/springwolf/docs"; + String actual = restTemplate.getForObject(url, String.class); + System.out.println("Got: " + actual); + + InputStream s = this.getClass().getResourceAsStream("/asyncapi.json"); + String expected = new String(s.readAllBytes(), StandardCharsets.UTF_8); + + assertEquals(expected, actual); + } +} diff --git a/springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/SnsTestContainerExtension.java b/springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/SnsTestContainerExtension.java new file mode 100644 index 000000000..f4234bb01 --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/SnsTestContainerExtension.java @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.example.sns; + +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; + +import static org.junit.jupiter.api.extension.ExtensionContext.Namespace.GLOBAL; +import static org.testcontainers.containers.localstack.LocalStackContainer.Service.SNS; + +/** + * JUnit5 extension to start the localstack testcontainers once + * and keep it running until all test classes have been completed. + */ +public class SnsTestContainerExtension implements BeforeAllCallback, ExtensionContext.Store.CloseableResource { + + private static volatile boolean started = false; + + static LocalStackContainer localStack = + new LocalStackContainer(DockerImageName.parse("localstack/localstack:2.2.0")).withServices(SNS); + + @Override + public void beforeAll(ExtensionContext extensionContext) throws Exception { + if (!started) { + started = true; + + beforeAllOnce(); + + // Ensure closeableResource {@see #close()} method is called + extensionContext.getRoot().getStore(GLOBAL).put("any unique name", this); + } + } + + private static void beforeAllOnce() throws IOException, InterruptedException { + localStack.start(); + + localStack.execInContainer( + "awslocal", "sns", "create-topic", "--name", "another-topic", "--region", "eu-central-1"); + localStack.execInContainer( + "awslocal", "sns", "create-topic", "--name", "example-topic", "--region", "eu-central-1"); + } + + @Override + public void close() throws Throwable { + localStack.stop(); + } + + static void overrideConfiguration(DynamicPropertyRegistry registry) { + registry.add("spring.cloud.aws.endpoint", () -> localStack.getEndpoint()); + registry.add("spring.cloud.aws.credentials.access-key", localStack::getAccessKey); + registry.add("spring.cloud.aws.credentials.secret-key", localStack::getSecretKey); + } +} diff --git a/springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/SpringwolfSnsExampleApplicationIntegrationTest.java b/springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/SpringwolfSnsExampleApplicationIntegrationTest.java new file mode 100644 index 000000000..6f538d599 --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/src/test/java/io/github/stavshamir/springwolf/example/sns/SpringwolfSnsExampleApplicationIntegrationTest.java @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.example.sns; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SpringBootTest +@ExtendWith({SnsTestContainerExtension.class}) +class SpringwolfSnsExampleApplicationIntegrationTest { + @DynamicPropertySource + static void setUpTestContainers(DynamicPropertyRegistry registry) { + SnsTestContainerExtension.overrideConfiguration(registry); + } + + @Autowired + private ApplicationContext context; + + @Test + void testContext() { + assertNotNull(context); + } +} diff --git a/springwolf-examples/springwolf-sns-example/src/test/resources/asyncapi.json b/springwolf-examples/springwolf-sns-example/src/test/resources/asyncapi.json new file mode 100644 index 000000000..6daa45cb1 --- /dev/null +++ b/springwolf-examples/springwolf-sns-example/src/test/resources/asyncapi.json @@ -0,0 +1,166 @@ +{ + "asyncapi": "2.6.0", + "info": { + "title": "Springwolf example project - SNS", + "version": "1.0.0", + "description": "Springwolf example project to demonstrate springwolfs abilities", + "contact": { + "name": "springwolf", + "url": "https://github.com/springwolf/springwolf-core", + "email": "example@example.com" + }, + "license": { + "name": "Apache License 2.0" + } + }, + "defaultContentType": "application/json", + "servers": { + "sns": { + "url": "http://localhost:4566", + "protocol": "sns" + } + }, + "channels": { + "another-topic": { + "subscribe": { + "operationId": "another-topic_subscribe", + "description": "Custom, optional description defined in the AsyncPublisher annotation", + "bindings": { + "sns": { } + }, + "message": { + "schemaFormat": "application/vnd.oai.openapi+json;version=3.0.0", + "name": "io.github.stavshamir.springwolf.example.sns.dtos.AnotherPayloadDto", + "title": "AnotherPayloadDto", + "description": "Another payload model", + "payload": { + "$ref": "#/components/schemas/AnotherPayloadDto" + }, + "headers": { + "$ref": "#/components/schemas/HeadersNotDocumented" + }, + "bindings": { + "sns": { } + } + } + }, + "publish": { + "operationId": "another-topic_publish", + "description": "Auto-generated description", + "bindings": { + "sns": { } + }, + "message": { + "schemaFormat": "application/vnd.oai.openapi+json;version=3.0.0", + "name": "io.github.stavshamir.springwolf.example.sns.dtos.AnotherPayloadDto", + "title": "AnotherPayloadDto", + "description": "Another payload model", + "payload": { + "$ref": "#/components/schemas/AnotherPayloadDto" + }, + "headers": { + "$ref": "#/components/schemas/HeadersNotDocumented" + }, + "bindings": { + "sns": { } + } + } + } + }, + "example-topic": { + "publish": { + "operationId": "example-topic_publish", + "description": "Auto-generated description", + "bindings": { + "sns": { } + }, + "message": { + "schemaFormat": "application/vnd.oai.openapi+json;version=3.0.0", + "name": "io.github.stavshamir.springwolf.example.sns.dtos.ExamplePayloadDto", + "title": "ExamplePayloadDto", + "description": "Example payload model", + "payload": { + "$ref": "#/components/schemas/ExamplePayloadDto" + }, + "headers": { + "$ref": "#/components/schemas/HeadersNotDocumented" + }, + "bindings": { + "sns": { } + } + } + } + } + }, + "components": { + "schemas": { + "AnotherPayloadDto": { + "required": [ + "example" + ], + "type": "object", + "properties": { + "example": { + "$ref": "#/components/schemas/ExamplePayloadDto" + }, + "foo": { + "type": "string", + "description": "Foo field", + "example": "bar" + } + }, + "description": "Another payload model", + "example": { + "example": { + "someEnum": "FOO2", + "someLong": 5, + "someString": "some string value" + }, + "foo": "bar" + } + }, + "ExamplePayloadDto": { + "required": [ + "someEnum", + "someString" + ], + "type": "object", + "properties": { + "someEnum": { + "type": "string", + "description": "Some enum field", + "example": "FOO2", + "enum": [ + "FOO1", + "FOO2", + "FOO3" + ] + }, + "someLong": { + "type": "integer", + "description": "Some long field", + "format": "int64", + "example": 5 + }, + "someString": { + "type": "string", + "description": "Some string field", + "example": "some string value" + } + }, + "description": "Example payload model", + "example": { + "someEnum": "FOO2", + "someLong": 5, + "someString": "some string value" + } + }, + "HeadersNotDocumented": { + "type": "object", + "properties": { }, + "example": { } + } + } + }, + "tags": [ ] +} \ No newline at end of file diff --git a/springwolf-plugins/springwolf-sns-plugin/README.md b/springwolf-plugins/springwolf-sns-plugin/README.md new file mode 100644 index 000000000..176f2818b --- /dev/null +++ b/springwolf-plugins/springwolf-sns-plugin/README.md @@ -0,0 +1,42 @@ +# Springwolf SNS Plugin + +##### Automated documentation for Spring Boot application with AWS SNS (Simple Notification Service) consumers + +### Table Of Contents + +- [About](#about) +- [Usage](#usage) + - [Dependencies](#dependencies) + - [Configuration class](#configuration-class) +- [Verify](#verify) +- [Example Project](#example-project) + +### About + +This plugin generates an [AsyncAPI document](https://www.asyncapi.com/) from `@SnsAsyncOperationBinding` methods. + +### Usage + +Add the following dependencies and configuration class to enable this plugin. + +#### Dependencies + +```groovy +dependencies { + // Provides the documentation API + implementation 'io.github.springwolf:springwolf-sns:' + + // Provides the UI - optional (recommended) + runtimeOnly 'io.github.springwolf:springwolf-ui:' +} +``` + +#### Verify + +If you have included the UI dependency, access it with the following url: `localhost:8080/springwolf/asyncapi-ui.html`. +If not, try the following endpoint: `localhost:8080/springwolf/docs`. + +### Example Project + +See [springwolf-sns-example](https://github.com/springwolf/springwolf-core/tree/master/springwolf-examples/springwolf-sns-example) +. diff --git a/springwolf-plugins/springwolf-sns-plugin/build.gradle b/springwolf-plugins/springwolf-sns-plugin/build.gradle new file mode 100644 index 000000000..1bc29a3eb --- /dev/null +++ b/springwolf-plugins/springwolf-sns-plugin/build.gradle @@ -0,0 +1,81 @@ +plugins { + id 'java-library' + + id 'org.springframework.boot' + id 'io.spring.dependency-management' + id 'ca.cutterslade.analyze' +} + +dependencyManagement { + imports { + mavenBom "io.awspring.cloud:spring-cloud-aws-dependencies:3.0.2" + } +} + +dependencies { + api project(":springwolf-core") + + implementation "com.asyncapi:asyncapi-core:${asyncapiCoreVersion}" + implementation "org.slf4j:slf4j-api:${slf4jApiVersion}" + + implementation 'io.awspring.cloud:spring-cloud-aws-sns' + implementation 'io.awspring.cloud:spring-cloud-aws-starter-sns' + permitUnusedDeclared 'io.awspring.cloud:spring-cloud-aws-starter-sns' + implementation "org.springframework:spring-beans" + implementation "org.springframework:spring-context" + implementation "org.springframework:spring-core" + implementation "org.springframework:spring-messaging" + implementation "org.springframework:spring-web" + implementation "org.springframework.boot:spring-boot" + implementation "org.springframework.boot:spring-boot-autoconfigure" + + implementation "com.fasterxml.jackson.core:jackson-core:${jacksonVersion}" + implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}" + + compileOnly "com.google.code.findbugs:jsr305:${jsr305Version}" + permitUnusedDeclared "com.google.code.findbugs:jsr305:${jsr305Version}" + + annotationProcessor "org.projectlombok:lombok:${lombokVersion}" + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + + testAnnotationProcessor "org.projectlombok:lombok:${lombokVersion}" + + testImplementation "org.assertj:assertj-core:${assertjCoreVersion}" + testImplementation "org.junit.jupiter:junit-jupiter-api:${junitJupiterVersion}" + testImplementation "org.mockito:mockito-core:${mockitoCoreVersion}" + + testImplementation "org.springframework.boot:spring-boot-test" + testImplementation "org.springframework:spring-beans" + testImplementation "org.springframework:spring-test" + + testRuntimeOnly "org.junit.jupiter:junit-jupiter:${junitJupiterVersion}" + testAnnotationProcessor "org.projectlombok:lombok:${lombokVersion}" +} + +jar { + enabled = true + archiveClassifier = '' +} +bootJar.enabled = false + +java { + withJavadocJar() + withSourcesJar() +} + +test { + dependsOn spotlessApply // Automatically fix code formatting if possible + + useJUnitPlatform() +} + +publishing { + publications { + mavenJava(MavenPublication) { + pom { + name = 'springwolf-sns' + description = 'Automated JSON API documentation for AWS SNS Listeners built with Spring' + } + } + } +} diff --git a/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/controller/SpringwolfSnsController.java b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/controller/SpringwolfSnsController.java new file mode 100644 index 000000000..8fd66448d --- /dev/null +++ b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/controller/SpringwolfSnsController.java @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.asyncapi.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.stavshamir.springwolf.asyncapi.controller.dtos.MessageDto; +import io.github.stavshamir.springwolf.configuration.AsyncApiDocketService; +import io.github.stavshamir.springwolf.producer.SpringwolfSnsProducer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.text.MessageFormat; + +import static io.github.stavshamir.springwolf.configuration.properties.SpringwolfSnsConfigConstants.SPRINGWOLF_SNS_CONFIG_PREFIX; +import static io.github.stavshamir.springwolf.configuration.properties.SpringwolfSnsConfigConstants.SPRINGWOLF_SNS_PLUGIN_PUBLISHING_ENABLED; + +@Slf4j +@RestController +@RequestMapping("/springwolf/sns") +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = SPRINGWOLF_SNS_CONFIG_PREFIX, name = SPRINGWOLF_SNS_PLUGIN_PUBLISHING_ENABLED) +public class SpringwolfSnsController implements InitializingBean { + + private final AsyncApiDocketService asyncApiDocketService; + + private final SpringwolfSnsProducer producer; + + private final ObjectMapper objectMapper; + + @PostMapping("/publish") + public void publish(@RequestParam String topic, @RequestBody MessageDto message) { + if (!producer.isEnabled()) { + log.warn("SNS producer is not enabled - message will not be published"); + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "SNS producer is not enabled"); + } + + String payloadType = message.getPayloadType(); + if (payloadType.startsWith(asyncApiDocketService.getAsyncApiDocket().getBasePackage())) { + try { + Class payloadClass = Class.forName(payloadType); + Object payload = objectMapper.readValue(message.getPayload(), payloadClass); + + log.debug("Publishing to sns queue {}: {}", topic, payload); + producer.send(topic, MessageBuilder.withPayload(payload).build()); + } catch (ClassNotFoundException | JsonProcessingException ex) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + MessageFormat.format( + "Unable to create payload {0} from data: {1}", payloadType, message.getPayload())); + } + } else { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No payloadType specified."); + } + } + + @Override + public void afterPropertiesSet() { + log.debug("Message publishing via " + this.getClass().getSimpleName() + " is active."); + } +} diff --git a/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/SnsMessageBindingProcessor.java b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/SnsMessageBindingProcessor.java new file mode 100644 index 000000000..624692bb7 --- /dev/null +++ b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/SnsMessageBindingProcessor.java @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.asyncapi.scanners.channels.operationdata; + +import com.asyncapi.v2.binding.message.sns.SNSMessageBinding; +import io.github.stavshamir.springwolf.asyncapi.scanners.channels.operationdata.annotation.MessageBindingProcessor; +import io.github.stavshamir.springwolf.asyncapi.scanners.channels.operationdata.annotation.SnsAsyncOperationBinding; +import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.stereotype.Component; +import org.springframework.util.StringValueResolver; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Optional; + +@Component +public class SnsMessageBindingProcessor implements MessageBindingProcessor, EmbeddedValueResolverAware { + private StringValueResolver resolver; + + @Override + public void setEmbeddedValueResolver(StringValueResolver resolver) { + this.resolver = resolver; + } + + @Override + public Optional process(Method method) { + return Arrays.stream(method.getAnnotations()) + .filter(SnsAsyncOperationBinding.class::isInstance) + .map(SnsAsyncOperationBinding.class::cast) + .findAny() + .map(this::mapToMessageBinding); + } + + private ProcessedMessageBinding mapToMessageBinding(SnsAsyncOperationBinding bindingAnnotation) { + return new ProcessedMessageBinding(bindingAnnotation.type(), new SNSMessageBinding()); + } +} diff --git a/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/SnsOperationBindingProcessor.java b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/SnsOperationBindingProcessor.java new file mode 100644 index 000000000..6df6f9554 --- /dev/null +++ b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/SnsOperationBindingProcessor.java @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.asyncapi.scanners.channels.operationdata; + +import com.asyncapi.v2.binding.operation.sqs.SQSOperationBinding; +import io.github.stavshamir.springwolf.asyncapi.scanners.channels.AbstractOperationBindingProcessor; +import io.github.stavshamir.springwolf.asyncapi.scanners.channels.operationdata.annotation.SnsAsyncOperationBinding; +import org.springframework.stereotype.Component; + +@Component +public class SnsOperationBindingProcessor extends AbstractOperationBindingProcessor { + + @Override + protected ProcessedOperationBinding mapToOperationBinding(SnsAsyncOperationBinding bindingAnnotation) { + return new ProcessedOperationBinding(bindingAnnotation.type(), new SQSOperationBinding()); + } +} diff --git a/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/annotation/SnsAsyncOperationBinding.java b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/annotation/SnsAsyncOperationBinding.java new file mode 100644 index 000000000..a9b584c40 --- /dev/null +++ b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/asyncapi/scanners/channels/operationdata/annotation/SnsAsyncOperationBinding.java @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.asyncapi.scanners.channels.operationdata.annotation; + +import io.github.stavshamir.springwolf.asyncapi.scanners.channels.annotation.AsyncOperationBinding; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * {@code @SnsAsyncOperationBinding} is a method-level annotation used in combination with {@link AsyncPublisher} or {@link AsyncListener}. + * It configures the operation binding for the SNS protocol. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(value = {ElementType.METHOD}) +@AsyncOperationBinding +public @interface SnsAsyncOperationBinding { + + String type() default "sns"; +} diff --git a/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/configuration/properties/SpringwolfSnsConfigConstants.java b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/configuration/properties/SpringwolfSnsConfigConstants.java new file mode 100644 index 000000000..0f4341386 --- /dev/null +++ b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/configuration/properties/SpringwolfSnsConfigConstants.java @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.configuration.properties; + +import static io.github.stavshamir.springwolf.configuration.properties.SpringwolfConfigConstants.ENABLED; +import static io.github.stavshamir.springwolf.configuration.properties.SpringwolfConfigConstants.SCANNER; +import static io.github.stavshamir.springwolf.configuration.properties.SpringwolfConfigConstants.SPRINGWOLF_PLUGIN_CONFIG_PREFIX; + +public class SpringwolfSnsConfigConstants { + + public static final String SPRINGWOLF_SNS_CONFIG_PREFIX = SPRINGWOLF_PLUGIN_CONFIG_PREFIX + ".sns"; + + public static final String SPRINGWOLF_SNS_PLUGIN_PUBLISHING_ENABLED = "publishing.enabled"; + + public static final String SPRINGWOLF_SCANNER_SNS_LISTENER_ENABLED = + SPRINGWOLF_SNS_CONFIG_PREFIX + SCANNER + ".sns-listener" + ENABLED; +} diff --git a/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/configuration/properties/SpringwolfSnsConfigProperties.java b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/configuration/properties/SpringwolfSnsConfigProperties.java new file mode 100644 index 000000000..1c7ae2dce --- /dev/null +++ b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/configuration/properties/SpringwolfSnsConfigProperties.java @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.configuration.properties; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.Nullable; + +/** + * This class is used to create metadata for auto-completion in spring configuration properties/yaml by using + * the spring-boot-configuration-processor. + */ +@Configuration +@ConfigurationProperties(prefix = SpringwolfSnsConfigConstants.SPRINGWOLF_SNS_CONFIG_PREFIX) +@ConditionalOnProperty(name = SpringwolfConfigConstants.SPRINGWOLF_ENABLED, matchIfMissing = true) +@Getter +@Setter +public class SpringwolfSnsConfigProperties { + + @Nullable + private Publishing publishing; + + @Nullable + private Scanner scanner; + + @Getter + @Setter + public static class Publishing { + + /** + * Enables/Disables the possibility to publish messages through springwolf on the configured sns instance. + */ + private boolean enabled = false; + } + + @Getter + @Setter + public static class Scanner { + + private static SnsListener snsListener; + + @Getter + @Setter + public static class SnsListener { + + /** + * This mirrors the ConfigConstant {@see SpringwolfSnsConfigConstants#SPRINGWOLF_SCANNER_SNS_LISTENER_ENABLED} + */ + private boolean enabled = true; + } + } +} diff --git a/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/producer/SpringwolfSnsProducer.java b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/producer/SpringwolfSnsProducer.java new file mode 100644 index 000000000..0dafb296f --- /dev/null +++ b/springwolf-plugins/springwolf-sns-plugin/src/main/java/io/github/stavshamir/springwolf/producer/SpringwolfSnsProducer.java @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.producer; + +import io.awspring.cloud.sns.core.SnsTemplate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.messaging.Message; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +import static io.github.stavshamir.springwolf.configuration.properties.SpringwolfSnsConfigConstants.SPRINGWOLF_SNS_CONFIG_PREFIX; +import static io.github.stavshamir.springwolf.configuration.properties.SpringwolfSnsConfigConstants.SPRINGWOLF_SNS_PLUGIN_PUBLISHING_ENABLED; + +@Slf4j +@Service +@ConditionalOnProperty(prefix = SPRINGWOLF_SNS_CONFIG_PREFIX, name = SPRINGWOLF_SNS_PLUGIN_PUBLISHING_ENABLED) +public class SpringwolfSnsProducer { + + private final Optional template; + + public SpringwolfSnsProducer(List templates) { + this.template = templates.isEmpty() ? Optional.empty() : Optional.of(templates.get(0)); + } + + public boolean isEnabled() { + return template.isPresent(); + } + + public void send(String channelName, Message payload) { + if (template.isPresent()) { + template.get().send(channelName, payload); + } else { + log.warn("SNS producer is not configured"); + } + } +} diff --git a/springwolf-plugins/springwolf-sns-plugin/src/test/java/io/github/stavshamir/springwolf/configuration/ObjectMapperTestConfiguration.java b/springwolf-plugins/springwolf-sns-plugin/src/test/java/io/github/stavshamir/springwolf/configuration/ObjectMapperTestConfiguration.java new file mode 100644 index 000000000..6e87c75f9 --- /dev/null +++ b/springwolf-plugins/springwolf-sns-plugin/src/test/java/io/github/stavshamir/springwolf/configuration/ObjectMapperTestConfiguration.java @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.configuration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class ObjectMapperTestConfiguration { + + @ConditionalOnMissingBean + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } +} diff --git a/springwolf-plugins/springwolf-sns-plugin/src/test/java/io/github/stavshamir/springwolf/configuration/SpringwolfSnsProducerConfigurationIntegrationTest.java b/springwolf-plugins/springwolf-sns-plugin/src/test/java/io/github/stavshamir/springwolf/configuration/SpringwolfSnsProducerConfigurationIntegrationTest.java new file mode 100644 index 000000000..81b72170d --- /dev/null +++ b/springwolf-plugins/springwolf-sns-plugin/src/test/java/io/github/stavshamir/springwolf/configuration/SpringwolfSnsProducerConfigurationIntegrationTest.java @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.configuration; + +import io.awspring.cloud.sns.core.SnsTemplate; +import io.github.stavshamir.springwolf.asyncapi.AsyncApiService; +import io.github.stavshamir.springwolf.asyncapi.ChannelsService; +import io.github.stavshamir.springwolf.asyncapi.controller.SpringwolfSnsController; +import io.github.stavshamir.springwolf.configuration.properties.SpringwolfConfigProperties; +import io.github.stavshamir.springwolf.configuration.properties.SpringwolfSnsConfigProperties; +import io.github.stavshamir.springwolf.producer.SpringwolfSnsProducer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.MockBeans; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +public class SpringwolfSnsProducerConfigurationIntegrationTest { + + @ExtendWith(SpringExtension.class) + @ContextConfiguration( + classes = { + DefaultAsyncApiDocketService.class, + SpringwolfSnsProducer.class, + SpringwolfSnsController.class, + ObjectMapperTestConfiguration.class + }) + @EnableConfigurationProperties(value = {SpringwolfConfigProperties.class, SpringwolfSnsConfigProperties.class}) + @TestPropertySource( + properties = { + "springwolf.enabled=true", + "springwolf.docket.info.title=Info title was loaded from spring properties", + "springwolf.docket.info.version=1.0.0", + "springwolf.docket.base-package=io.github.stavshamir.springwolf.example", + "springwolf.docket.servers.test-protocol.protocol=test", + "springwolf.docket.servers.test-protocol.url=some-server:1234", + "springwolf.plugin.sns.publishing.enabled=true" + }) + @MockBeans(value = {@MockBean(AsyncApiService.class), @MockBean(SnsTemplate.class)}) + @Nested + class SqsProducerWillBeCreatedIfEnabledTest { + @Autowired + private Optional springwolfSqsProducer; + + @Autowired + private Optional springwolfSqsController; + + @Test + void springwolfSqsProducerShouldBePresentInSpringContext() { + assertThat(springwolfSqsProducer).isPresent(); + assertThat(springwolfSqsController).isPresent(); + } + } + + @ExtendWith(SpringExtension.class) + @ContextConfiguration( + classes = { + DefaultAsyncApiDocketService.class, + SpringwolfSnsProducer.class, + SpringwolfSnsController.class, + ObjectMapperTestConfiguration.class + }) + @EnableConfigurationProperties(value = {SpringwolfConfigProperties.class, SpringwolfSnsConfigProperties.class}) + @TestPropertySource( + properties = { + "springwolf.enabled=true", + "springwolf.docket.info.title=Info title was loaded from spring properties", + "springwolf.docket.info.version=1.0.0", + "springwolf.docket.base-package=io.github.stavshamir.springwolf.example", + "springwolf.docket.servers.test-protocol.protocol=test", + "springwolf.docket.servers.test-protocol.url=some-server:1234", + "springwolf.plugin.sns.publishing.enabled=false" + }) + @MockBeans(value = {@MockBean(ChannelsService.class), @MockBean(SnsTemplate.class)}) + @Nested + class SqsProducerWillNotBeCreatedIfDisabledTest { + @Autowired + private Optional springwolfSqsProducer; + + @Autowired + private Optional springwolfSqsController; + + @Test + void springwolfSqsProducerShouldNotBePresentInSpringContext() { + assertThat(springwolfSqsProducer).isNotPresent(); + assertThat(springwolfSqsController).isNotPresent(); + } + } +} diff --git a/springwolf-plugins/springwolf-sns-plugin/src/test/java/io/github/stavshamir/springwolf/producer/SpringwolfSnsProducerTest.java b/springwolf-plugins/springwolf-sns-plugin/src/test/java/io/github/stavshamir/springwolf/producer/SpringwolfSnsProducerTest.java new file mode 100644 index 000000000..b6b3bef21 --- /dev/null +++ b/springwolf-plugins/springwolf-sns-plugin/src/test/java/io/github/stavshamir/springwolf/producer/SpringwolfSnsProducerTest.java @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.stavshamir.springwolf.producer; + +import io.awspring.cloud.sns.core.SnsTemplate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class SpringwolfSnsProducerTest { + private SpringwolfSnsProducer springwolfSqsProducer; + + private SnsTemplate template; + + @BeforeEach + void setUp() { + template = mock(SnsTemplate.class); + + springwolfSqsProducer = new SpringwolfSnsProducer(Collections.singletonList(template)); + } + + @Test + void send_defaultExchangeAndChannelNameAsRoutingKey() { + Map payload = new HashMap<>(); + Message> message = + MessageBuilder.withPayload(payload).build(); + springwolfSqsProducer.send("channel-name", message); + + verify(template).send(eq("channel-name"), same(message)); + } +} diff --git a/springwolf-ui/src/app/shared/mock/mock-server.ts b/springwolf-ui/src/app/shared/mock/mock-server.ts index 5fe7371ce..180faf6c9 100644 --- a/springwolf-ui/src/app/shared/mock/mock-server.ts +++ b/springwolf-ui/src/app/shared/mock/mock-server.ts @@ -7,6 +7,7 @@ import { import mockSpringwolfAmqp from "../../../../../springwolf-examples/springwolf-amqp-example/src/test/resources/asyncapi.json"; import mockSpringwolfCloudStream from "../../../../../springwolf-examples/springwolf-cloud-stream-example/src/test/resources/asyncapi.json"; import mockSpringwolfKafka from "../../../../../springwolf-examples/springwolf-kafka-example/src/test/resources/asyncapi.json"; +import mockSpringwolfSns from "../../../../../springwolf-examples/springwolf-sns-example/src/test/resources/asyncapi.json"; import mockSpringwolfSqs from "../../../../../springwolf-examples/springwolf-sqs-example/src/test/resources/asyncapi.json"; export class MockServer implements InMemoryDbService { @@ -48,6 +49,8 @@ export class MockServer implements InMemoryDbService { return mockSpringwolfAmqp; } else if (hostname.includes("cloud-stream")) { return mockSpringwolfCloudStream; + } else if (hostname.includes("sns")) { + return mockSpringwolfSns; } else if (hostname.includes("sqs")) { return mockSpringwolfSqs; }