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:
parent
a292b623ad
commit
51e624a1ff
|
@ -18,6 +18,7 @@
|
|||
|
||||
class CanvasSchema < GraphQL::Schema
|
||||
query Types::QueryType
|
||||
mutation Types::MutationType
|
||||
|
||||
use GraphQL::Batch
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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.
|
|
@ -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!
|
||||
}
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue