Created
March 18, 2025 21:22
-
-
Save gmac/f3e02b87c2ff552a715a960deac3ae81 to your computer and use it in GitHub Desktop.
graphql response fixture
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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