├── docs ├── .nojekyll ├── getting_started │ └── setup.md ├── _sidebar.md ├── other_tools │ ├── schema_dump.md │ └── query_runner.md ├── logging_and_monitoring │ └── logging_and_monitoring.md ├── testing │ └── testing.md ├── index.html └── components │ └── decorator.md ├── .ruby-version ├── .rspec ├── .hound.yml ├── lib ├── graphql_rails │ ├── version.rb │ ├── errors │ │ ├── error.rb │ │ ├── execution_error.rb │ │ ├── system_error.rb │ │ ├── custom_execution_error.rb │ │ └── validation_error.rb │ ├── railtie.rb │ ├── types │ │ ├── object_type.rb │ │ ├── argument_type.rb │ │ ├── field_type.rb │ │ ├── input_object_type.rb │ │ └── hidable_by_group.rb │ ├── concerns │ │ ├── service.rb │ │ └── chainable_options.rb │ ├── router │ │ ├── plain_cursor_encoder.rb │ │ ├── query_route.rb │ │ ├── mutation_route.rb │ │ ├── schema.rb │ │ ├── event_route.rb │ │ ├── route.rb │ │ ├── schema_builder.rb │ │ ├── resource_routes_builder.rb │ │ └── build_schema_action_type.rb │ ├── attributes.rb │ ├── integrations.rb │ ├── tasks │ │ ├── schema.rake │ │ ├── dump_graphql_schema.rb │ │ └── dump_graphql_schemas.rb │ ├── attributes │ │ ├── type_name_info.rb │ │ ├── input_type_parser.rb │ │ ├── attribute_name_parser.rb │ │ ├── attributable.rb │ │ ├── attribute.rb │ │ ├── attribute_configurable.rb │ │ ├── input_attribute.rb │ │ └── type_parser.rb │ ├── integrations │ │ ├── sentry.rb │ │ └── lograge.rb │ ├── model │ │ ├── build_connection_type │ │ │ └── count_items.rb │ │ ├── direct_field_resolver.rb │ │ ├── find_or_build_graphql_input_type.rb │ │ ├── input.rb │ │ ├── build_connection_type.rb │ │ ├── configurable.rb │ │ ├── add_fields_to_graphql_type.rb │ │ ├── find_or_build_graphql_type_class.rb │ │ ├── build_enum_type.rb │ │ ├── call_graphql_model_method.rb │ │ ├── find_or_build_graphql_type.rb │ │ └── configuration.rb │ ├── controller │ │ ├── action_hook.rb │ │ ├── build_controller_action_resolver │ │ │ └── controller_action_resolver.rb │ │ ├── build_controller_action_resolver.rb │ │ ├── action.rb │ │ ├── request.rb │ │ ├── request │ │ │ └── format_errors.rb │ │ ├── action_hooks_runner.rb │ │ ├── handle_controller_error.rb │ │ ├── log_controller_action.rb │ │ ├── configuration.rb │ │ └── action_configuration.rb │ ├── input_configurable.rb │ ├── model.rb │ ├── decorator.rb │ ├── query_runner.rb │ ├── decorator │ │ └── relation_decorator.rb │ ├── controller.rb │ ├── rspec_controller_helpers.rb │ └── router.rb ├── generators │ └── graphql_rails │ │ ├── templates │ │ ├── graphql_application_controller.erb │ │ ├── graphql_controller.erb │ │ ├── example_users_controller.erb │ │ ├── graphql_router_spec.erb │ │ └── graphql_router.erb │ │ └── install_generator.rb └── graphql_rails.rb ├── Rakefile ├── spec ├── support │ └── dummy_app │ │ ├── dummy.rb │ │ ├── models │ │ └── dummy │ │ │ └── user.rb │ │ └── controllers │ │ └── dummy │ │ └── users_controllers.rb ├── graphql_rails_spec.rb ├── lib │ └── graphql_rails │ │ ├── router │ │ ├── resource_routes_builder_spec.rb │ │ ├── plain_cursor_encoder_spec.rb │ │ ├── build_schema_action_type_spec.rb │ │ └── route_spec.rb │ │ ├── errors │ │ ├── system_error_spec.rb │ │ └── validation_error_spec.rb │ │ ├── controller │ │ ├── request_spec.rb │ │ ├── build_controller_action_resolver_spec.rb │ │ ├── action_spec.rb │ │ ├── action_hook_spec.rb │ │ ├── request │ │ │ └── format_errors_spec.rb │ │ ├── log_controller_action_spec.rb │ │ └── handle_controller_error_spec.rb │ │ ├── concerns │ │ └── service_spec.rb │ │ ├── model │ │ ├── build_connection_type │ │ │ └── count_items_spec.rb │ │ ├── build_enum_type_spec.rb │ │ ├── find_or_build_graphql_input_type_spec.rb │ │ ├── find_or_build_graphql_type_class_spec.rb │ │ ├── input_spec.rb │ │ └── direct_field_resolver_spec.rb │ │ ├── tasks │ │ ├── dump_graphql_schemas_spec.rb │ │ └── dump_graphql_schema_spec.rb │ │ ├── query_runner_spec.rb │ │ ├── decorator_spec.rb │ │ ├── types │ │ └── hidable_by_group_spec.rb │ │ ├── attributes │ │ ├── attribute_name_parser_spec.rb │ │ └── type_parseable_spec.rb │ │ ├── rspec_controller_helpers_spec.rb │ │ └── decorator │ │ └── relation_decorator_spec.rb └── spec_helper.rb ├── bin ├── setup └── console ├── .gitignore ├── Gemfile ├── .github └── workflows │ └── ruby.yml ├── .rubocop.yml ├── LICENSE.txt ├── graphql_rails.gemspec └── CODE_OF_CONDUCT.md /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.2 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.hound.yml: -------------------------------------------------------------------------------- 1 | rubocop: 2 | config_file: .rubocop.yml 3 | version: 1.5.2 4 | fail_on_violations: true 5 | -------------------------------------------------------------------------------- /lib/graphql_rails/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | VERSION = '3.1.0' 5 | end 6 | -------------------------------------------------------------------------------- /lib/graphql_rails/errors/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | class Error < StandardError 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /spec/support/dummy_app/dummy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'controllers/dummy/users_controllers' 4 | 5 | module Dummy 6 | end 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /spec/graphql_rails_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe GraphqlRails do 4 | it 'has a version number' do 5 | expect(GraphqlRails::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /.history/ 10 | /.vscode/ 11 | 12 | # rspec failure tracking 13 | .rspec_status 14 | *.gem 15 | -------------------------------------------------------------------------------- /lib/graphql_rails/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | # Used to load rake tasks in RoR app 5 | class Railtie < Rails::Railtie 6 | rake_tasks do 7 | load 'graphql_rails/tasks/schema.rake' 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /docs/getting_started/setup.md: -------------------------------------------------------------------------------- 1 | ## Setup 2 | 3 | Add this line to your application's Gemfile: 4 | 5 | ```ruby 6 | gem 'graphql_rails' 7 | ``` 8 | 9 | And then execute: 10 | 11 | $ bundle 12 | 13 | Or install it yourself as: 14 | 15 | $ gem install graphql_rails 16 | 17 | -------------------------------------------------------------------------------- /lib/generators/graphql_rails/templates/graphql_application_controller.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Graphql 4 | # base class of all GraphqlControllers 5 | class GraphqlApplicationController < GraphqlRails::Controller 6 | # TODO: add app specific customizations here 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/graphql_rails/types/object_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/types/field_type' 4 | 5 | module GraphqlRails 6 | module Types 7 | # Base graphql type class for all GraphqlRails models 8 | class ObjectType < GraphQL::Schema::Object 9 | field_class(GraphqlRails::Types::FieldType) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/graphql_rails/types/argument_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/types/hidable_by_group' 4 | 5 | module GraphqlRails 6 | module Types 7 | # Base argument type for all GraphqlRails inputs 8 | class ArgumentType < GraphQL::Schema::Argument 9 | include GraphqlRails::Types::HidableByGroup 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/dummy_app/models/dummy/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dummy 4 | class User 5 | include GraphqlRails::Model 6 | 7 | graphql do |c| 8 | c.name("DummyUser#{SecureRandom.hex}") 9 | 10 | c.attribute :name 11 | end 12 | 13 | attr_reader :name 14 | 15 | def initialize(name:) 16 | @name = name 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/dummy_app/controllers/dummy/users_controllers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../models/dummy/user' 4 | 5 | module Dummy 6 | class UsersController < GraphqlRails::Controller 7 | action(:show) 8 | .permit(id: 'ID!') 9 | .returns(::Dummy::User.to_s) 10 | 11 | def show 12 | User.new(name: 'John') 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "graphql_rails" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /lib/graphql_rails/concerns/service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | # includes all service object related logic 5 | module Service 6 | require 'active_support/concern' 7 | extend ActiveSupport::Concern 8 | 9 | class_methods do 10 | def call(*args, **kwargs, &block) 11 | new(*args, **kwargs).call(&block) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/graphql_rails/router/plain_cursor_encoder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | class Router 5 | # simplest possible cursor encoder which returns element index 6 | module PlainCursorEncoder 7 | def self.encode(plain, _nonce) 8 | plain 9 | end 10 | 11 | def self.decode(plain, _nonce) 12 | plain 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/graphql_rails/attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | # attributes namespace 5 | module Attributes 6 | require_relative './attributes/attributable' 7 | require_relative './attributes/attribute' 8 | require_relative './attributes/input_attribute' 9 | 10 | require_relative './attributes/type_parser' 11 | require_relative './attributes/attribute_name_parser' 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/graphql_rails/router/query_route.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'route' 4 | 5 | module GraphqlRails 6 | class Router 7 | # stores query type graphql action info 8 | class QueryRoute < Route 9 | def query? 10 | true 11 | end 12 | 13 | def mutation? 14 | false 15 | end 16 | 17 | def event? 18 | false 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/graphql_rails/router/mutation_route.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'route' 4 | 5 | module GraphqlRails 6 | class Router 7 | # stores mutation type graphql action info 8 | class MutationRoute < Route 9 | def query? 10 | false 11 | end 12 | 13 | def mutation? 14 | true 15 | end 16 | 17 | def event? 18 | false 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/graphql_rails/types/field_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/types/argument_type' 4 | require 'graphql_rails/types/hidable_by_group' 5 | 6 | module GraphqlRails 7 | module Types 8 | # Base field for all GraphqlRails model fields 9 | class FieldType < GraphQL::Schema::Field 10 | include GraphqlRails::Types::HidableByGroup 11 | argument_class(GraphqlRails::Types::ArgumentType) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | group :development do 8 | gem 'rubocop', '1.5.2' 9 | gem 'rubocop-performance' 10 | gem 'rubocop-rspec' 11 | end 12 | 13 | group :test do 14 | gem 'codecov', require: false 15 | gem 'mongoid' 16 | gem 'simplecov', require: false 17 | end 18 | 19 | # Specify your gem's dependencies in graphql_rails.gemspec 20 | gemspec 21 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | * [Home](README) 2 | * Getting started 3 | * [Setup](getting_started/setup) 4 | * Components 5 | * [Routes](components/routes) 6 | * [Model](components/model) 7 | * [Controller](components/controller) 8 | * [Decorator](components/decorator) 9 | * [Logging and monitoring](logging_and_monitoring/logging_and_monitoring) 10 | * [Testing](testing/testing) 11 | * Other tools 12 | * [Schema Snapshot](other_tools/schema_dump) 13 | * [Query Runner](other_tools/query_runner) 14 | -------------------------------------------------------------------------------- /lib/graphql_rails/types/input_object_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/types/argument_type' 4 | 5 | module GraphqlRails 6 | module Types 7 | # Base graphql type class for all GraphqlRails models 8 | class InputObjectType < GraphQL::Schema::InputObject 9 | argument_class(GraphqlRails::Types::ArgumentType) 10 | 11 | def self.inspect 12 | "#{GraphQL::Schema::InputObject}(#{graphql_name})" 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/graphql_rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/module/delegation' 4 | 5 | require 'graphql_rails/version' 6 | require 'graphql_rails/model' 7 | require 'graphql_rails/router' 8 | require 'graphql_rails/controller' 9 | require 'graphql_rails/attributes' 10 | require 'graphql_rails/decorator' 11 | require 'graphql_rails/query_runner' 12 | require 'graphql_rails/railtie' if defined?(Rails) 13 | 14 | # wonders starts here 15 | module GraphqlRails 16 | autoload :Integrations, 'graphql_rails/integrations' 17 | end 18 | -------------------------------------------------------------------------------- /lib/generators/graphql_rails/templates/graphql_controller.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class GraphqlController < ApplicationController 4 | skip_before_action :verify_authenticity_token, only: :execute 5 | 6 | def execute 7 | render json: GraphqlRails::QueryRunner.call( 8 | params: params, 9 | context: graphql_context 10 | ) 11 | end 12 | 13 | private 14 | 15 | # data defined here will be accessible via `graphql_request.context` 16 | # in GraphqlRails::Controller instances 17 | def graphql_context 18 | {} 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/graphql_rails/errors/execution_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | require 'graphql' 5 | 6 | # base class which is returned in case something bad happens. Contains all error rendering structure 7 | class ExecutionError < GraphQL::ExecutionError 8 | def to_h 9 | super.merge(extra_graphql_data) 10 | end 11 | 12 | def extra_graphql_data 13 | {}.tap do |data| 14 | data['type'] = type if respond_to?(:type) && type 15 | data['code'] = type if respond_to?(:code) && code 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | on: [push, pull_request] 3 | jobs: 4 | specs: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | ruby-version: ["3.1", "3.2", "3.3"] 9 | 10 | runs-on: ubuntu-latest 11 | env: 12 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby-version }} 18 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 19 | - run: bundle exec rake 20 | -------------------------------------------------------------------------------- /lib/generators/graphql_rails/templates/example_users_controller.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Graphql 4 | # base class of all GraphqlControllers 5 | class ExampleUsersController < GraphqlApplicationController 6 | model('String') 7 | 8 | action(:show).permit(id: :ID!).returns_single 9 | action(:update).permit(id: :ID!).returns_single 10 | 11 | def show 12 | 'this is example show action. Remove it and write something real' 13 | end 14 | 15 | def update 16 | 'this is example update action. Remove it and write something real' 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/graphql_rails/errors/system_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | # Base class which is returned in case something bad happens. Contains all error rendering structure 5 | class SystemError < ExecutionError 6 | delegate :backtrace, to: :original_error 7 | 8 | attr_reader :original_error 9 | 10 | def initialize(original_error) 11 | super(original_error.message) 12 | 13 | @original_error = original_error 14 | end 15 | 16 | def to_h 17 | super.except('locations') 18 | end 19 | 20 | def type 21 | 'system_error' 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/graphql_rails/integrations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | # allows to enable various integrations 5 | module Integrations 6 | def self.enable(*integrations) 7 | @enabled_integrations ||= [] 8 | 9 | to_be_enabled_integrations = integrations.map(&:to_s) - @enabled_integrations 10 | 11 | to_be_enabled_integrations.each do |integration| 12 | require_relative "./integrations/#{integration}" 13 | Integrations.const_get(integration.classify).enable 14 | end 15 | 16 | @enabled_integrations += to_be_enabled_integrations 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/graphql_rails/tasks/schema.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/tasks/dump_graphql_schemas' 4 | 5 | namespace :graphql_rails do 6 | namespace :schema do 7 | desc 'Dump GraphQL schema' 8 | task(dump: :environment) do |_, args| 9 | groups_from_args = args.extras 10 | groups_from_env = ENV['SCHEMA_GROUP_NAME'].to_s.split(',').map(&:strip) 11 | groups = groups_from_args + groups_from_env 12 | dump_dir = ENV.fetch('GRAPHQL_SCHEMA_DUMP_DIR') { Rails.root.join('spec/fixtures').to_s } 13 | 14 | GraphqlRails::DumpGraphqlSchemas.call(groups: groups, dump_dir: dump_dir) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/graphql_rails/errors/custom_execution_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | # base class which is returned in case something bad happens. Contains all error rendering structure 5 | class CustomExecutionError < ExecutionError 6 | attr_reader :extra_graphql_data 7 | 8 | def self.accepts?(error) 9 | error.is_a?(Hash) && 10 | (error.key?(:message) || error.key?('message')) 11 | end 12 | 13 | def initialize(message, extra_graphql_data = {}) 14 | super(message) 15 | @extra_graphql_data = extra_graphql_data.stringify_keys 16 | end 17 | 18 | def to_h 19 | super 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/router/resource_routes_builder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'mongoid' 5 | require 'active_record' 6 | 7 | module GraphqlRails 8 | RSpec.describe Router::ResourceRoutesBuilder do 9 | subject(:builder) { described_class.new(:users, on: :member) } 10 | 11 | describe '#routes' do 12 | context 'when default options includes "on"' do 13 | it 'generates index as collection routes' do 14 | index_route = builder.routes.detect { |route| route.path == 'users#index' } 15 | 16 | expect(index_route).to be_collection 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/errors/system_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | RSpec.describe SystemError do 7 | subject(:system_error) { described_class.new(error) } 8 | 9 | let(:error) { Error.new } 10 | 11 | describe '#backtrace' do 12 | subject(:backtrace) { system_error.backtrace } 13 | 14 | it 'has same backtrace as original error' do 15 | expect(backtrace).to eq(error.backtrace) 16 | end 17 | end 18 | 19 | describe '#original_error' do 20 | subject(:original_error) { system_error.original_error } 21 | 22 | it 'points to original error' do 23 | expect(original_error).to eq(error) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/generators/graphql_rails/templates/graphql_router_spec.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe GraphqlRouter do 6 | subject(:schema) { described_class.graphql_schema } 7 | 8 | describe '#to_definition' do 9 | subject(:to_definition) { schema.to_definition.strip } 10 | 11 | let(:schema_dump_path) { Rails.root.join('spec', 'fixtures', 'graphql_schema.graphql') } 12 | let(:previous_definition) { File.read(schema_dump_path).strip } 13 | 14 | it 'returns correct structure' do 15 | # if you need to update saved_schema, run rake task: 16 | # $ RAILS_ENV=test bin/rake graphql_rails:schema:dump 17 | 18 | expect(to_definition).to eq(previous_definition) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/generators/graphql_rails/templates/graphql_router.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | GraphqlRouter = GraphqlRails::Router.draw do 4 | scope module: :graphql do 5 | # this will create all CRUD actions for Graphql::UsersController: 6 | # resources :users 7 | # 8 | # If you want non-CRUD custom actions, you can define them like this: 9 | # query :find_something, to: 'controller_name#action_name' 10 | # mutation :change_something, to: 'controller_name#action_name' 11 | # or you can include them in resources part: 12 | # resources :users do 13 | # query :find_one, on: :member 14 | # query :find_many, on: :collection 15 | # end 16 | 17 | resources :example_users, only: %i[show update] 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | 5 | require 'simplecov' 6 | SimpleCov.start do 7 | enable_coverage :branch 8 | add_filter(/_spec.rb\Z/) 9 | end 10 | 11 | require 'graphql_rails' 12 | 13 | if ENV['CODECOV_TOKEN'] 14 | require 'codecov' 15 | SimpleCov.formatter = SimpleCov::Formatter::Codecov 16 | end 17 | 18 | RSpec.configure do |config| 19 | # Enable flags like --only-failures and --next-failure 20 | config.example_status_persistence_file_path = '.rspec_status' 21 | 22 | # Disable RSpec exposing methods globally on `Module` and `main` 23 | config.disable_monkey_patching! 24 | 25 | config.mock_with :rspec do |mocks| 26 | mocks.verify_partial_doubles = true 27 | end 28 | 29 | config.expect_with :rspec do |c| 30 | c.syntax = :expect 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/graphql_rails/attributes/type_name_info.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Attributes 5 | # checks various attributes based on graphql type name 6 | class TypeNameInfo 7 | attr_reader :name 8 | 9 | def initialize(name) 10 | @name = name 11 | end 12 | 13 | def nullable_inner_name 14 | inner_name[/[^!]+/] 15 | end 16 | 17 | def inner_name 18 | name[/[^!\[\]]+!?/] 19 | end 20 | 21 | def required_inner_type? 22 | inner_name.include?('!') 23 | end 24 | 25 | def list? 26 | name.include?(']') 27 | end 28 | 29 | def required? 30 | name.end_with?('!') 31 | end 32 | 33 | def required_list? 34 | required? && list? 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: rubocop-rspec 2 | 3 | RSpec/NestedGroups: 4 | Enabled: false 5 | 6 | RSpec/MultipleMemoizedHelpers: 7 | Enabled: false 8 | 9 | Layout/LineLength: 10 | Enabled: true 11 | Max: 120 12 | 13 | Metrics/BlockLength: 14 | Exclude: 15 | - spec/**/*.rb 16 | Metrics/ModuleLength: 17 | Exclude: 18 | - spec/**/*_spec.rb 19 | Metrics/ClassLength: 20 | Exclude: 21 | - spec/**/*_spec.rb 22 | 23 | Lint/AmbiguousBlockAssociation: 24 | Exclude: 25 | - spec/**/*.rb 26 | 27 | Naming/MethodParameterName: 28 | AllowedNames: 29 | - 'to' 30 | - 'at' 31 | - 'on' 32 | - 'id' 33 | - 'in' 34 | - 'as' 35 | 36 | Style/ClassAndModuleChildren: 37 | Exclude: 38 | - spec/**/*_spec.rb 39 | 40 | AllCops: 41 | NewCops: disable # TODO: enable 42 | TargetRubyVersion: 2.7 43 | Exclude: 44 | - bin/* 45 | - graphql_rails.gemspec 46 | - Rakefile 47 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/router/plain_cursor_encoder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | class Router 7 | RSpec.describe PlainCursorEncoder do 8 | subject(:plain_cursor_encoder) { described_class } 9 | 10 | describe '.encode' do 11 | subject(:encode) { plain_cursor_encoder.encode(not_encoded_value, nil) } 12 | 13 | let(:not_encoded_value) { '123' } 14 | 15 | it 'does not modify original value' do 16 | expect(encode).to eq not_encoded_value 17 | end 18 | end 19 | 20 | describe '.decode' do 21 | subject(:decode) { plain_cursor_encoder.decode(encoded_value, nil) } 22 | 23 | let(:encoded_value) { '123' } 24 | 25 | it 'does not modify original value' do 26 | expect(decode).to eq encoded_value 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/graphql_rails/errors/validation_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | # GraphQL error that is raised when invalid data is given 5 | class ValidationError < ExecutionError 6 | BASE_FIELD_NAME = 'base' 7 | 8 | attr_reader :short_message, :field 9 | 10 | def initialize(short_message, field) 11 | super([humanized_field(field), short_message].compact.join(' ')) 12 | @short_message = short_message 13 | @field = field 14 | end 15 | 16 | def type 17 | 'validation_error' 18 | end 19 | 20 | def to_h 21 | super.merge('field' => field, 'short_message' => short_message) 22 | end 23 | 24 | private 25 | 26 | def humanized_field(field) 27 | return if field.blank? 28 | 29 | stringified_field = field.to_s 30 | return if stringified_field == BASE_FIELD_NAME 31 | 32 | stringified_field.humanize 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /docs/other_tools/schema_dump.md: -------------------------------------------------------------------------------- 1 | # Schema Dump 2 | 3 | GraphqlRails includes rake task to allow creating schema snapshots easier: 4 | 5 | ```bash 6 | rake graphql_rails:schema:dump 7 | ``` 8 | 9 | ## Dumping only selected schema groups 10 | 11 | You can specify which schema groups you want to dump. In order to do so, provide groups list as rake task argument and separate group names by comma: 12 | 13 | ```bash 14 | rake graphql_rails:schema:dump['your_group_name, your_group_name2'] 15 | ``` 16 | 17 | You can do this also by using ENV variable `SCHEMA_GROUP_NAME`: 18 | 19 | ```bash 20 | SCHEMA_GROUP_NAME="your_group_name, your_group_name2" rake graphql_rails:schema:dump 21 | ``` 22 | 23 | ## Dumping schema in to non default folder 24 | 25 | By default schema will be dumped to `spec/fixtures` directory. If you want different schema path, add `GRAPHQL_SCHEMA_DUMP_DIR` env variable, like this: 26 | 27 | ```bash 28 | GRAPHQL_SCHEMA_DUMP_DIR='path/to/graphql/dumps' rake graphql_rails:schema:dump 29 | ``` 30 | -------------------------------------------------------------------------------- /lib/graphql_rails/integrations/sentry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Integrations 5 | # sentry integration 6 | module Sentry 7 | require 'active_support/concern' 8 | 9 | # controller extension which logs errors to sentry 10 | module SentryLogger 11 | extend ActiveSupport::Concern 12 | 13 | included do 14 | around_action :log_to_sentry 15 | 16 | protected 17 | 18 | def log_to_sentry 19 | Raven.context.transaction.pop 20 | Raven.context.transaction.push "#{self.class}##{action_name}" 21 | yield 22 | rescue Exception => error # rubocop:disable Lint/RescueException 23 | Raven.capture_exception(error) unless error.is_a?(GraphQL::ExecutionError) 24 | raise error 25 | end 26 | end 27 | end 28 | 29 | def self.enable 30 | GraphqlRails::Controller.include(SentryLogger) 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/graphql_rails/model/build_connection_type/count_items.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Model 5 | class BuildConnectionType 6 | # Used when generating ConnectionType. 7 | # It handles all the logic which is related with counting total items 8 | class CountItems 9 | require 'graphql_rails/concerns/service' 10 | 11 | include ::GraphqlRails::Service 12 | 13 | def initialize(graphql_object) 14 | @graphql_object = graphql_object 15 | end 16 | 17 | def call 18 | if active_record? 19 | list.except(:offset).size 20 | else 21 | list.size 22 | end 23 | end 24 | 25 | private 26 | 27 | attr_reader :graphql_object 28 | 29 | def list 30 | graphql_object.items 31 | end 32 | 33 | def active_record? 34 | defined?(ActiveRecord) && list.is_a?(ActiveRecord::Relation) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/graphql_rails/controller/action_hook.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | class Controller 5 | # stores information about controller hooks like before_action, after_action, etc. 6 | class ActionHook 7 | attr_reader :name, :action_proc 8 | 9 | def initialize(name: nil, only: [], except: [], &action_proc) 10 | @name = name 11 | @action_proc = action_proc 12 | @only_actions = Array(only).map(&:to_sym) 13 | @except_actions = Array(except).map(&:to_sym) 14 | end 15 | 16 | def applicable_for?(action_name) 17 | if only_actions.any? 18 | only_actions.include?(action_name.to_sym) 19 | elsif except_actions.any? 20 | !except_actions.include?(action_name.to_sym) 21 | else 22 | true 23 | end 24 | end 25 | 26 | def anonymous? 27 | !!action_proc # rubocop:disable Style/DoubleNegation 28 | end 29 | 30 | private 31 | 32 | attr_reader :only_actions, :except_actions 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/graphql_rails/integrations/lograge.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Integrations 5 | # lograge integration 6 | # 7 | # usage: 8 | # add `GraphqlRails::Integrations::Lograge.enable` in your initializers 9 | module Lograge 10 | require 'lograge' 11 | 12 | # lograge subscriber for graphql_rails controller events 13 | class GraphqlActionControllerSubscriber < ::Lograge::LogSubscribers::Base 14 | def process_action(event) 15 | process_main_event(event) 16 | end 17 | 18 | private 19 | 20 | def initial_data(payload) 21 | { 22 | controller: payload[:controller], 23 | action: payload[:action] 24 | } 25 | end 26 | end 27 | 28 | def self.enable 29 | return unless active? 30 | 31 | GraphqlActionControllerSubscriber.attach_to :graphql_action_controller 32 | end 33 | 34 | def self.active? 35 | !defined?(Rails) || Rails.configuration&.lograge&.enabled 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Povilas Jurcys 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/graphql_rails/controller/build_controller_action_resolver/controller_action_resolver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/controller/request' 4 | require 'graphql_rails/types/argument_type' 5 | 6 | module GraphqlRails 7 | class Controller 8 | class BuildControllerActionResolver 9 | # Resolver which includes controller specific methods. 10 | # Used to simplify resolver build for each controller action 11 | class ControllerActionResolver < GraphQL::Schema::Resolver 12 | argument_class(GraphqlRails::Types::ArgumentType) 13 | 14 | def self.controller(controller_class = nil) 15 | @controller = controller_class if controller_class 16 | @controller 17 | end 18 | 19 | def self.controller_action_name(name = nil) 20 | @controller_action_name = name if name 21 | @controller_action_name 22 | end 23 | 24 | def resolve(**inputs) 25 | request = Request.new(object, inputs, context) 26 | self.class.controller.new(request).call(self.class.controller_action_name) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/graphql_rails/model/direct_field_resolver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Model 5 | # Takes shortcuts for simple cases to minimize allocations 6 | class DirectFieldResolver 7 | class << self 8 | def call(model:, attribute_config:, method_keyword_arguments:, graphql_context:) 9 | property = attribute_config.property 10 | 11 | if method_keyword_arguments.empty? && !attribute_config.paginated? 12 | return simple_resolver(model: model, graphql_context: graphql_context, property: property) 13 | end 14 | 15 | CallGraphqlModelMethod.call( 16 | model: model, 17 | attribute_config: attribute_config, 18 | method_keyword_arguments: method_keyword_arguments, 19 | graphql_context: graphql_context 20 | ) 21 | end 22 | 23 | def simple_resolver(model:, graphql_context:, property:) 24 | return model.send(property) unless model.respond_to?(:with_graphql_context) 25 | 26 | model.with_graphql_context(graphql_context) { model.send(property) } 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/graphql_rails/model/find_or_build_graphql_input_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/types/input_object_type' 4 | require 'graphql_rails/concerns/service' 5 | require 'graphql_rails/model/find_or_build_graphql_type' 6 | 7 | module GraphqlRails 8 | module Model 9 | # stores information about model specific config, like attributes and types 10 | class FindOrBuildGraphqlInputType < FindOrBuildGraphqlType 11 | include ::GraphqlRails::Service 12 | 13 | private 14 | 15 | def parent_class 16 | GraphqlRails::Types::InputObjectType 17 | end 18 | 19 | def add_attributes_batch(attributes) 20 | klass.class_eval do 21 | attributes.each do |attribute| 22 | argument(*attribute.input_argument_args, **attribute.input_argument_options) 23 | end 24 | end 25 | end 26 | 27 | def find_or_build_dynamic_type(attribute) 28 | graphql_model = attribute.graphql_model 29 | return unless graphql_model 30 | 31 | graphql = graphql_model.graphql.input(attribute.subtype) 32 | find_or_build_graphql_model_type(graphql) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/graphql_rails/tasks/dump_graphql_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | # Generates graphql schema dump files 5 | class DumpGraphqlSchema 6 | require 'graphql_rails/errors/error' 7 | 8 | class MissingGraphqlRouterError < GraphqlRails::Error; end 9 | 10 | def self.call(**args) 11 | new(**args).call 12 | end 13 | 14 | def initialize(group:, router:, dump_dir: nil) 15 | @group = group 16 | @router = router 17 | @dump_dir = dump_dir 18 | end 19 | 20 | def call 21 | File.write(schema_path, schema_dump) 22 | end 23 | 24 | private 25 | 26 | attr_reader :router, :group 27 | 28 | def schema_dump 29 | context = { graphql_group: group } 30 | schema.to_definition(context: context) 31 | end 32 | 33 | def schema 34 | @schema ||= router.graphql_schema(group.presence) 35 | end 36 | 37 | def schema_path 38 | FileUtils.mkdir_p(dump_dir) 39 | file_name = group.present? ? "graphql_#{group}_schema.graphql" : 'graphql_schema.graphql' 40 | 41 | "#{dump_dir}/#{file_name}" 42 | end 43 | 44 | def dump_dir 45 | @dump_dir ||= Rails.root.join('spec/fixtures').to_s 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /graphql_rails.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "graphql_rails/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'graphql_rails' 8 | spec.version = GraphqlRails::VERSION 9 | spec.authors = ['Povilas Jurčys'] 10 | spec.email = ['po.jurcys@gmail.com'] 11 | 12 | spec.summary = %q{Rails style structure for GraphQL API.} 13 | spec.homepage = 'https://github.com/samesystem/graphql_rails' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 17 | f.match(%r{^(test|spec|features)/}) 18 | end 19 | spec.bindir = 'exe' 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ['lib'] 22 | 23 | spec.add_dependency 'graphql', '~> 2' 24 | spec.add_dependency 'activesupport', '>= 4' 25 | 26 | spec.add_development_dependency 'bundler', '~> 2' 27 | spec.add_development_dependency 'rake', '~> 13.0' 28 | spec.add_development_dependency 'rspec', '~> 3.0' 29 | spec.add_development_dependency 'activerecord' 30 | spec.add_development_dependency 'pry-byebug' 31 | spec.add_development_dependency 'rails', '~> 6' 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/controller/request_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | RSpec.describe Controller::Request do 7 | subject(:request) { described_class.new(graphql_object, inputs, context) } 8 | 9 | let(:graphql_object) { double } 10 | let(:inputs) { { id: 1, firstName: 'John' } } 11 | let(:context) { double } 12 | 13 | describe '#lookahead' do 14 | subject(:lookahead) { request.lookahead } 15 | 16 | context 'when inputs do not contain "lookahead" key' do 17 | it { is_expected.to be_nil } 18 | end 19 | 20 | context 'when inputs contain "lookahead" key' do 21 | let(:inputs) { { lookahead: 'some_value' } } 22 | 23 | it 'returns value from inputs' do 24 | expect(lookahead).to eq 'some_value' 25 | end 26 | end 27 | end 28 | 29 | describe '#params' do 30 | subject(:params) { request.params } 31 | 32 | it 'returns inputs' do 33 | expect(params).to eq(inputs) 34 | end 35 | 36 | context 'when inputs contain "lookahead" key' do 37 | let(:inputs) { { lookahead: 'some_value' } } 38 | 39 | it 'returns inputs without lookahead key' do 40 | expect(params).to eq({}) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/graphql_rails/model/input.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Model 5 | # stores information about model input specific config, like attributes and types 6 | class Input 7 | require 'graphql_rails/concerns/chainable_options' 8 | require 'graphql_rails/model/configurable' 9 | require 'graphql_rails/model/find_or_build_graphql_input_type' 10 | 11 | include Configurable 12 | 13 | chainable_option :enum 14 | 15 | def initialize(model_class, input_name_suffix) 16 | @model_class = model_class 17 | @input_name_suffix = input_name_suffix 18 | end 19 | 20 | def graphql_input_type 21 | @graphql_input_type ||= FindOrBuildGraphqlInputType.call( 22 | name: name, description: description, attributes: attributes, type_name: type_name 23 | ) 24 | end 25 | 26 | private 27 | 28 | attr_reader :input_name_suffix, :model_class 29 | 30 | def build_attribute(attribute_name) 31 | Attributes::InputAttribute.new(attribute_name, config: self) 32 | end 33 | 34 | def default_name 35 | @default_name ||= begin 36 | suffix = input_name_suffix ? input_name_suffix.to_s.camelize : '' 37 | "#{model_class.name.split('::').last}#{suffix}Input" 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/graphql_rails/model/build_connection_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql' 4 | require 'graphql_rails/model/build_connection_type/count_items' 5 | 6 | module GraphqlRails 7 | module Model 8 | # builds connection type from graphql type with some extra attributes 9 | class BuildConnectionType 10 | require 'graphql_rails/concerns/service' 11 | 12 | include ::GraphqlRails::Service 13 | 14 | attr_reader :initial_type 15 | 16 | def initialize(initial_type) 17 | @initial_type = initial_type 18 | end 19 | 20 | def call 21 | build_connection_type 22 | end 23 | 24 | private 25 | 26 | def build_connection_type 27 | edge_type = build_edge_type 28 | type = initial_type 29 | Class.new(GraphQL::Types::Relay::BaseConnection) do 30 | graphql_name("#{type.graphql_name}Connection") 31 | edge_type(edge_type) 32 | 33 | field :total, Integer, null: false 34 | 35 | def total 36 | CountItems.call(object) 37 | end 38 | end 39 | end 40 | 41 | def build_edge_type 42 | type = initial_type 43 | 44 | Class.new(GraphQL::Types::Relay::BaseEdge) do 45 | graphql_name("#{type.graphql_name}Edge") 46 | 47 | node_type(type) 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/concerns/service_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | RSpec.describe Service do 7 | let(:service_class) do 8 | Class.new do 9 | include GraphqlRails::Service 10 | 11 | attr_reader :args, :kwargs 12 | 13 | def initialize(*args, **kwargs) 14 | @args = args 15 | @kwargs = kwargs 16 | end 17 | 18 | def call 19 | { args: args, kwargs: kwargs } 20 | end 21 | end 22 | end 23 | 24 | describe '.call' do 25 | it 'instantiates service and calls instance method' do 26 | result = service_class.call('arg1', 'arg2', key1: 'value1', key2: 'value2') 27 | expect(result).to eq(args: %w[arg1 arg2], kwargs: { key1: 'value1', key2: 'value2' }) 28 | end 29 | 30 | it 'works with empty kwargs' do 31 | result = service_class.call('arg1', 'arg2') 32 | expect(result).to eq(args: %w[arg1 arg2], kwargs: {}) 33 | end 34 | 35 | it 'passes block to instance call method' do 36 | service_instance = instance_double(service_class) 37 | allow(service_class).to receive(:new).and_return(service_instance) 38 | allow(service_instance).to receive(:call).and_yield 39 | expect { |block| service_class.call(&block) }.to yield_control 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/model/build_connection_type/count_items_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'active_record' 5 | 6 | class GraphqlRails::Model::BuildConnectionType 7 | RSpec.describe CountItems do 8 | subject(:count_items) { described_class.new(graphql_object) } 9 | 10 | let(:graphql_object) { double('GraphqlObject', items: items) } # rubocop:disable RSpec/VerifiedDoubles 11 | 12 | describe '.call' do 13 | subject(:call) { described_class.call(graphql_object) } 14 | 15 | context 'when items are instance of ActiveRecord::Relation' do 16 | let(:items) { instance_double(ActiveRecord::Relation, size: 5) } 17 | 18 | before do 19 | allow(items).to receive(:is_a?).with(ActiveRecord::Relation).and_return(true) 20 | allow(items).to receive(:except).and_return(items) 21 | end 22 | 23 | it 'excludes offset' do 24 | call 25 | expect(items).to have_received(:except).with(:offset) 26 | end 27 | 28 | it 'returns correct items number' do 29 | expect(call).to eq 5 30 | end 31 | end 32 | 33 | context 'when items are not instance of ActiveRecord::Relation' do 34 | let(:items) { %i[a b c] } 35 | 36 | it 'returns correct items count' do 37 | expect(call).to eq items.count 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /docs/logging_and_monitoring/logging_and_monitoring.md: -------------------------------------------------------------------------------- 1 | # Logging and monitoring 2 | 3 | GraphqlRails behaves similar as Ruby on Rails. This allows to use existing monitoring and logging tools. Here we will add some examples on how to setup various tools for GraphqlRails 4 | 5 | ## Integrating GraphqlRails with other tools 6 | 7 | In order to make GraphqlRails work with tools such as lograge or sentry, you need to enable them. In Ruby on Rails, you can add initializer: 8 | 9 | ```ruby 10 | # config/initializers/graphql_rails.rb 11 | GraphqlRails::Integrations.enable(:lograge, :sentry) 12 | ``` 13 | 14 | At the moment, GraphqlRails supports following integrations: 15 | 16 | * lograge 17 | * sentry 18 | 19 | ## Instrumentation 20 | 21 | GraphqlRails uses same instrumentation tool (`ActiveSupport::Notifications`) as Ruby on Rails. At the moment there are two notification types: 22 | 23 | * `process_action.graphql_action_controller` 24 | * `start_action.graphql_action_controller` 25 | 26 | you can watch those actions using with `ActiveSupport::Notifications#subscribe` like this: 27 | 28 | ```ruby 29 | key = 'process_action.graphql_action_controller' 30 | ActiveSupport::Notifications.subscribe(key) do |*_, payload| 31 | YourLogger.do_something(payload) 32 | end 33 | ``` 34 | 35 | or you can do the same with `ActiveSupport::LogSubscriber`. More details about it [here](https://api.rubyonrails.org/classes/ActiveSupport/LogSubscriber.html). 36 | -------------------------------------------------------------------------------- /lib/graphql_rails/router/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql/pagination/active_record_relation_connection' 4 | require 'graphql_rails/decorator/relation_decorator' 5 | require 'graphql_rails/router/plain_cursor_encoder' 6 | 7 | module GraphqlRails 8 | class Router 9 | # Builds GraphQL::Schema based on previously defined GraphQL data. 10 | # Also handles type error hooks (before_type_error, after_type_error) 11 | # and implements logic for skipping null errors. 12 | class Schema < ::GraphQL::Schema 13 | use GraphQL::Schema::Visibility 14 | 15 | connections.add( 16 | GraphqlRails::Decorator::RelationDecorator, 17 | GraphQL::Pagination::ActiveRecordRelationConnection 18 | ) 19 | 20 | cursor_encoder(Router::PlainCursorEncoder) 21 | 22 | def self.before_type_error(&block) 23 | @before_type_error = block if block_given? 24 | @before_type_error 25 | end 26 | 27 | def self.after_type_error(&block) 28 | @after_type_error = block if block_given? 29 | @after_type_error 30 | end 31 | 32 | def self.type_error(type_error, context) 33 | before_type_error&.call(type_error, context) 34 | 35 | return nil if type_error.is_a?(GraphQL::InvalidNullError) && context[:rendered_errors] 36 | 37 | super.tap do 38 | after_type_error&.call(type_error, context) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/graphql_rails/input_configurable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | # contains configuration options related with inputs 5 | module InputConfigurable 6 | def permit(*args) 7 | args.each do |arg| 8 | if arg.is_a? Hash 9 | arg.each { |attribute, type| permit_input(attribute, type: type) } 10 | else 11 | permit_input(arg) 12 | end 13 | end 14 | self 15 | end 16 | 17 | def permit_input(name, **input_options) 18 | field_name = name.to_s.remove(/!\Z/) 19 | 20 | attributes[field_name] = build_input_attribute(name.to_s, **input_options) 21 | self 22 | end 23 | 24 | def paginated(pagination_options = {}) 25 | pagination_options = {} if pagination_options == true 26 | pagination_options = nil if pagination_options == false 27 | 28 | @pagination_options = pagination_options 29 | self 30 | end 31 | 32 | def paginated? 33 | !pagination_options.nil? 34 | end 35 | 36 | def pagination_options 37 | @pagination_options 38 | end 39 | 40 | def input_attribute_options 41 | @input_attribute_options || {} 42 | end 43 | 44 | def build_input_attribute(name, options: {}, **other_options) 45 | input_options = input_attribute_options.merge(options) 46 | Attributes::InputAttribute.new(name.to_s, config: self).with(options: input_options, **other_options) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/graphql_rails/types/hidable_by_group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/types/argument_type' 4 | 5 | module GraphqlRails 6 | module Types 7 | # Add visibility option based on groups 8 | module HidableByGroup 9 | def initialize(*args, groups: [], hidden_in_groups: [], **kwargs, &block) 10 | super(*args, **kwargs, &block) 11 | 12 | @hidden_in_groups = hidden_in_groups.map(&:to_s) 13 | @groups = groups.map(&:to_s) - @hidden_in_groups 14 | end 15 | 16 | def visible?(context) 17 | super && visible_in_context_group?(context) 18 | end 19 | 20 | private 21 | 22 | def groups 23 | @groups 24 | end 25 | 26 | def hidden_in_groups 27 | @hidden_in_groups 28 | end 29 | 30 | def visible_in_context_group?(context) 31 | group = context_graphql_group(context) 32 | 33 | return true if no_visibility_configuration?(group) 34 | return groups.include?(group) unless groups.empty? 35 | 36 | !hidden_in_groups.include?(group) 37 | end 38 | 39 | def no_visibility_configuration?(group) 40 | return true if group.nil? 41 | 42 | groups.empty? && hidden_in_groups.empty? 43 | end 44 | 45 | def context_graphql_group(context) 46 | group = context[:graphql_group] || context['graphql_group'] 47 | 48 | group.nil? ? nil : group.to_s 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/graphql_rails/concerns/chainable_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | # Allows defining methods chained way 5 | module ChainableOptions 6 | NOT_SET = Object.new 7 | 8 | # nodoc 9 | module ClassMethods 10 | def chainable_option(option_name, default: nil) 11 | define_method(option_name) do |value = NOT_SET| 12 | get_or_set_chainable_option(option_name, value, default: default) 13 | end 14 | end 15 | end 16 | 17 | def self.included(base) 18 | base.extend(ClassMethods) 19 | end 20 | 21 | def initialize_copy(other) 22 | super 23 | @chainable_option = other.instance_variable_get(:@chainable_option).dup 24 | end 25 | 26 | def with(**options) 27 | options.each do |method_name, args| 28 | send_args = [method_name] 29 | send_args << args if method(method_name).parameters.present? 30 | public_send(*send_args) 31 | end 32 | self 33 | end 34 | 35 | private 36 | 37 | def fetch_chainable_option(option_name, *default, &block) 38 | @chainable_option.fetch(option_name.to_sym, *default, &block) 39 | end 40 | 41 | def get_or_set_chainable_option(option_name, value = NOT_SET, default: nil) 42 | @chainable_option ||= {} 43 | return fetch_chainable_option(option_name, default) if value == NOT_SET 44 | 45 | @chainable_option[option_name.to_sym] = value 46 | self 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/graphql_rails/tasks/dump_graphql_schemas.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/tasks/dump_graphql_schema' 4 | 5 | module GraphqlRails 6 | # Generates graphql schema dump files 7 | class DumpGraphqlSchemas 8 | require 'graphql_rails/errors/error' 9 | 10 | class MissingGraphqlRouterError < GraphqlRails::Error; end 11 | 12 | def self.call(**args) 13 | new(**args).call 14 | end 15 | 16 | def initialize(dump_dir:, groups: nil) 17 | @groups = groups.presence 18 | @dump_dir = dump_dir 19 | end 20 | 21 | def call 22 | validate 23 | return dump_default_schema if groups.empty? 24 | 25 | groups.each { |group| dump_graphql_schema(group) } 26 | end 27 | 28 | private 29 | 30 | attr_reader :dump_dir 31 | 32 | def dump_default_schema 33 | dump_graphql_schema('') 34 | end 35 | 36 | def dump_graphql_schema(group) 37 | DumpGraphqlSchema.call(group: group, router: router, dump_dir: dump_dir) 38 | end 39 | 40 | def validate 41 | return if router 42 | 43 | error_message = \ 44 | 'GraphqlRouter is missing. ' \ 45 | 'Run `rails g graphql_rails:install` to build it' 46 | raise MissingGraphqlRouterError, error_message 47 | end 48 | 49 | def router 50 | @router ||= '::GraphqlRouter'.safe_constantize 51 | end 52 | 53 | def groups 54 | @groups ||= router.routes.flat_map(&:groups).uniq 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/graphql_rails/controller/build_controller_action_resolver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/concerns/service' 4 | require 'graphql_rails/controller/action_configuration' 5 | require 'graphql_rails/controller/build_controller_action_resolver/controller_action_resolver' 6 | 7 | module GraphqlRails 8 | class Controller 9 | # graphql resolver which redirects actions to appropriate controller and controller action 10 | class BuildControllerActionResolver 11 | include ::GraphqlRails::Service 12 | 13 | def initialize(action:) 14 | @action = action 15 | end 16 | 17 | def call # rubocop:disable Metrics/AbcSize, Metrics/MethodLength 18 | action = self.action 19 | 20 | Class.new(ControllerActionResolver) do 21 | graphql_name("ControllerActionResolver#{SecureRandom.hex}") 22 | 23 | type(*action.type_args, **action.type_options) 24 | description(action.description) 25 | controller(action.controller) 26 | controller_action_name(action.name) 27 | 28 | action.arguments.each do |attribute| 29 | argument(*attribute.input_argument_args, **attribute.input_argument_options) 30 | end 31 | 32 | def self.inspect 33 | "ControllerActionResolver(#{controller.name}##{controller_action_name})" 34 | end 35 | end 36 | end 37 | 38 | private 39 | 40 | attr_reader :action 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/graphql_rails/router/event_route.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'route' 4 | 5 | module GraphqlRails 6 | class Router 7 | # stores subscription type graphql action info 8 | class EventRoute 9 | attr_reader :name, :module_name, :subscription_class, :groups, :scope_names 10 | 11 | def initialize(name, subscription_class: nil, groups: nil, scope_names: [], **options) 12 | @name = name.to_s.camelize(:lower) 13 | @module_name = options[:module].to_s 14 | @groups = groups 15 | @subscription_class = subscription_class 16 | @scope_names = scope_names 17 | end 18 | 19 | def show_in_group?(group_name) 20 | return true if groups.nil? || groups.empty? 21 | 22 | groups.include?(group_name&.to_sym) 23 | end 24 | 25 | def field_options 26 | { subscription: subscription } 27 | end 28 | 29 | def subscription 30 | if subscription_class.present? 31 | subscription_class.is_a?(String) ? Object.const_get(subscription_class) : subscription_class 32 | else 33 | klass_name = ['subscriptions/', name.underscore, 'subscription'].join('_').camelize 34 | 35 | Object.const_get(klass_name) 36 | end 37 | end 38 | 39 | def mutation? 40 | false 41 | end 42 | 43 | def query? 44 | false 45 | end 46 | 47 | def event? 48 | true 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/errors/validation_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | RSpec.describe ValidationError do 7 | subject(:validation_error) { described_class.new(short_message, field) } 8 | 9 | let(:short_message) { 'is invalid' } 10 | let(:field) { 'name' } 11 | 12 | describe '#message' do 13 | subject(:message) { validation_error.message } 14 | 15 | context 'when field is present' do 16 | it 'returns message with field name' do 17 | expect(message).to eq('Name is invalid') 18 | end 19 | end 20 | 21 | context 'when field is blank' do 22 | let(:field) { nil } 23 | 24 | it 'returns short message' do 25 | expect(message).to eq(short_message) 26 | end 27 | end 28 | 29 | context 'when field is "base"' do 30 | let(:field) { 'base' } 31 | 32 | it 'returns short message' do 33 | expect(message).to eq(short_message) 34 | end 35 | end 36 | end 37 | 38 | describe '#to_h' do 39 | subject(:to_h) { validation_error.to_h } 40 | 41 | it 'returns hash with error details' do # rubocop:disable RSpec/ExampleLength 42 | expect(to_h).to eq( 43 | 'message' => 'Name is invalid', 44 | 'type' => 'validation_error', 45 | 'short_message' => short_message, 46 | 'field' => field 47 | ) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/graphql_rails/model/configurable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Model 5 | # Contains methods which are shared between various configurations. 6 | # 7 | # Expects `default_name` to be defined. 8 | # Expects `build_attribute(attr_name)` method to be defined. 9 | module Configurable 10 | require 'active_support/concern' 11 | require 'graphql_rails/concerns/chainable_options' 12 | 13 | extend ActiveSupport::Concern 14 | 15 | included do 16 | include GraphqlRails::ChainableOptions 17 | 18 | chainable_option :description 19 | end 20 | 21 | def initialize_copy(other) 22 | super 23 | @type_name = nil 24 | @attributes = other.attributes.transform_values(&:dup) 25 | end 26 | 27 | def attributes 28 | @attributes ||= {} 29 | end 30 | 31 | def name(*args) 32 | get_or_set_chainable_option(:name, *args) || default_name 33 | end 34 | 35 | def type_name 36 | @type_name ||= "#{name.camelize}Type#{SecureRandom.hex}" 37 | end 38 | 39 | def attribute(attribute_name, **attribute_options) 40 | key = attribute_name.to_s 41 | 42 | attributes[key] ||= build_attribute(attribute_name).tap do |new_attribute| 43 | new_attribute.with(**attribute_options) unless attribute_options.empty? 44 | yield(new_attribute) if block_given? 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/graphql_rails/attributes/input_type_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql' 4 | 5 | module GraphqlRails 6 | module Attributes 7 | # converts string value in to GraphQL type 8 | class InputTypeParser 9 | require_relative './type_parseable' 10 | 11 | include TypeParseable 12 | 13 | def initialize(unparsed_type, subtype:) 14 | @unparsed_type = unparsed_type 15 | @subtype = subtype 16 | end 17 | 18 | def input_type_arg 19 | if list? 20 | list_type_arg 21 | else 22 | unwrapped_type 23 | end 24 | end 25 | 26 | private 27 | 28 | attr_reader :unparsed_type, :subtype 29 | 30 | def unwrapped_type 31 | raw_unwrapped_type || 32 | unwrapped_scalar_type || 33 | unwrapped_model_input_type || 34 | graphql_type_object || 35 | raise_not_supported_type_error 36 | end 37 | 38 | def raw_unwrapped_type 39 | return nil unless raw_graphql_type? 40 | 41 | unwrap_type(unparsed_type) 42 | end 43 | 44 | def list_type_arg 45 | if required_inner_type? 46 | [unwrapped_type] 47 | else 48 | [unwrapped_type, null: true] 49 | end 50 | end 51 | 52 | def unwrapped_model_input_type 53 | type_class = graphql_model 54 | return unless type_class 55 | 56 | type_class.graphql.input(subtype).graphql_input_type 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/graphql_rails/model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/model/configuration' 4 | 5 | module GraphqlRails 6 | # this module allows to convert any ruby class in to graphql type object 7 | # 8 | # usage: 9 | # class YourModel 10 | # include GraphqlRails::Model 11 | # 12 | # graphql do 13 | # attribute :id 14 | # attribute :title 15 | # end 16 | # end 17 | # 18 | # YourModel.new.graphql_type # => type with [:id, :title] attributes 19 | module Model 20 | # static methods for GraphqlRails::Model 21 | module ClassMethods 22 | def inherited(subclass) 23 | super 24 | subclass.instance_variable_set(:@graphql, graphql.dup) 25 | subclass.graphql.instance_variable_set(:@model_class, self) 26 | subclass.graphql.instance_variable_set(:@graphql_type, nil) 27 | end 28 | 29 | def graphql 30 | @graphql ||= Model::Configuration.new(self) 31 | @graphql.tap { |it| yield(it) }.with_ensured_fields! if block_given? 32 | @graphql 33 | end 34 | end 35 | 36 | def self.included(base) 37 | base.extend(ClassMethods) 38 | end 39 | 40 | def graphql_context 41 | @graphql_context 42 | end 43 | 44 | def graphql_context=(value) 45 | @graphql_context = value 46 | end 47 | 48 | def with_graphql_context(graphql_context) 49 | self.graphql_context = graphql_context 50 | yield(self) 51 | ensure 52 | self.graphql_context = nil 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/graphql_rails/model/add_fields_to_graphql_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Model 5 | # Adds graphql attributes as graphql fields to given graphql schema object. 6 | class AddFieldsToGraphqlType 7 | require 'graphql_rails/concerns/service' 8 | require 'graphql_rails/model/call_graphql_model_method' 9 | require 'graphql_rails/model/direct_field_resolver' 10 | 11 | include ::GraphqlRails::Service 12 | 13 | def initialize(klass:, attributes:) 14 | @klass = klass 15 | @attributes = attributes 16 | end 17 | 18 | def call 19 | attributes.each { |attribute| define_graphql_field(attribute) } 20 | end 21 | 22 | private 23 | 24 | attr_reader :attributes, :klass 25 | 26 | def define_graphql_field(attribute) # rubocop:disable Metrics/MethodLength) 27 | klass.class_eval do 28 | field(*attribute.field_args, **attribute.field_options) do 29 | attribute.attributes.values.each do |arg_attribute| 30 | argument(*arg_attribute.input_argument_args, **arg_attribute.input_argument_options) 31 | end 32 | end 33 | 34 | define_method(attribute.field_name) do |**kwargs| 35 | DirectFieldResolver.call( 36 | model: object, 37 | attribute_config: attribute, 38 | method_keyword_arguments: kwargs, 39 | graphql_context: context 40 | ) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/graphql_rails/attributes/attribute_name_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Attributes 5 | # Parses attribute name and can generates graphql scalar type, 6 | # graphql name and etc. based on that 7 | class AttributeNameParser 8 | def initialize(original_name, options: {}) 9 | @original_name = original_name.to_s 10 | @options = options 11 | end 12 | 13 | def field_name 14 | @field_name ||= \ 15 | if original_format? 16 | preprocessed_name 17 | else 18 | preprocessed_name.camelize(:lower) 19 | end 20 | end 21 | 22 | def graphql_type 23 | @graphql_type ||= \ 24 | case name 25 | when 'id', /_id\Z/ 26 | GraphQL::Types::ID 27 | when /\?\Z/ 28 | GraphQL::Types::Boolean 29 | else 30 | GraphQL::Types::String 31 | end 32 | end 33 | 34 | def required? 35 | original_name['!'].present? || original_name.end_with?('?') 36 | end 37 | 38 | def name 39 | @name ||= original_name.tr('!', '') 40 | end 41 | 42 | private 43 | 44 | attr_reader :options, :original_name 45 | 46 | def original_format? 47 | options[:input_format] == :original || options[:attribute_name_format] == :original 48 | end 49 | 50 | def preprocessed_name 51 | if name.end_with?('?') 52 | "is_#{name.remove(/\?\Z/)}" 53 | else 54 | name 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /docs/other_tools/query_runner.md: -------------------------------------------------------------------------------- 1 | # Query Runner 2 | 3 | `GraphqlRails::QueryRunner` is a helper class which let's you graphql queries in RoR controller without worrying about parsing details. Here is an example how to use it: 4 | 5 | ```ruby 6 | class MyRailsClass < ApplicationController 7 | def execute 8 | graphql_result = GraphqlRails::QueryRunner.call( 9 | params: params, router: GraphqlRouter 10 | ) 11 | 12 | render json: graphql_result 13 | end 14 | end 15 | ``` 16 | 17 | ## Executing grouped schema 18 | 19 | If you have multiple schemas (read [routes section](components/routes) on how to do that) and you want to render group specific schema, you need to provide group name, like this: 20 | 21 | ```ruby 22 | class MyRailsClass < ApplicationController 23 | def execute 24 | graphql_result = GraphqlRails::QueryRunner.call( 25 | group: :internal, # <- custom group name. Can by anything 26 | params: params, router: GraphqlRouter 27 | ) 28 | 29 | render json: graphql_result 30 | end 31 | end 32 | ``` 33 | 34 | ## Providing graphql-ruby options 35 | 36 | All graphql-ruby options are also supported, like [execution options](https://graphql-ruby.org/queries/executing_queries.html) or [visibility options](https://graphql-ruby.org/schema/limiting_visibility.html): 37 | 38 | ```ruby 39 | class MyRailsClass < ApplicationController 40 | def execute 41 | graphql_result = GraphqlRails::QueryRunner.call( 42 | validate: true, # <- graphql-ruby option 43 | params: params, router: GraphqlRouter 44 | ) 45 | 46 | render json: graphql_result 47 | end 48 | end 49 | ``` 50 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/controller/build_controller_action_resolver_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | class Controller 7 | RSpec.describe BuildControllerActionResolver do 8 | module Dummy 9 | module Foo 10 | class User 11 | include Model 12 | graphql do |c| 13 | c.attribute :name 14 | end 15 | end 16 | 17 | class UsersController < GraphqlRails::Controller 18 | action(:show).permit(:name!).returns(User.to_s) 19 | def show 20 | render 'show:OK' 21 | end 22 | end 23 | end 24 | end 25 | 26 | let(:route) do 27 | Router::QueryRoute.new(:users, on: :member, to: route_path, module: 'graphql_rails/controller/dummy/foo') 28 | end 29 | 30 | let(:route_path) { 'users#show' } 31 | 32 | let(:action) { Controller::Action.new(route) } 33 | 34 | describe '.call' do 35 | subject(:call) do 36 | described_class.call(action: action) 37 | end 38 | 39 | it 'returns child class of ControllerActionResolver' do 40 | expect(call < BuildControllerActionResolver::ControllerActionResolver).to be true 41 | end 42 | 43 | it 'returns class with correct arguments' do 44 | expect(call.arguments.keys).to eq(%w[name]) 45 | end 46 | 47 | it 'returns class with correct type' do 48 | expect(call.type).to eq(Dummy::Foo::User.graphql.graphql_type) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/graphql_rails/model/find_or_build_graphql_type_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Model 5 | # Initializes class to define graphql type and fields. 6 | class FindOrBuildGraphqlTypeClass 7 | require 'graphql_rails/concerns/service' 8 | require 'graphql_rails/types/object_type' 9 | 10 | include ::GraphqlRails::Service 11 | 12 | def initialize(name:, type_name:, parent_class:, description: nil, implements: []) 13 | @name = name 14 | @type_name = type_name 15 | @description = description 16 | @new_class = false 17 | @parent_class = parent_class 18 | @implements = implements 19 | end 20 | 21 | def klass 22 | @klass ||= Object.const_defined?(type_name) && Object.const_get(type_name) || build_graphql_type_klass 23 | end 24 | 25 | def new_class? 26 | new_class 27 | end 28 | 29 | private 30 | 31 | attr_accessor :new_class 32 | attr_reader :name, :type_name, :description, :parent_class, :implements 33 | 34 | def build_graphql_type_klass 35 | graphql_type_name = name 36 | graphql_type_description = description 37 | interfaces = implements 38 | 39 | graphql_type_klass = Class.new(parent_class) do 40 | graphql_name(graphql_type_name) 41 | description(graphql_type_description) 42 | interfaces.each { |interface| implements(interface) } 43 | end 44 | 45 | self.new_class = true 46 | 47 | Object.const_set(type_name, graphql_type_klass) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/model/build_enum_type_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | module Model 7 | RSpec.describe BuildEnumType do 8 | subject(:build_enum_type) do 9 | described_class.new(name, allowed_values: allowed_values, description: description) 10 | end 11 | 12 | let(:allowed_values) { [:yes, 'no'] } 13 | let(:name) { :my_enum } 14 | let(:description) { 'Enums are awesome!' } 15 | 16 | describe '#call' do 17 | subject(:call) { build_enum_type.call } 18 | 19 | it 'sets values with original mappings' do 20 | expect(call.values.transform_values(&:value)).to eq( 21 | 'YES' => :yes, 22 | 'NO' => 'no' 23 | ) 24 | end 25 | 26 | it 'sets camelized name' do 27 | expect(call.graphql_name).to eq 'MyEnum' 28 | end 29 | 30 | it 'sets correct description' do 31 | expect(call.description).to eq description 32 | end 33 | 34 | context 'when allowed values are empty' do 35 | let(:allowed_values) { [] } 36 | 37 | it 'raises error' do 38 | expect { call }.to raise_error('At lest one enum option must be given') 39 | end 40 | end 41 | 42 | context 'when allowed values has wrong type' do 43 | let(:allowed_values) { 'Hey' } 44 | 45 | it 'raises error' do 46 | expect { call }.to raise_error('Enum must be instance of Array, but instance of String was given') 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/graphql_rails/decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | # adds `.decorate` class method to any class. Handy when using with paginated responses 5 | # 6 | # usage: 7 | # class FriendDecorator < SimpleDecorator 8 | # include GraphqlRails::Decorator 9 | # 10 | # graphql.attribute :full_name 11 | # end 12 | # 13 | # class User 14 | # has_many :friends 15 | # graphql.attribute :decorated_friends, paginated: true, type: 'FriendDecorator!' 16 | # 17 | # def decorated_friends 18 | # FriendDecorator.decorate(friends) 19 | # end 20 | # end 21 | module Decorator 22 | require 'active_support/concern' 23 | require 'graphql_rails/decorator/relation_decorator' 24 | 25 | extend ActiveSupport::Concern 26 | 27 | class_methods do 28 | def decorate(object, *args, build_with: :new, **kwargs) 29 | if Decorator::RelationDecorator.decorates?(object) 30 | decorate_with_relation_decorator(object, args, kwargs, build_with: build_with) 31 | elsif object.nil? 32 | nil 33 | elsif object.is_a?(Array) 34 | object.map { |item| public_send(build_with, item, *args, **kwargs) } 35 | else 36 | public_send(build_with, object, *args, **kwargs) 37 | end 38 | end 39 | 40 | private 41 | 42 | def decorate_with_relation_decorator(object, args, kwargs, build_with:) 43 | Decorator::RelationDecorator.new( 44 | relation: object, 45 | decorator: self, 46 | decorator_args: args, 47 | decorator_kwargs: kwargs, 48 | build_with: build_with 49 | ) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/graphql_rails/attributes/attributable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/attributes/type_parser' 4 | require 'graphql_rails/attributes/attribute_name_parser' 5 | 6 | module GraphqlRails 7 | module Attributes 8 | # contains methods which are shared between various attribute-like classes 9 | # expects `initial_name` and `type` to be defined 10 | module Attributable 11 | def initialize_copy(_original) 12 | super 13 | @attribute_name_parser = nil 14 | @type_parser = nil 15 | end 16 | 17 | def field_name 18 | attribute_name_parser.field_name 19 | end 20 | 21 | def type_name 22 | type.to_s 23 | end 24 | 25 | def name 26 | attribute_name_parser.name 27 | end 28 | 29 | def required? 30 | return @required unless @required.nil? 31 | 32 | (type.nil? && attribute_name_parser.required?) || 33 | type.to_s[/!$/].present? || 34 | type.is_a?(GraphQL::Schema::NonNull) 35 | end 36 | 37 | def graphql_model 38 | type_parser.graphql_model 39 | end 40 | 41 | def optional? 42 | !required? 43 | end 44 | 45 | def scalar_type? 46 | type_parser.raw_graphql_type? || type_parser.core_scalar_type? 47 | end 48 | 49 | private 50 | 51 | def type_parser 52 | @type_parser ||= begin 53 | type_for_parser = type || attribute_name_parser.graphql_type 54 | TypeParser.new(type_for_parser, paginated: paginated?) 55 | end 56 | end 57 | 58 | def attribute_name_parser 59 | @attribute_name_parser ||= AttributeNameParser.new(initial_name, options: options) 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/graphql_rails/controller/action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/string/inflections' 4 | require 'graphql_rails/errors/error' 5 | require 'graphql_rails/model' 6 | require_relative 'request' 7 | 8 | module GraphqlRails 9 | class Controller 10 | # analyzes route and extracts controller action related data 11 | class Action 12 | delegate :relative_path, to: :route 13 | 14 | def initialize(route) 15 | @route = route 16 | end 17 | 18 | def return_type 19 | action_config.return_type 20 | end 21 | 22 | def arguments 23 | action_config.attributes.values 24 | end 25 | 26 | def controller 27 | @controller ||= "#{namespaced_controller_name}_controller".classify.constantize 28 | end 29 | 30 | def name 31 | @name ||= relative_path.split('#').last 32 | end 33 | 34 | def description 35 | action_config.description 36 | end 37 | 38 | def type_args 39 | [type_parser.type_arg] 40 | end 41 | 42 | def type_options 43 | { null: !type_parser.required? } 44 | end 45 | 46 | def action_config 47 | controller.controller_configuration.action_config(name) 48 | end 49 | 50 | private 51 | 52 | attr_reader :route 53 | 54 | delegate :type_parser, to: :action_config 55 | 56 | def namespaced_controller_name 57 | [route.module_name, controller_name].reject(&:empty?).join('/') 58 | end 59 | 60 | def controller_name 61 | @controller_name ||= relative_path.split('#').first 62 | end 63 | 64 | def namespaced_model_name 65 | namespaced_controller_name.singularize.classify 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/graphql_rails/controller/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | class Controller 5 | # Contains all info related with single request to controller 6 | class Request 7 | require 'graphql_rails/controller/request/format_errors' 8 | 9 | attr_accessor :object_to_return 10 | attr_reader :errors, :context, :lookahead 11 | 12 | def initialize(graphql_object, inputs, context) 13 | @graphql_object = graphql_object 14 | @inputs = inputs.except(:lookahead) 15 | @lookahead = inputs[:lookahead] 16 | @context = context 17 | @errors = [] 18 | end 19 | 20 | def errors=(new_errors) 21 | @errors = FormatErrors.call(not_formatted_errors: new_errors) 22 | 23 | @errors.each { |error| context.add_error(error) } 24 | end 25 | 26 | def no_object_to_return? 27 | !defined?(@object_to_return) 28 | end 29 | 30 | def params 31 | deep_transform_values(inputs.to_h) do |val| 32 | graphql_object_to_hash(val) 33 | end 34 | end 35 | 36 | private 37 | 38 | attr_reader :graphql_object, :inputs 39 | 40 | def graphql_object_to_hash(object) 41 | if object.is_a?(GraphQL::Dig) 42 | object.to_h 43 | elsif object.is_a?(Array) 44 | object.map { |item| graphql_object_to_hash(item) } 45 | else 46 | object 47 | end 48 | end 49 | 50 | def deep_transform_values(hash, &block) 51 | return hash unless hash.is_a?(Hash) 52 | 53 | hash.transform_values do |val| 54 | if val.is_a?(Hash) 55 | deep_transform_values(val, &block) 56 | else 57 | yield(val) 58 | end 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/generators/graphql_rails/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | # no-doc 5 | module Generators 6 | require 'rails/generators/base' 7 | 8 | # Add GraphQL to a Rails app with `rails g graphql_rails:install`. 9 | # 10 | # Setup a folder structure for GraphQL: 11 | # 12 | # ``` 13 | # - app/ 14 | # - controllers 15 | # - graphql_controller.rb 16 | # - graphql 17 | # - graphql_application_controller.rb 18 | # - graphql 19 | # - graphql_router.rb 20 | # ``` 21 | class InstallGenerator < Rails::Generators::Base 22 | desc 'Install GraphqlRails folder structure and boilerplate code' 23 | 24 | source_root File.expand_path('../templates', __FILE__) # rubocop:disable Style/ExpandPathArguments 25 | 26 | def create_folder_structure 27 | empty_directory('app/controllers') 28 | template('graphql_controller.erb', 'app/controllers/graphql_controller.rb') 29 | 30 | empty_directory('app/controllers/graphql') 31 | template('graphql_application_controller.erb', 'app/controllers/graphql/graphql_application_controller.rb') 32 | template('example_users_controller.erb', 'app/controllers/graphql/example_users_controller.rb') 33 | 34 | application do 35 | "config.autoload_paths << 'app/graphql'" 36 | end 37 | 38 | empty_directory('app/graphql') 39 | template('graphql_router.erb', 'app/graphql/graphql_router.rb') 40 | 41 | route('post "/graphql", to: "graphql#execute"') 42 | 43 | if File.directory?('spec') # rubocop:disable Style/GuardClause 44 | empty_directory('spec/graphql') 45 | template('graphql_router_spec.erb', 'spec/app/graphql/graphql_router_spec.rb') 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/router/build_schema_action_type_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'support/dummy_app/dummy' 5 | 6 | module GraphqlRails 7 | RSpec.describe Router::BuildSchemaActionType do 8 | describe '.call' do 9 | subject(:call) { described_class.call(type_name: type_name, routes: routes) } 10 | 11 | let(:type_name) { 'Test' } 12 | let(:routes) { router.routes } 13 | let(:router) { Router.new } 14 | 15 | context 'with no routes' do 16 | it 'returns GraphQL type with no fields' do 17 | expect(call.fields).to be_empty 18 | end 19 | end 20 | 21 | context 'with top level route' do 22 | before do 23 | router.query(:user, to: 'dummy/users#show') 24 | end 25 | 26 | it 'returns GraphQL type with fields matching routes', :aggregate_failures do 27 | expect(call.inspect).to eq('GraphQL::Schema::Object(Test)') 28 | expect(call.fields.keys).to match_array(%w[user]) 29 | end 30 | end 31 | 32 | context 'with scoped and namespaced route' do 33 | before do 34 | router.namespace(:dummy) do 35 | scope :users_area do 36 | query(:user, to: 'users#show') 37 | end 38 | end 39 | end 40 | 41 | it 'returns GraphQL type with deep nested fields matching', :aggregate_failures do 42 | expect(call.fields.keys).to match_array(%w[dummy]) 43 | 44 | dummy_namespace = call.fields['dummy'].type.unwrap 45 | expect(dummy_namespace.fields.keys).to match_array(%w[usersArea]) 46 | 47 | users_area_scope = dummy_namespace.fields['usersArea'].type.unwrap 48 | expect(users_area_scope.fields.keys).to match_array(%w[user]) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/graphql_rails/controller/request/format_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/concerns/service' 4 | require 'graphql_rails/errors/execution_error' 5 | require 'graphql_rails/errors/validation_error' 6 | require 'graphql_rails/errors/custom_execution_error' 7 | 8 | module GraphqlRails 9 | class Controller 10 | class Request 11 | # Converts user provided free-form errors in to meaningful graphql error classes 12 | class FormatErrors 13 | include Service 14 | 15 | def initialize(not_formatted_errors:) 16 | @not_formatted_errors = not_formatted_errors 17 | end 18 | 19 | def call 20 | if validation_errors? 21 | formatted_validation_errors 22 | else 23 | not_formatted_errors.map { |error| format_error(error) } 24 | end 25 | end 26 | 27 | private 28 | 29 | attr_reader :not_formatted_errors 30 | 31 | def validation_errors? 32 | defined?(ActiveModel) && 33 | defined?(ActiveModel::Errors) && 34 | not_formatted_errors.is_a?(ActiveModel::Errors) 35 | end 36 | 37 | def formatted_validation_errors 38 | not_formatted_errors.map do |field, message| 39 | ValidationError.new(message, field) 40 | end 41 | end 42 | 43 | def format_error(error) 44 | if error.is_a?(String) 45 | ExecutionError.new(error) 46 | elsif error.is_a?(GraphQL::ExecutionError) 47 | error 48 | elsif CustomExecutionError.accepts?(error) 49 | message = error[:message] || error['message'] 50 | CustomExecutionError.new(message, error.except(:message, 'message')) 51 | elsif error.respond_to?(:message) 52 | ExecutionError.new(error.message) 53 | end 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/graphql_rails/router/route.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../controller/build_controller_action_resolver' 4 | require 'graphql_rails/controller/action' 5 | 6 | module GraphqlRails 7 | class Router 8 | # Generic class for any type graphql action. Should not be used directly 9 | class Route 10 | attr_reader :name, :module_name, :on, :relative_path, :groups, :scope_names 11 | 12 | def initialize(name, on:, to: '', groups: nil, scope_names: [], **options) # rubocop:disable Metrics/ParameterLists 13 | @name = name.to_s.camelize(:lower) 14 | @module_name = options[:module].to_s 15 | @function = options[:function] 16 | @groups = groups 17 | @relative_path = to 18 | @on = on.to_sym 19 | @scope_names = scope_names 20 | end 21 | 22 | def path 23 | return relative_path if module_name.empty? 24 | 25 | [module_name, relative_path].join('/') 26 | end 27 | 28 | def collection? 29 | on == :collection 30 | end 31 | 32 | def show_in_group?(group_name) 33 | return true if groups.nil? || groups.empty? 34 | 35 | groups.include?(group_name&.to_sym) 36 | end 37 | 38 | def field_options 39 | if function 40 | { function: function } 41 | else 42 | { resolver: resolver, extras: [:lookahead], **resolver_options } 43 | end 44 | end 45 | 46 | private 47 | 48 | attr_reader :function 49 | 50 | def resolver 51 | @resolver ||= Controller::BuildControllerActionResolver.call(action: action) 52 | end 53 | 54 | def action 55 | @action ||= Controller::Action.new(self).tap(&:return_type) 56 | end 57 | 58 | def resolver_options 59 | action_config = action.action_config 60 | action_config.pagination_options || {} 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /docs/testing/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | ## Testing graphql controllers in RSpec 4 | 5 | ### Setup 6 | 7 | Add those lines in your `spec/spec_helper.rb` file 8 | 9 | ```ruby 10 | # spec/spec_helper.rb 11 | require 'graphql_rails/rspec_controller_helpers' 12 | 13 | RSpec.configure do |config| 14 | config.include(GraphqlRails::RSpecControllerHelpers, type: :graphql_controller) 15 | # ... your other configuration ... 16 | end 17 | ``` 18 | 19 | ### Helper methods 20 | 21 | There are 3 helper methods: 22 | 23 | * `mutation(:your_controller_action_name, params: {}, context: {})`. `params` and `context` are optional 24 | * `query(:your_controller_action_name, params: {}, context: {})`. `params` and `context` are optional 25 | * `response`. Response is set only after you call `mutation` or `query` 26 | 27 | ### Test examples 28 | 29 | ```ruby 30 | class MyGraphqlController 31 | action(:create_user).permit(:full_name, :email).returns(User) 32 | action(:index).returns('String') 33 | 34 | def index 35 | "Called from index: #{params[:message]}" 36 | end 37 | 38 | def create_user 39 | User.create!(params) 40 | end 41 | end 42 | 43 | RSpec.describe MyGraphqlController, type: :graphql_controller do 44 | describe '#index' do 45 | it 'is successful' do 46 | query(:index) 47 | expect(response).to be_successful 48 | end 49 | 50 | it 'returns correct message' do 51 | query(:index, params: { message: 'Hello world!' }) 52 | expect(response.result).to eq "Called from index: Hello world!" 53 | end 54 | end 55 | 56 | describe '#create_user' do 57 | context 'when bad email is given' do 58 | it 'fails' do 59 | mutation(:create_user, params { email: 'bad' }) 60 | expect(response).to be_failure 61 | end 62 | 63 | it 'contains errors' do 64 | mutation(:create_user, params { email: 'bad' }) 65 | expect(response.errors).not_to be_empty 66 | end 67 | end 68 | end 69 | end 70 | ``` 71 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/tasks/dump_graphql_schemas_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'graphql_rails/tasks/dump_graphql_schema' 5 | require 'graphql_rails/tasks/dump_graphql_schemas' 6 | 7 | module GraphqlRails 8 | RSpec.describe DumpGraphqlSchemas do 9 | subject(:dump_graphql_schema) { described_class.new(groups: groups, dump_dir: 'app/graphql') } 10 | 11 | let(:groups) { nil } 12 | 13 | describe '#call' do 14 | subject(:call) { dump_graphql_schema.call } 15 | 16 | let(:graphql_router) { GraphqlRails::Router.draw {} } 17 | 18 | let(:stub_router_constant) { stub_const('GraphqlRouter', graphql_router) } 19 | 20 | before do 21 | allow(DumpGraphqlSchema).to receive(:call).and_return(nil) 22 | stub_router_constant 23 | end 24 | 25 | context 'when groups are not given' do 26 | it 'dumps default schema', :aggregate_failures do 27 | call 28 | expect(DumpGraphqlSchema).to have_received(:call).once 29 | expect(DumpGraphqlSchema).to have_received(:call).with(hash_including(group: '')) 30 | end 31 | end 32 | 33 | context 'when groups are given' do 34 | let(:groups) { %w[group1 group2] } 35 | 36 | it 'dumps default schema', :aggregate_failures do 37 | call 38 | expect(DumpGraphqlSchema).to have_received(:call).twice 39 | expect(DumpGraphqlSchema).to have_received(:call).with(hash_including(group: 'group1')) 40 | expect(DumpGraphqlSchema).to have_received(:call).with(hash_including(group: 'group2')) 41 | end 42 | end 43 | 44 | context 'when "GraphqlRouter" is not defined' do 45 | let(:stub_router_constant) { nil } 46 | 47 | it 'raises error' do 48 | expect { call }.to raise_error( 49 | 'GraphqlRouter is missing. Run `rails g graphql_rails:install` to build it' 50 | ) 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/controller/action_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | module GraphqlRails 5 | class DummyUser 6 | include GraphqlRails::Model 7 | end 8 | 9 | class Controller 10 | RSpec.describe Action do 11 | class CustomDummyUsersController < GraphqlRails::Controller; end 12 | class DummyUsersController < GraphqlRails::Controller 13 | action(:show).returns(GraphQL::Types::String.to_non_null_type) 14 | action(:paginated_index).paginated 15 | end 16 | 17 | subject(:action) { described_class.new(route) } 18 | 19 | let(:action_name) { 'show' } 20 | 21 | let(:route) do 22 | Router::QueryRoute.new( 23 | :dummy_users, 24 | to: route_path, 25 | module: 'graphql_rails/controller', 26 | on: route_type 27 | ) 28 | end 29 | 30 | let(:route_type) { :member } 31 | let(:controller_name) { 'dummy_users' } 32 | let(:route_path) { "#{controller_name}##{action_name}" } 33 | 34 | describe '#controller' do 35 | it 'returns correct controller class' do 36 | expect(action.controller).to be(DummyUsersController) 37 | end 38 | end 39 | 40 | describe '#return_type' do 41 | subject(:return_type) { action.return_type } 42 | 43 | context 'when action configuration specifies return type' do 44 | it 'uses specified type' do 45 | expect(return_type).to eq(GraphQL::Types::String.to_non_null_type) 46 | end 47 | end 48 | end 49 | 50 | describe '#action_config' do 51 | it 'returns action configuration' do 52 | expect(action.action_config).to be_a(ActionConfiguration) 53 | end 54 | 55 | it 'returns action configuration with correct attributes' do 56 | expect(action.action_config).to have_attributes( 57 | name: action_name 58 | ) 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/graphql_rails/attributes/attribute.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql' 4 | require 'graphql_rails/attributes/attributable' 5 | require 'graphql_rails/attributes/attribute_configurable' 6 | require 'graphql_rails/input_configurable' 7 | 8 | module GraphqlRails 9 | module Attributes 10 | # contains info about single graphql attribute 11 | class Attribute 12 | include Attributable 13 | include AttributeConfigurable 14 | include InputConfigurable 15 | 16 | attr_reader :attributes 17 | 18 | def initialize(name) 19 | @initial_name = name 20 | @property = name.to_s 21 | @attributes ||= {} 22 | end 23 | 24 | def field_args 25 | [ 26 | field_name, 27 | type_parser.type_arg, 28 | description, 29 | *field_args_options 30 | ].compact 31 | end 32 | 33 | def field_options 34 | { 35 | method: property.to_sym, 36 | null: optional?, 37 | camelize: camelize?, 38 | groups: groups, 39 | hidden_in_groups: hidden_in_groups, 40 | **deprecation_reason_params, 41 | **extras_options 42 | } 43 | end 44 | 45 | protected 46 | 47 | attr_reader :initial_name 48 | 49 | private 50 | 51 | def extras_options 52 | return {} if extras.empty? 53 | 54 | { extras: extras } 55 | end 56 | 57 | def camelize? 58 | options[:input_format] != :original && options[:attribute_name_format] != :original 59 | end 60 | 61 | def deprecation_reason_params 62 | { deprecation_reason: deprecation_reason }.compact 63 | end 64 | 65 | def field_args_options 66 | options = { **field_args_pagination_options } 67 | return nil if options.empty? 68 | 69 | [options] 70 | end 71 | 72 | def field_args_pagination_options 73 | pagination_options || {} 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/graphql_rails/controller/action_hooks_runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | class Controller 5 | # runs {before/around/after}_action controller hooks 6 | class ActionHooksRunner 7 | def initialize(action_name:, controller:, graphql_request:) 8 | @action_name = action_name 9 | @controller = controller 10 | @graphql_request = graphql_request 11 | end 12 | 13 | def call(&block) 14 | result = nil 15 | run_action_hooks(:before) 16 | run_around_action_hooks { result = controller.instance_exec(&block) } 17 | run_action_hooks(:after) 18 | result 19 | end 20 | 21 | private 22 | 23 | attr_reader :action_name, :controller, :graphql_request 24 | 25 | def all_around_hooks 26 | controller_configuration.action_hooks_for(:around, action_name) 27 | end 28 | 29 | def controller_configuration 30 | controller.class.controller_configuration 31 | end 32 | 33 | def run_around_action_hooks(around_hooks = all_around_hooks, &block) 34 | return if graphql_request.errors.any? 35 | 36 | pending_around_hooks = around_hooks.clone 37 | action_hook = pending_around_hooks.shift 38 | 39 | if action_hook 40 | execute_hook(action_hook) { run_around_action_hooks(pending_around_hooks, &block) } 41 | else 42 | yield 43 | end 44 | end 45 | 46 | def execute_hook(action_hook, &block) 47 | return if graphql_request.errors.any? 48 | 49 | if action_hook.anonymous? 50 | controller.instance_exec(controller, *block, &action_hook.action_proc) 51 | else 52 | controller.send(action_hook.name, &block) 53 | end 54 | end 55 | 56 | def run_action_hooks(hook_type) 57 | action_hooks = controller_configuration.action_hooks_for(hook_type, action_name) 58 | action_hooks.each { |hook| execute_hook(hook) } 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/model/find_or_build_graphql_input_type_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | module Model 7 | RSpec.describe FindOrBuildGraphqlInputType do 8 | subject(:builder) do 9 | described_class.new( 10 | name: name, 11 | type_name: name.constantize.graphql.input.type_name, 12 | description: description, 13 | attributes: attributes 14 | ) 15 | end 16 | 17 | let(:name) { 'DummyInput' } 18 | let(:description) { 'This is dummy input' } 19 | let(:attributes) do 20 | { 21 | id: GraphqlRails::Attributes::InputAttribute.new(:id, config: nil), 22 | full_name: GraphqlRails::Attributes::InputAttribute.new(:full_name!, config: nil) 23 | } 24 | end 25 | 26 | let(:dummy_model_class) do 27 | graphql_name = name 28 | graphql_description = description 29 | 30 | Class.new do 31 | include Model 32 | 33 | graphql.input do |c| 34 | c.name graphql_name 35 | c.description graphql_description 36 | c.attribute :name 37 | end 38 | end 39 | end 40 | 41 | before do 42 | stub_const(name, dummy_model_class) 43 | end 44 | 45 | describe '#call' do 46 | subject(:call) { builder.call } 47 | 48 | it 'returns graphql input class' do 49 | expect(call < ::GraphQL::Schema::InputObject).to be true 50 | end 51 | 52 | it 'sets correct name' do 53 | expect(call.graphql_name).to eq name 54 | end 55 | 56 | it 'sets correct description' do 57 | expect(call.description).to eq description 58 | end 59 | 60 | it 'sets correct attributes', :aggregate_failures do 61 | expect(call.arguments['fullName'].type).to be_non_null 62 | expect(call.arguments['id'].type).not_to be_non_null 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/model/find_or_build_graphql_type_class_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | module Model 7 | RSpec.describe FindOrBuildGraphqlTypeClass do 8 | subject(:graphql_type_finder) do 9 | described_class.new( 10 | name: name, 11 | type_name: type_name, 12 | description: description, 13 | parent_class: GraphQL::Schema::Object, 14 | implements: implements 15 | ) 16 | end 17 | 18 | let(:name) { 'DummyModel' } 19 | let(:type_name) { 'DummyModelType' } 20 | let(:description) { 'DummyModelTypeDescription' } 21 | let(:implements) { [] } 22 | 23 | describe '#klass' do 24 | subject(:klass) { graphql_type_finder.klass } 25 | 26 | context 'when graphql type with given type name exists' do 27 | let(:type_class) do 28 | graphql_type_name = name 29 | graphql_type_description = description 30 | 31 | Class.new(GraphQL::Schema::Object) do 32 | graphql_name(graphql_type_name) 33 | description(graphql_type_description) 34 | end 35 | end 36 | 37 | before do 38 | stub_const(type_name, type_class) 39 | end 40 | 41 | it 'returns graphql type found by graphql type name' do 42 | expect(klass).to eq(type_name.constantize) 43 | end 44 | 45 | it 'does not create new class', :aggregate_failures do 46 | klass 47 | expect(graphql_type_finder).not_to be_new_class 48 | end 49 | end 50 | 51 | context 'when graphql type with given type name does not exist' do 52 | it 'sets graphql name and description', :aggregate_failures do 53 | expect(klass.graphql_name).to eq(name) 54 | expect(klass.description).to eq(description) 55 | expect(graphql_type_finder).to be_new_class 56 | end 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/graphql_rails/model/build_enum_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql' 4 | require 'graphql_rails/attributes/attributable' 5 | 6 | module GraphqlRails 7 | module Model 8 | # contains info about single graphql attribute 9 | class BuildEnumType 10 | class InvalidEnum < GraphqlRails::Error; end 11 | require 'graphql_rails/concerns/service' 12 | 13 | include ::GraphqlRails::Service 14 | 15 | def initialize(name, allowed_values:, description: nil) 16 | @name = name 17 | @allowed_values = allowed_values 18 | @description = description 19 | end 20 | 21 | def call 22 | validate 23 | build_enum 24 | end 25 | 26 | protected 27 | 28 | attr_reader :name, :allowed_values, :description 29 | 30 | def validate 31 | return if allowed_values.is_a?(Array) && !allowed_values.empty? 32 | 33 | validate_enum_type 34 | validate_enum_content 35 | end 36 | 37 | def validate_enum_type 38 | return if allowed_values.is_a?(Array) 39 | 40 | raise InvalidEnum, "Enum must be instance of Array, but instance of #{allowed_values.class} was given" 41 | end 42 | 43 | def validate_enum_content 44 | return unless allowed_values.empty? 45 | 46 | raise InvalidEnum, 'At lest one enum option must be given' 47 | end 48 | 49 | def formatted_name 50 | name.to_s.camelize 51 | end 52 | 53 | def build_enum(allowed_values: self.allowed_values, enum_name: formatted_name, enum_description: description) 54 | Class.new(GraphQL::Schema::Enum) do 55 | allowed_values.each do |allowed_value| 56 | graphql_name(enum_name) 57 | description(enum_description) if enum_description 58 | value(allowed_value.to_s.underscore.upcase, value: allowed_value) 59 | end 60 | 61 | def self.inspect 62 | "#{GraphQL::Schema::Enum}(#{graphql_name})" 63 | end 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/controller/action_hook_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | class Controller 7 | RSpec.describe ActionHook do 8 | subject(:hook) { described_class.new(name: action_name, only: only_actions, except: except_actions) } 9 | 10 | let(:only_actions) { [] } 11 | let(:except_actions) { [] } 12 | let(:action_name) { :some_action } 13 | 14 | describe '#applicable_for?' do 15 | context 'when `only` and `except` is not specified' do 16 | it { is_expected.to be_applicable_for(:any_action) } 17 | end 18 | 19 | context 'when `only` is specified' do 20 | let(:only_actions) { action_name } 21 | 22 | context 'when action name is included in `only` option as array' do 23 | let(:only_actions) { [action_name] } 24 | 25 | it { is_expected.to be_applicable_for(action_name) } 26 | end 27 | 28 | context 'when action name is included in `only` option' do 29 | it { is_expected.to be_applicable_for(action_name) } 30 | end 31 | 32 | context 'when action name is not included in `only` option' do 33 | it { is_expected.not_to be_applicable_for(:does_not_exist) } 34 | end 35 | end 36 | 37 | context 'when `except` is specified' do 38 | let(:except_actions) { action_name } 39 | 40 | context 'when action name is not included in `except` option' do 41 | it { is_expected.to be_applicable_for(:does_not_exist) } 42 | end 43 | 44 | context 'when action name is included in `except` option as array' do 45 | let(:except_actions) { [action_name] } 46 | 47 | it { is_expected.not_to be_applicable_for(action_name) } 48 | end 49 | 50 | context 'when action name is included in `except` option' do 51 | it { is_expected.not_to be_applicable_for(action_name) } 52 | end 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/controller/request/format_errors_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'active_model' 5 | 6 | module GraphqlRails 7 | RSpec.describe Controller::Request::FormatErrors do 8 | subject(:format_errors) { described_class.new(not_formatted_errors: errors) } 9 | 10 | let(:errors) { ['boom!'] } 11 | 12 | describe '#call' do 13 | subject(:call) { format_errors.call } 14 | 15 | context 'when errors are simple strings' do 16 | it 'returns ExecutionError instances' do 17 | expect(call.first).to be_a(ExecutionError) 18 | end 19 | 20 | it 'moves string values to error messages' do 21 | expect(call.first.message).to eq('boom!') 22 | end 23 | end 24 | 25 | context 'when errors are hash' do 26 | let(:errors) { [{ message: 'Boom!', code: 1337, type: 'testing_error' }] } 27 | 28 | it 'returns errors as instances of CustomExecutionError' do 29 | expect(call.first).to be_a(CustomExecutionError) 30 | end 31 | 32 | it 'includes all the information from hash' do 33 | expect(call.first.to_h).to eq( 34 | 'message' => 'Boom!', 35 | 'code' => 1337, 36 | 'type' => 'testing_error' 37 | ) 38 | end 39 | end 40 | 41 | context 'when errors are ActiveModel::Errors' do 42 | let(:errors) do 43 | ActiveModel::Errors.new({}).tap do |errors| 44 | errors.add(:test, 'boom!') 45 | end 46 | end 47 | 48 | it 'returns errors as instances of ValidationError' do 49 | expect(call.first).to be_a(ValidationError) 50 | end 51 | 52 | it 'all the information', :aggregate_failures do 53 | error = call.first 54 | expect(error.message).to eq('Test boom!') 55 | expect(error.type).to eq('validation_error') 56 | expect(error.short_message).to eq('boom!') 57 | expect(error.field).to eq(:test) 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/graphql_rails/query_runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | # executes GraphQL queries and returns json 5 | class QueryRunner 6 | require 'graphql_rails/router' 7 | require 'graphql_rails/concerns/service' 8 | 9 | include ::GraphqlRails::Service 10 | 11 | def initialize(params:, context: {}, schema: nil, router: nil, group: nil, **schema_options) # rubocop:disable Metrics/ParameterLists 12 | @group = group 13 | @graphql_schema = schema 14 | @params = params 15 | @router = router 16 | @initial_context = context 17 | @schema_options = schema_options 18 | end 19 | 20 | def call 21 | graphql_schema.execute( 22 | params[:query], 23 | variables: variables, 24 | operation_name: params[:operationName], 25 | context: context, 26 | **schema_options 27 | ) 28 | end 29 | 30 | private 31 | 32 | attr_reader :schema_options, :params, :group, :initial_context 33 | 34 | def context 35 | initial_context.merge(graphql_group: group) 36 | end 37 | 38 | def variables 39 | ensure_hash(params[:variables]) 40 | end 41 | 42 | def graphql_schema 43 | @graphql_schema ||= router_schema 44 | end 45 | 46 | def router 47 | @router ||= ::GraphqlRouter 48 | end 49 | 50 | def router_schema 51 | router.graphql_schema(group) 52 | end 53 | 54 | def ensure_hash(ambiguous_param) 55 | if ambiguous_param.blank? 56 | {} 57 | elsif ambiguous_param.is_a?(String) 58 | ensure_hash(JSON.parse(ambiguous_param)) 59 | elsif kind_of_hash?(ambiguous_param) 60 | ambiguous_param 61 | else 62 | raise ArgumentError, "Unexpected parameter: #{ambiguous_param.inspect}" 63 | end 64 | end 65 | 66 | def kind_of_hash?(object) 67 | return true if object.is_a?(Hash) 68 | 69 | defined?(ActionController) && 70 | defined?(ActionController::Parameters) && 71 | object.is_a?(ActionController::Parameters) 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/graphql_rails/controller/handle_controller_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | class Controller 5 | # runs {before/around/after}_action controller hooks 6 | class HandleControllerError 7 | def self.call(error:, controller:) 8 | new(error: error, controller: controller).call 9 | end 10 | 11 | def initialize(error:, controller:) 12 | @error = error 13 | @controller = controller 14 | end 15 | 16 | def call 17 | return custom_handle_error if custom_handle_error? 18 | 19 | render_unhandled_error(error) 20 | end 21 | 22 | private 23 | 24 | attr_reader :error, :controller 25 | 26 | def render_unhandled_error(error) 27 | return handle_graphql_execution_error(error) if error.is_a?(GraphQL::ExecutionError) 28 | 29 | render(error: SystemError.new(error)) 30 | end 31 | 32 | def handle_graphql_execution_error(error) 33 | raise error 34 | end 35 | 36 | def custom_handle_error 37 | return unless custom_handler 38 | 39 | begin 40 | if custom_handler.is_a?(Proc) 41 | controller.instance_exec(error, &custom_handler) 42 | else 43 | controller.send(custom_handler) 44 | end 45 | rescue StandardError => e 46 | render_unhandled_error(e) 47 | end 48 | end 49 | 50 | def custom_handler 51 | return @custom_handler if defined?(@custom_handler) 52 | 53 | handler = controller_config.error_handlers.detect do |error_class, _handler| 54 | error.class <= error_class 55 | end 56 | 57 | @custom_handler = handler&.last 58 | end 59 | 60 | def custom_handle_error? 61 | custom_handler.present? 62 | end 63 | 64 | def controller_config 65 | @controller_config ||= controller.class.controller_configuration 66 | end 67 | 68 | def render(*args, **kwargs, &block) 69 | controller.send(:render, *args, **kwargs, &block) 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/graphql_rails/router/schema_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/router/schema' 4 | 5 | module GraphqlRails 6 | class Router 7 | # builds GraphQL::Schema based on previously defined grahiti data 8 | class SchemaBuilder 9 | require_relative './plain_cursor_encoder' 10 | require_relative './build_schema_action_type' 11 | 12 | attr_reader :queries, :mutations, :events, :raw_actions 13 | 14 | def initialize(queries:, mutations:, events:, raw_actions:, group: nil) 15 | @queries = queries 16 | @mutations = mutations 17 | @events = events 18 | @raw_actions = raw_actions 19 | @group = group 20 | end 21 | 22 | def call 23 | query_type = build_group_type('Query', queries) 24 | mutation_type = build_group_type('Mutation', mutations) 25 | subscription_type = build_group_type('Subscription', events) 26 | 27 | define_schema_class(query_type, mutation_type, subscription_type, raw_actions) 28 | end 29 | 30 | private 31 | 32 | attr_reader :group 33 | 34 | # rubocop:disable Metrics/MethodLength 35 | def define_schema_class(query_type, mutation_type, subscription_type, raw) 36 | Class.new(GraphqlRails::Router::Schema) do 37 | raw.each { |action| send(action[:name], *action[:args], **action[:kwargs], &action[:block]) } 38 | 39 | query(query_type) if query_type 40 | mutation(mutation_type) if mutation_type 41 | subscription(subscription_type) if subscription_type 42 | end 43 | end 44 | # rubocop:enable Metrics/MethodLength 45 | 46 | def build_group_type(type_name, routes) 47 | group_name = group 48 | group_routes = 49 | routes 50 | .select { |route| route.show_in_group?(group_name) } 51 | .reverse 52 | .uniq(&:name) 53 | .reverse 54 | 55 | return if group_routes.empty? && type_name != 'Query' 56 | 57 | BuildSchemaActionType.call(type_name: type_name, routes: group_routes) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/tasks/dump_graphql_schema_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'graphql_rails/tasks/dump_graphql_schema' 5 | 6 | module GraphqlRails 7 | RSpec.describe DumpGraphqlSchema do 8 | subject(:dump_graphql_schema) do 9 | described_class.new(group: group, router: graphql_router, dump_dir: 'app/graphql') 10 | end 11 | 12 | let(:group) { nil } 13 | let(:default_schema_path) { 'app/graphql/graphql_schema.graphql' } 14 | 15 | let(:graphql_router) do 16 | GraphqlRails::Router.draw {} 17 | end 18 | 19 | before do 20 | allow(FileUtils).to receive(:mkdir_p).and_return(nil) 21 | allow(File).to receive(:write).and_return(1) 22 | end 23 | 24 | describe '#call' do 25 | subject(:call) { dump_graphql_schema.call } 26 | 27 | context 'when group is blank' do 28 | it 'writes router definition to default schema file' do 29 | call 30 | expect(File).to have_received(:write).with(default_schema_path, kind_of(String)) 31 | end 32 | end 33 | 34 | context 'when group name is given' do 35 | let(:group) { 'custom' } 36 | let(:schema_double) { double('Schema') } # rubocop:disable RSpec/VerifiedDoubles 37 | let(:graphql_router) { instance_double('GraphqlRails::Router', graphql_schema: schema_double) } 38 | 39 | before do 40 | allow(graphql_router).to receive(:graphql_schema).and_return(schema_double) 41 | allow(schema_double).to receive(:to_definition).and_return('') 42 | end 43 | 44 | it 'dumps schema with group context', :aggregate_failures do 45 | call 46 | expect(graphql_router).to have_received(:graphql_schema).with('custom') 47 | expect(schema_double).to have_received(:to_definition).with(context: { graphql_group: 'custom' }) 48 | end 49 | 50 | it 'writes router definition to group schema file' do 51 | call 52 | expected_path = 'app/graphql/graphql_custom_schema.graphql' 53 | expect(File).to have_received(:write).with(expected_path, kind_of(String)) 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/query_runner_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | RSpec.describe QueryRunner do 7 | class DummyQueryRunnerController < GraphqlRails::Controller 8 | action(:do_something).permit(:val).returns(:string) 9 | 10 | def do_something 11 | params[:val] || 'OK' 12 | end 13 | end 14 | 15 | subject(:query_runner) { described_class.new(params: params, router: router) } 16 | 17 | let(:params) do 18 | { 19 | query: query, 20 | variables: variables 21 | } 22 | end 23 | 24 | let(:query) { 'query { doSomething }' } 25 | let(:variables) { {} } 26 | 27 | let(:router) do 28 | Router.draw do 29 | query :do_something, to: 'graphql_rails/dummy_query_runner#do_something' 30 | end 31 | end 32 | 33 | describe '#call' do 34 | subject(:call) { query_runner.call } 35 | 36 | context 'when variables are not used' do 37 | it 'returns correct json' do 38 | result = call.to_h['data']['doSomething'] 39 | expect(result).to eq 'OK' 40 | end 41 | end 42 | 43 | context 'when variables are used' do 44 | let(:query) { 'query($val: String!) { doSomething(val: $val) }' } 45 | let(:variables) { { val: 'success!' } } 46 | 47 | context 'when variables are given as Hash' do 48 | it 'returns correct json' do 49 | result = call.to_h['data']['doSomething'] 50 | expect(result).to eq 'success!' 51 | end 52 | end 53 | 54 | context 'when variables are given as JSON string' do 55 | let(:variables) { { val: 'success!' }.to_json } 56 | 57 | it 'returns correct json' do 58 | result = call.to_h['data']['doSomething'] 59 | expect(result).to eq 'success!' 60 | end 61 | end 62 | 63 | context 'when variables are given as unsupported type' do 64 | let(:variables) { :unsupported } 65 | 66 | it 'returns correct json' do 67 | expect { call }.to raise_error('Unexpected parameter: :unsupported') 68 | end 69 | end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/controller/log_controller_action_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rails' 5 | 6 | module GraphqlRails 7 | class Controller 8 | RSpec.describe LogControllerAction do 9 | subject(:log_controller_action) do 10 | described_class.new( 11 | controller_name: 'UsersController', 12 | action_name: 'create', 13 | graphql_request: double('GraphqlRequest', errors: []), # rubocop:disable RSpec/VerifiedDoubles 14 | params: params 15 | ) 16 | end 17 | 18 | let(:params) { { email: 'john@example.com', password: 'secret123' } } 19 | 20 | # rubocop:disable RSpec/InstanceVariable 21 | describe '#call' do 22 | subject(:call) { log_controller_action.call {} } 23 | 24 | let(:last_event) { @last_process_event } 25 | 26 | before do 27 | allow(Rails).to receive(:application).and_return(OpenStruct.new(config: OpenStruct.new)) 28 | 29 | ActiveSupport::Notifications.subscribe(described_class::START_PROCESSING_KEY) do |*_, payload| 30 | @last_start_processing_event = payload 31 | end 32 | 33 | ActiveSupport::Notifications.subscribe(described_class::PROCESS_ACTION_KEY) do |*_, payload| 34 | @last_process_event = payload 35 | end 36 | end 37 | 38 | it 'logs events' do 39 | expect { call } 40 | .to change { @last_process_event } 41 | .and change { @last_start_processing_event } 42 | end 43 | 44 | context 'when filter options are given' do 45 | before do 46 | Rails.configuration.filter_parameters = [:password] 47 | end 48 | 49 | it 'filters sensitive params' do 50 | call 51 | expect(last_event[:params]).to include(password: '[FILTERED]') 52 | end 53 | end 54 | 55 | context 'when error happens while processing action' do 56 | subject(:call) do 57 | log_controller_action.call do 58 | raise GraphqlRails::ExecutionError, 'Stop! Hammer time!' 59 | end 60 | end 61 | 62 | it 'logs events', :aggregate_failures do 63 | expect { call } 64 | .to raise_error('Stop! Hammer time!') 65 | .and change { @last_process_event } 66 | end 67 | end 68 | end 69 | # rubocop:enable RSpec/InstanceVariable 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/graphql_rails/controller/log_controller_action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/errors/execution_error' 4 | 5 | module GraphqlRails 6 | class Controller 7 | # logs controller start and end times 8 | class LogControllerAction 9 | require 'graphql_rails/concerns/service' 10 | require 'active_support/notifications' 11 | 12 | include ::GraphqlRails::Service 13 | 14 | START_PROCESSING_KEY = 'start_processing.graphql_action_controller' 15 | PROCESS_ACTION_KEY = 'process_action.graphql_action_controller' 16 | 17 | def initialize(controller_name:, action_name:, params:, graphql_request:) 18 | @controller_name = controller_name 19 | @action_name = action_name 20 | @params = params 21 | @graphql_request = graphql_request 22 | end 23 | 24 | def call 25 | ActiveSupport::Notifications.instrument(START_PROCESSING_KEY, default_payload) 26 | ActiveSupport::Notifications.instrument(PROCESS_ACTION_KEY, default_payload) do |payload| 27 | yield.tap do 28 | payload[:status] = status 29 | end 30 | end 31 | end 32 | 33 | private 34 | 35 | attr_reader :controller_name, :action_name, :params, :graphql_request 36 | 37 | def default_payload 38 | { 39 | controller: controller_name, 40 | action: action_name, 41 | params: filtered_params 42 | } 43 | end 44 | 45 | def status 46 | graphql_request.errors.present? ? 500 : 200 47 | end 48 | 49 | def filtered_params 50 | @filtered_params ||= 51 | if filter_parameters.empty? 52 | params 53 | else 54 | filter_options = Rails.configuration.filter_parameters 55 | parameter_filter_class.new(filter_options).filter(params) 56 | end 57 | end 58 | 59 | def filter_parameters 60 | return [] if !defined?(Rails) || Rails.application.nil? 61 | 62 | Rails.application.config.filter_parameters || [] 63 | end 64 | 65 | def parameter_filter_class 66 | if ActiveSupport.gem_version.segments.first < 6 67 | return ActiveSupport::ParameterFilter if Object.const_defined?('ActiveSupport::ParameterFilter') 68 | 69 | ActionDispatch::Http::ParameterFilter 70 | else 71 | require 'active_support/parameter_filter' 72 | ActiveSupport::ParameterFilter 73 | end 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/decorator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'active_record' 5 | 6 | module GraphqlRails 7 | RSpec.describe Decorator do 8 | let(:decorator_class) do 9 | Class.new do 10 | include GraphqlRails::Decorator 11 | 12 | attr_reader :args, :object 13 | 14 | def self.name 15 | 'DummyDecorator' 16 | end 17 | 18 | def self.custom_build(object, *args) 19 | new(object, :custom, *args) 20 | end 21 | 22 | def initialize(object, *args) 23 | @object = object 24 | @args = args 25 | end 26 | end 27 | end 28 | 29 | describe '.decorate' do 30 | subject(:decorate) { decorator_class.decorate(object, *decorator_args) } 31 | 32 | let(:object) { double('Something') } # rubocop:disable RSpec/VerifiedDoubles 33 | let(:decorator_args) { nil } 34 | 35 | context 'when object is not array and not nil' do 36 | it 'returns decorator instance' do 37 | expect(decorate).to be_a(decorator_class) 38 | end 39 | end 40 | 41 | context 'when arguments are given' do 42 | let(:decorator_args) { %w[arg1 arg2] } 43 | 44 | it 'creates decorator with given args' do 45 | expect(decorate.args).to eq(decorator_args) 46 | end 47 | end 48 | 49 | context 'when custom build method is provided' do 50 | subject(:decorate) { decorator_class.decorate(object, *decorator_args, build_with: :custom_build) } 51 | 52 | let(:decorator_args) { %w[arg1 arg2] } 53 | 54 | it 'uses custom build method' do 55 | expect(decorate.args).to eq([:custom] + decorator_args) 56 | end 57 | end 58 | 59 | context 'when object is nil' do 60 | let(:object) { nil } 61 | 62 | it { is_expected.to be_nil } 63 | end 64 | 65 | context 'when object is instance of ActiveRecord::Relation' do 66 | let(:object) { instance_double(ActiveRecord::Relation) } 67 | 68 | before do 69 | allow(object).to receive(:is_a?).with(ActiveRecord::Relation).and_return(true) 70 | end 71 | 72 | it 'returns RelationDecorator instance' do 73 | expect(decorate).to be_a(Decorator::RelationDecorator) 74 | end 75 | end 76 | 77 | context 'when object is an Array' do 78 | let(:object) { ['a'] } 79 | 80 | it 'returns array of decorator instances' do 81 | expect(decorate).to all be_a(decorator_class) 82 | end 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |