lib/graphql/subscriptions.rb



# frozen_string_literal: true
require "securerandom"
require "graphql/subscriptions/event"
require "graphql/subscriptions/instrumentation"
require "graphql/subscriptions/serialize"
if defined?(ActionCable)
  require "graphql/subscriptions/action_cable_subscriptions"
end
require "graphql/subscriptions/subscription_root"

module GraphQL
  class Subscriptions
    # Raised when either:
    # - the triggered `event_name` doesn't match a field in the schema; or
    # - one or more arguments don't match the field arguments
    class InvalidTriggerError < GraphQL::Error
    end

    # @see {Subscriptions#initialize} for options, concrete implementations may add options.
    def self.use(defn, options = {})
      schema = defn.target
      options[:schema] = schema
      schema.subscriptions = self.new(options)
      instrumentation = Subscriptions::Instrumentation.new(schema: schema)
      defn.instrument(:field, instrumentation)
      defn.instrument(:query, instrumentation)
      nil
    end

    # @param schema [Class] the GraphQL schema this manager belongs to
    def initialize(schema:, **rest)
      @schema = schema
    end

    # Fetch subscriptions matching this field + arguments pair
    # And pass them off to the queue.
    # @param event_name [String]
    # @param args [Hash<String, Symbol => Object]
    # @param object [Object]
    # @param scope [Symbol, String]
    # @return [void]
    def trigger(event_name, args, object, scope: nil)
      event_name = event_name.to_s

      # Try with the verbatim input first:
      field = @schema.get_field(@schema.subscription, event_name)

      if field.nil?
        # And if it wasn't found, normalize it:
        normalized_event_name = normalize_name(event_name)
        field = @schema.get_field(@schema.subscription, normalized_event_name)
        if field.nil?
          raise InvalidTriggerError, "No subscription matching trigger: #{event_name} (looked for #{@schema.subscription.graphql_name}.#{normalized_event_name})"
        end
      else
        # Since we found a field, the original input was already normalized
        normalized_event_name = event_name
      end

      # Normalize symbol-keyed args to strings, try camelizing them
      normalized_args = normalize_arguments(normalized_event_name, field, args)

      event = Subscriptions::Event.new(
        name: normalized_event_name,
        arguments: normalized_args,
        field: field,
        scope: scope,
      )
      execute_all(event, object)
    end

    # `event` was triggered on `object`, and `subscription_id` was subscribed,
    # so it should be updated.
    #
    # Load `subscription_id`'s GraphQL data, re-evaluate the query, and deliver the result.
    #
    # This is where a queue may be inserted to push updates in the background.
    #
    # @param subscription_id [String]
    # @param event [GraphQL::Subscriptions::Event] The event which was triggered
    # @param object [Object] The value for the subscription field
    # @return [void]
    def execute(subscription_id, event, object)
      # Lookup the saved data for this subscription
      query_data = read_subscription(subscription_id)
      # Fetch the required keys from the saved data
      query_string = query_data.fetch(:query_string)
      variables = query_data.fetch(:variables)
      context = query_data.fetch(:context)
      operation_name = query_data.fetch(:operation_name)
      # Re-evaluate the saved query
      result = @schema.execute(
        {
          query: query_string,
          context: context,
          subscription_topic: event.topic,
          operation_name: operation_name,
          variables: variables,
          root_value: object,
        }
      )
      deliver(subscription_id, result)
    rescue GraphQL::Schema::Subscription::NoUpdateError
      # This update was skipped in user code; do nothing.
    rescue GraphQL::Schema::Subscription::UnsubscribedError
      # `unsubscribe` was called, clean up on our side
      # TODO also send `{more: false}` to client?
      delete_subscription(subscription_id)
    end

    # Event `event` occurred on `object`,
    # Update all subscribers.
    # @param event [Subscriptions::Event]
    # @param object [Object]
    # @return [void]
    def execute_all(event, object)
      each_subscription_id(event) do |subscription_id|
        execute(subscription_id, event, object)
      end
    end

    # Get each `subscription_id` subscribed to `event.topic` and yield them
    # @param event [GraphQL::Subscriptions::Event]
    # @yieldparam subscription_id [String]
    # @return [void]
    def each_subscription_id(event)
      raise GraphQL::RequiredImplementationMissingError
    end

    # The system wants to send an update to this subscription.
    # Read its data and return it.
    # @param subscription_id [String]
    # @return [Hash] Containing required keys
    def read_subscription(subscription_id)
      raise GraphQL::RequiredImplementationMissingError
    end

    # A subscription query was re-evaluated, returning `result`.
    # The result should be send to `subscription_id`.
    # @param subscription_id [String]
    # @param result [Hash]
    # @return [void]
    def deliver(subscription_id, result)
      raise GraphQL::RequiredImplementationMissingError
    end

    # `query` was executed and found subscriptions to `events`.
    # Update the database to reflect this new state.
    # @param query [GraphQL::Query]
    # @param events [Array<GraphQL::Subscriptions::Event>]
    # @return [void]
    def write_subscription(query, events)
      raise GraphQL::RequiredImplementationMissingError
    end

    # A subscription was terminated server-side.
    # Clean up the database.
    # @param subscription_id [String]
    # @return void.
    def delete_subscription(subscription_id)
      raise GraphQL::RequiredImplementationMissingError
    end

    # @return [String] A new unique identifier for a subscription
    def build_id
      SecureRandom.uuid
    end

    # Convert a user-provided event name or argument
    # to the equivalent in GraphQL.
    #
    # By default, it converts the identifier to camelcase.
    # Override this in a subclass to change the transformation.
    #
    # @param event_or_arg_name [String, Symbol]
    # @return [String]
    def normalize_name(event_or_arg_name)
      Schema::Member::BuildType.camelize(event_or_arg_name.to_s)
    end

    private

    # Recursively normalize `args` as belonging to `arg_owner`:
    # - convert symbols to strings,
    # - if needed, camelize the string (using {#normalize_name})
    # @param arg_owner [GraphQL::Field, GraphQL::BaseType]
    # @param args [Hash, Array, Any] some GraphQL input value to coerce as `arg_owner`
    # @return [Any] normalized arguments value
    def normalize_arguments(event_name, arg_owner, args)
      case arg_owner
      when GraphQL::Field, GraphQL::InputObjectType
        normalized_args = {}
        missing_arg_names = []
        args.each do |k, v|
          arg_name = k.to_s
          arg_defn = arg_owner.arguments[arg_name]
          if arg_defn
            normalized_arg_name = arg_name
          else
            normalized_arg_name = normalize_name(arg_name)
            arg_defn = arg_owner.arguments[normalized_arg_name]
          end

          if arg_defn
            normalized_args[normalized_arg_name] = normalize_arguments(event_name, arg_defn.type, v)
          else
            # Couldn't find a matching argument definition
            missing_arg_names << arg_name
          end
        end

        if missing_arg_names.any?
          arg_owner_name = if arg_owner.is_a?(GraphQL::Field)
            "Subscription.#{arg_owner.name}"
          else
            arg_owner.to_s
          end
          raise InvalidTriggerError, "Can't trigger Subscription.#{event_name}, received undefined arguments: #{missing_arg_names.join(", ")}. (Should match arguments of #{arg_owner_name}.)"
        end

        normalized_args
      when GraphQL::ListType
        args.map { |a| normalize_arguments(event_name, arg_owner.of_type, a) }
      when GraphQL::NonNullType
        normalize_arguments(event_name, arg_owner.of_type, args)
      else
        args
      end
    end
  end
end