All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.io.ktor.client.plugins.cache.HttpCache.kt Maven / Gradle / Ivy

/*
* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

@file:Suppress("DEPRECATION")

package io.ktor.client.plugins.cache

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.cache.storage.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.events.*
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.util.*
import io.ktor.util.date.*
import io.ktor.util.logging.*
import io.ktor.util.pipeline.*
import io.ktor.utils.io.*
import kotlin.coroutines.*

internal object CacheControl {
    internal val NO_STORE = HeaderValue("no-store")
    internal val NO_CACHE = HeaderValue("no-cache")
    internal val PRIVATE = HeaderValue("private")
    internal val ONLY_IF_CACHED = HeaderValue("only-if-cached")
    internal val MUST_REVALIDATE = HeaderValue("must-revalidate")
}

internal val LOGGER = KtorSimpleLogger("io.ktor.client.plugins.HttpCache")

/**
 * A plugin that allows you to save previously fetched resources in an in-memory cache.
 * For example, if you make two consequent requests to a resource with the configured `Cache-Control` header,
 * the client executes only the first request and skips the second one since data is already saved in a cache.
 *
 * You can learn more from [Caching](https://ktor.io/docs/client-caching.html).
 */
public class HttpCache private constructor(
    @Deprecated("This will become internal")
    public val publicStorage: HttpCacheStorage,
    @Deprecated("This will become internal")
    public val privateStorage: HttpCacheStorage,
    private val publicStorageNew: CacheStorage,
    private val privateStorageNew: CacheStorage,
    private val useOldStorage: Boolean,
    internal val isSharedClient: Boolean
) {
    /**
     * A configuration for the [HttpCache] plugin.
     */
    @KtorDsl
    public class Config {
        internal var publicStorageNew: CacheStorage = CacheStorage.Unlimited()
        internal var privateStorageNew: CacheStorage = CacheStorage.Unlimited()
        internal var useOldStorage = false

        /**
         * Specifies if the client where this plugin is installed is shared among multiple users.
         * When set to true, all responses with `private` Cache-Control directive will not be cached.
         */
        public var isShared: Boolean = false

        /**
         * Specifies a storage for public cache entries.
         *
         * [HttpCacheStorage.Unlimited] by default.
         */
        @Deprecated("This will become internal. Use setter method instead with new storage interface")
        public var publicStorage: HttpCacheStorage = HttpCacheStorage.Unlimited()
            set(value) {
                useOldStorage = true
                field = value
            }

        /**
         * Specifies a storage for private cache entries.
         *
         * [HttpCacheStorage.Unlimited] by default.
         *
         * Consider using [HttpCacheStorage.Disabled] if the client is used as intermediate.
         */
        @Deprecated("This will become internal. Use setter method instead with new storage interface")
        public var privateStorage: HttpCacheStorage = HttpCacheStorage.Unlimited()
            set(value) {
                useOldStorage = true
                field = value
            }

        /**
         * Specifies a storage for public cache entries.
         *
         * [CacheStorage.Unlimited] by default.
         */
        public fun publicStorage(storage: CacheStorage) {
            publicStorageNew = storage
        }

        /**
         * Specifies a storage for private cache entries.
         *
         * [CacheStorage.Unlimited] by default.
         *
         * Consider using [CacheStorage.Disabled] if the client is used as intermediate.
         */
        public fun privateStorage(storage: CacheStorage) {
            privateStorageNew = storage
        }
    }

    public companion object : HttpClientPlugin {
        override val key: AttributeKey = AttributeKey("HttpCache")

        public val HttpResponseFromCache: EventDefinition = EventDefinition()

        override fun prepare(block: Config.() -> Unit): HttpCache {
            val config = Config().apply(block)

            with(config) {
                return HttpCache(
                    publicStorage = publicStorage,
                    privateStorage = privateStorage,
                    publicStorageNew = publicStorageNew,
                    privateStorageNew = privateStorageNew,
                    useOldStorage = useOldStorage,
                    isSharedClient = isShared
                )
            }
        }

        @OptIn(InternalAPI::class)
        override fun install(plugin: HttpCache, scope: HttpClient) {
            val CachePhase = PipelinePhase("Cache")
            scope.sendPipeline.insertPhaseAfter(HttpSendPipeline.State, CachePhase)

            scope.sendPipeline.intercept(CachePhase) { content ->
                if (content !is OutgoingContent.NoContent) return@intercept
                if (context.method != HttpMethod.Get || !context.url.protocol.canStore()) return@intercept

                if (plugin.useOldStorage) {
                    interceptSendLegacy(plugin, content, scope)
                    return@intercept
                }

                val cache = plugin.findResponse(context, content)
                if (cache == null) {
                    LOGGER.trace("No cached response for ${context.url} found")
                    val header = parseHeaderValue(context.headers[HttpHeaders.CacheControl])
                    if (CacheControl.ONLY_IF_CACHED in header) {
                        LOGGER.trace("No cache found and \"only-if-cached\" set for ${context.url}")
                        proceedWithMissingCache(scope)
                    }
                    return@intercept
                }
                val validateStatus = shouldValidate(cache.expires, cache.headers, context)

                if (validateStatus == ValidateStatus.ShouldNotValidate) {
                    val cachedCall = cache
                        .createResponse(scope, RequestForCache(context.build()), context.executionContext)
                        .call
                    proceedWithCache(scope, cachedCall)
                    return@intercept
                }

                if (validateStatus == ValidateStatus.ShouldWarn) {
                    proceedWithWarning(cache, scope, context.executionContext)
                    return@intercept
                }

                cache.headers[HttpHeaders.ETag]?.let { etag ->
                    LOGGER.trace("Adding If-None-Match=$etag for ${context.url}")
                    context.header(HttpHeaders.IfNoneMatch, etag)
                }
                cache.headers[HttpHeaders.LastModified]?.let {
                    LOGGER.trace("Adding If-Modified-Since=$it for ${context.url}")
                    context.header(HttpHeaders.IfModifiedSince, it)
                }
            }

            scope.receivePipeline.intercept(HttpReceivePipeline.State) { response ->
                if (response.call.request.method != HttpMethod.Get) return@intercept

                if (plugin.useOldStorage) {
                    interceptReceiveLegacy(response, plugin, scope)
                    return@intercept
                }

                if (response.status.isSuccess()) {
                    LOGGER.trace("Caching response for ${response.call.request.url}")
                    val cachedData = plugin.cacheResponse(response)
                    if (cachedData != null) {
                        val reusableResponse = cachedData
                            .createResponse(scope, response.request, response.coroutineContext)
                        proceedWith(reusableResponse)
                        return@intercept
                    }
                }

                if (response.status == HttpStatusCode.NotModified) {
                    LOGGER.trace("Not modified response for ${response.call.request.url}, replying from cache")
                    response.complete()
                    val responseFromCache = plugin.findAndRefresh(response.call.request, response)
                        ?: throw InvalidCacheStateException(response.call.request.url)

                    scope.monitor.raise(HttpResponseFromCache, responseFromCache)
                    proceedWith(responseFromCache)
                }
            }
        }

        internal suspend fun PipelineContext.proceedWithCache(
            scope: HttpClient,
            cachedCall: HttpClientCall
        ) {
            finish()
            scope.monitor.raise(HttpResponseFromCache, cachedCall.response)
            proceedWith(cachedCall)
        }

        @OptIn(InternalAPI::class)
        private suspend fun PipelineContext.proceedWithWarning(
            cachedResponse: CachedResponseData,
            scope: HttpClient,
            callContext: CoroutineContext
        ) {
            val request = context.build()
            val response = HttpResponseData(
                statusCode = cachedResponse.statusCode,
                requestTime = cachedResponse.requestTime,
                headers = Headers.build {
                    appendAll(cachedResponse.headers)
                    append(HttpHeaders.Warning, "110")
                },
                version = cachedResponse.version,
                body = ByteReadChannel(cachedResponse.body),
                callContext = callContext
            )
            val call = HttpClientCall(scope, request, response)
            finish()
            scope.monitor.raise(HttpResponseFromCache, call.response)
            proceedWith(call)
        }

        @OptIn(InternalAPI::class)
        internal suspend fun PipelineContext.proceedWithMissingCache(
            scope: HttpClient
        ) {
            finish()
            val request = context.build()
            val response = HttpResponseData(
                statusCode = HttpStatusCode.GatewayTimeout,
                requestTime = GMTDate(),
                headers = Headers.Empty,
                version = HttpProtocolVersion.HTTP_1_1,
                body = ByteReadChannel(ByteArray(0)),
                callContext = request.executionContext
            )
            val call = HttpClientCall(scope, request, response)
            proceedWith(call)
        }
    }

    private suspend fun cacheResponse(response: HttpResponse): CachedResponseData? {
        val request = response.call.request
        val responseCacheControl: List = response.cacheControl()
        val requestCacheControl: List = request.cacheControl()

        val isPrivate = CacheControl.PRIVATE in responseCacheControl
        val storage = when {
            isPrivate && isSharedClient -> return null
            isPrivate -> privateStorageNew
            else -> publicStorageNew
        }

        if (CacheControl.NO_STORE in responseCacheControl || CacheControl.NO_STORE in requestCacheControl) {
            return null
        }

        return storage.store(response, response.varyKeys(), isSharedClient)
    }

    private suspend fun findAndRefresh(request: HttpRequest, response: HttpResponse): HttpResponse? {
        val url = response.call.request.url
        val cacheControl = response.cacheControl()

        val isPrivate = CacheControl.PRIVATE in cacheControl
        val storage = when {
            isPrivate && isSharedClient -> return null
            isPrivate -> privateStorageNew
            else -> publicStorageNew
        }

        val varyKeysFrom304 = response.varyKeys()
        val cache = findResponse(storage, varyKeysFrom304, url, request) ?: return null
        val newVaryKeys = varyKeysFrom304.ifEmpty { cache.varyKeys }
        storage.store(request.url, cache.copy(newVaryKeys, response.cacheExpires(isSharedClient)))
        return cache.createResponse(request.call.client, request, response.coroutineContext)
    }

    private suspend fun findResponse(
        storage: CacheStorage,
        varyKeys: Map,
        url: Url,
        request: HttpRequest
    ): CachedResponseData? = when {
        varyKeys.isNotEmpty() -> {
            storage.find(url, varyKeys)
        }

        else -> {
            val requestHeaders = mergedHeadersLookup(request.content, request.headers::get, request.headers::getAll)
            storage.findAll(url)
                .sortedByDescending { it.responseTime }
                .firstOrNull { cachedResponse ->
                    cachedResponse.varyKeys.all { (key, value) -> requestHeaders(key) == value }
                }
        }
    }

    private suspend fun findResponse(context: HttpRequestBuilder, content: OutgoingContent): CachedResponseData? {
        val url = Url(context.url)
        val lookup = mergedHeadersLookup(content, context.headers::get, context.headers::getAll)

        val cachedResponses = privateStorageNew.findAll(url) + publicStorageNew.findAll(url)
        for (item in cachedResponses) {
            val varyKeys = item.varyKeys
            if (varyKeys.isEmpty() || varyKeys.all { (key, value) -> lookup(key) == value }) {
                return item
            }
        }

        return null
    }
}

@OptIn(InternalAPI::class)
internal fun mergedHeadersLookup(
    content: OutgoingContent,
    headerExtractor: (String) -> String?,
    allHeadersExtractor: (String) -> List?,
): (String) -> String = block@{ header ->
    return@block when (header) {
        HttpHeaders.ContentLength -> content.contentLength?.toString() ?: ""
        HttpHeaders.ContentType -> content.contentType?.toString() ?: ""
        HttpHeaders.UserAgent -> {
            content.headers[HttpHeaders.UserAgent] ?: headerExtractor(HttpHeaders.UserAgent) ?: KTOR_DEFAULT_USER_AGENT
        }
        else -> {
            val value = content.headers.getAll(header) ?: allHeadersExtractor(header) ?: emptyList()
            value.joinToString(";")
        }
    }
}

@Suppress("KDocMissingDocumentation")
public class InvalidCacheStateException(requestUrl: Url) : IllegalStateException(
    "The entry for url: $requestUrl was removed from cache"
)

private fun URLProtocol.canStore(): Boolean = name == "http" || name == "https"

private class RequestForCache(data: HttpRequestData) : HttpRequest {
    override val call: HttpClientCall
        get() = throw IllegalStateException("This request has no call")
    override val method: HttpMethod = data.method
    override val url: Url = data.url
    override val attributes: Attributes = data.attributes
    override val content: OutgoingContent = data.body
    override val headers: Headers = data.headers
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy