lib/graphql/subscriptions.rb



# frozen_string_literal: true
require "securerandom"
require "graphql/subscriptions/broadcast_analyzer"
require "graphql/subscriptions/event"
require "graphql/subscriptions/instrumentation"
require "graphql/subscriptions/serialize"
require "graphql/subscriptions/action_cable_subscriptions"
require "graphql/subscriptions/default_subscription_resolve_extension"

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

    # Raised when either:
    # - An initial subscription didn't have a value for `context[subscription_scope]`
    # - Or, an update didn't pass `.trigger(..., scope:)`
    # When raised, the initial subscription or update fails completely.
    class SubscriptionScopeMissingError < GraphQL::Error
    end

    # @see {Subscriptions#initialize} for options, concrete implementations may add options.
    def self.use(defn, options = {})
      schema = defn.is_a?(Class) ? defn : defn.target

      if schema.subscriptions(inherited: false)
        raise ArgumentError, "Can't reinstall subscriptions. #{schema} is using #{schema.subscriptions}, can't also add #{self}"
      end

      instrumentation = Subscriptions::Instrumentation.new(schema: schema)
      defn.instrument(:query, instrumentation)
      options[:schema] = schema
      schema.subscriptions = self.new(**options)
      schema.add_subscription_extension_if_necessary
      nil
    end

    # @param schema [Class] the GraphQL schema this manager belongs to
    # @param validate_update [Boolean] If false, then validation is skipped when executing updates
    def initialize(schema:, validate_update: true, broadcast: false, default_broadcastable: false, **rest)
      if broadcast
        schema.query_analyzer(Subscriptions::BroadcastAnalyzer)
      end
      @default_broadcastable = default_broadcastable
      @schema = schema
      @validate_update = validate_update
    end

    # @return [Boolean] Used when fields don't have `broadcastable:` explicitly set
    attr_reader :default_broadcastable

    # 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]
    # @param context [Hash]
    # @return [void]
    def trigger(event_name, args, object, scope: nil, context: {})
      # Make something as context-like as possible, even though there isn't a current query:
      dummy_query = GraphQL::Query.new(@schema, "", validate: false, context: context)
      context = dummy_query.context
      event_name = event_name.to_s

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

      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, context)
        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
      # Should this accept a real context somehow?
      normalized_args = normalize_arguments(normalized_event_name, field, args, GraphQL::Query::NullContext)

      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 return the result.
    #
    # @param subscription_id [String]
    # @param event [GraphQL::Subscriptions::Event] The event which was triggered
    # @param object [Object] The value for the subscription field
    # @return [GraphQL::Query::Result]
    def execute_update(subscription_id, event, object)
      # Lookup the saved data for this subscription
      query_data = read_subscription(subscription_id)
      if query_data.nil?
        delete_subscription(subscription_id)
        return nil
      end

      # 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)
      execute_options = {
        query: query_string,
        context: context,
        subscription_topic: event.topic,
        operation_name: operation_name,
        variables: variables,
        root_value: object,
      }
      execute_options[:validate] = validate_update?(**execute_options)
      result = @schema.execute(**execute_options)
      subscriptions_context = result.context.namespace(:subscriptions)
      if subscriptions_context[:no_update]
        result = nil
      end

      unsubscribed = subscriptions_context[:unsubscribed]

      if unsubscribed
        # `unsubscribe` was called, clean up on our side
        # TODO also send `{more: false}` to client?
        delete_subscription(subscription_id)
        result = nil
      end

      result
    end

    # Define this method to customize whether to validate
    # this subscription when executing an update.
    #
    # @return [Boolean] defaults to `true`, or false if `validate: false` is provided.
    def validate_update?(query:, context:, root_value:, subscription_topic:, operation_name:, variables:)
      @validate_update
    end

    # Run the update query for this subscription and deliver it
    # @see {#execute_update}
    # @see {#deliver}
    # @return [void]
    def execute(subscription_id, event, object)
      res = execute_update(subscription_id, event, object)
      if !res.nil?
        deliver(subscription_id, res)
      end
    end

    # Event `event` occurred on `object`,
    # Update all subscribers.
    # @param event [Subscriptions::Event]
    # @param object [Object]
    # @return [void]
    def execute_all(event, object)
      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

    # @return [Boolean] if true, then a query like this one would be broadcasted
    def broadcastable?(query_str, **query_options)
      query = GraphQL::Query.new(@schema, query_str, **query_options)
      if !query.valid?
        raise "Invalid query: #{query.validation_errors.map(&:to_h).inspect}"
      end
      GraphQL::Analysis::AST.analyze_query(query, @schema.query_analyzers)
      query.context.namespace(:subscriptions)[:subscription_broadcastable]
    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, context)
      case arg_owner
      when GraphQL::Schema::Field, Class
        if arg_owner.is_a?(Class) && !arg_owner.kind.input_object?
          # it's a type, but not an input object
          return args
        end
        normalized_args = {}
        missing_arg_names = []
        args.each do |k, v|
          arg_name = k.to_s
          arg_defn = arg_owner.get_argument(arg_name, context)
          if arg_defn
            normalized_arg_name = arg_name
          else
            normalized_arg_name = normalize_name(arg_name)
            arg_defn = arg_owner.get_argument(normalized_arg_name, context)
          end

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

        # Backfill default values so that trigger arguments
        # match query arguments.
        arg_owner.arguments(context).each do |_name, arg_defn|
          if arg_defn.default_value? && !normalized_args.key?(arg_defn.name)
            default_value = arg_defn.default_value
            # We don't have an underlying "object" here, so it can't call methods.
            # This is broken.
            normalized_args[arg_defn.name] = arg_defn.prepare_value(nil, default_value, context: context)
          end
        end

        if missing_arg_names.any?
          arg_owner_name = if arg_owner.is_a?(GraphQL::Schema::Field)
            arg_owner.path
          elsif arg_owner.is_a?(Class)
            arg_owner.graphql_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::Schema::List
        args.map { |a| normalize_arguments(event_name, arg_owner.of_type, a, context) }
      when GraphQL::Schema::NonNull
        normalize_arguments(event_name, arg_owner.of_type, args, context)
      else
        args
      end
    end
  end
end