DEV Community

Alessandro Rodi
Alessandro Rodi

Posted on

9 6

Rails API: be nice to your clients

Since at Renuo we recently worked a lot on implementing APIs for third parties and we received strong compliments and comments like

Oh! Thank god! Finally a well-made API!

I decided to share with you the decisions we took and re-used and refined among three different projects, so you can also be nice to your clients ❤️

Errors

ActiveRecord errors

We read through the jsonapi standard and took out what we consider are the good parts, and removed everything that we didn't need.

That's the structure of our errors when we return them:

{
  "errors": [
    {
      "pointer": "first_name",
      "code": "blank",
      "detail": "First Name can't be blank"
    },
    {
      "pointer": "last_name",
      "code": "blank",
      "detail": "Last Name can't be blank"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This structure has the following advantages:

  1. Gives you the possibility to define multiple errors, and not a single one.
  2. Each error is structured separately and contains:
    • a pointer to the place where the error happened,
    • a code, readable by a machine, that defines a unique kind of error,
    • a detail, that contains text, easy to read by humans, to understand what is wrong.

These errors have all the characteristics necessary to be easily understood, debugged and solved by your clients.

We even return such errors:

{ "pointer": "gender",
  "code": "inclusion",
  "detail": "Gender is not included in the list. Allowed values: male, female, company, other" }
Enter fullscreen mode Exit fullscreen mode

by giving a hint on how to solve the problem in the detail itself.

The controller implementation is as easy as

def create
  if @model.save
    # ...
  else
    render json: { errors: ErrorsMapper.call(@model) }, status: :unprocessable_entity
  end
end
Enter fullscreen mode Exit fullscreen mode

You can find the implementation of the ErrorsMapper in this gist:

class ErrorsMapper
class << self
def call(model, *prefix)
model.errors.details.map { |attribute, detail| map_details(model, prefix, attribute, detail) }.flatten
end
def single(status: 422, attribute: :base, code: :invalid, message: I18n.t('activerecord.errors.messages.invalid'))
{ status: status, pointer: attribute, code: code, detail: message }
end
private
def map_details(model, prefix, attribute, detail)
detail.map.with_index do |error, index|
if error[:value].respond_to?(:errors)
call(error[:value], [*prefix, attribute])
else
code = error[:error]
message = full_message(model, attribute, index, code)
single(attribute: [*prefix, attribute].compact.join('.'), code: code, message: message)
end
end
end
def full_message(model, attribute, index, code)
message = model.errors.full_message(attribute, model.errors.messages[attribute][index])
message += allowed_values(model, attribute) if code == :inclusion
message
end
def allowed_values(model, attribute)
validator = model.class.
validators_on(attribute).
find { |v| v.is_a?(ActiveModel::Validations::InclusionValidator) }
if validator
values = validator.options[:in]
I18n.t('activerecord.errors.messages.inclusion_addition', values: values.join(', '))
else
''
end
end
end
end

Generic errors

How do we keep the same structure when an unexpected error happens? We use a custom middleware that is configured as exceptions_app in application.rb as follows:

# config/application.rb

config.exceptions_app = ->(env) { ActionDispatch::JsonApiPublicExceptions.new(Rails.public_path).call(env) }
Enter fullscreen mode Exit fullscreen mode

here is an example implementation of such middleware:

module ActionDispatch
class JsonApiPublicExceptions < PublicExceptions
def call(env)
request = ActionDispatch::Request.new(env)
status = request.path_info[1..].to_i
exception = env['action_dispatch.exception']
message = returned_message(exception, status)
attribute = exception.try(:param) || :base
code = convert_to_code(status)
body = { errors: [ErrorsMapper.single(status: status, attribute: attribute, code: code, message: message)] }
render(status, request.formats.first, body)
end
private
def returned_message(exception, status)
if status == 500
I18n.t('errors.internal_error.message')
else
exception.try(:reason)
end
end
def convert_to_code(status)
Rack::Utils::HTTP_STATUS_CODES.fetch(status, Rack::Utils::HTTP_STATUS_CODES[500]).downcase
end
end
end

it has two characteristics:

  1. It hides details in case of 500
  2. It shows the content of the attribute reason in the Exception, if present, allowing us to define custom errors and returning custom messages.
  3. It re-uses the ErrorsMapper seen above to keep the same errors structure.

This is an example of error:

{
  "errors": [
    {
      "status": 500,
      "code": "internal_server_error",
      "detail": "An internal error occurred. We have been notified and we will tackle the problem as soon as possible."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Custom errors

When we need to display a custom error, we can now rely on Rails exceptions_app configuration. If, for example, we want to show an error when the provided api key is missing or wrong we define our custom Exception:

class UnauthorizedError < StandardError
  attr_accessor :reason

  def initialize(reason = nil)
    @reason = reason
  end
end
Enter fullscreen mode Exit fullscreen mode

and we instruct Rails on how to treat this exception:

# config/application.rb

config.action_dispatch.rescue_responses.merge!(
      'UnauthorizedError' => :unauthorized
)
Enter fullscreen mode Exit fullscreen mode

we can then raise an Exception and specify also the reason why we raised it:

raise UnauthorizedError, :missing_api_key if api_key.blank?
Enter fullscreen mode Exit fullscreen mode

or

raise UnauthorizedError, :wrong_api_key if request.headers['Api-Key'] != ENV['API_KEY']
Enter fullscreen mode Exit fullscreen mode

That's all regarding the errors part. Let's now save some of our clients time 😉

fresh_when

I won't go deep in this blog post regarding the usage of fresh_when, since you can read everything about it in the documentation

I encourage you to use it when possible but do not abuse it and be careful. If, for example, in the response, you return nested resources, you should keep this in consideration when implementing the fresh_when. As always: caching is hard and adds complexity to the system. Do it wisely and document it.

Swagger

Provide a nice and up-to-date swagger documentation of your APIs. The gem rswag is able to publish a nice, clickable, documentation, generated from the swagger, and also to generate the documentation directly from your tests. Give it a try!

Strong Parameters

Last suggestion, with also another bit of code that you might re-use. How do you behave when a client sends an unknown parameter? By using StrongParameters you have, in general, two choices:

You raise an exception

You can configure:

config.action_controller.action_on_unpermitted_parameters = :raise
Enter fullscreen mode Exit fullscreen mode

and every time you receive an unknown parameter, your application will raise an exception. This is an ok behaviour, but it might not suite all the situations, that's also why the default is the next one:

You ignore them

By default, unpermitted_parameters are simply ignored and skipped, but this might lead to a problem when the client sends a non-mandatory field and commits a typo. ouch!
You defined the optional field as zip_code and they sent zip. Since is not mandatory, your API will simply ignore the field and return a nice 201 to the clients, informing them that the record has been saved.

You can be nice to your clients and still return a 201 but also giving them an hint that something might be wrong. We implemented and use the following concern in our controllers:

module UnpermittedParametersListener
extend ActiveSupport::Concern
included do
before_action :instrument_listener_for_unpermitted_params
after_action :merge_unpermitted_params_hints
attr_reader :permitted_action_params
end
private
def permitted_action_params=(params)
@permitted_action_params = params
@spell_checker = DidYouMean::SpellChecker.new(dictionary: deep_values(params))
end
def instrument_listener_for_unpermitted_params
@unpermitted_parameters ||= []
ActiveSupport::Notifications.subscribe('unpermitted_parameters.action_controller') do |_name, _start, _finish, _id, payload|
@unpermitted_parameters += payload[:keys]
@unpermitted_parameters.uniq!
end
end
def merge_unpermitted_params_hints
return unless @unpermitted_parameters.any?
json_response = JSON.parse(response.body).with_indifferent_access
json_response[:meta] = { hints: generate_hints }
response.body = json_response.to_json
end
def deep_values(object)
case true
when object.is_a?(Array)
object.map { |e| deep_values(e) }.flatten
when object.is_a?(Hash)
object.values.map { |e| deep_values(e) }.flatten
else
object
end
end
def generate_hints
@unpermitted_parameters.map do |attribute|
suggestions = @spell_checker.present? ? @spell_checker.correct(attribute) : []
hint = "#{attribute} is not a valid parameter."
if suggestions.any?
hint += " Did you mean #{suggestions.to_sentence(two_words_connector: ' or ', last_word_connector: ', or')}?"
end
hint
end
end
end

This concern will add a {"meta": {"hints": [...]}} part to your response, with the list of attributes sent in the request and not accepted by the API. By default, simply including this concern, you will obtain a response like:

{
 "meta": {
  "hints": ["zip is not a valid parameter"]
 }
}
Enter fullscreen mode Exit fullscreen mode

but you can also do one step more and set the list of allowed attributes with:


def create
  model.create(model_params)
end

def model_params
 self.permitted_action_params = %i[zip_code first_name last_name]
 params.require(:model_name).permit(permitted_action_params)
end
Enter fullscreen mode Exit fullscreen mode

and the error will magically be even more detailed. for the customer:

{
 "meta": {
  "hints": ["zip is not a valid parameter. Did you mean zip_code?"]
 }
}
Enter fullscreen mode Exit fullscreen mode

Versioning

There are different ways how you can version your APIs for breaking changes. The solution we adopt at Renuo is the Api-Version header. We went through all other possibilities before deciding that a version header is our first choice. Shortly:

  • URL versioning sucks, you need to define all new routes every time you need to release a new version, and do weird customizations to redirect v2 endpoints to v1 controllers if they don't have a v2 implementation. Also, your clients will need to invoke new endpoints 🤮.
  • Versioning via query parameter might work but you don't want to mix "meta" parameters with your actual ones.

We usually implement a very easy method that fetches the current wished version by the client:

def api_version
  request.headers['Api-Version']&.to_i || 1
end
Enter fullscreen mode Exit fullscreen mode

and what might sound weird but is actually really effective, is that at the very beginning, you can simply write something like:

def do_something
  if api_version > 1
    do_something_new
  else
   do_something_old
  end
end
Enter fullscreen mode Exit fullscreen mode

and you will cover already 80% of your needs.

Conclusions

I hope the tips above will help you with your work and to implement better APIs. Since it will happen that I am on the client-side, I hope that the developer on the server-side read this blog post.

If you need to implement APIs or need help with your Rails app get in touch with us at Renuo. We will be happy to help!

Please leave your appreciation by commenting on this post!

Let's go

Top comments (0)

Billboard image

Try REST API Generation for Snowflake

DevOps for Private APIs. Automate the building, securing, and documenting of internal/private REST APIs with built-in enterprise security on bare-metal, VMs, or containers.

  • Auto-generated live APIs mapped from Snowflake database schema
  • Interactive Swagger API documentation
  • Scripting engine to customize your API
  • Built-in role-based access control

Learn more

👋 Kindness is contagious

Discover a treasure trove of wisdom within this insightful piece, highly respected in the nurturing DEV Community enviroment. Developers, whether novice or expert, are encouraged to participate and add to our shared knowledge basin.

A simple "thank you" can illuminate someone's day. Express your appreciation in the comments section!

On DEV, sharing ideas smoothens our journey and strengthens our community ties. Learn something useful? Offering a quick thanks to the author is deeply appreciated.

Okay