Compare commits

...

12 Commits

Author SHA1 Message Date
Korna c5a175f2dc FIll missing points in readme 2022-11-08 15:18:34 +03:00
Artyom 9c66f3819c
Various fixes of bean/class naming and tests (#100)
* Add test for enum interface

* Specify bean names for PushMessageNotification converters

* Stop using qualifier as bean-name specifier

* Add credentials-object-mapper as standard bean for FCM PMP

* Specify basic webclient builders

* Fix PushMessageNotification converters bean and class names

* Update behavior for integration test

* Minor codestyle fix
2022-11-08 14:22:16 +03:00
Artyom 24aed8cc8b
Use new PushMessageSendResult class (#99) 2022-11-08 12:14:19 +03:00
Artyom 78008a9325
Update Readme for new push message provider modules (#98)
* Update readme for push message provider blocks
2022-11-08 11:55:46 +03:00
Korna fc8fbc68c3 Merge branch 'master' into feature/pmp-hpk 2022-11-07 20:22:22 +03:00
Artyom c89de4c548
Cover services with tests (#97)
* Cover module with tests

* Fix found bugs
2022-11-07 20:17:07 +03:00
Artyom 2771e92e77
Add services for HPS clients and implement push message provider service (#96)
* Add DTO converters

* Add HMS Oauth services and DTO's

* Add HMS HPK services

* Implement PushMessageProvider service for HPK
2022-11-07 19:15:06 +03:00
Artyom 1771271789
Add HMS Oauth and HMS HPK WebClients and request DTO's (#95)
* Add request and response DTO's and enums

* Add HMS HPK WebClient

* Add HMS OAuth DTO's

* Add HMS Oauth WebClient
2022-11-07 18:17:56 +03:00
Artyom 73d7a2325d
Add common and base DTO's (#94)
* Add config DTO's for platforms

* Add basic DTO's
2022-11-07 18:04:39 +03:00
Artyom 28355a9341
Add IOS and Web related DTO's (#93)
* Add APNS related DTO's and enums

* Add Web related DTO's and enums
2022-11-07 17:44:46 +03:00
Artyom 2dae5f7a77
Add HPK Android-related DTO's (#89)
* Add HPK Android-related DTO's
2022-11-07 17:14:59 +03:00
Artyom 5f6adc97fe
Add basic changes for HPK push provider (#88)
* Rename and add dto's

* Add new platform and provider

* Add module

* Add builder

* Add api enum

* Add conditional webclient

* Add configuration and properties
2022-11-02 16:40:01 +03:00
93 changed files with 3956 additions and 91 deletions

106
README.md
View File

@ -216,7 +216,47 @@ server.info:
## push-message-provider ## push-message-provider
Интерфейсы и компоненты для модулей по обеспечению интеграции с сервисами отправки пуш-уведомлений. Интерфейсы и компоненты для модулей по обеспечению интеграции с сервисами отправки пуш-уведомлений. Является необходимой для подключения зависимостью для использования провайдеров.
Далее рассматривается пример использования подключаемых модулей-провайдеров.
``` kotlin
@Service
class PushSendingService(
private val pushMessageProviderServiceFactory: PushMessageProviderServiceFactory
) {
fun sendPushMessage() {
val yourPushToken = "pushTokenForChecking"
val platform = PlatformType.ANDROID_GOOGLE
val pushMessageProvider: PushMessageProviderService = pushMessageProviderServiceFactory.get(platform)
val result = pushMessageProvider.check( // Проверка валидности токена для обозначения целесообразности отправки
PushTokenCheck(
pushToken = yourPushToken
)
)
if (result.status == PushTokenStatus.VALID) { // Токен валиден, PushMessageProviderService интегрирован в систему
// Отправка пуш-уведомления
pushMessageProvider.send(
PushTokenMessage(
token = yourPushToken,
pushMessageNotification = PushMessageNotification(
title = "Your PushMessage",
description = "Provided by PushMessageProviderService",
imageUrl = null
),
data = mapOf(
"customKey" to "customData"
)
)
)
}
}
}
```
## push-message-provider-fcm ## push-message-provider-fcm
@ -231,7 +271,7 @@ push-message-provider:
IOS: IOS:
- FCM - FCM
fcm: fcm:
appName: # Название приложения appName: yourAppName
auth: auth:
# Выбранный тип авторизации # Выбранный тип авторизации
client: client:
@ -258,16 +298,60 @@ C) Данные из файла консоли Firebase, добавляемые
auth: auth:
credentialsData: credentialsData:
type: service_account type: service_account
projectId: testProjectId projectId: yourProjectId
privateKeyId: testPrivateKeyId privateKeyId: yourPrivateKeyId
privateKey: | privateKey: |
-----BEGIN PRIVATE KEY----- -----BEGIN PRIVATE KEY-----
MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBALfBshaLMW2yddmAZJRNXTZzcSbwvY93Dnjj6naWgoBJoB3mOM5bcoyWwBw12A4rwecorz74OUOc6zdqX3j8hwsSyzgAUStKM5PkOvPNRKsI4eXAWU0fmb8h1jyXwftl7EzeBjEMBTpyXkgDk3wLfHN6ciCZrnQndOvS+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=
-----END PRIVATE KEY----- -----END PRIVATE KEY-----
clientEmail: testClientEmail clientEmail: yourClientEmail
clientId: testClientId clientId: yourClientId
authUri: testAuthUri authUri: yourAuthUri
tokenUri: testTokenUri tokenUri: yourTokenUri
authProviderX509CertUrl: testAuthProviderX509CertUrl authProviderX509CertUrl: yourAuthProviderX509CertUrl
clientX509CertUrl: testClientX509CertUrl clientX509CertUrl: yourClientX509CertUrl
```
## push-message-provider-hpk
Модуль по обеспечению интеграции с Huawei Push Kit.
1) Подключение нового провайдера осуществляется при помощи аннотации `@EnablePushMessageProviderHpk`.
2) Для логирования запросов к сервису HPK нужно встроить в контейнер Spring собственный `WebClientLogger` из пакета `common-spring-web` или же использовать стандартный посредством импорта конфигурации:
``` kotlin
@Import(
SpringLoggerConfiguration::class,
SpringLoggerWebConfiguration::class
)
class YourConfiguration
```
3) Нужно добавить конфигурацию для считывания модулем. Пример файла в формате yaml:
``` yaml
push-message-provider:
platformProviders:
ANDROID_HUAWEI:
- HPK
hpk:
web-services:
client-id: yourClientId
oauth:
client-secret: yourClientSecret
url: https://oauth-login.cloud.huawei.com/oauth2/v3/
http:
connection-timeout: 1s
read-timeout: 10s
write-timeout: 10s
ssl: # Опциональная структура
handshake-timeout: 1s
notify-read-timeout: 1s
notify-flush-timeout: 1s
hpk:
url: https://push-api.cloud.huawei.com/v1/
http:
connection-timeout: 1s
read-timeout: 10s
write-timeout: 10s
ssl: # Опциональная структура
handshake-timeout: 1s
notify-read-timeout: 1s
notify-flush-timeout: 1s
``` ```

View File

@ -6,7 +6,7 @@ import org.springframework.stereotype.Component
import ru.touchin.push.message.provider.dto.request.PushTokenCheck import ru.touchin.push.message.provider.dto.request.PushTokenCheck
import ru.touchin.push.message.provider.dto.request.PushTokenMessage import ru.touchin.push.message.provider.dto.request.PushTokenMessage
import ru.touchin.push.message.provider.dto.result.SendPushResult import ru.touchin.push.message.provider.dto.result.SendPushResult
import ru.touchin.push.message.provider.dto.result.SendPushTokenMessageResult import ru.touchin.push.message.provider.dto.result.SendPushTokenMessageTraceableResult
import ru.touchin.push.message.provider.enums.PushTokenStatus import ru.touchin.push.message.provider.enums.PushTokenStatus
import ru.touchin.push.message.provider.exceptions.InvalidPushTokenException import ru.touchin.push.message.provider.exceptions.InvalidPushTokenException
import ru.touchin.push.message.provider.exceptions.PushMessageProviderException import ru.touchin.push.message.provider.exceptions.PushMessageProviderException
@ -32,7 +32,7 @@ class FcmClient(
fun check(request: PushTokenCheck): PushTokenStatus { fun check(request: PushTokenCheck): PushTokenStatus {
val validationRequest = PushTokenMessage( val validationRequest = PushTokenMessage(
token = request.pushToken, token = request.pushToken,
notification = null, pushMessageNotification = null,
data = emptyMap() data = emptyMap()
) )
@ -54,7 +54,7 @@ class FcmClient(
return try { return try {
val messageId = firebaseMessaging.send(message, dryRun) val messageId = firebaseMessaging.send(message, dryRun)
SendPushTokenMessageResult(messageId) SendPushTokenMessageTraceableResult(messageId)
} catch (e: FirebaseMessagingException) { } catch (e: FirebaseMessagingException) {
throw firebaseMessagingExceptionConverter(e) throw firebaseMessagingExceptionConverter(e)
} }

View File

@ -1,6 +1,9 @@
package ru.touchin.push.message.provider.fcm.configurations package ru.touchin.push.message.provider.fcm.configurations
import com.fasterxml.jackson.annotation.JsonAutoDetect
import com.fasterxml.jackson.annotation.PropertyAccessor
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.google.auth.oauth2.AccessToken import com.google.auth.oauth2.AccessToken
import com.google.auth.oauth2.GoogleCredentials import com.google.auth.oauth2.GoogleCredentials
import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseApp
@ -25,6 +28,7 @@ class PushMessageProviderFcmConfiguration {
@Bean @Bean
fun firebaseMessaging( fun firebaseMessaging(
properties: PushMessageProviderFcmProperties, properties: PushMessageProviderFcmProperties,
@Qualifier("push-message-provider.fcm.credentials-object-mapper")
objectMapper: ObjectMapper objectMapper: ObjectMapper
): FirebaseMessaging { ): FirebaseMessaging {
val credentials = when { val credentials = when {
@ -60,10 +64,22 @@ class PushMessageProviderFcmConfiguration {
return FirebaseMessaging.getInstance(firebaseApp) return FirebaseMessaging.getInstance(firebaseApp)
} }
@Bean @Bean("push-message-provider.fcm.credentials-date-format")
@Qualifier("push-message-provider.fcm.auth")
fun simpleDateFormat(): SimpleDateFormat { fun simpleDateFormat(): SimpleDateFormat {
return SimpleDateFormat("yyyy-MM-dd HH:mm:ss X", Locale.getDefault()) return SimpleDateFormat("yyyy-MM-dd HH:mm:ss X", Locale.getDefault())
} }
@Bean("push-message-provider.fcm.credentials-object-mapper")
fun objectMapper(
@Qualifier("push-message-provider.fcm.credentials-date-format")
simpleDateFormat: SimpleDateFormat
): ObjectMapper {
return ObjectMapper().apply {
configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE)
setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
dateFormat = simpleDateFormat
}
}
} }

View File

@ -10,7 +10,7 @@ import java.util.*
@ConfigurationPropertiesBinding @ConfigurationPropertiesBinding
@Component @Component
class DateConverter( class DateConverter(
@Qualifier("push-message-provider.fcm.auth") @Qualifier("push-message-provider.fcm.credentials-date-format")
private val simpleDateFormat: SimpleDateFormat private val simpleDateFormat: SimpleDateFormat
) : Converter<String, Date> { ) : Converter<String, Date> {

View File

@ -1,18 +0,0 @@
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()
}
}

View File

@ -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.PushMessageNotification
@Component("push-message-provider.fcm.push-message-notification-converter")
class PushMessageNotificationConverter {
operator fun invoke(pushMessageNotification: PushMessageNotification): FcmNotification {
return FcmNotification.builder()
.setTitle(pushMessageNotification.title)
.setBody(pushMessageNotification.description)
.setImage(pushMessageNotification.imageUrl)
.build()
}
}

View File

@ -5,13 +5,14 @@ import com.google.firebase.messaging.AndroidNotification
import com.google.firebase.messaging.ApnsConfig import com.google.firebase.messaging.ApnsConfig
import com.google.firebase.messaging.Aps import com.google.firebase.messaging.Aps
import com.google.firebase.messaging.Message import com.google.firebase.messaging.Message
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import ru.touchin.push.message.provider.dto.Notification import ru.touchin.push.message.provider.dto.PushMessageNotification
import ru.touchin.push.message.provider.dto.request.PushTokenMessage import ru.touchin.push.message.provider.dto.request.PushTokenMessage
@Component @Component("push-message-provider.fcm.push-token-message-converter")
class PushTokenMessageConverter( class PushTokenMessageConverter(
private val notificationConverter: NotificationConverter private val pushMessageNotificationConverter: PushMessageNotificationConverter
) { ) {
private companion object { private companion object {
@ -26,14 +27,14 @@ class PushTokenMessageConverter(
.setToken(request.token) .setToken(request.token)
.setupApns() .setupApns()
.setupAndroid() .setupAndroid()
.setIfExists(request.notification) .setIfExists(request.pushMessageNotification)
.putAllData(request.data) .putAllData(request.data)
.build() .build()
} }
private fun Message.Builder.setIfExists(notification: Notification?): Message.Builder { private fun Message.Builder.setIfExists(pushMessageNotification: PushMessageNotification?): Message.Builder {
return if (notification != null) { return if (pushMessageNotification != null) {
setNotification(notificationConverter(notification)) setNotification(pushMessageNotificationConverter(pushMessageNotification))
} else { } else {
this this
} }

View File

@ -1,36 +1,16 @@
package ru.touchin.push.message.provider.fcm 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 com.google.firebase.FirebaseApp import com.google.firebase.FirebaseApp
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.SpringBootConfiguration import org.springframework.boot.SpringBootConfiguration
import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.ApplicationListener import org.springframework.context.ApplicationListener
import org.springframework.context.annotation.Bean
import org.springframework.context.event.ContextRefreshedEvent import org.springframework.context.event.ContextRefreshedEvent
import java.text.SimpleDateFormat
@TestConfiguration @TestConfiguration
@SpringBootConfiguration @SpringBootConfiguration
@EnablePushMessageProviderFcm @EnablePushMessageProviderFcm
class PushMessageProviderFcmTestApplication : ApplicationListener<ContextRefreshedEvent> { class PushMessageProviderFcmTestApplication : ApplicationListener<ContextRefreshedEvent> {
@Bean
fun objectMapper(
@Qualifier("push-message-provider.fcm.auth")
simpleDateFormat: SimpleDateFormat
): ObjectMapper {
return ObjectMapper().apply {
configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE)
setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
dateFormat = simpleDateFormat
}
}
override fun onApplicationEvent(event: ContextRefreshedEvent) { override fun onApplicationEvent(event: ContextRefreshedEvent) {
clearSingletonsOutsideContainer() clearSingletonsOutsideContainer()
} }

View File

@ -5,15 +5,15 @@ import org.junit.Assert
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import ru.touchin.push.message.provider.dto.Notification import ru.touchin.push.message.provider.dto.PushMessageNotification
import com.google.firebase.messaging.Notification as FcmNotification import com.google.firebase.messaging.Notification as FcmNotification
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
@SpringBootTest @SpringBootTest
class NotificationConverterTest { class PushMessageNotificationConverterTest {
@Autowired @Autowired
lateinit var notificationConverter: NotificationConverter lateinit var pushMessageNotificationConverter: PushMessageNotificationConverter
@Autowired @Autowired
lateinit var objectMapper: ObjectMapper lateinit var objectMapper: ObjectMapper
@ -21,19 +21,19 @@ class NotificationConverterTest {
@Test @Test
@DisplayName("Конвертация уведомления происходит корректно") @DisplayName("Конвертация уведомления происходит корректно")
fun invoke_basic() { fun invoke_basic() {
val notification = Notification( val pushMessageNotification = PushMessageNotification(
title = "title", title = "title",
description = "description", description = "description",
imageUrl = "imageUrl" imageUrl = "imageUrl"
) )
val realResult = notificationConverter(notification) val realResult = pushMessageNotificationConverter(pushMessageNotification)
val realResultJson = objectMapper.writeValueAsString(realResult) val realResultJson = objectMapper.writeValueAsString(realResult)
val expectedResult = FcmNotification.builder() val expectedResult = FcmNotification.builder()
.setTitle(notification.title) .setTitle(pushMessageNotification.title)
.setBody(notification.description) .setBody(pushMessageNotification.description)
.setImage(notification.imageUrl) .setImage(pushMessageNotification.imageUrl)
.build() .build()
val expectedResultJson = objectMapper.writeValueAsString(expectedResult) val expectedResultJson = objectMapper.writeValueAsString(expectedResult)

View File

@ -11,7 +11,7 @@ import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import ru.touchin.push.message.provider.dto.Notification import ru.touchin.push.message.provider.dto.PushMessageNotification
import ru.touchin.push.message.provider.dto.request.PushTokenMessage import ru.touchin.push.message.provider.dto.request.PushTokenMessage
@SpringBootTest @SpringBootTest
@ -21,7 +21,7 @@ class PushTokenMessageConverterTest {
lateinit var pushTokenMessageConverter: PushTokenMessageConverter lateinit var pushTokenMessageConverter: PushTokenMessageConverter
@Autowired @Autowired
lateinit var notificationConverter: NotificationConverter lateinit var pushMessageNotificationConverter: PushMessageNotificationConverter
@Autowired @Autowired
lateinit var objectMapper: ObjectMapper lateinit var objectMapper: ObjectMapper
@ -29,14 +29,14 @@ class PushTokenMessageConverterTest {
@Test @Test
@DisplayName("Конвертация сообщения с уведомлением происходит корректно") @DisplayName("Конвертация сообщения с уведомлением происходит корректно")
fun invoke_withNotification() { fun invoke_withNotification() {
val notification = Notification( val pushMessageNotification = PushMessageNotification(
title = "title", title = "title",
description = "description", description = "description",
imageUrl = "imageUrl" imageUrl = "imageUrl"
) )
val pushTokenMessage = PushTokenMessage( val pushTokenMessage = PushTokenMessage(
token = "token", token = "token",
notification = notification, pushMessageNotification = pushMessageNotification,
data = mapOf("testKey" to "testvalue") data = mapOf("testKey" to "testvalue")
) )
@ -45,7 +45,7 @@ class PushTokenMessageConverterTest {
val expectedResult = Message.builder() val expectedResult = Message.builder()
.setToken(pushTokenMessage.token) .setToken(pushTokenMessage.token)
.setNotification(notificationConverter(notification)) .setNotification(pushMessageNotificationConverter(pushMessageNotification))
.putAllData(pushTokenMessage.data) .putAllData(pushTokenMessage.data)
.setupApns() .setupApns()
.setupAndroid() .setupAndroid()
@ -65,7 +65,7 @@ class PushTokenMessageConverterTest {
fun invoke_withoutNotification() { fun invoke_withoutNotification() {
val pushTokenMessage = PushTokenMessage( val pushTokenMessage = PushTokenMessage(
token = "token", token = "token",
notification = null, pushMessageNotification = null,
data = mapOf("testKey" to "testvalue") data = mapOf("testKey" to "testvalue")
) )

View File

@ -10,7 +10,7 @@ import org.springframework.boot.test.mock.mockito.MockBean
import ru.touchin.push.message.provider.dto.request.PushTokenCheck import ru.touchin.push.message.provider.dto.request.PushTokenCheck
import ru.touchin.push.message.provider.dto.request.PushTokenMessage import ru.touchin.push.message.provider.dto.request.PushTokenMessage
import ru.touchin.push.message.provider.dto.result.CheckPushTokenResult import ru.touchin.push.message.provider.dto.result.CheckPushTokenResult
import ru.touchin.push.message.provider.dto.result.SendPushTokenMessageResult import ru.touchin.push.message.provider.dto.result.SendPushTokenMessageTraceableResult
import ru.touchin.push.message.provider.enums.PushTokenStatus import ru.touchin.push.message.provider.enums.PushTokenStatus
import ru.touchin.push.message.provider.fcm.clients.FcmClient import ru.touchin.push.message.provider.fcm.clients.FcmClient
import ru.touchin.push.message.provider.services.PushMessageProviderService import ru.touchin.push.message.provider.services.PushMessageProviderService
@ -29,11 +29,11 @@ class PushMessageProviderFcmServiceTest {
fun send_basic() { fun send_basic() {
val request = PushTokenMessage( val request = PushTokenMessage(
token = "testToken", token = "testToken",
notification = null, pushMessageNotification = null,
data = emptyMap() data = emptyMap()
) )
val expectedResult = SendPushTokenMessageResult("testMessageId") val expectedResult = SendPushTokenMessageTraceableResult("testMessageId")
Mockito.`when`( Mockito.`when`(
fcmClient.sendPushTokenMessage(request) fcmClient.sendPushTokenMessage(request)

View File

@ -0,0 +1,21 @@
plugins {
id("kotlin")
id("kotlin-spring")
id("maven-publish")
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation(project(":logger-spring"))
implementation(project(":common-spring-web"))
implementation(project(":push-message-provider"))
testImplementation(project(":logger-spring-web"))
testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.testcontainers:junit-jupiter")
}

View File

@ -0,0 +1,7 @@
package ru.touchin.push.message.provider.hpk
import org.springframework.context.annotation.Import
import ru.touchin.push.message.provider.hpk.configurations.PushMessageProviderHpkConfiguration
@Import(value = [PushMessageProviderHpkConfiguration::class])
annotation class EnablePushMessageProviderHpk

View File

@ -0,0 +1,3 @@
package ru.touchin.push.message.provider.hpk.base.builders
internal interface Buildable

View File

@ -0,0 +1,42 @@
package ru.touchin.push.message.provider.hpk.base.clients
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.web.reactive.function.client.ClientResponse
import ru.touchin.push.message.provider.hpk.base.clients.dto.ConditionalResponse
internal open class ConditionalWebClientParser(
private val objectMapper: ObjectMapper,
) {
open fun isOkResponse(clientResponse: ClientResponse): Boolean {
return clientResponse.statusCode().is2xxSuccessful
}
@Throws(Exception::class)
inline fun <reified S, reified F> parse(
clientResponse: ClientResponse,
body: String,
): ConditionalResponse<S, F> {
return if (isOkResponse(clientResponse)) {
ConditionalResponse<S, F>(
success = parseValue(body, S::class.java),
failure = null
)
} else {
ConditionalResponse(
success = null,
failure = parseValue(body, F::class.java)
)
}
}
private fun <T> parseValue(source: String?, clazz: Class<T>): T {
return if (clazz.canonicalName != String::class.java.canonicalName) {
objectMapper.readValue(source, clazz)
} else {
@Suppress("UNCHECKED_CAST")
source as T // T is String
}
}
}

View File

@ -0,0 +1,85 @@
package ru.touchin.push.message.provider.hpk.base.clients
import io.netty.channel.ChannelOption
import io.netty.handler.ssl.SslContextBuilder
import io.netty.handler.timeout.ReadTimeoutHandler
import io.netty.handler.timeout.WriteTimeoutHandler
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.web.reactive.function.client.ClientResponse
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import reactor.netty.http.client.HttpClient
import ru.touchin.common.spring.web.webclient.BaseLogWebClient
import ru.touchin.common.spring.web.webclient.dto.RequestLogData
import ru.touchin.common.spring.web.webclient.logger.WebClientLogger
import ru.touchin.push.message.provider.hpk.base.clients.dto.ConditionalResponse
import ru.touchin.push.message.provider.hpk.properties.HpkProperties
import java.util.concurrent.TimeUnit
abstract class ConfigurableWebClient(
webClientLogger: WebClientLogger,
webClientBuilder: WebClient.Builder,
protected val webService: HpkProperties.WebService,
) : BaseLogWebClient(webClientLogger, webClientBuilder) {
private val conditionalWebClientParser: Lazy<ConditionalWebClientParser> = lazy {
ConditionalWebClientParser(
objectMapper = getObjectMapper(),
)
}
protected fun WebClient.Builder.setTimeouts(): WebClient.Builder {
val httpClient: HttpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, webService.http.connectionTimeout.toMillis().toInt())
.doOnConnected { setup ->
setup.addHandlerLast(ReadTimeoutHandler(webService.http.readTimeout.toMillis(), TimeUnit.MILLISECONDS))
setup.addHandlerLast(WriteTimeoutHandler(webService.http.writeTimeout.toMillis(), TimeUnit.MILLISECONDS))
}
.let { httpClient ->
webService.ssl?.let { ssl ->
httpClient.secure { builder ->
builder
.sslContext(SslContextBuilder.forClient().build())
.handshakeTimeout(ssl.handshakeTimeout)
.closeNotifyFlushTimeout(ssl.notifyFlushTimeout)
.closeNotifyReadTimeout(ssl.notifyReadTimeout)
}
} ?: httpClient
}
return clientConnector(ReactorClientHttpConnector(httpClient))
}
internal inline fun <reified S, reified F> WebClient.RequestHeadersSpec<*>.exchangeWithWrap(
requestLogData: RequestLogData,
): Mono<ConditionalResponse<S, F>> {
return exchangeToMono { clientResponse ->
parse<S, F>(clientResponse)
}.doOnNext { responseWrapper ->
getLogger().log(
requestLogData.copy(
responseBody = responseWrapper.success ?: responseWrapper.failure
)
)
}
}
internal inline fun <reified S, reified F> parse(
clientResponse: ClientResponse,
): Mono<ConditionalResponse<S, F>> {
val responseBody = clientResponse
.bodyToMono(String::class.java)
.defaultIfEmpty(String())
.publishOn(Schedulers.parallel())
return responseBody
.map { body ->
conditionalWebClientParser.value.parse(
clientResponse = clientResponse,
body = body,
)
}
}
}

View File

@ -0,0 +1,15 @@
package ru.touchin.push.message.provider.hpk.base.clients.dto
internal open class ConditionalResponse<S, F>(
val success: S?,
val failure: F?,
) {
init {
// Only one value should be present
check((success == null) != (failure == null))
}
val isSuccess: Boolean = success != null
}

View File

@ -0,0 +1,12 @@
package ru.touchin.push.message.provider.hpk.base.enums
import com.fasterxml.jackson.annotation.JsonValue
internal interface ValueableSerializableEnum<T> {
val value: T
@JsonValue
fun toValue(): T = value
}

View File

@ -0,0 +1,7 @@
package ru.touchin.push.message.provider.hpk.base.extensions
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
fun <V, B : Buildable> B.ifNotNull(value: V?, setter: B.(V) -> B): B {
return value?.let { setter(it) } ?: this
}

View File

@ -0,0 +1,54 @@
package ru.touchin.push.message.provider.hpk.clients.hms.enums
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class HmsResponseCode(
override val value: Int,
val description: String,
) : ValueableSerializableEnum<Int> {
UNKNOWN(-1, "Unknown"),
INVALID_CLIENT_SECRET(1101, "Invalid client_secret: app or server has mismatching credentials"),
SUCCESS(80000000, "Success"),
SOME_TOKENS_ARE_INVALID(80100000, "Some tokens are right, the others are illegal"),
PARAMETERS_ARE_INVALID(80100001, "Parameters check error"),
PUSH_TOKEN_NOT_SPECIFIED(80100002, "Token number should be one when send sys message"),
INCORRECT_MESSAGE_STRUCTURE(80100003, "Incorrect message structure"),
TTL_IS_INVALID(80100004, "TTL is less than current time, please check"),
COLLAPSE_KEY_IS_INVALID(80100013, "Collapse_key is illegal, please check"),
MESSAGE_DATA_IS_VULNERABLE(80100016, "Message contains sensitive information, please check"),
TOPIC_AMOUNT_EXCEEDED(80100017, "A maximum of 100 topic-based messages can be sent at the same time"),
INVALID_MESSAGE_BODY(80100018, "Invalid message body"),
OAUTH_AUTHENTICATION_ERROR(80200001, "Oauth authentication error"),
OAUTH_TOKEN_EXPIRED(80200003, "Oauth Token expired"),
PERMISSION_DENIED(80300002, "There is no permission to send a message to a specified device"),
INVALID_TOKEN(80300007, "The specified token is invalid"),
MESSAGE_SIZE_EXCEEDED(80300008, "The message body size exceeds the default value set by the system (4K)"),
TOKEN_AMOUNT_EXCEEDED(80300010, "Tokens exceed the default value"),
MESSAGE_PERMISSION_DENIED(80300011, "No permission to send high-level notification messages"),
OAUTH_SERVER_ERROR(80600003, "Request OAuth service failed"),
INTERNAL_SERVER_ERROR(81000001, "System inner error"),
GROUP_ERROR(82000001, "GroupKey or groupName error"),
GROUP_MISMATCH(82000002, "GroupKey and groupName do not match"),
INVALID_TOKEN_ARRAY(82000003, "Token array is null"),
GROUP_NOT_EXIST(82000004, "Group do not exist"),
GROUP_APP_MISMATCH(82000005, "Group do not belong to this app"),
INVALID_TOKEN_ARRAY_OR_GROUP(82000006, "Token array or group number is transfinited"),
INVALID_TOPIC(82000007, "Invalid topic"),
TOKEN_AMOUNT_IS_NULL_OR_EXCEEDED(82000008, "Token array null or transfinited"),
TOO_MANY_TOPICS(82000009, "Topic amount exceeded: at most 2000"),
SOME_TOKENS_ARE_INCORRECT(82000010, "Some tokens are incorrect"),
TOKEN_IS_NULL(82000011, "Token is null"),
DATA_LOCATION_NOT_SPECIFIED(82000012, "Data storage location is not selected"),
DATA_LOCATION_MISMATCH(82000013, "Data storage location does not match the actual data");
companion object {
fun fromCode(code: String): HmsResponseCode {
return values().find { it.value.toString() == code }
?: UNKNOWN
}
}
}

View File

@ -0,0 +1,71 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.BodyInserters
import org.springframework.web.reactive.function.client.WebClient
import ru.touchin.common.spring.web.webclient.dto.RequestLogData
import ru.touchin.common.spring.web.webclient.logger.WebClientLogger
import ru.touchin.push.message.provider.hpk.base.clients.ConfigurableWebClient
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.bodies.HmsHpkMessagesSendBody
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.requests.HmsHpkMessagesSendRequest
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.responses.HmsHpkResponse
import ru.touchin.push.message.provider.hpk.properties.HpkProperties
/**
* Client for Huawei Push Kit.
* @see <a href="https://developer.huawei.com/consumer/en/doc/development/HMSCore-References/https-send-api-0000001050986197">Documentation</a>
*/
@Component
class HmsHpkWebClient(
webClientLogger: WebClientLogger,
@Qualifier("push-message-provider.hpk.hms-hpk-webclient-builder")
webClientBuilder: WebClient.Builder,
private val hpkProperties: HpkProperties,
@Qualifier("push-message-provider.hpk.webclient-objectmapper")
private val objectMapper: ObjectMapper,
) : ConfigurableWebClient(webClientLogger, webClientBuilder, hpkProperties.webServices.hpk) {
override fun getObjectMapper(): ObjectMapper = objectMapper
override fun getWebClient(): WebClient {
return getWebClientBuilder(
url = webService.url.toString(),
)
.setTimeouts()
.build()
}
internal fun messagesSend(hmsHpkMessagesSendRequest: HmsHpkMessagesSendRequest): HmsHpkResponse {
val url = "${hpkProperties.webServices.clientId}/$METHOD_MESSAGES_SEND"
return getWebClient().post()
.uri { builder ->
builder
.path(url)
.build()
}
.contentType(MediaType.APPLICATION_JSON)
.headers { it.setBearerAuth(hmsHpkMessagesSendRequest.accessToken) }
.body(BodyInserters.fromValue(hmsHpkMessagesSendRequest.hmsHpkMessagesSendBody))
.exchange(
clazz = HmsHpkResponse::class.java,
requestLogData = RequestLogData(
uri = url,
logTags = listOf(),
method = HttpMethod.POST,
)
)
.block() ?: throw IllegalStateException("No response")
}
private companion object {
const val METHOD_MESSAGES_SEND = "messages:send"
}
}

View File

@ -0,0 +1,12 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.bodies
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.Message
internal class HmsHpkMessagesSendBody(
/** Send "dry" message without notification delivery */
val validateOnly: Boolean,
/** Message structure, which must contain the valid message payload and valid sending object. */
@JsonProperty("message")
val message: Message,
)

View File

@ -0,0 +1,163 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android.AndroidNotificationConfig
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidFastAppTargetType
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidUrgency
internal data class AndroidConfig private constructor(
/**
* Mode for the Push Kit server to cache messages sent to an offline device.
* These cached messages will be delivered once the device goes online again.
* The options are as follows:
*
* 0: Only the latest message sent by each app to the user device is cached.
*
* -1: All messages are cached.
*
* 1-100: message cache group ID. Messages are cached by group. Each group can cache only one message for each app.
*
* For example, if you send 10 messages and set collapse_key to 1 for the first five messages and to 2 for the rest, the latest message whose value of collapse_key is 1 and the latest message whose value of collapse_key is 2 are sent to the user after the user's device goes online.
*
* The default value is -1.
* */
val collapseKey: Short?,
@JsonProperty("urgency")
val androidUrgency: AndroidUrgency?,
val category: String?,
/**
* Message cache duration, in seconds.
* When a user device is offline, the Push Kit server caches messages.
* If the user device goes online within the message cache time, the cached messages are delivered.
* Otherwise, the messages are discarded.
* */
val ttl: String?,
/**
* Tag of a message in a batch delivery task.
* The tag is returned to your server when Push Kit sends the message receipt.
* */
val biTag: String?,
/** State of a mini program when a quick app sends a data message. Default is [AndroidFastAppTargetType.PRODUCTION]*/
@JsonProperty("fast_app_target")
val androidFastAppTargetType: AndroidFastAppTargetType?,
/** Custom message payload. If the data parameter is set, the value of the [Message.data] field is overwritten. */
val data: String?,
@JsonProperty("notification")
val androidNotificationConfig: AndroidNotificationConfig?,
/** Unique receipt ID that associates with the receipt URL and configuration of the downlink message. */
val receiptId: String?,
) {
class Validator {
fun check(androidConfig: AndroidConfig, notification: Notification?) {
with(androidConfig) {
if (collapseKey != null) {
require(
collapseKey in COLLAPSE_KEY_RANGE_CONSTRAINT
)
{ "Collapse Key must be in $COLLAPSE_KEY_MIN_VALUE and $COLLAPSE_KEY_MAX_VALUE" }
}
if (ttl != null) {
require(ttl.matches(TTL_PATTERN)) { "The TTL's format is wrong" }
}
if (androidNotificationConfig != null) {
AndroidNotificationConfig.validator.check(androidNotificationConfig, notification)
}
}
}
private companion object {
const val COLLAPSE_KEY_MIN_VALUE: Byte = -1
const val COLLAPSE_KEY_MAX_VALUE: Byte = 100
val COLLAPSE_KEY_RANGE_CONSTRAINT: IntRange = COLLAPSE_KEY_MIN_VALUE..COLLAPSE_KEY_MAX_VALUE
val TTL_PATTERN: Regex = Regex("\\d+|\\d+[sS]|\\d+.\\d{1,9}|\\d+.\\d{1,9}[sS]")
}
}
class Builder : Buildable {
private var collapseKey: Short? = null
private var androidUrgency: AndroidUrgency? = null
private var category: String? = null
private var ttl: String? = null
private var biTag: String? = null
private var androidFastAppTargetType: AndroidFastAppTargetType? = null
private var data: String? = null
private var androidNotificationConfig: AndroidNotificationConfig? = null
private var receiptId: String? = null
fun setCollapseKey(collapseKey: Short): Builder {
this.collapseKey = collapseKey
return this
}
fun setUrgency(androidUrgency: AndroidUrgency): Builder {
this.androidUrgency = androidUrgency
return this
}
fun setCategory(category: String?): Builder {
this.category = category
return this
}
fun setTtl(ttl: String): Builder {
this.ttl = ttl
return this
}
fun setBiTag(biTag: String): Builder {
this.biTag = biTag
return this
}
fun setFastAppTargetType(androidFastAppTargetType: AndroidFastAppTargetType): Builder {
this.androidFastAppTargetType = androidFastAppTargetType
return this
}
fun setData(data: String): Builder {
this.data = data
return this
}
fun setAndroidNotificationConfig(androidNotificationConfig: AndroidNotificationConfig): Builder {
this.androidNotificationConfig = androidNotificationConfig
return this
}
fun setReceiptId(receiptId: String): Builder {
this.receiptId = receiptId
return this
}
fun build(): AndroidConfig {
return AndroidConfig(
collapseKey = collapseKey,
androidUrgency = androidUrgency,
category = category,
ttl = ttl,
biTag = biTag,
androidFastAppTargetType = androidFastAppTargetType,
data = data,
androidNotificationConfig = androidNotificationConfig,
receiptId = receiptId,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,76 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.apns.ApnsHeaders
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.apns.ApnsHmsOptions
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.apns.Aps
internal data class ApnsConfig private constructor(
@JsonProperty("hms_options")
val apnsHmsOptions: ApnsHmsOptions?,
@JsonProperty("apns_headers")
val apnsHeaders: ApnsHeaders,
val payload: Map<String, Any>,
) {
class Validator {
fun check(apnsConfig: ApnsConfig) {
with(apnsConfig) {
apnsHmsOptions?.also { ApnsHmsOptions.validator.check(it) }
ApnsHeaders.validator.check(apnsHeaders)
if (payload[APS_PAYLOAD_KEY] != null) {
val aps: Aps? = payload[APS_PAYLOAD_KEY] as Aps?
aps?.also { Aps.validator.check(it) }
}
}
}
}
class Builder : Buildable {
private var apnsHmsOptions: ApnsHmsOptions? = null
private var payload: MutableMap<String, Any> = mutableMapOf()
private var aps: Aps? = null
fun setApnsHmsOptions(apnsHmsOptions: ApnsHmsOptions): Builder {
this.apnsHmsOptions = apnsHmsOptions
return this
}
fun addPayload(payload: Map<String, Any>): Builder {
this.payload.putAll(payload)
return this
}
fun setAps(aps: Aps): Builder {
this.aps = aps
payload[APS_PAYLOAD_KEY] = aps
return this
}
fun build(apnsHeaders: ApnsHeaders): ApnsConfig {
return ApnsConfig(
apnsHmsOptions = apnsHmsOptions,
apnsHeaders = apnsHeaders,
payload = payload
)
}
}
companion object {
const val APS_PAYLOAD_KEY = "aps"
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,136 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
internal data class Message private constructor(
/** Custom message payload. Map of key-values */
val data: String?,
/** Notification message content. */
@JsonProperty("notification")
val notification: Notification?,
/** Android message push control. */
@JsonProperty("android")
val android: AndroidConfig?,
@JsonProperty("apns")
val apns: ApnsConfig?,
@JsonProperty("webpush")
val webpush: WebPushConfig?,
/** Push token of the target user of a message. */
val token: Collection<String>?,
/** Topic subscribed by the target user of a message. */
val topic: String?,
val condition: String?,
) {
class Validator {
fun check(message: Message) {
with(message) {
require(
arrayOf(
!token.isNullOrEmpty(),
!topic.isNullOrBlank(),
!condition.isNullOrBlank(),
).count { it } == MAX_TYPES_OF_DELIVERY
) { "Exactly one of token, topic or condition must be specified" }
if (token != null) {
require(
token.size in TOKENS_SIZE_RANGE_CONSTRAINT
) { "Number of tokens, if specified, must be from $TOKENS_SIZE_MIN to $TOKENS_SIZE_MAX" }
}
notification?.also { Notification.validator.check(it) }
android?.also { AndroidConfig.validator.check(it, notification) }
apns?.also { ApnsConfig.validator.check(it) }
webpush?.also { WebPushConfig.validator.check(it) }
}
}
private companion object {
const val MAX_TYPES_OF_DELIVERY: Int = 1
const val TOKENS_SIZE_MIN: Byte = 1
const val TOKENS_SIZE_MAX: Short = 1000
val TOKENS_SIZE_RANGE_CONSTRAINT: IntRange = TOKENS_SIZE_MIN..TOKENS_SIZE_MAX
}
}
class Builder : Buildable {
private var data: String? = null
private var notification: Notification? = null
private var androidConfig: AndroidConfig? = null
private var apns: ApnsConfig? = null
private var webpush: WebPushConfig? = null
private val token: MutableList<String> = mutableListOf()
private var topic: String? = null
private var condition: String? = null
fun setData(data: String): Builder {
this.data = data
return this
}
fun setNotification(notification: Notification): Builder {
this.notification = notification
return this
}
fun setAndroidConfig(androidConfig: AndroidConfig): Builder {
this.androidConfig = androidConfig
return this
}
fun setApns(apns: ApnsConfig): Builder {
this.apns = apns
return this
}
fun setWebpush(webpush: WebPushConfig): Builder {
this.webpush = webpush
return this
}
fun addToken(vararg token: String): Builder {
this.token.addAll(token)
return this
}
fun setTopic(topic: String): Builder {
this.topic = topic
return this
}
fun setCondition(condition: String): Builder {
this.condition = condition
return this
}
fun build(): Message {
return Message(
data = data,
notification = notification,
android = androidConfig,
apns = apns,
webpush = webpush,
topic = topic,
condition = condition,
token = token.takeIf(Collection<*>::isNotEmpty),
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,72 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
internal data class Notification private constructor(
/** Title for notification. Must be specified here or in [AndroidNotificationConfig.title] */
val title: String?,
/** Text body for notification. Must be specified here or in [AndroidNotificationConfig.body] */
val body: String?,
/** Url of image */
val image: String?,
) {
class Validator {
fun check(notification: Notification) {
with(notification) {
if (image != null) {
require(
image.startsWith(HTTPS_URL_PATTERN)
) { "image's url should start with $HTTPS_URL_PATTERN" }
}
}
}
private companion object {
const val HTTPS_URL_PATTERN: String = "https"
}
}
class Builder : Buildable {
private var title: String? = null
private var body: String? = null
private var image: String? = null
fun setTitle(title: String): Builder {
this.title = title
return this
}
fun setBody(body: String): Builder {
this.body = body
return this
}
fun setImage(image: String): Builder {
this.image = image
return this
}
fun build(): Notification {
return Notification(
title = title,
body = body,
image = image
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,77 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.web.WebHmsOptions
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.web.WebNotification
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.web.WebPushHeaders
internal data class WebPushConfig private constructor(
@JsonProperty("headers")
val webPushHeaders: WebPushHeaders?,
val data: String?,
@JsonProperty("notification")
val webNotification: WebNotification?,
@JsonProperty("hms_options")
val webHmsOptions: WebHmsOptions?,
) {
class Validator {
fun check(webPushConfig: WebPushConfig) {
with(webPushConfig) {
webPushHeaders?.let { WebPushHeaders.validator.check(it) }
webNotification?.let { WebNotification.validator.check(it) }
webHmsOptions?.let { WebHmsOptions.validator.check(it) }
}
}
}
class Builder : Buildable {
private var headers: WebPushHeaders? = null
private var data: String? = null
private var notification: WebNotification? = null
private var webHmsOptions: WebHmsOptions? = null
fun setHeaders(webPushHeaders: WebPushHeaders): Builder {
this.headers = webPushHeaders
return this
}
fun setData(data: String): Builder {
this.data = data
return this
}
fun setNotification(webNotification: WebNotification): Builder {
this.notification = notification
return this
}
fun setWebHmsOptions(webHmsOptions: WebHmsOptions): Builder {
this.webHmsOptions
return this
}
fun build(): WebPushConfig {
return WebPushConfig(
webPushHeaders = headers,
data = data,
webNotification = notification,
webHmsOptions = webHmsOptions,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,78 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
internal data class AndroidBadgeNotification private constructor(
/** Accumulative badge number. */
val addNum: Short?,
/** Full path of the app entry activity class. */
@JsonProperty("class")
val clazz: String,
/** Badge number. Overrides [addNum]. */
val setNum: Short?,
) {
class Validator {
fun check(androidBadgeNotification: AndroidBadgeNotification) {
with(androidBadgeNotification) {
if (addNum != null) {
require(
addNum in ADD_NUM_MIN_VALUE..ADD_NUM_MAX_VALUE
) { "add_num must locate in $ADD_NUM_MIN_VALUE and $ADD_NUM_MAX_VALUE" }
}
if (setNum != null) {
require(
setNum in SET_NUM_RANGE_CONSTRAINT
) { "set_num must locate between $SET_NUM_MIN_VALUE and $SET_NUM_MAX_VALUE" }
}
}
}
private companion object {
const val ADD_NUM_MIN_VALUE: Byte = 1
const val ADD_NUM_MAX_VALUE: Byte = 99
val ADD_NUM_RANGE_CONSTRAINT: IntRange = ADD_NUM_MIN_VALUE..ADD_NUM_MAX_VALUE
const val SET_NUM_MIN_VALUE: Byte = 0
const val SET_NUM_MAX_VALUE: Byte = 99
val SET_NUM_RANGE_CONSTRAINT: IntRange = SET_NUM_MIN_VALUE..SET_NUM_MAX_VALUE
}
}
class Builder : Buildable {
private var addNum: Short? = null
private var setNum: Short? = null
fun setAddNum(addNum: Short): Builder {
this.addNum = addNum
return this
}
fun setSetNum(setNum: Short): Builder {
this.setNum = setNum
return this
}
fun build(badgeClass: String): AndroidBadgeNotification {
return AndroidBadgeNotification(
addNum = addNum,
clazz = badgeClass,
setNum = setNum,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,80 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidActionType
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidIntentType
internal data class AndroidButton private constructor(
/** Button name. */
val name: String,
/** Button action. */
@JsonProperty("action_type")
val androidActionType: AndroidActionType,
/** Method of opening a custom app page. */
@JsonProperty("intent_type")
val androidIntentType: AndroidIntentType?,
val intent: String?,
/** Map of key-values. */
val data: String?,
) {
class Validator {
fun check(androidButton: AndroidButton) {
with(androidButton) {
require(
name.length <= NAME_MAX_LENGTH
) { "Button name length cannot exceed $NAME_MAX_LENGTH" }
if (androidActionType == AndroidActionType.SHARE_NOTIFICATION_MESSAGE) {
require(!data.isNullOrEmpty()) { "Data is needed when actionType is $androidActionType" }
require(data.length <= DATA_MAX_LENGTH) { "Data length cannot exceed $DATA_MAX_LENGTH chars" }
}
}
}
private companion object {
const val NAME_MAX_LENGTH: Byte = 40
const val DATA_MAX_LENGTH: Short = 1024
}
}
class Builder : Buildable {
private var intent: String? = null
private var data: String? = null
fun setIntent(intent: String): Builder {
this.intent = intent
return this
}
fun setData(data: String): Builder {
this.data = data
return this
}
fun build(name: String, androidActionType: AndroidActionType, androidIntentType: AndroidIntentType): AndroidButton {
return AndroidButton(
name = name,
androidActionType = androidActionType,
androidIntentType = androidIntentType,
intent = intent,
data = data
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,93 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidClickActionType
internal data class AndroidClickAction private constructor(
/** Message tapping action type. */
@JsonProperty("type")
val androidClickActionType: AndroidClickActionType,
val intent: String?,
/** URL to be opened. */
val url: String?,
/** Action corresponding to the activity of the page to be opened when the custom app page is opened through the action. */
val action: String?,
) {
class Validator {
fun check(androidClickAction: AndroidClickAction) {
with(androidClickAction) {
when (androidClickActionType) {
AndroidClickActionType.CUSTOMIZE_ACTION -> require(
!intent.isNullOrBlank() || !action.isNullOrBlank()
) { "intent or action is required when click type is $androidClickActionType" }
AndroidClickActionType.OPEN_URL -> {
require(!url.isNullOrBlank()) { "url is required when click type is $androidClickActionType" }
require(url.startsWith(HTTPS_PATTERN_START)) { "url must start with $HTTPS_PATTERN_START" }
}
AndroidClickActionType.OPEN_APP -> {
// no verification
}
}
}
}
private companion object {
const val HTTPS_PATTERN_START = "https"
}
}
class Builder : Buildable {
private var intent: String? = null
private var url: String? = null
private var richResource: String? = null
private var action: String? = null
fun setIntent(intent: String): Builder {
this.intent = intent
return this
}
fun setUrl(url: String): Builder {
this.url = url
return this
}
fun setRichResource(richResource: String): Builder {
this.richResource = richResource
return this
}
fun setAction(action: String): Builder {
this.action = action
return this
}
fun build(androidClickActionType: AndroidClickActionType): AndroidClickAction {
return AndroidClickAction(
androidClickActionType = androidClickActionType,
intent = intent.takeIf { androidClickActionType == AndroidClickActionType.CUSTOMIZE_ACTION },
action = action.takeIf { androidClickActionType == AndroidClickActionType.CUSTOMIZE_ACTION },
url = url.takeIf { androidClickActionType == AndroidClickActionType.OPEN_URL },
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,82 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
internal data class AndroidColor private constructor(
/** Alpha setting of the RGB color.*/
val alpha: Float,
/** Red setting of the RGB color. */
val red: Float,
/** Green setting of the RGB color. */
val green: Float,
/** Green setting of the RGB color. */
val blue: Float,
) {
class Validator {
fun check(androidColor: AndroidColor) {
with(androidColor) {
require(alpha in COLOR_RANGE_CONSTRAINT) { "Alpha must be locate between [0,1]" }
require(red in COLOR_RANGE_CONSTRAINT) { "Red must be locate between [0,1]" }
require(green in COLOR_RANGE_CONSTRAINT) { "Green must be locate between [0,1]" }
require(blue in COLOR_RANGE_CONSTRAINT) { "Blue must be locate between [0,1]" }
}
}
private companion object {
private const val ZERO: Float = 0.0f
private const val ONE: Float = 1.0f
val COLOR_RANGE_CONSTRAINT = ZERO..ONE
}
}
class Builder : Buildable {
private var alpha: Float = 1.0f
private var red: Float = 0.0f
private var green: Float = 0.0f
private var blue: Float = 0.0f
fun setAlpha(alpha: Float): Builder {
this.alpha = alpha
return this
}
fun setRed(red: Float): Builder {
this.red = red
return this
}
fun setGreen(green: Float): Builder {
this.green = green
return this
}
fun setBlue(blue: Float): Builder {
this.blue = blue
return this
}
fun build(): AndroidColor {
return AndroidColor(
alpha = alpha,
red = red,
green = green,
blue = blue
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,63 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
internal data class AndroidLightSettings private constructor(
/** Breathing light color. */
@JsonProperty("color")
val androidColor: AndroidColor,
/** Interval when a breathing light is on */
val lightOnDuration: String,
/** Interval when a breathing light is off */
val lightOffDuration: String,
) {
class Validator {
fun check(androidLightSettings: AndroidLightSettings) {
with(androidLightSettings) {
AndroidColor.validator.check(androidColor)
require(
lightOnDuration.matches(LIGHT_DURATION_PATTERN)
) { "light_on_duration pattern is wrong" }
require(
lightOffDuration.matches(LIGHT_DURATION_PATTERN)
) { "light_off_duration pattern is wrong" }
}
}
private companion object {
val LIGHT_DURATION_PATTERN: Regex = Regex("\\d+|\\d+[sS]|\\d+.\\d{1,9}|\\d+.\\d{1,9}[sS]")
}
}
class Builder : Buildable {
fun build(
color: AndroidColor,
lightOnDuration: String,
lightOffDuration: String
): AndroidLightSettings {
return AndroidLightSettings(
androidColor = color,
lightOnDuration = lightOnDuration,
lightOffDuration = lightOffDuration,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,417 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.Notification
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidImportance
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidStyleType
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidVisibility
internal data class AndroidNotificationConfig private constructor(
/**
* Title of an Android notification message.
* If the title parameter is set, the value of the [Notification.title] field is overwritten.
* */
val title: String?,
/**
* Body of an Android notification message.
* If the body parameter is set, the value of the [Notification.body] field is overwritten.
* */
val body: String?,
/**
* Custom app icon on the left of a notification message.
*/
val icon: String?,
/** Custom notification bar button color. */
val color: String?,
val sound: String?,
/** Indicates whether to use the default ringtone. */
val defaultSound: Boolean,
/**
* Message tag.
* Messages that use the same message tag in the same app will be overwritten by the latest message.
* */
val tag: String?,
@JsonProperty("click_action")
val androidClickAction: AndroidClickAction?,
val bodyLocKey: String?,
val bodyLocArgs: Collection<String>?,
val titleLocKey: String?,
val titleLocArgs: Collection<String>?,
val multiLangKey: Map<String, String>?,
/** Custom channel for displaying notification messages. */
val channelId: String?,
/** Brief description of a notification message to an Android app. */
val notifySummary: String?,
/** URL of the custom small image on the right of a notification message. */
val image: String?,
/** Notification bar style. */
@JsonProperty("style")
val androidStyleType: AndroidStyleType?,
/** Android notification message title in large text style. */
val bigTitle: String?,
val bigBody: String?,
/**
* Unique notification ID of a message.
* If a message does not contain the ID or the ID is -1, the NC will generate a unique ID for the message.
* Different notification messages can use the same notification ID, so that new messages can overwrite old messages.
* */
val notifyId: Int?,
/**
* Message group.
* For example, if 10 messages that contain the same value of group are sent to a device,
* the device displays only the latest message and the total number of messages received in the group,
* but does not display these 10 messages.
*/
val group: String?,
@JsonProperty("badge")
val androidBadgeNotification: AndroidBadgeNotification? = null,
val autoCancel: Boolean,
/**
* Time when Android notification messages are delivered, in the UTC timestamp format.
* If you send multiple messages at the same time,
* they will be sorted based on this value and displayed in the Android notification panel.
* Example: 2014-10-02T15:01:23.045123456Z
*/
@JsonProperty("when")
val sendAt: String?,
val localOnly: Boolean? = null,
/**
* Android notification message priority, which determines the message notification behavior of a user device.
*/
@JsonProperty("importance")
val androidImportance: AndroidImportance?,
/** Indicates whether to use the default vibration mode. */
val useDefaultVibrate: Boolean,
/** Indicates whether to use the default breathing light. */
val useDefaultLight: Boolean,
val vibrateConfig: Collection<String>?,
/** Android notification message visibility. */
@JsonProperty("visibility")
val androidVisibility: AndroidVisibility?,
@JsonProperty("light_settings")
val androidLightSettings: AndroidLightSettings?,
/**
* Indicates whether to display notification messages in the NC when your app is running in the foreground.
* If this parameter is not set, the default value true will be used,
* indicating that notification messages will be displayed in the NC when your app runs in the foreground.
* */
val foregroundShow: Boolean,
val inboxContent: Collection<String>?,
@JsonProperty("buttons")
val androidButtons: Collection<AndroidButton>?,
/** ID of the user-app relationship. */
val profileId: String?,
) {
class Validator {
fun check(androidNotificationConfig: AndroidNotificationConfig, notification: Notification?) {
with(androidNotificationConfig) {
androidBadgeNotification?.let { AndroidBadgeNotification.validator.check(it) }
androidLightSettings?.also { AndroidLightSettings.validator.check(it) }
androidClickAction?.also { AndroidClickAction.validator.check(it) }
require(!notification?.title.isNullOrBlank() || !title.isNullOrBlank()) { "title should be set" }
require(!notification?.body.isNullOrBlank() || !body.isNullOrBlank()) { "body should be set" }
if (!color.isNullOrBlank()) {
require(color.matches(COLOR_PATTERN)) { "Wrong color format, color must be in the form #RRGGBB" }
}
if (!image.isNullOrBlank()) {
require(image.startsWith(HTTPS_URL_PATTERN)) { "notifyIcon must start with $HTTPS_URL_PATTERN" }
}
if (androidStyleType != null) {
when (androidStyleType) {
AndroidStyleType.DEFAULT -> {
// no verification
}
AndroidStyleType.BIG_TEXT -> {
require(
!bigTitle.isNullOrBlank() && !bigBody.isNullOrBlank()
) { "title and body are required when style is $androidStyleType" }
}
AndroidStyleType.INBOX -> {
require(
!inboxContent.isNullOrEmpty()
) { "inboxContent is required when style is $androidStyleType" }
require(
inboxContent.size <= INBOX_CONTENT_MAX_ITEMS
) { "inboxContent must have at most $INBOX_CONTENT_MAX_ITEMS items" }
}
}
}
if (profileId != null) {
require(
profileId.length <= PROFILE_ID_MAX_LENGTH
) { "profileId length cannot exceed $PROFILE_ID_MAX_LENGTH characters" }
}
}
}
private companion object {
val COLOR_PATTERN: Regex = Regex("^#[0-9a-fA-F]{6}$")
const val HTTPS_URL_PATTERN: String = "https"
const val INBOX_CONTENT_MAX_ITEMS: Byte = 5
const val PROFILE_ID_MAX_LENGTH: Byte = 64
}
}
class Builder : Buildable {
private var title: String? = null
private var body: String? = null
private var icon: String? = null
private var color: String? = null
private var sound: String? = null
private var defaultSound = false
private var tag: String? = null
private var bodyLocKey: String? = null
private val bodyLocArgs: MutableList<String> = mutableListOf()
private var titleLocKey: String? = null
private val titleLocArgs: MutableList<String> = mutableListOf()
private var multiLangkey: Map<String, String>? = null
private var channelId: String? = null
private var notifySummary: String? = null
private var image: String? = null
private var androidStyleType: AndroidStyleType? = null
private var bigTitle: String? = null
private var bigBody: String? = null
private var notifyId: Int? = null
private var group: String? = null
private var androidBadgeNotification: AndroidBadgeNotification? = null
private var autoCancel = true
private var sendAt: String? = null
private var androidImportance: AndroidImportance? = null
private var useDefaultVibrate = false
private var useDefaultLight = false
private val vibrateConfig: MutableList<String> = mutableListOf()
private var androidVisibility: AndroidVisibility? = null
private var androidLightSettings: AndroidLightSettings? = null
private var foregroundShow = false
private val inboxContent: MutableList<String> = mutableListOf()
private val buttons: MutableList<AndroidButton> = mutableListOf()
private var profileId: String? = null
fun setTitle(title: String): Builder {
this.title = title
return this
}
fun setBody(body: String): Builder {
this.body = body
return this
}
fun setIcon(icon: String): Builder {
this.icon = icon
return this
}
fun setColor(color: String): Builder {
this.color = color
return this
}
fun setSound(sound: String): Builder {
this.sound = sound
return this
}
fun setDefaultSound(defaultSound: Boolean): Builder {
this.defaultSound = defaultSound
return this
}
fun setTag(tag: String): Builder {
this.tag = tag
return this
}
fun setBodyLocKey(bodyLocKey: String): Builder {
this.bodyLocKey = bodyLocKey
return this
}
fun addBodyLocArgs(vararg arg: String): Builder {
bodyLocArgs.addAll(arg)
return this
}
fun setTitleLocKey(titleLocKey: String): Builder {
this.titleLocKey = titleLocKey
return this
}
fun addTitleLocArgs(vararg args: String): Builder {
titleLocArgs.addAll(args)
return this
}
fun setMultiLangkey(multiLangkey: Map<String, String>): Builder {
this.multiLangkey = multiLangkey
return this
}
fun setChannelId(channelId: String): Builder {
this.channelId = channelId
return this
}
fun setNotifySummary(notifySummary: String): Builder {
this.notifySummary = notifySummary
return this
}
fun setImage(image: String): Builder {
this.image = image
return this
}
fun setStyle(androidStyleType: AndroidStyleType): Builder {
this.androidStyleType = androidStyleType
return this
}
fun setBigTitle(bigTitle: String): Builder {
this.bigTitle = bigTitle
return this
}
fun setBigBody(bigBody: String): Builder {
this.bigBody = bigBody
return this
}
fun setNotifyId(notifyId: Int): Builder {
this.notifyId = notifyId
return this
}
fun setGroup(group: String): Builder {
this.group = group
return this
}
fun setBadge(androidBadgeNotification: AndroidBadgeNotification): Builder {
this.androidBadgeNotification = androidBadgeNotification
return this
}
fun setAutoCancel(autoCancel: Boolean): Builder {
this.autoCancel = autoCancel
return this
}
fun sendAt(sendAt: String): Builder {
this.sendAt = sendAt
return this
}
fun setImportance(androidImportance: AndroidImportance): Builder {
this.androidImportance = androidImportance
return this
}
fun setUseDefaultVibrate(useDefaultVibrate: Boolean): Builder {
this.useDefaultVibrate = useDefaultVibrate
return this
}
fun setUseDefaultLight(useDefaultLight: Boolean): Builder {
this.useDefaultLight = useDefaultLight
return this
}
fun addVibrateConfig(vararg vibrateTimings: String): Builder {
vibrateConfig.addAll(vibrateTimings)
return this
}
fun setAndroidVisibility(androidVisibility: AndroidVisibility): Builder {
this.androidVisibility = androidVisibility
return this
}
fun setLightSettings(androidLightSettings: AndroidLightSettings): Builder {
this.androidLightSettings = androidLightSettings
return this
}
fun setForegroundShow(foregroundShow: Boolean): Builder {
this.foregroundShow = foregroundShow
return this
}
fun addInboxContent(vararg inboxContent: String): Builder {
this.inboxContent.addAll(inboxContent)
return this
}
fun addButton(vararg button: AndroidButton): Builder {
buttons.addAll(button)
return this
}
fun setProfileId(profileId: String): Builder {
this.profileId = profileId
return this
}
fun build(
androidClickAction: AndroidClickAction,
): AndroidNotificationConfig {
return AndroidNotificationConfig(
title = title,
body = body,
icon = icon,
color = color,
sound = sound,
defaultSound = defaultSound,
tag = tag,
androidClickAction = androidClickAction,
bodyLocKey = bodyLocKey,
bodyLocArgs = bodyLocArgs.takeIf(Collection<*>::isNotEmpty),
titleLocKey = titleLocKey,
titleLocArgs = titleLocArgs.takeIf(Collection<*>::isNotEmpty),
multiLangKey = multiLangkey,
channelId = channelId,
notifySummary = notifySummary,
image = image,
androidStyleType = androidStyleType,
bigTitle = bigTitle,
bigBody = bigBody,
notifyId = notifyId,
group = group,
androidBadgeNotification = androidBadgeNotification,
autoCancel = autoCancel,
sendAt = sendAt,
androidImportance = androidImportance,
useDefaultVibrate = useDefaultVibrate,
useDefaultLight = useDefaultLight,
vibrateConfig = vibrateConfig.takeIf(Collection<*>::isNotEmpty),
androidVisibility = androidVisibility,
androidLightSettings = androidLightSettings,
foregroundShow = foregroundShow,
inboxContent = inboxContent.takeIf(Collection<*>::isNotEmpty),
androidButtons = buttons.takeIf(Collection<*>::isNotEmpty),
profileId = profileId,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,107 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.apns
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy::class)
internal data class ApnsAlert private constructor(
val title: String?,
val body: String?,
val titleLocKey: String?,
val titleLocArgs: Collection<String>?,
val actionLocKey: String?,
val locKey: String?,
val locArgs: Collection<String>?,
val launchImage: String?,
) {
class Validator {
fun check(apnsAlert: ApnsAlert) {
with(apnsAlert) {
if (!locArgs.isNullOrEmpty()) {
require(!locKey.isNullOrBlank()) { "locKey is required when specifying locArgs" }
}
if (!titleLocArgs.isNullOrEmpty()) {
require(!titleLocKey.isNullOrBlank()) { "titleLocKey is required when specifying titleLocArgs" }
}
}
}
}
class Builder : Buildable {
private var title: String? = null
private var body: String? = null
private var titleLocKey: String? = null
private val titleLocArgs: MutableList<String> = mutableListOf()
private var actionLocKey: String? = null
private var locKey: String? = null
private val locArgs: MutableList<String> = mutableListOf()
private var launchImage: String? = null
fun setTitle(title: String): Builder {
this.title = title
return this
}
fun setBody(body: String): Builder {
this.body = body
return this
}
fun setTitleLocKey(titleLocKey: String): Builder {
this.titleLocKey = titleLocKey
return this
}
fun setAddTitleLocArg(vararg titleLocArg: String): Builder {
titleLocArgs.addAll(titleLocArg)
return this
}
fun setActionLocKey(actionLocKey: String): Builder {
this.actionLocKey = actionLocKey
return this
}
fun setLocKey(locKey: String): Builder {
this.locKey = locKey
return this
}
fun addAllLocArgs(vararg locArgs: String): Builder {
this.locArgs.addAll(locArgs)
return this
}
fun setLaunchImage(launchImage: String): Builder {
this.launchImage = launchImage
return this
}
fun build(): ApnsAlert {
return ApnsAlert(
title = title,
body = body,
titleLocKey = titleLocKey,
titleLocArgs = titleLocArgs.takeIf(Collection<*>::isNotEmpty),
actionLocKey = actionLocKey,
locKey = locKey,
locArgs = locArgs.takeIf(Collection<*>::isNotEmpty),
launchImage = launchImage,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,109 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.apns
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.apns.ApnsPriority
@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy::class)
internal data class ApnsHeaders private constructor(
val authorization: String?,
val apnsId: String?,
val apnsExpiration: Long?,
@JsonProperty("apns-priority")
val apnsPriority: ApnsPriority?,
val apnsTopic: String?,
val apnsCollapseId: String?,
) {
class Validator {
fun check(apnsHeaders: ApnsHeaders) {
with(apnsHeaders) {
if (authorization != null) {
require(
authorization.startsWith(AUTHORIZATION_PATTERN)
) { "authorization must start with bearer" }
}
if (apnsId != null) {
require(apnsId.matches(APN_ID_PATTERN)) { "apns-id format error" }
}
if (apnsCollapseId != null) {
require(
apnsCollapseId.toByteArray().size < APNS_COLLAPSE_ID_MAX_SIZE
) { "Number of apnsCollapseId bytes must be less than $APNS_COLLAPSE_ID_MAX_SIZE" }
}
}
}
private companion object {
const val AUTHORIZATION_PATTERN: String = "bearer"
val APN_ID_PATTERN: Regex = Regex("[0-9a-z]{8}(-[0-9a-z]{4}){3}-[0-9a-z]{12}")
const val APNS_COLLAPSE_ID_MAX_SIZE: Byte = 64
}
}
class Builder : Buildable {
private var authorization: String? = null
private var apnsId: String? = null
private var apnsExpiration: Long? = null
private var apnsPriority: ApnsPriority? = null
private var apnsTopic: String? = null
private var apnsCollapseId: String? = null
fun setAuthorization(authorization: String): Builder {
this.authorization = authorization
return this
}
fun setApnsId(apnsId: String): Builder {
this.apnsId = apnsId
return this
}
fun setApnsExpiration(apnsExpiration: Long): Builder {
this.apnsExpiration = apnsExpiration
return this
}
fun setApnsPriority(apnsPriority: ApnsPriority): Builder {
this.apnsPriority = apnsPriority
return this
}
fun setApnsTopic(apnsTopic: String): Builder {
this.apnsTopic = apnsTopic
return this
}
fun setApnsCollapseId(apnsCollapseId: String): Builder {
this.apnsCollapseId = apnsCollapseId
return this
}
fun build(): ApnsHeaders {
return ApnsHeaders(
authorization = authorization,
apnsId = apnsId,
apnsExpiration = apnsExpiration,
apnsPriority = apnsPriority,
apnsTopic = apnsTopic,
apnsCollapseId = apnsCollapseId,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,37 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.apns
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidTargetUserType
internal data class ApnsHmsOptions private constructor(
@JsonProperty("target_user_type")
val androidTargetUserType: AndroidTargetUserType,
) {
class Validator {
fun check(apnsHmsOptions: ApnsHmsOptions) {
// no validation
}
}
class Builder : Buildable {
fun build(androidTargetUserType: AndroidTargetUserType): ApnsHmsOptions {
return ApnsHmsOptions(
androidTargetUserType = androidTargetUserType,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,89 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.apns
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy::class)
internal class Aps private constructor(
@JsonProperty("alert")
val alert: ApnsAlert?,
val badge: Int?,
val sound: String?,
val contentAvailable: Int?,
val category: String?,
val threadId: String?,
) {
class Validator {
fun check(aps: Aps) {
with(aps) {
if (alert != null) {
ApnsAlert.validator.check(alert)
}
}
}
}
class Builder : Buildable {
private var alert: ApnsAlert? = null
private var badge: Int? = null
private var sound: String? = null
private var contentAvailable: Int? = null
private var category: String? = null
private var threadId: String? = null
fun setAlert(alert: ApnsAlert): Builder {
this.alert = alert
return this
}
fun setBadge(badge: Int): Builder {
this.badge = badge
return this
}
fun setSound(sound: String): Builder {
this.sound = sound
return this
}
fun setContentAvailable(contentAvailable: Int): Builder {
this.contentAvailable = contentAvailable
return this
}
fun setCategory(category: String): Builder {
this.category = category
return this
}
fun setThreadId(threadId: String): Builder {
this.threadId = threadId
return this
}
fun build(): Aps {
return Aps(
alert = alert,
badge = badge,
sound = sound,
contentAvailable = contentAvailable,
category = category,
threadId = threadId,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,58 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.web
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
internal data class WebActions private constructor(
val action: String?,
val icon: String?,
val title: String?,
) {
class Validator {
fun check() {
// no validation
}
}
class Builder : Buildable {
private var action: String? = null
private var icon: String? = null
private var title: String? = null
fun setAction(action: String): Builder {
this.action = action
return this
}
fun setIcon(icon: String): Builder {
this.icon = icon
return this
}
fun setTitle(title: String): Builder {
this.title = title
return this
}
fun build(): WebActions {
return WebActions(
action = action,
icon = icon,
title = title,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,52 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.web
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import java.net.MalformedURLException
import java.net.URL
internal data class WebHmsOptions private constructor(
val link: String?,
) {
class Validator {
fun check(webHmsOptions: WebHmsOptions) {
with(webHmsOptions) {
if (!link.isNullOrBlank()) {
try {
URL(link)
} catch (e: MalformedURLException) {
require(false) { "Invalid link" }
}
}
}
}
}
class Builder : Buildable {
private var link: String? = null
fun setLink(link: String): Builder {
this.link = link
return this
}
fun build(): WebHmsOptions {
return WebHmsOptions(
link = link,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,150 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.web
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.web.WebDir
import java.util.*
internal data class WebNotification private constructor(
val title: String?,
val body: String?,
val icon: String?,
val image: String?,
val lang: String?,
val tag: String?,
val badge: String?,
@JsonProperty("dir")
val dir: WebDir?,
val vibrate: Collection<Int>?,
val renotify: Boolean,
val requireInteraction: Boolean,
val silent: Boolean,
val timestamp: Long?,
@JsonProperty("actions")
val actions: Collection<WebActions>?,
) {
class Validator {
fun check(webNotification: WebNotification) {
// no verification required
}
}
class Builder : Buildable {
private var title: String? = null
private var body: String? = null
private var icon: String? = null
private var image: String? = null
private var lang: String? = null
private var tag: String? = null
private var badge: String? = null
private var dir: WebDir? = null
private val vibrate: MutableList<Int> = mutableListOf()
private var renotify = false
private var requireInteraction = false
private var silent = false
private var timestamp: Long? = null
private val actions: MutableList<WebActions> = mutableListOf()
fun setTitle(title: String): Builder {
this.title = title
return this
}
fun setBody(body: String): Builder {
this.body = body
return this
}
fun setIcon(icon: String): Builder {
this.icon = icon
return this
}
fun setImage(image: String): Builder {
this.image = image
return this
}
fun setLang(lang: String): Builder {
this.lang
return this
}
fun setTag(tag: String): Builder {
this.tag = tag
return this
}
fun setBadge(badge: String): Builder {
this.badge = badge
return this
}
fun setDir(dir: WebDir): Builder {
this.dir = dir
return this
}
fun addVibrate(vibrate: Int): Builder {
this.vibrate.add(vibrate)
return this
}
fun setRenotify(renotify: Boolean): Builder {
this.renotify = renotify
return this
}
fun setRequireInteraction(requireInteraction: Boolean): Builder {
this.requireInteraction = requireInteraction
return this
}
fun setSilent(silent: Boolean): Builder {
this.silent = silent
return this
}
fun setTimestamp(timestamp: Long): Builder {
this.timestamp = timestamp
return this
}
fun addActions(vararg webActions: WebActions): Builder {
this.actions.addAll(webActions)
return this
}
fun build(): WebNotification {
return WebNotification(
title = title,
body = body,
icon = icon,
image = image,
lang = lang,
tag = tag,
badge = badge,
dir = dir,
vibrate = vibrate.takeIf(Collection<*>::isNotEmpty),
renotify = renotify,
requireInteraction = requireInteraction,
silent = silent,
timestamp = timestamp,
actions = actions.takeIf(Collection<*>::isNotEmpty),
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,70 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.web
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.web.WebUrgency
internal data class WebPushHeaders private constructor(
val ttl: String?,
val topic: String?,
@JsonProperty("urgency")
val urgency: WebUrgency?,
) {
class Validator {
fun check(webpushHeaders: WebPushHeaders) {
with(webpushHeaders) {
if (ttl != null) {
require(ttl.matches(TTL_PATTERN)) { "Invalid ttl format" }
}
}
}
private companion object {
val TTL_PATTERN: Regex = Regex("[0-9]+|[0-9]+[sS]")
}
}
class Builder : Buildable {
private var ttl: String? = null
private var topic: String? = null
private var urgency: WebUrgency? = null
fun setTtl(ttl: String): Builder {
this.ttl = ttl
return this
}
fun setTopic(topic: String): Builder {
this.topic = topic
return this
}
fun setUrgency(urgency: WebUrgency): Builder {
this.urgency = urgency
return this
}
fun build(): WebPushHeaders {
return WebPushHeaders(
ttl = ttl,
topic = topic,
urgency = urgency,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,17 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidActionType(
override val value: Short
) : ValueableSerializableEnum<Short> {
OPEN_APP_HOME_PAGE(0),
OPEN_CUSTOM_APP_PAGE(1),
OPEN_WEB_PAGE(2),
DELETE_NOTIFICATION_MESSAGE(3),
/** Only for Huawei devices */
SHARE_NOTIFICATION_MESSAGE(4),
}

View File

@ -0,0 +1,13 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidClickActionType(
override val value: Short
) : ValueableSerializableEnum<Short> {
CUSTOMIZE_ACTION(1),
OPEN_URL(2),
OPEN_APP(3),
}

View File

@ -0,0 +1,12 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidFastAppTargetType(
override val value: Short
) : ValueableSerializableEnum<Short> {
DEVELOPMENT(1),
PRODUCTION(2),
}

View File

@ -0,0 +1,13 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidImportance(
override val value: String
) : ValueableSerializableEnum<String> {
LOW("LOW"),
NORMAL("NORMAL"),
HIGH("HIGH"), // TODO: check if this type is still supported by HMS HPK API
}

View File

@ -0,0 +1,12 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
enum class AndroidIntentType(
override val value: Short
) : ValueableSerializableEnum<Short> {
INTENT(0),
ACTION(1),
}

View File

@ -0,0 +1,13 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
enum class AndroidStyleType(
override val value: Short
) : ValueableSerializableEnum<Short> {
DEFAULT(0),
BIG_TEXT(1),
INBOX(3),
}

View File

@ -0,0 +1,13 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidTargetUserType(
override val value: Short
) : ValueableSerializableEnum<Short> {
TEST_USER(1),
FORMAL_USER(2),
VOIP_USER(3),
}

View File

@ -0,0 +1,13 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidTopicOperation(
override val value: String
) : ValueableSerializableEnum<String> {
SUBSCRIBE("subscribe"),
UNSUBSCRIBE("unsubscribe"),
LIST("list"),
}

View File

@ -0,0 +1,12 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidUrgency(
override val value: String
) : ValueableSerializableEnum<String> {
HIGH("HIGH"),
NORMAL("NORMAL"),
}

View File

@ -0,0 +1,24 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidVisibility(
override val value: String
) : ValueableSerializableEnum<String> {
/** The visibility is not specified. This value is equivalent to PRIVATE. */
VISIBILITY_UNSPECIFIED("VISIBILITY_UNSPECIFIED"),
/**
* If you have set a lock screen password and enabled Hide notification content under Settings > Notifications,
* the content of a received notification message is hidden on the lock screen.
* */
PRIVATE("PRIVATE"),
/** The content of a received notification message is displayed on the lock screen. */
PUBLIC("PUBLIC"),
/** A received notification message is not displayed on the lock screen. */
SECRET("SECRET"),
}

View File

@ -0,0 +1,12 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.apns
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
enum class ApnsPriority(
override val value: Short
) : ValueableSerializableEnum<Short> {
SEND_BY_GROUP(5),
SEND_IMMIDIATELY(10),
}

View File

@ -0,0 +1,13 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.web
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
enum class WebDir(
override val value: String
) : ValueableSerializableEnum<String> {
AUTO("auto"),
RTL("rtl"),
LTR("ltr"),
}

View File

@ -0,0 +1,14 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.web
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
enum class WebUrgency(
override val value: String
) : ValueableSerializableEnum<String> {
VERY_LOW("very-low"),
LOW("low"),
NORMAL("normal"),
HIGH("high"),
}

View File

@ -0,0 +1,10 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.requests
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.bodies.HmsHpkMessagesSendBody
internal class HmsHpkMessagesSendRequest(
val hmsHpkMessagesSendBody: HmsHpkMessagesSendBody,
accessToken: String,
) : HmsHpkRequest(
accessToken = accessToken,
)

View File

@ -0,0 +1,5 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.requests
internal open class HmsHpkRequest(
val accessToken: String,
)

View File

@ -0,0 +1,14 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.responses
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy::class)
internal class HmsHpkResponse(
/** Result code. */
val code: String,
/** Result code description. */
val msg: String,
/** Request ID. */
val requestId: String,
)

View File

@ -0,0 +1,78 @@
package ru.touchin.push.message.provider.hpk.clients.hms_oauth
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.BodyInserters
import org.springframework.web.reactive.function.client.WebClient
import ru.touchin.common.spring.web.webclient.dto.RequestLogData
import ru.touchin.common.spring.web.webclient.logger.WebClientLogger
import ru.touchin.push.message.provider.hpk.base.clients.ConfigurableWebClient
import ru.touchin.push.message.provider.hpk.base.clients.dto.ConditionalResponse
import ru.touchin.push.message.provider.hpk.clients.hms_oauth.response.HmsOauthErrorResponse
import ru.touchin.push.message.provider.hpk.clients.hms_oauth.response.HmsOauthTokenResponse
import ru.touchin.push.message.provider.hpk.properties.HpkProperties
/**
* Client for Huawei Oauth service.
* @see <a href="https://developer.huawei.com/consumer/en/doc/development/HMSCore-Guides/open-platform-oauth-0000001053629189">Documentation</a>
*/
@Component
class HmsOauthWebClient(
webClientLogger: WebClientLogger,
@Qualifier("push-message-provider.hpk.hms-oauth-webclient-builder")
webClientBuilder: WebClient.Builder,
private val hpkProperties: HpkProperties,
@Qualifier("push-message-provider.hpk.webclient-objectmapper")
private val objectMapper: ObjectMapper,
) : ConfigurableWebClient(webClientLogger, webClientBuilder, hpkProperties.webServices.oauth) {
override fun getObjectMapper(): ObjectMapper = objectMapper
override fun getWebClient(): WebClient {
return getWebClientBuilder(
url = webService.url.toString(),
)
.setTimeouts()
.build()
}
internal fun token(): ConditionalResponse<HmsOauthTokenResponse, HmsOauthErrorResponse> {
return getWebClient()
.post()
.uri { builder ->
builder
.path(METHOD_TOKEN)
.build()
}
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(
BodyInserters
.fromFormData(TOKEN_KEY_GRANT_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS)
.with(TOKEN_KEY_CLIENT_ID, hpkProperties.webServices.clientId)
.with(TOKEN_KEY_CLIENT_SECRET, hpkProperties.webServices.oauth.clientSecret)
)
.exchangeWithWrap<HmsOauthTokenResponse, HmsOauthErrorResponse>(
requestLogData = RequestLogData(
uri = METHOD_TOKEN,
logTags = listOf(),
method = HttpMethod.POST,
),
)
.block() ?: throw IllegalStateException("No response")
}
private companion object {
const val GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
const val METHOD_TOKEN = "token"
const val TOKEN_KEY_GRANT_TYPE = "grant_type"
const val TOKEN_KEY_CLIENT_ID = "client_id"
const val TOKEN_KEY_CLIENT_SECRET = "client_secret"
}
}

View File

@ -0,0 +1,7 @@
package ru.touchin.push.message.provider.hpk.clients.hms_oauth.response
internal class HmsOauthErrorResponse(
val error: Int,
val subError: Int,
val errorDescription: String,
)

View File

@ -0,0 +1,8 @@
package ru.touchin.push.message.provider.hpk.clients.hms_oauth.response
internal class HmsOauthTokenResponse(
val tokenType: String,
val accessToken: String,
/** Expiration in seconds. */
val expiresIn: Long,
)

View File

@ -0,0 +1,62 @@
package ru.touchin.push.message.provider.hpk.configurations
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.cache.CacheManager
import org.springframework.cache.concurrent.ConcurrentMapCache
import org.springframework.cache.support.SimpleCacheManager
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Import
import org.springframework.web.reactive.function.client.WebClient
import ru.touchin.push.message.provider.configurations.PushMessageProviderConfiguration
import ru.touchin.push.message.provider.hpk.services.HmsOauthAccessTokenCacheServiceImpl.Companion.HMS_CLIENT_SERVICE_CACHE_KEY
@ComponentScan("ru.touchin.push.message.provider.hpk")
@ConfigurationPropertiesScan(basePackages = ["ru.touchin.push.message.provider.hpk"])
@Import(value = [PushMessageProviderConfiguration::class])
class PushMessageProviderHpkConfiguration {
@Bean("push-message-provider.hpk.webclient-objectmapper")
fun webclientObjectMapper(): ObjectMapper {
return jacksonObjectMapper()
.registerModule(JavaTimeModule())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}
@Bean("push-message-provider.hpk.client-objectmapper")
fun clientObjectMapper(): ObjectMapper {
return jacksonObjectMapper()
.registerModule(JavaTimeModule())
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
}
@Bean("push-message-provider.hpk.webclient-cachemanager")
@ConditionalOnMissingBean
fun cacheManager(): CacheManager {
return SimpleCacheManager().also {
it.setCaches(
listOf(
ConcurrentMapCache(HMS_CLIENT_SERVICE_CACHE_KEY)
)
)
}
}
@Bean("push-message-provider.hpk.hms-oauth-webclient-builder")
fun hmsOauthWebClientBuilder(): WebClient.Builder = WebClient.builder()
@Bean("push-message-provider.hpk.hms-hpk-webclient-builder")
fun hmsHpkWebClientBuilder(): WebClient.Builder = WebClient.builder()
}

View File

@ -0,0 +1,19 @@
package ru.touchin.push.message.provider.hpk.converters
import org.springframework.stereotype.Component
import ru.touchin.push.message.provider.dto.PushMessageNotification
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.Notification as HmsNotification
import ru.touchin.push.message.provider.hpk.base.extensions.ifNotNull
@Component("push-message-provider.hpk.push-message-notification-converter")
class PushMessageNotificationConverter {
internal operator fun invoke(pushMessageNotification: PushMessageNotification): HmsNotification {
return HmsNotification.builder()
.ifNotNull(pushMessageNotification.imageUrl) { setImage(it) }
.ifNotNull(pushMessageNotification.title) { setTitle(it) }
.ifNotNull(pushMessageNotification.description) { setBody(it) }
.build()
}
}

View File

@ -0,0 +1,56 @@
package ru.touchin.push.message.provider.hpk.converters
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Component
import ru.touchin.push.message.provider.dto.request.PushTokenMessage
import ru.touchin.push.message.provider.hpk.base.extensions.ifNotNull
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.AndroidConfig
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.Message
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android.AndroidClickAction
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android.AndroidNotificationConfig
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidClickActionType
import kotlin.jvm.Throws
@Component("push-message-provider.hpk.push-token-message-converter")
class PushTokenMessageConverter(
private val pushMessageNotificationConverter: PushMessageNotificationConverter,
@Qualifier("push-message-provider.hpk.client-objectmapper")
private val objectMapper: ObjectMapper,
) {
@Throws(IllegalArgumentException::class)
internal operator fun invoke(request: PushTokenMessage): Message {
return Message.builder()
.addToken(request.token)
.ifNotNull(request.data.takeIf(Map<*, *>::isNotEmpty)) { data ->
setData(objectMapper.writeValueAsString(data))
}
.ifNotNull(request.pushMessageNotification) { notification ->
setNotification(pushMessageNotificationConverter(notification))
}
.setupAndroidConfig()
.build()
.also { Message.validator.check(it) }
}
private fun Message.Builder.setupAndroidConfig(): Message.Builder {
return setAndroidConfig(
AndroidConfig.builder()
.setAndroidNotificationConfig(
AndroidNotificationConfig.builder()
.setDefaultSound(USE_DEFAULT_SOUND)
.build(AndroidClickAction.builder().build(DEFAULT_ANDROID_CLICK_ACTION_TYPE))
)
.build()
)
}
private companion object {
const val USE_DEFAULT_SOUND = true
val DEFAULT_ANDROID_CLICK_ACTION_TYPE = AndroidClickActionType.OPEN_APP
}
}

View File

@ -0,0 +1,59 @@
package ru.touchin.push.message.provider.hpk.properties
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.ConstructorBinding
import java.net.URL
import java.time.Duration
@ConstructorBinding
@ConfigurationProperties(prefix = "push-message-provider.hpk")
data class HpkProperties(
val webServices: WebServices,
) {
class WebServices(
val clientId: String,
val oauth: Oauth,
val hpk: Hpk,
)
class Oauth(
val clientSecret: String,
url: URL,
http: Http,
ssl: Ssl?,
) : WebService(
url = url,
http = http,
ssl = ssl,
)
class Hpk(
url: URL,
http: Http,
ssl: Ssl?,
) : WebService(
url = url,
http = http,
ssl = ssl,
)
open class WebService(
val url: URL,
val http: Http,
val ssl: Ssl?,
)
class Http(
val readTimeout: Duration,
val writeTimeout: Duration,
val connectionTimeout: Duration,
)
class Ssl(
val handshakeTimeout: Duration,
val notifyFlushTimeout: Duration,
val notifyReadTimeout: Duration,
)
}

View File

@ -0,0 +1,13 @@
package ru.touchin.push.message.provider.hpk.services
import ru.touchin.push.message.provider.dto.request.PushTokenCheck
import ru.touchin.push.message.provider.dto.request.PushTokenMessage
import ru.touchin.push.message.provider.enums.PushTokenStatus
interface HmsHpkClientService {
fun send(request: PushTokenMessage)
fun check(request: PushTokenCheck): PushTokenStatus
}

View File

@ -0,0 +1,74 @@
package ru.touchin.push.message.provider.hpk.services
import org.springframework.stereotype.Service
import ru.touchin.push.message.provider.dto.request.PushTokenCheck
import ru.touchin.push.message.provider.dto.request.PushTokenMessage
import ru.touchin.push.message.provider.enums.PushTokenStatus
import ru.touchin.push.message.provider.exceptions.InvalidPushTokenException
import ru.touchin.push.message.provider.exceptions.PushMessageProviderException
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.HmsHpkWebClient
import ru.touchin.push.message.provider.hpk.clients.hms.enums.HmsResponseCode
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.bodies.HmsHpkMessagesSendBody
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.requests.HmsHpkMessagesSendRequest
import ru.touchin.push.message.provider.hpk.converters.PushTokenMessageConverter
@Service
class HmsHpkClientServiceImpl(
private val hmsHpkWebClient: HmsHpkWebClient,
private val hmsOauthClientService: HmsOauthClientService,
private val pushTokenMessageConverter: PushTokenMessageConverter,
) : HmsHpkClientService {
override fun send(request: PushTokenMessage) {
sendToPushToken(request, dryRun = false)
}
override fun check(request: PushTokenCheck): PushTokenStatus {
val validationRequest = PushTokenMessage(
token = request.pushToken,
pushMessageNotification = null,
data = emptyMap()
)
return try {
sendToPushToken(validationRequest, dryRun = false)
PushTokenStatus.VALID
} catch (ipte: InvalidPushTokenException) {
PushTokenStatus.INVALID
} catch (pmpe: PushMessageProviderException) {
PushTokenStatus.UNKNOWN
}
}
@Throws(PushMessageProviderException::class, InvalidPushTokenException::class)
private fun sendToPushToken(request: PushTokenMessage, dryRun: Boolean) {
val accessToken = hmsOauthClientService.getAccessToken()
val result = hmsHpkWebClient.messagesSend(
HmsHpkMessagesSendRequest(
hmsHpkMessagesSendBody = HmsHpkMessagesSendBody(
validateOnly = dryRun,
message = pushTokenMessageConverter(request),
),
accessToken = accessToken,
)
)
when (HmsResponseCode.fromCode(result.code)) {
HmsResponseCode.SUCCESS -> {
// pass result
}
HmsResponseCode.INVALID_TOKEN,
HmsResponseCode.INVALID_CLIENT_SECRET -> {
throw InvalidPushTokenException()
}
else -> {
throw PushMessageProviderException(result.msg, null)
}
}
}
}

View File

@ -0,0 +1,11 @@
package ru.touchin.push.message.provider.hpk.services
import ru.touchin.push.message.provider.hpk.services.dto.AccessToken
interface HmsOauthAccessTokenCacheService {
fun put(accessToken: AccessToken)
fun get(): AccessToken?
fun evict()
}

View File

@ -0,0 +1,81 @@
package ru.touchin.push.message.provider.hpk.services
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.cache.Cache
import org.springframework.cache.CacheManager
import org.springframework.stereotype.Service
import ru.touchin.logger.dto.LogData
import ru.touchin.logger.factory.LogBuilderFactory
import ru.touchin.push.message.provider.hpk.properties.HpkProperties
import ru.touchin.push.message.provider.hpk.services.dto.AccessToken
import java.time.Instant
@Service
class HmsOauthAccessTokenCacheServiceImpl(
private val logBuilderFactory: LogBuilderFactory<LogData>,
@Qualifier("push-message-provider.hpk.webclient-cachemanager")
private val cacheManager: CacheManager,
@Qualifier("push-message-provider.hpk.client-objectmapper")
private val objectMapper: ObjectMapper,
private val hpkProperties: HpkProperties,
) : HmsOauthAccessTokenCacheService {
override fun put(accessToken: AccessToken) {
getCache()?.put(hpkProperties.webServices.clientId, accessToken)
}
override fun get(): AccessToken? { // TODO: implement synchronization for all threads
val cachedValue = getCache()
?.get(hpkProperties.webServices.clientId)
?.get()
?: return null
val accessToken = safeCast(cachedValue, object : TypeReference<AccessToken>() {})
?: return null
return if (accessToken.isValid()) {
accessToken
} else {
null
}
}
override fun evict() {
getCache()?.evict(hpkProperties.webServices.clientId)
}
private fun <T> safeCast(item: Any, typeReference: TypeReference<T>): T? {
return try {
objectMapper.convertValue(item, typeReference)
} catch (e: Exception) {
logBuilderFactory.create(this::class.java)
.setMethod("safeCast")
.setError(e)
.build()
.error()
null
}
}
private fun AccessToken.isValid(): Boolean {
val expirationTime = with(hpkProperties.webServices.oauth) {
Instant.now().plus(http.connectionTimeout + http.readTimeout + http.writeTimeout)
}
return expiresAt.isAfter(expirationTime)
}
private fun getCache(): Cache? {
return cacheManager.getCache(HMS_CLIENT_SERVICE_CACHE_KEY)
}
companion object {
const val HMS_CLIENT_SERVICE_CACHE_KEY = "HMS_CLIENT_SERVICE"
}
}

View File

@ -0,0 +1,7 @@
package ru.touchin.push.message.provider.hpk.services
interface HmsOauthClientService {
fun getAccessToken(): String
}

View File

@ -0,0 +1,37 @@
package ru.touchin.push.message.provider.hpk.services
import org.springframework.stereotype.Service
import ru.touchin.push.message.provider.exceptions.PushMessageProviderException
import ru.touchin.push.message.provider.hpk.clients.hms_oauth.HmsOauthWebClient
import ru.touchin.push.message.provider.hpk.services.dto.AccessToken
import java.time.Instant
@Service
class HmsOauthClientServiceImpl(
private val hmsOauthAccessTokenCacheService: HmsOauthAccessTokenCacheService,
private val hmsOauthWebClient: HmsOauthWebClient,
) : HmsOauthClientService {
override fun getAccessToken(): String {
val accessToken = hmsOauthAccessTokenCacheService.get()
?: retrieveAccessToken().also(hmsOauthAccessTokenCacheService::put)
return accessToken.value
}
private fun retrieveAccessToken(): AccessToken {
val result = hmsOauthWebClient.token()
if (result.success == null) {
throw PushMessageProviderException(result.failure?.errorDescription.orEmpty(), null)
}
return with(result.success) {
AccessToken(
value = accessToken,
expiresAt = Instant.now().plusSeconds(expiresIn)
)
}
}
}

View File

@ -0,0 +1,37 @@
package ru.touchin.push.message.provider.hpk.services
import org.springframework.stereotype.Service
import ru.touchin.push.message.provider.dto.request.PushTokenCheck
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.CheckPushTokenResult
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.services.PushMessageProviderService
@Service
class PushMessageProviderHpkService(
private val hmsHpkClientService: HmsHpkClientService,
) : PushMessageProviderService {
override val type: PushMessageProviderType = PushMessageProviderType.HPK
@Throws(PushMessageProviderException::class, InvalidPushTokenException::class)
override fun send(request: SendPushRequest): SendPushResult {
when (request) {
is PushTokenMessage -> hmsHpkClientService.send(request)
}
return SendPushTokenMessageResult
}
override fun check(request: PushTokenCheck): CheckPushTokenResult {
val result = hmsHpkClientService.check(request)
return CheckPushTokenResult(result)
}
}

View File

@ -0,0 +1,8 @@
package ru.touchin.push.message.provider.hpk.services.dto
import java.time.Instant
data class AccessToken(
val value: String,
val expiresAt: Instant,
)

View File

@ -0,0 +1,16 @@
package ru.touchin.push.message.provider.hpk
import org.springframework.boot.SpringBootConfiguration
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Import
import ru.touchin.logger.spring.configurations.SpringLoggerConfiguration
import ru.touchin.logger.spring.web.configurations.SpringLoggerWebConfiguration
@TestConfiguration
@SpringBootConfiguration
@EnablePushMessageProviderHpk
@Import(
SpringLoggerConfiguration::class,
SpringLoggerWebConfiguration::class,
)
class PushMessageProviderHpkTestApplication

View File

@ -0,0 +1,52 @@
package ru.touchin.push.message.provider.hpk.base.builders
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import ru.touchin.push.message.provider.hpk.base.extensions.ifNotNull
class BuildableTest {
@Test
fun ifNotNull_setOnNotNull() {
val data = Data(property = true)
val builder = DataBuilder()
builder.ifNotNull(data.property) { setProperty(it) }
Assertions.assertNotNull(
builder.property
)
}
@Test
fun ifNotNull_notSetOnNull() {
val data = Data(property = null)
val builder = DataBuilder()
builder.ifNotNull(data.property) { setProperty(it) }
Assertions.assertNull(
builder.property
)
}
private class Data(val property: Boolean?)
private class DataBuilder : Buildable {
var property: Boolean? = null
fun setProperty(property: Boolean): DataBuilder {
this.property = property
return this
}
fun build(): Data {
return Data(
property = property,
)
}
}
}

View File

@ -0,0 +1,33 @@
package ru.touchin.push.message.provider.hpk.base.enums
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
class ValueableSerializableEnumTest {
private val objectMapper = jacksonObjectMapper()
@Test
fun toValue_correctJacksonSerialization() {
val enum = TestEnum.VALUE1
val expected = enum.toValue()
val actual = objectMapper.writeValueAsString(enum)
.let(objectMapper::readTree)
.textValue()
Assertions.assertEquals(
expected,
actual
)
}
private enum class TestEnum(override val value: String) : ValueableSerializableEnum<String> {
VALUE1("testValue1"),
}
}

View File

@ -0,0 +1,69 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk
import com.fasterxml.jackson.core.JsonParseException
import org.junit.jupiter.api.Assertions
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.hpk.clients.hms_hpk.bodies.HmsHpkMessagesSendBody
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.AndroidConfig
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.Message
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.Notification
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android.AndroidClickAction
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android.AndroidNotificationConfig
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidClickActionType
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidUrgency
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.requests.HmsHpkMessagesSendRequest
@SpringBootTest
class HmsHpkWebClientTest {
@Autowired
lateinit var hmsHpkWebClient: HmsHpkWebClient
@Test
fun messagesSend_returnsHtmlDocumentStringWith403CodeAtIncorrectAccessToken() {
val result = runCatching {
hmsHpkWebClient.messagesSend(
HmsHpkMessagesSendRequest(
hmsHpkMessagesSendBody = buildHmsHpkMessagesSendBody(
token = "pushTokenWithLongLength"
),
accessToken = "testAccessToken"
)
)
}
Assertions.assertEquals(
JsonParseException::class.java,
result.exceptionOrNull()?.cause?.let { it::class.java }
)
}
private fun buildHmsHpkMessagesSendBody(token: String): HmsHpkMessagesSendBody {
return HmsHpkMessagesSendBody(
validateOnly = true,
message = Message.builder()
.addToken(token)
.setNotification(
Notification.builder()
.setTitle("title")
.setBody("body")
.setImage("https://avatars.githubusercontent.com/u/1435794?s=200&v=4")
.build()
)
.setAndroidConfig(
AndroidConfig.builder()
.setUrgency(AndroidUrgency.HIGH)
.setAndroidNotificationConfig(
AndroidNotificationConfig.builder()
.setDefaultSound(true)
.build(AndroidClickAction.builder().build(AndroidClickActionType.OPEN_APP))
)
.build()
)
.build()
)
}
}

View File

@ -0,0 +1,27 @@
package ru.touchin.push.message.provider.hpk.clients.hms_oauth
import org.junit.jupiter.api.Assertions
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.hpk.clients.hms.enums.HmsResponseCode
@SpringBootTest
class HmsOauthWebClientTest {
@Autowired
lateinit var hmsOauthWebClient: HmsOauthWebClient
@Test
fun token_invalidClientSecretOnInvalidClientSecret() {
val result = hmsOauthWebClient.token()
Assertions.assertNotNull(result.failure)
Assertions.assertEquals(
result.failure?.error,
HmsResponseCode.INVALID_CLIENT_SECRET.value
)
}
}

View File

@ -0,0 +1,84 @@
package ru.touchin.push.message.provider.hpk.converters
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.PushMessageNotification
import ru.touchin.push.message.provider.dto.request.PushTokenMessage
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.Message
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.Notification as HmsNotification
import org.junit.jupiter.api.Assertions
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.AndroidConfig
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android.AndroidClickAction
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android.AndroidNotificationConfig
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidClickActionType
@SpringBootTest
class PushTokenMessageConverterTest {
@Autowired
lateinit var pushTokenMessageConverter: PushTokenMessageConverter
@Test
fun invoke_buildsComplexMessage() {
val request = PushTokenMessage(
token = "testToken",
pushMessageNotification = PushMessageNotification(
title = "title",
description = "description",
imageUrl = "https://avatars.githubusercontent.com/u/1435794?s=200&v=4"
),
data = mapOf(
"key1" to "value1",
)
)
val actualResult = pushTokenMessageConverter(request)
val expectedResult = Message.builder()
.addToken("testToken")
.setNotification(
HmsNotification.builder()
.setTitle("title")
.setBody("description")
.setImage("https://avatars.githubusercontent.com/u/1435794?s=200&v=4")
.build()
)
.setAndroidConfig(
AndroidConfig.builder()
.setAndroidNotificationConfig(
AndroidNotificationConfig.builder()
.setDefaultSound(true)
.build(AndroidClickAction.builder().build(AndroidClickActionType.OPEN_APP))
)
.build()
)
.setData("{\"key1\":\"value1\"}")
.build()
Assertions.assertEquals(
expectedResult,
actualResult
)
}
@Test
fun invoke_throwsValidationErrorAtHttpImageUrl() {
val request = PushTokenMessage(
token = "testToken",
pushMessageNotification = PushMessageNotification(
title = "title",
description = "description",
imageUrl = "http://avatars.githubusercontent.com/u/1435794?s=200&v=4"
),
data = mapOf(
"key1" to "value1",
)
)
Assertions.assertThrows(
IllegalArgumentException::class.java
) { pushTokenMessageConverter(request) }
}
}

View File

@ -0,0 +1,123 @@
package ru.touchin.push.message.provider.hpk.services
import com.nhaarman.mockitokotlin2.any
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import ru.touchin.push.message.provider.dto.PushMessageNotification
import ru.touchin.push.message.provider.dto.request.PushTokenMessage
import ru.touchin.push.message.provider.exceptions.InvalidPushTokenException
import ru.touchin.push.message.provider.exceptions.PushMessageProviderException
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.HmsHpkWebClient
import ru.touchin.push.message.provider.hpk.clients.hms.enums.HmsResponseCode
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.responses.HmsHpkResponse
@SpringBootTest
class HmsHpkClientServiceTest {
@MockBean
lateinit var hmsHpkWebClient: HmsHpkWebClient
@MockBean
lateinit var hmsOauthClientService: HmsOauthClientService
@Autowired
lateinit var hmsHpkClientService: HmsHpkClientService
@Test
fun send_throwsInvalidPushTokenExceptionForKnownErrors() {
Mockito.`when`(
hmsOauthClientService.getAccessToken()
).then { "accessToken" }
Mockito.`when`(
hmsHpkWebClient.messagesSend(any())
).then {
HmsHpkResponse(
code = HmsResponseCode.INVALID_TOKEN.value.toString(),
msg = "0",
requestId = "requestId"
)
}
val pushTokenMessage = PushTokenMessage(
token = "token",
pushMessageNotification = PushMessageNotification(
title = "title",
description = "description",
imageUrl = null
),
data = emptyMap()
)
Assertions.assertThrows(
InvalidPushTokenException::class.java
) { hmsHpkClientService.send(pushTokenMessage) }
}
@Test
fun send_throwsPushMessageProviderExceptionOnOtherExceptions() {
Mockito.`when`(
hmsOauthClientService.getAccessToken()
).then { "accessToken" }
Mockito.`when`(
hmsHpkWebClient.messagesSend(any())
).then {
HmsHpkResponse(
code = HmsResponseCode.OAUTH_TOKEN_EXPIRED.value.toString(),
msg = "0",
requestId = "requestId"
)
}
val pushTokenMessage = PushTokenMessage(
token = "token",
pushMessageNotification = PushMessageNotification(
title = "title",
description = "description",
imageUrl = null
),
data = emptyMap()
)
Assertions.assertThrows(
PushMessageProviderException::class.java
) { hmsHpkClientService.send(pushTokenMessage) }
}
@Test
fun send_passesSuccess() {
Mockito.`when`(
hmsOauthClientService.getAccessToken()
).then { "accessToken" }
Mockito.`when`(
hmsHpkWebClient.messagesSend(any())
).then {
HmsHpkResponse(
code = HmsResponseCode.SUCCESS.value.toString(),
msg = "0",
requestId = "requestId"
)
}
val pushTokenMessage = PushTokenMessage(
token = "token",
pushMessageNotification = PushMessageNotification(
title = "title",
description = "description",
imageUrl = null
),
data = emptyMap()
)
Assertions.assertDoesNotThrow {
hmsHpkClientService.send(pushTokenMessage)
}
}
}

View File

@ -0,0 +1,88 @@
package ru.touchin.push.message.provider.hpk.services
import org.junit.jupiter.api.Assertions
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.hpk.services.dto.AccessToken
import java.time.Instant
@SpringBootTest
class HmsOauthAccessTokenCacheServiceTest {
@Autowired
lateinit var hmsOauthAccessTokenCacheService: HmsOauthAccessTokenCacheService
private val validAccessToken: AccessToken
get() = AccessToken(
value = "token",
expiresAt = Instant.now().plusSeconds(600)
)
private val expiredAccessToken: AccessToken
get() = AccessToken(
value = "token",
expiresAt = Instant.now().minusSeconds(600)
)
@Test
fun get_noCacheReturnsNull() {
hmsOauthAccessTokenCacheService.evict()
val accessToken = hmsOauthAccessTokenCacheService.get()
Assertions.assertNull(accessToken)
}
@Test
fun get_validIsReturned() {
val expected = validAccessToken
hmsOauthAccessTokenCacheService.put(expected)
val actual = hmsOauthAccessTokenCacheService.get()
Assertions.assertEquals(
expected,
actual
)
}
@Test
fun get_expiredIsNotReturned() {
hmsOauthAccessTokenCacheService.put(expiredAccessToken)
val accessToken = hmsOauthAccessTokenCacheService.get()
Assertions.assertNull(accessToken)
}
@Test
fun put_valid() {
hmsOauthAccessTokenCacheService.put(validAccessToken)
val result = hmsOauthAccessTokenCacheService.get()
Assertions.assertNotNull(result)
}
@Test
fun put_expired() {
hmsOauthAccessTokenCacheService.put(expiredAccessToken)
val result = hmsOauthAccessTokenCacheService.get()
Assertions.assertNull(result)
}
@Test
fun evict_deletesCache() {
hmsOauthAccessTokenCacheService.put(validAccessToken)
hmsOauthAccessTokenCacheService.evict()
val result = hmsOauthAccessTokenCacheService.get()
Assertions.assertNull(result)
}
}

View File

@ -0,0 +1,101 @@
package ru.touchin.push.message.provider.hpk.services
import com.fasterxml.jackson.databind.JsonMappingException
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import ru.touchin.push.message.provider.exceptions.InvalidPushTokenException
import ru.touchin.push.message.provider.exceptions.PushMessageProviderException
import ru.touchin.push.message.provider.hpk.base.clients.dto.ConditionalResponse
import ru.touchin.push.message.provider.hpk.clients.hms.enums.HmsResponseCode
import ru.touchin.push.message.provider.hpk.clients.hms_oauth.HmsOauthWebClient
import ru.touchin.push.message.provider.hpk.clients.hms_oauth.response.HmsOauthErrorResponse
import ru.touchin.push.message.provider.hpk.clients.hms_oauth.response.HmsOauthTokenResponse
import java.net.SocketTimeoutException
@SpringBootTest
class HmsOauthClientServiceTest {
@MockBean
lateinit var hmsOauthWebClient: HmsOauthWebClient
@Autowired
lateinit var hmsOauthClientService: HmsOauthClientService
@Test
fun getAccessToken_throwsPushMessageProviderExceptionForUnknownError() {
Mockito.`when`(
hmsOauthWebClient.token()
).then {
ConditionalResponse(
success = null,
failure = HmsOauthErrorResponse(
error = HmsResponseCode.UNKNOWN.value,
subError = 0,
errorDescription = "errorDescription"
)
)
}
Assertions.assertThrows(
PushMessageProviderException::class.java
) { hmsOauthClientService.getAccessToken() }
}
@Test
fun getAccessToken_throwsNetworkExceptions() {
Mockito.`when`(
hmsOauthWebClient.token()
).then {
throw SocketTimeoutException()
}
Assertions.assertThrows(
SocketTimeoutException::class.java
) { hmsOauthClientService.getAccessToken() }
}
@Test
fun getAccessToken_throwsParsingExceptions() {
Mockito.`when`(
hmsOauthWebClient.token()
).then {
throw JsonMappingException({ }, "jsonMappingExceptionMsg")
}
Assertions.assertThrows(
JsonMappingException::class.java
) { hmsOauthClientService.getAccessToken() }
}
@Test
fun getAccessToken_cachesExpectedTime() {
val expected = "accessToken"
Mockito.`when`(
hmsOauthWebClient.token()
).then {
ConditionalResponse(
success = HmsOauthTokenResponse(
tokenType = "tokenType",
expiresIn = 60_000,
accessToken = expected
),
failure = null
)
}
val actual = hmsOauthClientService.getAccessToken()
Assertions.assertEquals(
expected,
actual
)
}
}

View File

@ -0,0 +1,60 @@
package ru.touchin.push.message.provider.hpk.services
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.only
import com.nhaarman.mockitokotlin2.verify
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import ru.touchin.push.message.provider.dto.request.PushTokenCheck
import ru.touchin.push.message.provider.dto.request.PushTokenMessage
import ru.touchin.push.message.provider.enums.PushTokenStatus
import ru.touchin.push.message.provider.services.PushMessageProviderService
@SpringBootTest
class PushMessageProviderHpkServiceTest {
@MockBean
lateinit var hmsHpkClientService: HmsHpkClientService
@Autowired
lateinit var pushMessageProviderService: PushMessageProviderService
@Test
@DisplayName("Обработка запроса на отправку единичного сообщения происходит корректно")
fun send_basic() {
Mockito.`when`(
hmsHpkClientService.send(any())
).then {
// returns Unit
}
val request = PushTokenMessage(
token = "testTokenWithLongLength",
pushMessageNotification = null,
data = emptyMap()
)
pushMessageProviderService.send(request)
verify(hmsHpkClientService, only()).send(any())
}
@Test
@DisplayName("Обработка запроса на валидацию пуш-токена происходит корректно")
fun check_basic() {
Mockito.`when`(
hmsHpkClientService.check(any())
).then {
PushTokenStatus.UNKNOWN
}
pushMessageProviderService.check(PushTokenCheck("testTokenWithLongLength"))
verify(hmsHpkClientService, only()).check(any())
}
}

View File

@ -0,0 +1,20 @@
push-message-provider:
platformProviders:
ANDROID_HUAWEI:
- HPK
hpk:
web-services:
client-id: testClientId
oauth:
client-secret: testClientSecret
url: https://oauth-login.cloud.huawei.com/oauth2/v3/
http:
connection-timeout: 5s
read-timeout: 10s
write-timeout: 10s
hpk:
url: https://push-api.cloud.huawei.com/v1/
http:
connection-timeout: 5s
read-timeout: 10s
write-timeout: 10s

View File

@ -9,7 +9,7 @@ import ru.touchin.push.message.provider.dto.request.PushTokenCheck
import ru.touchin.push.message.provider.dto.request.SendPushRequest import ru.touchin.push.message.provider.dto.request.SendPushRequest
import ru.touchin.push.message.provider.dto.result.CheckPushTokenResult import ru.touchin.push.message.provider.dto.result.CheckPushTokenResult
import ru.touchin.push.message.provider.dto.result.SendPushResult import ru.touchin.push.message.provider.dto.result.SendPushResult
import ru.touchin.push.message.provider.dto.result.SendPushTokenMessageResult import ru.touchin.push.message.provider.dto.result.SendPushTokenMessageTraceableResult
import ru.touchin.push.message.provider.enums.PlatformType import ru.touchin.push.message.provider.enums.PlatformType
import ru.touchin.push.message.provider.enums.PushMessageProviderType import ru.touchin.push.message.provider.enums.PushMessageProviderType
import ru.touchin.push.message.provider.factories.PushMessageProviderServiceFactory import ru.touchin.push.message.provider.factories.PushMessageProviderServiceFactory
@ -42,7 +42,7 @@ class PushMessageProviderMockServiceFactoryImpl(
Thread.sleep(millis) Thread.sleep(millis)
return SendPushTokenMessageResult( return SendPushTokenMessageTraceableResult(
messageId = UUID.randomUUID().toString(), messageId = UUID.randomUUID().toString(),
) )
} }

View File

@ -1,6 +1,6 @@
package ru.touchin.push.message.provider.dto package ru.touchin.push.message.provider.dto
class Notification( class PushMessageNotification(
val title: String?, val title: String?,
val description: String?, val description: String?,
val imageUrl: String? val imageUrl: String?

View File

@ -1,9 +1,9 @@
package ru.touchin.push.message.provider.dto.request package ru.touchin.push.message.provider.dto.request
import ru.touchin.push.message.provider.dto.Notification import ru.touchin.push.message.provider.dto.PushMessageNotification
class PushTokenMessage( class PushTokenMessage(
val token: String, val token: String,
override val notification: Notification?, override val pushMessageNotification: PushMessageNotification?,
override val data: Map<String, String> override val data: Map<String, String>
) : SendPushRequest ) : SendPushRequest

View File

@ -1,10 +1,10 @@
package ru.touchin.push.message.provider.dto.request package ru.touchin.push.message.provider.dto.request
import ru.touchin.push.message.provider.dto.Notification import ru.touchin.push.message.provider.dto.PushMessageNotification
sealed interface SendPushRequest { sealed interface SendPushRequest {
val notification: Notification? val pushMessageNotification: PushMessageNotification?
val data: Map<String, String> val data: Map<String, String>
} }

View File

@ -3,5 +3,5 @@ package ru.touchin.push.message.provider.dto.result
import ru.touchin.push.message.provider.enums.PushTokenStatus import ru.touchin.push.message.provider.enums.PushTokenStatus
data class CheckPushTokenResult( data class CheckPushTokenResult(
val status: PushTokenStatus val status: PushTokenStatus,
) )

View File

@ -1,5 +1,3 @@
package ru.touchin.push.message.provider.dto.result package ru.touchin.push.message.provider.dto.result
class SendPushTokenMessageResult( object SendPushTokenMessageResult : SendPushResult
val messageId: String
) : SendPushResult

View File

@ -0,0 +1,5 @@
package ru.touchin.push.message.provider.dto.result
data class SendPushTokenMessageTraceableResult(
val messageId: String
) : SendPushResult

View File

@ -3,6 +3,7 @@ package ru.touchin.push.message.provider.enums
enum class PlatformType { enum class PlatformType {
ANDROID_GOOGLE, ANDROID_GOOGLE,
ANDROID_HUAWEI,
IOS IOS
} }

View File

@ -2,6 +2,7 @@ package ru.touchin.push.message.provider.enums
enum class PushMessageProviderType { enum class PushMessageProviderType {
FCM FCM,
HPK,
} }

View File

@ -45,6 +45,7 @@ include("validation-spring")
include("version-spring-web") include("version-spring-web")
include("push-message-provider") include("push-message-provider")
include("push-message-provider-fcm") include("push-message-provider-fcm")
include("push-message-provider-hpk")
include("push-message-provider-mock") include("push-message-provider-mock")
include("response-wrapper-spring-web") include("response-wrapper-spring-web")
include("settings-spring-jpa") include("settings-spring-jpa")