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

commonMain.com.apollographql.apollo3.cache.normalized.ClientCacheExtensions.kt Maven / Gradle / Ivy

@file:JvmName("NormalizedCache")

package com.apollographql.apollo3.cache.normalized

import com.apollographql.apollo3.ApolloCall
import com.apollographql.apollo3.ApolloClient
import com.apollographql.apollo3.annotations.ApolloDeprecatedSince
import com.apollographql.apollo3.annotations.ApolloDeprecatedSince.Version.v3_0_0
import com.apollographql.apollo3.annotations.ApolloDeprecatedSince.Version.v3_7_5
import com.apollographql.apollo3.annotations.ApolloExperimental
import com.apollographql.apollo3.api.ApolloRequest
import com.apollographql.apollo3.api.ApolloResponse
import com.apollographql.apollo3.api.ExecutionContext
import com.apollographql.apollo3.api.MutableExecutionOptions
import com.apollographql.apollo3.api.Mutation
import com.apollographql.apollo3.api.Operation
import com.apollographql.apollo3.api.Query
import com.apollographql.apollo3.api.http.get
import com.apollographql.apollo3.cache.normalized.api.ApolloCacheHeaders
import com.apollographql.apollo3.cache.normalized.api.CacheHeaders
import com.apollographql.apollo3.cache.normalized.api.CacheKeyGenerator
import com.apollographql.apollo3.cache.normalized.api.CacheResolver
import com.apollographql.apollo3.cache.normalized.api.FieldPolicyCacheResolver
import com.apollographql.apollo3.cache.normalized.api.NormalizedCacheFactory
import com.apollographql.apollo3.cache.normalized.api.TypePolicyCacheKeyGenerator
import com.apollographql.apollo3.cache.normalized.internal.ApolloCacheInterceptor
import com.apollographql.apollo3.cache.normalized.internal.WatcherInterceptor
import com.apollographql.apollo3.exception.ApolloCompositeException
import com.apollographql.apollo3.exception.ApolloException
import com.apollographql.apollo3.exception.CacheMissException
import com.apollographql.apollo3.interceptor.ApolloInterceptor
import com.apollographql.apollo3.interceptor.ApolloInterceptorChain
import com.apollographql.apollo3.mpp.currentTimeMillis
import com.apollographql.apollo3.network.http.HttpInfo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlin.jvm.JvmName
import kotlin.jvm.JvmOverloads

enum class FetchPolicy {
  /**
   * Try the cache, if that failed, try the network.
   *
   * An [ApolloCompositeException] is thrown if the data is not in the cache and the network call failed.
   * If coming from the cache 1 value is emitted, otherwise 1 or multiple values can be emitted from the network.
   *
   * This is the default behaviour.
   */
  CacheFirst,

  /**
   * Only try the cache.
   *
   * A [CacheMissException] is thrown if the data is not in the cache, otherwise 1 value is emitted.
   */
  CacheOnly,

  /**
   * Try the network, if that failed, try the cache.
   *
   * An [ApolloCompositeException] is thrown if the network call failed and the data is not in the cache.
   * If coming from the network 1 or multiple values can be emitted, otherwise 1 value is emitted from the cache.
   */
  NetworkFirst,

  /**
   * Only try the network.
   *
   * An [ApolloException] is thrown if the network call failed, otherwise 1 or multiple values can be emitted.
   */
  NetworkOnly,

  /**
   * Try the cache, then also try the network.
   *
   * If the data is in the cache, it is emitted, if not, no exception is thrown at that point. Then the network call is made, and if
   * successful the value(s) are emitted, otherwise either an [ApolloCompositeException] (both cache miss and network failed) or an
   * [ApolloException] (only network failed) is thrown.
   */
  CacheAndNetwork,
}

/**
 * Configures an [ApolloClient] with a normalized cache.
 *
 * @param normalizedCacheFactory a factory that creates a [com.apollographql.apollo3.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("configureApolloClientBuilder")
fun ApolloClient.Builder.normalizedCache(
    normalizedCacheFactory: NormalizedCacheFactory,
    cacheKeyGenerator: CacheKeyGenerator = TypePolicyCacheKeyGenerator,
    cacheResolver: CacheResolver = FieldPolicyCacheResolver,
    writeToCacheAsynchronously: Boolean = false,
): ApolloClient.Builder {
  return store(ApolloStore(normalizedCacheFactory, cacheKeyGenerator, cacheResolver), 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 {
  return addInterceptor(WatcherInterceptor(store))
      .addInterceptor(FetchPolicyRouterInterceptor)
      .addInterceptor(ApolloCacheInterceptor(store))
      .writeToCacheAsynchronously(writeToCacheAsynchronously)
}

/**
 * Gets initial response(s) then observes the cache for any changes.
 * [fetchPolicy] controls how the result is first queried, while [refetchPolicy] will control the subsequent fetches.
 * Network and cache exceptions are ignored by default, this can be changed by setting [fetchThrows] for the first fetch and [refetchThrows]
 * for subsequent fetches (non Apollo exceptions like `OutOfMemoryError` are always propagated).
 *
 * @param fetchThrows whether to throw if an [ApolloException] happens during the initial fetch. Default: false
 * @param refetchThrows whether to throw if an [ApolloException] happens during a refetch. Default: false
 */
@JvmOverloads
fun  ApolloCall.watch(
    fetchThrows: Boolean = false,
    refetchThrows: Boolean = false,
): Flow> {
  return flow {
    var lastResponse: ApolloResponse? = null
    var response: ApolloResponse? = null

    toFlow()
        .catch {
          if (it !is ApolloException || fetchThrows) throw it
        }.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)
        .watch(response?.data) { _, _ ->
          // If the exception is ignored (refetchThrows is false), we should continue watching - so retry
          !refetchThrows
        }.onStart {
          if (lastResponse != null) {
            emit(lastResponse!!)
          }
        }.collect {
          emit(it)
        }
  }
}

/**
 * Observes the cache for the given data. Unlike [watch], no initial request is executed on the network.
 * Network and cache exceptions are ignored by default, this can be controlled with the [retryWhen] lambda.
 * The fetch policy set by [fetchPolicy] will be used.
 */
fun  ApolloCall.watch(
    data: D?,
    retryWhen: suspend (cause: Throwable, attempt: Long) -> Boolean = { _, _ -> true },
): Flow> {
  return copy().addExecutionContext(WatchContext(data, retryWhen)).toFlow()
}

/**
 * Gets the result from the cache first and always fetch from the network. Use this to get an early
 * cached result while also updating the network values.
 *
 * Any [FetchPolicy] previously set will be ignored
 */
@Deprecated("Use fetchPolicy(FetchPolicy.CacheAndNetwork) instead", ReplaceWith("fetchPolicy(FetchPolicy.CacheAndNetwork).toFlow()"))
@ApolloDeprecatedSince(v3_7_5)
fun  ApolloCall.executeCacheAndNetwork(): Flow> {
  return flow {
    var cacheException: ApolloException? = null
    var networkException: ApolloException? = null
    try {
      emit(copy().fetchPolicy(FetchPolicy.CacheOnly).execute())
    } catch (e: ApolloException) {
      cacheException = e
    }

    try {
      emit(copy().fetchPolicy(FetchPolicy.NetworkOnly).execute())
    } catch (e: ApolloException) {
      networkException = e
    }

    if (cacheException != null && networkException != null) {
      throw ApolloCompositeException(
          first = cacheException,
          second = networkException
      )
    }
  }
}

val ApolloClient.apolloStore: ApolloStore
  get() {
    return interceptors.firstOrNull { it is ApolloCacheInterceptor }?.let {
      (it as ApolloCacheInterceptor).store
    } ?: error("no cache configured")
  }

@Deprecated("Used for backward compatibility with 2.x.", ReplaceWith("apolloStore"))
@ApolloDeprecatedSince(v3_0_0)
fun ApolloClient.apolloStore(): ApolloStore = apolloStore

@Deprecated(
    message = "Use apolloStore directly",
    replaceWith = ReplaceWith("apolloStore.clearAll()")
)
@ApolloDeprecatedSince(v3_0_0)
fun ApolloClient.clearNormalizedCache() = apolloStore.clearAll()

/**
 * 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 emitCacheMisses Whether to emit cache misses instead of throwing.
 * The returned response will have `response.data == null`
 * You can read `response.cacheInfo` to get more information about the cache miss
 *
 * Default: false
 */
fun  MutableExecutionOptions.emitCacheMisses(emitCacheMisses: Boolean) = addExecutionContext(
    EmitCacheMissesContext(emitCacheMisses)
)

/**
 * @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
 */
@ApolloExperimental
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
 */
@ApolloExperimental
fun  MutableExecutionOptions.storeExpirationDate(storeExpirationDate: Boolean): T {
  addExecutionContext(StoreExpirationDateContext(storeExpirationDate))
  if (this is ApolloClient.Builder) {
    check(interceptors.none { it is StoreExpirationInterceptor }) {
      "Apollo: storeExpirationDate() can only be called once on ApolloClient.Builder()"
    }
    addInterceptor(StoreExpirationInterceptor())
  }
  @Suppress("UNCHECKED_CAST")
  return this as T
}

private class StoreExpirationInterceptor: 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.DATE, expires.toString())
                  .build()
          )
          .build()
    }
  }
}

/**
 * @param cacheHeaders additional cache headers to be passed to your [com.apollographql.apollo3.cache.normalized.api.NormalizedCache]
 */
fun  MutableExecutionOptions.cacheHeaders(cacheHeaders: CacheHeaders) = addExecutionContext(
    CacheHeadersContext(cacheHeaders)
)

/**
 * @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.storePartialResponses
  get() = executionContext[StorePartialResponsesContext]?.value ?: false

internal val  ApolloRequest.storeReceiveDate
  get() = executionContext[StoreReceiveDateContext]?.value ?: false

internal val  ApolloRequest.emitCacheMisses
  get() = executionContext[EmitCacheMissesContext]?.value ?: false

internal val  ApolloRequest.writeToCacheAsynchronously
  get() = executionContext[WriteToCacheAsynchronouslyContext]?.value ?: false

internal val  ApolloRequest.optimisticData
  get() = executionContext[OptimisticUpdatesContext]?.value

internal val  ApolloRequest.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?,
) : ExecutionContext.Element {

  @Deprecated("Use CacheInfo.Builder")
  constructor(
      millisStart: Long,
      millisEnd: Long,
      hit: Boolean,
      missedKey: String?,
      missedField: String?,
  ) : this(
      cacheStartMillis = millisStart,
      cacheEndMillis = millisEnd,
      networkStartMillis = 0,
      networkEndMillis = 0,
      isCacheHit = hit,
      cacheMissException = missedKey?.let { CacheMissException(it, missedField) },
      networkException = null
  )

  override val key: ExecutionContext.Key<*>
    get() = Key

  @Deprecated("Use cacheStartMillis instead", ReplaceWith("cacheStartMillis"))
  val millisStart: Long
    get() = cacheStartMillis

  @Deprecated("Use cacheEndMillis instead", ReplaceWith("cacheEndMillis"))
  val millisEnd: Long
    get() = cacheEndMillis

  @Deprecated("Use cacheHit instead", ReplaceWith("cacheHit"))
  val hit: Boolean
    get() = isCacheHit

  @Deprecated("Use cacheMissException?.key instead", ReplaceWith("cacheMissException?.key"))
  val missedKey: String?
    get() = cacheMissException?.key

  @Deprecated("Use cacheMissException?.fieldName instead", ReplaceWith("cacheMissException?.fieldName"))
  val missedField: String?
    get() = cacheMissException?.fieldName


  companion object Key : ExecutionContext.Key

  fun newBuilder(): Builder {
    return Builder().cacheStartMillis(cacheStartMillis)
        .cacheEndMillis(cacheEndMillis)
        .networkStartMillis(networkStartMillis)
        .networkEndMillis(networkEndMillis)
        .cacheHit(isCacheHit)
        .networkException(networkException)
  }

  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

    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 build(): CacheInfo = CacheInfo(
        cacheStartMillis = cacheStartMillis,
        cacheEndMillis = cacheEndMillis,
        networkStartMillis = networkStartMillis,
        networkEndMillis = networkEndMillis,
        isCacheHit = cacheHit,
        cacheMissException = cacheMissException,
        networkException = networkException
    )
  }
}

val  ApolloResponse.isFromCache: Boolean
  get() {
    return cacheInfo?.isCacheHit == true
  }

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 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 EmitCacheMissesContext(val value: Boolean) : 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?,
    val retryWhen: suspend (cause: Throwable, attempt: Long) -> Boolean,
) : 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