diff --git a/build.gradle.kts b/build.gradle.kts index 67805b7..ca61521 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -72,6 +72,8 @@ subprojects { dependency("com.auth0:java-jwt:3.10.3") dependency("software.amazon.awssdk:s3:2.10.11") + + dependency("com.google.firebase:firebase-admin:9.0.0") } } diff --git a/push-message-provider-fcm/build.gradle.kts b/push-message-provider-fcm/build.gradle.kts new file mode 100644 index 0000000..51d866e --- /dev/null +++ b/push-message-provider-fcm/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("kotlin") + id("kotlin-spring") + id("maven-publish") +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + + implementation("org.springframework.boot:spring-boot") + + implementation(project(":logger-spring")) + + implementation(project(":push-message-provider")) + implementation("com.google.firebase:firebase-admin") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.testcontainers:junit-jupiter") +} diff --git a/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/EnablePushMessageProviderFcm.kt b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/EnablePushMessageProviderFcm.kt new file mode 100644 index 0000000..d83f0f8 --- /dev/null +++ b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/EnablePushMessageProviderFcm.kt @@ -0,0 +1,8 @@ +@file:Suppress("unused") +package ru.touchin.push.message.provider.fcm + +import org.springframework.context.annotation.Import +import ru.touchin.push.message.provider.fcm.configurations.PushMessageProviderFcmConfiguration + +@Import(value = [PushMessageProviderFcmConfiguration::class]) +annotation class EnablePushMessageProviderFcm diff --git a/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/configurations/PushMessageProviderFcmConfiguration.kt b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/configurations/PushMessageProviderFcmConfiguration.kt new file mode 100644 index 0000000..04e8015 --- /dev/null +++ b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/configurations/PushMessageProviderFcmConfiguration.kt @@ -0,0 +1,37 @@ +package ru.touchin.push.message.provider.fcm.configurations + +import com.google.auth.oauth2.GoogleCredentials +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.messaging.FirebaseMessaging +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Import +import org.springframework.core.io.ClassPathResource +import ru.touchin.push.message.provider.configurations.PushMessageProviderConfiguration +import ru.touchin.push.message.provider.fcm.properties.PushMessageProviderFcmProperties + +@ComponentScan("ru.touchin.push.message.provider.fcm") +@ConfigurationPropertiesScan(basePackages = ["ru.touchin.push.message.provider.fcm"]) +@Import(value = [PushMessageProviderConfiguration::class]) +class PushMessageProviderFcmConfiguration { + + @Bean + fun firebaseMessaging( + properties: PushMessageProviderFcmProperties + ): FirebaseMessaging { + val credentials = GoogleCredentials.fromStream(ClassPathResource(properties.auth.resourcePath).inputStream) + + val options: FirebaseOptions = FirebaseOptions.builder() + .setCredentials(credentials) + .setConnectTimeout(properties.client.connectionTimeout.toMillis().toInt()) + .setReadTimeout(properties.client.readTimeout.toMillis().toInt()) + .build() + + val firebaseApp: FirebaseApp = FirebaseApp.initializeApp(options, properties.appName) + + return FirebaseMessaging.getInstance(firebaseApp) + } + +} diff --git a/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/converters/FirebaseMessagingExceptionConverter.kt b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/converters/FirebaseMessagingExceptionConverter.kt new file mode 100644 index 0000000..5d5df01 --- /dev/null +++ b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/converters/FirebaseMessagingExceptionConverter.kt @@ -0,0 +1,24 @@ +package ru.touchin.push.message.provider.fcm.converters + +import com.google.firebase.messaging.FirebaseMessagingException +import com.google.firebase.messaging.MessagingErrorCode +import org.springframework.stereotype.Component +import ru.touchin.common.exceptions.CommonException +import ru.touchin.push.message.provider.exceptions.InvalidPushTokenException +import ru.touchin.push.message.provider.exceptions.PushMessageProviderException + +@Component +class FirebaseMessagingExceptionConverter { + + operator fun invoke(exception: FirebaseMessagingException): CommonException { + return when (exception.messagingErrorCode) { + MessagingErrorCode.INVALID_ARGUMENT, + MessagingErrorCode.UNREGISTERED -> InvalidPushTokenException() + else -> PushMessageProviderException( + description = exception.message.orEmpty(), + cause = exception + ) + } + } + +} diff --git a/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/converters/NotificationConverter.kt b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/converters/NotificationConverter.kt new file mode 100644 index 0000000..cfd40bc --- /dev/null +++ b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/converters/NotificationConverter.kt @@ -0,0 +1,18 @@ +package ru.touchin.push.message.provider.fcm.converters + +import com.google.firebase.messaging.Notification as FcmNotification +import org.springframework.stereotype.Component +import ru.touchin.push.message.provider.dto.Notification + +@Component +class NotificationConverter { + + operator fun invoke(notification: Notification): FcmNotification { + return FcmNotification.builder() + .setTitle(notification.title) + .setBody(notification.description) + .setImage(notification.imageUrl) + .build() + } + +} diff --git a/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/converters/PushTokenMessageConverter.kt b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/converters/PushTokenMessageConverter.kt new file mode 100644 index 0000000..752d786 --- /dev/null +++ b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/converters/PushTokenMessageConverter.kt @@ -0,0 +1,29 @@ +package ru.touchin.push.message.provider.fcm.converters + +import com.google.firebase.messaging.Message +import org.springframework.stereotype.Component +import ru.touchin.push.message.provider.dto.Notification +import ru.touchin.push.message.provider.dto.request.PushTokenMessage + +@Component +class PushTokenMessageConverter( + private val notificationConverter: NotificationConverter +) { + + operator fun invoke(request: PushTokenMessage): Message { + return Message.builder() + .setToken(request.token) + .setIfExists(request.notification) + .putAllData(request.data) + .build() + } + + private fun Message.Builder.setIfExists(notification: Notification?): Message.Builder { + return if (notification != null) { + setNotification(notificationConverter(notification)) + } else { + this + } + } + +} diff --git a/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/properties/PushMessageProviderFcmProperties.kt b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/properties/PushMessageProviderFcmProperties.kt new file mode 100644 index 0000000..05755bf --- /dev/null +++ b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/properties/PushMessageProviderFcmProperties.kt @@ -0,0 +1,31 @@ +package ru.touchin.push.message.provider.fcm.properties + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConstructorBinding +import ru.touchin.push.message.provider.enums.PlatformType +import ru.touchin.push.message.provider.enums.PushMessageProviderType +import ru.touchin.push.message.provider.properties.PushMessageProviderProperties +import java.time.Duration + +@ConstructorBinding +@ConfigurationProperties(prefix = "push-message-provider.fcm") +class PushMessageProviderFcmProperties( + val appName: String, + val auth: Auth.Credentials, + val client: Client +) { + + sealed interface Auth { + + data class Credentials( + val resourcePath: String + ) : Auth + + } + + data class Client( + val readTimeout: Duration, + val connectionTimeout: Duration + ) + +} diff --git a/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/services/PushMessageProviderFcmService.kt b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/services/PushMessageProviderFcmService.kt new file mode 100644 index 0000000..3275813 --- /dev/null +++ b/push-message-provider-fcm/src/main/kotlin/ru/touchin/push/message/provider/fcm/services/PushMessageProviderFcmService.kt @@ -0,0 +1,49 @@ +package ru.touchin.push.message.provider.fcm.services + +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.FirebaseMessagingException +import org.springframework.stereotype.Service +import ru.touchin.push.message.provider.dto.request.PushTokenMessage +import ru.touchin.push.message.provider.dto.request.SendPushRequest +import ru.touchin.push.message.provider.dto.result.SendPushResult +import ru.touchin.push.message.provider.dto.result.SendPushTokenMessageResult +import ru.touchin.push.message.provider.enums.PushMessageProviderType +import ru.touchin.push.message.provider.exceptions.InvalidPushTokenException +import ru.touchin.push.message.provider.exceptions.PushMessageProviderException +import ru.touchin.push.message.provider.fcm.converters.FirebaseMessagingExceptionConverter +import ru.touchin.push.message.provider.fcm.converters.PushTokenMessageConverter +import ru.touchin.push.message.provider.services.PushMessageProviderService + +/** + * Service that provides integration with FCM. + * @see FCM documentation + */ +@Service +class PushMessageProviderFcmService( + private val firebaseMessaging: FirebaseMessaging, + private val pushTokenMessageConverter: PushTokenMessageConverter, + private val firebaseMessagingExceptionConverter: FirebaseMessagingExceptionConverter +) : PushMessageProviderService { + + override val type: PushMessageProviderType = PushMessageProviderType.FCM + + @Throws(PushMessageProviderException::class, InvalidPushTokenException::class) + override fun send(request: SendPushRequest): SendPushResult { + return when (request) { + is PushTokenMessage -> sendPushTokenMessage(request) + } + } + + private fun sendPushTokenMessage(request: PushTokenMessage): SendPushResult { + val message = pushTokenMessageConverter(request) + + return try { + val messageId = firebaseMessaging.send(message) + + SendPushTokenMessageResult(messageId) + } catch (e: FirebaseMessagingException) { + throw firebaseMessagingExceptionConverter(e) + } + } + +} diff --git a/push-message-provider-fcm/src/test/kotlin/ru/touchin/push/message/provider/fcm/PushMessageProviderFcmTestApplication.kt b/push-message-provider-fcm/src/test/kotlin/ru/touchin/push/message/provider/fcm/PushMessageProviderFcmTestApplication.kt new file mode 100644 index 0000000..7ba4706 --- /dev/null +++ b/push-message-provider-fcm/src/test/kotlin/ru/touchin/push/message/provider/fcm/PushMessageProviderFcmTestApplication.kt @@ -0,0 +1,31 @@ +package ru.touchin.push.message.provider.fcm + +import com.fasterxml.jackson.annotation.JsonAutoDetect +import com.fasterxml.jackson.annotation.PropertyAccessor +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import org.springframework.boot.SpringBootConfiguration +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.test.context.ContextConfiguration +import ru.touchin.push.message.provider.fcm.configurations.PushMessageProviderFcmConfiguration + +@SpringBootConfiguration +@ContextConfiguration(classes = [PushMessageProviderFcmConfiguration::class]) +@TestConfiguration +@Import(PushMessageProviderFcmConfiguration::class) +@ConfigurationPropertiesScan(basePackages = ["ru.touchin.push.message.provider.fcm"]) +class PushMessageProviderFcmTestApplication { + + @Bean + fun objectMapper(): ObjectMapper { + return ObjectMapper().apply { + configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false) + setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE) + setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + } + } + +} diff --git a/push-message-provider-fcm/src/test/kotlin/ru/touchin/push/message/provider/fcm/converters/NotificationConverterTest.kt b/push-message-provider-fcm/src/test/kotlin/ru/touchin/push/message/provider/fcm/converters/NotificationConverterTest.kt new file mode 100644 index 0000000..a15c159 --- /dev/null +++ b/push-message-provider-fcm/src/test/kotlin/ru/touchin/push/message/provider/fcm/converters/NotificationConverterTest.kt @@ -0,0 +1,48 @@ +package ru.touchin.push.message.provider.fcm.converters + +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.Assert +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import ru.touchin.push.message.provider.dto.Notification +import com.google.firebase.messaging.Notification as FcmNotification +import org.junit.jupiter.api.DisplayName + +@SpringBootTest +class NotificationConverterTest { + + @Autowired + lateinit var notificationConverter: NotificationConverter + + @Autowired + lateinit var objectMapper: ObjectMapper + + @Test + @DisplayName("Конвертация уведомления происходит корректно") + fun invoke_basic() { + val notification = Notification( + title = "title", + description = "description", + imageUrl = "imageUrl" + ) + + val realResult = notificationConverter(notification) + val realResultJson = objectMapper.writeValueAsString(realResult) + + val expectedResult = FcmNotification.builder() + .setTitle(notification.title) + .setBody(notification.description) + .setImage(notification.imageUrl) + .build() + + val expectedResultJson = objectMapper.writeValueAsString(expectedResult) + + Assert.assertEquals( + "Конвертация некорректна", + realResultJson, + expectedResultJson + ) + } + +} diff --git a/push-message-provider-fcm/src/test/kotlin/ru/touchin/push/message/provider/fcm/converters/PushTokenMessageConverterTest.kt b/push-message-provider-fcm/src/test/kotlin/ru/touchin/push/message/provider/fcm/converters/PushTokenMessageConverterTest.kt new file mode 100644 index 0000000..dd2ffb6 --- /dev/null +++ b/push-message-provider-fcm/src/test/kotlin/ru/touchin/push/message/provider/fcm/converters/PushTokenMessageConverterTest.kt @@ -0,0 +1,83 @@ +package ru.touchin.push.message.provider.fcm.converters + +import com.fasterxml.jackson.databind.ObjectMapper +import com.google.firebase.messaging.Message +import org.junit.Assert +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import ru.touchin.push.message.provider.dto.Notification +import ru.touchin.push.message.provider.dto.request.PushTokenMessage + +@SpringBootTest +class PushTokenMessageConverterTest { + + @Autowired + lateinit var pushTokenMessageConverter: PushTokenMessageConverter + + @Autowired + lateinit var notificationConverter: NotificationConverter + + @Autowired + lateinit var objectMapper: ObjectMapper + + @Test + @DisplayName("Конвертация сообщения с уведомлением происходит корректно") + fun invoke_withNotification() { + val notification = Notification( + title = "title", + description = "description", + imageUrl = "imageUrl" + ) + val pushTokenMessage = PushTokenMessage( + token = "token", + notification = notification, + data = mapOf("testKey" to "testvalue") + ) + + val realResult = pushTokenMessageConverter(pushTokenMessage) + val realResultJson = objectMapper.writeValueAsString(realResult) + + val expectedResult = Message.builder() + .setToken(pushTokenMessage.token) + .setNotification(notificationConverter(notification)) + .putAllData(pushTokenMessage.data) + .build() + + val expectedResultJson = objectMapper.writeValueAsString(expectedResult) + + Assert.assertEquals( + "Конвертация некорректна", + realResultJson, + expectedResultJson + ) + } + + @Test + @DisplayName("Конвертация сообщения без уведомления происходит корректно") + fun invoke_withoutNotification() { + val pushTokenMessage = PushTokenMessage( + token = "token", + notification = null, + data = mapOf("testKey" to "testvalue") + ) + + val realResult = pushTokenMessageConverter(pushTokenMessage) + val realResultJson = objectMapper.writeValueAsString(realResult) + + val expectedResult = Message.builder() + .setToken(pushTokenMessage.token) + .putAllData(pushTokenMessage.data) + .build() + + val expectedResultJson = objectMapper.writeValueAsString(expectedResult) + + Assert.assertEquals( + "Конвертация некорректна", + realResultJson, + expectedResultJson + ) + } + +} diff --git a/push-message-provider-fcm/src/test/resources/application.yml b/push-message-provider-fcm/src/test/resources/application.yml new file mode 100644 index 0000000..d77c4bd --- /dev/null +++ b/push-message-provider-fcm/src/test/resources/application.yml @@ -0,0 +1,13 @@ +push-message-provider: + platformProviders: + ANDROID_GOOGLE: + - FCM + IOS: + - FCM + fcm: + appName: testAppName + auth: + resourcePath: credentials/firebase-admin.json + client: + readTimeout: 5s + connectionTimeout: 5s diff --git a/push-message-provider-fcm/src/test/resources/credentials/firebase-admin.json b/push-message-provider-fcm/src/test/resources/credentials/firebase-admin.json new file mode 100644 index 0000000..2eaa59f --- /dev/null +++ b/push-message-provider-fcm/src/test/resources/credentials/firebase-admin.json @@ -0,0 +1,12 @@ +{ + "type": "service_account", + "project_id": "testProjectId", + "private_key_id": "privateKeyId", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBALfBshaLMW2yddmAZJRNXTZzcSbwvY93Dnjj6naWgoBJoB3mOM5bcoyWwBw12A4rwecorz74OUOc6zdqX3j8hwsSyzgAUStKM5PkOvPNRKsI4eXAWU0fmb8h1jyXwftl7EzeBjEMBTpyXkgDk3wLfHN6ciCZrnQndOvS+mMl3b0hAgMBAAECgYEAmIQZByMSrITR0ewCDyFDO52HjhWEkF310hsBkNoNiOMTFZ3vCj/WjJ/W5dM+90wUTYN0KOSnytmkVUNh6K5Yekn+yRg/mBRTwwn88hU6umB8tUqoNz7AyUltAOGyQMWqAAcVgxV+mAp/Y018j69poEHgrW4qKol65/NRZyV7/J0CQQD4rCDjmxGEuA1yMzL2i8NyNl/5vvLVfLcEnVqpHbc1+KfUHZuY7iv38xpzfmErqhCxAXfQ52edq5rXmMIVSbFrAkEAvSvfSSK9XQDJl3NEyfR3BGbsoqKIYOuJAnv4OQPSODZfTNWhc11S8y914qaSWB+Iid9HoLvAIgPH5mrzPzjSowJBAJcw4FZCI+aTmOlEI8ous8gvMy8/X5lZWFUf7s0/2fKgmjmnPsE+ndEFJ6HsxturbLaR8+05pJAClARdRjN3OL0CQGoF+8gmw1ErztCmVyiFbms2MGxagesoN4r/5jg2Tw0YVENg/HMHHCWWNREJ4L2pNsJnNOL+N4oY6mHXEWwesdcCQCUYTfLYxi+Wg/5BSC7fgl/gu0mlx07AzMoMQLDOXdisV5rpxrOoT3BOLBqyccv37AZ3e2gqb8JYyNzO6C0zswQ=\n-----END PRIVATE KEY-----\n", + "client_email": "clientEmail", + "client_id": "clientId", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://accounts.google.com/o/oauth2/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "clientX509CertUrl" +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0a48a8f..3b2cbd7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,6 +43,7 @@ include("exception-handler-logger-spring-web") include("validation-spring") include("version-spring-web") include("push-message-provider") +include("push-message-provider-fcm") include("response-wrapper-spring-web") include("settings-spring-jpa") include("security-authorization-server-core")