diff --git a/.gitignore b/.gitignore index 7e6a5562..df8998ad 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,8 @@ README.md.* /spec/dummy/tmp/ /Gemfile.lock *.gemfile.lock -/*.sqlite3 +*.sqlite3 +*.sqlite3-journal /spec/dummy/db/development.sqlite3 /spec/dummy/db/test.sqlite3 /*.gem diff --git a/README.md b/README.md index b498a824..dbd8fbc6 100644 --- a/README.md +++ b/README.md @@ -12,25 +12,34 @@ GraphQL interface on top of the [Devise Token Auth](https://github.com/lynndylan * [Table of Contents](#table-of-contents) * [Introduction](#introduction) * [Installation](#installation) + * [Running the Generator](#running-the-generator) + * [Mounting the Schema in a Separate Route](#mounting-the-schema-in-a-separate-route) + * [Mounting Operations in Your Own Schema](#mounting-operations-in-your-own-schema) + * [Important](#important) * [Usage](#usage) - * [Mounting Routes manually](#mounting-routes-manually) - * [Available Operations](#available-operations) + * [Mounting Auth Schema on a Separate Route](#mounting-auth-schema-on-a-separate-route) + * [Mounting Operations Into Your Own Schema](#mounting-operations-into-your-own-schema) + * [Available Mount Options](#available-mount-options) + * [Available Operations](#available-operations) * [Configuring Model](#configuring-model) * [Customizing Email Templates](#customizing-email-templates) * [I18n](#i18n) * [Authenticating Controller Actions](#authenticating-controller-actions) + * [Authenticate Before Reaching Your GQL Schema](#authenticate-before-reaching-your-gql-schema) + * [Authenticate in Your GQL Schema](#authenticate-in-your-gql-schema) * [Making Requests](#making-requests) * [Mutations](#mutations) * [Queries](#queries) * [More Configuration Options](#more-configuration-options) * [Devise Token Auth Initializer](#devise-token-auth-initializer) * [Devise Initializer](#devise-initializer) + * [GraphQL Interpreter](#graphql-interpreter) * [Using Alongside Standard Devise](#using-alongside-standard-devise) * [Future Work](#future-work) * [Contributing](#contributing) * [License](#license) - + @@ -54,13 +63,11 @@ gem 'graphql_devise' ``` And then execute: +```bash +$ bundle +``` - $ bundle - -Next, you need to run the generator: - - $ bundle exec rails generate graphql_devise:install - +### Running the Generator Graphql Devise generator will execute `Devise` and `Devise Token Auth` generators for you. These will make the required changes for the gems to work correctly. All configurations for [Devise](https://github.com/plataformatec/devise) and @@ -68,6 +75,11 @@ work correctly. All configurations for [Devise](https://github.com/plataformatec so you can read the docs there to customize your options. Configurations are done via initializer files as usual, one per gem. +#### Mounting the Schema in a Separate Route +```bash +$ bundle exec rails generate graphql_devise:install +``` + The generator accepts 2 params: `user_class` and `mount_path`. The params will be used to mount the route in `config/routes.rb`. For instance the executing: @@ -87,14 +99,32 @@ Will do the following: `Admin` could be any model name you are going to be using for authentication, and `api/auth` could be any mount path you would like to use for auth. -**Important:** Remember this gem mounts a completely separate GraphQL schema on a separate controller in the route +#### Mounting Operations in Your Own Schema +Now you can provide to the generator an option specifying +the name of your GQL schema. Doing this will skip the insertion of the mount method in the +routes file and will also add our `SchemaPlugin` to the specified schema. `user_class` param is still optional (`Admin`) in the following example. + +```bash +$ bundle exec rails g graphql_devise:install Admin --mount MySchema +``` + +### Important +Remember that by default this gem mounts a completely separate GraphQL schema on a separate controller in the route provided by the `at` option in the `mount_graphql_devise_for` method in the `config/routes.rb` file. If no `at` -option is provided, the route will be `/graphql_auth`. This has no effect on your own application schema. -More on this in the next section. +option is provided, the route will be `/graphql_auth`. + +**Starting with `v0.12.0`** you can opt-in to load this gem's queries and mutations into your +own application's schema. You can actually mount a resource's auth schema in a separate route +and in your app's schema at the same time, but that's probably not a common scenario. More on +this in the next section. ## Usage -### Mounting Routes manually -Routes can be added using the initializer or manually. +### Mounting Auth Schema on a Separate Route +The generator can do this step for you by default. Remember now you can mount this gem's +auth operations into your own schema as described in [this section](#mounting-operations-into-your-own-schema). + + +Routes can be added using the generator or manually. You can mount this gem's GraphQL auth schema in your routes file like this: ```ruby @@ -120,11 +150,83 @@ Rails.application.routes.draw do ) end ``` +The second argument of the `mount_graphql_devise` method is a hash of options where you can +customize how the queries and mutations are mounted into the schema. For a list of available +options go [here](#available-mount-options) + +### Mounting Operations Into Your Own Schema +Starting with `v0.12.0` you can now mount the GQL operations provided by this gem into your +app's main schema. + +```ruby +# app/graphql/dummy_schema.rb + +class DummySchema < GraphQL::Schema + # It's important that this line goes before setting the query and mutation type on your + # schema in graphql versions < 1.10.0 + use GraphqlDevise::SchemaPlugin.new( + query: Types::QueryType, + mutation: Types::MutationType, + resource_loaders: [ + GraphqlDevise::ResourceLoader.new('User', only: [:login, :confirm_account]) + ] + ) + + mutation(Types::MutationType) + query(Types::QueryType) +end +``` +The example above describes just one of the possible scenarios you might need. +The second argument of the `GraphqlDevise::ResourceLoader` initializer is a hash of +options where you can customize how the queries and mutations are mounted into the schema. +For a list of available options go [here](#available-mount-options). + +It's important to use the plugin in your schema before assigning the mutation and query type to +it in graphql versions `< 1.10.0`. Otherwise the auth operations won't be available. + +You can provide as many resource loaders as you need to the `resource_loaders` option, and each +of those will be loaded into your schema. These are the options you can initialize the +`SchemaPlugin` with: + +1. `query`: This param is mandatory unless you skip all queries via the resource loader +options. This should be the same `QueryType` you provide to the `query` method +in your schema. +1. `mutation`: This param mandatory unless you skip all mutations via the resource loader +options. This should be the same `MutationType` you provide to the `mutation` method +in your schema. +1. `resource_loaders`: This is an optional array of `GraphqlDevise::ResourceLoader` instances. +Here is where you specify the operations that you want to load into your app's schema. +If no loader is provided, no operations will be added to your schema, but you will still be +able to authenticate queries and mutations selectively. More on this in the controller +authentication [section](#authenticating-controller-actions). +1. `authenticate_default`: This is a boolean value which is `true` by default. This value +defines what is the default behavior for authentication in your schema fields. `true` means +every root level field requires authentication unless specified otherwise using the +`authenticate: false` option on the field. `false` means your root level fields won't require +authentication unless specified otherwise using the `authenticate: true` option on the field. +1. `unauthenticated_proc`: This param is optional. Here you can provide a proc that receives +one argument (field name) and is called whenever a field that requires authentication +is called without an authenticated resource. By default a `GraphQL::ExecutionError` will be +raised if authentication fails. This will provide a GQL like error message on the response. + +### Available Mount Options +Both the `mount_graphql_devise_for` method and the `GraphqlDevise::ResourceLoader` class +take the same options. So, wether you decide to mount this gem in a separate route +from your main application's schema or you use our `GraphqlDevise::SchemaPlugin` to load +this gem's auth operation into your schema, these are the options you can provide as a hash. -Here are the options for the mount method: +```ruby +# Using the mount method in your config/routes.rb file +mount_graphql_devise_for('User', {}) + +# Providing options to a GraphqlDevise::ResourceLoader +GraphqlDevise::ResourceLoader.new('User', {}) +``` -1. `at`: Route where the GraphQL schema will be mounted on the Rails server. In this example your API will have these two routes: `POST /api/v1/graphql_auth` and `GET /api/v1/graphql_auth`. -If this option is not specified, the schema will be mounted at `/graphql_auth`. +1. `at`: Route where the GraphQL schema will be mounted on the Rails server. +In [this example](#mounting-auth-schema-on-a-separate-route) your API will have +these two routes: `POST /api/v1/graphql_auth` and `GET /api/v1/graphql_auth`. +If this option is not specified, the schema will be mounted at `/graphql_auth`. **This option only works if you are using the mount method.** 1. `operations`: Specifying this is optional. Here you can override default behavior by specifying your own mutations and queries for every GraphQL operation. Check available operations in this file [mutations](https://github.com/graphql-devise/graphql_devise/blob/b5985036e01ea064e43e457b4f0c8516f172471c/lib/graphql_devise/rails/routes.rb#L19) @@ -163,7 +265,7 @@ or [base resolver](https://github.com/graphql-devise/graphql_devise/blob/master/ respectively, to take advantage of some of the methods provided by devise just like with `devise_scope` -#### Available Operations +### Available Operations The following is a list of the symbols you can provide to the `operations`, `skip` and `only` options of the mount method: ```ruby :login @@ -175,7 +277,6 @@ The following is a list of the symbols you can provide to the `operations`, `ski :check_password_token ``` - ### Configuring Model Just like with Devise and DTA, you need to include a module in your authenticatable model, so with our example, your user model will have to look like this: @@ -216,6 +317,9 @@ Keep in mind that if your app uses multiple locales, you should set the `I18n.lo ### Authenticating Controller Actions Just like with Devise or DTA, you will need to authenticate users in your controllers. +For this you have two alternatives. + +#### Authenticate Before Reaching Your GQL Schema For this you need to call `authenticate_!` in a before_action hook of your controller. In our example our model is `User`, so it would look like this: ```ruby @@ -234,6 +338,62 @@ end The install generator can do this for you because it executes DTA installer. See [Installation](#Installation) for details. +If authentication fails for the request for whatever reason, execution of the request is halted +and an error is returned in a REST format as the request never reaches your GQL schema. + +#### Authenticate in Your GQL Schema +For this you will need to add the `GraphqlDevise::SchemaPlugin` to your schema as described +[here](#mounting-operations-into-your-own-schema) and also set the authenticated resource +in a `before_action` hook. + +```ruby +# app/controllers/my_controller.rb + +class MyController < ApplicationController + include GraphqlDevise::Concerns::SetUserByToken + + before_action -> { set_resource_by_token(:user) } + + def my_action + render json: DummySchema.execute(params[:query], context: graphql_context) + end +end + +# @resource.to_s.underscore.tr('/', '_').to_sym +``` +The `set_resource_by_token` method receives a symbol identifying the resource you are trying +to authenticate. So if you mounted the `'User'` resource, the symbol is `:user`. You can use +this snippet to find the symbol for more complex scenarios +`resource_klass.to_s.underscore.tr('/', '_').to_sym`. + +The `graphql_context` method is simply a helper method that returns a hash like this +```ruby +{ current_resource: @resource, controller: self } +``` +These are the two values the gem needs to check if a user is authenticated and to perform +other auth operations. All `set_resource_by_token` does is set the `@resource` variable if +the provided authentication headers are valid. If authentication fails, resource will be `nil` +and this is how `GraphqlDevise::SchemaPlugin` knows if a user is authenticated or not in +each query. + +Please note that by using this mechanism your GQL schema will be in control of what queries are +restricted to authenticated users and you can only do this at the root level fields of your GQL +schema. Configure the plugin as explained [here](#mounting-operations-into-your-own-schema) +so this can work. + +In you main app's schema this is how you might specify if a field needs to be authenticated or not: +```ruby +module Types + class QueryType < Types::BaseObject + # user field used the default set in the Plugin's initializer + field :user, resolver: Resolvers::UserShow + # this field will never require authentication + field :public_field, String, null: false, authenticate: false + # this field requires authentication + field :private_field, String, null: false, authenticate: true + end +end +``` ### Making Requests Here is a list of the available mutations and queries assuming your mounted model is `User`. @@ -308,6 +468,14 @@ In this section the most important configurations will be highlighted. **Note:** Remember this gem adds a layer on top of Devise, so some configurations might not apply. +### GraphQL Interpreter +GraphQL-Ruby `>= 1.9.0` includes a new runtime module which you may use for your schema. +Eventually, it will become the default. You can read more about it +[here](https://graphql-ruby.org/queries/interpreter). + +This gem supports schemas using the interpreter and it is recommended as it introduces several +improvements which focus mainly on performance. + ### Using Alongside Standard Devise The DeviseTokenAuth gem allows experimental use of the standard Devise gem to be configured at the same time, for more information you can check [this answer here](https://github.com/lynndylanhurley/devise_token_auth/blob/2a32f18ccce15638a74e72f6cfde5cf15a808d3f/docs/faq.md#can-i-use-this-gem-alongside-standard-devise). @@ -318,7 +486,6 @@ standard Devise templates. ## Future Work We will continue to improve the gem and add better docs. -1. Add mount option that will create a separate schema for the mounted resource. 1. Make sure this gem can correctly work alongside DTA and the original Devise gem. 1. Improve DOCS. 1. Add support for unlockable and other Devise modules. diff --git a/app/controllers/graphql_devise/application_controller.rb b/app/controllers/graphql_devise/application_controller.rb index 427458d3..9df628d9 100644 --- a/app/controllers/graphql_devise/application_controller.rb +++ b/app/controllers/graphql_devise/application_controller.rb @@ -1,8 +1,7 @@ module GraphqlDevise - class ApplicationController < DeviseTokenAuth::ApplicationController - private - - def verify_authenticity_token - end + ApplicationController = if Rails::VERSION::MAJOR >= 5 + Class.new(ActionController::API) + else + Class.new(ActionController::Base) end end diff --git a/app/controllers/graphql_devise/concerns/set_user_by_token.rb b/app/controllers/graphql_devise/concerns/set_user_by_token.rb index 0c0481f8..03c4a1e8 100644 --- a/app/controllers/graphql_devise/concerns/set_user_by_token.rb +++ b/app/controllers/graphql_devise/concerns/set_user_by_token.rb @@ -1,5 +1,28 @@ module GraphqlDevise module Concerns SetUserByToken = DeviseTokenAuth::Concerns::SetUserByToken + + SetUserByToken.module_eval do + attr_accessor :client_id, :token, :resource + + alias_method :set_resource_by_token, :set_user_by_token + + def graphql_context + { + current_resource: @resource, + controller: self + } + end + + def build_redirect_headers(access_token, client, redirect_header_options = {}) + { + DeviseTokenAuth.headers_names[:"access-token"] => access_token, + DeviseTokenAuth.headers_names[:client] => client, + :config => params[:config], + :client_id => client, + :token => access_token + }.merge(redirect_header_options) + end + end end end diff --git a/app/controllers/graphql_devise/graphql_controller.rb b/app/controllers/graphql_devise/graphql_controller.rb index 6239b77c..9b18e3c3 100644 --- a/app/controllers/graphql_devise/graphql_controller.rb +++ b/app/controllers/graphql_devise/graphql_controller.rb @@ -2,6 +2,8 @@ module GraphqlDevise class GraphqlController < ApplicationController + include GraphqlDevise::Concerns::SetUserByToken + def auth result = if params[:_json] GraphqlDevise::Schema.multiplex( diff --git a/app/helpers/graphql_devise/mailer_helper.rb b/app/helpers/graphql_devise/mailer_helper.rb index f11fd03e..3e3a7dcf 100644 --- a/app/helpers/graphql_devise/mailer_helper.rb +++ b/app/helpers/graphql_devise/mailer_helper.rb @@ -1,7 +1,7 @@ module GraphqlDevise module MailerHelper def confirmation_query(resource_name:, token:, redirect_url:) - name = "#{resource_name.camelize(:lower)}ConfirmAccount" + name = "#{resource_name.underscore.tr('/', '_').camelize(:lower)}ConfirmAccount" raw = <<-GRAPHQL query($token:String!,$redirectUrl:String!){ #{name}(confirmationToken:$token,redirectUrl:$redirectUrl){ @@ -17,7 +17,7 @@ def confirmation_query(resource_name:, token:, redirect_url:) end def password_reset_query(token:, redirect_url:, resource_name:) - name = "#{resource_name.camelize(:lower)}CheckPasswordToken" + name = "#{resource_name.underscore.tr('/', '_').camelize(:lower)}CheckPasswordToken" raw = <<-GRAPHQL query($token:String!,$redirectUrl:String!){ #{name}(resetPasswordToken:$token,redirectUrl:$redirectUrl){ diff --git a/config/routes.rb b/config/routes.rb index c957aca8..554da454 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,5 +12,7 @@ GraphqlDevise::Schema.query(GraphqlDevise::Types::QueryType) GraphqlDevise.load_schema + + Devise.mailer.helper(GraphqlDevise::MailerHelper) end end diff --git a/lib/generators/graphql_devise/install_generator.rb b/lib/generators/graphql_devise/install_generator.rb index ee5305eb..af4b59b9 100644 --- a/lib/generators/graphql_devise/install_generator.rb +++ b/lib/generators/graphql_devise/install_generator.rb @@ -5,6 +5,8 @@ class InstallGenerator < ::Rails::Generators::Base argument :user_class, type: :string, default: 'User' argument :mount_path, type: :string, default: 'auth' + class_option :mount, type: :string, default: 'separate_route' + def execute_devise_installer generate 'devise:install' end @@ -29,15 +31,20 @@ def execute_dta_installer def mount_resource_route routes_file = 'config/routes.rb' - gem_route = "mount_graphql_devise_for '#{user_class}', at: '#{mount_path}'" dta_route = "mount_devise_token_auth_for '#{user_class}', at: '#{mount_path}'" - if file_contains_str?(routes_file, gem_route) + if options['mount'] != 'separate_route' gsub_file(routes_file, /^\s+#{Regexp.escape(dta_route + "\n")}/i, '') - - say_status('skipped', "Routes already exist for #{user_class} at #{mount_path}") else - gsub_file(routes_file, /#{Regexp.escape(dta_route)}/i, gem_route) + gem_route = "mount_graphql_devise_for '#{user_class}', at: '#{mount_path}'" + + if file_contains_str?(routes_file, gem_route) + gsub_file(routes_file, /^\s+#{Regexp.escape(dta_route + "\n")}/i, '') + + say_status('skipped', "Routes already exist for #{user_class} at #{mount_path}") + else + gsub_file(routes_file, /#{Regexp.escape(dta_route)}/i, gem_route) + end end end @@ -65,6 +72,22 @@ def set_change_headers_on_each_request_false ) end + def mount_in_schema + return if options['mount'] == 'separate_route' + + inject_into_file "app/graphql/#{options['mount'].underscore}.rb", after: "< GraphQL::Schema\n" do +<<-RUBY + use GraphqlDevise::SchemaPlugin.new( + query: Types::QueryType, + mutation: Types::MutationType, + resource_loaders: [ + GraphqlDevise::ResourceLoader.new('#{user_class}'), + ] + ) +RUBY + end + end + private def file_contains_str?(filename, regex_str) diff --git a/lib/graphql_devise.rb b/lib/graphql_devise.rb index ca44f3d1..9f7afb53 100644 --- a/lib/graphql_devise.rb +++ b/lib/graphql_devise.rb @@ -18,12 +18,21 @@ def self.load_schema @schema_loaded = true end - def self.mount_resource(resource) - @mounted_resources << resource + def self.resource_mounted?(mapping_name) + @mounted_resources.include?(mapping_name) end - def self.resource_mounted?(resource) - @mounted_resources.include?(resource) + def self.mount_resource(mapping_name) + @mounted_resources << mapping_name + end + + def self.add_mapping(mapping_name, resource) + return if Devise.mappings.key?(mapping_name) + + Devise.add_mapping( + mapping_name.to_s.pluralize.to_sym, + module: :devise, class_name: resource + ) end end @@ -47,3 +56,6 @@ def self.resource_mounted?(resource) require 'graphql_devise/mount_method/options_validator' require 'graphql_devise/mount_method/operation_preparer' require 'graphql_devise/mount_method/operation_sanitizer' + +require 'graphql_devise/resource_loader' +require 'graphql_devise/schema_plugin' diff --git a/lib/graphql_devise/mount_method/operation_preparer.rb b/lib/graphql_devise/mount_method/operation_preparer.rb index d3eabd1c..3089acf7 100644 --- a/lib/graphql_devise/mount_method/operation_preparer.rb +++ b/lib/graphql_devise/mount_method/operation_preparer.rb @@ -8,10 +8,10 @@ module GraphqlDevise module MountMethod class OperationPreparer - def initialize(resource:, selected_operations:, preparer:, custom:, additional_operations:) + def initialize(mapping_name:, selected_operations:, preparer:, custom:, additional_operations:) @selected_operations = selected_operations @preparer = preparer - @mapping_name = resource.underscore.tr('/', '_') + @mapping_name = mapping_name @custom = custom @additional_operations = additional_operations end diff --git a/lib/graphql_devise/mount_method/operation_preparers/resource_name_setter.rb b/lib/graphql_devise/mount_method/operation_preparers/resource_name_setter.rb index 56e18500..c1a03abc 100644 --- a/lib/graphql_devise/mount_method/operation_preparers/resource_name_setter.rb +++ b/lib/graphql_devise/mount_method/operation_preparers/resource_name_setter.rb @@ -7,7 +7,7 @@ def initialize(name) end def call(operation) - operation.instance_variable_set(:@resource_name, @name.to_sym) + operation.instance_variable_set(:@resource_name, @name) operation end diff --git a/lib/graphql_devise/mutations/sign_up.rb b/lib/graphql_devise/mutations/sign_up.rb index 6fa61553..4fb98440 100644 --- a/lib/graphql_devise/mutations/sign_up.rb +++ b/lib/graphql_devise/mutations/sign_up.rb @@ -35,7 +35,7 @@ def resolve(confirm_success_url: nil, **attrs) { authenticatable: resource } else - clean_up_passwords(resource) + resource.try(:clean_up_passwords) raise_user_error_list( I18n.t('graphql_devise.registration_failed'), errors: resource.errors.full_messages @@ -48,10 +48,6 @@ def resolve(confirm_success_url: nil, **attrs) def build_resource(attrs) resource_class.new(attrs) end - - def clean_up_passwords(resource) - controller.send(:clean_up_passwords, resource) - end end end end diff --git a/lib/graphql_devise/rails/routes.rb b/lib/graphql_devise/rails/routes.rb index e4b9a1c9..120c72c0 100644 --- a/lib/graphql_devise/rails/routes.rb +++ b/lib/graphql_devise/rails/routes.rb @@ -1,80 +1,13 @@ module ActionDispatch::Routing class Mapper - DEVISE_OPERATIONS = [ - :sessions, - :registrations, - :passwords, - :confirmations, - :omniauth_callbacks, - :unlocks, - :invitations - ].freeze - def mount_graphql_devise_for(resource, options = {}) - default_operations = GraphqlDevise::DefaultOperations::MUTATIONS.merge(GraphqlDevise::DefaultOperations::QUERIES) - - # clean_options responds to all keys defined in GraphqlDevise::MountMethod::SUPPORTED_OPTIONS - clean_options = GraphqlDevise::MountMethod::OptionSanitizer.new(options).call! - - GraphqlDevise::MountMethod::OptionsValidator.new( - [ - GraphqlDevise::MountMethod::OptionValidators::SkipOnlyValidator.new(options: clean_options), - GraphqlDevise::MountMethod::OptionValidators::ProvidedOperationsValidator.new( - options: clean_options, supported_operations: default_operations - ) - ] - ).validate! - - devise_for( - resource.pluralize.underscore.tr('/', '_').to_sym, - module: :devise, - class_name: resource, - skip: DEVISE_OPERATIONS + clean_options = GraphqlDevise::ResourceLoader.new(resource, options, true).call( + GraphqlDevise::Types::QueryType, + GraphqlDevise::Types::MutationType ) - devise_scope resource.underscore.tr('/', '_').to_sym do - post clean_options.at, to: 'graphql_devise/graphql#auth' - get clean_options.at, to: 'graphql_devise/graphql#auth' - end - - # Avoid routes reload done by Devise - return if GraphqlDevise.resource_mounted?(resource) - - authenticatable_type = clean_options.authenticatable_type.presence || - "Types::#{resource}Type".safe_constantize || - GraphqlDevise::Types::AuthenticatableType - - prepared_mutations = GraphqlDevise::MountMethod::OperationPreparer.new( - resource: resource, - custom: clean_options.operations, - additional_operations: clean_options.additional_mutations, - preparer: GraphqlDevise::MountMethod::OperationPreparers::MutationFieldSetter.new(authenticatable_type), - selected_operations: GraphqlDevise::MountMethod::OperationSanitizer.call( - default: GraphqlDevise::DefaultOperations::MUTATIONS, only: clean_options.only, skipped: clean_options.skip - ) - ).call - - prepared_mutations.each do |action, mutation| - GraphqlDevise::Types::MutationType.field(action, mutation: mutation) - end - - prepared_queries = GraphqlDevise::MountMethod::OperationPreparer.new( - resource: resource, - custom: clean_options.operations, - additional_operations: clean_options.additional_queries, - preparer: GraphqlDevise::MountMethod::OperationPreparers::ResolverTypeSetter.new(authenticatable_type), - selected_operations: GraphqlDevise::MountMethod::OperationSanitizer.call( - default: GraphqlDevise::DefaultOperations::QUERIES, only: clean_options.only, skipped: clean_options.skip - ) - ).call - - prepared_queries.each do |action, resolver| - GraphqlDevise::Types::QueryType.field(action, resolver: resolver) - end - - Devise.mailer.helper(GraphqlDevise::MailerHelper) - - GraphqlDevise.mount_resource(resource) + post clean_options.at, to: 'graphql_devise/graphql#auth' + get clean_options.at, to: 'graphql_devise/graphql#auth' end end end diff --git a/lib/graphql_devise/resource_loader.rb b/lib/graphql_devise/resource_loader.rb new file mode 100644 index 00000000..a0b9b44a --- /dev/null +++ b/lib/graphql_devise/resource_loader.rb @@ -0,0 +1,87 @@ +module GraphqlDevise + class ResourceLoader + def initialize(resource, options = {}, routing = false) + @resource = resource + @options = options + @routing = routing + @default_operations = GraphqlDevise::DefaultOperations::MUTATIONS.merge(GraphqlDevise::DefaultOperations::QUERIES) + end + + def call(query, mutation) + mapping_name = @resource.to_s.underscore.tr('/', '_').to_sym + + # clean_options responds to all keys defined in GraphqlDevise::MountMethod::SUPPORTED_OPTIONS + clean_options = GraphqlDevise::MountMethod::OptionSanitizer.new(@options).call! + + return clean_options if GraphqlDevise.resource_mounted?(mapping_name) && @routing + + validate_options!(clean_options) + + authenticatable_type = clean_options.authenticatable_type.presence || + "Types::#{@resource}Type".safe_constantize || + GraphqlDevise::Types::AuthenticatableType + + prepared_mutations = prepare_mutations(mapping_name, clean_options, authenticatable_type) + + if prepared_mutations.any? && mutation.blank? + raise GraphqlDevise::Error, 'You need to provide a mutation type unless all mutations are skipped' + end + + prepared_mutations.each do |action, prepared_mutation| + mutation.field(action, mutation: prepared_mutation, authenticate: false) + end + + prepared_resolvers = prepare_resolvers(mapping_name, clean_options, authenticatable_type) + + if prepared_resolvers.any? && query.blank? + raise GraphqlDevise::Error, 'You need to provide a query type unless all queries are skipped' + end + + prepared_resolvers.each do |action, resolver| + query.field(action, resolver: resolver, authenticate: false) + end + + GraphqlDevise.add_mapping(mapping_name, @resource) + GraphqlDevise.mount_resource(mapping_name) if @routing + + clean_options + end + + private + + def prepare_resolvers(mapping_name, clean_options, authenticatable_type) + GraphqlDevise::MountMethod::OperationPreparer.new( + mapping_name: mapping_name, + custom: clean_options.operations, + additional_operations: clean_options.additional_queries, + preparer: GraphqlDevise::MountMethod::OperationPreparers::ResolverTypeSetter.new(authenticatable_type), + selected_operations: GraphqlDevise::MountMethod::OperationSanitizer.call( + default: GraphqlDevise::DefaultOperations::QUERIES, only: clean_options.only, skipped: clean_options.skip + ) + ).call + end + + def prepare_mutations(mapping_name, clean_options, authenticatable_type) + GraphqlDevise::MountMethod::OperationPreparer.new( + mapping_name: mapping_name, + custom: clean_options.operations, + additional_operations: clean_options.additional_mutations, + preparer: GraphqlDevise::MountMethod::OperationPreparers::MutationFieldSetter.new(authenticatable_type), + selected_operations: GraphqlDevise::MountMethod::OperationSanitizer.call( + default: GraphqlDevise::DefaultOperations::MUTATIONS, only: clean_options.only, skipped: clean_options.skip + ) + ).call + end + + def validate_options!(clean_options) + GraphqlDevise::MountMethod::OptionsValidator.new( + [ + GraphqlDevise::MountMethod::OptionValidators::SkipOnlyValidator.new(options: clean_options), + GraphqlDevise::MountMethod::OptionValidators::ProvidedOperationsValidator.new( + options: clean_options, supported_operations: @default_operations + ) + ] + ).validate! + end + end +end diff --git a/lib/graphql_devise/schema_plugin.rb b/lib/graphql_devise/schema_plugin.rb new file mode 100644 index 00000000..9d4b8ffe --- /dev/null +++ b/lib/graphql_devise/schema_plugin.rb @@ -0,0 +1,87 @@ +module GraphqlDevise + class SchemaPlugin + DEFAULT_NOT_AUTHENTICATED = ->(field) { raise GraphqlDevise::UserError, "#{field} field requires authentication" } + + def initialize(query: nil, mutation: nil, authenticate_default: true, resource_loaders: [], unauthenticated_proc: DEFAULT_NOT_AUTHENTICATED) + @query = query + @mutation = mutation + @resource_loaders = resource_loaders + @authenticate_default = authenticate_default + @unauthenticated_proc = unauthenticated_proc + + # Must happen on initialize so operations are loaded before the types are added to the schema on GQL < 1.10 + load_fields + end + + def use(schema_definition) + schema_definition.tracer(self) + end + + def trace(event, trace_data) + # Authenticate only root level queries + return yield unless event == 'execute_field' && path(trace_data).count == 1 + + field = traced_field(trace_data) + provided_value = authenticate_option(field, trace_data) + + if !provided_value.nil? + raise_on_missing_resource(context(trace_data), field) if provided_value + elsif @authenticate_default + raise_on_missing_resource(context(trace_data), field) + end + + yield + end + + private + + def raise_on_missing_resource(context, field) + @unauthenticated_proc.call(field.name) if context[:current_resource].blank? + end + + def context(trace_data) + query = if trace_data[:context] + trace_data[:context].query + else + trace_data[:query] + end + + query.context + end + + def path(trace_data) + if trace_data[:context] + trace_data[:context].path + else + trace_data[:path] + end + end + + def traced_field(trace_data) + if trace_data[:context] + trace_data[:context].field + else + trace_data[:field] + end + end + + def authenticate_option(field, trace_data) + if trace_data[:context] + field.metadata[:authenticate] + else + field.graphql_definition.metadata[:authenticate] + end + end + + def load_fields + @resource_loaders.each do |resource_loader| + raise Error, 'Invalid resource loader instance' unless resource_loader.instance_of?(GraphqlDevise::ResourceLoader) + + resource_loader.call(@query, @mutation) + end + end + end +end + +GraphQL::Field.accepts_definitions(authenticate: GraphQL::Define.assign_metadata_key(:authenticate)) +GraphQL::Schema::Field.accepts_definition(:authenticate) diff --git a/spec/dummy/app/controllers/api/v1/graphql_controller.rb b/spec/dummy/app/controllers/api/v1/graphql_controller.rb index 3a535713..37801cfd 100644 --- a/spec/dummy/app/controllers/api/v1/graphql_controller.rb +++ b/spec/dummy/app/controllers/api/v1/graphql_controller.rb @@ -3,10 +3,14 @@ module V1 class GraphqlController < ApplicationController include GraphqlDevise::Concerns::SetUserByToken - before_action :authenticate_user! + before_action -> { set_resource_by_token(:user) } def graphql - render json: DummySchema.execute(params[:query]) + render json: DummySchema.execute(params[:query], context: graphql_context) + end + + def interpreter + render json: InterpreterSchema.execute(params[:query], context: graphql_context) end private diff --git a/spec/dummy/app/graphql/dummy_schema.rb b/spec/dummy/app/graphql/dummy_schema.rb index 4546ea5a..45fd2707 100644 --- a/spec/dummy/app/graphql/dummy_schema.rb +++ b/spec/dummy/app/graphql/dummy_schema.rb @@ -1,4 +1,13 @@ class DummySchema < GraphQL::Schema + use GraphqlDevise::SchemaPlugin.new( + query: Types::QueryType, + mutation: Types::MutationType, + resource_loaders: [ + GraphqlDevise::ResourceLoader.new('User', only: [:login, :confirm_account]), + GraphqlDevise::ResourceLoader.new('Guest', only: [:logout]) + ] + ) + mutation(Types::MutationType) query(Types::QueryType) end diff --git a/spec/dummy/app/graphql/interpreter_schema.rb b/spec/dummy/app/graphql/interpreter_schema.rb new file mode 100644 index 00000000..b69357fb --- /dev/null +++ b/spec/dummy/app/graphql/interpreter_schema.rb @@ -0,0 +1,9 @@ +class InterpreterSchema < GraphQL::Schema + use GraphQL::Execution::Interpreter if Gem::Version.new(GraphQL::VERSION) >= Gem::Version.new('1.9.0') + use GraphQL::Analysis::AST if Gem::Version.new(GraphQL::VERSION) >= Gem::Version.new('1.10.0') + + use GraphqlDevise::SchemaPlugin.new(query: Types::QueryType, authenticate_default: false) + + mutation(Types::MutationType) + query(Types::QueryType) +end diff --git a/spec/dummy/app/graphql/types/mutation_type.rb b/spec/dummy/app/graphql/types/mutation_type.rb index a9b4ec0d..5948ce61 100644 --- a/spec/dummy/app/graphql/types/mutation_type.rb +++ b/spec/dummy/app/graphql/types/mutation_type.rb @@ -1,6 +1,6 @@ module Types class MutationType < Types::BaseObject - field :dummy_mutation, String, null: false + field :dummy_mutation, String, null: false, authenticate: true def dummy_mutation 'Necessary so GraphQL gem does not complain about empty mutation type' diff --git a/spec/dummy/app/graphql/types/query_type.rb b/spec/dummy/app/graphql/types/query_type.rb index edaec075..3ccdcdd8 100644 --- a/spec/dummy/app/graphql/types/query_type.rb +++ b/spec/dummy/app/graphql/types/query_type.rb @@ -1,5 +1,15 @@ module Types class QueryType < Types::BaseObject field :user, resolver: Resolvers::UserShow + field :public_field, String, null: false, authenticate: false + field :private_field, String, null: false, authenticate: true + + def public_field + 'Field does not require authentication' + end + + def private_field + 'Field will always require authentication' + end end end diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index b7cbb357..c41b3357 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -28,4 +28,5 @@ ) post '/api/v1/graphql', to: 'api/v1/graphql#graphql' + post '/api/v1/interpreter', to: 'api/v1/graphql#interpreter' end diff --git a/spec/generators/graphql_devise/install_generator_spec.rb b/spec/generators/graphql_devise/install_generator_spec.rb index 5b4fb368..9f40978b 100644 --- a/spec/generators/graphql_devise/install_generator_spec.rb +++ b/spec/generators/graphql_devise/install_generator_spec.rb @@ -17,6 +17,24 @@ run_generator(args) end + context 'when mount option is schema' do + let(:args) { ['Admin', '--mount', 'GqldDummySchema'] } + + it 'mounts the SchemaPlugin' do + assert_file 'config/initializers/devise.rb' + assert_file 'config/initializers/devise_token_auth.rb', /^\s{2}#{Regexp.escape('config.change_headers_on_each_request = false')}/ + assert_file 'config/locales/devise.en.yml' + + assert_migration 'db/migrate/devise_token_auth_create_admins.rb' + + assert_file 'app/models/admin.rb', /^\s{2}devise :.+include GraphqlDevise::Concerns::Model/m + + assert_file 'app/controllers/application_controller.rb', /^\s{2}include GraphqlDevise::Concerns::SetUserByToken/ + + assert_file 'app/graphql/gqld_dummy_schema.rb', /\s+#{Regexp.escape("GraphqlDevise::ResourceLoader.new('Admin')")}/ + end + end + context 'when passing no params to the generator' do let(:args) { [] } @@ -59,5 +77,8 @@ def create_rails_project FileUtils.cd(File.join(destination_root, '..')) do `rails new gqld_dummy -S -C --skip-action-mailbox --skip-action-text -T --skip-spring --skip-bundle --skip-keeps -G --skip-active-storage -J --skip-listen --skip-bootsnap` end + FileUtils.cd(File.join(destination_root, '../gqld_dummy')) do + `rails generate graphql:install` + end end end diff --git a/spec/requests/user_controller_spec.rb b/spec/requests/user_controller_spec.rb index 7e133a4c..455806d5 100644 --- a/spec/requests/user_controller_spec.rb +++ b/spec/requests/user_controller_spec.rb @@ -1,40 +1,196 @@ require 'rails_helper' -RSpec.describe 'Integrations with the user controller' do +RSpec.describe "Integrations with the user's controller" do include_context 'with graphql query request' let(:user) { create(:user, :confirmed) } - let(:query) do - <<-GRAPHQL - query { - user( - id: #{user.id} - ) { - id - email + + describe 'publicField' do + let(:query) do + <<-GRAPHQL + query { + publicField + } + GRAPHQL + end + + context 'when using a regular schema' do + before { post_request('/api/v1/graphql') } + + it 'does not require authentication' do + expect(json_response[:data][:publicField]).to eq('Field does not require authentication') + end + end + + context 'when using an interpreter schema' do + before { post_request('/api/v1/interpreter') } + + it 'does not require authentication' do + expect(json_response[:data][:publicField]).to eq('Field does not require authentication') + end + end + end + + describe 'privateField' do + let(:query) do + <<-GRAPHQL + query { + privateField } - } - GRAPHQL + GRAPHQL + end + + context 'when using a regular schema' do + before { post_request('/api/v1/graphql') } + + context 'when user is authenticated' do + let(:headers) { user.create_new_auth_token } + + it 'allow to perform the query' do + expect(json_response[:data][:privateField]).to eq('Field will always require authentication') + end + end + + context 'when user is not authenticated' do + it 'returns a must sign in error' do + expect(json_response[:errors]).to contain_exactly( + hash_including(message: 'privateField field requires authentication', extensions: { code: 'USER_ERROR' }) + ) + end + end + end + + context 'when using an interpreter schema' do + before { post_request('/api/v1/interpreter') } + + context 'when user is authenticated' do + let(:headers) { user.create_new_auth_token } + + it 'allow to perform the query' do + expect(json_response[:data][:privateField]).to eq('Field will always require authentication') + end + end + + context 'when user is not authenticated' do + it 'returns a must sign in error' do + expect(json_response[:errors]).to contain_exactly( + hash_including(message: 'privateField field requires authentication', extensions: { code: 'USER_ERROR' }) + ) + end + end + end end - before { post_request('/api/v1/graphql') } + describe 'dummyMutation' do + let(:query) do + <<-GRAPHQL + mutation { + dummyMutation + } + GRAPHQL + end + + context 'when using a regular schema' do + before { post_request('/api/v1/graphql') } + + context 'when user is authenticated' do + let(:headers) { user.create_new_auth_token } - context 'when user is authenticated' do - let(:headers) { user.create_new_auth_token } + it 'allow to perform the query' do + expect(json_response[:data][:dummyMutation]).to eq('Necessary so GraphQL gem does not complain about empty mutation type') + end + end - it 'allow to perform the query' do - expect(json_response[:data][:user]).to match( - email: user.email, - id: user.id - ) + context 'when user is not authenticated' do + it 'returns a must sign in error' do + expect(json_response[:errors]).to contain_exactly( + hash_including(message: 'dummyMutation field requires authentication', extensions: { code: 'USER_ERROR' }) + ) + end + end + end + + context 'when using an interpreter schema' do + before { post_request('/api/v1/interpreter') } + + context 'when user is authenticated' do + let(:headers) { user.create_new_auth_token } + + it 'allow to perform the query' do + expect(json_response[:data][:dummyMutation]).to eq('Necessary so GraphQL gem does not complain about empty mutation type') + end + end + + context 'when user is not authenticated' do + it 'returns a must sign in error' do + expect(json_response[:errors]).to contain_exactly( + hash_including(message: 'dummyMutation field requires authentication', extensions: { code: 'USER_ERROR' }) + ) + end + end end end - context 'when user is not authenticated' do - it 'returns a must sign in error' do - expect(json_response[:errors]).to contain_exactly( - 'You need to sign in or sign up before continuing.' - ) + describe 'user' do + let(:query) do + <<-GRAPHQL + query { + user( + id: #{user.id} + ) { + id + email + } + } + GRAPHQL + end + + context 'when using a regular schema' do + before { post_request('/api/v1/graphql') } + + context 'when user is authenticated' do + let(:headers) { user.create_new_auth_token } + + it 'allow to perform the query' do + expect(json_response[:data][:user]).to match( + email: user.email, + id: user.id + ) + end + end + + context 'when user is not authenticated' do + it 'returns a must sign in error' do + expect(json_response[:errors]).to contain_exactly( + hash_including(message: 'user field requires authentication', extensions: { code: 'USER_ERROR' }) + ) + end + end + end + + context 'when using an interpreter schema' do + before { post_request('/api/v1/interpreter') } + + context 'when user is authenticated' do + let(:headers) { user.create_new_auth_token } + + it 'allow to perform the query' do + expect(json_response[:data][:user]).to match( + email: user.email, + id: user.id + ) + end + end + + context 'when user is not authenticated' do + # Interpreter schema fields are public unless specified otherwise (plugin setting) + it 'allow to perform the query' do + expect(json_response[:data][:user]).to match( + email: user.email, + id: user.id + ) + end + end end end end diff --git a/spec/services/mount_method/operation_preparer_spec.rb b/spec/services/mount_method/operation_preparer_spec.rb index c3a6a15a..00c1a12d 100644 --- a/spec/services/mount_method/operation_preparer_spec.rb +++ b/spec/services/mount_method/operation_preparer_spec.rb @@ -4,7 +4,7 @@ describe '#call' do subject(:prepared_operations) do described_class.new( - resource: resource, + mapping_name: mapping, selected_operations: selected, preparer: preparer, custom: custom, @@ -13,7 +13,7 @@ end let(:logout_class) { Class.new(GraphQL::Schema::Resolver) } - let(:resource) { 'User' } + let(:mapping) { :user } let(:selected) { { login: double(:login_default), logout: logout_class } } let(:preparer) { double(:preparer, call: logout_class) } let(:custom) { { login: double(:custom_login, graphql_name: nil) } } diff --git a/spec/services/mount_method/operation_preparers/custom_operation_preparer_spec.rb b/spec/services/mount_method/operation_preparers/custom_operation_preparer_spec.rb index 0f365fd2..db955337 100644 --- a/spec/services/mount_method/operation_preparers/custom_operation_preparer_spec.rb +++ b/spec/services/mount_method/operation_preparers/custom_operation_preparer_spec.rb @@ -6,7 +6,7 @@ let(:login_operation) { double(:confirm_operation, graphql_name: nil) } let(:logout_operation) { double(:sign_up_operation, graphql_name: nil) } - let(:mapping_name) { 'user' } + let(:mapping_name) { :user } let(:operations) { { login: login_operation, logout: logout_operation, invalid: double(:invalid) } } let(:selected_keys) { [:login, :logout, :sign_up, :confirm] } diff --git a/spec/services/mount_method/operation_preparers/default_operation_preparer_spec.rb b/spec/services/mount_method/operation_preparers/default_operation_preparer_spec.rb index 8b375591..793e5f05 100644 --- a/spec/services/mount_method/operation_preparers/default_operation_preparer_spec.rb +++ b/spec/services/mount_method/operation_preparers/default_operation_preparer_spec.rb @@ -9,7 +9,7 @@ let(:sign_up_operation) { double(:sign_up_operation, graphql_name: nil) } let(:login_operation) { double(:confirm_operation, graphql_name: nil) } let(:logout_operation) { double(:sign_up_operation, graphql_name: nil) } - let(:mapping_name) { 'user' } + let(:mapping_name) { :user } let(:preparer) { double(:preparer) } let(:operations) { { login: login_operation, logout: logout_operation, sign_up: sign_up_operation, confirm: confirm_operation } } let(:custom_keys) { [:login, :logout] } diff --git a/spec/services/mount_method/operation_preparers/resource_name_setter_spec.rb b/spec/services/mount_method/operation_preparers/resource_name_setter_spec.rb index 6b694e41..92993377 100644 --- a/spec/services/mount_method/operation_preparers/resource_name_setter_spec.rb +++ b/spec/services/mount_method/operation_preparers/resource_name_setter_spec.rb @@ -5,7 +5,7 @@ subject(:prepared_operation) { described_class.new(mapping_name).call(operation) } let(:operation) { double(:operation) } - let(:mapping_name) { 'user' } + let(:mapping_name) { :user } it 'sets a gql name to the operation' do expect(prepared_operation.instance_variable_get(:@resource_name)).to eq(:user) diff --git a/spec/services/resource_loader_spec.rb b/spec/services/resource_loader_spec.rb new file mode 100644 index 00000000..d97eff76 --- /dev/null +++ b/spec/services/resource_loader_spec.rb @@ -0,0 +1,82 @@ +require 'spec_helper' + +RSpec.describe GraphqlDevise::ResourceLoader do + describe '#call' do + subject(:loader) { described_class.new(resource, options, routing).call(query, mutation) } + + let(:query) { class_double(GraphQL::Schema::Object) } + let(:mutation) { class_double(GraphQL::Schema::Object) } + let(:routing) { false } + let(:mounted) { false } + let(:resource) { 'User' } + let(:options) { { only: [:login, :confirm_account] } } + + before do + allow(GraphqlDevise).to receive(:add_mapping).with(:user, resource) + allow(GraphqlDevise).to receive(:resource_mounted?).with(:user).and_return(mounted) + allow(GraphqlDevise).to receive(:mount_resource).with(:user) + end + + it 'loads operations into the provided types' do + expect(query).to receive(:field).with(:user_confirm_account, resolver: instance_of(Class), authenticate: false) + expect(mutation).to receive(:field).with(:user_login, mutation: instance_of(Class), authenticate: false) + expect(GraphqlDevise).to receive(:add_mapping).with(:user, resource) + expect(GraphqlDevise).not_to receive(:mount_resource) + + returned = loader + + expect(returned).to be_a(Struct) + end + + context 'when mutation is nil' do + let(:mutation) { nil } + + it 'raises an error' do + expect { loader }.to raise_error( + GraphqlDevise::Error, + 'You need to provide a mutation type unless all mutations are skipped' + ) + end + end + + context 'when query is nil' do + let(:query) { nil } + + before { allow(mutation).to receive(:field) } + + it 'raises an error' do + expect { loader }.to raise_error( + GraphqlDevise::Error, + 'You need to provide a query type unless all queries are skipped' + ) + end + end + + context 'when invoked from router' do + let(:routing) { true } + + before do + allow(query).to receive(:field) + allow(mutation).to receive(:field) + end + + it 'adds mappings' do + expect(GraphqlDevise).to receive(:add_mapping).with(:user, resource) + expect(GraphqlDevise).to receive(:mount_resource).with(:user) + + loader + end + + context 'when resource was already mounted' do + before { allow(GraphqlDevise).to receive(:resource_mounted?).with(:user).and_return(true) } + + it 'skips schema loading' do + expect(query).not_to receive(:field) + expect(mutation).not_to receive(:field) + expect(GraphqlDevise).not_to receive(:add_mapping).with(:user, resource) + expect(GraphqlDevise).not_to receive(:mount_resource) + end + end + end + end +end diff --git a/spec/services/schema_plugin_spec.rb b/spec/services/schema_plugin_spec.rb new file mode 100644 index 00000000..415c3acb --- /dev/null +++ b/spec/services/schema_plugin_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +RSpec.describe GraphqlDevise::SchemaPlugin do + describe '#call' do + subject(:plugin) { described_class.new(query: query, mutation: mutation, resource_loaders: loaders) } + + let(:query) { instance_double(GraphQL::Schema::Object) } + let(:mutation) { instance_double(GraphQL::Schema::Object) } + + context 'when loaders are not provided' do + let(:loaders) { [] } + + it 'does not fail' do + expect { plugin }.not_to raise_error + end + end + + context 'when a loaders is not an instance of loader' do + let(:loaders) { ['not a loader instance'] } + + it 'raises an error' do + expect { plugin }.to raise_error(GraphqlDevise::Error, 'Invalid resource loader instance') + end + end + end +end