Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IDL] Support accepts_definitions functions in IDL #789

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions lib/graphql/language/nodes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -354,10 +354,11 @@ def initialize_node(name: nil, type: nil, default_value: nil)
class VariableIdentifier < NameOnlyNode; end

class SchemaDefinition < AbstractNode
attr_accessor :query, :mutation, :subscription
scalar_attributes :query, :mutation, :subscription
attr_accessor :query, :mutation, :subscription, :description
scalar_attributes :query, :mutation, :subscription, :description

def initialize_node(query: nil, mutation: nil, subscription: nil)
def initialize_node(query: nil, mutation: nil, subscription: nil, description: nil)
@description = description
@query = query
@mutation = mutation
@subscription = subscription
Expand Down
2 changes: 1 addition & 1 deletion lib/graphql/language/parser.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/graphql/language/parser.y
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ rule
| directive_definition

schema_definition:
SCHEMA LCURLY operation_type_definition_list RCURLY { return make_node(:SchemaDefinition, position_source: val[0], **val[2]) }
SCHEMA LCURLY operation_type_definition_list RCURLY { return make_node(:SchemaDefinition, position_source: val[0], description: get_description(val[0]), **val[2]) }

operation_type_definition_list:
operation_type_definition
Expand Down
24 changes: 20 additions & 4 deletions lib/graphql/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class Schema
:object_from_id, :id_from_object,
:default_mask,
:cursor_encoder,
:description,
directives: ->(schema, directives) { schema.directives = directives.reduce({}) { |m, d| m[d.name] = d; m }},
instrument: ->(schema, type, instrumenter, after_built_ins: false) {
if type == :field && after_built_ins
Expand Down Expand Up @@ -444,13 +445,25 @@ def self.from_introspection(introspection_result)
GraphQL::Schema::Loader.load(introspection_result)
end

# Create schema from an IDL schema.
# @param definition_string [String] A schema definition string
# Create schema from an IDL schema or file containing an IDL definition.
# @param definition_or_path [String] A schema definition string, or a path to a file containing the definition
# @param default_resolve [<#call(type, field, obj, args, ctx)>] A callable for handling field resolution
# @param parser [Object] An object for handling definition string parsing (must respond to `parse`)
# @param definitions [Boolean] If true, parse `@`-definitions (this is UNSAFE, parse trusted input only)
# @return [GraphQL::Schema] the schema described by `document`
def self.from_definition(string, default_resolve: BuildFromDefinition::DefaultResolve, parser: BuildFromDefinition::DefaultParser)
GraphQL::Schema::BuildFromDefinition.from_definition(string, default_resolve: default_resolve, parser: parser)
def self.from_definition(definition_or_path, default_resolve: BuildFromDefinition::DefaultResolve, parser: BuildFromDefinition::DefaultParser, definitions: false)
# If the file ends in `.graphql`, treat it like a filepath
definition = if definition_or_path.end_with?(".graphql")
File.read(definition_or_path)
else
definition_or_path
end
GraphQL::Schema::BuildFromDefinition.from_definition(
definition,
default_resolve: default_resolve,
parser: parser,
definitions: definitions,
)
end

# Error that is raised when [#Schema#from_definition] is passed an invalid schema definition string.
Expand Down Expand Up @@ -491,6 +504,9 @@ def to_json(*args)
JSON.pretty_generate(as_json(*args))
end

# @return [String, nil] Used for loading definitions only
attr_accessor :description

protected

def rescues?
Expand Down
53 changes: 45 additions & 8 deletions lib/graphql/schema/build_from_definition.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# frozen_string_literal: true
require "graphql/schema/build_from_definition/define_instrumentation"
require "graphql/schema/build_from_definition/resolve_map"

module GraphQL
class Schema
module BuildFromDefinition
class << self
def from_definition(definition_string, default_resolve:, parser: DefaultParser)
def from_definition(definition_string, default_resolve:, parser:, definitions:)
document = parser.parse(definition_string)
Builder.build(document, default_resolve: default_resolve)
Builder.build(document, default_resolve: default_resolve, definitions: definitions)
end
end

Expand All @@ -25,19 +26,26 @@ def self.call(type, field, obj, args, ctx)
end
end

# @api private
module Builder
extend self

def build(document, default_resolve: DefaultResolve)
raise InvalidDocumentError.new('Must provide a document ast.') if !document || !document.is_a?(GraphQL::Language::Nodes::Document)
# @param document [GraphQL::Language::Nodes::Document]
# @param default_resolve [<#call, #coerce_input, #coerce_result, #resolve_type>]
# @param definitions [Boolean]
def build(document, default_resolve:, definitions:)
if !document || !document.is_a?(GraphQL::Language::Nodes::Document)
raise InvalidDocumentError.new('Must provide a document AST.')
end

if default_resolve.is_a?(Hash)
default_resolve = ResolveMap.new(default_resolve)
end

schema_definition = nil
types = {}
instrumenters = []
if definitions
instrumenters << DefineInstrumentation
end
types.merge!(GraphQL::Schema::BUILT_IN_TYPES)
directives = {}
type_resolver = ->(type) { -> { resolve_type(types, type) } }
Expand Down Expand Up @@ -68,6 +76,21 @@ def build(document, default_resolve: DefaultResolve)
directives[built_in_directive.name] = built_in_directive unless directives[built_in_directive.name]
end

types.each do |name, type|
if type.is_a?(GraphQL::EnumType)
type.values.each do |v_name, v|
v2 = apply_instrumenters(instrumenters, v)
type.add_value(v2)
end
elsif type.kind.fields?
type.fields.each do |f_name, f|
f2 = apply_instrumenters(instrumenters, f)
type.fields[f_name] = f2
end
end
types[name] = apply_instrumenters(instrumenters, type)
end

if schema_definition
if schema_definition.query
raise InvalidDocumentError.new("Specified query type \"#{schema_definition.query}\" not found in document.") unless types[schema_definition.query]
Expand All @@ -83,17 +106,20 @@ def build(document, default_resolve: DefaultResolve)
raise InvalidDocumentError.new("Specified subscription type \"#{schema_definition.subscription}\" not found in document.") unless types[schema_definition.subscription]
subscription_root_type = types[schema_definition.subscription]
end

schema_description = schema_definition.description
else
query_root_type = types['Query']
mutation_root_type = types['Mutation']
subscription_root_type = types['Subscription']
schema_definition = nil
end

raise InvalidDocumentError.new('Must provide schema definition with query type or a type named Query.') unless query_root_type

schema = Schema.define do
raise_definition_error true

description schema_description
query query_root_type
mutation mutation_root_type
subscription subscription_root_type
Expand All @@ -107,6 +133,8 @@ def build(document, default_resolve: DefaultResolve)
directives directives.values
end

schema = apply_instrumenters(instrumenters, schema)

schema
end

Expand All @@ -116,6 +144,10 @@ def build(document, default_resolve: DefaultResolve)

NullScalarCoerce = ->(val, _ctx) { val }

def apply_instrumenters(instrumenters, obj)
instrumenters.reduce(obj) { |o, i| i.instrument(o) }
end

def build_enum_type(enum_type_definition, type_resolver)
GraphQL::EnumType.define(
name: enum_type_definition.name,
Expand Down Expand Up @@ -286,7 +318,12 @@ def build_fields(field_definitions, type_resolver, default_resolve:)

def resolve_type(types, ast_node)
type = GraphQL::Schema::TypeExpression.build_type(types, ast_node)
raise InvalidDocumentError.new("Type \"#{ast_node.name}\" not found in document.") unless type
if type.nil?
while ast_node.respond_to?(:of_type)
ast_node = ast_node.of_type
end
raise InvalidDocumentError.new("Type \"#{ast_node.name}\" not found in document.")
end
type
end
end
Expand Down
55 changes: 55 additions & 0 deletions lib/graphql/schema/build_from_definition/define_instrumentation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true
module GraphQL
class Schema
module BuildFromDefinition
# Apply `accepts_definitions` functions from the schema IDL.
#
# Each macro is converted to a definition function call,
# then removed from the description.
#
# __WARNING!!__ The inputs are passed to `instance_eval`,
# So never pass user input to this instrumenter.
# If you do this, a malicious user could wipe your servers.
#
# @example Equivalent IDL definition
# # A programming language
# # @authorize role: :admin
# type Language {
# # This field is implemented by calling `language_name`
# # @property :language_name
# name: String!
# }
#
# @example Equivalent Ruby DSL definition
# Types::LanguageType = GraphQL::ObjectType.define do
# name "Language"
# description "A programming language"
# authorize(role: :admin)
# field :name, !types.String, property: :language_name
# end
#
module DefineInstrumentation
# This is pretty clugy, it just finds the function name
# and the arguments, then uses them to eval if a matching function is found.
PATTERN = /^\@(.*)$/

# @param target [<#redefine, #description>]
def self.instrument(target)
if target.description.nil?
target
else
defns = target.description.scan(PATTERN)
if defns.any?
target.redefine {
instance_eval(defns.join("\n"))
description(target.description.gsub(PATTERN, "").strip)
}
else
target
end
end
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true
require "spec_helper"

describe GraphQL::Schema::BuildFromDefinition::DefineInstrumentation do
let(:instrumentation) { GraphQL::Schema::BuildFromDefinition::DefineInstrumentation }

describe "PATTERN" do
let(:pattern) { instrumentation::PATTERN }
it "matches macros" do
assert_match pattern, "@thing"
assert_match pattern, "@thing 1, 2"
assert_match pattern, "@thing 1, b: 2"
end
end

describe ".instrument" do
it "applies instrumentation based on the description, removing the macro" do
field = GraphQL::Field.define do
name "f"
description "Calls prop\n@name \"f2\"\n@property :prop\n"
end

field_2 = instrumentation.instrument(field)

assert_equal :prop, field_2.property
assert_equal "f2", field_2.name
assert_equal "Calls prop", field_2.description
end

it "applies to types" do
type = GraphQL::ObjectType.define do
name "Thing"
description "@metadata :a, 1\n@metadata :x, Float::INFINITY"
end

type_2 = instrumentation.instrument(type)
assert_equal "", type_2.description
assert_equal 1, type_2.metadata[:a]
assert_equal Float::INFINITY, type_2.metadata[:x]
end

it "applies to schemas" do
schema = GraphQL::Schema.from_definition <<-GRAPHQL
type Query { i: Int! }
# @max_complexity 100
schema {
query: Query
}
GRAPHQL

schema_2 = instrumentation.instrument(schema)
assert_equal 100, schema_2.max_complexity
end

it "gives a decent backtrace when the syntax isn't valid ruby"
end
end
17 changes: 17 additions & 0 deletions spec/graphql/schema/build_from_definition_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -904,4 +904,21 @@ def self.parse(string)
end
end
end

describe "parsing definitions" do
it "applies definitions to schema, types and fields" do
schema = GraphQL::Schema.from_definition("./spec/support/magic_cards/schema.graphql", definitions: true)
# Schema definitions:
assert_equal 100, schema.max_depth
assert_equal MagicCards::ResolveType, schema.resolve_type_proc

# Field definitions
assert_equal 4, schema.get_field("Card", "colors").complexity
assert_equal "Color identity", schema.get_field("Card", "colors").description

# Type definitions:
assert_equal :ok, schema.types["Expansion"].metadata["thing"]
assert_equal :red, schema.types["Color"].values["RED"].value
end
end
end
7 changes: 7 additions & 0 deletions spec/graphql/schema_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@
built_schema = GraphQL::Schema.from_definition(schema)
assert_equal schema.chop, GraphQL::Schema::Printer.print_schema(built_schema)
end

it "builds from a file" do
schema = GraphQL::Schema.from_definition("spec/support/magic_cards/schema.graphql")
assert_instance_of GraphQL::Schema, schema
expected_types = ["Card", "Color", "Expansion", "Printing"]
assert_equal expected_types, (expected_types & schema.types.keys)
end
end

describe ".from_introspection" do
Expand Down
Loading