Skip to content

Commit

Permalink
Merge pull request #56 from dietmap/multitenancy
Browse files Browse the repository at this point in the history
Add support for multiple Google Play accounts handled on a single yaak instance
  • Loading branch information
wojtekbauman authored Jun 24, 2021
2 parents fb5addd + d45e133 commit 4821e3a
Show file tree
Hide file tree
Showing 6 changed files with 133 additions and 50 deletions.
3 changes: 2 additions & 1 deletion src/main/kotlin/com/dietmap/yaak/api/config/ApiCommons.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.dietmap.yaak.api.config

object ApiCommons {
const val API_KEY_HEADER : String = "Authorization"
const val API_KEY_HEADER: String = "Authorization"
const val TENANT_HEADER: String = "X-Tenant"
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
package com.dietmap.yaak.api.googleplay

import com.dietmap.yaak.api.config.ApiCommons.TENANT_HEADER
import com.dietmap.yaak.domain.googleplay.GooglePlaySubscriptionService
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.api.services.androidpublisher.model.SubscriptionPurchase
import mu.KotlinLogging
import org.apache.commons.codec.binary.Base64
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.*
import org.springframework.web.reactive.function.client.WebClientResponseException
import org.springframework.web.server.ResponseStatusException
import java.nio.charset.StandardCharsets.UTF_8
Expand All @@ -25,35 +24,47 @@ class GooglePlaySubscriptionController(val subscriptionService: GooglePlaySubscr
private val logger = KotlinLogging.logger { }

@PostMapping("/api/googleplay/subscriptions/purchases")
fun purchase(@RequestBody @Valid purchaseRequest: PurchaseRequest): SubscriptionPurchase? {
fun purchase(
@RequestHeader(TENANT_HEADER, required = false) tenant: String?,
@RequestBody @Valid purchaseRequest: PurchaseRequest
): SubscriptionPurchase? {
logger.info { "Received purchase request from Google Play: $purchaseRequest" }
try {
return subscriptionService.handlePurchase(purchaseRequest)
return subscriptionService.handlePurchase(purchaseRequest, tenant)
} catch (e: WebClientResponseException) {
logger.error(e) { "Error sending notification to user app" }
throw ResponseStatusException(e.statusCode, "Error sending notification to user app", e)
}
}

@PostMapping("/api/googleplay/subscriptions/cancel")
fun cancelSubscriptionPurchase(@RequestBody @Valid cancelRequest: SubscriptionCancelRequest) {
fun cancelSubscriptionPurchase(
@RequestHeader(TENANT_HEADER, required = false) tenant: String?,
@RequestBody @Valid cancelRequest: SubscriptionCancelRequest
) {
logger.info { "Received subscription purchase cancellation request: $cancelRequest" }
subscriptionService.cancelPurchase(cancelRequest)
subscriptionService.cancelPurchase(cancelRequest, tenant)
}

@PostMapping("/api/googleplay/subscriptions/orders/verify")
fun verifyOrders(@RequestBody @Valid ordersRequest: VerifyOrdersRequest): VerifyOrdersResponse {
fun verifyOrders(
@RequestHeader(TENANT_HEADER, required = false) tenant: String?,
@RequestBody @Valid ordersRequest: VerifyOrdersRequest
): VerifyOrdersResponse {
logger.info { "Received user orders for verification: ${ordersRequest.orders}" }
return VerifyOrdersResponse(subscriptionService.verifyOrders(ordersRequest.orders))
return VerifyOrdersResponse(subscriptionService.verifyOrders(ordersRequest.orders, tenant))
}

/**
* Publicly accessible PubSub notification webhook
*/
@PostMapping("/public/api/googleplay/subscriptions/notifications")
fun update(@RequestBody pubsubRequest: PubSubRequest) {
@PostMapping("/public/api/googleplay/subscriptions/notifications/{tenant}")
fun update(
@PathVariable("tenant") tenant: String,
@RequestBody pubsubRequest: PubSubRequest
) {
logger.info { "Received Google PubSub subscription notification: $pubsubRequest" }
subscriptionService.handleSubscriptionNotification(pubsubRequest.message.developerNotification)
subscriptionService.handleSubscriptionNotification(pubsubRequest.message.developerNotification, tenant)
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.google.common.base.Strings
import mu.KotlinLogging
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.ConstructorBinding
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.stereotype.Component
Expand All @@ -23,12 +24,25 @@ import java.util.*


@Component
@ConfigurationProperties(prefix = "yaak.googleplay")
@ConstructorBinding
@ConfigurationProperties(prefix = "yaak")
class GoogleDeveloperApiClientProperties {
lateinit var serviceAccountApiKeyBase64: String
lateinit var serviceAccountEmail: String
lateinit var applicationName: String;
lateinit var googleplay: GooglePlayProperties
var multitenant: List<MultitenantGooglePlayProperties> = emptyList()
}

@ConstructorBinding
class MultitenantGooglePlayProperties(
val tenant: String,
val googleplay: GooglePlayProperties
)

@ConstructorBinding
class GooglePlayProperties(
val serviceAccountApiKeyBase64: String,
val serviceAccountEmail: String,
val applicationName: String
) {
fun getServiceAccountApiKeyInputStream(): InputStream {
val decoded = Base64.getDecoder().decode(serviceAccountApiKeyBase64)
return ByteArrayInputStream(decoded)
Expand Down Expand Up @@ -56,23 +70,27 @@ class AndroidPublisherClientConfiguration(val properties: GoogleDeveloperApiClie
/** Global instance of the HTTP transport. */
private var HTTP_TRANSPORT: HttpTransport? = null

companion object {
const val DEFAULT_TENANT = "DEFAULT"
}

@Bean
@Throws(IOException::class, GeneralSecurityException::class)
fun androidPublisherApiClient(): AndroidPublisher {
Preconditions.checkArgument(!Strings.isNullOrEmpty(properties.applicationName),
"applicationName cannot be null or empty!")
// Authorization.
newTrustedTransport()
val credential = authorizeWithServiceAccount(properties.serviceAccountEmail)
fun androidPublishers() = properties.multitenant
.associate { m -> m.tenant to createAndroidPublisher(m.googleplay) }
.plus(DEFAULT_TENANT to createAndroidPublisher(properties.googleplay))

// Set up and return API client.
return AndroidPublisher.Builder(
HTTP_TRANSPORT!!, JSON_FACTORY, credential).setApplicationName(properties.applicationName)
.build()
private fun createAndroidPublisher(properties: GooglePlayProperties): AndroidPublisher {
Preconditions.checkArgument(!Strings.isNullOrEmpty(properties.applicationName), "applicationName cannot be null or empty!")
newTrustedTransport()
return AndroidPublisher
.Builder(HTTP_TRANSPORT!!, JSON_FACTORY, authorizeWithServiceAccount(properties.serviceAccountEmail, properties))
.setApplicationName(properties.applicationName)
.build()
}

@Throws(GeneralSecurityException::class, IOException::class)
private fun authorizeWithServiceAccount(serviceAccountEmail: String): Credential {
private fun authorizeWithServiceAccount(serviceAccountEmail: String, properties: GooglePlayProperties): Credential {
logger.info { "Authorizing using Service Account: $serviceAccountEmail" }
// Build service account credential.
return GoogleCredential.fromStream(properties.getServiceAccountApiKeyInputStream(), HTTP_TRANSPORT, JSON_FACTORY)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.dietmap.yaak.domain.googleplay

import com.dietmap.yaak.domain.googleplay.AndroidPublisherClientConfiguration.Companion.DEFAULT_TENANT
import com.google.api.services.androidpublisher.AndroidPublisher
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Service

@ConditionalOnProperty("yaak.google-play.enabled", havingValue = "true")
@Service
class AndroidPublisherService(private val androidPublishers: Map<String, AndroidPublisher>) {

fun tenant(tenant: String?) = androidPublishers.getOrDefault(tenant?.toUpperCase() ?: DEFAULT_TENANT, androidPublishers[DEFAULT_TENANT])!!

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,27 @@ import com.dietmap.yaak.api.googleplay.PurchaseRequest
import com.dietmap.yaak.api.googleplay.SubscriptionCancelRequest
import com.dietmap.yaak.domain.checkArgument
import com.dietmap.yaak.domain.userapp.*
import com.google.api.services.androidpublisher.AndroidPublisher
import com.google.api.services.androidpublisher.model.SubscriptionPurchase
import com.google.api.services.androidpublisher.model.SubscriptionPurchasesAcknowledgeRequest
import mu.KotlinLogging
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Service
import java.math.BigDecimal


@ConditionalOnBean(AndroidPublisher::class)
@ConditionalOnProperty("yaak.google-play.enabled", havingValue = "true")
@Service
class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublisher, val userAppClient: UserAppClient) {
class GooglePlaySubscriptionService(val androidPublisherService: AndroidPublisherService, val userAppClient: UserAppClient) {

private val PAYMENT_RECEIVED_CODE = 1
private val PAYMENT_FREE_TRIAL_CODE = 2
private val USER_ACCOUNT_ID_KEY = "obfuscatedExternalAccountId"
private val USER_APP_STATUS_ACTIVE = "ACTIVE"
private val logger = KotlinLogging.logger { }

fun handlePurchase(purchaseRequest: PurchaseRequest): SubscriptionPurchase? {
val subscription = androidPublisherApiClient.purchases().subscriptions().get(purchaseRequest.packageName, purchaseRequest.subscriptionId, purchaseRequest.purchaseToken).execute()
fun handlePurchase(purchaseRequest: PurchaseRequest, tenant: String? = null): SubscriptionPurchase? {
val subscription = androidPublisherService.tenant(tenant).purchases().subscriptions()
.get(purchaseRequest.packageName, purchaseRequest.subscriptionId, purchaseRequest.purchaseToken).execute()
checkArgument(subscription.paymentState in listOf(PAYMENT_RECEIVED_CODE, PAYMENT_FREE_TRIAL_CODE)) { "Subscription has not been paid yet, paymentState=${subscription.paymentState}" }

logger.info { "Handling purchase: $subscription, initial: ${subscription.isInitialPurchase()}" }
Expand All @@ -54,29 +54,31 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis
if (subscription.acknowledgementState == 0) {
logger.info { "Acknowledging Google Play subscription purchase of id=${subscription.orderId}, purchaseToken=${purchaseRequest.purchaseToken}" }
val content = SubscriptionPurchasesAcknowledgeRequest().setDeveloperPayload("{ applicationOrderId: ${notificationResponse?.orderId}, orderingUserId: ${purchaseRequest.orderingUserId} }")
androidPublisherApiClient.Purchases().Subscriptions().acknowledge(purchaseRequest.packageName, purchaseRequest.subscriptionId, purchaseRequest.purchaseToken, content).execute()
androidPublisherService.tenant(tenant).purchases().subscriptions()
.acknowledge(purchaseRequest.packageName, purchaseRequest.subscriptionId, purchaseRequest.purchaseToken, content).execute()
}
return subscription
}

fun cancelPurchase(cancelRequest: SubscriptionCancelRequest) {
fun cancelPurchase(cancelRequest: SubscriptionCancelRequest, tenant: String? = null) {
logger.info { "Cancelling subscription: $cancelRequest" }
try {
androidPublisherApiClient.purchases().subscriptions().cancel(cancelRequest.packageName, cancelRequest.subscriptionId, cancelRequest.purchaseToken).execute()
androidPublisherService.tenant(tenant).purchases().subscriptions()
.cancel(cancelRequest.packageName, cancelRequest.subscriptionId, cancelRequest.purchaseToken).execute()
} catch (e: Exception) {
logger.error(e) { "Error occurred during an attempt to cancel Google Play subscription: $cancelRequest" }
throw e
}
}

fun handleSubscriptionNotification(pubsubNotification: PubSubDeveloperNotification) {
fun handleSubscriptionNotification(pubsubNotification: PubSubDeveloperNotification, tenant: String) {
pubsubNotification.subscriptionNotification?.let {
logger.info { "Handling PubSub notification of type: ${it.notificationType}" }
try {
when (it.notificationType) {
SUBSCRIPTION_PURCHASED -> handlePurchase(PurchaseRequest(pubsubNotification.packageName, it.subscriptionId, it.purchaseToken))
SUBSCRIPTION_RENEWED -> handlePurchase(PurchaseRequest(pubsubNotification.packageName, it.subscriptionId, it.purchaseToken))
else -> handleStatusUpdate(pubsubNotification.packageName, it)
SUBSCRIPTION_PURCHASED -> handlePurchase(PurchaseRequest(pubsubNotification.packageName, it.subscriptionId, it.purchaseToken), tenant)
SUBSCRIPTION_RENEWED -> handlePurchase(PurchaseRequest(pubsubNotification.packageName, it.subscriptionId, it.purchaseToken), tenant)
else -> handleStatusUpdate(pubsubNotification.packageName, it, tenant)
}
} catch (e: Exception) {
logger.error(e) { "Error handling PubSub notification" }
Expand All @@ -85,22 +87,23 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis
}
}

fun verifyOrders(orders: Collection<PurchaseRequest>): Boolean {
fun verifyOrders(orders: Collection<PurchaseRequest>, tenant: String?): Boolean {
orders
.map(::tryToVerifyOrder)
.also { logger.info { "Verified ${it.size} user orders" } }
.map { o -> tryToVerifyOrder(o, tenant) }
.also { logger.info { "Verified ${it.size} user orders" } }
return userAppClient.checkSubscription()?.status == USER_APP_STATUS_ACTIVE
}

private fun tryToVerifyOrder(purchaseRequest: PurchaseRequest) = try {
private fun tryToVerifyOrder(purchaseRequest: PurchaseRequest, tenant: String?) = try {
logger.debug { "About to verify user order: $purchaseRequest" }
handlePurchase(purchaseRequest)
handlePurchase(purchaseRequest, tenant)
} catch (e: Exception) {
logger.error(e) { "Error during verification of user order" }
}

private fun handleStatusUpdate(packageName: String, notification: GooglePlaySubscriptionNotification) {
val subscription = androidPublisherApiClient.Purchases().Subscriptions().get(packageName, notification.subscriptionId, notification.purchaseToken).execute()
private fun handleStatusUpdate(packageName: String, notification: GooglePlaySubscriptionNotification, tenant: String?) {
val subscription = androidPublisherService.tenant(tenant).purchases().subscriptions()
.get(packageName, notification.subscriptionId, notification.purchaseToken).execute()
logger.debug { "Google Play subscription details: $subscription" }
subscription.cancelReason?.let { logger.info { "Subscription cancel reason: $it" } }
subscription.cancelSurveyResult?.let { logger.info { "Subscription cancel survey result: $it" } }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package com.dietmap.yaak.api.googleplay

import com.dietmap.yaak.SupportController
import com.dietmap.yaak.api.config.ApiCommons
import com.dietmap.yaak.domain.googleplay.AndroidPublisherClientConfiguration
import com.dietmap.yaak.domain.googleplay.GooglePlaySubscriptionService
import com.nimbusds.jose.util.Base64
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.BDDMockito.`when`
import org.mockito.Captor
import org.mockito.Mockito
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.HttpStatus
Expand All @@ -18,11 +22,16 @@ import org.springframework.web.server.ResponseStatusException

@TestPropertySource(properties = ["yaak.google-play.enabled = true"])
internal class GooglePlaySubscriptionControllerTest : SupportController() {

@MockBean
lateinit var config: AndroidPublisherClientConfiguration

@MockBean
lateinit var subscriptionService: GooglePlaySubscriptionService

@Captor
lateinit var tenantCaptor: ArgumentCaptor<String?>

@Test
fun `should handle PubSub requests`() {
val testNotification = PubSubDeveloperNotification(
Expand All @@ -36,7 +45,7 @@ internal class GooglePlaySubscriptionControllerTest : SupportController() {
val request = PubSubRequest("app.subscription", PubSubMessage("message.id", base64Data.toString()))

mockMvc.perform(
post("/public/api/googleplay/subscriptions/notifications")
post("/public/api/googleplay/subscriptions/notifications/sample-tenant")
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding("UTF-8")
.content(asJsonString(request))
Expand Down Expand Up @@ -85,4 +94,31 @@ internal class GooglePlaySubscriptionControllerTest : SupportController() {
Mockito.verify(subscriptionService).cancelPurchase(request)
}

}
@Test
fun `should handle multiple tenants`() {
val request = PurchaseRequest(
packageName = "app.package",
subscriptionId = "app.subscription.id",
purchaseToken = "purchase.token",
orderingUserId = "1",
discountCode = "234"
)
`when`(subscriptionService.handlePurchase(any(), capture(tenantCaptor))).thenReturn(null)

mockMvc.perform(
post("/api/googleplay/subscriptions/purchases")
.contentType(MediaType.APPLICATION_JSON)
.header(ApiCommons.TENANT_HEADER, "sample-tenant")
.characterEncoding("UTF-8")
.content(asJsonString(request))
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)

assertThat(tenantCaptor.value).isEqualTo("sample-tenant")
}

}

inline fun <reified T> any(): T = Mockito.any(T::class.java)

fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()

0 comments on commit 4821e3a

Please sign in to comment.