Joshua — Fast Ruby API
Joshua is opinionated API implementation for Ruby based clients, featuring automount for rack based clients.
Features
- Can work in REST or JSON RPC mode.
- Automatic routing + can be mounted as a Rack app, without framework, for unmatched speed and low memory usage
- Automatic documentation builder & Postman / Insomnia import link
- Nearly nothing to learn, pure Ruby clases
- Consistent and predictable request and response flow
- Errors and messages are localized
Installation
to install
gem install joshua
or in Gemfile
gem 'joshua'
or in Gemfile from GitHub
gem 'joshua', git: 'git@github.com:dux/joshua.git'
and to use
require 'joshua'
Components
- Request Flow
- REST / JSON RPC
- auto mount
- manual mount
- automatic routing
- API Methods
- collections or members
- helper methods
- description and detail
- annotations + custom annotations
- params + custom params
- models
- Response
- model exporters
- errors + custom errors
- success
- meta info
- message
- Class methods
- rescue from
- before and after
- extending and including
- Doc builder
Speed
Joshua directly maps requests to method calls, without routing and it also can work mounted directly on the rack interface, as demonstrated here.
By using plain ruby classes, direct mapping without routing and provideing direct rack
access if needed, it is hard to beat Egoist in pure speed.
Look and feel
-
member (/foo/123/bar)
andcollection (/foo/bar)
exist in separate namespace. You can havemember
andcollection
update
methods if you need to. -
rescue_from
,before
andafter
filters are supported. - you can inherit methods from parent class just as in plain ruby. Define generic
show
,create
,update
anddelete
methods and inherit them in parent classes. - many more stuff
class ModelApi < Joshua
rescue_from Policy::Error do |error|
error 403, 'Policy error: %s' % error.message
end
before do
@current_user = User.find_by token: @api.bearer
end
member do
def update
end
end
after do
response[:ip] = @api.request.ip
end
end
class UsersApi < ModelApi
collection do
# you can define methods as ruby methods
desc 'Login test'
detail 'user + pass = foo + bar'
params do
user
pass
end
def login
if params.user == 'foo' && params.pass == 'bar'
message 'Login ok'
'token-abcdefg'
else
error 'Bad user name or pass'
end
end
# or wrap them in define block for better visual semantics
define :login do
desc 'Login test'
detail 'user + pass = foo + bar'
params do
user
pass
end
proc do # or lambda or anything that responds to call
if params.user == 'foo' && params.pass == 'bar'
message 'Login ok'
'token-abcdefg'
else
error 'Bad user name or pass'
end
end
end
end
member do
def update
end
define :delete do
lambda {}
end
end
def helper_method
end
end
Annotated example
Featuring nearly all you have to know to start building your APIs using Joshua.
# in ApplicationApi we will define rules that will reflect all other API classes
class ApplicationAPI < Joshua
# inside ot the methods you can say `error :foo` and text error will raise
rescue_from :foo, 'Baz is angry'
# capture Policy::Error and add custom formating
rescue_from Policy::Error do |error|
error 403, 'Policy error: %s' % error.message
end
# define method annotation that will be run before the method executes
annotation :anonymous do
@anonymous_allowed = true
end
# define custom paramter called label
# that will allow only characters, with max length of 15
params :label do |value, opts|
error 'Label is not in the right format' unless value =~ /^\w{1,15}$/
end
# before block wil be executed before any method call
before do
# if token provided load user, raise error otherwise
if @api.bearer
@current_user = User.find_by token: @api.bearer
# raise unless user found
error 'Invalid API token' unless @current_user
end
# raise error unless @user defined and we dot allow anonymous access
if !@user && !@anonymous_allowed
error 'Anonymous access not allowed,please register'
end
# we will use this time to calcualte method execution speed
@_time = Time.now
end
# after block will be run after api method executes
after do
# add meta tag request.ip if request object is available
response[:ip] = @api.request ? @api.request.ip : '1.2.3.4'
# add meta tag speed in ms
response[:speed] = ((Time.now - @_time)*1000).round(3)
end
# `user` method will be available in member and collection methods
def user
# Raise and return error if user requested but not found
@current_user || error('User not loaded')
end
end
# we will create generic ModelAPI, that all models will inherit from
class ModelAPI < ApplicationAPI
# eexecute before all methods that inherit from ModelAPI
before do
# load generic object based on current class name
# UsersApi -> User
base = self
.class
.to_s
.sub(/Api$/, '')
.singularize
.constantize
# try to load the object
if @api.id
@model = base.find @api.id
error 'Object %s[%s] is not found' % [base, @api.id] unless @model
else
@model = base.new
end
# raise error unless object not found
error 404, 'Object %s[%s] is not found' % [base, @api.id] unless @model
end
# execute after method exection, only in member methods
after do
# add object path to response
response[:path] = @model.path
end
end
# example API class for User model
class UsersApi < ModelAPI
# document this class in various documentations
documented
# define methods for methods that do not need id
collection do
# describe the method
desc 'Signup via email to app'
# define email param, with type of email, required
params do
email :email
end
# define "/api/users/signup" method
def signup
# deliver magic login link
Mailer.email_login_magic_link(params.email).deliver
# add response message
message 'Email with login link sent to %s' % params.email
end
# params can be defined as a block as well
params do
# method name in a block is paramter name, and it is required
# String is defult type, you can skip writeing it
user String
# if you add question mark, it is not required
pass? :string
end
# /api/users/login
unsafe
def login
if params.user == 'foo' && params.pass == 'bar'
User.first.token
else
error 'Wrong user or pass'
end
end
end
member do
before do
@user = @model
# unless user is admin
unless user.can.admin?
# do not allow him to access member methods in UsersApi class
if @user.id != user.id
error('This is not you! Hack attempt logged :)')
end
end
end
# allow access via GET
allow :get
# /api/users/:id/show
def show
# export object hash
@user.api_export
end
# /api/users/:id/show
def delete
@user.destroy
message 'You deleted yourself'
end
# you can use define to create an api method, to have all nested under readable block
# just be sure that you return a proc or labmda as a last argument
# /api/users/:id/re_tokenize
define :re_tokenize do
desc 'Generate new user access token'
proc do
@user.update token: Crypt.random(40)
messsage 'New token generated'
end
end
end
end
# Example api call with response
UserApi.render :login, params: { user: 'foo', pass: 'bar' }
UserApi.render.login user: 'foo', pass: 'bar' }
# {
# success: true,
# message: 'login ok',
# meta: { ip: '127.0.0.1' }
# }
UserApi.render :login, params: { user: 'aaa', pass: 'bbb' }
# {
# success: false,
# error: {
# messages: ['Wrong user or pass']
# },
# meta: { ip: '127.0.0.1' }
# }
Main features in detail
Can work in REST or JSON RPC mode
By default API works on POST for all methods and raises error for any other reqest type. You can modify the behaviour by enabling specific methods using for example allow :get
to allow HTTP GET
, shortcut gettable
or force :get
to only allow HTTP GET
.
Example requests
# this POST request will in production by default
curl -d 'foo=bar' http://localhost:3000/api/orgs/1/show
# or as JSON RPC style POST
curl -d '{"id":"rand","action":["org","1","show"],"params":{"foo":"bar"}}' http://localhost:3000/api
# this will work only in development (GET request)
curl http://localhost:3000/api/orgs/1/show?foo=bar
Response is consistent because it is generated from Joshua::Response class but you can respond with anything you like
# respond with csv data
# /api/user/1/send_csv
def send_csv
response :csv do
@user.generate_csv_data
end
end
# Content-type: application/csv
# name, email, ...
# response with CSV in response data block
# /api/user/1/send_csv
def send_csv
@user.generate_csv_data
end
# Content-type: application/json
# {
# success: true,
# data: 'csv data...'
# }
Automatic routing
Requests are directly maped to ruby methods
Routes can have max 3 elements.
-
2 elements, "collection" routes without rosource indentifier
class / collection method -
3 elements
class / resource-id / member method
Example will say it all
class UsersApi
collection do
# /api/users/login
def login
'login'
end
end
member do
# /api/users/:id/update
def update
'update'
end
end
end
module Parent
class Child
member do
# Note that you separate modules/classes with a dot.
# /api/parent.child/:id/nested
def nested
end
end
end
end
It is possible to have custom routes as /api/:company/:class/:id/:method
etc but you have to configure that manualy. This is what you get "out of the box" by auto_mount
This is ALL you have to know about routing.
Automatic documentation builder
Beautiful documentation is automaticly build for you, with ready libraries for all popular languages.
To enable class documenttion add documented
class UserApi < Joshua
documented
# ...
end
Assuming that Joshua
mount point is /api
- You will find interactive HTML documentation on
/api
- RAW JSON is available on
/api/_/raw
-
Postman import URL is available on
/api/_/postman
Example screenshot
Consistent and predictable request and response flow
Routing is automatic and response is generated by Joshua::Response class.
# successuful request
{
success: true,
id: 'unique-response-id',
data: 'csv data...'
message: 'Object updated'
meta: {
foo: :bar
}
}
# request with errors - form submit example
{
success: false,
errors: {
messages: ['Foo error', 'Bar error'],
details: {
foo: 'Foo error',
bar: 'Bar error'
}
}
}
Class methods
Methods avaiable on class level.
Rescue from
Similar to Rails rescue_from
. You can call manualy with error :foo
or error 404
, capture named errors and format response as you fit.
class UsersApi < Joshua
rescue_from :foo, 'Baz is angry'
member do
# in method
def foo
error :foo
end
end
# capture Policy::Error and add custom formating
rescue_from Policy::Error do |error|
error 403, 'Policy error: %s' % error.message
end
collection do
# in method
def foo
@user.can.admin! # triggers Policy::Error, gets captured
end
end
end
Annotations
Annotations enable us to add API method annotations
Example: guest access
Case: If we add let_guests_in!
annotation we enable guests to use the method.
# define method annotation that will be run before the method executes
annotation :let_guests_in! do
@guets_allowed = true
end
before do
# before filter picks up annotation and can be used in logic
error 'Guest access not allowed' unless @user || @guets_allowed
end
collection do
let_guests_in! # annotation used
def login
error 'This will never trigger' unless @user || @guets_allowed
# ...
end
end
Example: working hcaptcha.com / recaptha
Case: If we add hcaptcha
annotation we enusre that https://hcaptcha.com
check is passed
annotation :hcaptcha! do
captcha = params['h-captcha-response'] || error('Captcha not selected')
data = JSON.parse `curl -d "response=#{captcha}&secret=#{Lux.secrets.hcaptcha.secret}" -X POST https://hcaptcha.com/siteverify`
unless data['success']
error 'HCaptcha error: %s' % data['error-codes'].join(', ')
end
end
collection do
define :lost_password do
desc 'Lost password email (hcaptcha required)'
hcaptcha!
params do
email :email
end
proc do
Mailer.lost_pass params.email
'Mail sent'
end
end
end
Params
-
you can define params directly on the params metod or you can pass as a block
-
every param can have
optional: true
or end name with?
# inline params :full_name, min: 2, max: 40 # inline optional params.full_name? # default String, required: false params.full_name String, required: false # same params.full_name String, optional: true # same # as a block params do user_email? :email # type: :email, required: false user_email :email, req: true # type: :email, required: true user_email :email, required: true # type: :email, required: true end
-
every param can have
default:
value that will be applies if value isblank?
-
min and max are available for Integer, Float
params do price Integer, min: 20, max: 100000, default: 1000 end
-
boolean types can be defined in 3 ways
params do is_active :boolean # { type: :boolean, default: false } is_active false # { type: :boolean, default: false } is_active true # { type: :boolean, default: true } end
-
array types are supported
params do labels Array[:label] # Collection labels Set[:label] # In Set duplicates are discarded # if data is provided in a string and not in a Array value # you can define a delimiter that will split String to Array labels Array[:label], delimiter: /\s*,\s*/ end
-
many supported types and you can define your own types
- native -
:integer
,:float
,:date
,:datetime
,:boolean
,:hash
- custom -
:email
,:url
,:oib
,:point
(geo point) - you can as well define your custom type
- native -
Define custom params type
You can define custom param type
- first argument is param type
- second argument is param options
- you must return value, value coarse is possible (as memonstrated below)
# define custom paramter called label
# that will allow only characters, with max length of 15
params :locale do |value, opts|
# allow 'en' or 'en-gb'
error 'Length should be 2 or max 5 chars' unless [2, 5].include?(value.
error 'Local is not in the right format' unless value =~ /^[\w\-]+$/
value.downcase
end
member do
params do
projet_locale :locale
end
def project
# ...
end
end
Before and after & members and collections
-
before
andafter
filters- if defined in root, fill be triggerd on every API method call.
- if nested under
member
andcollection
will be run only inmember
andcollection
api methods.
-
collection
api methods- can be written as
collection do ...
orcollections do ...
- will run methods when resource ID is NOT provided
- example route
/api/users/login
- example route
- can be written as
-
member
api methods- can be written as
member do ...
ormembers do ...
- will run methods when resource ID is provided
- example route
/api/users/123/show
or/api/users/abc-def/show
- accessible via
@api.id (type: String)
- example route
- can be written as
Example
class TestApi < Joshua
# before block wil be executed before any method call
before do
@num = 1
end
# after will be run after the method executes
after do
# ...
end
collection do
# /api/user/foo
def foo
@num + foo # 1 + 3 = 4
end
end
member do
# if defined in `member` of `collection`
# it will be called ONLY in respected groups.
before do
@num += 2
end
# execute after member methods
after do
# ...
end
# /api/user/:id/foo
def foo
@num + foo # (1 + 2) + 3 = 6
end
end
# this will not be in collision with member or collection methods
# any method that is not inside member or collection is a member method
def foo
3
end
end
after_auto_mount
If you want to modify api request after mount. first parameter is class+method path and second is all options hash.
# /api/cisco/contracts/list
# covert to
# /api/contracts/list?org_id=123
after_auto_mount do |nav, opts|
if org = Org.find_by code: nav.first
nav.shift
opts[:params][:org_id] = @org.id
end
end
unsafe
Methods marked as unsafe will set option @api.opts.unsafe == true
You can use that information not to check for bearer auth token in before
filter.
Models
API models can be defined and paramterers can be checked against the models
class ApplicaitonApi
model :company do
id Integer
name String
address :address
end
model User do
id Integer
name String
email :email
is_admin :boolean
# If proc is defined and returned, filtering will be applied
# before the data is forwarded to api method
# In this case raise error is :is_admin attribute is defined but user
# is not allowed to change it
proc do |data|
if !data[:is_admin].nil? && !user.can.admin?
error 'You are not allowed change the value of :is_admin attribute'
end
end
end
end
class UserApi
members do
desc 'Update user options'
params do
user model: User
end
def update
# ...
end
end
end
API methods - inline methods
Joshua specific methods you can call inside API methods (ones in member
or collection
blocks)
error
If you want to manualy trigger errors
rescue_from :foo do |error|
error 403, 'Policy error'
end
def foo
# trigger named erorr
error :foo # { success: false, code: 403, error: { messages: ['Policy error'] }}
# default response status is 400
error 'foo bar' # { success: false, code: 400, error: { messages: ['foo bar'] }}
# you can define response status
error 404, 'foo' # { success: false, code: 404, error: { messages: ['foo'] }}
end
response
Response object is responsible for response render
# respond with csv data
# /api/user/1/send_csv
def send_csv
response :csv do
@user.generate_csv_data
end
end
# Content-type: application/csv
# name, email, ...
# response with CSV in response data block
# /api/user/1/send_csv
def send_csv
# add "foo" meta response key with value
response[:foo] = :bar
# the same
response.meta :foo, :bar
# access rack response header
response.header['content-type'] = 'application/foo'
# force response.status 404
error 404, 'Object not found'
# defaults to status: 400
error 'Object not found'
# check if response has errors
response.error?
# manual set response data
response.data = :foo
@user.generate_csv_data
end
# Content-type: application/json
# {
# success: true,
# data: 'csv data...'
# message: 'Object updated'
# meta: {
# foo: :bar
# }
# }
message
Message method sends message to response.
def update
# add response message
message 'Object updated'
:foo
end
# {
# success: true,
# message: 'Object updated'
# data: 'foo'
# }
helper methods
Helper methods are all instance methods defined outside member
or collection
scopes
response errors
You are free to use all HTTP error status codes, but we suggest to use only 400
for handled errors and 500
for unhandled errors, and of course, try to provide nice error descriptions.
Example
rescue_from :big_load do
custom_looger :load_too_big
error 'There is to big load on the API, please try again or sign up for priority access'
end
def foo
# response.status: 400
error 'Object not found'
# response.status 400, error.code: 404
error 'Object not found', code: 404
# response.status 404, error.code: 404
error 'Object not found', status: 404, code: 404
# unhandled, response.status: 500
raise 'Some erorr'
# execute rescued :big_load
error :big_load
end
@api - instance variable
Joshua is not polluting scope with various instance varaibles. Only @api
variable is used.
Basicly, this are options passed to initialize
or auto_mount
+ instance specifics.
def foo
@api.action == :foo # true
end
-
@api.action
- original triggered action -
@api.bearer
- Bearer that is passed in or from aAuth
header -
@api.development
-true
orfalse
. In development mode -
@api.id
- inmember
methods, this will be resource ID. -
@api.opts
- Options passed to initializer -
@api.params
- Method params hash -
@api.rack_response
- original rack response object -
@api.request
- original rack request object -
@api.response
- internal response object, accessible fromresponse
method -
@api.uid
- if using JSON RPC and id is passed, it will be stored here
Extending, mounting, including
There is no mount
, you just include ruby files like you would to with any other ruby class.
There are 2 ways to create modules ready for inlude
Plain ruby
Define a module and include it as you would do with any other ruby class.
module ApiModuleClasic
def self.included base
base.collection do
def foo
message 'bar'
end
end
end
end
class UserApi < Joshua
include ApiModuleClasic
end
# /api/user/foo # { message: 'bar' }
Calling super methods
If you want to call super
to call super method inside api methods, you need to call them with super!
. You can also pass a super method name as a argument.
class ParentApi < Joshua
collection do
def foo
123
end
end
end
class ChildApi < ParentApi
collection do
def foo
super! # 122
345
end
def bar
foo # 345
super! :foo # 123
end
end
end
As a plugin
Plugin inteface has few lines less.
Joshua.plugin :foo_bar do
collection do
def foo
message 'baz'
end
end
end
class UserApi < Joshua
plugin :foo_bar
end
# /api/user/foo # { message: 'baz' }
Initializing
There are a three basic ways you can initialize yor app
1.using config.ru - withouth framework
This is the fasted way with best memory usage.
If you clone this repo and run puma -p 4000
in root, you can see how local example works.
require_relative 'joshua'
class ApplicationApi < Joshua
end
class UsersApi < ApplicationApi
collection do
def login
'To do'
end
end
end
run ApplicationApi
# /users/login -> { success: true, data: 'To do' }
2. auto mounting
Using Sinantra
# this will mount api in /api endpoint
post '/api*' do
ApplicationApi.auto_mount mount_on: '/api',
request: request,
response: response,
development: ENV['RACK_ENV'] == 'development'
end
Using Ruby On Rals
# config/routes.rb
mount ApplicationApi => '/api'
# or
match '/api/**', to: 'api#mount', via: [:get, :post]
# app/controllers/api_controller.rb
class ApiController < ApplicationController
def mount
ApplicationApi.auto_mount mount_on: '/api',
api_host: self,
bearer: user.try(:token),
development: Rails.env.development?
end
end
3. Manual mount
When manualy mounting APIs, you need to use specific Joshua endpoint and return the resposnse.
post '/api/users/index' do
result = UsersApi.render :index
my_format_api_response result
end
Testing & non api usage
No testing helpers provided (for now)
Use this for easy access (get response Hash
)
# call user collection method login
UserApi.render.login(user: 'foo', pass: 'bar')
# call user member method show
UserApi.render.show(123)
# call user member method foo
UserApi.render.foo(123, bar: 'baz')
# or wih user token expanded
UserApi.render :foo, id: 123, bearer: @user.token, params: { bar: 'baz' }
Demos
- Simple demo, runnable rack app https://github.com/dux/joshua/tree/master/demos/simple
- Real life AppicationApi, BaseModelApi, UserApiSimple demo https://github.com/dux/joshua/tree/master/demos/inherited-model
Dependencies
- rack - basic request, response lib
- json - better JSON export
- http - for JoshuaClient
-
dry-inflector -
classify
,constantize
, ... - html-tag - for documention builder
- clean-hash - for params in api methods
Development
After checking out the repo, run bundle install to install dependencies. Then, run rspec to run the tests.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/dux/joshua. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
License
The gem is available as open source under the terms of the MIT License.