diff --git a/.gitignore b/.gitignore index 031d3ff..3917fa4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ .byebug_history .idea/ public/ +node_modules/ diff --git a/Gemfile b/Gemfile index 78ccf7b..8045fb4 100644 --- a/Gemfile +++ b/Gemfile @@ -31,6 +31,9 @@ gem 'devise_token_auth' gem 'cancan' gem 'rolify' gem 'pry' +gem 'graphql' +gem 'graphiql-rails', group: :development +gem 'batch-loader' group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console @@ -48,6 +51,3 @@ group :development do gem 'spring' gem 'spring-watcher-listen', '~> 2.0.0' end - -# Windows does not include zoneinfo files, so bundle the tzinfo-data gem -gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] diff --git a/Gemfile.lock b/Gemfile.lock index d7503cb..922919d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,50 +1,52 @@ GEM remote: https://rubygems.org/ specs: - actioncable (5.0.2) - actionpack (= 5.0.2) + actioncable (5.0.6) + actionpack (= 5.0.6) nio4r (>= 1.2, < 3.0) websocket-driver (~> 0.6.1) - actionmailer (5.0.2) - actionpack (= 5.0.2) - actionview (= 5.0.2) - activejob (= 5.0.2) + actionmailer (5.0.6) + actionpack (= 5.0.6) + actionview (= 5.0.6) + activejob (= 5.0.6) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.0.2) - actionview (= 5.0.2) - activesupport (= 5.0.2) + actionpack (5.0.6) + actionview (= 5.0.6) + activesupport (= 5.0.6) rack (~> 2.0) rack-test (~> 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.0.2) - activesupport (= 5.0.2) + actionview (5.0.6) + activesupport (= 5.0.6) builder (~> 3.1) erubis (~> 2.7.0) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) - activejob (5.0.2) - activesupport (= 5.0.2) + activejob (5.0.6) + activesupport (= 5.0.6) globalid (>= 0.3.6) - activemodel (5.0.2) - activesupport (= 5.0.2) - activerecord (5.0.2) - activemodel (= 5.0.2) - activesupport (= 5.0.2) + activemodel (5.0.6) + activesupport (= 5.0.6) + activerecord (5.0.6) + activemodel (= 5.0.6) + activesupport (= 5.0.6) arel (~> 7.0) - activesupport (5.0.2) + activesupport (5.0.6) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (~> 0.7) minitest (~> 5.1) tzinfo (~> 1.1) arel (7.1.4) + batch-loader (1.0.3) bcrypt (3.1.11) builder (3.2.3) byebug (9.0.6) cancan (1.6.10) coderay (1.1.1) concurrent-ruby (1.0.5) + crass (1.0.2) devise (4.2.0) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -63,9 +65,12 @@ GEM ffi (1.9.18) foreman (0.82.0) thor (~> 0.19.1) - globalid (0.3.7) - activesupport (>= 4.1.0) - i18n (0.8.1) + globalid (0.4.0) + activesupport (>= 4.2.0) + graphiql-rails (1.4.4) + rails + graphql (1.7.3) + i18n (0.8.6) jsonapi-resources (0.9.0) activerecord (>= 4.1) concurrent-ruby @@ -73,19 +78,20 @@ GEM listen (3.0.8) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) - loofah (2.0.3) + loofah (2.1.0) + crass (~> 1.0.2) nokogiri (>= 1.5.9) - mail (2.6.4) + mail (2.6.6) mime-types (>= 1.16, < 4) method_source (0.8.2) mime-types (3.1) mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) - mini_portile2 (2.1.0) - minitest (5.10.1) - nio4r (2.0.0) - nokogiri (1.7.1) - mini_portile2 (~> 2.1.0) + mini_portile2 (2.3.0) + minitest (5.10.3) + nio4r (2.1.0) + nokogiri (1.8.1) + mini_portile2 (~> 2.3.0) orm_adapter (0.5.0) pg (0.20.0) pry (0.10.4) @@ -93,34 +99,34 @@ GEM method_source (~> 0.8.1) slop (~> 3.4) puma (3.8.2) - rack (2.0.1) + rack (2.0.3) rack-cors (0.4.1) rack-test (0.6.3) rack (>= 1.0) - rails (5.0.2) - actioncable (= 5.0.2) - actionmailer (= 5.0.2) - actionpack (= 5.0.2) - actionview (= 5.0.2) - activejob (= 5.0.2) - activemodel (= 5.0.2) - activerecord (= 5.0.2) - activesupport (= 5.0.2) - bundler (>= 1.3.0, < 2.0) - railties (= 5.0.2) + rails (5.0.6) + actioncable (= 5.0.6) + actionmailer (= 5.0.6) + actionpack (= 5.0.6) + actionview (= 5.0.6) + activejob (= 5.0.6) + activemodel (= 5.0.6) + activerecord (= 5.0.6) + activesupport (= 5.0.6) + bundler (>= 1.3.0) + railties (= 5.0.6) sprockets-rails (>= 2.0.0) - rails-dom-testing (2.0.2) - activesupport (>= 4.2.0, < 6.0) - nokogiri (~> 1.6) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) rails-html-sanitizer (1.0.3) loofah (~> 2.0) - railties (5.0.2) - actionpack (= 5.0.2) - activesupport (= 5.0.2) + railties (5.0.6) + actionpack (= 5.0.6) + activesupport (= 5.0.6) method_source rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) - rake (12.0.0) + rake (12.1.0) rb-fsevent (0.9.8) rb-inotify (0.9.8) ffi (>= 0.5.0) @@ -153,7 +159,7 @@ GEM sprockets (3.7.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.2.0) + sprockets-rails (3.2.1) actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) @@ -171,12 +177,15 @@ PLATFORMS ruby DEPENDENCIES + batch-loader byebug cancan devise_token_auth factory_girl faker foreman + graphiql-rails + graphql jsonapi-resources listen (~> 3.0.5) pg (~> 0.18) @@ -188,7 +197,6 @@ DEPENDENCIES rspec-rails spring spring-watcher-listen (~> 2.0.0) - tzinfo-data BUNDLED WITH 1.14.6 diff --git a/README.md b/README.md index be56415..3ec5282 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,140 @@ Demo user: user1@example.com / Secret123 ### Client Add list, edit and form components in `client/src/components/` based on one of existing. + +### [graphiql](http://localhost:3001/graphiql) + +Categories + + { + categories { + id + name + } + } + +Categories with posts + + { + categories(with_posts: true) { + id + name + posts { + id + title + category { + id + name + } + comments { + id + body + } + } + } + } + +One category + + { + category(id: 1) { + id + name + } + } + +Create category + + mutation { + createCategory(category: {name: "Graphql"}) { + id + name + errors + } + } + +Update category + + mutation { + updateCategory(category: {id: 1, name: "Graphql"}) { + id + name + errors + } + } + +Delete category + + mutation { + deleteCategory(id: 24) { + id + name + } + } + +Posts + + { + posts { + id + title + category { + id + name + } + } + } + +One post + + { + post(id: 1) { + id + title + category { + id + name + } + comments { + id + body + } + } + } + +Create post + + mutation { + createPost(post: {title: "Graphql", category_id: 1}) { + id + title + category { + id + name + } + errors + } + } + +Update post + + mutation { + updatePost(post: {id: 1, title: "Graphql", category_id: 1}) { + id + title + category { + id + name + } + errors + } + } + +Delete post + + mutation { + deletePost(id: 83) { + id + title + } + } diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb new file mode 100644 index 0000000..4194008 --- /dev/null +++ b/app/controllers/graphql_controller.rb @@ -0,0 +1,32 @@ +class GraphqlController < ApplicationController + def execute + variables = ensure_hash(params[:variables]) + query = params[:query] + context = { + # Query context goes here, for example: + # current_user: current_user, + } + result = RailsJsonApiServerSchema.execute(query, variables: variables, context: context) + render json: result + end + + private + + # Handle form data, JSON body, or a blank value + def ensure_hash(ambiguous_param) + case ambiguous_param + when String + if ambiguous_param.present? + ensure_hash(JSON.parse(ambiguous_param)) + else + {} + end + when Hash, ActionController::Parameters + ambiguous_param + when nil + {} + else + raise ArgumentError, "Unexpected parameter: #{ambiguous_param}" + end + end +end diff --git a/app/graphql/mutations/.keep b/app/graphql/mutations/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/graphql/rails_json_api_server_schema.rb b/app/graphql/rails_json_api_server_schema.rb new file mode 100644 index 0000000..e86927d --- /dev/null +++ b/app/graphql/rails_json_api_server_schema.rb @@ -0,0 +1,7 @@ +RailsJsonApiServerSchema = GraphQL::Schema.define do + query Types::QueryType + mutation Types::MutationType + use BatchLoader::GraphQL + max_depth 10 + rescue_from(ActiveRecord::RecordNotFound) { "Not found" } +end diff --git a/app/graphql/types/.keep b/app/graphql/types/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/graphql/types/category_type.rb b/app/graphql/types/category_type.rb new file mode 100644 index 0000000..a230f36 --- /dev/null +++ b/app/graphql/types/category_type.rb @@ -0,0 +1,16 @@ +Types::CategoryType = GraphQL::ObjectType.define do + name "Category" + + field :id, types.Int + field :name, types.String + field :created_at, types.String + field :posts, types[Types::PostType] + field :errors, Types::JSONType +end + +Types::CategoryInputType = GraphQL::InputObjectType.define do + name "CategoryInput" + + argument :id, types.Int + argument :name, types.String +end diff --git a/app/graphql/types/comment_type.rb b/app/graphql/types/comment_type.rb new file mode 100644 index 0000000..b2d6235 --- /dev/null +++ b/app/graphql/types/comment_type.rb @@ -0,0 +1,8 @@ +Types::CommentType = GraphQL::ObjectType.define do + name "Comment" + + field :id, types.Int + field :body, types.String + field :created_at, types.String + field :post, Types::PostType +end diff --git a/app/graphql/types/json_type.rb b/app/graphql/types/json_type.rb new file mode 100644 index 0000000..08140ca --- /dev/null +++ b/app/graphql/types/json_type.rb @@ -0,0 +1,6 @@ +Types::JSONType = GraphQL::ScalarType.define do + name "JSON" + + # coerce_input ->(value, _ctx) { value } + # coerce_result ->(value, _ctx) { value } +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb new file mode 100644 index 0000000..2c251b1 --- /dev/null +++ b/app/graphql/types/mutation_type.rb @@ -0,0 +1,52 @@ +Types::MutationType = GraphQL::ObjectType.define do + name "Mutation" + + field :createCategory, Types::CategoryType do + description "Create a category" + argument :category, Types::CategoryInputType + resolve ->(o, args, c) { + Category.create(args[:category].to_h) + } + end + + field :updateCategory, Types::CategoryType do + description "Update a category" + argument :category, Types::CategoryInputType + resolve ->(o, args, c) { + Category.update(args[:category][:id], args[:category].to_h.except!("id")) + } + end + + field :deleteCategory, Types::CategoryType do + description "Delete a category" + argument :id, types.Int + resolve ->(o, args, c) { + Category.destroy(args[:id]) + } + end + + field :createPost, Types::PostType do + description "Create a post" + + argument :post, Types::PostInputType + resolve ->(o, args, c) { + Post.create(args[:post].to_h) + } + end + + field :updatePost, Types::PostType do + description "Update a post" + argument :post, Types::PostInputType + resolve ->(o, args, c) { + Post.update(args[:post][:id], args[:post].to_h.except!("id")) + } + end + + field :deletePost, Types::PostType do + description "Delete a post" + argument :id, types.Int + resolve ->(o, args, c) { + Post.destroy(args[:id]) + } + end +end diff --git a/app/graphql/types/post_type.rb b/app/graphql/types/post_type.rb new file mode 100644 index 0000000..f132a31 --- /dev/null +++ b/app/graphql/types/post_type.rb @@ -0,0 +1,25 @@ +Types::PostType = GraphQL::ObjectType.define do + name "Post" + + field :id, types.Int + field :title, types.String + field :created_at, types.String + field :category do + type Types::CategoryType + resolve ->(obj, args, ctx) { + BatchLoader.for(obj.category_id).batch do |ids, loader| + Category.where(id: ids).each { |record| loader.call(record.id, record) } + end + } + end + field :comments, types[Types::CommentType] + field :errors, Types::JSONType +end + +Types::PostInputType = GraphQL::InputObjectType.define do + name "PostInput" + + argument :id, types.Int + argument :title, types.String + argument :category_id, types.Int +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb new file mode 100644 index 0000000..4db6ff0 --- /dev/null +++ b/app/graphql/types/query_type.rb @@ -0,0 +1,38 @@ +Types::QueryType = GraphQL::ObjectType.define do + name "Query" + description "The query root of this schema" + + field :category do + type Types::CategoryType + argument :id, !types.ID + description "Find a Category by ID" + resolve ->(obj, args, ctx) { Category.find(args["id"]) } + end + + field :categories do + type types[Types::CategoryType] + argument :id, types[types.ID] + argument :with_posts, types.Boolean + description "Find Categories" + resolve ->(obj, args, ctx) { + categories = (args["ids"] ? Category.where(id: args["ids"]) : Category.all) + args["with_posts"] ? categories.includes(posts: :comments) : categories + } + end + + field :posts do + type types[Types::PostType] + argument :id, types[types.ID] + description "Find Posts" + resolve ->(obj, args, ctx) { + args["ids"] ? Post.where(id: args["ids"]) : Post.all + } + end + + field :post do + type Types::PostType + argument :id, !types.ID + description "Find a Post by ID" + resolve ->(obj, args, ctx) { Post.find(args["id"]) } + end +end diff --git a/config/application.rb b/config/application.rb index 56c997b..cd2af55 100644 --- a/config/application.rb +++ b/config/application.rb @@ -9,7 +9,7 @@ require "action_mailer/railtie" require "action_view/railtie" require "action_cable/engine" -# require "sprockets/railtie" +require "sprockets/railtie" require "rails/test_unit/railtie" # Require the gems listed in Gemfile, including any gems @@ -38,5 +38,7 @@ class Application < Rails::Application # This is necessary if your schema can't be completely dumped by the schema dumper, # like if you have constraints or database-specific column types config.active_record.schema_format = :sql + + config.autoload_paths << Rails.root.join("app", "types") end end diff --git a/config/routes.rb b/config/routes.rb index bd0a65d..2fe9636 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,9 @@ Rails.application.routes.draw do + if Rails.env.development? + mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql" + end + + post "/graphql", to: "graphql#execute" mount_devise_token_auth_for 'User', at: 'auth' jsonapi_resources :categories jsonapi_resources :comments