pika
Version, currently 0.1.01 version
- 0.1.0latestMay 5, 2026
github.com/tekanic/pika
A Grape-inspired REST API framework for Crystal
Installation
# Add this to your shard.yml
dependencies:
pika:
github: tekanic/pika
version: ~> 0.1.0Then run:
shards installshard.yml
- Crystal
>= 1.0.0- License
- MIT
- Author
- Michael (Tekanics LLC)
Dependencies
This version declares no dependencies.
README
Pika
A Grape-inspired REST API framework for Crystal — declarative DSL, compile-time param validation, zero external dependencies.
require "pika"
class MyAPI < Pika::API
version "v1"
resource :users do
desc "Create a user"
params do
requires name : String, regexp: /\A\w+\z/
requires email : String
optional role : String = "member", values: %w[member admin]
end
post do
{created: true, name: declared_params.name}.to_json
end
end
end
MyAPI.run # 0.0.0.0:3000
Features
- Routing —
resource,namespace,route_param,version,mount; hand-rolled router on Crystal's stdlibHTTP::Server, zero external dependencies - Params —
requires/optionalwith type coercion (String,Int32,Int64,Float64,Bool, nilable variants);regexp,values,lengthconstraints;mutually_exclusive,at_least_one_of,exactly_one_of;params_from ModelClassto derive params from a Clear model column schema - Hooks —
before/afterblocks scoped per resource/namespace; errors raised in hooks are caught and formatted - Helpers —
helpersblock for class-level helper methods callable directly from handlers - Entities —
Pika::Entity(T)withpika_entity do...endDSL;expose :field, conditionalexpose :field, if: :flag, computedexpose(:key) { |obj| expr };present obj, using: EntityClassin handlers - Errors —
Pika::Errorhierarchy with pluggable formatters:error_formatter :rfc7807(default),:grape,:jsonapi - OpenAPI 3.1 — full spec via
MyAPI.openapi_doc;info title:, version:, description:macro;:param→{param}path conversion; schemas derived from entity and param definitions - Scalar UI —
docs at: "/docs"mounts an interactive API explorer + JSON spec endpoint directly on the API router - Concurrency — single-binary multi-thread via
--threads N(preview_mt); multi-process horizontal scaling viareuse_port: trueonMyAPI.run - Clear ORM bridge —
pika-clearshard (separate, versioned independently): auto-derives OpenAPI schemas, request validation, and entity exposure fromClear::Modelcolumn definitions
Installation
Add to your shard.yml:
dependencies:
pika:
github: tekanic/pika
version: "~> 0.1"
Then run shards install.
For the Clear ORM integration, also add pika-clear:
dependencies:
pika:
github: tekanic/pika
pika-clear:
github: tekanic/pika-clear
DSL overview
require "pika"
class MyAPI < Pika::API
info title: "My API", version: "1.0.0", description: "Example"
version "v1"
docs at: "/docs" # mounts Scalar UI + /docs/openapi.json
before do
raise Pika::UnauthorizedError.new unless env.request.headers["X-Token"]? == ENV["API_TOKEN"]
end
helpers do
def self.current_user(env) : String
env.request.headers["X-User"]? || "anonymous"
end
end
namespace :admin do
resource :users do
desc "List all users"
get do
{users: [], requested_by: self.current_user(env)}.to_json
end
desc "Create a user"
params do
requires name : String, regexp: /\A\w+\z/
requires email : String
optional role : String = "member", values: %w[member admin]
end
post do
{created: true, name: declared_params.name}.to_json
end
route_param :id do
desc "Get a user"
get do
{id: declared_params.id}.to_json
end
desc "Update a user"
params do
optional name : String?
optional role : String?
mutually_exclusive :name, :role
end
patch do
{updated: true}.to_json
end
end
end
end
resource :health do
get do
{status: "ok"}.to_json
end
end
end
MyAPI.run(port: 3000)
Routes registered:
GET /v1/admin/users
POST /v1/admin/users
GET /v1/admin/users/:id
PATCH /v1/admin/users/:id
GET /v1/health
GET /docs
GET /docs/openapi.json
Params
Params are declared with requires (mandatory) or optional (with optional default). Crystal types are coerced at request time; invalid params return 422 before your handler runs.
params do
requires id : Int64
requires name : String, length: 1..100, regexp: /\A\w+\z/
optional score : Float64 = 0.0
optional tags : String?, values: %w[a b c]
mutually_exclusive :name, :email # at most one
at_least_one_of :name, :email # at least one
exactly_one_of :card, :bank # exactly one
end
Inside handlers, params are accessed via declared_params:
get do
declared_params.name # String — type-safe, no casting
declared_params.score # Float64
end
Deriving params from a Clear model
params_from User, only: [:name, :email, :role]
Reads User::PIKA_COLUMNS (generated by pika-clear) and creates requires/optional entries matching the column types. Nilable columns (Int32?, String?) become optional params; non-nilable columns become requires.
DSL syntax notes
Pika uses Crystal's macro system to provide a declarative DSL. A few patterns look unusual compared to standard Crystal — here's why they work.
requires and optional look like variable declarations
params do
requires name : String
optional age : Int32 = 0
end
These are macro calls, not variable declarations. requires name : String is a call to the requires macro with the argument name : String, which Crystal parses as a typed declaration node. Pika's macro inspects that node at compile time to extract the param name, type, and default value, then generates a typed struct and a validation method. No runtime reflection is involved.
declared_params is a generated struct, not a hash
declared_params.name # String
declared_params.age # Int32
Each params block generates a unique Crystal struct with typed properties. declared_params is an instance of that struct, populated and validated before your handler runs. Accessing a nonexistent field is a compile-time error, not a runtime KeyError.
expose with a block uses a different call form
pika_entity do
expose :title # direct field access
expose :slug, if: :admin # conditional — only included when opts[:admin] is truthy
expose(:display_name) { |u| u.name } # computed — parentheses required when passing a block
end
The parenthesised form expose(:key) { |obj| ... } is needed when attaching a block in Crystal's macro call syntax. The bare form expose :field is shorthand for direct property access on the object.
self. is required for helper methods inside handlers
helpers do
def self.current_user(env)
env.request.headers["X-User"]? || "anonymous"
end
end
resource :users do
get do
self.current_user(env) # explicit self — bare `current_user(env)` may not resolve
end
end
Handler blocks are expanded inside a class-level proc. Helper methods are defined as class methods (def self.method), so they require an explicit self. receiver inside the handler body.
present uses a named using: argument
present user, using: UserEntity
present users, using: UserEntity, admin: true # extra kwargs forwarded to expose conditions
present is a class method with signature def self.present(obj, using entity_class, **opts). The using: keyword is a named argument — it reads naturally as prose and matches Grape's convention. Extra keyword arguments are forwarded to the entity's represent call and become available as condition flags in expose :field, if: :flag.
Entities
class UserEntity < Pika::Entity(User)
pika_entity do
expose :id
expose :name
expose :email
expose :role, if: :admin_view
expose(:display_name) { |u| "#{u.name} <#{u.email}>" }
end
end
# In a handler:
get do
user = find_user(declared_params.id)
present user, using: UserEntity, admin_view: self.current_user(env).admin?
end
Error handling
Raise any Pika::Error subclass — Pika catches it and renders the appropriate HTTP status and body:
raise Pika::UnauthorizedError.new # 401
raise Pika::ForbiddenError.new # 403
raise Pika::NotFoundError.new("No widget") # 404
raise Pika::ConflictError.new # 409
raise Pika::UnprocessableError.new("Bad") # 422
Param validation failures return 422 with a structured errors array automatically.
Change the error format globally:
class MyAPI < Pika::API
error_formatter :jsonapi # or :grape, :rfc7807 (default)
end
Mounting sub-APIs
class V2::UsersAPI < Pika::API
resource :users do
get do "v2 users" end
end
end
class MyAPI < Pika::API
version "v1"
mount V2::UsersAPI
end
Clear ORM integration (pika-clear)
pika-clear is a companion shard that bridges Pika and Clear, a PostgreSQL ORM for Crystal. It is versioned and released independently from Pika's core so that neither shard forces you to adopt the other.
What it provides
| Feature | Description |
|---|---|
Pika::Clear::Model | Mixin that generates a PIKA_COLUMNS compile-time constant from Clear column annotations |
expose_clear_model | Entity macro that derives field exposure directly from PIKA_COLUMNS |
params_from ModelClass | Derives a params block from a model's column schema |
paginate | Applies page/per_page to a Clear query and returns {"data":[...],"meta":{...}} |
Pika::ValidationError.from_clear_model | Converts Clear model validation errors into Pika 422 responses |
Pika::Clear.map_db_error | Maps database exceptions (unique violation, FK error, etc.) to Pika error classes |
Setup
# shard.yml
dependencies:
pika:
github: tekanic/pika
pika-clear:
github: tekanic/pika-clear
require "pika"
require "pika-clear"
Model setup
Include both Clear::Model and Pika::Clear::Model in your model. The Pika::Clear::Model mixin inspects @[Clear::Column]-annotated instance variables at compile time (via macro finished) and generates a PIKA_COLUMNS constant that the rest of pika-clear reads.
class User
include Clear::Model
include Pika::Clear::Model
self.table = "users"
column id : Int64, primary: true
column email : String
column name : String
column age : Int32? # nilable → becomes optional param
column role : String
timestamps
end
Entities with expose_clear_model
Instead of listing every column manually in pika_entity, use expose_clear_model to derive the field list from the model schema. Columns in except: are excluded.
class UserEntity < Pika::Entity(User)
expose_clear_model User, except: [:role]
# role is still accessible but not exposed unless you add it manually:
# expose :role, if: :admin_view
end
expose_clear_model generates both represent(obj) and represent(collection) methods, so the entity works for single objects and arrays.
Request validation with params_from
params_from reads PIKA_COLUMNS and synthesises a params block — non-nilable columns become requires, nilable columns become optional. Use only: or except: to limit the fields.
resource :users do
desc "Create a user"
params_from User, except: [:id, :created_at, :updated_at]
post do
user = User.new
user.email = declared_params.email
user.name = declared_params.name
user.age = declared_params.age # Int32? — may be nil
user.save!
present user, using: UserEntity
end
end
Pagination
paginate wraps a Clear query scope with LIMIT/OFFSET and returns a standard JSON envelope.
resource :users do
params do
optional page : Int32 = 1
optional per_page : Int32 = 25
end
get do
paginate(User.query, using: UserEntity,
page: declared_params.page,
per_page: declared_params.per_page)
end
end
Response shape:
{
"data": [...],
"meta": { "total": 120, "page": 2, "per_page": 25, "pages": 5 }
}
Error mapping
post do
user = User.build(declared_params)
unless user.valid?
raise Pika::ValidationError.from_clear_model(user) # → 422 with errors array
end
user.save!
rescue e : Exception
raise Pika::Clear.map_db_error(e) # unique violation → 409, FK error → 422, etc.
end
Concurrency & scaling
# Multi-threaded (compile with -Dpreview_mt)
MyAPI.run(port: 3000)
# Multi-process horizontal scaling — each process shares the port via SO_REUSEPORT
MyAPI.run(port: 3000, reuse_port: true)
Compile with --threads N for the multi-threaded build. For multi-process, spawn N copies with reuse_port: true; the OS load-balances across them.
Performance
Measured with bombardier -c 128 -d 15s on Apple M-series. No external HTTP dependency — Pika owns its router.
| Mode | Static route | JSON response | Validated params |
|---|---|---|---|
Single-threaded (--release) | 155,719 req/s | 142,126 req/s | 123,121 req/s |
--threads 4 (preview_mt) | 190,098 req/s | 166,117 req/s | 145,715 req/s |
4× processes (reuse_port) | 153,300 req/s | 145,029 req/s | 135,396 req/s |
Full numbers and methodology: bench/results.md.
Development
crystal spec # run the spec suite
crystal spec --error-trace # with backtraces
Roadmap
| Milestone | Status |
|---|---|
| PoC gate (params, OpenAPI, perf) | ✅ complete |
| v0.1 — skeleton, router, basic DSL | ✅ complete |
| v0.2 — full DSL, hooks, error hierarchy | ✅ complete |
v0.3 — entity layer, mount, formatters | ✅ complete |
| v0.4 — OpenAPI 3.1, Scalar UI, CI | ✅ complete |
v0.5 — Clear ORM integration (pika-clear shard) | ✅ complete |
v0.6 — benchmarks, reuse_port, params_from | ✅ complete |
| v1.0 — API freeze, docs site, launch | planned |
Attribution
Pika is heavily inspired by Grape, the REST-like API framework for Ruby. The core DSL concepts — resource, namespace, route_param, params/requires/optional, before/after hooks, helpers, mount, and the entity layer — are direct adaptations of Grape's design to Crystal's type system and macro capabilities. If you've built APIs with Grape, Pika should feel immediately familiar.
Contributing
Bug reports and pull requests are welcome on GitHub at tekanic/pika.
License
MIT
Documentation
Built from the current release. The first visit to a release nobody has asked for starts its build.
Links
This release
- Version
0.1.0- Tagged
- May 5, 2026
- Commit
eac0c81cd4d2- Crystal
>= 1.0.0- Indexed
- yes
Dependents
Repository
github.com/tekanic/pika
Metadata
- Created
- Aug 12, 2026
- Updated
- Sep 25, 2026
- Synced
- Sep 25, 2026
- Versions
- 1