From fd5e22622b9ea720e1383bf9dd7ec896737624ea Mon Sep 17 00:00:00 2001 From: atyutin Date: Tue, 30 Mar 2021 12:44:58 +0300 Subject: [PATCH] HUAWEI Push Kit --- .github/autolabeler.yml | 1 + build.sbt | 5 + docs/src/main/paradox/huawei-push-kit.md | 86 +++++ docs/src/main/paradox/index.md | 1 + .../src/main/resources/reference.conf | 14 + .../pushkit/ForwardProxyHttpsContext.scala | 58 ++++ .../pushkit/ForwardProxyPoolSettings.scala | 32 ++ .../alpakka/huawei/pushkit/HmsSettings.scala | 227 +++++++++++++ .../pushkit/PushKitNotificationModels.scala | 303 ++++++++++++++++++ .../huawei/pushkit/impl/HmsSession.scala | 42 +++ .../huawei/pushkit/impl/HmsTokenApi.scala | 70 ++++ .../huawei/pushkit/impl/PushKitFlows.scala | 36 +++ .../pushkit/impl/PushKitJsonSupport.scala | 194 +++++++++++ .../huawei/pushkit/impl/PushKitSender.scala | 71 ++++ .../huawei/pushkit/javadsl/HmsPushKit.scala | 23 ++ .../huawei/pushkit/scaladsl/HmsPushKit.scala | 22 ++ .../java/docs/javadsl/PushKitExamples.java | 66 ++++ .../src/test/resources/application.conf | 5 + .../src/test/resources/logback-test.xml | 29 ++ .../huawei/pushkit/ConditionBuilderSpec.scala | 42 +++ .../huawei/pushkit/impl/HmsTokenApiSpec.scala | 107 +++++++ .../pushkit/impl/PushKitSenderSpec.scala | 132 ++++++++ .../scala/docs/scaladsl/PushKitExamples.scala | 77 +++++ project/Dependencies.scala | 8 + project/project-info.conf | 18 ++ 25 files changed, 1669 insertions(+) create mode 100644 docs/src/main/paradox/huawei-push-kit.md create mode 100644 huawei-push-kit/src/main/resources/reference.conf create mode 100644 huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/ForwardProxyHttpsContext.scala create mode 100644 huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/ForwardProxyPoolSettings.scala create mode 100644 huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/HmsSettings.scala create mode 100644 huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/PushKitNotificationModels.scala create mode 100644 huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/HmsSession.scala create mode 100644 huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/HmsTokenApi.scala create mode 100644 huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitFlows.scala create mode 100644 huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitJsonSupport.scala create mode 100644 huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitSender.scala create mode 100644 huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/javadsl/HmsPushKit.scala create mode 100644 huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/scaladsl/HmsPushKit.scala create mode 100644 huawei-push-kit/src/test/java/docs/javadsl/PushKitExamples.java create mode 100644 huawei-push-kit/src/test/resources/application.conf create mode 100644 huawei-push-kit/src/test/resources/logback-test.xml create mode 100644 huawei-push-kit/src/test/scala/akka/stream/alpakka/huawei/pushkit/ConditionBuilderSpec.scala create mode 100644 huawei-push-kit/src/test/scala/akka/stream/alpakka/huawei/pushkit/impl/HmsTokenApiSpec.scala create mode 100644 huawei-push-kit/src/test/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitSenderSpec.scala create mode 100644 huawei-push-kit/src/test/scala/docs/scaladsl/PushKitExamples.scala diff --git a/.github/autolabeler.yml b/.github/autolabeler.yml index 7a0fc662c6..757b0f6033 100644 --- a/.github/autolabeler.yml +++ b/.github/autolabeler.yml @@ -26,6 +26,7 @@ dependency-change: "/project/Dependencies.scala" 'p:google-fcm': ["/google-fcm"] 'p:hbase': ["/hbase"] 'p:hdfs': ["/hdfs"] +'p:hms-push-kit': ["/hms-push-kit"] 'p:influxdb': ["/influxdb"] 'p:ironmq': ["/ironmq"] 'p:jms': ["/jms"] diff --git a/build.sbt b/build.sbt index cacc4b64d0..224f99e902 100644 --- a/build.sbt +++ b/build.sbt @@ -25,6 +25,7 @@ lazy val alpakka = project googleFcm, hbase, hdfs, + huaweiPushKit, influxdb, ironmq, jms, @@ -235,6 +236,10 @@ lazy val hbase = alpakkaProject("hbase", "hbase", Dependencies.HBase, Test / for lazy val hdfs = alpakkaProject("hdfs", "hdfs", Dependencies.Hdfs, fatalWarnings := true) +lazy val huaweiPushKit = + alpakkaProject("huawei-push-kit", "huawei.pushkit", Dependencies.HuaweiPushKit, fatalWarnings := true) + .disablePlugins(MimaPlugin) + lazy val influxdb = alpakkaProject("influxdb", "influxdb", Dependencies.InfluxDB, fatalWarnings := false) lazy val ironmq = alpakkaProject( diff --git a/docs/src/main/paradox/huawei-push-kit.md b/docs/src/main/paradox/huawei-push-kit.md new file mode 100644 index 0000000000..e05a56b304 --- /dev/null +++ b/docs/src/main/paradox/huawei-push-kit.md @@ -0,0 +1,86 @@ +# HUAWEI Push Kit + +@@@ note { title="HUAWEI Push Kit" } + +HUAWEI Push Kit is a messaging service provided for you. It establishes a messaging channel from the cloud to devices. By integrating Push Kit, you can send messages to your apps on users' devices in real time. + +@@@ + +The Alpakka HUAWEI Push Kit connector provides a way to send notifications with [HUAWEI Push Kit](https://developer.huawei.com/consumer/en/hms/huawei-pushkit). + +@@project-info{ projectId="huawei-push-kit" } + +## Artifacts + +@@dependency [sbt,Maven,Gradle] { +group=com.lightbend.akka +artifact=akka-stream-alpakka-huawei-push-kit_$scala.binary.version$ +version=$project.version$ +symbol2=AkkaVersion +value2=$akka.version$ +group2=com.typesafe.akka +artifact2=akka-stream_$scala.binary.version$ +version2=AkkaVersion +symbol3=AkkaHttpVersion +value3=$akka-http.version$ +group3=com.typesafe.akka +artifact3=akka-http_$scala.binary.version$ +version3=AkkaHttpVersion +group4=com.typesafe.akka +artifact4=akka-http-spray-json_$scala.binary.version$ +version4=AkkaHttpVersion +} + +The table below shows direct dependencies of this module and the second tab shows all libraries it depends on transitively. + +@@dependencies { projectId="huawei-push-kit" } + +## Settings + +Prepare your credentials for access to HUAWEI Push Kit. + +Scala +: @@snip [snip](/huawei-push-kit/src/test/scala/docs/scaladsl/PushKitExamples.scala) { #init-credentials } + +Java +: @@snip [snip](/huawei-push-kit/src/test/java/docs/javadsl/PushKitExamples.java) { #init-credentials } + +The last two parameters in [HmsSettings](akka.stream.alpakka.huawei.pushkit.HmsSettings) are the predefined values. +You can send test notifications [(so called validate only).](https://developer.huawei.com/consumer/en/doc/development/HMSCore-References-V5/https-send-api-0000001050986197-V5) +And you can set the number of maximum concurrent connections. + +## Sending notifications + +To send a notification message create your notification object, and send it! + +Scala +: @@snip [snip](/huawei-push-kit/src/test/scala/docs/scaladsl/PushKitExamples.scala) { #imports #asFlow-send } + +Java +: @@snip [snip](/huawei-push-kit/src/test/java/docs/javadsl/PushKitExamples.java) { #imports #asFlow-send } + +With this type of send you can get responses from the server. +These responses can be @scaladoc[PushKitResponse](akka.stream.alpakka.huawei.pushkit.PushKitResponse) or @scaladoc[ErrorResponse](akka.stream.alpakka.huawei.pushkit.ErrorResponse). +You can choose what you want to do with this information, but keep in mind +if you try to resend the failed messages you will need to use exponential backoff! (see [Akka docs `RestartFlow.onFailuresWithBackoff`](https://doc.akka.io/docs/akka/current/stream/operators/RestartFlow/onFailuresWithBackoff.html)) + +If you don't care if the notification was sent successfully, you may use `fireAndForget`. + +Scala +: @@snip [snip](/huawei-push-kit/src/test/scala/docs/scaladsl/PushKitExamples.scala) { #imports #simple-send } + +Java +: @@snip [snip](/huawei-push-kit/src/test/java/docs/javadsl/PushKitExamples.java) { #imports #simple-send } + +With fire and forget you will just send messages and ignore all the errors. + +To help the integration and error handling or logging, there is a variation of the flow where you can send data beside your notification. + +## Scala only + +You can build notification described in the original documentation. +It can be done by hand, or using some builder method. +Example is condition builder. + +Scala +: @@snip [snip](/huawei-push-kit/src/test/scala/docs/scaladsl/PushKitExamples.scala) { #condition-builder } \ No newline at end of file diff --git a/docs/src/main/paradox/index.md b/docs/src/main/paradox/index.md index fc605a54cc..ecca8dbe92 100644 --- a/docs/src/main/paradox/index.md +++ b/docs/src/main/paradox/index.md @@ -40,6 +40,7 @@ The [Alpakka project](https://doc.akka.io/docs/alpakka/current/) is an open sour * [gRPC](external/grpc.md) * [Hadoop Distributed File System](hdfs.md) * [HBase](hbase.md) +* [HUAWEI Push Kit](huawei-push-kit.md) * [HTTP](external/http.md) * [IBM Bluemix Cloud Object storage](bluemix-cos.md) * [IBM DB2 Event Store](external/db2-event-store.md) diff --git a/huawei-push-kit/src/main/resources/reference.conf b/huawei-push-kit/src/main/resources/reference.conf new file mode 100644 index 0000000000..e112dbbbee --- /dev/null +++ b/huawei-push-kit/src/main/resources/reference.conf @@ -0,0 +1,14 @@ +alpakka.huawei.pushkit { + # An address of a proxy that will be used for all connections using HTTP CONNECT tunnel. + # forward-proxy { + # host = "proxy" + # port = 8080 + # credentials { + # username = "username" + # password = "password" + # } + # trustPem { + # pemPath = "/path/to/file.pem" + # } + # } +} diff --git a/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/ForwardProxyHttpsContext.scala b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/ForwardProxyHttpsContext.scala new file mode 100644 index 0000000000..878bb0bdfa --- /dev/null +++ b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/ForwardProxyHttpsContext.scala @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit + +import akka.actor.ActorSystem +import akka.http.scaladsl.{Http, HttpsConnectionContext} + +import java.io.FileInputStream +import java.security.KeyStore +import java.security.cert.{CertificateFactory, X509Certificate} +import javax.net.ssl.{SSLContext, TrustManagerFactory} + +private[pushkit] object ForwardProxyHttpsContext { + + val SSL = "SSL" + val X509 = "X509" + + implicit class ForwardProxyHttpsContext(forwardProxy: ForwardProxy) { + + def httpsContext(system: ActorSystem) = { + forwardProxy.trustPem match { + case Some(trustPem) => createHttpsContext(trustPem) + case None => defaultHttpsContext(system) + } + } + } + + private def defaultHttpsContext(implicit system: ActorSystem) = { + Http().createDefaultClientHttpsContext() + } + + private def createHttpsContext(trustPem: ForwardProxyTrustPem) = { + val certificate = x509Certificate(trustPem) + val sslContext = SSLContext.getInstance(SSL) + + val alias = certificate.getIssuerDN.getName + val trustStore = KeyStore.getInstance(KeyStore.getDefaultType) + trustStore.load(null, null) + trustStore.setCertificateEntry(alias, certificate) + + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm) + tmf.init(trustStore) + val trustManagers = tmf.getTrustManagers + sslContext.init(null, trustManagers, null) + new HttpsConnectionContext(sslContext) + } + + private def x509Certificate(trustPem: ForwardProxyTrustPem) = { + val stream = new FileInputStream(trustPem.pemPath) + var result: X509Certificate = null + try result = CertificateFactory.getInstance(X509).generateCertificate(stream).asInstanceOf[X509Certificate] + finally if (stream != null) stream.close() + result + } + +} diff --git a/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/ForwardProxyPoolSettings.scala b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/ForwardProxyPoolSettings.scala new file mode 100644 index 0000000000..dc4762ee80 --- /dev/null +++ b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/ForwardProxyPoolSettings.scala @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit + +import akka.actor.ActorSystem +import akka.http.scaladsl.ClientTransport +import akka.http.scaladsl.model.headers.BasicHttpCredentials +import akka.http.scaladsl.settings.{ClientConnectionSettings, ConnectionPoolSettings} + +import java.net.InetSocketAddress + +private[pushkit] object ForwardProxyPoolSettings { + + implicit class ForwardProxyPoolSettings(forwardProxy: ForwardProxy) { + + def poolSettings(system: ActorSystem) = { + val address = InetSocketAddress.createUnresolved(forwardProxy.host, forwardProxy.port) + val transport = forwardProxy.credentials.fold(ClientTransport.httpsProxy(address))( + c => ClientTransport.httpsProxy(address, BasicHttpCredentials(c.username, c.password)) + ) + + ConnectionPoolSettings(system) + .withConnectionSettings( + ClientConnectionSettings(system) + .withTransport(transport) + ) + } + } + +} diff --git a/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/HmsSettings.scala b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/HmsSettings.scala new file mode 100644 index 0000000000..6a5c16a2b4 --- /dev/null +++ b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/HmsSettings.scala @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit + +import java.util.Objects +import scala.compat.java8.OptionConverters._ + +final class HmsSettings private ( + val appId: String, + val appSecret: String, + val forwardProxy: Option[ForwardProxy] = Option.empty, + val isTest: Boolean, + val maxConcurrentConnections: Int +) { + + def withAppId(value: String): HmsSettings = copy(appId = value) + def withAppSecret(value: String): HmsSettings = copy(appSecret = value) + def withIsTest(value: Boolean): HmsSettings = if (isTest == value) this else copy(isTest = value) + def withMaxConcurrentConnections(value: Int): HmsSettings = copy(maxConcurrentConnections = value) + def withForwardProxy(value: ForwardProxy): HmsSettings = copy(forwardProxy = Option(value)) + + private def copy( + appId: String = appId, + appSecret: String = appSecret, + isTest: Boolean = isTest, + maxConcurrentConnections: Int = maxConcurrentConnections, + forwardProxy: Option[ForwardProxy] = forwardProxy + ): HmsSettings = + new HmsSettings(appId = appId, + appSecret = appSecret, + isTest = isTest, + maxConcurrentConnections = maxConcurrentConnections, + forwardProxy = forwardProxy) + + override def toString = + s"""HmsSettings(appId=$appId, appSecret=$appSecret, isTest=$isTest, maxConcurrentConnections=$maxConcurrentConnections, forwardProxy=$forwardProxy)""" +} + +object ForwardProxyTrustPem { + + /** Scala API */ + def apply(pemPath: String): ForwardProxyTrustPem = + new ForwardProxyTrustPem(pemPath) + + /** Java API */ + def create(pemPath: String): ForwardProxyTrustPem = + apply(pemPath) + +} + +final class ForwardProxyTrustPem private (val pemPath: String) { + + def getPemPath: String = pemPath + + override def toString: String = + "ForwardProxyTrustPem(" + + s"pemPath=$pemPath," + + ")" + + override def equals(other: Any): Boolean = other match { + case that: ForwardProxyTrustPem => + Objects.equals(this.pemPath, that.pemPath) + case _ => false + } + + override def hashCode(): Int = + Objects.hash(pemPath) + +} + +object ForwardProxyCredentials { + + /** Scala API */ + def apply(username: String, password: String): ForwardProxyCredentials = + new ForwardProxyCredentials(username, password) + + /** Java API */ + def create(username: String, password: String): ForwardProxyCredentials = + apply(username, password) + +} + +final class ForwardProxyCredentials private (val username: String, val password: String) { + + /** Java API */ + def getUsername: String = username + + /** Java API */ + def getPassword: String = password + + def withUsername(username: String) = copy(username = username) + def withPassword(password: String) = copy(password = password) + + private def copy(username: String = username, password: String = password) = + new ForwardProxyCredentials(username, password) + + override def toString = + "ForwardProxyCredentials(" + + s"username=$username," + + s"password=******" + + ")" + + override def equals(other: Any): Boolean = other match { + case that: ForwardProxyCredentials => + Objects.equals(this.username, that.username) && + Objects.equals(this.password, that.password) + case _ => false + } + + override def hashCode(): Int = + Objects.hash(username, password) + +} + +object ForwardProxy { + + /** Scala API */ + def apply(host: String, port: Int) = + new ForwardProxy(host, port, Option.empty, Option.empty) + + def apply(host: String, port: Int, credentials: Option[ForwardProxyCredentials]) = + new ForwardProxy(host, port, credentials, Option.empty) + + def apply(host: String, + port: Int, + credentials: Option[ForwardProxyCredentials], + trustPem: Option[ForwardProxyTrustPem]) = + new ForwardProxy(host, port, credentials, trustPem) + + /** Java API */ + def create(host: String, port: Int) = + apply(host, port) + + def create(host: String, port: Int, credentials: Option[ForwardProxyCredentials]) = + apply(host, port, credentials) + + def create(host: String, + port: Int, + credentials: Option[ForwardProxyCredentials], + trustPem: Option[ForwardProxyTrustPem]) = + apply(host, port, credentials, trustPem) + +} + +final class ForwardProxy private (val host: String, + val port: Int, + val credentials: Option[ForwardProxyCredentials], + val trustPem: Option[ForwardProxyTrustPem]) { + + /** Java API */ + def getHost: String = host + + /** Java API */ + def getPort: Int = port + + /** Java API */ + def getCredentials: java.util.Optional[ForwardProxyCredentials] = credentials.asJava + + def getForwardProxyTrustPem: java.util.Optional[ForwardProxyTrustPem] = trustPem.asJava + + def withHost(host: String) = copy(host = host) + def withPort(port: Int) = copy(port = port) + def withCredentials(credentials: ForwardProxyCredentials) = copy(credentials = Option(credentials)) + + private def copy(host: String = host, + port: Int = port, + credentials: Option[ForwardProxyCredentials] = credentials, + trustPem: Option[ForwardProxyTrustPem] = trustPem) = + new ForwardProxy(host, port, credentials, trustPem) + + override def toString = + "ForwardProxy(" + + s"host=$host," + + s"port=$port," + + s"credentials=$credentials," + + s"trustPem=$trustPem" + + ")" + + override def equals(other: Any): Boolean = other match { + case that: ForwardProxy => + Objects.equals(this.host, that.host) && + Objects.equals(this.port, that.port) && + Objects.equals(this.credentials, that.credentials) && + Objects.equals(this.trustPem, that.trustPem) + case _ => false + } + + override def hashCode(): Int = + Objects.hash(host, Int.box(port), credentials) +} + +object HmsSettings { + + /** Scala API */ + def apply( + appId: String, + appSecret: String + ): HmsSettings = new HmsSettings( + appId, + appSecret, + isTest = false, + maxConcurrentConnections = 100 + ) + + def apply( + appId: String, + appSecret: String, + forwardProxy: ForwardProxy + ): HmsSettings = new HmsSettings( + appId, + appSecret, + isTest = false, + maxConcurrentConnections = 100, + forwardProxy = Option(forwardProxy) + ) + + /** Java API */ + def create(appId: String, appSecret: String): HmsSettings = { + apply(appId, appSecret) + } + + def create(appId: String, appSecret: String, forwardProxy: ForwardProxy): HmsSettings = { + apply(appId, appSecret, forwardProxy) + } +} diff --git a/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/PushKitNotificationModels.scala b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/PushKitNotificationModels.scala new file mode 100644 index 0000000000..3ad4be46cc --- /dev/null +++ b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/PushKitNotificationModels.scala @@ -0,0 +1,303 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit + +import PushKitNotificationModels._ + +object PushKitNotificationModels { + + case class Notification(title: Option[String] = None, body: Option[String] = None, image: Option[String] = None) + + case class WebNotification(title: Option[String] = None, + body: Option[String] = None, + icon: Option[String] = None, + image: Option[String] = None, + lang: Option[String] = None, + tag: Option[String] = None, + badge: Option[String] = None, + dir: Option[String] = None, + vibrate: Option[Seq[Int]] = None, + renotify: Option[Boolean] = None, + requireInteraction: Option[Boolean] = None, + silent: Option[Boolean] = None, + timestamp: Option[Long] = None, + actions: Option[String] = None) + + case class WebConfig(hmsOptions: Option[String] = None, + headers: Option[Map[String, String]] = None, + data: Option[String] = None, + notification: Option[WebNotification] = None) + + case class AndroidNotification( + title: Option[String] = None, + body: Option[String] = None, + icon: Option[String] = None, + color: Option[String] = None, + sound: Option[String] = None, + default_sound: Option[Boolean] = None, + tag: Option[String] = None, + click_action: Option[String] = None, + body_loc_key: Option[String] = None, + body_loc_args: Option[Seq[String]] = None, + title_loc_key: Option[String] = None, + title_loc_args: Option[Seq[String]] = None + ) + + case class AndroidConfig( + collapse_key: Option[Int] = None, + urgency: Option[String] = None, + category: Option[String] = None, + ttl: Option[String] = None, + bi_tag: Option[String] = None, + fast_app_target: Option[Int] = None, + data: Option[String] = None, + notification: Option[AndroidNotification] = None + ) + + case class ApnsConfig(hmsOptions: Option[String] = None, + headers: Option[String] = None, + rawPayload: Option[String] = None) + + sealed trait NotificationTarget + case class Tokens(token: Seq[String]) extends NotificationTarget + case class Topic(topic: String) extends NotificationTarget + case class Condition(conditionText: String) extends NotificationTarget + object Condition { + sealed trait ConditionBuilder { + def &&(condition: ConditionBuilder) = And(this, condition) + def ||(condition: ConditionBuilder) = Or(this, condition) + def unary_! = Not(this) + def toConditionText: String + } + case class Topic(topic: String) extends ConditionBuilder { + def toConditionText: String = s"'$topic' in topics" + } + case class And(condition1: ConditionBuilder, condition2: ConditionBuilder) extends ConditionBuilder { + def toConditionText: String = s"(${condition1.toConditionText} && ${condition2.toConditionText})" + } + case class Or(condition1: ConditionBuilder, condition2: ConditionBuilder) extends ConditionBuilder { + def toConditionText: String = s"(${condition1.toConditionText} || ${condition2.toConditionText})" + } + case class Not(condition: ConditionBuilder) extends ConditionBuilder { + def toConditionText: String = s"!(${condition.toConditionText})" + } + + def apply(builder: ConditionBuilder): Condition = + Condition(builder.toConditionText) + } +} + +case class PushKitNotification(data: Option[String] = None, + notification: Option[Notification] = None, + android: Option[AndroidConfig] = None, + apns: Option[ApnsConfig] = None, + webpush: Option[WebConfig] = None, + token: Option[Seq[String]] = None, + topic: Option[String] = None, + condition: Option[String] = None) + +case class PushKitNotificationBuilder(data: Option[String] = None, + notification: Option[Notification] = None, + android: Option[AndroidConfig] = None, + apns: Option[ApnsConfig] = None, + webpush: Option[WebConfig] = None, + token: Option[Seq[String]] = None, + topic: Option[String] = None, + condition: Option[String] = None) { + def withNotification(notification: Notification): PushKitNotificationBuilder = + this.copy(notification = Option(notification)) + def withData(data: String): PushKitNotificationBuilder = this.copy(data = Option(data)) + def withAndroidConfig(android: AndroidConfig): PushKitNotificationBuilder = this.copy(android = Option(android)) + def withApnsConfig(apns: ApnsConfig): PushKitNotificationBuilder = this.copy(apns = Option(apns)) + def withWebConfig(web: WebConfig): PushKitNotificationBuilder = this.copy(webpush = Option(web)) + def withTarget(target: NotificationTarget): PushKitNotificationBuilder = target match { + case Tokens(t) => this.copy(token = Option(t), topic = None, condition = None) + case Topic(t) => this.copy(token = None, topic = Option(t), condition = None) + case Condition(t) => this.copy(token = None, topic = None, condition = Option(t)) + } + def build: PushKitNotification = + new PushKitNotification(data, notification, android, apns, webpush, token, topic, condition) +} + +case class NotificationBuilder(title: Option[String] = None, + body: Option[String] = None, + image: Option[String] = None) { + def withTitle(value: String): NotificationBuilder = this.copy(title = Option(value)) + def withBody(value: String): NotificationBuilder = this.copy(body = Option(value)) + def withImage(value: String): NotificationBuilder = this.copy(image = Option(value)) + def build: Notification = new Notification(title, body, image) +} + +case class WebConfigBuilder(hmsOptions: Option[String] = None, + headers: Option[Map[String, String]] = None, + data: Option[String] = None, + notification: Option[WebNotification] = None) { + def withHmsOptions(value: String): WebConfigBuilder = this.copy(hmsOptions = Option(value)) + def withHeaders(headers: Map[String, String]): WebConfigBuilder = this.copy(headers = Option(headers)) + def withData(value: String): WebConfigBuilder = this.copy(data = Option(value)) + def withNotification(notification: WebNotification): WebConfigBuilder = + this.copy(notification = Option(notification)) + def build(): WebConfig = new WebConfig(hmsOptions, headers, data, notification) +} + +case class WebNotificationBuilder(title: Option[String] = None, + body: Option[String] = None, + icon: Option[String] = None, + image: Option[String] = None, + lang: Option[String] = None, + tag: Option[String] = None, + badge: Option[String] = None, + dir: Option[String] = None, + vibrate: Option[Seq[Int]] = None, + renotify: Option[Boolean] = None, + requireInteraction: Option[Boolean] = None, + silent: Option[Boolean] = None, + timestamp: Option[Long] = None, + actions: Option[String] = None) { + def withTitle(value: String): WebNotificationBuilder = this.copy(title = Option(value)) + def withBody(value: String): WebNotificationBuilder = this.copy(body = Option(value)) + def withIcon(value: String): WebNotificationBuilder = this.copy(icon = Option(value)) + def withImage(value: String): WebNotificationBuilder = this.copy(image = Option(value)) + def withLang(value: String): WebNotificationBuilder = this.copy(lang = Option(value)) + def withTag(value: String): WebNotificationBuilder = this.copy(tag = Option(value)) + def withBadge(value: String): WebNotificationBuilder = this.copy(badge = Option(value)) + def withDir(value: String): WebNotificationBuilder = this.copy(dir = Option(value)) + def withVibrate(value: Seq[Int]): WebNotificationBuilder = this.copy(vibrate = Option(value)) + def withRenotify(value: Boolean): WebNotificationBuilder = this.copy(renotify = Option(value)) + def withRequireInteraction(value: Boolean): WebNotificationBuilder = this.copy(requireInteraction = Option(value)) + def withSilent(value: Boolean): WebNotificationBuilder = this.copy(silent = Option(value)) + def withTimestamp(value: Long): WebNotificationBuilder = this.copy(timestamp = Option(value)) + def withActions(value: String): WebNotificationBuilder = this.copy(actions = Option(value)) + def build(): WebNotification = + new WebNotification(title, + body, + icon, + image, + lang, + tag, + badge, + dir, + vibrate, + renotify, + requireInteraction, + silent, + timestamp, + actions) +} + +case class AndroidNotificationBuilder(title: Option[String] = None, + body: Option[String] = None, + icon: Option[String] = None, + color: Option[String] = None, + sound: Option[String] = None, + default_sound: Option[Boolean] = None, + tag: Option[String] = None, + click_action: Option[String] = None, + body_loc_key: Option[String] = None, + body_loc_args: Option[Seq[String]] = None, + title_loc_key: Option[String] = None, + title_loc_args: Option[Seq[String]] = None) { + def withTitle(value: String): AndroidNotificationBuilder = this.copy(title = Option(value)) + def withBody(value: String): AndroidNotificationBuilder = this.copy(body = Option(value)) + def withIcon(value: String): AndroidNotificationBuilder = this.copy(icon = Option(value)) + def withColor(value: String): AndroidNotificationBuilder = this.copy(color = Option(value)) + def withSound(value: String): AndroidNotificationBuilder = this.copy(sound = Option(value)) + def withDefaultSound(value: Boolean): AndroidNotificationBuilder = this.copy(default_sound = Option(value)) + def withTag(value: String): AndroidNotificationBuilder = this.copy(tag = Option(value)) + def withClickAction(value: String): AndroidNotificationBuilder = this.copy(click_action = Option(value)) + def withBodyLocKey(value: String): AndroidNotificationBuilder = this.copy(body_loc_key = Option(value)) + def withBodyLocArgs(values: Seq[String]): AndroidNotificationBuilder = this.copy(body_loc_args = Option(values)) + def withTitleLocKey(value: String): AndroidNotificationBuilder = this.copy(title_loc_key = Option(value)) + def withTitleLocArgs(values: Seq[String]): AndroidNotificationBuilder = this.copy(title_loc_args = Option(values)) + def build(): AndroidNotification = + new AndroidNotification(title, + body, + icon, + color, + sound, + default_sound, + tag, + click_action, + body_loc_key, + body_loc_args, + title_loc_key, + title_loc_args) +} + +case class AndroidConfigBuilder(collapse_key: Option[Int] = None, + urgency: Option[String] = None, + category: Option[String] = None, + ttl: Option[String] = None, + bi_tag: Option[String] = None, + fast_app_target: Option[Int] = None, + data: Option[String] = None, + notification: Option[AndroidNotification] = None) { + def withCollapseKey(value: Int): AndroidConfigBuilder = this.copy(collapse_key = Option(value)) + def withUrgency(value: String): AndroidConfigBuilder = this.copy(urgency = Option(value)) + def withCategory(value: String): AndroidConfigBuilder = this.copy(category = Option(value)) + def withTtl(value: String): AndroidConfigBuilder = this.copy(ttl = Option(value)) + def withBiTag(value: String): AndroidConfigBuilder = this.copy(bi_tag = Option(value)) + def withFastAppTarget(value: Int): AndroidConfigBuilder = this.copy(fast_app_target = Option(value)) + def withData(value: String): AndroidConfigBuilder = this.copy(data = Option(value)) + def withNotification(notification: AndroidNotification): AndroidConfigBuilder = + this.copy(notification = Option(notification)) + def build(): AndroidConfig = + new AndroidConfig(collapse_key, urgency, category, ttl, bi_tag, fast_app_target, data, notification) +} + +case class ApnsConfigBuilder(hmsOptions: Option[String] = None, + headers: Option[String] = None, + rawPayload: Option[String] = None) { + def withHmsOptions(value: String): ApnsConfigBuilder = this.copy(hmsOptions = Option(value)) + def withHeaders(value: String): ApnsConfigBuilder = this.copy(headers = Option(value)) + def withPayload(value: String): ApnsConfigBuilder = this.copy(rawPayload = Option(value)) + def build(): ApnsConfig = new ApnsConfig(hmsOptions, headers, rawPayload) +} + +//Builder +object PushKitNotification { + val builder: PushKitNotificationBuilder = PushKitNotificationBuilder() +} + +object Notification { + val builder: NotificationBuilder = NotificationBuilder() +} + +object WebConfig { + val builder: WebConfigBuilder = WebConfigBuilder() +} + +object WebNotification { + val builder: WebNotificationBuilder = WebNotificationBuilder() +} + +object AndroidNotification { + val builder: AndroidNotificationBuilder = AndroidNotificationBuilder() +} + +object AndroidConfig { + val builder: AndroidConfigBuilder = AndroidConfigBuilder() +} + +object ApnsConfig { + val builder: ApnsConfigBuilder = ApnsConfigBuilder() +} + +//Response +sealed trait Response { + def isFailure: Boolean +} + +final case class PushKitResponse(code: String, msg: String, requestId: String) extends Response { + val isFailure = false + def getCode: String = code + def getMsg: String = msg + def getRequestId: String = requestId +} + +final case class ErrorResponse(rawError: String) extends Response { + val isFailure = true + def getRawError: String = rawError +} diff --git a/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/HmsSession.scala b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/HmsSession.scala new file mode 100644 index 0000000000..4811791269 --- /dev/null +++ b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/HmsSession.scala @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit.impl + +import akka.annotation.InternalApi +import akka.stream.Materializer +import akka.stream.alpakka.huawei.pushkit.impl.HmsTokenApi.AccessTokenExpiry + +import scala.concurrent.Future + +/** + * INTERNAL API + */ +@InternalApi +private class HmsSession(clientId: String, privateKey: String, tokenApi: HmsTokenApi) { + protected var maybeAccessToken: Option[Future[AccessTokenExpiry]] = None + + private def getNewToken()(implicit materializer: Materializer): Future[AccessTokenExpiry] = { + val accessToken = tokenApi.getAccessToken(clientId = clientId, privateKey = privateKey) + maybeAccessToken = Some(accessToken) + accessToken + } + + private def expiresSoon(g: AccessTokenExpiry): Boolean = + g.expiresAt < (tokenApi.now + 60) + + def getToken()(implicit materializer: Materializer): Future[String] = { + import materializer.executionContext + maybeAccessToken + .getOrElse(getNewToken()) + .flatMap { result => + if (expiresSoon(result)) { + getNewToken() + } else { + Future.successful(result) + } + } + .map(_.accessToken) + } +} diff --git a/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/HmsTokenApi.scala b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/HmsTokenApi.scala new file mode 100644 index 0000000000..23cd620dda --- /dev/null +++ b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/HmsTokenApi.scala @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit.impl + +import akka.actor.ActorSystem +import akka.annotation.InternalApi +import akka.http.scaladsl.HttpExt +import akka.http.scaladsl.model.{FormData, HttpMethods, HttpRequest} +import akka.http.scaladsl.unmarshalling.Unmarshal +import akka.stream.Materializer +import akka.stream.alpakka.huawei.pushkit.ForwardProxyHttpsContext.ForwardProxyHttpsContext +import akka.stream.alpakka.huawei.pushkit.ForwardProxyPoolSettings.ForwardProxyPoolSettings +import HmsTokenApi.{AccessTokenExpiry, OAuthResponse} +import akka.stream.alpakka.huawei.pushkit.ForwardProxy +import pdi.jwt.JwtTime + +import java.time.Clock +import scala.concurrent.Future + +/** + * INTERNAL API + */ +@InternalApi +private[pushkit] class HmsTokenApi(http: => HttpExt, system: ActorSystem, forwardProxy: Option[ForwardProxy]) { + import PushKitJsonSupport._ + + private val authUrl = "https://oauth-login.cloud.huawei.com/oauth2/v3/token" + + def now: Long = JwtTime.nowSeconds(Clock.systemUTC()) + + def getAccessToken(clientId: String, privateKey: String)( + implicit materializer: Materializer + ): Future[AccessTokenExpiry] = { + import materializer.executionContext + val expiresAt = now + 3600 + + val requestEntity = FormData( + "grant_type" -> "client_credentials", + "client_secret" -> privateKey, + "client_id" -> clientId + ).toEntity + + for { + response <- forwardProxy match { + case Some(fp) => + http.singleRequest(HttpRequest(HttpMethods.POST, authUrl, entity = requestEntity), + connectionContext = fp.httpsContext(system), + settings = fp.poolSettings(system)) + case None => http.singleRequest(HttpRequest(HttpMethods.POST, authUrl, entity = requestEntity)) + } + result <- Unmarshal(response.entity).to[OAuthResponse] + } yield { + AccessTokenExpiry( + accessToken = result.access_token, + expiresAt = expiresAt + ) + } + } +} + +/** + * INTERNAL API + */ +@InternalApi +private[pushkit] object HmsTokenApi { + case class AccessTokenExpiry(accessToken: String, expiresAt: Long) + case class OAuthResponse(access_token: String, token_type: String, expires_in: Int) +} diff --git a/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitFlows.scala b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitFlows.scala new file mode 100644 index 0000000000..28ed422834 --- /dev/null +++ b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitFlows.scala @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit.impl + +import akka.NotUsed +import akka.annotation.InternalApi +import akka.http.scaladsl.Http +import akka.stream.alpakka.huawei.pushkit.{HmsSettings, PushKitNotification, Response} +import akka.stream.scaladsl.Flow + +/** + * INTERNAL API + */ +@InternalApi +private[pushkit] object PushKitFlows { + + private[pushkit] def pushKit(conf: HmsSettings): Flow[PushKitNotification, Response, NotUsed] = + Flow + .fromMaterializer { (materializer, _) => + import materializer.executionContext + val http = Http()(materializer.system) + val session: HmsSession = + new HmsSession(conf.appId, conf.appSecret, new HmsTokenApi(http, materializer.system, conf.forwardProxy)) + val sender: PushKitSender = new PushKitSender() + Flow[PushKitNotification] + .mapAsync(conf.maxConcurrentConnections)( + in => + session.getToken()(materializer).flatMap { token => + sender.send(conf, token, http, PushKitSend(conf.isTest, in), materializer.system)(materializer) + } + ) + } + .mapMaterializedValue(_ => NotUsed) +} diff --git a/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitJsonSupport.scala b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitJsonSupport.scala new file mode 100644 index 0000000000..945f4b7dfe --- /dev/null +++ b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitJsonSupport.scala @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit.impl + +import akka.annotation.InternalApi +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import akka.stream.alpakka.huawei.pushkit.{ErrorResponse, PushKitResponse, Response} +import akka.stream.alpakka.huawei.pushkit.PushKitNotification +import akka.stream.alpakka.huawei.pushkit.PushKitNotificationModels._ +import akka.stream.alpakka.huawei.pushkit.impl.HmsTokenApi.OAuthResponse +import spray.json._ + +/** + * INTERNAL API + */ +@InternalApi +private[pushkit] case class PushKitSend(validate_only: Boolean, message: PushKitNotification) + +/** + * INTERNAL API + */ +@InternalApi +private[pushkit] object PushKitJsonSupport extends DefaultJsonProtocol with SprayJsonSupport { + + //custom formatters + implicit object OAuthResponseJsonFormat extends RootJsonFormat[OAuthResponse] { + override def write(c: OAuthResponse): JsValue = c.toJson(this) + override def read(value: JsValue): OAuthResponse = value match { + case JsObject(fields) if fields.contains("access_token") => + OAuthResponse(fields("access_token").convertTo[String], + fields("token_type").convertTo[String], + fields("expires_in").convertTo[Int]) + case other => throw DeserializationException(s"object containing `access_token` expected, but we get $other") + } + } + + implicit object HmsResponseJsonFormat extends RootJsonFormat[PushKitResponse] { + def write(c: PushKitResponse): JsValue = c.toJson(this) + + def read(value: JsValue) = value match { + case JsObject(fields) if fields.contains("code") && fields.contains("msg") => + PushKitResponse( + requestId = if (fields.contains("requestId")) fields("requestId").convertTo[String] else null, + code = fields("code").convertTo[String], + msg = fields("msg").convertTo[String] + ) + case other => throw DeserializationException(s"object containing `code`, `msg` expected, but we get $other") + } + } + + implicit object ErrorResponseJsonFormat extends RootJsonFormat[ErrorResponse] { + def write(c: ErrorResponse): JsValue = c.rawError.parseJson + def read(value: JsValue) = ErrorResponse(value.toString) + } + + implicit object ResponseFormat extends RootJsonReader[Response] { + def read(value: JsValue): Response = value match { + case JsObject(fields) if fields.keys.exists(_ == "code") => value.convertTo[PushKitResponse] + case JsObject(fields) if fields.keys.exists(_ != "code") => value.convertTo[ErrorResponse] + case other => throw DeserializationException(s"Response expected, but we get $other") + } + } + + //android -> huawei push kit + implicit object AndroidNotificationJsonFormat extends RootJsonFormat[AndroidNotification] { + override def write(obj: AndroidNotification): JsObject = { + val fields = scala.collection.mutable.Map[String, JsValue]() + if (obj.title.isDefined) fields += "title" -> obj.title.get.toJson + if (obj.body.isDefined) fields += "body" -> obj.body.get.toJson + if (obj.icon.isDefined) fields += "icon" -> obj.icon.get.toJson + if (obj.color.isDefined) fields += "color" -> obj.color.get.toJson + if (obj.sound.isDefined) fields += "sound" -> obj.sound.get.toJson + if (obj.default_sound.isDefined) fields += "default_sound" -> obj.default_sound.get.toJson + if (obj.tag.isDefined) fields += "tag" -> obj.tag.get.toJson + if (obj.click_action.isDefined) fields += "click_action" -> obj.click_action.get.parseJson + if (obj.body_loc_key.isDefined) fields += "body_loc_key" -> obj.body_loc_key.get.toJson + if (obj.body_loc_args.isDefined) fields += "body_loc_args" -> obj.body_loc_args.get.toJson + if (obj.title_loc_key.isDefined) fields += "title_loc_key" -> obj.title_loc_key.get.toJson + if (obj.title_loc_args.isDefined) fields += "title_loc_args" -> obj.title_loc_args.get.toJson + JsObject(fields.toMap) + } + override def read(json: JsValue): AndroidNotification = { + val map = json.asJsObject + AndroidNotification( + if (map.fields.contains("title")) Option(map.fields("title").toString) else None, + if (map.fields.contains("body")) Option(map.fields("body").toString) else None, + if (map.fields.contains("icon")) Option(map.fields("icon").toString) else None, + if (map.fields.contains("color")) Option(map.fields("color").toString) else None, + if (map.fields.contains("sound")) Option(map.fields("sound").toString) else None, + if (map.fields.contains("default_sound")) Option(map.fields("default_sound").convertTo[Boolean]) else None, + if (map.fields.contains("tag")) Option(map.fields("tag").toString) else None, + if (map.fields.contains("click_action")) Option(map.fields("click_action").toString) else None, + if (map.fields.contains("body_loc_key")) Option(map.fields("body_loc_key").toString) else None, + if (map.fields.contains("body_loc_args")) Option(map.fields("body_loc_args").convertTo[Seq[String]]) else None, + if (map.fields.contains("title_loc_key")) Option(map.fields("title_loc_key").toString) else None, + if (map.fields.contains("title_loc_args")) Option(map.fields("title_loc_args").convertTo[Seq[String]]) else None + ) + } + } + + //apns -> huawei push kit + implicit object ApnsConfigResponseJsonFormat extends RootJsonFormat[ApnsConfig] { + def write(obj: ApnsConfig): JsObject = { + val fields = scala.collection.mutable.Map[String, JsValue]() + if (obj.hmsOptions.isDefined) fields += "hms_options" -> obj.hmsOptions.get.parseJson + if (obj.headers.isDefined) fields += "headers" -> obj.headers.get.parseJson + if (obj.rawPayload.isDefined) fields += "payload" -> obj.rawPayload.get.parseJson + JsObject(fields.toMap) + } + + def read(json: JsValue): ApnsConfig = { + val map = json.asJsObject + ApnsConfig( + if (map.fields.contains("hms_options")) Option(map.fields("hms_options").toString) else None, + if (map.fields.contains("headers")) Option(map.fields("headers").toString) else None, + if (map.fields.contains("payload")) Option(map.fields("payload").toString) else None + ) + } + } + + //web -> huawei push kit + implicit object WebNotificationJsonFormat extends RootJsonFormat[WebNotification] { + override def write(obj: WebNotification): JsObject = { + val fields = scala.collection.mutable.Map[String, JsValue]() + if (obj.title.isDefined) fields += "title" -> obj.title.get.toJson + if (obj.body.isDefined) fields += "body" -> obj.body.get.toJson + if (obj.icon.isDefined) fields += "icon" -> obj.icon.get.toJson + if (obj.image.isDefined) fields += "image" -> obj.image.get.toJson + if (obj.lang.isDefined) fields += "lang" -> obj.lang.get.toJson + if (obj.tag.isDefined) fields += "tag" -> obj.tag.get.toJson + if (obj.badge.isDefined) fields += "badge" -> obj.badge.get.toJson + if (obj.dir.isDefined) fields += "dir" -> obj.dir.get.toJson + if (obj.vibrate.isDefined) fields += "vibrate" -> obj.vibrate.get.toJson + if (obj.renotify.isDefined) fields += "renotify" -> obj.renotify.get.toJson + if (obj.requireInteraction.isDefined) fields += "require_interaction" -> obj.requireInteraction.get.toJson + if (obj.silent.isDefined) fields += "silent" -> obj.silent.get.toJson + if (obj.timestamp.isDefined) fields += "timestamp" -> obj.timestamp.get.toJson + if (obj.actions.isDefined) fields += "actions" -> obj.actions.get.parseJson + JsObject(fields.toMap) + } + + override def read(json: JsValue): WebNotification = { + val map = json.asJsObject + WebNotification( + if (map.fields.contains("title")) Option(map.fields("title").toString) else None, + if (map.fields.contains("body")) Option(map.fields("body").toString) else None, + if (map.fields.contains("icon")) Option(map.fields("icon").toString) else None, + if (map.fields.contains("image")) Option(map.fields("image").toString) else None, + if (map.fields.contains("lang")) Option(map.fields("lang").toString) else None, + if (map.fields.contains("tag")) Option(map.fields("tag").toString) else None, + if (map.fields.contains("badge")) Option(map.fields("badge").toString) else None, + if (map.fields.contains("dir")) Option(map.fields("dir").toString) else None, + if (map.fields.contains("vibrate")) Option(map.fields("vibrate").convertTo[Seq[Int]]) else None, + if (map.fields.contains("renotify")) Option(map.fields("renotify").convertTo[Boolean]) else None, + if (map.fields.contains("require_interaction")) Option(map.fields("require_interaction").convertTo[Boolean]) + else None, + if (map.fields.contains("silent")) Option(map.fields("silent").convertTo[Boolean]) else None, + if (map.fields.contains("timestamp")) Option(map.fields("timestamp").convertTo[Long]) else None, + if (map.fields.contains("actions")) Option(map.fields("actions").toString()) else None + ) + } + } + + implicit object WebPushConfigJsonFormat extends RootJsonFormat[WebConfig] { + override def write(obj: WebConfig): JsObject = { + val fields = scala.collection.mutable.Map[String, JsValue]() + if (obj.hmsOptions.isDefined) fields += "hms_options" -> obj.hmsOptions.get.parseJson + if (obj.data.isDefined) fields += "data" -> obj.data.get.toJson + if (obj.headers.isDefined) fields += "headers" -> obj.headers.get.toJson + if (obj.notification.isDefined) fields += "notification" -> obj.notification.get.toJson + JsObject(fields.toMap) + } + + override def read(json: JsValue): WebConfig = { + val map = json.asJsObject + WebConfig( + if (map.fields.contains("hms_options")) Option(map.fields("hms_options").toString()) else None, + if (map.fields.contains("headers")) Option(map.fields("headers").convertTo[Map[String, String]]) else None, + if (map.fields.contains("data")) Option(map.fields("data").toString()) else None, + if (map.fields.contains("notification")) Option(map.fields("notification").convertTo[WebNotification]) else None + ) + } + } + + //app -> huawei push kit + implicit val androidConfigJsonFormat: RootJsonFormat[AndroidConfig] = jsonFormat8(AndroidConfig) + implicit val basicNotificationJsonFormat: RootJsonFormat[Notification] = jsonFormat3(Notification) + implicit val pushKitNotificationJsonFormat: RootJsonFormat[PushKitNotification] = jsonFormat8( + PushKitNotification.apply + ) + implicit val pushKitSendJsonFormat: RootJsonFormat[PushKitSend] = jsonFormat2(PushKitSend) +} diff --git a/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitSender.scala b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitSender.scala new file mode 100644 index 0000000000..7f3dd2fe81 --- /dev/null +++ b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitSender.scala @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit.impl + +import akka.actor.ActorSystem +import akka.annotation.InternalApi +import akka.http.scaladsl.HttpExt +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.headers.{Authorization, OAuth2BearerToken} +import akka.http.scaladsl.unmarshalling.Unmarshal +import akka.stream.Materializer +import akka.stream.alpakka.huawei.pushkit.ForwardProxyHttpsContext.ForwardProxyHttpsContext +import akka.stream.alpakka.huawei.pushkit.ForwardProxyPoolSettings.ForwardProxyPoolSettings +import akka.stream.alpakka.huawei.pushkit.{ErrorResponse, HmsSettings, PushKitResponse, Response} +import spray.json.enrichAny + +import scala.collection.immutable +import scala.concurrent.{ExecutionContext, Future} + +/** + * INTERNAL API + */ +@InternalApi +private[pushkit] class PushKitSender { + import PushKitJsonSupport._ + + def send(conf: HmsSettings, token: String, http: HttpExt, hmsSend: PushKitSend, system: ActorSystem)( + implicit materializer: Materializer + ): Future[Response] = { + val projectId = conf.appId + val forwardProxy = conf.forwardProxy + val url = s"https://push-api.cloud.huawei.com/v1/$projectId/messages:send" + + val response = forwardProxy match { + case Some(fp) => + http.singleRequest( + HttpRequest( + HttpMethods.POST, + url, + immutable.Seq(Authorization(OAuth2BearerToken(token))), + HttpEntity(ContentTypes.`application/json`, hmsSend.toJson.compactPrint) + ), + connectionContext = fp.httpsContext(system), + settings = fp.poolSettings(system) + ) + case None => + http.singleRequest( + HttpRequest( + HttpMethods.POST, + url, + immutable.Seq(Authorization(OAuth2BearerToken(token))), + HttpEntity(ContentTypes.`application/json`, hmsSend.toJson.compactPrint) + ) + ) + } + parse(response) + } + + private def parse(response: Future[HttpResponse])(implicit materializer: Materializer): Future[Response] = { + implicit val executionContext: ExecutionContext = materializer.executionContext + response.flatMap { rsp => + if (rsp.status.isSuccess) { + Unmarshal(rsp.entity).to[PushKitResponse] + } else { + Unmarshal(rsp.entity).to[ErrorResponse] + } + } + } +} diff --git a/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/javadsl/HmsPushKit.scala b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/javadsl/HmsPushKit.scala new file mode 100644 index 0000000000..677748c98a --- /dev/null +++ b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/javadsl/HmsPushKit.scala @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit.javadsl + +import akka.stream.alpakka.huawei.pushkit._ +import akka.stream.alpakka.huawei.pushkit.impl.PushKitFlows +import akka.stream.javadsl +import akka.{Done, NotUsed} + +import java.util.concurrent.CompletionStage + +object HmsPushKit { + + def send(conf: HmsSettings): javadsl.Flow[PushKitNotification, Response, NotUsed] = + PushKitFlows.pushKit(conf).asJava + + def fireAndForget(conf: HmsSettings): javadsl.Sink[PushKitNotification, CompletionStage[Done]] = + send(conf) + .toMat(javadsl.Sink.ignore(), javadsl.Keep.right[NotUsed, CompletionStage[Done]]) + +} diff --git a/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/scaladsl/HmsPushKit.scala b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/scaladsl/HmsPushKit.scala new file mode 100644 index 0000000000..82476ded23 --- /dev/null +++ b/huawei-push-kit/src/main/scala/akka/stream/alpakka/huawei/pushkit/scaladsl/HmsPushKit.scala @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit.scaladsl + +import akka.stream.alpakka.huawei.pushkit._ +import akka.stream.alpakka.huawei.pushkit.impl.PushKitFlows +import akka.stream.scaladsl.{Flow, Keep, Sink} +import akka.{Done, NotUsed} + +import scala.concurrent.Future + +object HmsPushKit { + + def send(conf: HmsSettings): Flow[PushKitNotification, Response, NotUsed] = + PushKitFlows.pushKit(conf) + + def fireAndForget(conf: HmsSettings): Sink[PushKitNotification, Future[Done]] = + send(conf).toMat(Sink.ignore)(Keep.right) + +} diff --git a/huawei-push-kit/src/test/java/docs/javadsl/PushKitExamples.java b/huawei-push-kit/src/test/java/docs/javadsl/PushKitExamples.java new file mode 100644 index 0000000000..e7975fe81c --- /dev/null +++ b/huawei-push-kit/src/test/java/docs/javadsl/PushKitExamples.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package docs.javadsl; + +import akka.actor.ActorSystem; + +// #imports +import akka.stream.alpakka.huawei.pushkit.*; +import akka.stream.alpakka.huawei.pushkit.javadsl.HmsPushKit; +import akka.stream.alpakka.huawei.pushkit.PushKitNotificationModels.Tokens; + +// #imports +import akka.stream.javadsl.Sink; +import akka.stream.javadsl.Source; +import scala.collection.immutable.Set; + +import java.util.List; +import java.util.concurrent.CompletionStage; + +public class PushKitExamples { + + public static void example() { + ActorSystem system = ActorSystem.create(); + + // #init-credentials + String appSecret = "a192c0f08d03216b0f03b946918d5c725bbf54264a434227928c612012eefd24"; + String appId = "105260069"; + HmsSettings hmsSettings = HmsSettings.create(appId, appSecret); + // #init-credentials + + // #simple-send + PushKitNotification notification = + PushKitNotification.builder() + .withNotification(Notification.builder().withTitle("title").withBody("body").build()) + .withAndroidConfig( + AndroidConfig.builder() + .withNotification( + AndroidNotification.builder().withClickAction("{\"type\": 3}").build()) + .build()) + .withTarget(new Tokens(new Set.Set1<>("token").toSeq())) + .build(); + + Source.single(notification).runWith(HmsPushKit.fireAndForget(hmsSettings), system); + // #simple-send + + // #asFlow-send + CompletionStage> result = + Source.single(notification) + .via(HmsPushKit.send(hmsSettings)) + .map( + res -> { + if (!res.isFailure()) { + PushKitResponse response = (PushKitResponse) res; + System.out.println("Response" + response); + } else { + ErrorResponse response = (ErrorResponse) res; + System.out.println("Send error " + response); + } + return res; + }) + .runWith(Sink.seq(), system); + // #asFlow-send + } +} diff --git a/huawei-push-kit/src/test/resources/application.conf b/huawei-push-kit/src/test/resources/application.conf new file mode 100644 index 0000000000..cda6c6a27e --- /dev/null +++ b/huawei-push-kit/src/test/resources/application.conf @@ -0,0 +1,5 @@ +akka { + loggers = ["akka.testkit.TestEventListener", "akka.event.slf4j.Slf4jLogger"] + logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" + loglevel = "INFO" +} diff --git a/huawei-push-kit/src/test/resources/logback-test.xml b/huawei-push-kit/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..3f6006f254 --- /dev/null +++ b/huawei-push-kit/src/test/resources/logback-test.xml @@ -0,0 +1,29 @@ + + + target/hms-push-kit.log + false + + %d{ISO8601} %-5level [%thread] [%logger{36}] %msg%n + + + + + + %d{HH:mm:ss.SSS} %-5level [%-20.20thread] %-36.36logger{36} %msg%n%rEx + + + + + + + + + + + + + + + + + diff --git a/huawei-push-kit/src/test/scala/akka/stream/alpakka/huawei/pushkit/ConditionBuilderSpec.scala b/huawei-push-kit/src/test/scala/akka/stream/alpakka/huawei/pushkit/ConditionBuilderSpec.scala new file mode 100644 index 0000000000..54cf700630 --- /dev/null +++ b/huawei-push-kit/src/test/scala/akka/stream/alpakka/huawei/pushkit/ConditionBuilderSpec.scala @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit + +import PushKitNotificationModels.Condition.{And, Not, Or, Topic} +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class ConditionBuilderSpec extends AnyWordSpec with Matchers { + + "ConditionBuilder" must { + + "serialize Topic as expected" in { + Topic("TopicA").toConditionText shouldBe """'TopicA' in topics""" + } + + "serialize And as expected" in { + And(Topic("TopicA"), Topic("TopicB")).toConditionText shouldBe """('TopicA' in topics && 'TopicB' in topics)""" + } + + "serialize Or as expected" in { + Or(Topic("TopicA"), Topic("TopicB")).toConditionText shouldBe """('TopicA' in topics || 'TopicB' in topics)""" + } + + "serialize Not as expected" in { + Not(Topic("TopicA")).toConditionText shouldBe """!('TopicA' in topics)""" + } + + "serialize recursively and stay correct" in { + And(Or(Topic("TopicA"), Topic("TopicB")), Or(Topic("TopicC"), Not(Topic("TopicD")))).toConditionText shouldBe + """(('TopicA' in topics || 'TopicB' in topics) && ('TopicC' in topics || !('TopicD' in topics)))""" + } + + "can use cool operators" in { + (Topic("TopicA") && (Topic("TopicB") || (Topic("TopicC") && !Topic("TopicD")))).toConditionText shouldBe + """('TopicA' in topics && ('TopicB' in topics || ('TopicC' in topics && !('TopicD' in topics))))""" + } + } + +} diff --git a/huawei-push-kit/src/test/scala/akka/stream/alpakka/huawei/pushkit/impl/HmsTokenApiSpec.scala b/huawei-push-kit/src/test/scala/akka/stream/alpakka/huawei/pushkit/impl/HmsTokenApiSpec.scala new file mode 100644 index 0000000000..2abc0c4834 --- /dev/null +++ b/huawei-push-kit/src/test/scala/akka/stream/alpakka/huawei/pushkit/impl/HmsTokenApiSpec.scala @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit.impl + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.{HttpExt, HttpsConnectionContext} +import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpRequest, HttpResponse} +import akka.http.scaladsl.settings.ConnectionPoolSettings +import akka.http.scaladsl.unmarshalling.Unmarshal +import HmsTokenApi.AccessTokenExpiry +import akka.stream.alpakka.testkit.scaladsl.LogCapturing +import akka.testkit.TestKit +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.{verify, when} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpecLike +import org.scalatestplus.mockito.MockitoSugar + +import scala.concurrent.{Await, ExecutionContext, Future} +import scala.concurrent.duration.DurationInt + +class HmsTokenApiSpec + extends TestKit(ActorSystem()) + with AnyWordSpecLike + with Matchers + with ScalaFutures + with MockitoSugar + with BeforeAndAfterAll + with LogCapturing { + + override def afterAll: Unit = + TestKit.shutdownActorSystem(system) + + implicit val defaultPatience = + PatienceConfig(timeout = 2.seconds, interval = 50.millis) + + val appSecret = "a192c0f08d03216b0f03b946918d5c725bbf54264a434227928c612012eefd24" + val appId = "105260069" + + implicit val executionContext: ExecutionContext = system.dispatcher + + "HmsTokenApi" should { + + "call the api as the docs want to" in { + + val http = mock[HttpExt] + when( + http.singleRequest(any[HttpRequest](), + any[HttpsConnectionContext](), + any[ConnectionPoolSettings](), + any[LoggingAdapter]()) + ).thenReturn( + Future.successful( + HttpResponse( + entity = HttpEntity(ContentTypes.`application/json`, + """{"access_token": "token", "token_type": "String", "expires_in": 3600}""") + ) + ) + ) + + val api = new HmsTokenApi(http, system, Option.empty) + Await.result(api.getAccessToken(appId, appSecret), defaultPatience.timeout) + + val captor: ArgumentCaptor[HttpRequest] = ArgumentCaptor.forClass(classOf[HttpRequest]) + verify(http).singleRequest(captor.capture(), + any[HttpsConnectionContext](), + any[ConnectionPoolSettings](), + any[LoggingAdapter]()) + val request: HttpRequest = captor.getValue + + request.uri.toString() shouldBe "https://oauth-login.cloud.huawei.com/oauth2/v3/token" + val data = Unmarshal(request.entity).to[String].futureValue + data should startWith( + "grant_type=client_credentials&client_secret=a192c0f08d03216b0f03b946918d5c725bbf54264a434227928c612012eefd24&client_id=105260069" + ) + } + + "return the token" in { + val http = mock[HttpExt] + when( + http.singleRequest(any[HttpRequest](), + any[HttpsConnectionContext](), + any[ConnectionPoolSettings](), + any[LoggingAdapter]()) + ).thenReturn( + Future.successful( + HttpResponse( + entity = HttpEntity(ContentTypes.`application/json`, + """{"access_token": "token", "token_type": "String", "expires_in": 3600}""") + ) + ) + ) + + val api = new HmsTokenApi(http, system, Option.empty) + api.getAccessToken(appId, appSecret).futureValue should matchPattern { + case AccessTokenExpiry("token", exp) if exp > (System.currentTimeMillis / 1000L + 3000L) => + } + } + } + +} diff --git a/huawei-push-kit/src/test/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitSenderSpec.scala b/huawei-push-kit/src/test/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitSenderSpec.scala new file mode 100644 index 0000000000..85660a9cf4 --- /dev/null +++ b/huawei-push-kit/src/test/scala/akka/stream/alpakka/huawei/pushkit/impl/PushKitSenderSpec.scala @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package akka.stream.alpakka.huawei.pushkit.impl + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.{HttpExt, HttpsConnectionContext} +import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpHeader, HttpRequest, HttpResponse, StatusCodes} +import akka.http.scaladsl.settings.ConnectionPoolSettings +import akka.http.scaladsl.unmarshalling.Unmarshal +import akka.stream.alpakka.huawei.pushkit.{ErrorResponse, HmsSettings, PushKitNotification, PushKitResponse} +import akka.stream.alpakka.testkit.scaladsl.LogCapturing +import akka.testkit.TestKit +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.{verify, when} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.matchers.should.Matchers +import org.scalatest.time.SpanSugar.convertIntToGrainOfTime +import org.scalatest.wordspec.AnyWordSpecLike +import org.scalatestplus.mockito.MockitoSugar + +import scala.concurrent.{Await, ExecutionContext, Future} + +class PushKitSenderSpec + extends TestKit(ActorSystem()) + with AnyWordSpecLike + with Matchers + with ScalaFutures + with MockitoSugar + with BeforeAndAfterAll + with LogCapturing { + + import PushKitJsonSupport._ + + val appSecret = "a192c0f08d03216b0f03b946918d5c725bbf54264a434227928c612012eefd24" + val appId = "105260069" + + override def afterAll: Unit = + TestKit.shutdownActorSystem(system) + + implicit val defaultPatience = + PatienceConfig(timeout = 2.seconds, interval = 50.millis) + + implicit val executionContext: ExecutionContext = system.dispatcher + + implicit val conf = HmsSettings.create(appId, appSecret) + + "HmsSender" should { + + "call the api as the docs want to" in { + val sender = new PushKitSender + val http = mock[HttpExt] + when( + http.singleRequest(any[HttpRequest](), + any[HttpsConnectionContext](), + any[ConnectionPoolSettings](), + any[LoggingAdapter]()) + ).thenReturn( + Future.successful( + HttpResponse( + entity = + HttpEntity(ContentTypes.`application/json`, """{"code": "", "msg": "", "requestId": ""}""".stripMargin) + ) + ) + ) + + Await.result(sender.send(conf, "token", http, PushKitSend(false, PushKitNotification.builder.build), system), + defaultPatience.timeout) + + val captor: ArgumentCaptor[HttpRequest] = ArgumentCaptor.forClass(classOf[HttpRequest]) + verify(http).singleRequest(captor.capture(), + any[HttpsConnectionContext](), + any[ConnectionPoolSettings](), + any[LoggingAdapter]()) + val request: HttpRequest = captor.getValue + Unmarshal(request.entity).to[PushKitSend].futureValue shouldBe PushKitSend(false, + PushKitNotification.builder.build) + request.uri.toString shouldBe "https://push-api.cloud.huawei.com/v1/" + appId + "/messages:send" + request.headers.size shouldBe 1 + request.headers.head should matchPattern { case HttpHeader("authorization", "Bearer token") => } + } + + "parse the success response correctly" in { + val sender = new PushKitSender + val http = mock[HttpExt] + when( + http.singleRequest(any[HttpRequest](), + any[HttpsConnectionContext](), + any[ConnectionPoolSettings](), + any[LoggingAdapter]()) + ).thenReturn( + Future.successful( + HttpResponse( + entity = HttpEntity(ContentTypes.`application/json`, + """{"code": "80000000", "msg": "Success", "requestId": "1357"}""") + ) + ) + ) + + sender + .send(conf, "token", http, PushKitSend(false, PushKitNotification.builder.build), system) + .futureValue shouldBe PushKitResponse("80000000", "Success", "1357") + } + + "parse the error response correctly" in { + val sender = new PushKitSender + val http = mock[HttpExt] + when( + http.singleRequest(any[HttpRequest](), + any[HttpsConnectionContext](), + any[ConnectionPoolSettings](), + any[LoggingAdapter]()) + ).thenReturn( + Future.successful( + HttpResponse( + status = StatusCodes.ServiceUnavailable, + entity = HttpEntity(ContentTypes.`application/json`, + """{"code": "80100003", "msg": "Illegal payload", "requestId": "1357"}""") + ) + ) + ) + + sender + .send(conf, "token", http, PushKitSend(false, PushKitNotification.builder.build), system) + .futureValue shouldBe ErrorResponse("""{"code":"80100003","msg":"Illegal payload","requestId":"1357"}""") + } + } +} diff --git a/huawei-push-kit/src/test/scala/docs/scaladsl/PushKitExamples.scala b/huawei-push-kit/src/test/scala/docs/scaladsl/PushKitExamples.scala new file mode 100644 index 0000000000..4b8ac1e884 --- /dev/null +++ b/huawei-push-kit/src/test/scala/docs/scaladsl/PushKitExamples.scala @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2016-2020 Lightbend Inc. + */ + +package docs.scaladsl + +import akka.actor.ActorSystem +//#imports +import akka.stream.alpakka.huawei.pushkit._ +import akka.stream.alpakka.huawei.pushkit.scaladsl.HmsPushKit +import akka.stream.alpakka.huawei.pushkit.PushKitNotificationModels.Condition +import akka.stream.alpakka.huawei.pushkit.PushKitNotificationModels.Tokens + +//#imports +import akka.stream.scaladsl.Source +import akka.stream.scaladsl.Sink + +import scala.collection.immutable +import scala.concurrent.Future + +class PushKitExamples { + + implicit val system = ActorSystem() + + // #init-credentials + val appSecret = "a192c0f08d03216b0f03b946918d5c725bbf54264a434227928c612012eefd24" + val appId = "105260069" + val hmsSettings: HmsSettings = HmsSettings.create(appId, appSecret) + // #init-credentials + + //#simple-send + val notification: PushKitNotification = + PushKitNotification.builder + .withNotification( + Notification.builder + .withTitle("title") + .withBody("body") + .build + ) + .withAndroidConfig( + AndroidConfig.builder + .withNotification( + AndroidNotification.builder + .withClickAction("{\"type\": 3}") + .build() + ) + .build() + ) + .withTarget(Tokens(Set[String]("token").toSeq)) + .build + + Source + .single(notification) + .runWith(HmsPushKit.fireAndForget(hmsSettings)) + //#simple-send + + //#asFlow-send + val result1: Future[immutable.Seq[Response]] = + Source + .single(notification) + .via(HmsPushKit.send(hmsSettings)) + .map { + case res @ PushKitResponse(code, msg, requestId) => + println(s"Response $res") + res + case res @ ErrorResponse(errorMessage) => + println(s"Send error $res") + res + } + .runWith(Sink.seq) + //#asFlow-send + + //#condition-builder + import akka.stream.alpakka.huawei.pushkit.PushKitNotificationModels.Condition.{Topic => CTopic} + val condition = Condition(CTopic("TopicA") && (CTopic("TopicB") || (CTopic("TopicC") && !CTopic("TopicD")))) + //#condition-builder +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index b35e216bc9..7d7de5a84c 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -293,6 +293,14 @@ object Dependencies { ) ) + val HuaweiPushKit = Seq( + libraryDependencies ++= Seq( + "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion, + "com.typesafe.akka" %% "akka-http-spray-json" % AkkaHttpVersion, + "com.pauldijou" %% "jwt-core" % JwtCoreVersion // ApacheV2 + ) ++ Mockito ++ Silencer + ) + val InfluxDB = Seq( libraryDependencies ++= Seq( "org.influxdb" % "influxdb-java" % InfluxDBJavaVersion // MIT diff --git a/project/project-info.conf b/project/project-info.conf index 6cc38d54ac..d41b1a2440 100644 --- a/project/project-info.conf +++ b/project/project-info.conf @@ -430,6 +430,24 @@ project-info { } ] } + huawei-push-kit: ${project-info.shared-info} { + title: "Alpakka HUAWEI Push Kit" + jpms-name: "akka.stream.alpakka.huawei.pushkit" + issues.url: ${project-info.labels}"huawei-push-kit" + levels: [ + { + readiness: CommunityDriven + since: "2021-03-26" + since-version: "0.1" + } + ] + api-docs: [ + { + url: ${project-info.scaladoc}"huawei.pushkit/index.html" + text: "API (Scaladoc)" + } + ] + } ironmq: ${project-info.shared-info} { title: "Alpakka IronMQ" jpms-name: "akka.stream.alpakka.ironmq"