Joshua
Fast, opinionated Ruby API framework with automatic routing and documentation.
Overview
Joshua maps HTTP requests directly to Ruby methods without routing configuration. It works as a standalone Rack app or integrates with Rails/Sinatra.
class UsersApi < Joshua
collection do
define :login do
desc 'Authenticate user'
params do
email :email
pass String
end
proc do
user = User.authenticate(params.email, params.pass)
user ? user.token : error('Invalid credentials')
end
end
end
member do
define :show do
proc { User.find(@api.id).to_h }
end
end
end
# Routes created automatically:
# POST /api/users/login
# POST /api/users/:id/showInstallation
# Gemfile
gem 'joshua'
# Or from GitHub
gem 'joshua', git: 'https://github.com/dux/joshua.git'Requires Ruby 2.5+.
Quick Start
Standalone (config.ru)
require 'joshua'
class ApplicationApi < Joshua
end
class UsersApi < ApplicationApi
collection do
def ping
'pong'
end
end
end
run ApplicationApiRun with rackup -p 3000, then curl http://localhost:3000/users/ping.
Rails Integration
# config/routes.rb
match '/api/*path', to: 'api#handle', via: [:get, :post]
# app/controllers/api_controller.rb
class ApiController < ApplicationController
def handle
ApplicationApi.auto_mount(
mount_on: '/api',
api_host: self,
bearer: current_user&.token,
development: Rails.env.development?
)
end
endSinatra Integration
post '/api/*' do
ApplicationApi.auto_mount(
mount_on: '/api',
request: request,
response: response
)
endDefining Endpoints
Use define :method_name do ... proc do ... end end to define endpoints (preferred). You can also use plain def methods:
collection do
# Preferred: define block with proc
define :login do
desc 'Login endpoint'
params do
email :email
end
proc { User.authenticate(params.email) }
end
# Alternative: plain def (less visual grouping)
desc 'Health check'
def ping
'pong'
end
endRouting
Routes map directly to methods. No configuration needed.
| Route Pattern | Block Type | Example |
|---|---|---|
/class/method |
collection |
/users/login |
/class/:id/method |
member |
/users/123/show |
class UsersApi < Joshua
collection do
define :login do # /api/users/login
proc { 'login' }
end
end
member do
define :show do # /api/users/:id/show
proc { @api.id } # => "123"
end
end
end
# Namespaced classes use dots
module Admin
class UsersApi < Joshua
member do
define :ban do # /api/admin.users/:id/ban
proc { }
end
end
end
endParameters
Define parameters inside define block:
collection do
define :signup do
params do
email :email # required email
name String # required string
age? Integer # optional integer
role String, default: 'user' # with default
score Integer, min: 0, max: 100
end
proc do
params.email # access via dot notation
params[:name] # or hash syntax
end
end
endBuilt-in Types
-
:string(default),:integer,:float,:boolean -
:email,:url,:date,:datetime,:hash
Custom Parameter Types
class ApplicationApi < Joshua
params :phone do |value, opts|
error 'Invalid phone' unless value =~ /^\+?[\d\-\s]+$/
value.gsub(/\D/, '') # return normalized value
end
end
class UsersApi < ApplicationApi
collection do
define :update_phone do
params do
contact_phone :phone
end
proc do
# params.contact_phone is normalized
end
end
end
endArray Parameters
params do
tags Array[:string]
tags Array[:string], delimiter: /\s*,\s*/ # split string input
endResponse Format
All responses follow a consistent structure:
# Success
{
success: true,
data: "returned value",
message: "Optional message",
meta: { custom: "metadata" }
}
# Error
{
success: false,
error: {
messages: ["Error description"],
details: { field: "Field error" }
}
}Response Methods
define :update do
proc do
message 'User updated' # set response message
response[:request_id] = @api.uid # add metadata
response.meta :version, '1.0' # same as above
{ id: 1, name: 'foo' } # returned value becomes data
end
endCustom Content Types
define :export_csv do
proc do
response do
@user.to_csv # bypasses JSON wrapper
end
end
end
# Returns raw CSV with text/plain content typeError Handling
define :foo do
proc do
error 'Something went wrong' # 400 status
error 404, 'Not found' # custom status
error 403, 'Forbidden', code: 'ACCESS_DENIED' # with error code
end
endNamed Errors and rescue_from
class ApplicationApi < Joshua
rescue_from :unauthorized, 'Authentication required'
rescue_from ActiveRecord::RecordNotFound do |e|
error 404, 'Record not found'
end
rescue_from :all do |e|
# catch-all for unhandled exceptions
error 500, 'Internal error'
end
end
class UsersApi < ApplicationApi
member do
define :show do
proc do
error :unauthorized unless @current_user
User.find(@api.id) # raises RecordNotFound if missing
end
end
end
endLifecycle Callbacks
class ApplicationApi < Joshua
before do
@current_user = User.find_by(token: @api.bearer)
end
after do
response[:timestamp] = Time.now.iso8601
end
end
class UsersApi < ApplicationApi
member do
before do
# runs after ApplicationApi's before, only for member methods
@user = User.find(@api.id)
end
define :show do
proc { @user.to_h }
end
end
endAuthentication
Bearer tokens are extracted from the Authorization header:
# Request: Authorization: Bearer abc123
class ApplicationApi < Joshua
before do
if @api.bearer
@current_user = User.find_by(token: @api.bearer)
error 401, 'Invalid token' unless @current_user
end
end
endUnsafe Methods
Mark methods that don't require authentication:
collection do
define :login do
unsafe
proc do
# @api.opts.unsafe == true
# before filter can check this to skip auth
end
end
endAnnotations
Create reusable method decorators:
class ApplicationApi < Joshua
annotation :require_admin do
error 403, 'Admin required' unless @current_user&.admin?
end
annotation :rate_limit do |limit|
check_rate_limit(@current_user, limit)
end
end
class AdminApi < ApplicationApi
collection do
define :delete_all do
require_admin
rate_limit 10
proc do
# protected by annotations
end
end
end
endModels
Define reusable parameter schemas:
class ApplicationApi < Joshua
model :user_input do
name String
email :email
role? String
end
end
class UsersApi < ApplicationApi
member do
define :update do
params do
user model: :user_input
end
proc do
@user.update(params.user)
end
end
end
endHTTP Methods
By default, all endpoints accept POST only. Use RESTful syntax to specify HTTP methods:
member do
# Single method - symbol key syntax
define get: :show do
proc { }
end
define put: :update do
proc { }
end
# Multiple methods - hash rocket required for array key
define [:get, :put] => :settings do
proc { }
end
# Alternative: allow inside block
define :archive do
allow :put
proc { }
end
define :config do
allow :get, :put, :delete
proc { }
end
endAPI Documentation
Enable automatic documentation:
class UsersApi < Joshua
documented
collection do
define :login do
desc 'Authenticate user'
detail 'Returns JWT token on success'
params do
email :email
pass String
end
proc { }
end
end
endDocumentation available at:
-
/api- Interactive HTML docs -
/api/_/raw- JSON schema -
/api/_/postman- Postman import URL
JSON RPC Mode
Joshua also accepts JSON RPC style requests:
curl -X POST http://localhost:3000/api \
-H "Content-Type: application/json" \
-d '{
"id": "req-123",
"action": ["users", "123", "show"],
"params": {"include": "profile"}
}'Testing
Call API methods directly without HTTP:
# Collection method
result = UsersApi.render.login(email: 'foo@bar.com', pass: 'secret')
# => { success: true, data: 'token-abc', ... }
# Member method
result = UsersApi.render.show(123)
# => { success: true, data: { id: 123, ... }, ... }
# With bearer token
result = UsersApi.render.show(123, bearer: 'user-token')
# Alternative syntax
result = UsersApi.render(:login, params: { email: 'foo@bar.com' })RSpec Example
RSpec.describe UsersApi do
describe '.login' do
it 'returns token for valid credentials' do
result = UsersApi.render.login(email: 'test@example.com', pass: 'valid')
expect(result[:success]).to be true
expect(result[:data]).to be_present
end
it 'returns error for invalid credentials' do
result = UsersApi.render.login(email: 'test@example.com', pass: 'wrong')
expect(result[:success]).to be false
end
end
endInheritance
API classes inherit from each other like normal Ruby:
class ApplicationApi < Joshua
before { @current_user = authenticate }
after { log_request }
end
class ModelApi < ApplicationApi
before { @model = load_model }
member do
define :show do
proc { @model.to_h }
end
define :delete do
proc do
@model.destroy
message 'Deleted'
end
end
end
end
class UsersApi < ModelApi
# inherits show, delete, and all callbacks
end
class PostsApi < ModelApi
member do
define :show do
proc do
super! # call ModelApi's show
# add extra logic
end
end
end
endPlugins
Create reusable API modules:
Joshua.plugin :pagination do
def paginate(collection)
page = (params.page || 1).to_i
per = (params.per || 20).to_i
collection.limit(per).offset((page - 1) * per)
end
end
class UsersApi < Joshua
plugin :pagination
collection do
define :index do
proc { paginate(User.all).map(&:to_h) }
end
end
endInstance Variables
Available in API methods via @api:
| Variable | Description |
|---|---|
@api.id |
Resource ID (member methods) |
@api.bearer |
Bearer token from Authorization header |
@api.action |
Current method name (symbol) |
@api.params |
Request parameters |
@api.request |
Rack request object |
@api.response |
Joshua response object |
@api.opts |
Options passed to initializer |
@api.development |
Development mode flag |
Dependencies
- rack
- json
- html-tag
- hash_wia
- typero
Development
git clone https://github.com/dux/joshua.git
cd joshua
bundle install
rspecLicense
MIT License
