FunApi
A minimal, async-first Ruby web framework inspired by FastAPI. Built on top of Falcon and dry-schema, FunApi provides a simple, performant way to build web APIs in Ruby with a focus on developer experience.
Philosophy
FunApi aims to bring FastAPI's excellent developer experience to Ruby by providing:
- Async-first: Built on Ruby's Async library and Falcon server for high-performance concurrent operations
- Simple validation: Using dry-schema for straightforward request validation
- Minimal magic: Clear, explicit APIs without heavy DSLs
- Easy to start: Get an API up and running in minutes
- Auto-documentation: Automatic OpenAPI/Swagger documentation generation
Installation
Requires Ruby 3.3.2 or newer and Rack 3.0.3 or newer (below 4). Use the latest patch release of a supported Ruby series. Earlier Rubies do not reliably cancel suspended dependency providers; Ruby 3.3.2 includes the upstream fiber cancellation fix.
Add this line to your application's Gemfile:
gem 'funapi'And then execute:
./bin/bundle installQuick Start
require 'funapi'
require 'funapi/server/falcon'
UserSchema = FunApi::Schema.define do
required(:name).filled(:string)
required(:email).filled(:string)
end
app = FunApi::App.new(
title: "My API",
version: "1.0.0",
description: "A simple API example"
) do |api|
api.get '/hello' do |input, req|
[{ message: 'Hello, World!' }, 200]
end
api.post '/users', body: UserSchema do |input, req|
user = input[:body]
[{ created: user }, 201]
end
end
FunApi::Server::Falcon.start(app, port: 9292)Visit http://localhost:9292/docs to see your interactive API documentation!
Core Features
1. Async-First Request Handling
Handlers take |input, req| and run on Falcon's fiber reactor. Spawn concurrent work with FunApi.async { } (returns a task; call #wait) and suspend without blocking via FunApi.sleep(n) — handler code never touches an event-loop handle:
api.get '/dashboard/:id' do |input, req|
user_id = input[:path]['id']
user_task = FunApi.async { fetch_user_data(user_id) }
posts_task = FunApi.async { fetch_user_posts(user_id) }
stats_task = FunApi.async { fetch_user_stats(user_id) }
data = {
user: user_task.wait,
posts: posts_task.wait,
stats: stats_task.wait
}
[{ dashboard: data }, 200]
endStreaming, SSE & WebSockets
Because FunApi is async-first, incremental responses are first-class. Return a StreamingResponse, stream Server-Sent Events, or upgrade to a WebSocket — each connection runs on its own fiber, so a slow stream never blocks other requests.
# Chunked streaming
api.get '/export' do |input, req|
FunApi::StreamingResponse.new(content_type: 'application/x-ndjson') do |stream|
Report.each_row { |row| stream << "#{JSON.generate(row)}\n" }
end
end
# Server-Sent Events with a heartbeat
api.get '/events' do |input, req|
FunApi::SSE.response(heartbeat: 15) do |sse|
sse.send(data: { tick: 1 }, event: 'tick', id: '1')
end
end
# WebSockets via async-websocket
api.websocket '/ws/:room' do |socket, input|
while (message = socket.read)
socket.write("echo: #{message.to_str}")
end
endFlagship demo — examples/llm_proxy_demo.rb is a ~30-line streaming LLM proxy: POST /chat streams tokens from a stub model over SSE, and many clients stream concurrently without blocking one another:
api.post '/chat', body: ChatRequest do |input, req|
FunApi::SSE.response(heartbeat: 15) do |sse|
stub_llm(input[:body][:prompt]) { |token| sse.send(data: token, event: 'token') }
sse.send(data: '[DONE]', event: 'done')
end
endSee the Streaming guide for disconnect handling, backpressure, and testing.
2. Request Validation
FastAPI-style request validation using dry-schema:
UserCreateSchema = FunApi::Schema.define do
required(:name).filled(:string)
required(:email).filled(:string)
required(:password).filled(:string)
optional(:age).filled(:integer)
end
QuerySchema = FunApi::Schema.define do
optional(:limit).filled(:integer)
optional(:offset).filled(:integer)
end
app = FunApi::App.new do |api|
api.get '/hello', query: QuerySchema do |input, req|
name = input[:query][:name] || 'World'
[{ msg: "Hello, #{name}!" }, 200]
end
api.post '/users', body: UserCreateSchema do |input, req|
user = input[:body]
[{ created: user }, 201]
end
api.post '/users/batch', body: [UserCreateSchema] do |input, req|
users = input[:body].map { |u| create_user(u) }
[users, 201]
end
end3. Response Schema Validation & Filtering
Automatically validate and filter response data, similar to FastAPI's response_model:
UserOutputSchema = FunApi::Schema.define do
required(:id).filled(:integer)
required(:name).filled(:string)
required(:email).filled(:string)
optional(:age).filled(:integer)
end
app = FunApi::App.new do |api|
api.post '/users',
body: UserCreateSchema,
response_schema: UserOutputSchema do |input, req|
user = {
id: 1,
name: input[:body][:name],
email: input[:body][:email],
password: input[:body][:password],
age: input[:body][:age]
}
[user, 201]
end
api.get '/users',
response_schema: [UserOutputSchema] do |input, req|
users = fetch_all_users()
[users, 200]
end
end4. Automatic OpenAPI Documentation
FunApi automatically generates OpenAPI 3.0 specifications from your route definitions and schemas:
app = FunApi::App.new(
title: "User Management API",
version: "1.0.0",
description: "A comprehensive user management system"
) do |api|
api.get '/users', query: QuerySchema, response_schema: [UserOutputSchema] do |input, req|
[fetch_users(input[:query]), 200]
end
api.post '/users', body: UserCreateSchema, response_schema: UserOutputSchema do |input, req|
[create_user(input[:body]), 201]
end
end
FunApi::Server::Falcon.start(app, port: 9292)Once running, you can access:
- Interactive docs: http://localhost:9292/docs (Swagger UI)
- OpenAPI spec: http://localhost:9292/openapi.json
The documentation is automatically generated from:
- Route paths and HTTP methods
- Path parameters (
:id→{id}) - Query parameter schemas
- Request body schemas
- Response schemas
- Schema names (from constant names)
5. FastAPI-Style Error Handling
Validation errors return detailed, structured responses:
{
"detail": [
{
"loc": ["body", "email"],
"msg": "is missing",
"type": "value_error"
}
]
}Custom exceptions with proper HTTP status codes:
raise FunApi::HTTPException.new(status_code: 404, detail: "User not found")
raise FunApi::ValidationError.new(errors: schema_errors)6. Middleware Support
FunApi supports both standard Rack middleware and provides FastAPI-style convenience methods for common use cases.
Built-in Middleware
app = FunApi::App.new do |api|
api.add_cors(
allow_origins: ['http://localhost:3000'],
allow_methods: ['GET', 'POST', 'PUT', 'DELETE'],
allow_headers: ['Content-Type', 'Authorization']
)
api.add_request_logger
api.add_trusted_host(
allowed_hosts: ['localhost', '127.0.0.1', /\.example\.com$/]
)
api.add_gzip
endUsing Standard Rack Middleware
Any Rack middleware works out of the box:
app = FunApi::App.new do |api|
api.use Rack::Attack
api.use Rack::ETag
api.use Rack::Session::Cookie, secret: 'your_secret'
api.get '/protected' do |input, req|
[{ data: 'Protected resource' }, 200]
end
endCustom Middleware
Create your own middleware following the Rack pattern:
class MyCustomMiddleware
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
headers['X-Custom-Header'] = 'my-value'
[status, headers, body]
end
end
app.use MyCustomMiddleware7. Input Structure
All route handlers receive a unified input hash:
{
path: { id: "123" },
query: { name: "John" },
body: { email: "..." }
}8. Background Tasks
Execute tasks after the response is sent, perfect for emails, logging, and webhooks:
api.post '/signup', body: UserSchema do |input, req, background:|
user = create_user(input[:body])
# Tasks execute AFTER response is sent but BEFORE dependencies close
background.add_task(method(:send_welcome_email), user[:email])
background.add_task(method(:log_signup_event), user[:id])
background.add_task(method(:notify_admin), user)
[{ user: user, message: 'Signup successful!' }, 201]
endKey Benefits:
- ✅ Response sent immediately to client
- ✅ Tasks run after handler completes
- ✅ Dependencies still available to tasks
- ✅ Multiple tasks execute in order
- ✅ Errors are handled gracefully
Perfect for:
- Email notifications
- Logging and analytics
- Cache warming
- Simple webhook calls
- Audit trail recording
Not for:
- Long-running jobs (> 30 seconds)
- Jobs requiring persistence/retries
- Jobs that must survive server restart → Use Sidekiq, GoodJob, or Que for these cases
With callable objects:
# Lambda
background.add_task(->(email) { send_email(email) }, user[:email])
# Proc
background.add_task(proc { |id| log_event(id) }, user[:id])
# Method reference
background.add_task(method(:send_email), user[:email])With arguments:
# Positional arguments
background.add_task(->(a, b) { sum(a, b) }, 5, 3)
# Keyword arguments
background.add_task(->(name:, age:) { greet(name, age) }, name: 'Alice', age: 30)
# Mixed
background.add_task(->(msg, to:) { send(msg, to) }, 'Hello', to: 'user@example.com')Access dependencies in background tasks:
api.register(:mailer) { Mailer.new }
api.register(:logger) { Logger.new }
api.post '/signup', depends: [:mailer, :logger] do |input, req, mailer:, logger:, background:|
user = create_user(input[:body])
# Dependencies captured in closure, available to background tasks
background.add_task(lambda {
mailer.send_welcome(user[:email])
logger.info("Welcome email sent to #{user[:email]}")
})
[{ user: user }, 201]
end9. Template Rendering
Render ERB templates for HTML responses, perfect for HTMX-style applications:
require 'funapi'
require 'funapi/templates'
templates = FunApi::Templates.new(directory: 'templates')
app = FunApi::App.new do |api|
api.get '/' do |input, req|
templates.response('index.html.erb', title: 'Home', message: 'Welcome!')
end
api.get '/users/:id' do |input, req|
user = fetch_user(input[:path]['id'])
templates.response('user.html.erb', user: user)
end
endLayouts
Use layouts to wrap your templates with common HTML structure:
templates = FunApi::Templates.new(
directory: 'templates',
layout: 'layouts/application.html.erb'
)
api.get '/' do |input, req|
templates.response('home.html.erb', title: 'Home')
end
# Disable layout for partials/HTMX responses
api.post '/items' do |input, req|
item = create_item(input[:body])
templates.response('items/_item.html.erb', layout: false, item: item, status: 201)
endUse with_layout to create a scoped templates object for route groups:
templates = FunApi::Templates.new(directory: 'templates')
# Create scoped templates for different sections
public_templates = templates.with_layout('layouts/public.html.erb')
admin_templates = templates.with_layout('layouts/admin.html.erb')
api.get '/' do |input, req|
public_templates.response('home.html.erb', title: 'Home')
end
api.get '/admin' do |input, req|
admin_templates.response('admin/dashboard.html.erb', title: 'Dashboard')
endLayout template with yield_content:
<!-- templates/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
</head>
<body>
<%= yield_content %>
</body>
</html>Partials
Render partials within templates using render_partial:
<!-- templates/items/index.html.erb -->
<ul>
<% items.each do |item| %>
<%= render_partial('items/_item.html.erb', item: item) %>
<% end %>
</ul>With HTMX
FunApi templates work great with HTMX for dynamic HTML updates:
api.get '/items' do |input, req|
items = fetch_items
templates.response('items/index.html.erb', items: items)
end
api.post '/items', body: ItemSchema do |input, req|
item = create_item(input[:body])
# Return partial for HTMX to insert
templates.response('items/_item.html.erb', layout: false, item: item, status: 201)
end
api.delete '/items/:id' do |input, req|
delete_item(input[:path]['id'])
# Return empty response for HTMX delete
FunApi::TemplateResponse.new('')
end<!-- With HTMX attributes -->
<form hx-post="/items" hx-target="#items" hx-swap="beforeend">
<input name="title" placeholder="New item">
<button type="submit">Add</button>
</form>See examples/templates_demo.rb for a complete HTMX todo app example.
10. Lifecycle Hooks
Execute code when the application starts up or shuts down:
app = FunApi::App.new do |api|
api.on_startup do
puts "Connecting to database..."
DB.connect
end
api.on_startup do
puts "Warming cache..."
Cache.warm
end
api.on_shutdown do
puts "Disconnecting..."
DB.disconnect
end
endKey behaviors:
- Multiple hooks supported (executed in registration order)
- Startup hooks run before server accepts requests
- Shutdown hooks run after server stops accepting requests
- Startup errors prevent server from starting
- Shutdown errors are logged but don't prevent other hooks from running
Public Falcon adapter shutdown: FunApi::Server::Falcon.start handles SIGINT
and SIGTERM by recording a shutdown request; the reactor checks it every 50 ms.
It stops accepting connections and requests cooperative cancellation of the
server's tasks. Tasks that defer cancellation get up to one second to finish,
then receive a second stop request to interrupt the deferral. Active streams
may be truncated; this is not a guarantee that every response will drain.
After the server task tree finishes, shutdown hooks run once in the live Async
reactor, so hooks can await async cleanup. Repeated signals during cleanup do
not rerun hooks. Prior signal handlers are restored even on failure, and the
adapter returns without calling exit. Shutdown hooks also run when startup
fails, to clean up partially initialized resources.
Cancellation is cooperative: non-yielding code, code that suppresses cancellation, and indefinitely blocked cleanup hooks can still prevent shutdown. The one-second grace period is not a hard process-exit deadline. Signal handlers are process-wide; run only one public adapter at a time. This policy does not change the CLI reloader.
Use cases:
- Database connection pool initialization
- Cache warming
- Background task supervisor setup
- Metrics/logging initialization
- Graceful resource cleanup
See examples/lifecycle_demo.rb for a complete example.
Complete Example
require 'funapi'
require 'funapi/server/falcon'
UserCreateSchema = FunApi::Schema.define do
required(:name).filled(:string)
required(:email).filled(:string)
required(:password).filled(:string)
optional(:age).filled(:integer)
end
UserOutputSchema = FunApi::Schema.define do
required(:id).filled(:integer)
required(:name).filled(:string)
required(:email).filled(:string)
optional(:age).filled(:integer)
end
QuerySchema = FunApi::Schema.define do
optional(:limit).filled(:integer)
optional(:offset).filled(:integer)
end
app = FunApi::App.new(
title: "User Management API",
version: "1.0.0",
description: "A simple user management API"
) do |api|
api.add_cors(allow_origins: ['*'])
api.add_request_logger
api.get '/users', query: QuerySchema, response_schema: [UserOutputSchema] do |input, req|
users = [
{ id: 1, name: 'John Doe', email: 'john@example.com', age: 30 },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }
]
[users, 200]
end
api.get '/users/:id', response_schema: UserOutputSchema do |input, req|
user_id = input[:path]['id']
user = { id: user_id.to_i, name: 'John Doe', email: 'john@example.com', age: 30 }
[user, 200]
end
api.post '/users', body: UserCreateSchema, response_schema: UserOutputSchema do |input, req|
user = input[:body].merge(id: rand(1000))
[user, 201]
end
api.get '/dashboard/:id' do |input, req|
user_id = input[:path]['id']
user_task = FunApi.async { fetch_user(user_id) }
posts_task = FunApi.async { fetch_posts(user_id) }
stats_task = FunApi.async { fetch_stats(user_id) }
data = {
user: user_task.wait,
posts: posts_task.wait,
stats: stats_task.wait
}
[{ dashboard: data }, 200]
end
end
FunApi::Server::Falcon.start(app, port: 9292)Architecture
- Router: Simple pattern-based routing with path parameter extraction
- Async Helpers: Wrapper around Ruby's Async library for concurrent operations
- Schema: Thin wrapper around dry-schema for validation
- Exceptions: FastAPI-inspired exception classes with proper HTTP responses
- Server: Falcon-based async HTTP server
- OpenAPI: Automatic OpenAPI 3.0 specification generation from routes and schemas
Dependencies
- rack (>= 3.0.0): Web server interface
- async (>= 2.8): Async/await and concurrency primitives
- async-websocket (>= 0.30): WebSocket support
- dry-schema (>= 1.13): Schema validation
- falcon (>= 0.44): High-performance async HTTP server
For the optional database path, add gem "sequel", "~> 5.0", ">= 5.63" and a driver (e.g. pg >= 1.3) to your own Gemfile and require "funapi/sequel" at boot. This enables Sequel's global fiber_concurrency extension and uses its maintained timed_queue pool (Ruby >= 3.2).
Design Goals
- Performance: Leverage Ruby's async capabilities for concurrent operations
- Simplicity: Minimal API surface, easy to learn
- Explicitness: No hidden magic, clear separation of concerns
- Type Safety: Validation at the edges using dry-schema
- FastAPI-inspired: Bring the best ideas from Python's FastAPI to Ruby
Current Status
Active development. Core features implemented:
- ✅ Async-first request handling with Async::Task
- ✅ Route definition with path params
- ✅ Request validation (body/query) with array support
- ✅ Response schema validation and filtering
- ✅ FastAPI-style error responses
- ✅ Falcon server integration
- ✅ OpenAPI/Swagger documentation generation
- ✅ Middleware support (Rack-compatible + convenience methods)
- ✅ Dependency injection with cleanup
- ✅ Background tasks (post-response execution)
- ✅ Template rendering (ERB with layouts and partials)
- ✅ Lifecycle hooks (startup/shutdown)
- ✅ Streaming responses, Server-Sent Events, and WebSockets
- ✅ Sequel integration with a fibered connection pool (
funapi/sequel)
Future Enhancements
-
Dependency injection system✅ Implemented -
Background tasks✅ Implemented -
Template rendering✅ Implemented -
Lifecycle hooks (startup/shutdown)✅ Implemented -
WebSocket support✅ Implemented - Path parameter type validation
- Response schema options (exclude_unset, include, exclude)
- Content negotiation (JSON, XML, etc.)
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/fun_api.
License
The gem is available as open source under the terms of the MIT License.