├── log └── .keep ├── storage └── .keep ├── tmp └── .keep ├── vendor └── .keep ├── .ruby-version ├── app ├── models │ ├── concerns │ │ └── .keep │ ├── application_record.rb │ ├── auth_token.rb │ └── user.rb ├── views │ └── layouts │ │ ├── mailer.text.erb │ │ └── mailer.html.erb ├── graphql │ ├── types │ │ ├── base_enum.rb │ │ ├── base_union.rb │ │ ├── base_scalar.rb │ │ ├── base_argument.rb │ │ ├── base_interface.rb │ │ ├── query_type.rb │ │ ├── base_input_object.rb │ │ ├── base_field.rb │ │ ├── subscription_type.rb │ │ ├── custom_types │ │ │ └── user_type.rb │ │ ├── base_object.rb │ │ └── mutation_type.rb │ ├── queries │ │ ├── base_query.rb │ │ └── user.rb │ ├── mutations │ │ ├── base_mutation.rb │ │ └── user_mutations │ │ │ ├── delete_user_mutation.rb │ │ │ ├── update_user_mutation.rb │ │ │ ├── create_user_mutation.rb │ │ │ └── sign_in_user_mutation.rb │ ├── query_analyzers │ │ ├── concerns │ │ │ └── introspectable.rb │ │ ├── query_depth_analyzer.rb │ │ └── query_complexity_analyzer.rb │ ├── loaders │ │ ├── record_loader.rb │ │ └── association_loader.rb │ └── rails_api_boilerplate_schema.rb ├── assets │ └── config │ │ └── manifest.js ├── controllers │ ├── application_controller.rb │ ├── graphql_controller.rb │ └── concerns │ │ ├── user_retriever.rb │ │ └── graphql_request_resolver.rb ├── channels │ ├── application_cable │ │ ├── channel.rb │ │ └── connection.rb │ └── graphql_channel.rb ├── mailers │ └── application_mailer.rb ├── jobs │ └── application_job.rb └── services │ └── application_service.rb ├── .rspec ├── .env.test ├── spec ├── support │ ├── simple_cov.rb │ ├── webmock.rb │ ├── factory_bot.rb │ ├── shoulda_matchers.rb │ ├── request_helpers.rb │ ├── graphql_utils.rb │ └── matchers │ │ └── render_status_code.rb ├── factories │ └── users.rb ├── models │ └── user_spec.rb ├── rails_helper.rb ├── spec_helper.rb ├── requests │ └── users │ │ ├── show_spec.rb │ │ ├── update_spec.rb │ │ ├── create_spec.rb │ │ └── sign_in_spec.rb └── rubocop │ └── cop │ └── migration │ └── add_index_spec.rb ├── rubocop ├── rubocop.rb ├── rubocop_rails.yml ├── migration_helpers.rb └── cop │ └── migration │ └── add_index.rb ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE ├── workflows │ ├── deploy.yml │ ├── deploy_to_dev.yml │ ├── deploy_to_prod.yml │ ├── deploy_to_staging.yml │ └── test_and_liters.yml └── dependabot.yml ├── public └── robots.txt ├── config ├── spring.rb ├── initializers │ ├── graphql_config.rb │ ├── graphiql.rb │ ├── mime_types.rb │ ├── filter_parameter_logging.rb │ ├── application_controller_renderer.rb │ ├── backtrace_silencers.rb │ ├── sentry.rb │ ├── cors.rb │ ├── wrap_parameters.rb │ ├── inflections.rb │ └── strong_migrations.rb ├── environment.rb ├── boot.rb ├── credentials │ ├── test.yml.enc │ └── development.yml.enc ├── routes.rb ├── cable.yml ├── database.yml ├── credentials.yml.enc ├── storage.yml ├── application.rb ├── locales │ └── en.yml ├── puma.rb └── environments │ ├── test.rb │ ├── development.rb │ └── production.rb ├── Procfile ├── config.ru ├── Rakefile ├── bin ├── rake ├── rails ├── spring ├── setup └── bundle ├── release_tasks.sh ├── .env.template ├── db ├── seeds.rb ├── migrate │ └── 20190703155941_create_users.rb └── schema.rb ├── lib └── tasks │ ├── linters.rake │ └── auto_annotate_models.rake ├── .gitignore ├── .rubocop_todo.yml ├── .rubocop.yml ├── LICENSE ├── Gemfile ├── .reek.yml ├── PAGINATION.md ├── README.md └── Gemfile.lock /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-2.7.2 2 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require rails_helper 2 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | JWT_ENCODING_KEY=dummy_secret_key 2 | -------------------------------------------------------------------------------- /spec/support/simple_cov.rb: -------------------------------------------------------------------------------- 1 | SimpleCov.start 2 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /rubocop/rubocop.rb: -------------------------------------------------------------------------------- 1 | require_relative 'cop/migration/add_index' 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @aperezcristina @elestu @mconiglio @diebas @Stefano-loop @fdecono 2 | -------------------------------------------------------------------------------- /app/graphql/types/base_enum.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class BaseEnum < GraphQL::Schema::Enum 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/graphql/types/base_union.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class BaseUnion < GraphQL::Schema::Union 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/webmock.rb: -------------------------------------------------------------------------------- 1 | require 'webmock/rspec' 2 | 3 | WebMock.disable_net_connect!(allow_localhost: true) 4 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link graphiql/rails/application.css 2 | //= link graphiql/rails/application.js 3 | -------------------------------------------------------------------------------- /app/graphql/queries/base_query.rb: -------------------------------------------------------------------------------- 1 | module Queries 2 | class BaseQuery < GraphQL::Schema::Resolver; end 3 | end 4 | -------------------------------------------------------------------------------- /app/graphql/types/base_scalar.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class BaseScalar < GraphQL::Schema::Scalar 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/factory_bot.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | config.include FactoryBot::Syntax::Methods 3 | end 4 | -------------------------------------------------------------------------------- /app/graphql/types/base_argument.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class BaseArgument < GraphQL::Schema::Argument 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::API 2 | include UserRetriever 3 | end 4 | -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | Spring.watch( 2 | '.ruby-version', 3 | '.rbenv-vars', 4 | 'tmp/restart.txt', 5 | 'tmp/caching-dev.txt' 6 | ) 7 | -------------------------------------------------------------------------------- /app/graphql/types/base_interface.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | module BaseInterface 3 | include GraphQL::Schema::Interface 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bin/rails server -p $PORT -e $RAILS_ENV 2 | worker: bundle exec sidekiq -C ./config/sidekiq.yml -v 3 | release: bash ./release_tasks.sh 4 | -------------------------------------------------------------------------------- /app/graphql/types/query_type.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class QueryType < Types::BaseObject 3 | field :user, resolver: Queries::User 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/initializers/graphql_config.rb: -------------------------------------------------------------------------------- 1 | module GraphqlConfig 2 | EXPOSE_API_INSIGHTS = ENV.fetch('EXPOSE_API_INSIGHTS', 'false') == 'true' 3 | end 4 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: ENV['DEFAULT_FROM_EMAIL_ADDRESS'] 3 | layout 'mailer' 4 | end 5 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /app/graphql/types/base_input_object.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class BaseInputObject < GraphQL::Schema::InputObject 3 | argument_class Types::BaseArgument 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/shoulda_matchers.rb: -------------------------------------------------------------------------------- 1 | Shoulda::Matchers.configure do |config| 2 | config.integrate do |with| 3 | with.test_framework :rspec 4 | with.library :rails 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /config/initializers/graphiql.rb: -------------------------------------------------------------------------------- 1 | GraphiQL::Rails.config.headers['Authorization'] = lambda { |_context| 2 | token = ENV['GRAPHIQL_SESSION_TOKEN'] 3 | token.present? ? "Bearer #{token}" : '' 4 | } 5 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /app/graphql/queries/user.rb: -------------------------------------------------------------------------------- 1 | module Queries 2 | class User < Queries::BaseQuery 3 | type Types::CustomTypes::UserType, null: true 4 | 5 | def resolve 6 | context[:current_user] 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | require 'bootsnap/setup' # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /app/graphql/types/base_field.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class BaseField < GraphQL::Schema::Field 3 | argument_class Types::BaseArgument 4 | 5 | def resolve_field(obj, args, ctx) 6 | resolve(obj, args, ctx) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('spring', __dir__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | require_relative '../config/boot' 8 | require 'rake' 9 | Rake.application.run 10 | -------------------------------------------------------------------------------- /config/credentials/test.yml.enc: -------------------------------------------------------------------------------- 1 | luuplRPk4/WRkQyMZvk6ztJ2nQnp4LwzIbSJVJFih5EMwcCnxzHQBcvOkpTZp2hkOOZyauCZ8csUeB6osQeuQImsyCpBA0lmw7RIJkrn6jxNf0iApP4nh+iihRKn45NQLqZ5DRdMwL9TxyTFsr2tnEWmjdLoVNHO8TpoUNhITXZJ2GOfPjGwANzBGoQg70IkQ8iDf9MEOA==--1rCo/bO9W66t4Hyk--y2FjSD2p6Xy6xXoG67SMqg== -------------------------------------------------------------------------------- /config/credentials/development.yml.enc: -------------------------------------------------------------------------------- 1 | xrCSU3AjceTzi25Wl5HHFjsKmlp5ZDGT3AwzexIZHV+rmW+HFkhR0bxMOaJfVYzrNMHCh2QIZSBTVRlPI2A0Ut/Un8qNQE/EmlzYFkxf9zNFdScSB//+F8iQiQqhdBE5DIDZL1HZTuzlLob2awfWP42OxN1yGII4MxKEFhWDPCZ6OFU3b5rDRgvhz4LRL2+mCrbP+eLxmQ==--5R2yIC0JuG5Bg4qS--0Iwt7cJ1PO2ZIsfgUMBbMA== -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | if GraphqlConfig::EXPOSE_API_INSIGHTS 3 | mount GraphiQL::Rails::Engine, at: '/graphiql', graphql_path: 'graphql#execute' 4 | end 5 | 6 | mount ActionCable.server, at: '/cable' 7 | post '/graphql', to: 'graphql#execute' 8 | end 9 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /rubocop/rubocop_rails.yml: -------------------------------------------------------------------------------- 1 | Rails/LexicallyScopedActionFilter: 2 | Exclude: 3 | - app/controllers/**/** 4 | 5 | Rails/NotNullColumn: 6 | Enabled: false 7 | 8 | Rails/RakeEnvironment: 9 | Exclude: 10 | - lib/tasks/auto_annotate_models.rake 11 | - lib/tasks/linters.rake 12 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: redis 3 | url: redis://localhost:6379/1 4 | 5 | test: 6 | adapter: test 7 | 8 | production: 9 | adapter: redis 10 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 11 | channel_prefix: rails_api_boilerplate_production 12 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('spring', __dir__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | APP_PATH = File.expand_path('../config/application', __dir__) 8 | require_relative '../config/boot' 9 | require 'rails/commands' 10 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/graphql/types/subscription_type.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class SubscriptionType < Types::BaseObject 3 | field( 4 | :user_updated, 5 | Types::CustomTypes::UserType, 6 | null: false, 7 | description: 'A user has been updated' 8 | ) 9 | 10 | def user_updated; end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/graphql/types/custom_types/user_type.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | module CustomTypes 3 | class UserType < Types::BaseObject 4 | field :id, ID, null: false 5 | field :first_name, String, null: true 6 | field :last_name, String, null: true 7 | field :email, String, null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /release_tasks.sh: -------------------------------------------------------------------------------- 1 | echo "Running Release Tasks" 2 | 3 | if [ "$MIGRATIONS_ON_RELEASE" == "true" ]; then 4 | echo "Running Migrations" 5 | bundle exec rails db:migrate 6 | fi 7 | 8 | if [ "$SEED_ON_RELEASE" == "true" ]; then 9 | echo "Seeding DB" 10 | bundle exec rails db:seed 11 | fi 12 | 13 | echo "Done running release_tasks.sh" 14 | -------------------------------------------------------------------------------- /app/controllers/graphql_controller.rb: -------------------------------------------------------------------------------- 1 | class GraphqlController < ApplicationController 2 | include GraphqlRequestResolver 3 | 4 | def execute 5 | result = resolve(query: params, context: context) 6 | 7 | render json: result 8 | end 9 | 10 | private 11 | 12 | def context 13 | { current_user: current_user } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | MAX_DEPTH=5 2 | MAX_COMPLEXITY=30 3 | JWT_ENCODING_KEY= 4 | 5 | # Local 6 | DB_HOST=localhost 7 | DB_USERNAME= 8 | DB_PASSWORD= 9 | DB_NAME=rails-graphql-api-boilerplate 10 | EXPOSE_API_INSIGHTS=true 11 | DEFAULT_FROM_EMAIL_ADDRESS=no-reply@my-app.com 12 | GRAPHIQL_SESSION_TOKEN= 13 | 14 | # Production 15 | SENTRY_DSN= 16 | EXPOSE_API_INSIGHTS=false 17 | -------------------------------------------------------------------------------- /app/graphql/mutations/base_mutation.rb: -------------------------------------------------------------------------------- 1 | module Mutations 2 | class BaseMutation < GraphQL::Schema::RelayClassicMutation 3 | object_class Types::BaseObject 4 | field_class Types::BaseField 5 | input_object_class Types::BaseInputObject 6 | 7 | def render_error(errors) 8 | raise GraphQL::ExecutionError.new(errors, extensions: { code: :bad_request }) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/services/application_service.rb: -------------------------------------------------------------------------------- 1 | class ApplicationService 2 | attr_reader :result 3 | 4 | class << self 5 | def call(*args, &block) 6 | service = new(*args, &block) 7 | service.call 8 | service 9 | end 10 | end 11 | 12 | def call; end 13 | 14 | def success? 15 | errors.empty? 16 | end 17 | 18 | def errors 19 | @errors ||= [] 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/controllers/concerns/user_retriever.rb: -------------------------------------------------------------------------------- 1 | module UserRetriever 2 | extend ActiveSupport::Concern 3 | 4 | def current_user 5 | @current_user ||= AuthToken.verify(token) if token.present? 6 | end 7 | 8 | private 9 | 10 | def token 11 | @token ||= authorization_headers&.split(' ')&.last 12 | end 13 | 14 | def authorization_headers 15 | request.headers['Authorization'] 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/graphql/mutations/user_mutations/delete_user_mutation.rb: -------------------------------------------------------------------------------- 1 | module Mutations 2 | module UserMutations 3 | class DeleteUserMutation < Mutations::BaseMutation 4 | field :user, Types::CustomTypes::UserType, null: false 5 | 6 | def resolve 7 | user = User.find(context[:current_user]&.id) 8 | user.destroy! 9 | 10 | { user: user } 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/graphql/types/base_object.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class BaseObject < GraphQL::Schema::Object 3 | def self.connection_type 4 | connection_class = super 5 | 6 | connection_class.define_method(:total_count) do 7 | object.nodes.size 8 | end 9 | 10 | connection_class.send(:field, :total_count, Integer, null: false) 11 | 12 | connection_class 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to 2 | # seed the database with its default values. 3 | # The data can then be loaded with the rails db:seed command 4 | # (or created alongside the database with db:setup). 5 | # 6 | # Examples: 7 | # 8 | # movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) 9 | # Character.create(name: 'Luke', movie: movies.first) 10 | -------------------------------------------------------------------------------- /app/graphql/types/mutation_type.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | class MutationType < Types::BaseObject 3 | field :create_user, mutation: Mutations::UserMutations::CreateUserMutation 4 | field :delete_user, mutation: Mutations::UserMutations::DeleteUserMutation 5 | field :update_user, mutation: Mutations::UserMutations::UpdateUserMutation 6 | field :sign_in_user, mutation: Mutations::UserMutations::SignInUserMutation 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/models/auth_token.rb: -------------------------------------------------------------------------------- 1 | class AuthToken 2 | KEY = ENV.fetch('JWT_ENCODING_KEY') 3 | ALGORITHM = 'HS256'.freeze 4 | 5 | def self.token(user) 6 | payload = { user_id: user.id } 7 | JWT.encode(payload, KEY, ALGORITHM) 8 | end 9 | 10 | def self.verify(token) 11 | decoded_token = JWT.decode(token, KEY, true, { algorithm: ALGORITHM }) 12 | 13 | User.find_by(id: decoded_token.first['user_id']) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE: -------------------------------------------------------------------------------- 1 | #### Description: 2 | 3 | Provide a good description of the problem this PR is trying to address. 4 | 5 | --- 6 | 7 | #### :pushpin: Notes: 8 | 9 | * Include TODOS, warnings, things other devs need to be aware of when merging, etc. 10 | 11 | --- 12 | 13 | #### :heavy_check_mark:Tasks: 14 | 15 | * Write the list of things you performed to help the reviewer. 16 | 17 | --- 18 | 19 | @loopstudio/ruby-devs 20 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: akhileshns/heroku-deploy@v3.0.4 14 | with: 15 | heroku_api_key: ${{secrets.HEROKU_API_KEY}} 16 | heroku_app_name: ${{secrets.HEROKU_PROD_APP}} 17 | heroku_email: ${{secrets.HEROKU_EMAIL}} 18 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: postgresql 3 | encoding: unicode 4 | host: <%= ENV['DB_HOST'] %> 5 | pool: 5 6 | timeout: 5000 7 | username: <%= ENV['DB_USERNAME'] %> 8 | password: <%= ENV['DB_PASSWORD'] %> 9 | database: <%= ENV['DB_NAME'] %> 10 | 11 | development: 12 | <<: *default 13 | database: <%= "#{ENV['DB_NAME']}-development" %> 14 | 15 | test: 16 | <<: *default 17 | database: <%= "#{ENV['DB_NAME']}-test" %> 18 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /rubocop/migration_helpers.rb: -------------------------------------------------------------------------------- 1 | module RuboCop 2 | # Module containing helper methods for writing migration cops. 3 | module MigrationHelpers 4 | # Returns true if the given node originated from the db/migrate directory. 5 | def in_migration?(node) 6 | dirname(node).end_with?('db/migrate') 7 | end 8 | 9 | private 10 | 11 | def dirname(node) 12 | File.dirname(node.location.expression.source_buffer.name) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | WwI/bF5uSezjhEHcSgO3gKZ+xIlTziA19QSGMqI5E2f1J2SRv37bld8HpR2pJ64JjM0GnFpIpWbSX8MTprgGJkdyHs4+3au/QpuDkbTswFQdByiTME3+XAXS97OwNE5NbqeF9s8Fo1FGQ9xFhoKYW0jbowGiDQ+GYjMOrjMQGjGwC6sNg6AKR3bIOAUmcElBpPnuruMh729S0onegWrgBCP8wBZT7Urt1IwYK0bvgxN7nu5ATC5G+Lv1OiV1PtMeZH5rT9Qk+zIC+/r5AvacBpVI2hodateARLKeuvcMAy0FLtFH6deOKcLbL1cwJTgOjQHv9zWcA2YO1kptvTrcPFiRaW81YZhdfhRFWAewcaUUlM32TMZ2SYcU846t7gXW0hlzEIhVP+W3Ey1YUayx8eajFlWCaqCRfiya--krzc1ag7n2IT7MxU--Dc9uUQP/Jr0iclKequD2GA== -------------------------------------------------------------------------------- /db/migrate/20190703155941_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[6.0] 2 | disable_ddl_transaction! 3 | enable_extension 'citext' 4 | 5 | def change 6 | create_table :users do |t| 7 | t.string :first_name 8 | t.string :last_name 9 | t.string :password_digest 10 | t.citext :email, null: false 11 | 12 | t.timestamps 13 | end 14 | 15 | add_index :users, :email, unique: true, algorithm: :concurrently 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.github/workflows/deploy_to_dev.yml: -------------------------------------------------------------------------------- 1 | # name: Deploy to Dev 2 | 3 | # on: 4 | # push: 5 | # branches: 6 | # - develop 7 | 8 | # jobs: 9 | # build: 10 | # runs-on: ubuntu-latest 11 | # steps: 12 | # - uses: actions/checkout@v2 13 | # - uses: akhileshns/heroku-deploy@v3.0.4 14 | # with: 15 | # heroku_api_key: ${{secrets.HEROKU_API_KEY}} 16 | # heroku_app_name: ${{secrets.HEROKU_DEV_APP}} 17 | # heroku_email: ${{secrets.HEROKU_EMAIL}} 18 | -------------------------------------------------------------------------------- /.github/workflows/deploy_to_prod.yml: -------------------------------------------------------------------------------- 1 | # name: Deploy to Production 2 | 3 | # on: 4 | # push: 5 | # branches: 6 | # - master 7 | 8 | # jobs: 9 | # build: 10 | # runs-on: ubuntu-latest 11 | # steps: 12 | # - uses: actions/checkout@v2 13 | # - uses: akhileshns/heroku-deploy@v3.0.4 14 | # with: 15 | # heroku_api_key: ${{secrets.HEROKU_API_KEY}} 16 | # heroku_app_name: ${{secrets.HEROKU_PROD_APP}} 17 | # heroku_email: ${{secrets.HEROKU_EMAIL}} 18 | -------------------------------------------------------------------------------- /.github/workflows/deploy_to_staging.yml: -------------------------------------------------------------------------------- 1 | # name: Deploy to Staging 2 | 3 | # on: 4 | # pull_request: 5 | # branches: 6 | # - master 7 | 8 | # jobs: 9 | # build: 10 | # runs-on: ubuntu-latest 11 | # steps: 12 | # - uses: actions/checkout@v2 13 | # - uses: akhileshns/heroku-deploy@v3.0.4 14 | # with: 15 | # heroku_api_key: ${{secrets.HEROKU_API_KEY}} 16 | # heroku_app_name: ${{secrets.HEROKU_STAGING_APP}} 17 | # heroku_email: ${{secrets.HEROKU_EMAIL}} 18 | -------------------------------------------------------------------------------- /config/initializers/sentry.rb: -------------------------------------------------------------------------------- 1 | Sentry.init do |config| 2 | config.dsn = ENV['SENTRY_DSN'] 3 | config.breadcrumbs_logger = %i[active_support_logger http_logger] 4 | config.send_default_pii = true 5 | config.sample_rate = 1.0 6 | config.async = ->(event) { Sentry::SendEventJob.perform_later(event) } 7 | 8 | # Param filtering 9 | filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters) 10 | config.before_send = lambda do |event, _hint| 11 | filter.filter(event.to_hash) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/tasks/linters.rake: -------------------------------------------------------------------------------- 1 | require 'optparse' 2 | 3 | task :linters do 4 | options = {} 5 | OptionParser.new { |opts| 6 | opts.on('-a') { |autofix| options[:autofix] = autofix } 7 | }.parse! 8 | 9 | files_diff = `git diff --diff-filter=ACMRTUXB --name-only origin/master... | \ 10 | xargs | sed "s/\\n/\\s/"` 11 | 12 | if files_diff.present? 13 | sh "bundle exec rubocop --force-exclusion #{'-a' if options[:autofix]} #{files_diff}" 14 | sh "bundle exec reek --force-exclusion -c .reek.yml #{files_diff}" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /config/initializers/cors.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Avoid CORS issues when API is called from the frontend app. 4 | # Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests. 5 | 6 | # Read more: https://github.com/cyu/rack-cors 7 | 8 | Rails.application.config.middleware.insert_before 0, Rack::Cors do 9 | allow do 10 | origins '*' 11 | 12 | resource '*', 13 | headers: :any, 14 | methods: %i[get post put patch delete options head] 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This file loads Spring without using Bundler, in order to be fast. 4 | # It gets overwritten when you run the `spring binstub` command. 5 | 6 | unless defined?(Spring) 7 | require 'rubygems' 8 | require 'bundler' 9 | 10 | lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read) 11 | spring = lockfile.specs.detect { |spec| spec.name == 'spring' } 12 | if spring 13 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 14 | gem 'spring', spring.version 15 | require 'spring/binstub' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/graphql/mutations/user_mutations/update_user_mutation.rb: -------------------------------------------------------------------------------- 1 | module Mutations 2 | module UserMutations 3 | class UpdateUserMutation < Mutations::BaseMutation 4 | argument :first_name, String, required: false 5 | argument :last_name, String, required: false 6 | argument :email, String, required: false 7 | argument :password, String, required: false 8 | 9 | field :user, Types::CustomTypes::UserType, null: false 10 | 11 | def resolve(**attributes) 12 | user = User.find(context[:current_user]&.id) 13 | user.update!(attributes) 14 | 15 | { user: user } 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/request_helpers.rb: -------------------------------------------------------------------------------- 1 | module RequestHelpers 2 | def auth_headers(some_user = user) 3 | { 'Authorization' => AuthToken.token(some_user) } 4 | end 5 | 6 | def errors 7 | json[:errors] 8 | end 9 | 10 | def first_error_message 11 | errors.first[:message] 12 | end 13 | 14 | def json 15 | raise 'Response is nil. Are you sure you made a request?' unless response 16 | 17 | JSON.parse(response.body, symbolize_names: true) 18 | end 19 | 20 | def graphql_request(request, variables: {}, headers: nil) 21 | GraphqlUtils.validate!(request.to_s) 22 | 23 | post(graphql_path, params: { query: request, variables: variables }, headers: headers) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/graphql/mutations/user_mutations/create_user_mutation.rb: -------------------------------------------------------------------------------- 1 | module Mutations 2 | module UserMutations 3 | class CreateUserMutation < Mutations::BaseMutation 4 | argument :first_name, String, required: true 5 | argument :last_name, String, required: true 6 | argument :email, String, required: true 7 | argument :password, String, required: true 8 | 9 | field :user, Types::CustomTypes::UserType, null: false 10 | field :token, String, null: false 11 | 12 | def resolve(**attributes) 13 | user = User.create!(attributes) 14 | token = AuthToken.token(user) 15 | 16 | { user: user, token: token } 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "08:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: graphql 11 | versions: 12 | - 1.12.5 13 | - 1.12.7 14 | - dependency-name: rails 15 | versions: 16 | - 6.1.2 17 | - 6.1.3 18 | - dependency-name: rspec-rails 19 | versions: 20 | - 4.1.0 21 | - 5.0.0 22 | - dependency-name: webmock 23 | versions: 24 | - 3.11.3 25 | - 3.12.0 26 | - dependency-name: rubocop 27 | versions: 28 | - 1.11.0 29 | - 1.9.0 30 | - dependency-name: bootsnap 31 | versions: 32 | - 1.6.0 33 | -------------------------------------------------------------------------------- /app/graphql/query_analyzers/concerns/introspectable.rb: -------------------------------------------------------------------------------- 1 | module QueryAnalyzers 2 | module Concerns 3 | module Introspectable 4 | extend ActiveSupport::Concern 5 | 6 | private 7 | 8 | def verify_introspection_availability 9 | introspection_not_available_error unless insights_enabled? 10 | end 11 | 12 | def introspection? 13 | query.operation_name == 'IntrospectionQuery' 14 | end 15 | 16 | def introspection_not_available_error 17 | GraphQL::AnalysisError.new(I18n.t('errors.instrospection_not_available')) 18 | end 19 | 20 | def insights_enabled? 21 | GraphqlConfig::EXPOSE_API_INSIGHTS 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/factories/users.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: users 4 | # 5 | # id :bigint not null, primary key 6 | # email :citext not null, indexed 7 | # first_name :string 8 | # last_name :string 9 | # password_digest :string 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # 13 | # Indexes 14 | # 15 | # index_users_on_email (email) UNIQUE 16 | # 17 | 18 | FactoryBot.define do 19 | factory :user do 20 | email { Faker::Internet.email } 21 | first_name { Faker::Name.first_name } 22 | last_name { Faker::Name.last_name } 23 | password { Faker::Internet.password } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/support/graphql_utils.rb: -------------------------------------------------------------------------------- 1 | module GraphqlUtils 2 | class ParseErrorBetterPrinted < ::GraphQL::ParseError 3 | def initialize(exc) 4 | super(exc.message, exc.line, exc.col, exc.query) 5 | end 6 | 7 | def to_s 8 | "#{super}\n\n#{query}" 9 | end 10 | end 11 | 12 | def self.hash_to_gql(attributes) 13 | attr_gql_lines = attributes.map do |key, value| 14 | new_key = key.to_s.camelize(:lower) 15 | "#{new_key}: #{value.inspect || '""'}" 16 | end 17 | 18 | " { #{attr_gql_lines.join(', ')} } " 19 | end 20 | 21 | def self.validate!(some_string) 22 | GraphQL.parse(some_string) 23 | rescue ::GraphQL::ParseError => e 24 | raise ParseErrorBetterPrinted, e 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/graphql/query_analyzers/query_depth_analyzer.rb: -------------------------------------------------------------------------------- 1 | module QueryAnalyzers 2 | class QueryDepthAnalyzer < GraphQL::Analysis::AST::QueryDepth 3 | include Concerns::Introspectable 4 | 5 | MAX_DEPTH = ENV.fetch('MAX_DEPTH', 5).to_i 6 | 7 | def result 8 | return verify_introspection_availability if introspection? 9 | 10 | @depth = super 11 | max_depth_exceeded_error if depth_exceeded? 12 | end 13 | 14 | private 15 | 16 | def max_depth_exceeded_error 17 | GraphQL::AnalysisError.new( 18 | I18n.t('errors.max_depth_exceeded', current_depth: @depth, max_depth: MAX_DEPTH) 19 | ) 20 | end 21 | 22 | def depth_exceeded? 23 | MAX_DEPTH < @depth 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/graphql/mutations/user_mutations/sign_in_user_mutation.rb: -------------------------------------------------------------------------------- 1 | module Mutations 2 | module UserMutations 3 | class SignInUserMutation < Mutations::BaseMutation 4 | argument :email, String, required: true 5 | argument :password, String, required: true 6 | 7 | field :user, Types::CustomTypes::UserType, null: true 8 | field :token, String, null: true 9 | 10 | def resolve(email:, password:) 11 | user = User.find_by(email: email) 12 | 13 | unless user&.authenticate(password) 14 | raise GraphQL::ExecutionError, I18n.t('errors.invalid_credentials') 15 | end 16 | 17 | token = AuthToken.token(user) 18 | { user: user, token: token } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/models/user_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: users 4 | # 5 | # id :bigint not null, primary key 6 | # email :citext not null, indexed 7 | # first_name :string 8 | # last_name :string 9 | # password_digest :string 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # 13 | # Indexes 14 | # 15 | # index_users_on_email (email) UNIQUE 16 | # 17 | 18 | RSpec.describe User, type: :model do 19 | subject(:user) { create(:user) } 20 | 21 | describe 'validations' do 22 | it { is_expected.to validate_presence_of(:email) } 23 | it { is_expected.to validate_uniqueness_of(:email).case_insensitive } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/matchers/render_status_code.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :render_error_code do |expected| 2 | match do |_actual| 3 | json[:errors]&.first&.dig(:extensions, :code).to_s == expected.to_s 4 | end 5 | 6 | failure_message do |_actual| 7 | "Expected request to render in the json a status code of #{expected} but #{reason}" \ 8 | "\nThe json obtained was: #{json}" 9 | end 10 | 11 | def reason 12 | errors = json[:errors] 13 | return "it did not contain the key 'errors'." unless errors 14 | 15 | error = errors.first 16 | return "'errors' was empty." unless error 17 | 18 | actual_code = error.dig(:extensions, :code) 19 | return "no 'code' key was present." unless actual_code 20 | 21 | "it was #{actual_code}" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/graphql/loaders/record_loader.rb: -------------------------------------------------------------------------------- 1 | module Loaders 2 | class RecordLoader < GraphQL::Batch::Loader 3 | def initialize(model, column: model.primary_key, where: nil) 4 | super 5 | @model = model 6 | @column = column.to_s 7 | @column_type = model.type_for_attribute(@column) 8 | @where = where 9 | end 10 | 11 | def load(key) 12 | super(@column_type.cast(key)) 13 | end 14 | 15 | def perform(keys) 16 | query(keys).each { |record| fulfill(record.public_send(@column), record) } 17 | keys.each { |key| fulfill(key, nil) unless fulfilled?(key) } 18 | end 19 | 20 | private 21 | 22 | def query(keys) 23 | scope = @model 24 | scope = scope.where(@where) if @where 25 | scope.where(@column => keys) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] ||= 'test' 2 | require File.expand_path('../config/environment', __dir__) 3 | 4 | abort('The Rails environment is running in production mode!') if Rails.env.production? 5 | 6 | require 'rspec/rails' 7 | require 'spec_helper' 8 | 9 | Dir[Rails.root.join('spec/support/**/*.rb')].sort.each { |file| require file } 10 | 11 | begin 12 | ActiveRecord::Migration.maintain_test_schema! 13 | rescue ActiveRecord::PendingMigrationError => e 14 | puts e.to_s.strip 15 | exit 1 16 | end 17 | 18 | RSpec.configure do |config| 19 | config.include RequestHelpers, type: :request 20 | config.include Rails.application.routes.url_helpers 21 | 22 | config.use_transactional_fixtures = true 23 | config.infer_spec_type_from_file_location! 24 | config.filter_rails_from_backtrace! 25 | end 26 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: users 4 | # 5 | # id :bigint not null, primary key 6 | # email :citext not null, indexed 7 | # first_name :string 8 | # last_name :string 9 | # password_digest :string 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # 13 | # Indexes 14 | # 15 | # index_users_on_email (email) UNIQUE 16 | # 17 | 18 | class User < ApplicationRecord 19 | has_secure_password 20 | 21 | after_update :notify_subscriber_of_addition 22 | 23 | validates :email, presence: true, uniqueness: true 24 | 25 | private 26 | 27 | def notify_subscriber_of_addition 28 | RailsApiBoilerplateSchema.subscriptions.trigger('user_updated', {}, self) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'active_job' 2 | require 'active_support/testing/time_helpers' 3 | 4 | RSpec.configure do |config| 5 | config.include ActiveJob::TestHelper 6 | config.include ActiveSupport::Testing::TimeHelpers 7 | 8 | config.expect_with :rspec do |expectations| 9 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 10 | expectations.syntax = :expect 11 | end 12 | 13 | config.mock_with :rspec do |mocks| 14 | mocks.verify_partial_doubles = true 15 | end 16 | 17 | config.shared_context_metadata_behavior = :apply_to_host_groups 18 | config.order = :random 19 | 20 | config.before do 21 | ActionMailer::Base.deliveries.clear 22 | ActiveJob::Base.queue_adapter = :test 23 | end 24 | 25 | config.after do 26 | FileUtils.rm_rf(Dir[Rails.root.join('/spec/support/uploads').to_s]) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /config/initializers/strong_migrations.rb: -------------------------------------------------------------------------------- 1 | # Mark existing migrations as safe 2 | StrongMigrations.start_after = 20201119202420 3 | 4 | # Set timeouts for migrations 5 | # If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user 6 | StrongMigrations.lock_timeout = 10.seconds 7 | StrongMigrations.statement_timeout = 1.hour 8 | 9 | # Analyze tables after indexes are added 10 | # Outdated statistics can sometimes hurt performance 11 | StrongMigrations.auto_analyze = true 12 | 13 | # Set the version of the production database 14 | # so the right checks are run in development 15 | # StrongMigrations.target_version = 10 16 | 17 | # Add custom checks 18 | # StrongMigrations.add_check do |method, args| 19 | # if method == :add_index && args[0].to_s == "users" 20 | # stop! "No more indexes on the users table" 21 | # end 22 | # end 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-journal 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/.keep 19 | 20 | # Ignore uploaded files in development. 21 | /storage/* 22 | !/storage/.keep 23 | .byebug_history 24 | 25 | # Ignore master key for decrypting credentials and more. 26 | /config/master.key 27 | 28 | /config/credentials/development.key 29 | 30 | /config/credentials/test.key 31 | 32 | .env 33 | coverage 34 | -------------------------------------------------------------------------------- /app/graphql/query_analyzers/query_complexity_analyzer.rb: -------------------------------------------------------------------------------- 1 | module QueryAnalyzers 2 | class QueryComplexityAnalyzer < GraphQL::Analysis::AST::QueryComplexity 3 | include Concerns::Introspectable 4 | 5 | MAX_COMPLEXITY = ENV.fetch('MAX_COMPLEXITY', 30).to_i 6 | 7 | def result 8 | return verify_introspection_availability if introspection? 9 | 10 | @complexity = super 11 | max_complexity_exceeded_error if complexity_exceeded? 12 | end 13 | 14 | private 15 | 16 | def max_complexity_exceeded_error 17 | GraphQL::AnalysisError.new( 18 | I18n.t( 19 | 'errors.max_complexity_exceeded', 20 | current_complexity: @complexity, 21 | max_complexity: MAX_COMPLEXITY 22 | ) 23 | ) 24 | end 25 | 26 | def complexity_exceeded? 27 | MAX_COMPLEXITY < @complexity 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2020-10-16 16:26:38 UTC using RuboCop version 0.93.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 8 10 | # Configuration parameters: Max. 11 | RSpec/ExampleLength: 12 | Exclude: 13 | - 'spec/requests/users/create_spec.rb' 14 | - 'spec/requests/users/show_spec.rb' 15 | - 'spec/requests/users/sign_in_spec.rb' 16 | - 'spec/requests/users/update_spec.rb' 17 | - 'spec/rubocop/cop/migration/add_index_spec.rb' 18 | 19 | # Offense count: 18 20 | # Configuration parameters: AllowSubject. 21 | RSpec/MultipleMemoizedHelpers: 22 | Max: 9 23 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - .rubocop_todo.yml 3 | - ./rubocop/rubocop_rails.yml 4 | 5 | inherit_mode: 6 | merge: 7 | - Exclude 8 | 9 | require: 10 | - rubocop-rails 11 | - rubocop-rspec 12 | - ./rubocop/rubocop 13 | 14 | AllCops: 15 | NewCops: enable 16 | Exclude: 17 | - vendor/**/* 18 | - .circleci/* 19 | - db/schema.rb 20 | - bin/bundle 21 | - rubocop/**/*.rb 22 | 23 | Layout/LineLength: 24 | Max: 100 25 | Exclude: 26 | - config/**/* 27 | 28 | Metrics/AbcSize: 29 | Exclude: 30 | - db/migrate/* 31 | - app/channels/graphql_channel.rb 32 | 33 | Metrics/BlockLength: 34 | Exclude: 35 | - config/**/* 36 | - spec/**/* 37 | - lib/tasks/auto_annotate_models.rake 38 | 39 | Style/BlockDelimiters: 40 | EnforcedStyle: braces_for_chaining 41 | 42 | Style/Documentation: 43 | Enabled: false 44 | 45 | Style/FrozenStringLiteralComment: 46 | Enabled: false 47 | 48 | Style/NumericLiterals: 49 | Exclude: 50 | - config/initializers/strong_migrations.rb 51 | -------------------------------------------------------------------------------- /app/graphql/rails_api_boilerplate_schema.rb: -------------------------------------------------------------------------------- 1 | class RailsApiBoilerplateSchema < GraphQL::Schema 2 | disable_introspection_entry_points unless GraphqlConfig::EXPOSE_API_INSIGHTS 3 | use GraphQL::Subscriptions::ActionCableSubscriptions, redis: Redis.new 4 | use GraphQL::Batch 5 | query_analyzer QueryAnalyzers::QueryComplexityAnalyzer 6 | query_analyzer QueryAnalyzers::QueryDepthAnalyzer 7 | 8 | mutation(Types::MutationType) 9 | query(Types::QueryType) 10 | subscription(Types::SubscriptionType) 11 | 12 | rescue_from(ActiveRecord::RecordInvalid) do |exception| 13 | errors = exception.record.errors.messages.to_json 14 | GraphQL::ExecutionError.new(errors, extensions: { code: :bad_request }) 15 | end 16 | 17 | rescue_from(ActiveRecord::RecordNotFound) do |exception| 18 | GraphQL::ExecutionError.new(exception, extensions: { code: :not_found }) 19 | end 20 | 21 | rescue_from(StandardError) do |exception| 22 | GraphQL::ExecutionError.new(exception, extensions: { code: :internal_server_error }) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Loop Studio 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path('..', __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to setup or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at anytime and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?('config/database.yml') 22 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! 'bin/rails db:prepare' 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! 'bin/rails log:clear tmp:clear' 30 | 31 | puts "\n== Restarting application server ==" 32 | system! 'bin/rails restart' 33 | end 34 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /app/controllers/concerns/graphql_request_resolver.rb: -------------------------------------------------------------------------------- 1 | module GraphqlRequestResolver 2 | include ActiveSupport::Concern 3 | 4 | def resolve(query:, context:) 5 | if _json 6 | resolve_multiplex(context) 7 | else 8 | resolve_execute(query, context) 9 | end 10 | end 11 | 12 | private 13 | 14 | def resolve_execute(query, context) 15 | RailsApiBoilerplateSchema.execute( 16 | query[:query], 17 | operation_name: query[:operationName], 18 | variables: hasherize(query[:variables]), 19 | context: context 20 | ) 21 | end 22 | 23 | def resolve_multiplex(context) 24 | input = _json.map do |query| 25 | { 26 | query: query[:query], 27 | operation_name: query[:operationName], 28 | variables: hasherize(query[:variables]), 29 | context: context 30 | } 31 | end 32 | 33 | RailsApiBoilerplateSchema.multiplex(input) 34 | end 35 | 36 | def hasherize(input) 37 | return {} if input.blank? 38 | 39 | case input 40 | when String then hasherize(JSON.parse(input)) 41 | when Hash, ActionController::Parameters then input 42 | else raise ArgumentError, I18n.t('errors.unexpected_param', param: ambiguous_param.to_s) 43 | end 44 | end 45 | 46 | def _json 47 | params[:_json] 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `rails 6 | # db:schema:load`. When creating a new database, `rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2019_07_03_155941) do 14 | 15 | # These are extensions that must be enabled in order to support this database 16 | enable_extension "citext" 17 | enable_extension "plpgsql" 18 | 19 | create_table "users", force: :cascade do |t| 20 | t.string "first_name" 21 | t.string "last_name" 22 | t.string "password_digest" 23 | t.citext "email", null: false 24 | t.datetime "created_at", precision: 6, null: false 25 | t.datetime "updated_at", precision: 6, null: false 26 | t.index ["email"], name: "index_users_on_email", unique: true 27 | end 28 | 29 | end 30 | -------------------------------------------------------------------------------- /.github/workflows/test_and_liters.yml: -------------------------------------------------------------------------------- 1 | name: Tests and Linters 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'master' 7 | - 'develop' 8 | push: 9 | branches: 10 | - 'master' 11 | - 'develop' 12 | - /^release.*/ 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | services: 19 | postgres: 20 | image: postgres:11.5 21 | ports: ["5432:5432"] 22 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | 27 | - name: Set up Ruby (.ruby-version) 28 | uses: ruby/setup-ruby@v1 29 | with: 30 | bundler-cache: true 31 | 32 | - name: Install PostgreSQL 11 client 33 | run: sudo apt-get -yqq install libpq-dev 34 | 35 | - name: Build App 36 | env: 37 | PGHOST: localhost 38 | PGUSER: postgres 39 | RAILS_ENV: test 40 | run: | 41 | gem install bundler 42 | bundle config path vendor/bundle 43 | bundle install --jobs 4 --retry 3 44 | bin/rails db:setup 45 | 46 | - name: Run Linters 47 | run: | 48 | git fetch origin master:refs/remotes/origin/master 49 | bundle exec rake linters 50 | 51 | - name: Run Tests 52 | env: 53 | PGHOST: localhost 54 | PGUSER: postgres 55 | RAILS_ENV: test 56 | run: bundle exec rspec 57 | -------------------------------------------------------------------------------- /spec/requests/users/show_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe 'Show current user request', type: :request do 4 | let!(:user) { create(:user) } 5 | 6 | let(:request_body) do 7 | <<~GQL 8 | query { 9 | user { 10 | id 11 | firstName 12 | lastName 13 | email 14 | } 15 | } 16 | GQL 17 | end 18 | 19 | context 'when the user is logged in' do 20 | subject(:request) do 21 | graphql_request(request_body, headers: auth_headers) 22 | end 23 | 24 | specify do 25 | request 26 | 27 | expect(response).to have_http_status(:ok) 28 | end 29 | 30 | specify do 31 | request 32 | 33 | expect(errors).to be_nil 34 | end 35 | 36 | it 'returns the current user info' do 37 | request 38 | 39 | expect(json.dig(:data, :user)).to include_json( 40 | id: user.id.to_s, 41 | firstName: user.first_name, 42 | lastName: user.last_name, 43 | email: user.email 44 | ) 45 | end 46 | end 47 | 48 | context 'when the user is not logged in' do 49 | subject(:request) do 50 | graphql_request(request_body) 51 | end 52 | 53 | it 'does not return an error message' do 54 | request 55 | 56 | expect(errors).to be_nil 57 | end 58 | 59 | it 'does not return any data' do 60 | request 61 | 62 | expect(json.dig(:data, :user)).to be_nil 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | require 'rails' 4 | # Pick the frameworks you want: 5 | require 'active_model/railtie' 6 | require 'active_job/railtie' 7 | require 'active_record/railtie' 8 | require 'active_storage/engine' 9 | require 'action_controller/railtie' 10 | require 'action_mailer/railtie' 11 | require 'action_mailbox/engine' 12 | require 'action_text/engine' 13 | require 'action_view/railtie' 14 | require 'action_cable/engine' 15 | require 'sprockets/railtie' 16 | require 'rails/test_unit/railtie' 17 | 18 | # Require the gems listed in Gemfile, including any gems 19 | # you've limited to :test, :development, or :production. 20 | Bundler.require(*Rails.groups) 21 | 22 | module RailsApiBoilerplate 23 | class Application < Rails::Application 24 | # Initialize configuration defaults for originally generated Rails version. 25 | config.load_defaults 6.0 26 | 27 | # Settings in config/environments/* take precedence over those specified here. 28 | # Application configuration can go into files in config/initializers 29 | # -- all .rb files in that directory are automatically loaded after loading 30 | # the framework and any gems in your application. 31 | 32 | # Only loads a smaller set of middleware suitable for API only apps. 33 | # Middleware like session, flash, cookies can be added back manually. 34 | # Skip views, helpers and assets when generating a new resource. 35 | config.api_only = true 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | errors: 35 | association_error: "%{model} loader can't load association for %{class}" 36 | association_not_found: "No association %{association} on %{model}" 37 | unexpected_param: "Unexpected parameter: %{param}" 38 | invalid_credentials: 'Invalid credentials' 39 | max_complexity_exceeded: "Complexity of %{current_complexity} exceeds max complexity of %{max_complexity}" 40 | max_depth_exceeded: "Depth of %{current_depth} exceeds max depth of %{max_depth}" 41 | instrospection_not_available: "Instrospection not available" 42 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) 8 | min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 12 | # 13 | port ENV.fetch('PORT', 3000) 14 | 15 | # Specifies the `environment` that Puma will run in. 16 | # 17 | environment ENV.fetch('RAILS_ENV') { 'development' } 18 | 19 | # Specifies the number of `workers` to boot in clustered mode. 20 | # Workers are forked web server processes. If using threads and workers together 21 | # the concurrency of the application would be max `threads` * `workers`. 22 | # Workers do not work on JRuby or Windows (both of which do not support 23 | # processes). 24 | # 25 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 26 | 27 | # Use the `preload_app!` method when specifying a `workers` number. 28 | # This directive tells Puma to first boot the application and load code 29 | # before forking the application. This takes advantage of Copy On Write 30 | # process behavior so workers use less memory. 31 | # 32 | # preload_app! 33 | 34 | # Allow puma to be restarted by `rails restart` command. 35 | plugin :tmp_restart 36 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | ruby '2.7.2' 3 | 4 | gem 'bootsnap', '>= 1.4.2', require: false 5 | gem 'rails', '~> 6.1.5' 6 | 7 | # WebServer 8 | gem 'puma', '~> 5.6' 9 | gem 'rack-cors', '~> 1.1.1' 10 | 11 | # Graphql 12 | gem 'graphql', '~> 2.0.11' 13 | gem 'graphql-batch', '~> 0.5.1' 14 | 15 | # Database 16 | gem 'pg', '~> 1.4.1' 17 | gem 'redis', '~> 4.6.0' 18 | gem 'strong_migrations', '~> 1.2.0' 19 | 20 | # Environment variables 21 | gem 'dotenv-rails', '~> 2.7.6' 22 | 23 | # Authentication 24 | gem 'jwt', '~> 2.4.1' 25 | 26 | # Encryption 27 | gem 'bcrypt', '~> 3.1.18' 28 | 29 | # Monitoring errors 30 | gem 'graphiql-rails', '~> 1.8.0' 31 | gem 'sentry-rails' 32 | gem 'sentry-ruby' 33 | 34 | group :development, :test do 35 | gem 'bullet', '~> 7.0.2' 36 | gem 'byebug', platforms: %i[mri mingw x64_mingw] 37 | gem 'factory_bot_rails', '~> 6.2.0' 38 | gem 'faker', '~> 2.21.0' 39 | gem 'rspec-rails', '~> 5.1.2' 40 | end 41 | 42 | group :development do 43 | gem 'annotate', '~> 3.2.0' 44 | gem 'letter_opener', '~> 1.8.1' 45 | gem 'listen', '>= 3.0.5', '< 3.8' 46 | gem 'reek', '~> 6.1.1', require: false 47 | gem 'rubocop', '~> 1.30.1', require: false 48 | gem 'rubocop-rails', '~> 2.15.0', require: false 49 | gem 'rubocop-rspec', '~> 2.11.1', require: false 50 | gem 'spring', '~> 2.1.1' 51 | gem 'spring-watcher-listen', '~> 2.0.0' 52 | end 53 | 54 | group :test do 55 | gem 'rspec-json_expectations', '~> 2.2.0' 56 | gem 'shoulda-matchers', '~> 5.1.0' 57 | gem 'simplecov', '~> 0.21.2' 58 | gem 'webmock', '~> 3.14.0' 59 | end 60 | 61 | gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] 62 | -------------------------------------------------------------------------------- /app/channels/graphql_channel.rb: -------------------------------------------------------------------------------- 1 | class GraphqlChannel < ApplicationCable::Channel 2 | delegate :subscription?, to: :result 3 | 4 | attr_reader :result 5 | 6 | def subscribed 7 | @subscription_ids = [] 8 | end 9 | 10 | def execute(data) 11 | context = { channel: self } 12 | 13 | set_result(data, context) 14 | 15 | payload = { 16 | result: subscription? ? { data: nil } : result.to_h, 17 | more: subscription? 18 | } 19 | 20 | # Track the subscription here so we can remove it on unsubscribe. 21 | @subscription_ids << context[:subscription_id] if result.context[:subscription_id] 22 | 23 | transmit(payload) 24 | end 25 | 26 | def unsubscribed 27 | @subscription_ids.each do |sid| 28 | RailsApiBoilerplateSchema.subscriptions.delete_subscription(sid) 29 | end 30 | end 31 | 32 | private 33 | 34 | def set_result(data, context) 35 | query, operation_name = data.values_at('query', 'operationName') 36 | variables = ensure_hash(data['variables']) 37 | 38 | @result = RailsApiBoilerplateSchema.execute( 39 | query: query, 40 | context: context, 41 | variables: variables, 42 | operation_name: operation_name 43 | ) 44 | end 45 | 46 | def ensure_hash(ambiguous_param) 47 | case ambiguous_param 48 | when String 49 | ambiguous_param.present? ? ensure_hash(JSON.parse(ambiguous_param)) : {} 50 | when Hash, ActionController::Parameters 51 | ambiguous_param 52 | when nil 53 | {} 54 | else 55 | raise ArgumentError, I18n.t('errors.unexpected_param', param: ambiguous_param.to_s) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /app/graphql/loaders/association_loader.rb: -------------------------------------------------------------------------------- 1 | module Loaders 2 | class AssociationLoader < GraphQL::Batch::Loader 3 | def self.validate(model, association_name) 4 | new(model, association_name) 5 | nil 6 | end 7 | 8 | def initialize(model, association_name) 9 | super 10 | @model = model 11 | @association_name = association_name 12 | validate 13 | end 14 | 15 | def load(record) 16 | unless record.is_a?(@model) 17 | raise TypeError, I18n.t('errors.association_error', model: @model, 18 | class: record.class.model_name.human) 19 | end 20 | return Promise.resolve(read_association(record)) if association_loaded?(record) 21 | 22 | super 23 | end 24 | 25 | # We want to load the associations on all records, even if they have the same id 26 | def cache_key(record) 27 | record.object_id 28 | end 29 | 30 | def perform(records) 31 | preload_association(records) 32 | records.each { |record| fulfill(record, read_association(record)) } 33 | end 34 | 35 | private 36 | 37 | def validate 38 | return if @model.reflect_on_association(@association_name) 39 | 40 | raise ArgumentError, I18n.t( 41 | 'errors.association_not_found', model: @model, association: @association_name 42 | ) 43 | end 44 | 45 | def preload_association(records) 46 | ::ActiveRecord::Associations::Preloader.new.preload(records, @association_name) 47 | end 48 | 49 | def read_association(record) 50 | record.public_send(@association_name) 51 | end 52 | 53 | def association_loaded?(record) 54 | record.association(@association_name).loaded? 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /rubocop/cop/migration/add_index.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../migration_helpers' 2 | 3 | module RuboCop 4 | module Cop 5 | module Migration 6 | # Cop that checks if indexes are added in a concurrent manner. 7 | class AddIndex < RuboCop::Cop::Cop 8 | include MigrationHelpers 9 | 10 | MSG_DDT = 'Prefer using disable_ddl_transaction! and { algorithm: :concurrently }'\ 11 | ' when creating an index'.freeze 12 | MSG_ALG = 'Prefer using { algorithm: :concurrently } when creating an index'.freeze 13 | 14 | def_node_search :disable_ddl_transaction?, '(send $_ :disable_ddl_transaction!)' 15 | 16 | def_node_matcher :indexes?, <<-MATCHER 17 | (send _ {:add_index :drop_index} $...) 18 | MATCHER 19 | 20 | def on_class(node) 21 | return unless in_migration?(node) 22 | 23 | return if ddl_transaction_disabled? 24 | 25 | @disable_ddl_transaction = disable_ddl_transaction?(node) 26 | @offensive_node = node 27 | end 28 | 29 | def on_send(node) 30 | return unless in_migration?(node) 31 | 32 | indexes?(node) do |args| 33 | add_offense(node, message: MSG_ALG) unless concurrently_enabled?(args.last) 34 | add_offense(@offensive_node, message: MSG_DDT) unless ddl_transaction_disabled? 35 | end 36 | end 37 | 38 | private 39 | 40 | def concurrently_enabled?(last_arg) 41 | last_arg.hash_type? && last_arg.each_descendant.any? do |node| 42 | next unless node.sym_type? 43 | 44 | concurrently?(node) 45 | end 46 | end 47 | 48 | def ddl_transaction_disabled? 49 | @disable_ddl_transaction 50 | end 51 | 52 | def concurrently?(node) 53 | node.children.any? { |sym| sym == :concurrently } 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/tasks/auto_annotate_models.rake: -------------------------------------------------------------------------------- 1 | if Rails.env.development? 2 | require 'annotate' 3 | task :set_annotation_options do 4 | # You can override any of these by setting an environment variable of the 5 | # same name. 6 | Annotate.set_defaults( 7 | 'additional_file_patterns' => [], 8 | 'routes' => 'false', 9 | 'models' => 'true', 10 | 'position_in_routes' => 'before', 11 | 'position_in_class' => 'before', 12 | 'position_in_test' => 'before', 13 | 'position_in_fixture' => 'before', 14 | 'position_in_factory' => 'before', 15 | 'position_in_serializer' => 'before', 16 | 'show_foreign_keys' => 'true', 17 | 'show_complete_foreign_keys' => 'false', 18 | 'show_indexes' => 'true', 19 | 'simple_indexes' => 'true', 20 | 'model_dir' => 'app/models', 21 | 'root_dir' => '', 22 | 'include_version' => 'false', 23 | 'require' => '', 24 | 'exclude_tests' => 'false', 25 | 'exclude_fixtures' => 'false', 26 | 'exclude_factories' => 'false', 27 | 'exclude_serializers' => 'false', 28 | 'exclude_scaffolds' => 'true', 29 | 'exclude_controllers' => 'true', 30 | 'exclude_helpers' => 'true', 31 | 'exclude_sti_subclasses' => 'false', 32 | 'ignore_model_sub_dir' => 'false', 33 | 'ignore_columns' => nil, 34 | 'ignore_routes' => nil, 35 | 'ignore_unknown_models' => 'false', 36 | 'hide_limit_column_types' => 'integer,bigint,boolean', 37 | 'hide_default_column_types' => 'json,jsonb,hstore,text', 38 | 'skip_on_db_migrate' => 'false', 39 | 'format_bare' => 'true', 40 | 'format_rdoc' => 'false', 41 | 'format_markdown' => 'false', 42 | 'sort' => 'false', 43 | 'force' => 'false', 44 | 'frozen' => 'false', 45 | 'classified_sort' => 'true', 46 | 'trace' => 'false', 47 | 'wrapper_open' => nil, 48 | 'wrapper_close' => nil, 49 | 'with_comment' => 'true' 50 | ) 51 | end 52 | 53 | Annotate.load_tasks 54 | end 55 | -------------------------------------------------------------------------------- /spec/rubocop/cop/migration/add_index_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | require 'rubocop' 6 | require 'rubocop/rspec/support' 7 | 8 | require_relative '../../../../rubocop/cop/migration/add_index' 9 | 10 | RSpec.describe RuboCop::Cop::Migration::AddIndex do 11 | include CopHelper 12 | include RuboCop::RSpec::ExpectOffense 13 | 14 | let(:cop) { described_class.new } 15 | 16 | before { allow(cop).to receive(:in_migration?).and_return(true) } 17 | 18 | context 'with concurrently and without disable_ddl_transaction!' do 19 | it 'reports an offense' do 20 | expect_offense(<<~RUBY) 21 | class Migration 22 | ^^^^^^^^^^^^^^^ Prefer using disable_ddl_transaction! and { algorithm: :concurrently } when creating an index 23 | def change 24 | add_index :table, :column, algorithm: :concurrently 25 | end 26 | end 27 | RUBY 28 | end 29 | end 30 | 31 | context 'without concurrently and without disable_ddl_transaction!' do 32 | it 'reports an offense' do 33 | expect_offense(<<~RUBY) 34 | class Migration 35 | ^^^^^^^^^^^^^^^ Prefer using disable_ddl_transaction! and { algorithm: :concurrently } when creating an index 36 | def change 37 | add_index :table, :column 38 | ^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer using { algorithm: :concurrently } when creating an index 39 | end 40 | end 41 | RUBY 42 | end 43 | end 44 | 45 | context 'with concurrently and with disable_ddl_transaction!' do 46 | it 'does not report an offense' do 47 | expect_no_offenses(<<~RUBY) 48 | class Migration 49 | disable_ddl_transaction! 50 | def change 51 | add_index :table, :column, algorithm: :concurrently 52 | end 53 | end 54 | RUBY 55 | end 56 | end 57 | 58 | context 'without concurrently and with disable_ddl_transaction!' do 59 | it 'reports an offense' do 60 | expect_offense(<<~RUBY) 61 | class Migration 62 | disable_ddl_transaction! 63 | def change 64 | add_index :table, :column 65 | ^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer using { algorithm: :concurrently } when creating an index 66 | end 67 | end 68 | RUBY 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # The test environment is used exclusively to run your application's 2 | # test suite. You never need to work with it otherwise. Remember that 3 | # your test database is "scratch space" for the test suite and is wiped 4 | # and recreated between test runs. Don't rely on the data there! 5 | 6 | Rails.application.configure do 7 | # Settings specified here will take precedence over those in config/application.rb. 8 | 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. This avoids loading your whole application 12 | # just for the purpose of running a single test. If you are using a tool that 13 | # preloads Rails for running tests, you may have to set it to true. 14 | config.eager_load = false 15 | 16 | # Configure public file server for tests with Cache-Control for performance. 17 | config.public_file_server.enabled = true 18 | config.public_file_server.headers = { 19 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 20 | } 21 | 22 | # Show full error reports and disable caching. 23 | config.consider_all_requests_local = true 24 | config.action_controller.perform_caching = false 25 | config.cache_store = :null_store 26 | 27 | # Raise exceptions instead of rendering exception templates. 28 | config.action_dispatch.show_exceptions = false 29 | 30 | # Disable request forgery protection in test environment. 31 | config.action_controller.allow_forgery_protection = false 32 | 33 | # Store uploaded files on the local file system in a temporary directory. 34 | config.active_storage.service = :test 35 | 36 | config.action_mailer.perform_caching = false 37 | 38 | # Tell Action Mailer not to deliver emails to the real world. 39 | # The :test delivery method accumulates sent emails in the 40 | # ActionMailer::Base.deliveries array. 41 | config.action_mailer.delivery_method = :test 42 | 43 | # Print deprecation notices to the stderr. 44 | config.active_support.deprecation = :stderr 45 | 46 | # Raises error for missing translations. 47 | config.action_view.raise_on_missing_translations = true 48 | 49 | config.after_initialize do 50 | Bullet.enable = true 51 | Bullet.bullet_logger = false 52 | Bullet.rails_logger = true 53 | Bullet.n_plus_one_query_enable = true 54 | Bullet.unused_eager_loading_enable = true 55 | Bullet.counter_cache_enable = true 56 | Bullet.raise = true 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | # Run rails dev:cache to toggle caching. 17 | if Rails.root.join('tmp/caching-dev.txt').exist? 18 | config.cache_store = :memory_store 19 | config.public_file_server.headers = { 20 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 21 | } 22 | else 23 | config.action_controller.perform_caching = false 24 | 25 | config.cache_store = :null_store 26 | end 27 | 28 | # Store uploaded files on the local file system (see config/storage.yml for options). 29 | config.active_storage.service = :local 30 | 31 | # Mailer 32 | config.action_mailer.delivery_method = :letter_opener 33 | config.action_mailer.default_url_options = { host: 'localhost' } 34 | config.action_mailer.perform_deliveries = true 35 | config.action_mailer.raise_delivery_errors = false 36 | config.action_mailer.perform_caching = false 37 | config.action_mailer.preview_path = Rails.root.join('spec/mailers/previews') 38 | 39 | # Print deprecation notices to the Rails logger. 40 | config.active_support.deprecation = :log 41 | 42 | # Raise an error on page load if there are pending migrations. 43 | config.active_record.migration_error = :page_load 44 | 45 | # Highlight code that triggered database queries in logs. 46 | config.active_record.verbose_query_logs = true 47 | 48 | # Raises error for missing translations. 49 | # config.action_view.raise_on_missing_translations = true 50 | 51 | # Use an evented file watcher to asynchronously detect changes in source code, 52 | # routes, locales, etc. This feature depends on the listen gem. 53 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 54 | 55 | config.after_initialize do 56 | Bullet.enable = true 57 | Bullet.alert = false 58 | Bullet.bullet_logger = true 59 | Bullet.console = true 60 | Bullet.rails_logger = true 61 | Bullet.add_footer = true 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/requests/users/update_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe 'Update user mutation request', type: :request do 4 | subject(:request) do 5 | graphql_request(request_body, variables: request_variables, headers: auth_headers) 6 | end 7 | 8 | let!(:user) { create(:user) } 9 | let(:first_name) { 'Obi Wan' } 10 | let(:last_name) { 'Kenobi' } 11 | let(:email) { 'obikenobi@rebel.com' } 12 | let(:password) { 'abcd1234' } 13 | let(:request_body) do 14 | <<~GQL 15 | mutation UpdateUser( 16 | $firstName: String, 17 | $lastName: String, 18 | $email: String, 19 | $password: String 20 | ) { 21 | updateUser(input: { 22 | firstName: $firstName, 23 | lastName: $lastName, 24 | email: $email, 25 | password: $password 26 | }) { 27 | user { 28 | id 29 | firstName 30 | lastName 31 | email 32 | } 33 | } 34 | } 35 | GQL 36 | end 37 | let(:request_variables) do 38 | { 39 | firstName: first_name, 40 | lastName: last_name, 41 | email: email, 42 | password: password 43 | } 44 | end 45 | let(:response_content) { json.dig(:data, :updateUser) } 46 | 47 | context 'with valid params' do 48 | let(:updated_user) { user.reload } 49 | 50 | specify do 51 | request 52 | 53 | expect(errors).to be_nil 54 | end 55 | 56 | specify do 57 | request 58 | 59 | expect(response).to have_http_status(:ok) 60 | end 61 | 62 | it 'updates current user' do 63 | request 64 | 65 | expect(updated_user.first_name).to eq(first_name) 66 | end 67 | 68 | it 'returns the user data' do 69 | request 70 | 71 | expect(response_content[:user]).to include_json( 72 | id: updated_user.id.to_s, 73 | firstName: updated_user.first_name, 74 | lastName: updated_user.last_name, 75 | email: updated_user.email 76 | ) 77 | end 78 | end 79 | 80 | context 'with invalid params' do 81 | context 'when the email is missing' do 82 | let(:email) { '' } 83 | 84 | it 'returns an error message' do 85 | request 86 | 87 | expect(first_error_message).not_to be_nil 88 | end 89 | 90 | it 'does not update current user' do 91 | expect { 92 | request 93 | }.not_to change(user.reload, :email) 94 | end 95 | end 96 | 97 | context 'when the email is taken' do 98 | before { create(:user, email: email) } 99 | 100 | it 'returns an error message' do 101 | request 102 | 103 | expect(first_error_message).not_to be_nil 104 | end 105 | 106 | it 'does not update current user' do 107 | expect { 108 | request 109 | }.not_to change(user.reload, :email) 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /spec/requests/users/create_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe 'Create user mutation request', type: :request do 4 | subject(:request) { graphql_request(mutation_request, variables: request_variables) } 5 | 6 | let(:first_name) { 'Obi Wan' } 7 | let(:last_name) { 'Kenobi' } 8 | let(:email) { 'obikenobi@rebel.com' } 9 | let(:password) { 'abcd1234' } 10 | 11 | let(:mutation_request) do 12 | <<~GQL 13 | mutation SignUp( 14 | $email: String!, 15 | $password: String!, 16 | $firstName: String!, 17 | $lastName: String! 18 | ) { 19 | createUser(input: { 20 | email: $email, 21 | password: $password, 22 | firstName: $firstName, 23 | lastName: $lastName 24 | }) { 25 | user { 26 | id 27 | firstName 28 | lastName 29 | email 30 | } 31 | token 32 | } 33 | } 34 | GQL 35 | end 36 | 37 | let(:request_variables) do 38 | { 39 | firstName: first_name, 40 | lastName: last_name, 41 | email: email, 42 | password: password 43 | } 44 | end 45 | 46 | let(:response_content) { json.dig(:data, :createUser) } 47 | 48 | context 'with valid params' do 49 | let(:created_user) { User.last } 50 | 51 | specify do 52 | request 53 | 54 | expect(errors).to be_nil 55 | end 56 | 57 | specify do 58 | request 59 | 60 | expect(response).to have_http_status(:ok) 61 | end 62 | 63 | it 'creates a user' do 64 | expect { 65 | request 66 | }.to change(User, :count).by(1) 67 | end 68 | 69 | it 'returns the user data' do 70 | request 71 | 72 | expect(response_content[:user]).to include_json( 73 | id: created_user.id.to_s, 74 | firstName: created_user.first_name, 75 | lastName: created_user.last_name, 76 | email: created_user.email 77 | ) 78 | end 79 | 80 | specify do 81 | request 82 | 83 | token = response_content[:token] 84 | 85 | expect(token).not_to be_nil 86 | end 87 | 88 | it 'sets the authentication headers' do 89 | request 90 | 91 | token = response_content[:token] 92 | 93 | expect(AuthToken.verify(token)).to eq(created_user) 94 | end 95 | end 96 | 97 | context 'with invalid params' do 98 | context 'when the email is missing' do 99 | let(:email) { '' } 100 | 101 | it 'returns an error message' do 102 | request 103 | 104 | expect(first_error_message).not_to be_nil 105 | end 106 | 107 | it 'does not create a user' do 108 | expect { 109 | request 110 | }.not_to change(User, :count) 111 | end 112 | end 113 | 114 | context 'when the password is missing' do 115 | let(:password) { '' } 116 | 117 | it 'returns an error message' do 118 | request 119 | 120 | expect(first_error_message).not_to be_nil 121 | end 122 | 123 | it 'does not create a user' do 124 | expect { 125 | request 126 | }.not_to change(User, :count) 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /spec/requests/users/sign_in_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe 'Sign in user mutation request', type: :request do 4 | subject(:request) do 5 | graphql_request(request_body, variables: request_variables) 6 | end 7 | 8 | let!(:user) { create(:user, email: email, password: password) } 9 | 10 | let(:email) { 'user@email.com' } 11 | let(:password) { 'abcd1234' } 12 | 13 | let(:request_body) do 14 | <<~GQL 15 | mutation SignIn($email: String!, $password: String!) { 16 | signInUser(input: {email: $email, password: $password}) { 17 | user { 18 | id 19 | firstName 20 | lastName 21 | email 22 | } 23 | token 24 | } 25 | } 26 | GQL 27 | end 28 | 29 | let(:request_variables) do 30 | { 31 | email: email_param, 32 | password: password_param 33 | } 34 | end 35 | 36 | let(:email_param) { email } 37 | let(:password_param) { password } 38 | 39 | let(:response_content) { json.dig(:data, :signInUser) } 40 | 41 | context 'with valid params' do 42 | specify do 43 | request 44 | 45 | expect(errors).to be_nil 46 | end 47 | 48 | specify do 49 | request 50 | 51 | expect(response).to have_http_status(:ok) 52 | end 53 | 54 | it 'does not create a new user' do 55 | expect { 56 | request 57 | }.not_to change(User, :count) 58 | end 59 | 60 | it 'returns the user data' do 61 | request 62 | 63 | expect(response_content[:user]).to include_json( 64 | id: user.id.to_s, 65 | firstName: user.first_name, 66 | lastName: user.last_name, 67 | email: user.email 68 | ) 69 | end 70 | 71 | specify do 72 | request 73 | 74 | token = response_content[:token] 75 | 76 | expect(token).not_to be_nil 77 | end 78 | 79 | it 'sets the authentication headers' do 80 | request 81 | 82 | token = response_content[:token] 83 | 84 | expect(AuthToken.verify(token)).to eq(user) 85 | end 86 | end 87 | 88 | context 'with invalid params' do 89 | let(:first_error) { errors.first[:message] } 90 | 91 | shared_examples_for 'does not sign in' do 92 | it 'returns an error message' do 93 | request 94 | 95 | expect(first_error).to eq(I18n.t('errors.invalid_credentials')) 96 | end 97 | 98 | it 'does not return the user sign in info' do 99 | request 100 | 101 | expect(response_content).to be_nil 102 | end 103 | end 104 | 105 | context 'when the email is missing' do 106 | let(:email_param) { '' } 107 | 108 | it_behaves_like 'does not sign in' 109 | end 110 | 111 | context 'when the password is missing' do 112 | let(:password_param) { '' } 113 | 114 | it_behaves_like 'does not sign in' 115 | end 116 | 117 | context 'when the email is not registered' do 118 | let(:email_param) { 'not@registered.com' } 119 | 120 | it_behaves_like 'does not sign in' 121 | end 122 | 123 | context 'when the password is invalid for the user' do 124 | let(:password_param) { 'notHisPassword' } 125 | 126 | it_behaves_like 'does not sign in' 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 || ">= 0.a" 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../../Gemfile", __FILE__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_version 64 | @bundler_version ||= begin 65 | env_var_version || cli_arg_version || 66 | lockfile_version || "#{Gem::Requirement.default}.a" 67 | end 68 | end 69 | 70 | def load_bundler! 71 | ENV["BUNDLE_GEMFILE"] ||= gemfile 72 | 73 | # must dup string for RG < 1.8 compatibility 74 | activate_bundler(bundler_version.dup) 75 | end 76 | 77 | def activate_bundler(bundler_version) 78 | if Gem::Version.correct?(bundler_version) && Gem::Version.new(bundler_version).release < Gem::Version.new("2.0") 79 | bundler_version = "< 2" 80 | end 81 | gem_error = activation_error_handling do 82 | gem "bundler", bundler_version 83 | end 84 | return if gem_error.nil? 85 | require_error = activation_error_handling do 86 | require "bundler/version" 87 | end 88 | return if require_error.nil? && Gem::Requirement.new(bundler_version).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 89 | warn "Activating bundler (#{bundler_version}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_version}'`" 90 | exit 42 91 | end 92 | 93 | def activation_error_handling 94 | yield 95 | nil 96 | rescue StandardError, LoadError => e 97 | e 98 | end 99 | end 100 | 101 | m.load_bundler! 102 | 103 | if m.invoked_as_script? 104 | load Gem.bin_path("bundler", "bundle") 105 | end 106 | -------------------------------------------------------------------------------- /.reek.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | ### Generic smell configuration 4 | 5 | detectors: 6 | Attribute: 7 | enabled: true 8 | BooleanParameter: 9 | enabled: true 10 | ClassVariable: 11 | enabled: true 12 | ControlParameter: 13 | enabled: true 14 | exclude: 15 | - resolve 16 | DataClump: 17 | enabled: true 18 | max_copies: 2 19 | min_clump_size: 2 20 | DuplicateMethodCall: 21 | enabled: true 22 | max_calls: 1 23 | FeatureEnvy: 24 | enabled: true 25 | exclude: 26 | - GraphqlRequestResolver#resolve_execute 27 | - GraphqlRequestResolver#resolve_multiplex 28 | - GraphqlUtils::ParseErrorBetterPrinted#initialize 29 | InstanceVariableAssumption: 30 | enabled: false 31 | IrresponsibleModule: 32 | enabled: false 33 | LongParameterList: 34 | enabled: true 35 | max_params: 3 36 | overrides: 37 | initialize: 38 | max_params: 5 39 | LongYieldList: 40 | enabled: true 41 | max_params: 3 42 | ManualDispatch: 43 | enabled: true 44 | MissingSafeMethod: 45 | enabled: false 46 | ModuleInitialize: 47 | enabled: true 48 | NestedIterators: 49 | enabled: true 50 | ignore_iterators: 51 | - tap 52 | max_allowed_nesting: 1 53 | NilCheck: 54 | enabled: false 55 | RepeatedConditional: 56 | enabled: true 57 | max_ifs: 2 58 | SubclassedFromCoreClass: 59 | enabled: true 60 | TooManyConstants: 61 | enabled: true 62 | max_constants: 5 63 | TooManyInstanceVariables: 64 | enabled: true 65 | max_instance_variables: 4 66 | TooManyMethods: 67 | enabled: true 68 | max_methods: 15 69 | TooManyStatements: 70 | enabled: true 71 | exclude: 72 | - initialize 73 | max_statements: 9 74 | UncommunicativeMethodName: 75 | enabled: true 76 | reject: 77 | - "/^[a-z]$/" 78 | - "/[0-9]$/" 79 | - "/[A-Z]/" 80 | UncommunicativeModuleName: 81 | accept: 82 | - "/V[0-9]/" 83 | enabled: true 84 | reject: 85 | - '/^.$/' 86 | - '/[0-9]$/' 87 | accept: 88 | - "/V[0-9]/" 89 | UncommunicativeParameterName: 90 | enabled: true 91 | reject: 92 | - /^.$/ 93 | - "/[0-9]$/" 94 | - "/[A-Z]/" 95 | - /^_/ 96 | UncommunicativeVariableName: 97 | accept: 98 | - /^_$/ 99 | - e 100 | enabled: true 101 | reject: 102 | - /^.$/ 103 | - "/[0-9]$/" 104 | - "/[A-Z]/" 105 | UnusedParameters: 106 | enabled: true 107 | UnusedPrivateMethod: 108 | enabled: false 109 | UtilityFunction: 110 | enabled: true 111 | exclude: 112 | - Loaders::AssociationLoader#cache_key 113 | - resolve 114 | public_methods_only: false 115 | 116 | ### Directory specific configuration 117 | 118 | directories: 119 | app/graphql/query_analyzers/concerns: 120 | UtilityFunction: 121 | enabled: false 122 | app/jobs: 123 | FeatureEnvy: 124 | enabled: false 125 | UtilityFunction: 126 | enabled: false 127 | app/presenters: 128 | FeatureEnvy: 129 | enabled: false 130 | UtilityFunction: 131 | enabled: false 132 | app/services: 133 | FeatureEnvy: 134 | enabled: false 135 | UtilityFunction: 136 | enabled: false 137 | app/uploaders: 138 | UtilityFunction: 139 | enabled: false 140 | db/migrate: 141 | UtilityFunction: 142 | enabled: false 143 | FeatureEnvy: 144 | enabled: false 145 | TooManyStatements: 146 | enabled: false 147 | UncommunicativeVariableName: 148 | enabled: false 149 | "rubocop": 150 | UtilityFunction: 151 | enabled: false 152 | FeatureEnvy: 153 | enabled: false 154 | -------------------------------------------------------------------------------- /PAGINATION.md: -------------------------------------------------------------------------------- 1 | Pagination 2 | -------- 3 | 4 | For cursor pagination, you need to define a connection for the model you are querying. 5 | 6 | For example if you need to return the collection of all the User records in the app: 7 | ``` 8 | field :users_connection, Types::CustomTypes::UserType.connection_type, null: false 9 | 10 | def users_connection 11 | User.all 12 | end 13 | ``` 14 | With this defined, the query would need to have the following structure: 15 | ``` 16 | query { 17 | usersConnection { 18 | edges { 19 | node { 20 | id 21 | firstName 22 | } 23 | } 24 | } 25 | } 26 | ``` 27 | and the response will have this structure: 28 | ``` 29 | { 30 | "data": { 31 | "userConnection": { 32 | "edges": [ 33 | { 34 | "node": { 35 | "id": "1", 36 | "firstName": "Myesha", 37 | "lastName": "Stoltenberg" 38 | } 39 | }, 40 | { 41 | "node": { 42 | "id": "2", 43 | "firstName": "Oscar", 44 | "lastName": "Kozey" 45 | } 46 | } 47 | ] 48 | } 49 | } 50 | } 51 | ```` 52 | With this set up we can request useful information for pagination 53 | 54 | at the connection level: 55 | ``` 56 | pageInfo { 57 | 58 | startCursor: the cursor of the first returned value 59 | 60 | endCursor: the cursor of the last returned value 61 | 62 | hasPreviousPage: a boolean indicating if there are records before the first cursor 63 | 64 | hasNextPage: a boolean indicating if there are records after the last cursor 65 | } 66 | 67 | totalCount: number of values that the collection contains 68 | 69 | ``` 70 | 71 | in the node level: 72 | ``` 73 | cursor: the cursor of the actual node 74 | ``` 75 | 76 | Adding this to the request would look something like: 77 | ``` 78 | query { 79 | usersConnection{ 80 | pageInfo { 81 | startCursor 82 | endCursor 83 | hasPreviousPage 84 | hasNextPage 85 | } 86 | edges { 87 | cursor 88 | node { 89 | id 90 | firstName 91 | lastName 92 | } 93 | } 94 | totalCount 95 | } 96 | } 97 | ``` 98 | with this response: 99 | ``` 100 | { 101 | "data": { 102 | "usersConnection": { 103 | "pageInfo": { 104 | "startCursor": "MQ", 105 | "endCursor": "Mw", 106 | "hasPreviousPage": false, 107 | "hasNextPage": true 108 | }, 109 | "totalCount": 21, 110 | "edges": [ 111 | { 112 | "node": { 113 | "id": "1", 114 | "firstName": "Myesha", 115 | "lastName": "Stoltenberg" 116 | }, 117 | "cursor": "MQ" 118 | }, 119 | { 120 | "node": { 121 | "id": "2", 122 | ... 123 | ] 124 | } 125 | } 126 | } 127 | ``` 128 | 129 | Finally, the connection could receive some values as parameters to customize de response for only returning the desirable records. 130 | 131 | `first: int` returns the first given number of records 132 | `last: int` returns the last given number of records 133 | `after: String` returns the records after the given cursor 134 | `before: String` returns the records before the given cursor 135 | 136 | For example: `usersConnection(first: 5 after: "Mw"){ ... }` returns the first 5 records after the node with the "Mw" cursor. 137 | 138 | 139 | It's important to take into account that `hasPreviousPage` would always be false unless you are paginating forward (`UserConnection(first: 5)`), and `hasNextPage` would always be false unless paginating backward (`UserConnection(last: 5)`). This is not an implementation error, it's due to how connections are designed. 140 | 141 | If you need the hasPreviosPage value when paginating forward, you could make an additional query requesting the last 0 values before the node of which we want to know if there are values before: 142 | ``` 143 | query { 144 | userConnection(last: 0, before: "MQ") { 145 | pageInfo { 146 | hasPreviousPage 147 | } 148 | } 149 | } 150 | ``` 151 | or the first 0 values after the desired node when we want to know if it `hasNextPage` while paginating backward. 152 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | 16 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 17 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 18 | # config.require_master_key = true 19 | 20 | # Disable serving static files from the `/public` folder by default since 21 | # Apache or NGINX already handles this. 22 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 23 | 24 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 25 | # config.action_controller.asset_host = 'http://assets.example.com' 26 | 27 | # Specifies the header that your server uses for sending files. 28 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 29 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 30 | 31 | # Store uploaded files on the local file system (see config/storage.yml for options). 32 | config.active_storage.service = :local 33 | 34 | # Mount Action Cable outside main process or domain. 35 | # config.action_cable.mount_path = nil 36 | # config.action_cable.url = 'wss://example.com/cable' 37 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 38 | 39 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 40 | # config.force_ssl = true 41 | 42 | # Use the lowest log level to ensure availability of diagnostic information 43 | # when problems arise. 44 | config.log_level = :debug 45 | 46 | # Prepend all log lines with the following tags. 47 | config.log_tags = [:request_id] 48 | 49 | # Use a different cache store in production. 50 | # config.cache_store = :mem_cache_store 51 | 52 | # Use a real queuing backend for Active Job (and separate queues per environment). 53 | # config.active_job.queue_adapter = :resque 54 | # config.active_job.queue_name_prefix = "rails_api_boilerplate_production" 55 | 56 | config.action_mailer.perform_caching = false 57 | 58 | # Ignore bad email addresses and do not raise email delivery errors. 59 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 60 | # config.action_mailer.raise_delivery_errors = false 61 | 62 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 63 | # the I18n.default_locale when a translation cannot be found). 64 | config.i18n.fallbacks = true 65 | 66 | # Send deprecation notices to registered listeners. 67 | config.active_support.deprecation = :notify 68 | 69 | # Use default logging formatter so that PID and timestamp are not suppressed. 70 | config.log_formatter = ::Logger::Formatter.new 71 | 72 | # Use a different logger for distributed setups. 73 | # require 'syslog/logger' 74 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 75 | 76 | if ENV['RAILS_LOG_TO_STDOUT'].present? 77 | logger = ActiveSupport::Logger.new($stdout) 78 | logger.formatter = config.log_formatter 79 | config.logger = ActiveSupport::TaggedLogging.new(logger) 80 | end 81 | 82 | # Do not dump schema after migrations. 83 | config.active_record.dump_schema_after_migration = false 84 | 85 | # Inserts middleware to perform automatic connection switching. 86 | # The `database_selector` hash is used to pass options to the DatabaseSelector 87 | # middleware. The `delay` is used to determine how long to wait after a write 88 | # to send a subsequent read to the primary. 89 | # 90 | # The `database_resolver` class is used by the middleware to determine which 91 | # database is appropriate to use based on the time delay. 92 | # 93 | # The `database_resolver_context` class is used by the middleware to set 94 | # timestamps for the last write to the primary. The resolver uses the context 95 | # class timestamps to determine how long to wait before reading from the 96 | # replica. 97 | # 98 | # By default Rails will store a last write timestamp in the session. The 99 | # DatabaseSelector middleware is designed as such you can define your own 100 | # strategy for connection switching and pass that into the middleware through 101 | # these configuration options. 102 | # config.active_record.database_selector = { delay: 2.seconds } 103 | # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver 104 | # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session 105 | end 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://user-images.githubusercontent.com/15303963/84329869-3dc6c880-ab5c-11ea-9cff-37a78887e75b.png) 2 | 3 |
Start your next Rails 6 + GraphQL API project in seconds
4 | 5 | ![Github Actions badge](https://github.com/loopstudio/rails-graphql-api-boilerplate/workflows/Tests%20and%20Linters/badge.svg) 6 | [![Codebeat badge](https://codebeat.co/badges/05ab59ff-1c2e-4d80-af7f-3d6b92ad8513)](https://codebeat.co/a/loopstudio/projects/github-com-loopstudio-rails-graphql-api-boilerplate-master) 7 | 8 | Created and maintained with ❤️ by LoopStudio 9 | 10 |
A foundation with a focus on performance and best practices
11 | 12 | ## Table of Contents 13 | 14 | - [Main Characteristics](#main-characteristics) 15 | - [Other Gems](#other-gems) 16 | - [Getting Started](#getting-started) 17 | - [Code quality](#code-quality) 18 | - [Tests](#tests) 19 | - [Graphiql](#graphiql) 20 | - [Pagination](#pagination) 21 | - [Continuous Deployment](#continuous-deployment) 22 | - [Contributing](#contributing) 23 | - [License](#license) 24 | 25 | ## Main Characteristics 26 | 27 | - Language: Ruby 2.7.2+ 28 | - Framework: Rails 6.1.1+ 29 | - Query language: GraphQL 1.12.2+ 30 | - Webserver: Puma 31 | - Test Framework: RSpec 32 | - Databases: Postgres & Redis 33 | - Async Processor: Sidekiq 34 | 35 | ## Other Gems 36 | 37 | #### dotenv-rails 38 | For environment variables. 39 | 40 | #### graphql 41 | For the implementation of [GraphQL](https://github.com/rmosolgo/graphql-ruby) query language in ruby. 42 | 43 | #### graphiql-rails 44 | [graphiql](https://github.com/graphql/graphiql) is an interactive in-browser IDE that simplifies the testing of your API and exposes the API available mutations, queries, and subscriptions. 45 | 46 | #### bullet 47 | For detecting N+1 issues. 48 | 49 | #### graphql-batch 50 | For allowing queries to be batched to avoid the N+1 issues. You can read the documentation [Here](https://github.com/Shopify/graphql-batch#usage). 51 | 52 | #### JWT 53 | To generate the tokens required to authenticate we use [JWT](https://github.com/jwt/ruby-jwt). 54 | 55 | ## Getting Started 56 | 57 | 1. Make sure that you have Rails 6, PostgreSQL, git cli, and bundle installed. 58 | 2. Clone this repo using `git clone --depth=1 https://github.com/LoopStudio/rails-graphql-api-boilerplate.git ` 59 | 3. Update the values of the `.env.template` file to match your app 60 | 4. Create your `.env` file. You have an example at `.env.template`. You should be able to copy it and set your own values. 61 | _It's a good practice to keep the `.env.template` updated every time you need a new environment variable._ 62 | 5. It is required to set the ENV variable `JWT_ENCODING_KEY` with the result of running `OpenSSL::Digest.new('sha256')` on the rails console to enable the token authentication. 63 | 6. Run `bundle install` 64 | 7. Run `bundle exec rake db:create` 65 | 8. Run `bundle exec rake db:migrate` 66 | 9. Run `bundle exec rake db:seed` 67 | 10. Check the test are passing running `rspec` 68 | _At this point you can run `rails s` and start making your API calls at `http://localhost:3000`_ 69 | 11. Edit or delete the `CODEOWNERS` file in `.github` directory 70 | 12. Edit this README file to match your project title and description 71 | _It's a good practice to keep this file updated as you make important changes to the installation instructions or project characteristics._ 72 | 13. Delete the `.github/workflows/deploy.yml` file, and uncomment the other workflows or configure your continuous deployment workflow since you might use different environments. 73 | 14. Modify the `.github/CODEOWNERS` file 74 | 75 | ## Tests 76 | 77 | You can run the unit tests with `rspec` or `rspec` followed by a specific test file or directory. 78 | 79 | ## Code Quality 80 | 81 | With `rake linters` you can run the code analysis tool, you can omit rules with: 82 | 83 | - [Rubocop](https://github.com/bbatsov/rubocop/blob/master/config/default.yml) Edit `.rubocop.yml` 84 | 85 | When you update RuboCop version, sometimes you need to change `.rubocop.yml`. If you use [mry](https://github.com/pocke/mry), you can update `.rubocop.yml` to the latest version automatically. 86 | 87 | - [Reek](https://github.com/troessner/reek#configuration-file) Edit `config.reek` 88 | 89 | Pass the `-a` option to auto-fix (only for some linters). 90 | 91 | ## Graphiql 92 | 93 | Graphiql will be mounted on the path `/graphiql`, only if you have set the environment variable `EXPOSE_API_INSIGHTS` in `"true"`. 94 | 95 | For example `http://localhost:3000/graphiql` if you are running your server on 3000 port. 96 | 97 | GraphiQL does not allow us to send headers in the request so if you want to use it as a signed-in user you should get the session token and set it as the environment variables `GRAPHIQL_SESSION_TOKEN`, restart the server and then you should be good to go. 98 | 99 | ## Pagination 100 | 101 | You can find a detailed guide on how to implement pagination [here](PAGINATION.md). 102 | 103 | ## Features 104 | 105 | You can always check all the Mutations and Queries available by going to `/graphiql` and looking at the Docs. 106 | 107 | ### Mutations 108 | 109 | - Sign up 110 | - Sign in 111 | - Update profile 112 | - Delete user 113 | 114 | ### Queries 115 | 116 | - Show profile 117 | 118 | ## Continuous Deployment 119 | 120 | **This boilerplate contains commented out code for a quick Continuous Deployment setup using Github actions and deploying to the Heroku platform.** 121 | 122 | *(If you are not using those two tools you might simply want to remove the workflows directory and disregard the rest of these instructions.)* 123 | 124 | Since we are used to using git-flow for branching and having **three different environments (dev, staging, and production)**, this boilerplate includes three commented out files on the `.github/workflows` folder so that, when using this repo for an actual project, you can keep these environments updated simply by doing a proper use of the branches. 125 | 126 | * **Deploy to dev**: Triggered every time `develop` branch gets pushed to. For instance, whenever a new feature branch gets merged into the develop branch. 127 | 128 | * **Deploy to staging**: Triggered every time somebody creates (or updates) a Pull Request to master. We usually call these branches using the format: `release/vx.y.z` but it will work regardless of the branch name. We create a release Pull Request at the end of each sprint to deploy to staging the new set of changes, and we leave the Pull Request `On Hold` until we are ready to ship to production. 129 | 130 | * **Deploy to production**: Once the staging changes are approved by the Product Owner, we merge the release branch Pull Request into master, triggering a push on the master branch which deploys to production. 131 | 132 | For this to work you will need the configure some Secrets on your GitHub repository. To add these go to your Github project, click on `Settings`, and then `Secrets`. 133 | 134 | You need to add the following Secrets: 135 | 136 | * **HEROKU_EMAIL**: Email of the owner account of the Heroku apps. 137 | * **HEROKU_API_KEY**: API Key of the owner account of the Heroku apps. Get it by going to your Heroku account, `Account Settings` and Scroll down to reveal the `API KEY`. 138 | * **HEROKU_DEV_APP**: Name of the development app. Eg. `my-project-develop-api` 139 | * **HEROKU_PROD_APP**: Name of the production app. Eg. `my-project-api` 140 | * **HEROKU_STAGING_APP**: Name of the staging app. Eg. `my-project-staging-api` 141 | 142 | ### Notes on Continuous Deployment 143 | 144 | * You can disregard and remove the `deploy.yml` file since we use it to deploy the boilerplate code itself as we work on it, but it will probably be useless to you once you clone this repo for your real-world use case. 145 | 146 | * If you use a different branching strategy or different environments layout, simply delete the files under the workflows directory and set up your own. 147 | 148 | ## Contributing 149 | 150 | If you've noticed a bug or find something that needs to be refactored, feel free to open an issue or even better, a Pull Request! 151 | 152 | ## License 153 | 154 | This project is licensed under the MIT license. 155 | 156 | Copyright (c) 2020 LoopStudio. 157 | 158 | For more information see [`LICENSE`](LICENSE) 159 | 160 | -------- 161 | 162 | [](https://loopstudio.dev) 163 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actioncable (6.1.5.1) 5 | actionpack (= 6.1.5.1) 6 | activesupport (= 6.1.5.1) 7 | nio4r (~> 2.0) 8 | websocket-driver (>= 0.6.1) 9 | actionmailbox (6.1.5.1) 10 | actionpack (= 6.1.5.1) 11 | activejob (= 6.1.5.1) 12 | activerecord (= 6.1.5.1) 13 | activestorage (= 6.1.5.1) 14 | activesupport (= 6.1.5.1) 15 | mail (>= 2.7.1) 16 | actionmailer (6.1.5.1) 17 | actionpack (= 6.1.5.1) 18 | actionview (= 6.1.5.1) 19 | activejob (= 6.1.5.1) 20 | activesupport (= 6.1.5.1) 21 | mail (~> 2.5, >= 2.5.4) 22 | rails-dom-testing (~> 2.0) 23 | actionpack (6.1.5.1) 24 | actionview (= 6.1.5.1) 25 | activesupport (= 6.1.5.1) 26 | rack (~> 2.0, >= 2.0.9) 27 | rack-test (>= 0.6.3) 28 | rails-dom-testing (~> 2.0) 29 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 30 | actiontext (6.1.5.1) 31 | actionpack (= 6.1.5.1) 32 | activerecord (= 6.1.5.1) 33 | activestorage (= 6.1.5.1) 34 | activesupport (= 6.1.5.1) 35 | nokogiri (>= 1.8.5) 36 | actionview (6.1.5.1) 37 | activesupport (= 6.1.5.1) 38 | builder (~> 3.1) 39 | erubi (~> 1.4) 40 | rails-dom-testing (~> 2.0) 41 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 42 | activejob (6.1.5.1) 43 | activesupport (= 6.1.5.1) 44 | globalid (>= 0.3.6) 45 | activemodel (6.1.5.1) 46 | activesupport (= 6.1.5.1) 47 | activerecord (6.1.5.1) 48 | activemodel (= 6.1.5.1) 49 | activesupport (= 6.1.5.1) 50 | activestorage (6.1.5.1) 51 | actionpack (= 6.1.5.1) 52 | activejob (= 6.1.5.1) 53 | activerecord (= 6.1.5.1) 54 | activesupport (= 6.1.5.1) 55 | marcel (~> 1.0) 56 | mini_mime (>= 1.1.0) 57 | activesupport (6.1.5.1) 58 | concurrent-ruby (~> 1.0, >= 1.0.2) 59 | i18n (>= 1.6, < 2) 60 | minitest (>= 5.1) 61 | tzinfo (~> 2.0) 62 | zeitwerk (~> 2.3) 63 | addressable (2.8.0) 64 | public_suffix (>= 2.0.2, < 5.0) 65 | annotate (3.2.0) 66 | activerecord (>= 3.2, < 8.0) 67 | rake (>= 10.4, < 14.0) 68 | ast (2.4.2) 69 | bcrypt (3.1.18) 70 | bootsnap (1.12.0) 71 | msgpack (~> 1.2) 72 | builder (3.2.4) 73 | bullet (7.0.2) 74 | activesupport (>= 3.0.0) 75 | uniform_notifier (~> 1.11) 76 | byebug (11.1.3) 77 | concurrent-ruby (1.1.10) 78 | crack (0.4.5) 79 | rexml 80 | crass (1.0.6) 81 | diff-lcs (1.5.0) 82 | docile (1.4.0) 83 | dotenv (2.7.6) 84 | dotenv-rails (2.7.6) 85 | dotenv (= 2.7.6) 86 | railties (>= 3.2) 87 | erubi (1.10.0) 88 | factory_bot (6.2.1) 89 | activesupport (>= 5.0.0) 90 | factory_bot_rails (6.2.0) 91 | factory_bot (~> 6.2.0) 92 | railties (>= 5.0.0) 93 | faker (2.21.0) 94 | i18n (>= 1.8.11, < 2) 95 | ffi (1.15.5) 96 | globalid (1.0.0) 97 | activesupport (>= 5.0) 98 | graphiql-rails (1.8.0) 99 | railties 100 | sprockets-rails 101 | graphql (2.0.11) 102 | graphql-batch (0.5.1) 103 | graphql (>= 1.10, < 3) 104 | promise.rb (~> 0.7.2) 105 | hashdiff (1.0.1) 106 | i18n (1.10.0) 107 | concurrent-ruby (~> 1.0) 108 | jwt (2.4.1) 109 | kwalify (0.7.2) 110 | launchy (2.5.0) 111 | addressable (~> 2.7) 112 | letter_opener (1.8.1) 113 | launchy (>= 2.2, < 3) 114 | listen (3.7.1) 115 | rb-fsevent (~> 0.10, >= 0.10.3) 116 | rb-inotify (~> 0.9, >= 0.9.10) 117 | loofah (2.18.0) 118 | crass (~> 1.0.2) 119 | nokogiri (>= 1.5.9) 120 | mail (2.7.1) 121 | mini_mime (>= 0.1.1) 122 | marcel (1.0.2) 123 | method_source (1.0.0) 124 | mini_mime (1.1.2) 125 | mini_portile2 (2.8.0) 126 | minitest (5.15.0) 127 | msgpack (1.5.2) 128 | nio4r (2.5.8) 129 | nokogiri (1.13.6) 130 | mini_portile2 (~> 2.8.0) 131 | racc (~> 1.4) 132 | parallel (1.22.1) 133 | parser (3.1.2.0) 134 | ast (~> 2.4.1) 135 | pg (1.4.1) 136 | promise.rb (0.7.4) 137 | public_suffix (4.0.7) 138 | puma (5.6.4) 139 | nio4r (~> 2.0) 140 | racc (1.6.0) 141 | rack (2.2.3.1) 142 | rack-cors (1.1.1) 143 | rack (>= 2.0.0) 144 | rack-test (1.1.0) 145 | rack (>= 1.0, < 3) 146 | rails (6.1.5.1) 147 | actioncable (= 6.1.5.1) 148 | actionmailbox (= 6.1.5.1) 149 | actionmailer (= 6.1.5.1) 150 | actionpack (= 6.1.5.1) 151 | actiontext (= 6.1.5.1) 152 | actionview (= 6.1.5.1) 153 | activejob (= 6.1.5.1) 154 | activemodel (= 6.1.5.1) 155 | activerecord (= 6.1.5.1) 156 | activestorage (= 6.1.5.1) 157 | activesupport (= 6.1.5.1) 158 | bundler (>= 1.15.0) 159 | railties (= 6.1.5.1) 160 | sprockets-rails (>= 2.0.0) 161 | rails-dom-testing (2.0.3) 162 | activesupport (>= 4.2.0) 163 | nokogiri (>= 1.6) 164 | rails-html-sanitizer (1.4.2) 165 | loofah (~> 2.3) 166 | railties (6.1.5.1) 167 | actionpack (= 6.1.5.1) 168 | activesupport (= 6.1.5.1) 169 | method_source 170 | rake (>= 12.2) 171 | thor (~> 1.0) 172 | rainbow (3.1.1) 173 | rake (13.0.6) 174 | rb-fsevent (0.11.1) 175 | rb-inotify (0.10.1) 176 | ffi (~> 1.0) 177 | redis (4.6.0) 178 | reek (6.1.1) 179 | kwalify (~> 0.7.0) 180 | parser (~> 3.1.0) 181 | rainbow (>= 2.0, < 4.0) 182 | regexp_parser (2.5.0) 183 | rexml (3.2.5) 184 | rspec-core (3.11.0) 185 | rspec-support (~> 3.11.0) 186 | rspec-expectations (3.11.0) 187 | diff-lcs (>= 1.2.0, < 2.0) 188 | rspec-support (~> 3.11.0) 189 | rspec-json_expectations (2.2.0) 190 | rspec-mocks (3.11.1) 191 | diff-lcs (>= 1.2.0, < 2.0) 192 | rspec-support (~> 3.11.0) 193 | rspec-rails (5.1.2) 194 | actionpack (>= 5.2) 195 | activesupport (>= 5.2) 196 | railties (>= 5.2) 197 | rspec-core (~> 3.10) 198 | rspec-expectations (~> 3.10) 199 | rspec-mocks (~> 3.10) 200 | rspec-support (~> 3.10) 201 | rspec-support (3.11.0) 202 | rubocop (1.30.1) 203 | parallel (~> 1.10) 204 | parser (>= 3.1.0.0) 205 | rainbow (>= 2.2.2, < 4.0) 206 | regexp_parser (>= 1.8, < 3.0) 207 | rexml (>= 3.2.5, < 4.0) 208 | rubocop-ast (>= 1.18.0, < 2.0) 209 | ruby-progressbar (~> 1.7) 210 | unicode-display_width (>= 1.4.0, < 3.0) 211 | rubocop-ast (1.18.0) 212 | parser (>= 3.1.1.0) 213 | rubocop-rails (2.15.0) 214 | activesupport (>= 4.2.0) 215 | rack (>= 1.1) 216 | rubocop (>= 1.7.0, < 2.0) 217 | rubocop-rspec (2.11.1) 218 | rubocop (~> 1.19) 219 | ruby-progressbar (1.11.0) 220 | sentry-rails (5.3.1) 221 | railties (>= 5.0) 222 | sentry-ruby-core (~> 5.3.1) 223 | sentry-ruby (5.3.1) 224 | concurrent-ruby (~> 1.0, >= 1.0.2) 225 | sentry-ruby-core (= 5.3.1) 226 | sentry-ruby-core (5.3.1) 227 | concurrent-ruby 228 | shoulda-matchers (5.1.0) 229 | activesupport (>= 5.2.0) 230 | simplecov (0.21.2) 231 | docile (~> 1.1) 232 | simplecov-html (~> 0.11) 233 | simplecov_json_formatter (~> 0.1) 234 | simplecov-html (0.12.3) 235 | simplecov_json_formatter (0.1.4) 236 | spring (2.1.1) 237 | spring-watcher-listen (2.0.1) 238 | listen (>= 2.7, < 4.0) 239 | spring (>= 1.2, < 3.0) 240 | sprockets (4.0.3) 241 | concurrent-ruby (~> 1.0) 242 | rack (> 1, < 3) 243 | sprockets-rails (3.4.2) 244 | actionpack (>= 5.2) 245 | activesupport (>= 5.2) 246 | sprockets (>= 3.0.0) 247 | strong_migrations (1.2.0) 248 | activerecord (>= 5.2) 249 | thor (1.2.1) 250 | tzinfo (2.0.4) 251 | concurrent-ruby (~> 1.0) 252 | unicode-display_width (2.1.0) 253 | uniform_notifier (1.16.0) 254 | webmock (3.14.0) 255 | addressable (>= 2.8.0) 256 | crack (>= 0.3.2) 257 | hashdiff (>= 0.4.0, < 2.0.0) 258 | websocket-driver (0.7.5) 259 | websocket-extensions (>= 0.1.0) 260 | websocket-extensions (0.1.5) 261 | zeitwerk (2.6.0) 262 | 263 | PLATFORMS 264 | ruby 265 | 266 | DEPENDENCIES 267 | annotate (~> 3.2.0) 268 | bcrypt (~> 3.1.18) 269 | bootsnap (>= 1.4.2) 270 | bullet (~> 7.0.2) 271 | byebug 272 | dotenv-rails (~> 2.7.6) 273 | factory_bot_rails (~> 6.2.0) 274 | faker (~> 2.21.0) 275 | graphiql-rails (~> 1.8.0) 276 | graphql (~> 2.0.11) 277 | graphql-batch (~> 0.5.1) 278 | jwt (~> 2.4.1) 279 | letter_opener (~> 1.8.1) 280 | listen (>= 3.0.5, < 3.8) 281 | pg (~> 1.4.1) 282 | puma (~> 5.6) 283 | rack-cors (~> 1.1.1) 284 | rails (~> 6.1.5) 285 | redis (~> 4.6.0) 286 | reek (~> 6.1.1) 287 | rspec-json_expectations (~> 2.2.0) 288 | rspec-rails (~> 5.1.2) 289 | rubocop (~> 1.30.1) 290 | rubocop-rails (~> 2.15.0) 291 | rubocop-rspec (~> 2.11.1) 292 | sentry-rails 293 | sentry-ruby 294 | shoulda-matchers (~> 5.1.0) 295 | simplecov (~> 0.21.2) 296 | spring (~> 2.1.1) 297 | spring-watcher-listen (~> 2.0.0) 298 | strong_migrations (~> 1.2.0) 299 | tzinfo-data 300 | webmock (~> 3.14.0) 301 | 302 | RUBY VERSION 303 | ruby 2.7.2p137 304 | 305 | BUNDLED WITH 306 | 2.1.4 307 | --------------------------------------------------------------------------------