Revision 0f61f53b
Added by Timo Goebel about 5 years ago
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
fixes #24008 - add graphql mutations