├── 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 | GraphqlRails 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /lib/graphql_rails/model/call_graphql_model_method.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Model 5 | # Executes model method and adds additional meta data if needed 6 | class CallGraphqlModelMethod 7 | require 'graphql_rails/concerns/service' 8 | 9 | include ::GraphqlRails::Service 10 | 11 | PAGINATION_KEYS = %i[before after first last].freeze 12 | 13 | @instance_cache = {} 14 | 15 | class << self 16 | attr_reader :instance_cache 17 | 18 | def call(*_args, **kwargs) 19 | cache_key = kwargs[:attribute_config].object_id 20 | @instance_cache[cache_key] ||= new 21 | @instance_cache[cache_key].call_with_args(**kwargs) 22 | end 23 | end 24 | 25 | def call_with_args(model:, method_keyword_arguments:, graphql_context:, attribute_config:) 26 | @model = model 27 | @method_keyword_arguments = method_keyword_arguments 28 | @graphql_context = graphql_context 29 | @attribute_config = attribute_config 30 | 31 | with_graphql_context do 32 | run_method 33 | end 34 | end 35 | 36 | private 37 | 38 | attr_reader :model, :attribute_config, :graphql_context, :method_keyword_arguments 39 | 40 | def run_method 41 | if custom_keyword_arguments.empty? 42 | model.send(method_name) 43 | else 44 | formatted_arguments = formatted_method_input(custom_keyword_arguments) 45 | model.send(method_name, **formatted_arguments) 46 | end 47 | end 48 | 49 | def formatted_method_input(keyword_arguments) 50 | keyword_arguments.transform_values do |input_argument| 51 | formatted_method_input_argument(input_argument) 52 | end 53 | end 54 | 55 | def formatted_method_input_argument(argument) 56 | return argument.to_h if argument.is_a?(GraphQL::Schema::InputObject) 57 | 58 | argument 59 | end 60 | 61 | def method_name 62 | attribute_config.property 63 | end 64 | 65 | def paginated? 66 | attribute_config.paginated? 67 | end 68 | 69 | def custom_keyword_arguments 70 | return method_keyword_arguments unless paginated? 71 | 72 | method_keyword_arguments.each_with_object({}) do |(key, value), result| 73 | result[key] = value unless PAGINATION_KEYS.include?(key) 74 | end 75 | end 76 | 77 | def with_graphql_context 78 | return yield unless model.respond_to?(:with_graphql_context) 79 | 80 | model.with_graphql_context(graphql_context) { yield } 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/graphql_rails/attributes/attribute_configurable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/attributes/type_parser' 4 | require 'graphql_rails/attributes/attribute_name_parser' 5 | require 'graphql_rails/model/build_enum_type' 6 | require 'graphql_rails/concerns/chainable_options' 7 | require 'active_support/concern' 8 | 9 | module GraphqlRails 10 | module Attributes 11 | # Allows to set or get various attribute parameters 12 | module AttributeConfigurable 13 | extend ActiveSupport::Concern 14 | 15 | included do 16 | include GraphqlRails::ChainableOptions 17 | 18 | chainable_option :description 19 | chainable_option :options, default: {} 20 | chainable_option :extras, default: [] 21 | chainable_option :type 22 | end 23 | 24 | def groups(new_groups = ChainableOptions::NOT_SET) 25 | @groups ||= [] 26 | return @groups if new_groups == ChainableOptions::NOT_SET 27 | 28 | @groups = Array(new_groups).map(&:to_s) 29 | self 30 | end 31 | 32 | def group(*args) 33 | groups(*args) 34 | end 35 | 36 | def hidden_in_groups(new_groups = ChainableOptions::NOT_SET) 37 | @hidden_in_groups ||= [] 38 | return @hidden_in_groups if new_groups == ChainableOptions::NOT_SET 39 | 40 | @hidden_in_groups = Array(new_groups).map(&:to_s) 41 | self 42 | end 43 | 44 | def required(new_value = true) # rubocop:disable Style/OptionalBooleanParameter 45 | @required = new_value 46 | self 47 | end 48 | 49 | def optional(new_value = true) # rubocop:disable Style/OptionalBooleanParameter 50 | required(!new_value) 51 | end 52 | 53 | def deprecated(reason = 'Deprecated') 54 | @deprecation_reason = \ 55 | if [false, nil].include?(reason) 56 | nil 57 | else 58 | reason.is_a?(String) ? reason : 'Deprecated' 59 | end 60 | 61 | self 62 | end 63 | 64 | def deprecation_reason 65 | @deprecation_reason 66 | end 67 | 68 | def property(new_value = ChainableOptions::NOT_SET) 69 | return @property if new_value == ChainableOptions::NOT_SET 70 | 71 | @property = new_value.to_s 72 | self 73 | end 74 | 75 | def same_as(other_attribute) 76 | other = other_attribute.dup 77 | other.instance_variables.each do |instance_variable| 78 | next if instance_variable == :@initial_name 79 | 80 | instance_variable_set(instance_variable, other.instance_variable_get(instance_variable)) 81 | end 82 | 83 | self 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/graphql_rails/attributes/input_attribute.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Attributes 5 | # contains info about single graphql input attribute 6 | class InputAttribute 7 | require 'graphql_rails/model/build_enum_type' 8 | require_relative './input_type_parser' 9 | require_relative './attribute_name_parser' 10 | include Attributable 11 | include AttributeConfigurable 12 | 13 | chainable_option :subtype 14 | chainable_option :enum 15 | chainable_option :default_value 16 | 17 | def initialize(name, config:) 18 | @config = config 19 | @initial_name = name 20 | end 21 | 22 | def input_argument_args 23 | type = raw_input_type || input_type_parser.input_type_arg 24 | 25 | [field_name, type] 26 | end 27 | 28 | def input_argument_options 29 | { 30 | required: required?, 31 | description: description, 32 | camelize: false, 33 | groups: groups, 34 | hidden_in_groups: hidden_in_groups, 35 | **default_value_option, 36 | **property_params, 37 | **deprecation_reason_params 38 | } 39 | end 40 | 41 | def paginated? 42 | false 43 | end 44 | 45 | private 46 | 47 | attr_reader :initial_name, :config 48 | 49 | def default_value_option 50 | { default_value: default_value }.compact 51 | end 52 | 53 | def attribute_name_parser 54 | @attribute_name_parser ||= AttributeNameParser.new( 55 | initial_name, options: attribute_naming_options 56 | ) 57 | end 58 | 59 | def deprecation_reason_params 60 | { deprecation_reason: deprecation_reason }.compact 61 | end 62 | 63 | def property_params 64 | { as: property }.compact 65 | end 66 | 67 | def attribute_naming_options 68 | options.slice(:input_format) 69 | end 70 | 71 | def input_type_parser 72 | @input_type_parser ||= begin 73 | initial_parseable_type = type || enum_type || attribute_name_parser.graphql_type 74 | InputTypeParser.new(initial_parseable_type, subtype: subtype) 75 | end 76 | end 77 | 78 | def enum_type 79 | return if enum.blank? 80 | 81 | ::GraphqlRails::Model::BuildEnumType.call( 82 | "#{config.name}_#{initial_name}_enum", 83 | allowed_values: enum 84 | ) 85 | end 86 | 87 | def raw_input_type 88 | return type if type.is_a?(GraphQL::Schema::InputObject) 89 | return type.graphql_input_type if type.is_a?(Model::Input) 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/types/hidable_by_group_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe GraphqlRails::Types::HidableByGroup do 6 | subject(:dummy_type) { dummy_type_class.new(groups: groups, hidden_in_groups: hidden_in_groups) } 7 | 8 | let(:dummy_class_parent) do 9 | Class.new do 10 | def visible?(_context) 11 | true 12 | end 13 | end 14 | end 15 | 16 | let(:dummy_type_class) do 17 | Class.new(dummy_class_parent) do 18 | include GraphqlRails::Types::HidableByGroup 19 | end 20 | end 21 | 22 | let(:groups) { [] } 23 | let(:hidden_in_groups) { [] } 24 | 25 | describe '#visible?' do 26 | subject(:visible?) { dummy_type.visible?(graphql_context) } 27 | 28 | let(:graphql_context) { { graphql_group: current_group } } 29 | let(:current_group) { 'dummy' } 30 | 31 | context 'when no groups are specified' do 32 | it { is_expected.to be true } 33 | end 34 | 35 | context 'when groups are specified' do 36 | context 'when current group is in groups' do 37 | let(:groups) { [current_group] } 38 | 39 | it { is_expected.to be true } 40 | end 41 | 42 | context 'when current group is not in groups' do 43 | let(:groups) { ['other_group'] } 44 | 45 | it { is_expected.to be false } 46 | end 47 | end 48 | 49 | context 'when hidden_in_groups are specified' do 50 | context 'when current group is in hidden_in_groups' do 51 | let(:hidden_in_groups) { [current_group] } 52 | 53 | it { is_expected.to be false } 54 | end 55 | 56 | context 'when current group is not in hidden_in_groups' do 57 | let(:hidden_in_groups) { ['other_group'] } 58 | 59 | it { is_expected.to be true } 60 | end 61 | end 62 | 63 | context 'when groups and hidden_in_groups are specified' do 64 | context 'when current group is in groups and hidden_in_groups' do 65 | let(:groups) { [current_group] } 66 | let(:hidden_in_groups) { [current_group] } 67 | 68 | it { is_expected.to be false } 69 | end 70 | 71 | context 'when current group is in groups and not in hidden_in_groups' do 72 | let(:groups) { [current_group] } 73 | let(:hidden_in_groups) { ['other_group'] } 74 | 75 | it { is_expected.to be true } 76 | end 77 | 78 | context 'when current group is not in groups and in hidden_in_groups' do 79 | let(:groups) { ['other_group'] } 80 | let(:hidden_in_groups) { [current_group] } 81 | 82 | it { is_expected.to be false } 83 | end 84 | 85 | context 'when current group is not in groups and not in hidden_in_groups' do 86 | let(:groups) { ['other_group'] } 87 | let(:hidden_in_groups) { ['other_group'] } 88 | 89 | it { is_expected.to be true } 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/attributes/attribute_name_parser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | module Attributes 7 | RSpec.describe AttributeNameParser do 8 | subject(:parser) { described_class.new(name, options: options) } 9 | 10 | let(:name) { 'name' } 11 | let(:options) { {} } 12 | 13 | describe '#field_name' do 14 | subject(:field_name) { parser.field_name } 15 | 16 | context 'when name contains multiple words' do 17 | let(:name) { 'full_name' } 18 | 19 | context 'without options' do 20 | it 'camelizes name' do 21 | expect(field_name).to eq('fullName') 22 | end 23 | end 24 | 25 | context 'with original input format option' do 26 | let(:options) { { input_format: :original } } 27 | 28 | it 'keeps original format' do 29 | expect(field_name).to eq(name) 30 | end 31 | end 32 | 33 | context 'with original attribute name format option' do 34 | let(:options) { { attribute_name_format: :original } } 35 | 36 | it 'keeps original format' do 37 | expect(field_name).to eq(name) 38 | end 39 | end 40 | end 41 | 42 | context 'when name ends with bang mark' do 43 | let(:name) { :awesome! } 44 | 45 | it 'removes bang mark' do 46 | expect(field_name).to eq 'awesome' 47 | end 48 | end 49 | 50 | context 'when name ends with question mark (?)' do 51 | let(:name) { :almighty_admin? } 52 | 53 | it 'adds "is" prefix and removes question mark' do 54 | expect(field_name).to eq 'isAlmightyAdmin' 55 | end 56 | end 57 | end 58 | 59 | describe '#graphql_type' do 60 | subject(:graphql_type) { parser.graphql_type } 61 | 62 | context 'when name ends with question mark (?)' do 63 | let(:name) { :admin? } 64 | 65 | it 'returns boolean type' do 66 | expect(graphql_type).to eq GraphQL::Types::Boolean 67 | end 68 | end 69 | 70 | context 'when name ends with "id"' do 71 | let(:name) { :id } 72 | 73 | it 'returns id type' do 74 | expect(graphql_type).to eq GraphQL::Types::ID 75 | end 76 | end 77 | 78 | context 'when name does not end with special suffix' do 79 | it 'returns String type' do 80 | expect(graphql_type).to eq GraphQL::Types::String 81 | end 82 | end 83 | end 84 | 85 | describe '#required?' do 86 | context 'when name does not have bang at the end' do 87 | it { is_expected.not_to be_required } 88 | end 89 | 90 | context 'when name has bang at the end' do 91 | let(:name) { 'name!' } 92 | 93 | it { is_expected.to be_required } 94 | end 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /docs/components/decorator.md: -------------------------------------------------------------------------------- 1 | # Decorator 2 | 3 | Decorator is mostly used whit paginated results, because it can wrap ActiveRecord relations in a "pagination-friendly" way 4 | 5 | ## Passing extra options to decorator 6 | 7 | Let's say you want to decorate `comment`, but you also need `user` in order to print some details. Here is decorator for such comment: 8 | 9 | ```ruby 10 | class CommentDecorator < SimpleDelegator 11 | include GraphqlRails::Decorator 12 | 13 | def initialize(comment, current_user) 14 | @comment = comment 15 | @current_user = user 16 | end 17 | 18 | def author_name 19 | if @current_user.can_see_author_name?(@comment) 20 | @comment.author_name 21 | else 22 | 'secret author' 23 | end 24 | end 25 | end 26 | ``` 27 | 28 | In order to decorate object with extra arguments, simply pass them to `.decorate` method. Like this: 29 | 30 | ```ruby 31 | CommentDecorator.decorate(comment, current_user) 32 | ``` 33 | 34 | The only requirement is that first object should be the object which you are decorating. Other arguments are treated as extra data and they are not modified 35 | 36 | ## Decorating controller responses 37 | 38 | If you want to decorate your controller response you can use `GraphqlRails::Decorator` module. It can decorate simple objects and ActiveRecord::Relation objects. This is very handy when you need to decorated paginated actions: 39 | 40 | ```ruby 41 | class User < ActiveRecord::Base 42 | # it's not GraphqlRails::Model ! 43 | end 44 | 45 | class UserDecorator < SimpleDelegator 46 | include GraphqlRails::Model 47 | include GraphqlRails::Decorator 48 | 49 | graphql do |c| 50 | # some setup, attributes, etc... 51 | end 52 | 53 | def initialize(user); end 54 | end 55 | 56 | class UsersController < GraphqlRails::Controller 57 | action(:index).paginated.returns('[UserDecorator!]!') 58 | 59 | def index 60 | users = User.where(active: true) 61 | UserDecorator.decorate(users) 62 | end 63 | 64 | def create 65 | user = User.create(params) 66 | UserDecorator.decorate(user) 67 | end 68 | end 69 | ``` 70 | 71 | ## Decorating with custom method 72 | 73 | Sometimes building decorator instance is not that straight-forward and you need to use custom build strategy. In such cases you can pass `build_with: :DESIRED_CLASS_METHOD` option: 74 | 75 | ```ruby 76 | class UserDecorator < SimpleDelegator 77 | include GraphqlRails::Model 78 | include GraphqlRails::Decorator 79 | # ... 80 | 81 | def self.custom_build(user) 82 | user.admin? ? new(user, admin: true) : new(user) 83 | end 84 | 85 | def initialize(user, admin: false) 86 | @user = user 87 | @admin = admin 88 | end 89 | end 90 | 91 | class UsersController < GraphqlRails::Controller 92 | action(:index).paginated.returns('[UserDecorator!]!') 93 | 94 | def index 95 | users = User.where(active: true) 96 | UserDecorator.decorate(user, build_with: :custom_build) 97 | end 98 | end 99 | ``` 100 | -------------------------------------------------------------------------------- /lib/graphql_rails/model/find_or_build_graphql_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Model 5 | # stores information about model specific config, like attributes and types 6 | class FindOrBuildGraphqlType 7 | require 'graphql_rails/concerns/service' 8 | require 'graphql_rails/model/find_or_build_graphql_type_class' 9 | require 'graphql_rails/model/add_fields_to_graphql_type' 10 | 11 | include ::GraphqlRails::Service 12 | 13 | # rubocop:disable Metrics/ParameterLists 14 | def initialize( 15 | name:, 16 | description:, 17 | attributes:, 18 | type_name:, 19 | force_define_attributes: false, 20 | implements: [] 21 | ) 22 | @name = name 23 | @description = description 24 | @attributes = attributes 25 | @type_name = type_name 26 | @force_define_attributes = force_define_attributes 27 | @implements = implements 28 | end 29 | # rubocop:enable Metrics/ParameterLists 30 | 31 | def call 32 | klass.tap do 33 | add_attributes if new_class? || force_define_attributes 34 | add_interfaces 35 | end 36 | end 37 | 38 | private 39 | 40 | attr_reader :name, :description, :attributes, :type_name, :force_define_attributes, 41 | :implements 42 | 43 | delegate :klass, :new_class?, to: :type_class_finder 44 | 45 | def parent_class 46 | GraphqlRails::Types::ObjectType 47 | end 48 | 49 | def add_attributes_batch(attributes) 50 | AddFieldsToGraphqlType.call(klass: klass, attributes: attributes) 51 | end 52 | 53 | def type_class_finder 54 | @type_class_finder ||= FindOrBuildGraphqlTypeClass.new( 55 | name: name, 56 | type_name: type_name, 57 | description: description, 58 | implements: implements, 59 | parent_class: parent_class 60 | ) 61 | end 62 | 63 | def add_attributes 64 | scalar_attributes, dynamic_attributes = attributes.values.partition(&:scalar_type?) 65 | 66 | add_attributes_batch(scalar_attributes) 67 | dynamic_attributes.each { |attribute| find_or_build_dynamic_type(attribute) } 68 | add_attributes_batch(dynamic_attributes) 69 | end 70 | 71 | def add_interfaces 72 | implements.each do |interface| 73 | next if klass.interfaces.include?(interface) 74 | 75 | klass.implements(interface) 76 | end 77 | end 78 | 79 | def find_or_build_dynamic_type(attribute) 80 | graphql_model = attribute.graphql_model 81 | return unless graphql_model 82 | 83 | find_or_build_graphql_model_type(graphql_model.graphql) 84 | end 85 | 86 | def find_or_build_graphql_model_type(graphql_config) 87 | self.class.call( 88 | name: graphql_config.name, 89 | description: graphql_config.description, 90 | attributes: graphql_config.attributes, 91 | type_name: graphql_config.type_name 92 | ) 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/router/route_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | RSpec.describe Router::Route do 5 | subject(:route) { described_class.new(:dummy, **params) } 6 | 7 | let(:params) do 8 | { 9 | on: :member, 10 | to: 'dummies#show' 11 | } 12 | end 13 | 14 | let(:controller) do 15 | user_type = Class.new(GraphQL::Schema::Object) do 16 | graphql_name 'User' 17 | 18 | field :name, String, null: false 19 | end 20 | 21 | Class.new(GraphqlRails::Controller) do 22 | action(:show) 23 | .permit(:name!) 24 | .paginated(max_page_size: 100, default_page_size: 10) 25 | .returns(user_type) 26 | 27 | def show 28 | 'OK' 29 | end 30 | end 31 | end 32 | 33 | let(:type) do 34 | Class.new do 35 | include GraphqlRails::Model 36 | 37 | graphql.name("SomeDummyModelType#{rand(10**9)}") 38 | graphql.attribute(:id) 39 | end 40 | end 41 | 42 | describe '#path' do 43 | subject(:path) { route.path } 44 | 45 | it 'returns correct path' do 46 | expect(path).to eq('dummies#show') 47 | end 48 | 49 | context 'when module is given' do 50 | let(:params) { super().merge(module: 'foo/bar') } 51 | 52 | it 'includes module in path' do 53 | expect(path).to eq('foo/bar/dummies#show') 54 | end 55 | end 56 | end 57 | 58 | describe '#collection?' do 59 | subject(:collection?) { route.collection? } 60 | 61 | context 'when "on" is :member' do 62 | it { is_expected.to be false } 63 | end 64 | 65 | context 'when "on" is :collection' do 66 | let(:params) { super().merge(on: :collection) } 67 | 68 | it { is_expected.to be true } 69 | end 70 | end 71 | 72 | describe '#show_in_group?' do 73 | subject(:show_in_group?) { route.show_in_group?(group_name) } 74 | 75 | let(:group_name) { :foo } 76 | 77 | context 'when groups are not given' do 78 | it { is_expected.to be true } 79 | end 80 | 81 | context 'when group is given' do 82 | let(:params) { super().merge(groups: %i[foo]) } 83 | 84 | context 'when group is correct' do 85 | it { is_expected.to be true } 86 | end 87 | 88 | context 'when group is incorrect' do 89 | let(:group_name) { :bar } 90 | 91 | it { is_expected.to be false } 92 | end 93 | end 94 | end 95 | 96 | describe '#field_options' do 97 | subject(:field_options) { route.field_options } 98 | 99 | before do 100 | allow(Object).to receive(:const_get).with('DummiesController').and_return(controller) 101 | allow(Object).to receive(:const_get).with('User').and_return(type) 102 | end 103 | 104 | it 'returns correct options' do 105 | expect(field_options).to include( 106 | extras: [:lookahead], 107 | max_page_size: 100, 108 | default_page_size: 10 109 | ) 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/graphql_rails/router/resource_routes_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'query_route' 4 | require_relative 'mutation_route' 5 | 6 | module GraphqlRails 7 | class Router 8 | # Generates graphql routes based on resource name and options 9 | class ResourceRoutesBuilder 10 | AVAILABLE_ROUTES = %i[show index create update destroy].freeze 11 | 12 | def initialize(name, only: nil, except: [], **options) 13 | @name = name.to_s 14 | 15 | @options = options 16 | @autogenerated_action_names = initial_action_names(only, except, AVAILABLE_ROUTES) 17 | end 18 | 19 | def routes 20 | @routes ||= initial_routes 21 | end 22 | 23 | def query(*args, **kwargs) 24 | routes << build_query(*args, **kwargs) 25 | end 26 | 27 | def mutation(*args, **kwargs) 28 | routes << build_mutation(*args, **kwargs) 29 | end 30 | 31 | private 32 | 33 | attr_reader :autogenerated_action_names, :name, :options 34 | 35 | def initial_routes 36 | routes = initial_query_routes 37 | routes << build_mutation(:create, on: :member) if autogenerated_action_names.include?(:create) 38 | routes << build_mutation(:update, on: :member) if autogenerated_action_names.include?(:update) 39 | routes << build_mutation(:destroy, on: :member) if autogenerated_action_names.include?(:destroy) 40 | routes 41 | end 42 | 43 | def initial_query_routes 44 | routes = Set.new 45 | 46 | if autogenerated_action_names.include?(:show) 47 | routes << build_route(QueryRoute, 'show', to: "#{name}#show", prefix: '', on: :member) 48 | end 49 | 50 | if autogenerated_action_names.include?(:index) 51 | routes << build_route(QueryRoute, 'index', to: "#{name}#index", prefix: '', on: :collection) 52 | end 53 | 54 | routes 55 | end 56 | 57 | def build_mutation(*args, **kwargs) 58 | build_route(MutationRoute, *args, **kwargs) 59 | end 60 | 61 | def build_query(*args, **kwargs) 62 | build_route(QueryRoute, *args, **kwargs) 63 | end 64 | 65 | # rubocop:disable Metrics/ParameterLists 66 | def build_route(builder, action, prefix: action, suffix: false, on: :member, **custom_options) 67 | if suffix == true 68 | suffix_name = action 69 | prefix = '' 70 | end 71 | 72 | action_options = options.merge(custom_options).merge(on: on) 73 | controller_method_name = action.to_s.underscore 74 | action_name = [prefix, resource_name(on), suffix_name].map(&:to_s).reject(&:empty?).join('_') 75 | builder.new(action_name, to: "#{name}##{controller_method_name}", **action_options) 76 | end 77 | # rubocop:enable Metrics/ParameterLists 78 | 79 | def initial_action_names(only, except, available) 80 | alowed_routes = Array(only || available) & available 81 | only_routes = alowed_routes.map(&:to_sym) - Array(except).map(&:to_sym) 82 | Set.new(only_routes) 83 | end 84 | 85 | def resource_name(type) 86 | type.to_sym == :member ? name.singularize : name 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/graphql_rails/attributes/type_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql' 4 | require 'graphql_rails/model/build_connection_type' 5 | require 'graphql_rails/errors/error' 6 | 7 | module GraphqlRails 8 | module Attributes 9 | # converts string value in to GraphQL type 10 | class TypeParser 11 | require_relative './type_name_info' 12 | require_relative './type_parseable' 13 | 14 | class NotSupportedFeature < GraphqlRails::Error; end 15 | 16 | include TypeParseable 17 | 18 | delegate :list?, :required_inner_type?, :required_list?, :required?, to: :type_name_info 19 | 20 | def initialize(unparsed_type, paginated: false) 21 | @unparsed_type = unparsed_type 22 | @paginated = paginated 23 | end 24 | 25 | def paginated? 26 | @paginated 27 | end 28 | 29 | def graphql_type 30 | return unparsed_type if raw_graphql_type? 31 | 32 | if list? 33 | parsed_list_type 34 | else 35 | parsed_inner_type 36 | end 37 | end 38 | 39 | def type_arg 40 | if paginated? 41 | paginated_type_arg 42 | elsif list? 43 | list_type_arg 44 | else 45 | raw_unwrapped_type 46 | end 47 | end 48 | 49 | protected 50 | 51 | def paginated_type_arg 52 | return graphql_model.graphql.connection_type if graphql_model 53 | 54 | error_message = "Unable to paginate #{unparsed_type.inspect}. " \ 55 | 'Pagination is only supported for models which include GraphqlRails::Model' 56 | raise NotSupportedFeature, error_message 57 | end 58 | 59 | def list_type_arg 60 | if required_inner_type? 61 | [raw_unwrapped_type] 62 | else 63 | [raw_unwrapped_type, null: true] 64 | end 65 | end 66 | 67 | def parsed_type 68 | return unparsed_type if raw_graphql_type? 69 | 70 | type_by_name 71 | end 72 | 73 | def raw_unwrapped_type 74 | @raw_unwrapped_type ||= unwrap_type(parsed_type) 75 | end 76 | 77 | private 78 | 79 | attr_reader :unparsed_type 80 | 81 | def parsed_list_type 82 | list_type = parsed_inner_type.to_list_type 83 | 84 | if required_list? 85 | list_type.to_non_null_type 86 | else 87 | list_type 88 | end 89 | end 90 | 91 | def parsed_inner_type 92 | if required_inner_type? 93 | type_by_name.to_non_null_type 94 | else 95 | type_by_name 96 | end 97 | end 98 | 99 | def type_name_info 100 | @type_name_info ||= begin 101 | type_name = \ 102 | if unparsed_type.respond_to?(:to_type_signature) 103 | unparsed_type.to_type_signature 104 | else 105 | unparsed_type.to_s 106 | end 107 | TypeNameInfo.new(type_name) 108 | end 109 | end 110 | 111 | def type_by_name 112 | unwrapped_scalar_type || graphql_type_object || raise_not_supported_type_error 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/graphql_rails/decorator/relation_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | module Decorator 5 | # wraps active record relation and returns decorated object instead 6 | class RelationDecorator 7 | delegate :map, :each, to: :to_a 8 | delegate :limit_value, :offset_value, :count, :size, :empty?, :loaded?, to: :relation 9 | 10 | def self.decorates?(object) 11 | (defined?(ActiveRecord) && object.is_a?(ActiveRecord::Relation)) || 12 | defined?(Mongoid) && object.is_a?(Mongoid::Criteria) 13 | end 14 | 15 | def initialize(decorator:, relation:, decorator_args: [], decorator_kwargs: {}, build_with: :new) 16 | @relation = relation 17 | @decorator = decorator 18 | @decorator_args = decorator_args 19 | @decorator_kwargs = decorator_kwargs 20 | @build_with = build_with 21 | end 22 | 23 | %i[where limit order group offset from select having all unscope].each do |method_name| 24 | define_method method_name do |*args, **kwargs, &block| 25 | chainable_method(method_name, *args, **kwargs, &block) 26 | end 27 | end 28 | 29 | %i[first second last find find_by].each do |method_name| 30 | define_method method_name do |*args, **kwargs, &block| 31 | decoratable_object_method(method_name, *args, **kwargs, &block) 32 | end 33 | end 34 | 35 | %i[find_each].each do |method_name| 36 | define_method method_name do |*args, **kwargs, &block| 37 | decoratable_block_method(method_name, *args, **kwargs, &block) 38 | end 39 | end 40 | 41 | def to_a 42 | @to_a ||= relation.to_a.map { |it| build_decorator(it, *decorator_args, **decorator_kwargs) } 43 | end 44 | 45 | private 46 | 47 | attr_reader :relation, :decorator, :decorator_args, :decorator_kwargs, :build_with 48 | 49 | def decoratable_object_method(method_name, *args, **kwargs, &block) 50 | object = relation.public_send(method_name, *args, **kwargs, &block) 51 | decorate(object) 52 | end 53 | 54 | def decorate(object_or_list) 55 | return object_or_list if object_or_list.blank? 56 | 57 | if object_or_list.is_a?(Array) 58 | object_or_list.map { |it| build_decorator(it, *decorator_args, **decorator_kwargs) } 59 | else 60 | build_decorator(object_or_list, *decorator_args, **decorator_kwargs) 61 | end 62 | end 63 | 64 | def build_decorator(*args, **kwargs, &block) 65 | decorator.public_send(build_with, *args, **kwargs, &block) 66 | end 67 | 68 | def decoratable_block_method(method_name, *args, **kwargs) 69 | relation.public_send(method_name, *args, **kwargs) do |object, *other_args| 70 | decorated_object = decorate(object) 71 | yield(decorated_object, *other_args) 72 | end 73 | end 74 | 75 | def chainable_method(method_name, *args, **kwargs, &block) 76 | new_relation = relation.public_send(method_name, *args, **kwargs, &block) 77 | self.class.new( 78 | decorator: decorator, relation: new_relation, 79 | decorator_args: decorator_args, decorator_kwargs: decorator_kwargs, 80 | build_with: build_with 81 | ) 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/graphql_rails/model/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails/attributes' 4 | require 'graphql_rails/model/find_or_build_graphql_type' 5 | require 'graphql_rails/model/input' 6 | require 'graphql_rails/model/configurable' 7 | require 'graphql_rails/model/build_connection_type' 8 | 9 | module GraphqlRails 10 | module Model 11 | # stores information about model specific config, like attributes and types 12 | class Configuration 13 | include ChainableOptions 14 | include Configurable 15 | 16 | def initialize(model_class) 17 | @model_class = model_class 18 | end 19 | 20 | def initialize_copy(other) 21 | super 22 | @connection_type = nil 23 | @graphql_type = nil 24 | @input = other.instance_variable_get(:@input)&.transform_values(&:dup) 25 | end 26 | 27 | def implements(*interfaces) 28 | previous_implements = get_or_set_chainable_option(:implements) || [] 29 | return previous_implements if interfaces.blank? 30 | 31 | full_implements = (previous_implements + interfaces).uniq 32 | 33 | get_or_set_chainable_option(:implements, full_implements) || [] 34 | end 35 | 36 | def attribute(attribute_name, **attribute_options) 37 | key = attribute_name.to_s 38 | 39 | attributes[key] ||= build_attribute(attribute_name) 40 | 41 | attributes[key].tap do |new_attribute| 42 | new_attribute.with(**attribute_options) 43 | yield(new_attribute) if block_given? 44 | end 45 | end 46 | 47 | def input(input_name = nil) 48 | @input ||= {} 49 | name = input_name.to_s 50 | 51 | if block_given? 52 | @input[name] ||= Model::Input.new(model_class, input_name) 53 | yield(@input[name]) 54 | end 55 | 56 | @input.fetch(name) do 57 | raise("GraphQL input with name #{input_name.inspect} is not defined for #{model_class.name}") 58 | end 59 | end 60 | 61 | def graphql_type 62 | @graphql_type ||= FindOrBuildGraphqlType.call( 63 | name: name, 64 | description: description, 65 | attributes: attributes, 66 | type_name: type_name, 67 | implements: implements 68 | ) 69 | end 70 | 71 | def connection_type 72 | @connection_type ||= BuildConnectionType.call(graphql_type) 73 | end 74 | 75 | def with_ensured_fields! 76 | return self if @graphql_type.blank? 77 | 78 | reset_graphql_type if attributes.any? && graphql_type.fields.length != attributes.length 79 | 80 | self 81 | end 82 | 83 | private 84 | 85 | attr_reader :model_class 86 | 87 | def build_attribute(attribute_name) 88 | Attributes::Attribute.new(attribute_name) 89 | end 90 | 91 | def default_name 92 | @default_name ||= model_class.name.split('::').last 93 | end 94 | 95 | def reset_graphql_type 96 | @graphql_type = FindOrBuildGraphqlType.call( 97 | name: name, 98 | description: description, 99 | attributes: attributes, 100 | type_name: type_name, 101 | implements: implements, 102 | force_define_attributes: true 103 | ) 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at povilas@samesystem.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /lib/graphql_rails/router/build_schema_action_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphqlRails 4 | class Router 5 | # Builds GraphQL type used in graphql schema 6 | class BuildSchemaActionType 7 | ROUTES_KEY = :__routes__ 8 | 9 | # @private 10 | class SchemaActionType < GraphQL::Schema::Object 11 | def self.inspect 12 | "#{GraphQL::Schema::Object}(#{graphql_name})" 13 | end 14 | 15 | class << self 16 | def fields_for_nested_routes(type_name_prefix:, scoped_routes:) 17 | routes_by_scope = scoped_routes.dup 18 | unscoped_routes = routes_by_scope.delete(ROUTES_KEY) || [] 19 | 20 | scoped_only_fields(type_name_prefix, routes_by_scope) 21 | unscoped_routes.each { route_field(_1) } 22 | end 23 | 24 | private 25 | 26 | def route_field(route) 27 | field(*route.name, **route.field_options) 28 | end 29 | 30 | def scoped_only_fields(type_name_prefix, routes_by_scope) 31 | routes_by_scope.each_pair do |scope_name, inner_scope_routes| 32 | scope_field(scope_name, "#{type_name_prefix}#{scope_name.to_s.camelize}", inner_scope_routes) 33 | end 34 | end 35 | 36 | def scope_field(scope_name, scope_type_name, scoped_routes) 37 | scope_type = build_scope_type_class( 38 | type_name: scope_type_name, 39 | scoped_routes: scoped_routes 40 | ) 41 | 42 | field(scope_name.to_s.camelize(:lower), scope_type, null: false) 43 | define_method(scope_type_name.underscore) { self } 44 | end 45 | 46 | def build_scope_type_class(type_name:, scoped_routes:) 47 | Class.new(SchemaActionType) do 48 | graphql_name("#{type_name}Scope") 49 | 50 | fields_for_nested_routes( 51 | type_name_prefix: type_name, 52 | scoped_routes: scoped_routes 53 | ) 54 | end 55 | end 56 | end 57 | end 58 | 59 | def self.call(**kwargs) 60 | new(**kwargs).call 61 | end 62 | 63 | def initialize(type_name:, routes:) 64 | @type_name = type_name 65 | @routes = routes 66 | end 67 | 68 | def call 69 | type_name = self.type_name 70 | scoped_routes = self.scoped_routes 71 | 72 | Class.new(SchemaActionType) do 73 | graphql_name(type_name) 74 | 75 | fields_for_nested_routes( 76 | type_name_prefix: type_name, 77 | scoped_routes: scoped_routes 78 | ) 79 | end 80 | end 81 | 82 | private 83 | 84 | attr_reader :type_name, :routes 85 | 86 | def scoped_routes 87 | routes.each_with_object({}) do |route, result| 88 | scope_names = route.scope_names.map { _1.to_s.camelize(:lower) } 89 | path_to_routes = scope_names + [ROUTES_KEY] 90 | deep_append(result, path_to_routes, route) 91 | end 92 | end 93 | 94 | # adds array element to nested hash 95 | # usage: 96 | # deep_hash = { a: { b: [1] } } 97 | # deep_append(deep_hash, [:a, :b], 2) 98 | # deep_hash #=> { a: { b: [1, 2] } } 99 | def deep_append(hash, keys, value) 100 | deepest_hash = hash 101 | *other_keys, last_key = keys 102 | 103 | other_keys.each do |key| 104 | deepest_hash[key] ||= {} 105 | deepest_hash = deepest_hash[key] 106 | end 107 | deepest_hash[last_key] ||= [] 108 | deepest_hash[last_key] += [value] 109 | end 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/graphql_rails/controller/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/string/inflections' 4 | require 'graphql_rails/controller/action_configuration' 5 | require 'graphql_rails/controller/action_hook' 6 | require 'graphql_rails/errors/error' 7 | 8 | module GraphqlRails 9 | class Controller 10 | # stores all graphql_rails controller specific config 11 | class Configuration 12 | class InvalidActionConfiguration < GraphqlRails::Error; end 13 | 14 | LIB_REGEXP = %r{/graphql_rails/lib/} 15 | 16 | attr_reader :action_by_name, :error_handlers 17 | 18 | def initialize(controller) 19 | @controller = controller 20 | @hooks = { 21 | before: {}, 22 | after: {}, 23 | around: {} 24 | } 25 | 26 | @action_by_name = {} 27 | @action_default = nil 28 | @error_handlers = {} 29 | end 30 | 31 | def initialize_copy(other) 32 | super 33 | 34 | @action_by_name = other.instance_variable_get(:@action_by_name).transform_values(&:dup) 35 | 36 | hooks_to_copy = other.instance_variable_get(:@hooks) 37 | @hooks = hooks_to_copy.each.with_object({}) do |(hook_type, type_hooks), new_hooks| 38 | new_hooks[hook_type] = type_hooks.transform_values(&:dup) 39 | end 40 | end 41 | 42 | def dup_with(controller:) 43 | dup.tap do |new_config| 44 | new_config.instance_variable_set(:@controller, controller) 45 | end 46 | end 47 | 48 | def action_hooks_for(hook_type, action_name) 49 | hooks[hook_type].values.select { |hook| hook.applicable_for?(action_name) } 50 | end 51 | 52 | def add_action_hook(hook_type, name = nil, **options, &block) 53 | hook_name = name&.to_sym 54 | hook_key = hook_name || :"anonymous_#{block.hash}" 55 | 56 | hooks[hook_type][hook_key] = \ 57 | ActionHook.new(name: hook_name, **options, &block) 58 | end 59 | 60 | def add_error_handler(error, with:, &block) 61 | @error_handlers[error] = with || block 62 | end 63 | 64 | def action_default 65 | @action_default ||= ActionConfiguration.new(name: :default, controller: nil) 66 | yield(@action_default) if block_given? 67 | @action_default 68 | end 69 | 70 | def action(method_name) 71 | action_name = method_name.to_s.underscore 72 | @action_by_name[action_name] ||= action_default.dup_with( 73 | name: action_name, 74 | controller: controller, 75 | defined_at: dynamic_source_location 76 | ) 77 | yield(@action_by_name[action_name]) if block_given? 78 | @action_by_name[action_name] 79 | end 80 | 81 | def action_config(method_name) 82 | action_name = method_name.to_s.underscore 83 | @action_by_name.fetch(action_name) { raise_invalid_config_error(action_name) } 84 | end 85 | 86 | def model(model = nil) 87 | action_default.model(model) 88 | end 89 | 90 | private 91 | 92 | attr_reader :hooks, :controller 93 | 94 | def dynamic_source_location 95 | project_trace = \ 96 | caller 97 | .dup 98 | .drop_while { |path| !path.match?(LIB_REGEXP) } 99 | .drop_while { |path| path.match?(LIB_REGEXP) } 100 | 101 | project_trace.first 102 | end 103 | 104 | def raise_invalid_config_error(action_name) 105 | error_message = \ 106 | "Missing action configuration for #{controller}##{action_name}. " \ 107 | "Please define it with `action(:#{action_name})`." 108 | 109 | raise InvalidActionConfiguration, error_message 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/rspec_controller_helpers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'graphql_rails/rspec_controller_helpers' 5 | 6 | module GraphqlRails 7 | RSpec.describe RSpecControllerHelpers do 8 | class TestableController < GraphqlRails::Controller 9 | Stats = Struct.new(:json) do 10 | include GraphqlRails::Model 11 | 12 | graphql do |c| 13 | c.attribute :json, type: :string! 14 | end 15 | end 16 | 17 | action(:paginated_index).paginated.returns("[#{Stats}!]!") 18 | action(:boom).returns('bool!') 19 | action(:index).returns(Stats) 20 | 21 | def paginated_index 22 | [] 23 | end 24 | 25 | def index 26 | Stats.new( 27 | { 28 | received_params: params, 29 | received_context: graphql_request.context.to_h 30 | }.to_json 31 | ) 32 | end 33 | 34 | def boom 35 | raise 'Boom!' 36 | end 37 | end 38 | 39 | RSpecLikeRunner = Struct.new(:described_class) do 40 | include RSpecControllerHelpers 41 | end 42 | 43 | subject(:runner) { RSpecLikeRunner.new(TestableController) } 44 | 45 | let(:action_params) { { id: 1 } } 46 | let(:action_context) { { current_user_id: 1 } } 47 | 48 | describe '#query' do 49 | subject(:json_result) do 50 | runner.query(action_name, params: action_params, context: action_context) 51 | JSON.parse(runner.response.result.json).deep_symbolize_keys 52 | end 53 | 54 | let(:action_name) { :index } 55 | 56 | it 'triggers controller with correct params' do 57 | expect(json_result).to eq( 58 | received_params: action_params.to_h, 59 | received_context: action_context.to_h 60 | ) 61 | end 62 | 63 | context 'when testing paginated action' do 64 | let(:action_name) { :paginated_index } 65 | 66 | it 'triggers controller with correct params' do 67 | runner.query(:paginated_index, context: action_context) 68 | 69 | expect(runner.response.result).to eq([]) 70 | end 71 | end 72 | end 73 | 74 | describe '#mutation' do 75 | subject(:json_result) do 76 | runner.mutation(:index, params: action_params, context: action_context) 77 | JSON.parse(runner.response.result.json).deep_symbolize_keys 78 | end 79 | 80 | it 'triggers controller with correct params' do 81 | expect(json_result).to eq( 82 | received_params: action_params.to_h, 83 | received_context: action_context.to_h 84 | ) 85 | end 86 | end 87 | 88 | describe '#response' do 89 | it 'includes controller name' do 90 | runner.query(:index, params: action_params, context: action_context) 91 | expect(runner.response.controller).to eq TestableController 92 | end 93 | 94 | it 'includes action name' do 95 | runner.query(:index, params: action_params, context: action_context) 96 | expect(runner.response.action_name).to eq :index 97 | end 98 | 99 | context 'when request was successful' do 100 | it 'sets status to success' do 101 | runner.query(:index, params: action_params, context: action_context) 102 | expect(runner.response) 103 | .to be_success 104 | .and be_successful 105 | end 106 | end 107 | 108 | context 'when error happens' do 109 | it 'registers error' do 110 | runner.query(:boom) 111 | expect(runner.response.errors.map(&:message)).to eq ['Boom!'] 112 | end 113 | 114 | it 'sets status to failure' do 115 | runner.query(:boom) 116 | expect(runner.response).to be_failure 117 | end 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/model/input_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | module Model 7 | RSpec.describe Input do 8 | subject(:input) { described_class.new(model, input_name) } 9 | 10 | let(:input_name) { :search_criteria } 11 | 12 | let(:model) do 13 | Class.new do 14 | include GraphqlRails::Model 15 | 16 | graphql do |c| 17 | c.description 'Used for test purposes' 18 | c.attribute :id 19 | c.attribute :valid? 20 | c.attribute :level, type: :int, description: 'over 9000!' 21 | end 22 | 23 | def self.name 24 | 'DummyModel' 25 | end 26 | end 27 | end 28 | 29 | describe '#name' do 30 | it 'joins model name and input name' do 31 | expect(input.name).to eq 'DummyModelSearchCriteriaInput' 32 | end 33 | end 34 | 35 | describe '#attribute' do 36 | let(:attribute_type) { input.attributes['fruit'].input_argument_args[1] } 37 | let(:attribute_type_options) { input.attributes['fruit'].input_argument_options } 38 | 39 | context 'when attribute has enum type' do 40 | context 'when enum is required' do 41 | before do 42 | input.attribute(:fruit, enum: %i[apple orange], required: true) 43 | end 44 | 45 | it 'adds non null enum type' do 46 | expect(attribute_type_options).to include(required: true) 47 | end 48 | 49 | it 'adds attribute with enum type' do 50 | expect(attribute_type < GraphQL::Schema::Enum).to be true 51 | end 52 | end 53 | 54 | context 'when enum is not required' do 55 | before do 56 | input.attribute(:fruit, enum: %i[apple orange]) 57 | end 58 | 59 | it 'adds not required enum type' do 60 | expect(attribute_type_options).to include(required: false) 61 | end 62 | 63 | it 'adds attribute with enum type' do 64 | expect(attribute_type < GraphQL::Schema::Enum).to be true 65 | end 66 | end 67 | end 68 | end 69 | 70 | describe '#graphql_input_type' do 71 | subject(:graphql_input_type) { input.graphql_input_type } 72 | 73 | context 'with attributes' do 74 | before do 75 | input.attribute(:first_name, type: :string!) 76 | input.attribute(:last_name, type: :string!) 77 | end 78 | 79 | it 'returns graphql input with arguments' do 80 | expect(graphql_input_type.arguments.keys).to match_array(%w[firstName lastName]) 81 | end 82 | end 83 | 84 | context 'when attribute points to another graphql input' do 85 | module InputSpec # rubocop:disable Lint/LeakyConstantDeclaration, Lint/ConstantDefinitionInBlock 86 | class ChildModel # rubocop:disable Lint/LeakyConstantDeclaration 87 | include GraphqlRails::Model 88 | 89 | graphql do |c| 90 | c.attribute(:something) 91 | end 92 | 93 | graphql.input(:update) do |c| 94 | c.attribute(:name).type('String!') 95 | end 96 | end 97 | 98 | class ParentModel # rubocop:disable Lint/LeakyConstantDeclaration 99 | include GraphqlRails::Model 100 | 101 | graphql.input do |c| 102 | c.attribute(:child) 103 | .type("[#{InputSpec::ChildModel}!]") 104 | .subtype(:update) 105 | end 106 | end 107 | end 108 | 109 | let(:input) { InputSpec::ParentModel.graphql.input } 110 | 111 | it 'returns correct graphql input type' do 112 | expect(graphql_input_type.arguments['child'].type.unwrap) 113 | .to eq(InputSpec::ChildModel.graphql.input(:update).graphql_input_type) 114 | end 115 | end 116 | end 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/controller/handle_controller_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe GraphqlRails::Controller::HandleControllerError do 4 | describe '#call' do 5 | subject(:call) { described_class.new(error: error, controller: controller).call } 6 | 7 | let(:controller_class) do 8 | Class.new(GraphqlRails::Controller) 9 | end 10 | let(:controller) { controller_class.new(graphql_request) } 11 | let(:graphql_request) { GraphqlRails::Controller::Request.new(graphql_object, inputs, context) } 12 | let(:graphql_object) { double } 13 | let(:inputs) { { id: 1, firstName: 'John' } } 14 | let(:context) { double(add_error: nil) } # rubocop:disable RSpec/VerifiedDoubles 15 | 16 | let(:error) { StandardError.new('error') } 17 | 18 | before do 19 | allow(context).to receive(:[]=).with(:rendered_errors, true) 20 | allow(controller).to receive(:render).and_call_original 21 | end 22 | 23 | context 'when error is a GraphQL::ExecutionError' do 24 | let(:error) { GraphQL::ExecutionError.new('error') } 25 | 26 | it 'raises error and does not set "rendered_errors" flag in context', :aggregate_failures do 27 | expect { call }.to raise_error(error) 28 | expect(context).not_to have_received(:[]=).with(:rendered_errors, true) 29 | end 30 | end 31 | 32 | context 'when error is not a GraphQL::ExecutionError' do 33 | it 'renders SystemError' do 34 | call 35 | expect(controller).to have_received(:render).with(error: GraphqlRails::SystemError.new(error)) 36 | end 37 | 38 | it 'sets "rendered_errors" flag in context' do 39 | call 40 | expect(context).to have_received(:[]=).with(:rendered_errors, true) 41 | end 42 | end 43 | 44 | context 'when controller has custom error handler' do 45 | let(:handled_error_class) { Class.new(StandardError) } 46 | let(:error_class) { handled_error_class } 47 | let(:error) { error_class.new('error') } 48 | 49 | context 'when custom handler is a block' do 50 | let(:controller_class) do 51 | error_to_handle = handled_error_class 52 | 53 | Class.new(super()) do 54 | rescue_from(error_to_handle) { |error| render(error: error.message) } 55 | end 56 | end 57 | 58 | it 'renders error' do 59 | call 60 | expect(controller).to have_received(:render).with(error: error.message) 61 | end 62 | 63 | it 'sets "rendered_errors" flag in context' do 64 | call 65 | expect(context).to have_received(:[]=).with(:rendered_errors, true) 66 | end 67 | end 68 | 69 | context 'when custom handler is a method' do 70 | let(:controller_class) do 71 | error_to_handle = handled_error_class 72 | 73 | Class.new(super()) do 74 | rescue_from error_to_handle, with: :custom_handler 75 | 76 | def custom_handler 77 | render(error: 'custom error') 78 | end 79 | end 80 | end 81 | 82 | it 'renders error' do 83 | call 84 | expect(controller).to have_received(:render).with(error: 'custom error') 85 | end 86 | 87 | it 'sets "rendered_errors" flag in context' do 88 | call 89 | expect(context).to have_received(:[]=).with(:rendered_errors, true) 90 | end 91 | end 92 | 93 | context 'when custom handler raises error' do 94 | let(:controller_class) do 95 | error_to_handle = handled_error_class 96 | 97 | Class.new(super()) do 98 | rescue_from(error_to_handle) { |error| raise error } 99 | end 100 | end 101 | 102 | it 'renders SystemError' do 103 | call 104 | expect(controller).to have_received(:render).with(error: GraphqlRails::SystemError) 105 | end 106 | 107 | it 'sets "rendered_errors" flag in context' do 108 | call 109 | expect(context).to have_received(:[]=).with(:rendered_errors, true) 110 | end 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/graphql_rails/controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/hash_with_indifferent_access' 4 | require 'active_support/core_ext/hash' 5 | require 'graphql_rails/controller/configuration' 6 | require 'graphql_rails/controller/request' 7 | require 'graphql_rails/controller/action_hooks_runner' 8 | require 'graphql_rails/controller/log_controller_action' 9 | require 'graphql_rails/controller/handle_controller_error' 10 | require 'graphql_rails/errors/system_error' 11 | 12 | module GraphqlRails 13 | # base class for all graphql_rails controllers 14 | class Controller 15 | class << self 16 | attr_accessor :error_handler 17 | 18 | def inherited(subclass) 19 | super 20 | new_config = controller_configuration.dup_with(controller: subclass) 21 | subclass.instance_variable_set(:@controller_configuration, new_config) 22 | end 23 | 24 | def before_action(*args, **kwargs, &block) 25 | controller_configuration.add_action_hook(:before, *args, **kwargs, &block) 26 | end 27 | 28 | def around_action(*args, **kwargs, &block) 29 | controller_configuration.add_action_hook(:around, *args, **kwargs, &block) 30 | end 31 | 32 | def after_action(*args, **kwargs, &block) 33 | controller_configuration.add_action_hook(:after, *args, **kwargs, &block) 34 | end 35 | 36 | def action(action_name) 37 | controller_configuration.action(action_name) 38 | end 39 | 40 | def action_default 41 | controller_configuration.action_default 42 | end 43 | 44 | def model(*args) 45 | controller_configuration.model(*args) 46 | end 47 | 48 | def controller_configuration 49 | @controller_configuration ||= Controller::Configuration.new(self) 50 | end 51 | 52 | def rescue_from(*errors, with: nil, &block) 53 | Array(errors).each do |error| 54 | controller_configuration.add_error_handler(error, with: with, &block) 55 | end 56 | end 57 | end 58 | 59 | attr_reader :action_name 60 | 61 | def initialize(graphql_request) 62 | @graphql_request = graphql_request 63 | end 64 | 65 | def call(method_name) 66 | @action_name = method_name 67 | with_controller_action_logging do 68 | call_with_rendering 69 | graphql_request.object_to_return 70 | end 71 | ensure 72 | @action_name = nil 73 | end 74 | 75 | protected 76 | 77 | attr_reader :graphql_request 78 | 79 | def render(object_or_errors) 80 | errors = graphql_errors_from_render_params(object_or_errors) 81 | object = errors.empty? ? object_or_errors : nil 82 | 83 | graphql_request.errors = errors 84 | graphql_request.context[:rendered_errors] = true unless errors.empty? 85 | graphql_request.object_to_return = object 86 | end 87 | 88 | def params 89 | @params ||= graphql_request.params.deep_transform_keys { |key| key.to_s.underscore }.with_indifferent_access 90 | end 91 | 92 | private 93 | 94 | def call_with_rendering 95 | hooks_runner = ActionHooksRunner.new(action_name: action_name, controller: self, graphql_request: graphql_request) 96 | response = hooks_runner.call { public_send(action_name) } 97 | 98 | render response if graphql_request.no_object_to_return? 99 | rescue StandardError => e 100 | error_handler = self.class.error_handler || HandleControllerError 101 | error_handler.call(error: e, controller: self) 102 | end 103 | 104 | def graphql_errors_from_render_params(rendering_params) 105 | return [] unless rendering_params.is_a?(Hash) 106 | return [] if rendering_params.keys.count != 1 107 | 108 | errors = rendering_params[:error] || rendering_params[:errors] 109 | errors.is_a?(Enumerable) ? errors : Array(errors) 110 | end 111 | 112 | def with_controller_action_logging(&block) 113 | LogControllerAction.call( 114 | controller_name: self.class.name, 115 | action_name: action_name, 116 | params: params, 117 | graphql_request: graphql_request, 118 | &block 119 | ) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/attributes/type_parseable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | module Attributes 7 | RSpec.describe TypeParseable do 8 | subject(:parser) { parser_class.new(unparsed_type: unparsed_type) } 9 | 10 | let(:parser_class) do 11 | Class.new do 12 | include GraphqlRails::Attributes::TypeParseable 13 | 14 | attr_reader :unparsed_type 15 | 16 | def initialize(unparsed_type:) 17 | @unparsed_type = unparsed_type 18 | end 19 | end 20 | end 21 | 22 | let(:unparsed_type) { 'int!' } 23 | 24 | describe '#unwrapped_scalar_type' do 25 | subject(:unwrapped_scalar_type) { parser.unwrapped_scalar_type } 26 | 27 | context 'when "id" type is given' do 28 | let(:unparsed_type) { 'id!' } 29 | 30 | it { is_expected.to eq GraphQL::Types::ID } 31 | end 32 | 33 | context 'when "int" type is given' do 34 | let(:unparsed_type) { 'int!' } 35 | 36 | it { is_expected.to eq GraphQL::Types::Int } 37 | end 38 | 39 | context 'when "integer" type is given' do 40 | let(:unparsed_type) { 'integer!' } 41 | 42 | it { is_expected.to eq GraphQL::Types::Int } 43 | end 44 | 45 | context 'when "big_int" type is given' do 46 | let(:unparsed_type) { 'big_int!' } 47 | 48 | it { is_expected.to eq GraphQL::Types::BigInt } 49 | end 50 | 51 | context 'when "bigint" type is given' do 52 | let(:unparsed_type) { 'bigint!' } 53 | 54 | it { is_expected.to eq GraphQL::Types::BigInt } 55 | end 56 | 57 | context 'when "float" type is given' do 58 | let(:unparsed_type) { 'float!' } 59 | 60 | it { is_expected.to eq GraphQL::Types::Float } 61 | end 62 | 63 | context 'when "double" type is given' do 64 | let(:unparsed_type) { 'double!' } 65 | 66 | it { is_expected.to eq GraphQL::Types::Float } 67 | end 68 | 69 | context 'when "decimal" type is given' do 70 | let(:unparsed_type) { 'decimal!' } 71 | 72 | it { is_expected.to eq GraphQL::Types::Float } 73 | end 74 | 75 | context 'when "bool" type is given' do 76 | let(:unparsed_type) { 'bool!' } 77 | 78 | it { is_expected.to eq GraphQL::Types::Boolean } 79 | end 80 | 81 | context 'when "boolean" type is given' do 82 | let(:unparsed_type) { 'boolean!' } 83 | 84 | it { is_expected.to eq GraphQL::Types::Boolean } 85 | end 86 | 87 | context 'when "string" type is given' do 88 | let(:unparsed_type) { 'string!' } 89 | 90 | it { is_expected.to eq GraphQL::Types::String } 91 | end 92 | 93 | context 'when "str" type is given' do 94 | let(:unparsed_type) { 'str!' } 95 | 96 | it { is_expected.to eq GraphQL::Types::String } 97 | end 98 | 99 | context 'when "text" type is given' do 100 | let(:unparsed_type) { 'text!' } 101 | 102 | it { is_expected.to eq GraphQL::Types::String } 103 | end 104 | 105 | context 'when "date" type is given' do 106 | let(:unparsed_type) { 'date' } 107 | 108 | it { is_expected.to eq GraphQL::Types::ISO8601Date } 109 | end 110 | 111 | context 'when "time" type is given' do 112 | let(:unparsed_type) { 'time!' } 113 | 114 | it { is_expected.to eq GraphQL::Types::ISO8601DateTime } 115 | end 116 | 117 | context 'when "datetime" type is given' do 118 | let(:unparsed_type) { 'datetime!' } 119 | 120 | it { is_expected.to eq GraphQL::Types::ISO8601DateTime } 121 | end 122 | 123 | context 'when "date_time" type is given' do 124 | let(:unparsed_type) { '[DateTime!]' } 125 | 126 | it { is_expected.to eq GraphQL::Types::ISO8601DateTime } 127 | end 128 | 129 | context 'when "json" type is given' do 130 | let(:unparsed_type) { 'json!' } 131 | 132 | it { is_expected.to eq GraphQL::Types::JSON } 133 | end 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/decorator/relation_decorator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'active_record' 5 | 6 | module GraphqlRails 7 | module Decorator 8 | RSpec.describe RelationDecorator do 9 | subject(:relation_decorator) do 10 | described_class.new( 11 | relation: relation, 12 | decorator: decorator, 13 | decorator_args: decorator_args 14 | ) 15 | end 16 | 17 | shared_examples 'single decorated relation result' do |method_name| 18 | it 'returns instance of decorator' do 19 | expect(subject).to be_a(decorator) 20 | end 21 | 22 | context 'when relation returns no record' do 23 | before do 24 | allow(relation).to receive(method_name).and_return(nil) 25 | end 26 | 27 | it { is_expected.to be nil } 28 | end 29 | end 30 | 31 | let(:decorator) do 32 | Class.new(SimpleDelegator) do 33 | attr_reader :args 34 | 35 | def initialize(object, *args) 36 | super(object) 37 | @args = args 38 | end 39 | 40 | def self.name 41 | 'DummyDecorator' 42 | end 43 | end 44 | end 45 | 46 | let(:relation) do 47 | instance_double( 48 | ActiveRecord::Relation, 49 | find: record, 50 | second: record, 51 | last: record, 52 | find_by: record, 53 | first: record, 54 | to_a: [record, record2], 55 | where: inner_relation 56 | ) 57 | end 58 | 59 | let(:decorator_args) { ['arg1'] } 60 | let(:inner_relation) { instance_double(ActiveRecord::Relation) } 61 | let(:record) { OpenStruct.new(name: 'John') } 62 | let(:record2) { OpenStruct.new(name: 'Jack') } 63 | 64 | describe '#find' do 65 | subject(:find) { relation_decorator.find(1) } 66 | 67 | it_behaves_like 'single decorated relation result', :find 68 | end 69 | 70 | describe '#find_by' do 71 | subject(:find_by) { relation_decorator.find_by(id: 1) } 72 | 73 | it_behaves_like 'single decorated relation result', :find_by 74 | end 75 | 76 | describe '#second' do 77 | subject(:second) { relation_decorator.second } 78 | 79 | it_behaves_like 'single decorated relation result', :second 80 | end 81 | 82 | describe '#last' do 83 | subject(:last) { relation_decorator.last } 84 | 85 | it_behaves_like 'single decorated relation result', :last 86 | end 87 | 88 | describe '#first' do 89 | subject(:first) { relation_decorator.first } 90 | 91 | it_behaves_like 'single decorated relation result', :first 92 | 93 | context 'when `first` is called with items count' do 94 | subject(:first) { relation_decorator.first(2) } 95 | 96 | before do 97 | allow(relation).to receive(:first).with(2).and_return([record, record2]) 98 | end 99 | 100 | it 'returns decorated list' do 101 | expect(first).to all be_a(decorator) 102 | end 103 | end 104 | end 105 | 106 | describe '#where' do 107 | let(:where) { relation_decorator.where(name: 'John') } 108 | 109 | it 'returns instance of relation decorator' do 110 | expect(where).to be_a(described_class) 111 | end 112 | end 113 | 114 | describe '#find_each' do 115 | before do 116 | allow(relation).to receive(:find_each) 117 | .and_yield(record) 118 | .and_yield(record2) 119 | end 120 | 121 | it 'returns instance of relation decorator' do 122 | relation_decorator.find_each do |decorated_item| 123 | expect(decorated_item).to be_a(decorator) 124 | end 125 | end 126 | end 127 | 128 | describe '#to_a' do 129 | subject(:to_a) { relation_decorator.to_a } 130 | 131 | it 'returns list of decorated items' do 132 | expect(to_a).to all be_a(decorator) 133 | end 134 | 135 | it 'decorates instances with given arguments' do 136 | expect(to_a.first.args).to eq(decorator_args) 137 | end 138 | end 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /spec/lib/graphql_rails/model/direct_field_resolver_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module GraphqlRails 6 | module Model 7 | RSpec.describe DirectFieldResolver do 8 | subject(:call) do 9 | described_class.call( 10 | model: model_instance, 11 | attribute_config: attribute_config, 12 | method_keyword_arguments: method_keyword_arguments, 13 | graphql_context: graphql_context 14 | ) 15 | end 16 | 17 | let(:model_instance) { model.new } 18 | let(:model) do 19 | Class.new do 20 | include GraphqlRails::Model 21 | 22 | graphql do |c| 23 | c.description 'Used for test purposes' 24 | c.attribute :simple_property 25 | c.attribute :context_aware_property 26 | c.attribute :paginated_property, paginated: true 27 | c.attribute :with_args_property, permit: { arg1: :string } 28 | end 29 | 30 | def self.name 31 | 'DummyModel' 32 | end 33 | 34 | def simple_property 35 | 'simple value' 36 | end 37 | 38 | def context_aware_property 39 | graphql_context[:value] 40 | end 41 | 42 | def paginated_property 43 | 'paginated value' 44 | end 45 | 46 | def with_args_property(arg1: 'default') 47 | arg1 48 | end 49 | 50 | def with_graphql_context(context) 51 | @graphql_context = context 52 | result = yield 53 | @graphql_context = nil 54 | result 55 | end 56 | 57 | private 58 | 59 | def graphql_context 60 | @graphql_context || {} 61 | end 62 | end 63 | end 64 | 65 | let(:attribute_config) { model.graphql.attributes['simple_property'] } 66 | let(:graphql_context) { { value: 'context value' } } 67 | let(:method_keyword_arguments) { {} } 68 | 69 | describe '#call' do 70 | context 'when method is simple with no arguments and not paginated' do 71 | it 'uses simple_resolver' do 72 | expect(call).to eq 'simple value' 73 | end 74 | 75 | it 'does not call CallGraphqlModelMethod' do 76 | allow(CallGraphqlModelMethod).to receive(:call) 77 | call 78 | expect(CallGraphqlModelMethod).not_to have_received(:call) 79 | end 80 | end 81 | 82 | context 'when method needs graphql context' do 83 | let(:attribute_config) { model.graphql.attributes['context_aware_property'] } 84 | 85 | it 'passes the context correctly' do 86 | expect(call).to eq 'context value' 87 | end 88 | end 89 | 90 | context 'when method is paginated' do 91 | let(:attribute_config) { model.graphql.attributes['paginated_property'] } 92 | 93 | before do 94 | allow(CallGraphqlModelMethod).to receive(:call).and_call_original 95 | 96 | call 97 | end 98 | 99 | it 'falls back to CallGraphqlModelMethod and works correctly' do 100 | expect(CallGraphqlModelMethod).to have_received(:call).with( 101 | model: model_instance, attribute_config: attribute_config, 102 | method_keyword_arguments: method_keyword_arguments, graphql_context: graphql_context 103 | ) 104 | end 105 | end 106 | 107 | context 'when method has arguments' do 108 | let(:attribute_config) { model.graphql.attributes['with_args_property'] } 109 | let(:method_keyword_arguments) { { arg1: 'custom value' } } 110 | 111 | before do 112 | allow(CallGraphqlModelMethod).to receive(:call).and_call_original 113 | 114 | call 115 | end 116 | 117 | it 'falls back to CallGraphqlModelMethod and passes arguments correctly' do 118 | expect(CallGraphqlModelMethod).to have_received(:call).with( 119 | model: model_instance, attribute_config: attribute_config, 120 | method_keyword_arguments: method_keyword_arguments, graphql_context: graphql_context 121 | ) 122 | end 123 | end 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/graphql_rails/rspec_controller_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql_rails' 4 | 5 | module GraphqlRails 6 | # provides all helpers neccesary for testing graphql controllers. It is similar to rspec controller specs 7 | # 8 | # Adds 3 helper methods in to rspec test: 9 | # * mutation 10 | # * query 11 | # * result 12 | # `mutation` and `query`` methods are identical 13 | # 14 | # Usage: 15 | # it 'works' do 16 | # mutation(:createUser, params: { name: 'John'}, context: { current_user_id: 1 }) 17 | # expect(response).to be_successful? 18 | # expect(response)not_to be_failure? 19 | # expect(response.result).to be_a(User) 20 | # expect(response.errors).to be_empty 21 | # end 22 | module RSpecControllerHelpers 23 | # contains all details about testing response. Similar as in rspec controllers tests 24 | class Response 25 | def initialize(request) 26 | @request = request 27 | end 28 | 29 | def result 30 | request.object_to_return 31 | end 32 | 33 | def errors 34 | request.errors 35 | end 36 | 37 | def success? 38 | request.errors.empty? 39 | end 40 | 41 | def successful? 42 | success? 43 | end 44 | 45 | def failure? 46 | !success? 47 | end 48 | 49 | def controller 50 | request.controller 51 | end 52 | 53 | def action_name 54 | request.action_name 55 | end 56 | 57 | private 58 | 59 | attr_reader :request 60 | end 61 | 62 | # instance which has similar behavior as 63 | class FakeContext 64 | extend Forwardable 65 | 66 | attr_reader :schema 67 | 68 | def_delegators :@provided_values, :[], :[]=, :to_h, :key?, :fetch 69 | 70 | def initialize(values:, schema:) 71 | @errors = [] 72 | @provided_values = values 73 | @schema = schema 74 | end 75 | 76 | def add_error(error) 77 | @errors << error 78 | end 79 | end 80 | 81 | # Custom error handler to avoid raising errors in controller tests 82 | class TestControllerErrorHandler < GraphqlRails::Controller::HandleControllerError 83 | private 84 | 85 | def handle_graphql_execution_error(error) 86 | render(error: error) 87 | end 88 | end 89 | 90 | class SingleControllerSchemaBuilder 91 | attr_reader :controller 92 | 93 | def initialize(controller) 94 | @controller = controller 95 | end 96 | 97 | def call 98 | config = controller.controller_configuration 99 | action_by_name = config.action_by_name 100 | controller_path = controller.name.underscore.sub(/_controller\Z/, '') 101 | 102 | router = Router.draw do 103 | action_by_name.keys.each do |action_name| 104 | query("#{action_name}_test", to: "#{controller_path}##{action_name}", group: :graphql_rspec_helpers) 105 | end 106 | end 107 | 108 | router.graphql_schema(:graphql_rspec_helpers) 109 | end 110 | end 111 | 112 | # controller request object more suitable for testing 113 | class Request < GraphqlRails::Controller::Request 114 | attr_reader :controller, :action_name 115 | 116 | def initialize(params, context, controller: nil, action_name: nil) 117 | inputs = params || {} 118 | inputs = inputs.merge(lookahead: ::GraphQL::Execution::Lookahead::NullLookahead.new) 119 | @controller = controller 120 | @action_name = action_name 121 | super(nil, inputs, context) 122 | end 123 | end 124 | 125 | def query(query_name, params: {}, context: {}) 126 | schema_builder = SingleControllerSchemaBuilder.new(described_class) 127 | context_object = FakeContext.new(values: context, schema: schema_builder.call) 128 | request = Request.new(params, context_object, controller: described_class, action_name: query_name) 129 | original_error_handler = described_class.error_handler 130 | 131 | described_class.error_handler = TestControllerErrorHandler 132 | described_class.new(request).call(query_name) 133 | described_class.error_handler = original_error_handler 134 | 135 | @response = Response.new(request) 136 | @response 137 | end 138 | 139 | def mutation(*args, **kwargs) 140 | query(*args, **kwargs) 141 | end 142 | 143 | def response 144 | @response 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/graphql_rails/controller/action_configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/string/filters' 4 | require 'graphql_rails/attributes' 5 | require 'graphql_rails/input_configurable' 6 | require 'graphql_rails/errors/error' 7 | 8 | module GraphqlRails 9 | class Controller 10 | # stores all graphql_rails controller specific config 11 | class ActionConfiguration 12 | class MissingConfigurationError < GraphqlRails::Error; end 13 | class DeprecatedDefaultModelError < GraphqlRails::Error; end 14 | 15 | include InputConfigurable 16 | 17 | attr_reader :attributes, :pagination_options, :name, :controller, :defined_at 18 | 19 | def initialize_copy(other) 20 | super 21 | @attributes = other.instance_variable_get(:@attributes).dup.transform_values(&:dup) 22 | @action_options = other.instance_variable_get(:@action_options).dup.transform_values(&:dup) 23 | @pagination_options = other.instance_variable_get(:@pagination_options)&.dup&.transform_values(&:dup) 24 | end 25 | 26 | def initialize(name:, controller:) 27 | @name = name 28 | @controller = controller 29 | @attributes = {} 30 | @action_options = {} 31 | end 32 | 33 | def dup_with(name:, controller:, defined_at:) 34 | dup.tap do |new_action| 35 | new_action.instance_variable_set(:@defined_at, defined_at) 36 | new_action.instance_variable_set(:@name, name) 37 | new_action.instance_variable_set(:@controller, controller) 38 | end 39 | end 40 | 41 | def options(action_options = nil) 42 | @input_attribute_options ||= {} 43 | return @input_attribute_options if action_options.nil? 44 | 45 | @input_attribute_options[:input_format] = action_options[:input_format] if action_options[:input_format] 46 | 47 | self 48 | end 49 | 50 | def paginated(*args) 51 | @return_type = nil 52 | super 53 | end 54 | 55 | def description(new_description = nil) 56 | if new_description 57 | @description = new_description 58 | self 59 | else 60 | @description 61 | end 62 | end 63 | 64 | def returns(custom_return_type) 65 | @return_type = nil 66 | @custom_return_type = custom_return_type 67 | self 68 | end 69 | 70 | def model(model_name = nil) 71 | if model_name 72 | @model = model_name 73 | self 74 | else 75 | @model || raise_missing_config_error 76 | end 77 | end 78 | 79 | def returns_single(required: true) 80 | model_name = model.to_s 81 | model_name = "#{model_name}!" if required 82 | 83 | returns(model_name) 84 | end 85 | 86 | def returns_list(required_inner: true, required_list: true) 87 | model_name = model.to_s 88 | model_name = "#{model_name}!" if required_inner 89 | list_name = "[#{model_name}]" 90 | list_name = "#{list_name}!" if required_list 91 | 92 | returns(list_name) 93 | end 94 | 95 | def return_type 96 | @return_type ||= build_return_type 97 | end 98 | 99 | def type_parser 100 | @type_parser ||= Attributes::TypeParser.new(custom_return_type, paginated: paginated?) 101 | end 102 | 103 | private 104 | 105 | attr_reader :custom_return_type 106 | 107 | def build_return_type 108 | return raise_deprecation_error if custom_return_type.nil? 109 | 110 | if paginated? 111 | type_parser.graphql_model ? type_parser.graphql_model.graphql.connection_type : nil 112 | else 113 | type_parser.graphql_type 114 | end 115 | end 116 | 117 | def raise_deprecation_error 118 | message = \ 119 | 'Default return types are deprecated. ' \ 120 | "You need to manually set something like `action(:#{name}).returns('#{suggested_model_name}')`" 121 | 122 | full_backtrace = ([defined_at] + caller).compact 123 | raise DeprecatedDefaultModelError, message, full_backtrace 124 | end 125 | 126 | def suggested_model_name 127 | controller&.name.to_s.demodulize.sub(/Controller$/, '').singularize 128 | end 129 | 130 | def raise_missing_config_error 131 | error_message = \ 132 | 'Default model for controller is not defined. To do so add `model(YourModel)`' 133 | 134 | raise MissingConfigurationError, error_message 135 | end 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/graphql_rails/router.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/string/inflections' 4 | 5 | require 'graphql_rails/router/schema_builder' 6 | require 'graphql_rails/router/mutation_route' 7 | require 'graphql_rails/router/query_route' 8 | require 'graphql_rails/router/event_route' 9 | require 'graphql_rails/router/resource_routes_builder' 10 | 11 | module GraphqlRails 12 | # graphql router that mimics Rails.application.routes 13 | class Router 14 | RAW_ACTION_NAMES = %i[ 15 | use rescue_from query_analyzer instrument cursor_encoder default_max_page_size tracer trace_with 16 | before_type_error after_type_error 17 | ].freeze 18 | 19 | def self.draw(&block) 20 | new.tap do |router| 21 | router.instance_eval(&block) 22 | end 23 | end 24 | 25 | attr_reader :routes, :namespace_name, :raw_graphql_actions, :scope_names 26 | 27 | def initialize(module_name: '', group_names: [], scope_names: []) 28 | @scope_names = scope_names 29 | @module_name = module_name 30 | @group_names = group_names 31 | @routes ||= Set.new 32 | @raw_graphql_actions ||= [] 33 | @graphql_schema = {} 34 | end 35 | 36 | def group(*group_names, &block) 37 | scoped_router = router_with(group_names: group_names) 38 | scoped_router.instance_eval(&block) 39 | routes.merge(scoped_router.routes) 40 | end 41 | 42 | def scope(new_scope_name = nil, **options, &block) 43 | scoped_router = router_with_scope_params(new_scope_name, **options) 44 | scoped_router.instance_eval(&block) 45 | routes.merge(scoped_router.routes) 46 | end 47 | 48 | def namespace(namespace_name, &block) 49 | scope(path: namespace_name, module: namespace_name, &block) 50 | end 51 | 52 | def resources(name, **options, &block) 53 | builder_options = full_route_options(options) 54 | routes_builder = ResourceRoutesBuilder.new(name, **builder_options) 55 | routes_builder.instance_eval(&block) if block 56 | routes.merge(routes_builder.routes) 57 | end 58 | 59 | def query(name, **options) 60 | routes << build_route(QueryRoute, name, **options) 61 | end 62 | 63 | def mutation(name, **options) 64 | routes << build_route(MutationRoute, name, **options) 65 | end 66 | 67 | def event(name, **options) 68 | routes << build_route(EventRoute, name, **options) 69 | end 70 | 71 | RAW_ACTION_NAMES.each do |action_name| 72 | define_method(action_name) do |*args, **kwargs, &block| 73 | add_raw_action(action_name, *args, **kwargs, &block) 74 | end 75 | end 76 | 77 | def graphql_schema(group = nil) 78 | @graphql_schema[group&.to_sym] ||= SchemaBuilder.new( 79 | queries: routes.select(&:query?), 80 | mutations: routes.select(&:mutation?), 81 | events: routes.select(&:event?), 82 | raw_actions: raw_graphql_actions, 83 | group: group 84 | ).call 85 | end 86 | 87 | def reload_schema 88 | @graphql_schema.clear 89 | end 90 | 91 | private 92 | 93 | attr_reader :module_name, :group_names 94 | 95 | def router_with_scope_params(new_scope_name, **options) 96 | new_scope_name ||= options[:path] 97 | 98 | full_module_name = [module_name, options[:module]].select(&:present?).join('/') 99 | full_scope_names = [*scope_names, new_scope_name].select(&:present?) 100 | 101 | router_with(module_name: full_module_name, scope_names: full_scope_names) 102 | end 103 | 104 | def router_with(new_router_options = {}) 105 | full_options = default_router_options.merge(new_router_options) 106 | 107 | self.class.new(**full_options) 108 | end 109 | 110 | def default_router_options 111 | { module_name: module_name, group_names: group_names, scope_names: scope_names } 112 | end 113 | 114 | def add_raw_action(name, *args, **kwargs, &block) 115 | raw_graphql_actions << { name: name, args: args, kwargs: kwargs, block: block } 116 | end 117 | 118 | def build_route(route_builder, name, **options) 119 | route_builder.new(name, **full_route_options(options)) 120 | end 121 | 122 | def full_route_options(extra_options) 123 | extra_groups = Array(extra_options[:group]) + Array(extra_options[:groups]) 124 | extra_options = extra_options.except(:group, :groups) 125 | groups = (group_names + extra_groups).uniq 126 | 127 | default_route_options.merge(extra_options).merge(groups: groups) 128 | end 129 | 130 | def default_route_options 131 | { module: module_name, on: :member, scope_names: scope_names } 132 | end 133 | end 134 | end 135 | --------------------------------------------------------------------------------