pika

Version, currently 0.1.01 version
  • 0.1.0latestMay 5, 2026

github.com/tekanic/pika

A Grape-inspired REST API framework for Crystal

1 stars
2 dependents
License: MIT

Installation

# Add this to your shard.yml
dependencies:
  pika:
    github: tekanic/pika
    version: ~> 0.1.0

Then run:

shards install

shard.yml

Crystal
>= 1.0.0
License
MIT
Author
Michael (Tekanics LLC)

Dependencies

This version declares no dependencies.

README

Pika

CI

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 stdlib HTTP::Server, zero external dependencies
  • Params — requires/optional with type coercion (String, Int32, Int64, Float64, Bool, nilable variants); regexp, values, length constraints; mutually_exclusive, at_least_one_of, exactly_one_of; params_from ModelClass to derive params from a Clear model column schema
  • Hooks — before/after blocks scoped per resource/namespace; errors raised in hooks are caught and formatted
  • Helpers — helpers block for class-level helper methods callable directly from handlers
  • Entities — Pika::Entity(T) with pika_entity do...end DSL; expose :field, conditional expose :field, if: :flag, computed expose(:key) { |obj| expr }; present obj, using: EntityClass in handlers
  • Errors — Pika::Error hierarchy 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 via reuse_port: true on MyAPI.run
  • Clear ORM bridge — pika-clear shard (separate, versioned independently): auto-derives OpenAPI schemas, request validation, and entity exposure from Clear::Model column 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

FeatureDescription
Pika::Clear::ModelMixin that generates a PIKA_COLUMNS compile-time constant from Clear column annotations
expose_clear_modelEntity macro that derives field exposure directly from PIKA_COLUMNS
params_from ModelClassDerives a params block from a model's column schema
paginateApplies page/per_page to a Clear query and returns {"data":[...],"meta":{...}}
Pika::ValidationError.from_clear_modelConverts Clear model validation errors into Pika 422 responses
Pika::Clear.map_db_errorMaps 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.

ModeStatic routeJSON responseValidated params
Single-threaded (--release)155,719 req/s142,126 req/s123,121 req/s
--threads 4 (preview_mt)190,098 req/s166,117 req/s145,715 req/s
4× processes (reuse_port)153,300 req/s145,029 req/s135,396 req/s

Full numbers and methodology: bench/results.md.


Development

crystal spec              # run the spec suite
crystal spec --error-trace  # with backtraces

Roadmap

MilestoneStatus
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, launchplanned

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