Compare commits

...

12 Commits

Author SHA1 Message Date
Korna 5d30c5ca6c Cover with tests 2022-11-02 16:00:27 +03:00
Korna 1283c89615 Add PushMessageProvider service and configuration 2022-11-02 16:00:21 +03:00
Korna 9c655b89e2 Add ClientServices 2022-11-02 16:00:06 +03:00
Korna 2f488548f7 Add Oauth WebClient 2022-11-02 15:59:28 +03:00
Korna 53ee76564d Add HPK WebClient 2022-11-02 15:59:18 +03:00
Korna 304a239871 Add HPK "*/messagesSend" dto's 2022-11-02 15:58:39 +03:00
Korna a4b440ce11 Add HPK web dto's 2022-11-02 15:57:18 +03:00
Korna 2ac72a1669 Add HPK APNs dto's 2022-11-02 15:57:03 +03:00
Korna 0d7865427d Add HPK Android dto's 2022-11-02 15:56:43 +03:00
Korna 3d67f4a1ce Add basic components for push-message-provider-hpk module 2022-11-02 15:55:01 +03:00
Korna 53a2bb680c Rename basic classes, add extra dto's, update enums 2022-11-02 15:54:26 +03:00
Korna f8dd12bee0 Add basic module 2022-11-02 15:51:14 +03:00
85 changed files with 3544 additions and 54 deletions

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.PushTokenMessage
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.exceptions.InvalidPushTokenException
import ru.touchin.push.message.provider.exceptions.PushMessageProviderException
@ -32,7 +32,7 @@ class FcmClient(
fun check(request: PushTokenCheck): PushTokenStatus {
val validationRequest = PushTokenMessage(
token = request.pushToken,
notification = null,
pushMessageNotification = null,
data = emptyMap()
)
@ -54,7 +54,7 @@ class FcmClient(
return try {
val messageId = firebaseMessaging.send(message, dryRun)
SendPushTokenMessageResult(messageId)
SendPushTokenMessageTraceableResult(messageId)
} catch (e: FirebaseMessagingException) {
throw firebaseMessagingExceptionConverter(e)
}

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
class PushMessageNotificationConverter {
operator fun invoke(pushMessageNotification: PushMessageNotification): FcmNotification {
return FcmNotification.builder()
.setTitle(pushMessageNotification.title)
.setBody(pushMessageNotification.description)
.setImage(pushMessageNotification.imageUrl)
.build()
}
}

View File

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

View File

@ -5,15 +5,15 @@ import org.junit.Assert
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import ru.touchin.push.message.provider.dto.Notification
import ru.touchin.push.message.provider.dto.PushMessageNotification
import com.google.firebase.messaging.Notification as FcmNotification
import org.junit.jupiter.api.DisplayName
@SpringBootTest
class NotificationConverterTest {
class PushMessageNotificationConverterTest {
@Autowired
lateinit var notificationConverter: NotificationConverter
lateinit var pushMessageNotificationConverter: PushMessageNotificationConverter
@Autowired
lateinit var objectMapper: ObjectMapper
@ -21,19 +21,19 @@ class NotificationConverterTest {
@Test
@DisplayName("Конвертация уведомления происходит корректно")
fun invoke_basic() {
val notification = Notification(
val pushMessageNotification = PushMessageNotification(
title = "title",
description = "description",
imageUrl = "imageUrl"
)
val realResult = notificationConverter(notification)
val realResult = pushMessageNotificationConverter(pushMessageNotification)
val realResultJson = objectMapper.writeValueAsString(realResult)
val expectedResult = FcmNotification.builder()
.setTitle(notification.title)
.setBody(notification.description)
.setImage(notification.imageUrl)
.setTitle(pushMessageNotification.title)
.setBody(pushMessageNotification.description)
.setImage(pushMessageNotification.imageUrl)
.build()
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.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import ru.touchin.push.message.provider.dto.Notification
import ru.touchin.push.message.provider.dto.PushMessageNotification
import ru.touchin.push.message.provider.dto.request.PushTokenMessage
@SpringBootTest
@ -21,7 +21,7 @@ class PushTokenMessageConverterTest {
lateinit var pushTokenMessageConverter: PushTokenMessageConverter
@Autowired
lateinit var notificationConverter: NotificationConverter
lateinit var pushMessageNotificationConverter: PushMessageNotificationConverter
@Autowired
lateinit var objectMapper: ObjectMapper
@ -29,14 +29,14 @@ class PushTokenMessageConverterTest {
@Test
@DisplayName("Конвертация сообщения с уведомлением происходит корректно")
fun invoke_withNotification() {
val notification = Notification(
val pushMessageNotification = PushMessageNotification(
title = "title",
description = "description",
imageUrl = "imageUrl"
)
val pushTokenMessage = PushTokenMessage(
token = "token",
notification = notification,
pushMessageNotification = pushMessageNotification,
data = mapOf("testKey" to "testvalue")
)
@ -45,7 +45,7 @@ class PushTokenMessageConverterTest {
val expectedResult = Message.builder()
.setToken(pushTokenMessage.token)
.setNotification(notificationConverter(notification))
.setNotification(pushMessageNotificationConverter(pushMessageNotification))
.putAllData(pushTokenMessage.data)
.setupApns()
.setupAndroid()
@ -65,7 +65,7 @@ class PushTokenMessageConverterTest {
fun invoke_withoutNotification() {
val pushTokenMessage = PushTokenMessage(
token = "token",
notification = null,
pushMessageNotification = null,
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.PushTokenMessage
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.fcm.clients.FcmClient
import ru.touchin.push.message.provider.services.PushMessageProviderService
@ -29,11 +29,11 @@ class PushMessageProviderFcmServiceTest {
fun send_basic() {
val request = PushTokenMessage(
token = "testToken",
notification = null,
pushMessageNotification = null,
data = emptyMap()
)
val expectedResult = SendPushTokenMessageResult("testMessageId")
val expectedResult = SendPushTokenMessageTraceableResult("testMessageId")
Mockito.`when`(
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"),
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,66 @@
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
private const val METHOD_MESSAGES_SEND = "messages:send"
/**
* 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,
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")
}
}

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,157 @@
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 -1..100) { "Collapse Key should be [-1, 100]" }
}
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 {
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,127 @@
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 } == 1
) { "Exactly one of token, topic or condition must be specified" }
if (token != null) {
require(
token.size in 1..1000
) { "Number of tokens, if specified, must be from 1 to 1000" }
}
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) }
}
}
}
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.matches(HTTPS_URL_PATTERN)
) { "image's url should start with HTTPS" }
}
}
}
private companion object {
val HTTPS_URL_PATTERN: Regex = Regex("^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,57 @@
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 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,67 @@
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 1..99
) { "add_num must locate between 0 and 100" }
}
if (setNum != null) {
require(
setNum in 0..99
) { "set_num must locate between -1 and 100" }
}
}
}
}
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,73 @@
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 < 40
) { "Button name length cannot exceed 40" }
if (androidActionType == AndroidActionType.SHARE_NOTIFICATION_MESSAGE) {
require(!data.isNullOrEmpty()) { "Data is needed when actionType is $androidActionType" }
require(data.length < 1024) { "Data length cannot exceed 1024 chars" }
}
}
}
}
class Builder : Buildable {
private var intent: String? = null
private var data: String? = null
fun setIntent(intent: String): Builder {
this.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.matches(HTTPS_PATTERN)) { "url must start with https" }
}
AndroidClickActionType.OPEN_APP -> {
// no verification
}
}
}
}
private companion object {
val HTTPS_PATTERN: Regex = Regex("^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,81 @@
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 > ZERO && alpha < ONE) { "Alpha must be locate between [0,1]" }
require(red > ZERO && red < ONE) { "Red must be locate between [0,1]" }
require(green > ZERO && green < ONE) { "Green must be locate between [0,1]" }
require(blue > ZERO && blue < ONE) { "Blue must be locate between [0,1]" }
}
}
private companion object {
private const val ZERO: Float = -0.000001f
private const val ONE: Float = 1.000001f
}
}
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 format is wrong" }
require(
lightOffDuration.matches(LIGHT_DURATION_PATTERN)
) { "light_off_duration format 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,410 @@
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.matches(HTTPS_URL_PATTERN)) { "notifyIcon must start with https" }
}
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.orEmpty().size <= 5
) { "inboxContent is required when style is $androidStyleType and at most 5 inbox content allowed" }
}
}
}
if (profileId != null) {
require(profileId.length <= 64) { "profileId length cannot exceed 64 characters" }
}
}
}
private companion object {
val COLOR_PATTERN: Regex = Regex("^#[0-9a-fA-F]{6}$")
val HTTPS_URL_PATTERN: Regex = Regex("^https.*")
}
}
class Builder : Buildable {
private var title: String? = null
private val 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
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,117 @@
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 setAddAllTitleLocArgs(titleLocArgs: Collection<String>): Builder {
this.titleLocArgs.addAll(titleLocArgs)
return this
}
fun setAddTitleLocArg(titleLocArg: String): Builder {
titleLocArgs.add(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(locArgs: Collection<String>): Builder {
this.locArgs.addAll(locArgs)
return this
}
fun addLocArg(locArg: String): Builder {
locArgs.add(locArg)
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,108 @@
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.matches(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 < 64
) { "Number of apnsCollapseId bytes should be less than 64" }
}
}
}
private companion object {
private val AUTHORIZATION_PATTERN: Regex = Regex("^bearer*")
private val APN_ID_PATTERN: Regex = Regex("[0-9a-z]{8}(-[0-9a-z]{4}){3}-[0-9a-z]{12}")
}
}
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,43 @@
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 build(): WebActions {
return WebActions(
action = action,
icon = icon,
title = title,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,47 @@
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 build(): WebHmsOptions {
return WebHmsOptions(
link = link,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,90 @@
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 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?,
val dir: String?,
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) {
with(webNotification) {
if (dir != null) {
require(
DIR_VALUE.any { it == dir }
) { "Invalid dir" }
}
}
}
private companion object {
val DIR_VALUE: Array<String> = arrayOf("auto", "ltr", "rtl")
}
}
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: String? = 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 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,73 @@
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 WebPushHeaders private constructor(
val ttl: String?,
val topic: String?,
val urgency: String?,
) {
class Validator {
fun check(webpushHeaders: WebPushHeaders) {
with(webpushHeaders) {
if (ttl != null) {
require(ttl.matches(TTL_PATTERN)) { "Invalid ttl format" }
}
if (urgency != null) {
require(
URGENCY_VALUE.all { it == urgency }
) { "Invalid urgency" }
}
}
}
private companion object {
val TTL_PATTERN: Regex = Regex("[0-9]+|[0-9]+[sS]")
val URGENCY_VALUE: Array<String> = arrayOf("very-low", "low", "normal", "high")
}
}
class Builder : Buildable {
private var ttl: String? = null
private var topic: String? = null
private var urgency: String? = null
fun setTtl(ttl: String): Builder {
this.ttl = ttl
return this
}
fun setTopic(topic: String): Builder {
this.topic = topic
return this
}
fun setUrgency(urgency: String): 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,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,65 @@
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
private const val GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
private const val METHOD_TOKEN = "token"
@Component
class HmsOauthWebClient(
webClientLogger: WebClientLogger,
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("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS)
.with("client_id", hpkProperties.webServices.clientId)
.with("client_secret", hpkProperties.webServices.oauth.clientSecret)
)
.exchangeWithWrap<HmsOauthTokenResponse, HmsOauthErrorResponse>(
requestLogData = RequestLogData(
uri = METHOD_TOKEN,
logTags = listOf(),
method = HttpMethod.POST,
),
)
.block() ?: throw IllegalStateException("No response")
}
}

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,59 @@
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.beans.factory.annotation.Qualifier
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 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
@Qualifier("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
@Qualifier("push-message-provider.hpk.client-objectmapper")
fun clientObjectMapper(): ObjectMapper {
return jacksonObjectMapper()
.registerModule(JavaTimeModule())
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
}
@Bean
@ConditionalOnMissingBean
@Qualifier("push-message-provider.hpk.webclient-cachemanager")
fun cacheManager(): CacheManager {
return SimpleCacheManager().also {
it.setCaches(
listOf(
ConcurrentMapCache(HMS_CLIENT_SERVICE_CACHE_KEY)
)
)
}
}
}

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
class NotificationConverter {
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
class PushTokenMessageConverter(
private val notificationConverter: NotificationConverter,
@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(notificationConverter(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,70 @@
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.INVALID_TOKEN,
HmsResponseCode.PERMISSION_DENIED -> {
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,73 @@
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.push.message.provider.hpk.properties.HpkProperties
import ru.touchin.push.message.provider.hpk.services.dto.AccessToken
import java.time.Instant
@Service
class HmsOauthAccessTokenCacheServiceImpl(
@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) {
print(e.message)
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,23 @@
package ru.touchin.push.message.provider.hpk
import org.springframework.boot.SpringBootConfiguration
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.web.reactive.function.client.WebClient
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 {
@Bean
fun webClientBuilder(): WebClient.Builder = WebClient.builder()
}

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,61 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk
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
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_pushTokenNotSpecified() {
val result = hmsHpkWebClient.messagesSend(
HmsHpkMessagesSendRequest(
hmsHpkMessagesSendBody = HmsHpkMessagesSendBody(
validateOnly = true,
message = Message.builder()
.addToken("pushTokenWithLongLength")
.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()
),
accessToken = "testAccessToken"
)
)
Assertions.assertEquals(
HmsResponseCode.PERMISSION_DENIED.value.toString(),
result.code
)
}
}

View File

@ -0,0 +1,26 @@
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,58 @@
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.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 getAccessToken_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) }
}
}

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,96 @@
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() {
Mockito.`when`(
hmsOauthWebClient.token()
).then {
ConditionalResponse(
success = HmsOauthTokenResponse(
tokenType = "tokenType",
expiresIn = 60_000,
accessToken = "accessToken"
),
failure = null
)
}
Assertions.assertThrows(
InvalidPushTokenException::class.java
) { hmsOauthClientService.getAccessToken() }
}
}

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: 1
oauth:
client-secret: 2
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

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

View File

@ -1,9 +1,9 @@
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(
val token: String,
override val notification: Notification?,
val pushMessageNotification: PushMessageNotification?,
override val data: Map<String, String>
) : SendPushRequest

View File

@ -1,10 +1,10 @@
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 {
val notification: Notification?
val pushMessageNotification: PushMessageNotification?
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
data class CheckPushTokenResult(
val status: PushTokenStatus
val status: PushTokenStatus,
)

View File

@ -1,5 +1,3 @@
package ru.touchin.push.message.provider.dto.result
class SendPushTokenMessageResult(
val messageId: String
) : SendPushResult
object SendPushTokenMessageResult : 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 {
ANDROID_GOOGLE,
ANDROID_HUAWEI,
IOS
}

View File

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

View File

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