opal

Opal

Opal is a modular HTTP, dependency injection, application bootstrap, and persistence toolkit for Crystal.

It is built on top of Crystal's standard HTTP::Handler stack and focuses on:

  • trie-based path matching
  • path parameter extraction
  • method-aware routing with 404 / 405 handling
  • lightweight API handlers via LF::HTTP::Controller
  • compile-time guards, pipes, interceptors, and exception filters
  • scoped dependency injection with deterministic lifecycle cleanup
  • optional compile-time application bootstrap
  • transaction-local persistence with compile-time entity and query contracts
  • server-rendered interactive pages with an Opal-owned LiveView runtime
  • optional accessible UI primitives with a precompiled Tailwind theme
  • opt-in typed RPC and event services with transport-neutral execution and RabbitMQ AMQP 0-9-1 support
  • durable typed projection handlers over RabbitMQ Streams and Crabbit

Documentation

The documentation site contains progressive tutorials, feature guides, Data and security references, architectural decisions, and generated Crystal API documentation. Start at docs/index.md, or build a local static preview with:

scripts/setup_docs.sh
scripts/serve_docs.sh

The server prints a local URL (by default http://127.0.0.1:8000). Run scripts/check_docs.sh before publishing; it also compiles the tutorial sources and every runnable example linked from the documentation.

Status

The routing, native WebSocket, LiveView, HTTP binding, DI lifecycle, Application compiler, and Data persistence/migration contracts are covered by specs in this repository.

Installation

Add this to your application's shard.yml:

dependencies:
  opal:
    github: mikeoz32/opal

Then install shards:

shards install

Core API

Opal exposes eleven independent layers:

  1. LF::HTTP::Router Low-level router with explicit handlers.

  2. LF::HTTP::App HTTP::Handler wrapper around a router with consistent HTTP error handling.

  3. LF::HTTP::Controller Compile-time route definition with request binding and constructor DI.

  4. @[LF::Application] and LF::ApplicationRuntime Optional compile-time application assembly and root-container ownership.

  5. require "opal/autoconfig/http" Optional HTTP controller discovery, server assembly, and lifecycle integration.

  6. require "opal/data" and require "opal/autoconfig/data" Explicit persistence APIs with optional Application-owned DataSource setup.

  7. LF::LiveView Server-owned interactive pages over Opal's native WebSocket transport, with pinned Phoenix/LiveView browser packages prebundled into the shard.

  8. require "opal/ui" Optional stateless actions, feedback, cards, form controls, tables, dialogs, disclosure/navigation primitives, and overlays with a precompiled Tailwind theme.

  9. require "opal/security" Optional authentication context, API-key and signed-session authenticators, authorization guards, and CSRF protection built on the HTTP policy pipeline.

  10. require "opal/microservices" and require "opal/microservices/rabbitmq" Optional message controllers and policies, typed RPC clients, Tori-compatible contracts, deterministic in-memory testing, and a RabbitMQ AMQP 0-9-1 adapter. The opal/autoconfig/microservices/rabbitmq entrypoint composes controllers and injectable typed clients from an application annotation. See the microservices guide for the minimal application and broker configuration.

  11. require "opal/autoconfig/microservices/crabbit_streams" Optional typed stream publishers and replayable projection handlers with Crabbit broker checkpoints, ordinary streams, and super streams. See the RabbitMQ Streams guide.

Basic Router

require "opal"

router = LF::HTTP::Router.new

router.get("/") do |ctx, _params|
  ctx.response.print "Welcome"
end

router.get("/users/:id") do |ctx, params|
  ctx.response.print "User #{params["id"]}"
end

router.post("/users") do |ctx, _params|
  ctx.response.status = HTTP::Status::CREATED
  ctx.response.print "created"
end

server = HTTP::Server.new([
  HTTP::LogHandler.new,
  router,
])

server.bind_tcp(8080)
server.listen

HTTP App

LF::HTTP::App wraps LF::HTTP::Router and converts LF::HTTP::BadRequest, LF::HTTP::NotFound, and other internal exceptions into HTTP responses.

require "opal"

app = LF::HTTP::App.new do |router|
  router.get("/hello/:name") do |ctx, params|
    ctx.response.print "Hello, #{params["name"]}"
  end
end

server = HTTP::Server.new([
  HTTP::LogHandler.new,
  app,
])

server.bind_tcp(8080)
server.listen

HTTP Controller

LF::HTTP::Controller is the higher-level API surface. It supports:

  • route params
  • query params
  • HTTP::Request
  • JSON body parsing for JSON::Serializable
  • automatic JSON responses for returned JSON::Serializable models
  • LF::HTTP::Response return types such as LF::HTTP::JSONResponse
  • constructor injection for controller dependencies

Route arguments are request inputs only. Inject services through the controller constructor; unsupported route arguments fail at compile time.

Guards, pipes, interceptors, and filters

Controller execution policies are ordinary DI beans selected at compile time:

  • guards authorize before request binding and return 403 Forbidden when they reject a request;
  • pipes validate or transform path/query strings and parsed JSON bodies before conversion to the declared action argument type;
  • interceptors wrap the action, can replace its response, or short-circuit it;
  • exception filters map errors raised anywhere in the controller pipeline to a response.

Apply policies globally, to a controller, to an action, or—in the case of pipes—to one parameter:

@[LF::DI::Service]
class AuthGuard < LF::HTTP::Guard
  def can_activate(context : LF::HTTP::ExecutionContext) : Bool
    context.request.headers.has_key?("Authorization")
  end
end

@[LF::DI::Service]
class TrimPipe < LF::HTTP::StringPipe
  def transform_string(value, metadata, context) : String
    value.strip
  end
end

@[LF::HTTP::UseGuards(AuthGuard)]
class UsersApi
  include LF::HTTP::Controller

  @[LF::HTTP::Controller::Get("/users")]
  def index(@[LF::HTTP::UsePipes(TrimPipe)] name : String)
    "Hello, #{name}"
  end
end

Controller-level annotations do not require a separate HttpPolicies class. Register the policy beans and set up the controller normally:

root.register(LF::DI::ServiceConfiguration.new)

app = LF::HTTP::App.new do |router|
  UsersApi.setup_routes(router, root)
end

The order is global → controller → action → parameter. Interceptors unwind in reverse order; filters search action → controller → global.

For @[LF::HTTP::Controller::WebSocket] actions, global, controller, and action guards run before the HTTP upgrade, so the same direct annotations can reject the handshake with 403. Pipes, interceptors, and filters retain their HTTP action semantics; use explicit WebSocket message validation inside the connection handler.

Global policies are optional. With HTTP autoconfiguration, put their annotations on the @[LF::Application] class. A manually assembled standalone application can use a separate annotation owner only when it actually needs global policies:

UsersApi.setup_routes(router, root, HttpPolicies)

See examples/http_execution_pipeline_example.cr for all four policy types together.

Example

require "opal"

class UserPayload
  include JSON::Serializable

  property name : String
end

class UserView
  include JSON::Serializable

  property id : Int32
  property name : String

  def initialize(@id : Int32, @name : String)
  end
end

@[LF::DI::Service]
class UserService
  def find(id : Int32) : UserView
    UserView.new(id, "User #{id}")
  end

  def create(name : String) : UserView
    UserView.new(1, name)
  end
end

class UsersApi
  include LF::HTTP::Controller

  def initialize(@users : UserService)
  end

  @[LF::HTTP::Controller::Get("/users/:id")]
  def show(id : Int32)
    @users.find(id)
  end

  @[LF::HTTP::Controller::Post("/users")]
  def create(payload : UserPayload)
    @users.create(payload.name)
  end
end

root = LF::DI::DefaultContainer.new
root.register(LF::DI::ServiceConfiguration.new)

app = LF::HTTP::App.new do |router|
  UsersApi.setup_routes(router, root)
end

server = HTTP::Server.new([
  LF::HTTP::DI::RequestScopeHandler.new(root),
  app,
])

WebSocket controller actions use the same route discovery and expose the raw Crystal socket. They may use callbacks:

@[LF::HTTP::Controller::WebSocket("/chat")]
def chat(ws : HTTP::WebSocket) : Nil
  ws.on_message { |message| ws.send("echo: #{message}") }
end

Or they may use an explicit synchronous receive loop in the action body:

@[LF::HTTP::Controller::WebSocket("/echo")]
def echo(ws : HTTP::WebSocket) : Nil
  while message = ws.receive?
    ws.send("echo: #{message}")
  end
end

These are two handler styles for the same WebSocket route type. Avoid mixing a manual receive loop and receive callbacks on one socket unless double processing is intentional. A finite synchronous action must call ws.close; returning alone hands control back to Crystal's HTTP::WebSocketHandler, which then starts its callback-mode ws.run loop.

A standalone server that exposes WebSocket controller routes needs both scope handlers, in this order:

connections = LF::HTTP::WebSocketConnectionRegistry.new
server = HTTP::Server.new([
  LF::HTTP::DI::WebSocketScopeHandler.new(root, "websocket", connections),
  LF::HTTP::DI::RequestScopeHandler.new(root),
  app,
])

On shutdown, close the server, call connections.shutdown(timeout_ms), and only then shut down the root container so active connection scopes exit first.

LiveView

Opal LiveView provides interactive server-rendered pages with a prebundled upstream Phoenix LiveView browser runtime. Application projects need neither Elixir/Phoenix nor an npm asset pipeline. A page owns state for one WebSocket connection:

@[LF::LiveView::Page("/counter")]
class CounterLive < LF::LiveView::View
  @count = 0

  def handle_event(event : String, value : JSON::Any) : Nil
    case event
    when "increment" then @count += 1
    else                    super
    end
  end

  def render : LF::LiveView::Rendered
    LF::LiveView::HTML.rendered(
      %(<button id="counter" phx-click="increment">#{@count}</button>)
    )
  end
end

With HTTP autoconfiguration, Opal discovers annotated pages, constructor- injects dependencies into separate request and WebSocket instances, serves the initial HTML and /_opal/live.js, and mounts the socket at /_opal/live. Configure live_view.secret with at least 32 bytes. See the LiveView guide for lifecycle, structural rendering, keyed comprehensions, nested stateful components and child LiveViews, streams, live navigation, JavaScript hooks, security, forms, reconnect, manual assembly, and current feature boundaries.

UI Primitives

The optional UI layer returns normal LiveView structural renders and keeps state in the application view:

require "opal/ui"

LF::UI.button(
  "Save",
  type: "submit",
  tone: LF::UI::Tone::Primary,
  attributes: {"phx-click" => "save"}
)

LF::UI.input(
  "Email",
  id: "email",
  name: "email",
  type: "email",
  error: @email_error,
  required: true
)

Use LF::UI.stylesheet_tag for a zero-setup embedded theme. Interactive primitives such as dialogs, dropdowns, tabs, toasts, accordions, and tooltips additionally load LF::UI.hook_script_tag before the LiveView client; pagination can use upstream Phoenix live patches without another hook. Both assets also have cacheable mounted routes. See the UI guide and runnable UI showcase.

DI Container

The built-in DI container lives under LF::DI.

Registering beans manually

root = LF::DI::DefaultContainer.new

root.add_bean(name: "greeting_service", scope: "request", type: GreetingService) do |_ctx|
  GreetingService.new("Hello")
end

Request scope

Controllers are request-scoped beans. Their constructor dependencies use the normal DI resolution rules. Route arguments bind only path, query, request, and JSON body values.

A built-in handler creates and closes a child scope around each request:

server = HTTP::Server.new([
  LF::HTTP::DI::RequestScopeHandler.new(root),
  app,
])

Autowired services

You can also declare services with @[LF::DI::Service] and register LF::DI::ServiceConfiguration.

Autowiring currently works like this:

  1. resolve by argument name and type
  2. if not found, fall back to type lookup
  3. if multiple beans of the same type exist, raise LF::DI::AmbiguousBeanError

Lifecycle callbacks

Beans can opt into lifecycle hooks by implementing:

  • LF::DI::Initializable#after_properties_set
  • LF::DI::Disposable#destroy

Lifecycle behavior:

  • init runs after instance creation and before cache commit
  • init runs exactly once per created instance
  • child-owned disposable instances are destroyed on scope.exit
  • root-owned disposable singletons are destroyed on root.shutdown
  • destroy order is reverse creation order within the owning context

Example:

class RequestResource
  include LF::DI::Initializable
  include LF::DI::Disposable

  def after_properties_set : Nil
    puts "resource ready"
  end

  def destroy : Nil
    puts "resource cleaned up"
  end
end

root = LF::DI::DefaultContainer.new

root.add_bean(name: "request_resource", scope: "request", type: RequestResource) do |_ctx|
  RequestResource.new
end

scope = root.enter_scope("request")
scope.get_bean("request_resource", RequestResource)
scope.exit

root.shutdown

Application Bootstrap

Application bootstrap is optional. Standalone DI and HTTP usage remain valid without an application marker.

@[LF::ApplicationConfiguration(priority: 10)]
class Infrastructure
  @[LF::DI::Bean]
  def clock : Clock
    Clock.new
  end
end

@[LF::Application]
class TodoApplication
end

TodoApplication.run do |application|
  application.resolve(TodoService).start
end

The generated bootstrap method owns a fresh root container. LF::ApplicationRuntime exposes typed resolution, extension installation, shutdown, and state/error contracts; it does not expose the mutable root container. Application extensions receive a controlled LF::ApplicationContext for bean registration and scope creation.

Data

The Data layer is loaded through require "opal/data"; concrete dialects and drivers remain opt-in. Start with the Data getting-started guide.

Public Data guides:

Transaction rollback

LF::Data::EntityManager is transaction-local. If a transaction fails after an explicit flush, generated IDs or optimistic-lock versions may already have been written to in-memory entities even though the database rolled back. Discard every entity obtained from the failed manager and do not reuse it:

begin
  source.transaction do |manager|
    manager.persist(entity)
    manager.flush
    raise "abort"
  end
rescue
  # `entity` belongs to the failed manager and must be discarded.
end

Configuration

Application bootstrap eagerly registers one immutable LF::ConfigService singleton. It reads config/application.yml by default. A missing default file means empty configuration; setting OPAL_CONFIG selects an explicit file and a missing explicit file is an error.

http:
  host: 127.0.0.1
  port: 8080

live_view:
  secret: replace-with-a-generated-production-secret
  join_timeout_ms: 10000
  idle_timeout_ms: 75000
config.get("http.port")       # YAML::Any
config.get("http.port", 8080) # Int32
config.section("http")        # YAML::Any mapping

Data Autoconfiguration

Data autoconfiguration is opt-in. The adapter owns one exact LF::Data::DataSource singleton and can apply one DI-provided migration set during Application startup:

require "opal"
require "opal/autoconfig/data"
require "sqlite3"

@[LF::ApplicationConfiguration]
class DataConfiguration
  @[LF::DI::Bean]
  def migration_set : LF::Data::MigrationSet
    LF::Data::MigrationSet.new(CreateProjects.new)
  end
end

@[LF::Application]
@[LF::AutoConfig::Data]
class TodoApplication
end
database:
  url: sqlite3://./todo.db
  migrations:
    run_on_startup: true

database.url is required and supports the sqlite3 and postgres schemes. Applications still require the matching concrete sqlite3 or pg driver. Startup migrations default to false; when enabled, exactly one LF::Data::MigrationSet bean must exist. If HTTP autoconfiguration is also enabled, HTTP stops first, then the DataSource closes, then DI disposes its remaining singletons.

HTTP Autoconfiguration

HTTP autoconfiguration is an explicit optional require:

require "opal"
require "opal/autoconfig/http"

@[LF::Application]
@[LF::AutoConfig::HTTP]
class TodoApplication
end

TodoApplication.run_http

At compile time Opal discovers LF::HTTP::Controller includers in fully-qualified name order. At startup it builds the controller route table, the log and WebSocket handlers, connection-drain-owned request scopes, and an HTTP::Server. The server uses http.host (0.0.0.0 by default), http.port (8080 by default), and the positive http.drain_timeout_ms (30000 by default). WebSocket connections receive 1001 Going Away and have http.websocket.shutdown_timeout_ms (5000 by default) to close before their transport is forced closed. run_http blocks in HTTP::Server#listen; process termination closes the server before application DI shutdown.

Shutdown closes listeners and idle keep-alive sockets, then waits through the configured deadline for response output and upgraded connection work. A deadline breach force-closes remaining transports and surfaces LF::HTTP::AutoConfig::DrainTimeoutError through application shutdown. Opal keeps root DI alive while timed-out request scopes can still use it; after those scopes exit, retrying application shutdown completes singleton disposal.

Method dispatch is exact: HEAD and OPTIONS are not inferred from GET and must be registered explicitly. A method mismatch returns 405 with a stable, sorted Allow header. Applications can pass an LF::HTTP::Router::ErrorMapper to LF::HTTP::App.new to replace the default error body; the router preserves the status and Allow metadata.

Integration Pattern

Opal is easiest to integrate anywhere that already uses Crystal's HTTP::Handler chain.

That includes:

  • plain HTTP::Server
  • custom middleware stacks
  • frameworks that expose handler-compatible extension points

Minimal pattern:

server = HTTP::Server.new([
  HTTP::LogHandler.new,
  SomeMiddleware.new,
  app_or_router,
])

Where app_or_router can be either:

  • LF::HTTP::Router
  • LF::HTTP::App

Examples

The repository includes these examples:

Run them with:

crystal run examples/router_example.cr
crystal run examples/api_route_di_example.cr
crystal run examples/handler_stack_example.cr
crystal run examples/application_bootstrap_example.cr

For the standalone SQLite Todo API example, run commands from examples/todo_api_sqlite:

shards install
crystal run src/todo_api_sqlite_example.cr

For the standalone data-layer showcase, run commands from examples/data_layer_sqlite:

shards install
crystal spec --no-color
crystal run src/data_layer_example_cli.cr
crystal run src/data_layer_example_http_cli.cr
crystal run src/data_layer_example_application_cli.cr

For the standalone LiveView example, run commands from examples/live_view_counter:

shards install
crystal spec --no-color
crystal run src/live_view_counter_example.cr

For the UI showcase, run commands from examples/ui_showcase:

shards install
crystal spec --no-color
crystal run src/ui_showcase_example.cr

Repository contributors can run the real-browser compatibility suite from the repository root with npm ci, npx playwright install --with-deps chromium, npm run check:live-view-client, and npm run test:live-view-browser.

Route Matching Rules

Current route behavior covered by specs:

  • exact matches win over parameter matches
  • root path / is supported
  • trailing slashes are normalized
  • repeated slashes are normalized
  • extra path segments do not match
  • multiple HTTP methods may share the same path
  • unsupported methods return 405 Method Not Allowed

Responses

Opal includes these response helpers:

  • LF::HTTP::TextResponse.create("...")
  • LF::HTTP::JSONResponse.create(serializable_object)

Controller methods may return a JSON::Serializable model directly; Opal writes it as application/json. Explicit LF::HTTP::Response implementations remain available when the response must control headers, status, or serialization behavior. Strings remain plain text; collections are not auto-serialized.

Error Types

HTTP layer

  • LF::HTTP::BadRequest
  • LF::HTTP::Forbidden
  • LF::HTTP::NotFound
  • LF::HTTP::InternalServerError

DI layer

  • LF::DI::BeanNotFoundError
  • LF::DI::BeanTypeMismatchError
  • LF::DI::DuplicateBeanError
  • LF::DI::ScopeMismatchError
  • LF::DI::AmbiguousBeanError
  • LF::DI::ContextClosedError

Application layer

  • LF::ConfigService::LoadError
  • LF::ConfigService::MissingKeyError
  • LF::ApplicationRuntime::ClosedError
  • LF::ApplicationRuntime::ShutdownError
  • LF::Data::AutoConfig::ConfigurationError
  • LF::HTTP::AutoConfig::ConfigurationError

Testing

Run the full test suite:

crystal spec

License

See LICENSE.

Repository

opal

Owner
Statistic
  • 0
  • 0
  • 9
  • 1
  • 6
  • about 2 hours ago
  • May 18, 2026
License

MIT License

Links
Synced at

Tue, 08 Sep 2026 17:25:57 GMT

Languages