
commonMain.com.apollographql.cache.normalized.ClientCacheExtensions.kt Maven / Gradle / Ivy
@file:JvmName("NormalizedCache")
package com.apollographql.cache.normalized
import com.apollographql.apollo.ApolloCall
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.CacheDumpProviderContext
import com.apollographql.apollo.api.ApolloRequest
import com.apollographql.apollo.api.ApolloResponse
import com.apollographql.apollo.api.ExecutionContext
import com.apollographql.apollo.api.ExecutionOptions
import com.apollographql.apollo.api.MutableExecutionOptions
import com.apollographql.apollo.api.Mutation
import com.apollographql.apollo.api.Operation
import com.apollographql.apollo.api.Query
import com.apollographql.apollo.api.http.get
import com.apollographql.apollo.exception.ApolloException
import com.apollographql.apollo.exception.CacheMissException
import com.apollographql.apollo.interceptor.ApolloInterceptor
import com.apollographql.apollo.interceptor.ApolloInterceptorChain
import com.apollographql.apollo.interceptor.AutoPersistedQueryInterceptor
import com.apollographql.apollo.mpp.currentTimeMillis
import com.apollographql.apollo.network.http.HttpInfo
import com.apollographql.cache.normalized.api.ApolloCacheHeaders
import com.apollographql.cache.normalized.api.CacheHeaders
import com.apollographql.cache.normalized.api.CacheKeyGenerator
import com.apollographql.cache.normalized.api.CacheResolver
import com.apollographql.cache.normalized.api.DefaultEmbeddedFieldsProvider
import com.apollographql.cache.normalized.api.DefaultFieldKeyGenerator
import com.apollographql.cache.normalized.api.DefaultRecordMerger
import com.apollographql.cache.normalized.api.EmbeddedFieldsProvider
import com.apollographql.cache.normalized.api.EmptyMetadataGenerator
import com.apollographql.cache.normalized.api.FieldKeyGenerator
import com.apollographql.cache.normalized.api.FieldPolicyCacheResolver
import com.apollographql.cache.normalized.api.MetadataGenerator
import com.apollographql.cache.normalized.api.NormalizedCacheFactory
import com.apollographql.cache.normalized.api.RecordMerger
import com.apollographql.cache.normalized.api.TypePolicyCacheKeyGenerator
import com.apollographql.cache.normalized.internal.ApolloCacheInterceptor
import com.apollographql.cache.normalized.internal.WatcherInterceptor
import com.apollographql.cache.normalized.internal.WatcherSentinel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlin.jvm.JvmName
import kotlin.jvm.JvmOverloads
import kotlin.time.Duration
/**
* Configures an [ApolloClient] with a normalized cache.
*
* @param normalizedCacheFactory a factory that creates a [com.apollographql.cache.normalized.api.NormalizedCache].
* It will only be called once.
* The reason this is a factory is to enforce creating the cache from a non-main thread. For native the thread
* where the cache is created will also be isolated so that the cache can be mutated.
*
* @param cacheResolver a [CacheResolver] to customize normalization
*
* @param writeToCacheAsynchronously set to true to write to the cache after the response has been emitted.
* This allows to display results faster
*/
@JvmOverloads
@JvmName("configureApolloClientBuilder2")
fun ApolloClient.Builder.normalizedCache(
normalizedCacheFactory: NormalizedCacheFactory,
cacheKeyGenerator: CacheKeyGenerator = TypePolicyCacheKeyGenerator,
metadataGenerator: MetadataGenerator = EmptyMetadataGenerator,
cacheResolver: CacheResolver = FieldPolicyCacheResolver,
recordMerger: RecordMerger = DefaultRecordMerger,
fieldKeyGenerator: FieldKeyGenerator = DefaultFieldKeyGenerator,
embeddedFieldsProvider: EmbeddedFieldsProvider = DefaultEmbeddedFieldsProvider,
writeToCacheAsynchronously: Boolean = false,
): ApolloClient.Builder {
return store(
ApolloStore(
normalizedCacheFactory = normalizedCacheFactory,
cacheKeyGenerator = cacheKeyGenerator,
metadataGenerator = metadataGenerator,
cacheResolver = cacheResolver,
recordMerger = recordMerger,
fieldKeyGenerator = fieldKeyGenerator,
embeddedFieldsProvider = embeddedFieldsProvider
), writeToCacheAsynchronously
)
}
@JvmName("-logCacheMisses")
fun ApolloClient.Builder.logCacheMisses(
log: (String) -> Unit = { println(it) },
): ApolloClient.Builder {
check(interceptors.none { it is ApolloCacheInterceptor }) {
"Apollo: logCacheMisses() must be called before setting up your normalized cache"
}
return addInterceptor(CacheMissLoggingInterceptor(log))
}
fun ApolloClient.Builder.store(store: ApolloStore, writeToCacheAsynchronously: Boolean = false): ApolloClient.Builder {
check(interceptors.none { it is AutoPersistedQueryInterceptor }) {
"Apollo: the normalized cache must be configured before the auto persisted queries"
}
// Removing existing interceptors added for configuring an [ApolloStore].
// If a builder is reused from an existing client using `newBuilder()` and we try to configure a new `store()` on it, we first need to
// remove the old interceptors.
val storeInterceptors = interceptors.filterIsInstance()
storeInterceptors.forEach {
removeInterceptor(it)
}
return addInterceptor(WatcherInterceptor(store))
.addInterceptor(FetchPolicyRouterInterceptor)
.addInterceptor(ApolloCacheInterceptor(store))
.writeToCacheAsynchronously(writeToCacheAsynchronously)
.addExecutionContext(CacheDumpProviderContext(store.cacheDumpProvider()))
}
/**
* Gets initial response(s) then observes the cache for any changes.
*
* There is a guarantee that the cache is subscribed before the initial response(s) finish emitting. Any update to the cache done after the initial response(s) are received will be received.
*
* [fetchPolicy] controls how the result is first queried, while [refetchPolicy] will control the subsequent fetches.
*
* @see fetchPolicy
* @see refetchPolicy
*/
fun ApolloCall.watch(): Flow> {
return flow {
var lastResponse: ApolloResponse? = null
var response: ApolloResponse? = null
toFlow()
.collect {
response = it
if (it.isLast) {
if (lastResponse != null) {
/**
* If we ever come here it means some interceptors built a new Flow and forgot to reset the isLast flag
* Better safe than sorry: emit them when we realize that. This will introduce a delay in the response.
*/
println("ApolloGraphQL: extra response received after the last one")
emit(lastResponse!!)
}
/**
* Remember the last response so that we can send it after we subscribe to the store
*
* This allows callers to use the last element as a synchronisation point to modify the store and still have the watcher
* receive subsequent updates
*
* See https://github.com/apollographql/apollo-kotlin/pull/3853
*/
lastResponse = it
} else {
emit(it)
}
}
copy().fetchPolicyInterceptor(refetchPolicyInterceptor)
.watchInternal(response?.data)
.collect {
if (it.exception === WatcherSentinel) {
if (lastResponse != null) {
emit(lastResponse!!)
lastResponse = null
}
} else {
emit(it)
}
}
}
}
/**
* Observes the cache for the given data. Unlike [watch], no initial request is executed on the network.
* The fetch policy set by [fetchPolicy] will be used.
*/
fun ApolloCall.watch(data: D?): Flow> {
return watchInternal(data).filter { it.exception !== WatcherSentinel }
}
/**
* Observes the cache for the given data. Unlike [watch], no initial request is executed on the network.
* The fetch policy set by [fetchPolicy] will be used.
*/
internal fun ApolloCall.watchInternal(data: D?): Flow> {
return copy().addExecutionContext(WatchContext(data)).toFlow()
}
val ApolloClient.apolloStore: ApolloStore
get() {
return interceptors.firstOrNull { it is ApolloCacheInterceptor }?.let {
(it as ApolloCacheInterceptor).store
} ?: error("no cache configured")
}
/**
* Sets the initial [FetchPolicy]
* This only has effects for queries. Mutations and subscriptions always use [FetchPolicy.NetworkOnly]
*/
fun MutableExecutionOptions.fetchPolicy(fetchPolicy: FetchPolicy) = addExecutionContext(
FetchPolicyContext(interceptorFor(fetchPolicy))
)
/**
* Sets the [FetchPolicy] used when watching queries and a cache change has been published
*/
fun MutableExecutionOptions.refetchPolicy(fetchPolicy: FetchPolicy) = addExecutionContext(
RefetchPolicyContext(interceptorFor(fetchPolicy))
)
/**
* Sets the initial [FetchPolicy]
* This only has effects for queries. Mutations and subscriptions always use [FetchPolicy.NetworkOnly]
*/
fun MutableExecutionOptions.fetchPolicyInterceptor(interceptor: ApolloInterceptor) = addExecutionContext(
FetchPolicyContext(interceptor)
)
/**
* Sets the [FetchPolicy] used when watching queries and a cache change has been published
*/
fun MutableExecutionOptions.refetchPolicyInterceptor(interceptor: ApolloInterceptor) = addExecutionContext(
RefetchPolicyContext(interceptor)
)
private fun interceptorFor(fetchPolicy: FetchPolicy) = when (fetchPolicy) {
FetchPolicy.CacheOnly -> CacheOnlyInterceptor
FetchPolicy.NetworkOnly -> NetworkOnlyInterceptor
FetchPolicy.CacheFirst -> CacheFirstInterceptor
FetchPolicy.NetworkFirst -> NetworkFirstInterceptor
FetchPolicy.CacheAndNetwork -> CacheAndNetworkInterceptor
}
/**
* @param doNotStore Whether to store the response in cache.
*
* Default: false
*/
fun MutableExecutionOptions.doNotStore(doNotStore: Boolean) = addExecutionContext(
DoNotStoreContext(doNotStore)
)
/**
* @param memoryCacheOnly Whether to store and read from a memory cache only.
*
* Default: false
*/
fun MutableExecutionOptions.memoryCacheOnly(memoryCacheOnly: Boolean) = addExecutionContext(
MemoryCacheOnlyContext(memoryCacheOnly)
)
/**
* @param storePartialResponses Whether to store partial responses.
*
* Errors are not stored in the cache and are therefore not replayed on cache reads.
* Set this to true if you want to store partial responses at the risk of also returning partial responses
* in subsequent cache reads.
*
* Default: false
*/
fun MutableExecutionOptions.storePartialResponses(storePartialResponses: Boolean) = addExecutionContext(
StorePartialResponsesContext(storePartialResponses)
)
/**
* @param storeReceiveDate Whether to store the receive date in the cache.
*
* Default: false
*/
fun MutableExecutionOptions.storeReceiveDate(storeReceiveDate: Boolean) = addExecutionContext(
StoreReceiveDateContext(storeReceiveDate)
)
/**
* @param storeExpirationDate Whether to store the expiration date in the cache.
*
* The expiration date is computed from the response HTTP headers
*
* Default: false
*/
fun MutableExecutionOptions.storeExpirationDate(storeExpirationDate: Boolean): T {
addExecutionContext(StoreExpirationDateContext(storeExpirationDate))
if (this is ApolloClient.Builder) {
check(interceptors.none { it is StoreExpirationDateInterceptor }) {
"Apollo: storeExpirationDate() can only be called once on ApolloClient.Builder()"
}
addInterceptor(StoreExpirationDateInterceptor())
}
@Suppress("UNCHECKED_CAST")
return this as T
}
private class StoreExpirationDateInterceptor : ApolloInterceptor {
override fun intercept(request: ApolloRequest, chain: ApolloInterceptorChain): Flow> {
return chain.proceed(request).map {
val store = request.executionContext[StoreExpirationDateContext]?.value
if (store != true) {
return@map it
}
val headers = it.executionContext[HttpInfo]?.headers.orEmpty()
val cacheControl = headers.get("cache-control")?.lowercase() ?: return@map it
val c = cacheControl.split(",").map { it.trim() }
val maxAge = c.mapNotNull {
if (it.startsWith("max-age=")) {
it.substring(8).toIntOrNull()
} else {
null
}
}.firstOrNull() ?: return@map it
val age = headers.get("age")?.toIntOrNull()
val expires = if (age != null) {
currentTimeMillis() / 1000 + maxAge - age
} else {
currentTimeMillis() / 1000 + maxAge
}
return@map it.newBuilder()
.cacheHeaders(
it.cacheHeaders.newBuilder()
.addHeader(ApolloCacheHeaders.EXPIRATION_DATE, expires.toString())
.build()
)
.build()
}
}
}
/**
* @param cacheHeaders additional cache headers to be passed to your [com.apollographql.cache.normalized.api.NormalizedCache]
*/
fun MutableExecutionOptions.cacheHeaders(cacheHeaders: CacheHeaders) = addExecutionContext(
CacheHeadersContext(cacheHeaders)
)
/**
* Add a cache header to be passed to your [com.apollographql.cache.normalized.api.NormalizedCache]
*/
fun MutableExecutionOptions.addCacheHeader(key: String, value: String) = cacheHeaders(
cacheHeaders.newBuilder().addHeader(key, value).build()
)
/**
* @param maxStale how long to accept stale fields
*/
fun MutableExecutionOptions.maxStale(maxStale: Duration) = addCacheHeader(
ApolloCacheHeaders.MAX_STALE, maxStale.inWholeSeconds.toString()
)
/**
* @param writeToCacheAsynchronously whether to return the response before writing it to the cache
*
* Setting this to true reduces the latency
*
* Default: false
*/
fun MutableExecutionOptions.writeToCacheAsynchronously(writeToCacheAsynchronously: Boolean) = addExecutionContext(
WriteToCacheAsynchronouslyContext(writeToCacheAsynchronously)
)
/**
* Sets the optimistic updates to write to the cache while a query is pending.
*/
fun ApolloRequest.Builder.optimisticUpdates(data: D) = addExecutionContext(
OptimisticUpdatesContext(data)
)
fun ApolloCall.optimisticUpdates(data: D) = addExecutionContext(
OptimisticUpdatesContext(data)
)
internal val ApolloRequest.fetchPolicyInterceptor
get() = executionContext[FetchPolicyContext]?.interceptor ?: CacheFirstInterceptor
internal val ApolloCall.fetchPolicyInterceptor
get() = executionContext[FetchPolicyContext]?.interceptor ?: CacheFirstInterceptor
private val MutableExecutionOptions.refetchPolicyInterceptor
get() = executionContext[RefetchPolicyContext]?.interceptor ?: CacheOnlyInterceptor
internal val ApolloRequest.doNotStore
get() = executionContext[DoNotStoreContext]?.value ?: false
internal val ApolloRequest.memoryCacheOnly
get() = executionContext[MemoryCacheOnlyContext]?.value ?: false
internal val ApolloRequest.storePartialResponses
get() = executionContext[StorePartialResponsesContext]?.value ?: false
internal val ApolloRequest.storeReceiveDate
get() = executionContext[StoreReceiveDateContext]?.value ?: false
internal val ApolloRequest.writeToCacheAsynchronously
get() = executionContext[WriteToCacheAsynchronouslyContext]?.value ?: false
internal val ApolloRequest.optimisticData
get() = executionContext[OptimisticUpdatesContext]?.value
internal val ExecutionOptions.cacheHeaders: CacheHeaders
get() = executionContext[CacheHeadersContext]?.value ?: CacheHeaders.NONE
internal val ApolloRequest.watchContext: WatchContext?
get() = executionContext[WatchContext]
/**
* @param isCacheHit true if this was a cache hit
* @param cacheMissException the exception while reading the cache. Note that it's possible to have [isCacheHit] == false && [cacheMissException] == null
* if no cache read was attempted
*/
class CacheInfo private constructor(
val cacheStartMillis: Long,
val cacheEndMillis: Long,
val networkStartMillis: Long,
val networkEndMillis: Long,
val isCacheHit: Boolean,
val cacheMissException: CacheMissException?,
val networkException: ApolloException?,
val isStale: Boolean,
) : ExecutionContext.Element {
override val key: ExecutionContext.Key<*>
get() = Key
companion object Key : ExecutionContext.Key
fun newBuilder(): Builder {
return Builder().cacheStartMillis(cacheStartMillis)
.cacheEndMillis(cacheEndMillis)
.networkStartMillis(networkStartMillis)
.networkEndMillis(networkEndMillis)
.cacheHit(isCacheHit)
.cacheMissException(cacheMissException)
.networkException(networkException)
.stale(isStale)
}
class Builder {
private var cacheStartMillis: Long = 0
private var cacheEndMillis: Long = 0
private var networkStartMillis: Long = 0
private var networkEndMillis: Long = 0
private var cacheHit: Boolean = false
private var cacheMissException: CacheMissException? = null
private var networkException: ApolloException? = null
private var stale: Boolean = false
fun cacheStartMillis(cacheStartMillis: Long) = apply {
this.cacheStartMillis = cacheStartMillis
}
fun cacheEndMillis(cacheEndMillis: Long) = apply {
this.cacheEndMillis = cacheEndMillis
}
fun networkStartMillis(networkStartMillis: Long) = apply {
this.networkStartMillis = networkStartMillis
}
fun networkEndMillis(networkEndMillis: Long) = apply {
this.networkEndMillis = networkEndMillis
}
fun cacheHit(cacheHit: Boolean) = apply {
this.cacheHit = cacheHit
}
fun cacheMissException(cacheMissException: CacheMissException?) = apply {
this.cacheMissException = cacheMissException
}
fun networkException(networkException: ApolloException?) = apply {
this.networkException = networkException
}
fun stale(stale: Boolean) = apply {
this.stale = stale
}
fun build(): CacheInfo = CacheInfo(
cacheStartMillis = cacheStartMillis,
cacheEndMillis = cacheEndMillis,
networkStartMillis = networkStartMillis,
networkEndMillis = networkEndMillis,
isCacheHit = cacheHit,
cacheMissException = cacheMissException,
networkException = networkException,
isStale = stale,
)
}
}
/**
* True if this response comes from the cache, false if it comes from the network.
*
* Note that this can be true regardless of whether the data was found in the cache.
* To know whether the **data** is from the cache, use `cacheInfo?.isCacheHit == true`.
*/
val ApolloResponse.isFromCache: Boolean
get() {
return cacheInfo?.isCacheHit == true || exception is CacheMissException
}
val ApolloResponse.cacheInfo
get() = executionContext[CacheInfo]
internal fun ApolloResponse.withCacheInfo(cacheInfo: CacheInfo) =
newBuilder().addExecutionContext(cacheInfo).build()
internal fun ApolloResponse.Builder.cacheInfo(cacheInfo: CacheInfo) = addExecutionContext(cacheInfo)
internal class FetchPolicyContext(val interceptor: ApolloInterceptor) : ExecutionContext.Element {
override val key: ExecutionContext.Key<*>
get() = Key
companion object Key : ExecutionContext.Key
}
internal class RefetchPolicyContext(val interceptor: ApolloInterceptor) : ExecutionContext.Element {
override val key: ExecutionContext.Key<*>
get() = Key
companion object Key : ExecutionContext.Key
}
internal class DoNotStoreContext(val value: Boolean) : ExecutionContext.Element {
override val key: ExecutionContext.Key<*>
get() = Key
companion object Key : ExecutionContext.Key
}
internal class MemoryCacheOnlyContext(val value: Boolean) : ExecutionContext.Element {
override val key: ExecutionContext.Key<*>
get() = Key
companion object Key : ExecutionContext.Key
}
internal class StorePartialResponsesContext(val value: Boolean) : ExecutionContext.Element {
override val key: ExecutionContext.Key<*>
get() = Key
companion object Key : ExecutionContext.Key
}
internal class StoreReceiveDateContext(val value: Boolean) : ExecutionContext.Element {
override val key: ExecutionContext.Key<*>
get() = Key
companion object Key : ExecutionContext.Key
}
internal class StoreExpirationDateContext(val value: Boolean) : ExecutionContext.Element {
override val key: ExecutionContext.Key<*>
get() = Key
companion object Key : ExecutionContext.Key
}
internal class WriteToCacheAsynchronouslyContext(val value: Boolean) : ExecutionContext.Element {
override val key: ExecutionContext.Key<*>
get() = Key
companion object Key : ExecutionContext.Key
}
internal class CacheHeadersContext(val value: CacheHeaders) : ExecutionContext.Element {
override val key: ExecutionContext.Key<*>
get() = Key
companion object Key : ExecutionContext.Key
}
internal class OptimisticUpdatesContext(val value: D) : ExecutionContext.Element {
override val key: ExecutionContext.Key<*>
get() = Key
companion object Key : ExecutionContext.Key>
}
internal class WatchContext(
val data: Query.Data?,
) : ExecutionContext.Element {
override val key: ExecutionContext.Key<*>
get() = Key
companion object Key : ExecutionContext.Key
}
internal class FetchFromCacheContext(val value: Boolean) : ExecutionContext.Element {
override val key: ExecutionContext.Key<*>
get() = Key
companion object Key : ExecutionContext.Key
}
internal fun ApolloRequest.Builder.fetchFromCache(fetchFromCache: Boolean) = apply {
addExecutionContext(FetchFromCacheContext(fetchFromCache))
}
internal val ApolloRequest.fetchFromCache
get() = executionContext[FetchFromCacheContext]?.value ?: false
fun ApolloResponse.Builder.cacheHeaders(cacheHeaders: CacheHeaders) =
addExecutionContext(CacheHeadersContext(cacheHeaders))
val ApolloResponse.cacheHeaders
get() = executionContext[CacheHeadersContext]?.value ?: CacheHeaders.NONE
© 2015 - 2025 Weber Informatics LLC | Privacy Policy