# frozen_string_literal: true # Grape helpers for caching. # # This module helps introduce standardised caching into the Grape API # in a similar manner to the standard Grape DSL. module API module Helpers module Caching # @return [ActiveSupport::Duration] DEFAULT_EXPIRY = 1.day # @return [ActiveSupport::Cache::Store] def cache Rails.cache end # This is functionally equivalent to the standard `#present` used in # Grape endpoints, but the JSON for the object, or for each object of # a collection, will be cached. # # With a collection all the keys will be fetched in a single call and the # Entity rendered for those missing from the cache, which are then written # back into it. # # Both the single object, and all objects inside a collection, must respond # to `#cache_key`. # # To override the Grape formatter we return a custom wrapper in # `Gitlab::Json::PrecompiledJson` which tells the `Gitlab::Json::GrapeFormatter` # to export the string without conversion. # # A cache context can be supplied to add more context to the cache key. This # defaults to including the `current_user` in every key for safety, unless overridden. # # @param obj_or_collection [Object, Enumerable] the object or objects to render # @param with [Grape::Entity] the entity to use for rendering # @param cache_context [Proc] a proc to call for each object to provide more context to the cache key # @param expires_in [ActiveSupport::Duration, Integer] an expiry time for the cache entry # @param presenter_args [Hash] keyword arguments to be passed to the entity # @return [Gitlab::Json::PrecompiledJson] def present_cached(obj_or_collection, with:, cache_context: -> (_) { current_user.cache_key }, expires_in: DEFAULT_EXPIRY, **presenter_args) json = if obj_or_collection.is_a?(Enumerable) cached_collection( obj_or_collection, presenter: with, presenter_args: presenter_args, context: cache_context, expires_in: expires_in ) else cached_object( obj_or_collection, presenter: with, presenter_args: presenter_args, context: cache_context, expires_in: expires_in ) end body Gitlab::Json::PrecompiledJson.new(json) end private # Optionally uses a `Proc` to add context to a cache key # # @param object [Object] must respond to #cache_key # @param context [Proc] a proc that will be called with the object as an argument, and which should return a # string or array of strings to be combined into the cache key # @return [String] def contextual_cache_key(object, context) return object.cache_key if context.nil? [object.cache_key, context.call(object)].flatten.join(":") end # Used for fetching or rendering a single object # # @param object [Object] the object to render # @param presenter [Grape::Entity] # @param presenter_args [Hash] keyword arguments to be passed to the entity # @param context [Proc] # @param expires_in [ActiveSupport::Duration, Integer] an expiry time for the cache entry # @return [String] def cached_object(object, presenter:, presenter_args:, context:, expires_in:) cache.fetch(contextual_cache_key(object, context), expires_in: expires_in) do Gitlab::Json.dump(presenter.represent(object, **presenter_args).as_json) end end # Used for fetching or rendering multiple objects # # @param objects [Enumerable] the objects to render # @param presenter [Grape::Entity] # @param presenter_args [Hash] keyword arguments to be passed to the entity # @param context [Proc] # @param expires_in [ActiveSupport::Duration, Integer] an expiry time for the cache entry # @return [Array] def cached_collection(collection, presenter:, presenter_args:, context:, expires_in:) json = fetch_multi(collection, context: context, expires_in: expires_in) do |obj| Gitlab::Json.dump(presenter.represent(obj, **presenter_args).as_json) end json.values end # An adapted version of ActiveSupport::Cache::Store#fetch_multi. # # The original method only provides the missing key to the block, # not the missing object, so we have to create a map of cache keys # to the objects to allow us to pass the object to the missing value # block. # # The result is that this is functionally identical to `#fetch`. def fetch_multi(*objs, context:, **kwargs) objs.flatten! map = multi_key_map(objs, context: context) cache.fetch_multi(*map.keys, **kwargs) do |key| yield map[key] end end # @param objects [Enumerable] objects which _must_ respond to `#cache_key` # @param context [Proc] a proc that can be called to help generate each cache key # @return [Hash] def multi_key_map(objects, context:) objects.index_by do |object| contextual_cache_key(object, context) end end end end end