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"
}
]
}
This structure has the following advantages:
- Gives you the possibility to define multiple errors, and not a single one.
- 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.
- a
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" }
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
You can find the implementation of the ErrorsMapper
in this gist:
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) }
here is an example implementation of such middleware:
it has two characteristics:
- It hides details in case of 500
- It shows the content of the attribute
reason
in the Exception, if present, allowing us to define custom errors and returning custom messages. - 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."
}
]
}
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
and we instruct Rails on how to treat this exception:
# config/application.rb
config.action_dispatch.rescue_responses.merge!(
'UnauthorizedError' => :unauthorized
)
we can then raise an Exception and specify also the reason why we raised it:
raise UnauthorizedError, :missing_api_key if api_key.blank?
or
raise UnauthorizedError, :wrong_api_key if request.headers['Api-Key'] != ENV['API_KEY']
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
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:
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"]
}
}
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
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?"]
}
}
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
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
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!
Top comments (0)