Skip to content

Commit

Permalink
Add support for multiple App Store accounts handled on a single yaak …
Browse files Browse the repository at this point in the history
…instance
  • Loading branch information
wojtekbauman authored Nov 7, 2021
1 parent cbcae19 commit 6cf8e40
Show file tree
Hide file tree
Showing 6 changed files with 133 additions and 116 deletions.
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String> =
if (subscriptionService.verifyReceipt(tenant, receiptRequest).isValid()) ResponseEntity.ok(VALID) else ResponseEntity.ok(NOT_VALID)

@PostMapping("/verify")
fun verifyWithResponse(@RequestBody @Valid receiptRequest: ReceiptRequest) : ResponseEntity<ReceiptValidationResponse> {
val receiptResponse = appStoreClient.verifyReceipt(receiptRequest)
fun verifyWithResponse(
@RequestBody @Valid receiptRequest: ReceiptRequest,
@RequestHeader(TENANT_HEADER, required = false) tenant: String?
): ResponseEntity<ReceiptValidationResponse> =
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"))
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
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
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


Expand All @@ -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<UserAppSubscriptionOrder?> {
fun handleInitialPurchase(
@RequestBody @Valid subscriptionPurchaseRequest: SubscriptionPurchaseRequest,
@RequestHeader(TENANT_HEADER, required = false) tenant: String?
): ResponseEntity<UserAppSubscriptionOrder?> {
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<Any> {
fun handleAutoRenewal(
@RequestBody @Valid subscriptionRenewRequest: SubscriptionRenewRequest,
@RequestHeader(TENANT_HEADER, required = false) tenant: String?
): ResponseEntity<Any> {
logger.debug { "handleAutoRenewal: $subscriptionRenewRequest" }

subscriptionService.handleAutoRenewal(subscriptionRenewRequest)

subscriptionService.handleAutoRenewal(tenant, subscriptionRenewRequest)
return ResponseEntity.ok().build()
}

Expand All @@ -49,20 +49,15 @@ class SubscriptionController(private val subscriptionService: AppStoreSubscripti
@PostMapping("/statusUpdateNotification")
fun handleStatusUpdateNotification(@Valid @RequestBody statusUpdateNotification: StatusUpdateNotification): ResponseEntity<Any> {
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()
}

Expand Down
122 changes: 67 additions & 55 deletions src/main/kotlin/com/dietmap/yaak/domain/appstore/AppStoreClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, AppStoreClientProperties> = 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))
Expand All @@ -67,38 +96,27 @@ 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)
}
}

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 }
Expand All @@ -107,10 +125,4 @@ class AppStoreClient {
return receiptResponse
}

private fun prepareHttpHeaders(receiptRequest: ReceiptRequest): HttpEntity<ReceiptRequest> {
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
return HttpEntity(receiptRequest, headers)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, AppStoreClient>) {

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" }

Expand Down Expand Up @@ -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" }

Expand Down Expand Up @@ -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])!!

}
Loading

0 comments on commit 6cf8e40

Please sign in to comment.