Skip to content

Instantly share code, notes, and snippets.

@gmac
Created March 18, 2025 21:22
Show Gist options
  • Save gmac/f3e02b87c2ff552a715a960deac3ae81 to your computer and use it in GitHub Desktop.
Save gmac/f3e02b87c2ff552a715a960deac3ae81 to your computer and use it in GitHub Desktop.
graphql response fixture
module GraphQL
class ResponseFixture
SYSTEM_TYPENAME = "__typename__"
SCALAR_VALIDATORS = {
"Boolean" => -> (data) { data.is_a?(TrueClass) || data.is_a?(FalseClass) },
"Float" => -> (data) { data.is_a?(Numeric) },
"ID" => -> (data) { data.is_a?(String) || data.is_a?(Integer) },
"Int" => -> (data) { data.is_a?(Integer) },
"String" => -> (data) { data.is_a?(String) },
"JSON" => -> (data) { data.is_a?(Hash) },
}.freeze
class ResponseFixtureError < StandardError; end
class Repository
def initialize(base_path: "./", scalar_validators: {}, system_typename: SYSTEM_TYPENAME)
@base_path = base_path
@scalar_validators = SCALAR_VALIDATORS.merge(scalar_validators)
@system_typename = system_typename
end
def fetch(fixture_name, query)
data = File.read(fixture_file_path(fixture_name))
fixture = ResponseFixture.new(
query,
data,
scalar_validators: @scalar_validators,
system_typename: @system_typename,
)
fixture.valid?
fixture.prune!
end
def write(fixture_name, data)
File.write(fixture_file_path(fixture_name), JSON.generate(data))
end
def fixture_file_path(fixture_name)
"#{@base_path}/#{fixture_name}.json"
end
end
attr_reader :error_message
def initialize(
query,
data,
scalar_validators: SCALAR_VALIDATORS,
system_typename: SYSTEM_TYPENAME,
)
@query = query
@data = data
@valid = nil
@error_message = nil
@scalar_validators = scalar_validators
@system_typename = system_typename
@system_typenames = Set.new
end
def valid?
return @valid unless @valid.nil?
op = @query.selected_operation
parent_type = @query.root_type_for_operation(op.operation_type)
validate_selections(parent_type, op, @data)
@valid = true
rescue ResponseFixtureError => e
@error_message = e.message
@valid = false
end
def prune!
@system_typenames.each { _1.delete(@system_typename) }
self
end
def to_h
@data
end
private
def validate_selections(parent_type, parent_node, data_part, path = [])
if parent_type.non_null?
raise ResponseFixtureError, "Expected non-null selection `#{path.join(".")}` to provide value" if data_part.nil?
return validate_selections(parent_type.of_type, parent_node, data_part, path)
elsif data_part.nil?
# nullable node with a null value is okay
return true
elsif parent_type.list?
raise ResponseFixtureError, "Expected list selection `#{path.join(".")}` to provide Array" unless data_part.is_a?(Array)
return data_part.all? { |item| validate_selections(parent_type.of_type, parent_node, item, path) }
elsif parent_type.kind.leaf?
return validate_leaf(parent_type, data_part, path)
elsif !data_part.is_a?(Hash)
raise ResponseFixtureError, "Expected composite selection `#{path.join(".")}` to provide Hash"
end
parent_node.selections.all? do |node|
case node
when GraphQL::Language::Nodes::Field
path << (node.alias || node.name)
raise ResponseFixtureError, "Expected data to provide field `#{path.join(".")}`" unless data_part.key?(path.last)
next_type = node.name == "__typename" ? @query.get_type("String") : @query.get_field(parent_type, node.name).type
next_value = data_part[path.last]
result = validate_selections(next_type, node, next_value, path)
path.pop
result
when GraphQL::Language::Nodes::InlineFragment
resolved_type = resolved_type(parent_type, data_part, path)
fragment_type = node.type.nil? ? parent_type : @query.get_type(node.type.name)
return true unless @query.possible_types(fragment_type).include?(resolved_type)
validate_selections(fragment_type, node, data_part, path)
when GraphQL::Language::Nodes::FragmentSpread
resolved_type = resolved_type(parent_type, data_part, path)
fragment_def = @query.fragments[node.name]
fragment_type = @query.get_type(fragment_def.type.name)
return true unless @query.possible_types(fragment_type).include?(resolved_type)
validate_selections(fragment_type, fragment_def, data_part, path)
end
end
end
def validate_leaf(parent_type, data, path)
valid = if parent_type.kind.enum?
parent_type.values.key?(data)
elsif parent_type.kind.scalar?
validator = @scalar_validators[parent_type.graphql_name]
validator.nil? || validator.call(data)
end
unless valid
raise ResponseFixtureError, "Expected #{parent_type.graphql_name} at `#{path.join(".")}` to provide a valid value"
end
true
end
def resolved_type(parent_type, data_part, path)
return parent_type unless parent_type.kind.abstract?
typename = data_part["__typename"] || data_part[@system_typename]
if typename.nil?
raise ResponseFixtureError, "Abstract position at `#{path.join(".")}` expects `__typename` or system typename hint"
end
@system_typenames.add(data_part) if data_part.key?(@system_typename)
@query.get_type(typename)
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment