From 6cf8e40514f6a9f0c6f5af6da5087473161c1a25 Mon Sep 17 00:00:00 2001 From: Wojciech Bauman Date: Sun, 7 Nov 2021 15:09:34 +0100 Subject: [PATCH] Add support for multiple App Store accounts handled on a single yaak instance --- .../api/appstore/receipt/ReceiptController.kt | 40 +++--- .../subscription/SubscriptionController.kt | 39 +++--- .../yaak/domain/appstore/AppStoreClient.kt | 122 ++++++++++-------- .../appstore/AppStoreSubscriptionService.kt | 22 +++- .../googleplay/AndroidPublisherApiClient.kt | 14 +- .../GooglePlaySubscriptionService.kt | 12 +- 6 files changed, 133 insertions(+), 116 deletions(-) diff --git a/src/main/kotlin/com/dietmap/yaak/api/appstore/receipt/ReceiptController.kt b/src/main/kotlin/com/dietmap/yaak/api/appstore/receipt/ReceiptController.kt index 55c9a40..598b259 100644 --- a/src/main/kotlin/com/dietmap/yaak/api/appstore/receipt/ReceiptController.kt +++ b/src/main/kotlin/com/dietmap/yaak/api/appstore/receipt/ReceiptController.kt @@ -1,33 +1,39 @@ package com.dietmap.yaak.api.appstore.receipt -import com.dietmap.yaak.domain.appstore.AppStoreClient +import com.dietmap.yaak.api.config.ApiCommons.TENANT_HEADER +import com.dietmap.yaak.domain.appstore.AppStoreSubscriptionService import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import javax.validation.Valid @ConditionalOnProperty("yaak.app-store.enabled", havingValue = "true") @RestController @RequestMapping("/api/appstore/receipts") -class ReceiptController(private val appStoreClient : AppStoreClient) { +class ReceiptController(private val subscriptionService: AppStoreSubscriptionService) { - @PostMapping - fun verify(@RequestBody @Valid receiptRequest: ReceiptRequest) : ResponseEntity { - val receiptResponse = appStoreClient.verifyReceipt(receiptRequest) - - return if (receiptResponse.isValid()) ResponseEntity.ok("VALID") - else ResponseEntity.ok("NOT_VALID") + companion object { + private const val VALID = "VALID" + private const val NOT_VALID = "NOT_VALID" } + @PostMapping + fun verify( + @RequestBody @Valid receiptRequest: ReceiptRequest, + @RequestHeader(TENANT_HEADER, required = false) tenant: String? + ): ResponseEntity = + if (subscriptionService.verifyReceipt(tenant, receiptRequest).isValid()) ResponseEntity.ok(VALID) else ResponseEntity.ok(NOT_VALID) + @PostMapping("/verify") - fun verifyWithResponse(@RequestBody @Valid receiptRequest: ReceiptRequest) : ResponseEntity { - val receiptResponse = appStoreClient.verifyReceipt(receiptRequest) + fun verifyWithResponse( + @RequestBody @Valid receiptRequest: ReceiptRequest, + @RequestHeader(TENANT_HEADER, required = false) tenant: String? + ): ResponseEntity = + subscriptionService.verifyReceipt(tenant, receiptRequest) + .let { + if (it.isValid()) ResponseEntity.ok(ReceiptValidationResponse(it, VALID)) + else ResponseEntity.ok(ReceiptValidationResponse(it, NOT_VALID)) + } - return if (receiptResponse.isValid()) ResponseEntity.ok(ReceiptValidationResponse(receiptResponse, "VALID")) - else ResponseEntity.ok(ReceiptValidationResponse(receiptResponse, "NOT_VALID")) - } } \ No newline at end of file diff --git a/src/main/kotlin/com/dietmap/yaak/api/appstore/subscription/SubscriptionController.kt b/src/main/kotlin/com/dietmap/yaak/api/appstore/subscription/SubscriptionController.kt index c831b83..56212cd 100644 --- a/src/main/kotlin/com/dietmap/yaak/api/appstore/subscription/SubscriptionController.kt +++ b/src/main/kotlin/com/dietmap/yaak/api/appstore/subscription/SubscriptionController.kt @@ -1,5 +1,6 @@ package com.dietmap.yaak.api.appstore.subscription +import com.dietmap.yaak.api.config.ApiCommons.TENANT_HEADER import com.dietmap.yaak.domain.appstore.AppStoreSubscriptionService import com.dietmap.yaak.domain.checkArgument import com.dietmap.yaak.domain.userapp.UserAppSubscriptionOrder @@ -7,10 +8,7 @@ import mu.KotlinLogging import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import javax.validation.Valid @@ -19,27 +17,29 @@ import javax.validation.Valid @RequestMapping("/api/appstore/subscriptions") class SubscriptionController(private val subscriptionService: AppStoreSubscriptionService) { - private val logger = KotlinLogging.logger { } + companion object { + private val logger = KotlinLogging.logger { } + } @PostMapping("/purchase") - fun handleInitialPurchase(@RequestBody @Valid subscriptionPurchaseRequest: SubscriptionPurchaseRequest): ResponseEntity { + fun handleInitialPurchase( + @RequestBody @Valid subscriptionPurchaseRequest: SubscriptionPurchaseRequest, + @RequestHeader(TENANT_HEADER, required = false) tenant: String? + ): ResponseEntity { logger.debug { "handleInitialPurchase: $subscriptionPurchaseRequest" } - - val subscriptionOrder = subscriptionService.handleInitialPurchase(subscriptionPurchaseRequest) - + val subscriptionOrder = subscriptionService.handleInitialPurchase(tenant, subscriptionPurchaseRequest) checkArgument(subscriptionOrder != null) { "Could not process SubscriptionPurchaseRequest $subscriptionPurchaseRequest in user app" } - logger.debug { "handleInitialPurchase: $subscriptionOrder" } - - return ResponseEntity.ok(subscriptionOrder !!) + return ResponseEntity.ok(subscriptionOrder!!) } @PostMapping("/renew") - fun handleAutoRenewal(@RequestBody @Valid subscriptionRenewRequest: SubscriptionRenewRequest): ResponseEntity { + fun handleAutoRenewal( + @RequestBody @Valid subscriptionRenewRequest: SubscriptionRenewRequest, + @RequestHeader(TENANT_HEADER, required = false) tenant: String? + ): ResponseEntity { logger.debug { "handleAutoRenewal: $subscriptionRenewRequest" } - - subscriptionService.handleAutoRenewal(subscriptionRenewRequest) - + subscriptionService.handleAutoRenewal(tenant, subscriptionRenewRequest) return ResponseEntity.ok().build() } @@ -49,20 +49,15 @@ class SubscriptionController(private val subscriptionService: AppStoreSubscripti @PostMapping("/statusUpdateNotification") fun handleStatusUpdateNotification(@Valid @RequestBody statusUpdateNotification: StatusUpdateNotification): ResponseEntity { logger.debug { "handleStatusUpdateNotification: $statusUpdateNotification" } - try { val subscriptionOrder = subscriptionService.handleSubscriptionNotification(statusUpdateNotification) - checkArgument(subscriptionOrder != null) { "Could not process StatusUpdateNotification ${statusUpdateNotification.notificationType} in user app" } - logger.debug { "handleStatusUpdateNotification: $subscriptionOrder" } - } catch (ex : Exception) { - + } catch (ex: Exception) { // Send HTTP 50x or 40x to have the App Store retry the notification logger.error(ex) { "There was an error during handling server-2-server notification" } return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build() } - return ResponseEntity.ok().build() } diff --git a/src/main/kotlin/com/dietmap/yaak/domain/appstore/AppStoreClient.kt b/src/main/kotlin/com/dietmap/yaak/domain/appstore/AppStoreClient.kt index 5069568..4e1497b 100644 --- a/src/main/kotlin/com/dietmap/yaak/domain/appstore/AppStoreClient.kt +++ b/src/main/kotlin/com/dietmap/yaak/domain/appstore/AppStoreClient.kt @@ -5,12 +5,14 @@ import com.dietmap.yaak.api.appstore.receipt.ReceiptResponse import com.dietmap.yaak.api.appstore.receipt.ReceiptResponseStatus import com.dietmap.yaak.api.appstore.receipt.ResponseStatusCode import mu.KotlinLogging -import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConstructorBinding import org.springframework.boot.web.client.RestTemplateBuilder -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.MediaType +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpHeaders.CONTENT_TYPE +import org.springframework.http.MediaType.* import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter import org.springframework.retry.annotation.Backoff import org.springframework.retry.annotation.Recover @@ -19,41 +21,68 @@ import org.springframework.stereotype.Component import org.springframework.web.client.RestTemplate import java.time.Duration - @Component -@ConditionalOnProperty("yaak.app-store.enabled", havingValue = "true") -class AppStoreClient { - - private val productionRestTemplate: RestTemplate - private val sandboxRestTemplate: RestTemplate - private val password: String - - private val logger = KotlinLogging.logger { } +@ConstructorBinding +@ConfigurationProperties(prefix = "yaak") +class AppStoreClientProperties { + var appstore: AppStoreProperties = AppStoreProperties.empty() + var multitenant: Map = emptyMap() +} + +@ConstructorBinding +class AppStoreProperties( + val password: String? = null, + val productionUrl: String? = null, + val sandboxUrl: String? = null +) { + companion object { + fun empty() = AppStoreProperties() + } +} - constructor(restTemplateBuilder: RestTemplateBuilder, - @Value("\${yaak.app-store.production-url}") productionUrl: String, - @Value("\${yaak.app-store.sandbox-url}") sandboxUrl: String, - @Value("\${yaak.app-store.password}") passwordIn: String) { +@Configuration +@ConditionalOnProperty("yaak.app-store.enabled", havingValue = "true") +class AppStoreClientConfiguration { - productionRestTemplate = restTemplateBuilder.rootUri(productionUrl) - .setConnectTimeout(Duration.ofSeconds(5)) - .setReadTimeout(Duration.ofSeconds(5)) - .build() + companion object { + const val DEFAULT_TENANT = "DEFAULT" + const val TIMEOUT_IN_SECS = 5L + } - sandboxRestTemplate = restTemplateBuilder.rootUri(sandboxUrl) - .setConnectTimeout(Duration.ofSeconds(5)) - .setReadTimeout(Duration.ofSeconds(5)) - .build() + @Bean + fun appStoreClients(properties: AppStoreClientProperties, builder: RestTemplateBuilder) = + properties.multitenant + .mapValues { t -> createAppStoreClient(builder, t.value.appstore, properties.appstore) } + .plus(DEFAULT_TENANT to createAppStoreClient(builder, properties.appstore)) + + private fun createAppStoreClient( + builder: RestTemplateBuilder, + tenantProperties: AppStoreProperties, + defaults: AppStoreProperties = AppStoreProperties.empty() + ): AppStoreClient { + val converter = MappingJackson2HttpMessageConverter() + converter.supportedMediaTypes = listOf(APPLICATION_JSON, APPLICATION_OCTET_STREAM) + val productionTemplate = builder.rootUri((tenantProperties.productionUrl ?: defaults.productionUrl)!!) + .setConnectTimeout(Duration.ofSeconds(TIMEOUT_IN_SECS)) + .setReadTimeout(Duration.ofSeconds(TIMEOUT_IN_SECS)) + .messageConverters(converter) + .defaultHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .build() + val sandboxTemplate = builder.rootUri((tenantProperties.sandboxUrl ?: defaults.sandboxUrl)!!) + .setConnectTimeout(Duration.ofSeconds(TIMEOUT_IN_SECS)) + .setReadTimeout(Duration.ofSeconds(TIMEOUT_IN_SECS)) + .messageConverters(converter) + .defaultHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .build() + return AppStoreClient(productionTemplate, sandboxTemplate, (tenantProperties.password ?: defaults.password)!!) + } - password = passwordIn +} - val converter = MappingJackson2HttpMessageConverter() - converter.supportedMediaTypes = listOf( - MediaType.APPLICATION_JSON, - MediaType.APPLICATION_OCTET_STREAM) +class AppStoreClient(private val productionTemplate: RestTemplate, private val sandboxTemplate: RestTemplate, private val password: String) { - productionRestTemplate.messageConverters.add(converter) - sandboxRestTemplate.messageConverters.add(converter) + companion object { + private val logger = KotlinLogging.logger { } } @Retryable(value = [RuntimeException::class], maxAttempts = 3, backoff = Backoff(delay = 3000)) @@ -67,19 +96,14 @@ class AppStoreClient { } @Recover - fun recoverVerifyReceipt(runtimeException: RuntimeException, receiptRequest: ReceiptRequest) : ReceiptResponse { - + fun recoverVerifyReceipt(runtimeException: RuntimeException, receiptRequest: ReceiptRequest): ReceiptResponse { logger.debug { "recoverVerifyReceipt: ReceiptRequest $receiptRequest for exception $runtimeException" } - - val receiptResponseStatus: ReceiptResponseStatus = - productionRestTemplate.postForObject("/verifyReceipt", prepareHttpHeaders(receiptRequest), ReceiptResponseStatus::class.java)!! - + val receiptResponseStatus = productionTemplate.postForObject("/verifyReceipt", receiptRequest, ReceiptResponseStatus::class.java)!! logger.debug { "recoverVerifyReceipt: ReceiptResponseStatus $receiptResponseStatus" } - if (receiptResponseStatus.responseStatusCode!! == ResponseStatusCode.CODE_21007) { - return sandboxRestTemplate.postForObject("/verifyReceipt", prepareHttpHeaders(receiptRequest), ReceiptResponse::class.java)!! + return sandboxTemplate.postForObject("/verifyReceipt", receiptRequest, ReceiptResponse::class.java)!! } else { - val message = "Cannot process ReceiptRequest due to exception $runtimeException"; + val message = "Cannot process ReceiptRequest due to exception $runtimeException" logger.error { message } throw ReceiptValidationException(message) } @@ -87,18 +111,12 @@ class AppStoreClient { private fun processRequest(receiptRequest: ReceiptRequest): ReceiptResponse { receiptRequest.password = password - logger.debug { "processRequest: ReceiptRequest $receiptRequest" } - - var receiptResponse: ReceiptResponse = - productionRestTemplate.postForObject("/verifyReceipt", prepareHttpHeaders(receiptRequest), ReceiptResponse::class.java)!! - + var receiptResponse = productionTemplate.postForObject("/verifyReceipt", receiptRequest, ReceiptResponse::class.java)!! if (receiptResponse.responseStatusCode!! == ResponseStatusCode.CODE_21007) { - receiptResponse = sandboxRestTemplate.postForObject("/verifyReceipt", prepareHttpHeaders(receiptRequest), ReceiptResponse::class.java)!! + receiptResponse = sandboxTemplate.postForObject("/verifyReceipt", receiptRequest, ReceiptResponse::class.java)!! } - logger.debug { "processRequest: ReceiptResponse $receiptResponse" } - if (receiptResponse.shouldRetry()) { val message = "Retrying due to ${receiptResponse.responseStatusCode} status code" logger.warn { message } @@ -107,10 +125,4 @@ class AppStoreClient { return receiptResponse } - private fun prepareHttpHeaders(receiptRequest: ReceiptRequest): HttpEntity { - val headers = HttpHeaders() - headers.contentType = MediaType.APPLICATION_JSON - return HttpEntity(receiptRequest, headers) - } - } \ No newline at end of file diff --git a/src/main/kotlin/com/dietmap/yaak/domain/appstore/AppStoreSubscriptionService.kt b/src/main/kotlin/com/dietmap/yaak/domain/appstore/AppStoreSubscriptionService.kt index 52ca60f..72a439e 100644 --- a/src/main/kotlin/com/dietmap/yaak/domain/appstore/AppStoreSubscriptionService.kt +++ b/src/main/kotlin/com/dietmap/yaak/domain/appstore/AppStoreSubscriptionService.kt @@ -5,6 +5,7 @@ import com.dietmap.yaak.api.appstore.subscription.AppStoreNotificationType import com.dietmap.yaak.api.appstore.subscription.StatusUpdateNotification import com.dietmap.yaak.api.appstore.subscription.SubscriptionPurchaseRequest import com.dietmap.yaak.api.appstore.subscription.SubscriptionRenewRequest +import com.dietmap.yaak.domain.appstore.AppStoreClientConfiguration.Companion.DEFAULT_TENANT import com.dietmap.yaak.domain.checkArgument import com.dietmap.yaak.domain.userapp.* import mu.KotlinLogging @@ -13,12 +14,14 @@ import org.springframework.stereotype.Service @Service @ConditionalOnProperty("yaak.app-store.enabled", havingValue = "true") -class AppStoreSubscriptionService(val userAppClient: UserAppClient, val appStoreClient: AppStoreClient) { +class AppStoreSubscriptionService(private val userAppClient: UserAppClient, private val appStoreClients: Map) { - private val logger = KotlinLogging.logger { } + companion object { + private val logger = KotlinLogging.logger { } + } - fun handleInitialPurchase(subscriptionPurchaseRequest: SubscriptionPurchaseRequest) : UserAppSubscriptionOrder? { - val receiptResponse = appStoreClient.verifyReceipt(ReceiptRequest(subscriptionPurchaseRequest.receipt)) + fun handleInitialPurchase(tenant: String?, subscriptionPurchaseRequest: SubscriptionPurchaseRequest) : UserAppSubscriptionOrder? { + val receiptResponse = appStoreClient(tenant).verifyReceipt(ReceiptRequest(subscriptionPurchaseRequest.receipt)) logger.debug { "handleInitialPurchase: ReceiptResponse: $receiptResponse" } @@ -56,8 +59,8 @@ class AppStoreSubscriptionService(val userAppClient: UserAppClient, val appStore } } - fun handleAutoRenewal(subscriptionRenewRequest: SubscriptionRenewRequest) { - val receiptResponse = appStoreClient.verifyReceipt(ReceiptRequest(subscriptionRenewRequest.receipt)) + fun handleAutoRenewal(tenant: String?, subscriptionRenewRequest: SubscriptionRenewRequest) { + val receiptResponse = appStoreClient(tenant).verifyReceipt(ReceiptRequest(subscriptionRenewRequest.receipt)) logger.debug { "handleAutoRenewal: ReceiptResponse: $receiptResponse" } @@ -181,6 +184,11 @@ class AppStoreSubscriptionService(val userAppClient: UserAppClient, val appStore logger.debug {"Sending UserAppSubscriptionNotification: $notification" } return userAppClient.sendSubscriptionNotification(notification) - } + + fun verifyReceipt(tenant: String?, receiptRequest: ReceiptRequest) = appStoreClient(tenant).verifyReceipt(receiptRequest) + + private fun appStoreClient(tenant: String?) = + appStoreClients.getOrDefault(tenant?.toUpperCase() ?: DEFAULT_TENANT, appStoreClients[DEFAULT_TENANT])!! + } \ No newline at end of file diff --git a/src/main/kotlin/com/dietmap/yaak/domain/googleplay/AndroidPublisherApiClient.kt b/src/main/kotlin/com/dietmap/yaak/domain/googleplay/AndroidPublisherApiClient.kt index 144ee16..b9d2c21 100644 --- a/src/main/kotlin/com/dietmap/yaak/domain/googleplay/AndroidPublisherApiClient.kt +++ b/src/main/kotlin/com/dietmap/yaak/domain/googleplay/AndroidPublisherApiClient.kt @@ -28,15 +28,9 @@ import java.util.* @ConfigurationProperties(prefix = "yaak") class GoogleDeveloperApiClientProperties { lateinit var googleplay: GooglePlayProperties - var multitenant: List = emptyList() + var multitenant: Map = emptyMap() } -@ConstructorBinding -class MultitenantGooglePlayProperties( - val tenant: String, - val googleplay: GooglePlayProperties -) - @ConstructorBinding class GooglePlayProperties( val serviceAccountApiKeyBase64: String, @@ -61,7 +55,7 @@ class GooglePlayProperties( */ @ConditionalOnProperty("yaak.google-play.enabled", havingValue = "true") @Configuration -class AndroidPublisherClientConfiguration(val properties: GoogleDeveloperApiClientProperties) { +class AndroidPublisherClientConfiguration { private val logger = KotlinLogging.logger { } /** Global instance of the JSON factory. */ @@ -76,8 +70,8 @@ class AndroidPublisherClientConfiguration(val properties: GoogleDeveloperApiClie @Bean @Throws(IOException::class, GeneralSecurityException::class) - fun androidPublishers() = properties.multitenant - .associate { m -> m.tenant to createAndroidPublisher(m.googleplay) } + fun androidPublishers(properties: GoogleDeveloperApiClientProperties) = properties.multitenant + .mapValues { t -> createAndroidPublisher(t.value.googleplay) } .plus(DEFAULT_TENANT to createAndroidPublisher(properties.googleplay)) private fun createAndroidPublisher(properties: GooglePlayProperties): AndroidPublisher { diff --git a/src/main/kotlin/com/dietmap/yaak/domain/googleplay/GooglePlaySubscriptionService.kt b/src/main/kotlin/com/dietmap/yaak/domain/googleplay/GooglePlaySubscriptionService.kt index 7d53f2c..8e76194 100644 --- a/src/main/kotlin/com/dietmap/yaak/domain/googleplay/GooglePlaySubscriptionService.kt +++ b/src/main/kotlin/com/dietmap/yaak/domain/googleplay/GooglePlaySubscriptionService.kt @@ -20,11 +20,13 @@ import java.math.BigDecimal @Service 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 { } + companion object { + private const val PAYMENT_RECEIVED_CODE = 1 + private const val PAYMENT_FREE_TRIAL_CODE = 2 + private const val USER_ACCOUNT_ID_KEY = "obfuscatedExternalAccountId" + private const val USER_APP_STATUS_ACTIVE = "ACTIVE" + private val logger = KotlinLogging.logger { } + } fun handlePurchase(purchaseRequest: PurchaseRequest, tenant: String? = null): SubscriptionPurchase? { val subscription = androidPublisherService.tenant(tenant).purchases().subscriptions()