diff --git a/subprojects/micronaut-amazon-awssdk-s3/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/s3/SimpleStorageServiceConfigurationSpec.groovy b/subprojects/micronaut-amazon-awssdk-s3/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/s3/SimpleStorageServiceConfigurationSpec.groovy index e849fced6..e0d3d27f8 100644 --- a/subprojects/micronaut-amazon-awssdk-s3/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/s3/SimpleStorageServiceConfigurationSpec.groovy +++ b/subprojects/micronaut-amazon-awssdk-s3/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/s3/SimpleStorageServiceConfigurationSpec.groovy @@ -67,6 +67,7 @@ class SimpleStorageServiceConfigurationSpec extends Specification { context.getBean(SimpleStorageService) context.getBean(SimpleStorageService, Qualifiers.byName('default')) context.getBean(SimpleStorageService, Qualifiers.byName('samplebucket')) + context.getBean(NamedSimpleStorageServiceConfiguration).name == 'samplebucket' } void 'configure default and named service'() { diff --git a/subprojects/micronaut-amazon-awssdk-sns/micronaut-amazon-awssdk-sns.gradle b/subprojects/micronaut-amazon-awssdk-sns/micronaut-amazon-awssdk-sns.gradle new file mode 100644 index 000000000..bf29c6ae0 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/micronaut-amazon-awssdk-sns.gradle @@ -0,0 +1,34 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +config { + bintray { + enabled = true + } +} + +dependencies { + compile project(':micronaut-amazon-awssdk-core') + + compile "space.jasan:groovy-closure-support:$closureSupportVersion" + + compile "software.amazon.awssdk:sns" + + + testCompile group: 'org.testcontainers', name: 'testcontainers', version: testcontainersVersion + testCompile group: 'org.testcontainers', name: 'spock', version: testcontainersVersion +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/DefaultSimpleNotificationService.java b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/DefaultSimpleNotificationService.java new file mode 100644 index 000000000..0e3335ac7 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/DefaultSimpleNotificationService.java @@ -0,0 +1,302 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.reactivex.Emitter; +import io.reactivex.Flowable; +import io.reactivex.functions.BiFunction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.CreatePlatformEndpointRequest; +import software.amazon.awssdk.services.sns.model.DeleteEndpointResponse; +import software.amazon.awssdk.services.sns.model.GetEndpointAttributesResponse; +import software.amazon.awssdk.services.sns.model.InvalidParameterException; +import software.amazon.awssdk.services.sns.model.ListTopicsResponse; +import software.amazon.awssdk.services.sns.model.MessageAttributeValue; +import software.amazon.awssdk.services.sns.model.NotFoundException; +import software.amazon.awssdk.services.sns.model.SetEndpointAttributesResponse; +import software.amazon.awssdk.services.sns.model.Topic; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class DefaultSimpleNotificationService implements SimpleNotificationService { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultSimpleNotificationService.class); + + private final Map namesToArn = new ConcurrentHashMap<>(); + private final SnsClient client; + private final SimpleNotificationServiceConfiguration configuration; + private final ObjectMapper objectMapper; + + public DefaultSimpleNotificationService(SnsClient client, SimpleNotificationServiceConfiguration configuration, ObjectMapper objectMapper) { + this.client = client; + this.configuration = configuration; + this.objectMapper = objectMapper; + } + + @Override + public String getAmazonApplicationArn() { + return checkNotEmpty(configuration.getAmazon().getArn(), "Amazon application arn must be defined in config"); + } + + @Override + public String getAndroidApplicationArn() { + return checkNotEmpty(configuration.getAmazon().getArn(), "Android application arn must be defined in config"); + } + + @Override + public String getIosApplicationArn() { + return checkNotEmpty(configuration.getIos().getArn(), "Ios application arn must be defined in config"); + } + + @Override + public String getIosSandboxApplicationArn() { + return checkNotEmpty(configuration.getIosSandbox().getArn(), "Ios sandbox application arn must be defined in config"); + } + + @Override + public String getDefaultTopicNameOrArn() { + return checkNotEmpty(ensureTopicArn(configuration.getTopic()), "Default topic not set for the configuration"); + } + + @Override + public String createTopic(String topicName) { + LOGGER.debug("Creating topic sns with name " + topicName); + return client.createTopic(b -> b.name(topicName)).topicArn(); + } + + @Override + public Flowable listTopics() { + return Flowable.generate(client::listTopics, (BiFunction>, ListTopicsResponse>) (listTopicsResult, topicEmitter) -> { + topicEmitter.onNext(listTopicsResult.topics()); + + if (listTopicsResult.nextToken() != null) { + return client.listTopics(r -> r.nextToken(listTopicsResult.nextToken())); + } + + topicEmitter.onComplete(); + + return null; + }).flatMap(Flowable::fromIterable); + } + + @Override + public void deleteTopic(String topicArn) { + LOGGER.debug("Deleting topic" + topicArn); + client.deleteTopic(b -> b.topicArn(ensureTopicArn(topicArn))); + } + + @Override + public String subscribeTopic(String topic, String protocol, String endpoint) { + LOGGER.debug("Creating a topic subscription to endpoint " + endpoint); + return client.subscribe(b -> b.topicArn(ensureTopicArn(topic)).protocol(protocol).endpoint(endpoint)).subscriptionArn(); + } + + @Override + public void unsubscribeTopic(String arn) { + LOGGER.debug("Deleting a topic subscription to number " + arn); + client.unsubscribe(b -> b.subscriptionArn(arn)); + } + + @Override + public String publishMessageToTopic(String topicArn, String subject, String message) { + return client.publish(r -> r.topicArn(ensureTopicArn(topicArn)).message(message).subject(subject)).messageId(); + } + + @Override + public String createPlatformApplication(String name, String platformType, String principal, String credential) { + return client.createPlatformApplication(r -> { + r.name(name).platform(platformType); + + Map attributes = new HashMap<>(); + + if (principal != null && !principal.isEmpty()) { + attributes.put("PlatformPrincipal", principal); + } + + if (credential != null && !credential.isEmpty()) { + attributes.put("PlatformCredential", credential); + } + + if (!attributes.isEmpty()) { + r.attributes(attributes); + } + + }).platformApplicationArn(); + } + + @Override + public String createPlatformEndpoint(String platformApplicationArn, String deviceToken, String customUserData) { + try { + LOGGER.debug("Creating platform endpoint with token " + deviceToken); + CreatePlatformEndpointRequest.Builder request = CreatePlatformEndpointRequest.builder() + .platformApplicationArn(platformApplicationArn) + .token(deviceToken); + if (customUserData != null && !customUserData.isEmpty()) { + request.customUserData(customUserData); + } + return client.createPlatformEndpoint(request.build()).endpointArn(); + } catch (InvalidParameterException ipe) { + String message = ipe.getMessage(); + LOGGER.debug("Exception message: " + message); + Pattern p = Pattern.compile(".*Endpoint (arn:aws:sns[^ ]+) already exists with the same Token.*"); + Matcher m = p.matcher(message); + if (m.matches()) { + // The platform endpoint already exists for this token, but with additional custom data that + // createEndpoint doesn't want to overwrite. Just use the existing platform endpoint. + return m.group(1); + } + // Rethrow the exception, the input is actually bad. + throw ipe; + } + } + + @Override + public String sendAndroidAppNotification(String endpointArn, Map notification, String collapseKey, boolean delayWhileIdle, int timeToLive, boolean dryRun) { + return publishToTarget(endpointArn, PLATFORM_TYPE_ANDROID, buildAndroidMessage(notification, collapseKey, delayWhileIdle, timeToLive, dryRun)); + } + + @Override + public String sendIosAppNotification(String endpointArn, Map notification, boolean sandbox) { + return publishToTarget(endpointArn, sandbox ? PLATFORM_TYPE_IOS_SANDBOX : PLATFORM_TYPE_IOS, buildIosMessage(notification)); + } + + @Override + public String validateDeviceToken(String platformApplicationArn, String endpointArn, String deviceToken, String customUserData) { + LOGGER.debug("Retrieving platform endpoint data..."); + // Look up the platform endpoint and make sure the data in it is current, even if it was just created. + try { + GetEndpointAttributesResponse result = client.getEndpointAttributes(r -> r.endpointArn(endpointArn)); + if (Objects.equals(result.attributes().get("Token"), deviceToken) && result.attributes().get("Enabled").equalsIgnoreCase(Boolean.TRUE.toString())) { + setEndpointAttributes(endpointArn, Collections.singletonMap("CustomUserData", customUserData)); + return endpointArn; + } + } catch (NotFoundException ignored) { + // We had a stored ARN, but the platform endpoint associated with it disappeared. Recreate it. + return createPlatformEndpoint(platformApplicationArn, deviceToken, customUserData); + } + + LOGGER.debug("Platform endpoint update required..."); + + // The platform endpoint is out of sync with the current data, update the token and enable it. + LOGGER.debug("Updating platform endpoint " + endpointArn); + try { + Map attrs = new LinkedHashMap<>(); + attrs.put("CustomUserData", customUserData); + attrs.put("Enabled", Boolean.TRUE.toString()); + setEndpointAttributes(endpointArn, attrs); + return endpointArn; + } catch (InvalidParameterException ignored) { + deleteEndpoint(endpointArn); + return createPlatformEndpoint(platformApplicationArn, deviceToken, customUserData); + } + } + + @Override + public void unregisterDevice(String endpointArn) { + deleteEndpoint(endpointArn); + } + + @Override + public String sendSMSMessage(String phoneNumber, String message, Map smsAttributes) { + return client.publish(r -> r.message(message).phoneNumber(phoneNumber).messageAttributes(smsAttributes)).messageId(); + } + + private static String checkNotEmpty(String arn, String errorMessage) { + if (arn == null || arn.isEmpty()) { + throw new IllegalStateException(errorMessage); + } + return arn; + } + + private String ensureTopicArn(String nameOrArn) { + if (nameOrArn == null || nameOrArn.isEmpty()) { + return ""; + } + if (nameOrArn.startsWith("arn:aws:sns")) { + return nameOrArn; + } + + if (namesToArn.containsKey(nameOrArn)) { + return namesToArn.get(nameOrArn); + } + + listTopics() + .takeUntil((Topic topic) -> topic.topicArn().endsWith(":" + nameOrArn)) + .subscribe(topic -> { + String topicName = topic.topicArn().substring(topic.topicArn().lastIndexOf(':') + 1); + namesToArn.put(topicName, topic.topicArn()); + }); + + String topicArn = namesToArn.get(nameOrArn); + + if (topicArn != null && !topicArn.isEmpty()) { + return topicArn; + } + + return createTopic(nameOrArn); + } + + private String buildAndroidMessage(Map data, String collapseKey, boolean delayWhileIdle, int timeToLive, boolean dryRun) { + Map value = new LinkedHashMap<>(); + value.put("collapse_key", collapseKey); + value.put("data", data); + value.put("delay_while_idle", delayWhileIdle); + value.put("time_to_live", timeToLive); + value.put("dry_run", dryRun); + return toJson(value); + } + + private String buildIosMessage(Map data) { + return toJson(Collections.singletonMap("aps", data)); + } + + private String publishToTarget(String endpointArn, String platformType, String message) { + return client.publish(r -> { + r.targetArn(endpointArn).messageStructure("json").message(toJson(Collections.singletonMap(platformType, message))); + }).messageId(); + } + + private SetEndpointAttributesResponse setEndpointAttributes(String endpointArn, Map attributes) { + return client.setEndpointAttributes(r -> r.endpointArn(endpointArn).attributes(attributes)); + } + + + private DeleteEndpointResponse deleteEndpoint(String endpointArn) { + return client.deleteEndpoint(r -> r.endpointArn(endpointArn)); + } + + private String toJson(Map message) { + try { + return objectMapper.writeValueAsString(message); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Cannot write json for message " + message, e); + } + } +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/DefaultSimpleNotificationServiceConfiguration.java b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/DefaultSimpleNotificationServiceConfiguration.java new file mode 100644 index 000000000..aa57ee314 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/DefaultSimpleNotificationServiceConfiguration.java @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns; + +import io.micronaut.context.annotation.ConfigurationProperties; +import io.micronaut.context.env.Environment; + +import javax.inject.Named; + +/** + * Default simple queue service configuration. + */ +@Named("default") +@ConfigurationProperties("aws.sns") +public class DefaultSimpleNotificationServiceConfiguration extends SimpleNotificationServiceConfiguration { + + public DefaultSimpleNotificationServiceConfiguration(Environment environment) { + super("aws.sns", environment); + } + +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/NamedSimpleNotificationServiceConfiguration.java b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/NamedSimpleNotificationServiceConfiguration.java new file mode 100644 index 000000000..7cef0a1b8 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/NamedSimpleNotificationServiceConfiguration.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns; + +import io.micronaut.context.annotation.EachProperty; +import io.micronaut.context.annotation.Parameter; +import io.micronaut.context.env.Environment; + +/** + * Named simple queue service configuration for each property key. + */ +@EachProperty("aws.sns.topics") +public class NamedSimpleNotificationServiceConfiguration extends SimpleNotificationServiceConfiguration { + public NamedSimpleNotificationServiceConfiguration(@Parameter String name, Environment environment) { + super("aws.sns.topics." + name, environment); + this.name = name; + } + + public final String getName() { + return name; + } + + private final String name; +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/NotificationClientIntroduction.java b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/NotificationClientIntroduction.java new file mode 100644 index 000000000..eb810cf3c --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/NotificationClientIntroduction.java @@ -0,0 +1,195 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns; + +import com.agorapulse.micronaut.amazon.awssdk.sns.annotation.NotificationClient; +import com.agorapulse.micronaut.amazon.awssdk.sns.annotation.Topic; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import groovy.transform.Undefined; +import io.micronaut.aop.MethodInterceptor; +import io.micronaut.aop.MethodInvocationContext; +import io.micronaut.context.BeanContext; +import io.micronaut.core.annotation.AnnotationValue; +import io.micronaut.core.type.Argument; +import io.micronaut.inject.qualifiers.Qualifiers; +import software.amazon.awssdk.services.sns.model.NotFoundException; + +import javax.inject.Singleton; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +@Singleton +public class NotificationClientIntroduction implements MethodInterceptor { + + private static final String SUBJECT = "subject"; + private static final String NUMBER = "number"; + + private static final Function> EMPTY_IF_UNDEFINED = (String s) -> Undefined.STRING.equals(s) ? Optional.empty() : Optional.of(s); + + private static class PublishingArguments { + Argument message; + Argument subject; + + boolean isValid() { + return message != null; + } + } + + private static class SmsMessageArguments { + Argument message; + Argument phoneNumber; + Argument attributes; + + boolean isValid() { + return message != null && phoneNumber != null; + } + } + + private final BeanContext beanContext; + private final ObjectMapper objectMapper; + + public NotificationClientIntroduction(BeanContext beanContext, ObjectMapper objectMapper) { + this.beanContext = beanContext; + this.objectMapper = objectMapper; + } + + @Override + public Object intercept(MethodInvocationContext context) { + AnnotationValue clientAnnotationValue = context.getAnnotation(NotificationClient.class); + + if (clientAnnotationValue == null) { + throw new IllegalStateException("Invocation beanContext is missing required annotation NotificationClient"); + } + + String configurationName = clientAnnotationValue.getValue(String.class).orElse("default"); + SimpleNotificationService service = beanContext.getBean(SimpleNotificationService.class, Qualifiers.byName(configurationName)); + + String topicName = clientAnnotationValue.get(NotificationClient.Constants.TOPIC, String.class).flatMap(EMPTY_IF_UNDEFINED).orElse(null); + + AnnotationValue topicAnnotaitonValue = context.getAnnotation(Topic.class); + + if (topicAnnotaitonValue != null) { + topicName = topicAnnotaitonValue.getRequiredValue(String.class); + } + + if (topicName == null) { + topicName = service.getDefaultTopicNameOrArn(); + } + + try { + return doIntercept(context, service, topicName); + } catch (NotFoundException nfe) { + service.createTopic(topicName); + return doIntercept(context, service, topicName); + } + } + + private Object doIntercept(MethodInvocationContext context, SimpleNotificationService service, String topicName) { + Argument[] arguments = context.getArguments(); + Map params = context.getParameterValueMap(); + + if (context.getMethodName().toLowerCase().contains("sms")) { + SmsMessageArguments smsMessageArguments = findSmsArguments(arguments); + + String phoneNumber = String.valueOf(params.get(smsMessageArguments.phoneNumber.getName())); + String message = String.valueOf(params.get(smsMessageArguments.message.getName())); + + Map attributes = Collections.emptyMap(); + + if (smsMessageArguments.attributes != null) { + attributes = (Map) params.get(smsMessageArguments.attributes.getName()); + } + + return service.sendSMSMessage(phoneNumber, message, attributes); + } + + if (arguments.length >= 1 && arguments.length <= 2) { + PublishingArguments publishingArguments = findArguments(arguments); + + String subject = null; + + if (publishingArguments.subject != null) { + Object subjectValue = params.get(publishingArguments.subject.getName()); + subject = subjectValue == null ? null : String.valueOf(subjectValue); + } + + Object message = params.get(publishingArguments.message.getName()); + Class messageType = publishingArguments.message.getType(); + + if (CharSequence.class.isAssignableFrom(messageType)) { + return service.publishMessageToTopic(topicName, subject, message.toString()); + } + + return publishJson(service, topicName, subject, message); + } + + throw new UnsupportedOperationException("Cannot implement method " + context.getExecutableMethod()); + } + + private String publishJson(SimpleNotificationService service, String topic, String subject, Object message) { + try { + return service.publishMessageToTopic(topic, subject, objectMapper.writeValueAsString(message)); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to marshal " + message + " to JSON", e); + } + } + + private PublishingArguments findArguments(Argument[] arguments) { + PublishingArguments names = new PublishingArguments(); + + for (Argument argument : arguments) { + if (argument.getName().toLowerCase().contains(SUBJECT)) { + names.subject = argument; + continue; + } + names.message = argument; + } + + if (!names.isValid()) { + throw new UnsupportedOperationException("Method needs to have at least one argument which name does not contain subject"); + } + + return names; + } + + private SmsMessageArguments findSmsArguments(Argument[] arguments) { + SmsMessageArguments names = new SmsMessageArguments(); + + for (Argument argument : arguments) { + if (argument.getName().toLowerCase().contains(NUMBER)) { + names.phoneNumber = argument; + continue; + } + if (Map.class.isAssignableFrom(argument.getType())) { + names.attributes = argument; + continue; + } + names.message = argument; + } + + if (!names.isValid()) { + throw new UnsupportedOperationException("Method needs to have at least two phone number and message"); + } + + return names; + } + +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationService.java b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationService.java new file mode 100644 index 000000000..d3772b9a9 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationService.java @@ -0,0 +1,632 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns; + +import io.reactivex.Flowable; +import software.amazon.awssdk.services.sns.model.MessageAttributeValue; +import software.amazon.awssdk.services.sns.model.Topic; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Collections; +import java.util.Map; + +/** + * Service to simplify interaction with Amazon SNS. + */ +public interface SimpleNotificationService { + + String MOBILE_PLATFORM_ANDROID = "android"; + String MOBILE_PLATFORM_IOS = "ios"; + String MOBILE_PLATFORM_IOS_SANDBOX = "iosSandbox"; + String MOBILE_PLATFORM_AMAZON = "amazon"; + String PLATFORM_TYPE_IOS_SANDBOX = "APNS_SANDBOX"; + String PLATFORM_TYPE_IOS = "APNS"; + String PLATFORM_TYPE_ANDROID = "GCM"; + String PLATFORM_TYPE_AMAZON = "ADM"; + + + /** + * @return the default Application ARN for Amazon devices integration. + */ + String getAmazonApplicationArn(); + + /** + * @return the default Application ARN for Android devices integration. + */ + String getAndroidApplicationArn(); + + /** + * @return the default Application ARN for iOS devices integration. + */ + String getIosApplicationArn(); + + /** + * @return the default Application ARN for iOS sandbox devices integration. + */ + String getIosSandboxApplicationArn(); + + /** + * @return the default topic ARN or name. + */ + String getDefaultTopicNameOrArn(); + + /** + * Creates new topic with given name. + * @param topicName topic name + * @return ARN of newly created topic + */ + String createTopic(String topicName); + + /** + * @return flowable iterating through all the topics available + */ + Flowable listTopics(); + + /** + * Deletes given topic. + * @param topicArn topic ARN or name + */ + void deleteTopic(String topicArn); + + /** + * Generic version of subscribing to the topic. + *

+ * It is recommended to use one of the specialised methods instead. + * + * @param topic ARN of the topic + * @param protocol see {@link software.amazon.awssdk.services.sns.model.SubscribeRequest} + * @param endpoint see {@link software.amazon.awssdk.services.sns.model.SubscribeRequest} + * @return subscription ARN + * @see #subscribeTopicWithApplication(String, String) + * @see #subscribeTopicWithEndpoint(String, String) + * @see #subscribeTopicWithEndpoint(String, URL) + * @see #subscribeTopicWithEmail(String, String) + * @see #subscribeTopicWithJsonEmail(String, String) + * @see #subscribeTopicWithQueue(String, String) + * @see #subscribeTopicWithSMS(String, String) + * @see #subscribeTopicWithFunction(String, String) + * @see software.amazon.awssdk.services.sns.model.SubscribeRequest + */ + String subscribeTopic(String topic, String protocol, String endpoint); + + /** + * Subscribe to topic with HTTP or HTTPS endpoint. + * @param topicArn topic ARN or name + * @param url the url to post new messages + * @return subscription ARN + */ + default String subscribeTopicWithEndpoint(String topicArn, String url) throws MalformedURLException { + return subscribeTopicWithEndpoint(topicArn, new URL(url)); + } + + /** + * Subscribe to topic with HTTP or HTTPS endpoint. + * @param topicArn topic ARN or name + * @param url the url to post new messages + * @return subscription ARN + */ + default String subscribeTopicWithEndpoint(String topicArn, URL url) { + if ("https".equals(url.getProtocol())) { + return subscribeTopic(topicArn, "https", url.toExternalForm()); + } + if ("http".equals(url.getProtocol())) { + return subscribeTopic(topicArn, "http", url.toExternalForm()); + } + throw new IllegalArgumentException("Can only subscribe to HTTP or HTTPS endpoints!"); + } + + /** + * Subscribes to the topic with an email. + * @param topicArn topic ARN or name + * @param email email to subscribe with + * @return subscription ARN + */ + default String subscribeTopicWithEmail(String topicArn, String email) { + return subscribeTopic(topicArn, "email", email); + } + + /** + * Subscribes to the topic with JSON email. + * @param topicArn topic ARN or name + * @param email email to subscribe with + * @return subscription ARN + */ + default String subscribeTopicWithJsonEmail(String topicArn, String email) { + return subscribeTopic(topicArn, "email-json", email); + } + + /** + * Subscribes to the topic with SMS to the phone number. + * @param topicArn topic ARN or name + * @param number phone number in international format + * @return subscription ARN + */ + default String subscribeTopicWithSMS(String topicArn, String number) { + return subscribeTopic(topicArn, "sms", number); + } + + /** + * Subscribes to the topic with SQS queue. + * @param topicArn topic ARN or name + * @param queueArn ARN of the queue to be subscribed + * @return subscription ARN + */ + default String subscribeTopicWithQueue(String topicArn, String queueArn) { + return subscribeTopic(topicArn, "sqs", queueArn); + } + + /** + * Subscribes to the topic with SNS application. + * @param topicArn topic ARN or name + * @param applicationEndpointArn ARN of the application to be subscribed + * @return subscription ARN + */ + default String subscribeTopicWithApplication(String topicArn, String applicationEndpointArn) { + return subscribeTopic(topicArn, "application", applicationEndpointArn); + } + + /** + * Subscribes to the topic with Lambda function. + * @param topicArn topic ARN or name + * @param lambdaArn ARN of the lambda to be subscribed with + * @return subscription ARN + */ + default String subscribeTopicWithFunction(String topicArn, String lambdaArn) { + return subscribeTopic(topicArn, "lambda", lambdaArn); + } + + /** + * Unsubscribes from the topic. + * @param arn ARN of the subscription + */ + void unsubscribeTopic(String arn); + + /** + * Publishes a message into the topic. + * @param topicArn topic ARN or name + * @param subject subject of the message (ignored by most of the protocols) + * @param message the message + * @return the is of the message published + */ + String publishMessageToTopic(String topicArn, String subject, String message); + + /** + * Creates new platform application. + * @param name name of the application + * @param platformType type of the platform + * @param principal user's principal + * @param credential user's credentials + * @return ARN of the platform + */ + String createPlatformApplication(String name, String platformType, String principal, String credential); + + /** + * Creates new platform application. + * @param name name of the application + * @param privateKey private key + * @param sslCertificate SSL certificate + * @param sandbox whether the application should be a iOS sandbox application + * @return ARN of the platform + */ + default String createIosApplication(String name, String privateKey, String sslCertificate, boolean sandbox) { + return createPlatformApplication(name, sandbox ? PLATFORM_TYPE_IOS_SANDBOX : PLATFORM_TYPE_IOS, sslCertificate, privateKey); + } + + /** + * Creates new platform application. + * @param name name of the application + * @param apiKey API key + * @return ARN of the platform + */ + default String createAndroidApplication(String name, String apiKey) { + return createPlatformApplication(name, PLATFORM_TYPE_ANDROID, null, apiKey); + } + + /** + * Creates new platform application. + * @param name name of the application + * @param clientId client ID + * @param clientSecret client secret + * @return ARN of the platform + */ + default String createAmazonApplication(String name, String clientId, String clientSecret) { + return createPlatformApplication(name, PLATFORM_TYPE_AMAZON, clientId, clientSecret); + } + + /** + * Register new device depending on patform + * @param platform device platform + * @param deviceToken device token + * @param customUserData custom user data + * @return ARN of the endpoint + */ + default String registerDevice(String platform, String deviceToken, String customUserData) { + if (MOBILE_PLATFORM_ANDROID.equals(platform)) { + return registerAndroidDevice(deviceToken, customUserData); + } + if (MOBILE_PLATFORM_IOS.equals(platform)) { + return registerIosDevice(deviceToken, customUserData); + } + if (MOBILE_PLATFORM_IOS_SANDBOX.equals(platform)) { + return registerIosSandboxDevice(deviceToken, customUserData); + } + if (MOBILE_PLATFORM_AMAZON.equals(platform)) { + return registerAmazonDevice(deviceToken, customUserData); + } + throw new IllegalArgumentException("Platform " + platform + " is not supported"); + } + + /** + * Register new device depending on patform + * @param platform + * @param deviceToken device token + * @return ARN of the endpoint + */ + default String registerDevice(String platform, String deviceToken) { + return registerDevice(platform, deviceToken, ""); + } + + /** + * Register new Android device. + * @param deviceToken device token + * @return ARN of the endpoint + */ + default String registerAndroidDevice(String deviceToken) { + return registerAndroidDevice(deviceToken, ""); + } + + /** + * Register new Android device. + * @param deviceToken device token + * @param customUserData custom user data + * @return ARN of the endpoint + */ + default String registerAndroidDevice(String deviceToken, String customUserData) { + return createPlatformEndpoint(getAndroidApplicationArn(), deviceToken, customUserData); + } + + /** + * Register new iOS device. + * @param deviceToken device token + * @return ARN of the endpoint + */ + default String registerIosDevice(String deviceToken) { + return registerIosDevice(deviceToken, ""); + } + + /** + * Register new iOS device. + * @param deviceToken device token + * @param customUserData custom user data + * @return ARN of the endpoint + */ + default String registerIosDevice(String deviceToken, String customUserData) { + return createPlatformEndpoint(getIosApplicationArn(), deviceToken, customUserData); + } + + /** + * Register new iOS Sandbox device. + * @param deviceToken device token + * @return ARN of the endpoint + */ + default String registerIosSandboxDevice(String deviceToken) { + return registerIosSandboxDevice(deviceToken, ""); + } + + /** + * Register new iOS Sandbox device. + * @param deviceToken device token + * @param customUserData custom user data + * @return ARN of the endpoint + */ + default String registerIosSandboxDevice(String deviceToken, String customUserData) { + return createPlatformEndpoint(getIosSandboxApplicationArn(), deviceToken, customUserData); + } + + /** + * Register new Amazon device. + * @param deviceToken device token + * @return ARN of the endpoint + */ + default String registerAmazonDevice(String deviceToken) { + return registerAmazonDevice(deviceToken, ""); + } + + /** + * Register new Amazon device. + * @param deviceToken device token + * @param customUserData custom user data + * @return ARN of the endpoint + */ + default String registerAmazonDevice(String deviceToken, String customUserData) { + return createPlatformEndpoint(getAmazonApplicationArn(), deviceToken, customUserData); + } + + /** + * Creates new application endpoint. + * @param platformApplicationArn application ARN + * @param deviceToken device token + * @param customUserData custom user data + * @return ARN of the endpoint + */ + String createPlatformEndpoint(String platformApplicationArn, String deviceToken, String customUserData); + + /** + * Creates new application endpoint. + * @param platformApplicationArn application ARN + * @param deviceToken device token + * @return ARN of the endpoint + */ + default String createPlatformEndpoint(String platformApplicationArn, String deviceToken) { + return createPlatformEndpoint(platformApplicationArn, deviceToken, null); + } + + /** + * Send Android application notification. + * @param endpointArn endpoint ARN + * @param notification notification payload + * @param collapseKey collapse key + * @param delayWhileIdle delay while idle + * @param timeToLive time to live + * @param dryRun dry run + * @return message id + */ + String sendAndroidAppNotification(String endpointArn, Map notification, String collapseKey, boolean delayWhileIdle, int timeToLive, boolean dryRun); + + /** + * Send Android application notification. + * @param endpointArn endpoint ARN + * @param notification notification payload + * @param collapseKey collapse key + * @param delayWhileIdle delay while idle + * @param timeToLive time to live + * @return message id + */ + default String sendAndroidAppNotification(String endpointArn, Map notification, String collapseKey, boolean delayWhileIdle, int timeToLive) { + return sendAndroidAppNotification(endpointArn, notification, collapseKey, delayWhileIdle, timeToLive, false); + } + + /** + * Send Android application notification. + * @param endpointArn endpoint ARN + * @param notification notification payload + * @param collapseKey collapse key + * @param delayWhileIdle delay while idle + * @return message id + */ + default String sendAndroidAppNotification(String endpointArn, Map notification, String collapseKey, boolean delayWhileIdle) { + return sendAndroidAppNotification(endpointArn, notification, collapseKey, delayWhileIdle, 125); + } + + /** + * Send Android application notification. + * @param endpointArn endpoint ARN + * @param notification notification payload + * @param collapseKey collapse key + * @return message id + */ + default String sendAndroidAppNotification(String endpointArn, Map notification, String collapseKey) { + return sendAndroidAppNotification(endpointArn, notification, collapseKey, true); + } + + + /** + * Send iOS application notification. + * @param endpointArn endpoint ARN + * @param notification notification payload + * @param sandbox whether the application is a sandbox application + * @return message id + */ + String sendIosAppNotification(String endpointArn, Map notification, boolean sandbox); + + /** + * Send iOS application notification. + * @param endpointArn endpoint ARN + * @param notification notification payload + * @return message id + */ + default String sendIosAppNotification(String endpointArn, Map notification) { + return sendIosAppNotification(endpointArn, notification, false); + } + + /** + * Validates Amazon device. + * + * This method is able to update the custom user data as well as the type of the platform. + * + * @param endpointArn endpoint ARN + * @param deviceToken device token + * @param customUserData custom user data + * @return endpoint ARN which can point to different platform if required + */ + default String validateAmazonDevice(String endpointArn, String deviceToken, String customUserData) { + return validateDeviceToken(getAmazonApplicationArn(), endpointArn, deviceToken, customUserData); + } + + /** + * Validates Amazon device. + * + * This method is able to update the custom user data as well as the type of the platform. + * + * @param endpointArn endpoint ARN + * @param deviceToken device token + * @return endpoint ARN which can point to different platform if required + */ + default String validateAmazonDevice(String endpointArn, String deviceToken) { + return validateAmazonDevice(endpointArn, deviceToken, ""); + } + + /** + * Validates Android device. + * + * This method is able to update the custom user data as well as the type of the platform. + * + * @param endpointArn endpoint ARN + * @param deviceToken device token + * @param customUserData custom user data + * @return endpoint ARN which can point to different platform if required + */ + default String validateAndroidDevice(String endpointArn, String deviceToken, String customUserData) { + return validateDeviceToken(getAndroidApplicationArn(), endpointArn, deviceToken, customUserData); + } + + /** + * Validates Android device. + * + * This method is able to update the custom user data as well as the type of the platform. + * + * @param endpointArn endpoint ARN + * @param deviceToken device token + * @return endpoint ARN which can point to different platform if required + */ + default String validateAndroidDevice(String endpointArn, String deviceToken) { + return validateAndroidDevice(endpointArn, deviceToken, ""); + } + + /** + * Validates iOS device. + * + * This method is able to update the custom user data as well as the type of the platform. + * + * @param endpointArn endpoint ARN + * @param deviceToken device token + * @param customUserData custom user data + * @return endpoint ARN which can point to different platform if required + */ + default String validateIosDevice(String endpointArn, String deviceToken, String customUserData) { + return validateDeviceToken(getIosApplicationArn(), endpointArn, deviceToken, customUserData); + } + + /** + * Validates iOS device. + * + * This method is able to update the custom user data as well as the type of the platform. + * + * @param endpointArn endpoint ARN + * @param deviceToken device token + * @return endpoint ARN which can point to different platform if required + */ + default String validateIosDevice(String endpointArn, String deviceToken) { + return validateIosDevice(endpointArn, deviceToken, ""); + } + + /** + * Validates iOS Sandbox device. + * + * This method is able to update the custom user data as well as the type of the platform. + * + * @param endpointArn endpoint ARN + * @param deviceToken device token + * @param customUserData custom user data + * @return endpoint ARN which can point to different platform if required + */ + default String validateIosSandboxDevice(String endpointArn, String deviceToken, String customUserData) { + return validateDeviceToken(getIosSandboxApplicationArn(), endpointArn, deviceToken, customUserData); + } + + /** + * Validates iOS Sandbox device. + * + * This method is able to update the custom user data as well as the type of the platform. + * + * @param endpointArn endpoint ARN + * @param deviceToken device token + * @return endpoint ARN which can point to different platform if required + */ + default String validateIosSandboxDevice(String endpointArn, String deviceToken) { + return validateIosSandboxDevice(endpointArn, deviceToken, ""); + } + + /** + * Validates device token + * + * This method is able to update the custom user data as well as the type of the platform. + * + * @param applicationArn application ARN + * @param endpointArn endpoint ARN + * @param deviceToken device token + * @param customUserData custom user data + * @return endpoint ARN which can point to different platform if required + */ + String validateDeviceToken(String applicationArn, String endpointArn, String deviceToken, String customUserData); + + /** + * Validates device token depending on platform + * + * This method is able to update the custom user data as well as the type of the platform. + * + * @param endpointArn endpoint ARN + * @param deviceToken device token + * @param customUserData custom user data + * @return endpoint ARN which can point to different platform if required + */ + default String validateDevice(String platform, String endpointArn, String deviceToken, String customUserData) { + if (MOBILE_PLATFORM_AMAZON.equals(platform)) { + return validateAmazonDevice(endpointArn, deviceToken, customUserData); + } + if (MOBILE_PLATFORM_ANDROID.equals(platform)) { + return validateAndroidDevice(endpointArn, deviceToken, customUserData); + } + if (MOBILE_PLATFORM_IOS.equals(platform)) { + return validateIosDevice(endpointArn, deviceToken, customUserData); + } + if (MOBILE_PLATFORM_IOS_SANDBOX.equals(platform)) { + return validateIosSandboxDevice(endpointArn, deviceToken, customUserData); + } + return null; + } + + /** + * Validates device token depending on platform + * + * This method is able to update the custom user data as well as the type of the platform. + * + * @param endpointArn endpoint ARN + * @param deviceToken device token + * @return endpoint ARN which can point to different platform if required + */ + default String validateDevice(String platform, String endpointArn, String deviceToken) { + return validateDevice(platform, endpointArn, deviceToken, ""); + } + + /** + * Unregisters existing device. + * @param endpointArn endpoint ARN + */ + void unregisterDevice(String endpointArn); + + /** + * Send SMS + * @param phoneNumber phone neumber in international format + * @param message message text + * @param smsAttributes optional SMS attributes + * @return message ID + */ + String sendSMSMessage(String phoneNumber, String message, Map smsAttributes); + + /** + * Send SMS + * @param phoneNumber phone neumber in international format + * @param message message text + * @return message ID + */ + default String sendSMSMessage(String phoneNumber, String message) { + return sendSMSMessage(phoneNumber, message, Collections.emptyMap()); + } +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceConfiguration.java b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceConfiguration.java new file mode 100644 index 000000000..8070155c5 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceConfiguration.java @@ -0,0 +1,86 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns; + +import com.agorapulse.micronaut.amazon.awssdk.core.DefaultRegionAndEndpointConfiguration; +import io.micronaut.context.env.Environment; + +/** + * Default simple queue service configuration. + */ +@SuppressWarnings("AbstractClassWithoutAbstractMethod") +public abstract class SimpleNotificationServiceConfiguration extends DefaultRegionAndEndpointConfiguration { + + protected SimpleNotificationServiceConfiguration(String prefix, Environment environment) { + ios = forPlatform(prefix, "ios", environment); + iosSandbox = forPlatform(prefix, "ios-sandbox", environment); + android = forPlatform(prefix, "android", environment); + amazon = forPlatform(prefix, "amazon", environment); + } + + @SuppressWarnings("DuplicateStringLiteral") + private static Application forPlatform(final String prefix, final String platform, final Environment environment) { + return new Application(environment.get(prefix + "." + platform + ".arn", String.class).orElseGet(() -> + environment.get(prefix + "." + platform + ".applicationArn", String.class).orElse(null) + )); + } + + public final Application getIos() { + return ios; + } + + public final Application getIosSandbox() { + return iosSandbox; + } + + public final Application getAndroid() { + return android; + } + + public final Application getAmazon() { + return amazon; + } + + public String getTopic() { + return topic; + } + + public void setTopic(String topic) { + this.topic = topic; + } + + private final Application ios; + private final Application iosSandbox; + private final Application android; + private final Application amazon; + private String topic = ""; + + public static class Application { + + public Application(String arn) { + this.arn = arn; + } + + public final String getArn() { + return arn; + } + + private final String arn; + + } +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceFactory.java b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceFactory.java new file mode 100644 index 000000000..9f4af78af --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceFactory.java @@ -0,0 +1,64 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.micronaut.context.annotation.EachBean; +import io.micronaut.context.annotation.Factory; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; +import software.amazon.awssdk.services.sns.SnsAsyncClient; +import software.amazon.awssdk.services.sns.SnsClient; + +import javax.inject.Singleton; + +@Factory +public class SimpleNotificationServiceFactory { + + @Singleton + @EachBean(SimpleNotificationServiceConfiguration.class) + SnsClient snsClient( + AwsCredentialsProvider credentialsProvider, + AwsRegionProvider awsRegionProvider, + SimpleNotificationServiceConfiguration configuration + ) { + return configuration.configure(SnsClient.builder(), awsRegionProvider) + .credentialsProvider(credentialsProvider) + .build(); + } + + @Singleton + @EachBean(SimpleNotificationServiceConfiguration.class) + SnsAsyncClient snsAsyncClient( + AwsCredentialsProvider credentialsProvider, + AwsRegionProvider awsRegionProvider, + SimpleNotificationServiceConfiguration configuration + ) { + return configuration.configure(SnsAsyncClient.builder(), awsRegionProvider) + .credentialsProvider(credentialsProvider) + .build(); + } + + + @Singleton + @EachBean(SimpleNotificationServiceConfiguration.class) + SimpleNotificationService simpleQueueService(SnsClient sqs, SimpleNotificationServiceConfiguration configuration, ObjectMapper mapper) { + return new DefaultSimpleNotificationService(sqs, configuration, mapper); + } + +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/annotation/NotificationClient.java b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/annotation/NotificationClient.java new file mode 100644 index 000000000..c5e2f278f --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/annotation/NotificationClient.java @@ -0,0 +1,56 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns.annotation; + +import com.agorapulse.micronaut.amazon.awssdk.sns.NotificationClientIntroduction; +import groovy.transform.Undefined; +import io.micronaut.aop.Introduction; +import io.micronaut.context.annotation.Type; + +import javax.inject.Scope; +import javax.inject.Singleton; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Introduction +@Type(NotificationClientIntroduction.class) +@Scope +@Singleton +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +public @interface NotificationClient { + + /** + * @return the name of the configuration to use for this client + */ + String value() default "default"; + + /** + * @return default topic for this client which overrides the one from the configuration + */ + String topic() default Undefined.STRING; + + class Constants { + public static final String TOPIC = "topic"; + } + +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/annotation/Topic.java b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/annotation/Topic.java new file mode 100644 index 000000000..84d563dab --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/main/java/com/agorapulse/micronaut/amazon/awssdk/sns/annotation/Topic.java @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns.annotation; + +import java.lang.annotation.*; + +@Inherited +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) +public @interface Topic { + + /** + * @return the topic for particular method call + */ + String value(); + +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/DefaultClient.java b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/DefaultClient.java new file mode 100644 index 000000000..c84bae627 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/DefaultClient.java @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns; + +import com.agorapulse.micronaut.amazon.awssdk.sns.annotation.NotificationClient; +import com.agorapulse.micronaut.amazon.awssdk.sns.annotation.Topic; + +import java.util.Map; + +@NotificationClient + // <1> +interface DefaultClient { + + String OTHER_TOPIC = "OtherTopic"; + + @Topic("OtherTopic") String publishMessageToDifferentTopic(Pogo pogo); // <2> + + String publishMessage(Pogo message); // <3> + String publishMessage(String subject, Pogo message); // <4> + String publishMessage(String message); // <5> + String publishMessage(String subject, String message); + + String sendSMS(String phoneNumber, String message); // <6> + String sendSms(String phoneNumber, String message, Map attributes); // <7> + +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/LocalStackContainer.java b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/LocalStackContainer.java new file mode 100644 index 000000000..6430a9720 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/LocalStackContainer.java @@ -0,0 +1,163 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns; + +import org.rnorth.ducttape.Preconditions; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + *

Container for Atlassian Labs Localstack, 'A fully functional local AWS cloud stack'.

+ *

{@link LocalStackContainer#withServices(Service...)} should be used to select which services + * are to be launched. See {@link Service} for available choices. It is advised that + * {@link LocalStackContainer#getEndpointOverride(Service)} and + * be used to obtain compatible endpoint configuration and credentials, respectively.

+ */ +public class LocalStackContainer extends GenericContainer { + + public static final String VERSION = "0.9.4"; + private static final String HOSTNAME_EXTERNAL_ENV_VAR = "HOSTNAME_EXTERNAL"; + + private final List services = new ArrayList<>(); + + public LocalStackContainer() { + this(VERSION); + } + + public LocalStackContainer(String version) { + super(TestcontainersConfiguration.getInstance().getLocalStackImage() + ":" + version); + + withFileSystemBind("//var/run/docker.sock", "/var/run/docker.sock"); + waitingFor(Wait.forLogMessage(".*Ready\\.\n", 1)); + } + + @Override + protected void configure() { + super.configure(); + + Preconditions.check("services list must not be empty", !services.isEmpty()); + + withEnv("SERVICES", services.stream().map(Service::getLocalStackName).collect(Collectors.joining(","))); + + String hostnameExternalReason; + if (getEnvMap().containsKey(HOSTNAME_EXTERNAL_ENV_VAR)) { + // do nothing + hostnameExternalReason = "explicitly as environment variable"; + } else if (getNetwork() != null && getNetworkAliases() != null && getNetworkAliases().size() >= 1) { + withEnv(HOSTNAME_EXTERNAL_ENV_VAR, getNetworkAliases().get(getNetworkAliases().size() - 1)); // use the last network alias set + hostnameExternalReason = "to match last network alias on container with non-default network"; + } else { + withEnv(HOSTNAME_EXTERNAL_ENV_VAR, getContainerIpAddress()); + hostnameExternalReason = "to match host-routable address for container"; + } + logger().info("{} environment variable set to {} ({})", HOSTNAME_EXTERNAL_ENV_VAR, getEnvMap().get(HOSTNAME_EXTERNAL_ENV_VAR), hostnameExternalReason); + + for (Service service : services) { + addExposedPort(service.getPort()); + } + } + + /** + * Declare a set of simulated AWS services that should be launched by this container. + * @param services one or more service names + * @return this container object + */ + public LocalStackContainer withServices(Service... services) { + this.services.addAll(Arrays.asList(services)); + return self(); + } + + public URI getEndpointOverride(Service service) { + try { + final String address = getContainerIpAddress(); + String ipAddress = address; + // resolve IP address and use that as the endpoint so that path-style access is automatically used for S3 + ipAddress = InetAddress.getByName(address).getHostAddress(); + return new URI("http://" + + ipAddress + + ":" + + getMappedPort(service.getPort())); + } catch (UnknownHostException | URISyntaxException e) { + throw new IllegalStateException("Cannot obtain endpoint URL", e); + } + } + + public String getDefaultAccessKey() { + return "accesskey"; + } + + public String getDefaultSecretKey() { + return "secretkey"; + } + + public String getDefaultRegion() { + return "us-east-1"; + } + + public enum Service { + API_GATEWAY("apigateway", 4567), + KINESIS("kinesis", 4568), + DYNAMODB("dynamodb", 4569), + DYNAMODB_STREAMS("dynamodbstreams", 4570), + // TODO: Clarify usage for ELASTICSEARCH and ELASTICSEARCH_SERVICE +// ELASTICSEARCH("es", 4571), + S3("s3", 4572), + FIREHOSE("firehose", 4573), + LAMBDA("lambda", 4574), + SNS("sns", 4575), + SQS("sqs", 4576), + REDSHIFT("redshift", 4577), + // ELASTICSEARCH_SERVICE("", 4578), + SES("ses", 4579), + ROUTE53("route53", 4580), + CLOUDFORMATION("cloudformation", 4581), + CLOUDWATCH("cloudwatch", 4582), + SSM("ssm", 4583), + SECRETSMANAGER("secretsmanager", 4584), + STEPFUNCTIONS("stepfunctions", 4585), + CLOUDWATCHLOGS("cloudwatchlogs", 4586), + STS("sts", 4592), + IAM("iam", 4593); + + final String localStackName; + final int port; + + public String getLocalStackName() { + return localStackName; + } + + public int getPort() { + return port; + } + + Service(String localStackName, int port) { + this.localStackName = localStackName; + this.port = port; + } + } +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/NotificationClientSpec.groovy b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/NotificationClientSpec.groovy new file mode 100644 index 000000000..0689848e9 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/NotificationClientSpec.groovy @@ -0,0 +1,202 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns + +import groovy.json.JsonOutput +import io.micronaut.context.ApplicationContext +import io.micronaut.inject.qualifiers.Qualifiers +import software.amazon.awssdk.services.sns.model.NotFoundException +import spock.lang.AutoCleanup +import spock.lang.Specification + +/** + * Tests for notification client. + */ +class NotificationClientSpec extends Specification { + + private static final String DEFAULT_TOPIC = 'DefaultTopic' + private static final Pogo POGO = new Pogo(foo: 'bar') + private static final String MESSAGE = 'Hello' + private static final String SUBJECT = 'Subject' + private static final String PHONE_NUMBER = '+883510000000094' + private static final Map SMS_ATTRIBUTES = Collections.singletonMap('foo', 'bar') + private static final String POGO_AS_JSON = JsonOutput.toJson(POGO) + private static final String MESSAGE_ID = '1234567890' + + SimpleNotificationService defaultService = Mock(SimpleNotificationService) { + getDefaultTopicNameOrArn() >> DEFAULT_TOPIC + } + + SimpleNotificationService testService = Mock(SimpleNotificationService) { + getDefaultTopicNameOrArn() >> DEFAULT_TOPIC + } + + @AutoCleanup ApplicationContext context + + void setup() { + context = ApplicationContext.build().build() + + context.registerSingleton(SimpleNotificationService, defaultService, Qualifiers.byName('default')) + context.registerSingleton(SimpleNotificationService, testService, Qualifiers.byName('test')) + + context.start() + } + + void 'can publish to different topic'() { + given: + DefaultClient client = context.getBean(DefaultClient) + when: + String messageId = client.publishMessageToDifferentTopic(POGO) + then: + messageId == MESSAGE_ID + + 1 * defaultService.publishMessageToTopic(DefaultClient.OTHER_TOPIC, null, POGO_AS_JSON) >> MESSAGE_ID + } + + void 'can publish to default topic'() { + given: + DefaultClient client = context.getBean(DefaultClient) + when: + String messageId = client.publishMessage(POGO) + then: + messageId == MESSAGE_ID + + 1 * defaultService.publishMessageToTopic(DEFAULT_TOPIC, null, POGO_AS_JSON) >> MESSAGE_ID + } + + void 'topic is created automatically'() { + given: + DefaultClient client = context.getBean(DefaultClient) + when: + String messageId = client.publishMessage(POGO) + then: + messageId == MESSAGE_ID + + 1 * defaultService.publishMessageToTopic(DEFAULT_TOPIC, null, POGO_AS_JSON) >> { + throw NotFoundException.builder().message('Not found').build() + } + 1 * defaultService.createTopic(DEFAULT_TOPIC) + 1 * defaultService.publishMessageToTopic(DEFAULT_TOPIC, null, POGO_AS_JSON) >> MESSAGE_ID + } + + void 'can publish to default topic with subject'() { + given: + DefaultClient client = context.getBean(DefaultClient) + when: + String messageId = client.publishMessage(SUBJECT, POGO) + then: + messageId == MESSAGE_ID + + 1 * defaultService.publishMessageToTopic(DEFAULT_TOPIC, SUBJECT, POGO_AS_JSON) >> MESSAGE_ID + } + + void 'cen publish string message'() { + given: + DefaultClient client = context.getBean(DefaultClient) + when: + String messageId = client.publishMessage(MESSAGE) + then: + messageId == MESSAGE_ID + + 1 * defaultService.publishMessageToTopic(DEFAULT_TOPIC, null, MESSAGE) >> MESSAGE_ID + } + + void 'can publish string to default topic with subject'() { + given: + DefaultClient client = context.getBean(DefaultClient) + when: + String messageId = client.publishMessage(SUBJECT, MESSAGE) + then: + messageId == MESSAGE_ID + + 1 * defaultService.publishMessageToTopic(DEFAULT_TOPIC, SUBJECT, MESSAGE) >> MESSAGE_ID + } + + void 'can send SMS'() { + given: + DefaultClient client = context.getBean(DefaultClient) + when: + String messageId = client.sendSMS(PHONE_NUMBER, MESSAGE) + then: + messageId == MESSAGE_ID + + 1 * defaultService.sendSMSMessage(PHONE_NUMBER, MESSAGE, [:]) >> MESSAGE_ID + } + + void 'can send SMS with additonal attributtes'() { + given: + DefaultClient client = context.getBean(DefaultClient) + when: + String messageId = client.sendSms(PHONE_NUMBER, MESSAGE, SMS_ATTRIBUTES) + then: + messageId == MESSAGE_ID + + 1 * defaultService.sendSMSMessage(PHONE_NUMBER, MESSAGE, SMS_ATTRIBUTES) >> MESSAGE_ID + } + + void 'can publish with different configuration'() { + given: + TestClient client = context.getBean(TestClient) + when: + String messageId = client.publishMessage(POGO) + then: + messageId == MESSAGE_ID + + 1 * testService.publishMessageToTopic(DEFAULT_TOPIC, null, POGO_AS_JSON) >> MESSAGE_ID + } + + void 'can publish with different topic'() { + given: + StreamClient client = context.getBean(StreamClient) + when: + String messageId = client.publishMessage(POGO) + then: + messageId == MESSAGE_ID + + 1 * defaultService.publishMessageToTopic(StreamClient.SOME_STREAM, null, POGO_AS_JSON) >> MESSAGE_ID + } + + void 'wrong sms method format'() { + given: + StreamClient client = context.getBean(StreamClient) + when: + client.sendSMS('+420555666777') + then: + thrown(UnsupportedOperationException) + } + + void 'wrong publish method format'() { + given: + StreamClient client = context.getBean(StreamClient) + when: + client.sendMessage('Hello') + then: + thrown(UnsupportedOperationException) + } + + void 'wrong method format'() { + given: + StreamClient client = context.getBean(StreamClient) + when: + client.doSomething('one', 'two', 'three') + then: + thrown(UnsupportedOperationException) + } + +} + diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/Pogo.groovy b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/Pogo.groovy new file mode 100644 index 000000000..00e528ac1 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/Pogo.groovy @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns + +import groovy.transform.CompileStatic +import groovy.transform.ToString + +/** + * Plain old Groovy object for testing. + */ +@ToString +@CompileStatic +class Pogo { + + // java way + Pogo(String foo) { + this.foo = foo + } + + // groovy way + Pogo() { } + + String foo + +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceConfigurationSpec.groovy b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceConfigurationSpec.groovy new file mode 100644 index 000000000..3f4530ae2 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceConfigurationSpec.groovy @@ -0,0 +1,93 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns + +import io.micronaut.context.ApplicationContext +import io.micronaut.inject.qualifiers.Qualifiers +import software.amazon.awssdk.services.sns.SnsAsyncClient +import software.amazon.awssdk.services.sns.SnsClient +import spock.lang.AutoCleanup +import spock.lang.Specification + +class SimpleNotificationServiceConfigurationSpec extends Specification { + + @AutoCleanup ApplicationContext context = null + + void 'one service present by default'() { + when: + context = ApplicationContext.run() + then: + context.getBeanDefinitions(SimpleNotificationServiceConfiguration).size() == 1 + context.getBeanDefinitions(SimpleNotificationService).size() == 1 + context.getBeanDefinitions(SnsClient).size() == 1 + context.getBeanDefinitions(SnsAsyncClient).size() == 1 + context.getBean(SimpleNotificationServiceConfiguration).topic == '' + } + + void 'configure single service'() { + when: + context = ApplicationContext.run( + 'aws.sns.topic': 'mytopic', + 'aws.sns.amazon.arn': 'my-amazon-arn', + 'aws.sns.android.arn': 'my-android-arn', + 'aws.sns.ios.arn': 'my-ios-arn', + 'aws.sns.iosSandbox.arn': 'my-ios-sandbox-arn', + ) + then: + context.getBeanDefinitions(SimpleNotificationService).size() == 1 + context.getBean(SimpleNotificationService) + context.getBean(SnsAsyncClient) + + when: + SimpleNotificationServiceConfiguration configuration = context.getBean(SimpleNotificationServiceConfiguration) + then: + configuration.topic == 'mytopic' + configuration.amazon.arn == 'my-amazon-arn' + configuration.android.arn == 'my-android-arn' + configuration.ios.arn == 'my-ios-arn' + configuration.iosSandbox.arn == 'my-ios-sandbox-arn' + } + + void 'configure single named service'() { + when: + context = ApplicationContext.run( + 'aws.sns.topics.mytopic.topic': 'mytopic' + ) + then: + context.getBeanDefinitions(SimpleNotificationService).size() == 2 + context.getBean(SimpleNotificationService) + context.getBean(SimpleNotificationService, Qualifiers.byName('default')) + context.getBean(SimpleNotificationService, Qualifiers.byName('mytopic')) + context.getBean(NamedSimpleNotificationServiceConfiguration).name == 'mytopic' + } + + void 'configure default and named service'() { + when: + context = ApplicationContext.run( + 'aws.sns.topic': 'defaulttopic', + 'aws.sns.topics.mytopic.topic': 'mycustomtopic' + ) + then: + context.getBeanDefinitions(SimpleNotificationService).size() == 2 + context.getBean(SimpleNotificationService, Qualifiers.byName('default')) + context.getBean(SimpleNotificationService, Qualifiers.byName('mytopic')) + context.getBean(SimpleNotificationServiceConfiguration, Qualifiers.byName('mytopic')) instanceof NamedSimpleNotificationServiceConfiguration + context.getBean(SimpleNotificationServiceConfiguration, Qualifiers.byName('mytopic')).topic == 'mycustomtopic' + } + +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceSpec.groovy b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceSpec.groovy new file mode 100644 index 000000000..98c259dc9 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceSpec.groovy @@ -0,0 +1,290 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns + +import com.fasterxml.jackson.databind.ObjectMapper +import io.micronaut.context.ApplicationContext +import org.testcontainers.spock.Testcontainers +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.sns.SnsClient +import spock.lang.AutoCleanup +import spock.lang.PendingFeature +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Stepwise + +/** + * Tests for simple notification service. + */ +@Stepwise +// tag::testcontainers-header[] +@Testcontainers // <1> +class SimpleNotificationServiceSpec extends Specification { + +// end::testcontainers-header[] + + private static final String TEST_TOPIC = 'TestTopic' + + // tag::testcontainers-fields[] + @Shared LocalStackContainer localstack = new LocalStackContainer('0.8.10') // <2> + .withServices(LocalStackContainer.Service.SNS) + + @AutoCleanup ApplicationContext context // <3> + + SimpleNotificationService service + SimpleNotificationServiceConfiguration configuration + // end::testcontainers-fields[] + + @Shared String androidEndpointArn + @Shared String amazonEndpointArn + @Shared String iosEndpointArn + @Shared String iosSandboxEndpointArn + @Shared String subscriptionArn + @Shared String topicArn + + // tag::testcontainers-setup[] + void setup() { + SnsClient sns = SnsClient // <4> + .builder() + .endpointOverride(localstack.getEndpointOverride(LocalStackContainer.Service.SNS)) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create( + localstack.defaultAccessKey, localstack.defaultSecretKey + ))) + .region(Region.of(localstack.defaultRegion)) + .build() + + SimpleNotificationServiceConfiguration mockConfiguration = Mock(SimpleNotificationServiceConfiguration) { + getTopic() >> TEST_TOPIC + } + + DefaultSimpleNotificationService configurer = new DefaultSimpleNotificationService(sns, mockConfiguration, new ObjectMapper()) + + context = ApplicationContext.build( + 'aws.sns.topic': TEST_TOPIC, + 'aws.sns.ios.arn': configurer.createIosApplication('IOS-APP', 'API-KEY', 'fake-cert', false), + 'aws.sns.ios-sandbox.arn': configurer.createIosApplication('IOS-APP', 'API-KEY', 'fake-cert', true), + 'aws.sns.android.arn': configurer.createAndroidApplication('ANDROID-APP', 'API-KEY'), + 'aws.sns.amazon.arn': configurer.createAmazonApplication('AMAZON-APP', 'API-KEY', 'API-SECRET') + ).build() // <5> + + context.registerSingleton(SnsClient, sns) + context.start() + + service = context.getBean(SimpleNotificationService) // <6> + configuration = context.getBean(SimpleNotificationServiceConfiguration) + } + // end::testcontainers-setup[] + + void 'new topic'() { + when: + String testTopic = 'TOPIC' + String created = service.createTopic(testTopic) + topicArn = testTopic + then: + created.endsWith(testTopic) + } + + void 'subscribe to the topic'() { + when: + subscriptionArn = service.subscribeTopicWithEmail(topicArn, 'vlad@agorapulse.com') + then: + subscriptionArn + } + + void 'subscribe to the json email'() { + when: + subscriptionArn = service.subscribeTopicWithJsonEmail(topicArn, 'vlad@agorapulse.com') + then: + subscriptionArn + } + + void 'register android device'() { + when: + androidEndpointArn = service.registerDevice(SimpleNotificationService.MOBILE_PLATFORM_ANDROID, 'ANDROID-TOKEN') + then: + androidEndpointArn + expect: + service.registerAndroidDevice('ANOTHER-ANDROID-TOKEN') + } + + void 'register amazon device'() { + when: + amazonEndpointArn = service.registerDevice(SimpleNotificationService.MOBILE_PLATFORM_AMAZON, 'AMAZON-TOKEN') + then: + amazonEndpointArn + expect: + service.registerAmazonDevice('ANOTHER-AMAZON-TOKEN') + } + + void 'register iOS device'() { + when: + iosEndpointArn = service.registerDevice(SimpleNotificationService.MOBILE_PLATFORM_IOS, 'IOS-TOKEN') + then: + iosEndpointArn + expect: + service.registerIosDevice('ANOTHER-IOS-TOKEN') + } + + void 'register iOS sandbox device'() { + when: + iosSandboxEndpointArn = service.registerDevice(SimpleNotificationService.MOBILE_PLATFORM_IOS_SANDBOX, 'IOS-SANDBOX-TOKEN') + then: + iosSandboxEndpointArn + expect: + service.registerIosSandboxDevice('ANOTHER-IOS-SANDBOX-TOKEN') + } + + @PendingFeature(reason = 'Needs to be tested with real SNS') + void 'register iOS sandbox device with the same token'() { + expect: + iosSandboxEndpointArn == service.createPlatformEndpoint(configuration.iosSandbox.arn, 'IOS-SANDBOX-TOKEN') + } + + void 'register unknown device'() { + when: + service.registerDevice('foo', 'FOO-SANDBOX-TOKEN') + then: + thrown(IllegalArgumentException) + } + + void 'publish message'() { + when: + String messageId = service.publishMessageToTopic(topicArn, 'SUBJECT', 'MESSAGE') + then: + messageId + } + + void 'validate android device'() { + expect: + androidEndpointArn == service.validateAndroidDevice(androidEndpointArn, 'ANDROID-TOKEN') + androidEndpointArn == service.validateDevice(SimpleNotificationService.MOBILE_PLATFORM_ANDROID, androidEndpointArn, 'ANDROID-TOKEN-NEW') + } + + void 'validate amazon device'() { + expect: + amazonEndpointArn == service.validateAmazonDevice(amazonEndpointArn, 'AMAZON-TOKEN') + amazonEndpointArn == service.validateDevice(SimpleNotificationService.MOBILE_PLATFORM_AMAZON, amazonEndpointArn, 'AMAZON-TOKEN') + } + + void 'validate ios device'() { + expect: + iosEndpointArn == service.validateIosDevice(iosEndpointArn, 'IOS-TOKEN') + iosEndpointArn == service.validateDevice(SimpleNotificationService.MOBILE_PLATFORM_IOS, iosEndpointArn, 'IOS-TOKEN') + } + + void 'validate ios sandbox device'() { + expect: + iosSandboxEndpointArn == service.validateIosSandboxDevice(iosSandboxEndpointArn, 'IOS-SANDBOX-TOKEN') + iosSandboxEndpointArn == service.validateDevice(SimpleNotificationService.MOBILE_PLATFORM_IOS_SANDBOX, iosSandboxEndpointArn, 'IOS-SANDBOX-TOKEN') + } + + void 'validate unknown platform'() { + expect: + !service.validateDevice('foo', 'arn', 'baz') + } + + void 'publish direct'() { + expect: + service.sendAndroidAppNotification(androidEndpointArn, [message: 'Hello'], 'key') + service.sendIosAppNotification(iosEndpointArn, [message: 'Hello']) + } + + void 'unregister device'() { + when: + service.unregisterDevice(androidEndpointArn) + then: + noExceptionThrown() + } + + @PendingFeature(reason = 'Needs to be tested with real SNS') + void 'validate unregistered device'() { + expect: + service.validateAndroidDevice(androidEndpointArn, 'ANDROID-TOKEN') + } + + void 'subscribe to the application'() { + when: + subscriptionArn = service.subscribeTopicWithApplication(topicArn, 'fake-app-arn') + then: + subscriptionArn + } + + void 'subscribe to the lambda'() { + when: + subscriptionArn = service.subscribeTopicWithFunction(topicArn, 'fake-fun-arn') + then: + subscriptionArn + } + + void 'subscribe to the queue'() { + when: + subscriptionArn = service.subscribeTopicWithQueue(topicArn, 'fake-queue-arn') + then: + subscriptionArn + } + + void 'subscribe to the sms'() { + when: + subscriptionArn = service.subscribeTopicWithSMS(topicArn, '+420555666777') + then: + subscriptionArn + } + + void 'subscribe to the topic with http'() { + when: + subscriptionArn = service.subscribeTopicWithEndpoint(topicArn, 'http://www.example.com') + then: + subscriptionArn + } + + void 'subscribe to the topic with https'() { + when: + subscriptionArn = service.subscribeTopicWithEndpoint(topicArn, 'https://www.example.com') + then: + subscriptionArn + } + + void 'can only subscribe with http or https'() { + when: + service.subscribeTopicWithEndpoint(topicArn, 'file:///tmp/foo.bar') + then: + thrown(IllegalArgumentException) + } + + void 'unsubscribe from the topic'() { + when: + service.unsubscribeTopic(subscriptionArn) + then: + noExceptionThrown() + } + + void 'delete topic'() { + when: + service.deleteTopic(topicArn) + then: + noExceptionThrown() + } + + void 'send sms'() { + expect: + service.sendSMSMessage('+420555666777', 'Hello') + } + +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceTest.java b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceTest.java new file mode 100644 index 000000000..82088eabb --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/SimpleNotificationServiceTest.java @@ -0,0 +1,163 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns; + +import io.micronaut.context.ApplicationContext; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.MessageAttributeValue; +import software.amazon.awssdk.services.sns.model.Topic; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class SimpleNotificationServiceTest { + + private static final String TEST_TOPIC = "TestTopic"; + private static final String EMAIL = "vlad@agorapulse.com"; + private static final String DEVICE_TOKEN = "DEVICE-TOKEN"; + private static final String API_KEY = "API-KEY"; + private static final String DATA = "Vlad"; + private static final String PHONE_NUMBER = "+420999888777"; + + // tag::testcontainers-setup[] + public ApplicationContext context; // <1> + + public SimpleNotificationService service; + + @Rule + public LocalStackContainer localstack = new LocalStackContainer("0.8.10") // <2> + .withServices(LocalStackContainer.Service.SNS); + + @Before + public void setup() { + SnsClient amazonSNS = SnsClient // <3> + .builder() + .endpointOverride(localstack.getEndpointOverride(LocalStackContainer.Service.SNS)) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create( + localstack.getDefaultAccessKey(), localstack.getDefaultSecretKey() + ))) + .region(Region.of(localstack.getDefaultRegion())) + .build(); + + + Map properties = new HashMap<>(); // <4> + properties.put("aws.sns.topic", TEST_TOPIC); + + + context = ApplicationContext.build(properties).build(); // <5> + context.registerSingleton(SnsClient.class, amazonSNS); + context.start(); + + service = context.getBean(SimpleNotificationService.class); + } + + @After + public void cleanup() { + if (context != null) { + context.close(); // <6> + } + } + // end::testcontainers-setup[] + + @Test + public void testWorkingWithTopics() { + // tag::new-topic[] + String topicArn = service.createTopic(TEST_TOPIC); // <1> + + Topic found = service.listTopics().filter(t -> // <2> + t.topicArn().endsWith(TEST_TOPIC) + ).blockingFirst(); + // end::new-topic[] + + assertNotNull(found); + + // CHECKSTYLE:OFF + // tag::subscription[] + String subArn = service.subscribeTopicWithEmail(topicArn, EMAIL); // <1> + + String messageId = service.publishMessageToTopic( // <2> + topicArn, + "Test Email", + "Hello World" + ); + + service.unsubscribeTopic(subArn); // <3> + // end::subscription[] + // CHECKSTYLE:ON + + assertNotNull(subArn); + assertNotNull(messageId); + + // tag::delete-topic[] + service.deleteTopic(topicArn); // <1> + + Long zero = service.listTopics().filter(t -> // <2> + t.topicArn().endsWith(TEST_TOPIC) + ).count().blockingGet(); + // end::delete-topic[] + + assertEquals(Long.valueOf(0), zero); + } + + @Test + public void testPlatformApplications() { + // tag::applications[] + String appArn = service.createAndroidApplication("my-app", API_KEY); // <1> + + String endpoint = service.createPlatformEndpoint(appArn, DEVICE_TOKEN, DATA); // <2> + + Map notif = new LinkedHashMap<>(); + notif.put("badge", "9"); + notif.put("data", "{\"foo\": \"some bar\"}"); + notif.put("title", "Some Title"); + + String msgId = service.sendAndroidAppNotification(endpoint, notif, "Welcome"); // <3> + + service.validateDeviceToken(appArn, endpoint, DEVICE_TOKEN, DATA); // <4> + + service.unregisterDevice(endpoint); // <5> + // end::applications[] + + assertNotNull(appArn); + assertNotNull(endpoint); + assertNotNull(msgId); + } + + @Test + public void sendSMS() { + // tag::sms[] + Map attrs = Collections.emptyMap(); + String msgId = service.sendSMSMessage(PHONE_NUMBER, "Hello World", attrs); // <1> + // end::sms[] + + assertNotNull(msgId); + } + +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/StreamClient.java b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/StreamClient.java new file mode 100644 index 000000000..a6aebfaa7 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/StreamClient.java @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns; + +import com.agorapulse.micronaut.amazon.awssdk.sns.annotation.NotificationClient; + +@NotificationClient(topic = "SomeTopic") interface StreamClient { + + String SOME_STREAM = "SomeTopic"; + + String publishMessage(Pogo message); + + // invalid + String sendSMS(String number); + String doSomething(String one, String two, String three); + String sendMessage(String subject); + +} diff --git a/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/TestClient.java b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/TestClient.java new file mode 100644 index 000000000..1da73d9a3 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-sns/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/sns/TestClient.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2020 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.micronaut.amazon.awssdk.sns; + +import com.agorapulse.micronaut.amazon.awssdk.sns.annotation.NotificationClient; + +@NotificationClient("test") interface TestClient { + String publishMessage(Pogo message); +}