module Surikat

Constants

VERSION

Attributes

options[RW]
session[RW]

Public Class Methods

allowed?(route) click to toggle source

Check if AAA is enabled and the route passes. If the route contains no permitted_roles then it's assumed to be public. If the value of permitted_roles is “any”, then it's assumed to be private regardless of the role of the current user. If the value of permitted_roles is an Array, then the route will be accepted if there's an intersection between the required roles and the role of the current user.

# File lib/surikat.rb, line 414
def allowed?(route)
  return true if route['permitted_roles'].nil?

  session = self.session || {}

  if route['permitted_roles']
    unless session[:user_id]
      puts "Route is private but there is no current user." if self.options[:debug]
      false
    else
      if route['permitted_roles'] == 'any'
        true
      else
        current_user = User.where(id: session[:user_id]).first
        if (route['permitted_roles'].to_a & current_user.roleids.to_s.split(',').map(&:strip)).empty?
          puts "Route is private and requires roles #{route['permitted_roles'].inspect} but current user has roles #{current_user.roleids.inspect}" if self.options[:debug]
          false
        else
          true
        end
      end
    end
  end

end
cast(data, type_name, is_array, field_name = nil) click to toggle source

Make sure that the type of the data conforms to what's in the requested type.

# File lib/surikat.rb, line 58
def cast(data, type_name, is_array, field_name = nil)
  type_singular_nobang = type_name.gsub(/[\[\]\!]/, '')

  if is_array
    raise "List of data of type #{type_name} in field '#{field_name}' may not contain nil values" if type_name.include?('!') && data.include?(nil)
    result = data.to_a.map do |x|
      if Types::BASIC.include? type_singular_nobang
        cast_scalar(x, type_singular_nobang)
      else
        r              = {}
        type           = Surikat.types[type_singular_nobang]
        allowed_fields = x.keys & type['fields'].keys
        allowed_fields.each do |af|
          type_name = type['fields'][af]

          r[af] = cast(x[af], type_name, type_name.first == '[', af)
        end

        r
      end
    end
  else
    raise "Data of type #{type_name} for field '#{field_name}' may not be nil" if type_name.last == '!' && data.nil?

    if Types::BASIC.include? type_singular_nobang
      result = cast_scalar(data, type_singular_nobang)
    else
      result         = {}
      type           = Surikat.types[type_singular_nobang]
      allowed_fields = data.keys & type['fields'].keys
      allowed_fields.each do |af|
        type_name  = type['fields'][af]
        result[af] = cast(data[af], type_name, type_name.first == '[', af)
      end
    end
  end
  result
end
cast_scalar(data, type_name) click to toggle source

Make sure that the type of the data (guaranteed to be scalar) conforms to the requested type.

# File lib/surikat.rb, line 38
def cast_scalar(data, type_name)
  return nil if data.nil?

  case type_name
  when 'Int'
    data.to_i
  when 'Float'
    data.to_f
  when 'Boolean'
    {'true' => true, 'false' => false}[data.to_s]
  when 'String'
    data.to_s
  when 'ID'
    data # could be Integer or String, depending on the AR adapter
  else
    raise "Unknown type, #{type_name}"
  end
end
check_variables(variables, variable_definition) click to toggle source
# File lib/surikat.rb, line 285
def check_variables(variables, variable_definition)
  variable_definition.each do |expected_var_name, expected_var_type|
    value = variables[expected_var_name]

    expected_var_type_singular = expected_var_type.gsub(/[\[\]]/, '')
    expected_var_type_simple   = expected_var_type.gsub(/[\[\]\!]/, '')
    is_plural                  = [expected_var_type.first, expected_var_type.last] == %w([ ])

    if is_plural
      unless value.is_a? Array
        raise "Variable '#{expected_var_name}' should be an array; its expected type is #{expected_var_type}."
      end

      value.each do |v_value|
        check_variables({v_value => v_value}, {v_value => expected_var_type_singular})
      end
    else # singular type
      if Types::BASIC.include?(expected_var_type_simple)

        if value.nil?
          if expected_var_type.include?('!')
            raise "Variable '#{expected_var_name}' is not allowed to be nil; its expected type is #{expected_var_type}."
          end
        end

        unless cast_scalar(value, expected_var_type_simple) == value
          raise "Variable '#{expected_var_name}' is of type #{value.class.to_s} which is incompatible with the expected type #{expected_var_type}"
        end
      else
        Types.new.all[expected_var_type]['arguments'].each do |arg_name, arg_type|
          check_variables({arg_name => variables[expected_var_name][arg_name]}, {arg_name => arg_type})
        end
      end
    end
  end

  true
end
config() click to toggle source
# File lib/surikat.rb, line 7
def config
  @config ||= OpenStruct.new({
                                 app: YamlConfigurator.config_for('application', ENV['RACK_ENV']),
                                 db:  YamlConfigurator.config_for('database', ENV['RACK_ENV'])
                             })
end
hashify(data, selections, type_name) click to toggle source

Convert a result set into a hash (if singular) or an array of hashes (if not singular) that contain only the requested selectors and their values.

# File lib/surikat.rb, line 100
def hashify(data, selections, type_name)
  puts "HASHIFY INPUT:
       \tdata: #{data.inspect}
       \tclass of data: #{data.class}
       \ttype_name: #{type_name.inspect}" if self.options[:debug]

  type_name_is_array = [type_name[0], type_name[-1]].join == '[]'

  # When no AR record was found, return a nil value rather than an empty instance
  # Sadly this causes a SELECT and I cannot find any way around it. Presumably it's a very cheap SELECT,
  # but it's still needless. :(
  if data.class.to_s.include?('ActiveRecord_Relation') && !data.exists?
    return type_name_is_array ? [] : nil
  end

  type_name_single = type_name.gsub(/[\[\]\!]/, '')

  if Types::BASIC.include? type_name_single
    type_is_basic = true
  else
    type_is_basic = false
    type          = types[type_name_single]
    fields        = type['fields']
    superclass = Object.const_get(type_name_single).superclass rescue nil
  end

  shallow_selectors, deep_selectors = selections.partition {|sel| sel.selections.empty?}

  if superclass.to_s.include? 'Surikat::BaseModel' # AR models have table_selectors because they have tables
    column_names = Object.const_get(type_name_single).column_names rescue []

    table_selectors, method_selectors = shallow_selectors.partition do |sel|
      column_names.include?(sel.name) && sel.arguments.empty? # a table selector becomes method selector if it has arguments.
    end

  else
    table_selectors  = []
    method_selectors = shallow_selectors
  end

  puts "
       \ttype_name_single: #{type_name_single}
       \tfields: #{fields.inspect}
       \tsuperclass: #{superclass}
       \tbasic type: #{type_is_basic}
       \tcolumn names: #{column_names}
       \ttable selectors: #{table_selectors.map(&:name).join(', ')}
       \tmethod selectors: #{method_selectors.map(&:name).join(', ')}
       \tdeep selectors: #{deep_selectors.map(&:name).join(', ')}
       \tshallow selectors: #{shallow_selectors.map(&:name).join(', ')}
       \ttype_name is array: #{type_name_is_array}
       \tdata is pluckable: #{data.respond_to?(:pluck).inspect}" if self.options[:debug]


  return cast(data, type_name, type_name_is_array, type_name) if type_is_basic
  data = data.first if !type_name_is_array && data.class.to_s == 'ActiveRecord::Relation'

  return({errors: data&.errors&.to_a}) if data.respond_to?(:errors) && data.errors.to_a.any?

  unless type_name_is_array # data is a single record
    hashified_data = {}

    unless table_selectors.empty?
      if data.respond_to?(:pluck) && method_selectors.empty? && deep_selectors.empty?
        unique_table_selector_names = table_selectors.map(&:name).uniq
        plucked_data                = data.pluck(*unique_table_selector_names).flatten
        unique_table_selector_names.each_with_index do |s_name, idx|
          hashified_data[s_name] = cast(plucked_data[idx], fields[s_name], false, s_name)
        end
      else
        method_selectors += table_selectors
      end
    end

    data = data.first if data.class.to_s.include?('ActiveRecord') && (method_selectors.any? || deep_selectors.any?)

    method_selectors.each do |s|
      if data.is_a? Hash
        accepted_arguments = []
      else
        accepted_arguments = data.class.instance_method(s.name)&.parameters&.select {|p| [p.first == :req]}&.map(&:last)
      end
      allowed_arguments = accepted_arguments.map {|aa| s.arguments.detect {|qa| qa.name.to_s == aa.to_s}&.value}

      uncast                 = data.is_a?(Hash) ? (data[s.name] || data[s.name.to_sym]) : data.send(s.name, *allowed_arguments)
      hashified_data[s.name] = cast(uncast, fields[s.name], uncast.is_a?(Array), s.name)
    end

    deep_selectors.each do |s|
      uncast                 = if data.is_a? Hash
                                 data[s.name] || data[s.name.to_sym]
                               else
                                 deeper = data.send(s.name)
                                 hashify(deeper, s.selections, fields[s.name])
                               end
      hashified_data[s.name] = cast(uncast, fields[s.name], uncast.is_a?(Array), s.name)
    end
  else # data is a set of records
    hashified_data = []
    # if there are no method selectors, use +pluck+ to optimise.
    if method_selectors.empty? && deep_selectors.empty? && !table_selectors.empty?
      data.pluck(*(table_selectors.map(&:name).uniq)).each do |record|
        hash = {}

        if table_selectors.size == 1 # if there's only one table selector, pluck returns a flatter array
          fname       = table_selectors.first.name
          hash[fname] = cast(record, fields[fname], false, fname)
        else
          table_selectors.each_with_index do |s, idx|
            hash[s.name] = cast(record[idx], fields[s.name], false, s.name)
          end
        end


        deep_selectors.each do |s|
          accepted_arguments = record.class.instance_method(s.name)&.parameters&.select {|p| [p.first == :req]}&.map(&:last)
          allowed_arguments  = accepted_arguments.map {|aa| s.arguments.detect {|qa| qa.name.to_s == aa.to_s}&.value}

          uncast       = hashify(
              record.send(s.name, *allowed_arguments),
              s.selections,
              fields[s.name]
          )
          hash[s.name] = cast(uncast, fields[s.name], uncast.is_a?(Array), s.name)
        end
        hashified_data << hash
      end
    else # We have method selectors, so we retrieve the entire records and then we can call the method selectors.
      data.each do |record|
        hash = {}

        # We need to cast the records into their type data so that we have access to their specific methods.
        if superclass == BaseType
          record = type_name_single.constantize.new(record)
        end

        shallow_selectors.each do |s|
          if record.is_a? Hash
            accepted_arguments = []
          else
            accepted_arguments = record.class.instance_method(s.name)&.parameters&.select {|p| [p.first == :req]}&.map(&:last)
          end

          allowed_arguments = accepted_arguments.map {|aa| s.arguments.detect {|qa| qa.name.to_s == aa.to_s}&.value}

          uncast       = record.is_a?(Hash) ? (record[s.name] || record[s.name.to_sym]) : record.send(s.name, *allowed_arguments)
          hash[s.name] = cast(uncast, fields[s.name], uncast.is_a?(Array), s.name)
        end

        deep_selectors.each do |s|
          uncast       = hashify(
              record.is_a?(Hash) ? (record[s.name] || record[s.name.to_sym]) : record.send(s.name),
              s.selections,
              fields[s.name]
          )
          hash[s.name] = cast(uncast, fields[s.name], uncast.is_a?(Array), s.name)
        end

        hashified_data << hash
      end
    end
  end

  hashified_data
end
invalid_selectors(given, expected) click to toggle source
# File lib/surikat.rb, line 324
def invalid_selectors(given, expected)
  expected_singular = expected.gsub(/[\[\]\!]/, '')

  if Types::BASIC.include?(expected_singular)
    expected_type = {'fields' => {}}
  else
    expected_type = Types.new.all[expected_singular]
  end

  given.selections.map(&:name) - expected_type['fields'].keys
end
mutation(selection, variable_definitions, variables) click to toggle source

Turn a parsed mutation into a response suitable for serialization. Returns the Hash object and an errors array.

# File lib/surikat.rb, line 379
def mutation(selection, variable_definitions, variables)
  name  = selection.name
  route = routes['mutations'][name]

  return([nil, [{unknownQueryName: true}]]) if route.nil?
  return([nil, [{accessDenied: true}]]) unless allowed?(route)

  begin
    check_variables(variables, variable_definitions)
  rescue Exception => e
    return([nil, [variableError: e.message]])
  end

  queries = Object.const_get(route['class']).new(variables, self.session)
  data    = queries.send(route['method'])

  begin
    hashified_data = hashify(data, selection.selections, route['output_type'])
    if hashified_data.is_a?(Hash) && hashified_data[:errors]
      [nil, hashified_data[:errors]]
    else
      [hashified_data, []]
    end
  rescue Exception => e
    puts "EXCEPTION: #{e.message}\n#{e.backtrace.join("\n")}"
    return([nil, [error: e.message]])
  end

end
query(selection) click to toggle source

Turn a parsed query into a response by means of a routing table Returns the response, suitable for serialization, and an errors array.

# File lib/surikat.rb, line 338
def query(selection)
  name  = selection.name
  route = routes['queries'][name]

  return([nil, [{unknownQueryName: true}]]) if route.nil?
  return([nil, [{accessDenied: true}]]) unless allowed?(route)

  arguments = {}
  selection.arguments.each do |argument|
    arguments[argument.name] = argument.value
  end

  unless cast_arguments = validate_arguments(arguments, route['arguments'])
    error = "Expected arguments: {#{route['arguments'].to_a.map {|k, v| "#{k} (#{v})"}.join(', ')}}. Received instead {#{arguments.to_a.map {|k, v| "#{k}: #{v}"}.join(', ')}}."
    return([nil, [{argumentError: error}]])
  end

  invalid_s = invalid_selectors(selection, route['output_type'])
  return([nil, [{selectorError: "Invalid selectors: #{invalid_s.join(', ')}"}]]) unless invalid_s.empty?

  queries = Object.const_get(route['class']).new(cast_arguments, self.session)
  data    = queries.send(route['method'])

  return([nil, [{noResult: true}]]) if data.nil? || data.class.to_s == 'ActiveRecord::Relation' && !data.exists?

  begin
    hashified_data = hashify(data, selection.selections, route['output_type'])
    if hashified_data.is_a?(Hash) && hashified_data[:errors]
      [nil, hashified_data[:errors]]
    else
      [hashified_data, []]
    end
  rescue Exception => e
    puts "EXCEPTION: #{e.message}\n#{e.backtrace.join("\n")}"
    return([nil, [{error: e.message}]])
  end

end
routes() click to toggle source
# File lib/surikat.rb, line 29
def routes
  @routes ||= Routes.new.all
end
run(query, variables = nil, options = {}) click to toggle source
# File lib/surikat.rb, line 440
def run(query, variables = nil, options = {})
  self.options = options
  parsed_query = GraphQL.parse query

  self.session = options[:session_key].blank? ? {} : Surikat::Session.new(options[:session_key])

  data   = {}
  errors = []

  parsed_query.definitions.each do |definition|
    case definition.operation_type

    when 'query'
      definition.selections.each do |selection|
        q_result, q_errors = query(selection)
        errors             += q_errors

        data[selection.name] = q_result
      end

    when 'mutation'
      variable_definitions = {}
      definition.variables.each {|v| variable_definitions[v.name] = v.type.name}

      definition.selections.each do |selection|
        q_result, q_errors = mutation(selection, variable_definitions, variables)
        errors             += q_errors

        data[selection.name] = q_result
      end
    end
  end

  result = {data: data}
  result.merge!({errors: errors}) unless errors.empty?

  result
end
types() click to toggle source
# File lib/surikat.rb, line 25
def types
  @types ||= Types.new.all
end
validate_arguments(given, expected) click to toggle source
# File lib/surikat.rb, line 266
def validate_arguments(given, expected)
  expected ||= {}
  given    ||= {}

  required = expected.keys.select {|k| expected[k].include?('!')}

  # Make sure all required arguments are present
  return false unless (required & given.keys) == required

  # Make sure no unknown arguments exist
  return false unless (given.keys - expected.keys).empty?

  given.each do |k, v|
    given[k] = cast_scalar(v, expected[k])
  end

  given
end