As I introduced last week in my first article, I'm working on an API that provides not only REST services but also recently a GraphQL endpoint.
As part of this, I had to create queries
repeating exactly the logic already present in some of our REST controllers for desktop applications designed entirely in GraphQL. There was a high risk of unnecessary code duplication.
Mutations (as opposed to Mutations)
Fortunately, my colleagues had already introduced the solution to this problem by lightening our controllers through the use of Mutations gem.
🤯 Yes, the name can make conversations tricky: difficult to make a clear sentence when talking about the mutation (of the gem) used inside a mutation (graphql).
At its core, Mutations allows you to create classes that manage :
- type checking of incoming parameters
- data validation
- customizable error messages
Lighten its controllers by isolating the business logic
Our initial use of gem was just to, as I said, lighten our controllers. For example, a controller like this:
# app/controllers/v1/providers_controller.rb
# ===================================================================
# === BEFORE REFACTORING ============================================
# ===================================================================
module V1
class ProvidersController < ApiController
def create
provider = Provider.new(permitted_params)
check_phone = Phonelib.valid?(
Phonelib.parse(permitted_params[:phone_number]).e164
)
provider.errors.add(:phone_number, :invalid) unless check_phone
iban_errors = IBANTools::IBAN.new(iban).validation_errors
iban_errors.each { |e| provider.errors.add(:iban, e) }
if provider.save
render json: provider
else
errors_json(provider.errors.symbolic, 422)
end
end
private
def permitted_params
params.require(:provider)
.permit(
:display_name,
:siret,
:phone_number,
:email,
:iban,
:bic
)
end
end
end
# ====================================================================
# ==== AFTER REFACTORING =============================================
# ====================================================================
module V1
class ProvidersController < ApiController
def create
outcome = ::Provider::CreateMutation.run(permitted_params)
respond_with_mutation outcome,
serializer: ProviderSerializer
end
private
def permitted_params
params.require(:provider)
.permit(
:display_name,
:siret,
:phone_number,
:email,
:iban,
:bic
)
end
end
end
By rewriting our controllers this way, we save ourselves the need to repeat if something.save else...
each time, using the respond_with_mutation
method we previously defined in ApiController
.
# app/controllers/api_controller.rb
class ApiController < ActionController::API
# ...
def respond_with_mutation(outcome, serializer_opts = {})
if outcome.success?
render serializer_opts.merge(json: outcome.result)
else
errors_json(outcome.errors.symbolic, 422)
end
end
# ...
end
Besides, as I was explaining, we gain a type check on the incoming parameters, which was not at all initially managed, and a clearer definition of the validations required before the record is created.
All this happens in the Mutation
which is defined as follows:
# app/mutations/provider/create_mutation.rb
class Provider::CreateMutation < BaseMutation
required do
string :display_name
string :siret
string :phone_number
string :email
string :iban
string :bic
end
# Optional inputs can be defined:
#
# optional do
# string :something
# integer :something_else
# datetime :lunch_time
# end
def validate
check_phone = Phonelib.valid?(Phonelib.parse(phone_number).e164)
add_error(:phone_number, :invalid) unless check_phone
iban_errors = IBANTools::IBAN.new(iban).validation_errors
iban_errors.each { |e| add_error(:iban, e) }
end
def execute
provider = Provider.new(inputs)
provider.save!
provider
end
end
The slightest error, whether in validate
, execute
, or directly in the format of the input arguments interrupts the Mutation
. The .success?
method will then return false
, and the details of the errors will be listed in outcome.errors
.
Reusing the logic in another part of the API
The consistent use of Mutations
to isolate the business logic saved me a lot of time when I started integrating GraphQL Ruby into our API.
Instead of having to duplicate this logic, I just had to call these existing classes in my GraphQL mutations. To do this, I only had to redefine within GraphQL the respond_with_mutation
method we saw above.
# app/graphql/mutations/base_mutation.rb
class Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation
# ...
def respond_with_mutation(outcome)
if outcome.success?
{ outcome.result.class.name.underscore => outcome.result }
else
errors = outcome.errors.symbolic.map do |k, v|
parse_error_hash(k, v)
end.flatten
{ errors: errors }
end
end
def parse_error_hash(args)
# crappy code which need clear refactor that transform Mutations errors
# into a Types::MutationErrorType compliant hash
end
# ...
end
This one is slightly heavier than the one defined in ApiController
, simply because I need to make sure the returned errors match the MutationErrorType
format I have defined in my code.
Once this method is defined, just call it:
# app/graphql/mutations/create_provider.rb
module Mutations
class CreateProvider < Mutations::BaseMutation
argument :display_name, String, required: true
argument :phone_number, String, required: true
argument :siret, String, required: true
argument :email, String, required: true
argument :iban, String, required: true
argument :bic, String, required: true
field :provider, Types::ProviderType, null: true
field :errors, [Types::MutationErrorType], null: true
def resolve(**args)
outcome = ::Provider::CreateMutation.run(args)
respond_with_mutation(outcome)
end
end
end
Thus, I can expose on my API a REST endpoint and a GraphQL mutation that offer the same action, without having to maintain twice the same logic in two different places!
Now what?
The use of Mutations
allowed me to acquire better reflexes as a developer. Before I started using GraphQL, it was my first approach to type checking, Ruby being my first programming language. It also led me to pay more attention to my validations, to identify the ones that were missing in my models, and so on.
Maybe it's just because my CTO happens to be an evangelist and I attended an inspiring presentation from @nathalyvillamor to @parisrb on the subject, but I feel like I've been hearing a lot about Monads
lately. Although this is a new concept for me, my first reads of the dry-monads documentation reminded me of Mutations
. So I wouldn't be surprised if more experienced developers advise me to look this way to go further.
In any case, I highly encourage beginners like me to look at one or the other, as these paradigms make the design of our APIs much more comfortable.
Top comments (1)
You can take a look at github.com/tymate/api-blocks/blob/... for an implementation of Mutation based on dry-rb.
Unfortunately this was built using dry-transaction which is going to be deprecated with the suggestion of using dry-monads directly. I will need to give some thoughts about how to re-implement it :)