graphql: add CreateGroupInSet mutation

refs GQL-3

test plan:
  * create a group in a group set using graphql

Change-Id: I81f3e3c02b19961a31cdd9695adf7a4c11c90693
Reviewed-on: https://gerrit.instructure.com/166125
Tested-by: Jenkins
Reviewed-by: Adrian Packel <apackel@instructure.com>
Reviewed-by: Carl Kibler <ckibler@instructure.com>
QA-Review: Cameron Matheson <cameron@instructure.com>
Product-Review: Cameron Matheson <cameron@instructure.com>
This commit is contained in:
Cameron Matheson 2018-10-01 15:12:05 -06:00
parent a292b623ad
commit 51e624a1ff
8 changed files with 332 additions and 20 deletions

View File

@ -18,6 +18,7 @@
class CanvasSchema < GraphQL::Schema
query Types::QueryType
mutation Types::MutationType
use GraphQL::Batch

View File

@ -0,0 +1,100 @@
#
# Copyright (C) 2018 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
##
# = Base Canvas Mutation class
#
# The most fundamental change this class makes compared to
# +GraphQL::Schema::Mutation+ is that it facilitates conveniently following
# the convention of always taking a single input argument and returning a
# unique payload per mutation.
#
# Any arguments defined in a mutation descended from this class will be
# hoisted into a custom input object. Fields on the mutation will similarly
# be hoisted into a custom payload object.
#
# An +errors+ field will be added to all payloads for validation errors.
class Mutations::BaseMutation < GraphQL::Schema::Mutation
field :errors, [Types::ValidationErrorType], null: true
def current_user
context[:current_user]
end
def session
context[:session]
end
def authorized_action?(obj, perm)
if obj.grants_right?(current_user, session, perm)
true
else
raise GraphQL::ExecutionError, "not found"
end
end
private
# returns validation errors in a consistent format (`Types::ValidationError`)
#
# validation errors on an attribute that match one of the mutation's input
# fields will be returned with that attribute specified (otherwise
# `attribute` will be null)
def errors_for(model)
# TODO - support renamed fields (e.g. workflow_state => state)
input_fields = Hash[self.class.arguments.values.map { |a| [ a.keyword, a.name ] }]
{
errors: model.errors.entries.map { |attribute, message|
[input_fields[attribute], message]
}
}
end
# this is copied from GraphQL::Schema::RelayClassicMutation - it moves all
# the arguments defined with the ruby-graphql DSL into an auto-generated
# input type
#
# this is a bit more convenient that defining the types by hand
#
# we could base this class on RelayClassicMutation but then we get the weird
# {client_mutation_id} fields that we don't care about
def self.field_options
super.tap do |res|
res[:arguments].clear
res[:arguments][:input] = {type: input_type, required: true}
end
end
def self.input_type
@input_type ||= begin
mutation_args = arguments
mutation_name = graphql_name
mutation_class = self
Class.new(Types::BaseInputObject) do
graphql_name("#{mutation_name}Input")
description("Autogenerated input type of #{mutation_name}")
mutation(mutation_class)
own_arguments.merge!(mutation_args)
end
end
end
end

View File

@ -0,0 +1,41 @@
#
# Copyright (C) 2018 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
class Mutations::CreateGroupInSet < Mutations::BaseMutation
graphql_name "CreateGroupInSet"
argument :name, String, required: true
argument :group_set_id, ID, required: true, prepare: GraphQLHelpers.relay_or_legacy_id_prepare_func("GroupSet")
field :group, Types::GroupType, null: true
def resolve(input:)
category_id = GraphQLHelpers.parse_relay_or_legacy_id(input[:group_set_id], "GroupSet")
set = GroupCategory.find(category_id)
if authorized_action?(set.context, :manage_groups)
group = set.groups.build(name: input[:name], context: set.context)
if group.save
{group: group}
else
errors_for(group)
end
end
rescue ActiveRecord::RecordNotFound
raise GraphQL::ExecutionError, "not found"
end
end

View File

@ -16,25 +16,8 @@
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
module Types
MutationType = GraphQL::ObjectType.define do
name "Mutation"
class Types::MutationType < Types::ApplicationObjectType
graphql_name "Mutation"
field :createAssignment, AssignmentType do
argument :assignment, !AssignmentInput
resolve -> (_, args, ctx) do
CanvasSchema.object_from_id(args[:assignment][:courseId], ctx).then do |course|
# NOTE: i guess i have to type check here since i'm using global ids?
if course && course.is_a?(Course)
assignment = course.assignments.new name: args[:assignment][:name]
if assignment.grants_right? ctx[:current_user], ctx[:session], :create
assignment.save!
end
end
assignment
end
end
end
end
field :create_group_in_set, mutation: Mutations::CreateGroupInSet
end

View File

@ -0,0 +1,27 @@
#
# Copyright (C) 2018 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
class Types::ValidationErrorType < Types::ApplicationObjectType
field :attribute, String, null: true
field :message, String, null: false
def object
attribute, message = super
{attribute: attribute, message: message}
end
end

View File

@ -0,0 +1,52 @@
# GraphQL Errors
The GraphQL spec describes a top-level errors key
(https://facebook.github.io/graphql/June2018/#sec-Errors). It is useful
for fatal errors that prevent an operation from returning data. The Ruby
GraphQL gem automatically populates `errors` whenever there is a
synatactical error, or when a resolver returns a `GraphQL::ExecutionError`.
## Mutation Errors
For fatal errors (like lack of permission), we return a
`GraphQL::ExecutionError` (populating the top-level errors field).
There are a number of reasons why we wouldn't want to use the top-level
errors field for user-facing errors (like validations).
* There are potentially many validation errors per model attribute, but
once the system encounters one `GraphQL::ExecutionError` it will stop
processing that mutation.
* Apollo client (and probably other GraphQL clients) have special treatment
for the top-level errors field which is probably not ideal for
displaying validation errors to the user.
* Validation errors are meant to be displayed to the user, whereas system
errors are not likely to be shown.
Instead, the common solution for handling these types of errors is to
include an `errors` key as part of the mutation response. There doesn't
yet seem to be any consensus around a common format for the shape of these
errors.
The primary source of validation errors in Canvas are the rails validations
defined in our models. The simplest way to expose those would be to add an
`errors` key that is a list of {attribute, message} pairs. Some
validations do not directly correspond to a GraphQL field (either because
it's not a field exposed in GraphQL, or the attribute has been renamed), so
we'll need way to either map those attribute names appropriately, or return
a generic object-wide error message (generic error messages could be
indicated by any error with a null attribute).
A different way to express errors would be to have a mutation-specific
error type for each mutation. The error shape could then look something
like {attribute1 => [message1, message2], attribute2 => [message1], ...}.
The big downside to this approach is that consuming this form of errors
would require enumerating all possible attributes at query time which feels
burdensome.
## A Note on Inst UI
Inst UI form components take a "message" attribute that is used to display
errors. We will want some kind of general purpose helper function that
extracts the validation errors from a mutation response and bundles them in
a format more consumable by Instructure UI.

View File

@ -428,6 +428,18 @@ enum CourseWorkflowState {
deleted
}
# Autogenerated input type of CreateGroupInSet
input CreateGroupInSetInput {
groupSetId: ID!
name: String!
}
# Autogenerated return type of CreateGroupInSet
type CreateGroupInSetPayload {
errors: [ValidationError!]
group: Group
}
# an ISO8601 formatted time string
scalar DateTime
@ -684,6 +696,10 @@ type Module implements Node & Timestamped {
updatedAt: DateTime
}
type Mutation {
createGroupInSet(input: CreateGroupInSetInput!): CreateGroupInSetPayload
}
# An object with an ID.
interface Node {
# ID of the object.
@ -974,3 +990,8 @@ type UserEdge {
# The item at the end of the edge.
node: User
}
type ValidationError {
attribute: String
message: String!
}

View File

@ -0,0 +1,87 @@
#
# Copyright (C) 2018 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require File.expand_path(File.dirname(__FILE__) + "/../../spec_helper")
describe Mutations::CreateGroupInSet do
before(:once) do
student_in_course(active_all: true)
@gc = @course.group_categories.create! name: "asdf"
end
def mutation_str(name: "zxcv", group_set_id: nil)
group_set_id ||= @gc.id
<<~GQL
mutation {
createGroupInSet(input: {
name: "#{name}"
groupSetId: "#{group_set_id}"
}) {
group {
_id
}
errors {
attribute
message
}
}
}
GQL
end
it "works" do
result = CanvasSchema.execute(mutation_str, context: {current_user: @teacher})
new_group_id = result.dig(*%w[data createGroupInSet group _id])
expect(Group.find(new_group_id).name).to eq "zxcv"
expect(result.dig(*%w[data createGroupInSet errors])).to be_nil
end
it "fails gracefully for invalid group sets" do
invalid_group_set_id = 111111111111111111
result = CanvasSchema.execute(mutation_str(group_set_id: invalid_group_set_id), context: {current_user: @student})
expect(result["errors"]).not_to be_nil
expect(result.dig(*%w[data createGroupInSet])).to be_nil
end
it "requires permission" do
result = CanvasSchema.execute(mutation_str, context: {current_user: @student})
expect(result["errors"]).not_to be_nil
expect(result.dig(*%w[data createGroupInSet])).to be_nil
end
context "validation errors" do
it "returns validation errors" do
result = CanvasSchema.execute(
mutation_str(name: "!" * (Group.maximum_string_length + 1)),
context: {current_user: @teacher}
)
# top-level errors are nil since this is a user error
expect(result["errors"]).to be_nil
validation_errors = result.dig(*%w[data createGroupInSet errors])
expect(validation_errors.size).to eq 1
expect(validation_errors[0]["attribute"]).to eq "name"
expect(validation_errors[0]["message"]).to_not be_nil
expect(result.dig("data", "createGroupInSet", "group")).to be_nil
end
end
end