Api design
Contents |
REST and JSON
Just to note that REST api means: Statelessness (no data between requests), Resource identification per request (update only one row, get could have more rows), Representational state transfer (returned representation JSON is enough to identify and manipulate row). REST enable cacheability so we can scale to large number of components.
Once API is exposed, you should not modify it, except for critical bugfixes. Use namespace
namespace :api, default: { format: :json } do
namespace :v1 do
resources :expenses, only: [:index, :create, :update, :destroy]
end
end
If you use jbuilder, than create folder app/views/api/v1/expenses
for view and
controller Api::V1::ExpensesController
JSONAPI Resources JR
jsonapi-resources give us implementation of JSONAPI
rails g jsonapi:resource Api::V1::Post
rails g jsonapi:controller Api::V1::Post
namespace :api do
namespace :v1 do
jsonapi_resources :posts
end
end
# config/initializers/jsonapi_resources.rb:
JSONAPI.configure do |config|
# built in paginators are :none, :offset, :paged
config.default_paginator = :paged
config.default_page_size = 50
config.maximum_page_size = 1000
# Do this if you use UUID's instead of Integers for id's
config.resource_key_type = :uuid
end
Each resource can be configured with:
attributes
attribute
- there are also
id
andtype
. Computed fields can use@model
but also need to be defined withattribute
fetchable_fields
all attributes are fetchable, and if you want to remove some than override methodself.updatable_fields
,self.creatable_fields
to override use class methodsclass ContactResource < JSONAPI::Resource attributes :name_first, :name_last, :full_name def full_name "#{@model.name_first}, #{@model.name_last}" end def self.updatable_fields(context) super - [:full_name] end # class method can be defines inside class << class << self def creatable_fields(context) super - [:full_name] end end end
- there are also
has_many
filters
,filter
Postman
You can use plugin Postman packaged app or Postman REST client to send API requests.
If you want to send json request from Postman than use header Accept:
application/json
. If server responds with json, it can set header
Content-type: Application/json
.
Nice extension is JSON Formatter that will render json response in readable way.
Curl commands:
POST vs PUT
When an user has one image (image is part of the user) than it is fine to do
PUT /users/1/image
when you want to update or create image.
PUT
should be idempotent (also DELETE
so you can call it several times).
If user have multiple images, than do not use nested, but use POST
/users/1/images
to create and PUT /images/1
to update image.
Use nouns
Only verb should be HTTP method (GET, POST, PUT, DELETE). Url should contain
only nouns that represent resource. So instead POST /users/1/send-message
it’s
better to use POST /users/1/messages
with Content-Type: application/json
{ "message": "Hello" }
.
If you need to send messages to multiple users, you can create new endpoint and
send data there POST /group-messages; Content-Type: application/json; { [ {
"user" : { "id" : 1 }, "message" : "Hello 1" },...] }
Use url params for search/filter GET /places?lat=123&lon=123
.
For authorization use headers POST /users/1/message; Authorization: Bearer
123
.
HTTP body can contain json data.
Response status codes:
Success:
- 200 OK (for GET)
- 201 Created successful POST, login
- 202 Accepted Accepted but is being processed async
- 204 No Content
:no_content
success delete, log out, sign out
For errors we could respond with head :not_found
(and hide sensitive
information) but its better to send the reason and some info (render json:
{ error: 'Some problem', error_status: 401, error_code: 1234 }, status: 401
)
- 303 See Other when session create (login) email does not exists
- 400 Bad Request
:bad_request
when we haveParameterMissing
- 401 Unauthorized
:unauthorized
used for wrong password on login form, or when there is no current_user but it should exists.- note that it should not send
:unauthorized
when user is changing password and type incorect old one (he still should be logged in, and not automatically logged out since unauthorized request occurs).
- note that it should not send
- 403 Forbidden
:forbidden
when current user exists but is forbidden from accessing this data - 404 Not Found
:not_found
url is not valid router or resource does not exist - 410 Gone data has been deleted, deactivated …
- 422 Unprocessable Entity
:unprocessable_entity
change password form but current password is bad, or form validation errorsif ! @user.update() render json: @user.errors.full_messages.join(','), status: :unprocessable_entity
, or when creating new resource but uniqueness validation (already exists) prevents
Server errors
500 Internal Server Error
unexpected happened on server side503 Service Unavailable
api is overloaded or in maintenance mode
Common responses:
Rails does not come with :unauthorized
exception
https://stackoverflow.com/questions/25892194/does-rails-come-with-a-not-authorized-exception
so you can create one
# app/models/authorization_exception.rb
class AuthorizationException < Exception
end
and you can use for example in login
# app/controllers/api/v2/sessions_controller.rb
def subscriber_login
# this will raise ActiveRecord::RecordNotFound
subscriber = Subscriber.find_by! username: params[:username]
# instead of bang we could use find_by and manually raise, result is the same
raise ActiveRecord::RecordNotFound.new("Couldn't find Subscriber") unless subscriber
raise AuthorizationException.new('Password is not correct') unless subscriber.authenticate(params[:password])
render json: { auth_token: JwtAuth.encode_subscriber_id(subscriber.id) }
and rescue from those exceptions and show json message
# app/controllers/application_controller.rb
rescue_from JWT::DecodeError do |e|
render json: { error_message: e.message, error_status: :bad_request}, status: :bad_request
end
rescue_from ActiveRecord::RecordNotFound do |e|
render json: { error_message: e.message, error_status: :not_found }, status: :not_found
end
rescue_from ActiveRecord::RecordInvalid do |e|
render json: { error_message: e.message, error_status: :unprocessable_entity }, status: :unprocessable_entity
end
# used with params.permit(:domain).fetch(:domain)
rescue_from ActionController::ParameterMissing do |e|
render json: { error_message: e.message, error_status: :bad_request }, status: :bad_request
end
rescue_from AuthorizationException do |e|
render json: { error_message: e.message, error_status: :unauthorized }, status: :unauthorized
end
In tests you can put byebug
below rescue_from ActiveRecord::RecordNotFound do |e|
so you can
Rails.application.class.parent.name.underscore # => myapp
puts exception.backtrace.select {|r| r.match Rails.application.class.parent.name.underscore }
Usually in case of validation error, I put only all error messages in one field
error_message
for example :
# app/controllers/users_controller.rb
def send_password
alert = "Failed to send new password"
format.json { render json: { error_message: alert, error_status: :unprocessable_entity }, status: :unprocessable_entity }
end
def create
@user = User.new(user_params)
if @user.save
render json: @user, status: :created
else
render json: { error_message: @user.errors.full_messages.to_sentence },
error_status: :unprocessable_entity
end
end
def update
unless @user.update(user_params)
render json: { error_message: @user.errors.full_messages.to_sentence },
error_status: :unprocessable_entity
end
end
In angular, I use connectionInterceptor
to set that data.error
in case of
other errors like: server offline.
In case of success, I use message
notice = "Successfully updated"
format.json { render json: { message: notice, data: user.to_json } }
Generate JSON
default ActiveModel JSON
Serializer
can be customized with render json: @phones.as_json(only: [:id], expect:
[:created_at], include: :posts)
but for larger API you need to have centralized serializers.
ActiveModelSerializer
To generate json you can use active_model_serializers documents
cat >> Gemfile << HERE_DOC
# serializer for api
gem 'active_model_serializers', '~>0.10.0'
HERE_DOC
bundle
rails g serializer user
If you rely on rails convention (empty def show;end
, implicit render json and
not specify render @user
) than serializer will not be used. If you specify
render json: @user
or render json: @users
than it will use UserSerialier
.
If you have some other structure like render json: { user: @user }
than
serializer will not be used. You can manully call render json: @user,
serializer: UserSerialier
or render json: @users, each_serializer:
UserSerialier
# app/serializers/application_serializer.rb
class ApplicationSerializer< ActiveModel::Serializer
include Rails.application.routes.url_helpers
end
# app/serializers/user_serializer.rb
def UserSerializer < ApplicationSerializer
attributes :id, :name
end
Associations are handled in reflections
You can call @users.active_model_serializer.new(@users, options).to_json
For grape you can use
https://github.com/ruby-grape/grape-active_model_serializers
json api
cat >> config/initializers/active_model_serializers.rb << HERE_DOC
ActiveModelSerializers.config.adapter = :json_api
HERE_DOC
JBuilder
jBuilder is already included in rails and is nice if you already have some views. https://github.com/rails/jbuilder
API Documentation
If you use rspec then go with rspec_api_documentation, or swagger
Test and Documentation from rspec
rspec_api_documentations/dsl and generate api docs rake docs:generate
and
gnome-open docs/
:
You need to put tests in /spec/acceptance
in order to get generate docs
working
Configuration
# spec/support/rspec_api_documentation.rb
RspecApiDocumentation.configure do |config|
config.docs_dir = Rails.root.join('public', 'api')
config.format = :json
# for json post or patch requests, request body needs to be encoded to json
# so use this condif instead of let(:raw_post) { params.to_json }
config.request_body_formatter = Proc.new { |params| params.to_json }
config.curl_host = 'admin.my-domain.com'
used_headers = ['Content-Type', 'Authentication']
ignored_headers = ['Cookie', 'Host']
config.curl_headers_to_filter = ignored_headers
config.request_headers_to_include = used_headers
config.response_headers_to_include = used_headers
# By default examples and resources are ordered by description. Set to true
# keep the source order.
config.keep_source_order = true
end
Usage:
resource
synonim for describe, but you need to useresource
if you want to generate docs and haveget
,post
,patch
,delete
- you can validate
response_status
andresponse_body
which isJSON.parse(response.body)
- you can validate
parameter
used to define params. You needlet(:param1) { 'my_email' }
in order to show it in example body or curlexample_request
is the same as callingexample
(synonim forit
) anddo_request
send :name
to get value ofname
no_doc
explanation
can be inside resource or it block
If you got 415 error UNSUPPORTED_MEDIA_TYPE
than set header.
If you got is not a valid resource
code 101, than you mispelled type
top
level attribute (for example users
) on resource
object.
If you got "no implicit conversion of nil into Hash"
than problem is that we
need to declare attribute
on the resource.
If you for does not contain a key
code 109, than you need to add :id
to both
“data” and “url” and use different name, for example this is duplication for
update resources:
patch '/v1/organizations/:organization_id' do
let(:organization) { create :organization, name: 'My Organization' }
let(:organization_id) { organization.id }
let(:id) { organization.id }
parameter :id, 'Id of the organization.', required: true
end
If you use parameter status than you need to disable dsl https://github.com/zipmark/rspec_api_documentation/issues/329 https://github.com/zipmark/rspec_api_documentation/issues/215
# config/support/rspec_api_documentation.rb
RspecApiDocumentation.configure do |config|
config.disable_dsl_status!
end
HTTP codes are standard (200 get, 201 created, 204 deleted).
You can debug with:
tail -f log/test.log
To change domain which is used in tests you can define full url in get
,
post
… like get "http://#{API_DOMAN}/posts" do
(note that we need to use
http prefix so it does not add it after example.org
http://example.org/www.api.domain/posts
)
Using RspecApiDocumentation.configure do |config|
and
config.curl_host = 'my.domain.com'
will change domain only on curl
example, not in test, so it will be curl -g
"my.domain.comwww.api.domain/posts"
. Another way is to use header
'Host', API_DOMAIN
so you do not need to use full url.
https://github.com/zipmark/rspec_api_documentation/issues/304 note that here we
do not need protocol http, we just need host domain
# spec/acceptance/api/v2/posts_spec.rb
require 'rspec_api_documentation/dsl'
API_DOMAIN = 'subdomain.my-domain.com'
resource 'Posts' do
header 'Accept', 'application/json'
header 'Authentication', :auth_token
# use header
header 'Host', API_DOMAIN
# or use full http path
get "#{API_DOMAN}/posts" do
end
let(:user) { create :user }
let(:auth_token) { JwtAuth.encode_user_id user.id }
let(:post) { create :post }
before do
user
post
end
end
JWT Json web tokens
gem 'jwt'
t = JWT.encode( {a: 1}, 'secret')
=> "eyJhbGciOiJIUzI1NiJ9.eyJhIjoxfQ.LrlPmSL4FxrzAHJSYbKzsA997COXdYCeFKlt3zt5DIY"
>> JWT.decode t, 'secret' #=> [{"a"=>1}, {"alg"=>"HS256"}]
Testing application is in ~/rails/temp/rails_5.2.3/
branch devise_jwt_token
.
You can add custom devise strategy so both devise http and jwt authentication
can work
http://blog.plataformatec.com.br/2019/01/custom-authentication-methods-with-devise/
api_token_strategy
# app/strategies/jwt_strategy.rb
class JwtStrategy < Warden::Strategies::Base
def valid?
# A copy of JwtStrategy has been removed from the module tree but is still active
# JwtAuth.token_from_request_headers request.headers
# so we need to copy same method here
request.headers['Authentication'].to_s.split(' ').last
end
def authenticate!
user = User.find_by(id: JwtAuth.decoded_user_id(request.headers))
if user
success! user
else
fail! 'Invalid email or password'
end
end
end
# app/services/jwt_auth.rb
class JwtAuth
SECRET = Rails.application.secrets.secret_key_base
def self.encode_user_id(user_id)
payload = { user_id: user_id }
JWT.encode(payload, SECRET)
end
def self.decoded_user_id(headers)
token = token_from_request_headers headers
payload = JWT.decode(token, SECRET)[0]
payload['user_id']
end
def self.token_from_request_headers(headers)
headers['Authentication'].to_s.split(' ').last
end
end
# config/initializers/devise.rb
config.warden do |manager|
manager.default_strategies(scope: :user).unshift :jwt_strategy
end
# config/initializers/warden.rb
Warden::Strategies.add(:jwt_strategy, JwtStrategy)
Swagger ui grape
https://github.com/kendrikat/grape-swagger-ui
# Gemfile
gem 'grape-swagger'
gem 'grape-swagger-ui'
# app/api/v1/root.rb
require 'grape-swagger'
module API
module V1
class Root < Grape::API
mount Events
add_swagger_documentation
end
end
end
# config/initializers/assets.rb
config.assets.precompile += %w(swagger_ui.js swagger_ui.css swagger_ui_print.css swagger_ui_screen.css)
Swagger docs
sed -i Gemfile -e '/group :development do/a \
# use github until it is merged https://github.com/richhollis/swagger-docs/pull/144
gem 'swagger-docs', git: 'https://github.com/kwerle/swagger-docs'
o
Basepath is important since other json files will be looked there.
param
first argument is: :query
, :path
or :form
swagger_controller :customer_sessions, "Customer Sessions"
swagger_api :create do
summary "Customer sign in"
notes "Sign in form"
param :form, :email, :string, :required, :Email"
end
To generate json, you need to run rake swagger:docs
or overide precompile task
# lib/tasks/precompile_overrides.rake
namespace :assets do
task :precompile do
Rake::Task['assets:precompile'].invoke
Rake::Task['swagger:docs'].invoke
end
end
Usually for precompile is disabled for production, so you need to enable it in
config/environments/production.rb
Copy dist
content to your public/apidocs
and update all paths in
public/apidocs/index.html
to match your /apidocs
, and change js url =
"/apidocs/api-docs.json";
- default sort is alphabetical for path and methods (delete, get, path…). You
can provide a function for
apisSorter
operationsSorter
but too complicated. Order in server response is not supported.
Appman swagger
TODO: https://www.loom.com/share/c922b8074b9443e899f388f1a19d095c https://rubygems.org/gems/appmap_swagger
Apipie rails
If you are using minitest there is not automatic way to generate docs, so you can use apipie-rails for documentation.
echo "gem 'apipie-rails'" >> Gemfile
bundle install
rails g apipie:install # this will generate config/initializers/apipie.rb and update routes
Jus add option config.translate = false
Grape
If grape is used than we can rescue from all exceptions, but that needs to be
before mount
methods and only for StandardError exceptions
# app/api/v1/root.rb
module API
module V1
class Root < Grape::API
rescue_from Koala::Facebook::AuthenticationError, MyappExceptions::Base do |e|
ExceptionNotifier.notify_exception(
Exception.new(e.message),
data: {
message: e.message,
stack: e.backtrace,
error: e
}
)
Rack::Response.new(["#{e.class} #{e.message}"], 500, 'Content-type' => 'text/error').finish
end
# now we can mount ...
mount Users
....
# app/models/myapp_exceptions.rb
module MyappExceptions
# use this base class so it is easier to rescue only that
# grape rescue only from standard error
class Base < StandardError
end
class UserNotFound < Base
end
end
Guide
https://github.com/interagent/http-api-design
Tips
- model skill level as string, but consider using array. For example if someone wants to cover all skill levels.
https://scotch.io/tutorials/build-a-restful-json-api-with-rails-5-part-two
https://medium.com/@stevenpetryk/providing-useful-error-responses-in-a-rails-api-24c004b31a2e#.lp6e0sjpf
https://github.com/tuwukee/jwt_sessions
In email search for Chris Kottom Lesson 9: Token-Based Authentication with JWTs Accessing Google oauth to list my videos https://martinfowler.com/articles/command-line-google.html