Isolating and recycling logic with Mutations

camblan

Julien Camblan

Posted on March 3, 2020

Isolating and recycling logic with Mutations

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.

💖 💪 🙅 🚩
camblan
Julien Camblan

Posted on March 3, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related