From 79606028dbe2380713c02ee34f5be192f1649cce Mon Sep 17 00:00:00 2001 From: James Reggio Date: Wed, 8 Jun 2016 16:36:13 -0500 Subject: [PATCH] Add documentation --- .gitignore | 2 + .../graphql/rails/schema_controller.rb | 14 +++-- config/initializers/graphiql.rb | 2 + graphql-rails.gemspec | 14 +++-- lib/graphql/rails.rb | 12 ++-- lib/graphql/rails/callbacks.rb | 17 +++++- lib/graphql/rails/config.rb | 9 +++ lib/graphql/rails/controller_extensions.rb | 13 +++-- lib/graphql/rails/dsl.rb | 7 +++ lib/graphql/rails/engine.rb | 12 ++-- lib/graphql/rails/extensions/cancan.rb | 8 ++- lib/graphql/rails/extensions/mongoid.rb | 46 +++++++++------ lib/graphql/rails/node_identification.rb | 1 + lib/graphql/rails/operations.rb | 56 ++++++++++++------- lib/graphql/rails/schema.rb | 14 ++++- lib/graphql/rails/types.rb | 23 +++++++- 16 files changed, 180 insertions(+), 70 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2bbe6eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Build +/*.gem diff --git a/app/controllers/graphql/rails/schema_controller.rb b/app/controllers/graphql/rails/schema_controller.rb index cf584b0..4f4bcc2 100644 --- a/app/controllers/graphql/rails/schema_controller.rb +++ b/app/controllers/graphql/rails/schema_controller.rb @@ -1,19 +1,23 @@ module GraphQL module Rails class SchemaController < ActionController::Base + # Extensions are dynamically loaded once during engine initialization; + # however, this controller can be reloaded at any time by Rails. To + # preserve extensions, we use the ControllerExtensions module as a cache. include ControllerExtensions + + # Defined in order of increasing specificity. rescue_from Exception, :with => :internal_error rescue_from GraphQL::ParseError, :with => :invalid_query rescue_from JSON::ParserError, :with => :invalid_variables + # Execute a GraphQL query against the current schema. def execute - query_string = params[:query] - query_variables = to_hash(params[:variables]) render json: Schema.instance.execute( - query_string, - variables: query_variables, + params[:query], + variables: to_hash(params[:variables]), context: context, - debug: true + debug: Rails.config.debug ) end diff --git a/config/initializers/graphiql.rb b/config/initializers/graphiql.rb index f7460e4..14b13fa 100644 --- a/config/initializers/graphiql.rb +++ b/config/initializers/graphiql.rb @@ -1 +1,3 @@ +# There is no apparent harm to enabling CSRF token-passing for GraphiQL, even +# if the Rails app doesn't use CSRF protection. GraphiQL::Rails.config.csrf = true diff --git a/graphql-rails.gemspec b/graphql-rails.gemspec index 545ea60..4035233 100644 --- a/graphql-rails.gemspec +++ b/graphql-rails.gemspec @@ -9,14 +9,20 @@ Gem::Specification.new do |s| s.email = ['james.reggio@gmail.com'] s.homepage = 'https://github.com/jamesreggio/graphql-rails' s.summary = 'Zero-configuration GraphQL + Relay support for Rails' - s.description = 'TODO' + s.description = <<-EOM +Zero-configuration GraphQL + Relay support for Rails. Adds a route to process +GraphQL operations and provides a visual editor (GraphiQL) during development. +Allows you to specify GraphQL queries and mutations as though they were +controller actions. Automatically maps Mongoid models to GraphQL types. +Seamlessly integrates with CanCan. + EOM s.license = 'MIT' - s.files = Dir['{app,config,lib}/**/*', 'README.md', 'LICENSE'] + s.files = Dir['{app,config,lib}/**/*', 'LICENSE'] + s.required_ruby_version = '>= 2.1.0' s.add_dependency 'rails', '~> 4' s.add_dependency 'graphql', '~> 0.13' s.add_dependency 'graphql-relay', '~> 0.9' - # s.add_dependency 'graphiql-rails', '~> 1.2' - s.add_development_dependency 'sqlite3' + s.add_dependency 'graphiql-rails', '~> 1.2' end diff --git a/lib/graphql/rails.rb b/lib/graphql/rails.rb index b673da2..0e73916 100644 --- a/lib/graphql/rails.rb +++ b/lib/graphql/rails.rb @@ -3,14 +3,16 @@ require 'graphql/relay' require 'graphiql/rails' +# Order dependent. + require 'graphql/rails/version' -require 'graphql/rails/dsl' -require 'graphql/rails/engine' -require 'graphql/rails/config' require 'graphql/rails/config' +require 'graphql/rails/engine' + +require 'graphql/rails/dsl' require 'graphql/rails/types' -require 'graphql/rails/node_identification' -require 'graphql/rails/controller_extensions' require 'graphql/rails/schema' require 'graphql/rails/callbacks' require 'graphql/rails/operations' +require 'graphql/rails/node_identification' +require 'graphql/rails/controller_extensions' diff --git a/lib/graphql/rails/callbacks.rb b/lib/graphql/rails/callbacks.rb index 3cf84f3..b324ac6 100644 --- a/lib/graphql/rails/callbacks.rb +++ b/lib/graphql/rails/callbacks.rb @@ -1,19 +1,27 @@ module GraphQL module Rails class Operations + # Implement callback methods on Operations. + # These are akin to the 'filters' available on ActionController::Base. + # http://api.rubyonrails.org/classes/AbstractController/Callbacks.html module Callbacks extend ActiveSupport::Concern include ActiveSupport::Callbacks + # All callbacks are registered under the :perform_operation event. included do define_callbacks :perform_operation end module ClassMethods + # Callbacks can be registered with the following methods: + # before_operation, before_filter + # around_operation, around_filter + # after_operation, after_filter [:before, :after, :around].each do |callback| - define_method "#{callback}_operation" do |*names, &blk| - insert_callbacks(names, blk) do |name, options| - set_callback(:perform_operation, callback, name, options) + define_method "#{callback}_operation" do |*names, &block| + insert_callbacks(names, block) do |target, options| + set_callback :perform_operation, callback, target, options end end alias_method :"#{callback}_filter", :"#{callback}_operation" @@ -21,11 +29,13 @@ module ClassMethods private + # Convert :only and :except options into :if and :unless blocks. def normalize_callback_options(options) normalize_callback_option(options, :only, :if) normalize_callback_option(options, :except, :unless) end + # Convert an operation name-based condition into an executable block. def normalize_callback_option(options, from, to) return unless options[from] check = -> do @@ -34,6 +44,7 @@ def normalize_callback_option(options, from, to) options[to] = Array(options[to]) + [check] end + # Normalize the arguments passed during callback registration. def insert_callbacks(callbacks, block = nil) options = callbacks.extract_options! normalize_callback_options(options) diff --git a/lib/graphql/rails/config.rb b/lib/graphql/rails/config.rb index 9ddc284..ee9de57 100644 --- a/lib/graphql/rails/config.rb +++ b/lib/graphql/rails/config.rb @@ -2,12 +2,17 @@ module GraphQL module Rails extend self + # Yields the configuration object to a block, per convention. def configure yield config end + # Configuration for this gem. def config @config ||= OpenStruct.new({ + # Should graphql-ruby be placed into debug mode? + :debug => ::Rails.env.development?, + # Should the GraphiQL web interface be served? :graphiql => ::Rails.env.development?, @@ -19,6 +24,10 @@ def config # This is necessary to conform to the Relay Global Object ID spec. :global_ids => true, + # Maximum nesting for GraphQL queries. + # Specify nil for unlimited nesting depth. + :max_depth => 8, + # Should the following extensions be loaded? :mongoid => defined?(::Mongoid), :cancan => defined?(::CanCan), diff --git a/lib/graphql/rails/controller_extensions.rb b/lib/graphql/rails/controller_extensions.rb index bedfee8..d975a4e 100644 --- a/lib/graphql/rails/controller_extensions.rb +++ b/lib/graphql/rails/controller_extensions.rb @@ -1,22 +1,25 @@ module GraphQL module Rails + # Extensions are dynamically loaded once during engine initialization; + # however, SchemaController can be reloaded at any time by Rails. To + # preserve extensions to SchemaController, they're registered here. module ControllerExtensions extend self def add(&block) - callbacks.push block + extensions.push block end def included(base) - callbacks.each do |callback| - base.class_eval(&callback) + extensions.each do |extensions| + base.class_eval(&extensions) end end private - def callbacks - @callbacks ||= [] + def extensions + @extensions ||= [] end end end diff --git a/lib/graphql/rails/dsl.rb b/lib/graphql/rails/dsl.rb index 6017ec6..d8634cd 100644 --- a/lib/graphql/rails/dsl.rb +++ b/lib/graphql/rails/dsl.rb @@ -1,5 +1,12 @@ module GraphQL module Rails + # Object that runs a block in the context of itself, but delegates unknown + # methods back to the block's original context. This is useful for creating + # DSLs to aid with object initialization. + # + # Note that this class extends from BasicObject, which means that _all_ + # global classes and modules must be prefixed by a double-colon (::) in + # order to resolve. class DSL < BasicObject def run(&block) @self = eval('self', block.binding) diff --git a/lib/graphql/rails/engine.rb b/lib/graphql/rails/engine.rb index 7af595e..6eebc39 100644 --- a/lib/graphql/rails/engine.rb +++ b/lib/graphql/rails/engine.rb @@ -11,10 +11,10 @@ module Rails class Engine < ::Rails::Engine isolate_namespace GraphQL::Rails + # Even though we aren't using symbolic autoloading of operations, they + # must be included in autoload_paths in order to be unloaded during + # reload operations. initializer 'graphql-rails.autoload', :before => :set_autoload_paths do |app| - # Even though we aren't using symbolic autoloading of operations, they - # must be included in autoload_paths in order to be unloaded during - # reload operations. @graph_path = app.root.join('app', 'graph') app.config.autoload_paths += [ @graph_path.join('types'), @@ -22,6 +22,7 @@ class Engine < ::Rails::Engine ] end + # Extend the Rails logger with a facility for logging exceptions. initializer 'graphql-rails.logger', :after => :initialize_logger do |app| logger = ::Rails.logger.clone logger.class_eval do @@ -37,14 +38,16 @@ def exception(e) Rails.logger.debug 'Initialized logger' end + # Extensions depend upon a loaded Rails app, so we load them dynamically. initializer 'graphql-rails.extensions', :after => :load_config_initializers do |app| - # These depend upon a loaded Rails app, so we load them dynamically. extensions = File.join(File.dirname(__FILE__), 'extensions', '*.rb') Dir[extensions].each do |file| require file end end + # Hook into Rails reloading in order to clear state from internal + # stateful modules and reload operations from the Rails app. initializer 'graphql-rails.prepare', :before => :add_to_prepare_blocks do # The block executes in the context of the reloader, so we have to # preserve a reference to the engine instance. @@ -54,6 +57,7 @@ def exception(e) end end + # Clear state and load operations from the Rails app. def reload! Types.clear Schema.clear diff --git a/lib/graphql/rails/extensions/cancan.rb b/lib/graphql/rails/extensions/cancan.rb index d16e4b9..f2e247f 100644 --- a/lib/graphql/rails/extensions/cancan.rb +++ b/lib/graphql/rails/extensions/cancan.rb @@ -3,6 +3,8 @@ module Rails if Rails.config.cancan Rails.logger.debug 'Loading CanCan extensions' + # Implement methods from CanCan::ControllerAdditions in Operations. + # http://www.rubydoc.info/github/ryanb/cancan/CanCan/ControllerAdditions Operations.class_eval do extend Forwardable def_delegators :current_ability, :can?, :cannot? @@ -26,7 +28,7 @@ def authorize!(*args) begin @authorized = true current_ability.authorize!(*args) - rescue CanCan::AccessDenied + rescue ::CanCan::AccessDenied raise 'You are not authorized to perform this operation' end end @@ -36,10 +38,12 @@ def current_ability end def current_user - ctx[:current_user] + context[:current_user] end end + # Make the current_user available during GraphQL execution via the + # operation context object. ControllerExtensions.add do before_filter do context[:current_user] = current_user diff --git a/lib/graphql/rails/extensions/mongoid.rb b/lib/graphql/rails/extensions/mongoid.rb index 59391c1..9b64c80 100644 --- a/lib/graphql/rails/extensions/mongoid.rb +++ b/lib/graphql/rails/extensions/mongoid.rb @@ -3,26 +3,30 @@ module Rails if Rails.config.mongoid Rails.logger.debug 'Loading Mongoid extensions' + # Use the built-in RelationConnection to handle Mongoid relations. GraphQL::Relay::BaseConnection.register_connection_implementation( ::Mongoid::Relations::Targets::Enumerable, GraphQL::Relay::RelationConnection ) + # Mongoid type extension for the GraphQL type system. module Mongoid extend self - NAMESPACE = 'MG' - + # Clear internal state, probably due to a Rails reload. def clear @types = nil end + # Resolve an arbitrary type to a GraphQL type. + # Returns nil if the type isn't a Mongoid document. def resolve(type) types[type] || build_type(type) end + # Lookup an arbitrary object from its GraphQL type name and ID. def lookup(type_name, id) - return if Types.use_namespaces? && !type_name.starts_with?(NAMESPACE) + return unless type_name.starts_with?(namespace) types.each_pair do |type, graph_type| return type.find(id) if graph_type.name == type_name end @@ -31,6 +35,17 @@ def lookup(type_name, id) private + # Namespace for Mongoid types, if namespaces are required. + def namespace + if Types.use_namespaces? + 'MG' + else + '' + end + end + + # Cached mapping of Mongoid types to GraphQL types, initialized with + # mappings for common built-in scalar types. def types @types ||= { ::Mongoid::Boolean => GraphQL::BOOLEAN_TYPE, @@ -38,27 +53,34 @@ def types } end + # Build a GraphQL type for a Mongoid document. + # Returns nil if the type isn't a Mongoid document. def build_type(type) return nil unless type.included_modules.include?(::Mongoid::Document) Rails.logger.debug "Building Mongoid::Document type: #{type.name}" - # TODO: Support parent types/interfaces. - type_name = to_name(type) + # Build and cache the GraphQL type. + # TODO: Map type inheritance to GraphQL interfaces. + type_name = Types.to_name(type.name, namespace) types[type] = GraphQL::ObjectType.define do name type_name + # Add the global node ID, if enabled. if Rails.config.global_ids interfaces [NodeIdentification.interface] global_id_field :id end + # Add each field from the document. + # TODO: Support field exclusion and renaming. type.fields.each_value do |field_value| - field field_value.name do + field Types.to_name(field_value.name) do type -> { Types.resolve(field_value.type) } description field_value.label unless field_value.label.blank? end end + # Add each relationship from the document as a Relay connection. type.relations.each_value do |relationship| # TODO: Add polymorphic support. if relationship.polymorphic? @@ -69,25 +91,17 @@ def build_type(type) end if relationship.many? - connection relationship.name do + connection Types.to_name(relationship.name) do type -> { Types.resolve(relationship.klass).connection_type } end else - field relationship.name do + field Types.to_name(relationship.name) do type -> { Types.resolve(relationship.klass) } end end end end end - - def to_name(type) - if Types.use_namespaces? - NAMESPACE + type.name - else - type.name - end - end end Types.add_extension Mongoid diff --git a/lib/graphql/rails/node_identification.rb b/lib/graphql/rails/node_identification.rb index b0b1d0e..2d3557f 100644 --- a/lib/graphql/rails/node_identification.rb +++ b/lib/graphql/rails/node_identification.rb @@ -1,5 +1,6 @@ module GraphQL module Rails + # Implements globally-unique object IDs for Relay compatibility. NodeIdentification = GraphQL::Relay::GlobalNodeIdentification.define do object_from_id -> (id, ctx) do Types.lookup(*NodeIdentification.from_global_id(id)) diff --git a/lib/graphql/rails/operations.rb b/lib/graphql/rails/operations.rb index b9a1f46..6c630cc 100644 --- a/lib/graphql/rails/operations.rb +++ b/lib/graphql/rails/operations.rb @@ -1,9 +1,16 @@ module GraphQL module Rails + # Base type for operations classes in the Rails app. + # Operations are specified in a manner similar to controller actions, and + # can access variables and state localized to the current operation. + # Classes can define callbacks similar to controller 'filters'. class Operations extend Forwardable include Callbacks + # Initialize an instance with state pertaining to the current operation. + # Accessors for this state are created and proxied through to the + # specified options hash. def initialize(options = {}) @options = OpenStruct.new(options) self.class.instance_eval do @@ -11,6 +18,18 @@ def initialize(options = {}) end end + # Define a query operation. + # Definitions should have the following form: + # + # query :find_cats => [Cat] do + # description 'This query returns a list of Cat models' + # argument :age, Integer, :required + # argument :breed, String + # resolve do + # raise 'Too old' if args[:age] > 20 + # Cat.find(age: args[:age], breed: args[:breed]) + # end + # end def self.query(hash, &block) hash = extract_pair(hash) Rails.logger.debug "Adding query: #{to_name(hash[:name])}" @@ -25,26 +44,10 @@ def self.query(hash, &block) end # TODO: Implement mutations and subscriptions. - # TODO: Implement model functions (only, exclude, rename, etc.) private - def self.extract_pair(hash) - unless hash.length == 1 - raise 'Hash must contain a single :name => Type pair.' - end - {name: hash.keys.first, type: hash.values.first} - end - - # TODO: Ensure consistent naming convention around everything. - def self.to_name(symbol) - if Rails.config.camel_case - symbol.to_s.camelize(:lower) - else - symbol.to_s - end - end - + # DSL for query definition. class QueryDefinition < DSL attr_reader :field @@ -55,7 +58,7 @@ def initialize(klass) def name(name) @name = name - @field.name = to_name(name) + @field.name = Types.to_name(name) end def type(type) @@ -69,25 +72,30 @@ def description(description) def argument(name, type, required = false) argument = ::GraphQL::Argument.new - argument.name = to_name(name) + argument.name = Types.to_name(name) argument.type = Types.resolve(type, required == :required) @field.arguments[argument.name] = argument end def resolve(&block) field.resolve = -> (obj, args, ctx) do + # Instantiate the Operations class with state on this query. instance = @klass.new({ op: :query, name: @name, type: @type, - obj: obj, args: args, ctx: ctx + obj: obj, args: args, ctx: ctx, context: ctx }) begin + # Run callbacks for this Operations class. instance.run_callbacks(:perform_operation) do + # Call out to the app-defined resolver. instance.instance_eval(&block) end rescue => e + # Surface messages from standard errors in GraphQL response. ::GraphQL::ExecutionError.new(e.message) rescue ::Exception => e + # Log and genericize other runtime errors. Rails.logger.error "Unexpected exception during query: #{@name}" Rails.logger.exception e ::GraphQL::ExecutionError.new('Internal error') @@ -95,6 +103,14 @@ def resolve(&block) end end end + + # Extract parts from a hash passed to the operation definition DSL. + def self.extract_pair(hash) + unless hash.length == 1 + raise 'Hash must contain a single :name => Type pair.' + end + {name: hash.keys.first, type: hash.values.first} + end end end end diff --git a/lib/graphql/rails/schema.rb b/lib/graphql/rails/schema.rb index a079e81..461ff1d 100644 --- a/lib/graphql/rails/schema.rb +++ b/lib/graphql/rails/schema.rb @@ -1,8 +1,11 @@ module GraphQL module Rails + # Defines the GraphQL schema, consisting of + # queries, mutations, and subscriptions. module Schema extend self + # Clear internal state, probably due to a Rails reload. def clear @schema = nil @fields = Hash.new { |hash, key| hash[key] = [] } @@ -10,6 +13,7 @@ def clear TYPES = [:query, :mutation, :subscription] + # Register a field in the GraphQL schema. TYPES.each do |type| define_method "add_#{type.to_s}" do |field| @schema = nil # Invalidate cached schema. @@ -17,19 +21,23 @@ def clear end end + # Lazily build the GraphQL schema instance. def instance - # TODO: Support max_depth and types. - # TODO: Sweep available options and expose in config. @schema ||= GraphQL::Schema.new begin - TYPES.reduce({}) do |schema, type| + TYPES.reduce({ + max_depth: Rails.config.max_depth, + }) do |schema, type| fields = @fields[type] unless fields.empty? + # Build an object for each operation type. schema[type] = GraphQL::ObjectType.define do name type.to_s.capitalize description "Root #{type.to_s} for this schema" + # Add a field for each operation. fields.each do |value| field value.name, field: value end + # Add the global node ID lookup query. if Rails.config.global_ids && type == :query field :node, field: NodeIdentification.field end diff --git a/lib/graphql/rails/types.rb b/lib/graphql/rails/types.rb index 69c69af..ae46cbb 100644 --- a/lib/graphql/rails/types.rb +++ b/lib/graphql/rails/types.rb @@ -1,8 +1,11 @@ module GraphQL module Rails + # Type system responsible for resolving GraphQL types. + # Delegates creation of GraphQL types to ORM-specific extensions. module Types extend self + # Clear internal state, probably due to a Rails reload. def clear @types = nil extensions.each do |extension| @@ -11,23 +14,25 @@ def clear end # Resolve an arbitrary type to a GraphQL type. + # Lists can be specified with single-element arrays; for example: + # [String] resolves to a list of GraphQL::STRING_TYPE objects. def resolve(type, required = false) if type.nil? - raise 'Cannot resolve nil type.' + raise 'Cannot resolve nil type' elsif required resolve(type).to_non_null_type elsif type.is_a?(GraphQL::BaseType) type elsif type.is_a?(Array) unless type.length == 1 - raise 'Lists must be specified with single-element arrays.' + raise 'Lists must be specified with single-element arrays' end resolve(type.first).to_list_type elsif types.include?(type) resolve(types[type]) else resolve(try_extensions(:resolve, type) || begin - # TODO: Remove this hack. + # TODO: Decide whether to use String as a fallback, or raise. Rails.logger.warn "Unable to resolve type: #{type.name}" String end) @@ -52,6 +57,17 @@ def add_extension(extension) extensions.push extension end + # Convert a type or field name to a string with the correct convention, + # applying an optional namespace. + def to_name(name, namespace = '') + return namespace + to_name(name) unless namespace.blank? + if Rails.config.camel_case + name.to_s.camelize(:lower) + else + name.to_s + end + end + private # Default mapping of built-in scalar types to GraphQL types. @@ -74,6 +90,7 @@ def types } end + # List of registered extensions. def extensions @extensions ||= [] end