diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 94f5eecc..29af0e09 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,8 +10,16 @@ Screenshots/screencasts of the pull request introduced functionality. List of steps to manually test introduced functionality: -- Log in -- Go to http://localhost:3000 +- Go to http://localhost:3000/graphiql +- Make request using schema: +```graphql + query { + me { + id + name + } + } +``` ### Deploy notes diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b9342684..8693b440 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,4 +34,7 @@ jobs: - name: Run rubocop run: bin/rubocop + + - name: Run tests + run: bin/rspec ... diff --git a/.rubocop.yml b/.rubocop.yml index 924e11d2..da1f3d02 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -8,6 +8,14 @@ require: AllCops: NewCops: enable + Exclude: + - bin/**/* + - db/**/* + - vendor/**/* + - tmp/**/* + - app/controllers/graphql_controller.rb + - app/graphql/task_tracker_itis_schema.rb + - app/graphql/types/mutation_type.rb I18n/GetText/DecorateString: Enabled: false diff --git a/Gemfile b/Gemfile index b2b828f2..8a834768 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ ruby "2.7.4" gem "action_policy" gem "active_model_serializers" gem "enumerize" +gem "graphql" gem "interactor", "~> 3.0" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "~> 6.1.7" @@ -60,6 +61,7 @@ group :development do gem "listen", "~> 3.3" gem "rack-mini-profiler", "~> 2.0" # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring + gem "graphiql-rails" gem "spring" end diff --git a/Gemfile.lock b/Gemfile.lock index 0e859d0c..f1e1f7a8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -111,6 +111,11 @@ GEM ffi (1.15.5) globalid (1.2.1) activesupport (>= 6.1) + graphiql-rails (1.9.0) + railties + sprockets-rails + graphql (2.1.6) + racc (~> 1.4) i18n (1.14.1) concurrent-ruby (~> 1.0) interactor (3.1.2) @@ -346,6 +351,8 @@ DEPENDENCIES enumerize factory_bot_rails faker + graphiql-rails + graphql interactor (~> 3.0) jbuilder (~> 2.7) jwt diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb new file mode 100644 index 00000000..a62c440e --- /dev/null +++ b/app/controllers/graphql_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class GraphqlController < ApplicationController + # If accessing from outside this domain, nullify the session + # This allows for outside API access while preventing CSRF attacks, + # but you'll have to authenticate your user separately + protect_from_forgery with: :null_session + + skip_verify_authorized + + def execute + variables = prepare_variables(params[:variables]) + query = params[:query] + operation_name = params[:operationName] + context = { + current_user: current_user + } + result = TaskTrackerItisSchema.execute(query, variables: variables, context: context, operation_name: operation_name) + render json: result + rescue StandardError => e + raise e unless Rails.env.development? + handle_error_in_development(e) + end + + private + + # Handle variables in form data, JSON body, or a blank value + def prepare_variables(variables_param) + case variables_param + when String + if variables_param.present? + JSON.parse(variables_param) || {} + else + {} + end + when Hash + variables_param + when ActionController::Parameters + variables_param.to_unsafe_hash # GraphQL-Ruby will validate name and type of incoming variables. + when nil + {} + else + raise ArgumentError, "Unexpected parameter: #{variables_param}" + end + end + + def handle_error_in_development(e) + logger.error e.message + logger.error e.backtrace.join("\n") + + render json: { errors: [{ message: e.message, backtrace: e.backtrace }], data: {} }, status: 500 + end +end diff --git a/app/graphql/concerns/graphql_errors.rb b/app/graphql/concerns/graphql_errors.rb new file mode 100644 index 00000000..92a24ec5 --- /dev/null +++ b/app/graphql/concerns/graphql_errors.rb @@ -0,0 +1,11 @@ +module GraphqlErrors + def formatted_errors(model) + model.errors.map do |error| + path = ["attributes", error.attribute.to_s.camelize(:lower)] + { + path: path, + message: error.message + } + end + end +end diff --git a/app/graphql/mutations/.keep b/app/graphql/mutations/.keep new file mode 100644 index 00000000..e69de29b diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb new file mode 100644 index 00000000..f733c75e --- /dev/null +++ b/app/graphql/mutations/base_mutation.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Mutations + class BaseMutation < GraphQL::Schema::Mutation + include GraphqlErrors + + argument_class Types::BaseArgument + field_class Types::BaseField + object_class Types::BaseObject + + def current_user + @context[:current_user] + end + end +end diff --git a/app/graphql/mutations/create_project.rb b/app/graphql/mutations/create_project.rb new file mode 100644 index 00000000..e7421804 --- /dev/null +++ b/app/graphql/mutations/create_project.rb @@ -0,0 +1,13 @@ +module Mutations + class CreateProject < BaseMutation + argument :input, Types::Inputs::CreateProjectInput, required: true + + type Types::Payloads::CreateProjectPayload + + def resolve(input:) + result = Projects::Create.call(project_params: input.to_h, user: current_user) + + result.to_h.merge(errors: formatted_errors(result.project)) + end + end +end diff --git a/app/graphql/resolvers/base.rb b/app/graphql/resolvers/base.rb new file mode 100644 index 00000000..e5162987 --- /dev/null +++ b/app/graphql/resolvers/base.rb @@ -0,0 +1,9 @@ +module Resolvers + class Base < GraphQL::Schema::Resolver + argument_class Types::BaseArgument + + def current_user + @context[:current_user] + end + end +end diff --git a/app/graphql/resolvers/project.rb b/app/graphql/resolvers/project.rb new file mode 100644 index 00000000..7e50ad5d --- /dev/null +++ b/app/graphql/resolvers/project.rb @@ -0,0 +1,11 @@ +module Resolvers + class Project < Resolvers::Base + argument :id, ID, required: true + + type Types::ProjectType, null: true + + def resolve(**options) + ::Project.find_by(id: options[:id]) + end + end +end diff --git a/app/graphql/resolvers/projects.rb b/app/graphql/resolvers/projects.rb new file mode 100644 index 00000000..ab5d2eed --- /dev/null +++ b/app/graphql/resolvers/projects.rb @@ -0,0 +1,9 @@ +module Resolvers + class Projects < Resolvers::Base + type [Types::ProjectType], null: false + + def resolve(**_options) + ::Project.all + end + end +end diff --git a/app/graphql/task_tracker_itis_schema.rb b/app/graphql/task_tracker_itis_schema.rb new file mode 100644 index 00000000..3c0798c8 --- /dev/null +++ b/app/graphql/task_tracker_itis_schema.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class TaskTrackerItisSchema < GraphQL::Schema + mutation(Types::MutationType) + query(Types::QueryType) + + # For batch-loading (see https://graphql-ruby.org/dataloader/overview.html) + use GraphQL::Dataloader + + # GraphQL-Ruby calls this when something goes wrong while running a query: + def self.type_error(err, context) + # if err.is_a?(GraphQL::InvalidNullError) + # # report to your bug tracker here + # return nil + # end + super + end + + # Union and Interface Resolution + def self.resolve_type(abstract_type, obj, ctx) + # TODO: Implement this method + # to return the correct GraphQL object type for `obj` + raise(GraphQL::RequiredImplementationMissingError) + end + + # Stop validating when it encounters this many errors: + validate_max_errors(100) + + # Relay-style Object Identification: + + # Return a string UUID for `object` + def self.id_from_object(object, type_definition, query_ctx) + # For example, use Rails' GlobalID library (https://github.com/rails/globalid): + object.to_gid_param + end + + # Given a string UUID, find the object + def self.object_from_id(global_id, query_ctx) + # For example, use Rails' GlobalID library (https://github.com/rails/globalid): + GlobalID.find(global_id) + end +end diff --git a/app/graphql/types/.keep b/app/graphql/types/.keep new file mode 100644 index 00000000..e69de29b diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb new file mode 100644 index 00000000..2e2278c5 --- /dev/null +++ b/app/graphql/types/base_argument.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Types + class BaseArgument < GraphQL::Schema::Argument + end +end diff --git a/app/graphql/types/base_connection.rb b/app/graphql/types/base_connection.rb new file mode 100644 index 00000000..366c69e8 --- /dev/null +++ b/app/graphql/types/base_connection.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class BaseConnection < Types::BaseObject + # add `nodes` and `pageInfo` fields, as well as `edge_type(...)` and `node_nullable(...)` overrides + include GraphQL::Types::Relay::ConnectionBehaviors + end +end diff --git a/app/graphql/types/base_edge.rb b/app/graphql/types/base_edge.rb new file mode 100644 index 00000000..e0d2f79c --- /dev/null +++ b/app/graphql/types/base_edge.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class BaseEdge < Types::BaseObject + # add `node` and `cursor` fields, as well as `node_type(...)` override + include GraphQL::Types::Relay::EdgeBehaviors + end +end diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb new file mode 100644 index 00000000..cf43fea4 --- /dev/null +++ b/app/graphql/types/base_enum.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Types + class BaseEnum < GraphQL::Schema::Enum + end +end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb new file mode 100644 index 00000000..611eb056 --- /dev/null +++ b/app/graphql/types/base_field.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Types + class BaseField < GraphQL::Schema::Field + argument_class Types::BaseArgument + end +end diff --git a/app/graphql/types/base_input_object.rb b/app/graphql/types/base_input_object.rb new file mode 100644 index 00000000..27951132 --- /dev/null +++ b/app/graphql/types/base_input_object.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Types + class BaseInputObject < GraphQL::Schema::InputObject + argument_class Types::BaseArgument + end +end diff --git a/app/graphql/types/base_interface.rb b/app/graphql/types/base_interface.rb new file mode 100644 index 00000000..18899387 --- /dev/null +++ b/app/graphql/types/base_interface.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module BaseInterface + include GraphQL::Schema::Interface + edge_type_class(Types::BaseEdge) + connection_type_class(Types::BaseConnection) + + field_class Types::BaseField + end +end diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb new file mode 100644 index 00000000..487af2f5 --- /dev/null +++ b/app/graphql/types/base_object.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + class BaseObject < GraphQL::Schema::Object + edge_type_class(Types::BaseEdge) + connection_type_class(Types::BaseConnection) + field_class Types::BaseField + end +end diff --git a/app/graphql/types/base_scalar.rb b/app/graphql/types/base_scalar.rb new file mode 100644 index 00000000..719bc808 --- /dev/null +++ b/app/graphql/types/base_scalar.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Types + class BaseScalar < GraphQL::Schema::Scalar + end +end diff --git a/app/graphql/types/base_union.rb b/app/graphql/types/base_union.rb new file mode 100644 index 00000000..95941696 --- /dev/null +++ b/app/graphql/types/base_union.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class BaseUnion < GraphQL::Schema::Union + edge_type_class(Types::BaseEdge) + connection_type_class(Types::BaseConnection) + end +end diff --git a/app/graphql/types/inputs/create_project_input.rb b/app/graphql/types/inputs/create_project_input.rb new file mode 100644 index 00000000..4f1d69fa --- /dev/null +++ b/app/graphql/types/inputs/create_project_input.rb @@ -0,0 +1,8 @@ +module Types + module Inputs + class CreateProjectInput < Types::BaseInputObject + argument :name, String, required: true + argument :description, String, required: false + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb new file mode 100644 index 00000000..0b8c4060 --- /dev/null +++ b/app/graphql/types/mutation_type.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Types + class MutationType < Types::BaseObject + field :create_project, mutation: Mutations::CreateProject + end +end diff --git a/app/graphql/types/node_type.rb b/app/graphql/types/node_type.rb new file mode 100644 index 00000000..c71ec3ee --- /dev/null +++ b/app/graphql/types/node_type.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + module NodeType + include Types::BaseInterface + # Add the `id` field + include GraphQL::Types::Relay::NodeBehaviors + end +end diff --git a/app/graphql/types/payloads/create_project_payload.rb b/app/graphql/types/payloads/create_project_payload.rb new file mode 100644 index 00000000..11f52716 --- /dev/null +++ b/app/graphql/types/payloads/create_project_payload.rb @@ -0,0 +1,8 @@ +module Types + module Payloads + class CreateProjectPayload < Types::BaseObject + field :project, ProjectType, null: true + field :errors, [Types::UserError], null: true + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb new file mode 100644 index 00000000..843e332f --- /dev/null +++ b/app/graphql/types/project_type.rb @@ -0,0 +1,10 @@ +module Types + class ProjectType < Types::BaseObject + field :id, ID, null: false + field :name, String, null: false + field :description, String, null: false + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + field :tasks, [TaskType], null: false + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb new file mode 100644 index 00000000..2d71e00c --- /dev/null +++ b/app/graphql/types/query_type.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class QueryType < Types::BaseObject + field :project, resolver: Resolvers::Project + field :projects, resolver: Resolvers::Projects + end +end diff --git a/app/graphql/types/task_status_type.rb b/app/graphql/types/task_status_type.rb new file mode 100644 index 00000000..6dc53ed0 --- /dev/null +++ b/app/graphql/types/task_status_type.rb @@ -0,0 +1,7 @@ +module Types + class TaskStatusType < Types::BaseEnum + value "UNSTARTED", value: "unstarted", description: "Unstarted" + value "STARTED", value: "started", description: "Started" + value "FINISHED", value: "finished", description: "Finished" + end +end diff --git a/app/graphql/types/task_type.rb b/app/graphql/types/task_type.rb new file mode 100644 index 00000000..9b5f169e --- /dev/null +++ b/app/graphql/types/task_type.rb @@ -0,0 +1,13 @@ +module Types + class TaskType < Types::BaseObject + field :id, ID, null: false + field :name, String, null: false + field :description, String + field :deadline_at, GraphQL::Types::ISO8601DateTime + field :project_id, Integer, null: false + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + field :status, TaskStatusType, null: false + field :project, ProjectType, null: false + end +end diff --git a/app/graphql/types/user_error.rb b/app/graphql/types/user_error.rb new file mode 100644 index 00000000..49a5952d --- /dev/null +++ b/app/graphql/types/user_error.rb @@ -0,0 +1,8 @@ +module Types + class UserError < Types::BaseObject + description "A user-readable error" + + field :message, String, null: false, description: "A description of the error" + field :path, [String], description: "Which input value this error came from" + end +end diff --git a/bin/rspec b/bin/rspec new file mode 100755 index 00000000..a6c78521 --- /dev/null +++ b/bin/rspec @@ -0,0 +1,29 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'rspec' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "pathname" +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", + Pathname.new(__FILE__).realpath) + +bundle_binstub = File.expand_path("../bundle", __FILE__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("rspec-core", "rspec") diff --git a/config/routes.rb b/config/routes.rb index 995a9fdf..6a51dae0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,9 @@ Rails.application.routes.draw do mount Sidekiq::Web => "/sidekiq" + mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql" if Rails.env.development? + + post "/graphql", to: "graphql#execute" namespace :api, defaults: { format: :json } do namespace :v1 do