Project

General

Profile

« Previous | Next » 

Revision 0f61f53b

Added by Timo Goebel about 5 years ago

fixes #24008 - add graphql mutations

View differences:

app/graphql/foreman_graphql_schema.rb
use GraphQL::Batch
query(Types::Query)
mutation(Types::Mutation)
rescue_from ActiveRecord::RecordInvalid, &:message
rescue_from ActiveRecord::Rollback, &:message
rescue_from StandardError, &:message
rescue_from ActiveRecord::RecordNotUnique, &:message
rescue_from ActiveRecord::RecordNotFound, &:message
def self.id_from_object(object, type_definition, query_ctx)
Foreman::GlobalId.encode(type_definition.name, object.id)
app/graphql/mutations/base_mutation.rb
module Mutations
class BaseMutation < GraphQL::Schema::RelayClassicMutation
class << self
def argument(*args, **kwargs, &block)
required = kwargs.key?(:required) ? kwargs[:required] : attribute_required?(args.first)
super(*args, **kwargs.except(:required), required: required, &block)
end
def resource_class(new_resource_class = nil)
if new_resource_class
@resource_class = new_resource_class
else
@resource_class ||= "::#{self.to_s.split('::')[-2].singularize}".safe_constantize
end
end
private
def attribute_required?(attribute)
GraphqlAttribute.for(resource_class).required?(attribute)
end
end
object_class Types::BaseObject
input_object_class Types::BaseInputObject
private
delegate :resource_class, to: :class
def authorize!(resource, action)
user = context[:current_user]
authorizer = Authorizer.new(user)
permission_name = resource.permission_name(action)
unless authorizer.can?(permission_name, resource)
raise GraphQL::ExecutionError.new(
_('Unauthorized. You do not have the required permission %s.') % permission_name
)
end
end
def validate_object(resource)
unless resource.is_a?(resource_class)
raise GraphQL::ExecutionError.new("Resource mismatch, expected #{resource_class.name}, got #{resource.class.name}")
end
end
def load_object_by(id:)
object = GraphQL::Batch.batch { ForemanGraphqlSchema.object_from_id(id, context) }
raise GraphQL::ExecutionError.new(_('Could not resolve ID.')) unless object
validate_object(object)
object
end
def save_object(resource)
User.as(context[:current_user]) do
errors = if resource.save
[]
else
map_errors_to_path(resource)
end
{
resource_class.name.downcase => resource,
errors: errors
}
end
end
def map_errors_to_path(resource)
resource.errors.map do |attribute, message|
{
path: ['attributes', attribute.to_s.camelize(:lower)],
message: message
}
end
end
end
end
app/graphql/mutations/create_mutation.rb
module Mutations
class CreateMutation < BaseMutation
field :errors, [Types::AttributeError], null: false
def resolve(params)
object = initialize_object(params)
validate_object(object)
authorize!(object, :create)
save_object(object)
end
private
def initialize_object(params)
resource_class.new(params)
end
end
end
app/graphql/mutations/delete_mutation.rb
module Mutations
class DeleteMutation < BaseMutation
argument :id, ID, required: true
field :id, ID, 'The deleted object ID.', null: false
field :errors, [Types::AttributeError], null: false
def resolve(id:)
object = load_object_by(id: id)
authorize!(object, :destroy)
User.as(context[:current_user]) do
errors = if object.destroy
[]
else
map_errors_to_path(object)
end
{
id: id,
errors: errors
}
end
end
end
end
app/graphql/mutations/models/create.rb
module Mutations
module Models
class Create < CreateMutation
graphql_name 'CreateModelMutation'
description 'Creates a new hardware model.'
argument :name, String
argument :info, String
argument :vendor_class, String
argument :hardware_model, String
field :model, Types::Model, 'The new hardware model.', null: true
end
end
end
app/graphql/mutations/models/delete.rb
module Mutations
module Models
class Delete < DeleteMutation
graphql_name 'DeleteModelMutation'
description 'Deletes a hardware model.'
end
end
end
app/graphql/mutations/models/update.rb
module Mutations
module Models
class Update < UpdateMutation
graphql_name 'UpdateModelMutation'
description 'Updates existing hardware model.'
argument :id, ID, required: true
argument :name, String
argument :info, String
argument :vendor_class, String
argument :hardware_model, String
field :model, Types::Model, 'The hardware model.', null: true
end
end
end
app/graphql/mutations/update_mutation.rb
module Mutations
class UpdateMutation < BaseMutation
field :errors, [Types::AttributeError], null: false
def resolve(params)
object = load_object_by(id: params[:id])
authorize!(object, :edit)
object.assign_attributes(params.except(:id))
save_object(object)
end
end
end
app/graphql/types/attribute_error.rb
module Types
class AttributeError < BaseObject
description 'A user-readable error'
field :message, String, null: false,
description: 'A description of the error'
field :path, [String], null: true,
description: 'Which input value this error came from'
end
end
app/graphql/types/base_object.rb
private
def attribute_required?(attribute)
return true if model_class.columns_hash[attribute.to_s]&.null == false
return true if model_class.validators_on(attribute).find do |validator|
validator.is_a?(ActiveModel::Validations::PresenceValidator) && ([:if, :unless] & validator.options.keys).none?
end
reflection = model_class.reflect_on_association(attribute)
return true if reflection && reflection.macro == :belongs_to && attribute_required?(reflection.foreign_key)
false
end
def nullable?(attribute)
!attribute_required?(attribute)
!GraphqlAttribute.for(model_class).required?(attribute)
end
end
end
app/graphql/types/mutation.rb
module Types
class Mutation < BaseObject
graphql_name 'Mutation'
field :create_model, mutation: Mutations::Models::Create
field :update_model, mutation: Mutations::Models::Update
field :delete_model, mutation: Mutations::Models::Delete
end
end
app/graphql/types/query.rb
module Types
class Query < GraphQL::Schema::Object
class Query < BaseObject
graphql_name 'Query'
class << self
app/models/concerns/foreman/thread_session.rb
# @param [block] block to execute
def as(login)
old_user = current
self.current = User.unscoped.find_by_login(login)
self.current = if login.is_a?(User)
login
else
User.unscoped.find_by_login(login)
end
raise ::Foreman::Exception.new(N_("Cannot find user %s when switching context"), login) unless self.current.present?
yield if block_given?
ensure
app/services/graphql_attribute.rb
class GraphqlAttribute
attr_reader :resource_class
def self.for(resource_class)
new(resource_class: resource_class)
end
def initialize(resource_class:)
@resource_class = resource_class
end
def required?(attribute)
return false unless resource_class
return true if resource_class.columns_hash[attribute.to_s]&.null == false
return true if resource_class.validators_on(attribute).find do |validator|
validator.is_a?(ActiveModel::Validations::PresenceValidator) && ([:if, :unless] & validator.options.keys).none?
end
reflection = resource_class.reflect_on_association(attribute)
return true if reflection && reflection.macro == :belongs_to && required?(reflection.foreign_key)
false
end
end
test/graphql/mutations/models/create_mutation_test.rb
require 'test_helper'
module Mutations
module Models
class CreateMutationTest < ActiveSupport::TestCase
let(:variables) do
{
name: 'SUN T2000',
info: 'Sun Sparc Enterprise T2000',
vendorClass: 'Sun-Fire-T200',
hardwareModel: 'SUN4V'
}
end
let(:query) do
<<-GRAPHQL
mutation createModelMutation(
$name: String!,
$info: String,
$vendorClass: String,
$hardwareModel: String
) {
createModel(input: {
name: $name,
info: $info,
vendorClass: $vendorClass,
hardwareModel: $hardwareModel
}) {
model {
id,
name,
info,
vendorClass
hardwareModel
},
errors {
path
message
}
}
}
GRAPHQL
end
context 'with admin user' do
let(:user) { FactoryBot.create(:user, :admin) }
test 'create a model' do
context = { current_user: user }
assert_difference('Model.count', +1) do
result = ForemanGraphqlSchema.execute(query, variables: variables, context: context)
assert_empty result['errors']
assert_empty result['data']['createModel']['errors']
end
assert_equal user.id, Audit.last.user_id
end
end
context 'with user with view permissions' do
setup do
@user = setup_user 'view', 'models'
end
test 'cannot create a model' do
context = { current_user: @user }
assert_difference('::Model.count', 0) do
result = ForemanGraphqlSchema.execute(query, variables: variables, context: context)
assert_not_empty result['errors']
end
end
end
end
end
end
test/graphql/mutations/models/delete_mutation_test.rb
require 'test_helper'
module Mutations
module Models
class DeleteMutationTest < ActiveSupport::TestCase
let(:model) { FactoryBot.create(:model) }
let(:model_id) { Foreman::GlobalId.for(model) }
let(:variables) do
{
id: model_id
}
end
let(:query) do
<<-GRAPHQL
mutation deleteModelMutation($id: ID!) {
deleteModel(input: {id: $id}) {
id,
errors {
path
message
}
}
}
GRAPHQL
end
context 'with admin user' do
let(:user) { FactoryBot.create(:user, :admin) }
test 'deletes a model' do
context = { current_user: user }
model
assert_difference('::Model.count', -1) do
result = ForemanGraphqlSchema.execute(query, variables: variables, context: context)
assert_empty result['errors']
assert_empty result['data']['deleteModel']['errors']
assert_equal model_id, result['data']['deleteModel']['id']
end
assert_equal user.id, Audit.last.user_id
end
end
context 'with user with view permissions' do
setup do
model
@user = setup_user 'view', 'models'
end
test 'cannot delete a model' do
context = { current_user: @user }
assert_difference('Model.count', 0) do
result = ForemanGraphqlSchema.execute(query, variables: variables, context: context)
assert_not_empty result['errors']
assert_includes result['errors'].map { |error| error['message'] }.to_sentence, 'Unauthorized.'
end
end
end
end
end
end
test/graphql/mutations/models/update_mutation_test.rb
require 'test_helper'
module Mutations
module Models
class UpdateMutationTest < ActiveSupport::TestCase
let(:model) { FactoryBot.create(:model) }
let(:model_id) { Foreman::GlobalId.for(model) }
let(:variables) do
{
id: model_id,
name: 'SUN T2000',
info: 'Sun Sparc Enterprise T2000',
vendorClass: 'Sun-Fire-T200',
hardwareModel: 'SUN4V'
}
end
let(:query) do
<<-GRAPHQL
mutation updateModelMutation(
$id: ID!,
$name: String!,
$info: String,
$vendorClass: String,
$hardwareModel: String
) {
updateModel(input: {
id: $id,
name: $name,
info: $info,
vendorClass: $vendorClass,
hardwareModel: $hardwareModel
}) {
model {
id,
name,
info,
vendorClass
hardwareModel
},
errors {
path
message
}
}
}
GRAPHQL
end
context 'with admin user' do
let(:user) { FactoryBot.create(:user, :admin) }
test 'updates a model' do
context = { current_user: user }
model
assert_difference('::Model.count', 0) do
result = ForemanGraphqlSchema.execute(query, variables: variables, context: context)
assert_empty result['errors']
assert_empty result['data']['updateModel']['errors']
end
assert_equal user.id, Audit.last.user_id
model.reload
assert_equal 'SUN T2000', model.name
end
end
context 'with user with view permissions' do
setup do
model
@user = setup_user 'view', 'models'
end
test 'cannot update a model' do
context = { current_user: @user }
assert_difference('Model.count', 0) do
result = ForemanGraphqlSchema.execute(query, variables: variables, context: context)
assert_not_empty result['errors']
end
end
end
end
end
end
test/unit/foreman/global_id_test.rb
assert_equal 'Model', type_name
assert_equal '123', object_value
end
test 'generates id for object' do
model = FactoryBot.create(:model)
assert_equal Foreman::GlobalId.encode('Model', model.id), Foreman::GlobalId.for(model)
end
end
end
test/unit/grqphal_attribute_test.rb
require 'test_helper'
class GraphqlAttributeTest < ActiveSupport::TestCase
let(:resource_class) { Model }
let(:graphql_attribute) { GraphqlAttribute.for(resource_class) }
describe '#required?' do
it 'detects than an attribute is required' do
assert_equal true, graphql_attribute.required?(:name)
end
it 'detects than an attribute is optional' do
assert_equal false, graphql_attribute.required?(:description)
end
end
end

Also available in: Unified diff