Untitled

 avatar
unknown
plain_text
2 years ago
8.8 kB
7
Indexable

import org.http4k.client.ApacheClient
import org.http4k.core.Body
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Method
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status
import org.http4k.core.Uri
import org.http4k.core.extend
import org.http4k.core.then
import org.http4k.core.with
import org.http4k.filter.ClientFilters
import org.http4k.filter.ResponseFilters
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.util.UUID

interface ApiGwClient {
    fun subscribeForProjectUpdates(projectId: String): ProjectSubscription
    fun getProjectUpdateSubscription(projectId: String): ProjectSubscription
    fun deleteProjectUpdateSubscription(projectId: String): Boolean

    data class ProjectSubscription(
        val projectId: String,
        val callbackURL: String,
    ) {
        constructor(projectId: String, callbackURL: Uri) : this(projectId, callbackURL.toString())
    }
}

@Component
class EbipClient(
    apiGwBaseUrl: Uri,
    private val callbackBaseUrl: Uri,
    private val apiKey: String,
    private val apiUser: String,
    private val tokenUrl: String,
    private val tokenAuthorization: String,
    private val tokenRefreshBeforeExpiry: Int
) : ApiGwClient {

    @Autowired
    constructor(
        @Value("\${api.gateway.url}") apiGwBaseUrl: String,
        @Value("\${eca.integrator.base.url}") callbackBaseUrl: String,
        @Value("\${api.gateway.key}") apiKey: String,
        @Value("\${api.gateway.user}") apiUser: String,
        @Value("\${api.gateway.token.url}") tokenUrl: String,
        @Value("\${api.gateway.token.authorization}") tokenAuthorization: String,
        @Value("\${api.gateway.token.refresh}") tokenRefreshBeforeExpiry: Int
    ) : this(
        apiGwBaseUrl = Uri.of(apiGwBaseUrl),
        callbackBaseUrl = Uri.of(callbackBaseUrl),
        apiKey = apiKey,
        apiUser = apiUser,
        tokenUrl = tokenUrl,
        tokenAuthorization = tokenAuthorization,
        tokenRefreshBeforeExpiry = tokenRefreshBeforeExpiry
    )

    private val client = ClientFilters.SetBaseUriFrom(apiGwBaseUrl)
        .then(ResponseFilters.ReportHttpTransaction { tx ->
            logger.info("{}\n\nduration: {}\n{}", tx.request, tx.duration, tx.response)
        })
        .then(EBIPAuthentication(
            apiKey = apiKey,
            apiUser = apiUser,
            tokenUrl = tokenUrl,
            tokenAuthorization = tokenAuthorization,
            tokenRefreshBeforeExpiry = tokenRefreshBeforeExpiry
        ))
        .then(ApacheClient())

    private val projectSubscriptionsBaseUrl = Uri.of("acceptanceprojecteventsubscription/v1/subscriptions")

    override fun subscribeForProjectUpdates(projectId: String): ApiGwClient.ProjectSubscription {
        val subscription = ApiGwClient.ProjectSubscription(
            projectId = projectId,
            callbackURL = callbackBaseUrl / "projects/$projectId/update-event"
        )

        return runCatching {
            client(
                Request(Method.POST, projectSubscriptionsBaseUrl)
                    .with(Body.auto<ApiGwClient.ProjectSubscription>().toLens() of subscription)
            )
        }.recoverCatching { exception ->
            throw ApiGwException.ApiException(exception.message!!, exception)
        }.mapCatching { response ->
            if (response.status.successful) subscription else throw getErrorFromResponse(response)
        }.getOrThrow()

    }

    override fun getProjectUpdateSubscription(projectId: String): ApiGwClient.ProjectSubscription {
        val response = client(Request(Method.GET, projectSubscriptionsBaseUrl / projectId))
        return if (response.status.successful) {
            Body.auto<ApiGwClient.ProjectSubscription>().toLens().extract(response)
        } else {
            throw getErrorFromResponse(response)
        }
    }

    override fun deleteProjectUpdateSubscription(projectId: String): Boolean {
        val response = client(Request(Method.DELETE, projectSubscriptionsBaseUrl / projectId))
        return when {
            response.status.successful -> true
            // Bad implementation on the API GW. When fixed this can be removed
            response.status == Status.INTERNAL_SERVER_ERROR &&
                response.bodyString().trim() == "Subscription event deleted successfully" -> true
            else -> throw getErrorFromResponse(response)
        }
    }

    private operator fun Uri.div(path: String): Uri = extend(Uri.of(path))
    private operator fun Uri.div(path: UUID): Uri = extend(Uri.of(path.toString()))

    private fun getErrorFromResponse(response: Response): ApiGwException =
        runCatching { Body.auto<ErrorInfo>().toLens().extract(response) }
            .map { errorInfo ->
                ApiGwException.ApiException(
                    message = "${errorInfo.responseStatusCode} ${errorInfo.responseMessage}",
                    description = errorInfo.responseMessageDescription,
                    traceId = errorInfo.traceId
                )
            }.recover {
                ApiGwException.ApiException(
                    message = response.status.toString(),
                    description = response.bodyString()
                )
            }.getOrThrow()

    data class ErrorInfo(
        val traceId: String?,
        val responseStatusCode: Int,
        val responseMessage: String,
        val responseMessageDescription: String?
    )

    companion object {
        private val logger = LoggerFactory.getLogger(EbipClient::class.java)
    }
}

/**
 * Filter that will add authentication headers to the request
 *
 * Each request to EBIP API Gateway needs the following headers
 *  - "Authorization"
 *  - "x-Gateway-APIKey"
 *  - "x-api-user"
 */
class EBIPAuthentication(
    private val apiKey: String,
    private val apiUser: String,
    private val tokenUrl: String,
    private val tokenAuthorization: String,
    private val tokenRefreshBeforeExpiry: Int
) : Filter {
    override operator fun invoke(next: HttpHandler): HttpHandler = { request ->
        val (token, _) = getToken()
        next(request.headers(
            listOf(
                AUTHORIZATION to "Bearer $token",
                GATEWAY_API_KEY to apiKey,
                GATEWAY_API_USER to apiUser,
            )
        ))
    }

    /**
     * Get new token, [refreshBeforeExpiry] seconds before expiry
     * reuse current token if expiry date is further
     */
    private fun getToken(
        refreshBeforeExpiry: Int = tokenRefreshBeforeExpiry
    ): TokenWithExpiration {
        synchronized(tokenUrl) {
            val currentToken = cachedToken
            if (currentToken?.isExpired(-refreshBeforeExpiry) == false)
                return currentToken
            logger.info("Token expired or missing. Try to obtain new token...")
            val client = ApacheClient()
            val request = Request(Method.POST, tokenUrl)
                .body("grant_type=client_credentials")
                .headers(
                    listOf(
                        "Authorization" to tokenAuthorization,
                        "Content-Type" to "application/x-www-form-urlencoded"
                    )
                )
            val result = client(request)
            if (!result.status.successful) {
                cachedToken = null
                throw ApiGwException.TokenGenerationException(result)
            }

            val resultJson = CustomJackson.parse(result.bodyString())
            val token = resultJson.get("access_token").asText()
            val expire = resultJson.get("expires_on").asLong()
            logger.info("Got new token that will expire at $expire")
            return TokenWithExpiration(token, expire).also { cachedToken = it }
        }
    }

    private var cachedToken: TokenWithExpiration? = null

    private data class TokenWithExpiration(val token: String, val expiresAt: Long) {
        /** Check if current token is expired.
         *
         *  The token is considered valid [tolerance] seconds after [expiresAt].
         *  If [tolerance] is negative then the token will be considered expired early.
         */
        fun isExpired(tolerance: Int = 0): Boolean =
            expiresAt + tolerance < System.currentTimeMillis() / 1000
    }

    companion object {
        private val logger = LoggerFactory.getLogger(EBIPAuthentication::class.java)
        private const val AUTHORIZATION = "Authorization"
        private const val GATEWAY_API_KEY = "x-Gateway-APIKey"
        private const val GATEWAY_API_USER = "x-api-user"
    }
}
Editor is loading...